diff --git a/CHANGELOG.md b/CHANGELOG.md index 41bf05e17..e9f22a3df 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,43 @@ 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`. Host injection requires Copilot CLI `1.0.79-5` or later and does not require an SDK protocol version bump. + +The generated session-event types also expose truthful injected-policy provenance: `session.managed_settings_resolved` can report `source` as `client` or `mixed`, with optional `clientManaged` metadata. + +```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 = 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 diff --git a/dotnet/src/Client.cs b/dotnet/src/Client.cs index 228b11560..2df1f05d1 100644 --- a/dotnet/src/Client.cs +++ b/dotnet/src/Client.cs @@ -785,7 +785,7 @@ private CopilotSession InitializeSession( session.RegisterTools(config.Tools ?? []); session.RegisterPermissionHandler( config.OnPermissionRequest, - config.EnableManagedSettings is true); + config.EnableManagedSettings is true || config.ManagedSettings is not null); session.RegisterMcpAuthHandler(config.OnMcpAuthRequest); session.RegisterCommands(config.Commands); session.RegisterElicitationHandler(config.OnElicitationRequest); @@ -1205,6 +1205,7 @@ public async Task CreateSessionAsync(SessionConfig config, Cance ExpAssignments: config.ExpAssignments, EnableManagedSettings: config.EnableManagedSettings, GitHubMcpToolConfig: config.GitHubMcpToolConfig, + ManagedSettings: config.ManagedSettings, EnableGitHubTelemetryForwarding: _options.OnGitHubTelemetry != null ? true : null, AdditionalDirectories: config.AdditionalDirectories); @@ -1425,6 +1426,7 @@ public async Task ResumeSessionAsync(string sessionId, ResumeSes ExpAssignments: config.ExpAssignments, EnableManagedSettings: config.EnableManagedSettings, GitHubMcpToolConfig: config.GitHubMcpToolConfig, + ManagedSettings: config.ManagedSettings, EnableGitHubTelemetryForwarding: _options.OnGitHubTelemetry != null ? true : null, AdditionalDirectories: config.AdditionalDirectories); @@ -2781,6 +2783,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, [property: JsonPropertyName("githubMcpToolConfig")] GitHubMcpToolConfig? GitHubMcpToolConfig = null, IList? AdditionalDirectories = null); @@ -2895,6 +2898,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, [property: JsonPropertyName("githubMcpToolConfig")] GitHubMcpToolConfig? GitHubMcpToolConfig = null, IList? AdditionalDirectories = null); diff --git a/dotnet/src/Types.cs b/dotnet/src/Types.cs index 7489fbb4e..680955a01 100644 --- a/dotnet/src/Types.cs +++ b/dotnet/src/Types.cs @@ -3044,6 +3044,70 @@ public sealed class GitHubMcpToolConfig public bool? DisableFormDeferral { get; set; } } +/// +/// Controls whether bypass-permissions mode is available in a managed session. +/// +[JsonConverter(typeof(JsonStringEnumConverter))] +public enum DisableBypassPermissionsMode +{ + /// Turn off bypass-permissions mode. + [JsonStringEnumMemberName("disable")] + Disable +} + +/// +/// Permission rules injected as a managed-settings layer at session bootstrap. +/// All fields are optional; omitted fields impose no constraint from this layer. +/// +/// +/// This layer composes restrictively with any server- or device-level managed +/// settings: and rules are unioned across +/// layers, every present list must admit a tool for it to be +/// allowed, and is honored if any +/// layer sets it (deny-wins). +/// +public sealed class ManagedSettingsPermissions +{ + /// + /// When set to "disable", bypass-permissions mode is turned off for the + /// session regardless of other layers. Serialized as + /// disableBypassPermissionsMode. + /// + [JsonPropertyName("disableBypassPermissionsMode")] + public DisableBypassPermissionsMode? DisableBypassPermissionsMode { get; set; } + + /// Tool-permission patterns that are always denied. + [JsonPropertyName("deny")] + public IList? Deny { get; set; } + + /// Tool-permission patterns that require an explicit ask. + [JsonPropertyName("ask")] + public IList? Ask { get; set; } + + /// Tool-permission patterns that are allowed without prompting. + [JsonPropertyName("allow")] + public IList? Allow { get; set; } +} + +/// +/// Managed-settings layer injected at session startup. Currently carries only a +/// object. +/// +/// +/// This layer is startup-only and is not persisted with the session. It must be +/// re-supplied on to remain in +/// effect; omitting it on resume clears the previously injected layer. It can be +/// combined with . Older +/// runtimes may ignore this additive field, so hosts must not rely on injected +/// policy until they ship a compatible runtime. +/// +public sealed class ManagedSettings +{ + /// Permission rules for this managed-settings layer. + [JsonPropertyName("permissions")] + public ManagedSettingsPermissions? Permissions { get; set; } +} + /// /// Shared configuration properties for creating or resuming a Copilot session. /// Use when creating a new session, or @@ -3136,6 +3200,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; @@ -3601,6 +3666,17 @@ protected SessionConfigBase(SessionConfigBase? other) /// public bool? EnableManagedSettings { get; set; } + /// + /// 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 . Serialized on the wire + /// as managedSettings. + /// + public ManagedSettings? ManagedSettings { get; set; } + #pragma warning disable GHCP001 /// /// Canvas declarations advertised by this connection. The runtime forwards diff --git a/dotnet/test/Unit/ClientSessionLifetimeTests.cs b/dotnet/test/Unit/ClientSessionLifetimeTests.cs index 7b7d8c109..d4b4100b4 100644 --- a/dotnet/test/Unit/ClientSessionLifetimeTests.cs +++ b/dotnet/test/Unit/ClientSessionLifetimeTests.cs @@ -10,6 +10,7 @@ using System.Runtime.CompilerServices; using System.Text; using System.Text.Json; +using GitHub.Copilot.Rpc; using Xunit; namespace GitHub.Copilot.Test.Unit; @@ -515,6 +516,94 @@ 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(); + var permissionInvocation = new TaskCompletionSource( + TaskCreationOptions.RunContinuationsAsynchronously); + + await using var session = await client.CreateSessionAsync(new SessionConfig + { + ManagedSettings = new ManagedSettings + { + Permissions = new ManagedSettingsPermissions + { + DisableBypassPermissionsMode = DisableBypassPermissionsMode.Disable, + Deny = ["shell(rm*)"], + Ask = ["write"], + Allow = [] + } + }, + OnPermissionRequest = (_, invocation) => + { + permissionInvocation.TrySetResult(invocation); + return Task.FromResult(PermissionDecision.NoResult()); + } + }); + + var request = Assert.Single(server.Requests, request => request.Method == "session.create"); + Assert.False(request.Params.TryGetProperty("enableManagedSettings", out _)); + 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()); + + DispatchEvent(session, new PermissionRequestedEvent + { + Data = new PermissionRequestedData + { + PermissionRequest = new PermissionRequest { Kind = "read" }, + RequestId = "managed-permission" + } + }); + var invocation = await permissionInvocation.Task.WaitAsync(TimeSpan.FromSeconds(5)); + Assert.True(invocation.ManagedSettingsEnabled); + } + + [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) diff --git a/dotnet/test/Unit/SessionEventSerializationTests.cs b/dotnet/test/Unit/SessionEventSerializationTests.cs index 64e28a5ae..326ac3f3c 100644 --- a/dotnet/test/Unit/SessionEventSerializationTests.cs +++ b/dotnet/test/Unit/SessionEventSerializationTests.cs @@ -368,4 +368,65 @@ public void McpOauthRequiredData_Preserves_Static_Client_Secret() Assert.NotNull(authEvent.Data.StaticClientConfig); Assert.Equal("static-secret", authEvent.Data.StaticClientConfig.ClientSecret); } + + [Fact] + public void ManagedSettingsResolvedData_Preserves_Client_Provenance() + { + Assert.Equal("server", ManagedSettingsResolvedSource.Server.Value); + Assert.Equal("device", ManagedSettingsResolvedSource.Device.Value); + Assert.Equal("client", ManagedSettingsResolvedSource.Client.Value); + Assert.Equal("mixed", ManagedSettingsResolvedSource.Mixed.Value); + Assert.Equal("none", ManagedSettingsResolvedSource.None.Value); + + const string clientJson = """ + { + "id": "11111111-1111-1111-1111-111111111111", + "timestamp": "2026-03-15T21:26:54.987Z", + "parentId": null, + "type": "session.managed_settings_resolved", + "data": { + "source": "client", + "serverManaged": false, + "deviceManaged": false, + "clientManaged": true, + "failClosed": false, + "bypassPermissionsDisabled": true, + "managedKeys": ["permissions"] + } + } + """; + + var clientEvent = Assert.IsType( + SessionEvent.FromJson(clientJson)); + Assert.Equal(ManagedSettingsResolvedSource.Client, clientEvent.Data.Source); + Assert.True(clientEvent.Data.ClientManaged); + using (var document = JsonDocument.Parse(clientEvent.ToJson())) + { + Assert.True(document.RootElement.GetProperty("data").GetProperty("clientManaged").GetBoolean()); + } + + const string mixedJson = """ + { + "id": "22222222-2222-2222-2222-222222222222", + "timestamp": "2026-03-15T21:26:54.987Z", + "parentId": null, + "type": "session.managed_settings_resolved", + "data": { + "source": "mixed", + "serverManaged": true, + "deviceManaged": true, + "failClosed": false, + "bypassPermissionsDisabled": true, + "managedKeys": ["permissions"] + } + } + """; + + var mixedEvent = Assert.IsType( + SessionEvent.FromJson(mixedJson)); + Assert.Equal(ManagedSettingsResolvedSource.Mixed, mixedEvent.Data.Source); + Assert.Null(mixedEvent.Data.ClientManaged); + using var mixedDocument = JsonDocument.Parse(mixedEvent.ToJson()); + Assert.False(mixedDocument.RootElement.GetProperty("data").TryGetProperty("clientManaged", out _)); + } } diff --git a/go/client.go b/go/client.go index d2c43c26b..856e933ea 100644 --- a/go/client.go +++ b/go/client.go @@ -750,6 +750,10 @@ func extractTransformCallbacks(config *SystemMessageConfig) (*SystemMessageConfi return wireConfig, callbacks } +func hasManagedSettings(enableManagedSettings *bool, managedSettings *ManagedSettings) bool { + return (enableManagedSettings != nil && *enableManagedSettings) || managedSettings != nil +} + func (c *Client) CreateSession(ctx context.Context, config *SessionConfig) (*Session, error) { if config == nil { config = &SessionConfig{} @@ -833,6 +837,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)) @@ -917,7 +922,7 @@ func (c *Client) CreateSession(ctx context.Context, config *SessionConfig) (*Ses sessionID, c.client, "", - config.EnableManagedSettings != nil && *config.EnableManagedSettings, + hasManagedSettings(config.EnableManagedSettings, config.ManagedSettings), ) s.registerTools(config.Tools) @@ -1215,6 +1220,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) } @@ -1250,7 +1256,7 @@ func (c *Client) ResumeSessionWithOptions(ctx context.Context, sessionID string, sessionID, c.client, "", - config.EnableManagedSettings != nil && *config.EnableManagedSettings, + hasManagedSettings(config.EnableManagedSettings, config.ManagedSettings), ) session.registerTools(config.Tools) diff --git a/go/client_test.go b/go/client_test.go index b5274cfda..3322d7741 100644 --- a/go/client_test.go +++ b/go/client_test.go @@ -3563,3 +3563,162 @@ func TestIsTerminal(t *testing.T) { } }) } + +func TestSessionRequests_ManagedSettings(t *testing.T) { + settings := &ManagedSettings{ + Permissions: &ManagedSettingsPermissions{ + DisableBypassPermissionsMode: DisableBypassPermissionsModeDisable, + 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("direct injection enables managed safeguards", func(t *testing.T) { + if !hasManagedSettings(nil, settings) { + t.Fatal("expected injected managed settings to enable managed safeguards") + } + if hasManagedSettings(nil, nil) { + t.Fatal("expected an ordinary session to remain unmanaged") + } + }) + + 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("preserves explicit empty permission arrays", func(t *testing.T) { + // A non-nil empty allow list is restrictive: it admits no operations. + // Preserve field presence while still omitting nil slices. + req := createSessionRequest{ManagedSettings: &ManagedSettings{ + Permissions: &ManagedSettingsPermissions{ + DisableBypassPermissionsMode: DisableBypassPermissionsModeDisable, + 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 value, ok := perms[key].([]any); !ok || len(value) != 0 { + t.Errorf("Expected %s to be an explicit empty array, got %v", key, perms[key]) + } + } + }) + + t.Run("distinguishes explicit empty allow from an absent allow", func(t *testing.T) { + // Security-critical: a present empty allow list admits nothing, while an + // absent allow list imposes no allow restriction. The wire output must + // tell these apart per-field, so an explicit empty slice serializes as + // `[]` while a nil slice is omitted entirely. + req := createSessionRequest{ManagedSettings: &ManagedSettings{ + Permissions: &ManagedSettingsPermissions{ + Allow: []string{}, // present but empty: admit nothing + // Deny and Ask left nil: no such restriction supplied. + }, + }} + 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) + + allow, ok := perms["allow"].([]any) + if !ok || len(allow) != 0 { + t.Errorf("Expected allow to be an explicit empty array, got %v", perms["allow"]) + } + if _, present := perms["deny"]; present { + t.Errorf("Expected deny to be omitted when nil, got %v", perms["deny"]) + } + if _, present := perms["ask"]; present { + t.Errorf("Expected ask to be omitted when nil, got %v", perms["ask"]) + } + }) + + t.Run("distinguishes explicit empty arrays on resume", func(t *testing.T) { + req := resumeSessionRequest{SessionID: "s1", ManagedSettings: &ManagedSettings{ + Permissions: &ManagedSettingsPermissions{ + 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) + for _, key := range []string{"deny", "ask", "allow"} { + if value, ok := perms[key].([]any); !ok || len(value) != 0 { + t.Errorf("Expected %s to be an explicit empty array on resume, got %v", key, perms[key]) + } + } + }) +} diff --git a/go/session_event_serialization_test.go b/go/session_event_serialization_test.go index bd47fdfbe..ee9258b22 100644 --- a/go/session_event_serialization_test.go +++ b/go/session_event_serialization_test.go @@ -189,3 +189,69 @@ func TestRawSessionEventDataWithNilRawMarshalsAsNull(t *testing.T) { t.Fatalf("expected missing raw data to marshal as null, got %v", serialized["data"]) } } + +func TestManagedSettingsResolvedProvenanceRoundTrips(t *testing.T) { + sources := []ManagedSettingsResolvedSource{ + ManagedSettingsResolvedSourceServer, + ManagedSettingsResolvedSourceDevice, + ManagedSettingsResolvedSourceClient, + ManagedSettingsResolvedSourceMixed, + ManagedSettingsResolvedSourceNone, + } + expectedSources := []string{"server", "device", "client", "mixed", "none"} + for i, source := range sources { + if string(source) != expectedSources[i] { + t.Fatalf("expected source %q, got %q", expectedSources[i], source) + } + } + + clientManaged := true + resolved := SessionManagedSettingsResolvedData{ + BypassPermissionsDisabled: true, + ClientManaged: &clientManaged, + DeviceManaged: false, + FailClosed: false, + ManagedKeys: []string{"permissions"}, + ServerManaged: false, + Source: ManagedSettingsResolvedSourceClient, + } + data, err := json.Marshal(resolved) + if err != nil { + t.Fatalf("failed to marshal managed settings resolution: %v", err) + } + + var serialized map[string]any + if err := json.Unmarshal(data, &serialized); err != nil { + t.Fatalf("failed to inspect managed settings resolution: %v", err) + } + if serialized["source"] != "client" || serialized["clientManaged"] != true { + t.Fatalf("expected client provenance, got %v", serialized) + } + + var roundTripped SessionManagedSettingsResolvedData + if err := json.Unmarshal(data, &roundTripped); err != nil { + t.Fatalf("failed to round-trip managed settings resolution: %v", err) + } + if roundTripped.Source != ManagedSettingsResolvedSourceClient || + roundTripped.ClientManaged == nil || + !*roundTripped.ClientManaged { + t.Fatalf("expected client provenance to round-trip, got %#v", roundTripped) + } + + resolved.Source = ManagedSettingsResolvedSourceMixed + resolved.ClientManaged = nil + data, err = json.Marshal(resolved) + if err != nil { + t.Fatalf("failed to marshal mixed managed settings resolution: %v", err) + } + serialized = nil + if err := json.Unmarshal(data, &serialized); err != nil { + t.Fatalf("failed to inspect mixed managed settings resolution: %v", err) + } + if serialized["source"] != "mixed" { + t.Fatalf("expected mixed provenance, got %v", serialized["source"]) + } + if _, ok := serialized["clientManaged"]; ok { + t.Fatalf("expected absent clientManaged to be omitted, got %v", serialized) + } +} diff --git a/go/types.go b/go/types.go index 6669564bf..6d6a877d3 100644 --- a/go/types.go +++ b/go/types.go @@ -1498,6 +1498,51 @@ type SessionConfig struct { // be set; if omitted, the runtime is expected to reject session creation // (fail-closed). Unset behaves exactly as before. EnableManagedSettings *bool + // ManagedSettings supplies host-injected enterprise managed settings for + // the session. Unlike EnableManagedSettings (which asks the runtime to + // self-fetch account/org and device policy), this provides the managed + // policy directly. The runtime validates it with the same + // managed-permission parser it uses for fetched policy and composes it + // restrictively with any self-fetched (server) and device-managed (MDM) + // layers. It is startup-only and not persisted: re-supply it on resume, + // where it replaces the prior injected layer (omitting it clears the + // layer). It may be combined with EnableManagedSettings. Requires a runtime + // whose RPC schema includes managedSettings. + ManagedSettings *ManagedSettings +} + +// ManagedSettings is host-injected enterprise managed settings for a session. +// The first supported contract is permissions-only; unknown sibling keys are +// rejected by the runtime. Serialized on the wire as managedSettings. +type ManagedSettings struct { + // Permissions is the managed permission policy for the session. + Permissions *ManagedSettingsPermissions `json:"permissions,omitempty"` +} + +// DisableBypassPermissionsMode is the managed bypass-permissions policy. +type DisableBypassPermissionsMode = rpc.DisableBypassPermissionsMode + +const ( + // DisableBypassPermissionsModeDisable turns off bypass-permissions mode. + DisableBypassPermissionsModeDisable = rpc.DisableBypassPermissionsModeDisable +) + +// ManagedSettingsPermissions is the permissions-only managed policy injected +// via ManagedSettings. Rule strings use the same vocabulary the runtime +// accepts for fetched managed policy (e.g. "Read(**)", "Shell(git push *)"); +// malformed rules are rejected by the runtime at session creation. +type ManagedSettingsPermissions struct { + // DisableBypassPermissionsMode, when set to "disable", turns off + // bypass-permissions ("yolo") mode for the session. Deny-wins: no other + // layer can re-enable it. + DisableBypassPermissionsMode DisableBypassPermissionsMode `json:"disableBypassPermissionsMode,omitempty"` + // Deny lists operations that must always be denied. Unioned across layers. + Deny []string `json:"deny,omitzero"` + // Ask lists operations that must prompt for approval. Unioned across layers. + Ask []string `json:"ask,omitzero"` + // Allow lists operations permitted without prompting. Every declared allow + // list across managed layers must admit an operation for it to be allowed. + Allow []string `json:"allow,omitzero"` } // ToolDefer controls whether a tool may be deferred (loaded lazily via tool @@ -1961,6 +2006,11 @@ type ResumeSessionConfig struct { // SessionConfig.EnableManagedSettings. Re-supply on resume so the runtime // re-applies the managed-settings self-fetch after a CLI process restart. EnableManagedSettings *bool + // ManagedSettings re-injects host-provided managed settings on resume. See + // SessionConfig.ManagedSettings. It must be re-supplied on resume: it + // replaces the prior injected layer, and omitting it clears that layer so + // warm and cold resume behave identically. + ManagedSettings *ManagedSettings } // ProviderTokenArgs carries the context passed to a [BearerTokenProvider] callback @@ -2423,6 +2473,7 @@ type createSessionRequest struct { CanvasProvider *CanvasProviderIdentity `json:"canvasProvider,omitempty"` ExpAssignments *CopilotExpAssignmentResponse `json:"expAssignments,omitempty"` EnableManagedSettings *bool `json:"enableManagedSettings,omitempty"` + ManagedSettings *ManagedSettings `json:"managedSettings,omitempty"` Traceparent string `json:"traceparent,omitempty"` Tracestate string `json:"tracestate,omitempty"` } @@ -2519,6 +2570,7 @@ type resumeSessionRequest struct { CanvasProvider *CanvasProviderIdentity `json:"canvasProvider,omitempty"` ExpAssignments *CopilotExpAssignmentResponse `json:"expAssignments,omitempty"` EnableManagedSettings *bool `json:"enableManagedSettings,omitempty"` + ManagedSettings *ManagedSettings `json:"managedSettings,omitempty"` Traceparent string `json:"traceparent,omitempty"` Tracestate string `json:"tracestate,omitempty"` } diff --git a/java/src/main/java/com/github/copilot/SessionRequestBuilder.java b/java/src/main/java/com/github/copilot/SessionRequestBuilder.java index add4a79b6..23e4f77b4 100644 --- a/java/src/main/java/com/github/copilot/SessionRequestBuilder.java +++ b/java/src/main/java/com/github/copilot/SessionRequestBuilder.java @@ -201,6 +201,7 @@ static CreateSessionRequest buildCreateRequest(SessionConfig config, String sess request.setCloud(config.getCloud()); request.setExpAssignments(config.getExpAssignments()); config.getEnableManagedSettings().ifPresent(request::setEnableManagedSettings); + request.setManagedSettings(config.getManagedSettings()); return request; } @@ -337,6 +338,7 @@ static ResumeSessionRequest buildResumeRequest(String sessionId, ResumeSessionCo request.setRemoteSession(config.getRemoteSession()); request.setExpAssignments(config.getExpAssignments()); config.getEnableManagedSettings().ifPresent(request::setEnableManagedSettings); + request.setManagedSettings(config.getManagedSettings()); return request; } @@ -374,7 +376,8 @@ static void configureSession(CopilotSession session, SessionConfig config) { if (config.getOnPermissionRequest() != null) { session.registerPermissionHandler(config.getOnPermissionRequest()); } - session.setManagedSettingsEnabled(config.getEnableManagedSettings().orElse(false)); + session.setManagedSettingsEnabled( + config.getEnableManagedSettings().orElse(false) || config.getManagedSettings() != null); if (config.getOnMcpAuthRequest() != null) { session.registerMcpAuthHandler(config.getOnMcpAuthRequest()); } @@ -425,7 +428,8 @@ static void configureSession(CopilotSession session, ResumeSessionConfig config) if (config.getOnPermissionRequest() != null) { session.registerPermissionHandler(config.getOnPermissionRequest()); } - session.setManagedSettingsEnabled(config.getEnableManagedSettings().orElse(false)); + session.setManagedSettingsEnabled( + config.getEnableManagedSettings().orElse(false) || config.getManagedSettings() != null); if (config.getOnMcpAuthRequest() != null) { session.registerMcpAuthHandler(config.getOnMcpAuthRequest()); } diff --git a/java/src/main/java/com/github/copilot/rpc/CreateSessionRequest.java b/java/src/main/java/com/github/copilot/rpc/CreateSessionRequest.java index 46f59a28c..4c74e38ac 100644 --- a/java/src/main/java/com/github/copilot/rpc/CreateSessionRequest.java +++ b/java/src/main/java/com/github/copilot/rpc/CreateSessionRequest.java @@ -234,6 +234,10 @@ public final class CreateSessionRequest { @JsonInclude(JsonInclude.Include.NON_NULL) private Boolean enableManagedSettings; + @JsonProperty("managedSettings") + @JsonInclude(JsonInclude.Include.NON_NULL) + private ManagedSettings managedSettings; + /** Gets the model name. @return the model */ public String getModel() { return model; @@ -1095,4 +1099,17 @@ public void setEnableManagedSettings(boolean enableManagedSettings) { public void clearEnableManagedSettings() { this.enableManagedSettings = null; } + + /** @return host-injected managed settings, or {@code null} when unset */ + public ManagedSettings getManagedSettings() { + return managedSettings; + } + + /** + * @param managedSettings + * host-injected managed settings + */ + public void setManagedSettings(ManagedSettings managedSettings) { + this.managedSettings = managedSettings; + } } diff --git a/java/src/main/java/com/github/copilot/rpc/ManagedSettings.java b/java/src/main/java/com/github/copilot/rpc/ManagedSettings.java new file mode 100644 index 000000000..39e8fcf55 --- /dev/null +++ b/java/src/main/java/com/github/copilot/rpc/ManagedSettings.java @@ -0,0 +1,34 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------------------------------------------*/ +package com.github.copilot.rpc; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonProperty; + +/** + * Managed settings an SDK host may inject at session create or resume. + * + *

