From 475a97d6f8b39f80c2ea089cfa59eb2002ebfc8d Mon Sep 17 00:00:00 2001 From: Matthew Rayermann Date: Mon, 17 Aug 2026 17:33:26 -0700 Subject: [PATCH 1/4] [Node] Let Extensions Request Sensitive Environment Variables Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- CHANGELOG.md | 13 ++++++++ nodejs/docs/extensions.md | 19 ++++++++++++ nodejs/src/client.ts | 27 +++++++++++++++-- nodejs/src/extension.ts | 30 +++++++++++++++++- nodejs/src/types.ts | 14 +++++++++ nodejs/test/client.test.ts | 57 +++++++++++++++++++++++++++++++++++ nodejs/test/extension.test.ts | 27 +++++++++++++++++ 7 files changed, 183 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e9f22a3df..85afd4070 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,19 @@ See [GitHub Releases](https://github.com/github/copilot-sdk/releases) for the fu ## [Unreleased] +### Feature: extensions can request sensitive environment variables + +Copilot CLI extensions can now ask for named sensitive environment variables when they join a session. `joinSession()` accepts an `env` option listing the variable names the extension needs. The CLI shows a permission prompt naming the extension and the exact variables requested. On approval, only those variables reach that extension and their values are written into the extension process's `process.env` before `joinSession()` resolves. On denial, `joinSession()` rejects, the extension does not load, and its tools never reach the model. + +An approval is remembered against the exact set of names the user saw, so an extension that later asks for one more variable prompts again. Names that are unset, or that the CLI does not filter from extensions, are not prompted for. This is the client half of the feature; it requires a Copilot CLI that supports extension environment access, and older CLIs ignore the request and grant nothing. + +```ts +import { joinSession } from "@github/copilot-sdk/extension"; + +const session = await joinSession({ env: ["GITHUB_TOKEN"] }); +const token = process.env.GITHUB_TOKEN; +``` + ### Feature: host-injected managed settings permissions Session create and resume accept a new optional `managedSettings` option that injects an enterprise permissions policy at session startup, alongside the existing `enableManagedSettings` self-fetch flag. The current contract is permissions-only: `disableBypassPermissionsMode` (the literal `"disable"`), plus `deny`, `ask`, and `allow` rule lists. The layer composes restrictively with any server- or device-level managed settings (deny/ask are unioned, every present allow list must admit a tool, and `disableBypassPermissionsMode` is deny-wins). diff --git a/nodejs/docs/extensions.md b/nodejs/docs/extensions.md index d33a73312..e568410fc 100644 --- a/nodejs/docs/extensions.md +++ b/nodejs/docs/extensions.md @@ -53,6 +53,25 @@ const session = await joinSession({ The `session` object provides methods for sending messages, logging to the timeline, listening to events, and accessing the RPC API. See the `.d.ts` files in the SDK package for full type information. +## Requesting sensitive environment variables + +The CLI strips sensitive environment variables (for example `GITHUB_TOKEN`) from every extension process before it starts. An extension that needs one asks for it by name: + +```js +import { joinSession } from "@github/copilot-sdk/extension"; + +const session = await joinSession({ env: ["GITHUB_TOKEN"] }); + +// Granted values are in process.env once joinSession resolves. +const token = process.env.GITHUB_TOKEN; +``` + +The CLI prompts the user with the extension's name and the exact list of variables requested. If the user approves, only those variables reach this extension and their values are written into `process.env` before `joinSession()` resolves. If the user denies, `joinSession()` rejects, the extension does not load, and its tools never reach the model. + +An approval is remembered against the exact set of names the user saw, so an extension that later asks for an additional variable prompts again. Names that are unset, or that the CLI does not filter from extensions, are not prompted for. + +An approved extension can pass a granted value to anything it starts, so ask only for what the extension genuinely needs. + ## Further Reading - `examples.md` — Practical code examples for tools, hooks, events, and complete extensions diff --git a/nodejs/src/client.ts b/nodejs/src/client.ts index 30095186e..331c38dce 100644 --- a/nodejs/src/client.ts +++ b/nodejs/src/client.ts @@ -65,6 +65,7 @@ import type { ModelInfo, NamedProviderConfig, ProviderConfig, + ExtensionJoinOptions, ResumeSessionConfig, SectionTransformFn, SessionConfig, @@ -1711,15 +1712,17 @@ export class CopilotClient { async resumeSessionForExtension( sessionId: string, config: ResumeSessionConfig, - factories?: FactoryHandle[] + factories?: FactoryHandle[], + extensionOptions?: ExtensionJoinOptions ): Promise { - return this.resumeSessionInternal(sessionId, config, factories); + return this.resumeSessionInternal(sessionId, config, factories, extensionOptions); } private async resumeSessionInternal( sessionId: string, config: ResumeSessionConfig, - factories?: FactoryHandle[] + factories?: FactoryHandle[], + extensionOptions?: ExtensionJoinOptions ): Promise { if (!this.connection) { await this.start(); @@ -1884,8 +1887,26 @@ export class CopilotClient { expAssignments: config.expAssignments, enableManagedSettings: config.enableManagedSettings, managedSettings: config.managedSettings, + ...(extensionOptions?.requestedEnvironmentVariables + ? { + requestedEnvironmentVariables: + extensionOptions.requestedEnvironmentVariables, + } + : {}), }); + // The host answers an approved environment request with the resolved + // values, and this method consumes the response, so the grant has to be + // applied here — no caller ever sees it. + if (extensionOptions?.requestedEnvironmentVariables) { + const { grantedEnvironmentVariables } = response as { + grantedEnvironmentVariables?: Record; + }; + for (const [name, value] of Object.entries(grantedEnvironmentVariables ?? {})) { + process.env[name] = value; + } + } + const { workspacePath, capabilities, openCanvases } = response as { sessionId: string; workspacePath?: string; diff --git a/nodejs/src/extension.ts b/nodejs/src/extension.ts index c3ae0fd87..9af1ab6b0 100644 --- a/nodejs/src/extension.ts +++ b/nodejs/src/extension.ts @@ -27,6 +27,32 @@ export type JoinSessionConfig = Omit< "onPermissionRequest" | "extensionSdkPath" > & { onPermissionRequest?: PermissionHandler; + /** + * Names of sensitive environment variables this extension needs, such as + * `"GITHUB_TOKEN"`. + * + * The Copilot CLI strips sensitive variables from every extension process + * before it starts, so an extension that needs one must ask for it by name. + * The CLI prompts the user with the extension's name and the exact list of + * variables requested. On approval the granted values are written into this + * process's `process.env` before {@link joinSession} resolves, so they are + * readable afterwards. On denial the join rejects and the extension does not + * load, so its tools never reach the model. + * + * An approval is remembered against the exact set of names the user saw, so + * asking for an additional variable later prompts again. Names that are unset + * or that the CLI does not filter from extensions are not prompted for. + * + * Requires a Copilot CLI that supports extension environment access; older + * CLIs ignore the request and grant nothing. + * + * @example + * ```typescript + * const session = await joinSession({ env: ["GITHUB_TOKEN"] }); + * const token = process.env.GITHUB_TOKEN; + * ``` + */ + env?: string[]; /** * Factory handles to register when the extension joins the session. * @@ -94,6 +120,7 @@ export async function joinSession(config: JoinSessionConfig = {}): Promise { expect(payload.openCanvasInstances).toBeUndefined(); }); + it("forwards an extension environment request and applies the grant to process.env", async () => { + const client = new CopilotClient(); + await client.start(); + onTestFinished(() => stopClient(client)); + onTestFinished(() => { + delete process.env.SDK_TEST_GRANTED_TOKEN; + }); + + const spy = vi + .spyOn((client as any).connection!, "sendRequest") + .mockImplementation(async (method: string, params: any) => { + if (method === "session.resume") { + return { + sessionId: params.sessionId, + grantedEnvironmentVariables: { SDK_TEST_GRANTED_TOKEN: "granted-value" }, + }; + } + throw new Error(`Unexpected method: ${method}`); + }); + + await client.resumeSessionForExtension( + "session-env", + { onPermissionRequest: defaultJoinSessionPermissionHandler }, + undefined, + { requestedEnvironmentVariables: ["SDK_TEST_GRANTED_TOKEN"] } + ); + + const payload = spy.mock.calls.find(([method]) => method === "session.resume")![1] as any; + expect(payload.requestedEnvironmentVariables).toEqual(["SDK_TEST_GRANTED_TOKEN"]); + expect(process.env.SDK_TEST_GRANTED_TOKEN).toBe("granted-value"); + }); + + it("omits the environment request when a resume does not ask for one", async () => { + const client = new CopilotClient(); + await client.start(); + onTestFinished(() => stopClient(client)); + + const spy = vi + .spyOn((client as any).connection!, "sendRequest") + .mockImplementation(async (method: string, params: any) => { + if (method === "session.resume") { + return { + sessionId: params.sessionId, + grantedEnvironmentVariables: { SDK_TEST_UNREQUESTED: "leaked" }, + }; + } + throw new Error(`Unexpected method: ${method}`); + }); + + await client.resumeSession("session-no-env", { onPermissionRequest: approveAll }); + + const payload = spy.mock.calls.find(([method]) => method === "session.resume")![1] as any; + expect(payload).not.toHaveProperty("requestedEnvironmentVariables"); + // A grant is only honored for a request this client actually made. + expect(process.env.SDK_TEST_UNREQUESTED).toBeUndefined(); + }); + it("forwards reasoningSummary in session.create and session.resume", async () => { const client = new CopilotClient(); await client.start(); diff --git a/nodejs/test/extension.test.ts b/nodejs/test/extension.test.ts index e94ad2204..e4fef5ab7 100644 --- a/nodejs/test/extension.test.ts +++ b/nodejs/test/extension.test.ts @@ -47,6 +47,33 @@ describe("joinSession", () => { expect(config.suppressResumeEvent).toBe(false); }); + it("forwards the requested environment variables and keeps them off the resume config", async () => { + process.env.SESSION_ID = "session-123"; + const resumeForExtension = vi + .spyOn(CopilotClient.prototype, "resumeSessionForExtension") + .mockResolvedValue({} as any); + + await joinSession({ env: ["GITHUB_TOKEN", "MY_SECRET"], tools: [] }); + + const [, config, , extensionOptions] = resumeForExtension.mock.calls[0]!; + expect(extensionOptions).toEqual({ + requestedEnvironmentVariables: ["GITHUB_TOKEN", "MY_SECRET"], + }); + expect(config).not.toHaveProperty("env"); + }); + + it("requests no environment variables when env is omitted", async () => { + process.env.SESSION_ID = "session-123"; + const resumeForExtension = vi + .spyOn(CopilotClient.prototype, "resumeSessionForExtension") + .mockResolvedValue({} as any); + + await joinSession({ tools: [] }); + + const [, , , extensionOptions] = resumeForExtension.mock.calls[0]!; + expect(extensionOptions?.requestedEnvironmentVariables).toBeUndefined(); + }); + it("exports the canvas helper from the extension surface", () => { const canvas = createCanvas({ id: "counter", From 858d3e6c609ed1fc3126226971fae59cf6f1b8bb Mon Sep 17 00:00:00 2001 From: Matthew Rayermann Date: Mon, 17 Aug 2026 17:52:37 -0700 Subject: [PATCH 2/4] - Add E2E coverage for the extension environment request - Fix the factory join-path assertion broken by the new argument - Pass extension join options only when an extension asks for variables Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- nodejs/src/extension.ts | 2 +- .../test/e2e/extension_env_access.e2e.test.ts | 213 ++++++++++++++++++ .../e2e/fixtures/env-access-extension.mjs | 69 ++++++ nodejs/test/extension.test.ts | 2 +- nodejs/test/factory.test.ts | 3 +- 5 files changed, 286 insertions(+), 3 deletions(-) create mode 100644 nodejs/test/e2e/extension_env_access.e2e.test.ts create mode 100644 nodejs/test/e2e/fixtures/env-access-extension.mjs diff --git a/nodejs/src/extension.ts b/nodejs/src/extension.ts index 9af1ab6b0..5ca1bc456 100644 --- a/nodejs/src/extension.ts +++ b/nodejs/src/extension.ts @@ -135,6 +135,6 @@ export async function joinSession(config: JoinSessionConfig = {}): Promise; + /** Sample taken before the join, so an inherited value is distinguishable from a granted one. */ + prejoin: string; + postjoin: string; + result: string; +} + +/** + * Run the fixture extension as a real child process against a stub host that + * speaks the extension side of the wire. + * + * The extension connection is plain JSON-RPC over the extension process's own + * stdio, so a stub host observes exactly what the CLI observes. That is the only + * way to cover this feature end to end today: the released CLI predates the host + * half (github/copilot-agent-runtime#15144), so it ignores the request and grants + * nothing. Once the `@github/copilot` dependency carries the host half, the + * real-CLI case below can assert the grant instead. + */ +async function runExtensionAgainstStubHost(options: { + requested: string[]; + /** Values the host resolves for an approved request, or undefined to deny it. */ + granted?: Record; +}): Promise { + if (!existsSync(join(DIST_DIR, "extension.js"))) { + throw new Error(`Built SDK not found at ${DIST_DIR}. Run \`npm run build\` first.`); + } + + const dir = mkdtempSync(join(tmpdir(), "copilot-env-access-")); + const prejoinFile = join(dir, "prejoin"); + const postjoinFile = join(dir, "postjoin"); + const resultFile = join(dir, "result"); + + const child = spawn(process.execPath, [FIXTURE], { + stdio: ["pipe", "pipe", "pipe"], + env: { + ...process.env, + SESSION_ID: "stub-host-session", + EXTENSION_SDK_MODULE: pathToFileURL(join(DIST_DIR, "extension.js")).href, + EXTENSION_ENV_REQUEST: options.requested.join(","), + EXTENSION_PREJOIN_FILE: prejoinFile, + EXTENSION_POSTJOIN_FILE: postjoinFile, + EXTENSION_RESULT_FILE: resultFile, + }, + }); + + const stderr: string[] = []; + child.stderr!.on("data", (chunk) => stderr.push(String(chunk))); + + let capturedResumeParams: Record = {}; + const connection = createMessageConnection( + new StreamMessageReader(child.stdout!), + new StreamMessageWriter(child.stdin!) + ); + connection.onRequest("connect", () => ({ protocolVersion: getSdkProtocolVersion() })); + connection.onRequest("session.resume", (params: Record) => { + capturedResumeParams = params; + if (!options.granted) { + throw new Error( + 'Extension "env-access" was denied access to sensitive environment variables' + ); + } + return { sessionId: params.sessionId, grantedEnvironmentVariables: options.granted }; + }); + // Everything else the SDK issues while joining is irrelevant here, and an + // unanswered request would hang the join. + connection.onRequest(() => ({})); + connection.onNotification(() => {}); + connection.listen(); + + try { + await retry( + "wait for the fixture extension to report its join result", + async () => { + expect( + existsSync(resultFile), + `extension never reported; stderr: ${stderr.join("")}` + ).toBe(true); + }, + 300, + 100 + ); + + return { + resumeParams: capturedResumeParams, + prejoin: readFileSync(prejoinFile, "utf-8"), + postjoin: readFileSync(postjoinFile, "utf-8"), + result: readFileSync(resultFile, "utf-8"), + }; + } finally { + connection.dispose(); + child.kill(); + // Windows keeps the directory locked until the child is gone. + await new Promise((resolveExit) => { + if (child.exitCode !== null || child.signalCode !== null) { + resolveExit(); + return; + } + child.once("exit", () => resolveExit()); + }); + await rm(dir, { recursive: true, force: true, maxRetries: 20, retryDelay: 100 }); + } +} + +it("puts an extension's environment request on the wire and applies the grant", async () => { + const run = await runExtensionAgainstStubHost({ + requested: ["E2E_SDK_TOKEN", "E2E_SDK_OTHER"], + granted: { E2E_SDK_TOKEN: "granted-token", E2E_SDK_OTHER: "granted-other" }, + }); + + expect(run.resumeParams.requestedEnvironmentVariables).toEqual([ + "E2E_SDK_TOKEN", + "E2E_SDK_OTHER", + ]); + // The values crossed the process boundary rather than being inherited. + expect(run.prejoin).toBe("E2E_SDK_TOKEN=\nE2E_SDK_OTHER="); + expect(run.postjoin).toBe("E2E_SDK_TOKEN=granted-token\nE2E_SDK_OTHER=granted-other"); + expect(run.result).toBe("joined"); +}); + +it("grants nothing to an extension whose request the host denies", async () => { + const run = await runExtensionAgainstStubHost({ requested: ["E2E_SDK_TOKEN"] }); + + expect(run.resumeParams.requestedEnvironmentVariables).toEqual(["E2E_SDK_TOKEN"]); + expect(run.result).toContain("denied access to sensitive environment variables"); + expect(run.postjoin).toBe("E2E_SDK_TOKEN="); +}); + +it("leaves the wire payload alone when an extension asks for nothing", async () => { + const run = await runExtensionAgainstStubHost({ requested: [], granted: {} }); + + expect(run.resumeParams).not.toHaveProperty("requestedEnvironmentVariables"); + expect(run.result).toBe("joined"); +}); + +const cliObservations = isInProcessTransport + ? "" + : mkdtempSync(join(tmpdir(), "copilot-env-access-cli-")); +const cliResultFile = join(cliObservations, "result"); +const cliContext = isInProcessTransport + ? undefined + : await createSdkTestContext({ + copilotClientOptions: { + env: { + COPILOT_CLI_ENABLED_FEATURE_FLAGS: "EXTENSIONS", + EXTENSION_ENV_REQUEST: "E2E_SDK_TOKEN", + EXTENSION_RESULT_FILE: cliResultFile, + EXTENSION_PREJOIN_FILE: join(cliObservations, "prejoin"), + EXTENSION_POSTJOIN_FILE: join(cliObservations, "postjoin"), + }, + }, + }); + +// The released CLI ignores `requestedEnvironmentVariables`, so this covers the +// half a real CLI can prove today: asking for variables does not break the join. +// It becomes the grant test once `@github/copilot` carries the host half. +it.skipIf(isInProcessTransport)( + "joins a real CLI that does not support environment requests", + async () => { + if (!cliContext) { + throw new Error("Extension E2E requires an out-of-process transport"); + } + const { workDir, copilotClient } = cliContext; + const extensionDir = join(workDir, ".github", "extensions", "env-access"); + await rm(join(workDir, ".github"), { recursive: true, force: true }); + await rm(cliResultFile, { force: true }); + await mkdir(extensionDir, { recursive: true }); + await copyFile(FIXTURE, join(extensionDir, "extension.mjs")); + execFileSync("git", ["init", "--quiet"], { cwd: workDir }); + + await using _session = await copilotClient.createSession({ + requestExtensions: true, + extensionSdkPath: DIST_DIR, + onPermissionRequest: approveAll, + }); + + await retry( + "wait for the env-access extension to join the session", + async () => { + expect(existsSync(cliResultFile)).toBe(true); + }, + 300, + 100 + ); + + expect(readFileSync(cliResultFile, "utf-8")).toBe("joined"); + } +); diff --git a/nodejs/test/e2e/fixtures/env-access-extension.mjs b/nodejs/test/e2e/fixtures/env-access-extension.mjs new file mode 100644 index 000000000..a88316df0 --- /dev/null +++ b/nodejs/test/e2e/fixtures/env-access-extension.mjs @@ -0,0 +1,69 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------------------------------------------*/ + +// Extension that asks for named sensitive environment variables at join time. +// +// Every input is an environment variable so one fixture serves both the stub-host +// case (spawned directly, importing the built SDK through EXTENSION_SDK_MODULE) +// and the real-CLI case (forked by the CLI, which injects the SDK module). +// +// - EXTENSION_SDK_MODULE: import specifier for the SDK. Defaults to the module +// name the CLI resolves for a forked extension. +// - EXTENSION_ENV_REQUEST: comma-separated names to pass to joinSession({ env }). +// - EXTENSION_PREJOIN_FILE: `NAME=` per requested name, sampled BEFORE the +// join. A test compares it with the post-join sample to tell a value the +// process already inherited from one the host granted. +// - EXTENSION_POSTJOIN_FILE: the same sample, written once the join settles. +// - EXTENSION_RESULT_FILE: `joined` or `rejected:`. A denied extension +// has no session to report through, so it reports here. + +import { writeFileSync } from "node:fs"; + +const sdkModule = process.env.EXTENSION_SDK_MODULE ?? "@github/copilot-sdk/extension"; +const { joinSession } = await import(sdkModule); + +const requested = (process.env.EXTENSION_ENV_REQUEST ?? "") + .split(",") + .map((name) => name.trim()) + .filter((name) => name.length > 0); + +const sample = () => requested.map((name) => `${name}=${process.env[name] ?? ""}`).join("\n"); + +const record = (file, contents) => { + if (file) { + writeFileSync(file, contents); + } +}; + +record(process.env.EXTENSION_PREJOIN_FILE, sample()); + +const config = { + tools: [ + { + name: "env_access_greeter", + description: "Greets someone. Always call this tool when asked to greet.", + parameters: { type: "object", properties: { name: { type: "string" } } }, + handler: async (args) => `Hello from env-access, ${args.name || "World"}!`, + }, + ], +}; +// An extension that wants nothing omits the option entirely, as an ordinary +// extension does. +if (requested.length > 0) { + config.env = requested; +} + +try { + await joinSession(config); + record(process.env.EXTENSION_POSTJOIN_FILE, sample()); + record(process.env.EXTENSION_RESULT_FILE, "joined"); +} catch (error) { + // Sampled after the rejection too, so a test can prove a denied extension + // never saw the value rather than only that the join failed. + record(process.env.EXTENSION_POSTJOIN_FILE, sample()); + record( + process.env.EXTENSION_RESULT_FILE, + `rejected:${error instanceof Error ? error.message : String(error)}` + ); +} diff --git a/nodejs/test/extension.test.ts b/nodejs/test/extension.test.ts index e4fef5ab7..8dc8dc013 100644 --- a/nodejs/test/extension.test.ts +++ b/nodejs/test/extension.test.ts @@ -71,7 +71,7 @@ describe("joinSession", () => { await joinSession({ tools: [] }); const [, , , extensionOptions] = resumeForExtension.mock.calls[0]!; - expect(extensionOptions?.requestedEnvironmentVariables).toBeUndefined(); + expect(extensionOptions).toBeUndefined(); }); it("exports the canvas helper from the extension surface", () => { diff --git a/nodejs/test/factory.test.ts b/nodejs/test/factory.test.ts index 3d85b972d..13133397f 100644 --- a/nodejs/test/factory.test.ts +++ b/nodejs/test/factory.test.ts @@ -627,7 +627,8 @@ describe("factories", () => { expect(resumeSessionForExtension).toHaveBeenCalledWith( "session-extension", expect.objectContaining({ suppressResumeEvent: true }), - [factory] + [factory], + undefined ); }); From d4750e8ac5275dae25ed4ecfb44ced4b63157be3 Mon Sep 17 00:00:00 2001 From: Matthew Rayermann Date: Mon, 17 Aug 2026 18:18:28 -0700 Subject: [PATCH 3/4] - Treat an empty env list as no environment request - Apply only approved names from a grant - Match the docs heading style in the extensions guide Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- nodejs/docs/extensions.md | 2 +- nodejs/src/client.ts | 9 +++-- nodejs/src/extension.ts | 5 +-- nodejs/test/client.test.ts | 36 +++++++++++++++++++ .../test/e2e/extension_env_access.e2e.test.ts | 32 +++++++++++++++-- .../e2e/fixtures/env-access-extension.mjs | 20 ++++++++--- nodejs/test/extension.test.ts | 9 +++-- 7 files changed, 98 insertions(+), 15 deletions(-) diff --git a/nodejs/docs/extensions.md b/nodejs/docs/extensions.md index e568410fc..c577e1966 100644 --- a/nodejs/docs/extensions.md +++ b/nodejs/docs/extensions.md @@ -53,7 +53,7 @@ const session = await joinSession({ The `session` object provides methods for sending messages, logging to the timeline, listening to events, and accessing the RPC API. See the `.d.ts` files in the SDK package for full type information. -## Requesting sensitive environment variables +## Requesting Sensitive Environment Variables The CLI strips sensitive environment variables (for example `GITHUB_TOKEN`) from every extension process before it starts. An extension that needs one asks for it by name: diff --git a/nodejs/src/client.ts b/nodejs/src/client.ts index 331c38dce..9454f815f 100644 --- a/nodejs/src/client.ts +++ b/nodejs/src/client.ts @@ -1897,13 +1897,18 @@ export class CopilotClient { // The host answers an approved environment request with the resolved // values, and this method consumes the response, so the grant has to be - // applied here — no caller ever sees it. + // applied here — no caller ever sees it. Only the names the user + // approved may reach the extension, so a host that answers with + // anything extra cannot widen the grant. if (extensionOptions?.requestedEnvironmentVariables) { + const requested = new Set(extensionOptions.requestedEnvironmentVariables); const { grantedEnvironmentVariables } = response as { grantedEnvironmentVariables?: Record; }; for (const [name, value] of Object.entries(grantedEnvironmentVariables ?? {})) { - process.env[name] = value; + if (requested.has(name)) { + process.env[name] = value; + } } } diff --git a/nodejs/src/extension.ts b/nodejs/src/extension.ts index 5ca1bc456..19854d074 100644 --- a/nodejs/src/extension.ts +++ b/nodejs/src/extension.ts @@ -41,7 +41,8 @@ export type JoinSessionConfig = Omit< * * An approval is remembered against the exact set of names the user saw, so * asking for an additional variable later prompts again. Names that are unset - * or that the CLI does not filter from extensions are not prompted for. + * or that the CLI does not filter from extensions are not prompted for. An + * empty list means the same as omitting the option: nothing is requested. * * Requires a Copilot CLI that supports extension environment access; older * CLIs ignore the request and grant nothing. @@ -135,6 +136,6 @@ export async function joinSession(config: JoinSessionConfig = {}): Promise { expect(process.env.SDK_TEST_GRANTED_TOKEN).toBe("granted-value"); }); + it("ignores granted variables the extension never requested", async () => { + const client = new CopilotClient(); + await client.start(); + onTestFinished(() => stopClient(client)); + onTestFinished(() => { + delete process.env.SDK_TEST_GRANTED_TOKEN; + }); + + vi.spyOn((client as any).connection!, "sendRequest").mockImplementation( + async (method: string, params: any) => { + if (method === "session.resume") { + return { + sessionId: params.sessionId, + grantedEnvironmentVariables: { + SDK_TEST_GRANTED_TOKEN: "granted-value", + SDK_TEST_SMUGGLED: "not-approved", + }, + }; + } + throw new Error(`Unexpected method: ${method}`); + } + ); + + await client.resumeSessionForExtension( + "session-env-extra", + { onPermissionRequest: defaultJoinSessionPermissionHandler }, + undefined, + { requestedEnvironmentVariables: ["SDK_TEST_GRANTED_TOKEN"] } + ); + + expect(process.env.SDK_TEST_GRANTED_TOKEN).toBe("granted-value"); + // The user approved one name, so a host answering with a second one + // cannot widen the grant. + expect(process.env.SDK_TEST_SMUGGLED).toBeUndefined(); + }); + it("omits the environment request when a resume does not ask for one", async () => { const client = new CopilotClient(); await client.start(); diff --git a/nodejs/test/e2e/extension_env_access.e2e.test.ts b/nodejs/test/e2e/extension_env_access.e2e.test.ts index 7f1aafa8f..f034db905 100644 --- a/nodejs/test/e2e/extension_env_access.e2e.test.ts +++ b/nodejs/test/e2e/extension_env_access.e2e.test.ts @@ -44,6 +44,10 @@ interface ExtensionRun { */ async function runExtensionAgainstStubHost(options: { requested: string[]; + /** Pass an explicit empty list rather than omitting the option. */ + requestEmptyList?: boolean; + /** Names the extension samples but never asks for. */ + observe?: string[]; /** Values the host resolves for an approved request, or undefined to deny it. */ granted?: Record; }): Promise { @@ -63,6 +67,8 @@ async function runExtensionAgainstStubHost(options: { SESSION_ID: "stub-host-session", EXTENSION_SDK_MODULE: pathToFileURL(join(DIST_DIR, "extension.js")).href, EXTENSION_ENV_REQUEST: options.requested.join(","), + EXTENSION_ENV_REQUEST_EMPTY: options.requestEmptyList ? "1" : "", + EXTENSION_OBSERVE_ENV_NAMES: (options.observe ?? []).join(","), EXTENSION_PREJOIN_FILE: prejoinFile, EXTENSION_POSTJOIN_FILE: postjoinFile, EXTENSION_RESULT_FILE: resultFile, @@ -152,10 +158,30 @@ it("grants nothing to an extension whose request the host denies", async () => { }); it("leaves the wire payload alone when an extension asks for nothing", async () => { - const run = await runExtensionAgainstStubHost({ requested: [], granted: {} }); + const omitted = await runExtensionAgainstStubHost({ requested: [], granted: {} }); + const emptyList = await runExtensionAgainstStubHost({ + requested: [], + requestEmptyList: true, + granted: {}, + }); - expect(run.resumeParams).not.toHaveProperty("requestedEnvironmentVariables"); - expect(run.result).toBe("joined"); + expect(omitted.resumeParams).not.toHaveProperty("requestedEnvironmentVariables"); + // An empty list is the other public way to ask for nothing. + expect(emptyList.resumeParams).not.toHaveProperty("requestedEnvironmentVariables"); + expect(omitted.result).toBe("joined"); + expect(emptyList.result).toBe("joined"); +}); + +it("ignores a granted variable the extension never requested", async () => { + const run = await runExtensionAgainstStubHost({ + requested: ["E2E_SDK_TOKEN"], + observe: ["E2E_SDK_SMUGGLED"], + granted: { E2E_SDK_TOKEN: "granted-token", E2E_SDK_SMUGGLED: "not-approved" }, + }); + + // The user approved one name, so a host answering with a second one cannot + // widen the grant. + expect(run.postjoin).toBe("E2E_SDK_TOKEN=granted-token\nE2E_SDK_SMUGGLED="); }); const cliObservations = isInProcessTransport diff --git a/nodejs/test/e2e/fixtures/env-access-extension.mjs b/nodejs/test/e2e/fixtures/env-access-extension.mjs index a88316df0..929bdb0b6 100644 --- a/nodejs/test/e2e/fixtures/env-access-extension.mjs +++ b/nodejs/test/e2e/fixtures/env-access-extension.mjs @@ -17,6 +17,11 @@ // - EXTENSION_POSTJOIN_FILE: the same sample, written once the join settles. // - EXTENSION_RESULT_FILE: `joined` or `rejected:`. A denied extension // has no session to report through, so it reports here. +// - EXTENSION_ENV_REQUEST_EMPTY: set to `1` to pass an explicit empty list when +// EXTENSION_ENV_REQUEST names nothing, instead of omitting the option. +// - EXTENSION_OBSERVE_ENV_NAMES: comma-separated names that are sampled but +// deliberately NOT requested, so a test can prove a name outside the approved +// set never reached this process. import { writeFileSync } from "node:fs"; @@ -27,8 +32,12 @@ const requested = (process.env.EXTENSION_ENV_REQUEST ?? "") .split(",") .map((name) => name.trim()) .filter((name) => name.length > 0); - -const sample = () => requested.map((name) => `${name}=${process.env[name] ?? ""}`).join("\n"); +const observed = (process.env.EXTENSION_OBSERVE_ENV_NAMES ?? "") + .split(",") + .map((name) => name.trim()) + .filter((name) => name.length > 0); +const sampledNames = [...requested, ...observed]; +const sample = () => sampledNames.map((name) => `${name}=${process.env[name] ?? ""}`).join("\n"); const record = (file, contents) => { if (file) { @@ -48,10 +57,13 @@ const config = { }, ], }; -// An extension that wants nothing omits the option entirely, as an ordinary -// extension does. +// An extension that wants nothing normally omits the option entirely. +// EXTENSION_ENV_REQUEST_EMPTY covers the other public way to ask for nothing: +// passing an empty list, which must reach the wire the same way. if (requested.length > 0) { config.env = requested; +} else if (process.env.EXTENSION_ENV_REQUEST_EMPTY === "1") { + config.env = []; } try { diff --git a/nodejs/test/extension.test.ts b/nodejs/test/extension.test.ts index 8dc8dc013..82f34cdfe 100644 --- a/nodejs/test/extension.test.ts +++ b/nodejs/test/extension.test.ts @@ -62,16 +62,19 @@ describe("joinSession", () => { expect(config).not.toHaveProperty("env"); }); - it("requests no environment variables when env is omitted", async () => { + it("requests no environment variables when env is omitted or empty", async () => { process.env.SESSION_ID = "session-123"; const resumeForExtension = vi .spyOn(CopilotClient.prototype, "resumeSessionForExtension") .mockResolvedValue({} as any); await joinSession({ tools: [] }); + await joinSession({ env: [], tools: [] }); - const [, , , extensionOptions] = resumeForExtension.mock.calls[0]!; - expect(extensionOptions).toBeUndefined(); + expect(resumeForExtension.mock.calls[0]![3]).toBeUndefined(); + // An empty list means the same as omitting the option, so it must not put + // an environment request on the wire either. + expect(resumeForExtension.mock.calls[1]![3]).toBeUndefined(); }); it("exports the canvas helper from the extension surface", () => { From 3a164fa5db89128e7031dd17c8c4d9a0f7070c1f Mon Sep 17 00:00:00 2001 From: Matthew Rayermann Date: Mon, 17 Aug 2026 18:20:42 -0700 Subject: [PATCH 4/4] Keep the client type imports in order Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- nodejs/src/client.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/nodejs/src/client.ts b/nodejs/src/client.ts index 9454f815f..196d52642 100644 --- a/nodejs/src/client.ts +++ b/nodejs/src/client.ts @@ -54,6 +54,7 @@ import type { CustomAgentConfig, ExitPlanModeRequest, ExitPlanModeResult, + ExtensionJoinOptions, ForegroundSessionInfo, GetAuthStatusResponse, BearerTokenProvider, @@ -65,7 +66,6 @@ import type { ModelInfo, NamedProviderConfig, ProviderConfig, - ExtensionJoinOptions, ResumeSessionConfig, SectionTransformFn, SessionConfig,