Skip to content

Commit 525866c

Browse files
[Node] Let Extensions Request Sensitive Environment Variables (#2348)
* [Node] Let Extensions Request Sensitive Environment Variables Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * - 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> * - 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> * Keep the client type imports in order Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * Rename the extension join option to requestedEnvironmentVariables `env` means a map of values to supply to a process everywhere else in this SDK, so a list of names the extension asks for needs its own name. The new name also matches the wire field exactly. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
1 parent eb7ba24 commit 525866c

10 files changed

Lines changed: 560 additions & 5 deletions

File tree

CHANGELOG.md

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,21 @@ See [GitHub Releases](https://github.com/github/copilot-sdk/releases) for the fu
77

88
## [Unreleased]
99

10+
### Feature: extensions can request sensitive environment variables
11+
12+
Copilot CLI extensions can now ask for named sensitive environment variables when they join a session. `joinSession()` accepts a `requestedEnvironmentVariables` 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.
13+
14+
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.
15+
16+
```ts
17+
import { joinSession } from "@github/copilot-sdk/extension";
18+
19+
const session = await joinSession({
20+
requestedEnvironmentVariables: ["GITHUB_TOKEN"],
21+
});
22+
const token = process.env.GITHUB_TOKEN;
23+
```
24+
1025
### Feature: host-injected managed settings permissions
1126

1227
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).

nodejs/docs/extensions.md

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,27 @@ const session = await joinSession({
5353

5454
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.
5555

56+
## Requesting Sensitive Environment Variables
57+
58+
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:
59+
60+
```js
61+
import { joinSession } from "@github/copilot-sdk/extension";
62+
63+
const session = await joinSession({
64+
requestedEnvironmentVariables: ["GITHUB_TOKEN"],
65+
});
66+
67+
// Granted values are in process.env once joinSession resolves.
68+
const token = process.env.GITHUB_TOKEN;
69+
```
70+
71+
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.
72+
73+
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.
74+
75+
An approved extension can pass a granted value to anything it starts, so ask only for what the extension genuinely needs.
76+
5677
## Further Reading
5778

5879
- `examples.md` — Practical code examples for tools, hooks, events, and complete extensions

nodejs/src/client.ts

Lines changed: 29 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,7 @@ import type {
5454
CustomAgentConfig,
5555
ExitPlanModeRequest,
5656
ExitPlanModeResult,
57+
ExtensionJoinOptions,
5758
ForegroundSessionInfo,
5859
GetAuthStatusResponse,
5960
BearerTokenProvider,
@@ -1711,15 +1712,17 @@ export class CopilotClient {
17111712
async resumeSessionForExtension(
17121713
sessionId: string,
17131714
config: ResumeSessionConfig,
1714-
factories?: FactoryHandle[]
1715+
factories?: FactoryHandle[],
1716+
extensionOptions?: ExtensionJoinOptions
17151717
): Promise<CopilotSession> {
1716-
return this.resumeSessionInternal(sessionId, config, factories);
1718+
return this.resumeSessionInternal(sessionId, config, factories, extensionOptions);
17171719
}
17181720

17191721
private async resumeSessionInternal(
17201722
sessionId: string,
17211723
config: ResumeSessionConfig,
1722-
factories?: FactoryHandle[]
1724+
factories?: FactoryHandle[],
1725+
extensionOptions?: ExtensionJoinOptions
17231726
): Promise<CopilotSession> {
17241727
if (!this.connection) {
17251728
await this.start();
@@ -1884,8 +1887,31 @@ export class CopilotClient {
18841887
expAssignments: config.expAssignments,
18851888
enableManagedSettings: config.enableManagedSettings,
18861889
managedSettings: config.managedSettings,
1890+
...(extensionOptions?.requestedEnvironmentVariables
1891+
? {
1892+
requestedEnvironmentVariables:
1893+
extensionOptions.requestedEnvironmentVariables,
1894+
}
1895+
: {}),
18871896
});
18881897

1898+
// The host answers an approved environment request with the resolved
1899+
// values, and this method consumes the response, so the grant has to be
1900+
// applied here — no caller ever sees it. Only the names the user
1901+
// approved may reach the extension, so a host that answers with
1902+
// anything extra cannot widen the grant.
1903+
if (extensionOptions?.requestedEnvironmentVariables) {
1904+
const requested = new Set(extensionOptions.requestedEnvironmentVariables);
1905+
const { grantedEnvironmentVariables } = response as {
1906+
grantedEnvironmentVariables?: Record<string, string>;
1907+
};
1908+
for (const [name, value] of Object.entries(grantedEnvironmentVariables ?? {})) {
1909+
if (requested.has(name)) {
1910+
process.env[name] = value;
1911+
}
1912+
}
1913+
}
1914+
18891915
const { workspacePath, capabilities, openCanvases } = response as {
18901916
sessionId: string;
18911917
workspacePath?: string;

nodejs/src/extension.ts

Lines changed: 32 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,35 @@ export type JoinSessionConfig = Omit<
2727
"onPermissionRequest" | "extensionSdkPath"
2828
> & {
2929
onPermissionRequest?: PermissionHandler;
30+
/**
31+
* Names of sensitive environment variables this extension needs, such as
32+
* `"GITHUB_TOKEN"`.
33+
*
34+
* The Copilot CLI strips sensitive variables from every extension process
35+
* before it starts, so an extension that needs one must ask for it by name.
36+
* The CLI prompts the user with the extension's name and the exact list of
37+
* variables requested. On approval the granted values are written into this
38+
* process's `process.env` before {@link joinSession} resolves, so they are
39+
* readable afterwards. On denial the join rejects and the extension does not
40+
* load, so its tools never reach the model.
41+
*
42+
* An approval is remembered against the exact set of names the user saw, so
43+
* asking for an additional variable later prompts again. Names that are unset
44+
* or that the CLI does not filter from extensions are not prompted for. An
45+
* empty list means the same as omitting the option: nothing is requested.
46+
*
47+
* Requires a Copilot CLI that supports extension environment access; older
48+
* CLIs ignore the request and grant nothing.
49+
*
50+
* @example
51+
* ```typescript
52+
* const session = await joinSession({
53+
* requestedEnvironmentVariables: ["GITHUB_TOKEN"],
54+
* });
55+
* const token = process.env.GITHUB_TOKEN;
56+
* ```
57+
*/
58+
requestedEnvironmentVariables?: string[];
3059
/**
3160
* Factory handles to register when the extension joins the session.
3261
*
@@ -94,6 +123,7 @@ export async function joinSession(config: JoinSessionConfig = {}): Promise<Copil
94123
const {
95124
extensionSdkPath: _stripped,
96125
factories,
126+
requestedEnvironmentVariables,
97127
...rest
98128
} = config as JoinSessionConfig & {
99129
extensionSdkPath?: string;
@@ -107,6 +137,7 @@ export async function joinSession(config: JoinSessionConfig = {}): Promise<Copil
107137
onPermissionRequest: config.onPermissionRequest ?? defaultJoinSessionPermissionHandler,
108138
suppressResumeEvent: config.suppressResumeEvent ?? true,
109139
},
110-
factories
140+
factories,
141+
requestedEnvironmentVariables?.length ? { requestedEnvironmentVariables } : undefined
111142
);
112143
}

nodejs/src/types.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2879,6 +2879,20 @@ export interface ResumeSessionConfig extends SessionConfigBase {
28792879
openCanvases?: OpenCanvasInstance[];
28802880
}
28812881

2882+
/**
2883+
* Options that only an extension join may supply, kept off {@link ResumeSessionConfig}
2884+
* because the runtime ignores them for every other kind of connection.
2885+
*
2886+
* @internal
2887+
*/
2888+
export interface ExtensionJoinOptions {
2889+
/**
2890+
* Names of sensitive environment variables the extension asks the host to grant.
2891+
* Sent on the `session.resume` wire payload as `requestedEnvironmentVariables`.
2892+
*/
2893+
requestedEnvironmentVariables?: string[];
2894+
}
2895+
28822896
/**
28832897
* Arguments passed to a {@link BearerTokenProvider} callback when the runtime needs a
28842898
* fresh bearer token for a BYOK provider.

nodejs/test/client.test.ts

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -622,6 +622,99 @@ describe("CopilotClient", () => {
622622
expect(payload.openCanvasInstances).toBeUndefined();
623623
});
624624

625+
it("forwards an extension environment request and applies the grant to process.env", async () => {
626+
const client = new CopilotClient();
627+
await client.start();
628+
onTestFinished(() => stopClient(client));
629+
onTestFinished(() => {
630+
delete process.env.SDK_TEST_GRANTED_TOKEN;
631+
});
632+
633+
const spy = vi
634+
.spyOn((client as any).connection!, "sendRequest")
635+
.mockImplementation(async (method: string, params: any) => {
636+
if (method === "session.resume") {
637+
return {
638+
sessionId: params.sessionId,
639+
grantedEnvironmentVariables: { SDK_TEST_GRANTED_TOKEN: "granted-value" },
640+
};
641+
}
642+
throw new Error(`Unexpected method: ${method}`);
643+
});
644+
645+
await client.resumeSessionForExtension(
646+
"session-env",
647+
{ onPermissionRequest: defaultJoinSessionPermissionHandler },
648+
undefined,
649+
{ requestedEnvironmentVariables: ["SDK_TEST_GRANTED_TOKEN"] }
650+
);
651+
652+
const payload = spy.mock.calls.find(([method]) => method === "session.resume")![1] as any;
653+
expect(payload.requestedEnvironmentVariables).toEqual(["SDK_TEST_GRANTED_TOKEN"]);
654+
expect(process.env.SDK_TEST_GRANTED_TOKEN).toBe("granted-value");
655+
});
656+
657+
it("ignores granted variables the extension never requested", async () => {
658+
const client = new CopilotClient();
659+
await client.start();
660+
onTestFinished(() => stopClient(client));
661+
onTestFinished(() => {
662+
delete process.env.SDK_TEST_GRANTED_TOKEN;
663+
});
664+
665+
vi.spyOn((client as any).connection!, "sendRequest").mockImplementation(
666+
async (method: string, params: any) => {
667+
if (method === "session.resume") {
668+
return {
669+
sessionId: params.sessionId,
670+
grantedEnvironmentVariables: {
671+
SDK_TEST_GRANTED_TOKEN: "granted-value",
672+
SDK_TEST_SMUGGLED: "not-approved",
673+
},
674+
};
675+
}
676+
throw new Error(`Unexpected method: ${method}`);
677+
}
678+
);
679+
680+
await client.resumeSessionForExtension(
681+
"session-env-extra",
682+
{ onPermissionRequest: defaultJoinSessionPermissionHandler },
683+
undefined,
684+
{ requestedEnvironmentVariables: ["SDK_TEST_GRANTED_TOKEN"] }
685+
);
686+
687+
expect(process.env.SDK_TEST_GRANTED_TOKEN).toBe("granted-value");
688+
// The user approved one name, so a host answering with a second one
689+
// cannot widen the grant.
690+
expect(process.env.SDK_TEST_SMUGGLED).toBeUndefined();
691+
});
692+
693+
it("omits the environment request when a resume does not ask for one", async () => {
694+
const client = new CopilotClient();
695+
await client.start();
696+
onTestFinished(() => stopClient(client));
697+
698+
const spy = vi
699+
.spyOn((client as any).connection!, "sendRequest")
700+
.mockImplementation(async (method: string, params: any) => {
701+
if (method === "session.resume") {
702+
return {
703+
sessionId: params.sessionId,
704+
grantedEnvironmentVariables: { SDK_TEST_UNREQUESTED: "leaked" },
705+
};
706+
}
707+
throw new Error(`Unexpected method: ${method}`);
708+
});
709+
710+
await client.resumeSession("session-no-env", { onPermissionRequest: approveAll });
711+
712+
const payload = spy.mock.calls.find(([method]) => method === "session.resume")![1] as any;
713+
expect(payload).not.toHaveProperty("requestedEnvironmentVariables");
714+
// A grant is only honored for a request this client actually made.
715+
expect(process.env.SDK_TEST_UNREQUESTED).toBeUndefined();
716+
});
717+
625718
it("forwards reasoningSummary in session.create and session.resume", async () => {
626719
const client = new CopilotClient();
627720
await client.start();

0 commit comments

Comments
 (0)