Skip to content

Commit 135b25a

Browse files
joshspicerCopilot
andcommitted
feat(sdk): add optional managedSettings.permissions to session create/resume
Add an optional per-session `managedSettings` field (permissions-only contract) across all six language SDKs, alongside the existing `enableManagedSettings` boolean. Hosts can inject enterprise permission policy at session startup via: managedSettings.permissions = { disableBypassPermissionsMode?: "disable", deny?: string[], ask?: string[], allow?: string[], } Semantics: startup-only (not persisted), must be re-supplied on resume, composes restrictively with runtime-managed settings, and older runtimes fail closed. Wired through hand-written wire types at both create and resume in Node, Python, Go, .NET, Rust, and Java, plus tests, docs, and a CHANGELOG entry. Generated RPC mirror types regenerated from the runtime schema (TS/Python/Go/Rust; C# unaffected as it does not mirror SessionOpenOptions). No SDK protocol bump. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
1 parent 7f1f847 commit 135b25a

30 files changed

Lines changed: 1305 additions & 4 deletions

CHANGELOG.md

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,41 @@ All notable changes to the Copilot SDK are documented in this file.
55
This changelog is automatically generated by an AI agent when stable releases are published.
66
See [GitHub Releases](https://github.com/github/copilot-sdk/releases) for the full list.
77

8+
## [Unreleased]
9+
10+
### Feature: host-injected managed settings permissions
11+
12+
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).
13+
14+
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.
15+
16+
```ts
17+
const session = await client.createSession({
18+
managedSettings: {
19+
permissions: {
20+
disableBypassPermissionsMode: "disable",
21+
deny: ["shell(rm*)"],
22+
ask: ["write"],
23+
},
24+
},
25+
});
26+
```
27+
28+
```cs
29+
var session = await client.CreateSessionAsync(new SessionConfig
30+
{
31+
ManagedSettings = new ManagedSettings
32+
{
33+
Permissions = new ManagedSettingsPermissions
34+
{
35+
DisableBypassPermissionsMode = "disable",
36+
Deny = ["shell(rm*)"],
37+
Ask = ["write"],
38+
},
39+
},
40+
});
41+
```
42+
843
## [v1.0.7](https://github.com/github/copilot-sdk/releases/tag/v1.0.7) (2026-07-16)
944

1045
### Feature: in-process (FFI) transport

dotnet/src/Client.cs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1196,6 +1196,7 @@ public async Task<CopilotSession> CreateSessionAsync(SessionConfig config, Cance
11961196
ToolFilterPrecedence: toolFilter.ToolFilterPrecedence,
11971197
ExpAssignments: config.ExpAssignments,
11981198
EnableManagedSettings: config.EnableManagedSettings,
1199+
ManagedSettings: config.ManagedSettings,
11991200
EnableGitHubTelemetryForwarding: _options.OnGitHubTelemetry != null ? true : null);
12001201

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

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

@@ -2868,6 +2871,7 @@ internal record ResumeSessionRequest(
28682871
OptionsUpdateToolFilterPrecedence? ToolFilterPrecedence = null,
28692872
[property: JsonPropertyName("expAssignments")] CopilotExpAssignmentResponse? ExpAssignments = null,
28702873
[property: JsonPropertyName("enableManagedSettings")] bool? EnableManagedSettings = null,
2874+
[property: JsonPropertyName("managedSettings")] ManagedSettings? ManagedSettings = null,
28712875
bool? EnableGitHubTelemetryForwarding = null);
28722876
#pragma warning restore GHCP001
28732877

dotnet/src/Types.cs

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2957,6 +2957,59 @@ public sealed class CopilotExpAssignmentResponse
29572957
public string AssignmentContext { get; set; } = string.Empty;
29582958
}
29592959

