Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down
19 changes: 19 additions & 0 deletions nodejs/docs/extensions.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
32 changes: 29 additions & 3 deletions nodejs/src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ import type {
CustomAgentConfig,
ExitPlanModeRequest,
ExitPlanModeResult,
ExtensionJoinOptions,
ForegroundSessionInfo,
GetAuthStatusResponse,
BearerTokenProvider,
Expand Down Expand Up @@ -1711,15 +1712,17 @@ export class CopilotClient {
async resumeSessionForExtension(
sessionId: string,
config: ResumeSessionConfig,
factories?: FactoryHandle[]
factories?: FactoryHandle[],
extensionOptions?: ExtensionJoinOptions
): Promise<CopilotSession> {
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<CopilotSession> {
if (!this.connection) {
await this.start();
Expand Down Expand Up @@ -1884,8 +1887,31 @@ 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. 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<string, string>;
};
for (const [name, value] of Object.entries(grantedEnvironmentVariables ?? {})) {
if (requested.has(name)) {
process.env[name] = value;
}
}
}

const { workspacePath, capabilities, openCanvases } = response as {
sessionId: string;
workspacePath?: string;
Expand Down
31 changes: 30 additions & 1 deletion nodejs/src/extension.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,33 @@ 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. 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.
*
* @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.
*
Expand Down Expand Up @@ -94,6 +121,7 @@ export async function joinSession(config: JoinSessionConfig = {}): Promise<Copil
const {
extensionSdkPath: _stripped,
factories,
env,
...rest
} = config as JoinSessionConfig & {
extensionSdkPath?: string;
Expand All @@ -107,6 +135,7 @@ export async function joinSession(config: JoinSessionConfig = {}): Promise<Copil
onPermissionRequest: config.onPermissionRequest ?? defaultJoinSessionPermissionHandler,
suppressResumeEvent: config.suppressResumeEvent ?? true,
},
factories
factories,
env?.length ? { requestedEnvironmentVariables: env } : undefined
);
}
14 changes: 14 additions & 0 deletions nodejs/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2879,6 +2879,20 @@ export interface ResumeSessionConfig extends SessionConfigBase {
openCanvases?: OpenCanvasInstance[];
}

/**
* Options that only an extension join may supply, kept off {@link ResumeSessionConfig}
* because the runtime ignores them for every other kind of connection.
*
* @internal
*/
export interface ExtensionJoinOptions {
/**
* Names of sensitive environment variables the extension asks the host to grant.
* Sent on the `session.resume` wire payload as `requestedEnvironmentVariables`.
*/
requestedEnvironmentVariables?: string[];
}

/**
* Arguments passed to a {@link BearerTokenProvider} callback when the runtime needs a
* fresh bearer token for a BYOK provider.
Expand Down
93 changes: 93 additions & 0 deletions nodejs/test/client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -622,6 +622,99 @@ describe("CopilotClient", () => {
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("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();
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();
Expand Down
Loading
Loading