Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 35 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,41 @@ All notable changes to the Copilot SDK are documented in this file.
This changelog is automatically generated by an AI agent when stable releases are published.
See [GitHub Releases](https://github.com/github/copilot-sdk/releases) for the full list.

## [Unreleased]

### Feature: host-injected managed settings permissions

Session create and resume accept a new optional `managedSettings` option that injects an enterprise permissions policy at session startup, alongside the existing `enableManagedSettings` self-fetch flag. The current contract is permissions-only: `disableBypassPermissionsMode` (the literal `"disable"`), plus `deny`, `ask`, and `allow` rule lists. The layer composes restrictively with any server- or device-level managed settings (deny/ask are unioned, every present allow list must admit a tool, and `disableBypassPermissionsMode` is deny-wins).

This layer is startup-only and is not persisted with the session, so it must be re-supplied on resume to remain in effect; omitting it on resume clears the previously injected layer. It can be combined with `enableManagedSettings`. Older runtimes that do not recognize the field reject session creation (fail-closed) rather than silently ignoring it, so it requires a Copilot CLI runtime whose schema includes managed settings.

```ts
const session = await client.createSession({
managedSettings: {
permissions: {
disableBypassPermissionsMode: "disable",
deny: ["shell(rm*)"],
ask: ["write"],
},
},
});
```

```cs
var session = await client.CreateSessionAsync(new SessionConfig
{
ManagedSettings = new ManagedSettings
{
Permissions = new ManagedSettingsPermissions
{
DisableBypassPermissionsMode = "disable",
Deny = ["shell(rm*)"],
Ask = ["write"],
},
},
});
```

## [v1.0.7](https://github.com/github/copilot-sdk/releases/tag/v1.0.7) (2026-07-16)

### Feature: in-process (FFI) transport
Expand Down
4 changes: 4 additions & 0 deletions dotnet/src/Client.cs
Original file line number Diff line number Diff line change
Expand Up @@ -1196,6 +1196,7 @@ public async Task<CopilotSession> CreateSessionAsync(SessionConfig config, Cance
ToolFilterPrecedence: toolFilter.ToolFilterPrecedence,
ExpAssignments: config.ExpAssignments,
EnableManagedSettings: config.EnableManagedSettings,
ManagedSettings: config.ManagedSettings,
EnableGitHubTelemetryForwarding: _options.OnGitHubTelemetry != null ? true : null);

var rpcTimestamp = Stopwatch.GetTimestamp();
Expand Down Expand Up @@ -1410,6 +1411,7 @@ public async Task<CopilotSession> ResumeSessionAsync(string sessionId, ResumeSes
ToolFilterPrecedence: toolFilter.ToolFilterPrecedence,
ExpAssignments: config.ExpAssignments,
EnableManagedSettings: config.EnableManagedSettings,
ManagedSettings: config.ManagedSettings,
EnableGitHubTelemetryForwarding: _options.OnGitHubTelemetry != null ? true : null);

var rpcTimestamp = Stopwatch.GetTimestamp();
Expand Down Expand Up @@ -2762,6 +2764,7 @@ internal record CreateSessionRequest(
OptionsUpdateToolFilterPrecedence? ToolFilterPrecedence = null,
[property: JsonPropertyName("expAssignments")] CopilotExpAssignmentResponse? ExpAssignments = null,
[property: JsonPropertyName("enableManagedSettings")] bool? EnableManagedSettings = null,
[property: JsonPropertyName("managedSettings")] ManagedSettings? ManagedSettings = null,
bool? EnableGitHubTelemetryForwarding = null);
#pragma warning restore GHCP001

Expand Down Expand Up @@ -2868,6 +2871,7 @@ internal record ResumeSessionRequest(
OptionsUpdateToolFilterPrecedence? ToolFilterPrecedence = null,
[property: JsonPropertyName("expAssignments")] CopilotExpAssignmentResponse? ExpAssignments = null,
[property: JsonPropertyName("enableManagedSettings")] bool? EnableManagedSettings = null,
[property: JsonPropertyName("managedSettings")] ManagedSettings? ManagedSettings = null,
bool? EnableGitHubTelemetryForwarding = null);
#pragma warning restore GHCP001

Expand Down
65 changes: 65 additions & 0 deletions dotnet/src/Types.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2957,6 +2957,59 @@ public sealed class CopilotExpAssignmentResponse
public string AssignmentContext { get; set; } = string.Empty;
}