2960+
/// <summary>
2961+
/// Permission rules injected as a managed-settings layer at session bootstrap.
2962+
/// All fields are optional; omitted fields impose no constraint from this layer.
2963+
/// </summary>
2964+
/// <remarks>
2965+
/// This layer composes restrictively with any server- or device-level managed
2966+
/// settings: <see cref="Deny"/> and <see cref="Ask"/> rules are unioned across
2967+
/// layers, every present <see cref="Allow"/> list must admit a tool for it to be
2968+
/// allowed, and <see cref="DisableBypassPermissionsMode"/> is honored if any
2969+
/// layer sets it (deny-wins).
2970+
/// </remarks>
2971+
public sealed class ManagedSettingsPermissions
2972+
{
2973+
/// <summary>
2974+
/// When set to <c>"disable"</c>, bypass-permissions mode is turned off for the
2975+
/// session regardless of other layers. Serialized as
2976+
/// <c>disableBypassPermissionsMode</c>.
2977+
/// </summary>
2978+
[JsonPropertyName("disableBypassPermissionsMode")]
2979+
public string? DisableBypassPermissionsMode { get; set; }
2980+
2981+
/// <summary>Tool-permission patterns that are always denied.</summary>
2982+
[JsonPropertyName("deny")]
2983+
public IList<string>? Deny { get; set; }
2984+
2985+
/// <summary>Tool-permission patterns that require an explicit ask.</summary>
2986+
[JsonPropertyName("ask")]
2987+
public IList<string>? Ask { get; set; }
2988+
2989+
/// <summary>Tool-permission patterns that are allowed without prompting.</summary>
2990+
[JsonPropertyName("allow")]
2991+
public IList<string>? Allow { get; set; }
2992+
}
2993+
2994+
/// <summary>
2995+
/// Managed-settings layer injected at session startup. Currently carries only a
2996+
/// <see cref="Permissions"/> object.
2997+
/// </summary>
2998+
/// <remarks>
2999+
/// This layer is startup-only and is not persisted with the session. It must be
3000+
/// re-supplied on <see cref="CopilotClient.ResumeSessionAsync"/> to remain in
3001+
/// effect; omitting it on resume clears the previously injected layer. It can be
3002+
/// combined with <see cref="SessionConfigBase.EnableManagedSettings"/>. Older
3003+
/// runtimes that do not recognize the <c>managedSettings</c> field reject session
3004+
/// creation (fail-closed).
3005+
/// </remarks>
3006+
public sealed class ManagedSettings
3007+
{
3008+
/// <summary>Permission rules for this managed-settings layer.</summary>
3009+
[JsonPropertyName("permissions")]
3010+
public ManagedSettingsPermissions? Permissions { get; set; }
3011+
}
3012+
29603013
/// <summary>
29613014
/// Shared configuration properties for creating or resuming a Copilot session.
29623015
/// Use <see cref="SessionConfig"/> when creating a new session, or
@@ -3033,6 +3086,7 @@ protected SessionConfigBase(SessionConfigBase? other)
30333086
RemoteSession = other.RemoteSession;
30343087
ExpAssignments = other.ExpAssignments;
30353088
EnableManagedSettings = other.EnableManagedSettings;
3089+
ManagedSettings = other.ManagedSettings;
30363090
#pragma warning disable GHCP001
30373091
Canvases = other.Canvases is not null ? [.. other.Canvases] : null;
30383092
RequestCanvasRenderer = other.RequestCanvasRenderer;
@@ -3475,6 +3529,17 @@ protected SessionConfigBase(SessionConfigBase? other)
34753529
/// </summary>
34763530
public bool? EnableManagedSettings { get; set; }
34773531

3532+
/// <summary>
3533+
/// Optional managed-settings layer injected at session bootstrap. Currently
3534+
/// carries a permissions object that composes restrictively with any
3535+
/// server- or device-level managed settings. This layer is startup-only and
3536+
/// is not persisted: it must be re-supplied on resume to remain in effect,
3537+
/// and omitting it on resume clears the previously injected layer. Can be
3538+
/// combined with <see cref="EnableManagedSettings"/>. Serialized on the wire
3539+
/// as <c>managedSettings</c>.
3540+
/// </summary>
3541+
public ManagedSettings? ManagedSettings { get; set; }
3542+
34783543
#pragma warning disable GHCP001
34793544
/// <summary>
34803545
/// Canvas declarations advertised by this connection. The runtime forwards

dotnet/test/Unit/ClientSessionLifetimeTests.cs

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -450,6 +450,78 @@ private static int GetPrivateDictionaryCount(CopilotClient client, string fieldN
450450
return (int)count.GetValue(dictionary)!;
451451
}
452452

