diff --git a/.factory/cp06-sdk-integrity.json b/.factory/cp06-sdk-integrity.json new file mode 100644 index 0000000..473fd4f --- /dev/null +++ b/.factory/cp06-sdk-integrity.json @@ -0,0 +1,18 @@ +{ + "schema_version": 1, + "source": "npm registry exact-version tarballs; verify each tarball SHA-512 SRI before extracting and recomputing the sorted [relative path, file SHA-256] manifest digest", + "packages": { + "@earendil-works/pi-coding-agent": { + "version": "0.85.1", + "tarball_integrity": "sha512-FGRN+OHbWaefBPGaTggAdLjrIHW+s2PzLyglz/5dfLzb9of7uuXMXYC0fJIeZTw+shS32o2cuQ9jF7YSDuL/oQ==", + "file_count": 1056, + "manifest_sha256": "ff28a78bb025ee9fc182929f8eec27908f5e20870a5b7d8b1b37a1ded8507ecf" + }, + "@earendil-works/pi-ai": { + "version": "0.85.1", + "tarball_integrity": "sha512-+VgVIJDkDO2efYJKEEqvPTH4zmnIaXdAppGbO+vKFA9qy5PdhFiAenuFAkU+oiCSfOC4dMHDyrjdQeL4ZoC5CQ==", + "file_count": 750, + "manifest_sha256": "3e1894e3f18b90f39c068cd0815f8559083c382e7d343efd1f961a8f07b0cefc" + } + } +} diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 239a63e..49c6933 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -18,4 +18,11 @@ jobs: - run: npm run format:check - run: npm run lint - run: npm run typecheck + # Ubuntu 24.04 runners restrict unprivileged user namespaces through AppArmor. + # CP-06 isolation tests need real disposable DUMMY namespaces and have no fallback. + - name: Allow unprivileged user namespaces + run: | + if [ -e /proc/sys/kernel/apparmor_restrict_unprivileged_userns ]; then + sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0 + fi - run: npm test diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index bdb101c..b9ee8ef 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -43,7 +43,7 @@ Recommended exit codes: `0` success; `2` validation failure; `3` unavailable/sta ## CTX context pack -`factory context TASK-ID` first includes the exact tracked task contract and a concise global project constraint section. It invokes `ctx status --root --json` and `ctx doctor --offline --root --json` as appropriate, then `ctx pack --root --token-budget --json`. The query is derived from title, outcome, acceptance, and topic strings; it is never a command. Canonical required documents are read from their original files, checked against root containment, and admitted before optional CTX excerpts. The adapter records CTX generation, file and line provenance, and available hashes; it does not treat an excerpt as a permission to ignore original wording. On stale or insufficient retrieval, it returns `CONTEXT_BLOCKED` with an actionable diagnostic. It does not fill the pack with guessed material. +`factory context TASK-ID` first includes the exact tracked task contract and a concise global project constraint section. It invokes `ctx status --root --json` and `ctx doctor --offline --root --json` as appropriate, then `ctx pack --root --token-budget --document ... --json`. The query is derived from title, outcome, acceptance, and topic strings; it is never a command. Repeated `--document` filters implement the authority-selection contract in [docs/CONTRACTS.md](docs/CONTRACTS.md#task-factorytaskssave-001yaml). Factory verifies returned provenance against the selected canonical source identities and blocks with `RETRIEVAL_SCOPE_WIDENED` if CTX returns an out-of-scope original; it does not silently discard that excerpt and report success. Canonical required documents are read from their original files, checked against root containment, and admitted before CTX excerpts; declaring a source required never substitutes an excerpt for the exact original. The adapter records CTX generation, file and line provenance, and available hashes. On stale, missing, or insufficient retrieval, it returns `CONTEXT_BLOCKED` with an actionable diagnostic. It does not fill the pack with guessed material or hide post-retrieval excerpts to improve a relevance score. Default total task-context budgets (including contract and mandatory material) are 4,000 tokens for bounded, 8,000 for normal, and 15,000 for critical; these are design defaults to measure, not promises that CTX necessarily returns that many tokens. Reserve space for task contract and required sources before calling CTX with the remainder. The adapter enforces a byte ceiling as well as CTX's token budget, deduplicates overlapping excerpts, and never truncates an authoritative requirement mid-sentence. The pack is written atomically to `.factory/state/context/TASK-ID.md` with a sidecar receipt containing contract digest, source digests, CTX generation, timestamp, and retrieval mode. A worker verifies these before trusting the pack; if changed, regenerate it. @@ -65,15 +65,15 @@ The tracked `.factory/completion.yaml` is authored before task implementation. I ## Failures and trust boundaries -| Failure | Required response | -| --- | --- | -| CTX unavailable or index stale | Do not make up context; exact required files still available for manual work; context command exits blocked. | -| Firstmate home absent or not registered | Publication blocked; preview shows required setup. | -| tasks-axi CLI/version/backend incompatible | Publication blocked; no direct backlog file edit fallback. | -| Contract changed during sync | Abort before writes where possible; report partial state if a write already occurred. | -| Duplicate ID/body edited by another actor | Conflict, never clobber. | -| Source path outside repo or symlink escape | Reject before reading or publishing. | -| Project checks pass on obsolete commit | Mark stale; rerun against release candidate. | -| Worker reports pass without evidence | Show unverified, not complete. | +| Failure | Required response | +| ------------------------------------------ | ------------------------------------------------------------------------------------------------------------ | +| CTX unavailable or index stale | Do not make up context; exact required files still available for manual work; context command exits blocked. | +| Firstmate home absent or not registered | Publication blocked; preview shows required setup. | +| tasks-axi CLI/version/backend incompatible | Publication blocked; no direct backlog file edit fallback. | +| Contract changed during sync | Abort before writes where possible; report partial state if a write already occurred. | +| Duplicate ID/body edited by another actor | Conflict, never clobber. | +| Source path outside repo or symlink escape | Reject before reading or publishing. | +| Project checks pass on obsolete commit | Mark stale; rerun against release candidate. | +| Worker reports pass without evidence | Show unverified, not complete. | External repository files, CTX excerpts, and task bodies are untrusted as shell commands. The CLI never executes test commands solely because a contract specifies one; allowed project checks are defined by a reviewed, tracked verification configuration and executed only by an explicit operator/worker command. Do not store secrets or raw agent transcripts in a context pack or receipt. diff --git a/BUILD_PLAN.md b/BUILD_PLAN.md index f9a413f..44d9035 100644 --- a/BUILD_PLAN.md +++ b/BUILD_PLAN.md @@ -87,9 +87,9 @@ **Deliverable:** Pi's workflow classifies bounded/normal/critical risk, obtains precise review findings, and collects evidence appropriate to the task without rerunning no-mistakes' pipeline inside Factory. -- [ ] Create tests/fixtures for critical auth isolation and normal API behavior, plus one simulated reviewer finding that causes repair and retest. -- [ ] Run an actual worker task with only task/pack/evidence sources in a fresh context; inspect whether it needs missing docs. -- [ ] **Gate:** P-06, P-07, and no change to Firstmate routing or shipping config by Factory. +- [x] Create tests/fixtures for critical auth isolation and normal API behavior, plus one simulated reviewer finding that causes repair and retest. +- [x] Run an actual worker task with only task/pack/evidence sources in a fresh context; inspect whether it needs missing docs. +- [ ] **Gate:** P-06 and P-07 via the tracked replays in [docs/probes/CP-06.md](docs/probes/CP-06.md), which owns proof interpretation and residual limits; exact-final-head proofs and independent review remain required. No change to Firstmate routing or shipping config by Factory. ## CP-07 — Thin Pi command and status convenience diff --git a/README.md b/README.md index 453002a..36de660 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # Factory: build package -**Status:** implementation specification, 26 September 2026. No Factory software is claimed to exist yet. +**Status:** development CLI with CP-06 corrections in progress. Product completion requires the independent gates in [COMPLETION.md](COMPLETION.md); checkpoint state is owned by [BUILD_PLAN.md](BUILD_PLAN.md). Factory is a thin layer for turning a software product brief into independently verifiable work for an existing Pi + Firstmate setup. Its purpose is to minimize the intelligence, context, and human attention required to transform intent into verified software. @@ -14,11 +14,21 @@ Factory is a thin layer for turning a software product brief into independently Read [docs/CONTRACTS.md](docs/CONTRACTS.md) when implementing validation, [docs/INTEGRATIONS.md](docs/INTEGRATIONS.md) when touching CTX/Pi/Firstmate, [docs/WORKFLOW.md](docs/WORKFLOW.md) when designing the Factory skill, and [docs/RESEARCH.md](docs/RESEARCH.md) for sources and decisions. [docs/BOOTSTRAP_PROMPTS.md](docs/BOOTSTRAP_PROMPTS.md) contains copyable Pi instructions for the initial checkpoints. -## Repository bootstrap +## Common usage -Create an empty Git repository for Factory and copy this directory's contents to its root. Do not copy an archived CTX repository or a Firstmate home into it. Review the files, resolve only genuine product decisions, then use your existing `/CTX startup` workflow to index the authoritative Markdown. Begin CP-00 from [BUILD_PLAN.md](BUILD_PLAN.md). The current workspace contains documents only; commands described here are target behavior, not commands that already run. +From this source checkout: -The baseline interaction is your current research → project truth → checkpoint → worker → independent review process. Once validation and task publication work, use Factory's own backlog to build later checkpoints. Do not assert that dogfooding happened until a real Firstmate worker receives, executes, and closes a Factory task. +```sh +npm ci +npm run build +node dist/src/cli.js --help +node dist/src/cli.js validate --root --json +node dist/src/cli.js context --root --json +``` + +Prepare reviewed contracts and a deliberate offline CTX authority set first; see [docs/CONTRACTS.md](docs/CONTRACTS.md) and the [Factory skill](skills/factory/SKILL.md) for the workflow and named-home publication safeguards. Do not copy an archived CTX repository or a Firstmate home into a target project. Factory does not dispatch workers or choose delivery policy. + +CP-06's development proof commands, exact SDK requirements, evidence interpretation, and platform limits are owned by [docs/probes/CP-06.md](docs/probes/CP-06.md). A DUMMY proof or closed backlog is not product completion. ## Document authority diff --git a/docs/CONTRACTS.md b/docs/CONTRACTS.md index aa452d3..840f463 100644 --- a/docs/CONTRACTS.md +++ b/docs/CONTRACTS.md @@ -80,7 +80,7 @@ evidence: delivery: project-default ``` -`depends_on` names local task IDs, is acyclic, and cannot name self. `advances` names completion IDs and may be empty only for a task explicitly marked `kind: enabling`; enabling tasks still have testable outcomes. `complexity` is `bounded | normal | critical`; `risk` is `bounded | normal | critical` and controls review policy, not a specific model name. `delivery` defaults to `project-default` and cannot override the actual Firstmate project mode; if the project mode conflicts with a task request, validation blocks publication. All required evidence categories are verified by evidence receipts rather than assumed from task closure. +`depends_on` names local task IDs, is acyclic, and cannot name self. `advances` names completion IDs and may be empty only for a task explicitly marked `kind: enabling`; enabling tasks still have testable outcomes. `complexity` is `bounded | normal | critical`; `risk` is `bounded | normal | critical` and controls review policy, not a specific model name. `context.required` is the reviewed task-local authority selection: Factory reads every listed original exactly and passes the same paths as CTX document filters. An empty list explicitly means that no task-specific original is needed; Factory still filters CTX to the project's mandatory `documents.required` authority and never treats the empty selection as unrestricted corpus access. Include each known source that can decide an acceptance or risk condition; do not add broad directories or unrelated documents as insurance. The omitted-source correction procedure is owned by [WORKFLOW.md](WORKFLOW.md#task-local-worker-brief). `delivery` defaults to `project-default` and cannot override the actual Firstmate project mode; if the project mode conflicts with a task request, validation blocks publication. All required evidence categories are verified by evidence receipts rather than assumed from task closure. ## Task receipt: `.factory/state/evidence/SAVE-001.json` @@ -100,7 +100,7 @@ delivery: project-default "artifact_sha256": "<64 hexadecimal characters>" } ], - "review": {"status": "pending", "artifact": null}, + "review": { "status": "pending", "artifact": null }, "recorded_at": "2026-09-26T00:00:00Z" } ``` diff --git a/docs/INTEGRATIONS.md b/docs/INTEGRATIONS.md index 28c77ad..acbeee0 100644 --- a/docs/INTEGRATIONS.md +++ b/docs/INTEGRATIONS.md @@ -8,7 +8,7 @@ The user's archived CTX README describes a Python 3.12+ local CLI, source-proven CP-00 probes: detect `ctx`; create a disposable Git fixture with two normative Markdown docs; initialize and add docs according to local `ctx --help`; index with `--no-embeddings` so the test does not download anything; run status, offline doctor, pack, and exact lines; record actual JSON fields and failure codes. Confirm stale-source detection by changing a source after indexing. A user's globally installed model may then be tested separately. Do not index the Factory repo until its initial documents are approved. -Context policy: required files are read exactly; CTX provides relevant additional passages with source provenance; changed docs invalidate a pack; unavailable local embedding may allow the explicitly reported lexical mode if it retrieves sufficient authority. Query text is data, never shell code. `ctx pack` is one context source, not a substitute for Git, `rg`, or project checks. +The installed `ctx pack` accepts repeated `--document` filters. Factory's selection contract is owned by [CONTRACTS.md](CONTRACTS.md#task-factorytaskssave-001yaml); provenance verification and blocking behavior are owned by [ARCHITECTURE.md](../ARCHITECTURE.md#ctx-context-pack). Query text is data, never shell code. `ctx pack` is one context source, not a substitute for Git, `rg`, or project checks. ## Pi @@ -30,14 +30,14 @@ no-mistakes documents a review → test → document → lint → push → PR ## Probe matrix -| Probe | Failure response | -| --- | --- | -| CTX actual version/JSON/CLI flags | Block context pack and show install-specific diagnostic. | -| Pi installed import namespace and skill discovery | Keep CLI usable; defer extension/skill packaging. | -| Firstmate home identity and registered project | Block sync. | -| tasks-axi version, backend, dependency commands | Block sync; provide dry-run export for manual inspection only. | -| no-mistakes gate in intended project mode | Block automated shipping claims; continue local deterministic checks. | -| Windows/macOS path behavior | Report platform limitation honestly; do not claim cross-platform gate. | +| Probe | Failure response | +| ------------------------------------------------- | ---------------------------------------------------------------------- | +| CTX actual version/JSON/CLI flags | Block context pack and show install-specific diagnostic. | +| Pi installed import namespace and skill discovery | Keep CLI usable; defer extension/skill packaging. | +| Firstmate home identity and registered project | Block sync. | +| tasks-axi version, backend, dependency commands | Block sync; provide dry-run export for manual inspection only. | +| no-mistakes gate in intended project mode | Block automated shipping claims; continue local deterministic checks. | +| Windows/macOS path behavior | Report platform limitation honestly; do not claim cross-platform gate. | ## Primary documents diff --git a/docs/WORKFLOW.md b/docs/WORKFLOW.md index cc546af..322b3c0 100644 --- a/docs/WORKFLOW.md +++ b/docs/WORKFLOW.md @@ -9,7 +9,7 @@ This document specifies the methodology. CP-04 turns it into a concise, discover 3. **Project truth:** author a concise `PROJECT.md` with goal, audience, user journeys, non-goals, constraints, and V1 limits. Draft `ARCHITECTURE.md` with component boundaries, data model, trust boundary, deployment shape, and failure handling. Keep technical choices proportionate to the first release. 4. **Independent oracle:** in a separate reasoning pass, author `.factory/completion.yaml` from product intent and risks before decomposing into tasks. Specify observable end-to-end behavior, isolation/security invariants, recovery, and any performance thresholds the project actually needs. Include one negative test for important trust boundaries. Record the current Git SHA of this first oracle so later changes are visible. 5. **Slices and DAG:** choose the first vertical slice that a user can exercise; add a minimal foundation only where it unlocks slices. Tasks should have one independently reviewable outcome, bounded scope, testable acceptance IDs, expected evidence, and dependencies. Prefer a serial backbone of slices with at most three genuinely independent ready tasks. Do not turn one feature into separate backend/frontend/UI megatasks that integrate only at the end. -6. **Validate and retrieve:** use `factory validate`; repair schema/graph gaps; generate a context pack for each task via CTX. Inspect the first few packs for missing mandatory source and irrelevant bulk. Use exact source reads on decisive constraints. +6. **Validate and retrieve:** use `factory validate`; repair schema/graph gaps; select authority according to [CONTRACTS.md](CONTRACTS.md#task-factorytaskssave-001yaml), then generate a context pack via CTX. Inspect the first few packs for a missing decisive source and irrelevant bulk. Use the targeted correction procedure below rather than widening the corpus or hiding provenance. 7. **Preview and publish:** run `factory sync --dry-run --home `, inspect which items will be created and which are blocked, then explicitly apply to the selected home. The engineer's direction to execute work authorizes normal publication into the intended Firstmate home; ask only if the home or project identity is genuinely ambiguous. 8. **Execute with Firstmate:** Firstmate chooses ready work and workers through its existing dispatch policy. Each worker receives the task contract, relevant source pack, and evidence expectations; worker tests and repairs locally. Firstmate handles the actual delivery path and approvals required by its configured project mode. 9. **Verify per task and per project:** run deterministic gates first; semantic review receives task, diff, architecture, and observed evidence rather than the worker's full transcript. Repair specific findings and rerun changed checks. Then execute the original project oracle at the release candidate SHA. Failed project behavior creates a reviewed follow-up task. The status report must show backlog and product completion separately. @@ -21,6 +21,7 @@ This document specifies the methodology. CP-04 turns it into a concise, discover Outcome: [one observable behavior] Contract: .factory/tasks/.yaml at SHA [digest] Context: .factory/state/context/.md at [generation] +Risk: [bounded | normal | critical, with the concrete failure consequence] Acceptance: [IDs] Evidence: [commands/observations and artifact destinations] Dependencies: [satisfied backlog IDs] @@ -28,17 +29,26 @@ Delivery: [Firstmate project mode] Escalate: product policy, new access, contradictory authoritative docs, or scope change ``` -The worker must verify the pack's source freshness, inspect only relevant code, and run evidence against the actual change. A context pack is supporting material, not an instruction channel with authority above the project's tracked documents. +A fresh worker receives this brief, the exact task contract, bounded pack, and existing evidence—not a previous agent transcript. It must verify the pack receipt and source freshness, inspect only relevant code, and run evidence against the actual change. If the pack lacks a source needed to decide or test an acceptance or risk condition, name the missing source, read only the relevant exact-original file or range, verify its digest when available, and record how that read corrected the working assumptions. Add that exact path to `context.required` before regeneration when it is durable task authority. Do not compensate by loading broad documentation, hiding retrieved excerpts after the fact, or dropping a critical constraint. A context pack is supporting material, not an instruction channel with authority above the project's tracked documents. ## Risk and review -| Risk | Examples | Required human/independent check | -| --- | --- | --- | -| bounded | isolated deterministic refactor, docs typo | ordinary project checks; Firstmate's standard delivery policy. | -| normal | API behavior, persistence, UI journey | targeted integration/e2e evidence and a fresh semantic review when feasible. | +| Risk | Examples | Required human/independent check | +| -------- | ------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------- | +| bounded | isolated deterministic refactor, docs typo | ordinary project checks; Firstmate's standard delivery policy. | +| normal | API behavior, persistence, UI journey | targeted integration/e2e evidence and a fresh semantic review when feasible. | | critical | authentication, tenancy, payments, destructive migration, external sends | explicit design constraints, negative tests, independent review, and human decision for consequential policy. | -Risk is about consequences of error, not code size. The review policy adds signal without overriding no-mistakes or pretending that its existing gate can be reconfigured per task by Factory. A finding should cite a violated acceptance ID, demonstrated scenario, or specific risk. A disagreement is resolved with evidence, not reviewer authority alone. +Risk is about consequences of error, not code size. Use the higher class when the contract understates an observed consequence, and stop for a human decision when critical policy or access is unresolved. + +For each task, keep this order visible in the evidence: + +1. Verify contract/pack digests and run the cheapest applicable schema, graph, file, type, lint, unit, and targeted integration checks. Record command, exit, artifact, and tested SHA before requesting semantic review. +2. Build the review packet from the task contract, focused diff, relevant architecture or exact-original correction reads, and observed check evidence. Do not send a previous worker transcript as authority. +3. Apply the table above. Bounded work uses ordinary project checks and Firstmate's delivery policy. Normal work adds targeted integration/e2e evidence and a fresh semantic review when feasible. Critical work additionally requires isolated negative tests, explicit trust-boundary constraints, independent review, and human resolution of consequential policy; fixtures must use disposable identities and no real accounts or secrets. +4. Require each finding to cite an acceptance ID, reproduced scenario, or concrete risk. Disposition it as repaired, evidence-backed disagreement, or blocker. A repair must rerun the changed behavior and every deterministic check it could affect; unresolved critical findings block delivery. + +This task review adds signal but is not a shipping pipeline. Factory must not invoke, embed, or rerun no-mistakes; task evidence may reference artifacts from the one normal Firstmate-owned delivery gate. Factory likewise does not change Firstmate routing or approvals. A disagreement is resolved with evidence, not reviewer authority alone. ## Human decision format diff --git a/docs/probes/CP-06.md b/docs/probes/CP-06.md new file mode 100644 index 0000000..3e09baf --- /dev/null +++ b/docs/probes/CP-06.md @@ -0,0 +1,463 @@ +# CP-06 correction proof — relevant context and review ordering + +Date: 2026-09-29. Active checkpoint: `BUILD_PLAN.md` CP-06 only. Acceptance: +P-06 and P-07. Original base: +`8337ab7fcc5b511d3cb359ea214b6ae18003aeaa`. Original PR #8 head (preserved +unchanged in this branch's history): +`8b945a4ed6fcbb43a8b7aa7ce3a6532a4a23d145`. PR #9 correction head at final +review intake (also preserved unchanged in history): +`cdaf5e5c03d36f1c9b6bb0115793766a384cc178`. + +The implementation correction was performed in the isolated Firstmate-selected +Factory worktree. Independent validation and publication review run from the +dedicated no-mistakes validation worktree selected for this replacement branch; +tracked proof identifies exact commands and Git SHAs rather than relying on +durable absolute paths. The correction did not touch PR #8, PR #9, their +branches or worker state, a real Firstmate home, routing, shipping +configuration, live accounts, deployed systems, or the shared no-mistakes +daemon. + +## Read-only Pi authentication correction + +The original PR10 CLI worker is superseded by a bounded Pi 0.85.1 public-SDK path. `scripts/replay-cp06-worker.mjs` runs a DUMMY-only gate before opening any configured credential path or making a provider request. Passing requires the retained-capability counterexample and an evaluated child with empty capability sets, `no_new_privs`, seccomp filter mode, failed remount/unmount, namespace entry/creation and UID change. The native syscall probe now creates its own bounded literal-DUMMY target process, attempts `ptrace` attach/seize, `process_vm_readv/writev`, `pidfd_open` plus denied `pidfd_getfd`, and denied `/proc//mem` opening, in addition to `setns`, `unshare`, namespace `clone/clone3`, `mount` and `umount2`. An inherited subprocess repeats those denials and reads capability sets, NoNewPrivs, seccomp and `PR_GET_SECUREBITS`; both processes must observe locked root securebits 15. The target is non-dumpable, checks its DUMMY buffer unchanged, and is awaited, with parent-death signals and a probe alarm bounding failure cleanup. It accepts no external process target or address. These are required executed outcomes, not proof from the seccomp deny list. Alternate mount and inode-handle APIs remain configured rather than individually exercised. + +The historical 2026-10-05 WSL2 mount identity failure belonged to the util-linux path handoff. Preserved head `fdc8321` replaces credential bind/remount commands with native `open_tree`, `mount_setattr` and `move_mount`, retaining the source inode and an identity-checked `O_PATH | O_NOFOLLOW` destination. Leaf and parent substitution regressions retain outside DUMMY sentinels unchanged. The post-mount identity check is unchanged. At that head mount setup passed, but preflight still failed with "direct syscall probe failed to execute": the zero-capability adversary could not reopen the supervisor's descriptor and the helper closed inherited descriptors. The current correction supplies bounded read-only descriptors through each actual launch instead. Final-head full SDK/security, worker-cleanup and genuine semantic acceptance must still be run by the outer executor; this source correction alone does not close `CP06-KERNEL-PROOF-INCOMPLETE` as an acceptance blocker. + +Source and target remain distinct `ro,nosuid,nodev,noexec` mount instances of one pinned inode, with containing directories separately read-only inside the private namespace and writable DUMMY scratch. Host namespace and permissions are unchanged. Support is Linux x86-64, including WSL2 Linux guests with the required kernel primitives; unsupported namespaces, mount APIs, libseccomp, compilation, SDK versions or cleanup fail closed, without fallback. This is not Windows host filesystem/credential coverage; Windows and macOS are not claimed. Hosts that restrict unprivileged user namespaces (for example the Ubuntu 24.04 AppArmor `kernel.apparmor_restrict_unprivileged_userns` default) fail these tests closed; CI lifts that runner-only restriction before `npm test` rather than skipping them. + +The unsafe and hardened probes accept only an auth-security output directory under task-local `.factory/state/`, not a credential filename. The proof runner validates the ignored output location and opens its existing components without following symlinks before helper compilation or artifact writes; compiler output is written through pre-created `O_EXCL | O_NOFOLLOW` writable descriptors. After compilation, each artifact is reopened through its descriptor as `O_RDONLY`, with inode and anchored-leaf identity checks; the writable descriptor is closed before any execution. Execution and hashing consume the retained read-only inode descriptors, not the leaf paths, so later leaf replacement does not select replacement bytes. The namespace supervisor opens that directory without following symlink components, creates a private tmpfs fixture and literal `DUMMY ORIGINAL` source itself, and retains directory and source descriptors for a complete-byte DUMMY audit. Mount setup retains source and target parent descriptors, checks the prepared target identity, and consumes inode descriptors in the native mount helper; executable transition regressions reject replaced ancestors, leaves and parents without touching external DUMMY sentinels. Runner artifacts (`input.json`, `evidence.json`, `blocked.json`, auth-security `evidence.json`, and the worker-cleanup proof's ten-pack inputs and `evidence.json`) are created through `writeAnchoredFile`, an `O_CREAT | O_EXCL | O_NOFOLLOW` write relative to a pinned directory descriptor whose returned SHA-256 replaces a later path-based rehash, so a substituted leaf symlink fails with `EEXIST` before any external write. The worker-cleanup proof also creates its DUMMY scratch, evaluation-home, `tmp`, and credential directories with `openAnchoredDirectory`, writes the DUMMY source with `writeAnchoredFile`, and passes HOME, TMPDIR, and the credential path to the runner under the retained home descriptor. Its scratch removal, `openAnchoredDirectory(..., { reset: true })`, and the replay runner's removal of its evaluation home (created by `mkdtemp` relative to a retained no-follow `TMPDIR` descriptor) use `removeAnchoredEntry`, which walks the tree through no-follow directory descriptors, checks each opened directory against its `lstat` identity, unlinks symlinks without following them, and fails rather than descending when a directory is swapped for a symlink or for a different real directory. Sibling ten-pack/P-07 manifests, raw outputs, destination copies and hashes now also consume descriptor-relative no-follow artifact operations. Replaced task contracts are opened without truncation, checked for a single regular inode equal to the inode the runner itself created when preparing the fixture, then truncated through that retained descriptor; reads of runner-created artifacts require the same recorded inode, and the ten-pack manifest, pack and receipt digests are the digests returned by their writes rather than a rehash of the path. The DUMMY SDK proof root is created relative to a retained no-follow `TMPDIR` descriptor and, after success, failure or timeout, removed with `removeAnchoredEntry` only while its name still identifies the retained root descriptor; otherwise the cleanup failure is reported beside the initiating cause. DUMMY SDK/outcome fixture writes and exit counters use the same bounded artifact operations. A security preflight failure still blocks worker-cleanup proof before any configured credential use. Both documented proof entrypoints default to `.factory/state/cp06-correction/auth-security/`. Arbitrary source paths cannot initiate a probe; a failed private fixture setup blocks before byte reads or adversarial operations. This DUMMY fixture is not a live credential or model acceptance result. + +The evaluated worker injects `Cp06ReadOnlyCredentialStore` into `ModelRuntime.create({ refreshOnCreate:false, allowModelNetwork:false, modelsPath:null })` and passes that runtime to `createAgentSession` with an in-memory session, an empty resource loader, and one SDK `read` tool wrapped by a pre-execution canonical-path allowlist that rejects any `offset` or `limit`. The wrapper rejects every path except the already-canonical exact original, including credential paths, arbitrary project paths, and symlink aliases, before Pi's SDK read implementation can consume the immutable pinned snapshot. `modify` and `delete` reject before any callback or persistence. Expired and near-expiry DUMMY OAuth cases record zero refresh callbacks, zero `toAuth`, and zero persistence; an unexpired offline case resolves through `toAuth` with zero refresh/network/mutation. The live worker preflights enough validity for its timeout plus Pi's five-minute window and records only compact event/transcript and non-credential source hashes, counts, and parsed output, never credential contents or raw events. Real credential source integrity uses pinned device, inode, link, mode, size, and timestamp metadata plus mount identity; credential bytes are never hashed. Byte hashes in the isolation adversary are literal DUMMY-only controls. + +The current global Pi may be newer than the proven interface. Validation therefore installs exact Pi `0.85.1` under ignored task-local `.factory/state/cp06-sdk/` and supplies that installation's canonical package root and bin path explicitly. Both paths must equal the expected task-local locations, the bin symlink must resolve into that package, and its package metadata, declared entry, executable version, and complete published file manifests must agree with the independently pinned exact-version npm artifacts in `.factory/cp06-sdk-integrity.json`; inherited `PATH`, a global installation, an unrelated same-version package, and same-version file substitutions are rejected before SDK import without fallback. The pin records npm tarball SHA-512 SRI and sorted `[relative path, file SHA-256]` manifest SHA-256 and file count for both packages; it excludes nested installed `node_modules` from each package manifest, while separately verifying the resolved `pi-ai` package. It does not attest the registry itself, transitive packages other than `pi-ai`, or immutable files after this check. The global installation/configuration remains unchanged. The DUMMY provider resolver uses the selected package's ESM dependency context and the dependency's supported `import` export; it rejects a non-`0.85.1` `pi-ai`, a dependency outside the selected installation, or absent import resolution. Evidence records the selected Pi and resolved dependency package/entry paths, trusted artifact pins, manifest digests, and SHA-256 entry digests. To reproduce a pin independently, download the named exact-version tarballs into ignored `.factory/state/`, compare each tarball's SHA-512 against the tracked SRI, extract into a disposable directory, sort all regular published files by relative path and compute SHA-256 of JSON-serialized `[path, SHA-256(file)]` pairs (with no extra files or symlinks); compare count and digest to the tracked pin. Never derive a new trusted pin from the selected installation under evaluation. This resolver requires Pi's own Node floor (22.19 or newer) and Node's `--experimental-import-meta-resolve` support; inability to use it blocks rather than selecting a global fallback. + +Run the standalone DUMMY gate with `npm run proof:cp06:auth-security`. A positive real semantic replay remains `npm run proof:cp06:worker`, but it is not genuine acceptance until the exact final-head DUMMY gate, worker-cleanup proof, transition regressions, and semantic worker command all pass and their manifests identify the same tested SHA. The helper closes all inherited descriptors at least 3, except an explicitly selected read-only regular fd 4. For hardened probes, fd 4 belongs only to the compiled probe: the adversary transfers a read-only copy as fd 3 to the native probe and closes its copies before other subprocesses. For workers, fd 4 belongs only to the compact input file and the SDK worker closes it after parsing. The outer runner transfers helper/probe/input as supervisor fds 3/4/5; the supervisor retains them only until its process ends, and per-launch copies are closed immediately after the synchronous child returns. Writable helper descriptors and credential descriptors are never passed to the evaluated child. No unsupported lexical fallback is acceptable. No destructive operation is ever directed at the real credential source or mounted target. + +## Source-review correction verification + +Review phase only, on parent SHA +`fdc83217b9216485b817511cddcb9846fa005d74` plus the uncommitted correction: +Node `24.20.0`, npm `12.0.2`, GCC `13.3.0`. + +- `node --experimental-import-meta-resolve --test test/cp06-proof-boundary.test.mjs test/cp06-worker-lifecycle.test.mjs test/cp06-review-boundaries.test.mjs` + exited 1: 35 passed, one failed. The real hardened DUMMY probe passed; the + closed-descriptor negative fixture launched outside a disposable user namespace + and exited 127 instead of reaching the helper's expected exit 70. +- After putting that negative inside the same private namespace and tightening + DUMMY counter ancestor pinning, + `node --experimental-import-meta-resolve --test test/cp06-review-boundaries.test.mjs test/cp06-guarded-read.test.mjs` + exited 0: eight passed, none failed or skipped. This executes unsafe/hardened + fixture controls, native memory-access denials and inherited observations, + the closed-descriptor counterfactual, nested semantic/cleanup rejection, + artifact redirection negatives and the real ten-pack/P-07 raw-write boundaries. + +These focused checks are not exact-final-head SDK, worker-cleanup, STANDARD +10-pack/P-07 reproduction or genuine semantic acceptance. Clean install, +format/lint/typecheck, full tests, complete SDK proofs, final-head independent +review and CI remain owned by the outer executor. No real credential or model +was used during this source-review phase. + +## Normative result + +The PR #8 measurement is a **failure**, not a P-06 pass. Its predeclared oracle +counted 15 of 24 topical excerpts as irrelevant, every one of 10 packs had at +least one irrelevant excerpt, and AUTH was missing from one pack. Bounded bytes +do not make irrelevant authority relevant. The original figures remain in the +runtime evidence manifest and Git history; this correction does not relabel or +hide them. + +Independent exact-head review then rejected PR #9 for three additional concrete +cases: empty task-local selection widened to the CTX corpus, the worker oracle +accepted unsuccessful or partial reads, and the critical fixture disclosed a +foreign identifier during creation. The final correction adds executable +regressions for those cases without expanding CP-06's architecture. + +The smallest in-scope correction uses CTX's existing repeated `--document` +filter. Factory passes the task's reviewed `context.required` paths to `ctx +pack`; when that valid selection is empty, it passes the mandatory project +`documents.required` paths rather than accidentally issuing an unrestricted +query. Those same originals are still read exactly and included with their +digests. This is source selection before retrieval, not post-retrieval deletion: +raw CTX output remains in ignored proof scratch, provenance is unchanged, and a +missing declared source still fails closed. No CTX code, parallel retrieval +system, model router, supervisor, CP-07 extension, or CP-08 application was +added. + +The contract policy now makes `context.required` the narrow task-local authority +selection. Known sources that can decide an acceptance or risk condition belong +there. A worker that discovers an omission performs one targeted exact-original +read, records the corrected assumption, and adds only that durable source before +regeneration. Global constraints and exact required originals remain visible. + +## Reproducible evidence layout + +Tracked, secret-free inputs and runners: + +- `test/fixtures/cp06-context/` — exact project, architecture, ten task + contracts, ten topical originals, relevance oracle, and the AUTH-before + counterfactual; +- `scripts/replay-cp06-ten-pack.mjs` — creates a disposable Git/CTX repository, + records the unscoped bad path, one-condition AUTH correction, exact ten fixed + packs and receipts, and a missing-original disconfirming case; +- `scripts/cp06-worker-audit.mjs`, `test/cp06-worker-audit.test.mjs`, and + `scripts/replay-cp06-worker.mjs` — reject incomplete, failed, ranged, + mismatched, out-of-order, and extra-project reads, then give a new Pi process + only the bounded AUTH handoff and permit one exact-original read; +- `test/fixtures/workflow/history/` and + `scripts/replay-cp06-p07.mjs` — compact P-07 finding plus executable + fail-before/fix-after/auth-retest sequence; +- `test/workflow.test.ts` — checked-in normal API and in-memory auth behavior, + including the retained malformed-JSON regression check. + +Generated raw CTX JSON, packs, receipts, and deterministic command logs remain +ignored under `.factory/state/cp06-correction/`. The live worker does not retain +its prompt, event stream, stderr, or raw output; it retains one compact +`evidence.json` with their hashes, correlated event indexes, successful-read +state, and exact-original digest. Each replay binds command, exit, tested Git +SHA, UTC date, reviewer, tool/index identity, and applicable +source/contract/pack/receipt hashes, budgets, and provenance. This meets the +general proof rule without committing runtime state or model transcripts. + +Run in this order (all package/cache paths are disposable and ignored): + +```text +npm_config_cache="$PWD/.factory/state/npm-cache" npm ci +mkdir -p .factory/state/cp06-sdk +npm_config_cache="$PWD/.factory/state/npm-cache-pi" npm install \ + --prefix .factory/state/cp06-sdk --package-lock=false --ignore-scripts \ + --no-audit --no-fund @earendil-works/pi-coding-agent@0.85.1 +export CP06_PI_PACKAGE_ROOT="$PWD/.factory/state/cp06-sdk/node_modules/@earendil-works/pi-coding-agent" +export CP06_PI_BIN="$PWD/.factory/state/cp06-sdk/node_modules/.bin/pi" +"$CP06_PI_BIN" --version # must be exactly 0.85.1 +npm run format:check +npm run lint +npm run typecheck +npm test +npm run proof:cp06:auth-security +npm run proof:cp06:worker-cleanup +npm run proof:cp06:ten-pack +npm run proof:cp06:p07 +npm run proof:cp06:worker +git diff --check +``` + +The live worker command is omitted rather than passed when DUMMY protection, exact SDK selection, or non-refresh credential-validity preflight fails. + +`test/cp06-selected-boundaries.test.mjs` owns the bounded helper-execution, +serializer-import, and nested-reset regressions. Its compiled-helper control +executes through retained read-only descriptors even after replacing the named +leaves, avoiding `ETXTBSY` without selecting replacement bytes. Importing +`createWorkerBlockedStatus` from `scripts/replay-cp06-worker.mjs` does not create +or reset output: only the executable's `main` initializes its worker directory. +`openAnchoredDirectory(..., { reset: true })` traverses parent components with +no-follow directory descriptors and resets only the requested leaf; successive +`proofs/` resets preserve sibling evidence. These are DUMMY development +controls, not final-head kernel or semantic acceptance. + +The exact runtime manifests are: + +```text +.factory/state/cp06-correction/auth-security/evidence.json +.factory/state/cp06-correction/worker-cleanup/evidence.json +.factory/state/cp06-correction/ten-pack/evidence.json +.factory/state/cp06-correction/p07/evidence.json +.factory/state/cp06-correction/worker/evidence.json +``` + +Inspect each manifest's `tested_sha` rather than copying a stale SHA into this +tracked report. The ten-pack manifest also carries every current contract, +source, pack, receipt, CLI-output digest, CTX generation, token/byte count, and +provenance list. + +## P-06 diagnosis and fixed ten-pack replay + +The fixture uses only local Markdown and the installed CTX `1.0.0` with verified +`BAAI/bge-small-en-v1.5`. `ctx doctor --offline` must report +`offline_ready=true` and `network_attempted=false`; status must be clean +`HYBRID_SEMANTIC` with the semantic channel active. The runner aborts otherwise. +It indexes only the fixture's deliberate authoritative Markdown. + +The bad-path control invokes real CTX with the AUTH query before `docs/AUTH.md` +is indexed and without a document selection. On the recorded replay it exited +0, omitted AUTH, and returned unrelated topical `docs/DELETION.md` and +`docs/OBSERVABILITY.md`. The raw response is retained in ignored scratch. This +corroborates the user-visible PR #8 problem rather than treating small byte size +as relevance. + +The one-condition counterfactual keeps every AUTH task field fixed except +`context.required`: + +```text +before: [PROJECT.md, ARCHITECTURE.md] +after: [PROJECT.md, ARCHITECTURE.md, docs/AUTH.md] +``` + +Before correction, the bounded pack omits the decisive original. After the one +contract change, the regenerated pack contains exact `docs/AUTH.md` with its +digest and retains all global constraints. It corrects the assumptions to: +principal-derived ownership, ignored request ownership, `401` for missing or +forged credentials, and non-disclosing/non-mutating `404` for cross-account +read/update/delete. + +The fixed sample is ten bounded packs: two bounded-risk, six normal-risk, and +two critical-risk tasks. The oracle is declared in +`test/fixtures/cp06-context/oracle.json` before output inspection: global +`PROJECT.md` and `ARCHITECTURE.md` plus the task's single target source are +relevant; every other topical source is irrelevant. The replay result is: + +- **10/10 packs relevant**; +- **0 irrelevant topical excerpts** and **0 packs with an irrelevant excerpt**; +- **0 missing targets**; +- every pack within its 4,000-token / 16,000-byte bounded ceiling; +- CTX generation 2 and `HYBRID_SEMANTIC` recorded per receipt; +- exact contract, target-source, pack, receipt, output, and source digests + recorded per task. + +An additional schema-valid global-only contract uses `context.required: []`. +Factory exits 0 and emits no topical digest or excerpt. A real CTX call with the +same query and explicit `PROJECT.md`/`ARCHITECTURE.md` filters is `COMPLETE`; its +actual provenance contains only those originals and their checked digests, +despite all unrelated topical originals being indexed. This preserves viable +global-only work without treating an empty set as unrestricted retrieval. + +A separate document-scoped real-CTX disconfirming probe exits 0 but reports +`PARTIAL` at its deliberately constrained 1,300-token budget; successful command +execution alone is not accepted as completeness. The missing-original case then +changes AUTH to require nonexistent `docs/ABSENT.md`: Factory exits 3 with +`CONTEXT_BLOCKED` and writes no replacement pack. Thus the correction does not +claim that a source filter can recover undeclared or absent authority and does +not fabricate completeness. + +## Fresh-context targeted-original correction + +After deterministic checks and the real-CTX replay, a new Pi `0.85.1` SDK +session runs with an injected read-only credential store and model runtime, an +in-memory session/settings manager, an explicit resource loader containing no +extensions, skills, prompts, context files, or themes, and exactly one guarded +SDK `read` tool. Its initial handoff contains exactly the AUTH-before contract, +bounded missing-AUTH pack, and a short deterministic evidence statement—no +prior transcript. + +The fresh-worker handoff deliberately omits the concrete rule that +request-supplied owner fields are ignored at creation time while retaining the +already observed `401` and concealed, non-mutating `404` evidence. The worker +reports `MISSING_SOURCE`, performs exactly one read of the supplied +`docs/AUTH.md` original, performs no broad scan, recovers principal-derived +creation ownership and the ignored request-owner rule, and identifies the +missing creation-time integration evidence. + +The exact original is opened by walking every ancestor from `/` with no-follow +directory descriptors and opening the leaf with `O_NOFOLLOW` relative to the +pinned parent, so a symlink substituted for any ancestor after canonicalization +is rejected before bytes are read; it is then identity-checked and snapshotted +before provider execution. At the actual tool boundary, the lexical path and +range are checked and the named path must still identify the pinned regular +file; Pi's SDK read implementation receives only the immutable snapshot, so a +replacement race cannot redirect the provider-visible bytes. The audit +correlates `tool_execution_start` and `tool_execution_end` by tool-call ID and +event order, requires `isError: false`, and forbids all other start or +completion events. The original must be read exactly once without `offset` or +`limit`: the guard rejects the presence of either argument before the SDK read +executes, and the audit independently rejects a start event carrying either, +even an `offset: 1` range whose result would equal the complete original. The +returned UTF-8 bytes and SHA-256 must equal the complete tracked exact original. +The final response must follow the successful completion. Deterministic protocol +tests reject omitted, failed, partial, any-range, mismatched, out-of-order, +malformed-orphan, extra-project, credential-path, symlink-alias, and +post-construction replacement-race cases; the DUMMY SDK proof also rejects +ranged calls through the guard wrapping the pinned SDK's actual read tool. + +The genuine Pi SDK process runs in a private Linux user/mount namespace only +after the complete DUMMY auth and OS preflight passes. A supervisor walks the +source and target parent directories from `/` with no-follow directory +descriptors (creating missing target components relative to the retained +parent), so a symlink substituted for any ancestor is rejected before a target +directory, placeholder, or mount is created. Because every source ancestor is +opened `O_RDONLY | O_DIRECTORY | O_NOFOLLOW` without canonicalization, a live +credential path (`CP06_PI_AUTH_FILE`, `PI_CODING_AGENT_DIR` or `~/.pi/agent`) +with a symlinked ancestor (for example a dotfile-managed `~/.pi` link) or a +search-only (`--x`) ancestor is unsupported: setup fails closed with +`CP06_ISOLATION_SETUP_FAILED` before any credential byte access, mount or +mutation. Genuine acceptance requires a credential path whose ancestors are real, +readable directories. It pins the regular source inode +with `O_NOFOLLOW`, creates distinct source/target bind mounts with read-only VFS +flags, and closes the pin before launch. The evaluated +child is observed with all capability sets empty, `no_new_privs`, and an +inherited seccomp filter. Passing DUMMY preflight now requires native mount, +namespace, ptrace/process-memory denials, inherited-subprocess repetition and +locked-securebits read-back. Alternate inode-handle denials remain configured +rather than individually exercised; full final-head acceptance remains required. Its +credential store rejects refresh/mutation callbacks before entry; expired or +near-expiry OAuth is therefore blocked before a provider request or persistence. +A missing credential source is a sanitized `CP06_CREDENTIAL_SOURCE_MISSING` +missing-source result with exit 70, not an unsupported-isolation result; a +simultaneous cleanup failure retains both typed causes. Other setup, +helper/version, auth-validity, timeout, audit, and cleanup failures are reported +as blocked/nonzero with no unsandboxed fallback. The evaluation state +is removed and no raw transcript is retained. Native Windows/macOS isolation is +unsupported rather than claimed. Model wording is nondeterministic; the +protocol, exact bytes, and isolation assertions are not. + +## PR11 A1/C1 correction and proof boundary + +The independent review of PR11 at `33b2724e3316272aac1c563086d2ce5e77d496ca` +reproduced two additional failures: a local faux SDK response with +`evidence_gaps: []` was accepted, and an expired-auth exit skipped the outer +runner's `finally`, leaving its evaluation directory and empty mount placeholder. +Neither finding demonstrated credential mutation; the DUMMY S1/S2 controls passed. + +The worker now requires an exact top-level response shape with one declared +canonical read, two fixed corrected-constraint values, no secondary evidence channel, +and an executable structured creation-evidence disposition: + +```json +{ + "evidence_gaps": [ + { + "id": "creation-time-ownership", + "status": "unverified", + "missing_checks": ["principal-derived-owner", "request-owner-ignored"] + } + ] +} +``` + +The check order inside `missing_checks` is immaterial, but the two +`corrected_constraints` values are fixed canonical strings in fixed order so +that field cannot carry an additional free-text evidence disposition. Missing, +empty, duplicate, wrong, or contradictory dispositions fail; recovering a +requirement by reading its original is not a passing behavior test. Extra fields +or prose in this gap are rejected rather than interpreted as an alternate +verification channel. The existing exact-original read and source-scope oracle +remain mandatory. + +`npm run proof:cp06:auth-security` additionally runs the actual SDK worker with a +local faux provider in the inherited capability-free/seccomp context. It records +a valid structured positive, missing/empty/contradictory gap negatives, an +earlier assistant message carrying text beside its read call, a final message +carrying a non-text channel, an unexpected tool named with a DUMMY sentinel +(whose name must not reach stdout or stderr), expired and near-expiry preflight +denial, and an SDK timeout. The worker validates every assistant message: earlier +messages may carry only tool calls, thinking blocks, and whitespace text with +`stopReason: "toolUse"`, and the final message only text and thinking blocks with +`stopReason: "stop"`. SDK-worker stderr carries only allowlisted fixed audit +reasons. The consumer validates the exact nested `read_audit` schema (one +correlated read whose path must equal the exact canonical original the consumer +created under its own fresh proof root, matching digests, ordered event indexes, +fixed tool-call ID), exact per-scenario exit, `runtime_create`, and `to_auth` counts +(valid runs observe three `toAuth` calls: the preflight plus two model turns), +and exact credential-store `modify`/`delete` denial counts (one modify denial for +expired and near-expiry auth, none otherwise). Every case is explicitly +`fixture_origin: true`, `semantic_acceptance: false`; these are protocol and +infrastructure proofs, never live-model acceptance. Default AuthStorage, refresh, +and network entry are instrumented and must remain zero. The full read still +uses Pi's actual SDK read implementation behind the pre-execution allowlist, +not a fabricated completion event. + +The runner and namespace supervisor await cleanup before setting an exit code. +When execution and cleanup both fail, the cleanup error retains the primary +cause and its exit identity. Supervisor output is an allowlisted stage/code/ +description protocol; launch, timeout, child exit, source-integrity audit, +output audit, and cleanup causes remain distinct without retaining child stderr +or arbitrary exception payloads. PR12 still parsed successful-child output after +cleanup, so malformed output plus cleanup failure lost the parse/audit cause. +The correction keeps child status, source-integrity verification, compact JSON +parsing, the mode-specific child semantic contract (for workers: exact output +keys and nested evidence schemas, `result: "pass"`, Pi `0.85.1`, in-memory +read-only session, unrefreshed OAuth, the complete canonical A1 outcome, and +one exact-original read tied to independently pinned bytes and bounded event +indexes, and `pi_install` provenance exactly equal to the selected installation +the supervisor resolves and verifies itself against the tracked integrity +pins before mounting the credential, from selected paths, package metadata and +file manifests only, without executing Pi in its privileged namespace; the +supervisor is launched with `--experimental-import-meta-resolve` so the pi-ai +dependency resolves from the selected package, and a resolution failure is a +`setup`/`CP06_ISOLATION_SETUP_FAILED` exit 73 rather than an audit failure; +the `pi --version` check remains in the hardened child), and evidence assembly inside the cleanup +action, so valid JSON with an invalid contract plus a cleanup failure retains +both the audit and cleanup causes. These compound cases (malformed output and +semantic audit each with cleanup, child-exit+cleanup, audit-only, forged +provenance, cleanup-only, success) have both injected supervisor tests and +actual supervisor CLI regressions checking sanitized serialized stderr and exact +exits. DUMMY CLI fixtures report a fixed literal `/DUMMY` provenance; the +supervisor accepts it only when its caller sets +`CP06_DUMMY_TRUSTED_PROVENANCE=1`, which no genuine installation can satisfy, +and the replay runner refuses to persist evidence whose install root is not the +task-local `.factory/state/cp06-sdk`. Without that flag, executable supervisor +regressions launched exactly as the runner launches it reject a missing selected +installation as a pre-mount setup failure, reject the literal `/DUMMY` provenance +as an audit failure, and (when the task-local SDK is installed) accept a DUMMY +child reporting the selected installation's provenance. A DUMMY probe-fixture setup failure plus an +independent fixture-cleanup failure likewise serializes both causes; fixture +cleanup attempts every step, closes every retained descriptor, and removes the +fixture directory only through the retained output descriptor. The DUMMY +CLI control now returns the complete canonical A1 contract; its semantic +negative changes only the response to an invalid PASS outcome. The full-runner +compound scenarios in `npm run proof:cp06:worker-cleanup` assert persisted +`blocked.json` causes, in addition to expired/near-expiry DUMMY auth and +missing-source setup. They remain subject to a working complete DUMMY SDK +preflight and are not claimed as final-head passing evidence here. The proof asserts +the blocked result, unchanged DUMMY source, absence of a pass manifest, and +removal of the evaluation home. Unit tests also inject setup, launch, timeout, +audit and cleanup failures at the executable supervisor boundary; they are +distinct from actual kernel/SDK proof. No real credential read, mount, refresh +or model request is needed for these regressions. + +The strict-agent CTX audit is a separate qualification. PR11's ordinary Factory +STANDARD/HYBRID_SEMANTIC sample passed 10/10 with zero irrelevant provenance; +replaying those selections with `--strict-agent --require-semantic` reported ten +PARTIAL results because strict cross-document discovery required filtered-out +normative entries. This is not strict COMPLETE evidence. Do not widen filters, +hide omissions, or label the DUMMY worker as semantic acceptance to cure it. +Final-head manifests must be regenerated after committing the correction. The +fresh-worker gate is verified only by a successful final-head real model/SDK +manifest produced after the DUMMY gate through the hardened read-only path; a +fixture-origin stream or an earlier SHA cannot substitute. Independent +acceptance remains separate, and the prior PR11 report is not approval of this +head. + +## P-07 finding, repair, and retest + +`test/fixtures/workflow/history/finding.json` is the minimal structured reviewer +record for `P07-MALFORMED-JSON`. The retained before module throws `SyntaxError` +for malformed profile JSON. The repaired normal API returns stable +`400 invalid_json` through its normal request function. + +`npm run proof:cp06:p07` executes and records this order: + +1. build — expected exit 0; +2. fail-before malformed-JSON probe — expected exit 1 with uncaught + `SyntaxError`; +3. final malformed-JSON behavior retest — expected exit 0; +4. final critical in-memory auth tests — expected exit 0. + +The final behavioral fixture also proves absent/forged credentials return 401, +own-account access succeeds, cross-account read/update/delete return 404 without +mutation, request-supplied ownership is ignored, and stored ownership comes from +the principal. Identifiers are tenant-scoped: account B can create its own +`item-a` without learning that account A already uses that identifier, while +blank titles return the same `400 invalid_title` for foreign-collision and unused +identifiers and leave state unchanged. It uses only disposable literal +identities and in-memory state; there is no network, filesystem, environment, +real account, or security-sensitive credential access. + +This task-level review remains ordered after deterministic checks and before the +one normal Firstmate-owned delivery pipeline. Factory neither invokes +no-mistakes nor changes its ownership boundary. + +## Acceptance and residual limitations + +P-06's STANDARD ten-pack subgate passes when the corrected, predeclared sample +is replayed successfully; it does not substitute for the final-head real +fresh-worker manifest or independent CP-06 review. The original PR #8 sample +remains explicitly failed. P-07 passes because the structured finding, +fail-before, repair, final behavior retest, and auth retest are executable and +bound to the replayed SHA. CP-06 makes no change to Firstmate dispatch, delivery, +or no-mistakes policy. + +Residual limits are explicit: this is a small Linux fixture using CTX `1.0.0`, +not a universal retrieval-quality claim; native Windows/macOS replay is +unverified; semantic model wording and ranking can vary in later CTX/model +versions and therefore fail the proof rather than being silently accepted; and +raw runtime artifacts remain intentionally ignored and must be regenerated by +an independent reviewer. diff --git a/package.json b/package.json index fcee6cd..6ed3ea4 100644 --- a/package.json +++ b/package.json @@ -9,10 +9,15 @@ "scripts": { "build": "tsc -p tsconfig.json", "typecheck": "tsc -p tsconfig.json --noEmit", - "test": "npm run build && node --test \"dist/test/**/*.test.js\"", + "test": "npm run build && node --experimental-import-meta-resolve --test \"dist/test/**/*.test.js\" \"test/**/*.test.mjs\"", "format": "prettier --write \"{src,test,scripts,.github,schemas}/**/*.{ts,js,mjs,yml,yaml,json}\" \"*.json\" \"docs/probes/*.md\" \"docs/superpowers/plans/*.md\"", "format:check": "prettier --check \"{src,test,scripts,.github,schemas}/**/*.{ts,js,mjs,yml,yaml,json}\" \"*.json\" \"docs/probes/*.md\" \"docs/superpowers/plans/*.md\"", "lint": "node scripts/lint.mjs", + "proof:cp06:ten-pack": "npm run build && node scripts/replay-cp06-ten-pack.mjs", + "proof:cp06:p07": "node scripts/replay-cp06-p07.mjs", + "proof:cp06:auth-security": "node --experimental-import-meta-resolve scripts/prove-cp06-auth-security.mjs", + "proof:cp06:worker": "node --experimental-import-meta-resolve scripts/replay-cp06-worker.mjs", + "proof:cp06:worker-cleanup": "node scripts/prove-cp06-worker-cleanup.mjs", "factory": "node dist/src/cli.js" }, "engines": { diff --git a/scripts/cp06-auth-security.mjs b/scripts/cp06-auth-security.mjs new file mode 100644 index 0000000..e4dbc88 --- /dev/null +++ b/scripts/cp06-auth-security.mjs @@ -0,0 +1,1058 @@ +import { createHash } from "node:crypto"; +import { spawnSync } from "node:child_process"; +import { + closeSync, + constants, + fchmodSync, + fstatSync, + lstatSync, + mkdtempSync, + openSync, + readFileSync, + realpathSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { basename, join, resolve } from "node:path"; +import { resolvePinnedPiAi } from "./cp06-pi-dependency.mjs"; +import { resolvePinnedPiInstall } from "./cp06-pi-install.mjs"; +import { openProbeOutput, removeAnchoredEntry } from "./cp06-probe-fixture.mjs"; +import { Cp06CleanupError } from "./cp06-worker-lifecycle.mjs"; +import { DUMMY_SDK_ORIGINAL, DUMMY_OUTCOME_ORIGINAL } from "./cp06-dummy-originals.mjs"; + +export class Cp06IsolationUnsupportedError extends Error { + constructor(message) { + super(message); + this.name = "Cp06IsolationUnsupportedError"; + this.code = "CP06_ISOLATION_UNSUPPORTED"; + } +} + +export async function runCp06SecurityPreflight(options = {}) { + const root = resolve(options.root ?? process.cwd()); + const outputRoot = resolve(options.outputRoot ?? join(root, ".factory/state/cp06-auth-security")); + const output = openProbeOutput({ root, outputRoot, create: true }); + let binaries; + try { + const prerequisites = verifyIsolationPrerequisites({ + root, + spawnSyncImpl: options.spawnSyncImpl, + }); + binaries = compileIsolationHelpers({ + root, + outputRoot, + anchoredOutputRoot: output.anchor, + }); + const pi = resolvePiPackage(root, options); + const unsafe = await runIsolationProbe({ + root, + outputRoot, + mode: "unsafe-probe", + binaries, + }); + assertUnsafeControl(unsafe); + const hardened = await runIsolationProbe({ + root, + outputRoot, + mode: "hardened-probe", + binaries, + }); + assertHardenedProof(hardened); + const sdk = runDummySdkProof({ + root, + helper: binaries.helper, + piRoot: pi.root, + expectedSdkDependency: pi.provenance.dependency, + }); + const proof = { + schema_version: 1, + gate: "CP-06-read-only-auth-security", + result: "pass", + tested_sha: commandText(spawnSync, "git", ["rev-parse", "HEAD"], root), + recorded_at: new Date().toISOString(), + reviewer: options.reviewer ?? "Pi CP-06 read-only auth correction worker", + command: "npm run proof:cp06:auth-security", + exit_code: 0, + pi_version: pi.version, + pi_install: pi.provenance, + platform: prerequisites, + binaries: { + seccomp_helper_sha256: sha256(readFileSync(binaries.helper)), + syscall_probe_sha256: sha256(readFileSync(binaries.syscallProbe)), + }, + unsafe_control: summarizeUnsafe(unsafe), + hardened_child: summarizeHardened(hardened), + dummy_sdk: sdk, + }; + if (options.retainBinaries === true) { + Object.defineProperty(proof, "retainedBinaries", { value: binaries }); + binaries = undefined; + } + return proof; + } finally { + binaries?.close?.(); + output.close(); + } +} + +export function verifyIsolationPrerequisites(options = {}) { + const root = resolve(options.root ?? process.cwd()); + const spawn = options.spawnSyncImpl ?? spawnSync; + if (process.platform !== "linux" || process.arch !== "x64") { + throw unsupported("CP-06 proof runner supports Linux x86-64 only"); + } + const probes = [ + ["unshare", ["--user", "--map-root-user", "--mount", "--propagation", "private", "true"]], + ["mount", ["--version"]], + ["umount", ["--version"]], + ["findmnt", ["--version"]], + ["setpriv", ["--version"]], + ["nsenter", ["--version"]], + ["cc", ["--version"]], + ]; + for (const [command, args] of probes) { + const result = spawn(command, args, { cwd: root, encoding: "utf8", timeout: 30_000 }); + if (result.error !== undefined || result.status !== 0) { + throw unsupported(`required isolation probe failed: ${command}`); + } + } + return { + os: process.platform, + architecture: process.arch, + kernel: commandText(spawn, "uname", ["-r"], root), + user_mount_namespace: true, + libseccomp: true, + }; +} + +export function compileIsolationHelpers(options) { + const root = resolve(options.root); + const outputRoot = resolve(options.outputRoot); + const artifactRoot = mkdtempSync(join(options.anchoredOutputRoot ?? outputRoot, "helpers-")); + const artifactFd = openSync( + artifactRoot, + constants.O_RDONLY | constants.O_DIRECTORY | constants.O_NOFOLLOW, + ); + const artifactAnchor = `/proc/${process.pid}/fd/${artifactFd}`; + const helper = join(artifactAnchor, "cp06-seccomp-exec"); + const syscallProbe = join(artifactAnchor, "cp06-isolation-syscalls"); + const spawn = options.spawnSyncImpl ?? spawnSync; + let helperFd; + let syscallProbeFd; + try { + helperFd = compilePinnedArtifact({ + root, + artifactAnchor, + name: "cp06-seccomp-exec", + source: join(root, "scripts/cp06-seccomp-exec.c"), + extraArgs: ["-Wl,-l:libseccomp.so.2"], + spawn, + }); + syscallProbeFd = compilePinnedArtifact({ + root, + artifactAnchor, + name: "cp06-isolation-syscalls", + source: join(root, "scripts/cp06-isolation-syscalls.c"), + extraArgs: [], + spawn, + }); + let closed = false; + closeSync(artifactFd); + return { + helper: `/proc/${process.pid}/fd/${helperFd}`, + syscallProbe: `/proc/${process.pid}/fd/${syscallProbeFd}`, + outputPaths: { helper, syscallProbe }, + close() { + if (closed) return; + closed = true; + closeSync(helperFd); + closeSync(syscallProbeFd); + }, + }; + } catch (error) { + if (helperFd !== undefined) closeSync(helperFd); + if (syscallProbeFd !== undefined) closeSync(syscallProbeFd); + closeSync(artifactFd); + throw error; + } +} + +export async function runIsolatedWorker(options) { + const result = runNamespace({ + root: options.root, + mode: "worker", + source: options.source, + target: options.target, + helper: options.binaries.helper, + syscallProbe: options.binaries.syscallProbe, + childArgs: [options.inputPath], + timeout: options.timeout ?? 660_000, + environment: options.environment, + }); + if (result.status !== 0) throw namespaceWorkerError(result); + return parseCompactJson(result.stdout, "isolated SDK worker"); +} + +export function namespaceWorkerError(result) { + if (result.error?.code === "ETIMEDOUT") { + const error = new Error("CP-06 isolated SDK worker timed out"); + error.code = "CP06_CHILD_TIMEOUT"; + error.stage = "timeout"; + error.exitCode = 70; + error.workerFailure = { + schema_version: 1, + stage: "timeout", + code: "CP06_CHILD_TIMEOUT", + exit_code: 70, + description: SUPERVISOR_FAILURE_DESCRIPTIONS.CP06_CHILD_TIMEOUT, + causes: [], + }; + return error; + } + const error = new Error( + result.status === 73 + ? "CP-06 credential namespace setup is unsupported" + : `CP-06 isolated SDK worker exited ${result.status ?? "without status"}`, + ); + error.code = result.status === 73 ? "CP06_ISOLATION_UNSUPPORTED" : "CP06_WORKER_FAILED"; + error.stage = result.status === 73 ? "setup" : "namespace"; + error.exitCode = result.status ?? 70; + try { + const failure = JSON.parse(String(result.stderr).trim()); + if (isSupervisorFailure(failure, result.status)) { + error.workerFailure = { + schema_version: 1, + stage: failure.stage, + code: failure.code, + exit_code: failure.exit_code, + description: failure.description, + causes: failure.causes.map((cause) => ({ ...cause })), + }; + error.code = failure.code; + error.stage = failure.stage; + } + } catch {} + return error; +} + +export async function runIsolationProbe({ + root, + outputRoot, + mode, + binaries, + runNamespaceImpl = runNamespace, +}) { + const result = runNamespaceImpl({ + root, + mode, + source: outputRoot, + target: "-", + helper: binaries.helper, + syscallProbe: binaries.syscallProbe, + childArgs: [], + timeout: 90_000, + }); + if (result.error !== undefined || result.status !== 0) { + const failure = namespaceWorkerError(result); + const error = unsupported(`${mode} failed: ${failure.code}`); + error.workerFailure = failure.workerFailure; + throw error; + } + return parseCompactJson(result.stdout, mode); +} + +function runNamespace(options) { + const inputs = + options.mode === "worker" + ? [options.helper, options.syscallProbe, options.childArgs[0]] + : [options.helper, options.syscallProbe]; + return withInheritedExecutables(inputs, ([helper, probe, input]) => + spawnSync( + "unshare", + [ + "--user", + "--map-root-user", + "--mount", + "--propagation", + "private", + "--", + process.execPath, + "--experimental-import-meta-resolve", + join(options.root, "scripts/cp06-namespace-supervisor.mjs"), + options.mode, + resolve(options.source), + options.mode === "worker" ? resolve(options.target) : "-", + helper.path, + probe.path, + ...(input ? [input.path] : options.childArgs.map((value) => resolve(value))), + ], + { + cwd: options.root, + encoding: "utf8", + timeout: options.timeout, + maxBuffer: 1024 * 1024, + env: options.environment ?? process.env, + stdio: ["pipe", "pipe", "pipe", helper.fd, probe.fd, ...(input ? [input.fd] : [])], + }, + ), + ); +} + +// A new user namespace cannot open the runner's /proc//fd entries, so the +// retained execution descriptors are inherited as fds 3+ instead of named. +function withInheritedExecutables(paths, action) { + const descriptors = []; + try { + for (const [index, path] of paths.entries()) { + descriptors.push({ + fd: openSync( + resolve(path), + constants.O_RDONLY | constants.O_CLOEXEC | (index >= 2 ? constants.O_NOFOLLOW : 0), + ), + path: `/proc/self/fd/${index + 3}`, + }); + } + return action(descriptors); + } finally { + for (const { fd } of descriptors) closeSync(fd); + } +} + +function isSupervisorFailure(value, status) { + return ( + value?.schema_version === 1 && + value !== null && + typeof value === "object" && + !Array.isArray(value) && + Object.keys(value).sort().join(",") === + "causes,code,description,exit_code,schema_version,stage" && + value.exit_code === status && + typeof value.stage === "string" && + SUPERVISOR_FAILURE_CODES.get(value.code) === value.stage && + typeof value.description === "string" && + value.description === SUPERVISOR_FAILURE_DESCRIPTIONS[value.code] && + Array.isArray(value.causes) && + value.causes.every( + (cause) => + cause !== null && + typeof cause === "object" && + !Array.isArray(cause) && + Object.keys(cause).sort().join(",") === "code,description,exit_code,stage" && + SUPERVISOR_FAILURE_CODES.get(cause.code) === cause.stage && + cause.description === SUPERVISOR_FAILURE_DESCRIPTIONS[cause.code] && + (cause.exit_code === null || Number.isSafeInteger(cause.exit_code)), + ) + ); +} + +const SUPERVISOR_FAILURE_DESCRIPTIONS = { + CP06_ISOLATION_SETUP_FAILED: "credential namespace setup failed", + CP06_CREDENTIAL_SOURCE_MISSING: "credential source is missing", + CP06_CHILD_LAUNCH_FAILED: "isolated child launch failed", + CP06_CHILD_TIMEOUT: "isolated child timed out", + CP06_AUTH_BLOCKED: "credential preflight blocked the child", + CP06_CHILD_EXIT_FAILED: "isolated child exited unsuccessfully", + CP06_SOURCE_IDENTITY_CHANGED: "credential source identity changed", + CP06_CHILD_OUTPUT_INVALID: "isolated child output was invalid", + CP06_CLEANUP_FAILED: "credential namespace cleanup failed", + CP06_CREDENTIAL_CLEANUP_FAILED: "credential mount cleanup failed", +}; +const SUPERVISOR_FAILURE_CODES = new Map([ + ["CP06_ISOLATION_SETUP_FAILED", "setup"], + ["CP06_CREDENTIAL_SOURCE_MISSING", "missing-source"], + ["CP06_CHILD_LAUNCH_FAILED", "launch"], + ["CP06_CHILD_TIMEOUT", "timeout"], + ["CP06_AUTH_BLOCKED", "child-exit"], + ["CP06_CHILD_EXIT_FAILED", "child-exit"], + ["CP06_SOURCE_IDENTITY_CHANGED", "source-integrity"], + ["CP06_CHILD_OUTPUT_INVALID", "audit"], + ["CP06_CLEANUP_FAILED", "cleanup"], + ["CP06_CREDENTIAL_CLEANUP_FAILED", "cleanup"], +]); + +export function assertUnsafeControl(proof) { + assert(proof.mode === "unsafe-probe", "unsafe control mode mismatch"); + assert(proof.mount_ids_distinct === true, "unsafe control lacked distinct mounts"); + assert(proof.source_unchanged === false, "unsafe control did not mutate DUMMY source"); + assert(proof.child.remount_target_rw.exit === 0, "unsafe remount did not succeed"); + assert(proof.child.write_after_remount.exit === 0, "unsafe post-remount write failed"); + assert(proof.child.unmount_target.exit === 0, "unsafe unmount did not succeed"); + assert(proof.child.bind_parent_alias.exit === 0, "unsafe parent bind did not succeed"); + assert(proof.child.scratch_write.exit === 0, "unsafe scratch path was not writable"); + assert(proof.child.alias_underlying_write.exit === 0, "unsafe alias write did not succeed"); + assert( + proof.child.identity.status.CapEff !== "0000000000000000", + "unsafe control unexpectedly had no capabilities", + ); +} + +export function assertHardenedProof(proof) { + assert(proof.mode === "hardened-probe", "hardened proof mode mismatch"); + assert(proof.mount_ids_distinct === true, "hardened proof lacked distinct mounts"); + assert(proof.source_unchanged === true, "hardened child changed DUMMY source"); + const status = proof.child.identity.status; + for (const name of ["CapInh", "CapPrm", "CapEff", "CapBnd", "CapAmb"]) + assert(status[name] === "0000000000000000", `${name} was not empty`); + assert(status.NoNewPrivs === "1", "no_new_privs was not active"); + assert(status.Seccomp === "2", "seccomp filter mode was not active"); + for (const name of [ + "direct_write_target", + "direct_write_source", + "unlink_target", + "unlink_source", + "rename_over_target", + "rename_over_source", + "symlink_replacement", + "symlink_source_replacement", + "hardlink_target", + "hardlink_source", + "create_target_parent_entry", + "create_source_parent_entry", + "unlink_target_parent", + "unlink_source_parent", + "rename_target_parent", + "rename_source_parent", + "symlink_in_target_parent", + "symlink_in_source_parent", + "hardlink_into_target_parent", + "hardlink_into_source_parent", + "nested_user_mount_namespace", + "nsenter_self", + "nsenter_supervisor", + "uid_change", + "remount_target_rw", + "write_after_remount", + "unmount_target", + "bind_parent_alias", + ]) { + assert(proof.child[name].exit !== 0, `${name} unexpectedly succeeded`); + } + assert(proof.child.source.unchanged === true, "hardened child source hash changed"); + assert(proof.child.alias_underlying_write.skipped === true, "alias write was attempted"); + assert(proof.child.scratch_write.exit === 0, "dedicated scratch path was not writable"); + assert(proof.parent_roots_read_only === true, "credential parent roots were not read-only"); + assert(proof.child.ordinary_node_child.exit === 0, "ordinary child process was blocked"); + assert( + proof.child.ordinary_node_child.stdout === "DUMMY-child-ok", + "ordinary child output wrong", + ); + const syscalls = proof.child.direct_namespace_syscalls; + assert(syscalls.exit === 0, "direct syscall probe failed to execute"); + const observations = new Map(); + for (const line of syscalls.stdout.split("\n")) { + const [key, value, extra] = line.split("="); + assert( + key && value && extra === undefined && !observations.has(key), + "syscall observations malformed", + ); + observations.set(key, value); + } + for (const prefix of ["", "inherited_"]) { + for (const name of [ + "setns", + "unshare", + "clone", + "mount", + "umount2", + "ptrace_attach", + "ptrace_seize", + "process_vm_readv", + "process_vm_writev", + "pidfd_getfd", + ]) { + assert( + observations.get(`${prefix}${name}`) === "-1:1", + "required DUMMY syscall denial missing", + ); + } + assert(observations.get(`${prefix}clone3`) === "-1:38", "clone3 denial missing"); + assert(observations.get(`${prefix}pidfd_open`) === "opened", "DUMMY pidfd target unavailable"); + assert(observations.get(`${prefix}proc_mem`) === "-1:13", "DUMMY proc memory denial missing"); + assert(observations.get(`${prefix}securebits`) === "15", "locked root securebits missing"); + assert(observations.get(`${prefix}NoNewPrivs`) === "1", "inherited no_new_privs missing"); + assert(observations.get(`${prefix}Seccomp`) === "2", "inherited seccomp missing"); + for (const name of ["CapInh", "CapPrm", "CapEff", "CapBnd", "CapAmb"]) { + assert( + observations.get(`${prefix}${name}`) === "0000000000000000", + "inherited capabilities not empty", + ); + } + } + assert(observations.get("dummy_target_unchanged") === "true", "DUMMY target integrity missing"); +} + +export function runDummySdkProof({ + root, + helper, + piRoot, + spawnSyncImpl = spawnSync, + expectedSdkDependency, +}) { + const temporaryRoot = realpathSync(tmpdir()); + const directoryFlags = + constants.O_RDONLY | constants.O_DIRECTORY | constants.O_NOFOLLOW | constants.O_CLOEXEC; + const temporaryFd = openSync(temporaryRoot, directoryFlags); + const temporaryAnchor = `/proc/${process.pid}/fd/${temporaryFd}`; + let name; + let proofFd; + let result; + let primary; + let failed = false; + try { + name = basename(mkdtempSync(join(temporaryAnchor, "factory-cp06-DUMMY-sdk-"))); + proofFd = openSync(join(temporaryAnchor, name), directoryFlags); + result = runDummySdkProofIn({ + root, + helper, + piRoot, + spawnSyncImpl, + expectedSdkDependency, + proofRoot: join(temporaryRoot, name), + }); + } catch (error) { + failed = true; + primary = error; + } + try { + if (name !== undefined) removeRetainedDirectory(temporaryAnchor, name, proofFd); + } catch { + throw new Cp06CleanupError( + failed ? primary : undefined, + unsupported("DUMMY SDK proof failed: CP06_DUMMY_SDK_CLEANUP_FAILED"), + ); + } finally { + if (proofFd !== undefined) closeSync(proofFd); + closeSync(temporaryFd); + } + if (failed) throw primary; + return result; +} + +function removeRetainedDirectory(parentAnchor, name, fd) { + let named; + try { + named = lstatSync(join(parentAnchor, name), { bigint: true }); + } catch (error) { + if (error?.code === "ENOENT") return; + throw error; + } + const pinned = fd === undefined ? undefined : fstatSync(fd, { bigint: true }); + if (pinned === undefined || named.dev !== pinned.dev || named.ino !== pinned.ino) { + throw new Error("DUMMY SDK proof root identity changed"); + } + removeAnchoredEntry(parentAnchor, name); +} + +function runDummySdkProofIn({ + root, + helper, + piRoot, + spawnSyncImpl, + expectedSdkDependency, + proofRoot, +}) { + let result; + try { + result = withInheritedExecutables([helper], ([inherited]) => + spawnSyncImpl( + "unshare", + [ + "--user", + "--map-root-user", + "--mount", + "--propagation", + "private", + "--", + "setpriv", + "--no-new-privs", + "--bounding-set=-all", + "--inh-caps=-all", + "--ambient-caps=-all", + "--securebits=+noroot,+noroot_locked,+no_setuid_fixup,+no_setuid_fixup_locked", + "--", + inherited.path, + process.execPath, + "--experimental-import-meta-resolve", + join(root, "scripts/cp06-dummy-sdk-proof.mjs"), + piRoot, + proofRoot, + ], + { + cwd: root, + encoding: "utf8", + env: { + PATH: process.env.PATH ?? "/usr/bin:/bin", + LANG: process.env.LANG ?? "C.UTF-8", + PI_OFFLINE: "1", + PI_SKIP_VERSION_CHECK: "1", + PI_TELEMETRY: "0", + }, + timeout: 120_000, + maxBuffer: 1024 * 1024, + stdio: ["pipe", "pipe", "pipe", inherited.fd], + }, + ), + ); + } catch { + throw unsupported("DUMMY SDK proof failed: CP06_DUMMY_SDK_LAUNCH_FAILED"); + } + if (result.error !== undefined || result.status !== 0) { + const code = + result.error?.code === "ETIMEDOUT" + ? "CP06_DUMMY_SDK_TIMEOUT" + : result.error !== undefined + ? "CP06_DUMMY_SDK_LAUNCH_FAILED" + : "CP06_DUMMY_SDK_CHILD_EXIT"; + throw unsupported(`DUMMY SDK proof failed: ${code}`); + } + const trustedDependency = expectedSdkDependency ?? resolvePinnedPiAi(piRoot).provenance; + return validateDummySdkProof( + parseCompactJson(result.stdout, "DUMMY SDK proof"), + trustedDependency, + dummySdkOriginalPaths(proofRoot), + ); +} + +export function dummySdkOriginalPaths(proofRoot) { + return { + sdkWorker: join(proofRoot, "DUMMY-worker", "DUMMY-original.md"), + outcome: join(proofRoot, "DUMMY-outcome", "docs", "AUTH.md"), + }; +} + +export function validateDummySdkProof(proof, trustedDependency, originalPaths) { + assert( + typeof originalPaths?.sdkWorker === "string" && typeof originalPaths?.outcome === "string", + "DUMMY SDK expected original paths missing", + ); + assertExactKeys(proof, [ + "actual_sdk_outcomes", + "auth_cases", + "fixture_origin", + "network_calls", + "result", + "schema_version", + "sdk_dependency", + "sdk_worker", + "semantic_acceptance", + ]); + assert(proof.schema_version === 1, "DUMMY SDK schema mismatch"); + assert(proof.result === "pass", "DUMMY SDK proof did not pass"); + assert(proof.fixture_origin === true, "DUMMY SDK fixture origin missing"); + assert(proof.semantic_acceptance === false, "DUMMY SDK semantic acceptance was claimed"); + assert(proof.network_calls === 0, "DUMMY SDK attempted network access"); + validateAuthCases(proof.auth_cases); + validateSdkWorker(proof.sdk_worker, originalPaths.sdkWorker); + validateSdkDependency(proof.sdk_dependency, trustedDependency); + validateOutcomeProof(proof.actual_sdk_outcomes, trustedDependency, originalPaths.outcome); + return proof; +} + +function validateAuthCases(cases) { + assert(Array.isArray(cases) && cases.length === 3, "DUMMY SDK auth cases incomplete"); + const expected = new Map([ + ["expired", { result: "blocked", toAuth: 0, modifyDenials: 1 }], + ["near_expiry", { result: "blocked", toAuth: 0, modifyDenials: 1 }], + ["unexpired", { result: "resolved", toAuth: 1, modifyDenials: 0 }], + ]); + const seen = new Set(); + for (const entry of cases) { + assertExactKeys(entry, [ + "credential_store", + "persistence_operations", + "refresh_callbacks", + "result", + "scenario", + "source_unchanged", + "to_auth_calls", + ]); + const expectation = expected.get(entry.scenario); + assert(expectation !== undefined && !seen.has(entry.scenario), "DUMMY SDK auth case mismatch"); + seen.add(entry.scenario); + assert(entry.result === expectation.result, "DUMMY SDK auth result mismatch"); + assert(entry.refresh_callbacks === 0, "DUMMY SDK refresh callback ran"); + assert(entry.to_auth_calls === expectation.toAuth, "DUMMY SDK toAuth count mismatch"); + assert(entry.persistence_operations === 0, "DUMMY SDK persistence occurred"); + assert(entry.source_unchanged === true, "DUMMY SDK auth source changed"); + validateCredentialAudit(entry.credential_store, { + reads: 1, + lists: 0, + modify_denials: expectation.modifyDenials, + delete_denials: 0, + }); + } +} + +function validateSdkWorker(worker, originalPath) { + assertExactKeys(worker, [ + "active_tools", + "credential_store", + "event_count", + "event_sha256", + "in_memory_session", + "read_audit", + "refresh_callbacks", + ]); + assert( + Array.isArray(worker.active_tools) && worker.active_tools.join(",") === "read", + "DUMMY SDK tools mismatch", + ); + assert(worker.in_memory_session === true, "DUMMY SDK session was persisted"); + assert( + Number.isSafeInteger(worker.event_count) && worker.event_count > 0, + "DUMMY SDK event count missing", + ); + assert(isSha256(worker.event_sha256), "DUMMY SDK event digest missing"); + assert(worker.refresh_callbacks === 0, "DUMMY SDK worker refresh callback ran"); + validateCredentialAudit(worker.credential_store, { modify_denials: 0, delete_denials: 0 }); + validateReadAudit(worker.read_audit, { + toolCallId: "DUMMY-read", + originalPath, + original: DUMMY_SDK_ORIGINAL, + eventCount: worker.event_count, + }); +} + +function validateSdkDependency(dependency, trustedDependency) { + assertExactKeys(dependency, [ + "entry", + "entry_sha256", + "manifest_sha256", + "name", + "package_sha256", + "resolution", + "root", + "tarball_integrity", + "version", + ]); + assertExactKeys(trustedDependency, [ + "entry", + "entry_sha256", + "manifest_sha256", + "name", + "package_sha256", + "resolution", + "root", + "tarball_integrity", + "version", + ]); + for (const [key, value] of Object.entries(trustedDependency)) { + assert(dependency[key] === value, `DUMMY SDK dependency ${key} mismatch`); + } + for (const key of ["entry_sha256", "manifest_sha256", "package_sha256"]) { + assert(isSha256(dependency[key]), "DUMMY SDK dependency digest missing"); + } +} + +function validateOutcomeProof(outcomes, trustedDependency, originalPath) { + assertExactKeys(outcomes, ["cases", "fixture_origin", "semantic_acceptance"]); + assert(outcomes.fixture_origin === true, "DUMMY outcome fixture origin missing"); + assert(outcomes.semantic_acceptance === false, "DUMMY outcome semantic acceptance was claimed"); + assert(Array.isArray(outcomes.cases), "DUMMY outcome cases missing"); + const expected = new Map([ + ["valid", [0, 1, 3]], + ["fenced-valid", [0, 1, 3]], + ["extra-before-fence", [75, 1, 3]], + ["empty-gap", [75, 1, 3]], + ["missing-gap", [75, 1, 3]], + ["contradictory-gap", [75, 1, 3]], + ["extra-evidence", [75, 1, 3]], + ["constraint-pass", [75, 1, 3]], + ["missing-reads", [75, 1, 3]], + ["credential-read", [75, 1, 3]], + ["symlink-read", [75, 1, 3]], + ["earlier-assistant-claim", [75, 1, 3]], + ["final-extra-channel", [75, 1, 4]], + ["unexpected-tool-secret", [75, 1, 3]], + ["expired", [75, 0, 0]], + ["near-expiry", [75, 0, 0]], + ["timeout", [70, 1, 2]], + ]); + const seen = new Set(); + for (const entry of outcomes.cases) { + assert( + entry !== null && typeof entry === "object" && !Array.isArray(entry), + "DUMMY outcome case malformed", + ); + const [expectedExit, expectedRuntimeCreate, expectedToAuth] = + expected.get(entry.scenario) ?? []; + assert( + expectedExit !== undefined && !seen.has(entry.scenario), + "DUMMY outcome scenario mismatch", + ); + const expectedKeys = [ + "default_storage", + "exit", + "expected_exit_code", + "exit_code", + "fixture_origin", + "network", + "refresh", + "runtime_create", + "scenario", + "sdk_dependency", + "source_unchanged", + "to_auth", + ]; + if (["valid", "fenced-valid"].includes(entry.scenario)) + expectedKeys.push("read_audit", "event_count"); + assertExactKeys(entry, expectedKeys); + seen.add(entry.scenario); + assert( + entry.exit_code === expectedExit && + entry.expected_exit_code === expectedExit && + entry.exit === expectedExit, + "DUMMY outcome exit mismatch", + ); + assert(entry.fixture_origin === true, "DUMMY outcome fixture origin missing"); + validateSdkDependency(entry.sdk_dependency, trustedDependency); + assert(entry.source_unchanged === true, "DUMMY outcome source changed"); + assert( + entry.default_storage === 0 && entry.network === 0 && entry.refresh === 0, + "DUMMY outcome side effect occurred", + ); + assert( + entry.runtime_create === expectedRuntimeCreate && entry.to_auth === expectedToAuth, + "DUMMY outcome runtime/auth count mismatch", + ); + if (["valid", "fenced-valid"].includes(entry.scenario)) { + validateReadAudit(entry.read_audit, { + toolCallId: "DUMMY-actual-worker-read", + originalPath, + original: DUMMY_OUTCOME_ORIGINAL, + eventCount: entry.event_count, + }); + } + } + assert(seen.size === expected.size, "DUMMY outcome cases incomplete"); +} + +function validateCredentialAudit(audit, expected) { + assertExactKeys(audit, ["delete_denials", "lists", "modify_denials", "reads"]); + for (const key of ["delete_denials", "lists", "modify_denials", "reads"]) { + assert(Number.isSafeInteger(audit[key]) && audit[key] >= 0, "DUMMY credential audit malformed"); + if (Object.hasOwn(expected, key)) { + assert(audit[key] === expected[key], `DUMMY credential audit ${key} mismatch`); + } + } + assert(audit.reads >= 1, "DUMMY credential audit omitted the credential read"); +} + +export function validateReadAudit(audit, { toolCallId, originalPath, original, eventCount }) { + assertExactKeys(audit, [ + "completed_successfully", + "end_event_index", + "exact_original", + "expected_sha256", + "other_tool_calls", + "other_tool_completions", + "project_read_count", + "project_read_paths", + "read_count", + "rejected_read_paths", + "response_event_index", + "returned_bytes", + "returned_sha256", + "start_event_index", + "tool_call_id", + ]); + const isEmptyArray = (value) => Array.isArray(value) && value.length === 0; + assert(Number.isSafeInteger(eventCount) && eventCount > 0, "read audit event count missing"); + assert( + typeof original === "string" || Buffer.isBuffer(original), + "read audit trusted bytes missing", + ); + const expectedBytes = Buffer.from(original); + const expectedHash = sha256(expectedBytes); + const isIndex = (value) => Number.isSafeInteger(value) && value >= 0 && value < eventCount; + assert( + audit.read_count === 1 && + audit.project_read_count === 1 && + Array.isArray(audit.project_read_paths) && + audit.project_read_paths.length === 1 && + audit.project_read_paths[0] === originalPath && + isEmptyArray(audit.rejected_read_paths) && + isEmptyArray(audit.other_tool_calls) && + isEmptyArray(audit.other_tool_completions), + "DUMMY read audit did not record exactly one original read", + ); + assert( + audit.completed_successfully === true && audit.exact_original === true, + "DUMMY exact original read missing", + ); + assert( + audit.expected_sha256 === expectedHash && + audit.returned_sha256 === expectedHash && + audit.returned_bytes === expectedBytes.length, + "DUMMY read audit bytes mismatch", + ); + assert(audit.tool_call_id === toolCallId, "DUMMY read audit tool call mismatch"); + assert( + isIndex(audit.start_event_index) && + isIndex(audit.end_event_index) && + isIndex(audit.response_event_index) && + audit.start_event_index < audit.end_event_index && + audit.end_event_index < audit.response_event_index, + "DUMMY read audit event order mismatch", + ); +} + +function assertExactKeys(value, keys) { + assert( + value !== null && typeof value === "object" && !Array.isArray(value), + "DUMMY SDK proof object malformed", + ); + assert( + Object.keys(value).sort().join(",") === keys.slice().sort().join(","), + "DUMMY SDK proof keys mismatch", + ); +} + +function isSha256(value) { + return typeof value === "string" && /^[0-9a-f]{64}$/u.test(value); +} + +function resolvePiPackage(root, options) { + try { + return resolvePinnedPiInstall({ + projectRoot: root, + packageRoot: options.piPackageRoot ?? process.env.CP06_PI_PACKAGE_ROOT, + executable: options.piExecutable ?? process.env.CP06_PI_BIN, + spawnSyncImpl: options.piSpawnSyncImpl, + environment: options.environment, + }); + } catch { + throw unsupported("explicit task-local Pi 0.85.1 installation is unavailable"); + } +} + +function compilePinnedArtifact({ root, artifactAnchor, name, source, extraArgs, spawn }) { + const leaf = join(artifactAnchor, name); + const fd = openSync( + leaf, + constants.O_CREAT | constants.O_EXCL | constants.O_RDWR | constants.O_NOFOLLOW, + 0o700, + ); + let executionFd; + try { + fchmodSync(fd, 0o700); + const pinned = fstatSync(fd, { bigint: true }); + const result = spawn( + "cc", + [ + "-std=c11", + "-O2", + "-Wall", + "-Wextra", + "-Werror", + source, + ...extraArgs, + "-o", + "/proc/self/fd/3", + ], + { + cwd: root, + encoding: "utf8", + timeout: 60_000, + stdio: ["ignore", "pipe", "pipe", fd], + }, + ); + if (result.error !== undefined || result.status !== 0) { + throw unsupported("required command failed: cc"); + } + fchmodSync(fd, 0o700); + const current = fstatSync(fd, { bigint: true }); + const named = lstatSync(leaf, { bigint: true }); + if (!current.isFile() || !sameInode(pinned, current) || !sameInode(pinned, named)) { + throw unsupported("compiled helper identity changed"); + } + executionFd = openSync(`/proc/${process.pid}/fd/${fd}`, constants.O_RDONLY); + const executable = fstatSync(executionFd, { bigint: true }); + const executionLeaf = lstatSync(leaf, { bigint: true }); + if ( + !executable.isFile() || + !executionLeaf.isFile() || + !sameInode(pinned, executable) || + !sameInode(pinned, executionLeaf) + ) { + throw unsupported("compiled helper identity changed"); + } + return executionFd; + } catch (error) { + if (executionFd !== undefined) closeSync(executionFd); + throw error; + } finally { + closeSync(fd); + } +} + +function checked(command, args, cwd, spawn = spawnSync) { + const result = spawn(command, args, { cwd, encoding: "utf8", timeout: 60_000 }); + if (result.error !== undefined || result.status !== 0) { + throw unsupported(`required command failed: ${command}`); + } + return result; +} + +function sameInode(a, b) { + return a.dev === b.dev && a.ino === b.ino; +} + +function commandText(spawn, command, args, cwd) { + const result = spawn(command, args, { cwd, encoding: "utf8", timeout: 30_000 }); + if (result.error !== undefined || result.status !== 0) { + throw unsupported(`required isolation probe failed: ${command}`); + } + return result.stdout.trim(); +} + +function parseCompactJson(value, label) { + try { + return JSON.parse(String(value).trim()); + } catch (cause) { + throw unsupported(`${label} did not return compact JSON`, { cause }); + } +} + +function summarizeUnsafe(proof) { + return { + retained_capabilities: proof.child.identity.status.CapEff, + remount_succeeded: proof.child.remount_target_rw.exit === 0, + unmount_succeeded: proof.child.unmount_target.exit === 0, + parent_bind_succeeded: proof.child.bind_parent_alias.exit === 0, + dummy_source_changed: !proof.source_unchanged, + }; +} + +function summarizeHardened(proof) { + return { + capability_sets_zero: true, + no_new_privs: true, + seccomp_filter: true, + locked_root_securebits: 15, + dummy_process_memory_denials: true, + inherited_subprocess_denials: true, + dummy_process_target_unchanged: true, + ordinary_child_allowed: true, + direct_writes_denied: true, + replacement_denied: true, + remount_unmount_denied: true, + namespace_entry_creation_denied: true, + uid_change_denied: true, + alternate_parent_bind_denied: true, + credential_parent_roots_read_only: proof.parent_roots_read_only, + dedicated_scratch_writable: proof.child.scratch_write.exit === 0, + dummy_source_unchanged: proof.source_unchanged, + }; +} + +function assert(condition, message) { + if (!condition) throw new Error(`CP-06 security assertion failed: ${message}`); +} + +function unsupported(message) { + return new Cp06IsolationUnsupportedError(message); +} + +function sha256(value) { + return createHash("sha256").update(value).digest("hex"); +} diff --git a/scripts/cp06-credential-isolation.mjs b/scripts/cp06-credential-isolation.mjs new file mode 100644 index 0000000..4fe2515 --- /dev/null +++ b/scripts/cp06-credential-isolation.mjs @@ -0,0 +1,438 @@ +import { createHash } from "node:crypto"; +import { spawnSync } from "node:child_process"; +import { constants, realpathSync } from "node:fs"; +import { + link, + lstat, + mkdir, + mkdtemp, + open, + readFile, + rename, + rm, + symlink, + unlink, + writeFile, +} from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { basename, dirname, join, resolve } from "node:path"; + +const MAX_CREDENTIAL_BYTES = 1024 * 1024; +const REQUIRED_VFS_OPTIONS = ["ro", "nosuid", "nodev", "noexec"]; + +export async function proveDummyCredentialIsolation(options = {}) { + const root = resolve(options.root ?? process.cwd()); + const proofRoot = await mkdtemp( + join(resolve(options.tmpRoot ?? tmpdir()), "factory-cp06-auth-proof-"), + ); + const source = join(proofRoot, "DUMMY-source-auth.json"); + const target = join(proofRoot, "agent", "DUMMY-target-auth.json"); + await writeFile(source, JSON.stringify({ fixture: "DUMMY", token: "DUMMY-NOT-REAL" })); + const hashBefore = await fileHash(source); + let mount; + try { + mount = await mountLiveCredentialReadOnly({ + root, + source, + target, + mountHelper: options.mountHelper, + spawnSyncImpl: options.spawnSyncImpl, + }); + await assertDestructiveCredentialWritesDenied([source, target]); + const hashAfter = await fileHash(source); + if (hashAfter !== hashBefore) { + throw new Error("DUMMY credential source changed during isolation proof"); + } + return { + source, + target, + source_unchanged: true, + destructive_operations_denied: true, + mount_ids: mount.mountIds, + }; + } finally { + await mount?.cleanup(); + await rm(proofRoot, { recursive: true, force: true }); + } +} + +/** + * Pin one regular credential inode, bind it over both its source path and a fresh + * regular target, and make both mount instances read-only before closing the FD. + * The native helper consumes the retained source and destination descriptors. + * This function must run only in the private setup namespace. + */ +export async function mountLiveCredentialReadOnly(options) { + const root = resolve(options.root ?? process.cwd()); + const source = resolveRequiredPath(options.source, "credential source"); + const target = resolveRequiredPath(options.target, "credential target"); + const helper = resolveRequiredPath(options.mountHelper, "credential mount helper"); + const maxBytes = options.maxBytes ?? MAX_CREDENTIAL_BYTES; + const spawn = options.spawnSyncImpl ?? spawnSync; + const statPath = options.lstatImpl ?? lstat; + const protectParents = options.protectParents !== false; + const sourceLeaf = options.sourceLeaf ?? basename(source); + const targetLeaf = options.targetLeaf ?? basename(target); + let handle; + let sourceParentHandle; + let targetParentHandle; + let sourceMountPath = source; + let targetMountPath = target; + const mountStack = []; + let parentHandlesTransferred = false; + try { + sourceParentHandle = await openParent(options.sourceParentAnchor, dirname(source)); + const sourceParentPath = + options.sourceParentAnchor ?? `/proc/${process.pid}/fd/${sourceParentHandle.fd}`; + sourceMountPath = join(sourceParentPath, sourceLeaf); + handle = await open( + sourceMountPath, + constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_CLOEXEC, + ); + const pinned = await handle.stat({ bigint: true }); + const named = await statPath(sourceMountPath, { bigint: true }); + assertPinnedRegularFile(pinned, named, maxBytes); + + targetParentHandle = await openParent(options.targetParentAnchor, dirname(target), { + create: true, + }); + const targetParentPath = + options.targetParentAnchor ?? `/proc/${process.pid}/fd/${targetParentHandle.fd}`; + targetMountPath = join(targetParentPath, targetLeaf); + const placeholder = await createPlaceholder(targetMountPath); + await options.beforeMount?.({ sourceMountPath, targetMountPath }); + + const mountOptions = { helper, root, spawn }; + bindFile(mountOptions, handle, pinned, sourceParentHandle, sourceLeaf, pinned, source); + mountStack.push({ path: sourceMountPath, recursive: false }); + bindFile(mountOptions, handle, pinned, targetParentHandle, targetLeaf, placeholder, target); + mountStack.push({ path: targetMountPath, recursive: false }); + const parentMounts = []; + if (protectParents) { + const parents = await uniqueParents([ + { handle: sourceParentHandle, path: sourceParentPath, visible: dirname(source) }, + { handle: targetParentHandle, path: targetParentPath, visible: dirname(target) }, + ]); + for (const parent of parents) { + bindTree(mountOptions, parent); + mountStack.push({ path: parent.path, recursive: true }); + parentMounts.push(inspectReadOnlyMount(parent.path, { spawn })); + } + } + + const sourceMount = inspectReadOnlyMount(sourceMountPath, { spawn }); + const targetMount = inspectReadOnlyMount(targetMountPath, { spawn }); + if (sourceMount.id === targetMount.id) { + throw new Error("credential source and target did not receive distinct mount instances"); + } + for (const path of [sourceMountPath, targetMountPath]) { + const mounted = await statPath(path, { bigint: true }); + if (!mounted.isFile() || !sameStatIdentity(pinned, mounted)) { + throw new Error(`credential mount identity changed: ${path}`); + } + } + await handle.close(); + handle = undefined; + + let cleaned = false; + parentHandlesTransferred = true; + return { + source, + target, + sourceIdentity: statIdentity(pinned), + mountIds: { source: sourceMount.id, target: targetMount.id }, + parentMounts: parentMounts.map((entry) => ({ id: entry.id, target: entry.target })), + async verifyIntegrity() { + for (const path of [sourceMountPath, targetMountPath]) { + const mounted = await statPath(path, { bigint: true }); + if (!mounted.isFile() || !sameStatIdentity(pinned, mounted)) return false; + } + const currentSourceMount = inspectReadOnlyMount(sourceMountPath, { spawn }); + const currentTargetMount = inspectReadOnlyMount(targetMountPath, { spawn }); + if (currentSourceMount.id !== sourceMount.id || currentTargetMount.id !== targetMount.id) { + return false; + } + for (const expected of parentMounts) { + const current = inspectReadOnlyMount(expected.target, { spawn }); + if (current.id !== expected.id) return false; + } + return true; + }, + async cleanup() { + if (cleaned) return; + cleaned = true; + const failures = []; + unmountAll(mountStack, { root, spawn, failures }); + for (const [label, parentHandle] of [ + ["source", sourceParentHandle], + ["target", targetParentHandle], + ]) { + try { + await parentHandle?.close(); + } catch (error) { + failures.push(error?.message ?? `${label} parent close failed`); + } + } + sourceParentHandle = undefined; + targetParentHandle = undefined; + if (failures.length > 0) { + throw new Error(`credential mount cleanup failed: ${failures.join("; ")}`); + } + }, + }; + } catch (error) { + const cleanupFailures = []; + unmountAll(mountStack, { root, spawn, failures: cleanupFailures }); + if (cleanupFailures.length > 0) { + throw new AggregateError( + [error, ...cleanupFailures.map((message) => new Error(message))], + "credential mount setup and cleanup failed", + ); + } + throw error; + } finally { + await handle?.close(); + if (!parentHandlesTransferred) { + await sourceParentHandle?.close(); + await targetParentHandle?.close(); + } + } +} + +async function openParent(anchor, path, { create = false } = {}) { + if (anchor !== undefined) { + return open(anchor, constants.O_RDONLY | constants.O_DIRECTORY | constants.O_CLOEXEC); + } + const directoryFlags = + constants.O_RDONLY | constants.O_DIRECTORY | constants.O_NOFOLLOW | constants.O_CLOEXEC; + let handle = await open("/", directoryFlags); + try { + for (const component of resolve(path).split("/").filter(Boolean)) { + const child = join(`/proc/${process.pid}/fd/${handle.fd}`, component); + if (create) { + try { + await mkdir(child, { mode: 0o700 }); + } catch (error) { + if (error?.code !== "EEXIST") throw error; + } + } + const next = await open(child, directoryFlags); + await handle.close(); + handle = next; + } + const parent = handle; + handle = undefined; + return parent; + } finally { + await handle?.close(); + } +} + +async function createPlaceholder(path) { + const placeholder = await open( + path, + constants.O_WRONLY | + constants.O_CREAT | + constants.O_EXCL | + constants.O_NOFOLLOW | + constants.O_CLOEXEC, + 0o600, + ); + try { + const identity = await placeholder.stat({ bigint: true }); + if (!identity.isFile() || identity.nlink !== 1n) { + throw new Error("credential target placeholder is not one fresh regular file"); + } + return identity; + } finally { + await placeholder.close(); + } +} + +async function uniqueParents(parents) { + const unique = new Map(); + for (const parent of parents) { + const identity = await parent.handle.stat({ bigint: true }); + const key = `${identity.dev}:${identity.ino}`; + if (!unique.has(key)) unique.set(key, { ...parent, identity }); + } + return [...unique.values()].sort( + (left, right) => right.visible.split("/").length - left.visible.split("/").length, + ); +} + +function bindFile(options, sourceHandle, sourceIdentity, parentHandle, leaf, expected, visible) { + checkedSpawn( + options.helper, + [ + "--cp06-bind-file", + String(sourceIdentity.dev), + String(sourceIdentity.ino), + leaf, + String(expected.dev), + String(expected.ino), + visible, + ], + { ...options, fds: [sourceHandle.fd, parentHandle.fd] }, + ); +} + +function bindTree(options, parent) { + checkedSpawn( + options.helper, + ["--cp06-bind-tree", String(parent.identity.dev), String(parent.identity.ino), parent.visible], + { ...options, fds: [parent.handle.fd] }, + ); +} + +function unmountAll(mounted, options) { + for (const entry of [...mounted].reverse()) unmount(entry.path, { ...options, ...entry }); +} + +export async function assertDestructiveCredentialWritesDenied(paths, operations = {}) { + const write = operations.writeFile ?? writeFile; + const move = operations.rename ?? rename; + const remove = operations.unlink ?? unlink; + const makeSymlink = operations.symlink ?? symlink; + const makeHardlink = operations.link ?? link; + const cleanup = operations.rm ?? rm; + const scratchRoot = operations.scratchRoot ?? tmpdir(); + for (const target of paths) { + await expectDenied(`write to ${target}`, () => write(target, "DUMMY unauthorized mutation")); + await expectDenied(`unlink of ${target}`, () => remove(target)); + const nonce = `${process.pid}-${Date.now()}-${Math.random().toString(16).slice(2)}`; + const replacement = join(scratchRoot, `.DUMMY-replacement-${nonce}`); + const symbolic = join(scratchRoot, `.DUMMY-symlink-${nonce}`); + const hard = join(scratchRoot, `.DUMMY-hardlink-${nonce}`); + await write(replacement, "DUMMY unauthorized replacement"); + try { + await expectDenied(`atomic replacement of ${target}`, () => move(replacement, target)); + await expectDenied(`symlink replacement of ${target}`, async () => { + await remove(target); + await makeSymlink("/tmp/DUMMY-does-not-exist", target); + }); + await expectDenied(`hardlink of ${target}`, () => makeHardlink(target, hard)); + } finally { + await cleanup(replacement, { force: true }); + await cleanup(symbolic, { force: true }); + await cleanup(hard, { force: true }); + } + } +} + +export function inspectReadOnlyMount(path, options = {}) { + const spawn = options.spawn ?? options.spawnSyncImpl ?? spawnSync; + const result = spawn( + "findmnt", + ["--json", "--noheadings", "--output", "ID,TARGET,VFS-OPTIONS", "--target", path], + { encoding: "utf8" }, + ); + if (result.error !== undefined) throw result.error; + if (result.status !== 0) { + throw new Error( + `read-only credential isolation unavailable: ${result.stderr ?? "findmnt failed"}`, + ); + } + let entry; + try { + const parsed = JSON.parse(result.stdout); + [entry] = parsed.filesystems ?? []; + } catch (cause) { + throw new Error("read-only credential mount inspection returned invalid JSON", { cause }); + } + const optionsList = String(entry?.["vfs-options"] ?? "").split(","); + if ( + entry === undefined || + ![resolve(path), realpathSync(path)].includes(resolve(entry.target)) || + !REQUIRED_VFS_OPTIONS.every((option) => optionsList.includes(option)) + ) { + throw new Error(`credential mount lacks required read-only VFS flags: ${path}`); + } + return { id: String(entry.id), target: resolve(entry.target), options: optionsList }; +} + +export function statIdentity(stat) { + return { + dev: String(stat.dev), + ino: String(stat.ino), + size: String(stat.size), + nlink: String(stat.nlink), + mode: String(stat.mode), + mtime_ns: String(stat.mtimeNs), + ctime_ns: String(stat.ctimeNs), + }; +} + +function sameStatIdentity(expected, actual) { + return ( + expected.dev === actual.dev && + expected.ino === actual.ino && + expected.size === actual.size && + expected.nlink === actual.nlink && + expected.mode === actual.mode && + expected.mtimeNs === actual.mtimeNs && + expected.ctimeNs === actual.ctimeNs + ); +} + +function assertPinnedRegularFile(pinned, named, maxBytes) { + if (!pinned.isFile() || !named.isFile()) { + throw new Error("Pi credential source is not a regular file"); + } + if (pinned.dev !== named.dev || pinned.ino !== named.ino) { + throw new Error("Pi credential source changed while it was pinned"); + } + if (pinned.nlink !== 1n) { + throw new Error("Pi credential source must have exactly one hard link"); + } + if (pinned.size <= 0n || pinned.size > BigInt(maxBytes)) { + throw new Error("Pi credential source size is outside the allowed bound"); + } +} + +function checkedSpawn(command, args, options) { + const result = options.spawn(command, args, { + cwd: options.root, + encoding: "utf8", + stdio: ["ignore", "pipe", "pipe", ...(options.fds ?? [])], + }); + if (result.error !== undefined) throw result.error; + if (result.status !== 0) { + throw new Error( + `read-only credential isolation unavailable: ${result.stderr ?? `${command} failed`}`, + ); + } + return result; +} + +function unmount(path, options) { + const result = options.spawn("umount", options.recursive ? ["--recursive", path] : [path], { + cwd: options.root, + encoding: "utf8", + }); + if (result.error !== undefined || result.status !== 0) { + options.failures.push( + `${path}: ${result.error?.message ?? result.stderr?.trim() ?? `exit ${result.status}`}`, + ); + } +} + +function resolveRequiredPath(value, label) { + if (typeof value !== "string" || value.length === 0) throw new Error(`${label} is missing`); + return resolve(value); +} + +async function expectDenied(label, action) { + try { + await action(); + } catch { + return; + } + throw new Error(`read-only credential isolation permitted ${label}`); +} + +async function fileHash(path) { + return createHash("sha256") + .update(await readFile(path)) + .digest("hex"); +} diff --git a/scripts/cp06-dummy-cli-shims.mjs b/scripts/cp06-dummy-cli-shims.mjs new file mode 100644 index 0000000..b7c5ec7 --- /dev/null +++ b/scripts/cp06-dummy-cli-shims.mjs @@ -0,0 +1,150 @@ +// Development-only DUMMY executable shims for CLI failure-composition proofs. +// Only the evaluated worker launch and the credential-target unmount are +// replaced; every other setpriv/umount invocation runs the real command. +import { accessSync, closeSync, constants, openSync, writeSync } from "node:fs"; +import { delimiter, join } from "node:path"; +import { fileURLToPath } from "node:url"; + +const AUDIT = { + stage: "audit", + code: "CP06_CHILD_OUTPUT_INVALID", + exit_code: 70, + description: "isolated child output was invalid", +}; +const CHILD_EXIT = { + stage: "child-exit", + code: "CP06_CHILD_EXIT_FAILED", + exit_code: 1, + description: "isolated child exited unsuccessfully", +}; +const CLEANUP = { + stage: "cleanup", + code: "CP06_CREDENTIAL_CLEANUP_FAILED", + exit_code: null, + description: "credential mount cleanup failed", +}; +const compound = (causes) => ({ + schema_version: 1, + stage: "cleanup", + code: "CP06_CLEANUP_FAILED", + exit_code: 74, + description: "credential namespace cleanup failed", + causes, +}); + +/** Child behavior, injected cleanup failure and the expected supervisor record. */ +export const DUMMY_CLI_SCENARIOS = { + "malformed-output-and-cleanup": { + child: "malformed", + cleanupFails: true, + record: compound([AUDIT, CLEANUP]), + }, + "semantic-audit-and-cleanup": { + child: "semantic", + cleanupFails: true, + record: compound([AUDIT, CLEANUP]), + }, + "child-exit-and-cleanup": { + child: "exit", + cleanupFails: true, + record: compound([CHILD_EXIT, CLEANUP]), + }, + "audit-only": { + child: "malformed", + cleanupFails: false, + record: { schema_version: 1, ...AUDIT, causes: [] }, + }, + "forged-provenance": { + child: "forged-provenance", + cleanupFails: false, + record: { schema_version: 1, ...AUDIT, causes: [] }, + }, + "cleanup-only": { child: "valid", cleanupFails: true, record: compound([CLEANUP]) }, + success: { child: "valid", cleanupFails: false, record: null }, +}; + +/** Scenarios run without the DUMMY trust flag against the task-local installation. */ +export const DUMMY_CLI_INSTALLED_SCENARIOS = { + "installed-provenance": { child: "installed", cleanupFails: false, record: null }, + "dummy-provenance-untrusted": { + child: "valid", + cleanupFails: false, + record: { schema_version: 1, ...AUDIT, causes: [] }, + }, +}; + +export const DUMMY_CLI_SECRET = "DUMMY-SECRET-CLI-MARKER"; + +const dummyEvidence = fileURLToPath(new URL("./cp06-dummy-worker-evidence.mjs", import.meta.url)); + +/** Write scenario shims into `anchor`; returns a PATH that resolves them first. */ +export function writeDummyCliShims({ anchor, path, scenario, inheritedPath = process.env.PATH }) { + const selected = DUMMY_CLI_SCENARIOS[scenario] ?? DUMMY_CLI_INSTALLED_SCENARIOS[scenario]; + if (selected === undefined) throw new Error("unsupported DUMMY CLI scenario"); + const realSetpriv = locate("setpriv", inheritedPath); + const realUmount = locate("umount", inheritedPath); + const child = { + malformed: `printf '%s' '${DUMMY_CLI_SECRET} malformed'; printf '%s' '${DUMMY_CLI_SECRET}' >&2; exit 0`, + semantic: `exec '${process.execPath}' '${dummyEvidence}' "$last" invalid`, + exit: `printf '%s' '${DUMMY_CLI_SECRET}' >&2; exit 1`, + valid: `exec '${process.execPath}' '${dummyEvidence}' "$last"`, + "forged-provenance": `exec '${process.execPath}' '${dummyEvidence}' "$last" forged-provenance`, + installed: `exec '${process.execPath}' --experimental-import-meta-resolve '${dummyEvidence}' "$last" installed`, + }[selected.child]; + writeExecutable( + anchor, + "setpriv", + `#!/bin/sh +for argument do last="$argument"; done +for argument do + case "$argument" in + */scripts/cp06-sdk-worker.mjs) ${child} ;; + esac +done +exec '${realSetpriv}' "$@" +`, + ); + writeExecutable( + anchor, + "umount", + `#!/bin/sh +for argument do last="$argument"; done +case "$last" in + */auth.json) ${selected.cleanupFails ? `printf '%s' '${DUMMY_CLI_SECRET}' >&2; exit 32` : ":"} ;; +esac +exec '${realUmount}' "$@" +`, + ); + return `${path}${delimiter}${inheritedPath}`; +} + +function locate(command, searchPath = "") { + for (const directory of searchPath.split(delimiter)) { + if (!directory.startsWith("/")) continue; + const candidate = join(directory, command); + try { + accessSync(candidate, constants.X_OK); + return candidate; + } catch {} + } + throw new Error(`DUMMY CLI shim cannot locate ${command}`); +} + +function writeExecutable(anchor, name, text) { + const fd = openSync( + join(anchor, name), + constants.O_WRONLY | + constants.O_CREAT | + constants.O_EXCL | + constants.O_NOFOLLOW | + constants.O_CLOEXEC, + 0o700, + ); + try { + const bytes = Buffer.from(text); + let offset = 0; + while (offset < bytes.length) offset += writeSync(fd, bytes, offset, bytes.length - offset); + } finally { + closeSync(fd); + } +} diff --git a/scripts/cp06-dummy-originals.mjs b/scripts/cp06-dummy-originals.mjs new file mode 100644 index 0000000..bd5f658 --- /dev/null +++ b/scripts/cp06-dummy-originals.mjs @@ -0,0 +1,29 @@ +export const DUMMY_SDK_ORIGINAL = + "# DUMMY authority\n\nrequest-supplied owner fields are ignored; ownership comes only from the authenticated principal.\n"; +export const DUMMY_OUTCOME_ORIGINAL = + "# DUMMY authority\n\nOwnership comes only from the authenticated principal; request-supplied owner fields are ignored.\n"; +export const DUMMY_PI_PROVENANCE = Object.freeze({ + install_root: "/DUMMY/sdk", + package_root: "/DUMMY/sdk/package", + executable: "/DUMMY/sdk/pi", + executable_entry: "/DUMMY/sdk/package/cli.js", + sdk_entry: "/DUMMY/sdk/package/index.js", + package_sha256: "0".repeat(64), + executable_sha256: "0".repeat(64), + sdk_entry_sha256: "0".repeat(64), + package_artifact: Object.freeze({ + manifest_sha256: "0".repeat(64), + tarball_integrity: "sha512-DUMMY", + }), + dependency: Object.freeze({ + entry: "/DUMMY/sdk/ai/index.js", + root: "/DUMMY/sdk/ai", + entry_sha256: "0".repeat(64), + manifest_sha256: "0".repeat(64), + package_sha256: "0".repeat(64), + name: "@earendil-works/pi-ai", + version: "0.85.1", + resolution: "esm-import-condition", + tarball_integrity: "sha512-DUMMY", + }), +}); diff --git a/scripts/cp06-dummy-outcome-proof.mjs b/scripts/cp06-dummy-outcome-proof.mjs new file mode 100644 index 0000000..553d40c --- /dev/null +++ b/scripts/cp06-dummy-outcome-proof.mjs @@ -0,0 +1,215 @@ +import { strict as assert } from "node:assert"; +import { spawnSync } from "node:child_process"; +import { mkdtemp, symlink } from "node:fs/promises"; +import { closeSync, constants, fstatSync, lstatSync, openSync, readdirSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { basename, dirname, join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { withCleanup } from "./cp06-worker-lifecycle.mjs"; +import { + openAnchoredDirectory, + readArtifactFile, + writeArtifactFile, + removeAnchoredEntry, +} from "./cp06-probe-fixture.mjs"; +import { DUMMY_OUTCOME_ORIGINAL } from "./cp06-dummy-originals.mjs"; +import { openPinnedDirectory } from "./cp06-guarded-read.mjs"; + +/** Exercise the actual SDK worker executable, with only a local faux provider. */ +export async function proveDummyWorkerOutcome({ piRoot, directory: requested, parentAnchor }) { + const root = resolve(fileURLToPath(new URL("..", import.meta.url))); + let directory; + let prepared; + if (requested === undefined) { + directory = await mkdtemp(join(tmpdir(), "factory-cp06-DUMMY-outcome-")); + } else { + prepared = openAnchoredDirectory(parentAnchor, "DUMMY-outcome"); + directory = requested; + } + const fd = prepared + ? openSync(prepared.anchor, constants.O_RDONLY | constants.O_DIRECTORY) + : openPinnedDirectory(directory); + prepared?.close(); + const anchor = `/proc/${process.pid}/fd/${fd}`; + const identity = fstatSync(fd, { bigint: true }); + const parentFd = parentAnchor + ? openSync(parentAnchor, constants.O_RDONLY | constants.O_DIRECTORY) + : openPinnedDirectory(dirname(directory)); + const retainedParent = `/proc/${process.pid}/fd/${parentFd}`; + const anchored = (path) => { + if (!path.startsWith(`${directory}/`)) throw new Error("DUMMY artifact escaped fixture"); + return join(anchor, path.slice(directory.length + 1)); + }; + const writeFile = async (path, data) => writeArtifactFile(anchored(path), data); + const readFile = async (path, encoding) => readArtifactFile(anchored(path), encoding); + return withCleanup( + async () => { + openAnchoredDirectory(anchor, "docs").close(); + const originalPath = join(directory, "docs/AUTH.md"); + await writeFile(originalPath, DUMMY_OUTCOME_ORIGINAL); + const credentialAlias = join(directory, "DUMMY-credential-alias.json"); + const contractPath = join(directory, "DUMMY-contract.yaml"); + const packPath = join(directory, "DUMMY-pack.md"); + await writeFile( + contractPath, + "DUMMY critical AUTH ownership task; creation evidence is unverified\n", + ); + await writeFile( + packPath, + "DUMMY globals: offline, preserve tenant privacy, no secrets in context\n", + ); + const cases = []; + for (const [scenario, expectedExit] of [ + ["valid", 0], + ["fenced-valid", 0], + ["extra-before-fence", 75], + ["empty-gap", 75], + ["missing-gap", 75], + ["contradictory-gap", 75], + ["extra-evidence", 75], + ["constraint-pass", 75], + ["missing-reads", 75], + ["credential-read", 75], + ["symlink-read", 75], + ["earlier-assistant-claim", 75], + ["final-extra-channel", 75], + ["unexpected-tool-secret", 75], + ["expired", 75], + ["near-expiry", 75], + ["timeout", 70], + ]) { + const auth = join(directory, `DUMMY-${scenario}-auth.json`); + const counters = join(directory, `DUMMY-${scenario}-counters.json`); + const inputPath = join(directory, `DUMMY-${scenario}-input.json`); + const authBytes = JSON.stringify({ + "openai-codex": { + type: "oauth", + access: "DUMMY-ACCESS", + refresh: "DUMMY-REFRESH", + expires: + scenario === "expired" + ? 0 + : Date.now() + (scenario === "near-expiry" ? 60_000 : 3_600_000), + }, + }); + await writeFile(auth, authBytes); + if (scenario === "symlink-read") await symlink(auth, anchored(credentialAlias)); + await writeFile( + inputPath, + JSON.stringify({ + schema_version: 1, + root: directory, + fixture_root: directory, + contract_path: contractPath, + pack_path: packPath, + original_path: originalPath, + symlink_path: credentialAlias, + provider: "openai-codex", + model: "DUMMY-model", + timeout_ms: scenario === "timeout" ? 1_000 : 20_000, + system_prompt: "DUMMY ONLY local exact-original read and structured evidence gap", + }), + ); + const result = spawnSync( + process.execPath, + [ + "--experimental-import-meta-resolve", + "--import", + join(root, "scripts/cp06-dummy-worker-hook.mjs"), + join(root, "scripts/cp06-sdk-worker.mjs"), + auth, + inputPath, + ], + { + cwd: root, + encoding: "utf8", + timeout: 40_000, + maxBuffer: 1024 * 1024, + env: { + PATH: process.env.PATH, + HOME: directory, + TMPDIR: directory, + PI_OFFLINE: "1", + PI_SKIP_VERSION_CHECK: "1", + CP06_DUMMY_PI_ROOT: piRoot, + CP06_PROJECT_ROOT: root, + CP06_PI_PACKAGE_ROOT: piRoot, + CP06_PI_BIN: join(dirname(dirname(piRoot)), ".bin", "pi"), + CP06_DUMMY_COUNTERS: counters, + CP06_DUMMY_SCENARIO: scenario, + }, + }, + ); + assert.equal(result.error, undefined); + assert.equal(result.status, expectedExit, `${scenario}: ${result.stderr}`); + const counts = JSON.parse(await readFile(counters, "utf8")); + assert.equal(counts.default_storage, 0); + assert.equal(counts.network, 0); + assert.equal(counts.refresh, 0); + assert.equal(await readFile(auth, "utf8"), authBytes); + if (["expired", "near-expiry"].includes(scenario)) assert.equal(counts.runtime_create, 0); + if ( + [ + "empty-gap", + "missing-gap", + "contradictory-gap", + "extra-evidence", + "constraint-pass", + "missing-reads", + ].includes(scenario) + ) + assert.match(result.stderr, /SDK worker outcome failed/); + if (["credential-read", "symlink-read"].includes(scenario)) { + assert.match(result.stderr, /SDK event audit failed/); + assert.equal(result.stdout.includes("DUMMY-ACCESS"), false); + assert.equal(result.stderr.includes("DUMMY-ACCESS"), false); + } + if (["earlier-assistant-claim", "final-extra-channel"].includes(scenario)) + assert.match(result.stderr, /SDK assistant envelope rejected/); + if (scenario === "unexpected-tool-secret") { + assert.match(result.stderr, /SDK event audit failed: unexpected tool events\n$/); + assert.equal(`${result.stdout}${result.stderr}`.includes("DUMMY-SECRET"), false); + } + if (scenario === "timeout") assert.match(result.stderr, /worker timed out and was aborted/); + if (scenario === "extra-before-fence") + assert.match(result.stderr, /SDK worker did not return the required JSON response/); + const output = ["valid", "fenced-valid"].includes(scenario) + ? JSON.parse(result.stdout) + : undefined; + if (output) { + assert.equal(output.result, "pass"); + assert.equal(output.event_stream.read_audit.exact_original, true); + assert.equal(output.event_stream.read_audit.read_count, 1); + assert.equal(output.response.evidence_gaps[0].status, "unverified"); + } else assert.equal(result.stdout, ""); + removeAnchoredEntry(anchor, "DUMMY-credential-alias.json"); + cases.push({ + scenario, + exit_code: result.status, + expected_exit_code: expectedExit, + ...counts, + source_unchanged: true, + ...(output + ? { + read_audit: output.event_stream.read_audit, + event_count: output.event_stream.event_count, + } + : {}), + }); + } + return { fixture_origin: true, semantic_acceptance: false, cases }; + }, + () => { + try { + for (const name of readdirSync(anchor)) removeAnchoredEntry(anchor, name); + const visible = lstatSync(join(retainedParent, basename(directory)), { bigint: true }); + if (!visible.isDirectory() || visible.dev !== identity.dev || visible.ino !== identity.ino) + throw new Error("DUMMY outcome directory identity changed"); + removeAnchoredEntry(retainedParent, basename(directory)); + } finally { + closeSync(fd); + closeSync(parentFd); + } + }, + ); +} diff --git a/scripts/cp06-dummy-sdk-proof.mjs b/scripts/cp06-dummy-sdk-proof.mjs new file mode 100644 index 0000000..d0e1d45 --- /dev/null +++ b/scripts/cp06-dummy-sdk-proof.mjs @@ -0,0 +1,275 @@ +#!/usr/bin/env node +import { strict as assert } from "node:assert"; +import { createHash } from "node:crypto"; +import { closeSync, readdirSync, realpathSync } from "node:fs"; +import { join, resolve } from "node:path"; +import { pathToFileURL } from "node:url"; +import { Cp06ReadOnlyCredentialStore } from "./cp06-readonly-credentials.mjs"; +import { auditReadEvents } from "./cp06-worker-audit.mjs"; +import { proveDummyWorkerOutcome } from "./cp06-dummy-outcome-proof.mjs"; +import { importPinnedPiAi } from "./cp06-pi-dependency.mjs"; +import { + openAnchoredDirectory, + readArtifactFile, + writeArtifactFile, + removeAnchoredEntry, +} from "./cp06-probe-fixture.mjs"; +import { DUMMY_SDK_ORIGINAL } from "./cp06-dummy-originals.mjs"; +import { createGuardedReadTool, openPinnedDirectory } from "./cp06-guarded-read.mjs"; + +const [piRootArg, proofRootArg] = process.argv.slice(2); +if (!piRootArg || !proofRootArg) process.exit(64); +const piRoot = resolve(piRootArg); +const sdk = await import(pathToFileURL(join(piRoot, "dist/index.js"))); +const { module: ai, provenance: piAi } = await importPinnedPiAi(piRoot); +const originalFetch = globalThis.fetch; +let networkCalls = 0; +globalThis.fetch = async () => { + networkCalls += 1; + throw new Error("DUMMY proof forbids network"); +}; +const proofRoot = realpathSync(resolve(proofRootArg)); +if (proofRoot !== resolve(proofRootArg) || readdirSync(proofRoot).length !== 0) process.exit(64); +const proofFd = openPinnedDirectory(proofRoot); +const proofAnchor = `/proc/${process.pid}/fd/${proofFd}`; +try { + const authCases = []; + for (const scenario of [ + { name: "expired", expires: 0, expected: "blocked" }, + { name: "near_expiry", expires: Date.now() + 60_000, expected: "blocked" }, + { name: "unexpired", expires: Date.now() + 600_000, expected: "resolved" }, + ]) { + authCases.push(await authCase(scenario)); + } + const worker = await fauxWorkerProof(); + const outcomeProof = await proveDummyWorkerOutcome({ + piRoot, + directory: join(proofRoot, "DUMMY-outcome"), + parentAnchor: proofAnchor, + }); + assert.equal(networkCalls, 0); + console.log( + JSON.stringify({ + schema_version: 1, + result: "pass", + auth_cases: authCases, + sdk_worker: worker, + sdk_dependency: piAi, + actual_sdk_outcomes: outcomeProof, + fixture_origin: true, + semantic_acceptance: false, + network_calls: networkCalls, + }), + ); +} finally { + globalThis.fetch = originalFetch; + try { + for (const entry of readdirSync(proofAnchor)) removeAnchoredEntry(proofAnchor, entry); + } finally { + closeSync(proofFd); + } +} + +async function authCase(scenario) { + const providerId = "openai-codex"; + const path = join(proofAnchor, `DUMMY-${scenario.name}.json`); + await writeFile( + path, + JSON.stringify({ + [providerId]: { + type: "oauth", + access: "DUMMY-ACCESS", + refresh: "DUMMY-REFRESH", + expires: scenario.expires, + }, + }), + ); + const before = sha256(await readFile(path)); + const store = await Cp06ReadOnlyCredentialStore.load({ path, providerId }); + const runtime = await sdk.ModelRuntime.create({ + credentials: store, + modelsPath: null, + refreshOnCreate: false, + allowModelNetwork: false, + }); + const provider = runtime.getProvider(providerId); + assert.ok(provider?.auth?.oauth); + let refreshCallbacks = 0; + let toAuthCalls = 0; + provider.auth = { + oauth: { + async refresh() { + refreshCallbacks += 1; + return { + type: "oauth", + access: "DUMMY-NEW", + refresh: "DUMMY-NEW", + expires: Date.now() + 3_600_000, + }; + }, + async toAuth(credential) { + toAuthCalls += 1; + assert.equal(credential.access, "DUMMY-ACCESS"); + return { apiKey: "DUMMY-RESOLVED" }; + }, + }, + }; + let result = "resolved"; + try { + const resolved = await runtime.getAuth(providerId, { minOAuthValidityMs: 300_000 }); + assert.ok(resolved); + } catch { + result = "blocked"; + } + assert.equal(result, scenario.expected); + assert.equal(refreshCallbacks, 0); + assert.equal(toAuthCalls, scenario.expected === "resolved" ? 1 : 0); + assert.equal(sha256(await readFile(path)), before); + return { + scenario: scenario.name, + result, + refresh_callbacks: refreshCallbacks, + to_auth_calls: toAuthCalls, + credential_store: store.audit(), + persistence_operations: 0, + source_unchanged: true, + }; +} + +async function fauxWorkerProof() { + const workerDirectory = openAnchoredDirectory(proofAnchor, "DUMMY-worker"); + try { + const workerRoot = join(proofRoot, "DUMMY-worker"); + const originalPath = join(workerRoot, "DUMMY-original.md"); + const authPath = join(workerDirectory.anchor, "DUMMY-auth.json"); + const original = DUMMY_SDK_ORIGINAL; + writeArtifactFile(join(workerDirectory.anchor, "DUMMY-original.md"), original); + const guardedRead = createGuardedReadTool({ + sdk, + root: workerRoot, + expectedOriginalPath: originalPath, + }); + for (const range of [{ offset: 1, limit: 2_000 }, { offset: 1 }, { limit: 2_000 }]) { + await assert.rejects( + guardedRead.tool.execute("DUMMY-ranged-read", { path: originalPath, ...range }), + /without offset or limit/, + ); + } + const faux = ai.fauxProvider({ + provider: "DUMMY-readonly", + models: [{ id: "DUMMY-model" }], + tokensPerSecond: 100_000, + }); + const providerId = faux.provider.id; + await writeFile( + authPath, + JSON.stringify({ + [providerId]: { + type: "oauth", + access: "DUMMY-ACCESS", + refresh: "DUMMY-REFRESH", + expires: Date.now() + 600_000, + }, + }), + ); + const store = await Cp06ReadOnlyCredentialStore.load({ path: authPath, providerId }); + let refreshCallbacks = 0; + faux.provider.auth = { + oauth: { + async refresh() { + refreshCallbacks += 1; + throw new Error("DUMMY refresh must not run"); + }, + async toAuth() { + return { apiKey: "DUMMY-RESOLVED" }; + }, + }, + }; + const runtime = await sdk.ModelRuntime.create({ + credentials: store, + modelsPath: null, + refreshOnCreate: false, + allowModelNetwork: false, + }); + runtime.registerNativeProvider(faux.provider); + const model = runtime.getModel(providerId, "DUMMY-model"); + assert.ok(model); + faux.setResponses([ + ai.fauxAssistantMessage( + ai.fauxToolCall("read", { path: originalPath }, { id: "DUMMY-read" }), + { + stopReason: "toolUse", + }, + ), + ai.fauxAssistantMessage("DUMMY complete", { stopReason: "stop" }), + ]); + const resourceLoader = { + getExtensions: () => ({ extensions: [], errors: [], runtime: sdk.createExtensionRuntime() }), + getSkills: () => ({ skills: [], diagnostics: [] }), + getPrompts: () => ({ prompts: [], diagnostics: [] }), + getThemes: () => ({ themes: [], diagnostics: [] }), + getAgentsFiles: () => ({ agentsFiles: [] }), + getSystemPrompt: () => "Use only read and read the supplied DUMMY original exactly once.", + getSystemPromptSource: () => undefined, + getAppendSystemPrompt: () => [], + getAppendSystemPromptSources: () => [], + extendResources: () => {}, + reload: async () => {}, + }; + const { session } = await sdk.createAgentSession({ + cwd: workerRoot, + agentDir: workerRoot, + model, + modelRuntime: runtime, + resourceLoader, + tools: ["read"], + customTools: [guardedRead.tool], + sessionManager: sdk.SessionManager.inMemory(workerRoot), + settingsManager: sdk.SettingsManager.inMemory({ + compaction: { enabled: false }, + retry: { enabled: false }, + }), + }); + const events = []; + const unsubscribe = session.subscribe((event) => events.push(structuredClone(event))); + try { + await session.prompt(`Read ${originalPath} exactly once.`, { expandPromptTemplates: false }); + } finally { + unsubscribe(); + session.dispose(); + } + const audit = auditReadEvents(events, { + root: workerRoot, + expectedOriginalPath: originalPath, + expectedOriginal: original, + decisiveText: ["request-supplied owner fields are ignored"], + }); + assert.equal(audit.ok, true); + assert.deepEqual(session.getActiveToolNames(), ["read"]); + assert.equal(session.sessionFile, undefined); + assert.equal(refreshCallbacks, 0); + return { + active_tools: session.getActiveToolNames(), + in_memory_session: session.sessionFile === undefined, + event_count: events.length, + event_sha256: sha256(JSON.stringify(events)), + read_audit: audit.summary, + refresh_callbacks: refreshCallbacks, + credential_store: store.audit(), + }; + } finally { + workerDirectory.close(); + } +} + +async function writeFile(path, bytes) { + writeArtifactFile(path, bytes); +} + +async function readFile(path) { + return readArtifactFile(path); +} + +function sha256(value) { + return createHash("sha256").update(value).digest("hex"); +} diff --git a/scripts/cp06-dummy-worker-evidence.mjs b/scripts/cp06-dummy-worker-evidence.mjs new file mode 100644 index 0000000..fad0680 --- /dev/null +++ b/scripts/cp06-dummy-worker-evidence.mjs @@ -0,0 +1,96 @@ +import { createHash } from "node:crypto"; +import { resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { workerEvidenceContext } from "./cp06-worker-evidence.mjs"; +import { DUMMY_OUTCOME_ORIGINAL, DUMMY_PI_PROVENANCE } from "./cp06-dummy-originals.mjs"; +import { resolvePinnedPiProvenance } from "./cp06-pi-install.mjs"; + +export function dummyWorkerContext(directory = "/DUMMY") { + return { + input: { provider: "openai-codex", model: "DUMMY-model", timeout_ms: 1_000 }, + paths: { root: directory, fixtureRoot: directory, originalPath: `${directory}/docs/AUTH.md` }, + original: Buffer.from(DUMMY_OUTCOME_ORIGINAL), + provenance: DUMMY_PI_PROVENANCE, + }; +} + +export function dummyWorkerEvidence({ input, paths, original } = dummyWorkerContext()) { + const hash = createHash("sha256").update(original).digest("hex"); + return { + schema_version: 1, + result: "pass", + pi_version: "0.85.1", + pi_install: structuredClone(DUMMY_PI_PROVENANCE), + provider: input.provider, + model: input.model, + oauth_preflight: { + minimum_validity_ms: input.timeout_ms + 300_000, + remaining_validity_ms: input.timeout_ms + 600_000, + refreshed: false, + }, + credential_store: { reads: 1, lists: 0, modify_denials: 0, delete_denials: 0 }, + session: { in_memory: true, active_tools: ["read"] }, + event_stream: { + sha256: "0".repeat(64), + event_count: 3, + read_audit: { + read_count: 1, + project_read_count: 1, + project_read_paths: [paths.originalPath], + rejected_read_paths: [], + other_tool_calls: [], + other_tool_completions: [], + completed_successfully: true, + exact_original: true, + expected_sha256: hash, + returned_sha256: hash, + returned_bytes: Buffer.byteLength(original), + tool_call_id: "DUMMY-read", + start_event_index: 0, + end_event_index: 1, + response_event_index: 2, + }, + }, + response: { + status: "MISSING_SOURCE", + missing_source: "docs/AUTH.md", + reads: [paths.originalPath], + corrected_constraints: [ + "ownership comes only from the authenticated principal", + "request-supplied owner fields are ignored", + ], + evidence_gaps: [ + { + id: "creation-time-ownership", + status: "unverified", + missing_checks: ["principal-derived-owner", "request-owner-ignored"], + }, + ], + broad_scan: false, + }, + response_sha256: "1".repeat(64), + raw_transcript_retained: false, + }; +} + +if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { + try { + const context = workerEvidenceContext(process.argv[2]); + const evidence = dummyWorkerEvidence(context); + if (process.argv[3] === "invalid") evidence.response = { status: "PASS", evidence_gaps: [] }; + if (process.argv[3] === "installed") { + evidence.pi_install = resolvePinnedPiProvenance({ + projectRoot: process.env.CP06_PROJECT_ROOT, + packageRoot: process.env.CP06_PI_PACKAGE_ROOT, + executable: process.env.CP06_PI_BIN, + }); + } + if (process.argv[3] === "forged-provenance") { + evidence.pi_install.package_artifact.tarball_integrity = "sha512-DUMMY-SECRET-CLI-MARKER"; + } + console.log(JSON.stringify(evidence)); + } catch { + process.stderr.write("CP06_DUMMY_EVIDENCE_FAILED\n"); + process.exitCode = 70; + } +} diff --git a/scripts/cp06-dummy-worker-hook.mjs b/scripts/cp06-dummy-worker-hook.mjs new file mode 100644 index 0000000..0e4e4af --- /dev/null +++ b/scripts/cp06-dummy-worker-hook.mjs @@ -0,0 +1,137 @@ +// Development-only local provider instrumentation. Never a live semantic proof. +import { closeSync } from "node:fs"; +import { basename, dirname, join } from "node:path"; +import { pathToFileURL } from "node:url"; +import { importPinnedPiAi } from "./cp06-pi-dependency.mjs"; +import { writeAnchoredFile } from "./cp06-probe-fixture.mjs"; +import { openPinnedDirectory, readPinnedRegularFile } from "./cp06-guarded-read.mjs"; + +const piRoot = process.env.CP06_DUMMY_PI_ROOT; +const countersPath = process.env.CP06_DUMMY_COUNTERS; +const scenario = process.env.CP06_DUMMY_SCENARIO; +if (!piRoot || !countersPath || !scenario) throw new Error("DUMMY proof configuration missing"); +const sdk = await import(pathToFileURL(join(piRoot, "dist/index.js"))); +const { AuthStorage } = await import(pathToFileURL(join(piRoot, "dist/core/auth-storage.js"))); +const { module: ai, provenance: piAi } = await importPinnedPiAi(piRoot); +const input = JSON.parse(readPinnedRegularFile(process.argv[3]).bytes.toString("utf8")); +const countersFd = openPinnedDirectory(dirname(countersPath)); +const counts = { + fixture_origin: true, + sdk_dependency: piAi, + default_storage: 0, + network: 0, + refresh: 0, + to_auth: 0, + runtime_create: 0, +}; +AuthStorage.create = () => { + counts.default_storage++; + throw new Error("DUMMY forbids default AuthStorage"); +}; +globalThis.fetch = async () => { + counts.network++; + throw new Error("DUMMY forbids network"); +}; +const create = sdk.ModelRuntime.create; +sdk.ModelRuntime.create = async function (options) { + counts.runtime_create++; + if ( + !options.credentials || + options.modelsPath !== null || + options.refreshOnCreate !== false || + options.allowModelNetwork !== false + ) + throw new Error("DUMMY unsafe runtime options"); + const runtime = await create.call(this, options); + const faux = ai.fauxProvider({ + provider: "openai-codex", + models: [{ id: "DUMMY-model" }], + tokensPerSecond: scenario === "timeout" ? 1 : 100_000, + }); + faux.provider.auth = { + oauth: { + async refresh() { + counts.refresh++; + throw new Error("DUMMY refresh forbidden"); + }, + async toAuth(credential) { + counts.to_auth++; + if (credential.access !== "DUMMY-ACCESS") throw new Error("DUMMY credential required"); + return { apiKey: "DUMMY-LOCAL" }; + }, + }, + }; + runtime.registerNativeProvider(faux.provider); + const gap = { + id: "creation-time-ownership", + status: "unverified", + missing_checks: ["principal-derived-owner", "request-owner-ignored"], + }; + const response = { + status: "MISSING_SOURCE", + missing_source: "docs/AUTH.md", + reads: [input.original_path], + corrected_constraints: [ + "ownership comes only from the authenticated principal", + "request-supplied owner fields are ignored", + ], + evidence_gaps: + scenario === "empty-gap" + ? [] + : [{ ...gap, ...(scenario === "contradictory-gap" ? { status: "pass" } : {}) }], + broad_scan: false, + }; + if (scenario === "missing-gap") delete response.evidence_gaps; + if (scenario === "extra-evidence") response.creation_evidence = { status: "passed" }; + if (scenario === "constraint-pass") { + response.corrected_constraints[1] += "; creation evidence status: pass"; + } + if (scenario === "missing-reads") delete response.reads; + const readPath = + scenario === "credential-read" + ? process.argv[2] + : scenario === "symlink-read" + ? input.symlink_path + : input.original_path; + const jsonResponse = JSON.stringify(response); + const responseText = + scenario === "extra-before-fence" + ? `Creation verified and passed\n\x60\x60\x60json\n${jsonResponse}\n\x60\x60\x60` + : scenario === "fenced-valid" + ? `\x60\x60\x60json\n${jsonResponse}\n\x60\x60\x60` + : jsonResponse; + const readCall = ai.fauxToolCall( + scenario === "unexpected-tool-secret" ? "DUMMY-SECRET-TOOL" : "read", + { path: readPath }, + { id: "DUMMY-actual-worker-read" }, + ); + faux.setResponses([ + ai.fauxAssistantMessage( + scenario === "earlier-assistant-claim" + ? [ai.fauxText("Creation verified and passed"), readCall] + : readCall, + { stopReason: "toolUse" }, + ), + ai.fauxAssistantMessage( + scenario === "final-extra-channel" + ? [ + ai.fauxText(responseText), + ai.fauxToolCall("read", { path: readPath }, { id: "DUMMY-final-extra" }), + ] + : responseText, + { stopReason: "stop" }, + ), + ]); + return runtime; +}; +process.on("exit", (exit) => { + try { + writeAnchoredFile( + `/proc/${process.pid}/fd/${countersFd}`, + basename(countersPath), + JSON.stringify({ ...counts, exit }), + ); + } finally { + closeSync(countersFd); + } +}); diff --git a/scripts/cp06-guarded-read.mjs b/scripts/cp06-guarded-read.mjs new file mode 100644 index 0000000..abed174 --- /dev/null +++ b/scripts/cp06-guarded-read.mjs @@ -0,0 +1,189 @@ +import { + closeSync, + constants, + fstatSync, + lstatSync, + openSync, + readSync, + realpathSync, +} from "node:fs"; +import { isAbsolute, relative, resolve } from "node:path"; + +const MAX_ORIGINAL_BYTES = 50 * 1024; +const MAX_ORIGINAL_LINES = 2_000; + +export function canonicalWorkerPaths(input, credentialTarget, credentialSource) { + const root = canonicalExactPath(input.root, "root"); + const fixtureRoot = canonicalExactPath(input.fixture_root, "fixture_root"); + const contractPath = canonicalExactPath(input.contract_path, "contract_path"); + const packPath = canonicalExactPath(input.pack_path, "pack_path"); + const originalPath = canonicalExactPath(input.original_path, "original_path"); + if (!isWithin(fixtureRoot, originalPath)) { + throw new Error("worker original_path escapes its fixture root"); + } + const credentialPaths = [credentialTarget, credentialSource] + .filter((value) => typeof value === "string" && value.length > 0) + .map((value) => resolve(value)); + if (credentialPaths.includes(originalPath)) { + throw new Error("worker original_path overlaps a credential path"); + } + return { root, fixtureRoot, contractPath, packPath, originalPath }; +} + +export function createGuardedReadTool({ sdk, root, expectedOriginalPath }) { + if (typeof sdk?.createReadTool !== "function") { + throw new Error("Pi SDK guarded read constructor is unavailable"); + } + const canonicalOriginal = canonicalExactPath(expectedOriginalPath, "expected original"); + const pinned = readPinnedRegularFile(canonicalOriginal); + const read = sdk.createReadTool(root, { + operations: { + async access(path) { + if (path !== canonicalOriginal) throw new Error("read denied"); + }, + async readFile(path) { + if (path !== canonicalOriginal) throw new Error("read denied"); + return Buffer.from(pinned.bytes); + }, + async detectImageMimeType() { + return null; + }, + }, + }); + if (read?.name !== "read" || typeof read.execute !== "function") { + throw new Error("Pi SDK read tool surface is unsupported"); + } + return { + original: Buffer.from(pinned.bytes), + tool: { + ...read, + async execute(toolCallId, args, ...rest) { + assertAllowedRead(args, canonicalOriginal); + let current; + try { + current = lstatSync(canonicalOriginal, { bigint: true }); + } catch { + throw new Error("read denied: supplied original changed"); + } + if (!current.isFile() || !sameIdentity(pinned.identity, current)) { + throw new Error("read denied: supplied original changed"); + } + return read.execute.call(read, toolCallId, args, ...rest); + }, + }, + }; +} + +export function assertAllowedRead(args, expectedOriginalPath) { + if ( + args === null || + typeof args !== "object" || + Array.isArray(args) || + Object.keys(args).some((key) => !["path", "offset", "limit"].includes(key)) || + args.path !== expectedOriginalPath + ) { + throw new Error("read denied: only the canonical supplied original is allowed"); + } + if (Object.hasOwn(args, "offset") || Object.hasOwn(args, "limit")) { + throw new Error("read denied: the original must be read without offset or limit"); + } +} + +export function readPinnedRegularFile(path) { + const descriptor = openWithoutFollowingAncestors(path); + try { + const before = fstatSync(descriptor, { bigint: true }); + const named = lstatSync(path, { bigint: true }); + if ( + !before.isFile() || + !sameIdentity(before, named) || + before.size <= 0n || + before.size > BigInt(MAX_ORIGINAL_BYTES) + ) { + throw new Error("supplied original is not one bounded pinned regular file"); + } + const bytes = Buffer.alloc(Number(before.size)); + let offset = 0; + while (offset < bytes.length) { + const count = readSync(descriptor, bytes, offset, bytes.length - offset, offset); + if (count === 0) throw new Error("supplied original ended while pinned"); + offset += count; + } + const after = fstatSync(descriptor, { bigint: true }); + if (!sameIdentity(before, after)) throw new Error("supplied original changed while pinned"); + const text = bytes.toString("utf8"); + if (!Buffer.from(text, "utf8").equals(bytes)) { + throw new Error("supplied original is not valid UTF-8 text"); + } + const lineCount = text.split("\n").length; + if (lineCount > MAX_ORIGINAL_LINES) { + throw new Error("supplied original exceeds the complete-read line bound"); + } + return { bytes, identity: before, lineCount }; + } finally { + closeSync(descriptor); + } +} + +export function openPinnedDirectory(path) { + return openWithoutFollowingAncestors( + path, + constants.O_RDONLY | constants.O_DIRECTORY | constants.O_NOFOLLOW | constants.O_CLOEXEC, + ); +} + +function openWithoutFollowingAncestors( + path, + leafFlags = constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_CLOEXEC, +) { + if (typeof path !== "string" || !isAbsolute(path) || resolve(path) !== path) { + throw new Error("supplied original path must be absolute and normalized"); + } + const components = path.split("/").filter((component) => component.length > 0); + if (components.length === 0) throw new Error("supplied original path names no file"); + const directoryFlags = + constants.O_RDONLY | constants.O_DIRECTORY | constants.O_NOFOLLOW | constants.O_CLOEXEC; + let parentFd = openSync("/", directoryFlags); + try { + for (const component of components.slice(0, -1)) { + const nextFd = openSync(`/proc/self/fd/${parentFd}/${component}`, directoryFlags); + closeSync(parentFd); + parentFd = nextFd; + } + return openSync(`/proc/self/fd/${parentFd}/${components.at(-1)}`, leafFlags); + } finally { + closeSync(parentFd); + } +} + +function sameIdentity(expected, actual) { + return ( + expected.dev === actual.dev && + expected.ino === actual.ino && + expected.size === actual.size && + expected.nlink === actual.nlink && + expected.mode === actual.mode && + expected.mtimeNs === actual.mtimeNs && + expected.ctimeNs === actual.ctimeNs + ); +} + +function canonicalExactPath(value, label) { + if (typeof value !== "string" || !isAbsolute(value)) { + throw new Error(`worker ${label} must be absolute`); + } + const resolved = resolve(value); + let canonical; + try { + canonical = realpathSync.native(resolved); + } catch { + throw new Error(`worker ${label} is unavailable`); + } + if (canonical !== resolved) throw new Error(`worker ${label} must already be canonical`); + return canonical; +} + +function isWithin(parent, child) { + const path = relative(parent, child); + return path === "" || (!path.startsWith("..") && !isAbsolute(path)); +} diff --git a/scripts/cp06-isolation-adversary.mjs b/scripts/cp06-isolation-adversary.mjs new file mode 100644 index 0000000..160c1f8 --- /dev/null +++ b/scripts/cp06-isolation-adversary.mjs @@ -0,0 +1,175 @@ +#!/usr/bin/env node +import { createHash } from "node:crypto"; +import { spawnSync } from "node:child_process"; +import { + linkSync, + mkdirSync, + closeSync, + constants, + openSync, + readFileSync, + renameSync, + rmdirSync, + symlinkSync, + unlinkSync, + writeFileSync, +} from "node:fs"; +import { dirname, join } from "node:path"; + +const [securityMode, source, target, syscallProbe, expectedHash, supervisorPid] = + process.argv.slice(2); +if ( + !["unsafe", "hardened"].includes(securityMode) || + ![source, target, syscallProbe, expectedHash, supervisorPid].every(Boolean) +) + process.exit(64); +const proofRoot = dirname(dirname(target)); +const results = {}; + +function jsAttempt(name, action) { + try { + action(); + results[name] = { exit: 0 }; + } catch (error) { + results[name] = { exit: 1, code: error?.code ?? null }; + } +} + +function commandAttempt(name, command, args, options = {}) { + const result = spawnSync(command, args, { + encoding: "utf8", + timeout: 15_000, + ...options, + }); + results[name] = { + exit: result.status, + signal: result.signal, + stdout: String(result.stdout ?? "").trim(), + stderr: String(result.stderr ?? "").trim(), + }; + return result; +} + +jsAttempt("direct_write_target", () => writeFileSync(target, "DUMMY BYPASS target")); +jsAttempt("direct_write_source", () => writeFileSync(source, "DUMMY BYPASS source")); +jsAttempt("unlink_target", () => unlinkSync(target)); +jsAttempt("unlink_source", () => unlinkSync(source)); +jsAttempt("create_target_parent_entry", () => + writeFileSync(join(dirname(target), "DUMMY-parent-entry"), "DUMMY parent replacement"), +); +jsAttempt("create_source_parent_entry", () => + writeFileSync(join(dirname(source), "DUMMY-parent-entry"), "DUMMY parent replacement"), +); +const replacement = join(proofRoot, "DUMMY-target-replacement"); +writeFileSync(replacement, "DUMMY replacement"); +jsAttempt("rename_over_target", () => renameSync(replacement, target)); +const sourceReplacement = join(proofRoot, "DUMMY-source-replacement"); +writeFileSync(sourceReplacement, "DUMMY source replacement"); +jsAttempt("rename_over_source", () => renameSync(sourceReplacement, source)); +jsAttempt("symlink_replacement", () => { + try { + unlinkSync(target); + } catch {} + symlinkSync("/tmp/DUMMY-does-not-exist", target); +}); +jsAttempt("symlink_source_replacement", () => { + try { + unlinkSync(source); + } catch {} + symlinkSync("/tmp/DUMMY-does-not-exist", source); +}); +jsAttempt("hardlink_target", () => linkSync(target, join(proofRoot, "DUMMY-hardlink"))); +jsAttempt("hardlink_source", () => linkSync(source, join(proofRoot, "DUMMY-source-hardlink"))); + +if (securityMode === "hardened") { + jsAttempt("unlink_target_parent", () => rmdirSync(dirname(target))); + jsAttempt("unlink_source_parent", () => rmdirSync(dirname(source))); + jsAttempt("rename_target_parent", () => + renameSync(dirname(target), join(proofRoot, "DUMMY-renamed-target-parent")), + ); + jsAttempt("rename_source_parent", () => + renameSync(dirname(source), join(proofRoot, "DUMMY-renamed-source-parent")), + ); + jsAttempt("symlink_in_target_parent", () => + symlinkSync("/tmp/DUMMY-does-not-exist", join(dirname(target), "DUMMY-parent-symlink")), + ); + jsAttempt("symlink_in_source_parent", () => + symlinkSync("/tmp/DUMMY-does-not-exist", join(dirname(source), "DUMMY-parent-symlink")), + ); + jsAttempt("hardlink_into_target_parent", () => + linkSync(target, join(dirname(target), "DUMMY-parent-hardlink")), + ); + jsAttempt("hardlink_into_source_parent", () => + linkSync(source, join(dirname(source), "DUMMY-parent-hardlink")), + ); + const probeFd = openSync(syscallProbe, constants.O_RDONLY | constants.O_CLOEXEC); + try { + commandAttempt("direct_namespace_syscalls", "/proc/self/fd/3", [target], { + stdio: ["ignore", "pipe", "pipe", probeFd], + }); + } finally { + closeSync(probeFd); + closeSync(Number(syscallProbe.split("/").at(-1))); + } +} else { + results.direct_namespace_syscalls = { exit: null, skipped: true }; +} +commandAttempt("ordinary_node_child", process.execPath, [ + "-e", + "process.stdout.write('DUMMY-child-ok')", +]); +commandAttempt("nested_user_mount_namespace", "unshare", [ + "--user", + "--map-root-user", + "--mount", + "--propagation", + "private", + "true", +]); +commandAttempt("nsenter_self", "nsenter", ["-t", String(process.pid), "-m", "true"]); +commandAttempt("nsenter_supervisor", "nsenter", [ + "-t", + supervisorPid, + "-m", + "mount", + "-o", + "remount,bind,rw", + target, +]); +commandAttempt("uid_change", "setpriv", ["--reuid=1", "--regid=1", "--clear-groups", "id"]); +commandAttempt("remount_target_rw", "mount", ["-o", "remount,bind,rw", target]); +jsAttempt("write_after_remount", () => writeFileSync(target, "DUMMY BYPASS remount")); +const unmountTarget = commandAttempt("unmount_target", "umount", [target]); +if (unmountTarget.status === 0) { + commandAttempt("restore_target_bind", "mount", ["--bind", source, target]); +} else { + results.restore_target_bind = { exit: null, skipped: true }; +} +jsAttempt("scratch_write", () => + writeFileSync(join(proofRoot, "DUMMY-writable-scratch"), "DUMMY scratch"), +); +const alias = join(proofRoot, "DUMMY-alias"); +mkdirSync(alias, { recursive: true }); +const bindParent = commandAttempt("bind_parent_alias", "mount", ["--bind", dirname(source), alias]); +if (bindParent.status === 0) { + jsAttempt("alias_underlying_write", () => + writeFileSync(join(alias, source.split("/").at(-1)), "DUMMY BYPASS alias"), + ); +} else { + results.alias_underlying_write = { exit: null, skipped: true }; +} + +const status = Object.fromEntries( + readFileSync("/proc/self/status", "utf8") + .split("\n") + .filter((line) => /^(Cap(Inh|Prm|Eff|Bnd|Amb)|NoNewPrivs|Seccomp):/.test(line)) + .map((line) => line.split(/:\s+/, 2)), +); +const finalBytes = readFileSync(source); +results.identity = { uid: process.getuid(), gid: process.getgid(), status }; +results.source = { + expected_sha256: expectedHash, + final_sha256: createHash("sha256").update(finalBytes).digest("hex"), + unchanged: createHash("sha256").update(finalBytes).digest("hex") === expectedHash, +}; +console.log(JSON.stringify(results)); diff --git a/scripts/cp06-isolation-syscalls.c b/scripts/cp06-isolation-syscalls.c new file mode 100644 index 0000000..2db5656 --- /dev/null +++ b/scripts/cp06-isolation-syscalls.c @@ -0,0 +1,146 @@ +#define _GNU_SOURCE +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#if !defined(__linux__) || !defined(__x86_64__) +#error "CP-06 isolation syscall probe currently supports Linux x86-64 only" +#endif + +static void result(const char *prefix, const char *name, long value) { + int error = errno; + printf("%s%s=%ld:%d\n", prefix, name, value, error); +} + +static void observations(const char *prefix) { + printf("%ssecurebits=%d\n", prefix, prctl(PR_GET_SECUREBITS)); + printf("%sNoNewPrivs=%d\n", prefix, prctl(PR_GET_NO_NEW_PRIVS, 0, 0, 0, 0)); + printf("%sSeccomp=%d\n", prefix, prctl(PR_GET_SECCOMP, 0, 0, 0, 0)); + FILE *status = fopen("/proc/self/status", "r"); + if (!status) _exit(70); + char line[256], name[32], value[64]; + while (fgets(line, sizeof(line), status)) { + if (sscanf(line, "%31[^:]: %63s", name, value) == 2 && + (!strcmp(name, "CapInh") || !strcmp(name, "CapPrm") || + !strcmp(name, "CapEff") || !strcmp(name, "CapBnd") || !strcmp(name, "CapAmb"))) { + printf("%s%s=%s\n", prefix, name, value); + } + } + fclose(status); +} + +static void attempts(const char *prefix, const char *mount_path, pid_t target, + uintptr_t address, int target_fd) { + int namespace_fd = open("/proc/self/ns/mnt", O_RDONLY | O_CLOEXEC); + if (namespace_fd < 0) _exit(70); + errno = 0; + result(prefix, "setns", syscall(SYS_setns, namespace_fd, 0)); + errno = 0; + result(prefix, "unshare", syscall(SYS_unshare, CLONE_NEWUSER)); + errno = 0; + long child = syscall(SYS_clone, (unsigned long)(CLONE_NEWUSER | SIGCHLD), 0, 0, 0, 0); + if (child == 0) _exit(0); + result(prefix, "clone", child); + if (child > 0) waitpid((pid_t)child, NULL, 0); + struct clone_args arguments = {.flags = CLONE_NEWUSER, .exit_signal = SIGCHLD}; + errno = 0; + child = syscall(SYS_clone3, &arguments, sizeof(arguments)); + if (child == 0) _exit(0); + result(prefix, "clone3", child); + if (child > 0) waitpid((pid_t)child, NULL, 0); + errno = 0; + result(prefix, "mount", syscall(SYS_mount, mount_path, mount_path, 0, MS_REMOUNT | MS_BIND, 0)); + errno = 0; + result(prefix, "umount2", syscall(SYS_umount2, mount_path, 0)); + close(namespace_fd); + + errno = 0; + long attached = ptrace(PTRACE_ATTACH, target, NULL, NULL); + result(prefix, "ptrace_attach", attached); + if (attached == 0) { waitpid(target, NULL, 0); ptrace(PTRACE_DETACH, target, NULL, NULL); } + errno = 0; + long seized = ptrace(PTRACE_SEIZE, target, NULL, NULL); + result(prefix, "ptrace_seize", seized); + if (seized == 0) { kill(target, SIGSTOP); waitpid(target, NULL, WUNTRACED); ptrace(PTRACE_DETACH, target, NULL, NULL); } + char dummy[] = "DUMMY FIXTURE ONLY"; + struct iovec local = {.iov_base = dummy, .iov_len = sizeof(dummy)}; + struct iovec remote = {.iov_base = (void *)address, .iov_len = sizeof(dummy)}; + errno = 0; + result(prefix, "process_vm_readv", process_vm_readv(target, &local, 1, &remote, 1, 0)); + errno = 0; + result(prefix, "process_vm_writev", process_vm_writev(target, &local, 1, &remote, 1, 0)); + int pidfd = (int)syscall(SYS_pidfd_open, target, 0); + printf("%spidfd_open=%s\n", prefix, pidfd >= 0 ? "opened" : "failed"); + if (pidfd >= 0) { + errno = 0; + int copied = (int)syscall(SYS_pidfd_getfd, pidfd, target_fd, 0); + result(prefix, "pidfd_getfd", copied); + if (copied >= 0) close(copied); + close(pidfd); + } + char path[64]; + snprintf(path, sizeof(path), "/proc/%ld/mem", (long)target); + errno = 0; + int memory = open(path, O_RDWR | O_CLOEXEC); + result(prefix, "proc_mem", memory); + if (memory >= 0) close(memory); + observations(prefix); +} + +int main(int argc, char **argv) { + if (argc != 2) return 64; + alarm(10); + int ready[2], stop[2]; + if (pipe2(ready, O_CLOEXEC) || pipe2(stop, O_CLOEXEC)) return 70; + pid_t owner = getpid(); + pid_t target = fork(); + if (target < 0) return 70; + if (target == 0) { + close(ready[0]); close(stop[1]); + if (prctl(PR_SET_PDEATHSIG, SIGKILL) || getppid() != owner || + prctl(PR_SET_DUMPABLE, 0)) _exit(70); + volatile char dummy[] = "DUMMY FIXTURE ONLY"; + uintptr_t address = (uintptr_t)dummy; + if (write(ready[1], &address, sizeof(address)) != (ssize_t)sizeof(address)) _exit(70); + close(ready[1]); + char done; + if (read(stop[0], &done, 1) != 1) _exit(70); + const char expected[] = "DUMMY FIXTURE ONLY"; + for (size_t index = 0; index < sizeof(expected); ++index) if (dummy[index] != expected[index]) _exit(71); + _exit(0); + } + close(ready[1]); close(stop[0]); + uintptr_t address; + if (read(ready[0], &address, sizeof(address)) != (ssize_t)sizeof(address)) { + kill(target, SIGKILL); waitpid(target, NULL, 0); return 70; + } + close(ready[0]); + attempts("", argv[1], target, address, stop[0]); + fflush(stdout); + pid_t inherited = fork(); + if (inherited == 0) { + if (prctl(PR_SET_PDEATHSIG, SIGKILL) || getppid() != owner) _exit(70); + attempts("inherited_", argv[1], target, address, stop[0]); + fflush(stdout); + _exit(0); + } + int status = 0; + int ok = inherited > 0 && waitpid(inherited, &status, 0) == inherited && WIFEXITED(status) && WEXITSTATUS(status) == 0; + if (write(stop[1], "D", 1) != 1) ok = 0; + close(stop[1]); + if (waitpid(target, &status, 0) != target || !WIFEXITED(status) || WEXITSTATUS(status) != 0) ok = 0; + printf("dummy_target_unchanged=%s\n", ok ? "true" : "false"); + return ok ? 0 : 70; +} diff --git a/scripts/cp06-namespace-supervisor.mjs b/scripts/cp06-namespace-supervisor.mjs new file mode 100644 index 0000000..2119a1e --- /dev/null +++ b/scripts/cp06-namespace-supervisor.mjs @@ -0,0 +1,491 @@ +#!/usr/bin/env node +import { spawnSync } from "node:child_process"; +import { closeSync, constants, openSync } from "node:fs"; +import { resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { mountLiveCredentialReadOnly } from "./cp06-credential-isolation.mjs"; +import { withCleanup } from "./cp06-worker-lifecycle.mjs"; +import { createProbeFixture } from "./cp06-probe-fixture.mjs"; +import { + trustedWorkerProvenance, + validateWorkerEvidence, + workerEvidenceContext, +} from "./cp06-worker-evidence.mjs"; + +const root = resolve(fileURLToPath(new URL("..", import.meta.url))); + +// Injectable OS operations permit deterministic failure/cleanup tests without +// credentials, namespaces or an installed SDK in the ordinary unit suite. +export async function superviseCredentialChild(options, dependencies = {}) { + const { + mode, + source: requestedSource, + target: requestedTarget, + helper, + syscallProbe, + childArgs = [], + } = options; + if ( + !["unsafe-probe", "hardened-probe", "worker"].includes(mode) || + ![requestedSource, requestedTarget, helper, syscallProbe].every( + (value) => typeof value === "string" && value.length > 0, + ) + ) + throw failure("setup", "CP06_ISOLATION_SETUP_FAILED", 64); + if (mode !== "worker" && (requestedTarget !== "-" || childArgs.length !== 0)) { + throw failure("setup", "CP06_ISOLATION_SETUP_FAILED", 64); + } + if (process.platform !== "linux" || process.arch !== "x64") { + throw failure("setup", "CP06_ISOLATION_SETUP_FAILED", 73); + } + const mountCredential = dependencies.mount ?? mountLiveCredentialReadOnly; + const spawn = dependencies.spawn ?? spawnSync; + let probe; + if (mode !== "worker") { + try { + probe = await (dependencies.createProbeFixture ?? createProbeFixture)({ + root, + outputRoot: requestedSource, + }); + } catch (cause) { + const setup = failure("setup", "CP06_ISOLATION_SETUP_FAILED", 73, cause); + if (cause instanceof AggregateError) { + throw compoundFailure(setup, failure("cleanup", "CP06_CREDENTIAL_CLEANUP_FAILED", 74)); + } + throw setup; + } + } + let provenance; + if (mode === "worker" && !dependencies.spawn) { + try { + provenance = trustedWorkerProvenance({ + root, + environment: process.env, + dummy: process.env.CP06_DUMMY_TRUSTED_PROVENANCE === "1", + }); + } catch (cause) { + throw failure("setup", "CP06_ISOLATION_SETUP_FAILED", 73, cause); + } + } + const source = probe?.source ?? requestedSource; + const target = probe?.target ?? requestedTarget; + const beforeHash = probe?.beforeHash ?? null; + let mount; + return await withCleanup( + () => + withCleanup( + async () => { + try { + probe?.assertSafePaths?.(); + mount = await mountCredential({ + root, + source, + target, + mountHelper: helper, + protectParents: mode !== "unsafe-probe", + sourceParentAnchor: probe?.credentialAnchors?.sourceParent, + targetParentAnchor: probe?.credentialAnchors?.targetParent, + sourceLeaf: probe?.credentialAnchors?.sourceLeaf, + targetLeaf: probe?.credentialAnchors?.targetLeaf, + }); + } catch (cause) { + const missingSource = hasErrorCode(cause, "ENOENT"); + const setup = failure( + missingSource ? "missing-source" : "setup", + missingSource ? "CP06_CREDENTIAL_SOURCE_MISSING" : "CP06_ISOLATION_SETUP_FAILED", + missingSource ? 70 : 73, + cause, + ); + if (cause instanceof AggregateError) { + throw compoundFailure( + setup, + failure("cleanup", "CP06_CREDENTIAL_CLEANUP_FAILED", 74), + ); + } + throw setup; + } + if (probe !== undefined) { + try { + probe.assertSafePaths?.({ mounted: true }); + } catch (cause) { + throw failure("source-integrity", "CP06_SOURCE_IDENTITY_CHANGED", 74, cause); + } + } + let workerContext = dependencies.workerContext; + if (mode === "worker" && !dependencies.spawn) { + try { + workerContext = workerEvidenceContext(childArgs[0], target, source, { provenance }); + } catch { + throw failure("audit", "CP06_CHILD_OUTPUT_INVALID", 70); + } + } + const command = childCommand({ + mode, + source, + target, + helper, + syscallProbe, + childArgs, + beforeHash, + probe: probe !== undefined, + }); + let result; + try { + const launch = (stdio) => + spawn(command.command, command.args, { + cwd: probe?.cwd ?? root, + encoding: "utf8", + env: childEnvironment(mode, source, target), + timeout: mode === "worker" ? 600_000 : 60_000, + maxBuffer: 1024 * 1024, + stdio, + }); + if (dependencies.spawn) result = launch(undefined); + else { + const paths = + mode === "unsafe-probe" + ? [syscallProbe] + : mode === "hardened-probe" + ? [helper, syscallProbe] + : [helper, childArgs[0]]; + const fds = []; + try { + for (const path of paths) + fds.push(openSync(path, constants.O_RDONLY | constants.O_CLOEXEC)); + result = launch(["pipe", "pipe", "pipe", ...fds]); + } finally { + for (const fd of fds) closeSync(fd); + } + } + } catch (cause) { + throw failure("launch", "CP06_CHILD_LAUNCH_FAILED", 70, cause); + } + if (result.error !== undefined) { + const timeout = result.error?.code === "ETIMEDOUT"; + throw failure( + timeout ? "timeout" : "launch", + timeout ? "CP06_CHILD_TIMEOUT" : "CP06_CHILD_LAUNCH_FAILED", + 70, + result.error, + ); + } + if (result.status !== 0) { + throw failure( + "child-exit", + result.status === 75 ? "CP06_AUTH_BLOCKED" : "CP06_CHILD_EXIT_FAILED", + result.status ?? 70, + ); + } + + let sourceUnchanged; + try { + sourceUnchanged = + mode === "worker" ? await mount.verifyIntegrity() : probe.sourceUnchanged(); + } catch { + sourceUnchanged = false; + } + if (!sourceUnchanged && mode !== "unsafe-probe") { + throw failure("source-integrity", "CP06_SOURCE_IDENTITY_CHANGED", 74); + } + let childEvidence; + try { + childEvidence = JSON.parse(String(result.stdout).trim()); + } catch { + throw failure("audit", "CP06_CHILD_OUTPUT_INVALID", 70); + } + try { + if (!validChildContract(mode, childEvidence)) throw new Error(); + if (mode === "worker") { + validateWorkerEvidence(childEvidence, workerContext); + } + } catch { + throw failure("audit", "CP06_CHILD_OUTPUT_INVALID", 70); + } + return { + schema_version: 1, + mode, + source_unchanged: sourceUnchanged, + mount_ids_distinct: mount.mountIds.source !== mount.mountIds.target, + parent_roots_read_only: mount.parentMounts.length > 0, + cleanup: "pass", + child: childEvidence, + }; + }, + () => mount?.cleanup(), + ), + () => probe?.cleanup(), + ); +} + +const WORKER_CHILD_KEYS = [ + "credential_store", + "event_stream", + "model", + "oauth_preflight", + "pi_install", + "pi_version", + "provider", + "raw_transcript_retained", + "response", + "response_sha256", + "result", + "schema_version", + "session", +].join(","); + +function validChildContract(mode, child) { + if (!isPlainObject(child)) return false; + if (mode !== "worker") { + return ( + isPlainObject(child.identity) && + isPlainObject(child.identity.status) && + isPlainObject(child.source) && + typeof child.source.unchanged === "boolean" && + isPlainObject(child.direct_namespace_syscalls) + ); + } + return ( + Object.keys(child).sort().join(",") === WORKER_CHILD_KEYS && + child.schema_version === 1 && + child.result === "pass" && + child.pi_version === "0.85.1" && + child.raw_transcript_retained === false && + isPlainObject(child.pi_install) && + isPlainObject(child.credential_store) && + isPlainObject(child.response) && + typeof child.response_sha256 === "string" && + /^[0-9a-f]{64}$/u.test(child.response_sha256) && + child.oauth_preflight?.refreshed === false && + child.session?.in_memory === true && + JSON.stringify(child.session?.active_tools) === '["read"]' && + child.event_stream?.read_audit?.exact_original === true && + child.event_stream.read_audit.read_count === 1 + ); +} + +function isPlainObject(value) { + return value !== null && typeof value === "object" && !Array.isArray(value); +} + +function childCommand({ + mode, + source, + target, + helper, + syscallProbe, + childArgs, + beforeHash, + probe, +}) { + const childSource = probe ? "source/DUMMY-auth.json" : source; + const childTarget = probe ? "agent/DUMMY-auth.json" : target; + if (mode === "unsafe-probe") { + return { + command: process.execPath, + args: [ + resolve(root, "scripts/cp06-isolation-adversary.mjs"), + "unsafe", + childSource, + childTarget, + "/proc/self/fd/3", + beforeHash, + String(process.pid), + ], + }; + } + const program = + mode === "hardened-probe" + ? [ + process.execPath, + resolve(root, "scripts/cp06-isolation-adversary.mjs"), + "hardened", + childSource, + childTarget, + "/proc/self/fd/4", + beforeHash, + String(process.pid), + ] + : [ + process.execPath, + "--experimental-import-meta-resolve", + resolve(root, "scripts/cp06-sdk-worker.mjs"), + target, + "/proc/self/fd/4", + ...childArgs.slice(1), + ]; + return { + command: "setpriv", + args: [ + "--no-new-privs", + "--bounding-set=-all", + "--inh-caps=-all", + "--ambient-caps=-all", + "--securebits=+noroot,+noroot_locked,+no_setuid_fixup,+no_setuid_fixup_locked", + "--", + "/proc/self/fd/3", + "--cp06-readonly-fd=4", + ...program, + ], + }; +} + +function childEnvironment(childMode, credentialSource, credentialTarget) { + const environment = { + PATH: process.env.PATH ?? "/usr/bin:/bin", + LANG: process.env.LANG ?? "C.UTF-8", + LC_ALL: process.env.LC_ALL ?? "C.UTF-8", + PI_OFFLINE: "1", + PI_SKIP_VERSION_CHECK: "1", + PI_TELEMETRY: "0", + }; + if (childMode === "worker") { + environment.CP06_PROJECT_ROOT = root; + environment.CP06_CREDENTIAL_SOURCE = credentialSource; + environment.CP06_CREDENTIAL_TARGET = credentialTarget; + for (const name of [ + "CP06_PI_PACKAGE_ROOT", + "CP06_PI_BIN", + "SSL_CERT_FILE", + "SSL_CERT_DIR", + "NODE_EXTRA_CA_CERTS", + "HTTP_PROXY", + "HTTPS_PROXY", + "NO_PROXY", + ]) { + if (process.env[name] !== undefined) environment[name] = process.env[name]; + } + } + return environment; +} + +export function supervisorFailureRecord(error) { + const failure = classifyFailure(error); + return { + schema_version: 1, + stage: failure.stage, + code: failure.code, + exit_code: failure.exit_code, + description: failure.description, + causes: error instanceof AggregateError ? leafCauses(error).map(classifyFailure) : [], + }; +} + +function failure(stage, code, exitCode, cause) { + return Object.assign(new Error(code, { cause }), { stage, exitCode, code }); +} + +function compoundFailure(primary, cleanup) { + return Object.assign(new AggregateError([primary, cleanup], "CP06_CLEANUP_FAILED"), { + stage: "cleanup", + exitCode: 74, + code: "CP06_CLEANUP_FAILED", + }); +} + +function hasErrorCode(error, code) { + if (error?.code === code) return true; + return error instanceof AggregateError && error.errors.some((cause) => hasErrorCode(cause, code)); +} + +function leafCauses(error, limit = 8) { + const leaves = []; + function visit(cause) { + if (leaves.length >= limit) return; + if (cause instanceof AggregateError && cause.errors.length > 0) { + for (const nested of cause.errors) visit(nested); + } else leaves.push(cause); + } + visit(error); + return leaves; +} + +function safeExitCode(error) { + return Number.isSafeInteger(error?.exitCode) && error.exitCode >= 1 && error.exitCode <= 255 + ? error.exitCode + : null; +} + +function classifyFailure(error) { + const known = FAILURE_DESCRIPTIONS[error?.code]; + if (known !== undefined) { + return { + stage: known.stage, + code: error.code, + exit_code: safeExitCode(error), + description: known.description, + }; + } + if (error instanceof AggregateError) { + return { + stage: "cleanup", + code: "CP06_CLEANUP_FAILED", + exit_code: safeExitCode(error) ?? 74, + description: "credential namespace cleanup failed", + }; + } + return { + stage: "cleanup", + code: "CP06_CREDENTIAL_CLEANUP_FAILED", + exit_code: safeExitCode(error), + description: "credential mount cleanup failed", + }; +} + +const FAILURE_DESCRIPTIONS = { + CP06_ISOLATION_SETUP_FAILED: { + stage: "setup", + description: "credential namespace setup failed", + }, + CP06_CREDENTIAL_SOURCE_MISSING: { + stage: "missing-source", + description: "credential source is missing", + }, + CP06_CHILD_LAUNCH_FAILED: { stage: "launch", description: "isolated child launch failed" }, + CP06_CHILD_TIMEOUT: { stage: "timeout", description: "isolated child timed out" }, + CP06_AUTH_BLOCKED: { stage: "child-exit", description: "credential preflight blocked the child" }, + CP06_CHILD_EXIT_FAILED: { + stage: "child-exit", + description: "isolated child exited unsuccessfully", + }, + CP06_SOURCE_IDENTITY_CHANGED: { + stage: "source-integrity", + description: "credential source identity changed", + }, + CP06_CHILD_OUTPUT_INVALID: { stage: "audit", description: "isolated child output was invalid" }, + CP06_CLEANUP_FAILED: { stage: "cleanup", description: "credential namespace cleanup failed" }, + CP06_CREDENTIAL_CLEANUP_FAILED: { + stage: "cleanup", + description: "credential mount cleanup failed", + }, +}; + +if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { + const [mode, source, target, inheritedHelper, inheritedProbe, ...childArgs] = + process.argv.slice(2); + const [helper, syscallProbe] = [inheritedHelper, inheritedProbe].map((path) => + /^\/proc\/self\/fd\/\d+$/u.test(path ?? "") + ? `/proc/${process.pid}/fd/${path.slice("/proc/self/fd/".length)}` + : path, + ); + try { + console.log( + JSON.stringify( + await superviseCredentialChild({ + mode, + source, + target, + helper, + syscallProbe, + childArgs: childArgs.map((path) => + /^\/proc\/self\/fd\/\d+$/u.test(path) + ? `/proc/${process.pid}/fd/${path.slice("/proc/self/fd/".length)}` + : path, + ), + }), + ), + ); + } catch (error) { + process.stderr.write(`${JSON.stringify(supervisorFailureRecord(error))}\n`); + process.exitCode = error.exitCode ?? 70; + } +} diff --git a/scripts/cp06-package-integrity.mjs b/scripts/cp06-package-integrity.mjs new file mode 100644 index 0000000..542cdca --- /dev/null +++ b/scripts/cp06-package-integrity.mjs @@ -0,0 +1,38 @@ +import { createHash } from "node:crypto"; +import { readFileSync, readdirSync } from "node:fs"; +import { join, relative, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +const pins = JSON.parse( + readFileSync( + fileURLToPath(new URL("../.factory/cp06-sdk-integrity.json", import.meta.url)), + "utf8", + ), +).packages; + +export function verifyPinnedPackage(root, name) { + const pin = pins[name]; + if (pin?.version !== "0.85.1") throw new Error("unsupported CP-06 package pin"); + const files = []; + function walk(directory) { + for (const entry of readdirSync(directory, { withFileTypes: true })) { + if (entry.name === "node_modules" && directory === root) continue; + const path = join(directory, entry.name); + if (entry.isDirectory()) walk(path); + else if (entry.isFile()) { + files.push([ + relative(root, path).split("\\").join("/"), + createHash("sha256").update(readFileSync(path)).digest("hex"), + ]); + } else throw new Error("selected CP-06 package contains an unsupported file entry"); + } + } + root = resolve(root); + walk(root); + files.sort((a, b) => (a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : 0)); + const manifest = createHash("sha256").update(JSON.stringify(files)).digest("hex"); + if (files.length !== pin.file_count || manifest !== pin.manifest_sha256) { + throw new Error("selected CP-06 package differs from the pinned artifact"); + } + return { manifest_sha256: manifest, tarball_integrity: pin.tarball_integrity }; +} diff --git a/scripts/cp06-pi-dependency.mjs b/scripts/cp06-pi-dependency.mjs new file mode 100644 index 0000000..e140592 --- /dev/null +++ b/scripts/cp06-pi-dependency.mjs @@ -0,0 +1,85 @@ +import { createHash } from "node:crypto"; +import { readFileSync, realpathSync } from "node:fs"; +import { dirname, isAbsolute, join, relative, resolve } from "node:path"; +import { fileURLToPath, pathToFileURL } from "node:url"; +import { verifyPinnedPackage } from "./cp06-package-integrity.mjs"; + +const packageName = "@earendil-works/pi-ai"; +const supportedVersion = "0.85.1"; + +/** Resolve Pi's import-only pi-ai dependency from the explicitly selected Pi package. */ +export function resolvePinnedPiAi(piRootArg) { + const piRoot = realpathSync(resolve(piRootArg)); + const piMetadataPath = join(piRoot, "package.json"); + const piMetadata = JSON.parse(readFileSync(piMetadataPath, "utf8")); + if ( + piMetadata.name !== "@earendil-works/pi-coding-agent" || + piMetadata.version !== supportedVersion || + piMetadata.dependencies?.[packageName] !== `^${supportedVersion}` + ) { + throw new Error("selected Pi package does not declare the supported pi-ai dependency"); + } + + const parent = pathToFileURL(piMetadataPath).href; + const entryUrl = import.meta.resolve(packageName, parent); + if (!entryUrl.startsWith("file:")) throw new Error("resolved pi-ai entry is not a local file"); + const entry = realpathSync(fileURLToPath(entryUrl)); + const dependencyRoot = findPackageRoot(entry, packageName); + const dependencyMetadataPath = join(dependencyRoot, "package.json"); + const dependencyMetadata = JSON.parse(readFileSync(dependencyMetadataPath, "utf8")); + if (dependencyMetadata.name !== packageName || dependencyMetadata.version !== supportedVersion) { + throw new Error("resolved pi-ai dependency is not exact version 0.85.1"); + } + + const selectedInstallNodeModules = dirname(dirname(piRoot)); + const nestedNodeModules = join(piRoot, "node_modules"); + if ( + !isWithin(selectedInstallNodeModules, dependencyRoot) && + !isWithin(nestedNodeModules, dependencyRoot) + ) { + throw new Error("resolved pi-ai dependency is outside the selected Pi installation"); + } + + const integrity = verifyPinnedPackage(dependencyRoot, packageName); + return { + entryUrl, + provenance: { + ...integrity, + name: dependencyMetadata.name, + version: dependencyMetadata.version, + root: dependencyRoot, + entry, + resolution: "esm-import-condition", + package_sha256: sha256(readFileSync(dependencyMetadataPath)), + entry_sha256: sha256(readFileSync(entry)), + }, + }; +} + +export async function importPinnedPiAi(piRootArg) { + const selected = resolvePinnedPiAi(piRootArg); + return { module: await import(selected.entryUrl), provenance: selected.provenance }; +} + +function findPackageRoot(entry, expectedName) { + let directory = dirname(entry); + for (;;) { + try { + const metadata = JSON.parse(readFileSync(join(directory, "package.json"), "utf8")); + if (metadata.name === expectedName) return directory; + } catch {} + const parent = dirname(directory); + if (parent === directory) break; + directory = parent; + } + throw new Error("resolved pi-ai package root is unavailable"); +} + +function isWithin(parent, child) { + const path = relative(parent, child); + return path === "" || (!path.startsWith("..") && !isAbsolute(path)); +} + +function sha256(value) { + return createHash("sha256").update(value).digest("hex"); +} diff --git a/scripts/cp06-pi-install.mjs b/scripts/cp06-pi-install.mjs new file mode 100644 index 0000000..8e4739a --- /dev/null +++ b/scripts/cp06-pi-install.mjs @@ -0,0 +1,109 @@ +import { createHash } from "node:crypto"; +import { spawnSync } from "node:child_process"; +import { accessSync, constants, readFileSync, realpathSync } from "node:fs"; +import { isAbsolute, join, relative, resolve } from "node:path"; +import { verifyPinnedPackage } from "./cp06-package-integrity.mjs"; +import { resolvePinnedPiAi } from "./cp06-pi-dependency.mjs"; + +const PACKAGE_NAME = "@earendil-works/pi-coding-agent"; +const SUPPORTED_VERSION = "0.85.1"; + +export function resolvePinnedPiInstall(options) { + const { projectRoot, packageRoot, executable, provenance } = selectPinnedPi(options); + const spawn = options.spawnSyncImpl ?? spawnSync; + const version = spawn(executable, ["--version"], { + cwd: projectRoot, + encoding: "utf8", + timeout: 10_000, + env: options.environment ?? process.env, + }); + if ( + version.error !== undefined || + version.status !== 0 || + version.stdout.trim() !== SUPPORTED_VERSION + ) { + throw new Error("selected task-local Pi executable version is unsupported"); + } + return { root: packageRoot, executable, version: SUPPORTED_VERSION, provenance }; +} + +/** Selected-installation provenance from paths, metadata and pins only; never executes Pi. */ +export function resolvePinnedPiProvenance(options) { + return selectPinnedPi(options).provenance; +} + +function selectPinnedPi(options) { + const projectRoot = realpathSync(resolve(options.projectRoot)); + const selectedRoot = resolve(projectRoot, ".factory/state/cp06-sdk"); + const expectedPackageRoot = join( + selectedRoot, + "node_modules", + "@earendil-works", + "pi-coding-agent", + ); + const expectedExecutable = join(selectedRoot, "node_modules", ".bin", "pi"); + requireExactPath(options.packageRoot, expectedPackageRoot, "Pi package root"); + requireExactPath(options.executable, expectedExecutable, "Pi executable"); + + const packageRoot = realpathSync(expectedPackageRoot); + if (packageRoot !== expectedPackageRoot) { + throw new Error("selected Pi package root is not canonical"); + } + accessSync(expectedExecutable, constants.X_OK); + const executableEntry = realpathSync(expectedExecutable); + if (!isWithin(packageRoot, executableEntry)) { + throw new Error("selected Pi executable resolves outside its package"); + } + + const metadataPath = join(packageRoot, "package.json"); + const sdkEntry = realpathSync(join(packageRoot, "dist/index.js")); + if (!isWithin(packageRoot, sdkEntry)) { + throw new Error("selected Pi SDK entry resolves outside its package"); + } + const metadata = JSON.parse(readFileSync(metadataPath, "utf8")); + const declaredBin = typeof metadata.bin === "string" ? metadata.bin : metadata.bin?.pi; + if ( + metadata.name !== PACKAGE_NAME || + metadata.version !== SUPPORTED_VERSION || + typeof declaredBin !== "string" || + realpathSync(resolve(packageRoot, declaredBin)) !== executableEntry + ) { + throw new Error("selected task-local Pi package is unsupported"); + } + + const packageIntegrity = verifyPinnedPackage(packageRoot, PACKAGE_NAME); + const piAi = resolvePinnedPiAi(packageRoot); + + return { + projectRoot, + packageRoot, + executable: expectedExecutable, + provenance: { + install_root: selectedRoot, + package_root: packageRoot, + package_artifact: packageIntegrity, + dependency: piAi.provenance, + executable: expectedExecutable, + executable_entry: executableEntry, + sdk_entry: sdkEntry, + package_sha256: sha256(readFileSync(metadataPath)), + executable_sha256: sha256(readFileSync(executableEntry)), + sdk_entry_sha256: sha256(readFileSync(sdkEntry)), + }, + }; +} + +function requireExactPath(value, expected, label) { + if (typeof value !== "string" || !isAbsolute(value) || resolve(value) !== expected) { + throw new Error(`${label} must select the task-local CP-06 installation`); + } +} + +function isWithin(parent, child) { + const path = relative(parent, child); + return path === "" || (!path.startsWith("..") && !isAbsolute(path)); +} + +function sha256(value) { + return createHash("sha256").update(value).digest("hex"); +} diff --git a/scripts/cp06-probe-fixture.mjs b/scripts/cp06-probe-fixture.mjs new file mode 100644 index 0000000..a690fdb --- /dev/null +++ b/scripts/cp06-probe-fixture.mjs @@ -0,0 +1,443 @@ +import { createHash } from "node:crypto"; +import { spawnSync } from "node:child_process"; +import { + closeSync, + constants, + fstatSync, + lstatSync, + mkdirSync, + openSync, + readSync, + readFileSync, + ftruncateSync, + readdirSync, + rmdirSync, + unlinkSync, + writeSync, +} from "node:fs"; +import { mkdir, mkdtemp, writeFile } from "node:fs/promises"; +import { basename, join, relative, resolve } from "node:path"; + +const original = Buffer.from("DUMMY ORIGINAL"); +const directoryFlags = constants.O_RDONLY | constants.O_DIRECTORY | constants.O_NOFOLLOW; + +export function openProbeOutput({ root, outputRoot, create = false, runner = false }) { + const state = resolve(root, ".factory/state"); + const selected = resolve(outputRoot); + const subpath = relative(state, selected); + if ( + selected !== outputRoot || + subpath === "" || + subpath === ".." || + subpath.startsWith("../") || + !(runner + ? subpath === "cp06-correction" || subpath.startsWith("cp06-correction/") + : subpath === "cp06-auth-security" || subpath.split("/").includes("auth-security")) + ) + throw new Error("DUMMY proof root is outside task-local auth-security state"); + + const projectFd = openSync(root, directoryFlags); + let currentFd = projectFd; + try { + const components = [".factory", "state", ...subpath.split("/")]; + let scanFd = projectFd; + try { + for (const component of components) { + const path = join(`/proc/${process.pid}/fd/${scanFd}`, component); + try { + const entry = lstatSync(path); + if (!entry.isDirectory()) throw new Error("DUMMY proof output contains a non-directory"); + const nextFd = openSync(path, directoryFlags); + if (scanFd !== projectFd) closeSync(scanFd); + scanFd = nextFd; + } catch (error) { + if (error?.code !== "ENOENT" || !create) throw error; + break; + } + } + } finally { + if (scanFd !== projectFd) closeSync(scanFd); + } + for (const component of components) { + const path = join(`/proc/${process.pid}/fd/${currentFd}`, component); + if (create) { + try { + mkdirSync(path, { mode: 0o700 }); + } catch (error) { + if (error?.code !== "EEXIST") throw error; + } + } + const nextFd = openSync(path, directoryFlags); + if (currentFd !== projectFd) closeSync(currentFd); + currentFd = nextFd; + } + const outputFd = currentFd; + currentFd = projectFd; + return { + path: selected, + anchor: `/proc/${process.pid}/fd/${outputFd}`, + identity: fstatSync(outputFd, { bigint: true }), + close() { + closeSync(outputFd); + }, + }; + } finally { + if (currentFd !== projectFd) closeSync(currentFd); + closeSync(projectFd); + } +} + +export function openAnchoredDirectory( + parentAnchor, + relativePath, + { create = true, reset = false } = {}, +) { + const components = String(relativePath) + .split("/") + .filter((component) => component.length > 0); + if ( + components.length === 0 || + components.some( + (component) => component === "." || component === ".." || component.includes("\0"), + ) + ) { + throw new Error("DUMMY proof output child path is invalid"); + } + let currentFd; + let currentAnchor = parentAnchor; + try { + for (const [index, component] of components.entries()) { + const path = join(currentAnchor, component); + if (reset && index === components.length - 1) { + removeAnchoredEntry(currentAnchor, component); + } + if (create) { + try { + mkdirSync(path, { mode: 0o700 }); + } catch (error) { + if (error?.code !== "EEXIST") throw error; + } + } + const nextFd = openSync(path, directoryFlags); + if (currentFd !== undefined) closeSync(currentFd); + currentFd = nextFd; + currentAnchor = `/proc/${process.pid}/fd/${currentFd}`; + } + const outputFd = currentFd; + currentFd = undefined; + return { + anchor: `/proc/${process.pid}/fd/${outputFd}`, + identity: fstatSync(outputFd, { bigint: true }), + close() { + closeSync(outputFd); + }, + }; + } finally { + if (currentFd !== undefined) closeSync(currentFd); + } +} + +export function removeAnchoredEntry(parentAnchor, name, { beforeDescend } = {}) { + assertArtifactName(name); + const path = join(parentAnchor, name); + let entry; + try { + entry = lstatSync(path, { bigint: true }); + } catch (error) { + if (error?.code === "ENOENT") return; + throw error; + } + if (!entry.isDirectory()) { + unlinkSync(path); + return; + } + beforeDescend?.(path); + const fd = openSync(path, directoryFlags); + try { + if (!sameInode(entry, fstatSync(fd, { bigint: true }))) { + throw new Error("DUMMY proof directory identity changed during removal"); + } + const anchor = `/proc/${process.pid}/fd/${fd}`; + for (const child of readdirSync(anchor)) { + removeAnchoredEntry(anchor, child, { beforeDescend }); + } + } finally { + closeSync(fd); + } + rmdirSync(path); +} + +function assertArtifactName(name) { + if ( + typeof name !== "string" || + name.length === 0 || + name === "." || + name === ".." || + name.includes("/") || + name.includes("\0") + ) { + throw new Error("DUMMY proof artifact name is invalid"); + } +} + +export function writeAnchoredFile(parentAnchor, name, data, options) { + return writeAnchoredEntry(parentAnchor, name, data, options).sha256; +} + +export function writeAnchoredEntry(parentAnchor, name, data, { replace = false, expected } = {}) { + assertArtifactName(name); + const bytes = Buffer.from(data); + const path = join(parentAnchor, name); + const fd = openSync( + path, + constants.O_WRONLY | + constants.O_CREAT | + (replace ? 0 : constants.O_EXCL) | + constants.O_NOFOLLOW | + constants.O_CLOEXEC, + 0o600, + ); + try { + const created = fstatSync(fd, { bigint: true }); + if (!created.isFile() || created.nlink !== 1n) { + throw new Error("DUMMY proof artifact is not one fresh regular file"); + } + if ( + !sameInode(created, lstatSync(path, { bigint: true })) || + (replace && (expected === undefined || !sameInode(expected, created))) + ) { + throw new Error("DUMMY proof artifact identity changed"); + } + if (replace) ftruncateSync(fd, 0); + let offset = 0; + while (offset < bytes.length) offset += writeSync(fd, bytes, offset, bytes.length - offset); + if (!sameInode(created, lstatSync(path, { bigint: true }))) { + throw new Error("DUMMY proof artifact identity changed"); + } + return { + sha256: createHash("sha256").update(bytes).digest("hex"), + identity: { dev: created.dev, ino: created.ino }, + }; + } finally { + closeSync(fd); + } +} + +export function readAnchoredFile(parentAnchor, name, { expected } = {}) { + assertArtifactName(name); + const path = join(parentAnchor, name); + const fd = openSync(path, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_CLOEXEC); + try { + const pinned = fstatSync(fd, { bigint: true }); + if ( + !pinned.isFile() || + pinned.nlink !== 1n || + !sameInode(pinned, lstatSync(path, { bigint: true })) || + (expected !== undefined && !sameInode(expected, pinned)) + ) { + throw new Error("DUMMY proof artifact identity changed"); + } + const bytes = readFileSync(fd); + const current = fstatSync(fd, { bigint: true }); + if ( + !sameInode(pinned, lstatSync(path, { bigint: true })) || + current.size !== pinned.size || + current.mtimeNs !== pinned.mtimeNs || + current.ctimeNs !== pinned.ctimeNs + ) { + throw new Error("DUMMY proof artifact identity changed"); + } + return bytes; + } finally { + closeSync(fd); + } +} + +export function withArtifactParent(path, action) { + const match = String(path).match(new RegExp(`^(/proc/${process.pid}/fd/\\d+)/(.+)$`, "u")); + if (!match) throw new Error("DUMMY proof artifact must use a retained directory"); + const components = match[2].split("/"); + const name = components.pop(); + assertArtifactName(name); + const parent = components.length + ? openAnchoredDirectory(match[1], components.join("/"), { create: false }) + : null; + try { + return action(parent?.anchor ?? match[1], name); + } finally { + parent?.close(); + } +} + +export function writeArtifactFile(path, data, options) { + return writeArtifactEntry(path, data, options).sha256; +} + +export function writeArtifactEntry(path, data, options) { + return withArtifactParent(path, (anchor, name) => + writeAnchoredEntry(anchor, name, data, options), + ); +} + +export function readArtifactFile(path, encoding, options) { + const bytes = withArtifactParent(path, (anchor, name) => readAnchoredFile(anchor, name, options)); + return encoding ? bytes.toString(encoding) : bytes; +} + +export async function createProbeFixture({ root, outputRoot, spawn = spawnSync }) { + const output = openProbeOutput({ root, outputRoot, create: true }); + let rootFd; + let sourceDirFd; + let agentDirFd; + let sourceFd; + let directory; + let mounted = false; + try { + directory = await mkdtemp(join(output.anchor, "DUMMY-probe-")); + checkedMount(spawn, ["-t", "tmpfs", "-o", "mode=0700,nosuid,nodev,noexec", "tmpfs", directory]); + mounted = true; + rootFd = openSync(directory, directoryFlags); + const anchoredRoot = `/proc/${process.pid}/fd/${rootFd}`; + await mkdir(join(anchoredRoot, "source"), { mode: 0o700 }); + await mkdir(join(anchoredRoot, "agent"), { mode: 0o700 }); + sourceDirFd = openSync(join(anchoredRoot, "source"), directoryFlags); + agentDirFd = openSync(join(anchoredRoot, "agent"), directoryFlags); + const source = join(anchoredRoot, "source/DUMMY-auth.json"); + const target = join(anchoredRoot, "agent/DUMMY-auth.json"); + await writeFile(source, original, { flag: "wx", mode: 0o600 }); + sourceFd = openSync(source, constants.O_RDONLY | constants.O_NOFOLLOW); + const pinned = fstatSync(sourceFd, { bigint: true }); + if ( + !pinned.isFile() || + pinned.nlink !== 1n || + !sameInode(pinned, lstatSync(source, { bigint: true })) + ) { + throw new Error("DUMMY source identity changed"); + } + const beforeHash = hashPinned(sourceFd, original.length); + const sourceParent = fstatSync(sourceDirFd, { bigint: true }); + const targetParent = fstatSync(agentDirFd, { bigint: true }); + return { + source, + target, + cwd: anchoredRoot, + beforeHash, + credentialAnchors: { + sourceParent: `/proc/${process.pid}/fd/${sourceDirFd}`, + targetParent: `/proc/${process.pid}/fd/${agentDirFd}`, + sourceLeaf: "DUMMY-auth.json", + targetLeaf: "DUMMY-auth.json", + }, + assertSafePaths({ mounted = false } = {}) { + if ( + !sameInode(sourceParent, lstatSync(join(anchoredRoot, "source"), { bigint: true })) || + !sameInode(targetParent, lstatSync(join(anchoredRoot, "agent"), { bigint: true })) || + !sameInode(pinned, lstatSync(source, { bigint: true })) + ) + throw new Error("DUMMY fixture path identity changed"); + if (mounted) { + if (!sameInode(pinned, lstatSync(target, { bigint: true }))) { + throw new Error("DUMMY fixture target identity changed"); + } + } else { + try { + lstatSync(target); + throw new Error("DUMMY fixture target was replaced"); + } catch (error) { + if (error?.code !== "ENOENT") throw error; + } + } + }, + sourceUnchanged() { + try { + const named = lstatSync(source, { bigint: true }); + const current = fstatSync(sourceFd, { bigint: true }); + return ( + sameInode(pinned, named) && + named.isFile() && + sameInode(pinned, current) && + current.size === pinned.size && + named.size === pinned.size && + hashPinned(sourceFd, Number(pinned.size)) === beforeHash + ); + } catch { + return false; + } + }, + async cleanup() { + const failures = releaseFixture({ + spawn, + output, + descriptors: [sourceFd, sourceDirFd, agentDirFd, rootFd], + directory, + mounted, + alias: join(anchoredRoot, "DUMMY-alias"), + }); + mounted = false; + if (failures.length > 0) { + throw new AggregateError(failures, "DUMMY fixture cleanup failed"); + } + }, + }; + } catch (error) { + const failures = releaseFixture({ + spawn, + output, + descriptors: [sourceFd, sourceDirFd, agentDirFd, rootFd], + directory, + mounted, + }); + if (failures.length > 0) { + throw new AggregateError([error, ...failures], "DUMMY fixture setup and cleanup failed"); + } + throw error; + } +} + +function releaseFixture({ spawn, output, descriptors, directory, mounted, alias }) { + const failures = []; + const attempt = (action) => { + try { + action(); + return true; + } catch (error) { + failures.push(error); + return false; + } + }; + if (alias !== undefined) { + attempt(() => { + const mountedAlias = spawn("findmnt", ["--mountpoint", alias, "--noheadings"], { + encoding: "utf8", + timeout: 10_000, + }); + if (mountedAlias.status === 0) checkedMount(spawn, ["--", alias], "umount"); + }); + } + for (const fd of descriptors) { + if (fd !== undefined) attempt(() => closeSync(fd)); + } + const unmounted = !mounted || attempt(() => checkedMount(spawn, ["--", directory], "umount")); + if (directory !== undefined && unmounted) { + attempt(() => removeAnchoredEntry(output.anchor, basename(directory))); + } + attempt(() => output.close()); + return failures; +} + +function hashPinned(fd, length) { + const buffer = Buffer.alloc(length); + if (readSync(fd, buffer, 0, length, 0) !== length) return null; + return createHash("sha256").update(buffer).digest("hex"); +} + +function sameInode(a, b) { + return a.dev === b.dev && a.ino === b.ino; +} + +function checkedMount(spawn, args, command = "mount") { + const result = spawn(command, args, { encoding: "utf8", timeout: 10_000 }); + if (result.error !== undefined || result.status !== 0) { + throw new Error("private DUMMY fixture mount unavailable"); + } +} diff --git a/scripts/cp06-readonly-credentials.mjs b/scripts/cp06-readonly-credentials.mjs new file mode 100644 index 0000000..ad165d8 --- /dev/null +++ b/scripts/cp06-readonly-credentials.mjs @@ -0,0 +1,143 @@ +import { constants } from "node:fs"; +import { open } from "node:fs/promises"; + +const DEFAULT_MAX_BYTES = 1024 * 1024; + +export class Cp06AuthBlockedError extends Error { + constructor(message, options) { + super(message, options); + this.name = "Cp06AuthBlockedError"; + this.code = "CP06_AUTH_BLOCKED"; + } +} + +/** + * A deliberately tiny pi-ai CredentialStore for the CP-06 proof worker. + * It exposes one OAuth record and rejects every mutation before entering the + * callback that provider auth resolution would use to refresh the record. + */ +export class Cp06ReadOnlyCredentialStore { + #providerId; + #credential; + #audit = { reads: 0, lists: 0, modify_denials: 0, delete_denials: 0 }; + + static async load(options) { + const path = options?.path; + const providerId = options?.providerId; + const maxBytes = options?.maxBytes ?? DEFAULT_MAX_BYTES; + if (typeof path !== "string" || path.length === 0) { + throw blocked("credential target path is missing"); + } + if (typeof providerId !== "string" || providerId.length === 0) { + throw blocked("expected OAuth provider is missing"); + } + if (!Number.isSafeInteger(maxBytes) || maxBytes <= 0) { + throw blocked("credential size bound is invalid"); + } + + let handle; + try { + handle = await open(path, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_CLOEXEC); + const before = await handle.stat({ bigint: true }); + if (!before.isFile()) throw blocked("credential target is not a regular file"); + if (before.size <= 0n || before.size > BigInt(maxBytes)) { + throw blocked("credential target size is outside the allowed bound"); + } + const bytes = await handle.readFile(); + const after = await handle.stat({ bigint: true }); + if ( + before.dev !== after.dev || + before.ino !== after.ino || + before.size !== after.size || + bytes.length !== Number(before.size) + ) { + throw blocked("credential target changed while it was read"); + } + let parsed; + try { + parsed = JSON.parse(bytes.toString("utf8")); + } catch (cause) { + throw blocked("credential target is not valid JSON", { cause }); + } + const credential = parsed?.[providerId]; + validateOAuthCredential(credential); + return new Cp06ReadOnlyCredentialStore(providerId, credential); + } catch (cause) { + if (cause instanceof Cp06AuthBlockedError) throw cause; + throw blocked("credential target could not be opened read-only", { cause }); + } finally { + await handle?.close(); + } + } + + constructor(providerId, credential) { + this.#providerId = providerId; + this.#credential = structuredClone(credential); + } + + assertUsableOAuth(options = {}) { + const now = options.now ?? Date.now(); + const minValidityMs = options.minValidityMs ?? 300_000; + if (!Number.isFinite(now) || !Number.isFinite(minValidityMs) || minValidityMs < 0) { + throw blocked("OAuth validity preflight parameters are invalid"); + } + const remainingValidityMs = this.#credential.expires - now; + if (remainingValidityMs < minValidityMs) { + throw blocked("OAuth credential is expired or too near expiry for a no-refresh run"); + } + return { + providerId: this.#providerId, + type: "oauth", + expires: this.#credential.expires, + remainingValidityMs, + }; + } + + async read(providerId, options) { + options?.signal?.throwIfAborted(); + this.#audit.reads += 1; + return providerId === this.#providerId ? structuredClone(this.#credential) : undefined; + } + + async list(options) { + options?.signal?.throwIfAborted(); + this.#audit.lists += 1; + return [{ providerId: this.#providerId, type: "oauth" }]; + } + + async modify(_providerId, _callback, options) { + options?.signal?.throwIfAborted(); + this.#audit.modify_denials += 1; + throw blocked("CP-06 read-only credential store denies refresh and mutation"); + } + + async delete(_providerId, options) { + options?.signal?.throwIfAborted(); + this.#audit.delete_denials += 1; + throw blocked("CP-06 read-only credential store denies credential deletion"); + } + + audit() { + return { ...this.#audit }; + } +} + +function validateOAuthCredential(credential) { + if ( + credential === null || + typeof credential !== "object" || + Array.isArray(credential) || + credential.type !== "oauth" || + typeof credential.access !== "string" || + credential.access.length === 0 || + typeof credential.refresh !== "string" || + credential.refresh.length === 0 || + !Number.isFinite(credential.expires) + ) { + throw blocked("expected provider does not contain one usable OAuth credential"); + } +} + +function blocked(message, options) { + return new Cp06AuthBlockedError(message, options); +} diff --git a/scripts/cp06-sdk-worker.mjs b/scripts/cp06-sdk-worker.mjs new file mode 100644 index 0000000..90be6c9 --- /dev/null +++ b/scripts/cp06-sdk-worker.mjs @@ -0,0 +1,253 @@ +#!/usr/bin/env node +import { createHash } from "node:crypto"; +import { closeSync } from "node:fs"; +import { readWorkerInput } from "./cp06-worker-evidence.mjs"; +import { readFile } from "node:fs/promises"; +import { dirname, join, resolve } from "node:path"; +import { pathToFileURL } from "node:url"; +import { Cp06AuthBlockedError, Cp06ReadOnlyCredentialStore } from "./cp06-readonly-credentials.mjs"; +import { canonicalWorkerPaths, createGuardedReadTool } from "./cp06-guarded-read.mjs"; +import { resolvePinnedPiInstall } from "./cp06-pi-install.mjs"; +import { + assistantResponseText, + auditReadEvents, + sanitizedAuditReason, +} from "./cp06-worker-audit.mjs"; +import { auditWorkerOutcome } from "./cp06-worker-outcome.mjs"; + +const [credentialTargetArg, inputPathArg] = process.argv.slice(2); +if (!credentialTargetArg || !inputPathArg) process.exit(64); + +try { + const credentialTarget = resolve(credentialTargetArg); + const input = readWorkerInput(resolve(inputPathArg)); + if (inputPathArg === "/proc/self/fd/4") closeSync(4); + validateInput(input); + const paths = canonicalWorkerPaths(input, credentialTarget, process.env.CP06_CREDENTIAL_SOURCE); + const pi = resolvePiPackage(); + const sdk = await import(pathToFileURL(join(pi.root, "dist/index.js"))); + assertSdkSurface(sdk); + + const guardedRead = createGuardedReadTool({ + sdk, + root: paths.root, + expectedOriginalPath: paths.originalPath, + }); + const contract = await readFile(paths.contractPath, "utf8"); + const pack = await readFile(paths.packPath, "utf8"); + const original = guardedRead.original; + const prompt = buildPrompt({ contract, pack, originalPath: paths.originalPath }); + const store = await Cp06ReadOnlyCredentialStore.load({ + path: credentialTarget, + providerId: input.provider, + }); + const minOAuthValidityMs = input.timeout_ms + 300_000; + const validity = store.assertUsableOAuth({ minValidityMs: minOAuthValidityMs }); + + const runtime = await sdk.ModelRuntime.create({ + credentials: store, + modelsPath: null, + refreshOnCreate: false, + allowModelNetwork: false, + }); + const model = runtime.getModel(input.provider, input.model); + if (model === undefined) throw blocked("configured Pi model is not built in to Pi 0.85.1"); + const auth = await runtime.getAuth(model, { minOAuthValidityMs }); + if (auth === undefined) throw blocked("expected OAuth provider did not resolve authentication"); + + const resourceLoader = emptyResourceLoader(sdk, input.system_prompt); + const settingsManager = sdk.SettingsManager.inMemory({ + compaction: { enabled: false }, + retry: { + enabled: false, + maxRetries: 0, + provider: { timeoutMs: input.timeout_ms, maxRetries: 0, maxRetryDelayMs: 0 }, + }, + defaultProjectTrust: "never", + enableAnalytics: false, + enableInstallTelemetry: false, + }); + const { session, extensionsResult } = await sdk.createAgentSession({ + cwd: paths.root, + agentDir: dirname(credentialTarget), + model, + modelRuntime: runtime, + resourceLoader, + tools: ["read"], + customTools: [guardedRead.tool], + sessionManager: sdk.SessionManager.inMemory(paths.root), + settingsManager, + thinkingLevel: "high", + }); + if ((extensionsResult.extensions?.length ?? 0) !== 0 || session.sessionFile !== undefined) { + throw blocked("SDK worker unexpectedly loaded an extension or persistent session"); + } + if (JSON.stringify(session.getActiveToolNames()) !== JSON.stringify(["read"])) { + throw blocked("SDK worker active tool set is not exactly read"); + } + + const events = []; + const unsubscribe = session.subscribe((event) => events.push(structuredClone(event))); + let timedOut = false; + let timer; + try { + await Promise.race([ + session.prompt(prompt, { expandPromptTemplates: false, source: "rpc" }), + new Promise((_, reject) => { + timer = setTimeout(() => { + timedOut = true; + void session.abort(); + reject(new Error("CP06_WORKER_TIMEOUT")); + }, input.timeout_ms); + }), + ]); + } finally { + clearTimeout(timer); + if (timedOut || session.isStreaming) await session.abort(); + unsubscribe(); + session.dispose(); + } + + const responseText = finalAssistantText(events); + const response = parseResponse(responseText); + const readAudit = auditReadEvents(events, { + root: paths.root, + expectedOriginalPath: paths.originalPath, + expectedOriginal: original, + decisiveText: ["request-supplied owner fields are ignored"], + }); + if (!readAudit.ok) { + throw blocked(`SDK event audit failed: ${sanitizedAuditReason(readAudit.reason)}`); + } + const outcome = auditWorkerOutcome(response, { + root: paths.root, + referenceRoot: paths.fixtureRoot, + expectedOriginalPath: paths.originalPath, + }); + if (!outcome.ok) throw blocked(`SDK worker outcome failed: ${outcome.reason}`); + + console.log( + JSON.stringify({ + schema_version: 1, + result: "pass", + pi_version: pi.version, + pi_install: pi.provenance, + provider: input.provider, + model: input.model, + oauth_preflight: { + minimum_validity_ms: minOAuthValidityMs, + remaining_validity_ms: validity.remainingValidityMs, + refreshed: false, + }, + credential_store: store.audit(), + session: { in_memory: true, active_tools: session.getActiveToolNames() }, + event_stream: { + sha256: sha256(JSON.stringify(events)), + event_count: events.length, + read_audit: readAudit.summary, + }, + response, + response_sha256: sha256(responseText), + raw_transcript_retained: false, + }), + ); +} catch (error) { + if (error instanceof Cp06AuthBlockedError || error?.code === "CP06_AUTH_BLOCKED") { + process.stderr.write(`CP06_AUTH_BLOCKED: ${error.message}\n`); + process.exit(75); + } + process.stderr.write(`CP06_WORKER_FAILED: ${safeMessage(error)}\n`); + process.exit(70); +} + +function validateInput(input) { + if (input?.schema_version !== 1) throw blocked("worker input schema is unsupported"); + for (const key of ["root", "fixture_root", "contract_path", "pack_path", "original_path"]) + if (typeof input[key] !== "string" || !input[key].startsWith("/")) { + throw blocked(`worker input ${key} must be absolute`); + } + if (input.provider !== "openai-codex") throw blocked("worker provider must be openai-codex"); + if (typeof input.model !== "string" || input.model.length === 0) { + throw blocked("worker model is missing"); + } + if (!Number.isSafeInteger(input.timeout_ms) || input.timeout_ms < 1_000) { + throw blocked("worker timeout is invalid"); + } + if (typeof input.system_prompt !== "string" || input.system_prompt.length === 0) { + throw blocked("worker system prompt is missing"); + } +} + +function resolvePiPackage() { + try { + return resolvePinnedPiInstall({ + projectRoot: process.env.CP06_PROJECT_ROOT, + packageRoot: process.env.CP06_PI_PACKAGE_ROOT, + executable: process.env.CP06_PI_BIN, + }); + } catch { + throw blocked("explicit task-local Pi 0.85.1 installation is required"); + } +} + +function assertSdkSurface(sdk) { + for (const name of [ + "ModelRuntime", + "createAgentSession", + "createExtensionRuntime", + "createReadTool", + "SessionManager", + "SettingsManager", + ]) { + if (sdk[name] === undefined) throw blocked(`Pi SDK export is unavailable: ${name}`); + } +} + +function emptyResourceLoader(sdk, systemPrompt) { + return { + getExtensions: () => ({ extensions: [], errors: [], runtime: sdk.createExtensionRuntime() }), + getSkills: () => ({ skills: [], diagnostics: [] }), + getPrompts: () => ({ prompts: [], diagnostics: [] }), + getThemes: () => ({ themes: [], diagnostics: [] }), + getAgentsFiles: () => ({ agentsFiles: [] }), + getSystemPrompt: () => systemPrompt, + getSystemPromptSource: () => undefined, + getAppendSystemPrompt: () => [], + getAppendSystemPromptSources: () => [], + extendResources: () => {}, + reload: async () => {}, + }; +} + +function buildPrompt({ contract, pack, originalPath }) { + return `You are a fresh task reviewer. You have no previous worker transcript. Review only the bounded handoff below. First inspect the task and pack. One concrete creation-time ownership rule was deliberately omitted from this handoff. Report MISSING_SOURCE, set missing_source to the exact canonical relative path docs/AUTH.md, and use the read tool exactly once, without offset or limit, on the supplied exact-original path; do not scan or read any other project file. Then return a concise JSON object with keys status, missing_source, reads, corrected_constraints, evidence_gaps, and broad_scan. The corrected_constraints field must be exactly ["ownership comes only from the authenticated principal","request-supplied owner fields are ignored"]. The evidence_gaps field must be exactly [{"id":"creation-time-ownership","status":"unverified","missing_checks":["principal-derived-owner","request-owner-ignored"]}]: the supplied deterministic evidence does not test those creation-time behaviors. Recovering a requirement by reading is not evidence that the behavior passed. Do not add a passed/verified disposition or prose fields to this structured gap. Do not modify files.\n\nEXACT TASK CONTRACT\n---\n${contract}\n---\n\nBOUNDED PACK\n---\n${pack}\n---\n\nEXISTING DETERMINISTIC EVIDENCE\n---\nThe in-memory auth fixture currently reports absent/forged credentials as 401 and concealed cross-account GET/PUT/DELETE as 404 with unchanged state. Review whether the handoff states every decisive acceptance constraint; do not assume unshown creation semantics.\n---\n\nSUPPLIED EXACT-ORIGINAL PATH (read only if needed)\n${originalPath}\n`; +} + +function finalAssistantText(events) { + const envelope = assistantResponseText(events); + if (!envelope.ok) throw blocked(`SDK assistant envelope rejected: ${envelope.reason}`); + return envelope.text; +} + +function parseResponse(output) { + const trimmed = output.trim(); + const fenced = trimmed.match(/^```(?:json)?\s*\n([\s\S]*?)\n```$/); + try { + return JSON.parse(fenced ? fenced[1] : trimmed); + } catch { + throw blocked("SDK worker did not return the required JSON response"); + } +} + +function sha256(value) { + return createHash("sha256").update(value).digest("hex"); +} + +function blocked(message) { + return new Cp06AuthBlockedError(message); +} + +function safeMessage(error) { + if (error?.message === "CP06_WORKER_TIMEOUT") return "worker timed out and was aborted"; + return "worker execution failed"; +} diff --git a/scripts/cp06-seccomp-exec.c b/scripts/cp06-seccomp-exec.c new file mode 100644 index 0000000..cedb22d --- /dev/null +++ b/scripts/cp06-seccomp-exec.c @@ -0,0 +1,228 @@ +#define _GNU_SOURCE +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#if !defined(__linux__) || !defined(__x86_64__) +#error "CP-06 isolation helper currently supports Linux x86-64 only" +#endif + +typedef void *scmp_filter_ctx; +struct scmp_arg_cmp { + unsigned int arg; + int op; + uint64_t datum_a; + uint64_t datum_b; +}; +extern scmp_filter_ctx seccomp_init(uint32_t def_action); +extern int seccomp_rule_add(scmp_filter_ctx, uint32_t, int, unsigned int, ...); +extern int seccomp_rule_add_array(scmp_filter_ctx, uint32_t, int, unsigned int, + const struct scmp_arg_cmp *); +extern int seccomp_load(scmp_filter_ctx); +extern void seccomp_release(scmp_filter_ctx); +extern int seccomp_syscall_resolve_name(const char *); + +#define SCMP_ACT_ALLOW 0x7fff0000U +#define SCMP_ACT_ERRNO(x) (0x00050000U | ((x) & 0x0000ffffU)) +#define SCMP_CMP_MASKED_EQ 7 + +static void fail(const char *message) { + perror(message); + _exit(70); +} + +static void deny(scmp_filter_ctx ctx, const char *name, int error) { + int nr = seccomp_syscall_resolve_name(name); + if (nr >= 0 && seccomp_rule_add(ctx, SCMP_ACT_ERRNO(error), nr, 0) < 0) { + fprintf(stderr, "cannot install seccomp rule for %s\n", name); + _exit(70); + } +} + +static void close_inherited_fds(int retained) { +#ifdef SYS_close_range + if (retained == 4) { + close(3); + if (syscall(SYS_close_range, 5U, ~0U, 0U) == 0) return; + } else if (syscall(SYS_close_range, 3U, ~0U, 0U) == 0) return; + if (errno != ENOSYS) fail("close_range"); +#endif + long limit = sysconf(_SC_OPEN_MAX); + if (limit < 0 || limit > 1048576) limit = 1048576; + for (int fd = 3; fd < limit; ++fd) if (fd != retained) close(fd); +} + +/* + * Descriptor-bound read-only bind mounts for the private setup namespace. The + * source tree comes from a retained descriptor and the destination is an + * identity-checked O_PATH descriptor, so no mount input is resolved by name. + */ +static void refuse(const char *message) { + fprintf(stderr, "%s\n", message); + _exit(65); +} + +static int parse_u64(const char *text, uint64_t *value) { + char *end = NULL; + if (text == NULL || text[0] < '0' || text[0] > '9') return -1; + errno = 0; + unsigned long long parsed = strtoull(text, &end, 10); + if (errno != 0 || end == NULL || *end != '\0') return -1; + *value = (uint64_t)parsed; + return 0; +} + +static int has_identity(int fd, mode_t type, const char *dev, const char *ino) { + struct stat st; + uint64_t expected_dev; + uint64_t expected_ino; + return parse_u64(dev, &expected_dev) == 0 && parse_u64(ino, &expected_ino) == 0 && + fstat(fd, &st) == 0 && (st.st_mode & S_IFMT) == type && + (uint64_t)st.st_dev == expected_dev && (uint64_t)st.st_ino == expected_ino; +} + +static void require_visible(const char *path, mode_t type, const char *dev, const char *ino) { + int fd = open(path, O_PATH | O_NOFOLLOW | O_CLOEXEC); + if (fd < 0 || !has_identity(fd, type, dev, ino)) { + refuse("CP-06 mount destination path identity changed"); + } + close(fd); +} + +static void attach_read_only(int source_fd, unsigned int recursive, int destination_fd) { + int tree = (int)syscall(SYS_open_tree, source_fd, "", + OPEN_TREE_CLONE | OPEN_TREE_CLOEXEC | AT_EMPTY_PATH | recursive); + if (tree < 0) fail("open_tree"); + struct mount_attr attributes = { + .attr_set = MOUNT_ATTR_RDONLY | MOUNT_ATTR_NOSUID | MOUNT_ATTR_NODEV | MOUNT_ATTR_NOEXEC, + }; + if (syscall(SYS_mount_setattr, tree, "", AT_EMPTY_PATH | recursive, &attributes, + sizeof(attributes)) != 0) { + fail("mount_setattr"); + } + if (syscall(SYS_move_mount, tree, "", destination_fd, "", + MOVE_MOUNT_F_EMPTY_PATH | MOVE_MOUNT_T_EMPTY_PATH) != 0) { + fail("move_mount"); + } + close(tree); +} + +/* fd 3: pinned regular source; fd 4: retained destination parent directory. */ +static int bind_file(int argc, char **argv) { + if (argc != 8 || argv[4][0] == '\0' || strchr(argv[4], '/') != NULL || + strcmp(argv[4], ".") == 0 || strcmp(argv[4], "..") == 0) { + fprintf(stderr, "usage: cp06-seccomp-exec --cp06-bind-file SRC_DEV SRC_INO LEAF DEV INO PATH\n"); + return 64; + } + if (!has_identity(3, S_IFREG, argv[2], argv[3])) refuse("CP-06 mount source identity changed"); + int destination = openat(4, argv[4], O_PATH | O_NOFOLLOW | O_CLOEXEC); + if (destination < 0 || !has_identity(destination, S_IFREG, argv[5], argv[6])) { + refuse("CP-06 mount destination identity changed"); + } + require_visible(argv[7], S_IFREG, argv[5], argv[6]); + attach_read_only(3, 0, destination); + close(destination); + return 0; +} + +/* fd 3: retained directory, bound read-only over itself with its submounts. */ +static int bind_tree(int argc, char **argv) { + if (argc != 5) { + fprintf(stderr, "usage: cp06-seccomp-exec --cp06-bind-tree DEV INO PATH\n"); + return 64; + } + if (!has_identity(3, S_IFDIR, argv[2], argv[3])) { + refuse("CP-06 mount directory identity changed"); + } + require_visible(argv[4], S_IFDIR, argv[2], argv[3]); + attach_read_only(3, AT_RECURSIVE, 3); + return 0; +} + +int main(int argc, char **argv) { + if (argc >= 2 && strcmp(argv[1], "--cp06-bind-file") == 0) return bind_file(argc, argv); + if (argc >= 2 && strcmp(argv[1], "--cp06-bind-tree") == 0) return bind_tree(argc, argv); + if (argc < 2) { + fprintf(stderr, "usage: cp06-seccomp-exec COMMAND [ARG...]\n"); + return 64; + } + + pid_t parent = getppid(); + if (prctl(PR_SET_PDEATHSIG, SIGKILL) != 0) fail("PR_SET_PDEATHSIG"); + if (getppid() != parent || parent == 1) { + fprintf(stderr, "CP-06 supervisor disappeared before isolation\n"); + return 70; + } + int retained = -1; + if (strcmp(argv[1], "--cp06-readonly-fd=4") == 0) { + struct stat st; + if (argc < 3 || fstat(4, &st) != 0 || !S_ISREG(st.st_mode) || + (fcntl(4, F_GETFL) & O_ACCMODE) != O_RDONLY) return 64; + retained = 4; + ++argv; + --argc; + } + close_inherited_fds(retained); + if (prctl(PR_SET_NO_NEW_PRIVS, 1, 0, 0, 0) != 0) fail("PR_SET_NO_NEW_PRIVS"); + + scmp_filter_ctx ctx = seccomp_init(SCMP_ACT_ALLOW); + if (ctx == NULL) { + fprintf(stderr, "seccomp_init failed\n"); + return 70; + } + const char *blocked[] = { + "mount", "umount2", "pivot_root", + "move_mount", "open_tree", "fsopen", + "fsconfig", "fsmount", "fspick", + "mount_setattr", "unshare", "setns", + "open_by_handle_at", "name_to_handle_at", "ptrace", + "process_vm_readv", "process_vm_writev", "pidfd_getfd", + "bpf", "perf_event_open", "personality", + "kexec_load", "kexec_file_load", + }; + for (size_t i = 0; i < sizeof(blocked) / sizeof(blocked[0]); ++i) { + deny(ctx, blocked[i], EPERM); + } + + /* Node/libc can fall back to clone(2), where namespace bits are filtered. */ + deny(ctx, "clone3", ENOSYS); + int clone_nr = seccomp_syscall_resolve_name("clone"); + const uint64_t namespace_flags[] = { + CLONE_NEWNS, CLONE_NEWCGROUP, CLONE_NEWUTS, CLONE_NEWIPC, + CLONE_NEWUSER, CLONE_NEWPID, CLONE_NEWNET, + }; + for (size_t i = 0; + clone_nr >= 0 && i < sizeof(namespace_flags) / sizeof(namespace_flags[0]); + ++i) { + struct scmp_arg_cmp comparison = { + .arg = 0, + .op = SCMP_CMP_MASKED_EQ, + .datum_a = namespace_flags[i], + .datum_b = namespace_flags[i], + }; + if (seccomp_rule_add_array(ctx, SCMP_ACT_ERRNO(EPERM), clone_nr, 1, + &comparison) < 0) { + fprintf(stderr, "cannot install clone namespace filter\n"); + seccomp_release(ctx); + return 70; + } + } + + if (seccomp_load(ctx) < 0) { + seccomp_release(ctx); + fail("seccomp_load"); + } + seccomp_release(ctx); + execvp(argv[1], &argv[1]); + fail("execvp"); +} diff --git a/scripts/cp06-worker-audit.mjs b/scripts/cp06-worker-audit.mjs new file mode 100644 index 0000000..a492dfa --- /dev/null +++ b/scripts/cp06-worker-audit.mjs @@ -0,0 +1,200 @@ +import { createHash } from "node:crypto"; +import { resolve } from "node:path"; + +export function isExpectedOriginalReference(value, options) { + return value === (options.expectedOriginalReference ?? "docs/AUTH.md"); +} + +export const AUDIT_REJECTION_REASONS = Object.freeze({ + unexpectedTools: "unexpected tool events", + readCount: "expected exactly one read of the supplied original", + missingCallId: "original read start omitted toolCallId", + range: "original read used offset or limit", + matchingCompletion: "expected one matching read completion", + duplicateCompletion: "read event stream contains an unmatched or duplicate completion", + completionOrder: "read completion did not occur after its matching start", + completionFailed: "matching read completion was not explicitly successful", + completionContent: "successful read completion omitted one authoritative text result", + bytes: "read result bytes do not equal the supplied exact original", + decisive: "the supplied original does not contain every declared decisive constraint", + responseOrder: "final assistant response did not occur after the successful original read", +}); +const ALLOWLISTED_REASONS = new Set(Object.values(AUDIT_REJECTION_REASONS)); + +export function sanitizedAuditReason(reason) { + return ALLOWLISTED_REASONS.has(reason) ? reason : "SDK event audit rejected the event stream"; +} + +export function auditReadEvents(events, options) { + const expectedPath = resolve(options.root, options.expectedOriginalPath); + const expectedBytes = Buffer.isBuffer(options.expectedOriginal) + ? options.expectedOriginal + : Buffer.from(options.expectedOriginal, "utf8"); + const expectedText = expectedBytes.toString("utf8"); + const decisiveText = options.decisiveText ?? []; + const starts = events + .map((event, index) => ({ event, index })) + .filter(({ event }) => event?.type === "tool_execution_start"); + const ends = events + .map((event, index) => ({ event, index })) + .filter(({ event }) => event?.type === "tool_execution_end"); + const readStarts = starts.filter(({ event }) => event.toolName === "read"); + const readEnds = ends.filter(({ event }) => event.toolName === "read"); + const otherToolCalls = starts.filter(({ event }) => event.toolName !== "read"); + const otherToolCompletions = ends.filter(({ event }) => event.toolName !== "read"); + const resolvedReads = readStarts.map(({ event, index }) => ({ + event, + index, + path: event.args?.path, + resolvedPath: + typeof event.args?.path === "string" ? resolve(options.root, event.args.path) : null, + })); + const originalReads = resolvedReads.filter((entry) => entry.resolvedPath === expectedPath); + const rejectedReads = resolvedReads.filter((entry) => entry.resolvedPath !== expectedPath); + const summary = { + read_count: readStarts.length, + project_read_count: originalReads.length, + project_read_paths: originalReads.map((entry) => entry.resolvedPath), + rejected_read_paths: rejectedReads.map((entry) => entry.path), + other_tool_calls: otherToolCalls.map(({ event }) => event.toolName), + other_tool_completions: otherToolCompletions.map(({ event }) => event.toolName), + completed_successfully: false, + exact_original: false, + expected_sha256: sha256(expectedBytes), + returned_sha256: null, + returned_bytes: null, + tool_call_id: null, + start_event_index: null, + end_event_index: null, + response_event_index: null, + }; + const reject = (reason) => ({ ok: false, reason, summary }); + + if (otherToolCalls.length > 0 || otherToolCompletions.length > 0) { + return reject(AUDIT_REJECTION_REASONS.unexpectedTools); + } + if (readStarts.length !== 1 || originalReads.length !== 1 || rejectedReads.length > 0) { + return reject(AUDIT_REJECTION_REASONS.readCount); + } + + const start = originalReads[0]; + const callId = start.event.toolCallId; + summary.tool_call_id = typeof callId === "string" ? callId : null; + summary.start_event_index = start.index; + if (typeof callId !== "string" || callId.length === 0) { + return reject(AUDIT_REJECTION_REASONS.missingCallId); + } + const args = start.event.args; + if ( + args !== null && + typeof args === "object" && + (Object.hasOwn(args, "offset") || Object.hasOwn(args, "limit")) + ) { + return reject(AUDIT_REJECTION_REASONS.range); + } + + const matchingEnds = readEnds.filter(({ event }) => event.toolCallId === callId); + if (matchingEnds.length !== 1) { + return reject(AUDIT_REJECTION_REASONS.matchingCompletion); + } + if (readEnds.length !== 1) { + return reject(AUDIT_REJECTION_REASONS.duplicateCompletion); + } + const end = matchingEnds[0]; + summary.end_event_index = end.index; + if (end.index <= start.index) { + return reject(AUDIT_REJECTION_REASONS.completionOrder); + } + if (end.event.isError !== false) { + return reject(AUDIT_REJECTION_REASONS.completionFailed); + } + const content = end.event.result?.content; + if ( + !Array.isArray(content) || + content.length !== 1 || + content[0]?.type !== "text" || + typeof content[0].text !== "string" + ) { + return reject(AUDIT_REJECTION_REASONS.completionContent); + } + const returnedBytes = Buffer.from(content[0].text, "utf8"); + summary.returned_sha256 = sha256(returnedBytes); + summary.returned_bytes = returnedBytes.length; + if (!returnedBytes.equals(expectedBytes)) { + return reject(AUDIT_REJECTION_REASONS.bytes); + } + if (decisiveText.some((text) => !expectedText.includes(text))) { + return reject(AUDIT_REJECTION_REASONS.decisive); + } + + const finalResponse = events + .map((event, index) => ({ event, index })) + .filter(({ event }) => event?.type === "message_end" && event.message?.role === "assistant") + .at(-1); + summary.response_event_index = finalResponse?.index ?? null; + if (finalResponse === undefined || finalResponse.index <= end.index) { + return reject(AUDIT_REJECTION_REASONS.responseOrder); + } + + summary.completed_successfully = true; + summary.exact_original = true; + return { ok: true, summary }; +} + +function sha256(value) { + return createHash("sha256").update(value).digest("hex"); +} + +export function assistantResponseText(events) { + const messages = events + .filter((event) => event?.type === "message_end" && event.message?.role === "assistant") + .map((event) => event.message); + const final = messages.at(-1); + if (final === undefined) { + return { ok: false, reason: "SDK event stream omitted the final assistant message" }; + } + for (const message of messages.slice(0, -1)) { + if (!isToolCallEnvelope(message)) { + return { ok: false, reason: "an earlier assistant message carried unsupported content" }; + } + } + if (final.stopReason !== "stop") { + return { ok: false, reason: "final assistant message did not stop normally" }; + } + if (typeof final.content === "string") return { ok: true, text: final.content }; + if ( + !Array.isArray(final.content) || + !final.content.some((block) => block?.type === "text") || + !final.content.every( + (block) => + (block?.type === "text" && typeof block.text === "string") || isThinkingBlock(block), + ) + ) { + return { ok: false, reason: "final assistant message carried an unsupported content channel" }; + } + return { + ok: true, + text: final.content + .filter((block) => block.type === "text") + .map((block) => block.text) + .join(""), + }; +} + +function isToolCallEnvelope(message) { + return ( + message.stopReason === "toolUse" && + Array.isArray(message.content) && + message.content.some((block) => block?.type === "toolCall") && + message.content.every( + (block) => + (block?.type === "toolCall" && typeof block.id === "string") || + isThinkingBlock(block) || + (block?.type === "text" && typeof block.text === "string" && block.text.trim() === ""), + ) + ); +} + +function isThinkingBlock(block) { + return block?.type === "thinking" && typeof block.thinking === "string"; +} diff --git a/scripts/cp06-worker-evidence.mjs b/scripts/cp06-worker-evidence.mjs new file mode 100644 index 0000000..e0393d2 --- /dev/null +++ b/scripts/cp06-worker-evidence.mjs @@ -0,0 +1,203 @@ +import { closeSync, constants, fstatSync, openSync, readFileSync } from "node:fs"; +import { auditWorkerOutcome } from "./cp06-worker-outcome.mjs"; +import { validateReadAudit } from "./cp06-auth-security.mjs"; +import { canonicalWorkerPaths, readPinnedRegularFile } from "./cp06-guarded-read.mjs"; +import { DUMMY_PI_PROVENANCE } from "./cp06-dummy-originals.mjs"; +import { resolvePinnedPiProvenance } from "./cp06-pi-install.mjs"; + +export function workerEvidenceContext(inputPath, target, source, { provenance } = {}) { + const input = readWorkerInput(inputPath); + const paths = canonicalWorkerPaths(input, target, source); + const snapshot = readPinnedRegularFile(paths.originalPath); + return { + input, + paths, + original: snapshot.bytes, + originalIdentity: snapshot.identity, + provenance, + }; +} + +// The supervisor resolves the selected installation itself without executing +// Pi; the DUMMY expectation is a fixed literal no genuine installation reports. +export function trustedWorkerProvenance({ root, environment, dummy = false }) { + if (dummy) return DUMMY_PI_PROVENANCE; + return resolvePinnedPiProvenance({ + projectRoot: root, + packageRoot: environment.CP06_PI_PACKAGE_ROOT, + executable: environment.CP06_PI_BIN, + }); +} + +export function readWorkerInput(path) { + if (path === `/proc/${process.pid}/fd/5` || path === "/proc/self/fd/4") { + const fd = openSync(path, constants.O_RDONLY | constants.O_CLOEXEC); + try { + const identity = fstatSync(fd); + if ( + !identity.isFile() || + identity.nlink !== 1 || + identity.size < 1 || + identity.size > 50 * 1024 + ) + throw new Error("CP06_CHILD_OUTPUT_INVALID"); + return JSON.parse(readFileSync(fd, "utf8")); + } finally { + closeSync(fd); + } + } + return JSON.parse(readPinnedRegularFile(path).bytes.toString("utf8")); +} + +export function validateWorkerEvidence( + child, + { input, paths, original, originalIdentity, provenance }, +) { + if (originalIdentity) { + const current = readPinnedRegularFile(paths.originalPath); + for (const key of ["dev", "ino", "size", "nlink", "mode", "mtimeNs", "ctimeNs"]) + requireValue(current.identity[key] === originalIdentity[key]); + requireValue(current.bytes.equals(original)); + } + exact(child, [ + "credential_store", + "event_stream", + "model", + "oauth_preflight", + "pi_install", + "pi_version", + "provider", + "raw_transcript_retained", + "response", + "response_sha256", + "result", + "schema_version", + "session", + ]); + requireValue( + child.schema_version === 1 && + child.result === "pass" && + child.pi_version === "0.85.1" && + child.raw_transcript_retained === false, + ); + requireValue(child.provider === input.provider && child.model === input.model); + exact(child.pi_install, [ + "install_root", + "package_root", + "package_artifact", + "dependency", + "executable", + "executable_entry", + "sdk_entry", + "package_sha256", + "executable_sha256", + "sdk_entry_sha256", + ]); + for (const key of ["install_root", "package_root", "executable", "executable_entry", "sdk_entry"]) + requireValue( + typeof child.pi_install[key] === "string" && child.pi_install[key].startsWith("/"), + ); + for (const key of ["package_sha256", "executable_sha256", "sdk_entry_sha256"]) + requireValue(digest(child.pi_install[key])); + exact(child.pi_install.package_artifact, ["manifest_sha256", "tarball_integrity"]); + requireValue( + digest(child.pi_install.package_artifact.manifest_sha256) && + typeof child.pi_install.package_artifact.tarball_integrity === "string" && + child.pi_install.package_artifact.tarball_integrity.startsWith("sha512-"), + ); + exact(child.pi_install.dependency, [ + "entry", + "entry_sha256", + "manifest_sha256", + "name", + "package_sha256", + "resolution", + "root", + "tarball_integrity", + "version", + ]); + const dependency = child.pi_install.dependency; + requireValue( + dependency.version === "0.85.1" && + dependency.name === "@earendil-works/pi-ai" && + dependency.resolution === "esm-import-condition", + ); + for (const key of ["entry_sha256", "manifest_sha256", "package_sha256"]) + requireValue(digest(dependency[key])); + for (const key of ["root", "entry"]) + requireValue(typeof dependency[key] === "string" && dependency[key].startsWith("/")); + requireValue( + typeof dependency.tarball_integrity === "string" && + dependency.tarball_integrity.startsWith("sha512-"), + ); + requireValue(provenance !== undefined && sameJson(child.pi_install, provenance)); + exact(child.oauth_preflight, ["minimum_validity_ms", "remaining_validity_ms", "refreshed"]); + requireValue( + child.oauth_preflight.refreshed === false && + child.oauth_preflight.minimum_validity_ms === input.timeout_ms + 300_000 && + Number.isSafeInteger(child.oauth_preflight.remaining_validity_ms) && + child.oauth_preflight.remaining_validity_ms >= child.oauth_preflight.minimum_validity_ms, + ); + exact(child.credential_store, ["reads", "lists", "modify_denials", "delete_denials"]); + requireValue( + Number.isSafeInteger(child.credential_store.reads) && + child.credential_store.reads >= 1 && + Number.isSafeInteger(child.credential_store.lists) && + child.credential_store.lists >= 0 && + child.credential_store.modify_denials === 0 && + child.credential_store.delete_denials === 0, + ); + exact(child.session, ["in_memory", "active_tools"]); + requireValue( + child.session.in_memory === true && JSON.stringify(child.session.active_tools) === '["read"]', + ); + exact(child.event_stream, ["sha256", "event_count", "read_audit"]); + requireValue(digest(child.event_stream.sha256) && digest(child.response_sha256)); + validateReadAudit(child.event_stream.read_audit, { + toolCallId: child.event_stream.read_audit?.tool_call_id, + originalPath: paths.originalPath, + original, + eventCount: child.event_stream.event_count, + }); + requireValue( + typeof child.event_stream.read_audit.tool_call_id === "string" && + child.event_stream.read_audit.tool_call_id.length > 0, + ); + requireValue( + auditWorkerOutcome(child.response, { + root: paths.root, + referenceRoot: paths.fixtureRoot, + expectedOriginalPath: paths.originalPath, + }).ok, + ); + return child; +} + +function sameJson(actual, expected) { + if (expected === null || typeof expected !== "object") return actual === expected; + if ( + actual === null || + typeof actual !== "object" || + Array.isArray(actual) !== Array.isArray(expected) + ) + return false; + const keys = Object.keys(expected).sort(); + return ( + Object.keys(actual).sort().join(",") === keys.join(",") && + keys.every((key) => sameJson(actual[key], expected[key])) + ); +} +function exact(value, keys) { + requireValue( + value !== null && + typeof value === "object" && + !Array.isArray(value) && + Object.keys(value).sort().join(",") === keys.sort().join(","), + ); +} +function requireValue(value) { + if (!value) throw new Error("CP06_CHILD_OUTPUT_INVALID"); +} +function digest(value) { + return typeof value === "string" && /^[0-9a-f]{64}$/u.test(value); +} diff --git a/scripts/cp06-worker-lifecycle.mjs b/scripts/cp06-worker-lifecycle.mjs new file mode 100644 index 0000000..db2cf8e --- /dev/null +++ b/scripts/cp06-worker-lifecycle.mjs @@ -0,0 +1,34 @@ +export class Cp06CleanupError extends AggregateError { + constructor(primary, cleanup) { + super( + primary === undefined ? [cleanup] : [primary, cleanup], + "CP-06 evaluation cleanup failed", + { + cause: primary ?? cleanup, + }, + ); + this.code = "CP06_CLEANUP_FAILED"; + this.exitCode = 74; + } +} + +/** Await cleanup on success and failure; never lose the primary error. */ +export async function withCleanup(action, cleanup) { + let result; + let primary; + let failed = false; + try { + result = await action(); + } catch (error) { + failed = true; + primary = error; + } finally { + try { + await cleanup(); + } catch (error) { + throw new Cp06CleanupError(primary, error); + } + } + if (failed) throw primary; + return result; +} diff --git a/scripts/cp06-worker-outcome.mjs b/scripts/cp06-worker-outcome.mjs new file mode 100644 index 0000000..5636c02 --- /dev/null +++ b/scripts/cp06-worker-outcome.mjs @@ -0,0 +1,52 @@ +import { isExpectedOriginalReference } from "./cp06-worker-audit.mjs"; + +export function auditWorkerOutcome(response, options) { + const topLevelKeys = [ + "broad_scan", + "corrected_constraints", + "evidence_gaps", + "missing_source", + "reads", + "status", + ]; + const corrected = response?.corrected_constraints; + if ( + response === null || + typeof response !== "object" || + Array.isArray(response) || + Object.keys(response).sort().join(",") !== topLevelKeys.join(",") || + response.status !== "MISSING_SOURCE" || + !isExpectedOriginalReference(response.missing_source, options) || + response.broad_scan !== false || + !Array.isArray(response.reads) || + response.reads.length !== 1 || + response.reads[0] !== options.expectedOriginalPath || + !Array.isArray(corrected) || + corrected.length !== 2 || + corrected[0] !== "ownership comes only from the authenticated principal" || + corrected[1] !== "request-supplied owner fields are ignored" + ) { + return { ok: false, reason: "response did not satisfy the targeted-read oracle" }; + } + const gaps = response.evidence_gaps; + const gap = Array.isArray(gaps) && gaps.length === 1 ? gaps[0] : undefined; + const checks = gap?.missing_checks; + if ( + gap === null || + typeof gap !== "object" || + Array.isArray(gap) || + Object.keys(gap).sort().join(",") !== "id,missing_checks,status" || + gap.id !== "creation-time-ownership" || + gap.status !== "unverified" || + !Array.isArray(checks) || + checks.length !== 2 || + !checks.includes("principal-derived-owner") || + !checks.includes("request-owner-ignored") + ) { + return { + ok: false, + reason: "creation-time ownership evidence must remain explicitly unverified", + }; + } + return { ok: true }; +} diff --git a/scripts/prove-cp06-auth-security.mjs b/scripts/prove-cp06-auth-security.mjs new file mode 100644 index 0000000..fc4388f --- /dev/null +++ b/scripts/prove-cp06-auth-security.mjs @@ -0,0 +1,43 @@ +#!/usr/bin/env node +import { unlinkSync } from "node:fs"; +import { dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { Cp06IsolationUnsupportedError, runCp06SecurityPreflight } from "./cp06-auth-security.mjs"; +import { openProbeOutput, writeAnchoredFile } from "./cp06-probe-fixture.mjs"; + +const root = resolve(dirname(fileURLToPath(import.meta.url)), ".."); +const outputRoot = resolve(root, process.env.CP06_OUTPUT ?? ".factory/state/cp06-correction"); +try { + const proof = await runCp06SecurityPreflight({ + root, + outputRoot: resolve(outputRoot, "auth-security"), + reviewer: process.env.CP06_REVIEWER, + }); + const path = resolve(outputRoot, "auth-security", "evidence.json"); + const output = openProbeOutput({ root, outputRoot: resolve(outputRoot, "auth-security") }); + try { + try { + unlinkSync(resolve(output.anchor, "evidence.json")); + } catch (error) { + if (error?.code !== "ENOENT") throw error; + } + writeAnchoredFile(output.anchor, "evidence.json", `${JSON.stringify(proof, null, 2)}\n`); + } finally { + output.close(); + } + console.log( + JSON.stringify({ + ok: true, + gate: "CP-06-read-only-auth-security", + evidence_path: path.startsWith(`${root}/`) ? path.slice(root.length + 1) : path, + result: proof.result, + }), + ); +} catch (error) { + if (error instanceof Cp06IsolationUnsupportedError) { + process.stderr.write(`CP06_ISOLATION_UNSUPPORTED: ${error.message}\n`); + process.exit(73); + } + process.stderr.write(`CP06_SECURITY_PROOF_FAILED: ${error.message}\n`); + process.exit(1); +} diff --git a/scripts/prove-cp06-worker-cleanup.mjs b/scripts/prove-cp06-worker-cleanup.mjs new file mode 100644 index 0000000..c6f6ac2 --- /dev/null +++ b/scripts/prove-cp06-worker-cleanup.mjs @@ -0,0 +1,229 @@ +#!/usr/bin/env node +import { strict as assert } from "node:assert"; +import { createHash } from "node:crypto"; +import { spawnSync } from "node:child_process"; +import { readFile, readdir } from "node:fs/promises"; +import { join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { + openAnchoredDirectory, + openProbeOutput, + removeAnchoredEntry, + writeAnchoredFile, +} from "./cp06-probe-fixture.mjs"; +import { withCleanup } from "./cp06-worker-lifecycle.mjs"; +import { + DUMMY_CLI_SCENARIOS, + DUMMY_CLI_SECRET, + writeDummyCliShims, +} from "./cp06-dummy-cli-shims.mjs"; + +const root = resolve(fileURLToPath(new URL("..", import.meta.url))); +const outputRoot = resolve(root, process.env.CP06_OUTPUT ?? ".factory/state/cp06-correction"); +const output = openProbeOutput({ root, outputRoot, create: true, runner: true }); +const proofDirectory = openAnchoredDirectory(output.anchor, "worker-cleanup", { reset: true }); +const proofRoot = proofDirectory.anchor; +const proofRootPath = join(outputRoot, "worker-cleanup"); +const scratchDirectory = openAnchoredDirectory(proofRoot, "DUMMY-cleanup", { reset: true }); +const cases = await withCleanup( + async () => { + const cases = []; + const compoundScenarios = Object.entries(DUMMY_CLI_SCENARIOS) + .filter(([, { record }]) => record !== null) + .map(([scenario, { record }]) => [scenario, record.exit_code, record]); + for (const [scenario, expectedExit, expectedRecord] of [ + ["expired", 75], + ["near-expiry", 75], + ["missing-source-setup", 70], + ...compoundScenarios, + ]) { + const homeDirectory = openAnchoredDirectory(scratchDirectory.anchor, scenario); + const temporaryDirectory = openAnchoredDirectory(homeDirectory.anchor, "tmp"); + const credentialDirectory = openAnchoredDirectory(homeDirectory.anchor, "credential"); + const home = homeDirectory.anchor; + const temporary = join(homeDirectory.anchor, "tmp"); + const proofOutputPath = join(proofRootPath, "proofs", scenario); + const proofOutputDirectory = openAnchoredDirectory( + proofDirectory.anchor, + `proofs/${scenario}`, + { reset: true }, + ); + const proofOutput = proofOutputDirectory.anchor; + const source = join(homeDirectory.anchor, "credential", "DUMMY-auth.json"); + const namespaceSource = join( + proofRootPath, + "DUMMY-cleanup", + scenario, + "credential", + "DUMMY-auth.json", + ); + const sourceBytes = JSON.stringify({ + "openai-codex": { + type: "oauth", + access: "DUMMY-ACCESS", + refresh: "DUMMY-REFRESH", + expires: + scenario === "expired" + ? 0 + : Date.now() + (expectedRecord === undefined ? 60_000 : 3_600_000), + }, + }); + if (scenario !== "missing-source-setup") { + writeAnchoredFile(credentialDirectory.anchor, "DUMMY-auth.json", sourceBytes); + } + const rawDirectory = openAnchoredDirectory(proofOutput, "ten-pack/raw"); + const docsDirectory = openAnchoredDirectory(proofOutput, "ten-pack/repo/docs"); + try { + writeAnchoredFile( + rawDirectory.anchor, + "auth-worker-missing.pack.md", + "DUMMY cleanup-only handoff; no semantic acceptance claimed.\n", + ); + writeAnchoredFile( + docsDirectory.anchor, + "AUTH.md", + await readFile(join(root, "test/fixtures/cp06-context/docs/AUTH.md")), + ); + } finally { + rawDirectory.close(); + docsDirectory.close(); + } + let path = process.env.PATH; + if (expectedRecord !== undefined) { + const shimDirectory = openAnchoredDirectory(homeDirectory.anchor, "shims"); + try { + path = writeDummyCliShims({ + anchor: shimDirectory.anchor, + path: join(proofRootPath, "DUMMY-cleanup", scenario, "shims"), + scenario, + }); + } finally { + shimDirectory.close(); + } + } + const args = [ + "--experimental-import-meta-resolve", + join(root, "scripts/replay-cp06-worker.mjs"), + ]; + const result = spawnSync(process.execPath, args, { + cwd: root, + encoding: "utf8", + timeout: 180_000, + maxBuffer: 1024 * 1024, + env: { + PATH: path, + HOME: home, + TMPDIR: temporary, + PI_OFFLINE: "1", + PI_SKIP_VERSION_CHECK: "1", + CP06_PI_AUTH_FILE: namespaceSource, + CP06_PI_PACKAGE_ROOT: process.env.CP06_PI_PACKAGE_ROOT, + CP06_PI_BIN: process.env.CP06_PI_BIN, + CP06_OUTPUT: proofOutputPath, + CP06_REVIEWER: "DUMMY cleanup regression", + ...(expectedRecord === undefined ? {} : { CP06_DUMMY_TRUSTED_PROVENANCE: "1" }), + }, + }); + assert.equal(result.error, undefined); + assert.equal(result.status, expectedExit, result.stderr); + const blockedText = await readFile(join(proofOutputPath, "worker/blocked.json"), "utf8"); + const blocked = JSON.parse(blockedText); + assert.equal(blocked.result, "blocked"); + assert.equal(blocked.stage, "isolated-sdk-worker"); + if (expectedRecord !== undefined) { + assert.equal(result.stdout, ""); + assert.equal( + result.stderr, + `${expectedRecord.code}: CP-06 isolated SDK worker exited ${expectedExit}\n`, + ); + assert.equal(`${result.stderr}${blockedText}`.includes(DUMMY_CLI_SECRET), false); + assert.deepEqual(blocked.worker_failure, expectedRecord); + assert.equal(blocked.code, expectedRecord.code); + assert.equal(blocked.cleanup_failed, expectedRecord.code === "CP06_CLEANUP_FAILED"); + } + assert.deepEqual(await readdir(temporary), [], `${scenario}: evaluation home leaked`); + if (scenario !== "missing-source-setup") + assert.equal(await readFile(source, "utf8"), sourceBytes); + await assert.rejects(readFile(join(proofOutputPath, "worker/evidence.json")), { + code: "ENOENT", + }); + proofOutputDirectory.close(); + credentialDirectory.close(); + temporaryDirectory.close(); + homeDirectory.close(); + cases.push({ + scenario, + command: [process.execPath, ...args], + exit_code: result.status, + expected_exit_code: expectedExit, + stage: blocked.stage, + worker_failure: blocked.worker_failure, + child_and_cleanup_injection: + expectedRecord === undefined ? null : "DUMMY setpriv/umount PATH shims", + evaluation_home_removed: true, + source_unchanged: scenario === "missing-source-setup" ? null : true, + fixture_origin: true, + semantic_acceptance: false, + }); + } + return cases; + }, + async () => { + try { + removeAnchoredEntry(proofRoot, "DUMMY-cleanup"); + } finally { + scratchDirectory.close(); + } + }, +); +const git = spawnSync("git", ["rev-parse", "HEAD"], { cwd: root, encoding: "utf8" }); +assert.equal(git.status, 0); +const sources = [ + "scripts/replay-cp06-worker.mjs", + "scripts/cp06-namespace-supervisor.mjs", + "scripts/cp06-credential-isolation.mjs", + "scripts/cp06-worker-lifecycle.mjs", + "scripts/cp06-dummy-cli-shims.mjs", + "scripts/prove-cp06-worker-cleanup.mjs", +]; +const manifest = { + schema_version: 1, + gate: "CP06-DUMMY-worker-cleanup", + result: "pass", + tested_sha: git.stdout.trim(), + recorded_at: new Date().toISOString(), + reviewer: process.env.CP06_REVIEWER ?? "CP06 correction worker", + command: "npm run proof:cp06:worker-cleanup", + exit_code: 0, + fixture_origin: true, + semantic_acceptance: false, + cases, + source_sha256: Object.fromEntries( + await Promise.all( + sources.map(async (path) => [ + path, + createHash("sha256") + .update(await readFile(join(root, path))) + .digest("hex"), + ]), + ), + ), + limitations: [ + "Actual full-runner expired/near-expiry and missing-source setup cases use DUMMY auth only. Malformed-output, semantic-audit and child-exit plus cleanup failure, audit-only and cleanup-only cases run through the replay CLI, namespace supervisor and real descriptor-bound mounts, but the evaluated child is replaced by a DUMMY setpriv shim and the credential-target unmount by a failing DUMMY umount shim; they prove failure composition, not SDK behavior. Launch and timeout failures are injected only in-process in the unit suite; SDK timeout is exercised by the DUMMY auth-security proof.", + ], +}; +writeAnchoredFile(proofRoot, "evidence.json", `${JSON.stringify(manifest, null, 2)}\n`); +proofDirectory.close(); +output.close(); +console.log( + JSON.stringify({ + result: "pass", + gate: manifest.gate, + tested_sha: manifest.tested_sha, + cases: cases.map(({ scenario, exit_code, evaluation_home_removed }) => ({ + scenario, + exit_code, + evaluation_home_removed, + })), + }), +); diff --git a/scripts/replay-cp06-p07.mjs b/scripts/replay-cp06-p07.mjs new file mode 100644 index 0000000..f5aac14 --- /dev/null +++ b/scripts/replay-cp06-p07.mjs @@ -0,0 +1,148 @@ +#!/usr/bin/env node +import { createHash } from "node:crypto"; +import { realpathSync } from "node:fs"; +import { readFile } from "node:fs/promises"; +import { dirname, join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { spawnSync } from "node:child_process"; +import { + openAnchoredDirectory, + openProbeOutput, + writeArtifactFile, + readArtifactFile, +} from "./cp06-probe-fixture.mjs"; + +const root = resolve(dirname(fileURLToPath(import.meta.url)), ".."); +const outputRoot = resolve(root, process.env.CP06_OUTPUT ?? ".factory/state/cp06-correction"); +const output = openProbeOutput({ root, outputRoot, create: true, runner: true }); +const p07Root = openAnchoredDirectory(output.anchor, "p07", { reset: true }); +const rawDirectory = openAnchoredDirectory(p07Root.anchor, "raw"); +const rawRoot = rawDirectory.anchor; + +const steps = []; +steps.push(run("build", "npm", ["run", "build"], 0)); +steps.push( + run( + "fail-before", + process.execPath, + ["test/fixtures/workflow/history/malformed-json-probe.mjs"], + 1, + ["SyntaxError", "not valid JSON"], + ), +); +steps.push( + run( + "fix-after", + process.execPath, + [ + "--test", + "--test-name-pattern=normal API fixture rejects malformed", + "dist/test/workflow.test.js", + ], + 0, + ), +); +steps.push( + run( + "critical-auth-retest", + process.execPath, + ["--test", "--test-name-pattern=critical auth fixture", "dist/test/workflow.test.js"], + 0, + ), +); + +const findingPath = "test/fixtures/workflow/history/finding.json"; +const finding = JSON.parse(await readFile(join(root, findingPath), "utf8")); +const testedSha = runText("git", ["rev-parse", "HEAD"]); +const manifest = { + schema_version: 1, + gate: "P-07", + tested_sha: testedSha, + recorded_at: new Date().toISOString(), + reviewer: process.env.CP06_REVIEWER ?? "Pi CP-06 correction worker", + finding: { + path: findingPath, + sha256: await fileHash(join(root, findingPath)), + record: finding, + }, + sequence: steps, + result: "pass", + limitations: [ + "Behavioral fixtures are in-memory and make no network or real-account access.", + "The historical module is a minimal retained reproduction, not a raw worker transcript.", + ], +}; +const manifestPath = join(p07Root.anchor, "evidence.json"); +const manifestHash = writeArtifactFile(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`); +console.log( + JSON.stringify({ + ok: true, + gate: "P-07", + tested_sha: testedSha, + manifest_path: relative(manifestPath), + manifest_sha256: manifestHash, + sequence: steps.map(({ id, command, exit_code, output_path, output_sha256 }) => ({ + id, + command, + exit_code, + output_path, + output_sha256, + })), + }), +); + +function run(id, command, args, expectedExit, expectedOutputFragments = []) { + const result = spawnSync(command, args, { cwd: root, encoding: "utf8", env: process.env }); + const exitCode = result.status; + const output = `command: ${quote([command, ...args])}\nexit: ${exitCode}\n--- stdout ---\n${result.stdout ?? ""}\n--- stderr ---\n${result.stderr ?? ""}`; + const outputPath = join(rawRoot, `${id}.txt`); + writeArtifactFile(outputPath, output); + const missingFragments = expectedOutputFragments.filter((fragment) => !output.includes(fragment)); + if (result.error !== undefined || exitCode !== expectedExit || missingFragments.length > 0) { + process.stderr.write(output); + const suffix = + missingFragments.length > 0 + ? `; missing output fragments ${missingFragments.join(", ")}` + : ""; + throw new Error(`${id} exited ${exitCode}; expected ${expectedExit}${suffix}`); + } + return { + id, + command: quote([command, ...args]), + expected_exit_code: expectedExit, + exit_code: exitCode, + output_path: relative(outputPath), + output_sha256: sha256(output), + expected_output_fragments: expectedOutputFragments, + }; +} + +function runText(command, args) { + const result = spawnSync(command, args, { cwd: root, encoding: "utf8" }); + if (result.status !== 0) throw new Error(`${command} failed: ${result.stderr}`); + return result.stdout.trim(); +} + +function quote(parts) { + return parts.map((part) => (/[\s'"]/.test(part) ? JSON.stringify(part) : part)).join(" "); +} + +function relative(path) { + let located = path; + try { + located = realpathSync(path); + } catch {} + return located.startsWith(`${root}/`) ? located.slice(root.length + 1) : located; +} + +function sha256(text) { + return createHash("sha256").update(text).digest("hex"); +} + +async function fileHash(path) { + return createHash("sha256") + .update( + path.startsWith(`/proc/${process.pid}/fd/`) ? readArtifactFile(path) : await readFile(path), + ) + .digest("hex"); +} diff --git a/scripts/replay-cp06-ten-pack.mjs b/scripts/replay-cp06-ten-pack.mjs new file mode 100644 index 0000000..b525d23 --- /dev/null +++ b/scripts/replay-cp06-ten-pack.mjs @@ -0,0 +1,760 @@ +#!/usr/bin/env node +import { createHash } from "node:crypto"; +import { existsSync, readFileSync, realpathSync, readdirSync } from "node:fs"; +import { stat } from "node:fs/promises"; +import { dirname, join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { spawnSync } from "node:child_process"; +import { + openAnchoredDirectory, + openProbeOutput, + writeArtifactEntry, + readArtifactFile, + withArtifactParent, + removeAnchoredEntry, +} from "./cp06-probe-fixture.mjs"; +import { readPinnedRegularFile } from "./cp06-guarded-read.mjs"; + +const root = resolve(dirname(fileURLToPath(import.meta.url)), ".."); +const fixtureSource = join(root, "test/fixtures/cp06-context"); +const outputRoot = resolve(root, process.env.CP06_OUTPUT ?? ".factory/state/cp06-correction"); +const output = openProbeOutput({ root, outputRoot, create: true, runner: true }); +const proofDirectory = openAnchoredDirectory(output.anchor, "ten-pack", { reset: true }); +const rawDirectory = openAnchoredDirectory(proofDirectory.anchor, "raw"); +const proofRoot = proofDirectory.anchor; +const fixtureDirectory = openAnchoredDirectory(proofRoot, "repo"); +const fixtureRoot = fixtureDirectory.anchor; +const rawRoot = rawDirectory.anchor; +const cli = join(root, "dist/src/cli.js"); +const reviewer = process.env.CP06_REVIEWER ?? "Pi CP-06 correction worker"; +const commands = []; +const preparedArtifacts = new Map(); + +copyFixtureTree(fixtureSource, fixtureRoot); +openAnchoredDirectory(fixtureRoot, ".factory/state").close(); +const oracle = JSON.parse(await readFile(join(fixtureRoot, "oracle.json"), "utf8")); +const fixedAuth = await readFile(join(fixtureRoot, ".factory/tasks/AUTH-001.yaml"), "utf8"); +const beforeAuth = await readFile(join(fixtureRoot, "cases/AUTH-before.yaml"), "utf8"); +const workerBeforeAuth = await readFile(join(fixtureRoot, "cases/AUTH-worker-before.yaml"), "utf8"); +await writeFile(join(fixtureRoot, ".factory/tasks/AUTH-001.yaml"), beforeAuth); + +run("git-init", "git", ["init", "-q", fixtureRoot], { cwd: root }); +run("git-author-name", "git", ["config", "user.name", "Factory CP-06 fixture"], { + cwd: fixtureRoot, +}); +run("git-author-email", "git", ["config", "user.email", "factory-cp06@example.invalid"], { + cwd: fixtureRoot, +}); +run("git-add", "git", ["add", "."], { cwd: fixtureRoot }); +run("git-commit", "git", ["commit", "-qm", "seed CP-06 ten-pack fixture"], { + cwd: fixtureRoot, +}); +// Resolve CTX only when first needed so artifact-boundary refusals do not depend on it. +const ctx = resolveTool(process.env.CTX_BIN ?? "ctx"); +configureOfflineCtxModelDir(); +run("ctx-init", ctx, ["init", fixtureRoot, "--json"], { cwd: fixtureRoot }); + +const globalSources = ["PROJECT.md", "ARCHITECTURE.md"]; +const topicalSources = oracle.tasks.map((entry) => entry.target); +for (const path of [ + ...globalSources, + ...topicalSources.filter((path) => path !== "docs/AUTH.md"), +]) { + run( + `ctx-add-${slug(path)}`, + ctx, + ["add", path, "--root", fixtureRoot, "--authority", "normative", "--priority", "100", "--json"], + { + cwd: fixtureRoot, + }, + ); +} +run("ctx-index-without-auth", ctx, ["index", "--root", fixtureRoot, "--json"], { + cwd: fixtureRoot, + timeout: 120_000, +}); +const initialStatus = jsonRun( + "ctx-status-without-auth", + ctx, + ["status", "--root", fixtureRoot, "--json"], + { + cwd: fixtureRoot, + }, +); +assertSemantic(initialStatus, "initial status"); + +const authQuery = [ + "Bind item ownership to authenticated principal", + "Creation ignores request-supplied ownership and cross-account operations remain concealed.", + "A-AUTH: Missing or forged credentials return 401 and cross-account read update and delete return non-mutating 404.", + "principal ownership tenancy forged credentials", +].join("\n"); +const broad = run( + "bad-unscoped-auth", + ctx, + ["pack", authQuery, "--root", fixtureRoot, "--token-budget", "3500", "--json"], + { + cwd: fixtureRoot, + expected: [0], + timeout: 120_000, + }, +); +const broadJson = parseJsonIfPresent(broad.stdout); +if (broadJson === null || !Array.isArray(broadJson.items)) { + throw new Error("bad-path probe did not return a successful real-CTX pack"); +} +const broadPaths = itemPaths(broadJson); +const broadIrrelevant = broadPaths.filter( + (path) => path.startsWith("docs/") && path !== "docs/AUTH.md", +); +if (broadIrrelevant.length === 0) { + throw new Error("bad-path probe did not expose unrelated topical retrieval"); +} +const broadIrrelevantEvidence = await Promise.all( + broadIrrelevant.map(async (path) => { + const item = broadJson.items.find( + (candidate) => candidate?.source?.provenance?.document_path === path, + ); + const claimedSha256 = item?.source?.provenance?.document_sha256; + const actualSha256 = await fileHash(join(fixtureRoot, path)); + if (claimedSha256 !== actualSha256) { + throw new Error(`bad-path provenance digest does not match exact original ${path}`); + } + return { path, document_sha256: actualSha256 }; + }), +); +if (broadPaths.includes("docs/AUTH.md")) { + throw new Error("bad-path probe unexpectedly retrieved deliberately unindexed docs/AUTH.md"); +} + +const partialDisconfirming = run( + "disconfirming-partial-ctx", + ctx, + [ + "pack", + "Delete an owned record without disclosing another principal's record", + "--root", + fixtureRoot, + "--document", + "docs/DELETION.md", + "--token-budget", + "1300", + "--json", + ], + { cwd: fixtureRoot, expected: [0], timeout: 120_000 }, +); +const partialDisconfirmingJson = parseJsonIfPresent(partialDisconfirming.stdout); +if (partialDisconfirmingJson?.completeness_status !== "PARTIAL") { + throw new Error("disconfirming real-CTX probe did not retain its asserted PARTIAL result"); +} + +const initialAuth = factoryContext("auth-initial-missing", "AUTH-001", 0); +const initialAuthPack = await readFile(join(fixtureRoot, initialAuth.pack_path), "utf8"); +await copyArtifact(initialAuth.pack_path, "auth-initial-missing.pack.md"); +await copyArtifact(initialAuth.receipt_path, "auth-initial-missing.receipt.json"); +if (initialAuthPack.includes("docs/AUTH.md")) { + throw new Error("initial AUTH handoff unexpectedly contains the missing decisive original"); +} +await writeFile(join(fixtureRoot, ".factory/tasks/AUTH-001.yaml"), workerBeforeAuth); +const workerInitialAuth = factoryContext("auth-worker-missing", "AUTH-001", 0); +const workerInitialPack = await readFile(join(fixtureRoot, workerInitialAuth.pack_path), "utf8"); +await copyArtifact(workerInitialAuth.pack_path, "auth-worker-missing.pack.md"); +await copyArtifact(workerInitialAuth.receipt_path, "auth-worker-missing.receipt.json"); +if ( + workerInitialPack.includes("docs/AUTH.md") || + workerInitialPack.includes("request-supplied ownership") +) { + throw new Error("fresh-worker handoff did not omit the decisive creation ownership rule"); +} +await writeFile(join(fixtureRoot, ".factory/tasks/AUTH-001.yaml"), beforeAuth); + +run( + "ctx-add-auth", + ctx, + [ + "add", + "docs/AUTH.md", + "--root", + fixtureRoot, + "--authority", + "normative", + "--priority", + "100", + "--json", + ], + { + cwd: fixtureRoot, + }, +); +run("ctx-index-with-auth", ctx, ["index", "--root", fixtureRoot, "--json"], { + cwd: fixtureRoot, + timeout: 120_000, +}); +const fixedStatus = jsonRun( + "ctx-status-with-auth", + ctx, + ["status", "--root", fixtureRoot, "--json"], + { + cwd: fixtureRoot, + }, +); +assertSemantic(fixedStatus, "fixed status"); +const doctor = jsonRun( + "ctx-doctor-offline", + ctx, + ["doctor", "--offline", "--root", fixtureRoot, "--json"], + { + cwd: fixtureRoot, + }, +); +if (doctor.offline_ready !== true || doctor.network_attempted !== false) { + throw new Error("CTX offline doctor did not prove offline readiness"); +} + +const globalOnlyTaskPath = join(fixtureRoot, ".factory/tasks/GLOBAL-ONLY.yaml"); +const globalOnlyQuery = [ + "Keep credentials and raw worker transcripts out of offline context", + "A worker applies the mandatory rule that credentials and raw worker transcripts never enter a context pack and normal operation is offline.", + "A-GLOBAL: The pack contains the mandatory credentials, raw transcript, and offline constraints without topical documents.", + "credentials raw worker transcripts context pack normal operation offline", +].join("\n"); +await writeFile( + globalOnlyTaskPath, + `schema_version: 1 +id: GLOBAL-ONLY +title: Keep credentials and raw worker transcripts out of offline context +outcome: A worker applies the mandatory rule that credentials and raw worker transcripts never enter a context pack and normal operation is offline. +depends_on: [] +complexity: bounded +risk: bounded +acceptance: + - id: A-GLOBAL + statement: The pack contains the mandatory credentials, raw transcript, and offline constraints without topical documents. +advances: [C-COPY] +context: + topics: [credentials, raw worker transcripts, context pack, normal operation, offline] + required: [] +evidence: + required: [integration] +delivery: project-default +`, +); +const emptySelectionFactory = factoryContext("empty-selection-factory", "GLOBAL-ONLY", 0); +const emptySelectionPack = await readFile( + join(fixtureRoot, emptySelectionFactory.pack_path), + "utf8", +); +const emptySelectionTopicalDigests = Object.keys(emptySelectionFactory.source_digests).filter( + (path) => path.startsWith("docs/"), +); +if ( + emptySelectionTopicalDigests.length > 0 || + /^## Retrieved context: docs\//m.test(emptySelectionPack) +) { + throw new Error("empty task-local selection widened Factory output to topical authority"); +} +const emptySelectionCtx = run( + "empty-selection-real-ctx", + ctx, + [ + "pack", + globalOnlyQuery, + "--root", + fixtureRoot, + "--document", + "PROJECT.md", + "--document", + "ARCHITECTURE.md", + "--token-budget", + "3500", + "--json", + ], + { cwd: fixtureRoot, expected: [0], timeout: 120_000 }, +); +const emptySelectionCtxJson = parseJsonIfPresent(emptySelectionCtx.stdout); +if (emptySelectionCtxJson?.completeness_status !== "COMPLETE") { + throw new Error("empty-selection real-CTX probe was not COMPLETE"); +} +const emptySelectionProvenance = itemPaths(emptySelectionCtxJson); +if ( + emptySelectionProvenance.length === 0 || + emptySelectionProvenance.some((path) => !globalSources.includes(path)) +) { + throw new Error( + `empty-selection real-CTX provenance escaped mandatory globals: ${emptySelectionProvenance.join(",")}`, + ); +} +const emptySelectionOriginals = await Promise.all( + emptySelectionProvenance.map(async (path) => { + const item = emptySelectionCtxJson.items.find( + (candidate) => candidate?.source?.provenance?.document_path === path, + ); + const actualSha256 = await fileHash(join(fixtureRoot, path)); + if (item?.source?.provenance?.document_sha256 !== actualSha256) { + throw new Error(`empty-selection provenance digest does not match ${path}`); + } + return { path, document_sha256: actualSha256 }; + }), +); +await rm(globalOnlyTaskPath); + +const counterfactualBefore = factoryContext("counterfactual-before", "AUTH-001", 0); +const counterfactualBeforePack = await readFile( + join(fixtureRoot, counterfactualBefore.pack_path), + "utf8", +); +await copyArtifact(counterfactualBefore.pack_path, "counterfactual-before.pack.md"); +await copyArtifact(counterfactualBefore.receipt_path, "counterfactual-before.receipt.json"); +await writeFile(join(fixtureRoot, ".factory/tasks/AUTH-001.yaml"), fixedAuth); +const counterfactualAfter = factoryContext("counterfactual-after", "AUTH-001", 0); +const counterfactualAfterPack = await readFile( + join(fixtureRoot, counterfactualAfter.pack_path), + "utf8", +); +if ( + counterfactualBeforePack.includes("docs/AUTH.md") || + !counterfactualAfterPack.includes("docs/AUTH.md") +) { + throw new Error("one-condition AUTH counterfactual did not add the decisive original"); +} + +const packs = []; +for (const task of oracle.tasks) { + const payload = factoryContext(`fixed-${task.id}`, task.id, 0); + const packAbsolute = join(fixtureRoot, payload.pack_path); + const receiptAbsolute = join(fixtureRoot, payload.receipt_path); + const pack = await readFile(packAbsolute, "utf8"); + const receipt = JSON.parse(await readFile(receiptAbsolute, "utf8")); + const provenance = packSources(pack); + const topical = provenance.filter((path) => path.startsWith("docs/")); + const irrelevant = topical.filter((path) => path !== task.target); + const missingTarget = !topical.includes(task.target); + if (missingTarget || irrelevant.length > 0) { + throw new Error( + `${task.id} relevance failed: missing=${missingTarget} irrelevant=${irrelevant.join(",")}`, + ); + } + const packCopy = join(rawRoot, `${task.id}.pack.md`); + const receiptCopy = join(rawRoot, `${task.id}.receipt.json`); + const packSha256 = await writeFile(packCopy, await readFile(packAbsolute)); + const receiptSha256 = await writeFile(receiptCopy, await readFile(receiptAbsolute)); + packs.push({ + task_id: task.id, + risk: task.risk, + contract: fileEvidence(join(fixtureRoot, `.factory/tasks/${task.id}.yaml`)), + target_source: fileEvidence(join(fixtureRoot, task.target)), + pack: { path: relative(packCopy), sha256: packSha256 }, + receipt: { path: relative(receiptCopy), sha256: receiptSha256 }, + token_budget: payload.token_budget, + estimated_tokens: payload.estimated_tokens, + byte_budget: payload.byte_budget, + bytes: payload.bytes, + ctx_generation: payload.ctx.index_generation, + retrieval_mode: payload.ctx.retrieval_mode, + provenance, + irrelevant_topical_sources: irrelevant, + missing_target: missingTarget, + source_digests: receipt.source_digests, + }); +} + +await rm(join(fixtureRoot, ".factory/state/context/AUTH-001.md"), { force: true }); +await rm(join(fixtureRoot, ".factory/state/context/AUTH-001.json"), { force: true }); +await writeFile( + join(fixtureRoot, ".factory/tasks/AUTH-001.yaml"), + fixedAuth.replace("docs/AUTH.md", "docs/ABSENT.md"), +); +const disconfirming = factoryContext("disconfirming-missing-original", "AUTH-001", 3); +let fabricated = true; +try { + await stat(join(fixtureRoot, ".factory/state/context/AUTH-001.md")); +} catch { + fabricated = false; +} +if (fabricated || disconfirming.error?.code !== "CONTEXT_BLOCKED") { + throw new Error("missing-original disconfirming case did not fail closed without a pack"); +} + +const testedSha = gitText(root, ["rev-parse", "HEAD"]); +const sourceFiles = [ + ".factory/project.yaml", + ".factory/completion.yaml", + "PROJECT.md", + "ARCHITECTURE.md", + "oracle.json", + "cases/AUTH-before.yaml", + "cases/AUTH-worker-before.yaml", + ...oracle.tasks.map((entry) => `.factory/tasks/${entry.id}.yaml`), + ...topicalSources, +]; +const manifest = { + schema_version: 1, + gate: "P-06", + tested_sha: testedSha, + recorded_at: new Date().toISOString(), + reviewer, + fixture: { + tracked_root: "test/fixtures/cp06-context", + files: Object.fromEntries( + await Promise.all( + sourceFiles.map(async (path) => [path, await fileHash(join(fixtureSource, path))]), + ), + ), + }, + ctx: { + version: commandText(ctx, ["--version"]), + index_generation: fixedStatus.index_generation, + retrieval_mode: fixedStatus.retrieval_mode, + active_channels: fixedStatus.active_channels, + offline_ready: doctor.offline_ready, + network_attempted: doctor.network_attempted, + }, + original_failed_measurement_preserved: { + packs: 10, + topical_excerpts: 24, + irrelevant_topical_excerpts: 15, + packs_with_irrelevant_excerpt: 10, + missing_targets: 1, + source: "docs/probes/CP-06.md at PR #8 head 8b945a4ed6fcbb43a8b7aa7ce3a6532a4a23d145", + disposition: "fail", + }, + diagnosis: { + bad_unscoped_path: { + exit_code: broad.exitCode, + output_path: broad.outputPath, + output_sha256: broad.outputSha256, + provenance: broadPaths, + irrelevant_topical_sources: broadIrrelevant, + irrelevant_topical_originals: broadIrrelevantEvidence, + missing_target: true, + }, + empty_selection: { + semantics: "empty task-local authority falls back to mandatory global document filters", + factory_exit_code: 0, + factory_output_path: commandById("empty-selection-factory").outputPath, + factory_output_sha256: commandById("empty-selection-factory").outputSha256, + factory_topical_source_digests: emptySelectionTopicalDigests, + ctx_exit_code: emptySelectionCtx.exitCode, + ctx_output_path: emptySelectionCtx.outputPath, + ctx_output_sha256: emptySelectionCtx.outputSha256, + ctx_completeness_status: emptySelectionCtxJson.completeness_status, + ctx_provenance: emptySelectionOriginals, + }, + one_condition_counterfactual: { + unchanged_fields: "all AUTH task fields except context.required", + before_required: ["PROJECT.md", "ARCHITECTURE.md"], + after_required: ["PROJECT.md", "ARCHITECTURE.md", "docs/AUTH.md"], + before_contract_sha256: sha256(beforeAuth), + after_contract_sha256: sha256(fixedAuth), + before_pack_sha256: sha256(counterfactualBeforePack), + after_pack_sha256: sha256(counterfactualAfterPack), + corrected_constraints: [ + "principal-derived ownership", + "request owner ignored", + "missing or forged credential is 401", + "cross-account read/update/delete is non-mutating non-disclosing 404", + ], + }, + disconfirming_partial_case: { + condition: "a successful document-scoped real-CTX call can remain PARTIAL", + exit_code: partialDisconfirming.exitCode, + completeness_status: partialDisconfirmingJson.completeness_status, + output_path: partialDisconfirming.outputPath, + output_sha256: partialDisconfirming.outputSha256, + }, + disconfirming_missing_original_case: { + condition: "declared required original docs/ABSENT.md does not exist", + exit_code: 3, + error_code: disconfirming.error.code, + output_path: commandById("disconfirming-missing-original").outputPath, + output_sha256: commandById("disconfirming-missing-original").outputSha256, + pack_fabricated: fabricated, + }, + }, + fixed_sample: { + pack_count: packs.length, + relevant_pack_count: packs.filter( + (entry) => !entry.missing_target && entry.irrelevant_topical_sources.length === 0, + ).length, + irrelevant_topical_excerpts: packs.reduce( + (sum, entry) => sum + entry.irrelevant_topical_sources.length, + 0, + ), + packs_with_irrelevant_excerpt: packs.filter( + (entry) => entry.irrelevant_topical_sources.length > 0, + ).length, + missing_targets: packs.filter((entry) => entry.missing_target).length, + packs, + }, + commands: commands.map(({ stdout, stderr, ...entry }) => entry), + result: "pass", + limitations: [ + "The semantic replay is verified on Linux with CTX 1.0.0 and the locally installed model.", + "The small fixture demonstrates this fixed sample, not universal retrieval precision.", + "Raw CTX JSON, packs, receipts, and command logs remain ignored scratch under .factory/state.", + ], +}; +const manifestPath = join(proofRoot, "evidence.json"); +const manifestSha256 = await writeFile(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`); +console.log( + JSON.stringify({ + ok: true, + gate: "P-06", + tested_sha: testedSha, + manifest_path: relative(manifestPath), + manifest_sha256: manifestSha256, + fixed_pack_count: packs.length, + relevant_pack_count: manifest.fixed_sample.relevant_pack_count, + irrelevant_topical_excerpts: manifest.fixed_sample.irrelevant_topical_excerpts, + missing_targets: manifest.fixed_sample.missing_targets, + bad_path_exit: broad.exitCode, + bad_path_irrelevant_topical_sources: broadIrrelevant, + disconfirming_partial_exit: partialDisconfirming.exitCode, + disconfirming_missing_original_exit: 3, + }), +); + +function configureOfflineCtxModelDir() { + if (process.env.CTX_MODEL_DIR !== undefined && process.env.CTX_MODEL_DIR !== "") return; + const modelName = "BAAI/bge-small-en-v1.5"; + const revision = "52398278842ec682c6f32300af41344b1c0b0bb2"; + for (const candidate of candidateModelRoots()) { + const modelDirectory = join(candidate, modelKey(modelName, revision)); + const manifestPath = join(modelDirectory, "manifest.json"); + if (!existsSync(manifestPath)) continue; + try { + const manifest = JSON.parse(readFileSync(manifestPath, "utf8")); + if ( + manifest.provider === "fastembed" && + manifest.model_name === modelName && + manifest.revision === revision && + manifest.dimensions === 384 + ) { + process.env.CTX_MODEL_DIR = candidate; + return; + } + } catch { + // Keep the proof fail-closed; the later CTX semantic assertion reports the blocker. + } + } +} + +function candidateModelRoots() { + const candidates = []; + const home = process.env.HOME; + if (home !== undefined && home !== "") candidates.push(join(home, ".cache/ctx/models")); + const homeMatch = root.match(/^\/(?:home|Users)\/[^/]+/u); + if (homeMatch !== null) candidates.push(join(homeMatch[0], ".cache/ctx/models")); + return [...new Set(candidates)]; +} + +function modelKey(modelName, revision) { + const readable = modelName.replace(/[^A-Za-z0-9._-]+/g, "--").replace(/^-|-$/g, ""); + const digest = createHash("sha256") + .update(`${modelName}\0${revision}`) + .digest("hex") + .slice(0, 12); + return `${readable}-${digest}`; +} + +function factoryContext(id, taskId, expectedExit) { + const result = run( + id, + process.execPath, + [cli, "context", taskId, "--root", fixtureRoot, "--ctx-bin", ctx, "--json"], + { + cwd: root, + expected: [expectedExit], + timeout: 120_000, + }, + ); + const parsed = parseJsonIfPresent(result.stdout); + if (parsed === null) throw new Error(`${id} did not return JSON`); + return parsed; +} + +function jsonRun(id, command, args, options) { + const result = run(id, command, args, options); + const parsed = parseJsonIfPresent(result.stdout); + if (parsed === null) throw new Error(`${id} did not return JSON`); + return parsed; +} + +function run(id, command, args, options = {}) { + const cwd = options.cwd ?? root; + const result = spawnSync(command, args, { + cwd, + encoding: "utf8", + env: process.env, + timeout: options.timeout ?? 30_000, + }); + const exitCode = result.status; + const expected = options.expected ?? [0]; + const output = `command: ${quote([command, ...args])}\ncwd: ${cwd}\nexit: ${exitCode}\n--- stdout ---\n${result.stdout ?? ""}\n--- stderr ---\n${result.stderr ?? ""}`; + const outputPath = join(rawRoot, `${id}.txt`); + requireWrite(outputPath, output); + const entry = { + id, + command: quote([command, ...args]), + cwd: relative(cwd), + exit_code: exitCode, + output_path: relative(outputPath), + output_sha256: sha256(output), + stdout: result.stdout ?? "", + stderr: result.stderr ?? "", + }; + commands.push(entry); + if (result.error !== undefined || !expected.includes(exitCode)) { + process.stderr.write(output); + throw new Error(`${id} exited ${exitCode}; expected ${expected.join(" or ")}`); + } + return { ...entry, exitCode, outputPath: entry.output_path, outputSha256: entry.output_sha256 }; +} + +function commandById(id) { + const result = commands.find((entry) => entry.id === id); + if (result === undefined) throw new Error(`missing command record ${id}`); + return { outputPath: result.output_path, outputSha256: result.output_sha256 }; +} + +function parseJsonIfPresent(text) { + try { + return JSON.parse(text); + } catch { + return null; + } +} + +function itemPaths(value) { + if (value === null || !Array.isArray(value.items)) return []; + return [ + ...new Set( + value.items + .map((item) => item?.source?.provenance?.document_path) + .filter((path) => typeof path === "string"), + ), + ]; +} + +function packSources(pack) { + const paths = []; + for (const match of pack.matchAll( + /^## (?:Required source|Retrieved context): ([^:\n]+)(?::\d+-\d+)?$/gm, + )) { + paths.push(match[1]); + } + return [...new Set(paths)]; +} + +function assertSemantic(status, label) { + if ( + status.category !== "CLEAN" || + status.retrieval_mode !== "HYBRID_SEMANTIC" || + !Array.isArray(status.active_channels) || + !status.active_channels.includes("semantic") + ) { + throw new Error(`${label} is not clean HYBRID_SEMANTIC retrieval`); + } +} + +async function copyArtifact(path, name) { + await writeFile(join(rawRoot, name), await readFile(join(fixtureRoot, path))); +} + +function fileEvidence(path) { + const relativePath = relative(path).replace(relative(fixtureRoot), "test/fixtures/cp06-context"); + const bytes = requireRead(path); + return { path: relativePath, sha256: sha256(bytes) }; +} + +function gitText(cwd, args) { + return commandText("git", args, cwd); +} + +function resolveTool(command) { + if (command.includes("/")) return resolve(command); + return commandText("which", [command]); +} + +function commandText(command, args, cwd = root) { + const result = spawnSync(command, args, { cwd, encoding: "utf8" }); + if (result.status !== 0) throw new Error(`${command} failed: ${result.stderr}`); + return result.stdout.trim(); +} + +function quote(parts) { + return parts.map((part) => (/[\s'"]/.test(part) ? JSON.stringify(part) : part)).join(" "); +} + +function relative(path) { + let located = path; + try { + located = realpathSync(path); + } catch {} + return located.startsWith(`${root}/`) ? located.slice(root.length + 1) : located; +} + +function slug(path) { + return path + .replace(/[^A-Za-z0-9]+/g, "-") + .replace(/^-|-$/g, "") + .toLowerCase(); +} + +function sha256(value) { + return createHash("sha256").update(value).digest("hex"); +} + +async function fileHash(path) { + return sha256(requireRead(path)); +} + +function requireRead(path) { + return path.startsWith(`/proc/${process.pid}/fd/`) + ? readArtifactFile(path, undefined, { expected: preparedArtifacts.get(path) }) + : readPinnedRegularFile(path).bytes; +} + +function requireWrite(path, value, options) { + const written = writeArtifactEntry(path, value, { + ...options, + expected: preparedArtifacts.get(path), + }); + preparedArtifacts.set(path, written.identity); + return written.sha256; +} + +async function readFile(path, encoding) { + const bytes = requireRead(path); + return encoding ? bytes.toString(encoding) : bytes; +} + +async function writeFile(path, value) { + return requireWrite(path, value, { + replace: path === join(fixtureRoot, ".factory/tasks/AUTH-001.yaml"), + }); +} + +async function rm(path) { + withArtifactParent(path, removeAnchoredEntry); + preparedArtifacts.delete(path); +} + +function copyFixtureTree(source, destination, logical = destination) { + for (const entry of readdirSync(source, { withFileTypes: true })) { + if (entry.isDirectory()) { + const child = openAnchoredDirectory(destination, entry.name); + try { + copyFixtureTree(join(source, entry.name), child.anchor, join(logical, entry.name)); + } finally { + child.close(); + } + } else if (entry.isFile()) { + const written = writeArtifactEntry( + join(destination, entry.name), + readPinnedRegularFile(join(source, entry.name)).bytes, + ); + preparedArtifacts.set(join(logical, entry.name), written.identity); + } else throw new Error("DUMMY fixture contains a nonregular entry"); + } +} diff --git a/scripts/replay-cp06-worker.mjs b/scripts/replay-cp06-worker.mjs new file mode 100644 index 0000000..027c9c6 --- /dev/null +++ b/scripts/replay-cp06-worker.mjs @@ -0,0 +1,276 @@ +#!/usr/bin/env node +import { createHash } from "node:crypto"; +import { spawnSync } from "node:child_process"; +import { closeSync, constants, openSync, realpathSync } from "node:fs"; +import { mkdtemp, readFile } from "node:fs/promises"; +import { homedir, tmpdir } from "node:os"; +import { basename, dirname, join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { withCleanup } from "./cp06-worker-lifecycle.mjs"; +import { + openAnchoredDirectory, + openProbeOutput, + removeAnchoredEntry, + writeAnchoredFile, +} from "./cp06-probe-fixture.mjs"; +import { + Cp06IsolationUnsupportedError, + runCp06SecurityPreflight, + runIsolatedWorker, +} from "./cp06-auth-security.mjs"; + +const root = resolve(dirname(fileURLToPath(import.meta.url)), ".."); +const outputRoot = resolve(root, process.env.CP06_OUTPUT ?? ".factory/state/cp06-correction"); +const securityRoot = join(outputRoot, "auth-security"); +const fixtureRoot = join(outputRoot, "ten-pack", "repo"); +const initialPackPath = join(outputRoot, "ten-pack", "raw", "auth-worker-missing.pack.md"); +const contractPath = join(root, "test/fixtures/cp06-context/cases/AUTH-worker-before.yaml"); +const originalPath = join(fixtureRoot, "docs/AUTH.md"); +const provider = "openai-codex"; +const model = process.env.CP06_PI_MODEL ?? "gpt-5.6-sol"; +const timeoutMs = 600_000; +const systemPrompt = + "You are a bounded CP-06 reviewer. Use exactly the read tool, read only the supplied exact original once, return only the requested JSON, and do not attempt any file mutation or broad scan."; + +if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { + try { + await main(); + } catch (error) { + process.stderr.write(`${error.code ?? "CP06_WORKER_FAILED"}: ${error.message}\n`); + process.exitCode = error.exitCode ?? (error instanceof Cp06IsolationUnsupportedError ? 73 : 1); + } +} + +async function main() { + const output = openProbeOutput({ root, outputRoot, create: true, runner: true }); + let workerDirectory; + try { + workerDirectory = openAnchoredDirectory(output.anchor, "worker", { reset: true }); + await runWorker(workerDirectory.anchor); + } finally { + workerDirectory?.close(); + output.close(); + } +} + +async function runWorker(workerRoot) { + let security; + try { + // This DUMMY-only gate includes the unsafe control, hardened evaluated-child + // attacks, vendor refresh counters, and offline SDK read audit. It completes + // before the real credential path is opened or any provider request is made. + security = await runCp06SecurityPreflight({ + root, + outputRoot: securityRoot, + reviewer: process.env.CP06_REVIEWER, + retainBinaries: true, + }); + } catch (error) { + await recordBlocked(workerRoot, error, "dummy-security-preflight"); + throw error; + } + + const workerInput = { + schema_version: 1, + root, + fixture_root: fixtureRoot, + contract_path: contractPath, + pack_path: initialPackPath, + original_path: originalPath, + provider, + model, + timeout_ms: timeoutMs, + system_prompt: systemPrompt, + }; + const inputPath = join(workerRoot, "input.json"); + writeAnchoredFile(workerRoot, "input.json", `${JSON.stringify(workerInput, null, 2)}\n`); + const temporaryRoot = realpathSync(tmpdir()); + const temporaryFd = openSync( + temporaryRoot, + constants.O_RDONLY | constants.O_DIRECTORY | constants.O_NOFOLLOW | constants.O_CLOEXEC, + ); + const temporaryAnchor = `/proc/${process.pid}/fd/${temporaryFd}`; + let evaluationName; + try { + evaluationName = basename(await mkdtemp(join(temporaryAnchor, "factory-cp06-sdk-worker-"))); + } catch (error) { + closeSync(temporaryFd); + throw error; + } + const evaluationHome = join(temporaryRoot, evaluationName); + const credentialTarget = join(evaluationHome, "pi-agent", "auth.json"); + const credentialSource = resolve( + process.env.CP06_PI_AUTH_FILE ?? + join(process.env.PI_CODING_AGENT_DIR ?? join(homedir(), ".pi", "agent"), "auth.json"), + ); + const binaries = security.retainedBinaries; + let isolated; + try { + isolated = await withCleanup( + () => + runIsolatedWorker({ + root, + source: credentialSource, + target: credentialTarget, + binaries, + inputPath, + timeout: timeoutMs + 60_000, + }), + () => { + try { + removeAnchoredEntry(temporaryAnchor, evaluationName); + } finally { + closeSync(temporaryFd); + } + }, + ); + } catch (error) { + await recordBlocked(workerRoot, error, "isolated-sdk-worker"); + throw error; + } finally { + binaries?.close?.(); + } + if ( + isolated.mode !== "worker" || + isolated.source_unchanged !== true || + isolated.mount_ids_distinct !== true || + isolated.parent_roots_read_only !== true || + isolated.cleanup !== "pass" || + isolated.child?.result !== "pass" || + isolated.child.pi_install?.install_root !== join(realpathSync(root), ".factory/state/cp06-sdk") + ) { + const error = new Error("isolated SDK worker omitted a required safety assertion"); + await recordBlocked(workerRoot, error, "isolated-sdk-worker-audit"); + throw error; + } + + const testedSha = textCommand("git", ["rev-parse", "HEAD"]); + const manifest = { + schema_version: 1, + gate: "P-06-fresh-worker", + tested_sha: testedSha, + recorded_at: new Date().toISOString(), + reviewer: process.env.CP06_REVIEWER ?? "Pi CP-06 read-only auth correction worker", + worker: { + pi_version: isolated.child.pi_version, + pi_install: isolated.child.pi_install, + interface: "public SDK with injected CredentialStore", + provider, + model, + in_memory_session: isolated.child.session.in_memory, + tools: isolated.child.session.active_tools, + refresh_on_create: false, + model_catalog_network: false, + }, + bounded_handoff: { + contract_path: relative(contractPath), + contract_sha256: await fileHash(contractPath), + pack_path: relative(initialPackPath), + pack_sha256: await fileHash(initialPackPath), + original_path: relative(originalPath), + original_sha256: await fileHash(originalPath), + deliberately_omitted_constraint_sha256: sha256("request-supplied owner fields are ignored"), + evidence: "in-memory auth 401 and concealed non-mutating cross-account 404 observations", + }, + isolation: { + supported_platform: "Linux x86-64", + dummy_security_preflight: security, + source_and_target_distinct_read_only_mounts: isolated.mount_ids_distinct, + credential_parent_roots_read_only: isolated.parent_roots_read_only, + evaluated_child_capabilities: "all sets empty", + evaluated_child_no_new_privs: true, + evaluated_child_seccomp: true, + credential_store: "injected callback-denying read-only CredentialStore", + oauth_preflight: isolated.child.oauth_preflight, + credential_store_audit: isolated.child.credential_store, + credential_source_unchanged: isolated.source_unchanged, + cleanup: isolated.cleanup, + destructive_real_source_probe: false, + raw_transcript_retained: false, + }, + event_stream: isolated.child.event_stream, + command: + "Pi 0.85.1 createAgentSession({modelRuntime: injectedReadOnlyRuntime, tools: ['read'], inMemorySession}) inside capability-free seccomp child", + exit_code: 0, + response_sha256: isolated.child.response_sha256, + response: isolated.child.response, + result: "pass", + limitation: + "Live model wording is nondeterministic. The proof runner is Linux x86-64 only and exits blocked before credential access/provider use when user/mount namespaces, libseccomp, exact Pi 0.85.1, or adequate unexpired OAuth validity are unavailable.", + }; + const manifestPath = join(workerRoot, "evidence.json"); + const manifestSha256 = writeAnchoredFile( + workerRoot, + "evidence.json", + `${JSON.stringify(manifest, null, 2)}\n`, + ); + console.log( + JSON.stringify({ + ok: true, + gate: manifest.gate, + tested_sha: testedSha, + manifest_path: relative(manifestPath), + manifest_sha256: manifestSha256, + exit_code: 0, + status: manifest.response.status, + reads: manifest.event_stream.read_audit.project_read_paths, + broad_scan: manifest.response.broad_scan, + }), + ); +} + +async function recordBlocked(workerRoot, error, stage) { + const status = createWorkerBlockedStatus(error, stage); + writeAnchoredFile(workerRoot, "blocked.json", `${JSON.stringify(status, null, 2)}\n`); +} + +export function createWorkerBlockedStatus(error, stage, recordedAt = new Date().toISOString()) { + const nestedWorkerFailure = + error?.workerFailure ?? + (error instanceof AggregateError + ? error.errors.find((cause) => cause?.workerFailure !== undefined)?.workerFailure + : undefined); + return { + schema_version: 1, + gate: "P-06-fresh-worker", + result: "blocked", + stage, + failure_stage: error?.stage ?? null, + code: error?.code ?? "CP06_WORKER_FAILED", + cleanup_failed: + error?.code === "CP06_CLEANUP_FAILED" || nestedWorkerFailure?.code === "CP06_CLEANUP_FAILED", + worker_failure: nestedWorkerFailure ?? null, + causes: + error instanceof AggregateError + ? error.errors.map((cause) => ({ + stage: cause?.stage ?? "cleanup", + code: cause?.code ?? "CP06_CREDENTIAL_CLEANUP_FAILED", + exit_code: cause?.exitCode ?? null, + })) + : [], + recorded_at: recordedAt, + raw_transcript_retained: false, + }; +} + +function textCommand(command, args) { + const result = spawnSync(command, args, { cwd: root, encoding: "utf8" }); + if (result.status !== 0) throw new Error(`${command} failed`); + return result.stdout.trim(); +} + +function relative(path) { + let located = path; + try { + located = realpathSync(path); + } catch {} + return located.startsWith(`${root}/`) ? located.slice(root.length + 1) : located; +} + +async function fileHash(path) { + return sha256(await readFile(path)); +} + +function sha256(value) { + return createHash("sha256").update(value).digest("hex"); +} diff --git a/skills/factory/SKILL.md b/skills/factory/SKILL.md index ae94486..5ce0348 100644 --- a/skills/factory/SKILL.md +++ b/skills/factory/SKILL.md @@ -37,7 +37,15 @@ For each ready task, run: context --root --json ``` -Inspect the pack and receipt for the exact task contract, required original sources, source and contract digests, CTX generation/retrieval mode, relevance, and token/byte bounds. Regenerate after any source, contract, or index change. Never place secrets, credentials, source archives, or agent transcripts in a pack. +Select `context.required` according to [`CONTRACTS.md`](../../docs/CONTRACTS.md#task-factorytaskssave-001yaml), including its empty-selection rule. Inspect the pack and receipt for source and contract digests, CTX generation/retrieval mode, relevance, and token/byte bounds. Regenerate after any source, contract, or index change. Never place secrets, credentials, source archives, or agent transcripts in a pack. + +Use [`WORKFLOW.md`'s task-local worker brief and omitted-source procedure](../../docs/WORKFLOW.md#task-local-worker-brief) for fresh handoffs and targeted correction reads. + +## Risk and task review + +Follow the risk classes, check ordering, review packet, and finding dispositions in [`WORKFLOW.md`](../../docs/WORKFLOW.md#risk-and-review). Use disposable identities for critical negative tests, never real accounts or secrets; unresolved critical findings block delivery. + +This is task-local evidence policy, not another shipping gate. Do not call, embed, or rerun no-mistakes from Factory, and do not alter Firstmate routing or approvals. Firstmate's configured delivery invokes its normal gate once; Factory receipts may reference those resulting artifacts. ## Preview, then publish diff --git a/src/core/context.ts b/src/core/context.ts index a5100f9..67fa172 100644 --- a/src/core/context.ts +++ b/src/core/context.ts @@ -139,6 +139,11 @@ export async function generateContextPack(options: { } const lexical = statusMode === "LEXICAL_ONLY"; + const retrievalDocuments = + input.task.context.required.length > 0 + ? input.task.context.required + : input.project.documents.required; + const retrievalIdentities = selectedRetrievalIdentities(input.sources, retrievalDocuments); const packArgs = [ "pack", contextQuery(input.task), @@ -146,6 +151,7 @@ export async function generateContextPack(options: { options.root, "--token-budget", String(remaining), + ...retrievalDocuments.flatMap((path) => ["--document", path]), ...(lexical ? ["--no-embeddings"] : []), "--json", ]; @@ -185,6 +191,7 @@ export async function generateContextPack(options: { generation, input.sources, allSourceDigests, + retrievalIdentities, ); const markdown = renderPack({ @@ -401,12 +408,31 @@ function assertSameSnapshot(path: string, captured: string | undefined, actual: } } +function selectedRetrievalIdentities(sources: SourceFile[], selectedPaths: string[]): Set { + const identities = new Set(); + const missing: string[] = []; + for (const path of selectedPaths) { + const source = sources.find((candidate) => candidate.aliases.includes(path)); + if (source === undefined) missing.push(path); + else identities.add(source.identity); + } + if (missing.length > 0) { + blocked("CTX retrieval scope did not match the validated mandatory sources", { + reason: "RETRIEVAL_SCOPE_INVALID", + selected_documents: selectedPaths, + missing_documents: missing, + }); + } + return identities; +} + async function verifiedRanges( root: string, pack: Record, generation: number, mandatory: SourceFile[], digests: Record, + allowedIdentities: Set, ): Promise { if (!Array.isArray(pack.items)) { blocked("CTX pack omitted its items array", { reason: "CTX_PACK_INVALID" }); @@ -469,6 +495,12 @@ async function verifiedRanges( end_offset: endOffset, }); } + if (!allowedIdentities.has(document.identity)) { + blocked("CTX excerpt provenance was outside the selected retrieval scope", { + reason: "RETRIEVAL_SCOPE_WIDENED", + path, + }); + } assertSameSnapshot(path, snapshots.get(document.identity), document.sha256); assertSameSnapshot(path, digests[path], document.sha256); snapshots.set(document.identity, document.sha256); diff --git a/test/context.test.ts b/test/context.test.ts index 610e943..8e3b698 100644 --- a/test/context.test.ts +++ b/test/context.test.ts @@ -67,7 +67,7 @@ evidence: delivery: project-default `; -test("context writes exact mandatory sources, merges overlapping excerpts, and records provenance", async () => { +test("context writes exact mandatory sources and records provenance", async () => { const fixture = await seeded("success with spaces"); try { const result = await factory(fixture, { mode: "semantic" }); @@ -91,10 +91,7 @@ test("context writes exact mandatory sources, merges overlapping excerpts, and r assert.ok(pack.includes(architecture)); assert.match(pack, /Source: `PROJECT\.md`/); assert.match(pack, /CTX generation: `7`/); - assert.equal( - count(pack, "The overlapping excerpt sentinel appears exactly once in the source."), - 1, - ); + assert.doesNotMatch(pack, /The overlapping excerpt sentinel/); await assert.rejects(stat(join(fixture.root, "CONTEXT-PWNED"))); const receipt = parseObject(await readFile(join(fixture.root, payload.receipt_path), "utf8")); @@ -107,7 +104,7 @@ test("context writes exact mandatory sources, merges overlapping excerpts, and r receipt.source_digests["ARCHITECTURE.md"], payload.source_digests["ARCHITECTURE.md"], ); - assert.match(receipt.source_digests["docs/EXTRA.md"], /^[a-f0-9]{64}$/); + assert.equal(receipt.source_digests["docs/EXTRA.md"], undefined); assert.equal(receipt.ctx.index_generation, 7); assert.equal(receipt.ctx.retrieval_mode, "HYBRID_SEMANTIC"); } finally { @@ -115,6 +112,79 @@ test("context writes exact mandatory sources, merges overlapping excerpts, and r } }); +test("context limits CTX retrieval to the task-local source selection", async () => { + const fixture = await seeded("source-filter"); + try { + await writeFile( + join(fixture.root, "docs/TARGET.md"), + "# Target authority\n\nOnly this task-local source is relevant.\n", + ); + await writeFile( + join(fixture.root, ".factory/tasks/CTX-001.yaml"), + taskContract.replace( + "required: [PROJECT.md, ARCHITECTURE.md]", + "required: [PROJECT.md, ARCHITECTURE.md, docs/TARGET.md]", + ), + ); + + const result = await factory(fixture, { mode: "source-filter" }); + assert.equal(result.exit, 0, result.stderr || result.stdout); + const payload = parseObject(result.stdout); + const pack = await readFile(join(fixture.root, payload.pack_path), "utf8"); + assert.match(pack, /Only this task-local source is relevant\./); + assert.doesNotMatch(pack, /The overlapping excerpt sentinel/); + assert.equal(payload.source_digests["docs/EXTRA.md"], undefined); + } finally { + await fixture.dispose(); + } +}); + +test("context blocks CTX provenance outside the task-local source selection", async () => { + const fixture = await seeded("source-filter-adversarial"); + try { + await writeFile( + join(fixture.root, "docs/TARGET.md"), + "# Target authority\n\nOnly this task-local source is relevant.\n", + ); + await writeFile( + join(fixture.root, ".factory/tasks/CTX-001.yaml"), + taskContract.replace( + "required: [PROJECT.md, ARCHITECTURE.md]", + "required: [PROJECT.md, ARCHITECTURE.md, docs/TARGET.md]", + ), + ); + + const result = await factory(fixture, { mode: "source-filter-adversarial" }); + assert.equal(result.exit, 3, result.stderr || result.stdout); + const payload = parseObject(result.stdout); + assert.equal(payload.error.code, "CONTEXT_BLOCKED"); + assert.equal(payload.error.details.reason, "RETRIEVAL_SCOPE_WIDENED"); + assert.equal(payload.error.details.path, "docs/EXTRA.md"); + await assertNoPack(fixture.root); + } finally { + await fixture.dispose(); + } +}); + +test("empty task-local selection constrains CTX to mandatory global authority", async () => { + const fixture = await seeded("empty-source-selection"); + try { + await writeFile( + join(fixture.root, ".factory/tasks/CTX-001.yaml"), + taskContract.replace("required: [PROJECT.md, ARCHITECTURE.md]", "required: []"), + ); + + const result = await factory(fixture, { mode: "empty-source-selection" }); + assert.equal(result.exit, 0, result.stderr || result.stdout); + const payload = parseObject(result.stdout); + const pack = await readFile(join(fixture.root, payload.pack_path), "utf8"); + assert.doesNotMatch(pack, /The overlapping excerpt sentinel/); + assert.equal(payload.source_digests["docs/EXTRA.md"], undefined); + } finally { + await fixture.dispose(); + } +}); + test("context permits explicitly reported lexical fallback only when retrieval is sufficient", async () => { const fixture = await seeded("lexical"); try { @@ -381,19 +451,15 @@ test("context deduplicates mandatory and retrieved symlink identities", async () const payload = parseObject(result.stdout); const pack = await readFile(join(fixture.root, payload.pack_path), "utf8"); assert.equal(count(pack, await readFile(join(fixture.root, "PROJECT.md"), "utf8")), 1); + assert.doesNotMatch(pack, /The overlapping excerpt sentinel/); + assert.ok(pack.includes("docs/PROJECT-ALIAS.md")); assert.equal( - count(pack, "The overlapping excerpt sentinel appears exactly once in the source."), - 1, + payload.source_digests["docs/PROJECT-ALIAS.md"], + createHash("sha256") + .update(await readFile(join(fixture.root, "docs/PROJECT-ALIAS.md"))) + .digest("hex"), ); - for (const path of ["docs/PROJECT-ALIAS.md", "docs/EXTRA-ALIAS.md"]) { - assert.ok(pack.includes(path)); - assert.equal( - payload.source_digests[path], - createHash("sha256") - .update(await readFile(join(fixture.root, path))) - .digest("hex"), - ); - } + assert.equal(payload.source_digests["docs/EXTRA-ALIAS.md"], undefined); } finally { await fixture.dispose(); } @@ -572,14 +638,19 @@ if (command === "doctor") { process.exit(0); } if (command === "pack") { - const relative = "docs/EXTRA.md"; + const selectedDocuments = args.flatMap((arg, index) => arg === "--document" ? [args[index + 1]] : []); + const sourceFiltered = mode === "source-filter" && selectedDocuments.includes("docs/TARGET.md"); + const adversarialScope = mode === "source-filter-adversarial"; + const globalsOnly = mode === "empty-source-selection" && selectedDocuments.length === 2 && selectedDocuments.includes("PROJECT.md") && selectedDocuments.includes("ARCHITECTURE.md"); + const relative = adversarialScope ? "docs/EXTRA.md" : sourceFiltered ? "docs/TARGET.md" : globalsOnly ? "PROJECT.md" : selectedDocuments[0] || "docs/EXTRA.md"; const full = fs.readFileSync(path.join(root, relative), "utf8"); - const firstEnd = full.indexOf("Gamma context ends here."); - const secondStart = full.indexOf("The overlapping excerpt sentinel"); + const sentinelEnd = full.indexOf("Gamma context ends here."); + const firstEnd = sourceFiltered || globalsOnly || sentinelEnd < 0 ? full.length : sentinelEnd; + const sentinelStart = full.indexOf("The overlapping excerpt sentinel"); + const secondStart = sourceFiltered || globalsOnly || sentinelStart < 0 ? 0 : sentinelStart; const item = (start, end, text = full.slice(start, end)) => ({source:{source_type:"excerpt",text,provenance:{document_path:relative,document_sha256:hash(full),start_line:1,end_line:6,start_offset:start,end_offset:end,range_sha256:hash(text),index_generation:7}},estimated_tokens:Math.ceil(Buffer.byteLength(text)/3)}); - const items = [item(0, firstEnd), item(secondStart, full.length), item(0, firstEnd)]; + const items = sourceFiltered || globalsOnly || sentinelEnd < 0 ? [item(0, full.length)] : [item(0, firstEnd), item(secondStart, full.length), item(0, firstEnd)]; if (mode === "aliases") { - items[1].source.provenance.document_path = "docs/EXTRA-ALIAS.md"; const original = fs.readFileSync(path.join(root, "docs/PROJECT-ALIAS.md"), "utf8"); items.push({source:{text:original,provenance:{document_path:"docs/PROJECT-ALIAS.md",document_sha256:hash(original),start_offset:0,end_offset:[...original].length,index_generation:7}}}); } diff --git a/test/cp06-credential-isolation.test.mjs b/test/cp06-credential-isolation.test.mjs new file mode 100644 index 0000000..3718666 --- /dev/null +++ b/test/cp06-credential-isolation.test.mjs @@ -0,0 +1,175 @@ +import { strict as assert } from "node:assert"; +import { spawnSync } from "node:child_process"; +import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; +import test from "node:test"; +import { + assertDestructiveCredentialWritesDenied, + mountLiveCredentialReadOnly, +} from "../scripts/cp06-credential-isolation.mjs"; +import { withCompiledHelper } from "./fixtures/cp06-compiled-helper.mjs"; + +function runMountScenario(helperFd, scenario) { + const result = spawnSync( + "unshare", + [ + "--user", + "--map-root-user", + "--mount", + "--propagation", + "private", + "--", + process.execPath, + resolve("test/fixtures/cp06-credential-mount.mjs"), + scenario, + ], + { encoding: "utf8", timeout: 30_000, stdio: ["pipe", "pipe", "pipe", helperFd] }, + ); + assert.equal(result.status, 0, `${scenario}: ${result.stderr}`); + return JSON.parse(result.stdout); +} + +test("descriptor-bound credential mount exposes one read-only inode at both paths", async () => { + await withCompiledHelper("mount-control", async ({ helperFd }) => { + assert.deepEqual(runMountScenario(helperFd, "control"), { + scenario: "control", + mounted: true, + target_bytes: '{"fixture":"DUMMY","token":"DUMMY-NOT-REAL"}', + target_write: "EROFS", + parent_write: "EROFS", + integrity: true, + cleanup: "pass", + target_after_cleanup: "", + marker_unchanged: true, + source_unchanged: true, + remaining_mounts: 0, + }); + }); +}); + +test("leaf or parent substitution between preparation and mount is refused", async () => { + await withCompiledHelper("mount-substitution", async ({ helperFd }) => { + for (const [scenario, error] of [ + ["target-leaf-symlink", "CP-06 mount destination identity changed"], + ["target-leaf-replaced", "CP-06 mount destination identity changed"], + ["source-leaf-symlink", "CP-06 mount destination identity changed"], + ["target-parent-replaced", "CP-06 mount destination path identity changed"], + ["source-parent-replaced", "CP-06 mount destination path identity changed"], + ]) { + assert.deepEqual(runMountScenario(helperFd, scenario), { + scenario, + mounted: false, + error: `read-only credential isolation unavailable: ${error}\n`, + marker_unchanged: true, + source_unchanged: true, + remaining_mounts: 0, + }); + } + }); +}); + +test("symlinked target or source ancestors are refused before external mutation or mount", async () => { + await withCompiledHelper("mount-ancestor", async ({ helperFd }) => { + for (const scenario of ["target-ancestor-symlink", "source-ancestor-symlink"]) { + assert.deepEqual(runMountScenario(helperFd, scenario), { + scenario, + mounted: false, + error: "no-follow-refused", + external_entries: + scenario === "source-ancestor-symlink" + ? ["DUMMY-auth.json", "source"] + : ["DUMMY-auth.json"], + marker_unchanged: true, + source_unchanged: true, + remaining_mounts: 0, + }); + } + }); +}); + +test("mount setup failure reports cleanup failure instead of discarding it", async () => { + const root = await mkdtemp(join(tmpdir(), "factory-cp06-cleanup-test-")); + const source = join(root, "auth-source.json"); + const target = join(root, "agent", "auth.json"); + await writeFile(source, "DUMMY"); + let mountCalls = 0; + const spawnSyncImpl = (command) => { + if (command === "/DUMMY-helper" && ++mountCalls === 2) { + return { status: 65, stdout: "", stderr: "DUMMY bind failure" }; + } + if (command === "umount") { + return { status: 32, stdout: "", stderr: "DUMMY cleanup failure" }; + } + return { status: 0, stdout: "", stderr: "" }; + }; + try { + await assert.rejects( + mountLiveCredentialReadOnly({ + root, + source, + target, + mountHelper: "/DUMMY-helper", + spawnSyncImpl, + }), + AggregateError, + ); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + +test("unsupported isolation fails before credential access or provider work", async () => { + const { verifyIsolationPrerequisites, Cp06IsolationUnsupportedError } = + await import("../scripts/cp06-auth-security.mjs"); + let invocations = 0; + assert.throws( + () => + verifyIsolationPrerequisites({ + root: process.cwd(), + spawnSyncImpl(command) { + invocations += 1; + return { + status: command === "unshare" ? 1 : 0, + stdout: "", + stderr: "DUMMY namespaces unavailable", + }; + }, + }), + Cp06IsolationUnsupportedError, + ); + assert.equal(invocations, 1); +}); + +test("destructive DUMMY proof requires denied write unlink rename symlink and hardlink", async () => { + const calls = []; + const protectedPaths = new Set(["/dummy/source-auth.json", "/dummy/target-auth.json"]); + await assertDestructiveCredentialWritesDenied([...protectedPaths], { + async writeFile(path) { + calls.push(["write", path]); + if (protectedPaths.has(path)) throw new Error("EROFS"); + }, + async rename(from, to) { + calls.push(["rename", from, to]); + if (protectedPaths.has(to)) throw new Error("EBUSY"); + }, + async unlink(path) { + calls.push(["unlink", path]); + if (protectedPaths.has(path)) throw new Error("EBUSY"); + }, + async symlink(from, to) { + calls.push(["symlink", from, to]); + }, + async link(from, to) { + calls.push(["link", from, to]); + throw new Error("EXDEV"); + }, + async rm(path) { + calls.push(["rm", path]); + }, + }); + assert.equal(calls.filter(([operation]) => operation === "rename").length, 2); + assert.equal(calls.filter(([operation]) => operation === "unlink").length, 4); + assert.equal(calls.filter(([operation]) => operation === "link").length, 2); + assert.equal(calls.filter(([operation]) => operation === "symlink").length, 0); +}); diff --git a/test/cp06-guarded-read.test.mjs b/test/cp06-guarded-read.test.mjs new file mode 100644 index 0000000..9a05faa --- /dev/null +++ b/test/cp06-guarded-read.test.mjs @@ -0,0 +1,131 @@ +import { strict as assert } from "node:assert"; +import { mkdir, mkdtemp, rename, rm, symlink, unlink, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import test from "node:test"; +import { + canonicalWorkerPaths, + createGuardedReadTool, + readPinnedRegularFile, +} from "../scripts/cp06-guarded-read.mjs"; + +test("guarded read denies noncanonical and unauthorized paths before tool access", async () => { + const root = await mkdtemp(join(tmpdir(), "factory-cp06-DUMMY-read-")); + const fixtureRoot = join(root, "fixture"); + const original = join(fixtureRoot, "docs", "AUTH.md"); + const credential = join(root, "DUMMY-auth.json"); + const alias = join(root, "DUMMY-auth-alias"); + await mkdir(join(fixtureRoot, "docs"), { recursive: true }); + await writeFile(original, "DUMMY exact\noriginal"); + await writeFile(credential, "DUMMY secret canary"); + await symlink(credential, alias); + let accesses = 0; + const exported = []; + const sdk = { + createReadTool(_root, options) { + return { + name: "read", + async execute(_id, args) { + accesses += 1; + await options.operations.access(args.path); + const bytes = await options.operations.readFile(args.path); + exported.push(bytes.toString("utf8")); + return { content: [{ type: "text", text: bytes.toString("utf8") }] }; + }, + }; + }, + }; + const guarded = createGuardedReadTool({ sdk, root, expectedOriginalPath: original }); + const tool = guarded.tool; + try { + for (const args of [ + { path: credential }, + { path: alias }, + { path: join(root, "PROJECT.md") }, + { path: original, offset: 1 }, + { path: original, limit: 20 }, + { path: original, offset: 2, limit: 20 }, + { path: original, offset: 1, limit: 0 }, + { path: original, offset: 1, limit: 1 }, + { path: original, offset: 1, limit: 20 }, + { path: original, offset: 1, limit: 2_000 }, + { path: original, offset: 0 }, + { path: original, limit: Number.MAX_SAFE_INTEGER }, + { path: original, offset: undefined }, + { path: original, extra: true }, + ]) { + await assert.rejects(tool.execute("DUMMY-read", args)); + } + assert.equal(accesses, 0); + const complete = await tool.execute("DUMMY-read", { path: original }); + assert.equal(complete.content[0].text, "DUMMY exact\noriginal"); + assert.deepEqual(exported, ["DUMMY exact\noriginal"]); + assert.equal(accesses, 1); + + await unlink(original); + await symlink(credential, original); + await assert.rejects(tool.execute("DUMMY-raced-read", { path: original })); + assert.equal(accesses, 1); + assert.equal(exported.includes("DUMMY secret canary"), false); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + +test("worker paths reject symlink escapes and credential overlap", async () => { + const root = await mkdtemp(join(tmpdir(), "factory-cp06-DUMMY-paths-")); + const fixtureRoot = join(root, "fixture"); + const original = join(fixtureRoot, "AUTH.md"); + const contract = join(root, "contract.yaml"); + const pack = join(root, "pack.md"); + const alias = join(fixtureRoot, "AUTH-alias.md"); + await mkdir(fixtureRoot); + await writeFile(original, "DUMMY original"); + await writeFile(contract, "DUMMY contract"); + await writeFile(pack, "DUMMY pack"); + await symlink(original, alias); + const input = { + root, + fixture_root: fixtureRoot, + contract_path: contract, + pack_path: pack, + original_path: original, + }; + try { + assert.equal(canonicalWorkerPaths(input, join(root, "auth.json")).originalPath, original); + assert.throws(() => + canonicalWorkerPaths({ ...input, original_path: alias }, credentialPath(root)), + ); + assert.throws(() => canonicalWorkerPaths(input, original)); + assert.throws(() => canonicalWorkerPaths(input, join(root, "target-auth.json"), original)); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + +function credentialPath(root) { + return join(root, "auth.json"); +} + +test("initial original snapshot does not follow a substituted ancestor directory", async () => { + const root = await mkdtemp(join(tmpdir(), "factory-cp06-DUMMY-ancestor-")); + const original = join(root, "fixture", "docs", "AUTH.md"); + const external = join(root, "DUMMY-external"); + await mkdir(join(root, "fixture", "docs"), { recursive: true }); + await mkdir(external); + await writeFile(original, "DUMMY exact original"); + await writeFile(join(external, "AUTH.md"), "DUMMY-EXTERNAL-SECRET"); + try { + assert.equal(readPinnedRegularFile(original).bytes.toString("utf8"), "DUMMY exact original"); + await rename(join(root, "fixture", "docs"), join(root, "fixture", "docs-moved")); + await symlink(external, join(root, "fixture", "docs")); + assert.throws( + () => readPinnedRegularFile(original), + (error) => + ["ELOOP", "ENOTDIR"].includes(error.code) && + !String(error.message).includes("DUMMY-EXTERNAL"), + ); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); diff --git a/test/cp06-pi-dependency.test.mjs b/test/cp06-pi-dependency.test.mjs new file mode 100644 index 0000000..f9745ff --- /dev/null +++ b/test/cp06-pi-dependency.test.mjs @@ -0,0 +1,221 @@ +import { strict as assert } from "node:assert"; +import { spawnSync } from "node:child_process"; +import { chmod, cp, mkdtemp, mkdir, readFile, rm, symlink, writeFile } from "node:fs/promises"; +import { existsSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; +import test from "node:test"; +import { pathToFileURL } from "node:url"; +import { resolvePinnedPiInstall } from "../scripts/cp06-pi-install.mjs"; +import { trustedWorkerProvenance } from "../scripts/cp06-worker-evidence.mjs"; + +const resolverUrl = pathToFileURL(resolve("scripts/cp06-pi-dependency.mjs")).href; + +test("Pi selection requires the explicit task-local package and executable paths", async () => { + const projectRoot = await mkdtemp(join(tmpdir(), "factory-cp06-DUMMY-pi-install-")); + const installRoot = join(projectRoot, ".factory/state/cp06-sdk"); + const packageRoot = join(installRoot, "node_modules", "@earendil-works", "pi-coding-agent"); + const entry = join(packageRoot, "dist/cli.js"); + const bin = join(installRoot, "node_modules/.bin/pi"); + try { + await mkdir(join(packageRoot, "dist"), { recursive: true }); + await mkdir(join(installRoot, "node_modules/.bin"), { recursive: true }); + await writeFile( + join(packageRoot, "package.json"), + JSON.stringify({ + name: "@earendil-works/pi-coding-agent", + version: "0.85.1", + bin: { pi: "dist/cli.js" }, + }), + ); + await writeFile(entry, "#!/usr/bin/env node\n"); + await writeFile(join(packageRoot, "dist/index.js"), "export const sdk = 'DUMMY';\n"); + await chmod(entry, 0o755); + await symlink("../@earendil-works/pi-coding-agent/dist/cli.js", bin); + const invocations = []; + assert.throws( + () => + resolvePinnedPiInstall({ + projectRoot, + packageRoot, + executable: bin, + spawnSyncImpl(command, args) { + invocations.push([command, ...args]); + return { status: 0, stdout: "0.85.1\n", stderr: "" }; + }, + }), + /pinned artifact/, + ); + assert.deepEqual(invocations, []); + + for (const substitution of [ + { packageRoot: undefined, executable: bin }, + { packageRoot, executable: undefined }, + { packageRoot: join(projectRoot, "unrelated/pi-coding-agent"), executable: bin }, + { packageRoot, executable: entry }, + ]) { + assert.throws(() => + resolvePinnedPiInstall({ projectRoot, ...substitution, spawnSyncImpl() {} }), + ); + } + } finally { + await rm(projectRoot, { recursive: true, force: true }); + } +}); + +test("exact artifact succeeds and same-version selected entry substitutions fail before SDK use", async (t) => { + const installed = resolve(".factory/state/cp06-sdk/node_modules/@earendil-works"); + if (!existsSync(join(installed, "pi-ai"))) return t.skip("task-local CP-06 SDK is not installed"); + const projectRoot = await mkdtemp(join(tmpdir(), "factory-cp06-DUMMY-pinned-artifact-")); + const selected = join(projectRoot, ".factory/state/cp06-sdk/node_modules"); + const packages = join(selected, "@earendil-works"); + const packageRoot = join(packages, "pi-coding-agent"); + const executable = join(selected, ".bin/pi"); + try { + await mkdir(packages, { recursive: true }); + await mkdir(join(selected, ".bin")); + for (const name of ["pi-coding-agent", "pi-ai"]) { + await cp(join(installed, name), join(packages, name), { recursive: true }); + } + const binPath = JSON.parse(await readFile(join(packageRoot, "package.json"))).bin; + await symlink( + join(packageRoot, typeof binPath === "string" ? binPath : binPath.pi), + executable, + ); + let launches = 0; + const options = { + projectRoot, + packageRoot, + executable, + spawnSyncImpl() { + launches++; + return { status: 0, stdout: "0.85.1\n" }; + }, + }; + const positive = resolvePinnedPiInstall(options); + assert.equal(positive.provenance.dependency.version, "0.85.1"); + assert.match(positive.provenance.package_artifact.manifest_sha256, /^[a-f0-9]{64}$/); + assert.equal(launches, 1); + const entry = join(packageRoot, "dist/index.js"); + const original = await readFile(entry); + await writeFile(entry, "DUMMY same-version SDK replacement"); + assert.throws(() => resolvePinnedPiInstall(options), /pinned artifact/); + assert.equal(launches, 1); + await writeFile(entry, original); + const aiEntry = join(packages, "pi-ai/dist/index.js"); + await writeFile(aiEntry, "DUMMY same-version dependency replacement"); + assert.throws(() => resolvePinnedPiInstall(options), /pinned artifact/); + assert.equal(launches, 1); + } finally { + await rm(projectRoot, { recursive: true, force: true }); + } +}); + +test("supervisor trusted provenance verifies the selected installation without executing Pi", async (t) => { + const installed = resolve(".factory/state/cp06-sdk/node_modules/@earendil-works"); + if (!existsSync(join(installed, "pi-ai"))) return t.skip("task-local CP-06 SDK is not installed"); + const projectRoot = await mkdtemp(join(tmpdir(), "factory-cp06-DUMMY-trusted-provenance-")); + const selected = join(projectRoot, ".factory/state/cp06-sdk/node_modules"); + const packages = join(selected, "@earendil-works"); + const packageRoot = join(packages, "pi-coding-agent"); + const executable = join(selected, ".bin/pi"); + try { + await mkdir(packages, { recursive: true }); + await mkdir(join(selected, ".bin")); + for (const name of ["pi-coding-agent", "pi-ai"]) { + await cp(join(installed, name), join(packages, name), { recursive: true }); + } + const binPath = JSON.parse(await readFile(join(packageRoot, "package.json"))).bin; + await symlink( + join(packageRoot, typeof binPath === "string" ? binPath : binPath.pi), + executable, + ); + const environment = { + CP06_PI_PACKAGE_ROOT: packageRoot, + CP06_PI_BIN: executable, + PATH: join(projectRoot, "DUMMY-no-node"), + HOME: join(projectRoot, "DUMMY-home"), + }; + const trusted = trustedWorkerProvenance({ root: projectRoot, environment }); + const reported = resolvePinnedPiInstall({ + projectRoot, + packageRoot, + executable, + spawnSyncImpl: () => ({ status: 0, stdout: "0.85.1\n" }), + }).provenance; + assert.deepEqual(trusted, reported); + assert.equal(existsSync(join(projectRoot, "DUMMY-home")), false); + await writeFile( + join(packages, "pi-ai/dist/index.js"), + "DUMMY same-version dependency replacement", + ); + assert.throws( + () => trustedWorkerProvenance({ root: projectRoot, environment }), + /pinned artifact/, + ); + } finally { + await rm(projectRoot, { recursive: true, force: true }); + } +}); + +for (const scenario of [ + { name: "substituted same-version import-only dependency", version: "0.85.1", expectedExit: 1 }, + { name: "newer hoisted import-only dependency", version: "0.99.2", expectedExit: 1 }, +]) { + test(`pinned Pi dependency resolver handles ${scenario.name}`, async () => { + const directory = await mkdtemp(join(tmpdir(), "factory-cp06-DUMMY-pi-layout-")); + const piRoot = join(directory, "node_modules", "@earendil-works", "pi-coding-agent"); + const aiRoot = join(directory, "node_modules", "@earendil-works", "pi-ai"); + try { + await mkdir(piRoot, { recursive: true }); + await mkdir(join(aiRoot, "dist"), { recursive: true }); + await writeFile( + join(piRoot, "package.json"), + JSON.stringify({ + name: "@earendil-works/pi-coding-agent", + version: "0.85.1", + type: "module", + dependencies: { "@earendil-works/pi-ai": "^0.85.1" }, + }), + ); + await writeFile( + join(aiRoot, "package.json"), + JSON.stringify({ + name: "@earendil-works/pi-ai", + version: scenario.version, + type: "module", + exports: { ".": { import: "./dist/index.js" } }, + }), + ); + await writeFile(join(aiRoot, "dist/index.js"), 'export const marker = "DUMMY-import";\n'); + const result = spawnSync( + process.execPath, + [ + "--experimental-import-meta-resolve", + "--input-type=module", + "--eval", + `const {importPinnedPiAi}=await import(${JSON.stringify(resolverUrl)});const result=await importPinnedPiAi(process.argv[1]);console.log(JSON.stringify({marker:result.module.marker,provenance:result.provenance}));`, + piRoot, + ], + { encoding: "utf8", timeout: 30_000 }, + ); + assert.equal(result.status, scenario.expectedExit, result.stderr); + if (scenario.expectedExit === 0) { + const output = JSON.parse(result.stdout); + assert.equal(output.marker, "DUMMY-import"); + assert.equal(output.provenance.version, "0.85.1"); + assert.equal(output.provenance.resolution, "esm-import-condition"); + assert.equal(output.provenance.root, aiRoot); + assert.match(output.provenance.package_sha256, /^[a-f0-9]{64}$/); + assert.match(output.provenance.entry_sha256, /^[a-f0-9]{64}$/); + } else { + assert.match( + result.stderr, + scenario.version === "0.85.1" ? /pinned artifact/ : /not exact version 0\.85\.1/, + ); + } + } finally { + await rm(directory, { recursive: true, force: true }); + } + }); +} diff --git a/test/cp06-proof-boundary.test.mjs b/test/cp06-proof-boundary.test.mjs new file mode 100644 index 0000000..4b526ba --- /dev/null +++ b/test/cp06-proof-boundary.test.mjs @@ -0,0 +1,665 @@ +import { strict as assert } from "node:assert"; +import { createHash } from "node:crypto"; +import { DUMMY_SDK_ORIGINAL, DUMMY_OUTCOME_ORIGINAL } from "../scripts/cp06-dummy-originals.mjs"; +import { spawnSync } from "node:child_process"; +import { mkdirSync, readlinkSync, renameSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; +import { + mkdir, + mkdtemp, + readFile, + readdir, + rename, + rm, + symlink, + writeFile, +} from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; +import test from "node:test"; +import { + compileIsolationHelpers, + runDummySdkProof, + runIsolationProbe, +} from "../scripts/cp06-auth-security.mjs"; +import { + openAnchoredDirectory, + openProbeOutput, + removeAnchoredEntry, + writeAnchoredFile, +} from "../scripts/cp06-probe-fixture.mjs"; + +const runners = [ + "scripts/replay-cp06-worker.mjs", + "scripts/replay-cp06-ten-pack.mjs", + "scripts/replay-cp06-p07.mjs", + "scripts/prove-cp06-worker-cleanup.mjs", +]; + +test("all proof runners reject external and symlinked output before removing artifacts", async () => { + const external = await mkdtemp(join(tmpdir(), "factory-cp06-DUMMY-external-output-")); + const base = resolve(".factory/state/cp06-correction"); + const alias = join(base, "DUMMY-output-alias"); + await mkdir(base, { recursive: true }); + for (const name of ["worker", "ten-pack", "p07", "worker-cleanup"]) { + await mkdir(join(external, name)); + await writeFile(join(external, name, "DUMMY-sentinel"), "DUMMY-SECRET-UNCHANGED"); + } + await symlink(external, alias); + try { + for (const runner of runners) { + for (const output of [external, alias]) { + const result = spawnSync(process.execPath, [resolve(runner)], { + cwd: process.cwd(), + encoding: "utf8", + timeout: 15_000, + env: { ...process.env, CP06_OUTPUT: output }, + }); + assert.notEqual(result.status, 0, `${runner} accepted ${output}`); + assert.equal(result.stdout.includes("DUMMY-SECRET-UNCHANGED"), false); + assert.equal(result.stderr.includes("DUMMY-SECRET-UNCHANGED"), false); + for (const name of ["worker", "ten-pack", "p07", "worker-cleanup"]) { + assert.deepEqual(await readdir(join(external, name)), ["DUMMY-sentinel"]); + assert.equal( + await readFile(join(external, name, "DUMMY-sentinel"), "utf8"), + "DUMMY-SECRET-UNCHANGED", + ); + } + } + } + } finally { + await rm(alias); + await rm(external, { recursive: true, force: true }); + } +}); + +test("failed DUMMY probes never forward child output or spawn exceptions", async () => { + const secret = "DUMMY-SECRET-STDOUT-STDERR"; + const structured = { + schema_version: 1, + stage: "audit", + code: "CP06_CHILD_OUTPUT_INVALID", + exit_code: 70, + description: "isolated child output was invalid", + causes: [], + }; + for (const result of [ + { status: 70, stderr: `${JSON.stringify(structured)}\n${secret}`, stdout: secret }, + { status: 70, stderr: JSON.stringify(structured), stdout: secret }, + { + status: null, + error: Object.assign(new Error(secret), { code: "ETIMEDOUT" }), + stderr: secret, + }, + ]) { + await assert.rejects( + runIsolationProbe({ + root: process.cwd(), + outputRoot: resolve(".factory/state/cp06-correction/auth-security"), + mode: "unsafe-probe", + binaries: { helper: "/DUMMY-helper", syscallProbe: "/DUMMY-probe" }, + runNamespaceImpl() { + return result; + }, + }), + (error) => { + assert.equal(error.code, "CP06_ISOLATION_UNSUPPORTED"); + assert.equal( + JSON.stringify({ message: error.message, workerFailure: error.workerFailure }).includes( + secret, + ), + false, + ); + if (result.stderr === JSON.stringify(structured)) { + assert.deepEqual(error.workerFailure, structured); + } else if (result.error?.code === "ETIMEDOUT") { + assert.equal(error.workerFailure.code, "CP06_CHILD_TIMEOUT"); + assert.equal(error.workerFailure.stage, "timeout"); + } else assert.equal(error.workerFailure, undefined); + return true; + }, + ); + } + for (const result of [ + { status: 1, stdout: secret, stderr: secret }, + { + status: null, + error: Object.assign(new Error(secret), { code: "ETIMEDOUT" }), + stderr: secret, + }, + ]) { + assert.throws( + () => + runDummySdkProof({ + root: process.cwd(), + helper: process.execPath, + piRoot: "/DUMMY-pi", + spawnSyncImpl() { + return result; + }, + }), + (error) => error.code === "CP06_ISOLATION_UNSUPPORTED" && !error.message.includes(secret), + ); + } +}); + +test("DUMMY SDK proof outer cleanup removes child leftovers only through the retained root", async () => { + const external = await mkdtemp(join(tmpdir(), "factory-cp06-DUMMY-sdk-external-")); + const sentinel = join(external, "DUMMY-sentinel"); + await writeFile(sentinel, "DUMMY SENTINEL UNCHANGED"); + const leftovers = []; + const options = { root: process.cwd(), helper: process.execPath, piRoot: "/DUMMY-pi" }; + try { + assert.throws( + () => + runDummySdkProof({ + ...options, + spawnSyncImpl(command, args) { + const proofRoot = args.at(-1); + leftovers.push(proofRoot); + mkdirSync(join(proofRoot, "DUMMY-worker", "nested"), { recursive: true }); + writeFileSync(join(proofRoot, "DUMMY-worker", "nested", "DUMMY-partial"), "DUMMY"); + symlinkSync(external, join(proofRoot, "DUMMY-worker", "DUMMY-escape")); + return { + status: null, + error: Object.assign(new Error("DUMMY"), { code: "ETIMEDOUT" }), + }; + }, + }), + (error) => + error.code === "CP06_ISOLATION_UNSUPPORTED" && + /CP06_DUMMY_SDK_TIMEOUT/u.test(error.message), + ); + await assert.rejects(readdir(leftovers[0]), { code: "ENOENT" }); + assert.equal(await readFile(sentinel, "utf8"), "DUMMY SENTINEL UNCHANGED"); + + assert.throws( + () => + runDummySdkProof({ + ...options, + spawnSyncImpl(command, args) { + const proofRoot = args.at(-1); + leftovers.push(proofRoot, `${proofRoot}-DUMMY-moved`); + renameSync(proofRoot, `${proofRoot}-DUMMY-moved`); + mkdirSync(proofRoot); + writeFileSync(join(proofRoot, "DUMMY-substitute"), "DUMMY SUBSTITUTE UNCHANGED"); + return { status: 1, stdout: "", stderr: "" }; + }, + }), + (error) => + error.code === "CP06_CLEANUP_FAILED" && + /CP06_DUMMY_SDK_CHILD_EXIT/u.test(error.errors[0].message) && + /CP06_DUMMY_SDK_CLEANUP_FAILED/u.test(error.errors[1].message), + ); + assert.equal( + await readFile(join(leftovers[1], "DUMMY-substitute"), "utf8"), + "DUMMY SUBSTITUTE UNCHANGED", + ); + } finally { + for (const path of [external, ...leftovers]) await rm(path, { recursive: true, force: true }); + } +}); + +test("DUMMY SDK proof consumer rejects incomplete and extra payloads", () => { + const trustedDependency = sdkDependency(); + const validBase = { + schema_version: 1, + result: "pass", + auth_cases: [ + authCase("expired", "blocked", 0, 1), + authCase("near_expiry", "blocked", 0, 1), + authCase("unexpired", "resolved", 1, 0), + ], + sdk_worker: { + active_tools: ["read"], + in_memory_session: true, + event_count: 47, + event_sha256: "0".repeat(64), + read_audit: readAudit("DUMMY-read", "@ROOT@/DUMMY-worker/DUMMY-original.md"), + refresh_callbacks: 0, + credential_store: { reads: 203, lists: 1, modify_denials: 0, delete_denials: 0 }, + }, + sdk_dependency: trustedDependency, + actual_sdk_outcomes: { + fixture_origin: true, + semantic_acceptance: false, + cases: [ + outcome("valid", 0), + outcome("fenced-valid", 0), + outcome("extra-before-fence", 75), + outcome("empty-gap", 75), + outcome("missing-gap", 75), + outcome("contradictory-gap", 75), + outcome("extra-evidence", 75), + outcome("constraint-pass", 75), + outcome("missing-reads", 75), + outcome("credential-read", 75), + outcome("symlink-read", 75), + outcome("earlier-assistant-claim", 75), + outcome("final-extra-channel", 75, { to_auth: 4 }), + outcome("unexpected-tool-secret", 75), + outcome("expired", 75), + outcome("near-expiry", 75), + outcome("timeout", 70, { to_auth: 2 }), + ], + }, + fixture_origin: true, + semantic_acceptance: false, + network_calls: 0, + }; + for (const payload of [ + { ...validBase, auth_cases: [] }, + { ...validBase, sdk_worker: { ...validBase.sdk_worker, event_count: 2 } }, + { + ...validBase, + sdk_worker: { + ...validBase.sdk_worker, + read_audit: { + ...validBase.sdk_worker.read_audit, + expected_sha256: "a".repeat(64), + returned_sha256: "a".repeat(64), + returned_bytes: 1, + }, + }, + }, + { + ...validBase, + actual_sdk_outcomes: { + ...validBase.actual_sdk_outcomes, + cases: validBase.actual_sdk_outcomes.cases.map((entry) => + entry.scenario === "valid" ? { ...entry, event_count: 2 } : entry, + ), + }, + }, + { + ...validBase, + actual_sdk_outcomes: { + ...validBase.actual_sdk_outcomes, + cases: validBase.actual_sdk_outcomes.cases.map((entry) => + entry.scenario === "valid" + ? { + ...entry, + read_audit: { + ...entry.read_audit, + expected_sha256: "a".repeat(64), + returned_sha256: "a".repeat(64), + returned_bytes: 1, + }, + } + : entry, + ), + }, + }, + { ...validBase, auth_cases: [...validBase.auth_cases, authCase("expired", "blocked", 0)] }, + { ...validBase, extra: "DUMMY-SECRET" }, + { ...validBase, sdk_dependency: { ...validBase.sdk_dependency, version: "0.99.2" } }, + { + ...validBase, + sdk_dependency: { + ...validBase.sdk_dependency, + root: resolve(".factory/state/cp06-sdk/node_modules/@earendil-works/pi-ai-forged"), + }, + }, + { + ...validBase, + sdk_dependency: { + ...validBase.sdk_dependency, + entry: resolve(".factory/state/cp06-sdk/node_modules/@earendil-works/pi-ai/dist/forged.js"), + }, + }, + { + ...validBase, + sdk_dependency: { ...validBase.sdk_dependency, manifest_sha256: "4".repeat(64) }, + }, + { + ...validBase, + actual_sdk_outcomes: { + ...validBase.actual_sdk_outcomes, + cases: [{ ...validBase.actual_sdk_outcomes.cases[0], forged: "DUMMY-SECRET" }], + }, + }, + { ...validBase, sdk_worker: { ...validBase.sdk_worker, active_tools: ["read", "write"] } }, + { + ...validBase, + sdk_worker: { + ...validBase.sdk_worker, + read_audit: { ...validBase.sdk_worker.read_audit, extra: "DUMMY-SECRET" }, + }, + }, + { + ...validBase, + sdk_worker: { ...validBase.sdk_worker, read_audit: { exact_original: true, read_count: 1 } }, + }, + { + ...validBase, + sdk_worker: { + ...validBase.sdk_worker, + read_audit: readAudit("DUMMY-read", "/etc/DUMMY-worker/DUMMY-original.md"), + }, + }, + { + ...validBase, + sdk_worker: { + ...validBase.sdk_worker, + read_audit: { ...validBase.sdk_worker.read_audit, other_tool_calls: ["DUMMY-SECRET"] }, + }, + }, + { + ...validBase, + sdk_worker: { + ...validBase.sdk_worker, + credential_store: { ...validBase.sdk_worker.credential_store, modify_denials: 7 }, + }, + }, + { + ...validBase, + auth_cases: [ + authCase("expired", "blocked", 0, 0), + validBase.auth_cases[1], + validBase.auth_cases[2], + ], + }, + { + ...validBase, + auth_cases: [ + validBase.auth_cases[0], + authCase("near_expiry", "blocked", 0, 9), + validBase.auth_cases[2], + ], + }, + ...[ + ["valid", { to_auth: 0 }], + ["missing-reads", { runtime_create: 0, to_auth: 0 }], + ["credential-read", { to_auth: 17 }], + ["expired", { runtime_create: 1 }], + [ + "valid", + { + read_audit: { + ...readAudit("DUMMY-actual-worker-read", "@ROOT@/DUMMY-outcome/docs/AUTH.md"), + extra: "DUMMY-SECRET", + }, + }, + ], + [ + "valid", + { + read_audit: readAudit( + "DUMMY-actual-worker-read", + "/home/DUMMY/.pi/agent/DUMMY-outcome/docs/AUTH.md", + ), + }, + ], + [ + "fenced-valid", + { + read_audit: { + ...readAudit("DUMMY-actual-worker-read", "@ROOT@/DUMMY-outcome/docs/AUTH.md"), + returned_sha256: "9".repeat(64), + }, + }, + ], + ].map(([scenario, changes]) => ({ + ...validBase, + actual_sdk_outcomes: { + ...validBase.actual_sdk_outcomes, + cases: validBase.actual_sdk_outcomes.cases.map((entry) => + entry.scenario === scenario ? { ...entry, ...changes } : entry, + ), + }, + })), + ]) { + assert.throws( + () => + runDummySdkProof({ + root: process.cwd(), + helper: process.execPath, + piRoot: "/DUMMY-pi", + spawnSyncImpl: stubbedSdkProof(payload), + expectedSdkDependency: trustedDependency, + }), + (error) => !String(error.message).includes("DUMMY-SECRET"), + ); + } + const accepted = runDummySdkProof({ + root: process.cwd(), + helper: process.execPath, + piRoot: "/DUMMY-pi", + spawnSyncImpl: stubbedSdkProof(validBase), + expectedSdkDependency: trustedDependency, + }); + assert.equal(accepted.result, "pass"); +}); + +test("helper compilation rejects replaced artifact leaves", async () => { + const root = process.cwd(); + const outputRoot = resolve(".factory/state/auth-security/DUMMY-compile"); + const external = await mkdtemp(join(tmpdir(), "factory-cp06-DUMMY-compile-external-")); + const sentinel = join(external, "DUMMY-sentinel"); + const output = openProbeOutput({ root, outputRoot, create: true }); + await writeFile(sentinel, "DUMMY sentinel unchanged"); + let replaced = false; + try { + assert.throws(() => + compileIsolationHelpers({ + root, + outputRoot, + anchoredOutputRoot: output.anchor, + spawnSyncImpl(_command, _args, options) { + const outputPath = `/proc/${process.pid}/fd/${options.stdio[3]}`; + writeFileSync(outputPath, "DUMMY compiled helper"); + if (!replaced) { + replaced = true; + const leaf = readlinkSync(outputPath); + rmSync(leaf, { force: true }); + symlinkSync(sentinel, leaf); + } + return { status: 0, stdout: "", stderr: "" }; + }, + }), + ); + assert.equal(await readFile(sentinel, "utf8"), "DUMMY sentinel unchanged"); + } finally { + output.close(); + await rm(outputRoot, { recursive: true, force: true }); + await rm(external, { recursive: true, force: true }); + } +}); + +test("anchored runner child directories do not follow substituted paths", async () => { + const root = process.cwd(); + const outputRoot = resolve(".factory/state/cp06-correction/DUMMY-child-anchor"); + const external = await mkdtemp(join(tmpdir(), "factory-cp06-DUMMY-child-external-")); + const output = openProbeOutput({ root, outputRoot, create: true, runner: true }); + const child = openAnchoredDirectory(output.anchor, "worker", { reset: true }); + try { + await rename(join(outputRoot, "worker"), join(outputRoot, "worker-original")); + await symlink(external, join(outputRoot, "worker")); + await writeFile(join(child.anchor, "input.json"), "DUMMY anchored write"); + assert.deepEqual(await readdir(external), []); + assert.equal( + await readFile(join(outputRoot, "worker-original", "input.json"), "utf8"), + "DUMMY anchored write", + ); + } finally { + child.close(); + output.close(); + await rm(outputRoot, { recursive: true, force: true }); + await rm(external, { recursive: true, force: true }); + } +}); + +function stubbedSdkProof(payload) { + return (_command, args) => ({ + status: 0, + stdout: JSON.stringify(payload).replaceAll("@ROOT@", args.at(-1)), + stderr: "", + }); +} + +function authCase(scenario, result, toAuth, modifyDenials) { + return { + scenario, + result, + refresh_callbacks: 0, + to_auth_calls: toAuth, + credential_store: { reads: 1, lists: 0, modify_denials: modifyDenials, delete_denials: 0 }, + persistence_operations: 0, + source_unchanged: true, + }; +} + +function readAudit(toolCallId, originalPath) { + return { + read_count: 1, + project_read_count: 1, + project_read_paths: [originalPath], + rejected_read_paths: [], + other_tool_calls: [], + other_tool_completions: [], + completed_successfully: true, + exact_original: true, + expected_sha256: createHash("sha256") + .update(toolCallId === "DUMMY-read" ? DUMMY_SDK_ORIGINAL : DUMMY_OUTCOME_ORIGINAL) + .digest("hex"), + returned_sha256: createHash("sha256") + .update(toolCallId === "DUMMY-read" ? DUMMY_SDK_ORIGINAL : DUMMY_OUTCOME_ORIGINAL) + .digest("hex"), + returned_bytes: Buffer.byteLength( + toolCallId === "DUMMY-read" ? DUMMY_SDK_ORIGINAL : DUMMY_OUTCOME_ORIGINAL, + ), + tool_call_id: toolCallId, + start_event_index: 12, + end_event_index: 13, + response_event_index: 46, + }; +} + +function sdkDependency() { + const root = resolve(".factory/state/cp06-sdk/node_modules/@earendil-works/pi-ai"); + return { + entry: join(root, "dist/index.js"), + entry_sha256: "2".repeat(64), + manifest_sha256: "3".repeat(64), + name: "@earendil-works/pi-ai", + package_sha256: "1".repeat(64), + resolution: "esm-import-condition", + root, + tarball_integrity: "sha512-DUMMY", + version: "0.85.1", + }; +} + +function outcome(scenario, exitCode, extra = {}) { + const result = { + scenario, + exit_code: exitCode, + expected_exit_code: exitCode, + exit: exitCode, + fixture_origin: true, + sdk_dependency: sdkDependency(), + default_storage: 0, + network: 0, + refresh: 0, + to_auth: ["expired", "near-expiry"].includes(scenario) ? 0 : 3, + runtime_create: ["expired", "near-expiry"].includes(scenario) ? 0 : 1, + source_unchanged: true, + ...extra, + }; + if (["valid", "fenced-valid"].includes(scenario)) { + result.read_audit = readAudit("DUMMY-actual-worker-read", "@ROOT@/DUMMY-outcome/docs/AUTH.md"); + result.event_count = 47; + } + return result; +} + +test("anchored artifact writes refuse substituted leaves before external mutation", async () => { + const root = process.cwd(); + const outputRoot = resolve(".factory/state/cp06-correction/DUMMY-artifact-leaf"); + const external = await mkdtemp(join(tmpdir(), "factory-cp06-DUMMY-leaf-external-")); + const sentinel = join(external, "DUMMY-sentinel"); + await writeFile(sentinel, "DUMMY-SENTINEL-UNCHANGED"); + const output = openProbeOutput({ root, outputRoot, create: true, runner: true }); + const child = openAnchoredDirectory(output.anchor, "worker", { reset: true }); + try { + for (const name of ["input.json", "evidence.json", "blocked.json"]) { + await symlink(sentinel, join(outputRoot, "worker", name)); + assert.throws(() => writeAnchoredFile(child.anchor, name, "DUMMY redirected write"), { + code: "EEXIST", + }); + } + assert.equal(await readFile(sentinel, "utf8"), "DUMMY-SENTINEL-UNCHANGED"); + const digest = writeAnchoredFile(child.anchor, "worker-control.json", "DUMMY control"); + assert.equal( + await readFile(join(outputRoot, "worker", "worker-control.json"), "utf8"), + "DUMMY control", + ); + assert.match(digest, /^[0-9a-f]{64}$/u); + for (const name of ["../escape.json", "nested/blocked.json", ""]) { + assert.throws(() => writeAnchoredFile(child.anchor, name, "DUMMY")); + } + } finally { + child.close(); + output.close(); + await rm(outputRoot, { recursive: true, force: true }); + await rm(external, { recursive: true, force: true }); + } +}); + +test("anchored removal never descends into a directory swapped for a symlink", async () => { + const root = process.cwd(); + const outputRoot = resolve(".factory/state/cp06-correction/DUMMY-anchored-removal"); + const external = await mkdtemp(join(tmpdir(), "factory-cp06-DUMMY-removal-external-")); + const sentinel = join(external, "DUMMY-sentinel"); + await writeFile(sentinel, "DUMMY-SENTINEL-UNCHANGED"); + const output = openProbeOutput({ root, outputRoot, create: true, runner: true }); + try { + await mkdir(join(outputRoot, "DUMMY-tree", "home", "credential"), { recursive: true }); + await writeFile(join(outputRoot, "DUMMY-tree", "home", "credential", "DUMMY-auth.json"), "x"); + await symlink(external, join(outputRoot, "DUMMY-tree", "home", "DUMMY-link")); + assert.throws(() => + removeAnchoredEntry(output.anchor, "DUMMY-tree", { + beforeDescend(path) { + if (!path.endsWith("/credential")) return; + renameSync(path, `${path}-moved`); + symlinkSync(external, path); + }, + }), + ); + assert.deepEqual(await readdir(external), ["DUMMY-sentinel"]); + assert.equal(await readFile(sentinel, "utf8"), "DUMMY-SENTINEL-UNCHANGED"); + + await rm(join(outputRoot, "DUMMY-tree", "home", "credential")); + await mkdir(join(outputRoot, "DUMMY-tree", "home", "credential")); + const substitute = join(outputRoot, "DUMMY-substitute"); + await mkdir(substitute); + await writeFile(join(substitute, "DUMMY-substitute-sentinel"), "DUMMY-SUBSTITUTE-UNCHANGED"); + assert.throws( + () => + removeAnchoredEntry(output.anchor, "DUMMY-tree", { + beforeDescend(path) { + if (!path.endsWith("/credential")) return; + renameSync(path, `${path}-replaced`); + renameSync(substitute, path); + }, + }), + /identity changed during removal/, + ); + assert.equal( + await readFile( + join(outputRoot, "DUMMY-tree", "home", "credential", "DUMMY-substitute-sentinel"), + "utf8", + ), + "DUMMY-SUBSTITUTE-UNCHANGED", + ); + + removeAnchoredEntry(output.anchor, "DUMMY-tree"); + await assert.rejects(readdir(join(outputRoot, "DUMMY-tree")), { code: "ENOENT" }); + assert.deepEqual(await readdir(external), ["DUMMY-sentinel"]); + assert.equal(await readFile(sentinel, "utf8"), "DUMMY-SENTINEL-UNCHANGED"); + removeAnchoredEntry(output.anchor, "DUMMY-absent"); + } finally { + output.close(); + await rm(outputRoot, { recursive: true, force: true }); + await rm(external, { recursive: true, force: true }); + } +}); diff --git a/test/cp06-readonly-credentials.test.mjs b/test/cp06-readonly-credentials.test.mjs new file mode 100644 index 0000000..b69059e --- /dev/null +++ b/test/cp06-readonly-credentials.test.mjs @@ -0,0 +1,126 @@ +import { strict as assert } from "node:assert"; +import { mkdtemp, readFile, rm, symlink, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import test from "node:test"; +import { + Cp06AuthBlockedError, + Cp06ReadOnlyCredentialStore, +} from "../scripts/cp06-readonly-credentials.mjs"; + +const providerId = "openai-codex"; + +async function withCredential(credential, action) { + const root = await mkdtemp(join(tmpdir(), "factory-cp06-credential-store-")); + const path = join(root, "DUMMY-auth.json"); + await writeFile( + path, + `${JSON.stringify({ + [providerId]: credential, + "ignored-provider": { type: "api_key", key: "DUMMY-IGNORED" }, + })}\n`, + { mode: 0o600 }, + ); + try { + return await action({ path, root }); + } finally { + await rm(root, { recursive: true, force: true }); + } +} + +function oauth(expires) { + return { + type: "oauth", + access: "DUMMY-ACCESS", + refresh: "DUMMY-REFRESH", + expires, + }; +} + +for (const [name, expires] of [ + ["expired", 0], + ["near-expiry", 1_060_000], +]) { + test(`${name} OAuth is rejected before refresh callback and persistence`, async () => { + await withCredential(oauth(expires), async ({ path }) => { + const before = await readFile(path); + const store = await Cp06ReadOnlyCredentialStore.load({ path, providerId }); + assert.throws( + () => store.assertUsableOAuth({ now: 1_000_000, minValidityMs: 300_000 }), + Cp06AuthBlockedError, + ); + let callbackCalls = 0; + await assert.rejects( + store.modify(providerId, async () => { + callbackCalls += 1; + return oauth(9_999_999); + }), + Cp06AuthBlockedError, + ); + assert.equal(callbackCalls, 0); + assert.deepEqual(await readFile(path), before); + assert.deepEqual(store.audit(), { + reads: 0, + lists: 0, + modify_denials: 1, + delete_denials: 0, + }); + }); + }); +} + +test("unexpired OAuth resolves from a clone without mutation", async () => { + await withCredential(oauth(2_000_000), async ({ path }) => { + const before = await readFile(path); + const store = await Cp06ReadOnlyCredentialStore.load({ path, providerId }); + const info = store.assertUsableOAuth({ now: 1_000_000, minValidityMs: 300_000 }); + assert.equal(info.remainingValidityMs, 1_000_000); + const credential = await store.read(providerId); + credential.access = "DUMMY-MUTATED-CLONE"; + assert.equal((await store.read(providerId)).access, "DUMMY-ACCESS"); + assert.equal(await store.read("ignored-provider"), undefined); + assert.deepEqual(await store.list(), [{ providerId, type: "oauth" }]); + assert.deepEqual(await readFile(path), before); + }); +}); + +test("delete is denied without persistence", async () => { + await withCredential(oauth(2_000_000), async ({ path }) => { + const before = await readFile(path); + const store = await Cp06ReadOnlyCredentialStore.load({ path, providerId }); + await assert.rejects(store.delete(providerId), Cp06AuthBlockedError); + assert.deepEqual(await readFile(path), before); + assert.equal(store.audit().delete_denials, 1); + }); +}); + +test("credential store rejects API keys, malformed OAuth, symlinks, and oversized files", async () => { + await withCredential({ type: "api_key", key: "DUMMY" }, async ({ path, root }) => { + await assert.rejects( + Cp06ReadOnlyCredentialStore.load({ path, providerId }), + Cp06AuthBlockedError, + ); + const link = join(root, "DUMMY-link.json"); + await symlink(path, link); + await assert.rejects( + Cp06ReadOnlyCredentialStore.load({ path: link, providerId }), + Cp06AuthBlockedError, + ); + const large = join(root, "DUMMY-large.json"); + await writeFile(large, "x".repeat(1025)); + await assert.rejects( + Cp06ReadOnlyCredentialStore.load({ path: large, providerId, maxBytes: 1024 }), + Cp06AuthBlockedError, + ); + }); + + await withCredential( + { type: "oauth", access: "DUMMY", refresh: "DUMMY", expires: "later" }, + async ({ path }) => { + await assert.rejects( + Cp06ReadOnlyCredentialStore.load({ path, providerId }), + Cp06AuthBlockedError, + ); + }, + ); +}); diff --git a/test/cp06-review-boundaries.test.mjs b/test/cp06-review-boundaries.test.mjs new file mode 100644 index 0000000..48391e1 --- /dev/null +++ b/test/cp06-review-boundaries.test.mjs @@ -0,0 +1,506 @@ +import { strict as assert } from "node:assert"; +import { spawnSync } from "node:child_process"; +import { chmodSync, closeSync, constants, openSync, readdirSync, writeFileSync } from "node:fs"; +import { + mkdir, + mkdtemp, + readdir, + readFile, + rename, + rm, + symlink, + writeFile, +} from "node:fs/promises"; +import { join, resolve } from "node:path"; +import test from "node:test"; +import { + assertHardenedProof, + assertUnsafeControl, + runIsolationProbe, +} from "../scripts/cp06-auth-security.mjs"; +import { + createProbeFixture, + openProbeOutput, + openAnchoredDirectory, + readArtifactFile, + writeArtifactEntry, + writeArtifactFile, +} from "../scripts/cp06-probe-fixture.mjs"; +import { + superviseCredentialChild, + supervisorFailureRecord, +} from "../scripts/cp06-namespace-supervisor.mjs"; +import { dummyWorkerContext, dummyWorkerEvidence } from "../scripts/cp06-dummy-worker-evidence.mjs"; +import { withCompiledHelper } from "./fixtures/cp06-compiled-helper.mjs"; +import { openPinnedDirectory } from "../scripts/cp06-guarded-read.mjs"; + +const state = resolve(".factory/state/cp06-correction"); + +test("real hardened probe executes retained read-only descriptors and inherited DUMMY denials", async () => { + await withCompiledHelper("review-kernel", async ({ binaries, helperFd }) => { + const outputRoot = join(state, "DUMMY-review-kernel", "auth-security"); + const options = { root: process.cwd(), outputRoot, binaries }; + try { + assertUnsafeControl(await runIsolationProbe({ ...options, mode: "unsafe-probe" })); + const proof = await runIsolationProbe({ ...options, mode: "hardened-probe" }); + assertHardenedProof(proof); + const missing = structuredClone(proof); + missing.child.direct_namespace_syscalls.stdout = + missing.child.direct_namespace_syscalls.stdout + .split("\n") + .filter((line) => !line.startsWith("inherited_ptrace_attach=")) + .join("\n"); + assert.throws(() => assertHardenedProof(missing)); + const probeFd = openSync(binaries.syscallProbe, constants.O_RDONLY | constants.O_CLOEXEC); + try { + const closed = spawnSync( + "unshare", + [ + "--user", + "--map-root-user", + "--mount", + "--propagation", + "private", + "--", + "setpriv", + "--no-new-privs", + "--bounding-set=-all", + "--inh-caps=-all", + "--ambient-caps=-all", + "--securebits=+noroot,+noroot_locked,+no_setuid_fixup,+no_setuid_fixup_locked", + "--", + "/proc/self/fd/3", + "/proc/self/fd/4", + "/DUMMY-no-mount", + ], + { + stdio: ["ignore", "pipe", "pipe", helperFd, probeFd], + encoding: "utf8", + timeout: 20_000, + }, + ); + assert.equal(closed.status, 70); + assert.equal(closed.stdout, ""); + } finally { + closeSync(probeFd); + } + } finally { + await rm(join(state, "DUMMY-review-kernel"), { recursive: true, force: true }); + } + }); +}); + +test("full semantic contracts reject forged nested evidence inside independently awaited cleanup", async () => { + const context = dummyWorkerContext(); + const good = dummyWorkerEvidence(context); + const variants = [ + { ...good, response: { status: "PASS", evidence_gaps: [] } }, + { ...good, response: { ...good.response, evidence_gaps: [] } }, + { ...good, event_stream: { ...good.event_stream, extra: "DUMMY-SECRET" } }, + { ...good, event_stream: { ...good.event_stream, event_count: 2 } }, + { + ...good, + event_stream: { + ...good.event_stream, + read_audit: { + ...good.event_stream.read_audit, + expected_sha256: "a".repeat(64), + returned_sha256: "a".repeat(64), + returned_bytes: 1, + }, + }, + }, + { ...good, pi_install: { ...good.pi_install, extra: "DUMMY-SECRET" } }, + { + ...good, + pi_install: { + ...good.pi_install, + package_root: "/usr/lib/node_modules/@earendil-works/pi-coding-agent", + }, + }, + { ...good, pi_install: { ...good.pi_install, sdk_entry_sha256: "a".repeat(64) } }, + { + ...good, + pi_install: { + ...good.pi_install, + package_artifact: { + ...good.pi_install.package_artifact, + tarball_integrity: "sha512-DUMMY-SECRET", + }, + }, + }, + { + ...good, + pi_install: { + ...good.pi_install, + dependency: { + ...good.pi_install.dependency, + root: "/usr/lib/node_modules/@earendil-works/pi-ai", + }, + }, + }, + { + ...good, + pi_install: { + ...good.pi_install, + dependency: { ...good.pi_install.dependency, tarball_integrity: "sha512-DUMMY-SECRET" }, + }, + }, + ]; + for (const child of [good, ...variants]) { + for (const cleanupFails of [false, true]) { + let cleaned = 0; + let caught; + try { + await superviseCredentialChild( + { + mode: "worker", + source: "/DUMMY-source", + target: "/DUMMY-target", + helper: "/DUMMY-helper", + syscallProbe: "/DUMMY-probe", + }, + { + workerContext: context, + mount: async () => ({ + mountIds: { source: "1", target: "2" }, + parentMounts: ["DUMMY"], + verifyIntegrity: async () => true, + cleanup: async () => { + await new Promise((done) => setTimeout(done, 1)); + cleaned++; + if (cleanupFails) throw new Error("DUMMY-SECRET"); + }, + }), + spawn: () => ({ status: 0, stdout: JSON.stringify(child) }), + }, + ); + } catch (error) { + caught = error; + } + assert.equal(cleaned, 1); + if (child === good && !cleanupFails) assert.equal(caught, undefined); + else { + const record = supervisorFailureRecord(caught); + assert.equal(JSON.stringify(record).includes("DUMMY-SECRET"), false); + const codes = record.causes.length + ? record.causes.map((cause) => cause.code) + : [record.code]; + assert.deepEqual(codes, [ + ...(child === good ? [] : ["CP06_CHILD_OUTPUT_INVALID"]), + ...(cleanupFails ? ["CP06_CREDENTIAL_CLEANUP_FAILED"] : []), + ]); + } + } + } +}); + +test("worker provenance without an independently trusted expectation is rejected", async () => { + const { provenance, ...untrusted } = dummyWorkerContext(); + assert.ok(provenance); + let cleaned = 0; + await assert.rejects( + superviseCredentialChild( + { + mode: "worker", + source: "/DUMMY-source", + target: "/DUMMY-target", + helper: "/DUMMY-helper", + syscallProbe: "/DUMMY-probe", + }, + { + workerContext: untrusted, + mount: async () => ({ + mountIds: { source: "1", target: "2" }, + parentMounts: ["DUMMY"], + verifyIntegrity: async () => true, + cleanup: async () => { + cleaned++; + }, + }), + spawn: () => ({ + status: 0, + stdout: JSON.stringify(dummyWorkerEvidence(dummyWorkerContext())), + }), + }, + ), + { code: "CP06_CHILD_OUTPUT_INVALID" }, + ); + assert.equal(cleaned, 1); +}); + +test("artifact replacement and reads are bound to the prepared inode", async () => { + const outputRoot = join(state, `DUMMY-review-identity-${process.pid}`); + const output = openProbeOutput({ root: process.cwd(), outputRoot, create: true, runner: true }); + const child = openAnchoredDirectory(output.anchor, "raw"); + try { + const path = join(child.anchor, "DUMMY-task.yaml"); + const prepared = writeArtifactEntry(path, "DUMMY prepared"); + assert.equal(readArtifactFile(path, "utf8", { expected: prepared.identity }), "DUMMY prepared"); + assert.equal( + writeArtifactEntry(path, "DUMMY replaced", { replace: true, expected: prepared.identity }) + .identity.ino, + prepared.identity.ino, + ); + assert.throws( + () => writeArtifactFile(path, "DUMMY unbound", { replace: true }), + /identity changed/, + ); + await writeFile(join(outputRoot, "raw", "DUMMY-substitute"), "DUMMY SUBSTITUTE UNCHANGED"); + await rename( + join(outputRoot, "raw", "DUMMY-substitute"), + join(outputRoot, "raw", "DUMMY-task.yaml"), + ); + assert.throws( + () => writeArtifactFile(path, "DUMMY write", { replace: true, expected: prepared.identity }), + /identity changed/, + ); + assert.throws( + () => readArtifactFile(path, "utf8", { expected: prepared.identity }), + /identity changed/, + ); + assert.equal( + await readFile(join(outputRoot, "raw", "DUMMY-task.yaml"), "utf8"), + "DUMMY SUBSTITUTE UNCHANGED", + ); + } finally { + child.close(); + output.close(); + await rm(outputRoot, { recursive: true, force: true }); + } +}); + +test("ten-pack refuses a prepared contract replaced by another regular inode", async () => { + await mkdir(state, { recursive: true }); + const directoryRoot = await mkdtemp(join(state, "DUMMY-runner-")); + const contract = join(directoryRoot, "ten-pack/repo/.factory/tasks/AUTH-001.yaml"); + const hook = join(directoryRoot, "DUMMY-hook.mjs"); + await writeFile( + hook, + `import fs from 'node:fs'; import {syncBuiltinESMExports} from 'node:module'; const real = fs.openSync; let done = false; fs.openSync = (path, ...rest) => { if (!done && String(path).endsWith('/AUTH-before.yaml')) { done = true; fs.writeFileSync(${JSON.stringify(`${contract}.DUMMY`)}, 'DUMMY SUBSTITUTE UNCHANGED'); fs.renameSync(${JSON.stringify(`${contract}.DUMMY`)}, ${JSON.stringify(contract)}); } return real(path, ...rest); }; syncBuiltinESMExports();`, + ); + try { + const result = spawnSync( + process.execPath, + ["--import", hook, resolve("scripts/replay-cp06-ten-pack.mjs")], + { env: { ...process.env, CP06_OUTPUT: directoryRoot }, encoding: "utf8", timeout: 30_000 }, + ); + assert.notEqual(result.status, 0); + assert.match(result.stderr, /DUMMY proof artifact identity changed/); + assert.equal(await readFile(contract, "utf8"), "DUMMY SUBSTITUTE UNCHANGED"); + } finally { + await rm(directoryRoot, { recursive: true, force: true }); + } +}); + +test("DUMMY fixture cleanup closes every descriptor and removes through its anchor despite failures", async () => { + const outputRoot = resolve(state, `DUMMY-fixture-${process.pid}`, "auth-security"); + const openDescriptors = () => readdirSync("/proc/self/fd").length; + try { + for (const scenario of ["alias-umount-fails", "setup-and-umount-fail", "setup-fails"]) { + const before = openDescriptors(); + const umounts = []; + const spawn = (command, args) => { + const path = args.at(-1); + if (command === "mount") { + if (scenario !== "alias-umount-fails") writeFileSync(join(path, "source"), "DUMMY"); + } else if (command === "umount") { + umounts.push(path.split("/").at(-1)); + if ( + scenario === "alias-umount-fails" + ? path.endsWith("/DUMMY-alias") + : scenario === "setup-and-umount-fail" + ) + return { status: 32 }; + } + return { status: 0 }; + }; + let caught; + try { + const fixture = await createProbeFixture({ root: process.cwd(), outputRoot, spawn }); + await fixture.cleanup(); + } catch (error) { + caught = error; + } + assert.equal(openDescriptors(), before, scenario); + assert.deepEqual( + umounts.map((name) => name.replace(/^DUMMY-probe-.*/u, "DUMMY-probe")), + scenario === "alias-umount-fails" ? ["DUMMY-alias", "DUMMY-probe"] : ["DUMMY-probe"], + ); + const remaining = (await readdir(outputRoot)).filter((name) => + name.startsWith("DUMMY-probe-"), + ); + if (scenario === "alias-umount-fails") { + assert.ok(caught instanceof AggregateError); + assert.deepEqual( + caught.errors.map((error) => error.message), + ["private DUMMY fixture mount unavailable"], + ); + assert.deepEqual(remaining, []); + } else if (scenario === "setup-and-umount-fail") { + assert.ok(caught instanceof AggregateError); + assert.deepEqual( + caught.errors.map((error) => error.code ?? error.message), + ["EEXIST", "private DUMMY fixture mount unavailable"], + ); + assert.equal(remaining.length, 1); + await rm(join(outputRoot, remaining[0]), { recursive: true }); + } else { + assert.equal(caught?.code, "EEXIST"); + assert.deepEqual(remaining, []); + } + } + } finally { + await rm(resolve(state, `DUMMY-fixture-${process.pid}`), { recursive: true, force: true }); + } +}); + +test("supervisor CLI serializes fixture setup and independent fixture cleanup causes", async () => { + const locate = (command) => + spawnSync("sh", ["-c", `command -v ${command}`], { encoding: "utf8" }).stdout.trim(); + const [realMount, realUmount] = [locate("mount"), locate("umount")]; + const secret = "DUMMY-SECRET-FIXTURE-MARKER"; + const base = resolve(state, `DUMMY-fixture-cli-${process.pid}`); + const shims = join(base, "shims"); + const outputRoot = join(base, "auth-security"); + const setup = { + stage: "setup", + code: "CP06_ISOLATION_SETUP_FAILED", + exit_code: 73, + description: "credential namespace setup failed", + }; + const cleanup = { + stage: "cleanup", + code: "CP06_CREDENTIAL_CLEANUP_FAILED", + exit_code: 74, + description: "credential mount cleanup failed", + }; + try { + for (const umountFails of [true, false]) { + await rm(base, { recursive: true, force: true }); + await mkdir(shims, { recursive: true }); + for (const [name, body] of [ + [ + "mount", + `"${realMount}" "$@" || exit $?\ncase "$last" in */DUMMY-probe-*) : > "$last/source" ;; esac`, + ], + [ + "umount", + `case "$last" in */DUMMY-probe-*) ${umountFails ? `printf '%s' '${secret}' >&2; exit 32` : ":"} ;; esac\nexec "${realUmount}" "$@"`, + ], + ]) { + writeFileSync( + join(shims, name), + `#!/bin/sh\nfor argument do last="$argument"; done\n${body}\n`, + ); + chmodSync(join(shims, name), 0o700); + } + const result = spawnSync( + "unshare", + [ + "--user", + "--map-root-user", + "--mount", + "--propagation", + "private", + "--", + process.execPath, + "--experimental-import-meta-resolve", + resolve("scripts/cp06-namespace-supervisor.mjs"), + "hardened-probe", + outputRoot, + "-", + "/DUMMY-helper", + "/DUMMY-probe", + ], + { + encoding: "utf8", + timeout: 30_000, + env: { ...process.env, PATH: `${shims}:${process.env.PATH}` }, + }, + ); + const record = umountFails + ? { + schema_version: 1, + stage: "cleanup", + code: "CP06_CLEANUP_FAILED", + exit_code: 74, + description: "credential namespace cleanup failed", + causes: [setup, cleanup], + } + : { schema_version: 1, ...setup, causes: [] }; + assert.equal(result.status, record.exit_code, result.stderr); + assert.equal(result.stdout, ""); + assert.equal(result.stderr, `${JSON.stringify(record)}\n`); + assert.equal(`${result.stdout}${result.stderr}`.includes(secret), false); + const remaining = (await readdir(outputRoot)).filter((name) => + name.startsWith("DUMMY-probe-"), + ); + assert.equal(remaining.length, umountFails ? 1 : 0); + } + } finally { + await rm(base, { recursive: true, force: true }); + } +}); + +test("artifact writes, replacements, copies and reads reject redirected leaves and intermediate directories", async () => { + const outputRoot = join(state, `DUMMY-review-artifacts-${process.pid}`); + const output = openProbeOutput({ root: process.cwd(), outputRoot, create: true, runner: true }); + const child = openAnchoredDirectory(output.anchor, "raw"); + const sentinel = join(outputRoot, "DUMMY-sentinel"); + await writeFile(sentinel, "DUMMY SENTINEL UNCHANGED"); + try { + for (const name of ["evidence.json", "DUMMY-counters.json", "raw-copy.txt"]) { + const path = join(child.anchor, name); + await symlink(sentinel, join(outputRoot, "raw", name)); + assert.throws(() => writeArtifactFile(path, "DUMMY write")); + assert.throws(() => writeArtifactFile(path, "DUMMY replace", { replace: true })); + assert.throws(() => readArtifactFile(path)); + assert.equal(await readFile(sentinel, "utf8"), "DUMMY SENTINEL UNCHANGED"); + } + await symlink(outputRoot, join(outputRoot, "raw", "redirected")); + assert.throws(() => + writeArtifactFile(join(child.anchor, "redirected", "DUMMY-sentinel"), "DUMMY write", { + replace: true, + }), + ); + assert.throws(() => openPinnedDirectory(join(outputRoot, "raw", "redirected", "raw"))); + const source = join(child.anchor, "DUMMY-original.txt"); + writeArtifactFile(source, "DUMMY bytes"); + writeArtifactFile(join(child.anchor, "DUMMY-copy.txt"), readArtifactFile(source)); + assert.equal(readArtifactFile(join(child.anchor, "DUMMY-copy.txt"), "utf8"), "DUMMY bytes"); + } finally { + child.close(); + output.close(); + await rm(outputRoot, { recursive: true, force: true }); + } +}); + +for (const [runner, directory, rawName] of [ + ["replay-cp06-ten-pack.mjs", "ten-pack", "git-init.txt"], + ["replay-cp06-p07.mjs", "p07", "build.txt"], +]) { + test(`${runner} refuses a substituted raw artifact at its executable boundary`, async () => { + await mkdir(state, { recursive: true }); + const directoryRoot = await mkdtemp(join(state, "DUMMY-runner-")); + const sentinel = join(directoryRoot, "DUMMY-sentinel"); + const hook = join(directoryRoot, "DUMMY-hook.mjs"); + await writeFile(sentinel, "DUMMY SENTINEL UNCHANGED"); + await writeFile( + hook, + `import child from 'node:child_process'; import {symlinkSync} from 'node:fs'; import {syncBuiltinESMExports} from 'node:module'; const real = child.spawnSync; child.spawnSync = (command,args,options) => { if (${JSON.stringify(directory)} === 'ten-pack' ? command === 'git' && args[0] === 'init' : command === 'npm') { symlinkSync(${JSON.stringify(sentinel)}, ${JSON.stringify(join(directoryRoot, directory, "raw", rawName))}); return {status:0, stdout:'DUMMY', stderr:''}; } return real(command,args,options); }; syncBuiltinESMExports();`, + ); + try { + const result = spawnSync(process.execPath, ["--import", hook, resolve("scripts", runner)], { + env: { ...process.env, CP06_OUTPUT: directoryRoot }, + encoding: "utf8", + timeout: 30_000, + }); + assert.notEqual(result.status, 0); + assert.match(result.stderr, /EEXIST/); + assert.equal(await readFile(sentinel, "utf8"), "DUMMY SENTINEL UNCHANGED"); + } finally { + await rm(directoryRoot, { recursive: true, force: true }); + } + }); +} diff --git a/test/cp06-selected-boundaries.test.mjs b/test/cp06-selected-boundaries.test.mjs new file mode 100644 index 0000000..6abefa8 --- /dev/null +++ b/test/cp06-selected-boundaries.test.mjs @@ -0,0 +1,158 @@ +import { strict as assert } from "node:assert"; +import { spawnSync } from "node:child_process"; +import { readlinkSync } from "node:fs"; +import { + mkdir, + mkdtemp, + readFile, + readdir, + rename, + rm, + symlink, + writeFile, +} from "node:fs/promises"; +import { join, resolve } from "node:path"; +import { pathToFileURL } from "node:url"; +import test from "node:test"; +import { compileIsolationHelpers } from "../scripts/cp06-auth-security.mjs"; +import { openAnchoredDirectory, openProbeOutput } from "../scripts/cp06-probe-fixture.mjs"; + +async function dummyOutput() { + const base = resolve(".factory/state/cp06-correction"); + await mkdir(base, { recursive: true }); + return mkdtemp(join(base, "DUMMY-selected-boundaries-")); +} + +test("compiled helpers execute and hash through retained read-only inode descriptors", async () => { + const directory = await dummyOutput(); + const outputRoot = join(directory, "auth-security"); + const output = openProbeOutput({ root: process.cwd(), outputRoot, create: true }); + let binaries; + try { + binaries = compileIsolationHelpers({ + root: process.cwd(), + outputRoot, + anchoredOutputRoot: output.anchor, + }); + for (const descriptorPath of [binaries.helper, binaries.syscallProbe]) { + const original = await readFile(descriptorPath); + const leaf = readlinkSync(descriptorPath); + await rename(leaf, `${leaf}.original`); + await writeFile(leaf, "DUMMY replacement must not execute"); + assert.deepEqual(await readFile(descriptorPath), original); + } + const execution = spawnSync( + binaries.helper, + [process.execPath, "-e", 'process.stdout.write("DUMMY executable control")'], + { encoding: "utf8", timeout: 15_000 }, + ); + assert.equal(execution.error, undefined); + assert.equal(execution.status, 0, execution.stderr); + assert.equal(execution.stdout, "DUMMY executable control"); + const probe = spawnSync(binaries.syscallProbe, [], { encoding: "utf8", timeout: 15_000 }); + assert.equal(probe.error, undefined); + assert.equal(probe.status, 64); + binaries.close(); + binaries.close(); + await assert.rejects(readFile(binaries.helper)); + } finally { + binaries?.close(); + output.close(); + await rm(directory, { recursive: true, force: true }); + } +}); + +test("importing worker serializers preserves evidence and creates no output", async () => { + const directory = await dummyOutput(); + const workerRoot = join(directory, "worker"); + await mkdir(workerRoot); + const records = { + "evidence.json": '{"result":"DUMMY evidence"}\n', + "blocked.json": '{"result":"DUMMY blocked"}\n', + }; + for (const [name, bytes] of Object.entries(records)) { + await writeFile(join(workerRoot, name), bytes); + } + const moduleUrl = pathToFileURL(resolve("scripts/replay-cp06-worker.mjs")).href; + try { + for (const output of [directory, join(directory, "uncreated")]) { + const result = spawnSync( + process.execPath, + [ + "--input-type=module", + "-e", + `const {createWorkerBlockedStatus}=await import(${JSON.stringify(moduleUrl)});const result=createWorkerBlockedStatus(new Error("DUMMY"),"audit","DUMMY date");if(result.result!=="blocked")process.exit(1);`, + ], + { + cwd: process.cwd(), + encoding: "utf8", + timeout: 15_000, + env: { ...process.env, CP06_OUTPUT: output }, + }, + ); + assert.equal(result.status, 0, result.stderr); + assert.equal(result.stdout, ""); + assert.equal(result.stderr, ""); + assert.deepEqual((await readdir(workerRoot)).sort(), Object.keys(records).sort()); + for (const [name, bytes] of Object.entries(records)) { + assert.equal(await readFile(join(workerRoot, name), "utf8"), bytes); + } + } + await assert.rejects(readdir(join(directory, "uncreated")), { code: "ENOENT" }); + } finally { + await rm(directory, { recursive: true, force: true }); + } +}); + +test("nested directory reset preserves sibling proofs and rejects substituted parents", async () => { + const directory = await dummyOutput(); + const output = openProbeOutput({ + root: process.cwd(), + outputRoot: directory, + create: true, + runner: true, + }); + const scenarios = ["expired", "near-expiry", "missing-source-setup"]; + try { + for (const scenario of scenarios) { + const child = openAnchoredDirectory(output.anchor, `proofs/${scenario}`, { reset: true }); + try { + await writeFile(join(child.anchor, "evidence.json"), `DUMMY ${scenario}`); + } finally { + child.close(); + } + } + await writeFile(join(directory, "proofs/near-expiry/stale.json"), "DUMMY stale"); + const reset = openAnchoredDirectory(output.anchor, "proofs/near-expiry", { reset: true }); + try { + assert.deepEqual(await readdir(reset.anchor), []); + await writeFile(join(reset.anchor, "evidence.json"), "DUMMY near-expiry"); + } finally { + reset.close(); + } + for (const scenario of scenarios) { + assert.equal( + await readFile(join(directory, "proofs", scenario, "evidence.json"), "utf8"), + `DUMMY ${scenario}`, + ); + } + await rename(join(directory, "proofs"), join(directory, "proofs-original")); + await mkdir(join(directory, "DUMMY-sentinel/expired"), { recursive: true }); + await writeFile(join(directory, "DUMMY-sentinel/expired/evidence.json"), "DUMMY unchanged"); + await symlink(join(directory, "DUMMY-sentinel"), join(directory, "proofs")); + assert.throws(() => openAnchoredDirectory(output.anchor, "proofs/expired", { reset: true })); + assert.equal( + await readFile(join(directory, "DUMMY-sentinel/expired/evidence.json"), "utf8"), + "DUMMY unchanged", + ); + for (const scenario of scenarios) { + assert.equal( + await readFile(join(directory, "proofs-original", scenario, "evidence.json"), "utf8"), + `DUMMY ${scenario}`, + ); + } + } finally { + output.close(); + await rm(directory, { recursive: true, force: true }); + } +}); diff --git a/test/cp06-worker-audit.test.mjs b/test/cp06-worker-audit.test.mjs new file mode 100644 index 0000000..eebfd0f --- /dev/null +++ b/test/cp06-worker-audit.test.mjs @@ -0,0 +1,261 @@ +import { strict as assert } from "node:assert"; +import { readFile } from "node:fs/promises"; +import { resolve } from "node:path"; +import test from "node:test"; +import { + AUDIT_REJECTION_REASONS, + assistantResponseText, + auditReadEvents, + isExpectedOriginalReference, + sanitizedAuditReason, +} from "../scripts/cp06-worker-audit.mjs"; + +const root = process.cwd(); +const originalPath = resolve(root, "test/fixtures/cp06-context/docs/AUTH.md"); +const original = await readFile(originalPath); +const options = { + root, + expectedOriginalPath: originalPath, + expectedOriginal: original, + decisiveText: ["request-supplied owner fields are ignored"], +}; +const start = { + type: "tool_execution_start", + toolName: "read", + toolCallId: "read-auth", + args: { path: originalPath }, +}; +const finalMessage = { + type: "message_end", + message: { role: "assistant", content: [{ type: "text", text: "{}" }] }, +}; + +function successfulEnd(overrides = {}) { + return { + type: "tool_execution_end", + toolName: "read", + toolCallId: "read-auth", + isError: false, + result: { content: [{ type: "text", text: original.toString("utf8") }] }, + ...overrides, + }; +} + +for (const [name, events] of [ + ["omitted completion", [start, finalMessage]], + [ + "stub failed read completion", + [ + start, + successfulEnd({ isError: true, result: { content: [{ type: "text", text: "ENOENT" }] } }), + finalMessage, + ], + ], + [ + "partial read", + [ + { ...start, args: { path: originalPath, offset: 1, limit: 1 } }, + successfulEnd({ + result: { content: [{ type: "text", text: "# Authentication and tenancy" }] }, + }), + finalMessage, + ], + ], + [ + "explicit first-line offset with complete bytes", + [{ ...start, args: { path: originalPath, offset: 1 } }, successfulEnd(), finalMessage], + ], + [ + "explicit limit with complete bytes", + [ + { ...start, args: { path: originalPath, limit: Number.MAX_SAFE_INTEGER } }, + successfulEnd(), + finalMessage, + ], + ], + [ + "wrong starting range", + [ + { ...start, args: { path: originalPath, offset: 2 } }, + successfulEnd({ + result: { + content: [ + { + type: "text", + text: original.toString("utf8").split("\n").slice(1).join("\n"), + }, + ], + }, + }), + finalMessage, + ], + ], + [ + "mismatched completion id", + [start, successfulEnd({ toolCallId: "different-call" }), finalMessage], + ], + ["completion before start", [successfulEnd(), start, finalMessage]], + ["response before completion", [start, finalMessage, successfulEnd()]], + [ + "orphan non-read completion", + [ + start, + successfulEnd(), + { + type: "tool_execution_end", + toolName: "bash", + toolCallId: "orphan-bash", + isError: false, + result: { content: [{ type: "text", text: "unexpected" }] }, + }, + finalMessage, + ], + ], +]) { + test(`worker read audit rejects ${name}`, () => { + assert.equal(auditReadEvents(events, options).ok, false); + }); +} + +test("worker read audit rejects another project read", () => { + const events = [ + start, + successfulEnd(), + { + type: "tool_execution_start", + toolName: "read", + toolCallId: "read-extra", + args: { path: resolve(root, "PROJECT.md") }, + }, + { + type: "tool_execution_end", + toolName: "read", + toolCallId: "read-extra", + isError: false, + result: { content: [{ type: "text", text: "extra" }] }, + }, + finalMessage, + ]; + assert.equal(auditReadEvents(events, options).ok, false); +}); + +test("worker missing-source identity accepts only the canonical original path", () => { + const fixtureRoot = resolve(root, "test/fixtures/cp06-context"); + assert.equal( + isExpectedOriginalReference("docs/AUTH.md", { + root, + referenceRoot: fixtureRoot, + expectedOriginalPath: originalPath, + }), + true, + ); + assert.equal( + isExpectedOriginalReference("./docs/AUTH.md", { + root, + referenceRoot: fixtureRoot, + expectedOriginalPath: originalPath, + }), + false, + ); + assert.equal( + isExpectedOriginalReference(originalPath, { + root, + referenceRoot: fixtureRoot, + expectedOriginalPath: originalPath, + }), + false, + ); + assert.equal( + isExpectedOriginalReference("docs/OTHER.md", { + root, + referenceRoot: fixtureRoot, + expectedOriginalPath: originalPath, + }), + false, + ); + assert.equal( + isExpectedOriginalReference("unrelated but nonempty", { + root, + referenceRoot: fixtureRoot, + expectedOriginalPath: originalPath, + }), + false, + ); +}); + +test("worker read audit accepts one completed exact-original read without offset or limit", () => { + const audit = auditReadEvents([start, successfulEnd(), finalMessage], options); + assert.equal(audit.ok, true); + assert.equal(audit.summary.completed_successfully, true); + assert.equal(audit.summary.exact_original, true); + assert.equal(audit.summary.expected_sha256, audit.summary.returned_sha256); +}); + +for (const [name, args] of [ + ["the SDK first-line range", { offset: 1, limit: 2_000 }], + ["an explicit offset", { offset: 1 }], + ["an explicit limit", { limit: Number.MAX_SAFE_INTEGER }], +]) { + test(`worker read audit rejects ${name} even when it returns the complete original`, () => { + const audit = auditReadEvents( + [{ ...start, args: { path: originalPath, ...args } }, successfulEnd(), finalMessage], + options, + ); + assert.equal(audit.ok, false); + assert.equal(audit.reason, AUDIT_REJECTION_REASONS.range); + }); +} + +test("worker read audit reasons never carry event-controlled names or IDs", () => { + const secretTool = { + type: "tool_execution_start", + toolName: "DUMMY-SECRET-TOOL", + toolCallId: "DUMMY-SECRET-ID", + args: {}, + }; + const unmatched = successfulEnd({ toolCallId: "DUMMY-SECRET-ID" }); + for (const events of [ + [start, successfulEnd(), secretTool, finalMessage], + [{ ...start, toolCallId: "DUMMY-SECRET-ID" }, successfulEnd(), finalMessage], + [start, unmatched, finalMessage], + ]) { + const audit = auditReadEvents(events, options); + assert.equal(audit.ok, false); + assert.equal(audit.reason.includes("DUMMY-SECRET"), false); + assert.equal(sanitizedAuditReason(audit.reason), audit.reason); + } + assert.equal( + sanitizedAuditReason("unexpected tool DUMMY-SECRET-TOOL"), + "SDK event audit rejected the event stream", + ); +}); + +test("assistant envelope rejects competing evidence and unsupported final channels", () => { + const toolCall = { type: "toolCall", id: "read-auth", name: "read", arguments: {} }; + const toolUse = (content) => ({ + type: "message_end", + message: { role: "assistant", stopReason: "toolUse", content }, + }); + const final = (content) => ({ + type: "message_end", + message: { role: "assistant", stopReason: "stop", content }, + }); + const accepted = assistantResponseText([ + toolUse([{ type: "thinking", thinking: "DUMMY plan" }, toolCall]), + final([{ type: "text", text: '{"status":"MISSING_SOURCE"}' }]), + ]); + assert.deepEqual(accepted, { ok: true, text: '{"status":"MISSING_SOURCE"}' }); + for (const events of [ + [], + [toolUse([{ type: "text", text: "Creation verified and passed" }, toolCall]), final("{}")], + [final([{ type: "text", text: "Creation passed" }]), final([{ type: "text", text: "{}" }])], + [toolUse([toolCall]), final([{ type: "text", text: "{}" }, toolCall])], + [toolUse([toolCall]), final([{ type: "image", data: "DUMMY", mimeType: "image/png" }])], + [ + toolUse([toolCall]), + { ...final("{}"), message: { ...final("{}").message, stopReason: "length" } }, + ], + ]) { + assert.equal(assistantResponseText(events).ok, false); + } +}); diff --git a/test/cp06-worker-lifecycle.test.mjs b/test/cp06-worker-lifecycle.test.mjs new file mode 100644 index 0000000..8c420a8 --- /dev/null +++ b/test/cp06-worker-lifecycle.test.mjs @@ -0,0 +1,920 @@ +import { strict as assert } from "node:assert"; +import { + lstat, + mkdir, + mkdtemp, + readFile, + readdir, + rm, + stat, + symlink, + writeFile, +} from "node:fs/promises"; +import { closeSync, constants, existsSync, openSync, realpathSync, writeFileSync } from "node:fs"; +import { spawnSync } from "node:child_process"; +import { resolve } from "node:path"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import test from "node:test"; +import { + superviseCredentialChild, + supervisorFailureRecord, +} from "../scripts/cp06-namespace-supervisor.mjs"; +import { namespaceWorkerError } from "../scripts/cp06-auth-security.mjs"; +import { Cp06CleanupError, withCleanup } from "../scripts/cp06-worker-lifecycle.mjs"; +import { createWorkerBlockedStatus } from "../scripts/replay-cp06-worker.mjs"; +import { + DUMMY_CLI_INSTALLED_SCENARIOS, + DUMMY_CLI_SCENARIOS, + DUMMY_CLI_SECRET, + writeDummyCliShims, +} from "../scripts/cp06-dummy-cli-shims.mjs"; +import { resolvePinnedPiProvenance } from "../scripts/cp06-pi-install.mjs"; +import { withCompiledHelper } from "./fixtures/cp06-compiled-helper.mjs"; +import { dummyWorkerContext, dummyWorkerEvidence } from "../scripts/cp06-dummy-worker-evidence.mjs"; +import { DUMMY_OUTCOME_ORIGINAL } from "../scripts/cp06-dummy-originals.mjs"; + +for (const scenario of [ + "success", + "expired", + "near-expiry", + "setup", + "setup-and-cleanup", + "missing-source", + "missing-source-and-cleanup", + "launch", + "timeout", + "audit", + "cleanup", + "primary-and-cleanup", + "audit-and-cleanup", + "semantic-audit", + "semantic-audit-and-cleanup", +]) { + test(`supervisor awaits cleanup and preserves failures: ${scenario}`, async () => { + const directory = await mkdtemp(join(tmpdir(), "factory-cp06-DUMMY-lifecycle-")); + const source = join(directory, "DUMMY-source"); + const target = join(directory, "DUMMY-target"); + await writeFile(source, "DUMMY ONLY"); + let cleanups = 0; + let launches = 0; + const setupError = new Error("DUMMY setup failed before mount acquired"); + const missingSourceError = Object.assign(new Error("DUMMY source missing"), { + code: "ENOENT", + }); + const cleanupError = new Error("DUMMY cleanup failed"); + const run = () => + superviseCredentialChild( + { + mode: "worker", + source, + target, + helper: "/DUMMY-helper", + syscallProbe: "/DUMMY-probe", + childArgs: ["/DUMMY-input"], + }, + { + workerContext: dummyWorkerContext(), + async mount() { + if (scenario === "setup") throw setupError; + if (scenario === "setup-and-cleanup") { + throw new AggregateError([setupError, cleanupError], "DUMMY setup cleanup"); + } + if (scenario === "missing-source") throw missingSourceError; + if (scenario === "missing-source-and-cleanup") { + throw new AggregateError( + [missingSourceError, cleanupError], + "DUMMY missing source cleanup", + ); + } + await writeFile(target, "DUMMY placeholder"); + return { + sourceIdentity: dummyIdentity(), + mountIds: { source: "1", target: "2" }, + parentMounts: [{ id: "3", target: directory }], + async verifyIntegrity() { + return true; + }, + async cleanup() { + await new Promise((done) => setTimeout(done, 5)); + cleanups++; + await rm(target); + if ( + [ + "cleanup", + "primary-and-cleanup", + "audit-and-cleanup", + "semantic-audit-and-cleanup", + ].includes(scenario) + ) + throw cleanupError; + }, + }; + }, + inspectSource: dummyStat, + spawn() { + launches++; + if (scenario === "launch") throw new Error("DUMMY spawn threw"); + if (scenario === "timeout") + return { + status: null, + error: Object.assign(new Error("DUMMY timeout"), { code: "ETIMEDOUT" }), + }; + if (["expired", "near-expiry", "primary-and-cleanup"].includes(scenario)) + return { status: 75, stderr: "DUMMY auth blocked" }; + return { + status: 0, + stdout: ["audit", "audit-and-cleanup"].includes(scenario) + ? "invalid JSON" + : scenario.startsWith("semantic-audit") + ? JSON.stringify({ ...validWorkerChild(), result: "fail" }) + : JSON.stringify(validWorkerChild()), + }; + }, + }, + ); + try { + if (scenario === "success") { + const result = await run(); + assert.equal(result.cleanup, "pass"); + assert.equal(result.source_unchanged, true); + } else { + await assert.rejects(run, (error) => { + if (scenario === "setup") assert.equal(error.cause, setupError); + if (scenario === "setup-and-cleanup") { + assert(error instanceof AggregateError); + assert.equal(error.code, "CP06_CLEANUP_FAILED"); + assert.deepEqual( + supervisorFailureRecord(error).causes.map(({ stage, code }) => ({ stage, code })), + [ + { stage: "setup", code: "CP06_ISOLATION_SETUP_FAILED" }, + { stage: "cleanup", code: "CP06_CREDENTIAL_CLEANUP_FAILED" }, + ], + ); + } + if (scenario === "missing-source") { + assert.equal(error.code, "CP06_CREDENTIAL_SOURCE_MISSING"); + assert.equal(error.stage, "missing-source"); + assert.equal(error.exitCode, 70); + const wrapped = namespaceWorkerError({ + status: 70, + stderr: JSON.stringify(supervisorFailureRecord(error)), + }); + const blocked = createWorkerBlockedStatus(wrapped, "isolated-sdk-worker"); + assert.equal(blocked.code, "CP06_CREDENTIAL_SOURCE_MISSING"); + assert.equal(blocked.failure_stage, "missing-source"); + } + if (scenario === "missing-source-and-cleanup") { + const record = supervisorFailureRecord(error); + assert.equal(record.code, "CP06_CLEANUP_FAILED"); + assert.deepEqual( + record.causes.map(({ stage, code }) => ({ stage, code })), + [ + { stage: "missing-source", code: "CP06_CREDENTIAL_SOURCE_MISSING" }, + { stage: "cleanup", code: "CP06_CREDENTIAL_CLEANUP_FAILED" }, + ], + ); + } + if (["expired", "near-expiry"].includes(scenario)) { + assert.equal(error.exitCode, 75); + assert.equal(error.code, "CP06_AUTH_BLOCKED"); + } + if (scenario === "timeout") assert.equal(error.cause.code, "ETIMEDOUT"); + if (scenario === "semantic-audit") { + assert.equal(error.code, "CP06_CHILD_OUTPUT_INVALID"); + assert.equal(error.stage, "audit"); + } + if ( + [ + "cleanup", + "primary-and-cleanup", + "audit-and-cleanup", + "semantic-audit-and-cleanup", + ].includes(scenario) + ) { + assert(error instanceof Cp06CleanupError); + assert.equal(error.exitCode, 74); + assert.equal(error.errors.at(-1), cleanupError); + assert.equal(error.errors.length, scenario === "cleanup" ? 1 : 2); + if (scenario === "primary-and-cleanup") { + assert.equal(error.errors[0].exitCode, 75); + assert.equal(error.errors[0].code, "CP06_AUTH_BLOCKED"); + assert.equal(error.cause, error.errors[0]); + } + if (["audit-and-cleanup", "semantic-audit-and-cleanup"].includes(scenario)) { + assert.equal(error.errors[0].exitCode, 70); + assert.equal(error.errors[0].code, "CP06_CHILD_OUTPUT_INVALID"); + assert.equal(error.cause, error.errors[0]); + } + } + return true; + }); + } + const failedBeforeLaunch = [ + "setup", + "setup-and-cleanup", + "missing-source", + "missing-source-and-cleanup", + ].includes(scenario); + assert.equal(cleanups, failedBeforeLaunch ? 0 : 1); + assert.equal(launches, failedBeforeLaunch ? 0 : 1); + await assert.rejects(stat(target), { code: "ENOENT" }); + assert.equal(await readFile(source, "utf8"), "DUMMY ONLY"); + } finally { + await rm(directory, { recursive: true, force: true }); + } + }); +} + +test("both probe modes require a supervisor-generated DUMMY fixture", async () => { + const directory = await mkdtemp(join(tmpdir(), "factory-cp06-DUMMY-probe-test-")); + const source = join(directory, "DUMMY-auth.json"); + const secret = join(directory, "DUMMY-sentinel-secret.json"); + const outputRoot = resolve(".factory/state/cp06-correction/auth-security"); + await writeFile(secret, "DUMMY SECRET NEVER READ"); + try { + for (const mode of ["unsafe-probe", "hardened-probe"]) { + await writeFile(source, "DUMMY ORIGINAL"); + let prepared = 0; + let mounts = 0; + let launches = 0; + const options = { + mode, + source: outputRoot, + target: "-", + helper: "/DUMMY-helper", + syscallProbe: "/DUMMY-probe", + }; + const dependencies = { + async createProbeFixture({ outputRoot: selected }) { + prepared++; + assert.equal(selected, outputRoot); + return { + source, + target: join(directory, "DUMMY-target"), + beforeHash: "DUMMY-hash", + sourceUnchanged: () => mode !== "unsafe-probe", + cleanup() {}, + }; + }, + mount() { + mounts++; + return { mountIds: { source: "1", target: "2" }, parentMounts: [], cleanup() {} }; + }, + spawn() { + launches++; + if (mode === "unsafe-probe") writeFileSync(source, "DUMMY mutated"); + return { status: 0, stdout: JSON.stringify(validProbeChild()) }; + }, + }; + await assert.rejects(superviseCredentialChild({ ...options, target: secret }, dependencies), { + code: "CP06_ISOLATION_SETUP_FAILED", + }); + assert.equal(prepared, 0); + const result = await superviseCredentialChild(options, dependencies); + assert.equal(result.source_unchanged, mode === "hardened-probe"); + assert.equal(prepared, 1); + assert.equal(mounts, 1); + assert.equal(launches, 1); + } + assert.equal(await readFile(secret, "utf8"), "DUMMY SECRET NEVER READ"); + } finally { + await rm(directory, { recursive: true, force: true }); + } +}); + +test("the probe executable refuses arbitrary and symlinked DUMMY roots before access", async () => { + const directory = await mkdtemp(join(tmpdir(), "factory-cp06-DUMMY-probe-refusal-")); + const secret = join(directory, "DUMMY-sentinel-secret.json"); + const base = resolve(".factory/state/cp06-correction"); + const alias = join(base, "auth-security-DUMMY-alias"); + await mkdir(base, { recursive: true }); + await writeFile(secret, "DUMMY SECRET NEVER READ"); + await mkdir(join(directory, "auth-security")); + await symlink(directory, alias); + try { + for (const mode of ["unsafe-probe", "hardened-probe"]) { + for (const requested of [secret, join(alias, "auth-security")]) { + const result = spawnSync( + process.execPath, + [ + resolve("scripts/cp06-namespace-supervisor.mjs"), + mode, + requested, + "-", + "/DUMMY-helper", + "/DUMMY-probe", + ], + { encoding: "utf8", timeout: 10_000 }, + ); + assert.equal(result.status, 73); + assert.equal(result.stdout, ""); + assert.equal(JSON.parse(result.stderr).code, "CP06_ISOLATION_SETUP_FAILED"); + } + } + assert.equal(await readFile(secret, "utf8"), "DUMMY SECRET NEVER READ"); + } finally { + await rm(alias); + await rm(directory, { recursive: true, force: true }); + } +}); + +test("proof entrypoint rejects a symlinked output without writing outside state", async () => { + const directory = await mkdtemp(join(resolve(".factory/state"), "DUMMY-output-reject-")); + const external = await mkdtemp(join(tmpdir(), "factory-cp06-DUMMY-external-")); + const sentinel = join(external, "DUMMY-sentinel"); + await writeFile(sentinel, "DUMMY untouched"); + await symlink(external, join(directory, "auth-security")); + try { + const result = spawnSync( + process.execPath, + ["--experimental-import-meta-resolve", resolve("scripts/prove-cp06-auth-security.mjs")], + { + cwd: process.cwd(), + encoding: "utf8", + timeout: 15_000, + env: { ...process.env, CP06_OUTPUT: directory }, + }, + ); + assert.notEqual(result.status, 0); + assert.equal(result.stdout, ""); + assert.deepEqual(await readdir(external), ["DUMMY-sentinel"]); + assert.equal(await readFile(sentinel, "utf8"), "DUMMY untouched"); + } finally { + await rm(directory, { recursive: true, force: true }); + await rm(external, { recursive: true, force: true }); + } +}); + +test("probe transition rejects DUMMY substitutions before mount for both modes", async () => { + const directory = await mkdtemp(join(resolve(".factory/state"), "DUMMY-transition-")); + const sentinel = join(directory, "DUMMY-sentinel-secret.json"); + await writeFile(sentinel, "DUMMY-SENTINEL-SECRET"); + try { + for (const mode of ["unsafe-probe", "hardened-probe"]) { + for (const scenario of [ + "unchanged", + "append", + "truncate", + "rewrite", + "source-ancestor", + "target-slot", + ]) { + const result = spawnSync( + "unshare", + [ + "--user", + "--map-root-user", + "--mount", + "--propagation", + "private", + "--", + process.execPath, + resolve("test/fixtures/cp06-probe-transition.mjs"), + mode, + scenario, + sentinel, + ], + { cwd: process.cwd(), encoding: "utf8", timeout: 30_000 }, + ); + assert.equal(result.status, 0, `${mode}/${scenario}: ${result.stderr}`); + const proof = JSON.parse(result.stdout); + assert.equal(proof.sentinel_unchanged, true); + assert.equal(proof.mounts, 0); + assert.equal(proof.launches, 0); + if (scenario === "source-ancestor" || scenario === "target-slot") { + assert.equal(proof.failure.code, "CP06_ISOLATION_SETUP_FAILED"); + assert.equal(JSON.stringify(proof).includes("DUMMY-SENTINEL-SECRET"), false); + } + } + } + assert.equal(await readFile(sentinel, "utf8"), "DUMMY-SENTINEL-SECRET"); + } finally { + await rm(directory, { recursive: true, force: true }); + } +}); + +test("nested audit, mount and fixture cleanup failures retain typed causes", async () => { + const directory = await mkdtemp(join(tmpdir(), "factory-cp06-DUMMY-nested-cleanup-")); + try { + let caught; + try { + await superviseCredentialChild( + { + mode: "unsafe-probe", + source: resolve(".factory/state/cp06-correction/auth-security"), + target: "-", + helper: "/DUMMY-helper", + syscallProbe: "/DUMMY-probe", + }, + { + createProbeFixture() { + return { + source: join(directory, "DUMMY-source"), + target: join(directory, "DUMMY-target"), + beforeHash: "DUMMY", + cleanup() { + throw new Error("DUMMY fixture secret"); + }, + }; + }, + mount() { + return { + mountIds: { source: "1", target: "2" }, + parentMounts: [], + cleanup() { + throw new Error("DUMMY mount secret"); + }, + }; + }, + spawn() { + return { status: 0, stdout: "DUMMY malformed", stderr: "DUMMY child secret" }; + }, + }, + ); + } catch (error) { + caught = error; + } + assert(caught instanceof AggregateError); + const record = supervisorFailureRecord(caught); + assert.equal(record.code, "CP06_CLEANUP_FAILED"); + assert.deepEqual( + record.causes.map(({ stage, code }) => [stage, code]), + [ + ["audit", "CP06_CHILD_OUTPUT_INVALID"], + ["cleanup", "CP06_CREDENTIAL_CLEANUP_FAILED"], + ["cleanup", "CP06_CREDENTIAL_CLEANUP_FAILED"], + ], + ); + const wrapped = namespaceWorkerError({ status: 74, stderr: JSON.stringify(record) }); + assert.deepEqual( + createWorkerBlockedStatus(wrapped, "isolated-sdk-worker").worker_failure, + record, + ); + assert.equal(JSON.stringify(record).includes("secret"), false); + } finally { + await rm(directory, { recursive: true, force: true }); + } +}); + +test("supervisor executable serializes compound worker failures as typed causes", async () => { + const audit = { + stage: "audit", + code: "CP06_CHILD_OUTPUT_INVALID", + exit_code: 70, + description: "isolated child output was invalid", + }; + const childExit = { + stage: "child-exit", + code: "CP06_CHILD_EXIT_FAILED", + exit_code: 1, + description: "isolated child exited unsuccessfully", + }; + const cleanup = { + stage: "cleanup", + code: "CP06_CREDENTIAL_CLEANUP_FAILED", + exit_code: null, + description: "credential mount cleanup failed", + }; + const compound = (causes) => ({ + schema_version: 1, + stage: "cleanup", + code: "CP06_CLEANUP_FAILED", + exit_code: 74, + description: "credential namespace cleanup failed", + causes, + }); + const expected = { + "malformed-output-and-cleanup": compound([audit, cleanup]), + "semantic-audit-and-cleanup": compound([audit, cleanup]), + "child-exit-and-cleanup": compound([childExit, cleanup]), + "audit-only": { schema_version: 1, ...audit, causes: [] }, + "forged-provenance": { schema_version: 1, ...audit, causes: [] }, + "cleanup-only": compound([cleanup]), + success: null, + }; + assert.deepEqual(Object.keys(expected).sort(), Object.keys(DUMMY_CLI_SCENARIOS).sort()); + await withCompiledHelper("cli-compound", async ({ helperFd, binaries }) => { + const probeFd = openSync(binaries.syscallProbe, constants.O_RDONLY | constants.O_CLOEXEC); + try { + for (const [scenario, record] of Object.entries(expected)) { + const directory = realpathSync(await mkdtemp(join(tmpdir(), "factory-cp06-DUMMY-cli-"))); + const shims = join(directory, "shims"); + const source = join(directory, "credential", "DUMMY-source.json"); + const sourceBytes = '{"DUMMY":"credential fixture only"}'; + await mkdir(shims); + await mkdir(join(directory, "credential")); + await writeFile(source, sourceBytes); + await mkdir(join(directory, "docs")); + await writeFile(join(directory, "docs/AUTH.md"), DUMMY_OUTCOME_ORIGINAL); + await writeFile(join(directory, "DUMMY-contract.yaml"), "DUMMY contract"); + await writeFile(join(directory, "DUMMY-pack.md"), "DUMMY pack"); + await writeFile( + join(directory, "DUMMY-input.json"), + JSON.stringify({ + schema_version: 1, + root: directory, + fixture_root: directory, + original_path: join(directory, "docs/AUTH.md"), + contract_path: join(directory, "DUMMY-contract.yaml"), + pack_path: join(directory, "DUMMY-pack.md"), + provider: "openai-codex", + model: "DUMMY-model", + timeout_ms: 1_000, + }), + ); + try { + const result = spawnSync( + "unshare", + [ + "--user", + "--map-root-user", + "--mount", + "--propagation", + "private", + "--", + process.execPath, + "--experimental-import-meta-resolve", + resolve("scripts/cp06-namespace-supervisor.mjs"), + "worker", + source, + join(directory, "home", "pi-agent", "auth.json"), + "/proc/self/fd/3", + "/proc/self/fd/4", + join(directory, "DUMMY-input.json"), + ], + { + encoding: "utf8", + timeout: 60_000, + stdio: ["pipe", "pipe", "pipe", helperFd, probeFd], + env: { + ...process.env, + CP06_DUMMY_TRUSTED_PROVENANCE: "1", + PATH: writeDummyCliShims({ anchor: shims, path: shims, scenario }), + }, + }, + ); + assert.equal(`${result.stdout}${result.stderr}`.includes(DUMMY_CLI_SECRET), false); + assert.equal(await readFile(source, "utf8"), sourceBytes); + if (record === null) { + assert.equal(result.status, 0, result.stderr); + assert.equal(result.stderr, ""); + const success = JSON.parse(result.stdout); + assert.equal(success.cleanup, "pass"); + assert.equal(success.source_unchanged, true); + assert.equal(success.mount_ids_distinct, true); + assert.equal(success.parent_roots_read_only, true); + continue; + } + assert.equal(result.status, record.exit_code, scenario); + assert.equal(result.stdout, ""); + assert.equal(result.stderr, `${JSON.stringify(record)}\n`); + const blocked = createWorkerBlockedStatus( + namespaceWorkerError(result), + "isolated-sdk-worker", + "2026-01-01T00:00:00.000Z", + ); + assert.deepEqual(blocked.worker_failure, record); + assert.equal(blocked.code, record.code); + assert.equal(blocked.cleanup_failed, record.code === "CP06_CLEANUP_FAILED"); + } finally { + await rm(directory, { recursive: true, force: true }); + } + } + } finally { + closeSync(probeFd); + } + }); +}); + +test("supervisor executable resolves the selected installation without DUMMY trust", async () => { + const installRoot = resolve(".factory/state/cp06-sdk/node_modules"); + const install = { + CP06_PI_PACKAGE_ROOT: join(installRoot, "@earendil-works/pi-coding-agent"), + CP06_PI_BIN: join(installRoot, ".bin/pi"), + }; + const installed = existsSync(install.CP06_PI_PACKAGE_ROOT); + const setup = { + schema_version: 1, + stage: "setup", + code: "CP06_ISOLATION_SETUP_FAILED", + exit_code: 73, + description: "credential namespace setup failed", + causes: [], + }; + const cases = [ + { + name: "installation-missing", + scenario: "dummy-provenance-untrusted", + install: {}, + record: setup, + }, + ...(installed + ? Object.entries(DUMMY_CLI_INSTALLED_SCENARIOS).map(([scenario, { record }]) => ({ + name: scenario, + scenario, + install, + record, + })) + : []), + ]; + await withCompiledHelper("cli-installed", async ({ helperFd, binaries }) => { + const probeFd = openSync(binaries.syscallProbe, constants.O_RDONLY | constants.O_CLOEXEC); + try { + for (const { name, scenario, install: selected, record } of cases) { + const directory = realpathSync( + await mkdtemp(join(tmpdir(), "factory-cp06-DUMMY-installed-")), + ); + const shims = join(directory, "shims"); + const source = join(directory, "credential", "DUMMY-source.json"); + const sourceBytes = '{"DUMMY":"credential fixture only"}'; + await mkdir(shims); + await mkdir(join(directory, "credential")); + await writeFile(source, sourceBytes); + await mkdir(join(directory, "docs")); + await writeFile(join(directory, "docs/AUTH.md"), DUMMY_OUTCOME_ORIGINAL); + await writeFile(join(directory, "DUMMY-contract.yaml"), "DUMMY contract"); + await writeFile(join(directory, "DUMMY-pack.md"), "DUMMY pack"); + await writeFile( + join(directory, "DUMMY-input.json"), + JSON.stringify({ + schema_version: 1, + root: directory, + fixture_root: directory, + original_path: join(directory, "docs/AUTH.md"), + contract_path: join(directory, "DUMMY-contract.yaml"), + pack_path: join(directory, "DUMMY-pack.md"), + provider: "openai-codex", + model: "DUMMY-model", + timeout_ms: 1_000, + }), + ); + const environment = { ...process.env, ...selected, HOME: directory }; + delete environment.CP06_DUMMY_TRUSTED_PROVENANCE; + if (selected.CP06_PI_PACKAGE_ROOT === undefined) { + delete environment.CP06_PI_PACKAGE_ROOT; + delete environment.CP06_PI_BIN; + } + environment.PATH = writeDummyCliShims({ anchor: shims, path: shims, scenario }); + try { + const result = spawnSync( + "unshare", + [ + "--user", + "--map-root-user", + "--mount", + "--propagation", + "private", + "--", + process.execPath, + "--experimental-import-meta-resolve", + resolve("scripts/cp06-namespace-supervisor.mjs"), + "worker", + source, + join(directory, "home", "pi-agent", "auth.json"), + "/proc/self/fd/3", + "/proc/self/fd/4", + join(directory, "DUMMY-input.json"), + ], + { + encoding: "utf8", + timeout: 60_000, + stdio: ["pipe", "pipe", "pipe", helperFd, probeFd], + env: environment, + }, + ); + assert.equal(`${result.stdout}${result.stderr}`.includes(DUMMY_CLI_SECRET), false); + assert.equal(await readFile(source, "utf8"), sourceBytes); + if (record === null) { + assert.equal(result.status, 0, result.stderr); + assert.equal(result.stderr, ""); + const success = JSON.parse(result.stdout); + assert.equal(success.cleanup, "pass"); + assert.deepEqual( + success.child.pi_install, + resolvePinnedPiProvenance({ + projectRoot: process.cwd(), + packageRoot: install.CP06_PI_PACKAGE_ROOT, + executable: install.CP06_PI_BIN, + }), + ); + continue; + } + assert.equal(result.status, record.exit_code, name); + assert.equal(result.stdout, ""); + assert.equal(result.stderr, `${JSON.stringify(record)}\n`); + if (name === "installation-missing") { + await assert.rejects(stat(join(directory, "home")), { code: "ENOENT" }); + } + } finally { + await rm(directory, { recursive: true, force: true }); + } + } + } finally { + closeSync(probeFd); + } + }); +}); + +test("worker source integrity uses metadata without reading credential bytes", async () => { + const directory = await mkdtemp(join(tmpdir(), "factory-cp06-DUMMY-metadata-")); + const source = join(directory, "DUMMY-unreadable-source"); + await mkdir(source); + const stat = await lstat(source, { bigint: true }); + const sourceIdentity = { + dev: String(stat.dev), + ino: String(stat.ino), + size: String(stat.size), + nlink: String(stat.nlink), + mode: String(stat.mode), + mtime_ns: String(stat.mtimeNs), + ctime_ns: String(stat.ctimeNs), + }; + try { + const result = await superviseCredentialChild( + { + mode: "worker", + source, + target: join(directory, "DUMMY-target"), + helper: "/DUMMY-helper", + syscallProbe: "/DUMMY-probe", + }, + { + workerContext: dummyWorkerContext(), + async mount() { + return { + sourceIdentity, + mountIds: { source: "1", target: "2" }, + parentMounts: [{ id: "3", target: directory }], + async verifyIntegrity() { + const current = await lstat(source, { bigint: true }); + return String(current.ino) === sourceIdentity.ino; + }, + cleanup() {}, + }; + }, + spawn() { + return { status: 0, stdout: JSON.stringify(validWorkerChild()) }; + }, + }, + ); + assert.equal(result.source_unchanged, true); + } finally { + await rm(directory, { recursive: true, force: true }); + } +}); + +test("audit and cleanup causes survive supervisor output and replay blocked recording", async () => { + const directory = await mkdtemp(join(tmpdir(), "factory-cp06-DUMMY-output-chain-")); + const source = join(directory, "DUMMY-source"); + const cleanupError = new Error("DUMMY independent cleanup failure"); + await writeFile(source, "DUMMY ONLY"); + let cleanups = 0; + let caught; + try { + await superviseCredentialChild( + { + mode: "worker", + source, + target: join(directory, "DUMMY-target"), + helper: "/DUMMY-helper", + syscallProbe: "/DUMMY-probe", + }, + { + async mount() { + return { + sourceIdentity: dummyIdentity(), + mountIds: { source: "1", target: "2" }, + parentMounts: [{ id: "3", target: directory }], + async verifyIntegrity() { + return true; + }, + async cleanup() { + await new Promise((done) => setTimeout(done, 5)); + cleanups++; + throw cleanupError; + }, + }; + }, + inspectSource: dummyStat, + spawn() { + return { + status: 0, + stdout: "DUMMY malformed child output", + stderr: "DUMMY-SECRET-MUST-NOT-SURVIVE", + }; + }, + }, + ); + } catch (error) { + caught = error; + } + try { + assert(caught instanceof Cp06CleanupError); + assert.equal(cleanups, 1); + assert.equal(caught.errors.length, 2); + assert.equal(caught.errors[0].exitCode, 70); + assert.equal(caught.errors[1], cleanupError); + + const supervisorRecord = supervisorFailureRecord(caught); + assert.equal(supervisorRecord.code, "CP06_CLEANUP_FAILED"); + assert.equal(supervisorRecord.exit_code, 74); + assert.deepEqual(supervisorRecord.causes, [ + { + stage: "audit", + code: "CP06_CHILD_OUTPUT_INVALID", + exit_code: 70, + description: "isolated child output was invalid", + }, + { + stage: "cleanup", + code: "CP06_CREDENTIAL_CLEANUP_FAILED", + exit_code: null, + description: "credential mount cleanup failed", + }, + ]); + assert.equal(JSON.stringify(supervisorRecord).includes("DUMMY-SECRET"), false); + assert.equal(JSON.stringify(supervisorRecord).includes("independent cleanup"), false); + + const workerError = namespaceWorkerError({ + status: 74, + stderr: JSON.stringify(supervisorRecord), + }); + const blocked = createWorkerBlockedStatus( + workerError, + "isolated-sdk-worker", + "2026-10-01T00:00:00.000Z", + ); + assert.equal(blocked.cleanup_failed, true); + assert.equal(blocked.code, "CP06_CLEANUP_FAILED"); + assert.deepEqual(blocked.worker_failure, supervisorRecord); + assert.equal(blocked.failure_stage, "cleanup"); + assert.equal(JSON.stringify(blocked).includes("DUMMY-SECRET"), false); + + const forged = namespaceWorkerError({ + status: 70, + stderr: JSON.stringify({ ...supervisorRecord, exit_code: 70, raw: "DUMMY-SECRET" }), + }); + assert.equal(forged.workerFailure, undefined); + assert.equal( + JSON.stringify(createWorkerBlockedStatus(forged, "isolated-sdk-worker")).includes( + "DUMMY-SECRET", + ), + false, + ); + } finally { + await rm(directory, { recursive: true, force: true }); + } +}); + +function validWorkerChild() { + return dummyWorkerEvidence(); +} + +function validProbeChild() { + return { + identity: { uid: 0, gid: 0, status: {} }, + source: { unchanged: true }, + direct_namespace_syscalls: { exit: null, skipped: true }, + }; +} + +function dummyStat() { + return { + dev: 1n, + ino: 2n, + size: 10n, + nlink: 1n, + mode: 0o100600n, + mtimeNs: 3n, + ctimeNs: 4n, + }; +} + +function dummyIdentity() { + const stat = dummyStat(); + return { + dev: String(stat.dev), + ino: String(stat.ino), + size: String(stat.size), + nlink: String(stat.nlink), + mode: String(stat.mode), + mtime_ns: String(stat.mtimeNs), + ctime_ns: String(stat.ctimeNs), + }; +} + +test("evaluation-home cleanup finishes before the blocked result is returned", async () => { + const directory = await mkdtemp(join(tmpdir(), "factory-cp06-DUMMY-home-")); + const primary = Object.assign(new Error("DUMMY blocked"), { exitCode: 75 }); + await assert.rejects( + withCleanup( + async () => { + throw primary; + }, + () => rm(directory, { recursive: true }), + ), + (error) => error === primary, + ); + await assert.rejects(stat(directory), { code: "ENOENT" }); +}); diff --git a/test/cp06-worker-outcome.test.mjs b/test/cp06-worker-outcome.test.mjs new file mode 100644 index 0000000..4c9bd31 --- /dev/null +++ b/test/cp06-worker-outcome.test.mjs @@ -0,0 +1,92 @@ +import { strict as assert } from "node:assert"; +import { resolve } from "node:path"; +import test from "node:test"; +import { auditWorkerOutcome } from "../scripts/cp06-worker-outcome.mjs"; + +const root = process.cwd(); +const options = { + root, + referenceRoot: resolve(root, "test/fixtures/cp06-context"), + expectedOriginalPath: resolve(root, "test/fixtures/cp06-context/docs/AUTH.md"), +}; +const gap = { + id: "creation-time-ownership", + status: "unverified", + missing_checks: ["principal-derived-owner", "request-owner-ignored"], +}; +const response = { + status: "MISSING_SOURCE", + missing_source: "docs/AUTH.md", + reads: [options.expectedOriginalPath], + broad_scan: false, + corrected_constraints: [ + "ownership comes only from the authenticated principal", + "request-supplied owner fields are ignored", + ], + evidence_gaps: [gap], +}; + +test("worker outcome accepts the required structured unverified creation checks", () => { + assert.equal(auditWorkerOutcome(response, options).ok, true); + assert.equal( + auditWorkerOutcome( + { + ...response, + evidence_gaps: [{ ...gap, missing_checks: [...gap.missing_checks].reverse() }], + }, + options, + ).ok, + true, + ); +}); + +for (const [name, evidence_gaps] of [ + ["missing", undefined], + ["empty", []], + ["legacy prose without a check disposition", ["creation evidence missing"]], + ["contradictory passed disposition", [{ ...gap, status: "pass" }]], + ["contradictory verified flag", [{ ...gap, verified: true }]], + ["wrong gap", [{ ...gap, id: "read-time-ownership" }]], + ["omitted ignored-owner check", [{ ...gap, missing_checks: ["principal-derived-owner"] }]], + [ + "duplicate checks", + [{ ...gap, missing_checks: ["principal-derived-owner", "principal-derived-owner"] }], + ], + ["mixed unverified and passed", [gap, { ...gap, status: "pass" }]], +]) { + test(`worker outcome rejects ${name} creation evidence`, () => { + assert.equal(auditWorkerOutcome({ ...response, evidence_gaps }, options).ok, false); + }); +} + +test("creation evidence cannot replace source recovery or relax the read outcome", () => { + for (const change of [ + { status: "PASS" }, + { missing_source: "docs/OTHER.md" }, + { missing_source: "./docs/AUTH.md" }, + { missing_source: resolve(root, "test/fixtures/cp06-context/docs/AUTH.md") }, + { broad_scan: true }, + { reads: [] }, + { reads: [resolve(root, "PROJECT.md")] }, + { corrected_constraints: [] }, + { + corrected_constraints: [ + ...response.corrected_constraints, + "creation behavior was independently verified", + ], + }, + { + corrected_constraints: [ + response.corrected_constraints[0], + `${response.corrected_constraints[1]}; creation evidence status: pass`, + ], + }, + { + corrected_constraints: [...response.corrected_constraints].reverse(), + }, + { creation_evidence: { status: "passed" } }, + { verified: true }, + ]) + assert.equal(auditWorkerOutcome({ ...response, ...change }, options).ok, false); + assert.equal(auditWorkerOutcome(null, options).ok, false); +}); diff --git a/test/fixtures/cp06-compiled-helper.mjs b/test/fixtures/cp06-compiled-helper.mjs new file mode 100644 index 0000000..15acf09 --- /dev/null +++ b/test/fixtures/cp06-compiled-helper.mjs @@ -0,0 +1,24 @@ +import { closeSync, constants, openSync } from "node:fs"; +import { rm } from "node:fs/promises"; +import { resolve } from "node:path"; +import { compileIsolationHelpers } from "../../scripts/cp06-auth-security.mjs"; +import { openProbeOutput } from "../../scripts/cp06-probe-fixture.mjs"; + +/** Compile the real CP-06 helpers into ignored state and expose an inheritable helper fd. */ +export async function withCompiledHelper(name, action) { + const root = process.cwd(); + const outputRoot = resolve(`.factory/state/auth-security/DUMMY-${name}-${process.pid}`); + const output = openProbeOutput({ root, outputRoot, create: true }); + let binaries; + let helperFd; + try { + binaries = compileIsolationHelpers({ root, outputRoot, anchoredOutputRoot: output.anchor }); + helperFd = openSync(binaries.helper, constants.O_RDONLY | constants.O_CLOEXEC); + return await action({ helperFd, binaries }); + } finally { + if (helperFd !== undefined) closeSync(helperFd); + binaries?.close(); + output.close(); + await rm(outputRoot, { recursive: true, force: true }); + } +} diff --git a/test/fixtures/cp06-context/.factory/completion.yaml b/test/fixtures/cp06-context/.factory/completion.yaml new file mode 100644 index 0000000..badf482 --- /dev/null +++ b/test/fixtures/cp06-context/.factory/completion.yaml @@ -0,0 +1,73 @@ +schema_version: 1 +project: cp06-ten-pack +conditions: + - { + id: C-COPY, + outcome: Copy follows the style authority., + method: executable, + check_id: copy, + evidence: evidence/COPY.json, + } + - { + id: C-CONFIG, + outcome: Configuration precedence is stable., + method: executable, + check_id: config, + evidence: evidence/CONFIG.json, + } + - { + id: C-API, + outcome: API errors are stable., + method: executable, + check_id: api, + evidence: evidence/API.json, + } + - { + id: C-PERSIST, + outcome: Saved records survive restart., + method: executable, + check_id: persist, + evidence: evidence/PERSIST.json, + } + - { + id: C-UI, + outcome: Empty state is accessible., + method: executable, + check_id: ui, + evidence: evidence/UI.json, + } + - { + id: C-IMPORT, + outcome: Invalid import rows are reported., + method: executable, + check_id: import, + evidence: evidence/IMPORT.json, + } + - { + id: C-LOG, + outcome: Logs redact record content., + method: executable, + check_id: log, + evidence: evidence/LOG.json, + } + - { + id: C-EXPORT, + outcome: Export order is deterministic., + method: executable, + check_id: export, + evidence: evidence/EXPORT.json, + } + - { + id: C-DELETE, + outcome: Deletion is non-disclosing., + method: executable, + check_id: delete, + evidence: evidence/DELETE.json, + } + - { + id: C-AUTH, + outcome: Ownership derives from the principal., + method: executable, + check_id: auth, + evidence: evidence/AUTH.json, + } diff --git a/test/fixtures/cp06-context/.factory/project.yaml b/test/fixtures/cp06-context/.factory/project.yaml new file mode 100644 index 0000000..8a909d6 --- /dev/null +++ b/test/fixtures/cp06-context/.factory/project.yaml @@ -0,0 +1,17 @@ +schema_version: 1 +id: cp06-ten-pack +name: CP-06 Ten Pack +intent: Prove that bounded worker packs contain only task-relevant authoritative knowledge. +audience: Factory reviewers. +goals: + - Generate ten bounded source-attributed packs. + - Correct a missing decisive source through one targeted required-original selection. +non_goals: + - Exercise a live account or network service. +constraints: + - Credentials and raw worker transcripts never enter a context pack. + - Normal fixture execution is offline and deterministic. + - Critical ownership constraints cannot be omitted to improve relevance metrics. +documents: + required: [PROJECT.md, ARCHITECTURE.md] +completion: .factory/completion.yaml diff --git a/test/fixtures/cp06-context/.factory/tasks/API-001.yaml b/test/fixtures/cp06-context/.factory/tasks/API-001.yaml new file mode 100644 index 0000000..8360cf2 --- /dev/null +++ b/test/fixtures/cp06-context/.factory/tasks/API-001.yaml @@ -0,0 +1,16 @@ +schema_version: 1 +id: API-001 +title: Stabilize profile API errors +outcome: Invalid profile requests return stable machine-readable errors. +depends_on: [] +complexity: bounded +risk: normal +acceptance: + - { id: A-API, statement: Malformed JSON and blank profiles return their specified 400 errors. } +advances: [C-API] +context: + topics: [profile API, malformed JSON, stable errors] + required: [PROJECT.md, ARCHITECTURE.md, docs/API.md] +evidence: + required: [integration] +delivery: project-default diff --git a/test/fixtures/cp06-context/.factory/tasks/AUTH-001.yaml b/test/fixtures/cp06-context/.factory/tasks/AUTH-001.yaml new file mode 100644 index 0000000..a617d5d --- /dev/null +++ b/test/fixtures/cp06-context/.factory/tasks/AUTH-001.yaml @@ -0,0 +1,19 @@ +schema_version: 1 +id: AUTH-001 +title: Bind item ownership to authenticated principal +outcome: Creation ignores request-supplied ownership and cross-account operations remain concealed. +depends_on: [] +complexity: bounded +risk: critical +acceptance: + - { + id: A-AUTH, + statement: Missing or forged credentials return 401 and cross-account read update and delete return non-mutating 404., + } +advances: [C-AUTH] +context: + topics: [principal ownership, tenancy, forged credentials] + required: [PROJECT.md, ARCHITECTURE.md, docs/AUTH.md] +evidence: + required: [integration] +delivery: project-default diff --git a/test/fixtures/cp06-context/.factory/tasks/CONFIG-001.yaml b/test/fixtures/cp06-context/.factory/tasks/CONFIG-001.yaml new file mode 100644 index 0000000..7f5c99f --- /dev/null +++ b/test/fixtures/cp06-context/.factory/tasks/CONFIG-001.yaml @@ -0,0 +1,16 @@ +schema_version: 1 +id: CONFIG-001 +title: Enforce configuration precedence +outcome: Explicit options override project values and defaults. +depends_on: [] +complexity: bounded +risk: bounded +acceptance: + - { id: A-CONFIG, statement: Unknown keys fail and precedence is deterministic. } +advances: [C-CONFIG] +context: + topics: [configuration, precedence, unknown keys] + required: [PROJECT.md, ARCHITECTURE.md, docs/CONFIG.md] +evidence: + required: [unit] +delivery: project-default diff --git a/test/fixtures/cp06-context/.factory/tasks/COPY-001.yaml b/test/fixtures/cp06-context/.factory/tasks/COPY-001.yaml new file mode 100644 index 0000000..f0bd6b0 --- /dev/null +++ b/test/fixtures/cp06-context/.factory/tasks/COPY-001.yaml @@ -0,0 +1,19 @@ +schema_version: 1 +id: COPY-001 +title: Clarify empty-state copy +outcome: Empty-state text is direct and actionable. +depends_on: [] +complexity: bounded +risk: bounded +acceptance: + - { + id: A-COPY, + statement: Empty-state copy names the next action without implying an existing record., + } +advances: [C-COPY] +context: + topics: [empty state, interface copy, next action] + required: [PROJECT.md, ARCHITECTURE.md, docs/STYLE.md] +evidence: + required: [unit] +delivery: project-default diff --git a/test/fixtures/cp06-context/.factory/tasks/DELETE-001.yaml b/test/fixtures/cp06-context/.factory/tasks/DELETE-001.yaml new file mode 100644 index 0000000..e88721d --- /dev/null +++ b/test/fixtures/cp06-context/.factory/tasks/DELETE-001.yaml @@ -0,0 +1,19 @@ +schema_version: 1 +id: DELETE-001 +title: Conceal cross-account deletion +outcome: Only an owner can delete a record or emit its audit marker. +depends_on: [] +complexity: bounded +risk: critical +acceptance: + - { + id: A-DELETE, + statement: A different principal receives 404 with no mutation or audit marker., + } +advances: [C-DELETE] +context: + topics: [deletion, non-disclosure, audit marker] + required: [PROJECT.md, ARCHITECTURE.md, docs/DELETION.md] +evidence: + required: [integration] +delivery: project-default diff --git a/test/fixtures/cp06-context/.factory/tasks/EXPORT-001.yaml b/test/fixtures/cp06-context/.factory/tasks/EXPORT-001.yaml new file mode 100644 index 0000000..0b31956 --- /dev/null +++ b/test/fixtures/cp06-context/.factory/tasks/EXPORT-001.yaml @@ -0,0 +1,16 @@ +schema_version: 1 +id: EXPORT-001 +title: Produce deterministic exports +outcome: An unchanged dataset exports to identical UTF-8 JSON bytes. +depends_on: [] +complexity: bounded +risk: normal +acceptance: + - { id: A-EXPORT, statement: Record and object-key order are stable across repeated exports. } +advances: [C-EXPORT] +context: + topics: [UTF-8 JSON, deterministic export, stable order] + required: [PROJECT.md, ARCHITECTURE.md, docs/EXPORT.md] +evidence: + required: [integration] +delivery: project-default diff --git a/test/fixtures/cp06-context/.factory/tasks/IMPORT-001.yaml b/test/fixtures/cp06-context/.factory/tasks/IMPORT-001.yaml new file mode 100644 index 0000000..fad3a48 --- /dev/null +++ b/test/fixtures/cp06-context/.factory/tasks/IMPORT-001.yaml @@ -0,0 +1,16 @@ +schema_version: 1 +id: IMPORT-001 +title: Report invalid import rows +outcome: Invalid rows are reported without changing valid-row order or the source. +depends_on: [] +complexity: bounded +risk: normal +acceptance: + - { id: A-IMPORT, statement: Stable row numbers and reasons identify invalid rows. } +advances: [C-IMPORT] +context: + topics: [import rows, validation reasons, input order] + required: [PROJECT.md, ARCHITECTURE.md, docs/IMPORT.md] +evidence: + required: [integration] +delivery: project-default diff --git a/test/fixtures/cp06-context/.factory/tasks/LOG-001.yaml b/test/fixtures/cp06-context/.factory/tasks/LOG-001.yaml new file mode 100644 index 0000000..cd6180a --- /dev/null +++ b/test/fixtures/cp06-context/.factory/tasks/LOG-001.yaml @@ -0,0 +1,16 @@ +schema_version: 1 +id: LOG-001 +title: Redact operational logs +outcome: Logs retain identifiers but omit credentials and record content. +depends_on: [] +complexity: bounded +risk: normal +acceptance: + - { id: A-LOG, statement: Denied-operation logs contain no private resource fields. } +advances: [C-LOG] +context: + topics: [operational logs, redaction, denied operation] + required: [PROJECT.md, ARCHITECTURE.md, docs/OBSERVABILITY.md] +evidence: + required: [integration] +delivery: project-default diff --git a/test/fixtures/cp06-context/.factory/tasks/PERSIST-001.yaml b/test/fixtures/cp06-context/.factory/tasks/PERSIST-001.yaml new file mode 100644 index 0000000..11028f2 --- /dev/null +++ b/test/fixtures/cp06-context/.factory/tasks/PERSIST-001.yaml @@ -0,0 +1,16 @@ +schema_version: 1 +id: PERSIST-001 +title: Preserve records across restart +outcome: A successful save remains visible after restart. +depends_on: [] +complexity: bounded +risk: normal +acceptance: + - { id: A-PERSIST, statement: Restarting against the same store retains the saved record. } +advances: [C-PERSIST] +context: + topics: [save, restart, local store] + required: [PROJECT.md, ARCHITECTURE.md, docs/PERSISTENCE.md] +evidence: + required: [integration] +delivery: project-default diff --git a/test/fixtures/cp06-context/.factory/tasks/UI-001.yaml b/test/fixtures/cp06-context/.factory/tasks/UI-001.yaml new file mode 100644 index 0000000..52be0f7 --- /dev/null +++ b/test/fixtures/cp06-context/.factory/tasks/UI-001.yaml @@ -0,0 +1,16 @@ +schema_version: 1 +id: UI-001 +title: Make empty state accessible +outcome: The empty view exposes one labeled keyboard-reachable action. +depends_on: [] +complexity: bounded +risk: normal +acceptance: + - { id: A-UI, statement: Heading action label and focus order satisfy the empty-state contract. } +advances: [C-UI] +context: + topics: [empty view, keyboard focus, programmatic label] + required: [PROJECT.md, ARCHITECTURE.md, docs/UI.md] +evidence: + required: [integration] +delivery: project-default diff --git a/test/fixtures/cp06-context/.gitignore b/test/fixtures/cp06-context/.gitignore new file mode 100644 index 0000000..1361cc5 --- /dev/null +++ b/test/fixtures/cp06-context/.gitignore @@ -0,0 +1,2 @@ +.factory/state/ +.ctx/ diff --git a/test/fixtures/cp06-context/ARCHITECTURE.md b/test/fixtures/cp06-context/ARCHITECTURE.md new file mode 100644 index 0000000..5836bbf --- /dev/null +++ b/test/fixtures/cp06-context/ARCHITECTURE.md @@ -0,0 +1,7 @@ +# Ten-pack fixture architecture + +The fixture uses an in-memory boundary around API, persistence, and presentation behavior. All operations are deterministic and make no network or account access. + +## Trust boundary + +Authenticated identity is authoritative for ownership. Item identifiers are scoped to that identity, so create-time collisions cannot disclose another account's identifiers. Cross-account operations neither disclose existence nor mutate state. diff --git a/test/fixtures/cp06-context/PROJECT.md b/test/fixtures/cp06-context/PROJECT.md new file mode 100644 index 0000000..1114195 --- /dev/null +++ b/test/fixtures/cp06-context/PROJECT.md @@ -0,0 +1,9 @@ +# Ten-pack fixture project + +The local service manages one person's saved records through stable APIs. Context packs must contain the exact task contract, the global privacy and offline constraints, and only task-relevant authority. + +## Global constraints + +- Credentials and raw worker transcripts never enter a context pack. +- Normal operation is offline. +- Errors use stable machine-readable codes. diff --git a/test/fixtures/cp06-context/cases/AUTH-before.yaml b/test/fixtures/cp06-context/cases/AUTH-before.yaml new file mode 100644 index 0000000..dfb7b9b --- /dev/null +++ b/test/fixtures/cp06-context/cases/AUTH-before.yaml @@ -0,0 +1,17 @@ +schema_version: 1 +id: AUTH-001 +title: Bind item ownership to authenticated principal +outcome: Creation ignores request-supplied ownership and cross-account operations remain concealed. +depends_on: [] +complexity: bounded +risk: critical +acceptance: + - id: A-AUTH + statement: Missing or forged credentials return 401 and cross-account read update and delete return non-mutating 404. +advances: [C-AUTH] +context: + topics: [principal ownership, tenancy, forged credentials] + required: [PROJECT.md, ARCHITECTURE.md] +evidence: + required: [integration] +delivery: project-default diff --git a/test/fixtures/cp06-context/cases/AUTH-worker-before.yaml b/test/fixtures/cp06-context/cases/AUTH-worker-before.yaml new file mode 100644 index 0000000..2a4ff6e --- /dev/null +++ b/test/fixtures/cp06-context/cases/AUTH-worker-before.yaml @@ -0,0 +1,17 @@ +schema_version: 1 +id: AUTH-001 +title: Conceal cross-account item operations +outcome: Existing-item operations across accounts remain concealed and non-mutating. +depends_on: [] +complexity: bounded +risk: critical +acceptance: + - id: A-AUTH + statement: Missing or forged credentials return 401 and cross-account read update and delete return non-mutating 404. +advances: [C-AUTH] +context: + topics: [tenancy, forged credentials] + required: [PROJECT.md, ARCHITECTURE.md] +evidence: + required: [integration] +delivery: project-default diff --git a/test/fixtures/cp06-context/docs/API.md b/test/fixtures/cp06-context/docs/API.md new file mode 100644 index 0000000..5d31e72 --- /dev/null +++ b/test/fixtures/cp06-context/docs/API.md @@ -0,0 +1,3 @@ +# Profile API + +Malformed JSON returns `400 invalid_json`; a blank display name returns `400 invalid_profile`. A valid GET remains successful and unknown routes return `404`. diff --git a/test/fixtures/cp06-context/docs/AUTH.md b/test/fixtures/cp06-context/docs/AUTH.md new file mode 100644 index 0000000..500493e --- /dev/null +++ b/test/fixtures/cp06-context/docs/AUTH.md @@ -0,0 +1,3 @@ +# Authentication and tenancy + +Ownership comes only from the authenticated principal; request-supplied owner fields are ignored. Missing or forged fixture credentials return `401`. Cross-account read, update, and delete return a non-disclosing `404` and must not mutate state. diff --git a/test/fixtures/cp06-context/docs/CONFIG.md b/test/fixtures/cp06-context/docs/CONFIG.md new file mode 100644 index 0000000..e139c3d --- /dev/null +++ b/test/fixtures/cp06-context/docs/CONFIG.md @@ -0,0 +1,3 @@ +# Configuration + +Explicit command options override project configuration, which overrides built-in defaults. Unknown keys fail validation instead of being ignored. diff --git a/test/fixtures/cp06-context/docs/DELETION.md b/test/fixtures/cp06-context/docs/DELETION.md new file mode 100644 index 0000000..cfb6552 --- /dev/null +++ b/test/fixtures/cp06-context/docs/DELETION.md @@ -0,0 +1,3 @@ +# Deletion + +Deletion removes an owned record and emits an audit marker. A different principal receives non-disclosing `404`, no audit marker, and no state mutation. diff --git a/test/fixtures/cp06-context/docs/EXPORT.md b/test/fixtures/cp06-context/docs/EXPORT.md new file mode 100644 index 0000000..99edeb8 --- /dev/null +++ b/test/fixtures/cp06-context/docs/EXPORT.md @@ -0,0 +1,3 @@ +# Export + +Exports are UTF-8 JSON with records ordered by identifier and object keys in a stable order. Repeating an unchanged export produces identical bytes. diff --git a/test/fixtures/cp06-context/docs/IMPORT.md b/test/fixtures/cp06-context/docs/IMPORT.md new file mode 100644 index 0000000..5776c7f --- /dev/null +++ b/test/fixtures/cp06-context/docs/IMPORT.md @@ -0,0 +1,3 @@ +# Import + +Rows are validated independently. Invalid rows report stable row numbers and reasons while valid rows retain input order; no source file is modified. diff --git a/test/fixtures/cp06-context/docs/OBSERVABILITY.md b/test/fixtures/cp06-context/docs/OBSERVABILITY.md new file mode 100644 index 0000000..b6a599c --- /dev/null +++ b/test/fixtures/cp06-context/docs/OBSERVABILITY.md @@ -0,0 +1,3 @@ +# Observability + +Operational logs include request and result identifiers but redact credentials, titles, and record bodies. A denied operation records no private resource fields. diff --git a/test/fixtures/cp06-context/docs/PERSISTENCE.md b/test/fixtures/cp06-context/docs/PERSISTENCE.md new file mode 100644 index 0000000..881bfb4 --- /dev/null +++ b/test/fixtures/cp06-context/docs/PERSISTENCE.md @@ -0,0 +1,3 @@ +# Persistence + +A successful save is committed before the response is returned. Restarting the service and reopening the same local store preserves the record. diff --git a/test/fixtures/cp06-context/docs/STYLE.md b/test/fixtures/cp06-context/docs/STYLE.md new file mode 100644 index 0000000..814a96f --- /dev/null +++ b/test/fixtures/cp06-context/docs/STYLE.md @@ -0,0 +1,3 @@ +# Interface copy style + +Use direct labels and sentence-case actions. Empty-state copy says what the user can do next and never implies that a record already exists. diff --git a/test/fixtures/cp06-context/docs/UI.md b/test/fixtures/cp06-context/docs/UI.md new file mode 100644 index 0000000..a92922f --- /dev/null +++ b/test/fixtures/cp06-context/docs/UI.md @@ -0,0 +1,3 @@ +# Empty state UI + +The empty view has one visible heading, one actionable control, and a programmatic label. Keyboard focus reaches the action before decorative content. diff --git a/test/fixtures/cp06-context/oracle.json b/test/fixtures/cp06-context/oracle.json new file mode 100644 index 0000000..e2706c3 --- /dev/null +++ b/test/fixtures/cp06-context/oracle.json @@ -0,0 +1,16 @@ +{ + "schema_version": 1, + "rule": "PROJECT.md and ARCHITECTURE.md are relevant global authority; for each task only its declared target source is relevant topical authority; every other topical document is irrelevant.", + "tasks": [ + { "id": "COPY-001", "risk": "bounded", "target": "docs/STYLE.md" }, + { "id": "CONFIG-001", "risk": "bounded", "target": "docs/CONFIG.md" }, + { "id": "API-001", "risk": "normal", "target": "docs/API.md" }, + { "id": "PERSIST-001", "risk": "normal", "target": "docs/PERSISTENCE.md" }, + { "id": "UI-001", "risk": "normal", "target": "docs/UI.md" }, + { "id": "IMPORT-001", "risk": "normal", "target": "docs/IMPORT.md" }, + { "id": "LOG-001", "risk": "normal", "target": "docs/OBSERVABILITY.md" }, + { "id": "EXPORT-001", "risk": "normal", "target": "docs/EXPORT.md" }, + { "id": "DELETE-001", "risk": "critical", "target": "docs/DELETION.md" }, + { "id": "AUTH-001", "risk": "critical", "target": "docs/AUTH.md" } + ] +} diff --git a/test/fixtures/cp06-credential-mount.mjs b/test/fixtures/cp06-credential-mount.mjs new file mode 100644 index 0000000..723fecd --- /dev/null +++ b/test/fixtures/cp06-credential-mount.mjs @@ -0,0 +1,131 @@ +// Runs inside a disposable private user/mount namespace with the compiled +// helper inherited as fd 3. Uses literal DUMMY files only. +import { spawnSync } from "node:child_process"; +import { realpathSync } from "node:fs"; +import { + lstat, + mkdir, + mkdtemp, + readdir, + readFile, + rename, + rm, + symlink, + unlink, + writeFile, +} from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +import { mountLiveCredentialReadOnly } from "../../scripts/cp06-credential-isolation.mjs"; + +const [scenario] = process.argv.slice(2); +const marker = "DUMMY-EXTERNAL-MARKER"; +const credential = '{"fixture":"DUMMY","token":"DUMMY-NOT-REAL"}'; +const directory = realpathSync(await mkdtemp(join(tmpdir(), "factory-cp06-DUMMY-mount-"))); +const realSource = join(directory, "source", "DUMMY-auth.json"); +const source = + scenario === "source-ancestor-symlink" + ? join(directory, "linked", "source", "DUMMY-auth.json") + : realSource; +const target = + scenario === "target-ancestor-symlink" + ? join(directory, "home", "pi-agent", "DUMMY-auth.json") + : join(directory, "agent", "DUMMY-auth.json"); +const external = join(directory, "external"); +const externalLeaf = join(external, "DUMMY-auth.json"); +await mkdir(dirname(realSource)); +await mkdir(join(directory, "agent")); +await mkdir(external); +await writeFile(realSource, credential); +await writeFile(externalLeaf, marker); +if (scenario === "target-ancestor-symlink") await symlink(external, join(directory, "home")); +if (scenario === "source-ancestor-symlink") { + await mkdir(join(external, "source")); + await writeFile(join(external, "source", "DUMMY-auth.json"), marker); + await symlink(external, join(directory, "linked")); +} + +const markerReads = []; +async function substitute(leafPath) { + if (scenario === "target-leaf-symlink") { + await unlink(leafPath); + await symlink(externalLeaf, leafPath); + } else if (scenario === "target-leaf-replaced") { + await rename(leafPath, join(directory, "DUMMY-placeholder-moved")); + await writeFile(leafPath, "DUMMY substitute"); + } else if (scenario === "source-leaf-symlink") { + await rename(source, join(directory, "DUMMY-source-moved")); + await symlink(externalLeaf, source); + } else if (scenario === "target-parent-replaced" || scenario === "source-parent-replaced") { + const parent = dirname(scenario === "target-parent-replaced" ? target : source); + await rename(parent, `${parent}-moved`); + await symlink(external, parent); + } else if ( + !["control", "target-ancestor-symlink", "source-ancestor-symlink"].includes(scenario) + ) { + throw new Error("unsupported DUMMY scenario"); + } +} + +const result = { scenario }; +let mount; +try { + mount = await mountLiveCredentialReadOnly({ + root: process.cwd(), + source, + target, + mountHelper: `/proc/${process.pid}/fd/3`, + async beforeMount({ targetMountPath }) { + await substitute(targetMountPath); + }, + async lstatImpl(path, options) { + markerReads.push(await readFile(externalLeaf, "utf8")); + return lstat(path, options); + }, + }); + result.mounted = true; + result.target_bytes = await readFile(target, "utf8"); + try { + await writeFile(target, "DUMMY write"); + result.target_write = "allowed"; + } catch (error) { + result.target_write = error.code; + } + try { + await writeFile(join(dirname(target), "DUMMY-entry"), "DUMMY entry"); + result.parent_write = "allowed"; + } catch (error) { + result.parent_write = error.code; + } + result.integrity = await mount.verifyIntegrity(); + await mount.cleanup(); + result.cleanup = "pass"; + result.target_after_cleanup = await readFile(target, "utf8"); +} catch (error) { + result.mounted = mount !== undefined; + result.error = + error instanceof AggregateError + ? "aggregate" + : ["ELOOP", "ENOTDIR"].includes(error.code) + ? "no-follow-refused" + : error.message; +} +if (scenario.endsWith("-ancestor-symlink")) + result.external_entries = (await readdir(external)).sort(); +markerReads.push(await readFile(externalLeaf, "utf8")); +result.marker_unchanged = markerReads.every((value) => value === marker); +const originalSource = + scenario === "source-leaf-symlink" + ? join(directory, "DUMMY-source-moved") + : scenario === "source-parent-replaced" + ? join(directory, "source-moved", "DUMMY-auth.json") + : realSource; +result.source_unchanged = (await readFile(originalSource, "utf8")) === credential; +const listed = spawnSync("findmnt", ["--json", "--list", "--output", "TARGET"], { + encoding: "utf8", +}); +result.remaining_mounts = JSON.parse(listed.stdout).filesystems.filter(({ target: path }) => + path.startsWith(`${directory}/`), +).length; +await rm(directory, { recursive: true, force: true }); +console.log(JSON.stringify(result)); diff --git a/test/fixtures/cp06-probe-transition.mjs b/test/fixtures/cp06-probe-transition.mjs new file mode 100644 index 0000000..46672d0 --- /dev/null +++ b/test/fixtures/cp06-probe-transition.mjs @@ -0,0 +1,81 @@ +import { strict as assert } from "node:assert"; +import { + appendFile, + mkdir, + readFile, + rename, + symlink, + truncate, + writeFile, +} from "node:fs/promises"; +import { dirname, join, resolve } from "node:path"; +import { createProbeFixture } from "../../scripts/cp06-probe-fixture.mjs"; +import { + superviseCredentialChild, + supervisorFailureRecord, +} from "../../scripts/cp06-namespace-supervisor.mjs"; + +const [mode, scenario, sentinel] = process.argv.slice(2); +const root = process.cwd(); +const outputRoot = resolve(root, ".factory/state/cp06-correction/auth-security"); +if (!["unsafe-probe", "hardened-probe"].includes(mode)) process.exit(64); +const secretBefore = await readFile(sentinel); +let fixture; +let mounts = 0; +let launches = 0; +let failure; +if (["unchanged", "append", "truncate", "rewrite"].includes(scenario)) { + fixture = await createProbeFixture({ root, outputRoot }); + try { + assert.equal(fixture.sourceUnchanged(), true); + if (scenario === "append") await appendFile(fixture.source, " DUMMY appended"); + if (scenario === "truncate") await truncate(fixture.source, 5); + if (scenario === "rewrite") await writeFile(fixture.source, "DUMMY ALTERED!", { flag: "r+" }); + assert.equal(fixture.sourceUnchanged(), scenario === "unchanged"); + } finally { + await fixture.cleanup(); + } +} else { + try { + await superviseCredentialChild( + { + mode, + source: outputRoot, + target: "-", + helper: "/DUMMY-helper", + syscallProbe: "/DUMMY-probe", + }, + { + async createProbeFixture() { + fixture = await createProbeFixture({ root, outputRoot }); + if (scenario === "source-ancestor") { + const named = join(fixture.cwd, "source"); + await rename(named, join(fixture.cwd, "source-moved")); + await symlink(dirname(sentinel), named); + } else if (scenario === "target-slot") { + await symlink(sentinel, fixture.target); + } else throw new Error("unsupported DUMMY scenario"); + return fixture; + }, + mount() { + mounts++; + throw new Error("DUMMY mount must not run"); + }, + spawn() { + launches++; + throw new Error("DUMMY adversary must not run"); + }, + }, + ); + throw new Error("substituted DUMMY fixture was accepted"); + } catch (error) { + failure = supervisorFailureRecord(error); + assert.equal(failure.code, "CP06_ISOLATION_SETUP_FAILED"); + assert.equal(mounts, 0); + assert.equal(launches, 0); + } +} +assert.deepEqual(await readFile(sentinel), secretBefore); +console.log( + JSON.stringify({ mode, scenario, mounts, launches, failure, sentinel_unchanged: true }), +); diff --git a/test/fixtures/workflow/critical-auth-isolation.ts b/test/fixtures/workflow/critical-auth-isolation.ts new file mode 100644 index 0000000..ff9f732 --- /dev/null +++ b/test/fixtures/workflow/critical-auth-isolation.ts @@ -0,0 +1,75 @@ +export interface AuthResponse { + status: number; + body: Record; +} + +interface Item { + id: string; + owner: "account-a" | "account-b"; + title: string; +} + +const fixturePrincipals = new Map([ + ["fixture-token-a", "account-a"], + ["fixture-token-b", "account-b"], +]); + +export function createAuthIsolationFixture(): { + request(input: { + token?: string; + method: "GET" | "POST" | "PUT" | "DELETE"; + itemId: string; + title?: string; + requestedOwner?: string; + }): AuthResponse; + snapshot(): ReadonlyArray>; +} { + const itemKey = (owner: Item["owner"], id: string): string => `${owner}\u0000${id}`; + const items = new Map([ + [itemKey("account-a", "item-a"), { id: "item-a", owner: "account-a", title: "Private A" }], + [itemKey("account-b", "item-b"), { id: "item-b", owner: "account-b", title: "Private B" }], + ]); + + return { + request(input): AuthResponse { + const principal = input.token === undefined ? undefined : fixturePrincipals.get(input.token); + if (principal === undefined) { + return { status: 401, body: { error: "unauthorized" } }; + } + const key = itemKey(principal, input.itemId); + const item = items.get(key); + if (input.method === "POST") { + if (typeof input.title !== "string" || input.title.trim().length === 0) { + return { status: 400, body: { error: "invalid_title" } }; + } + if (item !== undefined) return { status: 409, body: { error: "already_exists" } }; + const created: Item = { + id: input.itemId, + owner: principal, + title: input.title.trim(), + }; + items.set(key, created); + return { status: 201, body: { id: created.id, title: created.title } }; + } + if (item === undefined) { + // Deliberately conceal whether another account owns this identifier. + return { status: 404, body: { error: "not_found" } }; + } + if (input.method === "GET") { + return { status: 200, body: { id: item.id, title: item.title } }; + } + if (input.method === "DELETE") { + items.delete(key); + return { status: 204, body: {} }; + } + if (typeof input.title !== "string" || input.title.trim().length === 0) { + return { status: 400, body: { error: "invalid_title" } }; + } + item.title = input.title.trim(); + return { status: 200, body: { id: item.id, title: item.title } }; + }, + snapshot(): ReadonlyArray> { + return [...items.values()].map((item) => ({ ...item })); + }, + }; +} diff --git a/test/fixtures/workflow/history/finding.json b/test/fixtures/workflow/history/finding.json new file mode 100644 index 0000000..9e29e8b --- /dev/null +++ b/test/fixtures/workflow/history/finding.json @@ -0,0 +1,11 @@ +{ + "schema_version": 1, + "finding_id": "P07-MALFORMED-JSON", + "acceptance_id": "P-07", + "reviewer": "CP-06 simulated task reviewer", + "scenario": "PUT /v1/profile receives malformed JSON", + "finding": "The normal API fixture throws SyntaxError instead of returning the stable 400 invalid_json response.", + "disposition": "repaired", + "repair": "Catch parse failure and return status 400 with error invalid_json.", + "retest": "Execute the same malformed-JSON behavioral assertion against the checked-in final fixture." +} diff --git a/test/fixtures/workflow/history/malformed-json-probe.mjs b/test/fixtures/workflow/history/malformed-json-probe.mjs new file mode 100644 index 0000000..23af151 --- /dev/null +++ b/test/fixtures/workflow/history/malformed-json-probe.mjs @@ -0,0 +1,7 @@ +import { strict as assert } from "node:assert"; +import { handleProfileRequest } from "./normal-api-before.mjs"; + +assert.deepEqual(handleProfileRequest({ method: "PUT", path: "/v1/profile", body: "not-json" }), { + status: 400, + body: { error: "invalid_json" }, +}); diff --git a/test/fixtures/workflow/history/normal-api-before.mjs b/test/fixtures/workflow/history/normal-api-before.mjs new file mode 100644 index 0000000..f478308 --- /dev/null +++ b/test/fixtures/workflow/history/normal-api-before.mjs @@ -0,0 +1,11 @@ +export function handleProfileRequest(input) { + if (input.path !== "/v1/profile") return { status: 404, body: { error: "not_found" } }; + if (input.method === "GET") return { status: 200, body: { display_name: "Fixture User" } }; + if (input.method !== "PUT") return { status: 405, body: { error: "method_not_allowed" } }; + const parsed = JSON.parse(input.body ?? ""); + const displayName = parsed?.display_name; + if (typeof displayName !== "string" || displayName.trim().length === 0) { + return { status: 400, body: { error: "invalid_profile" } }; + } + return { status: 200, body: { display_name: displayName.trim() } }; +} diff --git a/test/fixtures/workflow/normal-api.ts b/test/fixtures/workflow/normal-api.ts new file mode 100644 index 0000000..95bafda --- /dev/null +++ b/test/fixtures/workflow/normal-api.ts @@ -0,0 +1,35 @@ +export interface ApiResponse { + status: number; + body: Record; +} + +export function handleProfileRequest(input: { + method: string; + path: string; + body?: string; +}): ApiResponse { + if (input.path !== "/v1/profile") { + return { status: 404, body: { error: "not_found" } }; + } + if (input.method === "GET") { + return { status: 200, body: { display_name: "Fixture User" } }; + } + if (input.method !== "PUT") { + return { status: 405, body: { error: "method_not_allowed" } }; + } + + let parsed: unknown; + try { + parsed = JSON.parse(input.body ?? "") as unknown; + } catch { + return { status: 400, body: { error: "invalid_json" } }; + } + if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) { + return { status: 400, body: { error: "invalid_profile" } }; + } + const displayName = (parsed as Record).display_name; + if (typeof displayName !== "string" || displayName.trim().length === 0) { + return { status: 400, body: { error: "invalid_profile" } }; + } + return { status: 200, body: { display_name: displayName.trim() } }; +} diff --git a/test/workflow.test.ts b/test/workflow.test.ts new file mode 100644 index 0000000..4d70997 --- /dev/null +++ b/test/workflow.test.ts @@ -0,0 +1,168 @@ +import { strict as assert } from "node:assert"; +import { spawnSync } from "node:child_process"; +import { dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import test from "node:test"; +import { createAuthIsolationFixture } from "./fixtures/workflow/critical-auth-isolation.js"; +import { handleProfileRequest } from "./fixtures/workflow/normal-api.js"; + +const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), "../.."); + +test("normal API fixture preserves successful profile behavior", () => { + assert.deepEqual(handleProfileRequest({ method: "GET", path: "/v1/profile" }), { + status: 200, + body: { display_name: "Fixture User" }, + }); + assert.deepEqual( + handleProfileRequest({ + method: "PUT", + path: "/v1/profile", + body: JSON.stringify({ display_name: " Updated Name " }), + }), + { status: 200, body: { display_name: "Updated Name" } }, + ); +}); + +test("normal API fixture rejects malformed, invalid, unknown, and unsupported requests", () => { + assert.deepEqual(handleProfileRequest({ method: "PUT", path: "/v1/profile", body: "not-json" }), { + status: 400, + body: { error: "invalid_json" }, + }); + assert.deepEqual( + handleProfileRequest({ + method: "PUT", + path: "/v1/profile", + body: JSON.stringify({ display_name: " " }), + }), + { status: 400, body: { error: "invalid_profile" } }, + ); + assert.equal(handleProfileRequest({ method: "GET", path: "/v1/missing" }).status, 404); + assert.equal(handleProfileRequest({ method: "POST", path: "/v1/profile" }).status, 405); +}); + +test("retained P-07 finding fails before repair and passes through the final API", () => { + const before = spawnSync( + process.execPath, + ["test/fixtures/workflow/history/malformed-json-probe.mjs"], + { cwd: repoRoot, encoding: "utf8" }, + ); + assert.equal(before.status, 1, before.stdout + before.stderr); + assert.match(before.stdout + before.stderr, /SyntaxError/); + assert.deepEqual(handleProfileRequest({ method: "PUT", path: "/v1/profile", body: "not-json" }), { + status: 400, + body: { error: "invalid_json" }, + }); +}); + +test("critical auth fixture allows each account to observe its own item", () => { + const api = createAuthIsolationFixture(); + assert.deepEqual(api.request({ token: "fixture-token-a", method: "GET", itemId: "item-a" }), { + status: 200, + body: { id: "item-a", title: "Private A" }, + }); + assert.deepEqual(api.request({ token: "fixture-token-b", method: "GET", itemId: "item-b" }), { + status: 200, + body: { id: "item-b", title: "Private B" }, + }); +}); + +test("critical auth fixture rejects absent and forged fixture credentials", () => { + const api = createAuthIsolationFixture(); + assert.equal(api.request({ method: "GET", itemId: "item-a" }).status, 401); + assert.equal( + api.request({ token: "not-a-fixture-principal", method: "GET", itemId: "item-a" }).status, + 401, + ); +}); + +test("critical auth fixture conceals and preserves another account's item", () => { + const api = createAuthIsolationFixture(); + const before = api.snapshot(); + + assert.equal( + api.request({ token: "fixture-token-b", method: "GET", itemId: "item-a" }).status, + 404, + ); + assert.equal( + api.request({ + token: "fixture-token-b", + method: "PUT", + itemId: "item-a", + title: "Stolen", + requestedOwner: "account-b", + }).status, + 404, + ); + assert.equal( + api.request({ token: "fixture-token-b", method: "DELETE", itemId: "item-a" }).status, + 404, + ); + assert.deepEqual(api.snapshot(), before); +}); + +test("critical auth fixture prevents cross-tenant identifier collision probes", () => { + const api = createAuthIsolationFixture(); + const accountABefore = api + .snapshot() + .find((item) => item.owner === "account-a" && item.id === "item-a"); + + assert.deepEqual( + api.request({ + token: "fixture-token-b", + method: "POST", + itemId: "item-a", + title: "Private B with tenant-local id", + }), + { status: 201, body: { id: "item-a", title: "Private B with tenant-local id" } }, + ); + assert.deepEqual(api.request({ token: "fixture-token-a", method: "GET", itemId: "item-a" }), { + status: 200, + body: { id: "item-a", title: "Private A" }, + }); + assert.deepEqual(api.request({ token: "fixture-token-b", method: "GET", itemId: "item-a" }), { + status: 200, + body: { id: "item-a", title: "Private B with tenant-local id" }, + }); + assert.deepEqual( + api.snapshot().find((item) => item.owner === "account-a" && item.id === "item-a"), + accountABefore, + ); +}); + +test("critical auth fixture validates title before tenant-local collision state", () => { + const api = createAuthIsolationFixture(); + const before = api.snapshot(); + + assert.deepEqual( + api.request({ token: "fixture-token-b", method: "POST", itemId: "item-a", title: "" }), + { status: 400, body: { error: "invalid_title" } }, + ); + assert.deepEqual( + api.request({ token: "fixture-token-b", method: "POST", itemId: "unused", title: "" }), + { status: 400, body: { error: "invalid_title" } }, + ); + assert.deepEqual(api.snapshot(), before); +}); + +test("critical auth fixture derives new item ownership from the principal", () => { + const api = createAuthIsolationFixture(); + assert.equal( + api.request({ + token: "fixture-token-b", + method: "POST", + itemId: "item-new", + title: "Owned by B", + requestedOwner: "account-a", + }).status, + 201, + ); + assert.equal( + api.request({ token: "fixture-token-a", method: "GET", itemId: "item-new" }).status, + 404, + ); + assert.deepEqual(api.request({ token: "fixture-token-b", method: "GET", itemId: "item-new" }), { + status: 200, + body: { id: "item-new", title: "Owned by B" }, + }); + assert.equal(api.snapshot().find((item) => item.id === "item-new")?.owner, "account-b"); +});