+ * The initial public contract is permissions-only. + */ +@JsonInclude(JsonInclude.Include.NON_NULL) +public final class ManagedSettings { + @JsonProperty("permissions") + private ManagedSettingsPermissions permissions; + + /** @return the managed permission policy, or {@code null} when unset */ + public ManagedSettingsPermissions getPermissions() { + return permissions; + } + + /** + * @param permissions + * managed permission policy + * @return this settings object + */ + public ManagedSettings setPermissions(ManagedSettingsPermissions permissions) { + this.permissions = permissions; + return this; + } +} diff --git a/java/src/main/java/com/github/copilot/rpc/ManagedSettingsPermissions.java b/java/src/main/java/com/github/copilot/rpc/ManagedSettingsPermissions.java new file mode 100644 index 000000000..0923cea54 --- /dev/null +++ b/java/src/main/java/com/github/copilot/rpc/ManagedSettingsPermissions.java @@ -0,0 +1,90 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------------------------------------------*/ +package com.github.copilot.rpc; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonProperty; +import com.github.copilot.generated.rpc.DisableBypassPermissionsMode; +import java.util.ArrayList; +import java.util.List; + +/** + * Enterprise permission policy injected by an SDK host at session startup. + */ +@JsonInclude(JsonInclude.Include.NON_NULL) +public final class ManagedSettingsPermissions { + @JsonProperty("disableBypassPermissionsMode") + private DisableBypassPermissionsMode disableBypassPermissionsMode; + + @JsonProperty("deny") + private List deny; + + @JsonProperty("ask") + private List ask; + + @JsonProperty("allow") + private List allow; + + /** @return the bypass-permissions policy, or {@code null} when unset */ + public DisableBypassPermissionsMode getDisableBypassPermissionsMode() { + return disableBypassPermissionsMode; + } + + /** + * Disables bypass/allow-all permission modes. + * + * @param value + * bypass-permissions policy + * @return this policy + */ + public ManagedSettingsPermissions setDisableBypassPermissionsMode(DisableBypassPermissionsMode value) { + this.disableBypassPermissionsMode = value; + return this; + } + + /** @return rules that deny matching operations, or {@code null} when unset */ + public List getDeny() { + return deny; + } + + /** + * @param rules + * deny rules + * @return this policy + */ + public ManagedSettingsPermissions setDeny(List rules) { + this.deny = rules == null ? null : new ArrayList<>(rules); + return this; + } + + /** @return rules that require approval, or {@code null} when unset */ + public List getAsk() { + return ask; + } + + /** + * @param rules + * ask rules + * @return this policy + */ + public ManagedSettingsPermissions setAsk(List rules) { + this.ask = rules == null ? null : new ArrayList<>(rules); + return this; + } + + /** @return rules that allow matching operations, or {@code null} when unset */ + public List getAllow() { + return allow; + } + + /** + * @param rules + * allow rules + * @return this policy + */ + public ManagedSettingsPermissions setAllow(List rules) { + this.allow = rules == null ? null : new ArrayList<>(rules); + return this; + } +} diff --git a/java/src/main/java/com/github/copilot/rpc/ResumeSessionConfig.java b/java/src/main/java/com/github/copilot/rpc/ResumeSessionConfig.java index 1e32ec847..10641157b 100644 --- a/java/src/main/java/com/github/copilot/rpc/ResumeSessionConfig.java +++ b/java/src/main/java/com/github/copilot/rpc/ResumeSessionConfig.java @@ -107,6 +107,7 @@ public class ResumeSessionConfig { private String remoteSession; private CopilotExpAssignmentResponse expAssignments; private Boolean enableManagedSettings; + private ManagedSettings managedSettings; /** * Gets the AI model to use. @@ -1910,6 +1911,24 @@ public ResumeSessionConfig setEnableManagedSettings(boolean enableManagedSetting return this; } + /** @return host-injected managed settings, or {@code null} when unset */ + public ManagedSettings getManagedSettings() { + return managedSettings; + } + + /** + * Supplies permissions-only managed settings for this resume. The value + * replaces the prior injected layer and is not persisted. + * + * @param managedSettings + * the host-injected managed settings + * @return this config for method chaining + */ + public ResumeSessionConfig setManagedSettings(ManagedSettings managedSettings) { + this.managedSettings = managedSettings; + return this; + } + /** * Creates a shallow clone of this {@code ResumeSessionConfig} instance. *

@@ -1992,6 +2011,7 @@ public ResumeSessionConfig clone() { copy.remoteSession = this.remoteSession; copy.expAssignments = this.expAssignments; copy.enableManagedSettings = this.enableManagedSettings; + copy.managedSettings = this.managedSettings; return copy; } } diff --git a/java/src/main/java/com/github/copilot/rpc/ResumeSessionRequest.java b/java/src/main/java/com/github/copilot/rpc/ResumeSessionRequest.java index 3fe17b182..8c9d03ede 100644 --- a/java/src/main/java/com/github/copilot/rpc/ResumeSessionRequest.java +++ b/java/src/main/java/com/github/copilot/rpc/ResumeSessionRequest.java @@ -236,6 +236,10 @@ public final class ResumeSessionRequest { @JsonInclude(JsonInclude.Include.NON_NULL) private Boolean enableManagedSettings; + @JsonProperty("managedSettings") + @JsonInclude(JsonInclude.Include.NON_NULL) + private ManagedSettings managedSettings; + /** Gets the session ID. @return the session ID */ public String getSessionId() { return sessionId; @@ -1110,4 +1114,17 @@ public void setEnableManagedSettings(boolean enableManagedSettings) { public void clearEnableManagedSettings() { this.enableManagedSettings = null; } + + /** @return host-injected managed settings, or {@code null} when unset */ + public ManagedSettings getManagedSettings() { + return managedSettings; + } + + /** + * @param managedSettings + * host-injected managed settings + */ + public void setManagedSettings(ManagedSettings managedSettings) { + this.managedSettings = managedSettings; + } } diff --git a/java/src/main/java/com/github/copilot/rpc/SessionConfig.java b/java/src/main/java/com/github/copilot/rpc/SessionConfig.java index ad62551fd..3ccda690f 100644 --- a/java/src/main/java/com/github/copilot/rpc/SessionConfig.java +++ b/java/src/main/java/com/github/copilot/rpc/SessionConfig.java @@ -108,6 +108,7 @@ public class SessionConfig { private CloudSessionOptions cloud; private CopilotExpAssignmentResponse expAssignments; private Boolean enableManagedSettings; + private ManagedSettings managedSettings; /** * Gets the custom session ID. @@ -2041,6 +2042,29 @@ public SessionConfig setEnableManagedSettings(boolean enableManagedSettings) { return this; } + /** + * Gets host-injected managed settings for this session. + * + * @return the managed settings, or {@code null} when unset + */ + public ManagedSettings getManagedSettings() { + return managedSettings; + } + + /** + * Supplies permissions-only managed settings at session startup. The runtime + * validates and composes this policy restrictively with self-fetched and device + * policy. Re-supply it on resume because it is not persisted. + * + * @param managedSettings + * the host-injected managed settings + * @return this config instance for method chaining + */ + public SessionConfig setManagedSettings(ManagedSettings managedSettings) { + this.managedSettings = managedSettings; + return this; + } + /** * Creates a shallow clone of this {@code SessionConfig} instance. *

@@ -2128,6 +2152,7 @@ public SessionConfig clone() { copy.cloud = this.cloud; copy.expAssignments = this.expAssignments; copy.enableManagedSettings = this.enableManagedSettings; + copy.managedSettings = this.managedSettings; return copy; } } diff --git a/java/src/test/java/com/github/copilot/ManagedSettingsTest.java b/java/src/test/java/com/github/copilot/ManagedSettingsTest.java new file mode 100644 index 000000000..dbd19f3c9 --- /dev/null +++ b/java/src/test/java/com/github/copilot/ManagedSettingsTest.java @@ -0,0 +1,90 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------------------------------------------*/ +package com.github.copilot; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.github.copilot.generated.rpc.DisableBypassPermissionsMode; +import com.github.copilot.rpc.ManagedSettings; +import com.github.copilot.rpc.ManagedSettingsPermissions; +import com.github.copilot.rpc.PermissionRequestResult; +import com.github.copilot.rpc.ResumeSessionConfig; +import com.github.copilot.rpc.SessionConfig; +import java.util.List; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.atomic.AtomicBoolean; +import org.junit.jupiter.api.Test; + +class ManagedSettingsTest { + @Test + void forwardsManagedSettingsOnCreateAndResume() throws Exception { + var permissions = new ManagedSettingsPermissions() + .setDisableBypassPermissionsMode(DisableBypassPermissionsMode.DISABLE).setDeny(List.of("Shell(rm *)")) + .setAsk(List.of("Domain(publish.example)")).setAllow(List.of("Read(**)")); + var managedSettings = new ManagedSettings().setPermissions(permissions); + + var create = SessionRequestBuilder.buildCreateRequest( + new SessionConfig().setEnableManagedSettings(true).setManagedSettings(managedSettings), + "managed-create"); + var resume = SessionRequestBuilder.buildResumeRequest("managed-resume", + new ResumeSessionConfig().setEnableManagedSettings(true).setManagedSettings(managedSettings)); + + assertEquals(managedSettings, create.getManagedSettings()); + assertEquals(managedSettings, resume.getManagedSettings()); + var json = new ObjectMapper().writeValueAsString(create); + assertTrue(json.contains("\"enableManagedSettings\":true")); + assertTrue(json.contains("\"managedSettings\":{\"permissions\"")); + assertTrue(json.contains("\"disableBypassPermissionsMode\":\"disable\"")); + } + + @Test + void preservesExplicitEmptyPermissionArrays() throws Exception { + // Security-critical: a present empty allow list admits nothing, while an + // absent (null) list imposes no such restriction. Jackson NON_NULL must + // emit an explicit empty array as `[]` and omit null fields, so the two + // remain distinguishable on the wire. + var permissions = new ManagedSettingsPermissions().setDeny(List.of()).setAsk(List.of()).setAllow(List.of()); + var managedSettings = new ManagedSettings().setPermissions(permissions); + var create = SessionRequestBuilder.buildCreateRequest(new SessionConfig().setManagedSettings(managedSettings), + "managed-empty"); + + var json = new ObjectMapper().writeValueAsString(create); + assertTrue(json.contains("\"deny\":[]"), json); + assertTrue(json.contains("\"ask\":[]"), json); + assertTrue(json.contains("\"allow\":[]"), json); + } + + @Test + void distinguishesExplicitEmptyAllowFromAbsentAllow() throws Exception { + // Present empty allow admits nothing; the null deny/ask must be omitted. + var permissions = new ManagedSettingsPermissions().setAllow(List.of()); + var managedSettings = new ManagedSettings().setPermissions(permissions); + var create = SessionRequestBuilder.buildCreateRequest(new SessionConfig().setManagedSettings(managedSettings), + "managed-mixed"); + + var json = new ObjectMapper().writeValueAsString(create); + assertTrue(json.contains("\"allow\":[]"), json); + assertFalse(json.contains("\"deny\""), json); + assertFalse(json.contains("\"ask\""), json); + } + + @Test + void directInjectionEnablesManagedSafeguards() throws Exception { + var session = new CopilotSession("session-1", null); + var settings = new ManagedSettings().setPermissions(new ManagedSettingsPermissions()); + var managedSettingsEnabled = new AtomicBoolean(); + var config = new SessionConfig().setManagedSettings(settings).setOnPermissionRequest((request, invocation) -> { + managedSettingsEnabled.set(invocation.isManagedSettingsEnabled()); + return CompletableFuture.completedFuture(PermissionRequestResult.noResult()); + }); + + SessionRequestBuilder.configureSession(session, config); + session.handlePermissionRequest(new ObjectMapper().readTree("{\"kind\":\"read\"}")).get(); + + assertTrue(managedSettingsEnabled.get()); + } +} diff --git a/java/src/test/java/com/github/copilot/SessionEventDeserializationTest.java b/java/src/test/java/com/github/copilot/SessionEventDeserializationTest.java index fc978ed65..8d9b70a34 100644 --- a/java/src/test/java/com/github/copilot/SessionEventDeserializationTest.java +++ b/java/src/test/java/com/github/copilot/SessionEventDeserializationTest.java @@ -113,6 +113,54 @@ void testParseSessionIdleEvent() throws Exception { assertEquals("session.idle", event.getType()); } + @Test + void testManagedSettingsResolvedClientProvenance() throws Exception { + assertEquals("server", ManagedSettingsResolvedSource.SERVER.getValue()); + assertEquals("device", ManagedSettingsResolvedSource.DEVICE.getValue()); + assertEquals("client", ManagedSettingsResolvedSource.CLIENT.getValue()); + assertEquals("mixed", ManagedSettingsResolvedSource.MIXED.getValue()); + assertEquals("none", ManagedSettingsResolvedSource.NONE.getValue()); + + String clientJson = """ + { + "type": "session.managed_settings_resolved", + "data": { + "source": "client", + "serverManaged": false, + "deviceManaged": false, + "clientManaged": true, + "failClosed": false, + "bypassPermissionsDisabled": true, + "managedKeys": ["permissions"] + } + } + """; + + var clientEvent = assertInstanceOf(SessionManagedSettingsResolvedEvent.class, parseJson(clientJson)); + assertEquals(ManagedSettingsResolvedSource.CLIENT, clientEvent.getData().source()); + assertEquals(Boolean.TRUE, clientEvent.getData().clientManaged()); + assertTrue(MAPPER.writeValueAsString(clientEvent).contains("\"clientManaged\":true")); + + String mixedJson = """ + { + "type": "session.managed_settings_resolved", + "data": { + "source": "mixed", + "serverManaged": true, + "deviceManaged": true, + "failClosed": false, + "bypassPermissionsDisabled": true, + "managedKeys": ["permissions"] + } + } + """; + + var mixedEvent = assertInstanceOf(SessionManagedSettingsResolvedEvent.class, parseJson(mixedJson)); + assertEquals(ManagedSettingsResolvedSource.MIXED, mixedEvent.getData().source()); + assertNull(mixedEvent.getData().clientManaged()); + assertFalse(MAPPER.writeValueAsString(mixedEvent).contains("\"clientManaged\"")); + } + @Test void testParseSessionInfoEvent() throws Exception { String json = """ @@ -897,15 +945,16 @@ void testParseEmptyJson() throws Exception { @Test void testParseAllEventTypes() throws Exception { String[] types = {"session.start", "session.resume", "session.error", "session.idle", "session.info", - "session.model_change", "session.mode_changed", "session.plan_changed", - "session.workspace_file_changed", "session.handoff", "session.truncation", "session.snapshot_rewind", - "session.usage_info", "session.compaction_start", "session.compaction_complete", "user.message", - "pending_messages.modified", "assistant.turn_start", "assistant.intent", "assistant.reasoning", - "assistant.reasoning_delta", "assistant.message", "assistant.message_delta", "assistant.turn_end", - "assistant.usage", "abort", "tool.user_requested", "tool.execution_start", - "tool.execution_partial_result", "tool.execution_progress", "tool.execution_complete", - "subagent.started", "subagent.completed", "subagent.failed", "subagent.selected", "hook.start", - "hook.end", "system.message", "session.shutdown", "skill.invoked"}; + "session.model_change", "session.mode_changed", "session.managed_settings_resolved", + "session.managed_settings_enforced", "session.plan_changed", "session.workspace_file_changed", + "session.handoff", "session.truncation", "session.snapshot_rewind", "session.usage_info", + "session.compaction_start", "session.compaction_complete", "user.message", "pending_messages.modified", + "assistant.turn_start", "assistant.intent", "assistant.reasoning", "assistant.reasoning_delta", + "assistant.message", "assistant.message_delta", "assistant.turn_end", "assistant.usage", "abort", + "tool.user_requested", "tool.execution_start", "tool.execution_partial_result", + "tool.execution_progress", "tool.execution_complete", "subagent.started", "subagent.completed", + "subagent.failed", "subagent.selected", "hook.start", "hook.end", "system.message", "session.shutdown", + "skill.invoked"}; for (String type : types) { String json = """ diff --git a/nodejs/src/client.ts b/nodejs/src/client.ts index 4ed139be7..c30b2207b 100644 --- a/nodejs/src/client.ts +++ b/nodejs/src/client.ts @@ -1467,7 +1467,9 @@ export class CopilotClient { this.onGetTraceContext, { mcpAuthHandler: config.onMcpAuthRequest, - managedSettingsEnabled: config.enableManagedSettings, + managedSettingsEnabled: + config.enableManagedSettings === true || + config.managedSettings !== undefined, } ); s.registerTools(config.tools); @@ -1608,6 +1610,7 @@ export class CopilotClient { cloud: config.cloud, expAssignments: config.expAssignments, enableManagedSettings: config.enableManagedSettings, + managedSettings: config.managedSettings, }); const { @@ -1708,7 +1711,8 @@ export class CopilotClient { this.onGetTraceContext, { mcpAuthHandler: config.onMcpAuthRequest, - managedSettingsEnabled: config.enableManagedSettings, + managedSettingsEnabled: + config.enableManagedSettings === true || config.managedSettings !== undefined, } ); session.registerTools(config.tools); @@ -1855,6 +1859,7 @@ export class CopilotClient { openCanvases: config.openCanvases, expAssignments: config.expAssignments, enableManagedSettings: config.enableManagedSettings, + managedSettings: config.managedSettings, }); const { workspacePath, capabilities, openCanvases } = response as { diff --git a/nodejs/src/index.ts b/nodejs/src/index.ts index f915a8707..5ab53471a 100644 --- a/nodejs/src/index.ts +++ b/nodejs/src/index.ts @@ -107,6 +107,8 @@ export type { DefaultAgentConfig, BearerTokenProvider, MessageOptions, + ManagedSettings, + ManagedSettingsPermissions, ModelBilling, ModelBillingTokenPrices, ModelBillingTokenPricesLongContext, diff --git a/nodejs/src/types.ts b/nodejs/src/types.ts index d77a3a97c..4ff279189 100644 --- a/nodejs/src/types.ts +++ b/nodejs/src/types.ts @@ -2088,6 +2088,45 @@ export interface GitHubMcpToolConfig { disableFormDeferral?: boolean; } +/** + * Permissions-only managed policy injected by the host via + * {@link SessionConfigBase.managedSettings}. + * + * Rule strings use the same vocabulary the runtime accepts for fetched managed + * policy (e.g. `"Read(**)"`, `"Shell(git push *)"`); malformed rules are + * rejected at session creation. + */ +export interface ManagedSettingsPermissions { + /** + * When set to `"disable"`, bypass-permissions ("yolo") mode is turned off + * for the session. This is deny-wins: it cannot be re-enabled by any other + * layer. + */ + disableBypassPermissionsMode?: "disable"; + /** Operations that must always be denied. Unioned across managed layers. */ + deny?: string[]; + /** + * Operations that must prompt for approval. Unioned across managed layers. + */ + ask?: string[]; + /** + * Operations permitted without prompting. Every declared `allow` list + * (across managed layers) must admit an operation for it to be allowed. + */ + allow?: string[]; +} + +/** + * Host-injected enterprise managed settings. The first supported contract is + * permissions-only; unknown sibling keys are rejected by the runtime. + * + * @see {@link SessionConfigBase.managedSettings} + */ +export interface ManagedSettings { + /** Managed permission policy for the session. */ + permissions?: ManagedSettingsPermissions; +} + /** * Shared configuration fields used by both {@link SessionConfig} (for * creating a new session) and {@link ResumeSessionConfig} (for resuming @@ -2586,6 +2625,31 @@ export interface SessionConfigBase { */ enableManagedSettings?: boolean; + /** + * Host-injected enterprise managed settings for this session. + * + * Unlike {@link SessionConfigBase.enableManagedSettings} — which asks the + * runtime to *self-fetch* account/org and device policy — this field lets + * the host supply the managed policy directly. The runtime validates it + * with the same managed-permission parser it uses for fetched policy and + * composes it restrictively with any self-fetched (server) and + * device-managed (MDM) layers: `deny`/`ask` rules are unioned, every + * declared `allow` list must admit an operation, and + * `disableBypassPermissionsMode: "disable"` is deny-wins. + * + * This is startup-only. It is **not** persisted: it must be re-supplied on + * {@link CopilotClient.resumeSession | resume}, where it replaces the prior + * injected layer (omitting it clears the layer, so warm and cold resume + * behave identically). It may be combined with `enableManagedSettings`; + * when both are supplied the injected, server, and device restrictions all + * apply. + * + * Requires a Copilot runtime whose RPC schema includes `managedSettings`. + * Older runtimes may ignore this additive field, so hosts must not rely on + * injected policy until they ship a compatible runtime. + */ + managedSettings?: ManagedSettings; + /** * When true, skips embedding-based retrieval for this session. * Use in multitenant deployments to prevent cross-session information leakage diff --git a/nodejs/test/client.test.ts b/nodejs/test/client.test.ts index 962d90970..01a97e980 100644 --- a/nodejs/test/client.test.ts +++ b/nodejs/test/client.test.ts @@ -3659,3 +3659,101 @@ describe("CopilotClient", () => { }); }); }); + +describe("managedSettings serialization", () => { + async function captureCreateParams(config: Record): Promise { + const client = new CopilotClient(); + await client.start(); + onTestFinished(() => stopClient(client)); + const spy = vi + .spyOn((client as any).connection!, "sendRequest") + .mockImplementation(async (method: string, params: any) => { + if (method === "session.create") return { sessionId: params.sessionId }; + throw new Error(`Unexpected method: ${method}`); + }); + await client.createSession({ onPermissionRequest: approveAll, ...config }); + const call = spy.mock.calls.find(([method]) => method === "session.create"); + return call![1]; + } + + it("forwards the full permissions object on session.create", async () => { + const params = await captureCreateParams({ + managedSettings: { + permissions: { + disableBypassPermissionsMode: "disable", + deny: ["Shell(git push)"], + ask: ["Domain(publish.example)"], + allow: ["Read(**)"], + }, + }, + }); + expect(params.managedSettings).toEqual({ + permissions: { + disableBypassPermissionsMode: "disable", + deny: ["Shell(git push)"], + ask: ["Domain(publish.example)"], + allow: ["Read(**)"], + }, + }); + }); + + it("marks directly injected sessions as managed", async () => { + const client = new CopilotClient(); + await client.start(); + onTestFinished(() => stopClient(client)); + vi.spyOn((client as any).connection!, "sendRequest").mockImplementation( + async (method: string, params: any) => { + if (method === "session.create") return { sessionId: params.sessionId }; + throw new Error(`Unexpected method: ${method}`); + } + ); + + const session = await client.createSession({ + onPermissionRequest: approveAll, + managedSettings: { permissions: { deny: ["Edit(/secrets/**)"] } }, + }); + + expect((session as any).managedSettingsEnabled).toBe(true); + }); + + it("omits managedSettings when not supplied", async () => { + const params = await captureCreateParams({}); + expect(params.managedSettings).toBeUndefined(); + }); + + it("coexists with enableManagedSettings", async () => { + const params = await captureCreateParams({ + enableManagedSettings: true, + managedSettings: { permissions: { deny: ["Edit(/secrets/**)"] } }, + }); + expect(params.enableManagedSettings).toBe(true); + expect(params.managedSettings).toEqual({ permissions: { deny: ["Edit(/secrets/**)"] } }); + }); + + it("preserves empty arrays in the permissions object", async () => { + const params = await captureCreateParams({ + managedSettings: { permissions: { deny: [], ask: [], allow: [] } }, + }); + expect(params.managedSettings).toEqual({ permissions: { deny: [], ask: [], allow: [] } }); + }); + + it("forwards managedSettings on session.resume", async () => { + const client = new CopilotClient(); + await client.start(); + onTestFinished(() => stopClient(client)); + const spy = vi + .spyOn((client as any).connection!, "sendRequest") + .mockImplementation(async (method: string, params: any) => { + if (method === "session.resume") return { sessionId: params.sessionId }; + throw new Error(`Unexpected method: ${method}`); + }); + await client.resumeSession("session-1", { + onPermissionRequest: approveAll, + managedSettings: { permissions: { ask: ["Domain(publish.example)"] } }, + }); + const call = spy.mock.calls.find(([method]) => method === "session.resume"); + expect(call![1].managedSettings).toEqual({ + permissions: { ask: ["Domain(publish.example)"] }, + }); + }); +}); diff --git a/nodejs/test/session-event-types.test.ts b/nodejs/test/session-event-types.test.ts index fef7acdb2..5a8f2ca52 100644 --- a/nodejs/test/session-event-types.test.ts +++ b/nodejs/test/session-event-types.test.ts @@ -22,6 +22,9 @@ import type { PermissionRequest, PermissionRequestedData, PermissionRequestedEvent, + ManagedSettingsResolvedData, + ManagedSettingsResolvedEvent, + ManagedSettingsResolvedSource, // *Data payload types from the v0.3.0 generated session-event schema. AssistantMessageData, @@ -163,6 +166,45 @@ describe("Session event type exports (#1156)", () => { expect(permissionEvent.data.permissionRequest.managedApprovalRequired).toBe(true); }); + it("exposes managed settings client and mixed provenance", () => { + const sources: ManagedSettingsResolvedSource[] = [ + "server", + "device", + "client", + "mixed", + "none", + ]; + expect(sources).toEqual(["server", "device", "client", "mixed", "none"]); + + const clientData: ManagedSettingsResolvedData = { + bypassPermissionsDisabled: true, + clientManaged: true, + deviceManaged: false, + failClosed: false, + managedKeys: ["permissions"], + serverManaged: false, + source: "client", + }; + const clientEvent: ManagedSettingsResolvedEvent = { + ephemeral: true, + id: "evt-managed-1", + parentId: null, + timestamp: "2026-01-01T00:00:00.000Z", + type: "session.managed_settings_resolved", + data: clientData, + }; + expect(clientEvent.data.source).toBe("client"); + expect(clientEvent.data.clientManaged).toBe(true); + + const { clientManaged: _, ...withoutClientManaged } = clientData; + const mixedData: ManagedSettingsResolvedData = { + ...withoutClientManaged, + source: "mixed", + }; + expect(mixedData.source).toBe("mixed"); + expect("clientManaged" in mixedData).toBe(false); + }); + it("rejects approveAll in managed settings sessions", () => { expect(() => approveAll( @@ -260,6 +302,7 @@ describe("Session event type exports (#1156)", () => { assertImportable(); assertImportable(); assertImportable(); + assertImportable(); assertImportable(); assertImportable(); @@ -270,6 +313,8 @@ describe("Session event type exports (#1156)", () => { assertImportable(); assertImportable(); assertImportable(); + assertImportable(); + assertImportable(); // Supporting auxiliary types referenced by the *Data shapes — these // must round-trip through the package root too, otherwise consumers diff --git a/python/copilot/__init__.py b/python/copilot/__init__.py index 678fffbf1..a7366db54 100644 --- a/python/copilot/__init__.py +++ b/python/copilot/__init__.py @@ -41,6 +41,8 @@ GetStatusResponse, InProcessRuntimeConnection, LogLevel, + ManagedSettings, + ManagedSettingsPermissions, ModelBilling, ModelCapabilities, ModelInfo, @@ -272,6 +274,8 @@ "McpAuthStaticClientConfig", "McpAuthToken", "McpAuthWwwAuthenticateParams", + "ManagedSettings", + "ManagedSettingsPermissions", "ModelBilling", "ModelBillingTokenPrices", "ModelBillingTokenPricesLongContext", diff --git a/python/copilot/client.py b/python/copilot/client.py index 7c273bd01..21ceb6eee 100644 --- a/python/copilot/client.py +++ b/python/copilot/client.py @@ -247,6 +247,63 @@ def _capi_session_options_to_wire(options: CapiSessionOptions) -> dict[str, Any] return wire +@dataclass +class ManagedSettingsPermissions: + """Permissions-only managed policy injected via :class:`ManagedSettings`. + + Rule strings use the same vocabulary the runtime accepts for fetched + managed policy (e.g. ``"Read(**)"``, ``"Shell(git push *)"``); malformed + rules are rejected by the runtime at session creation. + """ + + disable_bypass_permissions_mode: Literal["disable"] | None = None + """When ``"disable"``, turns off bypass-permissions ("yolo") mode for the + session. Deny-wins: no other layer can re-enable it. Sent on the wire as + ``disableBypassPermissionsMode``.""" + deny: list[str] | None = None + """Operations that must always be denied. Unioned across managed layers.""" + ask: list[str] | None = None + """Operations that must prompt for approval. Unioned across managed layers.""" + allow: list[str] | None = None + """Operations permitted without prompting. Every declared ``allow`` list + across managed layers must admit an operation for it to be allowed.""" + + +@dataclass +class ManagedSettings: + """Host-injected enterprise managed settings for a session. + + Unlike ``enable_managed_settings`` — which asks the runtime to *self-fetch* + account/org and device policy — this supplies the managed policy directly. + The runtime validates it with the same managed-permission parser it uses + for fetched policy and composes it restrictively with any self-fetched + (server) and device-managed (MDM) layers. + + The first supported contract is permissions-only; unknown sibling keys are + rejected by the runtime. Serialized on the wire as ``managedSettings``. + """ + + permissions: ManagedSettingsPermissions | None = None + """Managed permission policy for the session.""" + + +def _managed_settings_to_dict(settings: ManagedSettings) -> dict[str, Any]: + wire: dict[str, Any] = {} + permissions = settings.permissions + if permissions is not None: + perms: dict[str, Any] = {} + if permissions.disable_bypass_permissions_mode is not None: + perms["disableBypassPermissionsMode"] = permissions.disable_bypass_permissions_mode + if permissions.deny is not None: + perms["deny"] = list(permissions.deny) + if permissions.ask is not None: + perms["ask"] = list(permissions.ask) + if permissions.allow is not None: + perms["allow"] = list(permissions.allow) + wire["permissions"] = perms + return wire + + # Implicit provider name for the singular, whole-session ``provider`` config. # Named providers are keyed by their own ``name``. _DEFAULT_BEARER_TOKEN_PROVIDER_NAME = "default" @@ -2090,6 +2147,7 @@ async def create_session( exp_assignments: CopilotExpAssignmentResponse | None = None, enable_managed_settings: bool | None = None, github_mcp_tool_config: GitHubMcpToolConfig | None = None, + managed_settings: ManagedSettings | None = None, ) -> CopilotSession: """ Create a new conversation session with the Copilot CLI. @@ -2241,6 +2299,15 @@ async def create_session( expected to reject session creation (fail-closed). When unset, behaves exactly as before. Sent on the wire as ``enableManagedSettings``. + managed_settings: Host-injected enterprise managed settings for the + session. Supplies managed policy directly instead of + self-fetching; the runtime validates it and composes it + restrictively with any self-fetched (server) and device-managed + layers. Startup-only and not persisted: re-supply on + :meth:`resume_session` (omitting it clears the injected layer). + May be combined with ``enable_managed_settings``. Requires a + runtime whose RPC schema includes ``managedSettings``. Sent on + the wire as ``managedSettings``. Returns: A :class:`CopilotSession` instance for the new session. @@ -2389,6 +2456,10 @@ async def create_session( if enable_managed_settings is not None: payload["enableManagedSettings"] = enable_managed_settings + # Host-injected managed settings (permissions-only contract) + if managed_settings is not None: + payload["managedSettings"] = _managed_settings_to_dict(managed_settings) + # Add working directory if provided if working_directory: payload["workingDirectory"] = working_directory @@ -2575,7 +2646,8 @@ def _initialize_session(sid: str) -> CopilotSession: sid, self._client, workspace_path=None, - managed_settings_enabled=enable_managed_settings is True, + managed_settings_enabled=enable_managed_settings is True + or managed_settings is not None, ) if self._session_fs_config: if create_session_fs_handler is None: @@ -2799,6 +2871,7 @@ async def resume_session( exp_assignments: CopilotExpAssignmentResponse | None = None, enable_managed_settings: bool | None = None, github_mcp_tool_config: GitHubMcpToolConfig | None = None, + managed_settings: ManagedSettings | None = None, ) -> CopilotSession: """ Resume an existing conversation session by its ID. @@ -2951,6 +3024,11 @@ async def resume_session( expected to reject session creation (fail-closed). When unset, behaves exactly as before. Sent on the wire as ``enableManagedSettings``. + managed_settings: Host-injected enterprise managed settings for the + session. Must be re-supplied on resume; it replaces the prior + injected layer, and omitting it clears that layer so warm and + cold resume behave identically. See :meth:`create_session`. Sent + on the wire as ``managedSettings``. Returns: A :class:`CopilotSession` instance for the resumed session. @@ -3122,6 +3200,10 @@ async def resume_session( if enable_managed_settings is not None: payload["enableManagedSettings"] = enable_managed_settings + # Host-injected managed settings (permissions-only contract) + if managed_settings is not None: + payload["managedSettings"] = _managed_settings_to_dict(managed_settings) + if working_directory: payload["workingDirectory"] = working_directory if additional_directories: @@ -3234,7 +3316,8 @@ async def resume_session( session_id, self._client, workspace_path=None, - managed_settings_enabled=enable_managed_settings is True, + managed_settings_enabled=enable_managed_settings is True + or managed_settings is not None, ) if self._session_fs_config: if create_session_fs_handler is None: diff --git a/python/test_client.py b/python/test_client.py index f101fc396..2375bc98a 100644 --- a/python/test_client.py +++ b/python/test_client.py @@ -28,6 +28,8 @@ CloudSessionRepository, CopilotExpAssignmentResponse, ExpConfigEntry, + ManagedSettings, + ManagedSettingsPermissions, ModelBilling, ModelCapabilities, ModelInfo, @@ -651,6 +653,61 @@ async def mock_request(method, params, **kwargs): finally: await client.force_stop() + @pytest.mark.asyncio + async def test_create_and_resume_session_forward_managed_settings(self): + client = CopilotClient(connection=RuntimeConnection.for_stdio(path=CLI_PATH)) + await client.start() + try: + captured = {} + + async def mock_request(method, params, **kwargs): + captured[method] = params + if method in ("session.create", "session.resume"): + result = {"sessionId": params.get("sessionId") or "session-1"} + callback = kwargs.get("on_response_inline") + if callback is not None: + callback(result) + return result + return {} + + client._client.request = mock_request + session = await client.create_session( + on_permission_request=PermissionHandler.approve_all, + enable_managed_settings=True, + managed_settings=ManagedSettings( + permissions=ManagedSettingsPermissions( + disable_bypass_permissions_mode="disable", + deny=["Shell(git push)"], + ask=["Domain(publish.example)"], + allow=["Read(**)"], + ) + ), + ) + resumed_session = await client.resume_session( + session.session_id, + on_permission_request=PermissionHandler.approve_all, + managed_settings=ManagedSettings( + permissions=ManagedSettingsPermissions(ask=["Domain(publish.example)"]) + ), + ) + + assert session._managed_settings_enabled is True + assert resumed_session._managed_settings_enabled is True + assert captured["session.create"]["enableManagedSettings"] is True + assert captured["session.create"]["managedSettings"] == { + "permissions": { + "disableBypassPermissionsMode": "disable", + "deny": ["Shell(git push)"], + "ask": ["Domain(publish.example)"], + "allow": ["Read(**)"], + } + } + assert captured["session.resume"]["managedSettings"] == { + "permissions": {"ask": ["Domain(publish.example)"]} + } + finally: + await client.force_stop() + @pytest.mark.asyncio async def test_create_and_resume_session_default_enable_experimental_mode_by_mode(self): with TemporaryDirectory() as base_directory: @@ -691,6 +748,7 @@ async def mock_request(method, params, **kwargs): finally: await client.force_stop() + async def test_managed_settings_omitted_when_not_supplied(self): client = CopilotClient(connection=RuntimeConnection.for_stdio(path=CLI_PATH)) await client.start() try: @@ -698,7 +756,7 @@ async def mock_request(method, params, **kwargs): async def mock_request(method, params, **kwargs): captured[method] = params - if method in ("session.create", "session.resume"): + if method == "session.create": result = {"sessionId": params.get("sessionId") or "session-1"} callback = kwargs.get("on_response_inline") if callback is not None: @@ -707,16 +765,42 @@ async def mock_request(method, params, **kwargs): return {} client._client.request = mock_request - session = await client.create_session( + await client.create_session( on_permission_request=PermissionHandler.approve_all, ) - await client.resume_session( - session.session_id, + + assert "managedSettings" not in captured["session.create"] + finally: + await client.force_stop() + + @pytest.mark.asyncio + async def test_managed_settings_preserves_empty_arrays(self): + client = CopilotClient(connection=RuntimeConnection.for_stdio(path=CLI_PATH)) + await client.start() + try: + captured = {} + + async def mock_request(method, params, **kwargs): + captured[method] = params + if method == "session.create": + result = {"sessionId": params.get("sessionId") or "session-1"} + callback = kwargs.get("on_response_inline") + if callback is not None: + callback(result) + return result + return {} + + client._client.request = mock_request + await client.create_session( on_permission_request=PermissionHandler.approve_all, + managed_settings=ManagedSettings( + permissions=ManagedSettingsPermissions(deny=[], ask=[], allow=[]) + ), ) - assert "isExperimentalMode" not in captured["session.create"] - assert "isExperimentalMode" not in captured["session.resume"] + assert captured["session.create"]["managedSettings"] == { + "permissions": {"deny": [], "ask": [], "allow": []} + } finally: await client.force_stop() diff --git a/python/test_event_forward_compatibility.py b/python/test_event_forward_compatibility.py index 1ffbd59c5..2e8015a97 100644 --- a/python/test_event_forward_compatibility.py +++ b/python/test_event_forward_compatibility.py @@ -18,10 +18,12 @@ ElicitationCompletedAction, ElicitationRequestedMode, ElicitationRequestedSchema, + ManagedSettingsResolvedSource, PermissionPromptRequestMemory, PermissionRequestMemory, PermissionRequestMemoryAction, SessionEventType, + SessionManagedSettingsResolvedData, SessionTaskCompleteData, UserMessageAgentMode, session_event_from_dict, @@ -136,6 +138,42 @@ def test_explicit_generated_symbols_remain_available(self): ) assert schema.to_dict()["type"] == "object" + def test_managed_settings_client_provenance_round_trips(self): + """Managed settings events should preserve truthful client provenance.""" + assert [source.value for source in ManagedSettingsResolvedSource] == [ + "server", + "device", + "client", + "mixed", + "none", + ] + + client = SessionManagedSettingsResolvedData( + bypass_permissions_disabled=True, + client_managed=True, + device_managed=False, + fail_closed=False, + managed_keys=["permissions"], + server_managed=False, + source=ManagedSettingsResolvedSource.CLIENT, + ) + serialized = client.to_dict() + assert serialized["source"] == "client" + assert serialized["clientManaged"] is True + assert SessionManagedSettingsResolvedData.from_dict(serialized) == client + + mixed = SessionManagedSettingsResolvedData( + bypass_permissions_disabled=True, + device_managed=True, + fail_closed=False, + managed_keys=["permissions"], + server_managed=True, + source=ManagedSettingsResolvedSource.MIXED, + ) + serialized = mixed.to_dict() + assert serialized["source"] == "mixed" + assert "clientManaged" not in serialized + def test_data_shim_preserves_raw_mapping_values(self): """Compatibility Data should keep arbitrary nested mappings as plain dicts.""" parsed = Data.from_dict( diff --git a/rust/src/session.rs b/rust/src/session.rs index d505541a5..c6c806b1c 100644 --- a/rust/src/session.rs +++ b/rust/src/session.rs @@ -66,6 +66,13 @@ pub(crate) struct SessionHandlers { pub tools: Arc>>, } +fn has_managed_settings( + enable_managed_settings: Option, + managed_settings: Option<&crate::types::ManagedSettings>, +) -> bool { + enable_managed_settings == Some(true) || managed_settings.is_some() +} + /// Shared state between a [`Session`] and its event loop, used by [`Session::send_and_wait`]. struct IdleWaiter { tx: oneshot::Sender, Error>>, @@ -899,7 +906,10 @@ impl Client { ); let handlers = SessionHandlers { permission: permission_handler, - managed_settings_enabled: wire.enable_managed_settings == Some(true), + managed_settings_enabled: has_managed_settings( + wire.enable_managed_settings, + wire.managed_settings.as_ref(), + ), elicitation: runtime.elicitation_handler.take(), mcp_auth: runtime.mcp_auth_handler.take(), user_input: runtime.user_input_handler.take(), @@ -1169,7 +1179,10 @@ impl Client { ); let handlers = SessionHandlers { permission: permission_handler, - managed_settings_enabled: wire.enable_managed_settings == Some(true), + managed_settings_enabled: has_managed_settings( + wire.enable_managed_settings, + wire.managed_settings.as_ref(), + ), elicitation: runtime.elicitation_handler.take(), mcp_auth: runtime.mcp_auth_handler.take(), user_input: runtime.user_input_handler.take(), @@ -2550,9 +2563,16 @@ fn inject_transform_sections_resume( mod tests { use serde_json::json; - use super::{notification_permission_payload, permission_request_data}; + use super::{has_managed_settings, notification_permission_payload, permission_request_data}; use crate::handler::PermissionResult; + #[test] + fn direct_injection_enables_managed_safeguards() { + let settings = crate::types::ManagedSettings::default(); + assert!(has_managed_settings(None, Some(&settings))); + assert!(!has_managed_settings(None, None)); + } + #[test] fn notification_payload_suppresses_no_result() { assert!(notification_permission_payload(&PermissionResult::NoResult).is_none()); diff --git a/rust/src/types.rs b/rust/src/types.rs index 37d3b248b..d3c4faa16 100644 --- a/rust/src/types.rs +++ b/rust/src/types.rs @@ -1763,6 +1763,99 @@ pub struct CopilotExpAssignmentResponse { pub assignment_context: String, } +/// Controls whether bypass-permissions mode is available in a managed session. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +#[non_exhaustive] +pub enum DisableBypassPermissionsMode { + /// Turn off bypass-permissions mode. + Disable, +} + +/// Permission rules injected as a managed-settings layer at session bootstrap. +/// +/// All fields are optional; an omitted field imposes no constraint from this +/// layer. This layer composes restrictively with any server- or device-level +/// managed settings: [`deny`](Self::deny) and [`ask`](Self::ask) rules are +/// unioned across layers, every present [`allow`](Self::allow) list must admit a +/// tool for it to be allowed, and +/// [`disable_bypass_permissions_mode`](Self::disable_bypass_permissions_mode) is +/// honored if any layer sets it (deny-wins). +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +#[non_exhaustive] +pub struct ManagedSettingsPermissions { + /// When set to `"disable"`, bypass-permissions mode is turned off for the + /// session regardless of other layers. Serialized as + /// `disableBypassPermissionsMode`. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub disable_bypass_permissions_mode: Option, + /// Tool-permission patterns that are always denied. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub deny: Option>, + /// Tool-permission patterns that require an explicit ask. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub ask: Option>, + /// Tool-permission patterns that are allowed without prompting. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub allow: Option>, +} + +impl ManagedSettingsPermissions { + /// Sets the bypass-permissions policy for this managed layer. + pub fn with_disable_bypass_permissions_mode( + mut self, + value: DisableBypassPermissionsMode, + ) -> Self { + self.disable_bypass_permissions_mode = Some(value); + self + } + + /// Sets the rules that are always denied. + pub fn with_deny(mut self, rules: Vec) -> Self { + self.deny = Some(rules); + self + } + + /// Sets the rules that require explicit approval. + pub fn with_ask(mut self, rules: Vec) -> Self { + self.ask = Some(rules); + self + } + + /// Sets the rules that are allowed without prompting. + pub fn with_allow(mut self, rules: Vec) -> Self { + self.allow = Some(rules); + self + } +} + +/// Managed-settings layer injected at session startup. Currently carries only a +/// [`permissions`](Self::permissions) object. +/// +/// This layer is startup-only and is not persisted with the session. 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 +/// [`SessionConfig::enable_managed_settings`]. Older runtimes may ignore this +/// additive field, so hosts must not rely on injected policy until they ship a +/// compatible runtime. +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +#[non_exhaustive] +pub struct ManagedSettings { + /// Permission rules for this managed-settings layer. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub permissions: Option, +} + +impl ManagedSettings { + /// Sets the permissions-only managed policy. + pub fn with_permissions(mut self, permissions: ManagedSettingsPermissions) -> Self { + self.permissions = Some(permissions); + self + } +} + /// Configuration for creating a new session via the `session.create` RPC. /// /// All fields are optional — the CLI applies sensible defaults. @@ -2055,6 +2148,15 @@ pub struct SessionConfig { /// (fail-closed). When `None`, behaves exactly as before. Set via /// [`with_enable_managed_settings`](Self::with_enable_managed_settings). pub enable_managed_settings: Option, + /// Optional managed-settings layer injected at session bootstrap. Currently + /// carries a [`permissions`](ManagedSettingsPermissions) 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. Can be combined with + /// [`enable_managed_settings`](Self::enable_managed_settings). Serialized on + /// the wire as `managedSettings`. Set via + /// [`with_managed_settings`](Self::with_managed_settings). + pub managed_settings: Option, /// Custom session filesystem provider for this session. Required when /// the [`Client`](crate::Client) was started with /// [`ClientOptions::session_fs`](crate::ClientOptions::session_fs) set. @@ -2201,6 +2303,7 @@ impl std::fmt::Debug for SessionConfig { .field("exp_assignments", &self.exp_assignments) .field("enable_managed_settings", &self.enable_managed_settings) .field("enable_experimental_mode", &self.enable_experimental_mode) + .field("managed_settings", &self.managed_settings) .field( "session_fs_provider", &self.session_fs_provider.as_ref().map(|_| ""), @@ -2312,6 +2415,7 @@ impl Default for SessionConfig { commands: None, exp_assignments: None, enable_managed_settings: None, + managed_settings: None, session_fs_provider: None, permission_handler: None, elicitation_handler: None, @@ -2477,6 +2581,7 @@ impl SessionConfig { exp_assignments: self.exp_assignments, enable_managed_settings: self.enable_managed_settings, is_experimental_mode: self.enable_experimental_mode, + managed_settings: self.managed_settings, }; let runtime = SessionConfigRuntime { @@ -3100,6 +3205,15 @@ impl SessionConfig { self.enable_managed_settings = Some(enabled); self } + + /// Inject a managed-settings layer (currently permission rules) at session + /// bootstrap. This layer is startup-only and is not persisted, so it must be + /// re-supplied on resume to remain in effect. Can be combined with + /// [`with_enable_managed_settings`](Self::with_enable_managed_settings). + pub fn with_managed_settings(mut self, managed_settings: ManagedSettings) -> Self { + self.managed_settings = Some(managed_settings); + self + } } /// /// See [`SessionConfig`] for the construction patterns (chained `with_*` @@ -3292,6 +3406,12 @@ pub struct ResumeSessionConfig { /// process restart. Set via /// [`with_enable_managed_settings`](Self::with_enable_managed_settings). pub enable_managed_settings: Option, + /// Optional managed-settings layer injected on resume. See + /// [`SessionConfig::managed_settings`]. This layer is not persisted, so it + /// must be re-supplied on resume to remain in effect; omitting it clears the + /// previously injected layer. Serialized on the wire as `managedSettings`. + /// Set via [`with_managed_settings`](Self::with_managed_settings). + pub managed_settings: Option, /// Custom session filesystem provider. Required on resume when the /// [`Client`](crate::Client) was started with /// [`ClientOptions::session_fs`](crate::ClientOptions::session_fs). @@ -3431,6 +3551,7 @@ impl std::fmt::Debug for ResumeSessionConfig { .field("exp_assignments", &self.exp_assignments) .field("enable_managed_settings", &self.enable_managed_settings) .field("enable_experimental_mode", &self.enable_experimental_mode) + .field("managed_settings", &self.managed_settings) .field( "session_fs_provider", &self.session_fs_provider.as_ref().map(|_| ""), @@ -3588,6 +3709,7 @@ impl ResumeSessionConfig { exp_assignments: self.exp_assignments, enable_managed_settings: self.enable_managed_settings, is_experimental_mode: self.enable_experimental_mode, + managed_settings: self.managed_settings, suppress_resume_event: self.suppress_resume_event, continue_pending_work: self.continue_pending_work, }; @@ -3681,6 +3803,7 @@ impl ResumeSessionConfig { commands: None, exp_assignments: None, enable_managed_settings: None, + managed_settings: None, session_fs_provider: None, suppress_resume_event: None, continue_pending_work: None, @@ -4285,6 +4408,14 @@ impl ResumeSessionConfig { self.enable_managed_settings = Some(enabled); self } + + /// Inject a managed-settings layer (currently permission rules) on resume. + /// See [`SessionConfig::with_managed_settings`]. Must be re-supplied on + /// resume; omitting it clears the previously injected layer. + pub fn with_managed_settings(mut self, managed_settings: ManagedSettings) -> Self { + self.managed_settings = Some(managed_settings); + self + } } /// Controls how the system message is constructed. diff --git a/rust/src/wire.rs b/rust/src/wire.rs index 3e19063fc..53ea1c448 100644 --- a/rust/src/wire.rs +++ b/rust/src/wire.rs @@ -186,6 +186,8 @@ pub(crate) struct SessionCreateWire { pub enable_managed_settings: Option, #[serde(skip_serializing_if = "Option::is_none")] pub is_experimental_mode: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub managed_settings: Option, } /// The exact JSON shape sent on the `session.resume` JSON-RPC request. @@ -335,4 +337,6 @@ pub(crate) struct SessionResumeWire { pub enable_managed_settings: Option, #[serde(skip_serializing_if = "Option::is_none")] pub is_experimental_mode: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub managed_settings: Option, } diff --git a/rust/tests/session_test.rs b/rust/tests/session_test.rs index 41cae3e95..727911081 100644 --- a/rust/tests/session_test.rs +++ b/rust/tests/session_test.rs @@ -17,12 +17,14 @@ use github_copilot_sdk::rpc::{ OpenCanvasInstance, }; use github_copilot_sdk::session_events::{ - McpOauthRequiredData, ReasoningSummary, SessionLimitsConfig, + ManagedSettingsResolvedSource, McpOauthRequiredData, ReasoningSummary, SessionLimitsConfig, + SessionManagedSettingsResolvedData, }; use github_copilot_sdk::types::{ CanvasProviderIdentity, CloudSessionOptions, CloudSessionRepository, CommandContext, - CommandDefinition, CommandHandler, DeliveryMode, ElicitationRequest, ElicitationResult, - ExitPlanModeData, ExtensionInfo, MessageOptions, RequestId, SessionConfig, SessionId, + CommandDefinition, CommandHandler, DeliveryMode, DisableBypassPermissionsMode, + ElicitationRequest, ElicitationResult, ExitPlanModeData, ExtensionInfo, ManagedSettings, + ManagedSettingsPermissions, MessageOptions, RequestId, SessionConfig, SessionId, SetModelOptions, Tool, ToolInvocation, ToolResult, }; use github_copilot_sdk::{Client, ContextTier, ErrorKind, ProtocolErrorKind, tool}; @@ -763,6 +765,135 @@ async fn create_session_sends_canvas_wire_fields() { timeout(TIMEOUT, create_handle).await.unwrap().unwrap(); } +#[tokio::test] +async fn create_and_resume_send_managed_settings_permissions() { + use github_copilot_sdk::types::ResumeSessionConfig; + + let (client, mut server_read, mut server_write) = make_client(); + + let managed = ManagedSettings::default().with_permissions( + ManagedSettingsPermissions::default() + .with_disable_bypass_permissions_mode(DisableBypassPermissionsMode::Disable) + .with_deny(vec!["shell(rm*)".to_string()]) + .with_ask(vec!["write".to_string()]) + .with_allow(vec![]), + ); + + let create_handle = tokio::spawn({ + let client = client.clone(); + let managed = managed.clone(); + async move { + client + .create_session( + SessionConfig::default() + .with_enable_managed_settings(true) + .with_managed_settings(managed), + ) + .await + .unwrap() + } + }); + + let request = read_framed(&mut server_read).await; + assert_eq!(request["method"], "session.create"); + assert_eq!(request["params"]["enableManagedSettings"], true); + let perms = &request["params"]["managedSettings"]["permissions"]; + assert_eq!(perms["disableBypassPermissionsMode"], "disable"); + assert_eq!(perms["deny"][0], "shell(rm*)"); + assert_eq!(perms["ask"][0], "write"); + assert_eq!(perms["allow"], serde_json::json!([])); + + let id = request["id"].as_u64().unwrap(); + let session_id = requested_session_id(&request).to_string(); + let response = serde_json::json!({ + "jsonrpc": "2.0", + "id": id, + "result": { "sessionId": session_id.clone() }, + }); + write_framed(&mut server_write, &serde_json::to_vec(&response).unwrap()).await; + timeout(TIMEOUT, create_handle).await.unwrap().unwrap(); + + let resume_handle = tokio::spawn({ + let client = client.clone(); + let session_id = session_id.clone(); + async move { + client + .resume_session( + ResumeSessionConfig::new(SessionId::from(session_id)) + .with_managed_settings(managed), + ) + .await + .unwrap() + } + }); + + let request = read_framed(&mut server_read).await; + assert_eq!(request["method"], "session.resume"); + assert_eq!( + request["params"]["managedSettings"]["permissions"]["deny"][0], + "shell(rm*)" + ); + + let id = request["id"].as_u64().unwrap(); + let response = serde_json::json!({ + "jsonrpc": "2.0", + "id": id, + "result": { "sessionId": session_id }, + }); + write_framed(&mut server_write, &serde_json::to_vec(&response).unwrap()).await; + + let reload = read_framed(&mut server_read).await; + assert_eq!(reload["method"], "session.skills.reload"); + let id = reload["id"].as_u64().unwrap(); + let response = serde_json::json!({ "jsonrpc": "2.0", "id": id, "result": {} }); + write_framed(&mut server_write, &serde_json::to_vec(&response).unwrap()).await; + + timeout(TIMEOUT, resume_handle).await.unwrap().unwrap(); +} + +#[test] +fn managed_settings_resolved_event_preserves_client_provenance() { + let sources = [ + (ManagedSettingsResolvedSource::Server, "server"), + (ManagedSettingsResolvedSource::Device, "device"), + (ManagedSettingsResolvedSource::Client, "client"), + (ManagedSettingsResolvedSource::Mixed, "mixed"), + (ManagedSettingsResolvedSource::None, "none"), + ]; + for (source, wire_value) in sources { + assert_eq!( + serde_json::to_value(source).unwrap(), + serde_json::json!(wire_value) + ); + } + + let with_client = SessionManagedSettingsResolvedData { + bypass_permissions_disabled: true, + client_managed: Some(true), + managed_keys: vec!["permissions".to_string()], + source: ManagedSettingsResolvedSource::Client, + ..Default::default() + }; + let serialized = serde_json::to_value(&with_client).unwrap(); + assert_eq!(serialized["source"], "client"); + assert_eq!(serialized["clientManaged"], true); + + let round_tripped: SessionManagedSettingsResolvedData = + serde_json::from_value(serialized).unwrap(); + assert_eq!(round_tripped.source, ManagedSettingsResolvedSource::Client); + assert_eq!(round_tripped.client_managed, Some(true)); + + let without_client = SessionManagedSettingsResolvedData { + bypass_permissions_disabled: true, + managed_keys: vec!["permissions".to_string()], + source: ManagedSettingsResolvedSource::Mixed, + ..Default::default() + }; + let serialized = serde_json::to_value(&without_client).unwrap(); + assert_eq!(serialized["source"], "mixed"); + assert!(serialized.get("clientManaged").is_none()); +} + fn make_client_with_telemetry( callback: github_copilot_sdk::github_telemetry::GitHubTelemetryCallback, ) -> (Client, tokio::io::DuplexStream, tokio::io::DuplexStream) {