/// <summary>
/// Permission rules injected as a managed-settings layer at session bootstrap.
/// All fields are optional; omitted fields impose no constraint from this layer.
/// </summary>
/// <remarks>
/// This layer composes restrictively with any server- or device-level managed
/// settings: <see cref="Deny"/> and <see cref="Ask"/> rules are unioned across
/// layers, every present <see cref="Allow"/> list must admit a tool for it to be
/// allowed, and <see cref="DisableBypassPermissionsMode"/> is honored if any
/// layer sets it (deny-wins).
/// </remarks>
public sealed class ManagedSettingsPermissions
{
/// <summary>
/// When set to <c>"disable"</c>, bypass-permissions mode is turned off for the
/// session regardless of other layers. Serialized as
/// <c>disableBypassPermissionsMode</c>.
/// </summary>
[JsonPropertyName("disableBypassPermissionsMode")]
public string? DisableBypassPermissionsMode { get; set; }

/// <summary>Tool-permission patterns that are always denied.</summary>
[JsonPropertyName("deny")]
public IList<string>? Deny { get; set; }

/// <summary>Tool-permission patterns that require an explicit ask.</summary>
[JsonPropertyName("ask")]
public IList<string>? Ask { get; set; }

/// <summary>Tool-permission patterns that are allowed without prompting.</summary>
[JsonPropertyName("allow")]
public IList<string>? Allow { get; set; }
}

/// <summary>
/// Managed-settings layer injected at session startup. Currently carries only a
/// <see cref="Permissions"/> object.
/// </summary>
/// <remarks>
/// This layer is startup-only and is not persisted with the session. It must be
/// re-supplied on <see cref="CopilotClient.ResumeSessionAsync"/> to remain in
/// effect; omitting it on resume clears the previously injected layer. It can be
/// combined with <see cref="SessionConfigBase.EnableManagedSettings"/>. Older
/// runtimes that do not recognize the <c>managedSettings</c> field reject session
/// creation (fail-closed).
/// </remarks>
public sealed class ManagedSettings
{
/// <summary>Permission rules for this managed-settings layer.</summary>
[JsonPropertyName("permissions")]
public ManagedSettingsPermissions? Permissions { get; set; }
}

/// <summary>
/// Shared configuration properties for creating or resuming a Copilot session.
/// Use <see cref="SessionConfig"/> when creating a new session, or
Expand Down Expand Up @@ -3033,6 +3086,7 @@ protected SessionConfigBase(SessionConfigBase? other)
RemoteSession = other.RemoteSession;
ExpAssignments = other.ExpAssignments;
EnableManagedSettings = other.EnableManagedSettings;
ManagedSettings = other.ManagedSettings;
#pragma warning disable GHCP001
Canvases = other.Canvases is not null ? [.. other.Canvases] : null;
RequestCanvasRenderer = other.RequestCanvasRenderer;
Expand Down Expand Up @@ -3475,6 +3529,17 @@ protected SessionConfigBase(SessionConfigBase? other)
/// </summary>
public bool? EnableManagedSettings { get; set; }

/// <summary>
/// Optional managed-settings layer injected at session bootstrap. Currently
/// carries a permissions object that composes restrictively with any
/// server- or device-level managed settings. This layer is startup-only and
/// is not persisted: it must be re-supplied on resume to remain in effect,
/// and omitting it on resume clears the previously injected layer. Can be
/// combined with <see cref="EnableManagedSettings"/>. Serialized on the wire
/// as <c>managedSettings</c>.
/// </summary>
public ManagedSettings? ManagedSettings { get; set; }

