From ae15ff4c3f4a868571b30c0bb65251ba55725606 Mon Sep 17 00:00:00 2001 From: Dotta Date: Mon, 28 Sep 2026 11:18:34 -0500 Subject: [PATCH] Isolate candidate ACP runtime credentials and provider homes Co-Authored-By: Paperclip --- .../src/drivers/acpx/environment.test.ts | 26 ++++++- .../src/drivers/acpx/environment.ts | 20 ++--- .../src/drivers/acpx/runtime-sandbox.test.ts | 53 ++++++++++++- .../src/drivers/acpx/runtime-sandbox.ts | 76 +++++++++++++++++++ 4 files changed, 164 insertions(+), 11 deletions(-) diff --git a/packages/paperclip-runner/src/drivers/acpx/environment.test.ts b/packages/paperclip-runner/src/drivers/acpx/environment.test.ts index 03e7d00aae4..2e74bd4507d 100644 --- a/packages/paperclip-runner/src/drivers/acpx/environment.test.ts +++ b/packages/paperclip-runner/src/drivers/acpx/environment.test.ts @@ -1,4 +1,6 @@ -import { describe, expect, it } from "vitest"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +afterEach(() => vi.unstubAllEnvs()); import { createSanitizedAcpxSpawnInput } from "./environment.js"; @@ -45,6 +47,28 @@ describe("ACPX launch environment", () => { expect(Object.isFrozen(codex.env)).toBe(true); }); + it.each([ + ["pi", "OPENROUTER_API_KEY"], ["cursor", "CURSOR_API_KEY"], + ["cursor", "CURSOR_AUTH_TOKEN"], ["copilot", "COPILOT_GITHUB_TOKEN"], + ] as const)("requires an explicit %s credential binding for %s", (agent, name) => { + vi.stubEnv(name, "ambient-secret"); + expect(createSanitizedAcpxSpawnInput(undefined, agent).env[name]).toBeUndefined(); + expect(createSanitizedAcpxSpawnInput({ [name]: "bound-secret" }, agent).env[name]).toBe("bound-secret"); + }); + + it("drops alternate credentials, provider injection and configuration overrides", () => { + const source = { + CURSOR_API_KEY: "cursor-key", CURSOR_AUTH_TOKEN: "cursor-token", COPILOT_GITHUB_TOKEN: "copilot-key", + GH_TOKEN: "gh-key", GITHUB_TOKEN: "github-key", COPILOT_PROVIDER_BASE_URL: "https://unapproved.invalid", + COPILOT_MODEL: "ambient-model", CURSOR_CONFIG_DIR: "/ambient/cursor", PI_CODING_AGENT_DIR: "/ambient/pi", + PAPERCLIP_PI_ENTRYPOINT: "/unverified/pi.js", NODE_OPTIONS: "--import=/unverified/hook.js", + COPILOT_PKG_CACHE_HOME: "/unverified/cache", COPILOT_ALLOW_ALL: "true", + }; + expect(createSanitizedAcpxSpawnInput(source, "cursor").env).toEqual({ CURSOR_API_KEY: "cursor-key", CURSOR_AUTH_TOKEN: "cursor-token" }); + expect(createSanitizedAcpxSpawnInput(source, "copilot").env).toEqual({ COPILOT_GITHUB_TOKEN: "copilot-key" }); + expect(createSanitizedAcpxSpawnInput(source, "pi").env).toEqual({}); + }); + it("rejects unsafe or unbounded retained values", () => { expect(() => createSanitizedAcpxSpawnInput({ PATH: "bad\0path" }, "codex"), diff --git a/packages/paperclip-runner/src/drivers/acpx/environment.ts b/packages/paperclip-runner/src/drivers/acpx/environment.ts index 57fef1b4e3c..c45ad4065e1 100644 --- a/packages/paperclip-runner/src/drivers/acpx/environment.ts +++ b/packages/paperclip-runner/src/drivers/acpx/environment.ts @@ -23,16 +23,15 @@ export function createSanitizedAcpxSpawnInput( agent: QualifiedAcpxAgent, ): SanitizedAcpxSpawnInput { const source = environment ?? process.env; + const candidate = agent === "pi" || agent === "cursor" || agent === "copilot"; const result: NodeJS.ProcessEnv = {}; - const credentialNames = - agent === "pi" - ? ["OPENROUTER_API_KEY"] - : agent === "claude" - ? ["ANTHROPIC_API_KEY", "CLAUDE_CODE_OAUTH_TOKEN"] - : [ - "OPENAI_API_KEY", - "CODEX_API_KEY", - ]; + const credentialNames: readonly string[] = { + pi: ["OPENROUTER_API_KEY"], + cursor: ["CURSOR_API_KEY", "CURSOR_AUTH_TOKEN"], + copilot: ["COPILOT_GITHUB_TOKEN"], + claude: ["ANTHROPIC_API_KEY", "CLAUDE_CODE_OAUTH_TOKEN"], + codex: ["OPENAI_API_KEY", "CODEX_API_KEY"], + }[agent]; const allowed = new Set([ "PATH", "LANG", @@ -60,6 +59,9 @@ export function createSanitizedAcpxSpawnInput( let retainedBytes = 0; for (const [key, value] of Object.entries(source)) { if (typeof value !== "string") continue; + // Candidate credentials must be explicitly bound to this run. Locale and + // transport settings may come from the host; login state never does. + if (candidate && environment === undefined && credentialNames.includes(key)) continue; if (!allowed.has(key) && !/^LC_[A-Z0-9_]{1,32}$/.test(key)) continue; if (key.includes("\0") || value.includes("\0")) { throw new Error("ACPX environment contains a null byte"); diff --git a/packages/paperclip-runner/src/drivers/acpx/runtime-sandbox.test.ts b/packages/paperclip-runner/src/drivers/acpx/runtime-sandbox.test.ts index cba482523a5..c083366b8c2 100644 --- a/packages/paperclip-runner/src/drivers/acpx/runtime-sandbox.test.ts +++ b/packages/paperclip-runner/src/drivers/acpx/runtime-sandbox.test.ts @@ -67,6 +67,8 @@ describe("ACPX runtime sandbox", () => { ["pi", "OPENROUTER_API_KEY", "pi-home"], ["claude", "ANTHROPIC_API_KEY", "claude-home"], ["codex", "OPENAI_API_KEY", "codex-home"], + ["cursor", "CURSOR_API_KEY", "cursor-home"], + ["copilot", "COPILOT_GITHUB_TOKEN", "copilot-home"], ] as const)( "creates a private %s filesystem and split environment", async (agent, credentialName, homeSuffix) => { @@ -74,10 +76,14 @@ describe("ACPX runtime sandbox", () => { const sandbox = await prepareAcpxRuntimeSandbox({ binding: fixture.binding, agent, + providerPolicy: { readOnly: false, systemInstructions: "bound instructions" }, environment: { PATH: process.env.PATH, [credentialName]: "provider-secret", UNRELATED_SECRET: "must-not-enter", + HOME: "/ambient/home", XDG_CONFIG_HOME: "/ambient/config", CURSOR_CONFIG_DIR: "/ambient/cursor", + COPILOT_HOME: "/ambient/copilot", COPILOT_PKG_CACHE_HOME: "/ambient/extraction-cache", COPILOT_ALLOW_ALL: "true", + PAPERCLIP_PI_READ_ONLY: "1", PAPERCLIP_PI_READ_ROOTS: '["/"]', PAPERCLIP_PI_ENTRYPOINT: "/ambient/pi.js", HTTPS_PROXY: "https://proxy-user:proxy-password@example.test", PAPERCLIP_NATIVE_MCP_URL: "https://mcp.example.test/connect?ticket=secret", @@ -137,7 +143,32 @@ describe("ACPX runtime sandbox", () => { 0o600, ); } + expect(sandbox.protectedPaths).toEqual([dirname(dirname(sandbox.root))]); + expect(sandbox.launchEnvironment.PAPERCLIP_PI_ENTRYPOINT).toBeUndefined(); + expect(sandbox.launchEnvironment.COPILOT_PKG_CACHE_HOME).toBeUndefined(); + if (agent === "cursor") { + expect(sandbox.launchEnvironment).toMatchObject({ + CURSOR_CONFIG_DIR: sandbox.agentHomeDirectory, CURSOR_DATA_DIR: sandbox.dataDirectory, + AGENT_CLI_CREDENTIAL_STORE: "memory", NO_OPEN_BROWSER: "1", NODE_DISABLE_COMPILE_CACHE: "1", + }); + } + if (agent === "copilot") { + expect(sandbox.launchEnvironment).toMatchObject({ + COPILOT_HOME: sandbox.agentHomeDirectory, COPILOT_CACHE_HOME: sandbox.cacheDirectory, + COPILOT_AUTO_UPDATE: "false", COPILOT_ALLOW_ALL: "false", COPILOT_DISABLE_TERMINAL_TITLE: "true", NO_COLOR: "1", + }); + expect(JSON.parse(await readFile(join(sandbox.agentHomeDirectory, "config.json"), "utf8"))).toEqual({ + autoUpdate: false, trustedFolders: [], disableAllHooks: true, memory: false, + ide: { autoConnect: false, openDiffOnEdit: false }, + }); + } if (agent === "pi") { + expect(sandbox.launchEnvironment).toMatchObject({ + PAPERCLIP_PI_READ_ONLY: "0", PAPERCLIP_PI_READ_ROOTS: "[]", + PAPERCLIP_PI_PROTECTED_ROOTS: JSON.stringify(sandbox.protectedPaths), + PAPERCLIP_PI_SYSTEM_INSTRUCTIONS: "bound instructions", + }); + expect(sandbox.persistedEnvironment.PAPERCLIP_PI_SYSTEM_INSTRUCTIONS).toBeUndefined(); await expect( readFile(join(sandbox.agentHomeDirectory, "settings.json"), "utf8"), ).resolves.toContain('"defaultProjectTrust":"never"'); @@ -145,6 +176,24 @@ describe("ACPX runtime sandbox", () => { }, ); + it("requires Pi task policy and validates assigned roots independently from approval mode", async () => { + const fixture = await sandboxFixture("pi"); + await expect(prepareAcpxRuntimeSandbox({ binding: fixture.binding, agent: "pi" })).rejects.toThrow("explicit task execution policy"); + const readRoot = join(fixture.root, "assigned-skills"); + await mkdir(readRoot); + const sandbox = await prepareAcpxRuntimeSandbox({ + binding: { ...fixture.binding, permissionMode: "approve-all" }, agent: "pi", + providerPolicy: { readOnly: true, readRoots: [readRoot], protectedPaths: ["/verified/provider-pack"] }, + }); + expect(sandbox.launchEnvironment.PAPERCLIP_PI_READ_ONLY).toBe("1"); + expect(JSON.parse(sandbox.launchEnvironment.PAPERCLIP_PI_READ_ROOTS!)).toHaveLength(1); + expect(sandbox.protectedPaths).toContain("/verified/provider-pack"); + for (const readRoots of [["/"], ["relative"], ["/missing/assigned-skills"]]) { + await expect(prepareAcpxRuntimeSandbox({ binding: fixture.binding, agent: "pi", providerPolicy: { readOnly: false, readRoots } })).rejects.toThrow(); + } + await expect(prepareAcpxRuntimeSandbox({ binding: fixture.binding, agent: "pi", providerPolicy: { readOnly: false, systemInstructions: "x".repeat(32 * 1024 + 1) } })).rejects.toThrow("bounded size"); + }); + it("re-prepares and re-synchronizes an existing private sandbox", async () => { const fixture = await sandboxFixture("claude"); const first = await prepareAcpxRuntimeSandbox({ @@ -318,7 +367,7 @@ describe("ACPX runtime sandbox", () => { ); }); -async function sandboxFixture(agent: "pi" | "claude" | "codex") { +async function sandboxFixture(agent: "pi" | "claude" | "codex" | "cursor" | "copilot") { const root = await mkdtemp(join(tmpdir(), "paperclip-acpx-sandbox-")); temporaryDirectories.push(root); const workspace = join(root, "workspace"); @@ -328,6 +377,8 @@ async function sandboxFixture(agent: "pi" | "claude" | "codex") { pi: "openrouter/deepseek/deepseek-v4-flash-0731", claude: "claude-sonnet-5", codex: "gpt-5.6-sol", + cursor: "provider-model-cursor", + copilot: "provider-model-copilot", } as const; const binding = await createAcpxRecoveryBinding({ runtimeDirectory, diff --git a/packages/paperclip-runner/src/drivers/acpx/runtime-sandbox.ts b/packages/paperclip-runner/src/drivers/acpx/runtime-sandbox.ts index 30d2f4cbb79..cf0229fd2c2 100644 --- a/packages/paperclip-runner/src/drivers/acpx/runtime-sandbox.ts +++ b/packages/paperclip-runner/src/drivers/acpx/runtime-sandbox.ts @@ -51,6 +51,8 @@ export interface AcpxRuntimeSandbox { workspaceRecordPath: string; launchEnvironment: Readonly; persistedEnvironment: Readonly; + /** The host sandbox must protect these runtime/config paths from tools. */ + protectedPaths: readonly string[]; } /** A recovered workspace pinned until the provider process is admitted. */ @@ -320,6 +322,15 @@ async function closeRecoveryHandles( } } +export interface AcpxProviderRuntimePolicy { + /** Task execution policy, independent of provider permission auto-approval. */ + readOnly: boolean; + /** Assigned, verified skill roots. Never taken from ambient provider config. */ + readRoots?: readonly string[]; + systemInstructions?: string; + protectedPaths?: readonly string[]; +} + /** Prepare the private filesystem and environment visible to an ACPX agent. */ export async function prepareAcpxRuntimeSandbox(input: { binding: AcpxRecoveryBinding; @@ -327,7 +338,12 @@ export async function prepareAcpxRuntimeSandbox(input: { environment?: NodeJS.ProcessEnv; /** Public operations on the runner-owned Paperclip MCP bridge only. */ tools?: readonly Readonly>[]; + providerPolicy?: AcpxProviderRuntimePolicy; }): Promise { + if (input.agent === "pi" && (input.providerPolicy === undefined || typeof input.providerPolicy.readOnly !== "boolean")) { + throw new Error("Pi admission requires an explicit task execution policy"); + } + const policy = await validateProviderPolicy(input.providerPolicy); const expectedRoot = input.binding.runtimeRoot; if (resolve(expectedRoot) !== expectedRoot) { throw new Error("ACPX runtime root must be an absolute normalized path"); @@ -364,6 +380,7 @@ export async function prepareAcpxRuntimeSandbox(input: { join(root, `${input.agent}-home`), root, ); + const protectedPaths = Object.freeze([...new Set([physicalRuntimeDirectory, ...policy.protectedPaths])]); const workspaceRecordPath = join(root, "workspace"); await writePrivateFile( workspaceRecordPath, @@ -394,6 +411,15 @@ export async function prepareAcpxRuntimeSandbox(input: { })}\n`, ); } + if (input.agent === "copilot") { + await writePrivateFile(join(agentHomeDirectory, "config.json"), `${JSON.stringify({ + autoUpdate: false, + trustedFolders: [], + disableAllHooks: true, + memory: false, + ide: { autoConnect: false, openDiffOnEdit: false }, + })}\n`); + } if (input.agent === "codex") { await writePrivateFile( join(agentHomeDirectory, "config.toml"), @@ -433,6 +459,31 @@ export async function prepareAcpxRuntimeSandbox(input: { PI_CODING_AGENT_DIR: agentHomeDirectory, PI_SKIP_VERSION_CHECK: "1", PI_TELEMETRY: "0", + PAPERCLIP_PI_READ_ONLY: policy.readOnly ? "1" : "0", + PAPERCLIP_PI_READ_ROOTS: JSON.stringify(policy.readRoots), + PAPERCLIP_PI_PROTECTED_ROOTS: JSON.stringify(protectedPaths), + PAPERCLIP_PI_SYSTEM_INSTRUCTIONS: policy.systemInstructions, + } + : {}), + ...(input.agent === "cursor" + ? { + CURSOR_CONFIG_DIR: agentHomeDirectory, + CURSOR_DATA_DIR: dataDirectory, + AGENT_CLI_CREDENTIAL_STORE: "memory", + NO_OPEN_BROWSER: "1", + NODE_DISABLE_COMPILE_CACHE: "1", + } + : {}), + ...(input.agent === "copilot" + ? { + COPILOT_HOME: agentHomeDirectory, + COPILOT_CACHE_HOME: cacheDirectory, + COPILOT_AUTO_UPDATE: "false", + COPILOT_ALLOW_ALL: "false", + COPILOT_DISABLE_TERMINAL_TITLE: "true", + NO_COLOR: "1", + // COPILOT_PKG_CACHE_HOME belongs to the per-spawn immutable command + // lease. Reusing a persistent extraction cache invalidates its proof. } : {}), ...(input.agent === "claude" @@ -465,11 +516,34 @@ export async function prepareAcpxRuntimeSandbox(input: { cacheDirectory, agentHomeDirectory, workspaceRecordPath, + protectedPaths, launchEnvironment: Object.freeze({ ...launchEnvironment }), persistedEnvironment: Object.freeze(persistedEnvironment), }; } +async function validateProviderPolicy(policy: AcpxProviderRuntimePolicy | undefined): Promise<{ + readOnly: boolean; readRoots: string[]; protectedPaths: string[]; systemInstructions: string; +}> { + if (policy !== undefined && typeof policy.readOnly !== "boolean") throw new Error("Provider task execution policy is invalid"); + const paths = async (values: readonly string[] | undefined, requireExisting: boolean): Promise => { + if (values !== undefined && (!Array.isArray(values) || values.length > 128)) throw new Error("Provider runtime paths are invalid"); + return await Promise.all((values ?? []).map(async (value) => { + if (typeof value !== "string" || !isAbsolute(value) || value === dirname(value) || /[\0\r\n]/.test(value)) throw new Error("Provider runtime path is invalid"); + const normalized = resolve(value); + return requireExisting ? await realpath(normalized) : normalized; + })); + }; + const systemInstructions = policy?.systemInstructions ?? ""; + if (typeof systemInstructions !== "string" || systemInstructions.includes("\0") || Buffer.byteLength(systemInstructions) > 32 * 1024) throw new Error("Provider runtime instructions exceed their bounded size"); + return { + readOnly: policy?.readOnly ?? true, + readRoots: await paths(policy?.readRoots, true), + protectedPaths: await paths(policy?.protectedPaths, false), + systemInstructions, + }; +} + async function ensurePrivateDirectory( directory: string, physicalParent: string, @@ -595,6 +669,8 @@ function isPersistableEnvironmentName(name: string): boolean { /^(?:HOME|XDG_CONFIG_HOME|XDG_DATA_HOME|XDG_CACHE_HOME)$/.test(name) || /^(?:PAPERCLIP_ACPX_PROFILE|PAPERCLIP_ACPX_ISOLATED_CONTEXT)$/.test(name) || /^(?:PI_CODING_AGENT_DIR|PI_SKIP_VERSION_CHECK|PI_TELEMETRY)$/.test(name) || + /^(?:CURSOR_CONFIG_DIR|CURSOR_DATA_DIR|AGENT_CLI_CREDENTIAL_STORE|NO_OPEN_BROWSER|NODE_DISABLE_COMPILE_CACHE)$/.test(name) || + /^(?:COPILOT_HOME|COPILOT_CACHE_HOME|COPILOT_AUTO_UPDATE|COPILOT_ALLOW_ALL|COPILOT_DISABLE_TERMINAL_TITLE|NO_COLOR)$/.test(name) || /^(?:CLAUDE_CONFIG_DIR|CODEX_HOME|NO_BROWSER|DEFAULT_AUTH_REQUEST)$/.test( name, )