diff --git a/build/lib/policies/policyData.jsonc b/build/lib/policies/policyData.jsonc index 0700cefd8c508b..7dba89b609083d 100644 --- a/build/lib/policies/policyData.jsonc +++ b/build/lib/policies/policyData.jsonc @@ -253,6 +253,24 @@ "github.copilot.chat.otel.captureContent" ] }, + { + "key": "chat.agentHost.otel.captureIdentity", + "name": "CopilotOtelCaptureIdentity", + "category": "InteractiveSession", + "minimumVersion": "1.139", + "localization": { + "description": { + "key": "chat.agentHost.otel.captureIdentity.policy", + "value": "Controls whether Copilot OpenTelemetry captures the authenticated account name, operating system username, and machine hostname. Independent of content capture." + } + }, + "type": "boolean", + "default": false, + "included": false, + "referencedSettings": [ + "github.copilot.chat.otel.captureIdentity" + ] + }, { "key": "chat.agentHost.otel.enabled", "name": "CopilotOtelEnabled", diff --git a/extensions/copilot/docs/monitoring/agent_monitoring.md b/extensions/copilot/docs/monitoring/agent_monitoring.md index 01ebf68235a16a..f9f81818395745 100644 --- a/extensions/copilot/docs/monitoring/agent_monitoring.md +++ b/extensions/copilot/docs/monitoring/agent_monitoring.md @@ -38,7 +38,7 @@ Open **Settings** (`Ctrl+,`) and add: } ``` -> **Note:** You can also use environment variables instead of VS Code settings (see [Configuration](#configuration)). Applied policy is included in settings values, but environment variables can still override those values in this extension. See the [activation limitations](#activation). +> **Note:** You can also use environment variables instead of VS Code settings (see [Configuration](#configuration)). Environment variables can still override some legacy policy-backed settings; identity capture and managed resource attributes enforce policy precedence. See [governed identity capture](#governed-identity-capture) and the [activation limitations](#activation). ### 3. Generate Telemetry @@ -72,6 +72,7 @@ Open **Settings** (`Ctrl+,`) and search for `copilot otel`: | `github.copilot.chat.otel.exporterType` | string | `"otlp-http"` | `otlp-http`, `otlp-grpc`, `console`, or `file` | | `github.copilot.chat.otel.otlpEndpoint` | string | `"http://localhost:4318"` | OTLP collector endpoint | | `github.copilot.chat.otel.captureContent` | boolean | `false` | Capture full prompt/response content | +| `github.copilot.chat.otel.captureIdentity` | boolean or null | `null` (off) | Capture authenticated account, OS username, and hostname independently of content. Environment overrides personal preferences; explicit managed policy always wins. | | `github.copilot.chat.otel.protocol` | string | `""` | OTLP wire protocol: `http/json` (default), `http/protobuf`, or `grpc` | | `github.copilot.chat.otel.serviceName` | string | `""` | Override the `service.name` resource attribute | | `github.copilot.chat.otel.resourceAttributes` | object | `{}` | Extra resource attributes (`{ "key": "value" }`) | @@ -82,12 +83,20 @@ Open **Settings** (`Ctrl+,`) and search for `copilot otel`: ### Environment Variables -Environment variables retain their existing precedence. When enterprise OTel configuration is +Except for governed identity and managed resource attributes, environment variables retain +their existing precedence. When enterprise OTel configuration is recognized through the application-scoped policy defaults, the entire Copilot OTel settings block comes from those policy values and schema defaults. Personal `settings.json` values are not used to fill omitted fields: headers and resource attributes default to empty maps, not the user's maps. Other VS Code settings are unaffected. +An identity-only managed block (including `telemetry.capture.identity: false`) is +recognizable enterprise OTel configuration. It therefore also replaces personally +enabled export, endpoints, headers, and DB export with policy/schema defaults. +To keep export enabled under that block, administrators should configure `enabled` +and the intended exporter destination as well. Identity omission remains distinct +from denial; it does not by itself prevent personal/environment identity opt-in. + | Variable | Default | Description | |---|---|---| | `COPILOT_OTEL_ENABLED` | `false` | Enable OTel. Also enabled when `OTEL_EXPORTER_OTLP_ENDPOINT` is set. | @@ -98,12 +107,63 @@ the user's maps. Other VS Code settings are unaffected. | `OTEL_SERVICE_NAME` | `copilot-chat` | Service name in resource attributes | | `OTEL_RESOURCE_ATTRIBUTES` | — | Extra resource attributes (`key1=val1,key2=val2`) | | `COPILOT_OTEL_CAPTURE_CONTENT` | `false` | Capture full prompt/response content | +| `COPILOT_OTEL_CAPTURE_IDENTITY` | `false` | Opt into identity capture unless explicitly denied by managed policy. | | `COPILOT_OTEL_MAX_ATTRIBUTE_SIZE_CHARS` | `0` | Override the max character size for OTel content attributes. `0` (default) disables truncation; set to a positive value when your backend has a per-attribute limit. Takes precedence over the `maxAttributeSizeChars` setting. | | `COPILOT_OTEL_LOG_LEVEL` | `info` | Min log level: `trace`, `debug`, `info`, `warn`, `error` | | `COPILOT_OTEL_FILE_EXPORTER_PATH` | — | Write all signals to this file (JSON-lines) | | `COPILOT_OTEL_HTTP_INSTRUMENTATION` | `false` | Enable HTTP-level OTel instrumentation | | `OTEL_EXPORTER_OTLP_HEADERS` | — | Auth headers (e.g., `Authorization=Bearer token`) | +### Governed identity capture + +Managed `telemetry.capture.identity` is independent of `captureContent` and +`lockCaptureContent`: neither content control enables or denies identity. An explicit +managed `false` overrides personal settings and environment opt-in. Omission is not +a denial and leaves the identity preference available, including when other managed +OTel settings replace the personal configuration block. + +Across managed sources, the highest-priority telemetry block wins as a whole: +native MDM, then server-delivered settings, then the managed-settings file. +Omitted identity, resource attributes, and other telemetry leaves are not filled +from weaker managed sources. For server and file object sources, an empty block, +or one containing only unsupported fields, still replaces the weaker block. +Native MDM exposes declared flat keys, not a telemetry object: only an observed +declared telemetry key establishes its block. Empty or unknown-only native blocks +cannot be observed. Removing all observable telemetry keys (or the server/file +block entirely) allows the next managed source to apply. This does not change +precedence for unrelated managed settings or the native runtime's own transport. +Native delivery also observes the other recognized `telemetry.capture.*` controls +for block selection only; this does not implement those content-capture classes +in Local or expose additional Local settings. + +When enabled, `user.name` is the current authenticated provider account name on +top-level, subagent, and inline `invoke_agent` spans. Each invocation reads the +authentication service's current session rather than caching an account name in +the telemetry service. Account changes and sign-out take effect once the +authentication service has refreshed its session. +No anonymous identity is invented, and `enduser.pseudo.id` is unchanged. +`process.user.name` and `host.name` are resource attributes. Explicit +`OTEL_RESOURCE_ATTRIBUTES` overrides detected OS/host values; managed +`resourceAttributes` overrides environment attributes. Identity attributes are +removed when capture is off, even if explicitly supplied as resource attributes. + +> **Compatibility note:** Explicit `host.name`, `process.user.name`, and `user.name` +> resource attributes from environment variables, personal settings, or managed +> `resourceAttributes` do not bypass the identity gate. They are removed by default +> and under an explicit managed denial, not just omitted from automatic detection. +> Other custom resource attributes are unaffected. + +Enabling capture still requires the normal reload/recovery flow. Disabling is +checked before subsequent exports and local completion events, including queued +spans, logs, metrics, and SQLite export. Already exported or persisted data cannot +be recalled, and an export already handed to its transport cannot be cancelled. +The nullable default preserves omission versus explicit managed `false` through +the editor's configuration API; it does not enable capture. + +This applies to the legacy Local extension-host harness. The native Copilot runtime +owns its own managed identity enforcement. The message-content environment bridge +does not enable identity or forward account/OS/host values. + ### Activation When late enterprise OTel settings turn on external export after Copilot's telemetry service @@ -113,7 +173,8 @@ the host restarts or the attempt ends. Successful recovery is logged without ano Restarting also interrupts other extensions in the window. If the restart is unavailable, vetoed, or fails to apply the settings, a warning offers **Reload Window** instead. User changes and policy withdrawal remain -opt-in reloads. Exporter behavior and environment-variable precedence are unchanged. +opt-in reloads, except that identity denial is enforced before subsequent exports. +Existing exporter selection and legacy environment-variable precedence are unchanged. It uses changes to the application-scoped, policy-backed configuration defaults as a recovery signal, without a new API. Normal personal settings changes do not change those defaults. If a recognizable enterprise OTel block was already present at initialization, later changes @@ -203,9 +264,9 @@ Inline chat uses the same invocation shape, with `invoke_agent Inline Chat` as t | `copilot_chat.repo.head_commit_hash` | **Legacy** — prefer `github.copilot.git.commit_sha` | `deadbeef...` | | `copilot_chat.turn_count` | Always | `4` | | `error.type` | On error | `Error` | -| `gen_ai.input.messages` | Always for the foreground agent | `[{"role":"user",...}]` | -| `gen_ai.output.messages` | Always for the foreground agent | `[{"role":"assistant",...}]` | -| `gen_ai.tool.definitions` | Always for the foreground agent | `[{"type":"function",...}]` | +| `gen_ai.input.messages` | Export opt-in (captureContent); retained locally for foreground debugging | `[{"role":"user",...}]` | +| `gen_ai.output.messages` | Export opt-in (captureContent); retained locally for foreground debugging | `[{"role":"assistant",...}]` | +| `gen_ai.tool.definitions` | Export opt-in (captureContent); retained locally for foreground debugging | `[{"type":"function",...}]` | **`chat`** — one span per LLM API call (span kind: `CLIENT`). @@ -259,10 +320,10 @@ Inline chat uses the same invocation shape, with `invoke_agent Inline Chat` as t | `github.copilot.tool.parameters.file_path` | File tools, opt-in (captureContent) | `/src/app.ts` | | `github.copilot.tool.parameters.mcp_server_name` | MCP tools, opt-in (captureContent) | `github` | | `error.type` | On error | `FileNotFoundError` | -| `gen_ai.tool.call.arguments` | Always (bounded) | `{"filePath":"/src/index.ts"}` | -| `gen_ai.tool.call.result` | Always (bounded) | `(file contents or summary)` | +| `gen_ai.tool.call.arguments` | Export opt-in (captureContent); retained locally (bounded) | `{"filePath":"/src/index.ts"}` | +| `gen_ai.tool.call.result` | Export opt-in (captureContent); retained locally (bounded) | `(file contents or summary)` | -**`execute_hook`** — one span per local hook command (span kind: `INTERNAL`). Hook input and successful output are retained for the Agent Debug Log and are therefore captured regardless of `captureContent`; `maxAttributeSizeChars` still applies. +**`execute_hook`** — one span per local hook command (span kind: `INTERNAL`). Hook input and successful output are retained for the Agent Debug Log regardless of `captureContent`, but require `captureContent` for primary span export; `maxAttributeSizeChars` still applies. | Attribute | Requirement | Example | |---|---|---| @@ -270,8 +331,8 @@ Inline chat uses the same invocation shape, with `invoke_agent Inline Chat` as t | `gen_ai.conversation.id` | Session correlation | `a1b2c3d4-...` | | `copilot_chat.hook_type` | Required | `PreToolUse` | | `copilot_chat.hook_command` | Always | `./scripts/check.sh` | -| `copilot_chat.hook_input` | Always (bounded) | `{"tool_name":"run_in_terminal",...}` | -| `copilot_chat.hook_output` | On successful output (bounded) | `ok` | +| `copilot_chat.hook_input` | Export opt-in (captureContent); retained locally (bounded) | `{"tool_name":"run_in_terminal",...}` | +| `copilot_chat.hook_output` | Export opt-in (captureContent); retained locally on success (bounded) | `ok` | | `copilot_chat.hook_result_kind` | On completion | `success` \| `error` \| `non_blocking_error` | | `github.copilot.hook.tool_names` | When available | `["run_in_terminal"]` | | `github.copilot.hook.duration` | On completion (seconds) | `0.25` | @@ -546,13 +607,19 @@ These custom attributes are included in all traces, metrics, and events, allowin > **Note:** `OTEL_RESOURCE_ATTRIBUTES` uses comma-separated `key=value` pairs. The extension splits on commas, trims surrounding whitespace, and does not percent-decode values. Use the `github.copilot.chat.otel.resourceAttributes` object setting when a value needs to contain a comma. +The governed identity keys (`user.name`, `process.user.name`, `host.name`) are +excluded even when explicitly configured unless identity capture is allowed. +See [Governed identity capture](#governed-identity-capture). + --- ## Content Capture -The `captureContent` setting controls content on individual LLM `chat` spans and inference events. Some content required by the Agent Debug Log is captured on foreground `invoke_agent`, `execute_tool`, and `execute_hook` spans regardless of this setting, including user and final assistant messages, tool definitions, tool arguments/results, and hook command input/output. These attributes are exported when extension OTel export is enabled. +The `captureContent` setting controls content on individual LLM `chat` spans and inference events. It also gates known content attributes on spans and span events sent through the Local harness's primary exporters: OTLP, file, and console. When off, the export boundary removes input/output messages, system instructions, tool definitions, tool arguments/results, user requests, reasoning, prompt context/instructions, markdown content, hook input/output, and generic `content`/`toolDefinitions` attributes. Span events can remain without their content attributes. + +Foreground `invoke_agent`, `execute_tool`, and `execute_hook` spans still retain content needed by the Agent Debug Log and the separate local SQLite exporter, regardless of this setting. Primary file export is not the SQLite debug exporter. Identity filtering applies to both paths independently. -To capture full LLM request and response content as well, add to your VS Code settings: +To include these content attributes in primary exports and capture full LLM request and response content, add to your VS Code settings: ```json { @@ -571,7 +638,7 @@ This additionally populates content attributes on `chat` spans and inference eve Content attributes are not truncated by default. Set `github.copilot.chat.otel.maxAttributeSizeChars` to a positive value when the backend imposes a per-attribute limit. -> **Warning:** Exported foreground spans can contain sensitive information such as code, file contents, commands, and user prompts even when `captureContent` is off. Enabling `captureContent` adds full LLM request and response content. Configure export only for trusted environments. +> **Warning:** This is filtering of known content attributes, not universal payload sanitization. Other metadata, span names, status messages, or unrecognized attributes may still contain sensitive information. Local debug content retention is separate. Enabling `captureContent` includes prompt, response, and tool content in primary exports. Configure export only for trusted environments. --- @@ -836,7 +903,7 @@ Refer to each backend's documentation for OTLP ingestion setup. ## Security & Privacy - **Export is off by default.** No OTel data is sent to an external exporter unless explicitly enabled. When disabled, the OTel SDK is not loaded; a lightweight in-memory service still records data needed by the Agent Debug Log. -- **Content capture is split.** Full LLM request/response content requires `captureContent`, but foreground agent, tool, and hook spans retain content needed by the Agent Debug Log even when that setting is off. Review [Content Capture](#content-capture) before enabling export. +- **Content export and local debug retention are separate.** Known span and span-event content attributes require `captureContent` in primary OTLP, file, and console exports. Foreground agent, tool, and hook spans retain content for local debugging even when that setting is off. This is not universal sanitization; review [Content Capture](#content-capture) before enabling export. - **Treat exported content as sensitive.** User messages, file contents, commands, tool payloads, and hook data can contain personal or confidential information. - **User-configured endpoints.** Data goes only where you point it — no phone-home behavior. - **Dynamic imports only.** OTel SDK packages are loaded on-demand, ensuring zero bundle impact when disabled. diff --git a/extensions/copilot/package.json b/extensions/copilot/package.json index a136afbad15055..a20e6be66fcde8 100644 --- a/extensions/copilot/package.json +++ b/extensions/copilot/package.json @@ -5373,6 +5373,21 @@ "advanced" ] }, + "github.copilot.chat.otel.captureIdentity": { + "type": [ + "boolean", + "null" + ], + "default": null, + "scope": "application", + "policyReference": { + "name": "CopilotOtelCaptureIdentity" + }, + "markdownDescription": "Capture the authenticated Copilot account name, operating system username, and machine hostname in OpenTelemetry. **Contains personally identifying data.** Independent of content capture. `null` means no personal preference (off by default). `COPILOT_OTEL_CAPTURE_IDENTITY` overrides personal settings; explicit enterprise policy overrides both. Enabling requires window reload; an effective denial applies immediately to subsequent exports.", + "tags": [ + "advanced" + ] + }, "github.copilot.chat.otel.serviceName": { "type": "string", "default": "", diff --git a/extensions/copilot/src/extension/extension/vscode-node/services.ts b/extensions/copilot/src/extension/extension/vscode-node/services.ts index 63950742c8406d..c859c2d3554107 100644 --- a/extensions/copilot/src/extension/extension/vscode-node/services.ts +++ b/extensions/copilot/src/extension/extension/vscode-node/services.ts @@ -308,7 +308,8 @@ export function registerServices(builder: IInstantiationServiceBuilder, extensio else if (level === 'warn') { console.warn(msg); } else { console.info(msg); } }; - builder.define(IOTelService, new NodeOTelService(otelConfig, logFn, otelConfig.dbSpanExporter ? otelSqliteStore : undefined)); + builder.define(IOTelService, new NodeOTelService(otelConfig, logFn, otelConfig.dbSpanExporter ? otelSqliteStore : undefined, + () => otelConfigResolver.resolve().config.captureIdentity)); } else { builder.define(IOTelService, new InMemoryOTelService(otelConfig)); } diff --git a/extensions/copilot/src/extension/inlineChat2/node/inlineChatIntent.ts b/extensions/copilot/src/extension/inlineChat2/node/inlineChatIntent.ts index 0032af4d6080f3..36972c05c6098f 100644 --- a/extensions/copilot/src/extension/inlineChat2/node/inlineChatIntent.ts +++ b/extensions/copilot/src/extension/inlineChat2/node/inlineChatIntent.ts @@ -18,6 +18,7 @@ import { ILogService } from '../../../platform/log/common/logService'; import { IChatEndpoint, IMakeChatRequestOptions } from '../../../platform/networking/common/networking'; import { CopilotChatAttr, GenAiAttr, GenAiMetrics, GenAiOperationName, GenAiProviderName, GitHubCopilotAttr, normalizeResponseModel, StdAttr, stringifyToolDefinitionsForOTel, truncateForOTel } from '../../../platform/otel/common/index'; import { IOTelService, SpanKind, SpanStatusCode } from '../../../platform/otel/common/otelService'; +import { agentIdentityAttributes } from '../../../platform/otel/common/otelIdentity'; import { IExperimentationService } from '../../../platform/telemetry/common/nullExperimentationService'; import { ChatResponseStreamImpl } from '../../../util/common/chatResponseStreamImpl'; import { toErrorMessage } from '../../../util/common/errorMessage'; @@ -204,6 +205,7 @@ class InlineChatToolCalling { @IConfigurationService private readonly _configurationService: IConfigurationService, @IExperimentationService private readonly _experimentationService: IExperimentationService, @IOTelService private readonly _otelService: IOTelService, + @IAuthenticationService private readonly _authenticationService: IAuthenticationService, ) { } async run(endpoint: IChatEndpoint, conversation: Conversation, request: vscode.ChatRequest, stream: vscode.ChatResponseStream, token: CancellationToken, documentContext: IDocumentContext, chatTelemetry: ChatTelemetryBuilder): Promise { @@ -216,6 +218,7 @@ class InlineChatToolCalling { kind: SpanKind.INTERNAL, attributes: { [GenAiAttr.OPERATION_NAME]: GenAiOperationName.INVOKE_AGENT, + ...agentIdentityAttributes(this._otelService.config, this._authenticationService), [GenAiAttr.PROVIDER_NAME]: GenAiProviderName.GITHUB, [GenAiAttr.AGENT_NAME]: 'Inline Chat', [GenAiAttr.CONVERSATION_ID]: conversation.sessionId, diff --git a/extensions/copilot/src/extension/intents/node/toolCallingLoop.ts b/extensions/copilot/src/extension/intents/node/toolCallingLoop.ts index 1f78d6090661e3..274d81e0473403 100644 --- a/extensions/copilot/src/extension/intents/node/toolCallingLoop.ts +++ b/extensions/copilot/src/extension/intents/node/toolCallingLoop.ts @@ -24,6 +24,8 @@ import { IChatEndpoint, IMakeChatRequestOptions } from '../../../platform/networ import { nanoAiuToCredits, OpenAIContextManagementResponse } from '../../../platform/networking/common/openai'; import { CopilotChatAttr, emitAgentTurnEvent, emitSessionStartEvent, GenAiAttr, GenAiMetrics, GenAiOperationName, GenAiProviderName, GitHubCopilotAttr, normalizeResponseModel, resolveWorkspaceOTelMetadata, StdAttr, stringifyToolDefinitionsForOTel, truncateForOTel, workspaceMetadataToOTelAttributes } from '../../../platform/otel/common/index'; import { IOTelService, ISpanHandle, SpanKind, SpanStatusCode } from '../../../platform/otel/common/otelService'; +import { agentIdentityAttributes } from '../../../platform/otel/common/otelIdentity'; +import { IAuthenticationService } from '../../../platform/authentication/common/authentication'; import { IRequestLogger } from '../../../platform/requestLogger/common/requestLogger'; import { getCurrentCapturingToken } from '../../../platform/requestLogger/node/requestLogger'; import { IExperimentationService } from '../../../platform/telemetry/common/nullExperimentationService'; @@ -416,6 +418,7 @@ export abstract class ToolCallingLoop { + protected override async buildPrompt(): Promise { return nullRenderPromptResult(); } + protected override async getAvailableTools(): Promise { return []; } + protected override async fetch(): Promise { throw new Error('Not used by invocation tests'); } + override async runOne(): Promise { + return { + response: { type: ChatFetchResponseType.Success, value: 'answer', requestId: 'test', serverRequestId: undefined, usage: undefined, resolvedModel: 'test' }, + round: new ToolCallRound('answer'), + hadIgnoredFiles: false, + lastRequestMessages: [], + availableTools: [], + }; + } +} + +/** Avoid model metadata caches and network requests: only the invocation boundary is exercised. */ +class IdentityTestEndpointProvider implements IEndpointProvider { + declare readonly _serviceBrand: undefined; + readonly onDidModelsRefresh = Event.None; + constructor(@IInstantiationService private readonly _instantiationService: IInstantiationService) { } + async getAllCompletionModels() { return []; } + async getAllChatEndpoints(): Promise { return [await this.getChatEndpoint()]; } + async getChatEndpoint(): Promise { return this._instantiationService.createInstance(MockEndpoint, 'test'); } + async getEmbeddingsEndpoint(): Promise { throw new Error('Not used by identity tests'); } +} + +class IdentityTestAuthentication extends StaticGitHubAuthenticationService { + setAccount(name: string | undefined): void { + this._anyGitHubSession = name === undefined ? undefined : { + id: name, accessToken: 'test-token', scopes: [], account: { id: name, label: name }, + }; + } +} + +function request(subagent: boolean): ChatRequest { + return { + prompt: 'hello', command: undefined, references: [], location: 1, location2: undefined, + attempt: 0, enableCommandDetection: false, isParticipantDetected: false, toolReferences: [], + toolInvocationToken: {} as ChatRequest['toolInvocationToken'], model: { family: 'test' } as LanguageModelChat, + tools: new Map(), id: generateUuid(), sessionId: generateUuid(), + sessionResource: {} as ChatRequest['sessionResource'], hasHooksEnabled: false, + ...(subagent ? { subAgentInvocationId: 'child', subAgentName: 'search' } : {}), + }; +} + +describe('ToolCallingLoop identity attribution', () => { + const disposables = new DisposableStore(); + let otel: InMemoryOTelService; + afterEach(async () => { + disposables.clear(); + await otel?.shutdown(); + }); + + it.each([false, true])('reads the current account for top-level and subagent spans (capture=%s)', async captureIdentity => { + const services = disposables.add(createExtensionUnitTestingServices()); + services.define(IEndpointProvider, new SyncDescriptor(IdentityTestEndpointProvider)); + services.define(IAuthenticationService, new SyncDescriptor(IdentityTestAuthentication, [undefined])); + otel = new InMemoryOTelService(resolveOTelConfig({ + env: {}, settingEnabled: true, settingCaptureIdentity: captureIdentity, settingCaptureContent: false, + extensionVersion: 'test', sessionId: 'test', + })); + services.define(IOTelService, otel); + const accessor = disposables.add(services.createTestingAccessor()); + const instantiation = accessor.get(IInstantiationService); + const auth = accessor.get(IAuthenticationService); + expect(auth).toBeInstanceOf(IdentityTestAuthentication); + if (!(auth instanceof IdentityTestAuthentication)) { + throw new Error('Identity test authentication was not registered'); + } + const completed: ICompletedSpanData[] = []; + disposables.add(otel.onDidCompleteSpan(span => completed.push(span))); + for (const [account, subagent] of [['first', false], ['first', true], ['second', true], [undefined, false]] as const) { + auth.setAccount(account); + const chatRequest = request(subagent); + const loop = disposables.add(instantiation.createInstance(IdentityTestLoop, { + request: chatRequest, toolCallLimit: 1, + conversation: new Conversation(generateUuid(), [new Turn(generateUuid(), { type: 'user', message: 'hello' })]), + })); + await loop.run(undefined, CancellationToken.None); + } + expect(completed.filter(span => span.attributes[GenAiAttr.OPERATION_NAME] === 'invoke_agent') + .map(span => span.attributes[StdAttr.USER_NAME])).toEqual(captureIdentity ? ['first', 'first', 'second', undefined] : [undefined, undefined, undefined, undefined]); + }); +}); diff --git a/extensions/copilot/src/extension/mcp/vscode-node/mcpToolCallingLoop.tsx b/extensions/copilot/src/extension/mcp/vscode-node/mcpToolCallingLoop.tsx index 48123dd9cc2ee4..9bb141f1a63476 100644 --- a/extensions/copilot/src/extension/mcp/vscode-node/mcpToolCallingLoop.tsx +++ b/extensions/copilot/src/extension/mcp/vscode-node/mcpToolCallingLoop.tsx @@ -5,6 +5,7 @@ import type { CancellationToken, LanguageModelToolInformation, Progress } from 'vscode'; import { IAuthenticationChatUpgradeService } from '../../../platform/authentication/common/authenticationUpgrade'; +import { IAuthenticationService } from '../../../platform/authentication/common/authentication'; import { IChatHookService } from '../../../platform/chat/common/chatHookService'; import { ChatLocation, ChatResponse } from '../../../platform/chat/common/commonTypes'; import { ISessionTranscriptService } from '../../../platform/chat/common/sessionTranscriptService'; @@ -48,8 +49,9 @@ export class McpToolCallingLoop extends ToolCallingLoop { log = new RecordingLogService(); }); + it('prompts on identity revocation without restarting or mutating the running config', async () => { + settings.policy = { ...managedPolicy, captureIdentity: true }; + const resolver = new TestResolver(settings); + const monitor = new OTelStaleConfigMonitor(resolver, host, log); + settings.policy.captureIdentity = false; + expect(await monitor.check()).toBe(OTelConfigDrift.Policy); + expect({ prompts: host.prompts, restarts: host.restarts, active: resolver.activeResolution.config.captureIdentity }).toEqual({ + prompts: 1, restarts: 0, active: true, + }); + expect(resolver.resolve().config.captureIdentity).toBe(false); + }); + it('restarts for policy that lands before the contribution can register its watcher', async () => { const resolver = new TestResolver(settings); expect(resolver.activeResolution.config.enabled).toBe(false); diff --git a/extensions/copilot/src/extension/prompt/node/codebaseToolCalling.ts b/extensions/copilot/src/extension/prompt/node/codebaseToolCalling.ts index 0fc1d4225c4eb1..67cf354c9f9741 100644 --- a/extensions/copilot/src/extension/prompt/node/codebaseToolCalling.ts +++ b/extensions/copilot/src/extension/prompt/node/codebaseToolCalling.ts @@ -6,6 +6,7 @@ import { randomUUID } from 'crypto'; import type { CancellationToken, ChatRequest, LanguageModelToolInformation, Progress } from 'vscode'; import { IAuthenticationChatUpgradeService } from '../../../platform/authentication/common/authenticationUpgrade'; +import { IAuthenticationService } from '../../../platform/authentication/common/authentication'; import { IChatHookService } from '../../../platform/chat/common/chatHookService'; import { ChatLocation, ChatResponse } from '../../../platform/chat/common/commonTypes'; import { ISessionTranscriptService } from '../../../platform/chat/common/sessionTranscriptService'; @@ -52,8 +53,9 @@ export class CodebaseToolCallingLoop extends ToolCallingLoop { @IFileSystemService fileSystemService: IFileSystemService, @IOTelService otelService: IOTelService, @IGitService gitService: IGitService, + @IAuthenticationService authenticationService: IAuthenticationService, ) { - super(options, instantiationService, endpointProvider, logService, requestLogger, authenticationChatUpgradeService, telemetryService, configurationService, experimentationService, chatHookService, sessionTranscriptService, fileSystemService, otelService, gitService); + super(options, instantiationService, endpointProvider, logService, requestLogger, authenticationChatUpgradeService, telemetryService, configurationService, experimentationService, chatHookService, sessionTranscriptService, fileSystemService, otelService, gitService, authenticationService); this._register(this.onDidBuildPrompt(({ result, tools, promptTokenLength, toolTokenCount }) => { if (result.metadata.get(SummarizedConversationHistoryMetadata)) { diff --git a/extensions/copilot/src/extension/prompt/node/executionSubagentToolCallingLoop.ts b/extensions/copilot/src/extension/prompt/node/executionSubagentToolCallingLoop.ts index a9e31b45480c7e..3a7999d916e67f 100644 --- a/extensions/copilot/src/extension/prompt/node/executionSubagentToolCallingLoop.ts +++ b/extensions/copilot/src/extension/prompt/node/executionSubagentToolCallingLoop.ts @@ -6,6 +6,7 @@ import { randomUUID } from 'crypto'; import type { CancellationToken, ChatRequest, ChatResponseStream, LanguageModelToolInformation, Progress } from 'vscode'; import { IAuthenticationChatUpgradeService } from '../../../platform/authentication/common/authenticationUpgrade'; +import { IAuthenticationService } from '../../../platform/authentication/common/authentication'; import { IChatHookService } from '../../../platform/chat/common/chatHookService'; import { ChatLocation, ChatResponse } from '../../../platform/chat/common/commonTypes'; import { ISessionTranscriptService } from '../../../platform/chat/common/sessionTranscriptService'; @@ -90,9 +91,10 @@ export class ExecutionSubagentToolCallingLoop extends ToolCallingLoop('chat.otel.protocol', ConfigType.Simple, ''); export const OTelOtlpEndpoint = defineSetting('chat.otel.otlpEndpoint', ConfigType.Simple, 'http://localhost:4318'); export const OTelCaptureContent = defineSetting('chat.otel.captureContent', ConfigType.Simple, false); + export const OTelCaptureIdentity = defineSetting('chat.otel.captureIdentity', ConfigType.Simple, null); export const OTelServiceName = defineSetting('chat.otel.serviceName', ConfigType.Simple, ''); export const OTelResourceAttributes = defineSetting>('chat.otel.resourceAttributes', ConfigType.Simple, {}); export const OTelHeaders = defineSetting>('chat.otel.headers', ConfigType.Simple, {}); diff --git a/extensions/copilot/src/platform/otel/common/agentOTelEnv.ts b/extensions/copilot/src/platform/otel/common/agentOTelEnv.ts index 563143995f3860..33c0dfb383e737 100644 --- a/extensions/copilot/src/platform/otel/common/agentOTelEnv.ts +++ b/extensions/copilot/src/platform/otel/common/agentOTelEnv.ts @@ -42,6 +42,9 @@ export function deriveCopilotCliOTelEnv(config: OTelConfig, env: Record setting > default(0 = unlimited). const maxAttributeSizeChars = parseMaxAttributeSizeChars(env['COPILOT_OTEL_MAX_ATTRIBUTE_SIZE_CHARS']) ?? input.settingMaxAttributeSizeChars @@ -270,6 +278,7 @@ export function resolveOTelConfig(input: OTelConfigInput): OTelConfig { otlpEndpoint, otlpProtocol, captureContent, + captureIdentity, maxAttributeSizeChars: maxAttributeSizeChars < 0 ? 0 : maxAttributeSizeChars, fileExporterPath, dbSpanExporter, @@ -292,6 +301,7 @@ function createDisabledConfig(input: OTelConfigInput): OTelConfig { otlpEndpoint: '', otlpProtocol: 'http/json' as const, captureContent: false, + captureIdentity: false, maxAttributeSizeChars: 0, dbSpanExporter: false, logLevel: 'info' as const, diff --git a/extensions/copilot/src/platform/otel/common/otelConfigResolution.ts b/extensions/copilot/src/platform/otel/common/otelConfigResolution.ts index be4ad547bc3a76..adb891d2b0f92a 100644 --- a/extensions/copilot/src/platform/otel/common/otelConfigResolution.ts +++ b/extensions/copilot/src/platform/otel/common/otelConfigResolution.ts @@ -14,6 +14,8 @@ export const OTEL_SETTING_DEFAULTS = { protocol: '', otlpEndpoint: DEFAULT_OTLP_ENDPOINT, captureContent: false, + // Unlike false, null preserves the distinction between omission and a managed denial. + captureIdentity: null, serviceName: '', resourceAttributes: {}, headers: {}, @@ -26,7 +28,7 @@ type OTelSettingKey = keyof typeof OTEL_SETTING_DEFAULTS; type OTelDefaultValues = Record; const settingKeys = Object.keys(OTEL_SETTING_DEFAULTS) as OTelSettingKey[]; const policySettingKeys = [ - 'enabled', 'exporterType', 'protocol', 'otlpEndpoint', 'captureContent', + 'enabled', 'exporterType', 'protocol', 'otlpEndpoint', 'captureContent', 'captureIdentity', 'serviceName', 'resourceAttributes', 'headers', 'outfile', ] as const; @@ -55,6 +57,7 @@ export function snapshotOTelEnv(env: Record): Record const keys = [ 'COPILOT_OTEL_ENABLED', 'COPILOT_OTEL_ENDPOINT', 'COPILOT_OTEL_PROTOCOL', 'COPILOT_OTEL_FILE_EXPORTER_PATH', 'COPILOT_OTEL_CAPTURE_CONTENT', + 'COPILOT_OTEL_CAPTURE_IDENTITY', 'COPILOT_OTEL_MAX_ATTRIBUTE_SIZE_CHARS', 'COPILOT_OTEL_LOG_LEVEL', 'COPILOT_OTEL_HTTP_INSTRUMENTATION', 'OTEL_EXPORTER_OTLP_ENDPOINT', 'OTEL_EXPORTER_OTLP_PROTOCOL', 'OTEL_EXPORTER_OTLP_HEADERS', @@ -88,12 +91,16 @@ export function resolveOTelConfigFromSettings( settingExporterType: read('exporterType'), settingOtlpEndpoint: read('otlpEndpoint'), settingCaptureContent: read('captureContent'), + // Omission is not a denial, even when another enterprise OTel field is present. + settingCaptureIdentity: settings.get('captureIdentity') ?? undefined, + policyCaptureIdentity: typeof defaultValues.captureIdentity === 'boolean' ? defaultValues.captureIdentity : undefined, settingMaxAttributeSizeChars: read('maxAttributeSizeChars'), settingOutfile: read('outfile') || undefined, settingDbSpanExporter: read('dbSpanExporter.enabled'), settingProtocol: read('protocol') || undefined, settingServiceName: read('serviceName') || undefined, settingResourceAttributes: read>('resourceAttributes'), + policyResourceAttributes: hasEnterpriseSettings ? read>('resourceAttributes') : undefined, settingHeaders: read>('headers'), extensionVersion, sessionId, diff --git a/extensions/copilot/src/platform/otel/common/otelIdentity.ts b/extensions/copilot/src/platform/otel/common/otelIdentity.ts new file mode 100644 index 00000000000000..a0d59a6bcd05ee --- /dev/null +++ b/extensions/copilot/src/platform/otel/common/otelIdentity.ts @@ -0,0 +1,46 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import type { IAuthenticationService } from '../../authentication/common/authentication'; +import { StdAttr } from './genAiAttributes'; +import type { OTelConfig } from './otelConfig'; +import type { ICompletedSpanData } from './otelService'; + +const identityAttributes = new Set([StdAttr.USER_NAME, StdAttr.PROCESS_USER_NAME, StdAttr.HOST_NAME]); + +/** Read the authentication service's current session for each invocation, without caching a separate account name. */ +export function agentIdentityAttributes(config: OTelConfig, authentication: Pick): Record { + const name = config.captureIdentity ? authentication.anyGitHubSession?.account.label : undefined; + return name ? { [StdAttr.USER_NAME]: name } : {}; +} + +/** Applies to all sinks, including SDK bridge events and local debug consumers. */ +export function filterIdentityAttributes(attributes: Readonly>, allowed: boolean): Record { + return Object.fromEntries(Object.entries(attributes).filter(([key]) => allowed || !identityAttributes.has(key))); +} + +/** Detect only after consent; explicit resource attributes override detected values. */ +export function identityResourceAttributes(attributes: Record, allowed: boolean, detect: () => { username: string; hostname: string }): Record { + if (!allowed) { + return filterIdentityAttributes(attributes, false); + } + const { username, hostname } = detect(); + return { + [StdAttr.PROCESS_USER_NAME]: username, + [StdAttr.HOST_NAME]: hostname, + ...attributes, + }; +} + +export function filterIdentitySpan(span: ICompletedSpanData, allowed: boolean): ICompletedSpanData { + return { + ...span, + attributes: filterIdentityAttributes(span.attributes, allowed), + events: span.events.map(event => ({ + ...event, + attributes: event.attributes && filterIdentityAttributes(event.attributes, allowed), + })), + }; +} diff --git a/extensions/copilot/src/platform/otel/common/test/agentOTelEnv.spec.ts b/extensions/copilot/src/platform/otel/common/test/agentOTelEnv.spec.ts index 209047cc494d5d..634651768f9480 100644 --- a/extensions/copilot/src/platform/otel/common/test/agentOTelEnv.spec.ts +++ b/extensions/copilot/src/platform/otel/common/test/agentOTelEnv.spec.ts @@ -16,6 +16,7 @@ function makeConfig(overrides: Partial = {}): OTelConfig { otlpEndpoint: 'http://localhost:4318', otlpProtocol: 'http/json', captureContent: false, + captureIdentity: false, maxAttributeSizeChars: 0, dbSpanExporter: false, logLevel: 'info', @@ -32,6 +33,14 @@ function makeConfig(overrides: Partial = {}): OTelConfig { const emptyEnv: Record = {}; describe('deriveCopilotCliOTelEnv', () => { + it('never treats identity as content or forwards identity and secret headers through env', () => { + const result = deriveCopilotCliOTelEnv(makeConfig({ + captureIdentity: true, captureContent: false, + resourceAttributes: { 'host.name': 'private-host' }, headers: { authorization: 'secret' }, + }), emptyEnv); + expect(result).toEqual({ COPILOT_OTEL_ENABLED: 'true', OTEL_EXPORTER_OTLP_ENDPOINT: 'http://localhost:4318' }); + }); + it('returns empty when disabled', () => { const result = deriveCopilotCliOTelEnv(makeConfig({ enabled: false }), emptyEnv); expect(result).toEqual({}); diff --git a/extensions/copilot/src/platform/otel/common/test/otelConfigResolution.spec.ts b/extensions/copilot/src/platform/otel/common/test/otelConfigResolution.spec.ts index ef63a6539f4799..97fb2b0ed7eaed 100644 --- a/extensions/copilot/src/platform/otel/common/test/otelConfigResolution.spec.ts +++ b/extensions/copilot/src/platform/otel/common/test/otelConfigResolution.spec.ts @@ -18,6 +18,67 @@ function resolve(settings: TestOTelSettings, env: Record { + it('defaults identity off without conflating omission with explicit policy false', () => { + const settings = new TestOTelSettings(); + settings.user = { enabled: true, captureIdentity: true }; + expect(resolve(settings).config.captureIdentity).toBe(true); + settings.policy = { enabled: true }; + expect(resolve(settings).config.captureIdentity).toBe(true); + settings.policy.captureIdentity = false; + expect(resolve(settings, { COPILOT_OTEL_CAPTURE_IDENTITY: 'true' }).config.captureIdentity).toBe(false); + expect(resolve(settings).hasEnterpriseSettings).toBe(true); + settings.policy = { enabled: true }; + settings.user = {}; + expect(resolve(settings).config.captureIdentity).toBe(false); + expect(resolve(settings, { COPILOT_OTEL_CAPTURE_IDENTITY: 'true' }).config.captureIdentity).toBe(true); + }); + + it('recognizes identity-only policy false even though capture is default-off', () => { + const settings = new TestOTelSettings(); + settings.user = { + enabled: true, otlpEndpoint: 'https://personal.example', + headers: { personal: 'header' }, 'dbSpanExporter.enabled': true, + }; + settings.policy = { captureIdentity: false }; + expect(resolve(settings)).toMatchObject({ + hasEnterpriseSettings: true, + config: { + enabled: false, captureIdentity: false, headers: {}, dbSpanExporter: false, + otlpEndpoint: '', + }, + }); + settings.policy = {}; + expect(resolve(settings)).toMatchObject({ hasEnterpriseSettings: false, config: { enabled: true } }); + }); + + it('identity policy true overrides environment denial without enabling content', () => { + const settings = new TestOTelSettings(); + settings.policy = { enabled: true, captureIdentity: true, captureContent: false }; + expect(resolve(settings, { COPILOT_OTEL_CAPTURE_IDENTITY: 'false' }).config).toMatchObject({ + captureIdentity: true, captureContent: false, + }); + }); + + it('recognizes identity revocation and withdrawal without changing startup resolution', () => { + const settings = new TestOTelSettings(); + settings.policy = { enabled: true, captureIdentity: true }; + const active = resolve(settings); + settings.policy.captureIdentity = false; + expect(classifyOTelConfigDrift(active, resolve(settings))).toBe(OTelConfigDrift.Policy); + expect(describeOTelConfigDrift(active.config, resolve(settings).config)).toEqual(['captureIdentity']); + delete settings.policy.captureIdentity; + expect(classifyOTelConfigDrift(active, resolve(settings))).toBe(OTelConfigDrift.Withdrawal); + expect(active.config.captureIdentity).toBe(true); + }); + + it('managed resource attributes override environment identity attributes', () => { + const settings = new TestOTelSettings(); + settings.policy = { enabled: true, captureIdentity: true, resourceAttributes: { 'host.name': 'managed-host' } }; + expect(resolve(settings, { OTEL_RESOURCE_ATTRIBUTES: 'host.name=env-host,process.user.name=env-user' }).config.resourceAttributes).toEqual({ + 'host.name': 'managed-host', 'process.user.name': 'env-user', + }); + }); + it('snapshots every OTel schema default, including settings outside the old six-key watcher', () => { const otelProperties = Object.entries(properties).filter(([key]) => key.startsWith(prefix)); const defaults = Object.fromEntries(otelProperties diff --git a/extensions/copilot/src/platform/otel/node/inMemoryOTelService.ts b/extensions/copilot/src/platform/otel/node/inMemoryOTelService.ts index 62d7f3d21124b2..16384d872963ad 100644 --- a/extensions/copilot/src/platform/otel/node/inMemoryOTelService.ts +++ b/extensions/copilot/src/platform/otel/node/inMemoryOTelService.ts @@ -4,7 +4,8 @@ *--------------------------------------------------------------------------------------------*/ import { AsyncLocalStorage } from 'async_hooks'; -import { Emitter, type Event } from '../../../util/vs/base/common/event'; +import { Emitter, Event } from '../../../util/vs/base/common/event'; +import { filterIdentityAttributes, filterIdentitySpan } from '../common/otelIdentity'; import type { OTelConfig } from '../common/otelConfig'; import { SpanStatusCode, type ICompletedSpanData, type IOTelService, type ISpanEventData, type ISpanEventRecord, type ISpanHandle, type SpanOptions, type TraceContext } from '../common/otelService'; @@ -133,9 +134,11 @@ export class InMemoryOTelService implements IOTelService { readonly config: OTelConfig; private readonly _onDidCompleteSpan = new Emitter(); - readonly onDidCompleteSpan: Event = this._onDidCompleteSpan.event; + readonly onDidCompleteSpan: Event = Event.map(this._onDidCompleteSpan.event, span => filterIdentitySpan(span, this.config.captureIdentity && this._currentIdentityAllowed())); private readonly _onDidEmitSpanEvent = new Emitter(); - readonly onDidEmitSpanEvent: Event = this._onDidEmitSpanEvent.event; + readonly onDidEmitSpanEvent: Event = Event.map(this._onDidEmitSpanEvent.event, event => ({ + ...event, attributes: filterIdentityAttributes(event.attributes, this.config.captureIdentity && this._currentIdentityAllowed()), + })); injectCompletedSpan(span: ICompletedSpanData): void { try { this._onDidCompleteSpan.fire(span); } catch { /* emitter may be disposed */ } @@ -149,7 +152,7 @@ export class InMemoryOTelService implements IOTelService { private readonly _traceContextStore = new Map(); private readonly _traceContextTimers = new Map>(); - constructor(config: OTelConfig) { + constructor(config: OTelConfig, private readonly _currentIdentityAllowed: () => boolean = () => config.captureIdentity) { this.config = config; } diff --git a/extensions/copilot/src/platform/otel/node/otelIdentityExporters.ts b/extensions/copilot/src/platform/otel/node/otelIdentityExporters.ts new file mode 100644 index 00000000000000..87177c1cac6ea3 --- /dev/null +++ b/extensions/copilot/src/platform/otel/node/otelIdentityExporters.ts @@ -0,0 +1,131 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import type { ExportResult } from '@opentelemetry/core'; +import type { Resource } from '@opentelemetry/resources'; +import type { LogRecordExporter, ReadableLogRecord } from '@opentelemetry/sdk-logs'; +import type { MetricData, PushMetricExporter, ResourceMetrics } from '@opentelemetry/sdk-metrics'; +import type { ReadableSpan, SpanExporter } from '@opentelemetry/sdk-trace-node'; +import { CopilotChatAttr, GenAiAttr } from '../common/genAiAttributes'; +import { filterIdentityAttributes } from '../common/otelIdentity'; + +type FilterResource = (resource: Resource) => Resource; + +// The local debug pipeline deliberately records these even with content export off. +const contentAttributes = new Set([ + GenAiAttr.INPUT_MESSAGES, GenAiAttr.OUTPUT_MESSAGES, GenAiAttr.SYSTEM_INSTRUCTIONS, + GenAiAttr.TOOL_DEFINITIONS, GenAiAttr.TOOL_CALL_ARGUMENTS, GenAiAttr.TOOL_CALL_RESULT, + CopilotChatAttr.USER_REQUEST, CopilotChatAttr.REASONING_CONTENT, CopilotChatAttr.PROMPT_CONTEXT, + CopilotChatAttr.PROMPT_INSTRUCTIONS, CopilotChatAttr.MARKDOWN_CONTENT, + CopilotChatAttr.HOOK_INPUT, CopilotChatAttr.HOOK_OUTPUT, + 'content', 'toolDefinitions', +]); + +/** + * Recheck at export, not just span creation: queued and in-flight spans may outlive + * a policy revocation. Every SDK exporter (including SQLite) uses this boundary. + */ +export class IdentitySpanExporter implements SpanExporter { + constructor( + private readonly _inner: SpanExporter, + private readonly _allowed: () => boolean, + private readonly _filterResource: FilterResource, + private readonly _captureContent = true, + ) { } + + export(spans: ReadableSpan[], callback: (result: ExportResult) => void): void { + const allowed = this._allowed(); + this._inner.export(spans.map(span => ({ + name: span.name, + kind: span.kind, + spanContext: () => span.spanContext(), + parentSpanContext: span.parentSpanContext, + startTime: span.startTime, + endTime: span.endTime, + status: span.status, + attributes: this._filterAttributes(span.attributes, allowed), + events: span.events.map(event => ({ ...event, attributes: event.attributes && this._filterAttributes(event.attributes, allowed) })), + links: span.links.map(link => ({ ...link, attributes: link.attributes && filterIdentityAttributes(link.attributes, allowed) })), + duration: span.duration, + ended: span.ended, + resource: this._filterResource(span.resource), + instrumentationScope: span.instrumentationScope, + droppedAttributesCount: span.droppedAttributesCount, + droppedEventsCount: span.droppedEventsCount, + droppedLinksCount: span.droppedLinksCount, + })), callback); + } + + private _filterAttributes(attributes: Readonly>, allowed: boolean): Record { + return Object.fromEntries(Object.entries(filterIdentityAttributes(attributes, allowed)) + .filter(([key]) => this._captureContent || !contentAttributes.has(key))); + } + + shutdown(): Promise { return this._inner.shutdown(); } + forceFlush(): Promise { return this._inner.forceFlush?.() ?? Promise.resolve(); } +} + +export class IdentityLogExporter implements LogRecordExporter { + constructor( + private readonly _inner: LogRecordExporter, + private readonly _allowed: () => boolean, + private readonly _filterResource: FilterResource, + ) { } + + export(logs: ReadableLogRecord[], callback: (result: ExportResult) => void): void { + const allowed = this._allowed(); + this._inner.export(logs.map(log => ({ + hrTime: log.hrTime, + hrTimeObserved: log.hrTimeObserved, + spanContext: log.spanContext, + severityText: log.severityText, + severityNumber: log.severityNumber, + body: log.body, + eventName: log.eventName, + instrumentationScope: log.instrumentationScope, + droppedAttributesCount: log.droppedAttributesCount, + attributes: filterIdentityAttributes(log.attributes, allowed), + resource: this._filterResource(log.resource), + })), callback); + } + + shutdown(): Promise { return this._inner.shutdown(); } +} + +export class IdentityMetricExporter implements PushMetricExporter { + readonly selectAggregationTemporality: PushMetricExporter['selectAggregationTemporality']; + readonly selectAggregation: PushMetricExporter['selectAggregation']; + + constructor( + private readonly _inner: PushMetricExporter, + private readonly _allowed: () => boolean, + private readonly _filterResource: FilterResource, + ) { + this.selectAggregationTemporality = _inner.selectAggregationTemporality && (type => _inner.selectAggregationTemporality!(type)); + this.selectAggregation = _inner.selectAggregation && (type => _inner.selectAggregation!(type)); + } + + export(metrics: ResourceMetrics, callback: (result: ExportResult) => void): void { + const allowed = this._allowed(); + // Preserve the discriminated metric type while replacing each point's attributes. + const scopeMetrics = metrics.scopeMetrics.map(scope => ({ + ...scope, + metrics: scope.metrics.map(metric => filterMetric(metric, allowed)), + })); + this._inner.export({ resource: this._filterResource(metrics.resource), scopeMetrics }, callback); + } + + forceFlush(): Promise { return this._inner.forceFlush(); } + shutdown(): Promise { return this._inner.shutdown(); } +} + +function filterMetric(metric: T, allowed: boolean): T { + return { + ...metric, + dataPoints: metric.dataPoints.map(point => ({ + ...point, attributes: filterIdentityAttributes(point.attributes, allowed), + })), + }; +} diff --git a/extensions/copilot/src/platform/otel/node/otelServiceImpl.ts b/extensions/copilot/src/platform/otel/node/otelServiceImpl.ts index d6272b5d8bbb83..024642f8a456b7 100644 --- a/extensions/copilot/src/platform/otel/node/otelServiceImpl.ts +++ b/extensions/copilot/src/platform/otel/node/otelServiceImpl.ts @@ -3,9 +3,12 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { Emitter, type Event } from '../../../util/vs/base/common/event'; +import * as os from 'os'; +import { Emitter, Event } from '../../../util/vs/base/common/event'; import { GenAiAttr, GenAiOperationName } from '../common/genAiAttributes'; import type { OTelConfig } from '../common/otelConfig'; +import { filterIdentityAttributes, filterIdentitySpan, identityResourceAttributes } from '../common/otelIdentity'; +import { IdentitySpanExporter, IdentityLogExporter, IdentityMetricExporter } from './otelIdentityExporters'; import { type ICompletedSpanData, type IOTelService, type ISpanEventData, type ISpanEventRecord, type ISpanHandle, SpanKind, type SpanOptions, SpanStatusCode, type TraceContext } from '../common/otelService'; // Type-only imports — erased by esbuild, zero bundle impact @@ -62,9 +65,11 @@ export class NodeOTelService implements IOTelService { // Event emitters for span lifecycle private readonly _onDidCompleteSpan = new Emitter(); - readonly onDidCompleteSpan: Event = this._onDidCompleteSpan.event; + readonly onDidCompleteSpan: Event = Event.map(this._onDidCompleteSpan.event, span => filterIdentitySpan(span, this._identityAllowed())); private readonly _onDidEmitSpanEvent = new Emitter(); - readonly onDidEmitSpanEvent: Event = this._onDidEmitSpanEvent.event; + readonly onDidEmitSpanEvent: Event = Event.map(this._onDidEmitSpanEvent.event, event => ({ + ...event, attributes: filterIdentityAttributes(event.attributes, this._identityAllowed()), + })); injectCompletedSpan(span: ICompletedSpanData): void { try { this._onDidCompleteSpan.fire(span); } catch { /* emitter may be disposed */ } @@ -73,7 +78,13 @@ export class NodeOTelService implements IOTelService { // Buffer events until SDK is ready private readonly _buffer: Array<() => void> = []; - constructor(config: OTelConfig, logFn?: OTelLogFn, sqliteStore?: OTelSqliteStore) { + constructor( + config: OTelConfig, + logFn?: OTelLogFn, + sqliteStore?: OTelSqliteStore, + private readonly _currentIdentityAllowed: () => boolean = () => config.captureIdentity, + private readonly _exporterFactory?: () => Promise, + ) { this.config = config; this._log = logFn ?? ((_level, _msg) => { /* silent when no logger wired */ }); this._sqliteStore = sqliteStore; @@ -81,6 +92,8 @@ export class NodeOTelService implements IOTelService { void this._initialize(); } + private readonly _identityAllowed = (): boolean => this.config.captureIdentity && this._currentIdentityAllowed(); + private async _initialize(): Promise { if (this._initialized || !this.config.enabled) { return; @@ -116,21 +129,24 @@ export class NodeOTelService implements IOTelService { 'service.name': this.config.serviceName, 'service.version': this.config.serviceVersion, 'session.id': this.config.sessionId, - ...this.config.resourceAttributes, + ...identityResourceAttributes(this.config.resourceAttributes, this._identityAllowed(), + () => ({ username: os.userInfo().username, hostname: os.hostname() })), }); + const filterResource = (value: import('@opentelemetry/resources').Resource) => resourcesMod.resourceFromAttributes( + filterIdentityAttributes(value.attributes, this._identityAllowed()), { schemaUrl: value.schemaUrl }); // Create exporters based on config - const { spanExporter, logExporter, metricExporter } = await this._createExporters(); + const { spanExporter, logExporter, metricExporter } = await (this._exporterFactory?.() ?? this._createExporters()); // Primary span processor: filters debug-panel-only spans for the user's exporter - const diagnosticSpanExporter = new DiagnosticSpanExporter(spanExporter, this.config.exporterType, this._log); + const diagnosticSpanExporter = new DiagnosticSpanExporter(new IdentitySpanExporter(spanExporter, this._identityAllowed, filterResource, this.config.captureContent), this.config.exporterType, this._log); this._spanProcessors.push(new BSP(diagnosticSpanExporter)); // SQLite DB span processor: standard GenAI spans only (same filter as primary). // Registered as a separate processor so it works in parallel with any user exporter. if (this.config.dbSpanExporter && this._sqliteStore) { const { SqliteSpanExporter } = await import('./sqlite/sqliteSpanExporter'); - const sqliteExporter = new FilteredSpanExporter(new SqliteSpanExporter(this._sqliteStore)); + const sqliteExporter = new FilteredSpanExporter(new IdentitySpanExporter(new SqliteSpanExporter(this._sqliteStore), this._identityAllowed, filterResource)); this._spanProcessors.push(new BSP(sqliteExporter)); } @@ -150,7 +166,7 @@ export class NodeOTelService implements IOTelService { this._otelApi = api; // Log provider — pass processors in constructor (SDK v2 uses 'processors' key) - this._logProcessor = new BLRP(logExporter, { + this._logProcessor = new BLRP(new IdentityLogExporter(logExporter, this._identityAllowed, filterResource), { scheduledDelayMillis: 1000, maxExportBatchSize: 512, }); @@ -163,7 +179,7 @@ export class NodeOTelService implements IOTelService { // Metric provider this._metricReader = new PEMR({ - exporter: metricExporter, + exporter: new IdentityMetricExporter(metricExporter, this._identityAllowed, filterResource), exportIntervalMillis: 10000, }); const meterProvider = new MeterProvider({ @@ -313,6 +329,7 @@ export class NodeOTelService implements IOTelService { } async startActiveSpan(name: string, options: SpanOptions, fn: (span: ISpanHandle) => Promise): Promise { + options = { ...options, attributes: options.attributes && filterIdentityAttributes(options.attributes, this._identityAllowed()) }; if (!this._tracer) { const handle = this.startSpan(name, options); try { @@ -434,6 +451,7 @@ export class NodeOTelService implements IOTelService { } private _createSpan(name: string, options?: SpanOptions): ISpanHandle { + options = { ...options, attributes: options?.attributes && filterIdentityAttributes(options.attributes, this._identityAllowed()) }; const spanOpts = { kind: toOTelSpanKind(options?.kind), attributes: options?.attributes as Attributes }; // If an explicit parent trace context is provided, create the span as its child. diff --git a/extensions/copilot/src/platform/otel/node/test/otelIdentity.spec.ts b/extensions/copilot/src/platform/otel/node/test/otelIdentity.spec.ts new file mode 100644 index 00000000000000..ce19ca2e2f3710 --- /dev/null +++ b/extensions/copilot/src/platform/otel/node/test/otelIdentity.spec.ts @@ -0,0 +1,243 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import type { ExportResult } from '@opentelemetry/core'; +import { resourceFromAttributes } from '@opentelemetry/resources'; +import type { LogRecordExporter, ReadableLogRecord } from '@opentelemetry/sdk-logs'; +import type { PushMetricExporter, ResourceMetrics } from '@opentelemetry/sdk-metrics'; +import type { ReadableSpan, SpanExporter } from '@opentelemetry/sdk-trace-node'; +import { afterEach, describe, expect, it } from 'vitest'; +import type { AuthenticationSession } from 'vscode'; +import { CopilotChatAttr, GenAiAttr, GenAiOperationName, StdAttr } from '../../common/genAiAttributes'; +import { resolveOTelConfig, type OTelConfigInput } from '../../common/otelConfig'; +import { agentIdentityAttributes, filterIdentityAttributes, identityResourceAttributes } from '../../common/otelIdentity'; +import type { ICompletedSpanData } from '../../common/otelService'; +import { InMemoryOTelService } from '../inMemoryOTelService'; +import { IdentitySpanExporter } from '../otelIdentityExporters'; +import { NodeOTelService } from '../otelServiceImpl'; + +class TestAuthentication { + anyGitHubSession: AuthenticationSession | undefined; + signIn(name: string): void { + this.anyGitHubSession = { id: name, accessToken: 'test-token', account: { id: name, label: name }, scopes: [] }; + } +} + +class RecordingSpanExporter implements SpanExporter { + readonly spans: ReadableSpan[] = []; + export(spans: ReadableSpan[], callback: (result: ExportResult) => void): void { + this.spans.push(...spans); + callback({ code: 0 }); + } + async shutdown(): Promise { } +} + +class RecordingLogExporter implements LogRecordExporter { + readonly logs: ReadableLogRecord[] = []; + export(logs: ReadableLogRecord[], callback: (result: ExportResult) => void): void { + this.logs.push(...logs); + callback({ code: 0 }); + } + async shutdown(): Promise { } +} + +class RecordingMetricExporter implements PushMetricExporter { + readonly metrics: ResourceMetrics[] = []; + export(metrics: ResourceMetrics, callback: (result: ExportResult) => void): void { + this.metrics.push(metrics); + callback({ code: 0 }); + } + async forceFlush(): Promise { } + async shutdown(): Promise { } +} + +function config(input: Partial = {}) { + return resolveOTelConfig({ env: {}, extensionVersion: 'test', sessionId: 'test', settingEnabled: true, ...input }); +} + +describe('governed OTel identity', () => { + let service: NodeOTelService | undefined; + afterEach(async () => { await service?.shutdown(); }); + + async function start(input: Partial = {}, allowed = () => true) { + const exporters = { + spanExporter: new RecordingSpanExporter(), + logExporter: new RecordingLogExporter(), + metricExporter: new RecordingMetricExporter(), + }; + service = new NodeOTelService(config(input), undefined, undefined, allowed, async () => exporters); + const current = service; + // Buffered completion proves SDK initialization finished without inspecting private state. + await new Promise(resolve => { + const listener = current.onDidCompleteSpan(span => { + if (span.name === 'ready') { + listener.dispose(); + resolve(); + } + }); + current.startSpan('ready').end(); + }); + await current.flush(); + exporters.spanExporter.spans.length = 0; + return { service: current, ...exporters }; + } + + it('does not detect or export identity by default, even from explicit resource attributes', async () => { + let detected = false; + expect(identityResourceAttributes({ 'host.name': 'explicit', team: 'test' }, false, () => { + detected = true; + return { username: 'os-user', hostname: 'os-host' }; + })).toEqual({ team: 'test' }); + expect(detected).toBe(false); + const { service, spanExporter } = await start({ settingResourceAttributes: { 'host.name': 'explicit', 'process.user.name': 'explicit' } }); + service.startSpan('invoke_agent', { attributes: { [StdAttr.USER_NAME]: 'sdk-opt-in', 'enduser.pseudo.id': 'unchanged' } }).end(); + await service.flush(); + expect(spanExporter.spans[0].attributes).toEqual({ 'enduser.pseudo.id': 'unchanged' }); + expect(spanExporter.spans[0].resource.attributes).not.toHaveProperty(StdAttr.HOST_NAME); + expect(spanExporter.spans[0].resource.attributes).not.toHaveProperty(StdAttr.PROCESS_USER_NAME); + }); + + it('attributes top-level and subagent invocations to the current account without enabling content', async () => { + const auth = new TestAuthentication(); + auth.signIn('first-account'); + const { service, spanExporter } = await start({ settingCaptureIdentity: true, settingCaptureContent: false }); + await service.startActiveSpan('invoke_agent parent', { + attributes: { [GenAiAttr.OPERATION_NAME]: GenAiOperationName.INVOKE_AGENT, ...agentIdentityAttributes(service.config, auth), [GenAiAttr.INPUT_MESSAGES]: 'private prompt' }, + }, async span => { + span.addEvent('user_message', { content: 'private prompt' }); + service.startSpan('invoke_agent subagent', { + attributes: { [GenAiAttr.OPERATION_NAME]: GenAiOperationName.INVOKE_AGENT, ...agentIdentityAttributes(service.config, auth) }, + }).end(); + }); + auth.signIn('second-account'); + service.startSpan('invoke_agent switched', { attributes: agentIdentityAttributes(service.config, auth) }).end(); + auth.anyGitHubSession = undefined; + service.startSpan('invoke_agent signed-out', { attributes: agentIdentityAttributes(service.config, auth) }).end(); + await service.flush(); + expect(spanExporter.spans.map(span => [span.name, span.attributes[StdAttr.USER_NAME]])).toEqual([ + ['invoke_agent subagent', 'first-account'], ['invoke_agent parent', 'first-account'], + ['invoke_agent switched', 'second-account'], ['invoke_agent signed-out', undefined], + ]); + expect(spanExporter.spans[1].attributes).not.toHaveProperty(GenAiAttr.INPUT_MESSAGES); + expect(spanExporter.spans[1].events[0].attributes).toEqual({}); + expect(spanExporter.spans[0].resource.attributes).toMatchObject({ + [StdAttr.PROCESS_USER_NAME]: expect.any(String), [StdAttr.HOST_NAME]: expect.any(String), + }); + }); + + it('uses managed, then environment, then detected resource identity', () => { + const resolved = config({ + settingCaptureIdentity: true, + env: { OTEL_RESOURCE_ATTRIBUTES: 'host.name=environment-host,process.user.name=environment-user' }, + policyResourceAttributes: { 'host.name': 'managed-host' }, + }); + expect(identityResourceAttributes(resolved.resourceAttributes, resolved.captureIdentity, () => ({ + username: 'detected-user', hostname: 'detected-host', + }))).toEqual({ 'host.name': 'managed-host', 'process.user.name': 'environment-user' }); + }); + + it('managed false defeats local, environment, and SDK-supplied identity', async () => { + const { service, spanExporter } = await start({ + settingCaptureIdentity: true, policyCaptureIdentity: false, + env: { COPILOT_OTEL_CAPTURE_IDENTITY: 'true', OTEL_RESOURCE_ATTRIBUTES: 'host.name=environment-host' }, + settingResourceAttributes: { 'user.name': 'personal-account' }, + policyResourceAttributes: { 'process.user.name': 'managed-user', team: 'managed-team' }, + }); + service.startSpan('invoke_agent', { attributes: { [StdAttr.USER_NAME]: 'sdk-identity' } }).end(); + await service.flush(); + expect(spanExporter.spans[0].attributes).toEqual({}); + expect(spanExporter.spans[0].resource.attributes).toMatchObject({ team: 'managed-team' }); + for (const key of [StdAttr.USER_NAME, StdAttr.HOST_NAME, StdAttr.PROCESS_USER_NAME]) { + expect(spanExporter.spans[0].resource.attributes).not.toHaveProperty(key); + } + }); + + for (const exporterType of ['otlp-http', 'otlp-grpc', 'file', 'console'] as const) { + for (const captureContent of [false, true]) { + it(`${exporterType} primary export gates known span/event content (${captureContent}) independently of local debug retention`, async () => { + const { service, spanExporter } = await start({ settingExporterType: exporterType, settingCaptureContent: captureContent }); + const content = Object.fromEntries([ + GenAiAttr.INPUT_MESSAGES, GenAiAttr.OUTPUT_MESSAGES, GenAiAttr.SYSTEM_INSTRUCTIONS, + GenAiAttr.TOOL_DEFINITIONS, GenAiAttr.TOOL_CALL_ARGUMENTS, GenAiAttr.TOOL_CALL_RESULT, + CopilotChatAttr.USER_REQUEST, CopilotChatAttr.REASONING_CONTENT, CopilotChatAttr.PROMPT_CONTEXT, + CopilotChatAttr.PROMPT_INSTRUCTIONS, CopilotChatAttr.MARKDOWN_CONTENT, + CopilotChatAttr.HOOK_INPUT, CopilotChatAttr.HOOK_OUTPUT, 'content', 'toolDefinitions', + ].map(key => [key, 'private content'])); + const metadata = { [GenAiAttr.OPERATION_NAME]: GenAiOperationName.INVOKE_AGENT, 'custom.attribute': 'not universally sanitized' }; + const completions: ICompletedSpanData[] = []; + const listener = service.onDidCompleteSpan(span => completions.push(span)); + const span = service.startSpan('invoke_agent', { attributes: { ...content, ...metadata } }); + span.addEvent('content-event', { ...content, ...metadata }); + span.end(); + await service.flush(); + listener.dispose(); + const expected = captureContent ? { ...content, ...metadata } : metadata; + expect({ + exported: spanExporter.spans[0].attributes, + eventName: spanExporter.spans[0].events[0].name, + event: spanExporter.spans[0].events[0].attributes, + local: completions[0].attributes, + localEvent: completions[0].events[0].attributes, + }).toEqual({ + exported: expected, + eventName: 'content-event', + event: expected, + local: { ...content, ...metadata }, + localEvent: { ...content, ...metadata }, + }); + }); + } + } + + it('revokes identity on queued spans, in-flight completions, logs, metrics, and resources before reload', async () => { + let allowed = true; + const { service, spanExporter, logExporter, metricExporter } = await start({ settingCaptureIdentity: true }, () => allowed); + const completions: ICompletedSpanData[] = []; + const listener = service.onDidCompleteSpan(span => completions.push(span)); + const attributes = { [StdAttr.USER_NAME]: 'private-account', [StdAttr.HOST_NAME]: 'private-host', 'enduser.pseudo.id': 'unchanged' }; + const inFlight = service.startSpan('in-flight', { attributes }); + service.startSpan('queued', { attributes }).end(); + service.emitLogRecord('event', attributes); + service.incrementCounter('test.identity', 1, attributes); + allowed = false; + inFlight.end(); + await service.flush(); + listener.dispose(); + expect(completions.at(-1)?.attributes).toEqual({ 'enduser.pseudo.id': 'unchanged' }); + expect(spanExporter.spans.map(span => span.attributes)).toEqual([ + { 'enduser.pseudo.id': 'unchanged' }, { 'enduser.pseudo.id': 'unchanged' }, + ]); + expect(logExporter.logs[0].attributes).toEqual({ 'enduser.pseudo.id': 'unchanged' }); + const metric = metricExporter.metrics.flatMap(batch => batch.scopeMetrics.flatMap(scope => scope.metrics)).find(metric => metric.descriptor.name === 'test.identity'); + expect(metric?.dataPoints[0].attributes).toEqual({ 'enduser.pseudo.id': 'unchanged' }); + for (const resource of [...spanExporter.spans, ...logExporter.logs, ...metricExporter.metrics].map(record => record.resource)) { + expect(resource.attributes).not.toHaveProperty(StdAttr.HOST_NAME); + expect(resource.attributes).not.toHaveProperty(StdAttr.PROCESS_USER_NAME); + } + }); + + it('filters the local SQLite boundary without removing debug content or mutating source spans', async () => { + const { service, spanExporter } = await start({ settingCaptureIdentity: true, settingCaptureContent: true }); + service.startSpan('invoke_agent', { attributes: { [StdAttr.USER_NAME]: 'account', [GenAiAttr.INPUT_MESSAGES]: 'debug content' } }).end(); + await service.flush(); + const sqlite = new RecordingSpanExporter(); + const exporter = new IdentitySpanExporter(sqlite, () => false, resource => resourceFromAttributes(filterIdentityAttributes(resource.attributes, false))); + exporter.export(spanExporter.spans, () => { }); + expect(sqlite.spans[0].attributes).toEqual({ [GenAiAttr.INPUT_MESSAGES]: 'debug content' }); + expect(spanExporter.spans[0].attributes[StdAttr.USER_NAME]).toBe('account'); + }); + + it('filters SDK-injected identity from the disabled in-memory/debug pipeline', async () => { + const memory = new InMemoryOTelService(config({ settingEnabled: false })); + const spans: ICompletedSpanData[] = []; + const listener = memory.onDidCompleteSpan(span => spans.push(span)); + memory.startSpan('sdk span', { attributes: { [StdAttr.USER_NAME]: 'sdk-opt-in', 'enduser.pseudo.id': 'unchanged' } }).end(); + expect(spans[0].attributes).toEqual({ 'enduser.pseudo.id': 'unchanged' }); + memory.injectCompletedSpan({ ...spans[0], attributes: { [StdAttr.USER_NAME]: 'sdk-injected', 'enduser.pseudo.id': 'unchanged' } }); + expect(spans[1].attributes).toEqual({ 'enduser.pseudo.id': 'unchanged' }); + listener.dispose(); + await memory.shutdown(); + }); +}); diff --git a/src/vs/platform/agentHost/OTEL.md b/src/vs/platform/agentHost/OTEL.md index 386a0325b961c1..21015c356ef3e4 100644 --- a/src/vs/platform/agentHost/OTEL.md +++ b/src/vs/platform/agentHost/OTEL.md @@ -207,6 +207,14 @@ src/vs/platform/otel/ `OTEL_EXPORTER_OTLP_HEADERS` flows via env inheritance only. `OTEL_EXPORTER_OTLP_PROTOCOL`, `OTEL_SERVICE_NAME`, and `OTEL_RESOURCE_ATTRIBUTES` are not translated from the local `chat.agentHost.otel.*` settings, but **enterprise managed settings (policy)** can set them on the spawned host: the renderer forwards the resolved policy to the starter, and managed values win over inherited env. +The shared `CopilotOtelCaptureIdentity` policy registers the boolean managed leaf +`telemetry.capture.identity` for the legacy Local extension's policy reference. +Its hidden `chat.agentHost.otel.captureIdentity` delivery slot is not translated +into environment variables: the native Copilot runtime owns managed identity +enforcement. The content-capture shorthand does not enable identity. See the +[Local harness documentation](../../../../extensions/copilot/docs/monitoring/agent_monitoring.md#governed-identity-capture) +for the extension-host implementation. + `readAgentHostOTelEnv()` ([node/otel/agentHostOTelService.ts](node/otel/agentHostOTelService.ts)) is the inverse: it reads `process.env` inside the agent host and produces the `ResolvedConfig` that drives mode selection and outbound forwarding. ## OTLP/HTTP Forwarder Conventions diff --git a/src/vs/platform/agentHost/common/agentHostStarter.config.contribution.ts b/src/vs/platform/agentHost/common/agentHostStarter.config.contribution.ts index 29256b0212e98d..c25a43ba9dac6f 100644 --- a/src/vs/platform/agentHost/common/agentHostStarter.config.contribution.ts +++ b/src/vs/platform/agentHost/common/agentHostStarter.config.contribution.ts @@ -7,7 +7,7 @@ import * as nls from '../../../nls.js'; import { IPolicyData } from '../../../base/common/defaultAccount.js'; import { PolicyCategory } from '../../../base/common/policy.js'; import { AgentHostConfigurationSyncScope, ConfigurationScope, Extensions as ConfigurationExtensions, IConfigurationPropertySchema, IConfigurationRegistry } from '../../configuration/common/configurationRegistry.js'; -import { COPILOT_OTEL_CAPTURE_CONTENT_KEY, COPILOT_OTEL_ENABLED_KEY, COPILOT_OTEL_ENDPOINT_KEY, COPILOT_OTEL_HEADERS_KEY, COPILOT_OTEL_LOCK_CAPTURE_CONTENT_KEY, COPILOT_OTEL_PROTOCOL_KEY, COPILOT_OTEL_RESOURCE_ATTRIBUTES_KEY, COPILOT_OTEL_SERVICE_NAME_KEY, managedSettingValue, thirdPartyAgentEnabledValue } from '../../policy/common/copilotManagedSettings.js'; +import { COPILOT_OTEL_CAPTURE_CONTENT_KEY, COPILOT_OTEL_CAPTURE_IDENTITY_KEY, COPILOT_OTEL_ENABLED_KEY, COPILOT_OTEL_ENDPOINT_KEY, COPILOT_OTEL_HEADERS_KEY, COPILOT_OTEL_LOCK_CAPTURE_CONTENT_KEY, COPILOT_OTEL_PROTOCOL_KEY, COPILOT_OTEL_RESOURCE_ATTRIBUTES_KEY, COPILOT_OTEL_SERVICE_NAME_KEY, managedSettingValue, thirdPartyAgentEnabledValue } from '../../policy/common/copilotManagedSettings.js'; import product from '../../product/common/product.js'; import { Registry } from '../../registry/common/platform.js'; import { @@ -466,6 +466,29 @@ configurationRegistry.registerConfiguration({ }, }, }, + // The native runtime consumes managed identity policy directly. This slot + // registers the shared policy for the legacy extension, not process env. + ['chat.agentHost.otel.captureIdentity']: { + type: 'boolean', + default: false, + scope: ConfigurationScope.APPLICATION, + included: false, + policy: { + name: 'CopilotOtelCaptureIdentity', + category: PolicyCategory.InteractiveSession, + minimumVersion: '1.139', + value: managedSettingValue(COPILOT_OTEL_CAPTURE_IDENTITY_KEY), + managedSettings: { + [COPILOT_OTEL_CAPTURE_IDENTITY_KEY]: { type: 'boolean' }, + }, + localization: { + description: { + key: 'chat.agentHost.otel.captureIdentity.policy', + value: nls.localize('chat.agentHost.otel.captureIdentity.policy', "Controls whether Copilot OpenTelemetry captures the authenticated account name, operating system username, and machine hostname. Independent of content capture."), + } + }, + }, + }, [AgentHostOTelOutfileSettingId]: { type: 'string', markdownDescription: nls.localize('chat.agentHost.otel.outfile', "Output path for span JSON lines when exporter type is `file`. Configurable in user settings only. Sets `COPILOT_OTEL_FILE_EXPORTER_PATH`."), diff --git a/src/vs/platform/configuration/test/common/policyConfiguration.test.ts b/src/vs/platform/configuration/test/common/policyConfiguration.test.ts index 39947f5eeae3c3..cdede4c0f9af47 100644 --- a/src/vs/platform/configuration/test/common/policyConfiguration.test.ts +++ b/src/vs/platform/configuration/test/common/policyConfiguration.test.ts @@ -345,6 +345,32 @@ suite('PolicyConfiguration', () => { assert.deepStrictEqual(actual.keys, ['policy.orphanReferenceSetting']); }); + test('initialize: nullable boolean reference preserves an explicit false policy and an unset default', async () => { + const reference: IConfigurationNode = { + id: '_test_nullable_policy_reference', + properties: { + 'policy.nullableReference': { + type: ['boolean', 'null'], + default: null, + policyReference: { name: 'PolicyShared' }, + }, + }, + }; + const registry = Registry.as(Extensions.Configuration); + registry.registerConfiguration(reference); + try { + await fileService.writeFile(policyFile, VSBuffer.fromString(JSON.stringify({ PolicyShared: false }))); + await testObject.initialize(); + assert.deepStrictEqual({ + defaultValue: defaultConfiguration.configurationModel.getValue('policy.nullableReference'), + policyValue: testObject.configurationModel.getValue('policy.nullableReference'), + policyType: policyService.policyDefinitions.PolicyShared.type, + }, { defaultValue: null, policyValue: false, policyType: 'boolean' }); + } finally { + registry.deregisterConfigurations([reference]); + } + }); + test('initialize: the owner definition is authoritative; a reference only contributes the policy name', async () => { await fileService.writeFile(policyFile, VSBuffer.fromString(JSON.stringify({ 'PolicyShared': false }))); diff --git a/src/vs/platform/policy/common/copilotManagedSettings.ts b/src/vs/platform/policy/common/copilotManagedSettings.ts index f928defff092f0..b152cf9bb18125 100644 --- a/src/vs/platform/policy/common/copilotManagedSettings.ts +++ b/src/vs/platform/policy/common/copilotManagedSettings.ts @@ -80,6 +80,12 @@ export const MANAGED_SETTINGS_CONTROL_DEFINITIONS: IManagedSettingsPolicyDefinit [COPILOT_FORCE_REMOTE_SETTINGS_REFRESH_KEY]: { type: 'boolean' }, [COPILOT_SANDBOX_ENABLED_KEY]: { type: 'boolean' }, [COPILOT_SANDBOX_ALLOW_BYPASS_KEY]: { type: 'boolean' }, + // Observe these only for whole-block source selection; Local does not implement their capture semantics. + 'telemetry.capture.prompts': { type: 'boolean' }, + 'telemetry.capture.responses': { type: 'boolean' }, + 'telemetry.capture.toolArguments': { type: 'boolean' }, + 'telemetry.capture.toolOutput': { type: 'boolean' }, + 'telemetry.capture.policyDetail': { type: 'boolean' }, }; /** Policy-only configuration delivery slot for {@link COPILOT_STRICT_PLUGIN_ONLY_CUSTOMIZATION_KEY}. */ @@ -126,6 +132,9 @@ export const COPILOT_OTEL_PROTOCOL_KEY = 'telemetry.protocol'; /** Managed-settings key for enterprise OTel content capture. */ export const COPILOT_OTEL_CAPTURE_CONTENT_KEY = 'telemetry.captureContent'; +/** Managed-settings key for independently governed OTel identity capture. */ +export const COPILOT_OTEL_CAPTURE_IDENTITY_KEY = 'telemetry.capture.identity'; + /** Managed-settings key that prevents users from enabling OTel content capture themselves. */ export const COPILOT_OTEL_LOCK_CAPTURE_CONTENT_KEY = 'telemetry.lockCaptureContent'; @@ -138,6 +147,18 @@ export const COPILOT_OTEL_RESOURCE_ATTRIBUTES_KEY = 'telemetry.resourceAttribute /** Managed-settings key for extra OTLP exporter headers (a `{ [k]: string }` map). */ export const COPILOT_OTEL_HEADERS_KEY = 'telemetry.headers'; +/** + * An empty normalized telemetry block is carried as the JSON string `{}` under `telemetry`. + * This preserves block presence across IPC and caching without inventing a policy leaf. Like + * undeclared telemetry leaves, this anchor is discarded by policy projection, not by selection. + */ +const telemetryBlockKey = 'telemetry'; + +/** Whether a canonical bag key belongs to the telemetry block. */ +function isTelemetrySettingKey(key: string): boolean { + return key === telemetryBlockKey || key.startsWith(`${telemetryBlockKey}.`); +} + const managedSettingValueCallbacks = new Map ManagedSettingValue | undefined>(); /** @@ -392,24 +413,29 @@ export interface IManagedSettingResolution { readonly contributions: readonly IManagedSettingsContribution[]; } -/** The result of merging managed settings from every delivery channel on a per-key basis. */ +/** The result of resolving managed settings across the delivery channels. */ export interface IManagedSettingsPick { - /** The effective merged bag: the winning value for each key contributed by any channel. */ + /** The effective bag, excluding telemetry leaves found only in weaker managed sources. */ readonly values: ManagedSettingsData; /** Per-key provenance: how each key resolved and which channels were overridden. */ readonly resolutions: ReadonlyMap; + /** Telemetry keys supplied only by weaker blocks, retained for diagnostics but never applied. */ + readonly suppressedTelemetry: ReadonlyMap; /** The channels that supplied at least one *winning* key, in precedence order. */ readonly activeSources: readonly ManagedSettingsChannel[]; } /** - * Merge the managed-settings bags from every delivery channel on a **per-key** basis. + * Merge the managed-settings bags from every delivery channel. * * Precedence (highest first): native MDM → server-delivered → file on disk. Unlike a single * authoritative source, the channels *are* merged key-by-key: for each key the highest-precedence * channel that supplies it wins, but a key that the higher channels never set is still filled in by - * a lower channel. The runtime-owned `sandbox.enabled` control is the exception: any managed - * `true` wins, so harness selection cannot discard a sandbox requirement from another channel. + * a lower channel. Telemetry instead selects the highest-priority block in its entirety, including + * empty or unrecognized server/file object blocks; native delivery observes declared flat keys only. + * Omitted leaves cannot inherit from a weaker managed source. + * The runtime-owned `sandbox.enabled` control is force-on-wins, so harness selection cannot + * discard a sandbox requirement from another channel. * * The parameter order matches the precedence so call sites read top-to-bottom. Centralizing the * resolution here (rather than inlining it at each call site) keeps policy evaluation @@ -422,6 +448,8 @@ export function pickManagedSettings(nativeMdm: ManagedSettingsData | undefined, // Preserve delivery order for provenance even when a sandbox requirement wins from a later channel. const resolutions = new Map(); + const suppressedTelemetry = new Map(); + let telemetrySource: ManagedSettingsChannel | undefined; for (const channel of MANAGED_SETTINGS_CHANNELS) { const bag = bags[channel]; if (!bag) { @@ -435,6 +463,15 @@ export function pickManagedSettings(nativeMdm: ManagedSettingsData | undefined, continue; } const existing = resolutions.get(key); + if (isTelemetrySettingKey(key)) { + telemetrySource ??= channel; + if (channel !== telemetrySource && !existing) { + const contributions = suppressedTelemetry.get(key) ?? []; + contributions.push({ channel, value }); + suppressedTelemetry.set(key, contributions); + continue; + } + } if (existing) { existing.contributions.push({ channel, value }); if (key === COPILOT_SANDBOX_ENABLED_KEY && value === true && existing.value !== true) { @@ -458,6 +495,7 @@ export function pickManagedSettings(nativeMdm: ManagedSettingsData | undefined, // Build via Object.fromEntries (define-property semantics) rather than bracket assignment so // an untrusted `__proto__` key can't corrupt the merged bag's prototype chain. values: Object.fromEntries(entries), + suppressedTelemetry, resolutions, // Preserve precedence order for a stable, readable report. activeSources: MANAGED_SETTINGS_CHANNELS.filter(channel => activeSources.has(channel)), @@ -629,6 +667,8 @@ function withNestedManagedKeyDeleted(obj: Record, dottedKey: st * `PolicyConfiguration` parses the JSON back into the object-typed setting on read. * `extraKnownMarketplaces` is normalized from the schema's `{ [id]: { source, autoUpdate? } }` * map to the policy-backed marketplace dict. + * - Telemetry blocks with no representable leaves retain a JSON `{}` anchor under `telemetry`, + * so an empty or future-only block still takes precedence over lower managed sources. * * Malformed marketplace entries are dropped (with an optional warning via {@link onWarn}) rather * than throwing, so a bad enterprise settings file degrades gracefully instead of blocking startup. @@ -639,6 +679,11 @@ export function normalizeManagedSettings(parsed: Record, onWarn // `__proto__` key, matching a destructuring rest. Structured keys may be nested (e.g. // `telemetry.resourceAttributes`), so removal clones only the touched path. let scalarRest: Record = { ...parsed }; + const hasTelemetryBlock = Object.hasOwn(parsed, telemetryBlockKey) && isObject(parsed[telemetryBlockKey]); + if (!hasTelemetryBlock) { + // A scalar named telemetry is not a block (nor a serialized presence anchor in raw input). + delete scalarRest[telemetryBlockKey]; + } for (const setting of STRUCTURED_MANAGED_SETTINGS) { scalarRest = withNestedManagedKeyDeleted(scalarRest, setting.key); } @@ -652,6 +697,10 @@ export function normalizeManagedSettings(parsed: Record, onWarn } } + if (hasTelemetryBlock && !Object.keys(result).some(isTelemetrySettingKey)) { + result[telemetryBlockKey] = '{}'; + } + return result; } diff --git a/src/vs/platform/policy/test/common/copilotManagedSettings.test.ts b/src/vs/platform/policy/test/common/copilotManagedSettings.test.ts index beb7ee54419059..30a91a67af2b01 100644 --- a/src/vs/platform/policy/test/common/copilotManagedSettings.test.ts +++ b/src/vs/platform/policy/test/common/copilotManagedSettings.test.ts @@ -6,8 +6,9 @@ import assert from 'assert'; import { IStringDictionary } from '../../../../base/common/collections.js'; import { IPolicyData } from '../../../../base/common/defaultAccount.js'; +import { ManagedSettingsData } from '../../../../base/common/policy.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; -import { collectManagedSettingsDefinitions, COPILOT_FORCE_REMOTE_SETTINGS_REFRESH_KEY, COPILOT_MODEL_KEY, COPILOT_SANDBOX_ENABLED_KEY, COPILOT_TOP_LEVEL_MODEL_KEY, hasManagedSettingsDefinitions, managedModelValue, managedSettingsDisabledValue, managedSettingValue, projectManagedSettings, pickManagedSettings, resolveForceRemoteSettingsRefresh } from '../../common/copilotManagedSettings.js'; +import { collectManagedSettingsDefinitions, COPILOT_FORCE_REMOTE_SETTINGS_REFRESH_KEY, COPILOT_MODEL_KEY, COPILOT_OTEL_CAPTURE_IDENTITY_KEY, COPILOT_SANDBOX_ENABLED_KEY, COPILOT_TOP_LEVEL_MODEL_KEY, hasManagedSettingsDefinitions, managedModelValue, managedSettingsDisabledValue, managedSettingValue, normalizeManagedSettings, projectManagedSettings, pickManagedSettings, resolveForceRemoteSettingsRefresh } from '../../common/copilotManagedSettings.js'; import { PolicyDefinition } from '../../common/policy.js'; suite('Copilot managed settings projection', () => { @@ -40,6 +41,24 @@ suite('Copilot managed settings projection', () => { assert.deepStrictEqual(collectManagedSettingsDefinitions({ P: { type: 'string' } }), {}); }); + test('identity capture preserves explicit false, drops invalid types, and leaves omission unset', () => { + const key = COPILOT_OTEL_CAPTURE_IDENTITY_KEY; + const declarations = collectManagedSettingsDefinitions({ + CopilotOtelCaptureIdentity: { type: 'boolean', managedSettings: { [key]: { type: 'boolean' } } }, + }); + const value = managedSettingValue(key); + assert.deepStrictEqual({ + denied: projectManagedSettings({ [key]: false }, declarations), + allowed: projectManagedSettings({ [key]: true }, declarations), + invalid: projectManagedSettings({ [key]: 'false' }, declarations), + absent: value({ managedSettings: { 'telemetry.lockCaptureContent': true } }), + explicitFalse: value({ managedSettings: { [key]: false, 'telemetry.captureContent': true } }), + }, { + denied: { [key]: false }, allowed: { [key]: true }, invalid: {}, + absent: undefined, explicitFalse: false, + }); + }); + test('hasManagedSettingsDefinitions detects whether any policy declares a managed key', () => { assert.deepStrictEqual( { @@ -178,10 +197,173 @@ suite('Copilot managed settings projection', () => { }); }); -suite('Copilot managed settings per-key precedence (pickManagedSettings)', () => { +suite('Copilot managed settings precedence (pickManagedSettings)', () => { ensureNoDisposablesAreLeakedInTestSuite(); + for (const higher of [ + {}, + { capture: {} }, + { endpoint: 'https://higher.example' }, + { capture: { identity: false } }, + { capture: { identity: true } }, + { futureControl: {} }, + { futureControl: ['unknown'] }, + { futureControl: true }, + { resourceAttributes: {} }, + { resourceAttributes: { 'host.name': 'higher-host' }, headers: { authorization: 'higher' } }, + ]) { + for (const lowerIdentity of [false, true]) { + for (const native of [false, true]) { + const selected = normalizeManagedSettings({ telemetry: higher }); + // Native delivery observes declared flat keys, not normalized empty/unknown object blocks. + if (native && Object.keys(selected).some(key => ![ + 'telemetry.endpoint', COPILOT_OTEL_CAPTURE_IDENTITY_KEY, 'telemetry.resourceAttributes', 'telemetry.headers', + ].includes(key))) { + continue; + } + test(`telemetry selects one whole ${native ? 'native' : 'server'} block: ${JSON.stringify(higher)}, lower identity ${lowerIdentity}`, () => { + const lower = normalizeManagedSettings({ telemetry: { + enabled: true, capture: { identity: lowerIdentity }, captureContent: true, + lockCaptureContent: true, endpoint: 'https://lower.example', protocol: 'grpc', + serviceName: 'lower', resourceAttributes: { 'host.name': 'lower-host', extra: 'lower' }, + headers: { authorization: 'lower' }, + } }); + const pick = native + ? pickManagedSettings(selected, lower, lower) + : pickManagedSettings(undefined, selected, lower); + const identity = COPILOT_OTEL_CAPTURE_IDENTITY_KEY; + const projected = projectManagedSettings(pick.values, { [identity]: { type: 'boolean' } }); + assert.deepStrictEqual({ + values: pick.values, + activeSources: pick.activeSources, + projected, + }, { + values: selected, + activeSources: [native ? 'nativeMdm' : 'server'], + projected: typeof selected[identity] === 'boolean' ? { [identity]: selected[identity] } : {}, + }); + }); + } + } + } + + test('absent telemetry blocks fall through without changing unrelated per-key or sandbox resolution', () => { + const file = normalizeManagedSettings({ telemetry: { capture: { identity: true } } }); + const pick = pickManagedSettings( + { model: 'native', [COPILOT_SANDBOX_ENABLED_KEY]: false }, + { model: 'server', serverOnly: true }, + { ...file, fileOnly: true, [COPILOT_SANDBOX_ENABLED_KEY]: true }, + ); + assert.deepStrictEqual(pick.values, { + model: 'native', serverOnly: true, fileOnly: true, + [COPILOT_SANDBOX_ENABLED_KEY]: true, [COPILOT_OTEL_CAPTURE_IDENTITY_KEY]: true, + }); + }); + + test('flat native telemetry selects the block and keeps contested-key provenance', () => { + const key = COPILOT_OTEL_CAPTURE_IDENTITY_KEY; + const pick = pickManagedSettings({ [key]: false }, { [key]: true, 'telemetry.enabled': true }, undefined); + assert.deepStrictEqual(pick, { + values: { [key]: false }, + resolutions: new Map([[key, { + value: false, source: 'nativeMdm', + contributions: [{ channel: 'nativeMdm', value: false }, { channel: 'server', value: true }], + }]]), + suppressedTelemetry: new Map([['telemetry.enabled', [{ channel: 'server', value: true }]]]), + activeSources: ['nativeMdm'], + }); + }); + + test('retains excluded telemetry provenance without applying lower-only leaves or activating their sources', () => { + const key = COPILOT_OTEL_CAPTURE_IDENTITY_KEY; + const pick = pickManagedSettings( + { 'telemetry.capture.prompts': false }, + { [key]: false, 'telemetry.endpoint': 'server-endpoint' }, + { [key]: true, 'telemetry.endpoint': 'file-endpoint' }, + ); + assert.deepStrictEqual({ + values: pick.values, + resolvedKeys: [...pick.resolutions.keys()], + suppressed: [...pick.suppressedTelemetry], + activeSources: pick.activeSources, + }, { + values: { 'telemetry.capture.prompts': false }, + resolvedKeys: ['telemetry.capture.prompts'], + suppressed: [ + [key, [{ channel: 'server', value: false }, { channel: 'file', value: true }]], + ['telemetry.endpoint', [{ channel: 'server', value: 'server-endpoint' }, { channel: 'file', value: 'file-endpoint' }]], + ], + activeSources: ['nativeMdm'], + }); + }); + + test('an empty server telemetry block preserves excluded file provenance separately from policy values', () => { + const key = COPILOT_OTEL_CAPTURE_IDENTITY_KEY; + const pick = pickManagedSettings(undefined, normalizeManagedSettings({ telemetry: {} }), { [key]: true }); + assert.deepStrictEqual({ + values: pick.values, + projected: projectManagedSettings(pick.values, { [key]: { type: 'boolean' } }), + marker: pick.resolutions.get('telemetry'), + suppressed: [...pick.suppressedTelemetry], + }, { + values: { telemetry: '{}' }, + projected: {}, + marker: { value: '{}', source: 'server', contributions: [{ channel: 'server', value: '{}' }] }, + suppressed: [[key, [{ channel: 'file', value: true }]]], + }); + }); + + test('diagnostic provenance does not change effective policy or unrelated precedence across telemetry lifecycle states', () => { + const identity = COPILOT_OTEL_CAPTURE_IDENTITY_KEY; + const enabled = 'telemetry.enabled'; + const sandbox = COPILOT_SANDBOX_ENABLED_KEY; + const serverTelemetry = { [identity]: true, [enabled]: true }; + const unrelated = { shared: 'native', serverOnly: 'server', fileOnly: 'file', [sandbox]: true }; + const states: { name: string; telemetry: ManagedSettingsData; expected: ManagedSettingsData; suppressed: string[] }[] = [ + { name: 'absent', telemetry: {}, expected: serverTelemetry, suppressed: [] }, + { name: 'managed false', telemetry: { [identity]: false }, expected: { [identity]: false }, suppressed: [enabled] }, + { name: 'managed true', telemetry: { [identity]: true }, expected: { [identity]: true }, suppressed: [enabled] }, + { name: 'identity omitted', telemetry: { 'telemetry.capture.prompts': false }, expected: { 'telemetry.capture.prompts': false }, suppressed: [identity, enabled] }, + { name: 'withdrawn', telemetry: {}, expected: serverTelemetry, suppressed: [] }, + ]; + for (const state of states) { + const pick = pickManagedSettings( + { shared: 'native', [sandbox]: false, ...state.telemetry }, + { shared: 'server', serverOnly: 'server', ...serverTelemetry }, + { shared: 'file', fileOnly: 'file', [sandbox]: true, [identity]: false, [enabled]: true }, + ); + assert.deepStrictEqual({ + values: pick.values, + resolutionValues: Object.fromEntries([...pick.resolutions].map(([key, resolution]) => [key, resolution.value])), + suppressed: [...pick.suppressedTelemetry.keys()].sort(), + activeSources: pick.activeSources, + sharedSource: pick.resolutions.get('shared')?.source, + sandboxSource: pick.resolutions.get(sandbox)?.source, + }, { + values: { ...unrelated, ...state.expected }, + resolutionValues: { ...unrelated, ...state.expected }, + suppressed: state.suppressed, + activeSources: ['nativeMdm', 'server', 'file'], + sharedSource: 'nativeMdm', + sandboxSource: 'file', + }, state.name); + } + }); + + test('removing a higher telemetry block reveals the lower block, replacing it with empty does not', () => { + const lower = normalizeManagedSettings({ telemetry: { capture: { identity: true } } }); + assert.deepStrictEqual({ + denied: pickManagedSettings(undefined, normalizeManagedSettings({ telemetry: { capture: { identity: false } } }), lower).values, + empty: pickManagedSettings(undefined, normalizeManagedSettings({ telemetry: {} }), lower).values, + withdrawn: pickManagedSettings(undefined, normalizeManagedSettings({}), lower).values, + }, { + denied: { [COPILOT_OTEL_CAPTURE_IDENTITY_KEY]: false }, + empty: { telemetry: '{}' }, + withdrawn: lower, + }); + }); + test('managed sandbox enablement is force-on-wins across every channel combination', () => { const key = COPILOT_SANDBOX_ENABLED_KEY; const values = [undefined, false, true]; @@ -214,6 +396,7 @@ suite('Copilot managed settings per-key precedence (pickManagedSettings)', () => { channel: 'file', value: true }, ], }]]), + suppressedTelemetry: new Map(), activeSources: ['server'], }); }); @@ -305,10 +488,10 @@ suite('Copilot managed settings per-key precedence (pickManagedSettings)', () => allEmpty: pickManagedSettings({}, {}, {}), }, { - partial: { values: { 'b': 'server' }, resolutions: new Map([['b', { value: 'server', source: 'server', contributions: [{ channel: 'server', value: 'server' }] }]]), activeSources: ['server'] }, + partial: { values: { 'b': 'server' }, resolutions: new Map([['b', { value: 'server', source: 'server', contributions: [{ channel: 'server', value: 'server' }] }]]), suppressedTelemetry: new Map(), activeSources: ['server'] }, gap: ['nativeMdm', 'file'], - allUndefined: { values: {}, resolutions: new Map(), activeSources: [] }, - allEmpty: { values: {}, resolutions: new Map(), activeSources: [] }, + allUndefined: { values: {}, resolutions: new Map(), suppressedTelemetry: new Map(), activeSources: [] }, + allEmpty: { values: {}, resolutions: new Map(), suppressedTelemetry: new Map(), activeSources: [] }, }, ); }); diff --git a/src/vs/platform/policy/test/common/fileManagedSettingsService.test.ts b/src/vs/platform/policy/test/common/fileManagedSettingsService.test.ts index 66e80eeb02913b..8ebcd908adae28 100644 --- a/src/vs/platform/policy/test/common/fileManagedSettingsService.test.ts +++ b/src/vs/platform/policy/test/common/fileManagedSettingsService.test.ts @@ -156,6 +156,21 @@ suite('normalizeManagedSettings', () => { assert.deepStrictEqual(normalizeManagedSettings({}), {}); }); + test('retains empty and future-only telemetry block presence through JSON serialization', () => { + const documents = [{ telemetry: {} }, { telemetry: { capture: {} } }, { telemetry: { future: ['value'] } }]; + assert.deepStrictEqual(documents.map(document => JSON.parse(JSON.stringify(normalizeManagedSettings(document)))), [ + { telemetry: '{}' }, { telemetry: '{}' }, { telemetry: '{}' }, + ]); + }); + + test('does not interpret malformed or inherited telemetry values as a block anchor', () => { + const documents = [ + {}, { telemetry: null }, { telemetry: false }, { telemetry: '{}' }, { telemetry: [] }, + Object.create({ telemetry: {} }), + ]; + assert.deepStrictEqual(documents.map(document => normalizeManagedSettings(document)), documents.map(() => ({}))); + }); + test('drops a structured key whose value is not an object', () => { const result = normalizeManagedSettings({ [COPILOT_ENABLED_PLUGINS_KEY]: 'already-a-string' @@ -341,6 +356,18 @@ suite('FileManagedSettingsChannelClient', () => { const disposables = ensureNoDisposablesAreLeakedInTestSuite(); + test('retains empty telemetry presence without modifying raw transport', async () => { + const channel = disposables.add(new DeferredManagedSettingsChannel()); + const client = disposables.add(new FileManagedSettingsChannelClient(channel)); + const raw = { telemetry: {} }; + channel.resolveInitialRawSnapshot(raw); + channel.resolveInitialSnapshot(JSON.parse(JSON.stringify(normalizeManagedSettings(raw)))); + await client.initialize(); + assert.deepStrictEqual({ raw: client.rawManagedSettings, normalized: client.managedSettings }, { + raw: { telemetry: {} }, normalized: { telemetry: '{}' }, + }); + }); + test('keeps newer event state when the initial snapshot resolves later', async () => { const channel = disposables.add(new DeferredManagedSettingsChannel()); const client = disposables.add(new FileManagedSettingsChannelClient(channel)); diff --git a/src/vs/platform/policy/test/node/nativeManagedSettingsService.test.ts b/src/vs/platform/policy/test/node/nativeManagedSettingsService.test.ts index 9e88d1c884768a..a17bb273540852 100644 --- a/src/vs/platform/policy/test/node/nativeManagedSettingsService.test.ts +++ b/src/vs/platform/policy/test/node/nativeManagedSettingsService.test.ts @@ -10,7 +10,7 @@ import { ManagedSettingsData } from '../../../../base/common/policy.js'; import { IChannel } from '../../../../base/parts/ipc/common/ipc.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; import { NullLogService } from '../../../log/common/log.js'; -import { COPILOT_DISABLE_BYPASS_PERMISSIONS_MODE_KEY, COPILOT_FORCE_REMOTE_SETTINGS_REFRESH_KEY, COPILOT_SANDBOX_ALLOW_BYPASS_KEY, COPILOT_SANDBOX_ENABLED_KEY } from '../../common/copilotManagedSettings.js'; +import { COPILOT_DISABLE_BYPASS_PERMISSIONS_MODE_KEY, COPILOT_FORCE_REMOTE_SETTINGS_REFRESH_KEY, COPILOT_OTEL_CAPTURE_IDENTITY_KEY, MANAGED_SETTINGS_CONTROL_DEFINITIONS, pickManagedSettings } from '../../common/copilotManagedSettings.js'; import { NativeManagedSettingsChannelClient } from '../../common/nativeManagedSettingsIpc.js'; import { PolicyValue } from '../../common/policy.js'; import { NativeManagedSettingsService, NativePolicyWatcherFactory } from '../../node/nativeManagedSettingsService.js'; @@ -25,9 +25,7 @@ suite('NativeManagedSettingsService', () => { const watcherFactory: NativePolicyWatcherFactory = (_productName, policies, callback) => { assert.deepStrictEqual(policies, { [COPILOT_DISABLE_BYPASS_PERMISSIONS_MODE_KEY]: { type: 'string' }, - [COPILOT_FORCE_REMOTE_SETTINGS_REFRESH_KEY]: { type: 'boolean' }, - [COPILOT_SANDBOX_ENABLED_KEY]: { type: 'boolean' }, - [COPILOT_SANDBOX_ALLOW_BYPASS_KEY]: { type: 'boolean' }, + ...MANAGED_SETTINGS_CONTROL_DEFINITIONS, }); onDidChange = callback; callback({}); @@ -66,15 +64,51 @@ suite('NativeManagedSettingsService', () => { watchedSettings, managedSettings: service.managedSettings, }, { - watchedSettings: { - [COPILOT_FORCE_REMOTE_SETTINGS_REFRESH_KEY]: { type: 'boolean' }, - [COPILOT_SANDBOX_ENABLED_KEY]: { type: 'boolean' }, - [COPILOT_SANDBOX_ALLOW_BYPASS_KEY]: { type: 'boolean' }, - }, + watchedSettings: MANAGED_SETTINGS_CONTROL_DEFINITIONS, managedSettings: { [COPILOT_FORCE_REMOTE_SETTINGS_REFRESH_KEY]: true }, }); }); + for (const leaf of ['prompts', 'responses', 'toolArguments', 'toolOutput', 'policyDetail']) { + test(`native capture.${leaf} selects telemetry without a corresponding Local capture policy`, async () => { + const key = `telemetry.capture.${leaf}`; + const watcherFactory: NativePolicyWatcherFactory = (_productName, policies, callback) => { + assert.deepStrictEqual(policies[key], { type: 'boolean' }); + callback({ [key]: false }); + return Disposable.None; + }; + const service = disposables.add(new NativeManagedSettingsService(new NullLogService(), 'com.github.copilot', undefined, watcherFactory)); + await service.initialize(); + const values = [false, true].map(identity => + pickManagedSettings(service.managedSettings, { [COPILOT_OTEL_CAPTURE_IDENTITY_KEY]: identity }, undefined).values); + assert.deepStrictEqual(values, [{ [key]: false }, { [key]: false }]); + }); + } + + test('without observed declared telemetry keys, native delivery leaves the server block active', async () => { + let onDidChange: ((update: Record) => void) | undefined; + const key = 'telemetry.capture.prompts'; + const watcherFactory: NativePolicyWatcherFactory = (_productName, policies, callback) => { + assert.deepStrictEqual({ + block: policies.telemetry, + unknown: policies['telemetry.futureControl'], + declared: policies[key], + }, { block: undefined, unknown: undefined, declared: { type: 'boolean' } }); + onDidChange = callback; + callback({}); + return Disposable.None; + }; + const service = disposables.add(new NativeManagedSettingsService(new NullLogService(), 'com.github.copilot', undefined, watcherFactory)); + await service.initialize(); + const server = { [COPILOT_OTEL_CAPTURE_IDENTITY_KEY]: true }; + const before = pickManagedSettings(service.managedSettings, server, undefined).values; + onDidChange?.({ [key]: false }); + const during = pickManagedSettings(service.managedSettings, server, undefined).values; + onDidChange?.({ [key]: undefined }); + const after = pickManagedSettings(service.managedSettings, server, undefined).values; + assert.deepStrictEqual({ before, during, after }, { before: server, during: { [key]: false }, after: server }); + }); + test('clears stale watcher values when managed-settings definitions are removed', async () => { let onDidChange: ((update: Record) => void) | undefined; let disposeCount = 0; diff --git a/src/vs/workbench/browser/actions/developerActions.ts b/src/vs/workbench/browser/actions/developerActions.ts index bc62b19db427a7..59d8bef1c0819c 100644 --- a/src/vs/workbench/browser/actions/developerActions.ts +++ b/src/vs/workbench/browser/actions/developerActions.ts @@ -1007,19 +1007,22 @@ class PolicyDiagnosticsAction extends Action2 { summary.effectiveManagedSettings = `${effectiveKeyCount} ${effectiveKeyCount === 1 ? 'key' : 'keys'}`; summary.managedSettingsIssues = `${parseErrors.length} ${parseErrors.length === 1 ? 'issue' : 'issues'}`; + const resolutions = [...pick.resolutions.entries()].filter(([key]) => key !== 'telemetry').sort(([first], [second]) => first.localeCompare(second)); + const suppressedTelemetry = [...pick.suppressedTelemetry.entries()].filter(([key]) => key !== 'telemetry').sort(([first], [second]) => first.localeCompare(second)); + const telemetrySource = [...pick.resolutions.entries()].find(([key]) => key === 'telemetry' || key.startsWith('telemetry.'))?.[1].source; content += markdownTable( ['Property', 'Value'], [ ['Active sources (precedence order)', activeSources], - ['Supplied keys', String(pick.resolutions.size)], + ['Supplied keys', String(resolutions.length + suppressedTelemetry.length)], + [localize('telemetryBlockSource', "Selected telemetry block"), managedSettingsSourceShortLabel(telemetrySource ?? 'none')], ['Effective VS Code policy keys', String(effectiveKeyCount)] ] ); - content += '*Precedence is resolved per key: native MDM wins over the server endpoint, which wins over the file on disk. A key left unset by a higher channel is still filled in by a lower one. For sandbox.enabled, any managed true wins regardless of channel precedence, matching the runtime sandbox floor.*\n\n'; + content += `*${localize('managedSettingsPrecedence', "Precedence is native MDM, then server, then file. Most settings resolve per key, filling gaps from lower channels. Telemetry selects one whole block: omitted leaves never inherit from weaker blocks. Empty or unknown-only server/file objects still select a block; native MDM exposes only declared flat keys. The internal telemetry presence marker is not a policy setting. For sandbox.enabled, any managed true wins regardless of channel precedence, matching the runtime sandbox floor.")}*\n\n`; content += '### Effective Resolution\n\n'; - if (pick.resolutions.size > 0) { - const resolutions = [...pick.resolutions.entries()].sort(([first], [second]) => first.localeCompare(second)); + if (resolutions.length > 0) { content += markdownTable( ['Key', 'Effective Value', 'Winning Source'], resolutions.map(([key, resolution]) => [ @@ -1040,7 +1043,20 @@ class PolicyDiagnosticsAction extends Action2 { markdownTable(['Key', 'Source', 'Value', 'Status'], contributionRows) ); } else { - content += '*No managed-settings keys are supplied by any channel.*\n\n'; + content += `*${localize('noSelectedManagedSettingsKeys', "The selected sources supply no managed-settings leaves.")}*\n\n`; + } + if (suppressedTelemetry.length > 0) { + content += markdownDetails( + localize('excludedTelemetryContributions', "Telemetry contributions excluded by the selected block"), + markdownTable( + ['Key', 'Source', 'Status'], + suppressedTelemetry.flatMap(([key, contributions]) => contributions.map(contribution => [ + key, + managedSettingsSourceShortLabel(contribution.channel), + localize('excludedTelemetryStatus', "Excluded by higher-priority telemetry block (value omitted)"), + ])) + ) + ); } content += markdownDetails('Merged normalized bag', markdownJsonBlock(pick.values)); content += markdownDetails('Effective VS Code policy bag', markdownJsonBlock(effective)); diff --git a/src/vs/workbench/services/accounts/browser/managedSettings.ts b/src/vs/workbench/services/accounts/browser/managedSettings.ts index b764f929f499db..e26c432db6373c 100644 --- a/src/vs/workbench/services/accounts/browser/managedSettings.ts +++ b/src/vs/workbench/services/accounts/browser/managedSettings.ts @@ -73,6 +73,7 @@ export interface IManagedSettingsResponse { readonly endpoint?: string; readonly protocol?: 'grpc' | 'http/protobuf' | 'http/json'; readonly captureContent?: boolean; + readonly capture?: { readonly identity?: boolean }; readonly lockCaptureContent?: boolean; readonly serviceName?: string; readonly resourceAttributes?: Record; diff --git a/src/vs/workbench/services/accounts/test/browser/managedSettings.test.ts b/src/vs/workbench/services/accounts/test/browser/managedSettings.test.ts index 477b4f9426d303..5ce1bac61353c8 100644 --- a/src/vs/workbench/services/accounts/test/browser/managedSettings.test.ts +++ b/src/vs/workbench/services/accounts/test/browser/managedSettings.test.ts @@ -17,6 +17,17 @@ suite('adaptManagedSettings', () => { }); }); + test('preserves an empty telemetry block and explicit identity leaves', () => { + assert.deepStrictEqual({ + empty: adaptManagedSettings({ telemetry: {} }), + denied: adaptManagedSettings({ telemetry: { capture: { identity: false } } }), + allowed: adaptManagedSettings({ telemetry: { capture: { identity: true } } }), + }, { + empty: { managedSettings: { telemetry: '{}' } }, + denied: { managedSettings: { 'telemetry.capture.identity': false } }, + allowed: { managedSettings: { 'telemetry.capture.identity': true } }, + }); + }); test('appends client identity to the request url', () => { assert.deepStrictEqual({ withRuntime: appendManagedSettingsClientIdentity('https://api.github.com/copilot_internal/managed_settings', { diff --git a/src/vs/workbench/services/policies/test/browser/accountPolicyService.test.ts b/src/vs/workbench/services/policies/test/browser/accountPolicyService.test.ts index c779c755943a30..6b2e381b5cb019 100644 --- a/src/vs/workbench/services/policies/test/browser/accountPolicyService.test.ts +++ b/src/vs/workbench/services/policies/test/browser/accountPolicyService.test.ts @@ -16,7 +16,7 @@ import { TestConfigurationService } from '../../../../../platform/configuration/ import { IDefaultAccountProvider, IDefaultAccountService, MANAGED_SETTINGS_FRESHNESS_NOT_REQUIRED } from '../../../../../platform/defaultAccount/common/defaultAccount.js'; import { NullLogService } from '../../../../../platform/log/common/log.js'; import { MockContextKeyService } from '../../../../../platform/keybinding/test/common/mockKeybindingService.js'; -import { COPILOT_DISABLE_BYPASS_PERMISSIONS_MODE_KEY, COPILOT_ENABLED_PLUGINS_KEY, COPILOT_SANDBOX_ENABLED_KEY, INativeManagedSettingsService, IFileManagedSettingsService, RawManagedSettingsData, managedSettingsDisabledValue } from '../../../../../platform/policy/common/copilotManagedSettings.js'; +import { COPILOT_DISABLE_BYPASS_PERMISSIONS_MODE_KEY, COPILOT_ENABLED_PLUGINS_KEY, COPILOT_OTEL_CAPTURE_IDENTITY_KEY, COPILOT_SANDBOX_ENABLED_KEY, INativeManagedSettingsService, IFileManagedSettingsService, RawManagedSettingsData, managedSettingsDisabledValue, managedSettingValue, normalizeManagedSettings } from '../../../../../platform/policy/common/copilotManagedSettings.js'; import { IManagedSettingsFreshness, ManagedSettingsFreshnessFailure, ManagedSettingsFreshnessState } from '../../../../../platform/policy/common/managedSettingsFreshness.js'; import { AbstractPolicyService, IPolicyService, PolicyDefinition, PolicyValue, PolicyValueSource } from '../../../../../platform/policy/common/policy.js'; import { Registry } from '../../../../../platform/registry/common/platform.js'; @@ -441,6 +441,31 @@ suite('AccountPolicyService', () => { }); }); + test('managed telemetry block replacement removes lower-source identity policy and withdrawal restores it', async () => { + const key = COPILOT_OTEL_CAPTURE_IDENTITY_KEY; + const file = disposables.add(new FakeFileManagedSettingsService(normalizeManagedSettings({ telemetry: { capture: { identity: true } } }))); + const native = disposables.add(new FakeNativeManagedSettingsService({})); + policyService = disposables.add(new AccountPolicyService(logService, defaultAccountService, undefined, native, file)); + await policyService.updatePolicyDefinitions({ + TelemetryIdentity: { type: 'boolean', value: managedSettingValue(key), managedSettings: { [key]: { type: 'boolean' } } }, + }); + const initial = policyService.getPolicyValue('TelemetryIdentity'); + const replaced = Event.toPromise(policyService.onDidChange); + native.setManagedSettings(normalizeManagedSettings({ telemetry: {} })); + await replaced; + const empty = policyService.getPolicyValue('TelemetryIdentity'); + const denied = Event.toPromise(policyService.onDidChange); + native.setManagedSettings(normalizeManagedSettings({ telemetry: { capture: { identity: false } } })); + await denied; + const explicitFalse = policyService.getPolicyValue('TelemetryIdentity'); + const withdrawn = Event.toPromise(policyService.onDidChange); + native.setManagedSettings({}); + await withdrawn; + assert.deepStrictEqual({ initial, empty, explicitFalse, withdrawn: policyService.getPolicyValue('TelemetryIdentity') }, { + initial: true, empty: undefined, explicitFalse: false, withdrawn: true, + }); + }); + test('managed sandbox policy refresh selects Agent Host Copilot despite device false and remembered local', async () => { const key = COPILOT_SANDBOX_ENABLED_KEY; const nativeManagedSettingsService = disposables.add(new FakeNativeManagedSettingsService({ [key]: false }));