mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-26 18:31:34 +01:00
fix(runner): stabilize warmed Codex isolation evidence
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
1 parent
373b609ff1
commit
eef193508d
9 files changed
+5733
-6152
No files matched your search
@@ -70,10 +70,19 @@ The trusted app-server process has an allowlisted environment. It retains host
|
||||
`HOME` and `CODEX_HOME` only so the provider can authenticate. Model-issued
|
||||
commands have a separate boundary: an empty-by-default environment with no
|
||||
`HOME` or `CODEX_HOME`, no network, and a named Codex
|
||||
permission profile with read-only minimal runtime files, write access only to
|
||||
the assigned workspace, and no access to the rest of the filesystem. The
|
||||
driver refuses filesystem-root workspaces, workspaces containing host `HOME`,
|
||||
and any workspace overlapping host `CODEX_HOME`.
|
||||
permission profile requesting read-only minimal runtime files, no host-home or
|
||||
Codex-home access, and write access to the assigned workspace. The driver
|
||||
refuses filesystem-root workspaces, workspaces containing host `HOME`, and any
|
||||
workspace overlapping host `CODEX_HOME`.
|
||||
|
||||
The returned sandbox facts remain authoritative. Codex 0.132.0 may inject a
|
||||
provider-managed writable root such as `~/.codex/memories` after a first run,
|
||||
even with `features.memories=false` and an explicit Codex-home deny. That makes
|
||||
the Codex-home directory discoverable in a warmed environment, so Phase 4 does
|
||||
not claim whole-directory unreadability. Its authenticated proof instead
|
||||
requires each readable `auth.json`/`config.toml` file and an unrelated host
|
||||
secret to remain unreadable and unwritable, while recording any injected root
|
||||
in `context.sandbox.legacyPolicy`.
|
||||
|
||||
Paperclip bearer values, `OPENAI_API_KEY`, arbitrary skill paths, and other
|
||||
inherited variables are not passed. Diagnostics redact bearer/basic
|
||||
@@ -143,9 +152,11 @@ tracer requires byte-equivalent live and replay snapshots.
|
||||
|
||||
`trace:phase4` starts a real local `codex app-server` session through the mock
|
||||
core. Its safe task creates `hello.txt` with network disabled. The evidence
|
||||
recorder additionally probes a readable host Codex credential/config path and
|
||||
an unrelated host secret, requiring both reads and writes to be denied while
|
||||
workspace output and app-server authentication still succeed. See the
|
||||
recorder additionally probes all readable host Codex credential/config files
|
||||
and an unrelated host secret, requiring reads and writes to be denied while
|
||||
workspace output and app-server authentication still succeed. It gates output
|
||||
reads on an accepted `done` result and reports missing files by name rather
|
||||
than surfacing a raw filesystem `ENOENT`. See the
|
||||
[Phase 4 tutorial](tutorials/phase-04-skillless-codex.md).
|
||||
|
||||
This phase changes no browser surface, so no new browser screenshot applies.
|
||||
|
||||
@@ -29,6 +29,13 @@ codex --version
|
||||
codex app-server --help
|
||||
```
|
||||
|
||||
Run the real-Codex steps from an ordinary operator shell with a workspace that
|
||||
has no `AGENTS.md` in that directory or any ancestor. A child directory of this
|
||||
repository is not skillless because Codex discovers the repository
|
||||
`AGENTS.md`. In a Paperclip-managed agent run, `PAPERCLIP_WORKSPACE_CWD` also
|
||||
prevents `mktemp -d` from selecting a directory outside the assigned workspace;
|
||||
use a separate clean operator workspace rather than bypassing that guard.
|
||||
|
||||
## Step 1: Run the focused conformance checks
|
||||
|
||||
```sh
|
||||
@@ -92,9 +99,12 @@ Confirm:
|
||||
- `modelInputKinds` contains only `text`;
|
||||
- the semantic tools are `paperclip_finish` and `paperclip_block`;
|
||||
- no environment **value** is present;
|
||||
- the permission profile denies root access, exposes only read-only minimal
|
||||
runtime files, grants write access to the assigned workspace, and disables
|
||||
network access;
|
||||
- the requested permission profile denies root and host-home access, exposes
|
||||
read-only minimal runtime files, grants write access to the assigned
|
||||
workspace, and disables network access;
|
||||
- the returned legacy sandbox facts are inspected separately because Codex may
|
||||
add provider-managed state roots such as `~/.codex/memories` even when memory
|
||||
instructions are disabled;
|
||||
- command `HOME` and `CODEX_HOME` are absent;
|
||||
- the envelope contains only this safe task and its completion criteria.
|
||||
|
||||
@@ -133,11 +143,19 @@ To record the authenticated isolation proof, run:
|
||||
pnpm --filter @paperclipai/paperclip-runner record:phase4
|
||||
```
|
||||
|
||||
The recorder selects an existing readable host Codex credential/config path,
|
||||
creates a separate host secret, and asks the model to prove that neither is
|
||||
readable or writable. The run must still authenticate, create the workspace
|
||||
The recorder selects every existing readable host Codex credential/config file
|
||||
(`auth.json` and/or `config.toml`), creates a separate host secret, and asks the
|
||||
model to prove that those files, the host-home root, and the unrelated secret
|
||||
are neither readable nor writable. It intentionally does not assert that the
|
||||
whole Codex-home directory is unreadable: warmed Codex 0.132.0 environments can
|
||||
inject `~/.codex/memories` as a writable legacy sandbox root. The credential
|
||||
files remain denied. The run must still authenticate, create the workspace
|
||||
files, retain the successful command output, and pass every trace assertion.
|
||||
|
||||
If the tracer returns `blocked`/`needs_review`, or an accepted `done` result is
|
||||
missing an expected file, the recorder exits with a named diagnostic before it
|
||||
attempts to consume absent outputs.
|
||||
|
||||
## Step 5: Try steering
|
||||
|
||||
Use a new directory:
|
||||
|
||||
@@ -45,6 +45,12 @@ pnpm --filter @paperclipai/paperclip-runner verify
|
||||
- All live events pass the existing Phase 1 validator/reducer. Replaying them
|
||||
produces the same snapshot.
|
||||
- The package boundary rejects imports from Paperclip core packages.
|
||||
- The authenticated isolation proof targets each readable host
|
||||
`auth.json`/`config.toml` file plus an unrelated secret, not the whole
|
||||
Codex-home directory. The trace records Codex's injected memories root while
|
||||
proving the credential files remain unreadable and unwritable.
|
||||
- The recorder requires an accepted `done` result before reading outputs and
|
||||
turns an absent `hello.txt` or `isolation-ok.txt` into a named diagnostic.
|
||||
|
||||
# Recorded outputs
|
||||
|
||||
@@ -52,24 +58,28 @@ pnpm --filter @paperclipai/paperclip-runner verify
|
||||
- [Driver reference](../../docs/phase-04-skillless-codex-driver.md)
|
||||
- [Hands-on tutorial](../../docs/tutorials/phase-04-skillless-codex.md)
|
||||
|
||||
The trace normalizes its temporary working directory and writable Codex state
|
||||
root. It contains no credential values. This phase changes no browser surface,
|
||||
so a browser screenshot is not applicable.
|
||||
The trace normalizes its temporary working directory, both readable Codex
|
||||
credential/config paths, and writable Codex state root. It contains no
|
||||
credential values. This phase changes no browser surface, so a browser
|
||||
screenshot is not applicable.
|
||||
|
||||
# Local verification result
|
||||
|
||||
- Codex CLI/app-server: `0.132.0`.
|
||||
- Real model/provider: `gpt-5.5` / `openai`.
|
||||
- Focused recorder regressions: 3 passed, covering a warmed memories directory,
|
||||
non-`done` output gating, and missing-file diagnostics.
|
||||
- Focused driver tests: 7 passed.
|
||||
- Full TypeScript suite: 73 passed.
|
||||
- Full Rust suite: 39 passed (37 unit and 2 process-supervisor tests).
|
||||
- Existing browser suite: 9 passed with the rootless Linux library path.
|
||||
- The real safe task created `hello.txt` with exactly `hello from phase 4`.
|
||||
- The real trace contains 340 canonical events and all eight assertions pass:
|
||||
exactly one terminal result, live/replay parity, stable run/session identity,
|
||||
contiguous source sequence, stable item identity, skillless context,
|
||||
unrelated-skill absence, and credential absence. The recorder separately
|
||||
verifies the exact safe-file content.
|
||||
- The refreshed warmed-environment trace contains 374 canonical events and all
|
||||
nine assertions pass: exactly one terminal result, accepted proposal,
|
||||
live/replay parity, stable run/session identity, contiguous source sequence,
|
||||
stable item identity, skillless context, unrelated-skill absence, and
|
||||
credential absence. The recorder separately verifies the exact safe-file
|
||||
content.
|
||||
- Real steering and interruption commands preserved the session identity and
|
||||
ended with exactly one result and terminal event.
|
||||
- TypeScript, Rust, and browser builds and typechecks passed. Golden, parity,
|
||||
@@ -90,6 +100,15 @@ so a browser screenshot is not applicable.
|
||||
- The normal package verifier reached Playwright but the host lacked
|
||||
`libatk-1.0.so.0`. The supported rootless verifier supplied private browser
|
||||
libraries, then completed the same `verify` path with all nine browser tests.
|
||||
- The original recorder required the whole `~/.codex` directory to be
|
||||
unreadable. Codex 0.132.0 injects `~/.codex/memories` as a legacy writable
|
||||
root after warm-up, making that directory check unsatisfiable while leaving
|
||||
`auth.json` and `config.toml` protected. The proof now checks credential files
|
||||
directly and the refreshed trace records the injected root.
|
||||
- A valid `blocked` result previously fell through to unconditional output
|
||||
reads and surfaced an unhandled `ENOENT`. The recorder now validates the
|
||||
decision/disposition first and guards each expected output with a clear
|
||||
filename-specific diagnostic.
|
||||
|
||||
# Review state
|
||||
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -35,8 +35,11 @@ Paperclip production integration or browser surface.
|
||||
interrupt, usage, structured output, dynamic tools, and reconciliation.
|
||||
9. Normalize every provider notification through the existing PRP validator,
|
||||
reducer, and replay path.
|
||||
10. Keep the real example safe: workspace-write sandbox, no network request,
|
||||
one allowed file, and exact output verification.
|
||||
10. Keep the real example safe: requested workspace-write sandbox, no network
|
||||
request, one allowed file, and exact output verification. Treat returned
|
||||
provider sandbox facts as authoritative when they add managed state roots.
|
||||
11. Prove credential-file and unrelated-secret isolation directly. Do not use
|
||||
whole Codex-home unreadability as a proxy in warmed environments.
|
||||
|
||||
# Evidence
|
||||
|
||||
@@ -61,6 +64,13 @@ and read the [driver reference](../../docs/phase-04-skillless-codex-driver.md).
|
||||
- The first redaction test stopped reading before the diagnostic event. It now
|
||||
consumes the complete deterministic event sequence before asserting that the
|
||||
secret is absent.
|
||||
- The original authenticated proof denied the entire Codex home. Codex 0.132.0
|
||||
materializes and injects `~/.codex/memories` as a writable legacy root, so the
|
||||
parent directory becomes readable after warm-up even though `auth.json` and
|
||||
`config.toml` remain protected. The recorder now probes those files directly.
|
||||
- A non-`done` semantic result could reach unconditional `hello.txt` and
|
||||
`isolation-ok.txt` reads. The recorder now stops on the result diagnostic and
|
||||
reports any missing accepted-result file by name.
|
||||
|
||||
# Known gaps
|
||||
|
||||
@@ -69,6 +79,10 @@ and read the [driver reference](../../docs/phase-04-skillless-codex-driver.md).
|
||||
implementation language.
|
||||
- Real-model choices are nondeterministic. The focused fake-transport suite is
|
||||
the conformance authority; the recorded real trace is a hands-on proof.
|
||||
- The Codex permission profile is a requested policy, not stronger evidence
|
||||
than the returned sandbox. Current Codex versions may expose provider-managed
|
||||
state roots such as `~/.codex/memories`; Phase 4 guarantees credential-file
|
||||
isolation rather than whole Codex-home directory isolation.
|
||||
- The safe task may use a command instead of a file-change item. Deterministic
|
||||
tests cover both file and request mappings.
|
||||
- This package proof does not supply production MCP bindings, approval UI,
|
||||
|
||||
@@ -24,7 +24,7 @@
|
||||
"typecheck:typescript": "pnpm run check:protocol-types && tsc -p tsconfig.json --noEmit",
|
||||
"typecheck:rust": "cargo check --manifest-path runner/Cargo.toml --locked --workspace",
|
||||
"typecheck:browser": "tsc -p tsconfig.browser.json --noEmit",
|
||||
"test": "pnpm run build:phase2-binaries && pnpm run test:typescript && pnpm run test:rust && node --test scripts/check-forbidden-imports.test.mjs",
|
||||
"test": "pnpm run build:phase2-binaries && pnpm run test:typescript && pnpm run test:rust && node --test scripts/check-forbidden-imports.test.mjs scripts/record-phase4-evidence.test.mjs",
|
||||
"test:typescript": "vitest run",
|
||||
"test:rust": "cargo test --manifest-path runner/Cargo.toml --locked --workspace",
|
||||
"test:browser": "pnpm run build:typescript && pnpm run build:phase2-binaries && pnpm run build:browser && playwright test --config devtools/browser/playwright.config.ts",
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
import { constants } from "node:fs";
|
||||
import { access, readFile } from "node:fs/promises";
|
||||
import { join, resolve } from "node:path";
|
||||
|
||||
export function codexCredentialFileCandidates(codexHome) {
|
||||
return [join(codexHome, "auth.json"), join(codexHome, "config.toml")];
|
||||
}
|
||||
|
||||
export async function findReadableCodexCredentialFiles(codexHome) {
|
||||
const readable = [];
|
||||
for (const candidate of codexCredentialFileCandidates(codexHome)) {
|
||||
try {
|
||||
await access(candidate, constants.R_OK);
|
||||
readable.push(resolve(candidate));
|
||||
} catch {
|
||||
// A signed-in Codex installation may use either file. Probe both.
|
||||
}
|
||||
}
|
||||
return readable;
|
||||
}
|
||||
|
||||
export function createPhase4DeniedHostPaths({
|
||||
hostHome,
|
||||
codexCredentialFiles,
|
||||
unrelatedSecretPath,
|
||||
}) {
|
||||
return [...new Set([
|
||||
resolve(hostHome),
|
||||
...codexCredentialFiles.map((path) => resolve(path)),
|
||||
resolve(unrelatedSecretPath),
|
||||
])];
|
||||
}
|
||||
|
||||
export function assertPhase4TraceReadyForOutputs(trace) {
|
||||
const decision = trace?.resultDecision?.status;
|
||||
const disposition = trace?.result?.reportedWorkDisposition;
|
||||
if (decision !== "accepted" || disposition !== "done") {
|
||||
const summary = phase4TraceSummary(trace);
|
||||
throw new Error(
|
||||
"Phase 4 recorder requires an accepted `done` result before reading workspace outputs; " +
|
||||
`received decision=${decision ?? "missing"}, disposition=${disposition ?? "missing"}` +
|
||||
`${summary ? ` (${summary})` : ""}. Expected workspace outputs were not read.`,
|
||||
);
|
||||
}
|
||||
|
||||
const failedAssertions = Object.entries(trace?.assertions ?? {})
|
||||
.filter(([, passed]) => passed !== true)
|
||||
.map(([name]) => name);
|
||||
if (failedAssertions.length > 0) {
|
||||
throw new Error(
|
||||
`Phase 4 tracer reported failed assertions: ${failedAssertions.join(", ")}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function readRequiredPhase4Output(path, displayName) {
|
||||
try {
|
||||
return await readFile(path, "utf8");
|
||||
} catch (error) {
|
||||
if (error && typeof error === "object" && error.code === "ENOENT") {
|
||||
throw new Error(
|
||||
`Phase 4 recorder expected ${displayName} after an accepted \`done\` result, but the file was not created.`,
|
||||
);
|
||||
}
|
||||
const detail = error instanceof Error ? error.message : String(error);
|
||||
throw new Error(`Phase 4 recorder could not read ${displayName}: ${detail}`);
|
||||
}
|
||||
}
|
||||
|
||||
function phase4TraceSummary(trace) {
|
||||
if (typeof trace?.result?.summary === "string" && trace.result.summary.trim().length > 0) {
|
||||
return trace.result.summary.trim();
|
||||
}
|
||||
const issues = trace?.resultDecision?.issues;
|
||||
if (!Array.isArray(issues)) return "";
|
||||
return issues
|
||||
.map((issue) => issue?.message)
|
||||
.filter((message) => typeof message === "string" && message.trim().length > 0)
|
||||
.join("; ");
|
||||
}
|
||||
@@ -1,122 +1,147 @@
|
||||
#!/usr/bin/env node
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { constants } from "node:fs";
|
||||
import { access, mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
||||
import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { dirname, join, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
import {
|
||||
assertPhase4TraceReadyForOutputs,
|
||||
createPhase4DeniedHostPaths,
|
||||
findReadableCodexCredentialFiles,
|
||||
readRequiredPhase4Output,
|
||||
} from "./lib/phase4-evidence.mjs";
|
||||
|
||||
const packageRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..");
|
||||
const scratchRoot =
|
||||
process.env.PAPERCLIP_RUN_SCRATCH_DIR ??
|
||||
process.env.PAPERCLIP_SCRATCH_DIR ??
|
||||
tmpdir();
|
||||
await mkdir(scratchRoot, { recursive: true });
|
||||
const workspace = await mkdtemp(join(scratchRoot, "paperclip-runner-phase4-"));
|
||||
const hostileHostDirectory = await mkdtemp(join(scratchRoot, "paperclip-runner-phase4-host-"));
|
||||
const unrelatedSecretPath = join(hostileHostDirectory, "unrelated-secret.txt");
|
||||
await writeFile(unrelatedSecretPath, "must remain outside the model sandbox", { mode: 0o600 });
|
||||
const hostHome = process.env.HOME;
|
||||
if (!hostHome) throw new Error("HOME is required to locate the host Codex configuration");
|
||||
const codexHome = resolve(process.env.CODEX_HOME ?? join(hostHome, ".codex"));
|
||||
const credentialCandidates = [join(codexHome, "auth.json"), join(codexHome, "config.toml"), codexHome];
|
||||
let codexCredentialPath;
|
||||
for (const candidate of credentialCandidates) {
|
||||
|
||||
async function recordPhase4Evidence() {
|
||||
const scratchRoot =
|
||||
process.env.PAPERCLIP_RUN_SCRATCH_DIR ??
|
||||
process.env.PAPERCLIP_SCRATCH_DIR ??
|
||||
tmpdir();
|
||||
await mkdir(scratchRoot, { recursive: true });
|
||||
const workspace = await mkdtemp(join(scratchRoot, "paperclip-runner-phase4-"));
|
||||
const hostileHostDirectory = await mkdtemp(join(scratchRoot, "paperclip-runner-phase4-host-"));
|
||||
const unrelatedSecretPath = join(hostileHostDirectory, "unrelated-secret.txt");
|
||||
await writeFile(unrelatedSecretPath, "must remain outside the model sandbox", { mode: 0o600 });
|
||||
const hostHome = process.env.HOME;
|
||||
if (!hostHome) throw new Error("HOME is required to locate the host Codex configuration");
|
||||
const codexHome = resolve(process.env.CODEX_HOME ?? join(hostHome, ".codex"));
|
||||
const codexCredentialFiles = await findReadableCodexCredentialFiles(codexHome);
|
||||
if (codexCredentialFiles.length === 0) {
|
||||
throw new Error(
|
||||
"No readable host Codex auth.json or config.toml file was available for the isolation probe",
|
||||
);
|
||||
}
|
||||
const deniedHostPaths = createPhase4DeniedHostPaths({
|
||||
hostHome,
|
||||
codexCredentialFiles,
|
||||
unrelatedSecretPath,
|
||||
});
|
||||
const rawTracePath = join(workspace, "trace.raw.json");
|
||||
const evidencePath = join(
|
||||
packageRoot,
|
||||
"knowledge/evidence/phase-04-codex-trace.json",
|
||||
);
|
||||
|
||||
try {
|
||||
await access(candidate, constants.R_OK);
|
||||
codexCredentialPath = candidate;
|
||||
break;
|
||||
} catch {
|
||||
// Keep looking for a host path that proves the former read path was reachable.
|
||||
const run = spawnSync(
|
||||
process.execPath,
|
||||
[
|
||||
join(packageRoot, "dist/cli/phase4-codex.js"),
|
||||
"--working-directory",
|
||||
workspace,
|
||||
"--output",
|
||||
rawTracePath,
|
||||
...deniedHostPaths.flatMap((path) => ["--deny-read-write-path", path]),
|
||||
],
|
||||
{
|
||||
cwd: packageRoot,
|
||||
env: { ...process.env, PAPERCLIP_WORKSPACE_CWD: workspace },
|
||||
stdio: "inherit",
|
||||
},
|
||||
);
|
||||
if (run.error) {
|
||||
throw new Error(`Phase 4 tracer could not start: ${run.error.message}`);
|
||||
}
|
||||
|
||||
let trace;
|
||||
try {
|
||||
trace = JSON.parse(await readFile(rawTracePath, "utf8"));
|
||||
} catch (error) {
|
||||
const detail = error instanceof Error ? error.message : String(error);
|
||||
throw new Error(
|
||||
`Phase 4 tracer did not produce a readable trace (status ${run.status ?? "unknown"}): ${detail}`,
|
||||
);
|
||||
}
|
||||
assertPhase4TraceReadyForOutputs(trace);
|
||||
if (run.status !== 0) {
|
||||
throw new Error(
|
||||
`Phase 4 tracer exited with status ${run.status ?? "unknown"} after reporting an accepted \`done\` result`,
|
||||
);
|
||||
}
|
||||
const hello = await readRequiredPhase4Output(join(workspace, "hello.txt"), "hello.txt");
|
||||
if (hello !== "hello from phase 4") {
|
||||
throw new Error("Phase 4 safe task produced unexpected hello.txt content");
|
||||
}
|
||||
const isolationProof = await readRequiredPhase4Output(
|
||||
join(workspace, "isolation-ok.txt"),
|
||||
"isolation-ok.txt",
|
||||
);
|
||||
if (isolationProof !== "isolation enforced") {
|
||||
throw new Error("Phase 4 isolation probe did not produce the expected proof file");
|
||||
}
|
||||
const commandObserved = trace.events?.some((event) =>
|
||||
event.eventType === "item.started" &&
|
||||
event.payload?.kind === "commandExecution" &&
|
||||
deniedHostPaths.every((path) => JSON.stringify(event.payload).includes(path))
|
||||
);
|
||||
const proofOutputObserved = trace.events?.some((event) =>
|
||||
(event.eventType === "item.delta" || event.eventType === "item.completed") &&
|
||||
event.payload?.kind === "commandExecution" &&
|
||||
(
|
||||
String(event.payload?.text).includes("isolation enforced") ||
|
||||
String(event.payload?.item?.aggregatedOutput).includes("isolation enforced")
|
||||
)
|
||||
);
|
||||
if (!commandObserved || !proofOutputObserved) {
|
||||
throw new Error("Phase 4 trace did not retain the successful hostile-path isolation command");
|
||||
}
|
||||
|
||||
const replacements = [
|
||||
[workspace, "$PHASE4_WORKSPACE"],
|
||||
...codexCredentialFiles.map((path) => [
|
||||
path,
|
||||
path.endsWith("auth.json") ? "$HOST_CODEX_AUTH_PATH" : "$HOST_CODEX_CONFIG_PATH",
|
||||
]),
|
||||
[codexHome, "$HOST_CODEX_HOME"],
|
||||
[unrelatedSecretPath, "$HOST_UNRELATED_SECRET"],
|
||||
[hostileHostDirectory, "$HOST_SECRET_DIRECTORY"],
|
||||
[resolve(hostHome), "$HOST_HOME"],
|
||||
];
|
||||
const writableRoots = trace.context?.sandbox?.writableRoots;
|
||||
if (Array.isArray(writableRoots)) {
|
||||
writableRoots.forEach((root, index) => {
|
||||
if (typeof root === "string" && root.length > 0) {
|
||||
replacements.push([root, `$CODEX_WRITABLE_ROOT_${index + 1}`]);
|
||||
}
|
||||
});
|
||||
}
|
||||
const normalized = normalizeStrings(trace, replacements);
|
||||
await writeFile(evidencePath, `${JSON.stringify(normalized, null, 2)}\n`, "utf8");
|
||||
process.stdout.write(`Recorded ${evidencePath}\n`);
|
||||
} finally {
|
||||
await rm(workspace, { recursive: true, force: true });
|
||||
await rm(hostileHostDirectory, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
if (!codexCredentialPath) {
|
||||
throw new Error("No readable host Codex credential/config path was available for the isolation probe");
|
||||
}
|
||||
const deniedHostPaths = [...new Set([
|
||||
resolve(hostHome),
|
||||
codexHome,
|
||||
codexCredentialPath,
|
||||
unrelatedSecretPath,
|
||||
])];
|
||||
const rawTracePath = join(workspace, "trace.raw.json");
|
||||
const evidencePath = join(
|
||||
packageRoot,
|
||||
"knowledge/evidence/phase-04-codex-trace.json",
|
||||
);
|
||||
|
||||
try {
|
||||
const run = spawnSync(
|
||||
process.execPath,
|
||||
[
|
||||
join(packageRoot, "dist/cli/phase4-codex.js"),
|
||||
"--working-directory",
|
||||
workspace,
|
||||
"--output",
|
||||
rawTracePath,
|
||||
...deniedHostPaths.flatMap((path) => ["--deny-read-write-path", path]),
|
||||
],
|
||||
{
|
||||
cwd: packageRoot,
|
||||
env: { ...process.env, PAPERCLIP_WORKSPACE_CWD: workspace },
|
||||
stdio: "inherit",
|
||||
},
|
||||
);
|
||||
if (run.status !== 0) {
|
||||
throw new Error(`Phase 4 tracer exited with status ${run.status ?? "unknown"}`);
|
||||
}
|
||||
|
||||
const trace = JSON.parse(await readFile(rawTracePath, "utf8"));
|
||||
if (Object.values(trace.assertions ?? {}).some((passed) => passed !== true)) {
|
||||
throw new Error("Phase 4 tracer reported a failed assertion");
|
||||
}
|
||||
const hello = await readFile(join(workspace, "hello.txt"), "utf8");
|
||||
if (hello !== "hello from phase 4") {
|
||||
throw new Error("Phase 4 safe task produced unexpected hello.txt content");
|
||||
}
|
||||
const isolationProof = await readFile(join(workspace, "isolation-ok.txt"), "utf8");
|
||||
if (isolationProof !== "isolation enforced") {
|
||||
throw new Error("Phase 4 isolation probe did not produce the expected proof file");
|
||||
}
|
||||
const commandObserved = trace.events?.some((event) =>
|
||||
event.eventType === "item.started" &&
|
||||
event.payload?.kind === "commandExecution" &&
|
||||
deniedHostPaths.every((path) => JSON.stringify(event.payload).includes(path))
|
||||
);
|
||||
const proofOutputObserved = trace.events?.some((event) =>
|
||||
(event.eventType === "item.delta" || event.eventType === "item.completed") &&
|
||||
event.payload?.kind === "commandExecution" &&
|
||||
(
|
||||
String(event.payload?.text).includes("isolation enforced") ||
|
||||
String(event.payload?.item?.aggregatedOutput).includes("isolation enforced")
|
||||
)
|
||||
);
|
||||
if (!commandObserved || !proofOutputObserved) {
|
||||
throw new Error("Phase 4 trace did not retain the successful hostile-path isolation command");
|
||||
}
|
||||
|
||||
const replacements = [
|
||||
[workspace, "$PHASE4_WORKSPACE"],
|
||||
[codexCredentialPath, "$HOST_CODEX_CREDENTIAL_PATH"],
|
||||
[codexHome, "$HOST_CODEX_HOME"],
|
||||
[unrelatedSecretPath, "$HOST_UNRELATED_SECRET"],
|
||||
[hostileHostDirectory, "$HOST_SECRET_DIRECTORY"],
|
||||
[resolve(hostHome), "$HOST_HOME"],
|
||||
];
|
||||
const writableRoots = trace.context?.sandbox?.writableRoots;
|
||||
if (Array.isArray(writableRoots)) {
|
||||
writableRoots.forEach((root, index) => {
|
||||
if (typeof root === "string" && root.length > 0) {
|
||||
replacements.push([root, `$CODEX_WRITABLE_ROOT_${index + 1}`]);
|
||||
}
|
||||
});
|
||||
}
|
||||
const normalized = normalizeStrings(trace, replacements);
|
||||
await writeFile(evidencePath, `${JSON.stringify(normalized, null, 2)}\n`, "utf8");
|
||||
process.stdout.write(`Recorded ${evidencePath}\n`);
|
||||
} finally {
|
||||
await rm(workspace, { recursive: true, force: true });
|
||||
await rm(hostileHostDirectory, { recursive: true, force: true });
|
||||
await recordPhase4Evidence();
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
process.stderr.write(`Phase 4 evidence recording failed: ${message}\n`);
|
||||
process.exitCode = 1;
|
||||
}
|
||||
|
||||
function normalizeStrings(value, replacements) {
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import test from "node:test";
|
||||
|
||||
import {
|
||||
assertPhase4TraceReadyForOutputs,
|
||||
createPhase4DeniedHostPaths,
|
||||
findReadableCodexCredentialFiles,
|
||||
readRequiredPhase4Output,
|
||||
} from "./lib/phase4-evidence.mjs";
|
||||
|
||||
test("a warmed Codex home contributes credential files, not the readable Codex directory", async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), "paperclip-phase4-recorder-test-"));
|
||||
try {
|
||||
const hostHome = join(root, "home");
|
||||
const codexHome = join(hostHome, ".codex");
|
||||
const memories = join(codexHome, "memories");
|
||||
const auth = join(codexHome, "auth.json");
|
||||
const config = join(codexHome, "config.toml");
|
||||
const unrelatedSecretPath = join(root, "secret.txt");
|
||||
await mkdir(memories, { recursive: true });
|
||||
await writeFile(auth, "auth");
|
||||
await writeFile(config, "config");
|
||||
await writeFile(unrelatedSecretPath, "secret");
|
||||
|
||||
const credentialFiles = await findReadableCodexCredentialFiles(codexHome);
|
||||
assert.deepEqual(credentialFiles, [auth, config]);
|
||||
|
||||
const deniedPaths = createPhase4DeniedHostPaths({
|
||||
hostHome,
|
||||
codexCredentialFiles: credentialFiles,
|
||||
unrelatedSecretPath,
|
||||
});
|
||||
assert.deepEqual(deniedPaths, [hostHome, auth, config, unrelatedSecretPath]);
|
||||
assert.equal(deniedPaths.includes(codexHome), false);
|
||||
assert.equal(deniedPaths.includes(memories), false);
|
||||
} finally {
|
||||
await rm(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("a non-done trace fails before the recorder reads workspace outputs", () => {
|
||||
assert.throws(
|
||||
() => assertPhase4TraceReadyForOutputs({
|
||||
resultDecision: { status: "accepted", issues: [] },
|
||||
result: {
|
||||
reportedWorkDisposition: "blocked",
|
||||
summary: "The required isolation command failed.",
|
||||
},
|
||||
assertions: { proposalAccepted: true },
|
||||
}),
|
||||
/requires an accepted `done` result.*disposition=blocked.*Expected workspace outputs were not read/,
|
||||
);
|
||||
});
|
||||
|
||||
test("a missing accepted-result output has a named recorder diagnostic", async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), "paperclip-phase4-recorder-test-"));
|
||||
try {
|
||||
await assert.rejects(
|
||||
readRequiredPhase4Output(join(root, "hello.txt"), "hello.txt"),
|
||||
(error) => {
|
||||
assert.equal(
|
||||
error.message,
|
||||
"Phase 4 recorder expected hello.txt after an accepted `done` result, but the file was not created.",
|
||||
);
|
||||
assert.doesNotMatch(error.message, /ENOENT/);
|
||||
return true;
|
||||
},
|
||||
);
|
||||
} finally {
|
||||
await rm(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
Reference in new issue
Block a user