#pragma warning disable GHCP001
/// <summary>
/// Canvas declarations advertised by this connection. The runtime forwards
Expand Down
72 changes: 72 additions & 0 deletions dotnet/test/Unit/ClientSessionLifetimeTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -450,6 +450,78 @@ private static int GetPrivateDictionaryCount(CopilotClient client, string fieldN
return (int)count.GetValue(dictionary)!;
}

[Fact]
public async Task CreateSessionAsync_Serializes_ManagedSettings_Permissions()
{
await using var server = await FakeCopilotServer.StartAsync();
await using var client = new CopilotClient(new CopilotClientOptions { Connection = RuntimeConnection.ForUri(server.Url) });
await client.StartAsync();

await using var session = await client.CreateSessionAsync(new SessionConfig
{
EnableManagedSettings = true,
ManagedSettings = new ManagedSettings
{
Permissions = new ManagedSettingsPermissions
{
DisableBypassPermissionsMode = "disable",
Deny = ["shell(rm*)"],
Ask = ["write"],
Allow = []
}
},
OnPermissionRequest = PermissionHandler.ApproveAll
});

var request = Assert.Single(server.Requests, request => request.Method == "session.create");
Assert.True(request.Params.GetProperty("enableManagedSettings").GetBoolean());
var permissions = request.Params.GetProperty("managedSettings").GetProperty("permissions");
Assert.Equal("disable", permissions.GetProperty("disableBypassPermissionsMode").GetString());
Assert.Equal("shell(rm*)", Assert.Single(permissions.GetProperty("deny").EnumerateArray()).GetString());
Assert.Equal("write", Assert.Single(permissions.GetProperty("ask").EnumerateArray()).GetString());
Assert.Empty(permissions.GetProperty("allow").EnumerateArray());
}

[Fact]
public async Task CreateSessionAsync_Omits_ManagedSettings_When_Unset()
{
await using var server = await FakeCopilotServer.StartAsync();
await using var client = new CopilotClient(new CopilotClientOptions { Connection = RuntimeConnection.ForUri(server.Url) });
await client.StartAsync();

await using var session = await client.CreateSessionAsync(new SessionConfig
{
OnPermissionRequest = PermissionHandler.ApproveAll
});

var request = Assert.Single(server.Requests, request => request.Method == "session.create");
Assert.False(request.Params.TryGetProperty("managedSettings", out _));
}

[Fact]
public async Task ResumeSessionAsync_Serializes_ManagedSettings_Permissions()
{
await using var server = await FakeCopilotServer.StartAsync();
await using var client = new CopilotClient(new CopilotClientOptions { Connection = RuntimeConnection.ForUri(server.Url) });

await using var session = await client.ResumeSessionAsync("session-managed", new ResumeSessionConfig
{
ManagedSettings = new ManagedSettings
{
Permissions = new ManagedSettingsPermissions
{
Deny = ["shell(rm*)"]
}
},
OnPermissionRequest = PermissionHandler.ApproveAll,
OnEvent = _ => { }
});

var request = Assert.Single(server.Requests, request => request.Method == "session.resume");
var permissions = request.Params.GetProperty("managedSettings").GetProperty("permissions");
Assert.Equal("shell(rm*)", Assert.Single(permissions.GetProperty("deny").EnumerateArray()).GetString());
}

