refactor(codex-local): stage an allowlist for sandbox CODEX_HOME sync (#9972)

## Thinking Path

> - Paperclip manages AI agents for work; agents can run in remote
sandboxes via the codex-local adapter
> - When a remote sandbox run starts, the codex-local adapter syncs the
host CODEX_HOME directory into the sandbox so Codex can authenticate and
load its configuration
> - The old approach staged the entire CODEX_HOME and relied on a 4-name
denylist (`tmp`, `.tmp`, `sessions`, `shell_snapshots`) to exclude
unnecessary state
> - The denylist was too narrow: large host-local runtime files
(`logs_2.sqlite`, `plugins/`, etc.) still uploaded on every remote run —
state the sandbox never uses
> - Additionally, `config.toml` (which embeds the managed MCP
`Authorization: Bearer …` header) was staged at 0644, relying solely on
the staging directory's 0700 permission for protection — a
credential-bearing file without per-file least-privilege
> - This pull request replaces the denylist with an explicit allowlist:
only `{auth.json, config.toml, config.json, instructions.md, skills/}`
are staged into a private `0700` temp dir; all staged regular files are
written `0600` (fail-closed on any staging error)
> - The fix is adapter-local to `packages/adapters/codex-local` with
zero changes to the shared `packages/adapter-utils` seam; inbound
auth-merge and outbound copy-back are preserved unchanged

## Linked Issues or Issue Description

No public GitHub issue for this specific bug. Describing the problem
directly:

**Bug:** The codex-local remote sandbox sync uploads more CODEX_HOME
content than the sandbox uses, and stages a credential-bearing file with
an over-permissive mode.

- **What happened:** The codex-local adapter synced the entire
CODEX_HOME directory minus four directory names (`tmp`, `.tmp`,
`sessions`, `shell_snapshots`). Large host-local runtime artifacts — a
SQLite write-ahead log that can reach 420 MB, plugin directories, crash
logs — uploaded on every remote run. `config.toml` (which embeds the
managed MCP `Authorization: Bearer …` header) was staged at `0644`; only
the staging temp dir's `0700` mode prevented broader exposure.
- **Expected behavior:** Only the files Codex needs inside the sandbox
should sync: `auth.json`, `config.toml`, `config.json`,
`instructions.md`, and `skills/`. All staged regular files should use
mode `0600` (single-layer credential protection is insufficient for
secret-bearing files).
- **Steps to reproduce:** Run a remote codex sandbox from a host with a
mature CODEX_HOME. Observe the sync payload includes `sessions/`,
`*.sqlite`, `plugins/`, etc.

Related work: Refs #9621 (outbound auth sync-back in the same
codex-local adapter area; this PR covers the inbound staging direction)

## What Changed

- New `stageCodexHomeForSync(codexHomeDir)` helper in
`packages/adapters/codex-local/src/server/codex-home.ts` stages only
allowlisted files into a fresh `fs.mkdtemp`-created `0700` temp dir,
then registers that dir as the `home` asset (dropping the old `exclude`
field entirely)
- All staged regular files written with mode `0600` — auth.json holds
single-use OAuth tokens; config.toml embeds MCP bearer headers; least
privilege applies throughout
- Symlinks dereferenced to resolved file bytes (`auth.json` single-use
token, each `skills/` entry land as real files)
- Missing-but-optional allowlist entries skipped silently (keyring-only
mode with no `auth.json`; absent `config.json`)
- Staged temp dir removed on teardown AND on error; staging is
fail-closed — a staging error aborts the run rather than shipping a
partial CODEX_HOME
- `execute.ts` updated to call `stageCodexHomeForSync` instead of the
old whole-dir + denylist path; the `exclude` field is removed from the
`home` asset
- Adapter-local fix: zero diff to `packages/adapter-utils` (shared
managed-runtime seam untouched); inbound auth-merge (`provision`) and
outbound copy-back (`restore`) preserved unchanged

## Verification

```sh
# Full codex-local test suite
npx vitest run packages/adapters/codex-local
# 174/174 pass

# Shared seam regression — must be zero diff to packages/adapter-utils
npx vitest run packages/adapter-utils
# 14/14 pass; zero changes to packages/adapter-utils source

# TypeScript (both packages)
npx tsc --noEmit -p packages/adapters/codex-local/tsconfig.json
npx tsc --noEmit -p packages/adapter-utils/tsconfig.json
# Both clean
```

Key new test coverage
(`packages/adapters/codex-local/src/server/codex-home.test.ts`):

- Stager copies exactly the allowlisted entries and nothing else
- Staged `auth.json` dereferenced from symlink and written mode `0600`
- Staged temp dir created with mode `0700`
- ALL staged regular files mode `0600`, including `config.toml` carrying
an MCP bearer token (regression test fails against the pre-fix `0644`
path)
- Fail-closed: I/O error during staging removes the temp dir and
re-throws
- Missing-but-optional entries (`config.json`) skipped without error
- Dangling symlink entries skipped without error

Execute-path integration test (`execute.remote.test.ts`): home asset
uses staged dir; staged config preserves provider block; skills survive;
staged temp dir removed after run teardown.

## Risks

- **Allowlist narrower than denylist:** Any CODEX_HOME file not in
`{auth.json, config.toml, config.json, instructions.md, skills/}` is now
excluded. If a codex-local configuration stores additional files in
CODEX_HOME that the sandbox requires, those runs will miss that state.
This tradeoff is intentional — only known-necessary files sync.
- **Permission change:** `config.toml` previously staged at `0644`, now
staged at `0600`. Strictly more restrictive; no known behavioral impact
(the sandbox reads as the file owner). This was the security gap that
motivated the fix.
- **Fail-closed staging:** A staging error (disk full, temp dir creation
failed) now aborts the run rather than falling back to the raw
CODEX_HOME. This is the safer behavior, but it changes the failure mode
from "run with potentially wrong state" to "run aborted cleanly."

## Model Used

