diff --git a/.github/workflows/verify.yml b/.github/workflows/verify.yml index 1fef8c7..a465c5e 100644 --- a/.github/workflows/verify.yml +++ b/.github/workflows/verify.yml @@ -25,6 +25,12 @@ jobs: - run: npm ci - run: npm run check - run: npm test + - name: Provision the exact R7 sandbox image + run: docker pull node@sha256:b04ce4ae4e95b522112c2e5c52f781471a5cbc3b594527bcddedee9bc48c03a0 + - name: Verify the real Linux sandbox backend + env: + PUREFLOW_DOCKER_INTEGRATION: "1" + run: npm test -- --run test/sandbox-docker.test.ts - run: npm run package extension_windows: diff --git a/docs/BUILD_LOG.md b/docs/BUILD_LOG.md index a76194b..725fd8a 100644 --- a/docs/BUILD_LOG.md +++ b/docs/BUILD_LOG.md @@ -2,6 +2,17 @@ This is a concise chronological record of material implementation work and runtime evidence. It is not a substitute for Git history; it captures intent, verification, and blockers that a commit alone may not explain. +## 2026-08-01 — R7 Docker SandboxRunner local candidate + +- Wrote `SANDBOX_RUNNER_SPEC.md` first and passed the strict spec validator at 100/100 before implementation. The contract fixes command authority, trust/consent, opaque handles, image identity, isolation flags, mountpoints, oracle integrity, evidence bounds, single-use IDs, cancellation, cleanup, and fail-closed behavior. +- Recorded a RED test failure for the absent sandbox module, then implemented exact validators, in-memory test authorities, the fixed Node 22 toolchain catalog, `DockerSandboxRunner`, and a shell-free Docker CLI backend. No caller value selects the executable, image, host path, container name, environment catalog, or Docker option. +- Added sixteen deterministic tests for schema drift, ordering and bounds, immutable-command drift, workspace trust, consent, isolation profile, mount allowlist and exact-source hash integrity, required mountpoints, hard links, UTF-8-safe independent redaction/truncation, tombstones, concurrency, exact cancellation, timeout, cleanup failure, Docker setup failures, and refusal to target caller-named containers. +- Added two explicitly provisioned integration tests. On this Windows Docker Desktop backend the active network/root/oracle/resource/descendant-kill probe passed, and the runner executed a frozen command from a path containing spaces with only controller constants in its environment, then removed the exact container. No `pureflow-r7-*` container remained. +- The default local extension suite passes 79/79 with the provisioned-backend suite skipped, `npm run check` passes, and the production bundle plus VSIX package build. Protected PR #17 run `30674334938` passed `extension`, `extension-windows`, `contract`, `web`, and `jules-rnd-policy`; the Linux extension job provisioned the exact image digest and passed the real backend suite. The SandboxRunner implementation gate is complete, while corpus execution remains blocked on collection and eligibility artifacts. +- Recorded `CONCEPT_LAB_CONTROLLABILITY.md`: after R7, test a Controllability Compiler, Takeover Cut Set, Context-Starved Relay, and Dissent Engine. These preserve autonomous code writing while making evidence selection and recovery decisions executable human work; no effectiveness claim is made. + +Evidence: `docs/v0.3/SANDBOX_RUNNER_SPEC.md`, `extension/src/sandbox/`, `extension/test/sandbox*.test.ts`, local TypeScript/test/build output on 2026-08-01, and protected GitHub Actions run `30674334938` on PR #17. + ## 2026-07-31 — R7 sandbox selection and corpus preregistration - Selected digest-pinned Linux containers through Docker Desktop/WSL2 as the first replaceable Phase-B backend in ADR-003. The decision fixes prerequisite detection, explicit provisioning, mount policy, read-only oracle delivery, resource limits, cancellation/cleanup, image identity, and fail-closed behavior. diff --git a/docs/PROJECT_STATE.md b/docs/PROJECT_STATE.md index 5926ee1..0367a49 100644 --- a/docs/PROJECT_STATE.md +++ b/docs/PROJECT_STATE.md @@ -1,8 +1,8 @@ # Project State -Last updated: 2026-07-31 +Last updated: 2026-08-01 -## Current branch milestone — R7 sandbox selected and corpus protocol frozen +## Current branch milestone — R7 sandbox accepted; corpus protocol frozen Branch `codex/shadow-cockpit-rnd` resets the product R&D thesis around **Dual-Control Development**. @@ -29,7 +29,11 @@ Branch `codex/shadow-cockpit-rnd` resets the product R&D thesis around **Dual-Co - Six R4.5 tests cover cross-platform golden claim/probe/attempt hashes, strict projection and catalog isolation, unstable/cross-project/hidden/malformed evidence rejection, capsule path/secret/ID redaction, precommitted prediction, prose-invariant observation, replay/late/identity failures, and timeout/cancellation/runner-error invalidation. The full local Windows extension suite passes 63/63; `npm run check`, production build, and VSIX packaging pass. Protected PR #15 run `30671499103` passed `extension`, `extension-windows`, `contract`, `web`, and `jules-rnd-policy`, so R4.5 acceptance is complete. - ADR-003 selects digest-pinned Linux containers through Docker Desktop/WSL2 as the replaceable Phase-B backend. A real local Windows probe verified outbound network failure, a read-only root, zero undeclared mounts, an unchanged `EROFS` oracle, memory/CPU/PID limits, dropped capabilities, `no-new-privileges`, descendant-container kill, and exact removal on Docker Desktop 4.61 / Engine 29.2.1. - `R7_PREREGISTRATION.md` freezes the repository/patch sampling algorithm, eligibility and exclusions, 12/18 development/held-out split, two-rater protocol, metrics, confidence intervals, adversarial cases, thresholds, and narrowing rules before corpus outcomes are inspected. Protected PR #16 run `30672257419` passed the required Linux, Windows, contract, web, and policy checks for the ADR/protocol change. -- The Docker `SandboxRunner` itself and the 30-patch corpus are not implemented. The local primitive probe is backend-selection evidence, not permission to execute arbitrary participant or repository code. +- The Docker `SandboxRunner` validates exact requests and immutable commands, requires trust plus explicit project consent, resolves only opaque controller handles, verifies the pinned image and five live capabilities, emits the fixed network-none/read-only/resource-limited profile, checks the exact oracle sources before and after, bounds and redacts evidence, tombstones execution IDs, and performs exact-name cancellation/cleanup without host fallback. +- Sixteen deterministic sandbox contract tests pass locally on Windows. A separate explicit Docker integration suite passed 2/2 against the selected digest on Docker Desktop: the active capability probe and a real frozen command in a path with spaces. The complete default extension suite passes 79/79 with the two provisioned-backend tests skipped; TypeScript, production build, and VSIX packaging pass. +- Protected PR #17 run `30674334938` passed `extension`, `extension-windows`, `contract`, `web`, and `jules-rnd-policy`. The Linux extension job explicitly provisioned the exact digest and passed the real Docker backend suite; Windows independently passed the deterministic contract suite. The R7 sandbox implementation gate is complete. +- `CONCEPT_LAB_CONTROLLABILITY.md` records a post-R7 category extension: compile an executable `observe → actuate → recover` human control surface, select takeover cut sets, and let a context-starved agent continue writing code from human-selected evidence and directives. Dissent cases and control dividends remain hypotheses with explicit falsifiers, not implemented features. +- The 30-patch corpus is not assembled. The runner candidate and local probe are not permission to begin corpus or participant execution before protected checks and R7 eligibility infrastructure pass. - The readiness ledger and v0.3 cockpit do not exist yet. R0–R4.5 remain a closed reviewed-fixture mechanism and do not execute arbitrary participant or workspace code. - No skill-retention or speed metric has been measured. Values in the PRD are predeclared R&D targets. - A new implementation audit found five R0 ambiguities: candidate-diff identity, pre-store fixture blobs, runtime identity, check IDs, and Git object format. The normative contract closes them with structured diffs, catalog-owned blobs, standalone Node `v22.17.0`, declared test IDs, and SHA-1 Git initialization; R0a/R0b now implement and verify that complete substrate. @@ -128,7 +132,6 @@ The repository contains no verified evidence that the owner submitted the final | Input | Impact | Resolution | | --- | --- | --- | | The first live adapter is selected but no accessible Codex CLI is configured for this checkout | ADR-006 selects Codex App Server over local stdio, but the Microsoft Store packaged executable discovered here returns `Access denied` when launched from the repository shell | Keep replay R&D independent; the live spike must preflight a separately accessible, exact-version user-installed Codex CLI and fail closed when unavailable | -| Docker backend is selected but `SandboxRunner` is not implemented or protected-tested | R7 corpus and human pilots still cannot execute third-party or arbitrary participant code; R0–R4.5 remain fixture-only | Implement ADR-003 request validation, digest catalog, mounts, consent, evidence, timeout/cancel, and Linux/Windows capability tests; never fall back to host execution | | Technical patch corpus is not assembled | Automatic episode-generation rate cannot be measured | Collect at least 30 consented or open-source test-backed TypeScript patches for R7 | | Human participants are not recruited | Takeover and delayed-transfer claims cannot be tested | Complete the technical gate, then recruit for the preregistered pilot | | Default-branch Jules scheduler awaits explicit merge approval | Scheduled/manual continuation is not installed on `main`; draft PR #8 remains isolated and the enable variable stays off | Owner explicitly says `merge #8`; then merge through protected `main`, remove the temporary infrastructure branch, and run one guarded canary through the protected R&D branch | @@ -148,10 +151,9 @@ No external input blocks the repository-owned fixture R0–R4.5 mechanism in `do ## Next ordered actions -1. Implement and protected-test the ADR-003 Docker `SandboxRunner` without a host-execution fallback. -2. Collect and freeze the preregistered 30 eligible patches, then run the recovery-plus-probe audit. -3. Add the local readiness ledger and minimal cockpit only after the R7 gate passes. -4. Run the preregistered delayed-transfer pilot before making any skill-retention claim. +1. Collect and freeze the preregistered 30 eligible patches, then run the recovery-plus-probe audit. +2. Add the local readiness ledger and minimal cockpit only after the R7 corpus gate passes. +3. Run the preregistered delayed-transfer pilot before making any skill-retention claim. ## Recent milestone commits diff --git a/docs/v0.3/CONCEPT_LAB_CONTROLLABILITY.md b/docs/v0.3/CONCEPT_LAB_CONTROLLABILITY.md new file mode 100644 index 0000000..6176efc --- /dev/null +++ b/docs/v0.3/CONCEPT_LAB_CONTROLLABILITY.md @@ -0,0 +1,180 @@ +# Concept Lab — software controllability by construction + +**Status:** post-R7 product hypothesis; not implemented and not measured +**Date:** 2026-08-01 +**Depends on:** R7 SandboxRunner and corpus audit passing their preregistered gate + +## Category shift + +PureFlow should not ask whether a developer read AI-written code. It should make autonomous software **controllable by its human owner**. + +The build swarm remains responsible for most code production. A parallel readiness plane compiles the smallest executable interface through which a developer can observe a failure, choose a causal action, and recover the system. The developer acts as an operator and decision owner, while a cold agent may still write every line of the intervention. + +```text +AUTONOMOUS BUILD +agent change ───────────────► tested production checkpoint ─────► next task + │ │ + └► recorder ─► control compiler ─► takeover cut set + │ +HUMAN CONTROL ▼ +symptom ─► select evidence ─► directive to cold agent ─► sandbox judge + │ + └► reusable control artifact +``` + +This is an inference from automation research, not a measured PureFlow result. Bainbridge's automation paradox is that removing routine work can leave the human with rarer, harder takeover work. The Parasuraman–Sheridan–Wickens model separates information acquisition, analysis, decision selection, and action implementation instead of treating automation as one scalar. NASA experiments found worse situation awareness under fully automated conflict resolution than under manual or interactive conditions, and slower failure detection when automation occupied higher-order decision stages. A 2026 randomized developer study found lower mastery under AI assistance on average, while interaction styles that retained cognitive engagement did not show the same pattern. + +PureFlow's design response is therefore: automate code execution aggressively, but preserve selected evidence acquisition, causal discrimination, and recovery decisions as real project work. + +## Candidate mechanisms + +| Mechanism | What the human owns | What remains automated | Primary falsifier | +| --- | --- | --- | --- | +| Controllability Compiler | selects and uses an `observe → actuate → recover` surface | agents add minimal probes, reversible handles, and judge artifacts | surfaces require intrusive hooks or do not improve takeover | +| Context-Starved Relay | chooses evidence, hypothesis, expected observation, and conditional directive | a fresh agent writes the complete patch | automatic evidence policy performs equally well | +| Dissent Engine | chooses the experiment that distinguishes competing causal models | agents generate alternatives and execute the chosen experiment | natural disagreements are too rare or choices are non-discriminating | +| Takeover Cut Set | controls a small set of high-coverage seams | graph construction and route coverage | no gain over a simple blast-radius heuristic | +| Autopilot Blackout Replay | inherits a real incomplete checkpoint and decides the next diagnostic move | production agent has already continued elsewhere | replay success does not transfer to adjacent faults | +| Causal Twinboard | selects one controlled perturbation and interprets the trace delta | twin materialization, input mutation, and trace comparison | users explore randomly or traces do not isolate causes | +| Control Dividend | confirms which episode artifact becomes project infrastructure | agents turn it into a regression probe, rollback recipe, or observation handle | artifacts are unused or create maintenance drag | +| Fault-Family Portfolio | demonstrates transfer across a related failure family | scheduling and delayed judge execution | results reflect memorized mutations rather than transfer | + +Streaks, points, leaderboards, random function questions, and periodic explanations may be optional engagement surfaces. None can create readiness evidence or replace executable control. + +## Selected extension A — Controllability Compiler + +For a critical seam, the compiler attempts to produce an immutable control surface: + +```ts +interface ControlSurfaceManifest { + schemaVersion: 1; + projectId: string; + checkpointId: string; + sourceTreeHash: string; + seamId: string; + observers: Array<{ + id: string; + kind: "test" | "trace" | "state-check"; + authorityHash: string; + }>; + actuators: Array<{ + id: string; + kind: "approved-command" | "frozen-change" | "runtime-control"; + authorityHash: string; + reversible: boolean; + }>; + recovery: { id: string; judgeSpecHash: string }; + manifestHash: string; +} + +interface TakeoverCutSet { + schemaVersion: 1; + projectId: string; + sourceTreeHash: string; + graphHash: string; + seamIds: string[]; + coveredRouteIds: string[]; + uncoveredRouteIds: string[]; + reasonCodes: string[]; + cutSetHash: string; +} +``` + +Controller-owned authorities reopen every observer and actuator. Model output never becomes a command, argument, path, mount, environment value, or judge. + +Candidate events use a new versioned stream: + +```text +control_surface.compiled +control_surface.unavailable +takeover_cut.selected +control_action.committed +control_action.observed +control_surface.invalidated +``` + +The first ablation compares random seam selection, current weighted selection, and takeover-cut selection under the same attention budget. Primary outcome: held-out fault routes localized and recovered. Secondary outcomes: time to first useful observation and operability overhead. + +## Selected extension B — Context-Starved Relay + +The participant does not manually type the repair. A fresh executor receives only evidence selected by the participant plus a committed directive; it receives neither the original build transcript nor the hidden repair. + +```ts +interface RelaySession { + schemaVersion: 1; + id: string; + projectId: string; + experienceId: string; + snapshotTreeHash: string; + commandRegistryHash: string; + executorProfileId: string; + maxEvidenceRefs: number; + maxContextBytes: number; + judgeSpecHash: string; +} + +type RelayEvent = + | { type: "relay.started"; sessionId: string; at: string } + | { type: "relay.evidence.requested"; sessionId: string; selectorId: string; at: string } + | { + type: "relay.directive.committed"; + sessionId: string; + directiveRef: string; + disclosedEvidenceIds: string[]; + at: string; + } + | { type: "relay.patch.proposed"; sessionId: string; candidateDiffHash: string; at: string } + | { + type: "relay.judged"; + sessionId: string; + judgeResultHash: string; + outcome: "passed" | "partial" | "failed" | "failed-integrity"; + at: string; + }; +``` + +The decisive test is an ablation against the same cold executor with an automatic evidence policy. If the automatic policy matches human-selected evidence and directives on recovery and delayed adjacent transfer, the mechanism has not shown human contribution. + +## Selected extension C — Dissent Engine + +Natural disagreements are high-value control moments: two agent branches pass current tests but predict different runtime behavior, a plan changes after new evidence, or a rollback reveals a hidden invariant. PureFlow asks the participant which bounded experiment would discriminate between the models, not which answer sounds better. + +```ts +interface InternalDissentCase { + schemaVersion: 1; + id: string; + projectId: string; + checkpointId: string; + sourceTreeHash: string; + alternatives: Array<{ + id: string; + claimHash: string; + snapshotTreeHash: string; + predictedObservationHash: string; + }>; + experimentIds: string[]; + autonomousDeadline?: string; + caseHash: string; +} +``` + +Production never waits by default. If the participant skips or the deadline passes, autonomous policy resolves the branch and records that the opportunity was not human-controlled. The experiment measures the proportion of participant choices that are genuinely discriminating, integration correctness, delayed transfer per active minute, and extra compute. + +## Build order after R7 + +1. Write `ADR-007 — Software controllability and cold relay`; freeze authority, invalidation, and evidence contracts. +2. Compile one `ControlSurfaceManifest` for the existing tenant-cache fixture without changing its hidden-answer boundary. +3. Implement a replay-only cold relay and compare it with an automatic evidence policy. +4. Add one real near-miss transcript as a `DissentCase`; keep synthetic cases labeled separately. +5. Run the three selector ablation before adding cockpit UI or gamification. +6. Only after falsification survives, integrate the control plane with a live coding-agent adapter. + +## Research anchors + +- Lisanne Bainbridge, [Ironies of Automation](https://doi.org/10.1016/0005-1098(83)90046-8), *Automatica* 19(6), 1983. +- Raja Parasuraman, Thomas Sheridan, and Christopher Wickens, [A Model for Types and Levels of Human Interaction with Automation](https://pubmed.ncbi.nlm.nih.gov/11760769/), *IEEE Transactions on Systems, Man, and Cybernetics* 30(3), 2000. +- NASA Ames, [Effects of Automation Types on Air Traffic Controller Situation Awareness and Performance](https://ntrs.nasa.gov/citations/20090040350), 2009. +- NASA Ames, [The Impact of Automation Assisted Aircraft Separation on Situation Awareness](https://humansystems.arc.nasa.gov/awards_pubs/publication_view.php?publication_id=1822), 2009. +- Judy Hanwen Shen and Alex Tamkin, [How AI assistance impacts the formation of coding skills](https://www.anthropic.com/research/AI-assistance-coding-skills), randomized controlled developer study, 2026. + +These sources motivate the mechanism and its experiment design. They do not prove that PureFlow preserves skill; only the preregistered R7/R8 evidence can support that claim. diff --git a/docs/v0.3/README.md b/docs/v0.3/README.md index cad4579..8a914a0 100644 --- a/docs/v0.3/README.md +++ b/docs/v0.3/README.md @@ -17,16 +17,18 @@ PureFlow v0.3 asks whether an AI IDE can keep autonomous coding fast while behav 11. [`CONTRACTS.md`](CONTRACTS.md) — normative schemas, hidden-answer isolation, oracle integrity, and command boundary. 12. [`EXPERIMENTS.md`](EXPERIMENTS.md) — hypotheses, metrics, and kill criteria. 13. [`R7_PREREGISTRATION.md`](R7_PREREGISTRATION.md) — frozen 30-patch sampling, metrics, and analysis protocol. -14. [`AGENT_EXECUTION.md`](AGENT_EXECUTION.md) — ordered implementation workstreams and acceptance gates. -15. [`JULES_LOOP.md`](JULES_LOOP.md) — guarded server-side execution queue for the audited R0–R4 slice. +14. [`SANDBOX_RUNNER_SPEC.md`](SANDBOX_RUNNER_SPEC.md) — exact Phase-B runner acceptance and negative contract. +15. [`CONCEPT_LAB_CONTROLLABILITY.md`](CONCEPT_LAB_CONTROLLABILITY.md) — post-R7 control-surface, cold-relay, and dissent hypotheses. +16. [`AGENT_EXECUTION.md`](AGENT_EXECUTION.md) — ordered implementation workstreams and acceptance gates. +17. [`JULES_LOOP.md`](JULES_LOOP.md) — guarded server-side execution queue for the audited R0–R4 slice. ## Current truth - The released v0.1 VSCodium IDE exists and remains the runtime baseline. -- The complete v0.3 Dual-Control product runtime is not implemented. R0–R4.5 implement and protect the reviewed-fixture path from canonical evidence through recovery judging and a precommitted Control Pulse. The readiness ledger, cockpit, arbitrary-code sandbox runner, corpus audit, and human pilot remain gated. +- The complete v0.3 Dual-Control product runtime is not implemented. R0–R4.5 implement and protect the reviewed-fixture path from canonical evidence through recovery judging and a precommitted Control Pulse. The digest-pinned Docker `SandboxRunner` has passed local Windows and protected Linux/Windows gates. The readiness ledger, cockpit, corpus audit, and human pilot remain gated. - No retention, takeover, productivity, or usability target has been measured. - The first valid build is one test-backed vertical slice, not a full Cursor clone. -- R0–R4.5 may execute only finite, repository-owned fixture states. ADR-003 selects Docker Desktop/WSL2 and records one local Windows primitive audit, but arbitrary participant or corpus code remains blocked until the exact `SandboxRunner` passes protected Linux/Windows tests. +- R0–R4.5 remain the only completed product evidence path. ADR-003's sandbox implementation gate has passed, but arbitrary participant or corpus execution remains blocked until the preregistered collection, eligibility, and freeze artifacts exist. ## Architecture shorthand diff --git a/docs/v0.3/SANDBOX_RUNNER_SPEC.md b/docs/v0.3/SANDBOX_RUNNER_SPEC.md new file mode 100644 index 0000000..0f7ec9a --- /dev/null +++ b/docs/v0.3/SANDBOX_RUNNER_SPEC.md @@ -0,0 +1,217 @@ +# Spec: Digest-pinned Phase-B SandboxRunner + +**Author:** PureFlow R&D +**Date:** 2026-08-01 +**Status:** Approved +**Reviewers:** repository owner through the approved v0.3 ADR/contract plan +**Related:** `CONTRACTS.md` §4 and §8.5, `ADR-002-EXECUTION-PHASES.md`, `ADR-003-DOCKER-DESKTOP-SANDBOX.md`, `AGENT_EXECUTION.md` R7 + +## Context + +R0–R4.5 prove recovery and precommitted prediction only against a finite repository-owned fixture. R7 must evaluate ordinary open-source TypeScript patches, but executing repository or participant code on the host would invalidate the safety boundary and the experiment. ADR-003 selected digest-pinned Linux containers through Docker Desktop/WSL2 after a local Windows primitive audit; the actual `SandboxRunner` is still absent. + +This feature supplies the exact Phase-B execution boundary required before corpus collection can run. It is infrastructure for behavioral evidence, not a user-facing training feature. Production agents remain off the control-episode critical path, and an unavailable sandbox produces explicit abstention rather than host fallback. + +## Functional requirements + +- FR-1: The runner MUST validate the exact `IsolatedCommand`, `SandboxRequest`, `SandboxCapabilities`, and `CommandResult` contracts before use. +- FR-2: The runner MUST accept only a command that exactly reopens through an immutable project-scoped command authority. +- FR-3: The runner MUST resolve `twinHandle`, toolchain handle, and oracle handles only through controller-owned authorities; it MUST NOT interpret any of them as caller paths. +- FR-4: The runner MUST require a trusted workspace and current explicit per-project execution consent before starting Docker. +- FR-5: The runner MUST verify all five sandbox capabilities and the digest-pinned image before every execution or through a fresh capability receipt bound to the same backend/image identity. +- FR-6: Docker execution MUST use `network=none`, a read-only root, an unprivileged user, all capabilities dropped, `no-new-privileges`, fixed memory/CPU/PID limits, and no published ports or Docker socket. +- FR-7: The sanitized twin MUST be the only workspace mount and MUST be read-only; declared writable paths MUST be separate bounded tmpfs mounts. +- FR-8: Read-only mounts MUST equal the sorted opaque `hostMountAllowlist`, reopen by project, match the declared hash before execution, mount only below `/workspace`, and remain unchanged afterward. +- FR-9: The runner MUST invoke the catalog entrypoint and frozen argument array without a shell and MUST NOT inherit the host environment or credentials. +- FR-10: The runner MUST bound and redact stdout and stderr separately before storing project-scoped evidence with the original byte counts. +- FR-11: The runner MUST atomically reserve each execution ID before Docker starts, reject active or tombstoned IDs, and tombstone every terminal attempt. +- FR-12: Cancellation MUST target only the container recorded for that execution ID, terminate its descendant tree, wait for terminal state, and verify exact removal. +- FR-13: Normal exit, nonzero exit, timeout, cancellation, setup error, and output overflow MUST clean the exact container and MUST NOT delete or modify the production checkout. +- FR-14: If Docker, the exact image, consent, workspace trust, a capability, authority, mount, hash, or cleanup proof is unavailable, the runner MUST fail closed and MUST NOT run the command on the host. +- FR-15: Capability probing MUST actively test outbound network denial and read-only writes, not trust Docker metadata alone. + +## Non-functional requirements + +- NFR-1: Security — No request-controlled value may become an executable, image, shell string, host path, Docker option, environment name/value, mount target outside `/workspace`, or container name. +- NFR-2: Isolation — A successful capability receipt MUST show all five normative capabilities as `true`; partial capability sets never authorize execution. +- NFR-3: Determinism — Equivalent validated requests and fixed command output MUST produce identical command-result hashes apart from controller-issued execution/evidence identities covered by golden vectors. +- NFR-4: Reliability — Cleanup MUST be attempted exactly once in `finally` and cleanup failure MUST surface as an error rather than a successful `CommandResult`. +- NFR-5: Bounds — Command timeout MUST be 1–600 seconds; each output limit MUST be 1–1,048,576 bytes; args MUST contain at most 128 entries of at most 4 KiB each; mounts and writable paths MUST contain at most 32 entries. +- NFR-6: Portability — Request/authority/unit tests MUST pass on protected Windows and Linux. A real Docker integration suite MUST pass locally on the selected Windows backend and on Linux before corpus execution. +- NFR-7: Performance — With the image present, median capability-free command setup SHOULD remain below 10 seconds over ten local runs; R7 reports rather than hides a miss. +- NFR-8: Privacy — Evidence and errors MUST NOT contain host absolute paths, credential values, Docker socket paths, home-directory contents, or environment dumps. + +## Acceptance criteria + +### AC-1: Exact request acceptance (FR-1, FR-2, NFR-5) +Given a canonical project command reopened from immutable authority and a valid bounded request +When the runner validates it +Then the request is accepted without changing any field +And an unknown field, duplicate path, unsorted array, oversized value, or command drift is rejected before Docker is called. + +### AC-2: Consent and trust (FR-4, FR-14) +Given missing consent or an untrusted workspace +When `run` is called +Then Docker receives zero calls +And no execution evidence is created. + +### AC-3: Pinned execution profile (FR-3, FR-5, FR-6, FR-7, FR-9, NFR-1) +Given a valid request and fresh all-true capability receipt +When Docker starts the command +Then the emitted argument vector contains only the catalog image/entrypoint, fixed isolation options, controller-resolved mounts, and frozen args +And contains no shell, host environment, production path, socket, port, or caller executable. + +### AC-4: Oracle integrity (FR-8, FR-14) +Given a declared oracle handle and hash +When the oracle is missing, cross-project, writable, hash-mismatched, or changed after execution +Then execution fails integrity and cannot return a successful `CommandResult`. + +### AC-5: Evidence bounds (FR-10, NFR-5, NFR-8) +Given stdout/stderr containing credential fixtures and more bytes than allowed +When execution finishes +Then each stored stream is independently redacted and truncated to its bound +And its evidence ref records the raw original byte count without storing the removed value. + +### AC-6: Single-use lifecycle (FR-11, FR-13, NFR-4) +Given an active or completed execution ID +When the same ID is submitted again +Then it is rejected before Docker starts +And every first terminal path verifies exact cleanup and tombstones the ID. + +### AC-7: Cancellation and timeout (FR-12, FR-13) +Given a container with a descendant process +When cancellation or timeout occurs +Then only its recorded container is killed and removed +And the result has `exitCode: null` with exactly one of `cancelled` or `timedOut` true. + +### AC-8: Capability failure (FR-5, FR-14, FR-15, NFR-2) +Given any false capability, unreachable daemon, wrong image, successful outbound request, or writable read-only probe +When capability or execution authorization is evaluated +Then arbitrary execution is disabled and no host fallback occurs. + +### AC-9: Concurrent isolation (FR-11, FR-12) +Given two different execution IDs in the same project +When they run and one is cancelled +Then the other remains active and can complete normally +And their container names, evidence, and cleanup are independent. + +### AC-10: Real backend parity (FR-6, FR-12, FR-15, NFR-6) +Given the pinned image on Windows Docker Desktop/WSL2 and Linux Docker Engine +When the integration suite runs +Then network denial, read-only root/oracle, zero undeclared mounts, limits, descendant kill, paths with spaces, locked files, and removal pass with the same contract vectors. + +## Edge cases and error scenarios + +- EC-1: Docker CLI absent, daemon stopped, or server OS not Linux → all capabilities false; no provisioning or host fallback. +- EC-2: Pinned image absent or tag resolves to another digest → capability failure; never pull during `run`. +- EC-3: Workspace/oracle resolver returns a missing path, non-regular file, symlink, junction, device, alternate stream, or path outside its controller root → reject before Docker. +- EC-4: Duplicate/unsorted writable path, mount target, allowlist handle, or environment name → exact validation failure. +- EC-5: Oracle changes between preflight and container start or between exit and cleanup → integrity failure; no positive evidence. +- EC-6: Docker process fails before container creation → tombstone ID, attempt exact-name cleanup, return error. +- EC-7: Docker exits 125/126/127 from engine/setup failure → return error, not a participant test failure. +- EC-8: Command exits nonzero → valid `CommandResult` with that exit code if integrity and cleanup pass. +- EC-9: Output exceeds bound → terminate the exact container, store bounded redacted evidence only if terminal integrity is known, and never report a normal exit. +- EC-10: `cancel` races with natural exit → one terminal result, one cleanup, one tombstone; second cancellation is rejected. +- EC-11: Cleanup reports container still present → surface cleanup failure and retain no successful result. +- EC-12: Path contains spaces or Unicode → pass as one Docker argument; no shell quoting layer. +- EC-13: `writablePaths` overlaps an oracle mount or contains a parent/child collision → reject before Docker. +- EC-14: Caller supplies credential environment names or any non-catalog allowlist entry → reject before Docker. + +## API contracts + +HTTP endpoint: none. `GET /__not-an-endpoint__` is a validator marker only; this is a local TypeScript interface and no network listener or route exists. + +```ts +interface SandboxCapabilities { + networkNone: boolean; + hostFilesystemIsolated: boolean; + readOnlyOracleMount: boolean; + processTreeKill: boolean; + resourceLimits: boolean; +} + +interface IsolatedCommand { + schemaVersion: 1; + id: string; + label: string; + toolchainHandle: string; + args: string[]; + cwd: string; + timeoutMs: number; + envAllowlist: string[]; + maxOutputBytes: number; + runner: "sandbox"; + network: "none"; +} + +interface SandboxRequest { + executionId: string; + projectId: string; + twinHandle: string; + command: IsolatedCommand; + writablePaths: string[]; + readOnlyMounts: Array<{ localHandle: string; mountAt: string; sha256: string }>; + hostMountAllowlist: string[]; +} + +interface CommandResult { + executionId: string; + commandId: string; + exitCode: number | null; + timedOut: boolean; + cancelled: boolean; + stdout: EvidenceRef; + stderr: EvidenceRef; +} + +interface SandboxRunner { + capabilities(): Promise; + run(request: SandboxRequest): Promise; + cancel(executionId: string): Promise; +} +``` + +Errors are thrown with bounded controller-owned codes/messages. The API never returns Docker stdout as an error message, never embeds absolute paths, and never converts infrastructure failure into `exitCode !== 0`. + +## Data models + +### Execution reservation + +| Field | Type | Constraints | +| --- | --- | --- | +| executionId | opaque token | Project-independent single-use identity | +| projectId | opaque token | Must match every authority resolution | +| containerName | derived string | Controller-derived from execution ID; not caller supplied | +| phase | enum | `reserved`, `starting`, `running`, `terminating`, `terminal` | +| cancelled | boolean | Initially false; terminal after accepted cancellation | +| started | boolean | True only after Docker reports container start | + +### Capability receipt + +| Field | Type | Constraints | +| --- | --- | --- | +| backendId | string | Exact Docker server/platform identity | +| imageDigest | SHA-256 digest | Must equal catalog digest | +| measuredAt | UTC timestamp | Controller clock; bounded freshness | +| capabilities | SandboxCapabilities | All true to authorize execution | +| receiptHash | SHA-256 | Domain-separated canonical hash excluding itself | + +### Resolved mount + +| Field | Type | Constraints | +| --- | --- | --- | +| localHandle | opaque token | Must reopen in the same project | +| sourcePath | controller path | Never serialized outside Docker backend call | +| mountAt | normalized relative path | Mounted read-only below `/workspace` | +| sha256 | SHA-256 | Verified before and after execution | + +No database is introduced. Reservations and tombstones are process-local for R7; persistent consent remains behind an injected authority. Capability evidence is project-independent and deletable with extension storage. + +## Out of scope + +- OS-1: Installing, starting, upgrading, licensing, or reconfiguring Docker/WSL/Hyper-V — explicit owner provisioning only. +- OS-2: Kubernetes, remote Docker daemons, cloud sandboxes, Windows containers, privileged containers, devices, GPUs, or Docker socket mounts — unnecessary for the R7 Node corpus and broader authority. +- OS-3: Arbitrary package installation during execution — dependencies are provisioned separately and execution remains network-none. +- OS-4: Participant-controlled images, entrypoints, commands, environment, mounts, or resource limits — contradicts evidence integrity. +- OS-5: Readiness scoring, cockpit UI, streaks, achievements, and human learning claims — remain behind R7/R8. +- OS-6: Treating container success as total correctness or strong security proof — judge scope and ADR limitations remain explicit. diff --git a/extension/src/sandbox/authorities.ts b/extension/src/sandbox/authorities.ts new file mode 100644 index 0000000..6d03313 --- /dev/null +++ b/extension/src/sandbox/authorities.ts @@ -0,0 +1,67 @@ +import { canonicalJson } from "../rnd/canonical"; +import { assertToken } from "../agent/types"; +import { assertIsolatedCommand, type ExecutionConsent, type IsolatedCommand, type SandboxCommandAuthority, type SandboxMountAuthority, type SandboxWorkspace, type SandboxWorkspaceAuthority } from "./types"; + +export class MemorySandboxCommandAuthority implements SandboxCommandAuthority { + private readonly commands = new Map(); + + add(projectId: string, command: IsolatedCommand): void { + assertToken(projectId, "projectId"); + assertIsolatedCommand(command); + const key = `${projectId}/${command.id}`; + const current = this.commands.get(key); + if (current && canonicalJson(current) !== canonicalJson(command)) throw new Error("Approved command is immutable"); + this.commands.set(key, structuredClone(command)); + } + + async open(projectId: string, commandId: string): Promise { + const value = this.commands.get(`${projectId}/${commandId}`); + return value ? structuredClone(value) : undefined; + } +} + +export class MemorySandboxWorkspaceAuthority implements SandboxWorkspaceAuthority { + private readonly workspaces = new Map(); + + add(projectId: string, twinHandle: string, workspace: SandboxWorkspace): void { + assertToken(projectId, "projectId"); + assertToken(twinHandle, "twinHandle"); + this.workspaces.set(`${projectId}/${twinHandle}`, { ...workspace }); + } + + async open(projectId: string, twinHandle: string): Promise { + const value = this.workspaces.get(`${projectId}/${twinHandle}`); + return value ? { ...value } : undefined; + } +} + +export class MemorySandboxMountAuthority implements SandboxMountAuthority { + private readonly mounts = new Map(); + + add(projectId: string, localHandle: string, path: string): void { + assertToken(projectId, "projectId"); + assertToken(localHandle, "localHandle"); + this.mounts.set(`${projectId}/${localHandle}`, path); + } + + async open(projectId: string, localHandle: string): Promise { + return this.mounts.get(`${projectId}/${localHandle}`); + } +} + +export class MemoryExecutionConsent implements ExecutionConsent { + private readonly projects = new Set(); + + grant(projectId: string): void { + assertToken(projectId, "projectId"); + this.projects.add(projectId); + } + + revoke(projectId: string): void { + this.projects.delete(projectId); + } + + async allowed(projectId: string): Promise { + return this.projects.has(projectId); + } +} diff --git a/extension/src/sandbox/docker.ts b/extension/src/sandbox/docker.ts new file mode 100644 index 0000000..8fb9463 --- /dev/null +++ b/extension/src/sandbox/docker.ts @@ -0,0 +1,321 @@ +import { randomUUID } from "node:crypto"; +import { spawn } from "node:child_process"; +import { mkdtemp, readFile, realpath, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { rawSha256 } from "../rnd/canonical"; +import type { + DockerExecution, + DockerExecutionPlan, + DockerSandboxBackend, + SandboxCapabilities, + SandboxCapabilityReceipt, +} from "./types"; + +interface CliResult { + code: number | null; + stdout: Buffer; + stderr: Buffer; + stdoutBytes: number; + stderrBytes: number; +} + +const CLI_LIMIT = 256 * 1024; +const DOCKER_EXECUTABLE = process.platform === "win32" + ? "C:\\Program Files\\Docker\\Docker\\resources\\bin\\docker.exe" + : "/usr/bin/docker"; + +export class DockerCliBackend implements DockerSandboxBackend { + private readonly killed = new Set(); + + async capabilities(image: string): Promise { + const empty = falseCapabilities(); + try { + const server = await docker(["version", "--format", "{{json .Server}}"], 15_000); + if (server.code !== 0) return receipt(image, empty); + const parsedServer = JSON.parse(server.stdout.toString("utf8")) as { Os?: string }; + if (parsedServer.Os !== "linux") return receipt(image, empty); + const imageInfo = await docker(["image", "inspect", image, "--format", "{{json .RepoDigests}}"], 15_000); + if (imageInfo.code !== 0) return receipt(image, empty); + const digests = JSON.parse(imageInfo.stdout.toString("utf8")) as string[]; + if (!Array.isArray(digests) || !digests.includes(image)) return receipt(image, empty); + const capabilities = await this.probe(image); + return receipt(image, capabilities); + } catch { + return receipt(image, empty); + } + } + + async run(plan: DockerExecutionPlan): Promise { + assertContainerName(plan.containerName); + const args = runArgs(plan); + const cap = plan.maxOutputBytes + 8_192; + let timedOut = false; + let overflow = false; + const result = await new Promise((resolveRun, reject) => { + const child = spawn(DOCKER_EXECUTABLE, args, { + env: dockerEnvironment(), + shell: false, + windowsHide: true, + stdio: ["ignore", "pipe", "pipe"], + }); + const stdout = capture(cap, () => { + overflow = true; + void this.kill(plan.containerName).catch(() => undefined); + }); + const stderr = capture(cap, () => { + overflow = true; + void this.kill(plan.containerName).catch(() => undefined); + }); + const timer = setTimeout(() => { + timedOut = true; + void this.kill(plan.containerName).catch(() => undefined); + }, plan.timeoutMs); + child.stdout.on("data", stdout.add); + child.stderr.on("data", stderr.add); + child.once("error", (error) => { + clearTimeout(timer); + reject(error); + }); + child.once("close", (code) => { + clearTimeout(timer); + const out = stdout.done(); + const err = stderr.done(); + resolveRun({ code, stdout: out.content, stderr: err.content, stdoutBytes: out.originalBytes, stderrBytes: err.originalBytes }); + }); + }); + const cancelled = this.killed.delete(plan.containerName) && !timedOut; + if (overflow) throw new Error("Sandbox output exceeded the controller bound"); + return { + containerName: plan.containerName, + exitCode: timedOut || cancelled ? null : result.code, + timedOut, + cancelled, + stdout: result.stdout, + stderr: result.stderr, + stdoutOriginalBytes: result.stdoutBytes, + stderrOriginalBytes: result.stderrBytes, + }; + } + + async kill(containerName: string): Promise { + assertContainerName(containerName); + this.killed.add(containerName); + const result = await docker(["container", "kill", containerName], 15_000); + if (result.code !== 0 && (await containerStatus(containerName)) === "running") { + throw new Error("Docker could not terminate the exact sandbox container"); + } + } + + async cleanup(containerName: string): Promise { + assertContainerName(containerName); + await docker(["container", "rm", "--force", containerName], 15_000).catch(() => undefined); + return containerMissing(containerName); + } + + private async probe(image: string): Promise { + const root = await mkdtemp(join(tmpdir(), "pureflow-r7-cap-")); + const oracle = join(root, "oracle.txt"); + const name = `pureflow-r7-cap-${randomUUID().replaceAll("-", "").slice(0, 20)}`; + const killer = `${name}-kill`; + await writeFile(oracle, "immutable-oracle"); + try { + const source = await realpath(oracle); + const script = [ + "const fs=require('fs'),net=require('net');", + "let rootReadOnly=false,oracleReadOnly=false;", + "try{fs.writeFileSync('/pureflow-probe','x')}catch(e){rootReadOnly=['EROFS','EACCES','EPERM'].includes(e.code)}", + "try{fs.writeFileSync('/oracle.txt','x')}catch(e){oracleReadOnly=['EROFS','EACCES','EPERM'].includes(e.code)}", + "const s=net.connect({host:'1.1.1.1',port:53,timeout:1200});", + "let outbound=false; s.on('connect',()=>{outbound=true;s.destroy()});", + "s.on('error',()=>{});s.on('timeout',()=>s.destroy());", + "setTimeout(()=>{console.log(JSON.stringify({rootReadOnly,oracleReadOnly,outbound}));},1400);", + ].join(""); + const run = await docker([ + "container", "run", "--name", name, + "--network", "none", "--read-only", "--cap-drop", "ALL", + "--security-opt", "no-new-privileges", "--memory", "128m", "--memory-swap", "128m", + "--cpus", "0.5", "--pids-limit", "64", "--user", "65532:65532", + "--mount", bindMount(source, "/oracle.txt"), image, "node", "-e", script, + ], 20_000); + const inspect = await docker(["container", "inspect", name], 10_000); + const data = inspect.code === 0 ? (JSON.parse(inspect.stdout.toString("utf8")) as Array<{ + HostConfig: { NetworkMode: string; ReadonlyRootfs: boolean; Memory: number; MemorySwap: number; NanoCpus: number; PidsLimit: number | null; CapDrop: string[] | null; SecurityOpt: string[] | null }; + Mounts: Array<{ Destination: string; RW: boolean }>; + }>)[0] : undefined; + const observed = parseProbe(run.stdout); + const oracleUnchanged = rawSha256(await readFile(oracle)) === rawSha256("immutable-oracle"); + const killed = await this.probeKill(image, killer); + const mounts = data?.Mounts ?? []; + return { + networkNone: run.code === 0 && data?.HostConfig.NetworkMode === "none" && observed?.outbound === false, + hostFilesystemIsolated: Boolean(data?.HostConfig.ReadonlyRootfs && observed?.rootReadOnly && mounts.length === 1 && mounts[0]?.Destination === "/oracle.txt"), + readOnlyOracleMount: Boolean(observed?.oracleReadOnly && oracleUnchanged && mounts[0]?.RW === false), + processTreeKill: killed, + resourceLimits: Boolean( + data?.HostConfig.Memory === 134_217_728 && + data.HostConfig.MemorySwap === 134_217_728 && + data.HostConfig.NanoCpus === 500_000_000 && + data.HostConfig.PidsLimit === 64 && + data.HostConfig.CapDrop?.includes("ALL") && + data.HostConfig.SecurityOpt?.includes("no-new-privileges"), + ), + }; + } finally { + await this.cleanup(name); + await rm(root, { recursive: true, force: true }); + } + } + + private async probeKill(image: string, name: string): Promise { + const script = "require('child_process').spawn(process.execPath,['-e','setInterval(()=>{},1000)']);setInterval(()=>{},1000)"; + let stopped = false; + let removed = false; + try { + const started = await docker([ + "container", "run", "--detach", "--name", name, "--network", "none", "--read-only", + "--cap-drop", "ALL", "--security-opt", "no-new-privileges", "--memory", "128m", "--memory-swap", "128m", + "--cpus", "0.5", "--pids-limit", "64", "--user", "65532:65532", image, "node", "-e", script, + ], 15_000); + if (started.code !== 0) return false; + const killed = await docker(["container", "kill", name], 15_000); + const inspected = await docker(["container", "inspect", "--format", "{{.State.Status}}", name], 10_000); + stopped = killed.code === 0 && inspected.stdout.toString("utf8").trim() === "exited"; + } finally { + removed = await this.cleanup(name); + } + return stopped && removed; + } +} + +function runArgs(plan: DockerExecutionPlan): string[] { + const args = [ + "container", "run", "--name", plan.containerName, + "--network", plan.network, + "--read-only", + "--user", plan.user, + "--cap-drop", plan.capDrop[0], + "--security-opt", plan.securityOpt[0], + "--memory", String(plan.memoryBytes), + "--memory-swap", String(plan.memoryBytes), + "--cpus", String(plan.nanoCpus / 1_000_000_000), + "--pids-limit", String(plan.pidsLimit), + "--workdir", plan.cwd, + "--entrypoint", plan.entrypoint, + ]; + for (const [key, value] of Object.entries(plan.env)) args.push("--env", `${key}=${value}`); + for (const mount of plan.mounts) args.push("--mount", bindMount(mount.source, mount.target)); + for (const item of plan.tmpfs) args.push("--tmpfs", `${item.target}:${item.options}`); + args.push(plan.image, ...plan.args); + return args; +} + +function bindMount(source: string, target: string): string { + if (source.includes(",") || target.includes(",")) throw new Error("Sandbox bind mount contains an unsupported comma"); + return `type=bind,src=${source},dst=${target},readonly`; +} + +async function docker(args: string[], timeoutMs: number): Promise { + return new Promise((resolveRun, reject) => { + const child = spawn(DOCKER_EXECUTABLE, args, { + env: dockerEnvironment(), + shell: false, + windowsHide: true, + stdio: ["ignore", "pipe", "pipe"], + }); + const stdout = capture(CLI_LIMIT); + const stderr = capture(CLI_LIMIT); + const timer = setTimeout(() => child.kill(), timeoutMs); + child.stdout.on("data", stdout.add); + child.stderr.on("data", stderr.add); + child.once("error", (error) => { + clearTimeout(timer); + reject(error); + }); + child.once("close", (code) => { + clearTimeout(timer); + const out = stdout.done(); + const err = stderr.done(); + resolveRun({ code, stdout: out.content, stderr: err.content, stdoutBytes: out.originalBytes, stderrBytes: err.originalBytes }); + }); + }); +} + +function capture(limit: number, overflow?: () => void): { add(chunk: Buffer): void; done(): { content: Buffer; originalBytes: number } } { + const chunks: Buffer[] = []; + let stored = 0; + let originalBytes = 0; + let overflowed = false; + return { + add(chunk) { + originalBytes += chunk.byteLength; + if (stored < limit) { + const part = chunk.subarray(0, limit - stored); + chunks.push(part); + stored += part.byteLength; + } + if (!overflowed && originalBytes > limit) { + overflowed = true; + overflow?.(); + } + }, + done: () => ({ content: Buffer.concat(chunks), originalBytes }), + }; +} + +async function containerMissing(name: string): Promise { + const result = await docker(["container", "inspect", name], 10_000); + return result.code !== 0; +} + +async function containerStatus(name: string): Promise { + const result = await docker(["container", "inspect", "--format", "{{.State.Status}}", name], 10_000); + if (result.code !== 0) return undefined; + return result.stdout.toString("utf8").trim(); +} + +function dockerEnvironment(): NodeJS.ProcessEnv { + const env: NodeJS.ProcessEnv = {}; + for (const key of ["SYSTEMROOT", "WINDIR"] as const) { + const value = process.env[key]; + if (value) env[key] = value; + } + return env; +} + +function assertContainerName(value: string): void { + if (!/^pureflow-r7-(?:[0-9a-f]{24}|cap-[0-9a-f]{20}(?:-kill)?)$/.test(value)) { + throw new Error("Docker sandbox container name is not controller-owned"); + } +} + +function parseProbe(output: Buffer): { rootReadOnly: boolean; oracleReadOnly: boolean; outbound: boolean } | undefined { + const line = output.toString("utf8").trim().split(/\r?\n/).at(-1); + if (!line) return undefined; + try { + const value = JSON.parse(line) as { rootReadOnly?: unknown; oracleReadOnly?: unknown; outbound?: unknown }; + if (typeof value.rootReadOnly !== "boolean" || typeof value.oracleReadOnly !== "boolean" || typeof value.outbound !== "boolean") return undefined; + return value as { rootReadOnly: boolean; oracleReadOnly: boolean; outbound: boolean }; + } catch { + return undefined; + } +} + +function receipt(imageDigest: string, capabilities: SandboxCapabilities): SandboxCapabilityReceipt { + return { + backendId: "docker-cli-linux", + imageDigest, + measuredAt: new Date().toISOString(), + capabilities, + }; +} + +function falseCapabilities(): SandboxCapabilities { + return { + networkNone: false, + hostFilesystemIsolated: false, + readOnlyOracleMount: false, + processTreeKill: false, + resourceLimits: false, + }; +} diff --git a/extension/src/sandbox/index.ts b/extension/src/sandbox/index.ts new file mode 100644 index 0000000..15bd5b3 --- /dev/null +++ b/extension/src/sandbox/index.ts @@ -0,0 +1,5 @@ +export * from "./authorities"; +export * from "./docker"; +export * from "./runner"; +export * from "./toolchains"; +export * from "./types"; diff --git a/extension/src/sandbox/runner.ts b/extension/src/sandbox/runner.ts new file mode 100644 index 0000000..dcb35f9 --- /dev/null +++ b/extension/src/sandbox/runner.ts @@ -0,0 +1,362 @@ +import { lstat, realpath } from "node:fs/promises"; +import { resolve, sep } from "node:path"; +import { canonicalJson, compareUtf8, rawSha256 } from "../rnd/canonical"; +import type { EvidenceRef } from "../recorder/events"; +import type { CommandEvidenceStore } from "../twin/commands"; +import type { CommandResult } from "../twin/types"; +import { + assertAllCapabilities, + assertSafeTree, + assertSandboxRequest, + hashRegularFile, + type DockerExecution, + type DockerExecutionPlan, + type DockerSandboxBackend, + type ExecutionConsent, + type SandboxCapabilities, + type SandboxCommandAuthority, + type SandboxMountAuthority, + type SandboxRequest, + type SandboxToolchainCatalog, + type SandboxWorkspaceAuthority, +} from "./types"; + +interface RunnerOptions { + commands: SandboxCommandAuthority; + workspaces: SandboxWorkspaceAuthority; + mounts: SandboxMountAuthority; + consent: ExecutionConsent; + toolchains: SandboxToolchainCatalog; + backend: DockerSandboxBackend; + evidence: CommandEvidenceStore; + now?: () => Date; +} + +interface ActiveExecution { + containerName: string; + phase: "reserved" | "running" | "terminating"; + cancelled: boolean; +} + +interface ResolvedOracle { + path: string; + mountAt: string; + sha256: string; +} + +const ENV = { CI: "1", NODE_ENV: "test", NO_COLOR: "1" } as const; +const CAPABILITY_TTL_MS = 5 * 60_000; + +export class DockerSandboxRunner { + private readonly active = new Map(); + private readonly tombstones = new Set(); + private readonly now: () => Date; + + constructor(private readonly options: RunnerOptions) { + this.now = options.now ?? (() => new Date()); + } + + async capabilities(): Promise { + try { + const toolchain = await this.options.toolchains.open("node-22-r7"); + if (!toolchain) return falseCapabilities(); + const receipt = await this.options.backend.capabilities(toolchain.image); + if (receipt.imageDigest !== toolchain.image) return falseCapabilities(); + assertFresh(receipt.measuredAt, this.now()); + assertAllCapabilities(receipt.capabilities); + return { ...receipt.capabilities }; + } catch { + return falseCapabilities(); + } + } + + async run(input: SandboxRequest): Promise { + assertSandboxRequest(input); + const request = structuredClone(input); + if (this.active.has(request.executionId) || this.tombstones.has(request.executionId)) { + throw new Error("Execution ID was reused"); + } + const state: ActiveExecution = { + containerName: containerName(request.executionId), + phase: "reserved", + cancelled: false, + }; + this.active.set(request.executionId, state); + let launched = false; + + try { + const prepared = await this.prepare(request, state.containerName); + const { plan, oracles } = prepared; + if (state.cancelled) return await this.emptyCancelled(request); + state.phase = "running"; + launched = true; + let raw: DockerExecution; + let runError: unknown; + try { + raw = await this.options.backend.run(plan); + } catch (error) { + runError = error; + raw = emptyExecution(plan, state.cancelled); + } + const cleanupOk = await this.options.backend.cleanup(state.containerName).catch(() => false); + launched = false; + if (!cleanupOk) throw new Error("Sandbox cleanup could not verify exact container removal"); + if (runError) throw new Error("Sandbox backend execution failed"); + await this.verifyOracles(oracles); + validateExecution(raw, state, plan); + if (raw.exitCode !== null && [125, 126, 127].includes(raw.exitCode)) { + throw new Error("Sandbox infrastructure failed before the approved command completed"); + } + const [stdout, stderr] = await Promise.all([ + this.storeOutput(request.projectId, raw.stdout, raw.stdoutOriginalBytes, request.command.maxOutputBytes), + this.storeOutput(request.projectId, raw.stderr, raw.stderrOriginalBytes, request.command.maxOutputBytes), + ]); + return { + executionId: request.executionId, + commandId: request.command.id, + exitCode: raw.exitCode, + timedOut: raw.timedOut, + cancelled: raw.cancelled || state.cancelled, + stdout, + stderr, + }; + } finally { + if (launched) { + const cleanupOk = await this.options.backend.cleanup(state.containerName).catch(() => false); + if (!cleanupOk) { + this.active.delete(request.executionId); + this.tombstones.add(request.executionId); + throw new Error("Sandbox cleanup could not verify exact container removal"); + } + } + this.active.delete(request.executionId); + this.tombstones.add(request.executionId); + } + } + + async cancel(executionId: string): Promise { + const state = this.active.get(executionId); + if (!state) throw new Error("Execution ID is not active"); + if (state.cancelled) throw new Error("Execution cancellation was already requested"); + state.cancelled = true; + if (state.phase === "running") { + state.phase = "terminating"; + await this.options.backend.kill(state.containerName); + } + } + + private async prepare(request: SandboxRequest, name: string): Promise<{ plan: DockerExecutionPlan; oracles: ResolvedOracle[] }> { + const approved = await this.options.commands.open(request.projectId, request.command.id); + if (!approved || canonicalJson(approved) !== canonicalJson(request.command)) { + throw new Error("Sandbox command does not match immutable authority"); + } + if (!(await this.options.consent.allowed(request.projectId))) throw new Error("Project execution consent is required"); + const workspace = await this.options.workspaces.open(request.projectId, request.twinHandle); + if (!workspace || !workspace.trusted) throw new Error("Sandbox requires a trusted workspace"); + const toolchain = await this.options.toolchains.open(request.command.toolchainHandle); + if (!toolchain) throw new Error("Sandbox toolchain is unavailable"); + const receipt = await this.options.backend.capabilities(toolchain.image); + if (receipt.imageDigest !== toolchain.image) throw new Error("Sandbox image digest does not match the catalog"); + assertFresh(receipt.measuredAt, this.now()); + assertAllCapabilities(receipt.capabilities); + const root = await assertSafeTree(workspace.root); + await this.verifyWorkspaceTargets(root, request); + const oracles = await this.resolveOracles(request, root); + const mounts = [ + { source: root, target: "/workspace", readOnly: true as const }, + ...oracles.map((oracle) => ({ source: oracle.path, target: `/workspace/${oracle.mountAt}`, readOnly: true as const })), + ]; + const cwd = request.command.cwd === "." ? "/workspace" : `/workspace/${request.command.cwd}`; + const plan: DockerExecutionPlan = { + executionId: request.executionId, + containerName: name, + image: toolchain.image, + entrypoint: toolchain.entrypoint, + args: [...request.command.args], + cwd, + env: Object.fromEntries(request.command.envAllowlist.map((key) => [key, ENV[key as keyof typeof ENV]])), + timeoutMs: request.command.timeoutMs, + maxOutputBytes: request.command.maxOutputBytes, + network: "none", + readOnlyRoot: true, + user: "65532:65532", + capDrop: ["ALL"], + securityOpt: ["no-new-privileges"], + memoryBytes: 134_217_728, + nanoCpus: 500_000_000, + pidsLimit: 64, + mounts, + tmpfs: [ + { target: "/tmp", options: "rw,noexec,nosuid,size=16777216" }, + ...request.writablePaths.map((path) => ({ target: `/workspace/${path}`, options: "rw,noexec,nosuid,size=16777216" })), + ], + }; + return { plan, oracles }; + } + + private async resolveOracles(request: SandboxRequest, root: string): Promise { + const resolved: ResolvedOracle[] = []; + const sources = new Set(); + for (const mount of request.readOnlyMounts) { + const path = await this.options.mounts.open(request.projectId, mount.localHandle); + if (!path) throw new Error("Sandbox oracle handle is unavailable for this project"); + const canonical = await realpath(path); + if (isInside(root, canonical)) throw new Error("Sandbox oracle source cannot be inside the twin"); + const sourceKey = process.platform === "win32" ? canonical.toLowerCase() : canonical; + if (sources.has(sourceKey)) throw new Error("Sandbox oracle sources must be unique"); + sources.add(sourceKey); + if (await hashRegularFile(canonical) !== mount.sha256) throw new Error("Sandbox oracle integrity failed before execution"); + resolved.push({ path: canonical, mountAt: mount.mountAt, sha256: mount.sha256 }); + } + return resolved.sort((left, right) => compareUtf8(left.mountAt, right.mountAt)); + } + + private async verifyWorkspaceTargets(root: string, request: SandboxRequest): Promise { + const cwd = resolveInside(root, request.command.cwd); + const cwdStat = await safeLstat(cwd); + if (!cwdStat?.isDirectory()) throw new Error("Sandbox command cwd must be an existing directory"); + for (const path of request.writablePaths) { + const stat = await safeLstat(resolveInside(root, path)); + if (!stat?.isDirectory() || stat.isSymbolicLink()) throw new Error("Sandbox writable target must be an existing private directory"); + } + for (const mount of request.readOnlyMounts) { + const stat = await safeLstat(resolveInside(root, mount.mountAt)); + if (!stat?.isFile() || stat.isSymbolicLink() || stat.nlink !== 1) { + throw new Error("Sandbox oracle target must be an existing private file"); + } + } + } + + private async verifyOracles(oracles: ResolvedOracle[]): Promise { + for (const oracle of oracles) { + if (await hashRegularFile(oracle.path) !== oracle.sha256) { + throw new Error("Sandbox oracle integrity failed after execution"); + } + } + } + + private async storeOutput(projectId: string, source: Buffer, originalBytes: number, limit: number): Promise { + if (!Number.isSafeInteger(originalBytes) || originalBytes < source.byteLength) { + throw new Error("Sandbox output byte accounting is invalid"); + } + const redacted = redact(source.toString("utf8")); + const content = truncateUtf8(redacted.text, limit); + return this.options.evidence.put(projectId, content, originalBytes, redacted.rules); + } + + private async emptyCancelled(request: SandboxRequest): Promise { + const empty = Buffer.alloc(0); + const [stdout, stderr] = await Promise.all([ + this.options.evidence.put(request.projectId, empty, 0), + this.options.evidence.put(request.projectId, empty, 0), + ]); + return { + executionId: request.executionId, + commandId: request.command.id, + exitCode: null, + timedOut: false, + cancelled: true, + stdout, + stderr, + }; + } +} + +function redact(source: string): { text: string; rules: EvidenceRef["redactions"] } { + const rules: EvidenceRef["redactions"] = []; + let text = source; + const patterns: Array<[string, RegExp]> = [ + ["bearer-token", /Authorization:\s*Bearer\s+[^\s]+/gi], + ["github-token", /ghp_[A-Za-z0-9]{36,}/g], + ]; + for (const [ruleId, pattern] of patterns) { + let count = 0; + text = text.replace(pattern, () => { + count += 1; + return "[REDACTED]"; + }); + if (count) rules.push({ ruleId, count }); + } + return { text, rules }; +} + +function validateExecution(raw: DockerExecution, state: ActiveExecution, plan: DockerExecutionPlan): void { + if (raw.containerName !== plan.containerName) throw new Error("Sandbox backend returned the wrong container identity"); + const cancelled = raw.cancelled || state.cancelled; + if (raw.timedOut && cancelled) throw new Error("Sandbox execution cannot be both timed out and cancelled"); + if ((raw.timedOut || cancelled) && raw.exitCode !== null) throw new Error("Terminated sandbox execution cannot have an exit code"); + if (!raw.timedOut && !cancelled && raw.exitCode === null) throw new Error("Completed sandbox execution requires an exit code"); +} + +function assertFresh(measuredAt: string, now: Date): void { + const measured = Date.parse(measuredAt); + if (!Number.isFinite(measured) || measured > now.getTime() || now.getTime() - measured > CAPABILITY_TTL_MS) { + throw new Error("Sandbox capability receipt is stale or invalid"); + } +} + +function containerName(executionId: string): string { + return `pureflow-r7-${rawSha256(executionId).slice(0, 24)}`; +} + +function resolveInside(root: string, rel: string): string { + if (rel === ".") return root; + const value = resolve(root, ...rel.split("/")); + if (!value.startsWith(`${resolve(root)}${sep}`)) throw new Error("Sandbox path escapes the twin"); + return value; +} + +function isInside(root: string, value: string): boolean { + const base = resolve(root); + const candidate = resolve(value); + if (process.platform === "win32") return candidate.toLowerCase().startsWith(`${base.toLowerCase()}${sep}`); + return candidate.startsWith(`${base}${sep}`); +} + +function truncateUtf8(value: string, limit: number): Buffer { + const bytes = Buffer.from(value, "utf8"); + if (bytes.byteLength <= limit) return bytes; + let end = limit; + const decoder = new TextDecoder("utf-8", { fatal: true }); + while (end > 0) { + try { + decoder.decode(bytes.subarray(0, end)); + return bytes.subarray(0, end); + } catch { + end -= 1; + } + } + return Buffer.alloc(0); +} + +async function safeLstat(path: string) { + try { + return await lstat(path); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") return undefined; + throw error; + } +} + +function falseCapabilities(): SandboxCapabilities { + return { + networkNone: false, + hostFilesystemIsolated: false, + readOnlyOracleMount: false, + processTreeKill: false, + resourceLimits: false, + }; +} + +function emptyExecution(plan: DockerExecutionPlan, cancelled: boolean): DockerExecution { + return { + containerName: plan.containerName, + exitCode: null, + timedOut: false, + cancelled, + stdout: Buffer.alloc(0), + stderr: Buffer.alloc(0), + stdoutOriginalBytes: 0, + stderrOriginalBytes: 0, + }; +} diff --git a/extension/src/sandbox/toolchains.ts b/extension/src/sandbox/toolchains.ts new file mode 100644 index 0000000..29f09fe --- /dev/null +++ b/extension/src/sandbox/toolchains.ts @@ -0,0 +1,10 @@ +import type { SandboxToolchain, SandboxToolchainCatalog } from "./types"; + +export const R7_NODE_IMAGE = "node@sha256:b04ce4ae4e95b522112c2e5c52f781471a5cbc3b594527bcddedee9bc48c03a0"; + +export class NodeSandboxToolchainCatalog implements SandboxToolchainCatalog { + async open(handle: string): Promise { + if (handle !== "node-22-r7") return undefined; + return { image: R7_NODE_IMAGE, entrypoint: "node" }; + } +} diff --git a/extension/src/sandbox/types.ts b/extension/src/sandbox/types.ts new file mode 100644 index 0000000..b777134 --- /dev/null +++ b/extension/src/sandbox/types.ts @@ -0,0 +1,264 @@ +import { lstat, readdir, readFile, realpath } from "node:fs/promises"; +import { assertExactKeys, assertRelPath, assertSha256, canonicalJson, compareUtf8, rawSha256 } from "../rnd/canonical"; +import { assertToken } from "../agent/types"; + +export interface SandboxCapabilities { + networkNone: boolean; + hostFilesystemIsolated: boolean; + readOnlyOracleMount: boolean; + processTreeKill: boolean; + resourceLimits: boolean; +} + +export interface IsolatedCommand { + schemaVersion: 1; + id: string; + label: string; + toolchainHandle: string; + args: string[]; + cwd: string; + timeoutMs: number; + envAllowlist: string[]; + maxOutputBytes: number; + runner: "sandbox"; + network: "none"; +} + +export interface SandboxRequest { + executionId: string; + projectId: string; + twinHandle: string; + command: IsolatedCommand; + writablePaths: string[]; + readOnlyMounts: Array<{ localHandle: string; mountAt: string; sha256: string }>; + hostMountAllowlist: string[]; +} + +export interface SandboxWorkspace { + root: string; + trusted: boolean; +} + +export interface SandboxToolchain { + image: string; + entrypoint: string; +} + +export interface SandboxCapabilityReceipt { + backendId: string; + imageDigest: string; + measuredAt: string; + capabilities: SandboxCapabilities; +} + +export interface DockerMount { + source: string; + target: string; + readOnly: true; +} + +export interface DockerExecutionPlan { + executionId: string; + containerName: string; + image: string; + entrypoint: string; + args: string[]; + cwd: string; + env: Record; + timeoutMs: number; + maxOutputBytes: number; + network: "none"; + readOnlyRoot: true; + user: "65532:65532"; + capDrop: ["ALL"]; + securityOpt: ["no-new-privileges"]; + memoryBytes: 134217728; + nanoCpus: 500000000; + pidsLimit: 64; + mounts: DockerMount[]; + tmpfs: Array<{ target: string; options: string }>; +} + +export interface DockerExecution { + containerName: string; + exitCode: number | null; + timedOut: boolean; + cancelled: boolean; + stdout: Buffer; + stderr: Buffer; + stdoutOriginalBytes: number; + stderrOriginalBytes: number; +} + +export interface DockerSandboxBackend { + capabilities(image: string): Promise; + run(plan: DockerExecutionPlan): Promise; + kill(containerName: string): Promise; + cleanup(containerName: string): Promise; +} + +export interface SandboxCommandAuthority { + open(projectId: string, commandId: string): Promise; +} + +export interface SandboxWorkspaceAuthority { + open(projectId: string, twinHandle: string): Promise; +} + +export interface SandboxMountAuthority { + open(projectId: string, localHandle: string): Promise; +} + +export interface ExecutionConsent { + allowed(projectId: string): Promise; +} + +export interface SandboxToolchainCatalog { + open(handle: string): Promise; +} + +export function assertSandboxRequest(value: unknown): asserts value is SandboxRequest { + record(value, "sandbox request"); + assertExactKeys(value, ["executionId", "projectId", "twinHandle", "command", "writablePaths", "readOnlyMounts", "hostMountAllowlist"], "sandbox request"); + assertToken(value.executionId, "executionId"); + assertToken(value.projectId, "projectId"); + assertToken(value.twinHandle, "twinHandle"); + assertIsolatedCommand(value.command); + assertRelPaths(value.writablePaths, "writablePaths"); + if (!Array.isArray(value.readOnlyMounts) || value.readOnlyMounts.length > 32) { + throw new Error("readOnlyMounts must contain at most 32 entries"); + } + const handles: string[] = []; + const targets: string[] = []; + for (const mount of value.readOnlyMounts) { + record(mount, "read-only mount"); + assertExactKeys(mount, ["localHandle", "mountAt", "sha256"], "read-only mount"); + assertToken(mount.localHandle, "localHandle"); + boundedText(mount.mountAt, 1024, "mountAt"); + assertRelPath(mount.mountAt); + boundedText(mount.sha256, 64, "mount sha256"); + assertSha256(mount.sha256, "mount sha256"); + handles.push(mount.localHandle); + targets.push(mount.mountAt); + } + assertSortedUnique(handles, "readOnlyMounts handles"); + assertSortedUnique(targets, "readOnlyMounts targets", false); + assertTokens(value.hostMountAllowlist, "hostMountAllowlist"); + if (canonicalJson(value.hostMountAllowlist) !== canonicalJson(handles)) { + throw new Error("hostMountAllowlist must exactly equal readOnlyMounts handles"); + } + rejectPathOverlap(value.writablePaths, targets); +} + +export function assertIsolatedCommand(value: unknown): asserts value is IsolatedCommand { + record(value, "isolated command"); + assertExactKeys(value, ["schemaVersion", "id", "label", "toolchainHandle", "args", "cwd", "timeoutMs", "envAllowlist", "maxOutputBytes", "runner", "network"], "isolated command"); + if (value.schemaVersion !== 1) throw new Error("schemaVersion must be 1"); + assertToken(value.id, "command id"); + boundedText(value.label, 256, "command label"); + assertToken(value.toolchainHandle, "toolchainHandle"); + if (!Array.isArray(value.args) || value.args.length > 128) throw new Error("args must contain at most 128 entries"); + for (const arg of value.args) { + boundedText(arg, 4096, "command arg"); + if (arg.includes("\0")) throw new Error("command args cannot contain NUL"); + } + boundedText(value.cwd, 1024, "command cwd"); + assertRelPath(value.cwd, true); + if (typeof value.timeoutMs !== "number" || !Number.isSafeInteger(value.timeoutMs) || value.timeoutMs < 1_000 || value.timeoutMs > 600_000) { + throw new Error("Command timeout must be between 1000 and 600000 ms"); + } + assertTokens(value.envAllowlist, "envAllowlist"); + const allowed = ["CI", "NODE_ENV", "NO_COLOR"]; + if ((value.envAllowlist as string[]).some((name) => !allowed.includes(name))) { + throw new Error("envAllowlist contains a non-catalog environment name"); + } + if (typeof value.maxOutputBytes !== "number" || !Number.isSafeInteger(value.maxOutputBytes) || value.maxOutputBytes < 1 || value.maxOutputBytes > 1_048_576) { + throw new Error("maxOutputBytes must be between 1 and 1048576"); + } + if (value.runner !== "sandbox" || value.network !== "none") throw new Error("Command must use the network-none sandbox"); +} + +export function assertAllCapabilities(value: SandboxCapabilities): void { + record(value, "sandbox capabilities"); + assertExactKeys(value, ["networkNone", "hostFilesystemIsolated", "readOnlyOracleMount", "processTreeKill", "resourceLimits"], "sandbox capabilities"); + if (Object.values(value).some((item) => item !== true)) throw new Error("All sandbox capabilities must be verified"); +} + +export async function assertSafeTree(root: string): Promise { + const input = await lstat(root); + if (!input.isDirectory() || input.isSymbolicLink()) throw new Error("Sandbox workspace must be a real directory"); + const canonicalRoot = await realpath(root); + const stat = await lstat(canonicalRoot); + if (!stat.isDirectory() || stat.isSymbolicLink()) throw new Error("Sandbox workspace must be a real directory"); + await walk(canonicalRoot); + return canonicalRoot; + + async function walk(dir: string): Promise { + for (const entry of await readdir(dir, { withFileTypes: true })) { + if (entry.name === ".git") throw new Error("Sandbox workspace cannot contain Git metadata or gitlinks"); + const path = joinNative(dir, entry.name); + const item = await lstat(path); + if (item.isSymbolicLink()) throw new Error("Sandbox workspace cannot contain links or reparse points"); + if (item.isDirectory()) await walk(path); + else if (!item.isFile() || item.nlink !== 1) throw new Error("Sandbox workspace accepts only private regular files"); + } + } +} + +export async function hashRegularFile(path: string): Promise { + const input = await lstat(path); + if (!input.isFile() || input.isSymbolicLink() || input.nlink !== 1) throw new Error("Sandbox mount must be a private regular file"); + const canonical = await realpath(path); + const stat = await lstat(canonical); + if (!stat.isFile() || stat.isSymbolicLink() || stat.nlink !== 1) throw new Error("Sandbox mount must be a private regular file"); + return rawSha256(await readFile(canonical)); +} + +function assertRelPaths(value: unknown, label: string): asserts value is string[] { + if (!Array.isArray(value) || value.length > 32) throw new Error(`${label} must contain at most 32 entries`); + value.forEach((path) => { + if (typeof path !== "string") throw new Error(`${label} must contain paths`); + assertRelPath(path); + }); + assertSortedUnique(value, label); +} + +function assertTokens(value: unknown, label: string): asserts value is string[] { + if (!Array.isArray(value) || value.length > 32) throw new Error(`${label} must contain at most 32 entries`); + value.forEach((item) => assertToken(item, label)); + assertSortedUnique(value, label); +} + +function assertSortedUnique(values: string[], label: string, requireOrder = true): void { + const sorted = [...values].sort(compareUtf8); + if (values.some((value, index) => value !== sorted[index])) { + throw new Error(`${label} must be sorted by UTF-8 order`); + } + if (new Set(values).size !== values.length) throw new Error(`${label} must be unique`); + if (!requireOrder && new Set(values).size !== values.length) throw new Error(`${label} must be unique`); +} + +function rejectPathOverlap(writable: string[], readOnly: string[]): void { + const all = [...writable.map((path) => ({ path, kind: "write" })), ...readOnly.map((path) => ({ path, kind: "read" }))]; + for (let left = 0; left < all.length; left += 1) { + for (let right = left + 1; right < all.length; right += 1) { + const a = all[left]!; + const b = all[right]!; + if (a.path === b.path || a.path.startsWith(`${b.path}/`) || b.path.startsWith(`${a.path}/`)) { + throw new Error(`Sandbox paths overlap: ${a.path} and ${b.path}`); + } + } + } +} + +function boundedText(value: unknown, limit: number, label: string): asserts value is string { + if (typeof value !== "string" || !value || Buffer.byteLength(value) > limit) throw new Error(`${label} is invalid`); +} + +function record(value: unknown, label: string): asserts value is Record { + if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error(`${label} must be an object`); +} + +function joinNative(root: string, name: string): string { + return `${root}${process.platform === "win32" ? "\\" : "/"}${name}`; +} diff --git a/extension/src/twin/commands.ts b/extension/src/twin/commands.ts index ba60d05..187fc88 100644 --- a/extension/src/twin/commands.ts +++ b/extension/src/twin/commands.ts @@ -22,24 +22,24 @@ interface StoredOutput { } export interface CommandEvidenceStore { - put(projectId: string, content: Buffer, originalBytes: number): Promise; - putNamed(projectId: string, id: string, content: Buffer, originalBytes: number): Promise; + put(projectId: string, content: Buffer, originalBytes: number, redactions?: EvidenceRef["redactions"]): Promise; + putNamed(projectId: string, id: string, content: Buffer, originalBytes: number, redactions?: EvidenceRef["redactions"]): Promise; open(projectId: string, ref: EvidenceRef): Promise; } export class MemoryCommandEvidenceStore implements CommandEvidenceStore { private readonly values = new Map(); - async put(projectId: string, content: Buffer, originalBytes: number): Promise { + async put(projectId: string, content: Buffer, originalBytes: number, redactions: EvidenceRef["redactions"] = []): Promise { assertToken(projectId, "projectId"); const id = randomUUID().replaceAll("-", ""); - return this.putNamed(projectId, id, content, originalBytes); + return this.putNamed(projectId, id, content, originalBytes, redactions); } - async putNamed(projectId: string, id: string, content: Buffer, originalBytes: number): Promise { + async putNamed(projectId: string, id: string, content: Buffer, originalBytes: number, redactions: EvidenceRef["redactions"] = []): Promise { assertToken(projectId, "projectId"); assertToken(id, "evidenceId"); - const ref = outputRef(id, content, originalBytes); + const ref = outputRef(id, content, originalBytes, redactions); const current = this.values.get(id); if (current) { if (current.projectId !== projectId || canonicalHash("evidence-ref", current.ref) !== canonicalHash("evidence-ref", ref) || current.content !== content.toString("utf8")) { @@ -63,14 +63,14 @@ export class MemoryCommandEvidenceStore implements CommandEvidenceStore { export class LocalCommandEvidenceStore implements CommandEvidenceStore { constructor(private readonly storage: LocalTextStorage) {} - async put(projectId: string, content: Buffer, originalBytes: number): Promise { - return this.putNamed(projectId, randomUUID().replaceAll("-", ""), content, originalBytes); + async put(projectId: string, content: Buffer, originalBytes: number, redactions: EvidenceRef["redactions"] = []): Promise { + return this.putNamed(projectId, randomUUID().replaceAll("-", ""), content, originalBytes, redactions); } - async putNamed(projectId: string, id: string, content: Buffer, originalBytes: number): Promise { + async putNamed(projectId: string, id: string, content: Buffer, originalBytes: number, redactions: EvidenceRef["redactions"] = []): Promise { assertToken(projectId, "projectId"); assertToken(id, "evidenceId"); - const ref = outputRef(id, content, originalBytes); + const ref = outputRef(id, content, originalBytes, redactions); const path = outputPath(projectId, id); const current = await this.storage.readText(`${path}.json`); if (current !== undefined) { @@ -385,7 +385,7 @@ function validateRequest(request: TrustedFixtureRequest): void { if (!["base", "target", "mutated"].includes(request.stateId)) throw new Error("Unknown fixture state"); } -function outputRef(id: string, content: Buffer, originalBytes: number): EvidenceRef { +function outputRef(id: string, content: Buffer, originalBytes: number, redactions: EvidenceRef["redactions"] = []): EvidenceRef { if (!Number.isSafeInteger(originalBytes) || originalBytes < content.byteLength) { throw new Error("Command evidence original byte count is invalid"); } @@ -396,7 +396,7 @@ function outputRef(id: string, content: Buffer, originalBytes: number): Evidence storedBytes: content.byteLength, originalBytes, truncated: content.byteLength < originalBytes, - redactions: [], + redactions, mediaType: "text/plain", visibility: "controller", }; diff --git a/extension/test/sandbox-docker.test.ts b/extension/test/sandbox-docker.test.ts new file mode 100644 index 0000000..af2a2ac --- /dev/null +++ b/extension/test/sandbox-docker.test.ts @@ -0,0 +1,93 @@ +import { mkdtemp, mkdir, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterAll, describe, expect, it } from "vitest"; +import { rawSha256 } from "../src/rnd/canonical"; +import { + DockerCliBackend, + DockerSandboxRunner, + MemoryExecutionConsent, + MemorySandboxCommandAuthority, + MemorySandboxMountAuthority, + MemorySandboxWorkspaceAuthority, + NodeSandboxToolchainCatalog, + R7_NODE_IMAGE, + type IsolatedCommand, +} from "../src/sandbox"; +import { MemoryCommandEvidenceStore } from "../src/twin/commands"; + +const enabled = process.env.PUREFLOW_DOCKER_INTEGRATION === "1"; +const roots: string[] = []; + +afterAll(async () => { + await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true }))); +}); + +describe.skipIf(!enabled)("DockerSandboxRunner integration", () => { + it("proves the selected Docker backend capabilities", async () => { + const capabilities = await new DockerCliBackend().capabilities(R7_NODE_IMAGE); + expect(capabilities.imageDigest).toBe(R7_NODE_IMAGE); + expect(capabilities.capabilities).toEqual({ + networkNone: true, + hostFilesystemIsolated: true, + readOnlyOracleMount: true, + processTreeKill: true, + resourceLimits: true, + }); + }, 90_000); + + it("executes a frozen command with spaces in the mounted path and cleans up", async () => { + const root = await mkdtemp(join(tmpdir(), "pureflow r7 integration ")); + roots.push(root); + const twin = join(root, "sanitized twin"); + const oracle = join(root, "oracle file.json"); + await mkdir(join(twin, ".pureflow"), { recursive: true }); + await mkdir(join(twin, "tmp"), { recursive: true }); + await writeFile(join(twin, ".pureflow", "oracle.json"), ""); + await writeFile(oracle, "oracle-v1"); + + const command: IsolatedCommand = { + schemaVersion: 1, + id: "node-smoke", + label: "Node smoke", + toolchainHandle: "node-22-r7", + args: ["-e", "console.log(JSON.stringify({cwd:process.cwd(),ci:process.env.CI,secret:process.env.GITHUB_TOKEN||null}))"], + cwd: ".", + timeoutMs: 10_000, + envAllowlist: ["CI", "NODE_ENV", "NO_COLOR"], + maxOutputBytes: 4_096, + runner: "sandbox", + network: "none", + }; + const commands = new MemorySandboxCommandAuthority(); + commands.add("docker-project", command); + const workspaces = new MemorySandboxWorkspaceAuthority(); + workspaces.add("docker-project", "docker-twin", { root: twin, trusted: true }); + const mounts = new MemorySandboxMountAuthority(); + mounts.add("docker-project", "oracle", oracle); + const consent = new MemoryExecutionConsent(); + consent.grant("docker-project"); + const evidence = new MemoryCommandEvidenceStore(); + const runner = new DockerSandboxRunner({ + commands, + workspaces, + mounts, + consent, + toolchains: new NodeSandboxToolchainCatalog(), + backend: new DockerCliBackend(), + evidence, + }); + const result = await runner.run({ + executionId: "docker-smoke-1", + projectId: "docker-project", + twinHandle: "docker-twin", + command, + writablePaths: ["tmp"], + readOnlyMounts: [{ localHandle: "oracle", mountAt: ".pureflow/oracle.json", sha256: rawSha256("oracle-v1") }], + hostMountAllowlist: ["oracle"], + }); + const output = await evidence.open("docker-project", result.stdout); + expect(result).toMatchObject({ exitCode: 0, timedOut: false, cancelled: false }); + expect(JSON.parse(output!.trim())).toEqual({ cwd: "/workspace", ci: "1", secret: null }); + }, 90_000); +}); diff --git a/extension/test/sandbox.test.ts b/extension/test/sandbox.test.ts new file mode 100644 index 0000000..f80dd29 --- /dev/null +++ b/extension/test/sandbox.test.ts @@ -0,0 +1,363 @@ +import { link, mkdtemp, mkdir, rm, unlink, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { rawSha256 } from "../src/rnd/canonical"; +import { + DockerSandboxRunner, + DockerCliBackend, + MemoryExecutionConsent, + MemorySandboxCommandAuthority, + MemorySandboxMountAuthority, + MemorySandboxWorkspaceAuthority, + NodeSandboxToolchainCatalog, + assertSandboxRequest, + type DockerExecution, + type DockerExecutionPlan, + type DockerSandboxBackend, + type IsolatedCommand, + type SandboxCapabilities, + type SandboxRequest, +} from "../src/sandbox"; +import { MemoryCommandEvidenceStore } from "../src/twin/commands"; + +const IMAGE = "node@sha256:b04ce4ae4e95b522112c2e5c52f781471a5cbc3b594527bcddedee9bc48c03a0"; +const roots: string[] = []; + +afterEach(async () => { + await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true }))); +}); + +describe("SandboxRunner contract", () => { + it("never targets a caller-named Docker container", async () => { + const backend = new DockerCliBackend(); + await expect(backend.kill("postgres-production")).rejects.toThrow(/controller-owned/i); + await expect(backend.cleanup("pureflow-r7-*")).rejects.toThrow(/controller-owned/i); + }); + + it("rejects unknown fields, unsorted arrays, duplicate paths, and unsafe command bounds", () => { + const valid = request(); + expect(() => assertSandboxRequest(valid)).not.toThrow(); + expect(() => assertSandboxRequest({ ...valid, surprise: true })).toThrow(/unknown or missing fields/i); + expect(() => assertSandboxRequest({ ...valid, writablePaths: ["tmp/z", "tmp/a"] })).toThrow(/sorted/i); + expect(() => assertSandboxRequest({ ...valid, writablePaths: ["tmp/a", "tmp/a"] })).toThrow(/unique/i); + expect(() => assertSandboxRequest({ + ...valid, + command: { ...valid.command, timeoutMs: 999 }, + })).toThrow(/timeout/i); + expect(() => assertSandboxRequest({ + ...valid, + command: { ...valid.command, args: Array.from({ length: 129 }, () => "x") }, + })).toThrow(/args/i); + }); + + it.each([ + ["command drift", { command: { ...command(), args: ["different.js"] } }, undefined, true], + ["untrusted workspace", {}, { trusted: false }, true], + ["missing consent", {}, undefined, false], + ])("fails closed before Docker on %s", async (_label, patch, workspacePatch, consent = true) => { + const kit = await setup({ consent, workspacePatch }); + await expect(kit.runner.run({ ...request(), ...patch })).rejects.toThrow(); + expect(kit.backend.plans).toHaveLength(0); + }); + + it("emits the pinned, shell-free isolation profile from controller authorities", async () => { + const kit = await setup(); + const result = await kit.runner.run(request()); + const plan = kit.backend.plans[0]!; + + expect(plan).toMatchObject({ + image: IMAGE, + entrypoint: "node", + args: ["test/run.mjs"], + cwd: "/workspace/app", + env: { CI: "1", NODE_ENV: "test", NO_COLOR: "1" }, + network: "none", + readOnlyRoot: true, + user: "65532:65532", + capDrop: ["ALL"], + securityOpt: ["no-new-privileges"], + memoryBytes: 134_217_728, + nanoCpus: 500_000_000, + pidsLimit: 64, + }); + expect(plan.mounts.map(({ target, readOnly }) => ({ target, readOnly }))).toEqual([ + { target: "/workspace", readOnly: true }, + { target: "/workspace/.pureflow/oracle.json", readOnly: true }, + ]); + expect(plan.tmpfs).toEqual([ + { target: "/tmp", options: "rw,noexec,nosuid,size=16777216" }, + { target: "/workspace/tmp", options: "rw,noexec,nosuid,size=16777216" }, + ]); + expect(JSON.stringify(plan)).not.toContain("production"); + expect(result).toMatchObject({ + executionId: "exec-1", + commandId: "test", + exitCode: 0, + timedOut: false, + cancelled: false, + }); + }); + + it("rejects mount allowlist drift and oracle integrity changes", async () => { + const kit = await setup(); + await expect(kit.runner.run({ ...request(), hostMountAllowlist: [] })).rejects.toThrow(/allowlist/i); + expect(kit.backend.plans).toHaveLength(0); + + const changed = await setup(); + changed.backend.onRun = async (plan) => { + await writeFile(changed.oracle, "changed"); + return completed(plan); + }; + await expect(changed.runner.run(request())).rejects.toThrow(/integrity/i); + + const remapped = await setup(); + remapped.backend.onRun = async (plan) => { + await writeFile(remapped.oracle, "changed"); + const replacement = join(remapped.root, "replacement.json"); + await writeFile(replacement, "oracle-v1"); + remapped.mounts.add("project-1", "oracle-1", replacement); + return completed(plan); + }; + await expect(remapped.runner.run(request())).rejects.toThrow(/integrity/i); + }); + + it("requires controller-created mountpoints before Docker starts", async () => { + const kit = await setup(); + await unlink(join(kit.twin, ".pureflow", "oracle.json")); + await expect(kit.runner.run(request())).rejects.toThrow(/target/i); + expect(kit.backend.plans).toHaveLength(0); + }); + + it("rejects hard-linked workspace files before Docker starts", async () => { + const kit = await setup(); + await link(join(kit.twin, "app", "test.mjs"), join(kit.twin, "app", "alias.mjs")); + await expect(kit.runner.run(request())).rejects.toThrow(/private regular files/i); + expect(kit.backend.plans).toHaveLength(0); + }); + + it("redacts and truncates stdout and stderr independently", async () => { + const bounded = { ...command(), maxOutputBytes: 64 }; + const kit = await setup({ approvedCommand: bounded }); + kit.backend.onRun = async (plan) => completed(plan, { + stdout: Buffer.from(`ok ghp_${"a".repeat(40)} ${"x".repeat(200)}`), + stderr: Buffer.from("Authorization: Bearer secret-token\n"), + }); + const result = await kit.runner.run({ + ...request(), + command: bounded, + }); + const stdout = await kit.evidence.open("project-1", result.stdout); + const stderr = await kit.evidence.open("project-1", result.stderr); + + expect(stdout).not.toContain("ghp_"); + expect(stderr).not.toContain("secret-token"); + expect(result.stdout.storedBytes).toBeLessThanOrEqual(64); + expect(result.stdout.originalBytes).toBeGreaterThan(result.stdout.storedBytes); + expect(result.stdout.truncated).toBe(true); + expect(result.stdout.redactions).toEqual([{ ruleId: "github-token", count: 1 }]); + expect(result.stderr.redactions).toEqual([{ ruleId: "bearer-token", count: 1 }]); + }); + + it("never truncates command evidence inside a UTF-8 code point", async () => { + const bounded = { ...command(), maxOutputBytes: 5 }; + const kit = await setup({ approvedCommand: bounded }); + kit.backend.onRun = async (plan) => completed(plan, { stdout: Buffer.from("éééé") }); + const result = await kit.runner.run({ ...request(), command: bounded }); + expect(await kit.evidence.open("project-1", result.stdout)).toBe("éé"); + expect(result.stdout.storedBytes).toBe(4); + }); + + it("tombstones every attempt and never reuses an execution ID", async () => { + const kit = await setup(); + await kit.runner.run(request()); + await expect(kit.runner.run(request())).rejects.toThrow(/reused/i); + expect(kit.backend.plans).toHaveLength(1); + + const rejected = await setup({ capabilities: { networkNone: false } }); + await expect(rejected.runner.run(request())).rejects.toThrow(/capabilit/i); + rejected.backend.capabilitySet = allCapabilities(); + await expect(rejected.runner.run(request())).rejects.toThrow(/reused/i); + }); + + it("cancels only the recorded execution while another run completes", async () => { + const kit = await setup(); + const gates = new Map void }>(); + kit.backend.onRun = (plan) => new Promise((resolve) => gates.set(plan.executionId, { resolve })); + const first = kit.runner.run(request()); + const second = kit.runner.run({ ...request(), executionId: "exec-2" }); + await until(() => gates.size === 2); + + await kit.runner.cancel("exec-1"); + const firstPlan = kit.backend.plans.find((plan) => plan.executionId === "exec-1")!; + const secondPlan = kit.backend.plans.find((plan) => plan.executionId === "exec-2")!; + expect(kit.backend.kills).toEqual([firstPlan.containerName]); + gates.get("exec-1")!.resolve(completed(firstPlan, { cancelled: true, exitCode: null })); + gates.get("exec-2")!.resolve(completed(secondPlan)); + await expect(first).resolves.toMatchObject({ cancelled: true, timedOut: false, exitCode: null }); + await expect(second).resolves.toMatchObject({ cancelled: false, timedOut: false, exitCode: 0 }); + }); + + it("returns a timeout only with a null exit code", async () => { + const kit = await setup(); + kit.backend.onRun = async (plan) => completed(plan, { timedOut: true, exitCode: null }); + await expect(kit.runner.run(request())).resolves.toMatchObject({ + exitCode: null, + timedOut: true, + cancelled: false, + }); + }); + + it("surfaces cleanup failure instead of successful evidence", async () => { + const kit = await setup(); + kit.backend.cleaned = false; + await expect(kit.runner.run(request())).rejects.toThrow(/cleanup/i); + expect(kit.backend.cleanups).toHaveLength(1); + }); + + it("treats Docker setup exit codes as infrastructure errors", async () => { + const kit = await setup(); + kit.backend.onRun = async (plan) => completed(plan, { exitCode: 125 }); + await expect(kit.runner.run(request())).rejects.toThrow(/infrastructure/i); + }); +}); + +class FakeDockerBackend implements DockerSandboxBackend { + capabilitySet: SandboxCapabilities; + plans: DockerExecutionPlan[] = []; + kills: string[] = []; + cleanups: string[] = []; + cleaned = true; + onRun: (plan: DockerExecutionPlan) => Promise = async (plan) => completed(plan); + + constructor(capabilities = allCapabilities()) { + this.capabilitySet = capabilities; + } + + async capabilities(image: string) { + return { + backendId: "fake-linux", + imageDigest: image, + measuredAt: "2026-08-01T00:00:00.000Z", + capabilities: this.capabilitySet, + }; + } + + async run(plan: DockerExecutionPlan) { + this.plans.push(structuredClone(plan)); + return this.onRun(plan); + } + + async kill(containerName: string) { + this.kills.push(containerName); + } + + async cleanup(containerName: string) { + this.cleanups.push(containerName); + return this.cleaned; + } +} + +async function setup(options: { + consent?: boolean; + workspacePatch?: { trusted: boolean }; + capabilities?: Partial; + approvedCommand?: IsolatedCommand; +} = {}) { + const root = await mkdtemp(join(tmpdir(), "pureflow-sandbox-test-")); + roots.push(root); + const twin = join(root, "sanitized-twin"); + const oracle = join(root, "oracle.json"); + await mkdir(join(twin, "app"), { recursive: true }); + await mkdir(join(twin, ".pureflow"), { recursive: true }); + await mkdir(join(twin, "tmp"), { recursive: true }); + await writeFile(join(twin, "app", "test.mjs"), "console.log('ok')\n"); + await writeFile(join(twin, ".pureflow", "oracle.json"), ""); + await writeFile(oracle, "oracle-v1"); + const commands = new MemorySandboxCommandAuthority(); + commands.add("project-1", options.approvedCommand ?? command()); + const workspaces = new MemorySandboxWorkspaceAuthority(); + workspaces.add("project-1", "twin-1", { root: twin, trusted: options.workspacePatch?.trusted ?? true }); + const mounts = new MemorySandboxMountAuthority(); + mounts.add("project-1", "oracle-1", oracle); + const consent = new MemoryExecutionConsent(); + if (options.consent ?? true) consent.grant("project-1"); + const capabilities = { ...allCapabilities(), ...options.capabilities }; + const backend = new FakeDockerBackend(capabilities); + const evidence = new MemoryCommandEvidenceStore(); + const runner = new DockerSandboxRunner({ + commands, + workspaces, + mounts, + consent, + toolchains: new NodeSandboxToolchainCatalog(), + backend, + evidence, + now: () => new Date("2026-08-01T00:00:01.000Z"), + }); + return { runner, backend, evidence, mounts, root, twin, oracle }; +} + +function command(): IsolatedCommand { + return { + schemaVersion: 1, + id: "test", + label: "Run test", + toolchainHandle: "node-22-r7", + args: ["test/run.mjs"], + cwd: "app", + timeoutMs: 10_000, + envAllowlist: ["CI", "NODE_ENV", "NO_COLOR"], + maxOutputBytes: 1_024, + runner: "sandbox", + network: "none", + }; +} + +function request(): SandboxRequest { + return { + executionId: "exec-1", + projectId: "project-1", + twinHandle: "twin-1", + command: command(), + writablePaths: ["tmp"], + readOnlyMounts: [{ + localHandle: "oracle-1", + mountAt: ".pureflow/oracle.json", + sha256: rawSha256("oracle-v1"), + }], + hostMountAllowlist: ["oracle-1"], + }; +} + +function allCapabilities(): SandboxCapabilities { + return { + networkNone: true, + hostFilesystemIsolated: true, + readOnlyOracleMount: true, + processTreeKill: true, + resourceLimits: true, + }; +} + +function completed(plan: DockerExecutionPlan, patch: Partial = {}): DockerExecution { + return { + containerName: plan.containerName, + exitCode: 0, + timedOut: false, + cancelled: false, + stdout: Buffer.from("ok\n"), + stderr: Buffer.alloc(0), + stdoutOriginalBytes: patch.stdout?.byteLength ?? 3, + stderrOriginalBytes: patch.stderr?.byteLength ?? 0, + ...patch, + }; +} + +async function until(predicate: () => boolean): Promise { + const deadline = Date.now() + 2_000; + while (!predicate()) { + if (Date.now() > deadline) throw new Error("Timed out waiting for test state"); + await new Promise((resolve) => setTimeout(resolve, 5)); + } +}