private static void DispatchEvent(CopilotSession session, SessionEvent evt)
{
var method = typeof(CopilotSession).GetMethod("DispatchEvent", BindingFlags.Instance | BindingFlags.NonPublic)
Expand Down
2 changes: 2 additions & 0 deletions go/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -828,6 +828,7 @@ func (c *Client) CreateSession(ctx context.Context, config *SessionConfig) (*Ses
req.ExtensionInfo = config.ExtensionInfo
req.ExpAssignments = config.ExpAssignments
req.EnableManagedSettings = config.EnableManagedSettings
req.ManagedSettings = config.ManagedSettings

if len(config.Commands) > 0 {
cmds := make([]wireCommand, 0, len(config.Commands))
Expand Down Expand Up @@ -1197,6 +1198,7 @@ func (c *Client) ResumeSessionWithOptions(ctx context.Context, sessionID string,
req.ExtensionInfo = config.ExtensionInfo
req.ExpAssignments = config.ExpAssignments
req.EnableManagedSettings = config.EnableManagedSettings
req.ManagedSettings = config.ManagedSettings
if config.OnPermissionRequest != nil {
req.RequestPermission = Bool(true)
}
Expand Down
97 changes: 97 additions & 0 deletions go/client_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3305,3 +3305,100 @@ func TestResumeSessionRequest_ExpAssignments(t *testing.T) {
}
})
}

func TestSessionRequests_ManagedSettings(t *testing.T) {
settings := &ManagedSettings{
Permissions: &ManagedSettingsPermissions{
DisableBypassPermissionsMode: String("disable"),
Deny: []string{"Shell(git push)"},
Ask: []string{"Domain(publish.example)"},
Allow: []string{"Read(**)"},
},
}

expectedPermissions := map[string]any{
"disableBypassPermissionsMode": "disable",
"deny": []any{"Shell(git push)"},
"ask": []any{"Domain(publish.example)"},
"allow": []any{"Read(**)"},
}

t.Run("includes managedSettings on create when set", func(t *testing.T) {
req := createSessionRequest{EnableManagedSettings: Bool(true), ManagedSettings: settings}
data, err := json.Marshal(req)
if err != nil {
t.Fatalf("Failed to marshal: %v", err)
}
var m map[string]any
if err := json.Unmarshal(data, &m); err != nil {
t.Fatalf("Failed to unmarshal: %v", err)
}
if m["enableManagedSettings"] != true {
t.Errorf("Expected enableManagedSettings true, got %v", m["enableManagedSettings"])
}
ms, ok := m["managedSettings"].(map[string]any)
if !ok {
t.Fatalf("Expected managedSettings object, got %v", m["managedSettings"])
}
perms, ok := ms["permissions"].(map[string]any)
if !ok {
t.Fatalf("Expected permissions object, got %v", ms["permissions"])
}
if !reflect.DeepEqual(perms, expectedPermissions) {
t.Errorf("permissions mismatch:\n got: %#v\nwant: %#v", perms, expectedPermissions)
}
})

t.Run("includes managedSettings on resume when set", func(t *testing.T) {
req := resumeSessionRequest{SessionID: "s1", ManagedSettings: settings}
data, err := json.Marshal(req)
if err != nil {
t.Fatalf("Failed to marshal: %v", err)
}
var m map[string]any
if err := json.Unmarshal(data, &m); err != nil {
t.Fatalf("Failed to unmarshal: %v", err)
}
if _, ok := m["managedSettings"].(map[string]any); !ok {
t.Fatalf("Expected managedSettings object, got %v", m["managedSettings"])
}
})

t.Run("omits managedSettings when nil", func(t *testing.T) {
req := createSessionRequest{}
data, _ := json.Marshal(req)
var m map[string]any
json.Unmarshal(data, &m)
if _, ok := m["managedSettings"]; ok {
t.Error("Expected managedSettings to be omitted when nil")
}
})

t.Run("omits empty permission arrays (omitempty idiom)", func(t *testing.T) {
// Go's `omitempty` drops both nil and empty slices; an empty rule list
// is semantically equivalent to no rules for that key.
req := createSessionRequest{ManagedSettings: &ManagedSettings{
Permissions: &ManagedSettingsPermissions{
DisableBypassPermissionsMode: String("disable"),
Deny: []string{},
Ask: []string{},
Allow: []string{},
},
}}
data, err := json.Marshal(req)
if err != nil {
t.Fatalf("Failed to marshal: %v", err)
}
var m map[string]any
json.Unmarshal(data, &m)
perms := m["managedSettings"].(map[string]any)["permissions"].(map[string]any)
if perms["disableBypassPermissionsMode"] != "disable" {
t.Errorf("Expected disableBypassPermissionsMode preserved, got %v", perms["disableBypassPermissionsMode"])
}
for _, key := range []string{"deny", "ask", "allow"} {
if _, ok := perms[key]; ok {
t.Errorf("Expected %s to be omitted when empty, got %v", key, perms[key])
}
}
})
}
Loading
Loading