453+
[Fact]
454+
public async Task CreateSessionAsync_Serializes_ManagedSettings_Permissions()
455+
{
456+
await using var server = await FakeCopilotServer.StartAsync();
457+
await using var client = new CopilotClient(new CopilotClientOptions { Connection = RuntimeConnection.ForUri(server.Url) });
458+
await client.StartAsync();
459+
460+
await using var session = await client.CreateSessionAsync(new SessionConfig
461+
{
462+
EnableManagedSettings = true,
463+
ManagedSettings = new ManagedSettings
464+
{
465+
Permissions = new ManagedSettingsPermissions
466+
{
467+
DisableBypassPermissionsMode = "disable",
468+
Deny = ["shell(rm*)"],
469+
Ask = ["write"],
470+
Allow = []
471+
}
472+
},
473+
OnPermissionRequest = PermissionHandler.ApproveAll
474+
});
475+
476+
var request = Assert.Single(server.Requests, request => request.Method == "session.create");
477+
Assert.True(request.Params.GetProperty("enableManagedSettings").GetBoolean());
478+
var permissions = request.Params.GetProperty("managedSettings").GetProperty("permissions");
479+
Assert.Equal("disable", permissions.GetProperty("disableBypassPermissionsMode").GetString());
480+
Assert.Equal("shell(rm*)", Assert.Single(permissions.GetProperty("deny").EnumerateArray()).GetString());
481+
Assert.Equal("write", Assert.Single(permissions.GetProperty("ask").EnumerateArray()).GetString());
482+
Assert.Empty(permissions.GetProperty("allow").EnumerateArray());
483+
}
484+
485+
[Fact]
486+
public async Task CreateSessionAsync_Omits_ManagedSettings_When_Unset()
487+
{
488+
await using var server = await FakeCopilotServer.StartAsync();
489+
await using var client = new CopilotClient(new CopilotClientOptions { Connection = RuntimeConnection.ForUri(server.Url) });
490+
await client.StartAsync();
491+
492+
await using var session = await client.CreateSessionAsync(new SessionConfig
493+
{
494+
OnPermissionRequest = PermissionHandler.ApproveAll
495+
});
496+
497+
var request = Assert.Single(server.Requests, request => request.Method == "session.create");
498+
Assert.False(request.Params.TryGetProperty("managedSettings", out _));
499+
}
500+
501+
[Fact]
502+
public async Task ResumeSessionAsync_Serializes_ManagedSettings_Permissions()
503+
{
504+
await using var server = await FakeCopilotServer.StartAsync();
505+
await using var client = new CopilotClient(new CopilotClientOptions { Connection = RuntimeConnection.ForUri(server.Url) });
506+
507+
await using var session = await client.ResumeSessionAsync("session-managed", new ResumeSessionConfig
508+
{
509+
ManagedSettings = new ManagedSettings
510+
{
511+
Permissions = new ManagedSettingsPermissions
512+
{
513+
Deny = ["shell(rm*)"]
514+
}
515+
},
516+
OnPermissionRequest = PermissionHandler.ApproveAll,
517+
OnEvent = _ => { }
518+
});
519+
520+
var request = Assert.Single(server.Requests, request => request.Method == "session.resume");
521+
var permissions = request.Params.GetProperty("managedSettings").GetProperty("permissions");
522+
Assert.Equal("shell(rm*)", Assert.Single(permissions.GetProperty("deny").EnumerateArray()).GetString());
523+
}
524+
453525
private static void DispatchEvent(CopilotSession session, SessionEvent evt)
454526
{
455527
var method = typeof(CopilotSession).GetMethod("DispatchEvent", BindingFlags.Instance | BindingFlags.NonPublic)

go/client.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -828,6 +828,7 @@ func (c *Client) CreateSession(ctx context.Context, config *SessionConfig) (*Ses
828828
req.ExtensionInfo = config.ExtensionInfo
829829
req.ExpAssignments = config.ExpAssignments
830830
req.EnableManagedSettings = config.EnableManagedSettings
831+
req.ManagedSettings = config.ManagedSettings
831832

832833
if len(config.Commands) > 0 {
833834
cmds := make([]wireCommand, 0, len(config.Commands))
@@ -1197,6 +1198,7 @@ func (c *Client) ResumeSessionWithOptions(ctx context.Context, sessionID string,
11971198
req.ExtensionInfo = config.ExtensionInfo
11981199
req.ExpAssignments = config.ExpAssignments
11991200
req.EnableManagedSettings = config.EnableManagedSettings
1201+
req.ManagedSettings = config.ManagedSettings
12001202
if config.OnPermissionRequest != nil {
12011203
req.RequestPermission = Bool(true)
12021204
}

go/client_test.go

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3305,3 +3305,100 @@ func TestResumeSessionRequest_ExpAssignments(t *testing.T) {
33053305
}
33063306
})
33073307
}
3308+
3309+
func TestSessionRequests_ManagedSettings(t *testing.T) {
3310+
settings := &ManagedSettings{
3311+
Permissions: &ManagedSettingsPermissions{
3312+
DisableBypassPermissionsMode: String("disable"),
3313+
Deny: []string{"Shell(git push)"},
3314+
Ask: []string{"Domain(publish.example)"},
3315+
Allow: []string{"Read(**)"},
3316+
},
3317+
}
3318+
3319+
expectedPermissions := map[string]any{
3320+
"disableBypassPermissionsMode": "disable",
3321+
"deny": []any{"Shell(git push)"},
3322+
"ask": []any{"Domain(publish.example)"},
3323+
"allow": []any{"Read(**)"},
3324+
}
3325+
3326+
t.Run("includes managedSettings on create when set", func(t *testing.T) {
3327+
req := createSessionRequest{EnableManagedSettings: Bool(true), ManagedSettings: settings}
3328+
data, err := json.Marshal(req)
3329+
if err != nil {
3330+
t.Fatalf("Failed to marshal: %v", err)
3331+
}
3332+
var m map[string]any
3333+
if err := json.Unmarshal(data, &m); err != nil {
3334+
t.Fatalf("Failed to unmarshal: %v", err)
3335+
}
3336+
if m["enableManagedSettings"] != true {
3337+
t.Errorf("Expected enableManagedSettings true, got %v", m["enableManagedSettings"])
3338+
}
3339+
ms, ok := m["managedSettings"].(map[string]any)
3340+
if !ok {
3341+
t.Fatalf("Expected managedSettings object, got %v", m["managedSettings"])
3342+
}
3343+
perms, ok := ms["permissions"].(map[string]any)
3344+
if !ok {
3345+
t.Fatalf("Expected permissions object, got %v", ms["permissions"])
3346+
}
3347+
if !reflect.DeepEqual(perms, expectedPermissions) {
3348+
t.Errorf("permissions mismatch:\n got: %#v\nwant: %#v", perms, expectedPermissions)
3349+
}
3350+
})
3351+
3352+
t.Run("includes managedSettings on resume when set", func(t *testing.T) {
3353+
req := resumeSessionRequest{SessionID: "s1", ManagedSettings: settings}
3354+
data, err := json.Marshal(req)
3355+
if err != nil {
3356+
t.Fatalf("Failed to marshal: %v", err)
3357+
}
3358+
var m map[string]any
3359+
if err := json.Unmarshal(data, &m); err != nil {
3360+
t.Fatalf("Failed to unmarshal: %v", err)
3361+
}
3362+
if _, ok := m["managedSettings"].(map[string]any); !ok {
3363+
t.Fatalf("Expected managedSettings object, got %v", m["managedSettings"])
3364+
}
3365+
})
3366+
3367+
t.Run("omits managedSettings when nil", func(t *testing.T) {
3368+
req := createSessionRequest{}
3369+
data, _ := json.Marshal(req)
3370+
var m map[string]any
3371+
json.Unmarshal(data, &m)
3372+
if _, ok := m["managedSettings"]; ok {
3373+
t.Error("Expected managedSettings to be omitted when nil")
3374+
}
3375+
})
3376+
3377+
t.Run("omits empty permission arrays (omitempty idiom)", func(t *testing.T) {
3378+
// Go's `omitempty` drops both nil and empty slices; an empty rule list
3379+
// is semantically equivalent to no rules for that key.
3380+
req := createSessionRequest{ManagedSettings: &ManagedSettings{
3381+
Permissions: &ManagedSettingsPermissions{
3382+
DisableBypassPermissionsMode: String("disable"),
3383+
Deny: []string{},
3384+
Ask: []string{},
3385+
Allow: []string{},
3386+
},
3387+
}}
3388+
data, err := json.Marshal(req)
3389+
if err != nil {
3390+
t.Fatalf("Failed to marshal: %v", err)
3391+
}
3392+
var m map[string]any
3393+
json.Unmarshal(data, &m)
3394+
perms := m["managedSettings"].(map[string]any)["permissions"].(map[string]any)
3395+
if perms["disableBypassPermissionsMode"] != "disable" {
3396+
t.Errorf("Expected disableBypassPermissionsMode preserved, got %v", perms["disableBypassPermissionsMode"])
3397+
}
3398+
for _, key := range []string{"deny", "ask", "allow"} {
3399+
if _, ok := perms[key]; ok {
3400+
t.Errorf("Expected %s to be omitted when empty, got %v", key, perms[key])
3401+
}
3402+
}
3403+
})
3404+
}

0 commit comments

Comments
 (0)