Isolate candidate ACP runtime credentials and provider homes

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-09-28 11:18:52 -05:00
1 parent 384ea84546
commit ae15ff4c3f
4 files changed
+164 -11

No files matched your search

@@ -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"),
@@ -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");
@@ -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,
@@ -51,6 +51,8 @@ export interface AcpxRuntimeSandbox {
workspaceRecordPath: string;
launchEnvironment: Readonly<NodeJS.ProcessEnv>;
persistedEnvironment: Readonly<NodeJS.ProcessEnv>;
/** 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<Record<string, unknown>>[];
providerPolicy?: AcpxProviderRuntimePolicy;
}): Promise<AcpxRuntimeSandbox> {
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<string[]> => {
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,
)