mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-07 16:35:48 +01:00
Isolate candidate ACP runtime credentials and provider homes
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
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,
|
||||
)
|
||||
|
||||
Reference in new issue
Block a user