diff --git a/packages/paperclip-runner/src/drivers/codex/codex-security-config.test.ts b/packages/paperclip-runner/src/drivers/codex/codex-security-config.test.ts new file mode 100644 index 0000000000..3984abe32a --- /dev/null +++ b/packages/paperclip-runner/src/drivers/codex/codex-security-config.test.ts @@ -0,0 +1,56 @@ +import { describe, expect, it } from "vitest"; + +import { + createIsolatedCodexAppServerArgs, + createSecuredCodexThreadParams, + createSkilllessCodexThreadConfig, +} from "./codex-security-config.js"; + +describe("Codex security configuration", () => { + it("disables host extensions and makes collaboration instructions explicit", () => { + expect(createSkilllessCodexThreadConfig("/workspace", {}, false)).toEqual({ + "skills.include_instructions": false, + include_apps_instructions: false, + include_collaboration_mode_instructions: false, + "features.apps": false, + "features.plugins": false, + "features.multi_agent": false, + "features.memories": false, + "features.image_generation": false, + }); + }); + + it("denies host roots, network access, and unlisted environment variables", () => { + const args = createIsolatedCodexAppServerArgs({ + HOME: "/host/home", + CODEX_HOME: "/host/codex", + PATH: "/safe/bin", + LANG: "C.UTF-8", + OPENAI_API_KEY: "must-not-cross", + }, ["/runner/context"]); + const serialized = args.join("\n"); + + expect(serialized).toContain('"/host/home"="none"'); + expect(serialized).toContain('"/host/codex"="none"'); + expect(serialized).toContain('"/runner/context"="read"'); + expect(serialized).toContain('":workspace_roots"={"."="write"}'); + expect(serialized).toContain('":workspace_roots"={"."="read"}'); + expect(serialized).toContain("network.enabled=false"); + expect(serialized).toContain('PATH="/safe/bin"'); + expect(serialized).toContain('LANG="C.UTF-8"'); + expect(serialized).not.toContain("OPENAI_API_KEY"); + expect(serialized).not.toContain("must-not-cross"); + }); + + it("uses a read-only permission profile for plan mode", () => { + expect(createSecuredCodexThreadParams("/workspace", "plan")).toMatchObject({ + cwd: "/workspace", + permissions: "paperclip-runner-workspace-read-only", + runtimeWorkspaceRoots: ["/workspace"], + config: { + "skills.include_instructions": false, + include_collaboration_mode_instructions: true, + }, + }); + }); +}); diff --git a/packages/paperclip-runner/src/drivers/codex/codex-security-config.ts b/packages/paperclip-runner/src/drivers/codex/codex-security-config.ts new file mode 100644 index 0000000000..0b80b835cb --- /dev/null +++ b/packages/paperclip-runner/src/drivers/codex/codex-security-config.ts @@ -0,0 +1,139 @@ +import { resolve } from "node:path"; + +const SKILLLESS_PERMISSION_PROFILE = "paperclip-runner-workspace-only"; +const PLANNING_PERMISSION_PROFILE = "paperclip-runner-workspace-read-only"; + +const SKILLLESS_BASE_CONFIG = { + "skills.include_instructions": false, + include_apps_instructions: false, + include_collaboration_mode_instructions: true, + "features.apps": false, + "features.plugins": false, + "features.multi_agent": false, + "features.memories": false, + "features.image_generation": false, +} as const; + +function commandEnvironment( + source: NodeJS.ProcessEnv = process.env, +): Record { + const environment: Record = {}; + for (const key of [ + "PATH", + "PATHEXT", + "SystemRoot", + "WINDIR", + "LANG", + "LC_ALL", + ] as const) { + const value = source[key]; + if (value !== undefined) environment[key] = value; + } + return environment; +} + +export function createSkilllessCodexThreadConfig( + _workingDirectory: string, + _source: NodeJS.ProcessEnv = process.env, + includeCollaborationModeInstructions = true, +): Record { + return { + ...SKILLLESS_BASE_CONFIG, + include_collaboration_mode_instructions: + includeCollaborationModeInstructions, + }; +} + +function collaborationThreadConfig( + includeCollaborationModeInstructions = true, + includeSkillInstructions = false, +) { + return { + ...SKILLLESS_BASE_CONFIG, + "skills.include_instructions": includeSkillInstructions, + include_collaboration_mode_instructions: + includeCollaborationModeInstructions, + }; +} + +function tomlString(value: string): string { + return JSON.stringify(value); +} + +export function createIsolatedCodexAppServerArgs( + source: NodeJS.ProcessEnv = process.env, + readOnlyRoots: string[] = [], +): string[] { + const deniedHostRoots = [ + ...new Set( + [source.HOME, source.CODEX_HOME] + .filter( + (value): value is string => + typeof value === "string" && value.trim().length > 0, + ) + .map((value) => resolve(value)), + ), + ]; + const filesystemRules = [ + `":root"="none"`, + `":minimal"="read"`, + `":tmpdir"="none"`, + ...deniedHostRoots.map((path) => `${tomlString(path)}="none"`), + ...readOnlyRoots.map((path) => `${tomlString(resolve(path))}="read"`), + `":workspace_roots"={"."="write"}`, + ].join(","); + const planningFilesystemRules = [ + `":root"="none"`, + `":minimal"="read"`, + `":tmpdir"="none"`, + ...deniedHostRoots.map((path) => `${tomlString(path)}="none"`), + ...readOnlyRoots.map((path) => `${tomlString(resolve(path))}="read"`), + `":workspace_roots"={"."="read"}`, + ].join(","); + const commandEnv = Object.entries(commandEnvironment(source)) + .map(([key, value]) => `${key}=${tomlString(value)}`) + .join(","); + return [ + "-c", + `default_permissions=${tomlString(SKILLLESS_PERMISSION_PROFILE)}`, + "-c", + `permissions.${SKILLLESS_PERMISSION_PROFILE}.filesystem={${filesystemRules}}`, + "-c", + `permissions.${SKILLLESS_PERMISSION_PROFILE}.network.enabled=false`, + "-c", + `permissions.${PLANNING_PERMISSION_PROFILE}.filesystem={${planningFilesystemRules}}`, + "-c", + `permissions.${PLANNING_PERMISSION_PROFILE}.network.enabled=false`, + "-c", + `shell_environment_policy.inherit="none"`, + "-c", + "shell_environment_policy.ignore_default_excludes=false", + ...(commandEnv.length > 0 + ? ["-c", `shell_environment_policy.set={${commandEnv}}`] + : []), + "--disable", + "image_generation", + "app-server", + ]; +} + +export function createSecuredCodexThreadParams( + workingDirectory: string, + mode: "default" | "plan" = "default", + includeCollaborationModeInstructions = true, + includeSkillInstructions = false, +): Record { + const permissionProfile = + mode === "plan" + ? PLANNING_PERMISSION_PROFILE + : SKILLLESS_PERMISSION_PROFILE; + return { + cwd: workingDirectory, + config: collaborationThreadConfig( + includeCollaborationModeInstructions, + includeSkillInstructions, + ), + permissions: permissionProfile, + runtimeWorkspaceRoots: [workingDirectory], + }; +}