Claude Opus 4.8 (claude-opus-4-8), Anthropic. Reasoning/agentic tool-use
mode (extended thinking, multi-step tool calls).

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [ ] All Paperclip CI gates are green
- [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [ ] I will address all Greptile and reviewer comments before
requesting merge

---------

Co-authored-by: Harold Kim <harold@paperclip.ing>
Co-authored-by: Paperclip <noreply@paperclip.ing>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Nicky Leach 2026-07-21 19:50:21 -07:00 committed by GitHub
parent cac3c0fa1a
commit 7985c1e000
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 781 additions and 22 deletions

View File

@ -3,6 +3,7 @@ import os from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it, vi } from "vitest";
import {
CODEX_SYNC_ALLOWLIST,
codexHomeHasUsableAuth,
ensureSymlink,
evaluateCodexCredentialReadiness,
@ -11,6 +12,7 @@ import {
prepareManagedCodexHome,
reconcileManagedCodexHome,
seedManagedCodexHome,
stageCodexHomeForSync,
writeManagedCodexMcpConfig,
} from "./codex-home.js";
@ -766,3 +768,363 @@ describe("evaluateCodexCredentialReadiness", () => {
}
});
});
describe("stageCodexHomeForSync", () => {
afterEach(() => {
vi.restoreAllMocks();
});
// Builds a fake managed CODEX_HOME containing the full allowlist (with
// `auth.json` as a symlink into a separate source-bytes file and a populated
// `skills/` symlink, mirroring the real managed home) plus decoy runtime
// state the allowlist must NOT copy.
async function buildFakeHome(root: string): Promise<{ home: string; authBytes: string; skillBytes: string }> {
const home = path.join(root, "codex-home");
const authSource = path.join(root, "shared", "auth.json");
const skillSource = path.join(root, "shared", "skill-src.md");
const authBytes = '{"tokens":{"account_id":"acct","refresh_token":"r"}}\n';
const skillBytes = "# injected skill\n";
await fs.mkdir(path.join(root, "shared"), { recursive: true });
await fs.writeFile(authSource, authBytes, "utf8");
await fs.writeFile(skillSource, skillBytes, "utf8");
await fs.mkdir(home, { recursive: true });
// auth.json is a symlink into the shared source (single-use rotating tokens).
await fs.symlink(authSource, path.join(home, "auth.json"));
await fs.writeFile(path.join(home, "config.toml"), "model_provider = \"paperclip\"\n", "utf8");
await fs.writeFile(path.join(home, "config.json"), "{}\n", "utf8");
await fs.writeFile(path.join(home, "instructions.md"), "hi\n", "utf8");
// skills/ is a directory of symlinks.
await fs.mkdir(path.join(home, "skills"), { recursive: true });
await fs.symlink(skillSource, path.join(home, "skills", "demo.md"));
// Decoys: large runtime state the 4-name denylist missed.
await fs.writeFile(path.join(home, "logs_2.sqlite"), "x", "utf8");
await fs.writeFile(path.join(home, "state_5.sqlite"), "x", "utf8");
await fs.mkdir(path.join(home, "plugins", "cache"), { recursive: true });
await fs.writeFile(path.join(home, "plugins", "cache", "x"), "x", "utf8");
await fs.mkdir(path.join(home, "sessions"), { recursive: true });
await fs.writeFile(path.join(home, "sessions", "y"), "x", "utf8");
await fs.mkdir(path.join(home, "tmp"), { recursive: true });
await fs.symlink("/usr/bin/env", path.join(home, "tmp", "arg0"));
return { home, authBytes, skillBytes };
}
it("stages exactly the allowlist, derefs auth.json to bytes, and excludes decoys", async () => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-codex-stage-"));
let staged: string | null = null;
try {
const { home, authBytes, skillBytes } = await buildFakeHome(root);
staged = await stageCodexHomeForSync(home, { runId: "run-1" });
const entries = (await fs.readdir(staged)).sort();
expect(entries).toEqual([...CODEX_SYNC_ALLOWLIST].sort());
// Decoys must be absent.
for (const decoy of ["logs_2.sqlite", "state_5.sqlite", "plugins", "sessions", "tmp"]) {
expect(entries).not.toContain(decoy);
}
// auth.json is a regular file (symlink dereferenced) whose bytes equal the target.
const stagedAuth = path.join(staged, "auth.json");
expect((await fs.lstat(stagedAuth)).isSymbolicLink()).toBe(false);
expect(await fs.readFile(stagedAuth, "utf8")).toBe(authBytes);
// skills/ copied recursively with the symlink dereferenced to bytes.
const stagedSkill = path.join(staged, "skills", "demo.md");
expect((await fs.lstat(stagedSkill)).isSymbolicLink()).toBe(false);
expect(await fs.readFile(stagedSkill, "utf8")).toBe(skillBytes);
// config.toml (post-rewrite state) carried through.
expect(await fs.readFile(path.join(staged, "config.toml"), "utf8")).toContain("model_provider");
} finally {
if (staged) await fs.rm(staged, { recursive: true, force: true });
await fs.rm(root, { recursive: true, force: true });
}
});
// C1 — staged credential file must be mode 0600 (not the world-readable default).
it("writes the staged auth.json with mode 0600", async () => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-codex-stage-mode-"));
let staged: string | null = null;
try {
const { home } = await buildFakeHome(root);
staged = await stageCodexHomeForSync(home, { runId: "run-mode" });
const mode = (await fs.stat(path.join(staged, "auth.json"))).mode & 0o777;
expect(mode).toBe(0o600);
} finally {
if (staged) await fs.rm(staged, { recursive: true, force: true });
await fs.rm(root, { recursive: true, force: true });
}
});
// config.toml carries the managed MCP `Authorization: Bearer …` header and is
// secret-bearing; the staged copy must be 0600, not the world-readable default.
it("writes the staged config.toml (managed MCP bearer header) with mode 0600", async () => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-codex-stage-toml-mode-"));
let staged: string | null = null;
try {
const home = path.join(root, "codex-home");
await fs.mkdir(home, { recursive: true });
// Mirror the source writer: config.toml holds an MCP gateway bearer token
// and is persisted 0600 on disk.
await fs.writeFile(
path.join(home, "config.toml"),
"[mcp_servers.paperclip]\nheaders = { Authorization = \"Bearer secret-token\" }\n",
{ mode: 0o600 },
);
staged = await stageCodexHomeForSync(home, { runId: "run-toml-mode" });
const mode = (await fs.stat(path.join(staged, "config.toml"))).mode & 0o777;
expect(mode).toBe(0o600);
} finally {
if (staged) await fs.rm(staged, { recursive: true, force: true });
await fs.rm(root, { recursive: true, force: true });
}
});
// Least privilege: no staged regular file needs group/other read, so every
// one (config.json, instructions.md — not just credentials) is staged 0600.
it("writes every staged regular file with mode 0600", async () => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-codex-stage-all-mode-"));
let staged: string | null = null;
try {
const { home } = await buildFakeHome(root);
staged = await stageCodexHomeForSync(home, { runId: "run-all-mode" });
for (const entry of ["auth.json", "config.toml", "config.json", "instructions.md"]) {
const mode = (await fs.stat(path.join(staged, entry))).mode & 0o777;
expect(mode, `${entry} should be staged 0600`).toBe(0o600);
}
} finally {
if (staged) await fs.rm(staged, { recursive: true, force: true });
await fs.rm(root, { recursive: true, force: true });
}
});
// C2 — staged dir must be 0700 (mkdtemp guarantees this on POSIX).
it("creates the staged dir with mode 0700", async () => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-codex-stage-dir-"));
let staged: string | null = null;
try {
const { home } = await buildFakeHome(root);
staged = await stageCodexHomeForSync(home, { runId: "run-dir" });
const mode = (await fs.stat(staged)).mode & 0o777;
expect(mode).toBe(0o700);
} finally {
if (staged) await fs.rm(staged, { recursive: true, force: true });
await fs.rm(root, { recursive: true, force: true });
}
});
it("skips absent optional entries without throwing", async () => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-codex-stage-absent-"));
let staged: string | null = null;
try {
// Keyring-credential mode: no auth.json, no config.json.
const home = path.join(root, "codex-home");
await fs.mkdir(home, { recursive: true });
await fs.writeFile(path.join(home, "config.toml"), "x\n", "utf8");
staged = await stageCodexHomeForSync(home, { runId: "run-absent" });
const entries = await fs.readdir(staged);
expect(entries).toEqual(["config.toml"]);
} finally {
if (staged) await fs.rm(staged, { recursive: true, force: true });
await fs.rm(root, { recursive: true, force: true });
}
});
it("treats a dangling auth.json symlink as absent (skips it, no throw)", async () => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-codex-stage-dangling-"));
let staged: string | null = null;
try {
const home = path.join(root, "codex-home");
await fs.mkdir(home, { recursive: true });
await fs.symlink(path.join(root, "gone", "auth.json"), path.join(home, "auth.json"));
await fs.writeFile(path.join(home, "config.toml"), "x\n", "utf8");
staged = await stageCodexHomeForSync(home, { runId: "run-dangling" });
expect(await fs.readdir(staged)).toEqual(["config.toml"]);
} finally {
if (staged) await fs.rm(staged, { recursive: true, force: true });
await fs.rm(root, { recursive: true, force: true });
}
});
// C3 + C4 — an unexpected I/O error must reject (fail-closed, not partial)
// AND remove the temp dir it created (cleanup on the error path).
it("fails closed and removes the temp dir on an unexpected I/O error", async () => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-codex-stage-fail-"));
try {
const { home } = await buildFakeHome(root);
let createdDir: string | null = null;
const realMkdtemp = fs.mkdtemp.bind(fs);
vi.spyOn(fs, "mkdtemp").mockImplementation(async (prefix: string, ...rest: unknown[]) => {
const dir = await (realMkdtemp as typeof fs.mkdtemp)(prefix, ...(rest as []));
createdDir = dir as string;
return dir;
});
vi.spyOn(fs, "readFile").mockRejectedValue(
Object.assign(new Error("boom"), { code: "EACCES" }),
);
await expect(stageCodexHomeForSync(home, { runId: "run-fail" })).rejects.toThrow("boom");
expect(createdDir).not.toBeNull();
// The staged temp dir was cleaned up despite the failure.
await expect(fs.access(createdDir as unknown as string)).rejects.toMatchObject({ code: "ENOENT" });
} finally {
await fs.rm(root, { recursive: true, force: true });
}
});
// Circular symlinks inside skills/ must be silently skipped (not throw ELOOP).
// Skill symlinks that point OUTSIDE skills/ are intentional design (Paperclip
// stores skill packages in a shared location) and are dereferenced normally;
// all resulting files land 0600 inside the 0700 staged dir.
it("skips circular skill symlinks (ELOOP) without throwing", async () => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-codex-stage-circular-"));
let staged: string | null = null;
try {
const home = path.join(root, "codex-home");
await fs.mkdir(path.join(home, "skills"), { recursive: true });
// Self-referential symlink: would loop forever — must be skipped.
const circularLink = path.join(home, "skills", "loop.md");
await fs.symlink(circularLink, circularLink);
// A normal skill file — must still be staged.
await fs.writeFile(path.join(home, "skills", "legit.md"), "# ok\n", "utf8");
staged = await stageCodexHomeForSync(home, { runId: "run-circular" });
const stagedSkillEntries = await fs.readdir(path.join(staged, "skills"));
// Circular link must be absent.
expect(stagedSkillEntries).not.toContain("loop.md");
// Normal skill file must be present.
expect(stagedSkillEntries).toContain("legit.md");
} finally {
if (staged) await fs.rm(staged, { recursive: true, force: true });
await fs.rm(root, { recursive: true, force: true });
}
});
// Mode normalization: nested skill files must be staged 0600 regardless of
// their source mode (0644 documents, 0755 scripts, etc.).
it("writes nested skill files with mode 0600 regardless of source mode", async () => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-codex-stage-skill-mode-"));
let staged: string | null = null;
try {
const home = path.join(root, "codex-home");
await fs.mkdir(path.join(home, "skills", "my-skill"), { recursive: true });
// Typical source modes: readable doc (0644) and executable script (0755);
// both must land 0600 in the staged dir.
await fs.writeFile(path.join(home, "skills", "my-skill", "SKILL.md"), "# skill\n", { mode: 0o644 });
await fs.writeFile(path.join(home, "skills", "my-skill", "run.sh"), "#!/bin/sh\n", { mode: 0o755 });
staged = await stageCodexHomeForSync(home, { runId: "run-skill-mode" });
for (const rel of ["my-skill/SKILL.md", "my-skill/run.sh"]) {
const mode = (await fs.stat(path.join(staged, "skills", rel))).mode & 0o777;
expect(mode, `skills/${rel} should be staged 0600`).toBe(0o600);
}
} finally {
if (staged) await fs.rm(staged, { recursive: true, force: true });
await fs.rm(root, { recursive: true, force: true });
}
});
// Finding 1 (Greptile): a directory symlink such as `back -> .` inside a skill
// resolves to an ancestor directory (it does NOT raise ELOOP), so naive
// recursion would traverse the same tree forever until disk/memory is
// exhausted. Cycle detection must let staging finish while still copying the
// real content and skipping the self-referential link.
it("does not infinitely traverse an ancestor directory link (back -> .) inside a skill", async () => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-codex-stage-cycle-"));
let staged: string | null = null;
try {
const home = path.join(root, "codex-home");
const skillDir = path.join(home, "skills", "my-skill");
await fs.mkdir(skillDir, { recursive: true });
await fs.writeFile(path.join(skillDir, "SKILL.md"), "# skill\n", "utf8");
// Directory symlink pointing at the skill's own dir — resolves to a
// directory already on the active traversal path; must be skipped.
await fs.symlink(".", path.join(skillDir, "back"));
staged = await stageCodexHomeForSync(home, { runId: "run-cycle" });
// Completed without hanging; the real file is staged and the cyclic link
// produced no runaway nested `back/back/…` chain (it is skipped entirely).
expect(await fs.readdir(path.join(staged, "skills", "my-skill"))).toEqual(["SKILL.md"]);
expect(await fs.readFile(path.join(staged, "skills", "my-skill", "SKILL.md"), "utf8")).toBe(
"# skill\n",
);
} finally {
if (staged) await fs.rm(staged, { recursive: true, force: true });
await fs.rm(root, { recursive: true, force: true });
}
});
// Finding 2 (Greptile): a symlink *inside* a skill that points to a host file
// or directory OUTSIDE that skill (e.g. `~/.ssh/id_rsa`) must NOT be
// dereferenced into the staged asset — otherwise a malformed/compromised skill
// could smuggle host secrets past CODEX_SYNC_ALLOWLIST.
it("does not stage a nested skill symlink that escapes the skill root", async () => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-codex-stage-escape-"));
let staged: string | null = null;
try {
const home = path.join(root, "codex-home");
const skillDir = path.join(home, "skills", "my-skill");
await fs.mkdir(skillDir, { recursive: true });
await fs.writeFile(path.join(skillDir, "SKILL.md"), "# skill\n", "utf8");
// A host "secret" file and directory living OUTSIDE the skill dir.
const secretFile = path.join(root, "host-secret.txt");
await fs.writeFile(secretFile, "TOP SECRET\n", "utf8");
const secretDir = path.join(root, "host-secret-dir");
await fs.mkdir(secretDir, { recursive: true });
await fs.writeFile(path.join(secretDir, "creds"), "creds\n", "utf8");
// Nested symlinks inside the skill escaping to those host paths.
await fs.symlink(secretFile, path.join(skillDir, "stolen.txt"));
await fs.symlink(secretDir, path.join(skillDir, "stolen-dir"));
staged = await stageCodexHomeForSync(home, { runId: "run-escape" });
const stagedEntries = await fs.readdir(path.join(staged, "skills", "my-skill"));
// The legit in-skill file is staged…
expect(stagedEntries).toContain("SKILL.md");
// …but neither escaping link is followed into the staged asset.
expect(stagedEntries).not.toContain("stolen.txt");
expect(stagedEntries).not.toContain("stolen-dir");
} finally {
if (staged) await fs.rm(staged, { recursive: true, force: true });
await fs.rm(root, { recursive: true, force: true });
}
});
// Defense-in-depth for finding 1: a degenerate top-level `skills/<x> -> ..`
// link resolves to an ancestor of `skills/` (the home). It must be skipped,
// never adopted as a containment root — otherwise the whole home
// (`sessions/`, `*.sqlite`, …) would be dragged into the staged skills asset.
it("skips a top-level skills entry that resolves to an ancestor of skills/", async () => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-codex-stage-ancestor-"));
let staged: string | null = null;
try {
const home = path.join(root, "codex-home");
await fs.mkdir(path.join(home, "skills"), { recursive: true });
await fs.writeFile(path.join(home, "skills", "legit.md"), "# ok\n", "utf8");
// Runtime state in the home that must never reach the staged asset.
await fs.writeFile(path.join(home, "logs.sqlite"), "x", "utf8");
// `up -> ..` resolves to the home dir (an ancestor of skills/).
await fs.symlink("..", path.join(home, "skills", "up"));
staged = await stageCodexHomeForSync(home, { runId: "run-ancestor" });
const stagedSkillEntries = await fs.readdir(path.join(staged, "skills"));
expect(stagedSkillEntries).toContain("legit.md");
// The ancestor link is skipped, so the home's runtime state is not dragged in.
expect(stagedSkillEntries).not.toContain("up");
} finally {
if (staged) await fs.rm(staged, { recursive: true, force: true });
await fs.rm(root, { recursive: true, force: true });
}
});
});

View File

@ -10,6 +10,22 @@ const SYMLINKED_SHARED_FILES = ["auth.json"] as const;
const MANAGED_MCP_BLOCK_START = "# BEGIN PAPERCLIP MANAGED MCP";
const MANAGED_MCP_BLOCK_END = "# END PAPERCLIP MANAGED MCP";
/**
* The allowlist of managed `CODEX_HOME` entries that the codex-local adapter
* stages into the sandbox `home` asset (see {@link stageCodexHomeForSync}).
* Derived from the seeding constants so it can never drift from what the adapter
* actually writes into the home: the copied static config files, the symlinked
* credential file, and the injected `skills/` directory. Everything else the
* stock upstream `codex` binary writes at runtime (`*.sqlite`, `*-wal`,
* `plugins/`, `cache/`, `sessions/`, `shell_snapshots/`, ) is intentionally
* excluded it is large host-local runtime state the sandbox run never needs.
*/
export const CODEX_SYNC_ALLOWLIST = [
...COPIED_SHARED_FILES,
...SYMLINKED_SHARED_FILES,
"skills",
] as const;
export type ManagedCodexMcpGateway = {
name: string;
endpointPath: string;
@ -321,6 +337,236 @@ export async function writeApiKeyAuthJson(home: string, apiKey: string): Promise
await fs.writeFile(target, JSON.stringify({ OPENAI_API_KEY: apiKey }), { mode: 0o600 });
}
export interface StageCodexHomeForSyncOptions {
/** Run id, used only to make the staged temp-dir name traceable in logs. */
runId?: string;
}
/**
* True when `candidate` is `root` itself or a descendant of it. Both arguments
* must be absolute, already-resolved (symlink-free) paths callers pass
* `fs.realpath` output so `path.relative` is a reliable containment test that
* is not fooled by `..` segments or a trailing-separator prefix collision
* (`/a/skills` vs `/a/skills-evil`).
*/
function isResolvedPathInside(candidate: string, root: string): boolean {
if (candidate === root) return true;
const rel = path.relative(root, candidate);
return rel.length > 0 && !rel.startsWith("..") && !path.isAbsolute(rel);
}
/**
* Recursively copies one skill subtree rooted at its real directory
* `containmentRoot` into `targetDir`, dereferencing symlinks to bytes (so the
* sandbox receives real file content, not host-relative links) and normalizing
* every copied regular file to mode `0600`. Created directories get mode `0700`.
*
* Two containment guards protect the staged upload:
*
* - **Allowlist escape (finding 2).** After dereferencing, a symlink whose real
* target falls *outside* `containmentRoot` is skipped. A malformed or
* compromised skill could otherwise smuggle host files that are not in
* `CODEX_SYNC_ALLOWLIST` (e.g. `~/.ssh/id_rsa`) into the upload by pointing a
* nested link at them. The skill's own top-level link into the shared skill
* store is still honoured it is what establishes `containmentRoot` in
* {@link stageDirectorySecure}; only links that escape *that* root are cut.
* - **Directory cycles (finding 1).** A directory symlink such as `back -> .` or
* `back -> ..` resolves to an ancestor directory instead of raising `ELOOP`;
* recursing into it would traverse the same tree forever until disk/memory is
* exhausted. A resolved directory already on the active traversal path
* (`activePath`) is therefore skipped.
*
* Dangling symlinks (`ENOENT`) and self-referential links that do trip `ELOOP`
* are silently skipped, as are non-file/dir entries (sockets, devices).
*/
async function stageContainedSubtree(
sourceDir: string,
targetDir: string,
containmentRoot: string,
activePath: Set<string>,
): Promise<void> {
await fs.mkdir(targetDir, { recursive: true, mode: 0o700 });
const entries = await fs.readdir(sourceDir, { withFileTypes: true });
for (const entry of entries) {
const entrySource = path.join(sourceDir, entry.name);
const entryTarget = path.join(targetDir, entry.name);
// Resolve the real path; dangling or self-referential (`ELOOP`) links skip.
const resolved = await fs.realpath(entrySource).catch((error) => {
const code = (error as NodeJS.ErrnoException).code;
if (code === "ENOENT" || code === "ELOOP") return null;
throw error;
});
if (!resolved) continue;
// Allowlist containment: never dereference a link that escapes this skill's
// real root (host files outside CODEX_SYNC_ALLOWLIST) into the upload.
if (!isResolvedPathInside(resolved, containmentRoot)) continue;
const entryStat = await fs.stat(resolved).catch((error) => {
if ((error as NodeJS.ErrnoException).code === "ENOENT") return null;
throw error;
});
if (!entryStat) continue;
if (entryStat.isDirectory()) {
// Cycle guard: a directory already open on the active path (reached via a
// `back -> .`-style link) would otherwise recurse forever.
if (activePath.has(resolved)) continue;
activePath.add(resolved);
await stageContainedSubtree(resolved, entryTarget, containmentRoot, activePath);
activePath.delete(resolved);
} else if (entryStat.isFile()) {
const bytes = await fs.readFile(resolved);
await fs.writeFile(entryTarget, bytes, { mode: 0o600 });
await fs.chmod(entryTarget, 0o600);
}
// Other types (sockets, devices) are silently skipped.
}
}
/**
* Recursively copies `sourceDir` (a directory allowlist entry currently only
* `skills/`) into `targetDir`, dereferencing symlinks to bytes and normalizing
* every copied regular file to mode `0600`. Created directories get mode `0700`.
*
* This replaces `fs.cp({ dereference: true })` which preserves source file modes,
* leaving `0644` documents and `0755` scripts group/other-readable in the staged
* asset; here all regular files are normalized to `0600` regardless of source mode.
*
* `sourceDir`'s *direct* children are the Paperclip-injected skill symlinks that
* intentionally point into a shared skill store *outside* `CODEX_HOME/skills/`,
* so each child is allowed to resolve anywhere and when it resolves to a
* directory it becomes the containment root for its own subtree. Everything
* *below* that root is copied via {@link stageContainedSubtree}, which refuses to
* follow a nested symlink out of the skill (finding 2) and detects directory
* cycles (finding 1). A direct child that resolves to `sourceDir` itself or to
* an ancestor of it (a degenerate `-> .` / `-> ..` link at the top level) is
* skipped rather than used as a root, so it can never drag the wider home into
* the staged skills asset. The `0700` staged directory and per-file `0600` mode
* together ensure even externally-sourced skill content is not group/world-readable.
*/
async function stageDirectorySecure(
sourceDir: string,
targetDir: string,
): Promise<void> {
await fs.mkdir(targetDir, { recursive: true, mode: 0o700 });
const realSourceDir = await fs.realpath(sourceDir);
const entries = await fs.readdir(sourceDir, { withFileTypes: true });
for (const entry of entries) {
const entrySource = path.join(sourceDir, entry.name);
const entryTarget = path.join(targetDir, entry.name);
// Resolve the real path; dangling or self-referential (`ELOOP`) links skip.
const resolved = await fs.realpath(entrySource).catch((error) => {
const code = (error as NodeJS.ErrnoException).code;
if (code === "ENOENT" || code === "ELOOP") return null;
throw error;
});
if (!resolved) continue;
// A top-level child that resolves to the skills dir itself or an ancestor
// of it (`back -> .` / `back -> ..`) is degenerate: using it as a root would
// re-stage the whole home under `skills/`. Skip it.
if (isResolvedPathInside(realSourceDir, resolved)) continue;
const entryStat = await fs.stat(resolved).catch((error) => {
if ((error as NodeJS.ErrnoException).code === "ENOENT") return null;
throw error;
});
if (!entryStat) continue;
if (entryStat.isDirectory()) {
// This child skill establishes its own containment root: nested links may
// not escape it, and the root seeds the cycle-detection active path.
await stageContainedSubtree(resolved, entryTarget, resolved, new Set([resolved]));
} else if (entryStat.isFile()) {
const bytes = await fs.readFile(resolved);
await fs.writeFile(entryTarget, bytes, { mode: 0o600 });
await fs.chmod(entryTarget, 0o600);
}
// Other types (sockets, devices) are silently skipped.
}
}
/**
* Copies a single allowlist entry from the managed home into the staged dir,
* dereferencing symlinks to bytes. Missing entries are skipped (keyring mode has
* no `auth.json`; some homes have no `config.json`). Every staged regular file is
* written `0600` (least privilege). Any non-`ENOENT` error propagates to the caller.
*/
async function stageCodexHomeEntry(
sourceHome: string,
stagedHome: string,
entry: string,
): Promise<void> {
const source = path.join(sourceHome, entry);
// `fs.stat` follows symlinks, so a dangling link (e.g. a removed auth source)
// reports ENOENT and is skipped exactly like a genuinely absent file.
const stat = await fs.stat(source).catch((error) => {
if ((error as NodeJS.ErrnoException).code === "ENOENT") return null;
throw error;
});
if (!stat) return;
const target = path.join(stagedHome, entry);
if (stat.isDirectory()) {
// Recursively copy with mode normalization — nested regular files land
// `0600` and dangling/circular symlinks are skipped.
await stageDirectorySecure(source, target);
return;
}
// `fs.readFile` follows the symlink into the shared source and returns the
// resolved bytes (the live single-use auth token), which we write as a plain
// regular file so copy-back and in-sandbox auth read real bytes.
const bytes = await fs.readFile(source);
// Stage every regular file `0600`, not just `auth.json`. The staged dir is a
// 0700 mkdtemp and each file is read back only by the owner (Codex in-sandbox +
// copy-back), so nothing needs group/other read. This is least privilege and,
// critically, keeps secret-bearing entries protected: `config.toml` embeds the
// managed MCP `Authorization = "Bearer …"` header (and the source writer
// persists it 0600), so a per-file credential allowlist would silently
// downgrade it to 0644 in a world-readable tmpdir.
await fs.writeFile(target, bytes, { mode: 0o600 });
// Explicit chmod so the mode is 0600 regardless of the process umask.
await fs.chmod(target, 0o600);
}
/**
* Stages exactly {@link CODEX_SYNC_ALLOWLIST} from `effectiveCodexHome` into a
* fresh private temp dir and returns its path, for registration as the sandbox
* `home` asset. This replaces syncing the whole managed home + a name denylist:
* only the files Codex actually needs are uploaded, so oversized runtime state
* (`sessions/`, `*.sqlite`, `plugins/`, ) never reaches the sandbox.
*
* - **Symlinks are dereferenced to bytes** the single-use `auth.json`
* credential (a symlink into the shared source home) and each `skills/` entry
* land as real files, never dangling links.
* - **Missing-but-optional entries are skipped** no `auth.json` in
* keyring-credential mode, or no `config.json`, is not an error.
* - **`mkdtemp` guarantees the staged dir is `0700`** on POSIX, and every staged
* regular file is written `0600` (least privilege), so staged credentials
* `auth.json` (OAuth token) and `config.toml` (managed MCP bearer header)
* are never group/other-readable.
* - **Fail-closed** any *unexpected* I/O error removes the partial temp dir
* and re-throws, so a run never proceeds with a partial or empty home.
*
* The caller owns removing the returned dir on run teardown.
*/
export async function stageCodexHomeForSync(
effectiveCodexHome: string,
options: StageCodexHomeForSyncOptions = {},
): Promise<string> {
const runIdPart = nonEmpty(options.runId ?? undefined);
const stagedHome = await fs.mkdtemp(
path.join(os.tmpdir(), `paperclip-codex-home-sync-${runIdPart ? `${runIdPart}-` : ""}`),
);
try {
for (const entry of CODEX_SYNC_ALLOWLIST) {
await stageCodexHomeEntry(effectiveCodexHome, stagedHome, entry);
}
return stagedHome;
} catch (error) {
// Fail-closed: never hand back a partial home. Remove the temp dir we
// created before propagating the failure.
await fs.rm(stagedHome, { recursive: true, force: true }).catch(() => {});
throw error;
}
}
/**
* Seeds auth/config into an explicit Paperclip-managed `targetHome`. Symlinks
* `auth.json` from the shared source home (so ChatGPT-subscription credentials

View File

@ -131,9 +131,16 @@ describe("codex sandbox auth precedence warning", () => {
},
});
expect(prepareAdapterExecutionTargetRuntime).toHaveBeenCalledWith(expect.objectContaining({
assets: [expect.objectContaining({ key: "home", localDir: hostCodexHome })],
}));
// The home asset now ships a curated *staged* allowlist dir (not the raw
// host CODEX_HOME) and carries no `exclude` denylist.
const runtimeCall = (prepareAdapterExecutionTargetRuntime.mock.calls[0] as unknown[])?.[0] as {
assets: Array<{ key: string; localDir: string; exclude?: string[] }>;
};
const homeAsset = runtimeCall.assets.find((asset) => asset.key === "home");
expect(homeAsset).toBeDefined();
expect(homeAsset?.localDir).not.toBe(hostCodexHome);
expect(homeAsset?.localDir).toContain("paperclip-codex-home-sync");
expect(homeAsset?.exclude).toBeUndefined();
expect(runAdapterExecutionTargetShellCommand).toHaveBeenCalledWith(
"run-auth-precedence",
expect.objectContaining({ kind: "remote", transport: "sandbox", remoteCwd: "/sandbox/workspace" }),

View File

@ -1,4 +1,4 @@
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
import { mkdir, mkdtemp, readdir, readFile, rm, symlink, writeFile } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it, vi } from "vitest";
@ -165,11 +165,19 @@ describe("codex remote execution", () => {
remoteDir: managedRemoteWorkspace,
}));
expect(syncDirectoryToSsh).toHaveBeenCalledTimes(1);
expect(syncDirectoryToSsh).toHaveBeenCalledWith(expect.objectContaining({
localDir: codexHomeDir,
remoteDir: `${managedRemoteWorkspace}/.paperclip-runtime/codex/home`,
followSymlinks: true,
}));
// The home asset now syncs a curated *staged* allowlist dir, not the raw
// managed CODEX_HOME, and carries no `exclude` denylist.
const homeSyncArgs = (syncDirectoryToSsh.mock.calls[0] as unknown[])?.[0] as {
localDir: string;
remoteDir: string;
followSymlinks?: boolean;
exclude?: string[];
};
expect(homeSyncArgs.localDir).not.toBe(codexHomeDir);
expect(homeSyncArgs.localDir).toContain("paperclip-codex-home-sync");
expect(homeSyncArgs.remoteDir).toBe(`${managedRemoteWorkspace}/.paperclip-runtime/codex/home`);
expect(homeSyncArgs.followSymlinks).toBe(true);
expect(homeSyncArgs.exclude).toBeUndefined();
expect(runChildProcess).toHaveBeenCalledTimes(1);
const call = runChildProcess.mock.calls[0] as unknown as
@ -203,6 +211,124 @@ describe("codex remote execution", () => {
}));
});
it("stages only the allowlist into the home asset: keeps config.toml/skills/auth, drops session+sqlite state, no exclude", async () => {
const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-codex-allowlist-"));
cleanupDirs.push(rootDir);
const workspaceDir = path.join(rootDir, "workspace");
const codexHomeDir = path.join(rootDir, "codex-home");
await mkdir(workspaceDir, { recursive: true });
await mkdir(codexHomeDir, { recursive: true });
// Seed the managed home with the files Codex needs (config.toml carries a
// provider-routing block; skills injected) plus large runtime decoys the
// old 4-name denylist missed.
await writeFile(path.join(codexHomeDir, "auth.json"), '{"tokens":{"account_id":"a","refresh_token":"r"}}', "utf8");
await writeFile(
path.join(codexHomeDir, "config.toml"),
'model_provider = "bifrost"\n\n[model_providers.bifrost]\nname = "bifrost"\n',
"utf8",
);
await writeFile(path.join(codexHomeDir, "instructions.md"), "hi\n", "utf8");
await mkdir(path.join(codexHomeDir, "skills", "demo"), { recursive: true });
await writeFile(path.join(codexHomeDir, "skills", "demo", "SKILL.md"), "# demo\n", "utf8");
// Decoys:
await writeFile(path.join(codexHomeDir, "logs_2.sqlite"), "x", "utf8");
await writeFile(path.join(codexHomeDir, "state_5.sqlite"), "x", "utf8");
await mkdir(path.join(codexHomeDir, "sessions"), { recursive: true });
await writeFile(path.join(codexHomeDir, "sessions", "rollout.jsonl"), "x", "utf8");
await mkdir(path.join(codexHomeDir, "tmp"), { recursive: true });
await symlink("/usr/bin/env", path.join(codexHomeDir, "tmp", "arg0"));
// Snapshot the staged dir contents at sync time — execute() removes the
// staged temp dir on teardown, so we cannot read it after execute returns.
let stagedSnapshot:
| { localDir: string; entries: string[]; skillEntries: string[]; configToml: string; authJson: string }
| null = null;
(syncDirectoryToSsh as unknown as {
mockImplementationOnce: (fn: (args: { localDir: string }) => Promise<void>) => void;
}).mockImplementationOnce(async (args: { localDir: string }) => {
const entries = (await readdir(args.localDir)).sort();
const skillEntries = entries.includes("skills")
? (await readdir(path.join(args.localDir, "skills"))).sort()
: [];
const configToml = entries.includes("config.toml")
? await readFile(path.join(args.localDir, "config.toml"), "utf8")
: "";
const authJson = entries.includes("auth.json")
? await readFile(path.join(args.localDir, "auth.json"), "utf8")
: "";
stagedSnapshot = { localDir: args.localDir, entries, skillEntries, configToml, authJson };
});
await execute({
runId: "run-allowlist",
agent: {
id: "agent-1",
companyId: "company-1",
name: "CodexCoder",
adapterType: "codex_local",
adapterConfig: {},
},
runtime: {
sessionId: null,
sessionParams: null,
sessionDisplayId: null,
taskKey: null,
},
config: {
command: "codex",
env: {
CODEX_HOME: codexHomeDir,
},
},
context: {
paperclipWorkspace: {
cwd: workspaceDir,
source: "project_primary",
},
},
executionTransport: {
remoteExecution: {
host: "127.0.0.1",
port: 2222,
username: "fixture",
remoteWorkspacePath: "/remote/workspace",
remoteCwd: "/remote/workspace",
privateKey: "PRIVATE KEY",
knownHosts: "[127.0.0.1]:2222 ssh-ed25519 AAAA",
strictHostKeyChecking: true,
},
},
onLog: async () => {},
});
expect(stagedSnapshot).not.toBeNull();
const snap = stagedSnapshot as unknown as {
localDir: string;
entries: string[];
skillEntries: string[];
configToml: string;
authJson: string;
};
// Allowlist present; decoys gone.
expect(snap.entries).toEqual(
["auth.json", "config.json", "config.toml", "instructions.md", "skills"]
.filter((e) => e !== "config.json") // no config.json was seeded
.sort(),
);
for (const decoy of ["logs_2.sqlite", "state_5.sqlite", "sessions", "tmp", "plugins"]) {
expect(snap.entries).not.toContain(decoy);
}
// Phase-3 behavioral invariants: provider routing + skills + auth survive staging.
expect(snap.configToml).toContain("[model_providers.bifrost]");
expect(snap.configToml).toContain("model_provider");
expect(snap.skillEntries).toContain("demo");
expect(snap.authJson).toContain("refresh_token");
// The staged temp dir is removed after execute completes (cleanup on teardown).
await expect(readdir(snap.localDir)).rejects.toMatchObject({ code: "ENOENT" });
});
it("does not resume saved Codex sessions for remote SSH execution without a matching remote identity", async () => {
const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-codex-remote-resume-"));
cleanupDirs.push(rootDir);

View File

@ -66,6 +66,7 @@ import {
resolveManagedCodexHomeDir,
resolveSharedCodexHomeDir,
seedManagedCodexHome,
stageCodexHomeForSync,
mergeManagedCodexMcpGateways,
writeManagedCodexMcpConfig,
type ManagedCodexMcpGateway,
@ -565,6 +566,10 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
env: envConfigStrings,
codexHome: configuredCodexHome ? null : effectiveCodexHome,
});
// Curated allowlist dir staged for the remote `home` asset (see below). Held
// here so the outer `finally` can remove it on every exit path (teardown and
// error), never only the happy path.
let stagedCodexHomeDir: string | null = null;
try {
for (const note of preparedRuntimeConfig.notes) {
await onLog("stdout", `[paperclip] ${note}\n`);
@ -616,6 +621,15 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
"stdout",
`[paperclip] Syncing workspace and CODEX_HOME to ${describeAdapterExecutionTarget(executionTarget)}.\n`,
);
// Stage only the files Codex actually needs into a curated temp dir and
// ship THAT as the `home` asset, instead of the whole managed
// CODEX_HOME + a name denylist. Staged AFTER the config.toml rewrites
// (provider merge + MCP block splice above) and skills injection, so the
// staged config.toml/skills reflect their final state. Symlinks (incl.
// the single-use `auth.json`) are dereferenced to bytes. This drops the
// large runtime state (`sessions/`, `*.sqlite`, `plugins/`, …) that the
// 4-name denylist missed and that a sandbox run never needs.
stagedCodexHomeDir = await stageCodexHomeForSync(effectiveCodexHome, { runId });
return await prepareAdapterExecutionTargetRuntime({
runId,
target: executionTarget,
@ -629,7 +643,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
assets: [
{
key: "home",
localDir: effectiveCodexHome,
localDir: stagedCodexHomeDir,
followSymlinks: true,
// Inbound (host→sandbox) auth-merge contribution: stages the two
// merge scripts and runs the merge-extract command so a sandbox
@ -652,18 +666,9 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
hostAuthPath: path.join(resolveSharedCodexHomeDir(process.env), "auth.json"),
log: (line) => onLog("stdout", `${line}\n`),
})),
// Exclude state that the sandbox run never needs so we don't
// tar/upload hundreds of MB on every run:
// - `tmp`/`.tmp`: transient dirs that can hold symlinks to the
// host Codex binary (e.g. `tmp/arg0`); followSymlinks would
// inline those binaries and bloat the archive.
// - `sessions`: prior conversation rollouts (host-local history,
// typically the bulk of CODEX_HOME) — irrelevant to a fresh run.
// - `shell_snapshots`: host shell captures that don't apply to
// the sandbox's (different) shell/OS.
// Auth, config, and skills (the bits Codex actually needs) are
// small and still uploaded.
exclude: ["tmp", ".tmp", "sessions", "shell_snapshots"],
// No `exclude` denylist: `stagedCodexHomeDir` already contains
// ONLY the allowlisted files (auth/config/skills), so there is
// nothing to filter out.
},
],
});
@ -1329,6 +1334,19 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
}
}
} finally {
// Remove the staged CODEX_HOME allowlist temp dir on every exit path
// (teardown AND error), never only the happy path. Cleanup failure is
// logged, not fatal — a leaked temp dir must not crash the run.
if (stagedCodexHomeDir) {
await fs.rm(stagedCodexHomeDir, { recursive: true, force: true }).catch(async (error) => {
await onLog(
"stderr",
`[paperclip] Failed to remove staged Codex home "${stagedCodexHomeDir}": ${
error instanceof Error ? error.message : String(error)
}\n`,
);
});
}
// Restore the managed config.toml so PAPERCLIP_CODEX_PROVIDERS changes
// (or removal) between runs never leave stale provider routing behind. This
// finally starts the moment prepareCodexRuntimeConfig returns, so a throw