From 7eaa86a7acd23709c26a1cf2add93174b56c29e3 Mon Sep 17 00:00:00 2001 From: joctaTorres Date: Thu, 9 Jul 2026 19:19:48 -0300 Subject: [PATCH] feat(runtime): reap agents on teardown, harden docker locus, honest isolation/permissions Phase 3 of the engine-runtime-hardening batch. - Deterministic process-group reaping across all three runtimes; sentinels relocated under .run/ and swept; realSpawner gains a timeout. - Docker locus runs as host uid with memory/pids/cpus/network knobs (new flat batch settings), documented isolation contract. - Manifest scope may only narrow permissions (no silent full-autonomy escalation). - Local agent env is allowlist-scoped (no host-secret leak to the agent). - batch config/view render real per-locus isolation + per-agent enforcement; doctor nudges the docker locus. Closes #79 Closes #85 Closes #87 Closes #86 Closes #88 Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_014hATZnT1NQP1STMr6pVxtm --- .../.run/mrdxzpsk-qt50cn/prompt.txt | 32 ++ .../engine-runtime-hardening/batch.yaml | 281 ++++++++++++++ .../changes/harden-docker-locus/.ratchet.yaml | 7 + .../configurable-knobs.feature | 51 +++ .../container-run-constraints.feature | 56 +++ .../isolation-contract-docs.feature | 16 + .ratchet/changes/harden-docker-locus/plan.md | 142 +++++++ .../reap-agents-on-teardown/.ratchet.yaml | 7 + .../process-group-launch.feature | 21 ++ .../teardown-reaping/sentinel-hygiene.feature | 21 ++ .../teardown-kills-agent.feature | 36 ++ .../changes/reap-agents-on-teardown/plan.md | 134 +++++++ .../.ratchet.yaml | 6 + .../config-isolation-per-locus.feature | 29 ++ .../doctor-local-locus-nudge.feature | 27 ++ .../posture-enforcement-rendering.feature | 38 ++ .../view-runtime-summary.feature | 10 + .../plan.md | 124 ++++++ .../.ratchet.yaml | 7 + .../apply-posture-banner.feature | 23 ++ .../escalation-opt-in.feature | 26 ++ .../narrow-only-resolution.feature | 41 ++ .../plan.md | 127 +++++++ .../scope-local-agent-env/.ratchet.yaml | 8 + .../agent-env-scoping/allowlist.feature | 45 +++ .../engine-spawn-env.feature | 29 ++ .../sidecar-bootstrap-env.feature | 27 ++ .../changes/scope-local-agent-env/plan.md | 114 ++++++ README.md | 4 +- docs/commands/doctor.md | 46 ++- src/cli/index.ts | 7 +- src/commands/batch/config.ts | 107 +++++- src/commands/batch/view.ts | 90 ++++- src/core/batch/config.ts | 235 +++++++++++- src/core/batch/engine/agent-env.ts | 153 ++++++++ .../batch/engine/runtime/rex-bootstrap.ts | 51 ++- src/core/batch/engine/runtime/test_sidecar.py | 329 +++++++++++++++- src/core/batch/permissions-policy.ts | 14 + src/core/batch/runtime/agent-permissions.ts | 111 +++++- src/core/batch/runtime/isolation.ts | 136 +++++++ src/core/doctor/checks/batch-isolation.ts | 76 ++++ src/core/doctor/index.ts | 6 + src/core/project-config.ts | 7 + test/batch-engine/agent-env.test.ts | 180 +++++++++ test/batch-engine/agent-permissions.test.ts | 90 ++++- test/batch-engine/engine-spawn-env.test.ts | 291 ++++++++++++++ test/batch-engine/isolation.test.ts | 114 ++++++ .../manifest-permission-escalation.test.ts | 356 ++++++++++++++++++ test/batch-engine/rex-bootstrap.test.ts | 152 +++++++- test/commands/batch/config.test.ts | 175 +++++++++ test/commands/batch/view.test.ts | 52 +++ test/core/batch/config.test.ts | 168 +++++++++ .../core/batch/permissions-resolution.test.ts | 35 +- test/core/doctor/doctor.test.ts | 137 ++++++- 54 files changed, 4552 insertions(+), 55 deletions(-) create mode 100644 .ratchet/batches/engine-runtime-hardening/.run/mrdxzpsk-qt50cn/prompt.txt create mode 100644 .ratchet/batches/engine-runtime-hardening/batch.yaml create mode 100644 .ratchet/changes/harden-docker-locus/.ratchet.yaml create mode 100644 .ratchet/changes/harden-docker-locus/features/docker-locus-hardening/configurable-knobs.feature create mode 100644 .ratchet/changes/harden-docker-locus/features/docker-locus-hardening/container-run-constraints.feature create mode 100644 .ratchet/changes/harden-docker-locus/features/docker-locus-hardening/isolation-contract-docs.feature create mode 100644 .ratchet/changes/harden-docker-locus/plan.md create mode 100644 .ratchet/changes/reap-agents-on-teardown/.ratchet.yaml create mode 100644 .ratchet/changes/reap-agents-on-teardown/features/teardown-reaping/process-group-launch.feature create mode 100644 .ratchet/changes/reap-agents-on-teardown/features/teardown-reaping/sentinel-hygiene.feature create mode 100644 .ratchet/changes/reap-agents-on-teardown/features/teardown-reaping/teardown-kills-agent.feature create mode 100644 .ratchet/changes/reap-agents-on-teardown/plan.md create mode 100644 .ratchet/changes/render-honest-isolation-and-enforcement/.ratchet.yaml create mode 100644 .ratchet/changes/render-honest-isolation-and-enforcement/features/runtime-honesty/config-isolation-per-locus.feature create mode 100644 .ratchet/changes/render-honest-isolation-and-enforcement/features/runtime-honesty/doctor-local-locus-nudge.feature create mode 100644 .ratchet/changes/render-honest-isolation-and-enforcement/features/runtime-honesty/posture-enforcement-rendering.feature create mode 100644 .ratchet/changes/render-honest-isolation-and-enforcement/features/runtime-honesty/view-runtime-summary.feature create mode 100644 .ratchet/changes/render-honest-isolation-and-enforcement/plan.md create mode 100644 .ratchet/changes/restrict-manifest-permission-escalation/.ratchet.yaml create mode 100644 .ratchet/changes/restrict-manifest-permission-escalation/features/manifest-permission-scope/apply-posture-banner.feature create mode 100644 .ratchet/changes/restrict-manifest-permission-escalation/features/manifest-permission-scope/escalation-opt-in.feature create mode 100644 .ratchet/changes/restrict-manifest-permission-escalation/features/manifest-permission-scope/narrow-only-resolution.feature create mode 100644 .ratchet/changes/restrict-manifest-permission-escalation/plan.md create mode 100644 .ratchet/changes/scope-local-agent-env/.ratchet.yaml create mode 100644 .ratchet/changes/scope-local-agent-env/features/agent-env-scoping/allowlist.feature create mode 100644 .ratchet/changes/scope-local-agent-env/features/agent-env-scoping/engine-spawn-env.feature create mode 100644 .ratchet/changes/scope-local-agent-env/features/agent-env-scoping/sidecar-bootstrap-env.feature create mode 100644 .ratchet/changes/scope-local-agent-env/plan.md create mode 100644 src/core/batch/engine/agent-env.ts create mode 100644 src/core/batch/runtime/isolation.ts create mode 100644 src/core/doctor/checks/batch-isolation.ts create mode 100644 test/batch-engine/agent-env.test.ts create mode 100644 test/batch-engine/engine-spawn-env.test.ts create mode 100644 test/batch-engine/isolation.test.ts create mode 100644 test/batch-engine/manifest-permission-escalation.test.ts diff --git a/.ratchet/batches/engine-runtime-hardening/.run/mrdxzpsk-qt50cn/prompt.txt b/.ratchet/batches/engine-runtime-hardening/.run/mrdxzpsk-qt50cn/prompt.txt new file mode 100644 index 0000000..a3f436a --- /dev/null +++ b/.ratchet/batches/engine-runtime-hardening/.run/mrdxzpsk-qt50cn/prompt.txt @@ -0,0 +1,32 @@ +You are advancing the ratchet batch "engine-runtime-hardening". +Perform EXACTLY ONE transition: APPLY for change "harden-docker-locus". +You MUST finish by running `ratchet batch report engine-runtime-hardening --change harden-docker-locus --complete ""` — without it this step is treated as unreported and parked. + +Active phase: lifecycle-and-isolation +Phase goal: Reap agents deterministically and make the sandbox/permission story honest: teardown kills the agent, not just the sidecar (#79); the docker locus stops being a root/unbounded/open-network mount (#85); posture output stops naming isolation that isn't there (#86); a repo-committed manifest can't silently escalate to full-autonomy (#87); and `batch config` renders per-agent enforcement truthfully (#88). DECOMPOSE (required): before authoring change intents, the decompose agent MUST `gh issue view` #79, #85, #86, #87, and #88 against the current tree and confirm each is still valid; adjust intents to reality (phase-1 env plumbing may have shifted #86) and record the check. PR: the phase PR MUST link `Closes #N` for every issue it actually fixes. +Phase success criteria: An overall timeout/teardown kills the agent's process group with no orphaned nohup/docker/remote agents and no leftover sentinel files; the docker locus runs as the host uid with memory/pids/network knobs and a documented isolation contract; `batch config` states the real isolation per locus and per-agent enforcement status; manifest scope can only NARROW permissions (raising posture requires operator-owned config or an explicit opt-in). The phase PR links every issue it fixes. +Phase proof-of-work (integration): run `npm test -- test/batch-engine/`, passes when exit code 0 — teardown-reaping, docker-hardening, posture-honesty, and manifest-escalation suites green +Definition of done: The docker locus (`docker_args` in sidecar.py) runs the agent as the host uid/gid (`--user $(id -u):$(id -g)`, overridable for images that need root setup), applies resource limits with sane defaults (`--memory`, `--pids-limit`, optional `--cpus`) overridable via batch settings, and makes network policy configurable (`network: bridge|none|`, default documented); the rw repo mount stays by design. `docs/engine/agent-runtime.md` documents the honest isolation contract (repo writable by design; uid, resources, env, and network per config — what the container does and does not protect). `gh issue view 85` confirmed OPEN and unfixed at decompose (2026-07-09). Fixes #85. + +Advance this change by invoking the ratchet apply skill — run: + /rct-apply harden-docker-locus Resume APPLY (OpenRouter limit was being raised). Implement harden-docker-locus per plan.md + docker-locus-hardening features (flat settings dockerUser/dockerMemory/dockerPidsLimit/dockerCpus/network threaded as REX_DOCKER_* for docker locus; apply --user/--memory/--pids-limit/--network/--cpus in sidecar.py docker_args; keep rw mount; docs; tests at each layer; run phase proof green), then report --complete. +It loads the project standards under ".ratchet/standards/" and is the single +author of the apply lifecycle. Do NOT hand-build or re-describe +the apply steps yourself — delegate to the skill and let it +author/advance the change to its canonical definition of done. +Anything after the change name above is the caller guidance / resume +context the engine already resolved — pass it to the skill as its +arguments ($ARGUMENTS); do not treat it as a separate, optional note. + +This step was previously parked on a blocker: + Question: Agent exited with code 0 without reporting completion or a blocker. +The resolved answer is attached to the invocation above as an argument — +incorporate it and continue the transition. Do not start over. + +Communicate ONLY by running these shell commands (do not prompt interactively): + ratchet batch report engine-runtime-hardening --change harden-docker-locus --status "" + ratchet batch report engine-runtime-hardening --change harden-docker-locus --blocker "" + ratchet batch report engine-runtime-hardening --change harden-docker-locus --needs-input "" + ratchet batch report engine-runtime-hardening --change harden-docker-locus --complete "" +Raise a blocker instead of guessing when a decision is required. +Post a completion ONLY when this single transition is genuinely finished. \ No newline at end of file diff --git a/.ratchet/batches/engine-runtime-hardening/batch.yaml b/.ratchet/batches/engine-runtime-hardening/batch.yaml new file mode 100644 index 0000000..97c877f --- /dev/null +++ b/.ratchet/batches/engine-runtime-hardening/batch.yaml @@ -0,0 +1,281 @@ +# Batch manifest — declarative intent for a multi-change effort. +# +# Objective: harden the ratchet batch ENGINE and rex RUNTIME by fixing GitHub +# issues #78-#91 (engine + rex-runtime), in dependency order. Each phase is a +# vertical slice a user can exercise via `ratchet batch apply` / `eval run` and +# ships behind an executable proof-of-work. Only phase 1 is decomposed into +# concrete change intents; phases 2-4 stay goal+proof and are decomposed lazily +# at phase entry with the prior phase's real results in hand. + +name: engine-runtime-hardening +created: 2026-07-09 + +settings: + # Stacked PRs: one PR per phase. Each phase PR is stacked on the previous + # phase's branch (see per-phase success criteria: the PR links every issue it + # fixes with `Closes #N`). + prGrouping: per-phase + # Per-stage agent routing (agent[:model]; spec splits on the FIRST colon, so + # the opencode provider/model id passes through intact). + agent: + propose: claude:fable + apply: opencode:openrouter/z-ai/glm-5.2 + verify: opencode:openrouter/z-ai/glm-5.2 + decompose: claude:opus + pr: claude:opus + +phases: + # ── Phase 1 — Wave 0: spawn/env plumbing (#89, #80) ──────────────────────── + - name: spawn-env-plumbing + goal: >- + Make the agent spawn seam trustworthy end to end: the per-step env the + engine builds actually reaches the spawned agent on both rex runtimes + (#89), and any RATCHET_BATCH_AGENT_CMD / RATCHET_EVAL_AGENT_CMD override is + loudly surfaced and audit-stamped (#80). This unblocks every later + env-based hardening step (#86 allowlist) and closes a live doc/code + contract violation. + PR: the phase PR MUST link `Closes #89` and `Closes #80`. + success: >- + Running `ratchet batch apply` / `eval run` with a per-step env var makes it + observable inside the spawned agent on the sidecar AND remote runtimes; an + active agent-cmd override prints a one-line notice (text output plus + `agentOverride: true` in `--json`) and stamps `via: env-override` + provenance into the journal entries/run records it produces; spawn-request + construction lives in one shared helper. The phase PR links Closes #89 and + Closes #80. + proofOfWork: + kind: integration + run: npm test -- test/batch-engine/rex-sidecar-runtime.test.ts test/batch-engine/rex-remote-runtime.test.ts + pass: exit code 0 — env threaded through both rex runtimes and override notice + provenance asserted + changes: + - name: thread-env-through-rex-runtimes + done: >- + Sidecar and remote rex runtimes export AgentSpawnRequest.env before + launching the agent; a test asserts a per-step env var set by the + engine is visible to the spawned command on BOTH runtimes; the + insecure/allowInsecure and env drift in docs/engine/agent-runtime.md is + corrected. Fixes #89. + - name: gate-and-mark-agent-cmd-override + after: [thread-env-through-rex-runtimes] + done: >- + An active RATCHET_BATCH_AGENT_CMD / RATCHET_EVAL_AGENT_CMD prints a + one-line override notice (text + `agentOverride: true` in `--json`) and + stamps `via: env-override` on the journal entries/run records produced + under it; the override+spawn-request construction is extracted into a + single shared helper (coordinates with the #67 triplication). Fixes #80. + + # ── Phase 2 — Wave 1: integrity gates (#78, #82, #81) ────────────────────── + - name: integrity-gates + goal: >- + Close the holes that let a batch advance a step without real evidence: + self-attested completions (#78), trivially-satisfiable proof-of-work + conditions (#82), and an `every-phase` gate that only parks propose (#81). + DECOMPOSE (required): before authoring change intents, the decompose agent + MUST run `gh issue view` on #78, #82, and #81 against the CURRENT tree and + confirm each is still open and unfixed; drop or trim any intent whose issue + phase 1 already resolved, and record that check in the change intents. + PR: the phase PR MUST link `Closes #N` for every issue it actually fixes. + success: >- + `ratchet batch report --complete` on propose/apply/verify is corroborated + against the disk evidence the engine already collects and fails closed + (blocked) on mismatch; empty `contains:`/`regex:` needles and + echo-your-own-pass-phrase proof conditions are rejected at manifest load; + the `every-phase` gate parks the transitions its docs now name. The phase + PR links every issue it fixes. + proofOfWork: + kind: integration + # Refined at phase entry to the specific outcome/proof-of-work/gate suites + # once the fixes name their test files. + run: npm test -- test/batch-engine/ + pass: exit code 0 — completion corroboration, proof-condition validation, and gate-matrix suites green + changes: + # DECOMPOSE check (2026-07-09): `gh issue view` #78, #82, #81 — all three + # confirmed OPEN and unfixed in the current tree after phase-1 + # (spawn-env-plumbing) shipped. Verified against code: mapSessionToOutcome + # (outcome.ts) advances on any completion entry without consulting the + # `diskEvidence` it already carries; evaluatePassCondition + # (proof-of-work.ts) does `stdout.includes(needle)` on empty needles and + # ProofOfWorkSchema (manifest.ts) only checks `pass` is non-empty; + # shouldParkForApproval (engine.ts) returns false for every transition but + # `propose`, so `every-phase` == `after-propose`. No intent dropped. + - name: corroborate-reported-completions + done: >- + `mapSessionToOutcome` no longer advances a step on a bare `--complete` + entry: a claimed completion is corroborated against the `diskEvidence` + the engine already collects (propose → plan/change dir exists; apply → + reported-complete tasks are checked/progressed; verify → the completion + carries the verify verdict), and a mismatch fails closed as `blocked` + with a "reported complete but disk disagrees" blocker; a completion + followed by a non-zero exit / signal is at least warned (arguably + blocked). Documented in outcome.ts. `gh issue view 78` confirmed OPEN + and unfixed at decompose (2026-07-09). Fixes #78. + - name: validate-proof-conditions-at-load + done: >- + Manifest load (ProofOfWorkSchema / the manifest validation step in + manifest.ts) rejects an empty `contains:` needle, an empty `regex:` + pattern, and an invalid `regex:` (surfacing the compile error), and + warns/rejects the echo-your-own-pass-phrase self-satisfying shape (the + literal pass needle appearing in the `run` command); the recorded proof + verdict persists which condition kind matched and the matched excerpt. + `gh issue view 82` confirmed OPEN and unfixed at decompose + (2026-07-09). Fixes #82. + - name: enforce-every-phase-gate-matrix + after: [corroborate-reported-completions] + done: >- + `shouldParkForApproval` threads the gate policy and transition instead + of hardcoding `propose`: `after-propose` parks propose only, + `every-phase` parks the transitions the docs now name (first step of + each phase, or every transition — one is picked and documented), + `autonomous` parks nothing; `docs/commands/batch.md` and the config + docs are updated so the gate's documented behavior matches the code, + and per-gate park scenarios are added. `gh issue view 81` confirmed + OPEN and unfixed at decompose (2026-07-09). Fixes #81. + + # ── Phase 3 — Wave 2: lifecycle & isolation (#79, #85, #86, #87, #88) ────── + - name: lifecycle-and-isolation + goal: >- + Reap agents deterministically and make the sandbox/permission story + honest: teardown kills the agent, not just the sidecar (#79); the docker + locus stops being a root/unbounded/open-network mount (#85); posture output + stops naming isolation that isn't there (#86); a repo-committed manifest + can't silently escalate to full-autonomy (#87); and `batch config` renders + per-agent enforcement truthfully (#88). + DECOMPOSE (required): before authoring change intents, the decompose agent + MUST `gh issue view` #79, #85, #86, #87, and #88 against the current tree + and confirm each is still valid; adjust intents to reality (phase-1 env + plumbing may have shifted #86) and record the check. + PR: the phase PR MUST link `Closes #N` for every issue it actually fixes. + success: >- + An overall timeout/teardown kills the agent's process group with no + orphaned nohup/docker/remote agents and no leftover sentinel files; the + docker locus runs as the host uid with memory/pids/network knobs and a + documented isolation contract; `batch config` states the real isolation per + locus and per-agent enforcement status; manifest scope can only NARROW + permissions (raising posture requires operator-owned config or an explicit + opt-in). The phase PR links every issue it fixes. + proofOfWork: + kind: integration + # Refined at phase entry; the rex e2e locus scripts (test/e2e/rex-*.sh) may + # join the runnable command once the teardown/sandbox fixes name them. + run: npm test -- test/batch-engine/ + pass: exit code 0 — teardown-reaping, docker-hardening, posture-honesty, and manifest-escalation suites green + changes: + # DECOMPOSE check (2026-07-09): `gh issue view` #79, #85, #86, #87, #88 — + # all five confirmed OPEN and valid in the current tree after phases 1-2 + # shipped. Verified against code: sidecar.py still launches the agent + # detached (`nohup bash -c … &`) with `ratchet-rex-.log/.done` + # sentinels in the workdir root and no pgid tracking, and `realSpawner` + # (agent.ts:366) has no timeout at all (#79); docker_args is only + # `-v mount_host:mount_container` — no `--user`/`--memory`/`--pids-limit`/ + # `--network` (#85); locus defaults to `local` (engine.ts:202), posture to + # `repo-sandboxed-permissive` (permissions-policy.ts:72) (#86/#87); + # `batch config` (config.ts:118) prints the posture bare with no isolation + # or per-agent enforcement status, and cursor emits no flags (#86/#88). + # REALITY ADJUSTMENT (#86): phase-1 (spawn-env-plumbing) threaded a + # per-step `AgentSpawnRequest.env` seam through both rex runtimes, but the + # local sidecar bootstrap (rex-bootstrap.ts:518) STILL spreads full + # `process.env` — so the env-allowlist half of #86 is unaddressed and is + # split into its own intent (`scope-local-agent-env`) that plugs into that + # new seam. #86's config/doctor/doc-honesty half is merged with #88 into + # `render-honest-isolation-and-enforcement` (same `batch config` surface). + # No intent dropped. + - name: reap-agents-on-teardown + done: >- + The agent is launched in its own process group (setsid / recorded + pgid) instead of a bare `nohup … &`, and teardown on ALL paths + (overall timeout, error, and clean shutdown) kills that group: the + sidecar attempts `{op:"shutdown"}` with a short grace then SIGKILLs the + group and gains a SIGTERM handler that stops the docker deployment, the + remote runtime kills the launched pid (via a runDir pidfile) before + closing the session, and `realSpawner` gains the sidecar's + timeout/kill semantics (or the eval judge is routed through the + timeout-aware runtime); `ratchet-rex-.log/.done` sentinels move + under `.ratchet/batches//.run//` and are swept in teardown. A + test asserts that after a timeout there are no orphaned + nohup/docker/remote agents and no leftover sentinel files. + `gh issue view 79` confirmed OPEN and unfixed at decompose + (2026-07-09). Fixes #79. + - name: harden-docker-locus + done: >- + The docker locus (`docker_args` in sidecar.py) runs the agent as the + host uid/gid (`--user $(id -u):$(id -g)`, overridable for images that + need root setup), applies resource limits with sane defaults + (`--memory`, `--pids-limit`, optional `--cpus`) overridable via batch + settings, and makes network policy configurable + (`network: bridge|none|`, default documented); the rw repo mount + stays by design. `docs/engine/agent-runtime.md` documents the honest + isolation contract (repo writable by design; uid, resources, env, and + network per config — what the container does and does not protect). + `gh issue view 85` confirmed OPEN and unfixed at decompose + (2026-07-09). Fixes #85. + - name: restrict-manifest-permission-escalation + done: >- + Permission-policy resolution lets the per-change/manifest scope only + NARROW permissions (add denies, lower posture) and NEVER raise posture + above the project/user-scope value; raising posture requires + operator-owned project/user config, or — if manifest-level raising is + retained — an explicit one-time confirmation that refuses in headless + mode without an `--allow-manifest-escalation` flag; `batch apply` + prints the effective posture and its source scope at the start of every + run. A test asserts a committed `batch.yaml` setting + `permissions.posture: full-autonomy` cannot silently escalate a run + whose project/user scope is `repo-sandboxed-permissive`. + `gh issue view 87` confirmed OPEN and unfixed at decompose + (2026-07-09). Fixes #87. + - name: scope-local-agent-env + done: >- + The local-locus agent spawn no longer inherits the full host + environment: the sidecar bootstrap that currently spreads full + `process.env` (rex-bootstrap.ts:518) passes only an allowlist (PATH, + HOME, adapter-required keys) layered over the per-step + `AgentSpawnRequest.env` seam that phase-1 threaded through the runtimes; + a test asserts a non-allowlisted host secret is NOT visible to the + spawned agent. This closes the env-scoping half of #86 (coordinates + with the same eval-judge fix in #59). `gh issue view 86` confirmed OPEN + at decompose (2026-07-09); phase-1 built the env seam but did not scope + it. Fixes #86 (env-scoping half). + - name: render-honest-isolation-and-enforcement + after: + [restrict-manifest-permission-escalation, scope-local-agent-env, harden-docker-locus] + done: >- + `batch config`/`batch view` states the REAL isolation per locus (local + = advisory, no filesystem/network isolation, env scoped to the + allowlist; docker = container isolation with the #85 uid/resource/ + network contract; remote = server boundary) and never displays an + unenforced posture bare — it renders per-agent enforcement status + (`claude: enforced via flags; cursor: NOT ENFORCED — agent defaults + apply`) and the escalation source + (`full-autonomy (set by batch manifest — repo-controlled)`); + `docs/engine/agent-runtime.md` documents the threat model per locus and + reframes the argv denylist as best-effort damage reduction (real + containment = docker locus); a `ratchet doctor` check nudges toward the + docker locus when batches run on `local` with a permissive/full-autonomy + posture (and/or probes that the agent's `--help` exposes the flags the + mapping uses). `gh issue view 86`/`88` confirmed OPEN at decompose + (2026-07-09). Fixes #86 (posture-honesty half) and #88. + + # ── Phase 4 — Wave 3: refactor & cleanup / direct-fixes (#83, #91, #90, #84) ─ + - name: refactor-and-cleanup + goal: >- + Consolidate duplicated engine/runtime logic and clear dead-code / doc + drift: one selection engine (#83), dedup shquote/tail-poll/command-builder + plus sidecar tests wired into CI (#91), bounded protocol buffers with + aligned cursor arithmetic and surfaced non-JSON frames (#90), and the + doc/code-drift + dead-branch sweep (#84). + DECOMPOSE (required): before authoring change intents, the decompose agent + MUST `gh issue view` #83, #91, #90, and #84 — several are cleanups that + earlier phases may have already absorbed (e.g. #90's cursor fix overlaps + #91's dedup); confirm each remaining item is still valid, drop what is done, + and record the check. + PR: the phase PR MUST link `Closes #N` for every issue it actually fixes. + success: >- + A single selection engine owns gate/eligibility (dead `selectRunnableStep` + removed); shquote/tail-poll/command-builder exist once with cross-language + contract tests and the sidecar test runs in CI; protocol partial-line + buffers are bounded, remote and sidecar byte cursors agree, and non-JSON + frames are surfaced not dropped; doc/code drift and dead branches are + removed. Full suite green. The phase PR links every issue it fixes. + proofOfWork: + kind: integration + run: npm test + pass: exit code 0 — full suite green after consolidation and cleanup diff --git a/.ratchet/changes/harden-docker-locus/.ratchet.yaml b/.ratchet/changes/harden-docker-locus/.ratchet.yaml new file mode 100644 index 0000000..fefa1b9 --- /dev/null +++ b/.ratchet/changes/harden-docker-locus/.ratchet.yaml @@ -0,0 +1,7 @@ +schema: ratchet +created: 2026-07-09 +standards: + - documentation + - testing + - generalizable-defaults + - multi-agent-support diff --git a/.ratchet/changes/harden-docker-locus/features/docker-locus-hardening/configurable-knobs.feature b/.ratchet/changes/harden-docker-locus/features/docker-locus-hardening/configurable-knobs.feature new file mode 100644 index 0000000..3b858a5 --- /dev/null +++ b/.ratchet/changes/harden-docker-locus/features/docker-locus-hardening/configurable-knobs.feature @@ -0,0 +1,51 @@ +Feature: Docker hardening knobs are batch settings + As a batch operator + I want the docker uid, resource limits, and network policy to be ordinary batch settings + So that I can tune them per project or per manifest through the same cascade as every other setting, with invalid values rejected before any container starts + + # New settings keys: `dockerUser`, `dockerMemory`, `dockerPidsLimit`, + # `dockerCpus`, and `network` (the issue-#85 vocabulary). They resolve + # through the standard nearest-wins cascade (default ← project ← manifest) + # and are threaded to the sidecar as REX_DOCKER_* environment values by + # `bootstrapRexRuntime`, exactly like `image` → REX_IMAGE. Defaults live in + # single TS constants (like DEFAULT_DOCKER_IMAGE); the host uid:gid default + # is computed at bootstrap time, not stored. + + Scenario: Setting a docker knob via batch config persists it + Given a project with a .ratchet/config.yaml + When the operator runs batch config --set with "network=none" + Then the project batch section persists network as "none" + And the resolved settings report network "none" sourced from the project scope + + Scenario Outline: Invalid knob values are rejected leaving the config unchanged + Given a project with a .ratchet/config.yaml + When the operator runs batch config --set with "=" + Then the command fails with an actionable error naming '' + And the config file is left unchanged + + Examples: + | key | value | + | dockerPidsLimit | zero | + | dockerPidsLimit | -5 | + | dockerCpus | many | + | dockerMemory | | + | dockerUser | | + | network | | + + Scenario: Bootstrap threads the configured knobs to the sidecar for the docker locus + Given resolved batch settings with locus "docker", dockerUser "0:0", dockerMemory "512m", dockerPidsLimit 128, dockerCpus 1.5, and network "none" + When the engine bootstraps the ReX runtime + Then the sidecar launch env carries REX_DOCKER_USER "0:0", REX_DOCKER_MEMORY "512m", REX_DOCKER_PIDS_LIMIT "128", REX_DOCKER_CPUS "1.5", and REX_DOCKER_NETWORK "none" + + Scenario: Bootstrap resolves defaults when no knob is configured + Given resolved batch settings with locus "docker" and no docker hardening knob configured + When the engine bootstraps the ReX runtime + Then the sidecar launch env carries REX_DOCKER_USER as ":" + And REX_DOCKER_MEMORY and REX_DOCKER_PIDS_LIMIT carry the documented defaults + And REX_DOCKER_NETWORK is "bridge" + And REX_DOCKER_CPUS is not set + + Scenario: The local locus is untouched by docker hardening env + Given resolved batch settings with locus "local" + When the engine bootstraps the ReX runtime + Then no REX_DOCKER_* variable is set in the sidecar launch env diff --git a/.ratchet/changes/harden-docker-locus/features/docker-locus-hardening/container-run-constraints.feature b/.ratchet/changes/harden-docker-locus/features/docker-locus-hardening/container-run-constraints.feature new file mode 100644 index 0000000..35ac351 --- /dev/null +++ b/.ratchet/changes/harden-docker-locus/features/docker-locus-hardening/container-run-constraints.feature @@ -0,0 +1,56 @@ +Feature: Docker locus container run constraints + As a batch operator + I want the docker locus to run the agent as the host user with resource and network constraints + So that a containerized agent cannot leave root-owned files on the repo mount, exhaust host resources, or use the network beyond the configured policy + + # `_make_deployment("docker")` in sidecar.py builds the `docker run` argv + # splice (`docker_args`). Today it passes only the `-v` repo mount; these + # scenarios harden everything around it. The rw repo mount itself stays by + # design (the agent must write code and the journal must propagate back). + # The Node side always threads resolved REX_DOCKER_* values; the sidecar's + # own defaults are pure unset-fallbacks (same contract as REX_IMAGE). + + Scenario: Container runs as the host uid and gid by default + Given REX_LOCUS is "docker" and REX_DOCKER_USER is not set + When the sidecar constructs the docker deployment + Then docker_args contains "--user" followed by ":" resolved from the sidecar process + And files the agent writes onto the bind mount are owned by the host user, not root + + Scenario: Configured user overrides the host uid default + Given REX_LOCUS is "docker" and REX_DOCKER_USER is "0:0" + When the sidecar constructs the docker deployment + Then docker_args contains "--user" followed by "0:0" + + Scenario: Memory and pids limits apply with sane defaults + Given REX_LOCUS is "docker" and neither REX_DOCKER_MEMORY nor REX_DOCKER_PIDS_LIMIT is set + When the sidecar constructs the docker deployment + Then docker_args contains "--memory" followed by the default memory limit + And docker_args contains "--pids-limit" followed by the default pids limit + + Scenario: Configured memory and pids limits override the defaults + Given REX_LOCUS is "docker" and REX_DOCKER_MEMORY is "512m" and REX_DOCKER_PIDS_LIMIT is "128" + When the sidecar constructs the docker deployment + Then docker_args contains "--memory" followed by "512m" + And docker_args contains "--pids-limit" followed by "128" + + Scenario: Cpus limit is applied only when configured + Given REX_LOCUS is "docker" and REX_DOCKER_CPUS is not set + When the sidecar constructs the docker deployment + Then docker_args contains no "--cpus" flag + But when REX_DOCKER_CPUS is "1.5" the deployment is constructed with "--cpus" followed by "1.5" + + Scenario Outline: Network policy is configurable with bridge as the default + Given REX_LOCUS is "docker" and REX_DOCKER_NETWORK is + When the sidecar constructs the docker deployment + Then docker_args contains "--network" followed by + + Examples: + | configured | effective | + | not set | "bridge" | + | "none" | "none" | + | "my-net" | "my-net" | + + Scenario: The repo bind mount stays read-write by design + Given REX_LOCUS is "docker" and REX_MOUNT_HOST names the project root + When the sidecar constructs the docker deployment + Then docker_args still contains "-v" followed by ":" with no ":ro" suffix diff --git a/.ratchet/changes/harden-docker-locus/features/docker-locus-hardening/isolation-contract-docs.feature b/.ratchet/changes/harden-docker-locus/features/docker-locus-hardening/isolation-contract-docs.feature new file mode 100644 index 0000000..ac0b60e --- /dev/null +++ b/.ratchet/changes/harden-docker-locus/features/docker-locus-hardening/isolation-contract-docs.feature @@ -0,0 +1,16 @@ +Feature: Honest docker isolation contract documentation + As a batch operator evaluating the docker locus + I want the reference docs to state exactly what the container does and does not protect + So that I never mistake the docker locus for a stronger sandbox than it is + + Scenario: agent-runtime.md documents the isolation contract + Given the docker locus applies uid, memory, pids, cpus, and network constraints + When a reader opens docs/engine/agent-runtime.md + Then the docker locus section documents an isolation contract stating the repo mount is writable by design + And it states the container user, resource limits, and network policy with their defaults and config keys + And it states what the container does NOT protect (the rw repo mount, and the network under the default bridge policy) + + Scenario: Configuration references enumerate the docker hardening keys + Given the settings vocabulary gained dockerUser, dockerMemory, dockerPidsLimit, dockerCpus, and network + When a reader opens docs/configuration/config-yaml.md and docs/commands/batch.md + Then each new key is listed with its type, default, and docker-locus scope diff --git a/.ratchet/changes/harden-docker-locus/plan.md b/.ratchet/changes/harden-docker-locus/plan.md new file mode 100644 index 0000000..2c8213f --- /dev/null +++ b/.ratchet/changes/harden-docker-locus/plan.md @@ -0,0 +1,142 @@ +# harden-docker-locus + +## Why + +The docker locus passes exactly one flag set to `docker run` — the rw repo bind +mount — so the "sandbox" runs the agent as in-container root (root-owned files +land on the host mount), with unrestricted outbound network and no resource +limits (issue #85). This change makes the container honest: host uid by +default, memory/pids/cpus limits, configurable network policy, and reference +docs that state exactly what the container does and does not protect. + +## What Changes + +Implements `features/docker-locus-hardening/*.feature`: + +- New batch settings — `dockerUser`, `dockerMemory`, `dockerPidsLimit`, + `dockerCpus`, `network` — resolved through the standard nearest-wins cascade + (default ← project ← manifest) and settable via `batch config --set` + (`container-run-constraints.feature`, `configurable-knobs.feature`). +- `bootstrapRexRuntime` threads the resolved knobs to the sidecar as + `REX_DOCKER_USER` / `REX_DOCKER_MEMORY` / `REX_DOCKER_PIDS_LIMIT` / + `REX_DOCKER_CPUS` / `REX_DOCKER_NETWORK` (docker locus only; `local` and + `remote` untouched), mirroring `image` → `REX_IMAGE`. +- `sidecar.py::_make_deployment("docker")` extends `docker_args` with + `--user` (host uid:gid default), `--memory`, `--pids-limit`, `--network` + (default `bridge`), and `--cpus` only when configured. The rw `-v` repo + mount stays by design (`container-run-constraints.feature`). +- `docs/engine/agent-runtime.md` gains an honest isolation-contract section + for the docker locus; `docs/configuration/config-yaml.md` and + `docs/commands/batch.md` enumerate the new keys + (`isolation-contract-docs.feature`). +- Not breaking: every knob has a default; existing docker-locus configs keep + working (they gain the hardened defaults). + +## Design + +**Flat scalar settings, not a structured `docker:` group.** The existing +locus-specific settings (`image`, `host`, `port`, `insecure`) are flat scalars +riding the generic `SETTING_KEYS` / `ALLOWED_VALUES` / `SETTING_CODECS` +machinery in `src/core/batch/config.ts`; five more flat keys reuse validation, +persistence, cascade, and `batch config --set` for free, where a structured +group would need bespoke flow-map parsing like `agent`. The network key is +named `network` (the #85 vocabulary); the docker-only knobs with generic names +take a `docker` prefix (`dockerUser`, `dockerMemory`, `dockerPidsLimit`, +`dockerCpus`) to stay self-describing next to locus-neutral keys. + +**Defaults live in single TS constants; Python keeps pure unset-fallbacks.** +Following the `DEFAULT_DOCKER_IMAGE` pattern: `config.ts` exports +`DEFAULT_DOCKER_MEMORY = '2g'`, `DEFAULT_DOCKER_PIDS_LIMIT = 512`, +`DEFAULT_DOCKER_NETWORK = 'bridge'`; the bootstrap always threads resolved +values, and `sidecar.py` holds mirrored fallbacks only for the unset case +(documented as deliberate cross-language duplicates, kept in sync). These +defaults are docker semantics, not ratchet-toolchain values, so nothing +ecosystem-specific leaks into consuming repos (`generalizable-defaults`). + +**Host uid:gid is computed, not stored.** When `dockerUser` is unconfigured, +`bootstrapRexRuntime` resolves `${process.getuid()}:${process.getgid()}` at +spawn time (guarded to POSIX — on platforms without `getuid` the variable is +omitted and the sidecar falls back to `os.getuid()`, itself guarded so a +non-POSIX host omits `--user` entirely). Images that need root setup override +with `dockerUser: "0:0"`. + +**Validation before any container starts.** `SETTING_CODECS` entries reject an +empty `dockerUser`/`dockerMemory`/`network`, a non-positive-integer +`dockerPidsLimit`, and a non-positive-number `dockerCpus`, leaving the config +file unchanged — the same fail-before-spawn contract as `image`/`port`. The +zod schemas in `manifest.ts` and `project-config.ts` mirror the types +(`z.string()` for the string knobs, `z.number().int().positive()` for +`dockerPidsLimit`, `z.number().positive()` for `dockerCpus`) so the write path +never persists what the loaders reject. + +**Threading path.** `selectRuntime` (engine.ts) already spreads docker-only +options; it adds the five knobs to `RexSidecarRuntimeOptions`, the sidecar +runtime forwards them to `bootstrapRexRuntime`'s `BootstrapOptions`, and the +bootstrap sets the env only when `locus === 'docker'`. No sidecar protocol +change — the knobs ride the env contract like `REX_IMAGE`. + +**Standards.** This change is agent-neutral (a runtime/settings change shared +by every coding agent; no skills, templates, or per-agent artifacts — +`multi-agent-support` holds by construction; `delegated-lifecycle` and +`instruction-fed-config` surfaces are untouched). Tests follow the `testing` +pyramid: pure validation/cascade at unit level in `test/core/batch/config.test.ts`, +env threading in `test/batch-engine/rex-bootstrap.test.ts` and +`test/batch-engine/rex-sidecar-runtime.test.ts` via the injected deps seams (no +real docker), and `docker_args` construction in the Python +`test_sidecar.py` harness alongside the existing `-v` mount cases. Docs are a +mandatory task per the `documentation` standard; the isolation contract is a +new section in the existing `agent-runtime.md` (whose overview diagram already +covers the runtime and stays accurate — no new diagram, deliberately, for a +leaf config surface). + +## Tasks + +- [x] 1.1 `src/core/batch/config.ts`: add `DEFAULT_DOCKER_MEMORY`, + `DEFAULT_DOCKER_PIDS_LIMIT`, `DEFAULT_DOCKER_NETWORK` constants; add + `dockerUser`/`dockerMemory`/`dockerPidsLimit`/`dockerCpus`/`network` to + `BatchSettings`, `SETTING_KEYS`, `ALLOWED_VALUES`, and the `sources` + init in `resolveBatchSettings`; add `SETTING_CODECS` entries (non-empty + strings; positive-int `dockerPidsLimit` and positive-number `dockerCpus` + serialized as numbers) +- [x] 1.2 Mirror the five keys in the settings zod schemas: + `src/core/batch/manifest.ts` and `src/core/project-config.ts` +- [x] 1.3 Unit tests in `test/core/batch/config.test.ts`: valid set/persist + for each key, each invalid-value rejection leaves the file unchanged, + and manifest-over-project cascade for one knob +- [x] 2.1 `src/core/batch/engine/runtime/rex-bootstrap.ts`: extend + `BootstrapOptions` with the five knobs; when `locus === 'docker'` set + `REX_DOCKER_USER` (configured value, else computed host `uid:gid`, + omitted on non-POSIX), `REX_DOCKER_MEMORY`/`REX_DOCKER_PIDS_LIMIT`/ + `REX_DOCKER_NETWORK` (configured, else the TS defaults), and + `REX_DOCKER_CPUS` only when configured +- [x] 2.2 Thread the knobs from settings to bootstrap: extend + `RexSidecarRuntimeOptions` (`rex-sidecar-runtime.ts`) and the + docker-only spread in `selectRuntime` (`engine.ts`) +- [x] 2.3 Tests: `test/batch-engine/rex-bootstrap.test.ts` asserts the + defaults case (computed uid:gid, `2g`/`512`/`bridge`, no + `REX_DOCKER_CPUS`), the fully-configured case, and that `local` sets no + `REX_DOCKER_*`; `test/batch-engine/rex-sidecar-runtime.test.ts` asserts + the options reach the bootstrap seam for the docker locus +- [x] 3.1 `src/core/batch/engine/runtime/sidecar.py`: extend + `_make_deployment("docker")` to append `--user` (env, else + `os.getuid():os.getgid()` guarded for non-POSIX), `--memory`, + `--pids-limit`, `--network` (env, else mirrored fallbacks), and + `--cpus` when the env value is non-empty; update the module docstring's + docker env contract +- [x] 3.2 `src/core/batch/engine/runtime/test_sidecar.py`: cases for the + default argv (uid:gid, `2g`, `512`, `bridge`, no `--cpus`), the + fully-overridden argv, and the rw mount staying `-v host:container` + with no `:ro` +- [x] 4.1 Documentation (mandatory, per the `documentation` standard): + rewrite the docker section of `docs/engine/agent-runtime.md` with the + hardened `docker run` constraints, the new `REX_DOCKER_*` rows in the + sidecar env table, and an "Isolation contract" subsection stating what + the container does and does not protect (repo mount writable by design; + uid, resources, and network per config; default `bridge` network means + outbound access); enumerate the five keys with type/default/scope in + `docs/configuration/config-yaml.md` and `docs/commands/batch.md` + (README checked — it does not enumerate per-key settings, no README + change) +- [x] 4.2 Run the full suite green (`npm test`), including + `npm test -- test/batch-engine/` (the phase proof-of-work) and the + sidecar Python tests per their documented invocation diff --git a/.ratchet/changes/reap-agents-on-teardown/.ratchet.yaml b/.ratchet/changes/reap-agents-on-teardown/.ratchet.yaml new file mode 100644 index 0000000..07284bc --- /dev/null +++ b/.ratchet/changes/reap-agents-on-teardown/.ratchet.yaml @@ -0,0 +1,7 @@ +schema: ratchet +created: 2026-07-09 +standards: + - delegated-lifecycle + - documentation + - multi-agent-support + - testing diff --git a/.ratchet/changes/reap-agents-on-teardown/features/teardown-reaping/process-group-launch.feature b/.ratchet/changes/reap-agents-on-teardown/features/teardown-reaping/process-group-launch.feature new file mode 100644 index 0000000..e77e63a --- /dev/null +++ b/.ratchet/changes/reap-agents-on-teardown/features/teardown-reaping/process-group-launch.feature @@ -0,0 +1,21 @@ +Feature: Agents launch in their own process group with a recorded pid + As a batch operator + I want every spawned agent to run as a process-group leader whose pid is recorded + So that teardown can kill the whole agent tree instead of orphaning it + + Scenario: Sidecar launcher starts the agent as a process-group leader + Given the ReX sidecar receives a run op for an agent command + When the sidecar builds the detached launcher + Then the launcher enables job control so the backgrounded agent becomes its own process-group leader + And the launcher records the agent's pid to a pidfile in the run directory before the agent pipeline starts + + Scenario: Remote runtime launcher records the launched pid in a runDir pidfile + Given the remote runtime launches an agent on a swerex-remote server + When the non-blocking launch command is issued + Then the launch enables job control so the backgrounded agent becomes its own process-group leader + And the launched process id is written to a pidfile under the server run directory + + Scenario: realSpawner starts the agent detached as a process-group leader + Given the in-process spawner runs an agent binary directly + When the child process is spawned on a POSIX platform + Then the child is spawned detached so it leads its own process group diff --git a/.ratchet/changes/reap-agents-on-teardown/features/teardown-reaping/sentinel-hygiene.feature b/.ratchet/changes/reap-agents-on-teardown/features/teardown-reaping/sentinel-hygiene.feature new file mode 100644 index 0000000..ab5093e --- /dev/null +++ b/.ratchet/changes/reap-agents-on-teardown/features/teardown-reaping/sentinel-hygiene.feature @@ -0,0 +1,21 @@ +Feature: Run sentinels live under the batch run directory and are swept on teardown + As a ratchet user + I want per-run log/done/pid sentinel files scoped to the batch run directory + So that timed-out runs never litter my repository root + + Scenario: Sidecar sentinels are written under the batch run directory + Given the Node runtime passes the run directory to the sidecar in the run op + When the sidecar launches the agent detached + Then the ratchet-rex log and done sentinels are created inside that run directory + And no sentinel file is created at the workdir root + + Scenario: Docker locus receives the in-container run directory path + Given a sidecar run under the docker locus + When the Node runtime builds the run op + Then the run directory it passes is translated onto the in-container bind mount + + Scenario: Teardown sweeps the run directory on timeout + Given a sidecar run that ends by overall timeout + When the Node runtime finishes the run + Then the run directory including prompt, log, done, and pid files is removed + And no sentinel files remain anywhere under the project root diff --git a/.ratchet/changes/reap-agents-on-teardown/features/teardown-reaping/teardown-kills-agent.feature b/.ratchet/changes/reap-agents-on-teardown/features/teardown-reaping/teardown-kills-agent.feature new file mode 100644 index 0000000..fe31f0e --- /dev/null +++ b/.ratchet/changes/reap-agents-on-teardown/features/teardown-reaping/teardown-kills-agent.feature @@ -0,0 +1,36 @@ +Feature: Teardown reaps the agent on every exit path + As a batch operator + I want timeout, error, and clean-shutdown teardown to kill the agent's process group + So that no nohup'd, dockerized, or remote agent survives its run + + Scenario: Sidecar overall timeout shuts down then force-kills + Given a sidecar run that exceeds the overall timeout + When the Node runtime tears the child down + Then it sends the shutdown op to the sidecar first + And after a short grace window it SIGKILLs the sidecar's process group + And the run resolves with a non-zero exit code and a timeout message in stderr + + Scenario: Sidecar shutdown kills the in-flight agent process group + Given the Python sidecar is streaming an agent it launched detached + When the sidecar handles a shutdown op + Then it kills the recorded agent process group before stopping the deployment + And it removes the run's log, done, and pid files + + Scenario: Python sidecar SIGTERM handler stops the deployment + Given the Python sidecar is running with an active deployment + When the sidecar process receives SIGTERM + Then it stops the deployment so no docker container is orphaned + And it exits instead of leaving the agent running + + Scenario: Remote teardown kills the launched agent before closing the session + Given a remote run that ends by timeout, error, or completion + When the remote runtime tears the session down + Then it kills the process group recorded in the runDir pidfile + And it removes the server run directory before closing the session and runtime + + Scenario: realSpawner enforces a timeout with SIGTERM then SIGKILL + Given the in-process spawner runs an agent with a timeout configured + When the agent is still running at the deadline + Then the spawner SIGTERMs the agent's process group + And escalates to SIGKILL after a grace window + And resolves with a timeout message in stderr instead of hanging diff --git a/.ratchet/changes/reap-agents-on-teardown/plan.md b/.ratchet/changes/reap-agents-on-teardown/plan.md new file mode 100644 index 0000000..8e96033 --- /dev/null +++ b/.ratchet/changes/reap-agents-on-teardown/plan.md @@ -0,0 +1,134 @@ +# reap-agents-on-teardown + +## Why + +Teardown today never reaps the agent, only its transport: the sidecar path SIGTERMs +the Python child while the agent it launched via a bare `nohup … &` keeps running, +the remote path closes the session leaving the nohup'd server-side agent alive, and +`realSpawner` waits forever on a hung agent. Timed-out runs also litter +`ratchet-rex-.log/.done` sentinels at the workdir root (the repository root +for the local locus). Fixes #79. + +## What Changes + +Implements `features/teardown-reaping/process-group-launch.feature`, +`features/teardown-reaping/teardown-kills-agent.feature`, and +`features/teardown-reaping/sentinel-hygiene.feature`. + +- `sidecar.py` launches the agent as a process-group leader (job control, `set -m`) + and records its pid to a pidfile in the run directory; `shutdown` kills that group + (TERM → short grace → KILL) before stopping the deployment, and a new SIGTERM + handler stops the deployment (docker container included) instead of dying silently. +- The `run` op gains an optional `run_dir` field; the Node sidecar runtime passes its + existing `.ratchet/batches//.run//` directory (docker: translated onto + the bind mount) and the sidecar writes its `ratchet-rex-.log/.done` sentinels + and the new pidfile there instead of the workdir root. Absent `run_dir` falls back + to the current workdir behaviour. +- `RexSidecarRuntime` teardown on ALL paths (overall timeout, error, clean shutdown) + sends `{op:"shutdown"}` first, then after `killGraceMs` SIGKILLs the sidecar's + process group (child spawned `detached: true`); the run-dir sweep (already in + `finish()`) now also removes the sentinels/pidfile that live there. +- `RexRemoteRuntime` launches with job control and writes `$!` to a `pid` file in the + server runDir; `teardown()` kills that recorded process group (TERM then KILL, + best-effort) BEFORE `rm -rf` runDir / `close_session` / `close`, on the timeout and + error paths as well as completion. +- `realSpawner` gains the sidecar's timeout/kill semantics: detached (own-group) + spawn on POSIX, a default overall timeout, and TERM → grace → KILL escalation on + the group, resolving with a timeout message in stderr instead of hanging. A + `makeRealSpawner({ timeoutMs, killGraceMs })` factory exposes the knobs; the + exported `realSpawner` keeps its `Spawner` shape so the eval judge and mutation + harness pick the semantics up unchanged. +- No breaking changes: op protocol is extended (optional field), `Spawner`/ + `AgentRuntime` signatures are unchanged. + +## Design + +**Process groups over lone pids.** Killing the recorded launch pid alone would strand +grandchildren (the agent pipeline is `cat prompt | agent`). The launcher runs under +`set -m` (POSIX job control), so the backgrounded pipeline becomes its own +process-group leader with pgid == pid; one `kill -- -` reaps the whole tree. +Job control is used instead of `setsid(1)` because macOS ships no `setsid` binary — +`set -m` works in bash and POSIX sh on both macOS (local locus) and Linux +(docker/remote loci). `realSpawner` gets the same property from Node's +`spawn(..., { detached: true })` plus `process.kill(-pid, sig)`, guarded to POSIX +(`process.platform !== 'win32'`; on Windows it falls back to `child.kill()`). + +**Sidecar launcher shape.** The current double-`nohup` launcher becomes (inner +command otherwise unchanged, still detached so `execute()` returns immediately): + +``` +nohup bash -c 'set -m; bash -c > 2>&1 & echo $! > ; wait $!; echo $? > ' >/dev/null 2>&1 & +``` + +The sidecar remembers the pidfile of the in-flight run; `shutdown` (op, stdin-EOF, +and the new SIGTERM handler all funnel into the same coroutine) reads it, sends +TERM to the group, waits a short grace, sends KILL, removes log/done/pid, then +`deployment.stop()` — so the docker container is stopped even when the Node parent +kills the sidecar instead of speaking the protocol. The SIGTERM handler is +registered via `loop.add_signal_handler`. + +**Teardown ordering on the Node side.** `teardownChild()` currently SIGTERMs the +Python child immediately, which is exactly how agents orphan: the sidecar dies +before it can reap. New order: write `{op:"shutdown"}` (idempotent — the clean path +has already sent it and received `closed`), then arm the existing `killGraceMs` +timer to SIGKILL the child's process group. The child `exit` handler already clears +the kill timer. `SidecarDeps` gains a `killGroup(pid, signal)` seam (default +`process.kill(-pid, signal)`) so unit tests observe the escalation without real +processes. The run-dir sweep stays in `finish()`, covering timeout/error/clean +paths alike; because sentinels now live in the run dir, the sweep is the "no +leftover sentinels" guarantee. + +**Remote pidfile.** The launch becomes +`nohup sh -c 'set -m; cd …; ( ) > 2>&1 & echo $! > ; wait $!; echo $? > ' … &`. +`teardown()` prepends a best-effort +`test -f && { kill -TERM -- -$(cat ); kill -KILL -- -$(cat ); } 2>/dev/null` +before the existing `rm -rf` / `close_session` / `close` — the server session is +still alive at that point on every finish path, so the kill can actually run. + +**Delegated lifecycle / multi-agent.** All of this is mechanical orchestration +(spawn, kill, sweep) — no lifecycle instruction text moves into the engine, and the +launchers wrap the adapter argv opaquely, so no agent is special-cased +(`delegated-lifecycle`, `multi-agent-support`). + +**Testing (pyramid).** Runtime behaviour is proven at the unit level in +`test/batch-engine/` with the existing injected seams (fake `SidecarChild` + fake +timers; mocked `fetch`): timeout → shutdown op then `killGroup(SIGKILL)` after +grace; run op carries `run_dir` (and its docker translation); remote launch +contains `set -m`/pidfile and teardown kills before `close_session`. One +integration-flavoured test spawns a real child through the timeout-aware spawner +and asserts the process group is dead and no sentinel files remain after a timeout +— the phase's orphan/sentinel assertion. Test headers name the `.feature` they +implement; everything uses tmpdir fixtures. + +## Tasks + +- [x] 1.1 `sidecar.py`: job-control launcher writing pid/log/done into the run + directory from the run op's new optional `run_dir` (fallback: workdir), pgid + recorded via pidfile +- [x] 1.2 `sidecar.py`: shutdown kills the recorded agent group (TERM → grace → + KILL) and removes log/done/pid before `deployment.stop()`; register a SIGTERM + handler that runs the same teardown; update `test_sidecar.py` +- [x] 2.1 `rex-sidecar-runtime.ts`: send `run_dir` (host path; docker → in-container + translation) in the run op; spawn the child `detached: true` +- [x] 2.2 `rex-sidecar-runtime.ts`: teardown = shutdown op first, then + `killGroup(SIGKILL)` after `killGraceMs` via a new `SidecarDeps.killGroup` + seam, on timeout/error/clean paths; run-dir sweep covers sentinels+pidfile +- [x] 3.1 `rex-remote-runtime.ts`: job-control launch writing `$!` to a runDir `pid` + file; `teardown()` kills the recorded group before `rm -rf`/`close_session`/ + `close` on all finish paths +- [x] 4.1 `agent.ts`: `makeRealSpawner({ timeoutMs, killGraceMs })` with detached + POSIX spawn and TERM → grace → KILL group escalation resolving a timeout + result; `realSpawner` re-exported from the factory with defaults so the eval + judge and mutation harness inherit it +- [x] 5.1 Unit tests in `test/batch-engine/` (feature named in each header): + sidecar timeout → shutdown-then-SIGKILL + run-dir sweep; run op `run_dir` + incl. docker translation; remote launcher pidfile + kill-before-close; + spawner timeout semantics +- [x] 5.2 Orphan/sentinel proof test: after a forced timeout the launched process + group is dead and no `ratchet-rex-*` sentinel or run-dir file survives under + the project fixture root; run `npm test -- test/batch-engine/` green +- [x] 6.1 Documentation (`documentation` standard, mandatory): update + `docs/engine/agent-runtime.md` — sidecar protocol (`run_dir` field), launcher + shape, teardown/reaping contract, sentinel/pidfile locations, spawner timeout + knobs — keep the existing overview diagram accurate; verify `README.md` + (no user-facing surface change expected, confirm and leave unchanged if so) diff --git a/.ratchet/changes/render-honest-isolation-and-enforcement/.ratchet.yaml b/.ratchet/changes/render-honest-isolation-and-enforcement/.ratchet.yaml new file mode 100644 index 0000000..d237ab7 --- /dev/null +++ b/.ratchet/changes/render-honest-isolation-and-enforcement/.ratchet.yaml @@ -0,0 +1,6 @@ +schema: ratchet +created: 2026-07-09 +standards: + - testing + - documentation + - multi-agent-support diff --git a/.ratchet/changes/render-honest-isolation-and-enforcement/features/runtime-honesty/config-isolation-per-locus.feature b/.ratchet/changes/render-honest-isolation-and-enforcement/features/runtime-honesty/config-isolation-per-locus.feature new file mode 100644 index 0000000..adec3d7 --- /dev/null +++ b/.ratchet/changes/render-honest-isolation-and-enforcement/features/runtime-honesty/config-isolation-per-locus.feature @@ -0,0 +1,29 @@ +Feature: batch config states the real isolation per locus + As an operator running batches + I want `ratchet batch config` to describe what the resolved locus actually isolates + So that I never mistake an advisory posture for a real sandbox + + Scenario: local locus is rendered as advisory with no isolation + Given a project whose resolved batch locus is "local" + When I run `ratchet batch config` + Then the output renders an isolation line for the local locus + And it states the local locus is advisory with no filesystem or network isolation + And it states the agent environment is scoped to the env allowlist + + Scenario: docker locus is rendered as container isolation with its contract + Given a project whose resolved batch locus is "docker" + When I run `ratchet batch config` + Then the output renders an isolation line for the docker locus + And it states the docker locus provides container isolation with the configured uid, memory, pids, and network policy + And it states the repository mount stays writable by design + + Scenario: remote locus is rendered as a server boundary + Given a project whose resolved batch locus is "remote" + When I run `ratchet batch config` + Then the output renders an isolation line for the remote locus + And it states isolation is the remote server's boundary, not one ratchet enforces + + Scenario: JSON output carries the isolation description machine-readably + Given a project whose resolved batch locus is "local" + When I run `ratchet batch config --json` + Then the JSON payload includes an isolation field describing the local locus as advisory diff --git a/.ratchet/changes/render-honest-isolation-and-enforcement/features/runtime-honesty/doctor-local-locus-nudge.feature b/.ratchet/changes/render-honest-isolation-and-enforcement/features/runtime-honesty/doctor-local-locus-nudge.feature new file mode 100644 index 0000000..2c1301d --- /dev/null +++ b/.ratchet/changes/render-honest-isolation-and-enforcement/features/runtime-honesty/doctor-local-locus-nudge.feature @@ -0,0 +1,27 @@ +Feature: doctor nudges toward the docker locus for permissive local runs + As an operator relying on ratchet doctor + I want a check that flags batches running on the local locus with a permissive or full-autonomy posture + So that I am pointed at the docker locus when my configuration has no real containment + + Scenario: full-autonomy on the local locus warns and names the docker locus + Given a project whose resolved batch locus is "local" and posture is "full-autonomy" + When I run `ratchet doctor` + Then the report includes a batch-isolation check with a warning status + And its remedy nudges toward `locus: docker` for real containment + + Scenario: the default permissive posture on the local locus gets an advisory nudge + Given a project whose resolved batch locus is "local" and posture is "repo-sandboxed-permissive" + When I run `ratchet doctor` + Then the report includes a batch-isolation check noting the posture is advisory on local + And the check is informational, not a failure + + Scenario: the docker locus does not trigger the nudge + Given a project whose resolved batch locus is "docker" + When I run `ratchet doctor` + Then the batch-isolation check passes or is absent + + Scenario: the nudge never fails doctor + Given a project whose resolved batch locus is "local" and posture is "full-autonomy" + And every required doctor check passes + When I run `ratchet doctor` + Then the process exits with code 0 diff --git a/.ratchet/changes/render-honest-isolation-and-enforcement/features/runtime-honesty/posture-enforcement-rendering.feature b/.ratchet/changes/render-honest-isolation-and-enforcement/features/runtime-honesty/posture-enforcement-rendering.feature new file mode 100644 index 0000000..a0767ca --- /dev/null +++ b/.ratchet/changes/render-honest-isolation-and-enforcement/features/runtime-honesty/posture-enforcement-rendering.feature @@ -0,0 +1,38 @@ +Feature: posture is never displayed bare — enforcement status and source are rendered + As an operator reading batch settings + I want the resolved posture shown with per-agent enforcement status and its source scope + So that an unenforced posture or a repo-controlled escalation is never presented as a guarantee + + Scenario: an argv-enforced agent renders as enforced via flags + Given a project whose resolved batch agent is "claude" + And the resolved posture is "repo-sandboxed-permissive" + When I run `ratchet batch config` + Then the permissions block renders "claude: enforced via flags" + + Scenario: an agent whose posture maps to no argv flags renders as NOT ENFORCED + Given a project whose resolved batch agent is "cursor" + And the resolved posture is "repo-sandboxed-permissive" + When I run `ratchet batch config` + Then the permissions block renders "cursor: NOT ENFORCED — agent defaults apply" + + Scenario: enforcement status is derived from the real permission translator + Given the per-agent permission translator maps a posture to an empty argv fragment for an agent + When the enforcement status for that agent and posture is resolved + Then it reports the posture as not enforced for that agent + And an agent whose mapping emits posture flags reports as enforced + + Scenario: a manifest-sourced posture names its source scope + Given a batch manifest that sets `permissions.posture: full-autonomy` + And manifest escalation is allowed for the run + When I run `ratchet batch config ` + Then the posture renders as "full-autonomy" annotated with "set by batch manifest — repo-controlled" + + Scenario: stage-mapped agents each get their own enforcement line + Given a project whose batch agent is a per-stage map resolving to "claude" and "cursor" + When I run `ratchet batch config` + Then the permissions block renders one enforcement line per distinct resolved agent + + Scenario: JSON output carries per-agent enforcement machine-readably + Given a project whose resolved batch agent is "cursor" + When I run `ratchet batch config --json` + Then the JSON payload includes an enforcement entry marking the posture as not enforced for "cursor" diff --git a/.ratchet/changes/render-honest-isolation-and-enforcement/features/runtime-honesty/view-runtime-summary.feature b/.ratchet/changes/render-honest-isolation-and-enforcement/features/runtime-honesty/view-runtime-summary.feature new file mode 100644 index 0000000..aa4d66a --- /dev/null +++ b/.ratchet/changes/render-honest-isolation-and-enforcement/features/runtime-honesty/view-runtime-summary.feature @@ -0,0 +1,10 @@ +Feature: batch view summarizes the real runtime isolation and posture + As an operator inspecting a batch + I want `ratchet batch view` to show the locus, its real isolation, and the posture with enforcement and source + So that the batch's runtime story is honest wherever settings are displayed + + Scenario: batch view renders a runtime summary line + Given a batch whose resolved locus is "local" and resolved posture is "repo-sandboxed-permissive" + When I run `ratchet batch view ` + Then the output includes a runtime summary naming the locus and its isolation ("advisory — no isolation") + And the summary names the posture with its source scope and per-agent enforcement status diff --git a/.ratchet/changes/render-honest-isolation-and-enforcement/plan.md b/.ratchet/changes/render-honest-isolation-and-enforcement/plan.md new file mode 100644 index 0000000..4f06cc7 --- /dev/null +++ b/.ratchet/changes/render-honest-isolation-and-enforcement/plan.md @@ -0,0 +1,124 @@ +# render-honest-isolation-and-enforcement + +## Why + +The batch runtime's displayed security story overstates reality: `batch config` +prints a posture like `repo-sandboxed-permissive` bare, even for agents (cursor, +opencode) whose posture maps to an EMPTY argv fragment — a silent no-op — and +says nothing about what the resolved locus actually isolates (`local` isolates +nothing). Nothing nudges an operator running permissive/full-autonomy batches on +`local` toward the docker locus that phase-mate #85 hardened. This closes the +posture-honesty half of #86 and all of #88. + +## What Changes + +Implements `features/runtime-honesty/*.feature`. + +- `ratchet batch config` renders an **isolation** line stating the real + isolation of the resolved locus (local = advisory, no filesystem/network + isolation, env scoped to the allowlist; docker = container isolation with the + resolved uid/memory/pids/network contract, repo mount writable by design; + remote = the server's boundary, not ratchet's) + (`config-isolation-per-locus.feature`). +- `ratchet batch config` never displays a posture bare: the permissions block + gains one enforcement line per distinct resolved stage agent + (`claude: enforced via flags` / `cursor: NOT ENFORCED — agent defaults apply`), + and a manifest-sourced posture renders its escalation source + (`full-autonomy (set by batch manifest — repo-controlled)`) + (`posture-enforcement-rendering.feature`). +- `ratchet batch config --json` carries `isolation` and per-agent `enforcement` + fields machine-readably. +- `ratchet batch view` gains a runtime summary line reusing the same + descriptors (`view-runtime-summary.feature`). +- `ratchet doctor` gains an optional **batch-isolation** check: warn on + `local` + `full-autonomy`, advisory note on `local` + the permissive default, + absent/pass on docker/remote; it never fails doctor + (`doctor-local-locus-nudge.feature`). +- `docs/engine/agent-runtime.md` documents the threat model per locus and + reframes the argv denylist (`REPO_SANDBOX_DENY_PATTERNS`) as best-effort + damage reduction — real containment is the docker locus. + +## Design + +**Enforcement is derived from the real translator, never a parallel table.** +`src/core/batch/runtime/agent-permissions.ts` is the single place agent flags +live (its own header contract). A new exported pure query +`resolvePostureEnforcement(agentName, policy, repoRoot)` computes +`{ agent, enforced, detail }` by consulting the same per-agent mappers that +build spawn argv: a posture that yields a non-empty posture-flag fragment (or +the full-autonomy bypass flag) is `enforced via flags`; an empty fragment is +`NOT ENFORCED — agent defaults apply`. Deriving from the mappers means the +rendering can never drift from what actually reaches the agent — the exact +failure mode #88 names. The mappers' one-time `console.warn` side effects +(cursor/opencode) are gated behind an internal option so the pure query never +warns; the spawn path keeps warning exactly as today. + +**Isolation descriptions are pure data over resolved settings.** A new module +`src/core/batch/runtime/isolation.ts` exports +`describeLocusIsolation(settings)` returning a short honest description per +locus, parameterized by the resolved docker knobs (`dockerUser`, +`dockerMemory`, `dockerPidsLimit`, `network`) so the docker line states the +actual #85 contract, not a generic claim. Pure function → unit tests, no +filesystem (testing standard: prove at the unit level). + +**Rendering stays in the command layer.** `src/commands/batch/config.ts` +(`printResolved` + the `--json` branch) and `src/commands/batch/view.ts` +consume the two pure helpers; `resolveBatchSettings` already exposes +`sources.permissions` for the escalation-source phrasing and the per-stage +agent resolution for distinct-agent enforcement lines. No new config reads — +everything renders from the already-resolved `ResolvedBatchSettings`. + +**Doctor check follows the existing check-engine pattern.** A new +`src/core/doctor/checks/batch-isolation.ts` resolves project batch settings via +`resolveBatchSettings(projectRoot, null)` and reports through the same +`DoctorCheck` shape as `checkDocker`; it is `optional` severity so +`exitCodeFor` never turns the nudge into a failure. Registered in +`runDoctorChecks` after the docker check. + +**Multi-agent support (standard: multi-agent-support).** Enforcement lines are +produced by iterating the resolved stage agents and the translator registry — +no agent name is special-cased in shared render code, and the enforcement unit +tests iterate every agent in `PERMISSION_RAW_AGENTS` × every posture rather +than hard-coding claude. Per-agent outputs: this change touches CLI output and +docs only — no generated skills/commands — so no per-agent artifact files are +produced. + +**Testing (standard: testing, 95% floor).** Unit: enforcement query and +isolation descriptor (pure, no fs). Integration: `batch config` / +`batch view` rendering and JSON over a tmpdir fixture repo +(`fs.mkdtemp` + `afterEach` cleanup, per the fixture pattern), and the doctor +check over injected fake deps. Each test file header names the `.feature` it +proves. Phase proof-of-work: `npm test -- test/batch-engine/` exits 0. + +**Documentation (standard: documentation — mandatory, blocking).** Tasks 5.x +update `docs/engine/agent-runtime.md` (threat model per locus, denylist +reframed as best-effort damage reduction), `docs/commands/batch.md` +(config/view output incl. isolation + enforcement lines), and +`docs/commands/doctor.md` (batch-isolation check); `README.md` is updated iff +it describes the changed surfaces. + +## Tasks + +## 1. Pure descriptors + +- [x] 1.1 Add `resolvePostureEnforcement(agentName, policy, repoRoot)` to `src/core/batch/runtime/agent-permissions.ts`, derived from the existing per-agent mappers with the cursor/opencode one-time warnings gated so the query is side-effect-free; unit tests iterate all `PERMISSION_RAW_AGENTS` × all postures (header names `posture-enforcement-rendering.feature`) +- [x] 1.2 Add `src/core/batch/runtime/isolation.ts` with `describeLocusIsolation(settings)` covering local/docker/remote, docker parameterized by resolved uid/memory/pids/network; unit tests for all three loci (header names `config-isolation-per-locus.feature`) + +## 2. batch config rendering + +- [x] 2.1 Render the isolation line and per-distinct-stage-agent enforcement lines in `printResolved` (`src/commands/batch/config.ts`), and phrase a manifest-sourced posture as `full-autonomy (set by batch manifest — repo-controlled)`; integration tests over a tmpdir fixture for local/docker/remote and enforced/unenforced agents +- [x] 2.2 Add `isolation` and `enforcement` fields to the `batch config --json` payload; integration test asserts the not-enforced entry for cursor + +## 3. batch view + doctor + +- [x] 3.1 Add the runtime summary line to `ratchet batch view` reusing the same descriptors; integration test (header names `view-runtime-summary.feature`) +- [x] 3.2 Add `src/core/doctor/checks/batch-isolation.ts` (optional severity: warn on local+full-autonomy, advisory on local+permissive default, pass/absent otherwise), register it in `runDoctorChecks`, and unit-test statuses plus that `exitCodeFor` stays 0 (header names `doctor-local-locus-nudge.feature`) + +## 4. Verification + +- [x] 4.1 Run `npm test -- test/batch-engine/` and the new suites to exit 0 (phase proof-of-work), fixing regressions + +## 5. Documentation (documentation standard — required, blocking) + +- [x] 5.1 Update `docs/engine/agent-runtime.md`: add the per-locus threat model (local advisory / docker contract / remote server boundary) and reframe `REPO_SANDBOX_DENY_PATTERNS` as best-effort damage reduction with the docker locus as real containment +- [x] 5.2 Update `docs/commands/batch.md` (config/view isolation + enforcement + escalation-source output) and `docs/commands/doctor.md` (batch-isolation check); update `README.md` iff it describes these surfaces diff --git a/.ratchet/changes/restrict-manifest-permission-escalation/.ratchet.yaml b/.ratchet/changes/restrict-manifest-permission-escalation/.ratchet.yaml new file mode 100644 index 0000000..0994ed3 --- /dev/null +++ b/.ratchet/changes/restrict-manifest-permission-escalation/.ratchet.yaml @@ -0,0 +1,7 @@ +schema: ratchet +created: 2026-07-09 +standards: + - testing + - documentation + - multi-agent-support + - delegated-lifecycle diff --git a/.ratchet/changes/restrict-manifest-permission-escalation/features/manifest-permission-scope/apply-posture-banner.feature b/.ratchet/changes/restrict-manifest-permission-escalation/features/manifest-permission-scope/apply-posture-banner.feature new file mode 100644 index 0000000..afdd7f2 --- /dev/null +++ b/.ratchet/changes/restrict-manifest-permission-escalation/features/manifest-permission-scope/apply-posture-banner.feature @@ -0,0 +1,23 @@ +Feature: batch apply prints the effective posture and its source scope + As a ratchet operator starting a batch run + I want every batch apply run to state the effective permission posture and which scope supplied it + So that the permission story of the run is visible up front instead of buried in config + + Scenario: The posture banner opens every batch apply run + Given a batch with a ready step + And a project config whose batch permissions posture is "repo-sandboxed-permissive" + When "ratchet batch apply" runs + Then the output begins with a line stating the effective posture "repo-sandboxed-permissive" and its source scope "project" + + Scenario: The banner attributes a manifest-narrowed posture to the manifest + Given a batch with a ready step + And a project config whose batch permissions posture is "full-autonomy" + And a batch manifest whose settings set permissions posture to "repo-sandboxed-permissive" + When "ratchet batch apply" runs + Then the posture banner states "repo-sandboxed-permissive" with source scope "manifest" + + Scenario: JSON output suppresses the human banner line + Given a batch with a ready step + And a project config whose batch permissions posture is "repo-sandboxed-permissive" + When "ratchet batch apply" runs with --json + Then the human banner line is not printed diff --git a/.ratchet/changes/restrict-manifest-permission-escalation/features/manifest-permission-scope/escalation-opt-in.feature b/.ratchet/changes/restrict-manifest-permission-escalation/features/manifest-permission-scope/escalation-opt-in.feature new file mode 100644 index 0000000..8fdf2e1 --- /dev/null +++ b/.ratchet/changes/restrict-manifest-permission-escalation/features/manifest-permission-scope/escalation-opt-in.feature @@ -0,0 +1,26 @@ +Feature: Manifest posture escalation requires an explicit opt-in flag + As a ratchet operator who deliberately wants a manifest-raised posture + I want an explicit --allow-manifest-escalation flag on batch apply + So that raising the posture from the repo-controlled manifest is a per-invocation operator decision, never a silent default in headless runs + + Scenario: batch apply honors the manifest raise only with --allow-manifest-escalation + Given a project config whose batch permissions posture is "repo-sandboxed-permissive" + And a batch manifest whose settings set permissions posture to "full-autonomy" + When the batch settings are resolved with manifest escalation allowed + Then the effective posture is "full-autonomy" + And the posture source is "manifest" + + Scenario: Headless batch apply without the flag refuses the escalation and says how to opt in + Given a project config whose batch permissions posture is "repo-sandboxed-permissive" + And a batch manifest whose settings set permissions posture to "full-autonomy" + When "ratchet batch apply" runs without the --allow-manifest-escalation flag + Then the run proceeds under the "repo-sandboxed-permissive" posture + And the output warns that the manifest requested "full-autonomy" and was refused + And the warning names the --allow-manifest-escalation flag and the operator-owned config scopes as the ways to raise the posture + + Scenario: The opt-in flag changes nothing when the manifest does not raise the posture + Given a project config whose batch permissions posture is "repo-sandboxed-permissive" + And a batch manifest that sets no permissions posture + When the batch settings are resolved with manifest escalation allowed + Then the effective posture is "repo-sandboxed-permissive" + And the posture source is "project" diff --git a/.ratchet/changes/restrict-manifest-permission-escalation/features/manifest-permission-scope/narrow-only-resolution.feature b/.ratchet/changes/restrict-manifest-permission-escalation/features/manifest-permission-scope/narrow-only-resolution.feature new file mode 100644 index 0000000..32a7dc4 --- /dev/null +++ b/.ratchet/changes/restrict-manifest-permission-escalation/features/manifest-permission-scope/narrow-only-resolution.feature @@ -0,0 +1,41 @@ +Feature: Manifest permission scope can only narrow, never escalate + As a ratchet operator running a batch in a cloned repository + I want the repo-committed manifest to only narrow the permission policy + So that a batch.yaml I did not author can never silently raise the agent posture above what my own config allows + + Scenario: Committed manifest full-autonomy cannot silently escalate over project scope + Given a project config whose batch permissions posture is "repo-sandboxed-permissive" + And a batch manifest whose settings set permissions posture to "full-autonomy" + When the batch settings are resolved without manifest escalation allowed + Then the effective posture is "repo-sandboxed-permissive" + And the posture source is "project" + And the resolution reports the manifest's suppressed escalation request for "full-autonomy" + + Scenario: Manifest cannot raise posture above the built-in default when no operator scope sets one + Given no user or project config sets a batch permissions posture + And a batch manifest whose settings set permissions posture to "full-autonomy" + When the batch settings are resolved without manifest escalation allowed + Then the effective posture is "repo-sandboxed-permissive" + And the posture source is "default" + + Scenario: Manifest may lower the posture below the operator scope + Given a project config whose batch permissions posture is "full-autonomy" + And a batch manifest whose settings set permissions posture to "repo-sandboxed-permissive" + When the batch settings are resolved without manifest escalation allowed + Then the effective posture is "repo-sandboxed-permissive" + And the posture source is "manifest" + + Scenario: Manifest deny additions still union while its posture raise is suppressed + Given a project config whose batch permissions posture is "repo-sandboxed-permissive" with deny pattern "Bash(rm -rf*)" + And a batch manifest that sets permissions posture to "full-autonomy" and adds deny pattern "Bash(git push*)" + When the batch settings are resolved without manifest escalation allowed + Then the effective posture is "repo-sandboxed-permissive" + And the effective deny list contains both "Bash(rm -rf*)" and "Bash(git push*)" + + Scenario: Operator-owned scopes may still raise the posture + Given a user config whose batch permissions posture is "repo-sandboxed-permissive" + And a project config whose batch permissions posture is "full-autonomy" + And a batch manifest that sets no permissions posture + When the batch settings are resolved without manifest escalation allowed + Then the effective posture is "full-autonomy" + And the posture source is "project" diff --git a/.ratchet/changes/restrict-manifest-permission-escalation/plan.md b/.ratchet/changes/restrict-manifest-permission-escalation/plan.md new file mode 100644 index 0000000..058c1f8 --- /dev/null +++ b/.ratchet/changes/restrict-manifest-permission-escalation/plan.md @@ -0,0 +1,127 @@ +# restrict-manifest-permission-escalation + +## Why + +`full-autonomy` maps to each agent's strongest bypass flag (claude +`--dangerously-skip-permissions`, gemini `--yolo`, …) and drops the baseline +destructive-op denylist, yet `resolvePermissionsPolicy` resolves posture +nearest-wins across every scope — including the repo-committed per-batch +manifest. Cloning a repo whose `batch.yaml` sets +`permissions.posture: full-autonomy` and running `ratchet batch apply` silently +runs an unconstrained agent with no confirmation and no visible escalation +notice. The manifest is repo-author-controlled, not operator-controlled, so it +must never be able to raise the posture on its own. Fixes #87 (confirmed OPEN +and unfixed at decompose, 2026-07-09). + +## What Changes + +Implements `features/manifest-permission-scope/*.feature`: + +- Permission-policy resolution clamps the **manifest** layer's posture: it may + only NARROW (lower posture; its `deny` additions still union), never raise + posture above the value resolved from the default/user/project layers + (`narrow-only-resolution.feature`). Operator-owned user/project scopes keep + their existing raise ability. +- A suppressed manifest raise is reported in the resolution result so callers + can surface it instead of hiding it. +- `ratchet batch apply` gains an explicit `--allow-manifest-escalation` flag — + the only way a manifest-raised posture takes effect. Without it, a headless + run proceeds under the clamped posture and prints a warning naming the + requested posture, the flag, and the operator-owned config scopes as the + legitimate ways to raise it (`escalation-opt-in.feature`). +- `batch apply` prints the effective posture and its source scope at the start + of every human-readable run; `--json` runs suppress the banner line + (`apply-posture-banner.feature`). +- **BREAKING** (behavioral): a committed manifest that previously escalated a + run to `full-autonomy` now runs at the operator-scope posture unless the + operator passes `--allow-manifest-escalation` or raises posture in their own + user/project config. + +## Design + +**Posture privilege ranking** (`src/core/batch/permissions-policy.ts`): export +a ranking `curated-allowlist (0) < repo-sandboxed-permissive (1) < +full-autonomy (2)` next to `PERMISSION_POSTURE_VALUES` so "raise" vs "narrow" +has exactly one definition. Pure data, unit-testable. + +**Clamp at the merge seam** (`src/core/batch/config.ts`): +`resolvePermissionsPolicy(layers, options?)` gains +`options.allowManifestEscalation` (default `false`). In the existing low→high +fold, when the `manifest` layer's posture ranks ABOVE the posture accumulated +from the lower layers (default/user/project) and escalation is not allowed, +the posture assignment is skipped — posture and `postureSource` keep the +operator-scope values — and the return value carries +`suppressedEscalation: { scope: 'manifest', requested }`. A manifest posture at +or below the accumulated rank applies unchanged (narrowing stays allowed, and +`postureSource` becomes `manifest`, keeping `batch config` attribution +truthful). Deny-union, allow replace-by-nearest, and `raw` semantics are +untouched — the manifest's `deny` additions still land even when its posture +raise is refused. Only the `manifest` scope is clamped: user/project config is +operator-owned by definition, which is the trust boundary #87 draws. This is a +data-only change to a pure function — agent-neutral by construction +(`multi-agent-support`): the per-agent flag translator downstream sees only the +resolved posture, so no agent is special-cased. + +**Threading** : `resolveBatchSettings(projectRoot, manifest?, options?)` +forwards `allowManifestEscalation` and exposes the suppression on +`ResolvedBatchSettings` (e.g. `suppressedEscalation`). All existing callers +(`batch config`, defaults resolution) pass no options and keep today's +narrow-by-default behavior — the safe direction. + +**Apply surface** (`src/commands/batch/apply.ts`, `src/cli/index.ts`): +`batch apply` registers `--allow-manifest-escalation` (Commander maps it to +`BatchApplyOptions.allowManifestEscalation`) and passes it into +`resolveBatchSettings`. Immediately after resolution, before step selection, +the command prints a one-line banner `permissions: ( scope)` +(chalk-dim, matching existing output style) and — when a manifest raise was +suppressed — a chalk-yellow warning naming the requested posture and both +remediations. `--json` runs print neither line (JSON payload shapes stay +unchanged; this keeps the slice thin and machine consumers unbroken). The +engine, step selection, and lifecycle instructions are untouched — the CLI +stays a mechanical orchestrator (`delegated-lifecycle`); no lifecycle prose or +done-rule changes. + +**Trade-offs / residuals**: the manifest can still add `allow` entries +(replace-by-nearest) under a non-raised posture, and `phase.proofOfWork.run` +remains manifest-supplied code execution — both are documented residuals of +#87, out of this thin slice. No confirmation prompt is added: `batch apply` is +headless by design, so the flag IS the explicit one-time confirmation and the +flagless refusal IS the headless refusal the issue requires. + +**Testing** (`testing` standard): the new suite lives at +`test/batch-engine/manifest-permission-escalation.test.ts` (the phase +proof-of-work runs `npm test -- test/batch-engine/`), with the `.feature` files +named in the test header. Resolution-clamp scenarios are pure unit tests over +in-memory layers (no filesystem); the flag/banner scenarios are integration +tests over `batchApplyCommand` using the established +`fs.mkdtemp(os.tmpdir())` fixture pattern with a minimal `.ratchet/` tree, +cleaned in `afterEach`, asserting the committed-`batch.yaml` +`full-autonomy`-vs-project-`repo-sandboxed-permissive` case cannot silently +escalate. Existing suites touching `resolvePermissionsPolicy` / +`resolveBatchSettings` (`test/core/batch/config.test.ts`, +`test/batch-engine/agent-permissions.test.ts`, +`test/batch-engine/permission-flags-in-argv.test.ts`) are updated for the new +return shape and stay green; the coverage gate stays at or above the enforced +threshold. + +**Documentation** (`documentation` standard): reference docs change in the same +change — `docs/configuration/config-yaml.md` (the `batch.permissions` merge +semantics: posture is nearest-wins across operator scopes but manifest-scope +posture is narrow-only, plus the suppression behavior) and +`docs/commands/batch.md` (the `--allow-manifest-escalation` flag and the +posture banner). Any existing diagram depicting the permissions merge is +updated if the clamp makes it stale. `README.md` is updated only if it +describes an affected surface (its command table row for `batch apply`). + +## Tasks + +- [x] 1.1 Add the posture privilege ranking (curated-allowlist < repo-sandboxed-permissive < full-autonomy) to `src/core/batch/permissions-policy.ts` +- [x] 1.2 Clamp the manifest layer's posture raise in `resolvePermissionsPolicy` behind an `allowManifestEscalation` option and return the suppressed-escalation report (`narrow-only-resolution.feature`) +- [x] 1.3 Thread `allowManifestEscalation` through `resolveBatchSettings` and expose the suppression on `ResolvedBatchSettings` +- [x] 2.1 Register `--allow-manifest-escalation` on `batch apply` in `src/cli/index.ts` and `BatchApplyOptions`, passing it into settings resolution (`escalation-opt-in.feature`) +- [x] 2.2 Print the effective-posture banner and the suppressed-escalation warning at the start of `batchApplyCommand`, suppressed under `--json` (`apply-posture-banner.feature`, `escalation-opt-in.feature`) +- [x] 3.1 Add `test/batch-engine/manifest-permission-escalation.test.ts` covering all narrow-only-resolution scenarios as pure unit tests, including the committed `batch.yaml` `full-autonomy` vs project `repo-sandboxed-permissive` silent-escalation assertion +- [x] 3.2 Add integration tests in the same suite for the `--allow-manifest-escalation` flag, the refusal warning, and the posture banner over `batchApplyCommand` with the tmpdir fixture pattern +- [x] 3.3 Update existing suites touching the resolution seam (`test/core/batch/config.test.ts`, `test/batch-engine/agent-permissions.test.ts`, `test/batch-engine/permission-flags-in-argv.test.ts`) for the new return shape; full suite and coverage gate green +- [x] 4.1 Documentation (`documentation` standard, mandatory): update `docs/configuration/config-yaml.md` permissions-merge semantics and `docs/commands/batch.md` (`--allow-manifest-escalation`, posture banner), refresh any stale permissions-merge diagram, and update `README.md` where it describes an affected surface +- [x] 5.1 Run `npm test -- test/batch-engine/` (phase proof-of-work) and the full suite; confirm exit code 0 diff --git a/.ratchet/changes/scope-local-agent-env/.ratchet.yaml b/.ratchet/changes/scope-local-agent-env/.ratchet.yaml new file mode 100644 index 0000000..f3907f3 --- /dev/null +++ b/.ratchet/changes/scope-local-agent-env/.ratchet.yaml @@ -0,0 +1,8 @@ +schema: ratchet +created: 2026-07-09 +standards: + - delegated-lifecycle + - documentation + - generalizable-defaults + - multi-agent-support + - testing diff --git a/.ratchet/changes/scope-local-agent-env/features/agent-env-scoping/allowlist.feature b/.ratchet/changes/scope-local-agent-env/features/agent-env-scoping/allowlist.feature new file mode 100644 index 0000000..0f9915a --- /dev/null +++ b/.ratchet/changes/scope-local-agent-env/features/agent-env-scoping/allowlist.feature @@ -0,0 +1,45 @@ +Feature: Agent environment allowlist + As a ratchet operator running batch steps on my own machine + I want engine-spawned agents to receive only an allowlisted environment + So that host secrets outside the allowlist never reach a spawned agent + + Scenario: A non-allowlisted host secret is dropped + Given a host environment containing "SUPER_SECRET_TOKEN=hunter2" alongside PATH and HOME + When the agent environment is scoped through the allowlist + Then the scoped environment does not contain "SUPER_SECRET_TOKEN" + And the scoped environment contains PATH and HOME unchanged + + Scenario: Baseline process variables pass through + Given a host environment with PATH, HOME, TMPDIR, LANG, TERM, and an HTTPS_PROXY value + When the agent environment is scoped through the allowlist + Then every one of those baseline variables is present in the scoped environment with its host value + + Scenario: Ratchet control variables pass through + Given a host environment containing "RATCHET_BATCH_AGENT_CMD=echo stub-agent" + When the agent environment is scoped through the allowlist + Then the scoped environment contains "RATCHET_BATCH_AGENT_CMD" with the host value + + Scenario Outline: Every registered agent's declared keys pass through + Given the adapter registry entry for "" declaring its env passthrough + And a host environment containing a variable matching that declaration + When the agent environment is scoped through the allowlist + Then the matching variable is present in the scoped environment + + Examples: + | agent | + | claude | + | codex | + | gemini | + | cursor | + | opencode | + + Scenario: Every registered adapter declares an env passthrough + Given the built-in adapter registry + When each registered adapter is inspected + Then each adapter declares an env passthrough list so the allowlist cannot silently omit a newly added agent + + Scenario: Operator escape hatch extends the allowlist + Given a host environment containing "MY_CUSTOM_CA=/etc/ca.pem" and "RATCHET_AGENT_ENV_ALLOW=MY_CUSTOM_CA" + When the agent environment is scoped through the allowlist + Then the scoped environment contains "MY_CUSTOM_CA" with the host value + And a host secret not named by the escape hatch is still dropped diff --git a/.ratchet/changes/scope-local-agent-env/features/agent-env-scoping/engine-spawn-env.feature b/.ratchet/changes/scope-local-agent-env/features/agent-env-scoping/engine-spawn-env.feature new file mode 100644 index 0000000..5d73b59 --- /dev/null +++ b/.ratchet/changes/scope-local-agent-env/features/agent-env-scoping/engine-spawn-env.feature @@ -0,0 +1,29 @@ +Feature: Engine spawn requests carry a scoped environment + As a ratchet operator + I want every engine spawn site to thread the scoped environment into the AgentSpawnRequest + So that no engine path exports the full host environment into an agent session + + Scenario: A change-transition spawn request excludes a host secret + Given a host environment containing "SUPER_SECRET_TOKEN=hunter2" + And a batch with a change ready for its next transition + When the engine builds the spawn request for that transition + Then the request env does not contain "SUPER_SECRET_TOKEN" + And the request env contains "RATCHET_BATCH_NAME" with the batch name + + Scenario Outline: Decompose and PR spawns are scoped the same way + Given a host environment containing "SUPER_SECRET_TOKEN=hunter2" + And a batch whose next step is a "" spawn + When the engine builds the spawn request for that step + Then the request env does not contain "SUPER_SECRET_TOKEN" + And the request env contains "RATCHET_BATCH_NAME" with the batch name + + Examples: + | stage | + | decompose | + | pr | + + Scenario: The agent-cmd override still works under the scoped environment + Given a host environment containing "RATCHET_BATCH_AGENT_CMD=echo stub-agent" and "SUPER_SECRET_TOKEN=hunter2" + When the engine builds the spawn request for a change transition + Then the override command stands in for the coding agent + And the request env does not contain "SUPER_SECRET_TOKEN" diff --git a/.ratchet/changes/scope-local-agent-env/features/agent-env-scoping/sidecar-bootstrap-env.feature b/.ratchet/changes/scope-local-agent-env/features/agent-env-scoping/sidecar-bootstrap-env.feature new file mode 100644 index 0000000..d1936ff --- /dev/null +++ b/.ratchet/changes/scope-local-agent-env/features/agent-env-scoping/sidecar-bootstrap-env.feature @@ -0,0 +1,27 @@ +Feature: Sidecar bootstrap launches with a scoped environment + As a ratchet operator using the local locus + I want the ReX sidecar process to launch with only the allowlisted environment + So that a local-locus agent, which inherits the sidecar's environment, cannot see non-allowlisted host secrets + + Scenario: The sidecar launch env excludes a non-allowlisted host secret + Given a host environment containing "SUPER_SECRET_TOKEN=hunter2" alongside PATH and HOME + When the ReX runtime is bootstrapped for the local locus + Then the resolved launch env does not contain "SUPER_SECRET_TOKEN" + And the resolved launch env contains HOME with the host value + + Scenario: The venv wiring survives scoping + Given a ready ReX venv and a host PATH + When the ReX runtime is bootstrapped for the local locus + Then the launch env PATH starts with the venv bin directory followed by the host PATH + And the launch env contains VIRTUAL_ENV pointing at the venv directory + + Scenario: Locus threading vars survive scoping + Given a bootstrap invocation for the docker locus with an image and mount configured + When the ReX runtime is bootstrapped + Then the launch env still carries the REX_LOCUS, REX_WORKDIR, and REX_IMAGE values threaded by the bootstrap + + Scenario: The per-step request env overlays the scoped base in the agent command + Given an AgentSpawnRequest whose env contains "RATCHET_BATCH_NAME=demo" + When the sidecar run command is built for that request + Then the command exports "RATCHET_BATCH_NAME" before invoking the agent + And the exports contain no variable that is absent from the request env diff --git a/.ratchet/changes/scope-local-agent-env/plan.md b/.ratchet/changes/scope-local-agent-env/plan.md new file mode 100644 index 0000000..6671bf3 --- /dev/null +++ b/.ratchet/changes/scope-local-agent-env/plan.md @@ -0,0 +1,114 @@ +# scope-local-agent-env + +## Why + +Engine-spawned agents currently see the operator's entire host environment — every +secret in `process.env` — through two spread sites: the engine builds +`AgentSpawnRequest.env` as `{ ...process.env }` (`src/core/batch/engine/engine.ts:347/488/668`, +serialized into `export` statements in the agent's shell command), and the ReX +bootstrap spreads `...process.env` into the sidecar process env +(`src/core/batch/engine/runtime/rex-bootstrap.ts:540`), which the local-locus agent +inherits. Scoping this to an allowlist is the one real containment improvement +available to the local locus and closes the env-scoping half of issue #86 +(`Fixes #86`, env-scoping half; the eval judge gets the same treatment in #59, +out of scope here). + +## What Changes + +Implements `features/agent-env-scoping/*.feature`. + +- New pure scoping helper (`scopeAgentEnv`) that filters a host environment down to + an allowlist: baseline process vars (PATH, HOME, TMPDIR, locale/terminal, proxy + vars, Windows basics), `RATCHET_*` control vars, forge auth (`GH_TOKEN`, + `GITHUB_TOKEN` — the PR stage drives a forge CLI), and the union of + adapter-declared env keys across the registered agents. +- Every `AgentAdapter` in the built-in registry declares an `envPassthrough` list + (exact names or `PREFIX_*` patterns) — claude, codex, gemini, cursor, opencode — + with a drift guard asserting every registered adapter declares one. +- The three engine spawn sites (change transition, decompose, pr) build the request + env from `scopeAgentEnv(process.env)` plus `RATCHET_BATCH_NAME` instead of + spreading full `process.env`. **BREAKING**: agents no longer see non-allowlisted + host vars; the `RATCHET_AGENT_ENV_ALLOW` escape hatch (comma-separated extra + names) restores specific vars when an operator needs them. +- The ReX bootstrap builds the sidecar launch env from the scoped base instead of + `...process.env`, preserving the venv PATH prefix, `VIRTUAL_ENV`, and all + `REX_*` threading vars. +- Tests assert a planted non-allowlisted host secret is NOT visible in the spawn + request env nor in the sidecar launch env. +- Reference docs: `docs/engine/agent-runtime.md` documents the agent environment + contract (what passes, per-agent keys, the escape hatch). + +## Design + +**One scoping policy, applied at both leak sites.** A single pure module +`src/core/batch/engine/agent-env.ts` owns the allowlist so the engine seam and the +bootstrap seam cannot drift. It is a deterministic function over an in-memory env +object — unit-testable with no filesystem or spawn (testing standard: prove at the +unit level, wire at integration). + +**Union of adapter keys, not per-active-agent.** The engine builds the env before +the adapter is resolved (and the `RATCHET_BATCH_AGENT_CMD` override path has no +adapter at all), so the scoped env is the union of every registered adapter's +declared keys. This keeps the env identical for every agent (multi-agent-support: +no agent special-cased in shared paths) and matters in practice: opencode is +multi-provider and legitimately needs other agents' provider keys. Adapter +declarations live on the adapter (`envPassthrough`), next to the argv they already +own, and the existing registry drift-guard pattern is extended so a newly added +agent cannot silently ship without a declaration. Tests iterate the registry, never +hard-code one agent. + +**Layering is unchanged; only the contents narrow.** Phase 1 built the seam: the +per-step `AgentSpawnRequest.env` is exported over the runtime session's base env +(`buildEnvExports`, overlay semantics). This change does not alter that mechanism — +it narrows WHAT the engine puts in `request.env` and WHAT the bootstrap passes as +the session base, exactly the follow-up `spawn-command.ts` documents as issue #86. +The legacy in-process spawner (`realSpawner`) already uses `request.env` wholesale, +so it is scoped for free via the engine seam. + +**Escape hatch is operator-owned.** `RATCHET_AGENT_ENV_ALLOW` is read from the host +environment (set by the operator invoking ratchet), never from a repo-committed +manifest — consistent with the phase rule that repo-committed config can only +narrow, not escalate. Baseline vars include proxy settings (`HTTP_PROXY`/ +`HTTPS_PROXY`/`NO_PROXY` and lowercase forms) because agents must reach their APIs +through corporate proxies; Windows basics (`SYSTEMROOT`, `COMSPEC`, `PATHEXT`, +`USERPROFILE`, `TEMP`, `TMP`, `APPDATA`, `LOCALAPPDATA`, `PROGRAMDATA`) are listed +unconditionally — absent vars are simply skipped. The allowlist names no package +manager, test runner, or toolchain (generalizable-defaults). + +**Out of scope (thin slice).** The eval judge's identical spread (#59), the venv +*build* step's env (`realRun` during `pip install` — a Node-side build concern, +not agent-visible), and the posture-naming half of #86 (separate change in this +phase). + +## Tasks + +- [x] 1.1 Add `src/core/batch/engine/agent-env.ts`: `scopeAgentEnv(hostEnv)` with the + baseline allowlist, `RATCHET_*`/`LC_*` prefixes, forge keys, adapter-key union, + and the `RATCHET_AGENT_ENV_ALLOW` escape hatch; unit tests + (`test/batch-engine/agent-env.test.ts`, header referencing + `agent-env-scoping/allowlist.feature`) covering secret-dropped, baseline-kept, + ratchet-vars-kept, escape hatch, and per-registry adapter keys. +- [x] 1.2 Declare `envPassthrough` on `AgentAdapter` and every `BUILTIN_ADAPTERS` + entry; extend the registry drift guard so each registered adapter declares one + (iterating the registry, not naming one agent). +- [x] 2.1 Replace the three `{ ...process.env }` spreads in + `src/core/batch/engine/engine.ts` (change transition :347, decompose :488, + pr :668) with the scoped env + `RATCHET_BATCH_NAME`; integration tests assert a + planted `SUPER_SECRET_TOKEN` is absent from the captured spawn-request env for + all three stages, `RATCHET_BATCH_NAME` present, and the + `RATCHET_BATCH_AGENT_CMD` override still honored + (`agent-env-scoping/engine-spawn-env.feature`). +- [x] 2.2 Replace the `...process.env` spread in `bootstrapRexRuntime` + (`src/core/batch/engine/runtime/rex-bootstrap.ts:540`) with the scoped base, + keeping the venv PATH prefix, `VIRTUAL_ENV`, and `REX_*` threading; update + `test/batch-engine/rex-bootstrap.test.ts` to assert the launch env drops a + planted secret and keeps the venv/REX wiring + (`agent-env-scoping/sidecar-bootstrap-env.feature`). +- [x] 3.1 Documentation task (per the `documentation` standard, mandatory): add an + "Agent environment" reference section to `docs/engine/agent-runtime.md` + describing the allowlist contract (baseline vars, `RATCHET_*`, forge keys, + per-agent `envPassthrough`, `RATCHET_AGENT_ENV_ALLOW`), accurate to the code in + this change; verify `README.md` describes no now-stale env behavior and update + it if it does. +- [x] 3.2 Run `npm test -- test/batch-engine/` (phase proof-of-work) and the full + suite with the coverage gate; all green. diff --git a/README.md b/README.md index e0286bd..ed7a19f 100644 --- a/README.md +++ b/README.md @@ -144,7 +144,7 @@ ratchet --version | **Playwright** | Drives the Given/When/Then browser scenarios of a `kind: web` eval binding. | Only when a `kind: web` eval binding is in scope | | **A configured git remote** | PR grouping spawns a PR agent at batch completion that pushes the work branch and opens a PR, which needs somewhere to push. | Only when `prGrouping` is active | -Run **`ratchet doctor`** to validate your setup — it checks each of these and prints an actionable remedy for anything missing (including an advisory warning when `prGrouping` is active but your repo has no configured git remote). Doctor also verifies that any agent CLI named by your project's `batch.agent` setting is installed (an `agent[:model]` spec is parsed and the agent part's binary is probed; the model part is never validated). `ratchet init` also runs these checks once, automatically, the first time you initialize a project (advisory only — it never blocks setup). +Run **`ratchet doctor`** to validate your setup — it checks each of these and prints an actionable remedy for anything missing (including an advisory warning when `prGrouping` is active but your repo has no configured git remote, and an advisory nudge toward the `docker` locus when batch runs are local with a permissive permission posture). Doctor also verifies that any agent CLI named by your project's `batch.agent` setting is installed (an `agent[:model]` spec is parsed and the agent part's binary is probed; the model part is never validated). `ratchet init` also runs these checks once, automatically, the first time you initialize a project (advisory only — it never blocks setup). ### From source (development) @@ -230,7 +230,7 @@ The `core` profile installed by a stock `ratchet init` ships the change workflow | `new batch ` | Scaffold a batch manifest (`.ratchet/batches//batch.yaml`) | | `batch status [name]` | Live phase/change status derived from disk, incl. parked gates/blockers (`--json`) | | `batch view` / `batch list` | Rich dashboards of a batch (or all batches) | -| `batch config [name]` | Resolved batch settings: project defaults + manifest overrides + agent permissions | +| `batch config [name]` | Resolved batch settings: project defaults + manifest overrides + agent permissions, with honest per-locus isolation and per-agent enforcement rendering | | `batch apply [name]` | Advance the batch by **one** transition via the bundled engine (single-step) | | `batch report [name]` | Record an agent answer / approval to cross a halt (`--change`, `--answer`) | | `batch rerun-proof [name]` | Invalidate a phase's recorded proof-of-work (`--phase`, `--json`) so the next `batch apply` re-runs its boundary proof | diff --git a/docs/commands/doctor.md b/docs/commands/doctor.md index 580fe6c..49d33f7 100644 --- a/docs/commands/doctor.md +++ b/docs/commands/doctor.md @@ -21,14 +21,17 @@ ratchet doctor [options] ## Checks -Three checks always run, in a fixed order: agent, runtime, docker. Two further -checks are conditional and each is appended only when it is relevant, otherwise -absent from the report entirely (not merely hidden or skipped): +Three checks always run, in a fixed order: agent, runtime, docker. Three +further checks are conditional and each is appended only when it is relevant, +otherwise absent from the report entirely (not merely hidden or skipped): - **Playwright** — appended only when a `kind: web` binding is present among the eval bindings resolved from `.ratchet/evals/specs/`. - **Git remote (`pr-remote`)** — appended only when `prGrouping` is active for the project (resolved from config) **and** the repo has no configured git remote. +- **Batch isolation (`batch-isolation`)** — appended only when the resolved + batch locus is `local` **and** the effective permission posture is permissive + (`repo-sandboxed-permissive` or `full-autonomy`). ### Coding-agent CLI (`agent`) — required @@ -97,8 +100,39 @@ PR grouping is active but the repo has no remote to push to. Remedy: configure a remote (e.g. `git remote add `). The remedy names no forge-specific CLI — which forge (`gh`, `glab`, or other) opens the PR is left to the user's environment. -Like Docker and Playwright, this `info` notice never fails doctor or affects the exit -code. +Like Docker and Playwright, this `info` notice never fails doctor or affects the +exit code. + +### Batch isolation (`batch-isolation`) — optional, conditional + +Appended only when the resolved batch locus is `local` **and** the effective +permission posture is permissive (`repo-sandboxed-permissive` or `full-autonomy`). +It is absent from the report when the locus is `docker` or `remote`, or when the +posture is `curated-allowlist` (already restrictive enough that no nudge is +warranted). The check resolves the same batch settings `batch config` uses (see +[`batch config`](batch.md#batch-config)). + +The local locus imposes no process boundary — the permission posture is the only +gate. A permissive posture on the local locus means the agent can write anywhere +the operator's account can. This check nudges the operator toward the `docker` +locus, which adds a real container boundary, rather than silently relying on +advisory posture alone. + +**Info**: locus is `local` and posture is `full-autonomy` — the strongest nudge. +Detail explains that full autonomy with no process boundary gives the agent the +operator's full write surface. Remedy: set `locus: docker` to add a container +boundary (and see +[#85](https://github.com/anomaly-ai/ratchet/issues/85) for the container +hardening contract). + +**Info**: locus is `local` and posture is `repo-sandboxed-permissive` — a softer +advisory nudge. Remedy: consider `locus: docker` for a process boundary, or +`curated-allowlist` to restrict the agent to an approved command set. + +**Absent**: locus is `docker` or `remote`, or posture is `curated-allowlist`. + +Like the other optional checks, this `info` notice never fails doctor or affects +the exit code. ## Human output @@ -157,7 +191,7 @@ Fields: | Field | Type | Description | |---|---|---| | `ok` | boolean | `true` iff every `required` check has `status: "pass"`. Drives the exit code. | -| `checks[].id` | string | Stable machine id: `agent`, `runtime`, `docker`; `playwright` only when a `kind: web` binding is in scope; `pr-remote` only when `prGrouping` is active and no git remote is configured. | +| `checks[].id` | string | Stable machine id: `agent`, `runtime`, `docker`; `playwright` only when a `kind: web` binding is in scope; `pr-remote` only when `prGrouping` is active and no git remote is configured; `batch-isolation` only when locus is `local` and posture is permissive. | | `checks[].label` | string | Short human label. | | `checks[].status` | `"pass"` \| `"fail"` \| `"info"` | Verdict for this check. | | `checks[].severity` | `"required"` \| `"optional"` | Whether a failure gates the exit code. | diff --git a/src/cli/index.ts b/src/cli/index.ts index 3dce38d..32c18d9 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -553,6 +553,7 @@ batchCmd .description('Resolve, get, or set batch settings') .option('--set ', 'Set a project-level batch setting') .option('--json', 'Output as JSON') + .option('--allow-manifest-escalation', 'Let a manifest raise the posture (preview only)') .action(async (name: string | undefined, options: BatchConfigOptions) => { try { await batchConfigCommand(name, options); @@ -573,7 +574,7 @@ batchCmd .option('--complete ', 'Signal the step produced its output') .option('--answer ', 'Record an answer to a parked blocker') .option('--reject ', 'Reject an awaiting-approval step with feedback') - .option('--awaiting-approval', 'Mark a completion as awaiting approval (after-propose gate)') + .option('--awaiting-approval', 'Mark a completion as awaiting approval (gate×transition matrix)') .option('--json', 'Output as JSON') .action(async (name: string | undefined, options: BatchReportOptions) => { try { @@ -604,6 +605,10 @@ batchCmd .command('apply [name]') .description('Advance the batch by one step via the bundled engine') .option('--json', 'Output as JSON') + .option( + '--allow-manifest-escalation', + 'Let the repo-committed manifest raise the posture above operator scopes (default: manifest may only narrow)' + ) .action(async (name: string | undefined, options: BatchApplyOptions) => { try { await batchApplyCommand(name, options); diff --git a/src/commands/batch/config.ts b/src/commands/batch/config.ts index e1ef40b..e1a295a 100644 --- a/src/commands/batch/config.ts +++ b/src/commands/batch/config.ts @@ -22,10 +22,23 @@ import { type BatchSettings, } from '../../core/batch/config.js'; import { batchExists } from '../../core/batch/manifest.js'; +import { AGENT_STAGE_KEYS, resolveAgentForStage } from '../../core/batch/agent-setting.js'; +import { + resolvePostureEnforcement, + type PostureEnforcement, +} from '../../core/batch/runtime/agent-permissions.js'; +import { describeLocusIsolation } from '../../core/batch/runtime/isolation.js'; export interface BatchConfigOptions { set?: string; json?: boolean; + /** + * Per-invocation opt-in letting a repo-committed manifest RAISE the posture + * above the operator-owned scopes (mirrors `batch apply`). Set via + * `batch config --allow-manifest-escalation` so an operator can preview the + * escalated posture and its manifest-source annotation without running. + */ + allowManifestEscalation?: boolean; } export async function batchConfigCommand( @@ -67,7 +80,9 @@ export async function batchConfigCommand( throw new Error(`Batch '${name}' not found under .ratchet/batches.`); } - const resolved = resolveBatchSettings(projectRoot, manifest); + const resolved = resolveBatchSettings(projectRoot, manifest, { + allowManifestEscalation: options.allowManifestEscalation, + }); if (options.json) { // Redact the secret authToken before printing — `ratchet batch config` @@ -76,11 +91,19 @@ export async function batchConfigCommand( ...resolved, settings: redactSettings(resolved.settings), }; - console.log(JSON.stringify({ name: name ?? null, ...safe }, null, 2)); + const isolation = describeLocusIsolation(resolved.settings); + const enforcement = resolveEnforcementEntries(resolved, projectRoot); + console.log( + JSON.stringify( + { name: name ?? null, ...safe, isolation, enforcement }, + null, + 2 + ) + ); return; } - printResolved(name, resolved); + printResolved(name, resolved, projectRoot); } const KEYS: (keyof BatchSettings)[] = [ @@ -95,7 +118,11 @@ const KEYS: (keyof BatchSettings)[] = [ 'authToken', ]; -function printResolved(name: string | undefined, resolved: ResolvedBatchSettings): void { +function printResolved( + name: string | undefined, + resolved: ResolvedBatchSettings, + repoRoot: string +): void { const heading = name ? `Effective batch settings for '${name}'` : 'Batch settings (project)'; console.log(chalk.bold(`\n${heading}\n`)); @@ -109,13 +136,27 @@ function printResolved(name: string | undefined, resolved: ResolvedBatchSettings console.log(` ${key.padEnd(12)} ${valueText.padEnd(18)} ${sourceText}`); } + // Isolation line: state the real isolation of the resolved locus so an + // operator can never mistake an advisory posture (local) for a real sandbox. + // The description is pure data over the resolved settings (see + // `runtime/isolation.ts`), so this rendering can never drift from the locus + // the engine actually runs under. + const isolation = describeLocusIsolation(resolved.settings); + console.log(chalk.bold('\n isolation')); + console.log(` ${isolation.locus.padEnd(10)} ${isolation.description}`); + // Permissions is a structured policy, not a scalar — render it as its own // block. `display` is already redacted, so any secret-bearing `raw` value is // masked here too. const permissions = display.permissions; if (permissions) { console.log(chalk.bold('\n permissions')); - console.log(` posture ${permissions.posture} ${sourceLabel(resolved.sources.permissions)}`); + const postureSource = resolved.sources.permissions; + const postureLabel = + postureSource === 'manifest' + ? `${permissions.posture} (set by batch manifest — repo-controlled)` + : permissions.posture; + console.log(` posture ${postureLabel} ${sourceLabel(postureSource)}`); if (permissions.allow.length > 0) { console.log(` allow ${permissions.allow.join(', ')}`); } @@ -129,6 +170,21 @@ function printResolved(name: string | undefined, resolved: ResolvedBatchSettings console.log(` raw.${agent.padEnd(8)} ${fragment.join(' ')}`); } } + // Per-agent enforcement: one line per distinct resolved stage agent, + // derived from the SAME per-agent mappers that build spawn argv so the + // rendered status can never drift from what reaches the agent (#88). A + // posture that yields no argv fragment (cursor / opencode) renders as + // `NOT ENFORCED — agent defaults apply` so an operator is never told a + // posture is enforced when the agent will run on its own defaults. + const enforcement = resolveEnforcementEntries(resolved, repoRoot); + if (enforcement.length > 0) { + for (const e of enforcement) { + const statusText = e.enforced + ? chalk.green(e.detail) + : chalk.yellow(e.detail); + console.log(` enforce ${e.agent.padEnd(10)} ${statusText}`); + } + } } } @@ -144,3 +200,44 @@ function sourceLabel(source: ResolvedBatchSettings['sources'][keyof ResolvedBatc return chalk.dim('[default]'); } } + +/** + * Resolve the distinct agent names the batch will actually spawn, by iterating + * every routable stage over the resolved `agent` setting. A scalar covers every + * stage (so it dedupes to one agent); a per-stage map yields the named agents; + * an unset setting yields none (the caller's default agent — not rendered here). + * Order is stage-order then first-seen, so the rendered enforcement lines are + * stable and readable. + */ +function resolveDistinctStageAgents(settings: BatchSettings): string[] { + const seen = new Set(); + const ordered: string[] = []; + for (const stage of AGENT_STAGE_KEYS) { + const spec = resolveAgentForStage(settings.agent, stage); + if (spec === undefined) continue; // unset stage → caller's default agent + const agentName = spec.split(':')[0]; + if (agentName.length === 0) continue; + if (!seen.has(agentName)) { + seen.add(agentName); + ordered.push(agentName); + } + } + return ordered; +} + +/** + * Resolve one enforcement entry per distinct resolved stage agent, consulting the + * SAME per-agent translators that build spawn argv (see + * `resolvePostureEnforcement`) so the rendered status never drifts from what + * reaches the agent. Returns `[]` when no agent is resolved (unset setting → + * the caller's default agent, not rendered here) or when the policy is absent. + */ +function resolveEnforcementEntries( + resolved: ResolvedBatchSettings, + repoRoot: string +): PostureEnforcement[] { + const policy = resolved.settings.permissions; + if (!policy) return []; + const agents = resolveDistinctStageAgents(resolved.settings); + return agents.map((agent) => resolvePostureEnforcement(agent, policy, repoRoot)); +} diff --git a/src/commands/batch/view.ts b/src/commands/batch/view.ts index 9e9018e..d5975ed 100644 --- a/src/commands/batch/view.ts +++ b/src/commands/batch/view.ts @@ -20,6 +20,13 @@ import { } from '../../core/batch/status.js'; import { readRunState, readJournal } from '../../core/batch/journal.js'; import { resolveBatchName, listBatchNames } from './shared.js'; +import { resolveBatchSettings } from '../../core/batch/config.js'; +import { describeLocusIsolation } from '../../core/batch/runtime/isolation.js'; +import { + resolvePostureEnforcement, + type PostureEnforcement, +} from '../../core/batch/runtime/agent-permissions.js'; +import { AGENT_STAGE_KEYS, resolveAgentForStage } from '../../core/batch/agent-setting.js'; export interface BatchViewOptions { json?: boolean; @@ -78,16 +85,88 @@ export async function batchViewCommand( // (an all-tasks-checked change with no journaled verify renders awaiting-verify). const journal = readJournal(projectRoot, batchName); const status = await computeBatchStatus(projectRoot, manifest, runState, journal); + // Resolve the batch's runtime settings so the dashboard renders an honest + // runtime summary (locus + its real isolation, posture + source + per-agent + // enforcement) — the same descriptors `batch config` uses, so the runtime + // story can never drift between surfaces (#86 posture-honesty half). + const resolved = resolveBatchSettings(projectRoot, manifest); if (options.json) { - console.log(JSON.stringify(status, null, 2)); + const isolation = describeLocusIsolation(resolved.settings); + const enforcement = resolveViewEnforcement(resolved, projectRoot); + console.log( + JSON.stringify( + { ...status, isolation, enforcement, posture: resolved.settings.permissions?.posture }, + null, + 2 + ) + ); return; } - renderSingleBatch(status); + renderSingleBatch(status, resolved, projectRoot); } -function renderSingleBatch(status: BatchStatusInfo): void { +/** + * Render the runtime summary line: the locus and its real isolation, plus the + * posture with its source scope and per-agent enforcement status. Reuses the + * same descriptors as `batch config` so the wording never drifts. Printed right + * under the progress bar so an operator inspecting a batch sees the honest + * runtime story before the change list. + */ +function renderRuntimeSummary( + resolved: ReturnType, + repoRoot: string +): void { + const isolation = describeLocusIsolation(resolved.settings); + console.log( + chalk.dim(` runtime: ${isolation.locus} — ${isolation.description}`) + ); + const policy = resolved.settings.permissions; + if (!policy) return; + const sourceTag = + resolved.sources.permissions === 'manifest' + ? ' (set by batch manifest — repo-controlled)' + : resolved.sources.permissions === 'project' + ? ' [project]' + : resolved.sources.permissions === 'user' + ? ' [user]' + : ' [default]'; + console.log(chalk.dim(` posture: ${policy.posture}${sourceTag}`)); + const enforcement = resolveViewEnforcement(resolved, repoRoot); + for (const e of enforcement) { + const tag = e.enforced ? 'enforced' : 'NOT ENFORCED'; + console.log(chalk.dim(` ${e.agent}: ${tag}`)); + } +} + +/** Distinct stage agents → per-agent enforcement entries (mirrors `batch config`). */ +function resolveViewEnforcement( + resolved: ReturnType, + repoRoot: string +): PostureEnforcement[] { + const policy = resolved.settings.permissions; + if (!policy) return []; + const seen = new Set(); + const ordered: string[] = []; + for (const stage of AGENT_STAGE_KEYS) { + const spec = resolveAgentForStage(resolved.settings.agent, stage); + if (spec === undefined) continue; + const agentName = spec.split(':')[0]; + if (agentName.length === 0) continue; + if (!seen.has(agentName)) { + seen.add(agentName); + ordered.push(agentName); + } + } + return ordered.map((a) => resolvePostureEnforcement(a, policy, repoRoot)); +} + +function renderSingleBatch( + status: BatchStatusInfo, + resolved: ReturnType, + repoRoot: string +): void { console.log(chalk.bold(`\nBatch: ${status.name}`)); console.log('═'.repeat(60)); @@ -101,6 +180,11 @@ function renderSingleBatch(status: BatchStatusInfo): void { )}` ); + // Honest runtime summary: locus + real isolation, posture + source + per- + // agent enforcement. Rendered before the change list so an operator sees the + // security story first (see view-runtime-summary.feature). + renderRuntimeSummary(resolved, repoRoot); + if (status.changeCount === 0) { console.log( chalk.dim( diff --git a/src/core/batch/config.ts b/src/core/batch/config.ts index 216da4f..fea7460 100644 --- a/src/core/batch/config.ts +++ b/src/core/batch/config.ts @@ -19,6 +19,7 @@ import { PERMISSION_RAW_AGENTS, PermissionsPolicySchema, DEFAULT_PERMISSION_POSTURE, + POSTURE_PRIVILEGE_RANK, } from './permissions-policy.js'; import type { PermissionPosture, @@ -71,6 +72,32 @@ export const PR_GROUPING_VALUES = ['off', 'whole-batch', 'per-phase', 'per-chang */ export const DEFAULT_DOCKER_IMAGE = 'python:3.12'; +/** + * Default memory limit for `locus: docker` when no `dockerMemory` is configured. + * Docker accepts `2g` (2 gibibytes); a sane, modest default that bounds the + * agent without starving typical coding-agent workloads. + * + * SINGLE TS SOURCE OF TRUTH: the bootstrap threads the resolved value via + * `REX_DOCKER_MEMORY`; the Python sidecar keeps its OWN mirror only as a pure + * unset-fallback (Node always threads the env). Keep the two in sync. + */ +export const DEFAULT_DOCKER_MEMORY = '2g'; + +/** + * Default `--pids-limit` for `locus: docker` when no `dockerPidsLimit` is + * configured. 512 bounds fork-bomb-style runaway while leaving headroom for + * normal agent tooling. SINGLE TS SOURCE OF TRUTH (see `DEFAULT_DOCKER_MEMORY`). + */ +export const DEFAULT_DOCKER_PIDS_LIMIT = 512; + +/** + * Default `--network` mode for `locus: docker` when no `network` is configured. + * `bridge` is Docker's default and means the container HAS outbound network + * access (the honest isolation contract documents this explicitly). Operators + * who want no network set `network: none`. SINGLE TS SOURCE OF TRUTH. + */ +export const DEFAULT_DOCKER_NETWORK = 'bridge'; + export type Gate = (typeof GATE_VALUES)[number]; export type Strategy = (typeof STRATEGY_VALUES)[number]; export type ProofOfWorkPolicy = (typeof PROOF_OF_WORK_POLICY_VALUES)[number]; @@ -95,6 +122,7 @@ export { PERMISSION_RAW_AGENTS, PermissionsPolicySchema, DEFAULT_PERMISSION_POSTURE, + POSTURE_PRIVILEGE_RANK, } from './permissions-policy.js'; export type { PermissionPosture, @@ -190,6 +218,38 @@ export interface BatchSettings { * `RATCHET_AGENT_TIMEOUT_MS` env override taking precedence over this key. */ agentTimeoutMs?: number; + /** + * Run the agent container as this host uid:gid for `locus: docker`. When + * unset and locus is `docker`, the runtime resolves the current host + * uid:gid (so container writes land as the host user, not root). Ignored + * for `local`/`remote`. Free-form string (`"1000:1000"`). + */ + dockerUser?: string; + /** + * `--memory` limit for `locus: docker` (e.g. `"2g"`). When unset and locus + * is `docker`, the runtime uses `DEFAULT_DOCKER_MEMORY`. Ignored for + * `local`/`remote`. Free-form string passed verbatim to `docker run`. + */ + dockerMemory?: string; + /** + * `--pids-limit` for `locus: docker` (positive integer). When unset and + * locus is `docker`, the runtime uses `DEFAULT_DOCKER_PIDS_LIMIT`. Ignored + * for `local`/`remote`. + */ + dockerPidsLimit?: number; + /** + * `--cpus` quota for `locus: docker` (positive number, may be fractional). + * When unset, NO `--cpus` flag is passed (Docker's default applies). + * Ignored for `local`/`remote`. + */ + dockerCpus?: number; + /** + * `--network` mode for `locus: docker` (e.g. `"bridge"`, `"none"`). When + * unset and locus is `docker`, the runtime uses `DEFAULT_DOCKER_NETWORK` + * (`bridge` — the container HAS outbound network; set `none` to fully + * isolate). Ignored for `local`/`remote`. Free-form string passed verbatim. + */ + network?: string; } /** @@ -314,6 +374,29 @@ export interface ResolvedBatchSettings { * engine's model-failure hint. See {@link resolveAgentStageScopes}. */ agentStageScopes: Partial>; + /** + * A manifest layer's request to raise the posture above the operator-owned + * scopes that was refused because manifest escalation was not allowed. Set + * when the repo-committed manifest tried to raise posture and + * {@link ResolveBatchSettingsOptions.allowManifestEscalation} was not set, so + * `batch apply` can print a warning naming the requested posture and the + * opt-in flag. Absent when no raise was attempted or when escalation was + * explicitly allowed. + */ + suppressedEscalation?: SuppressedEscalation; +} + +/** Options for {@link resolveBatchSettings}. */ +export interface ResolveBatchSettingsOptions { + /** + * Whether a repo-committed manifest layer is allowed to RAISE the posture + * above the operator-owned (default/user/project) scopes' accumulated value. + * Default `false`: a manifest may only NARROW posture (lower it); its `deny` + * additions still union. Pass `true` for the per-invocation opt-in + * (`batch apply --allow-manifest-escalation`) to let a manifest raise posture + * unchanged. See {@link resolvePermissionsPolicy} for the clamping semantics. + */ + allowManifestEscalation?: boolean; } export const DEFAULT_BATCH_SETTINGS: BatchSettings = { @@ -337,6 +420,11 @@ const SETTING_KEYS: (keyof BatchSettings)[] = [ 'authToken', 'insecure', 'agentTimeoutMs', + 'dockerUser', + 'dockerMemory', + 'dockerPidsLimit', + 'dockerCpus', + 'network', ]; const ALLOWED_VALUES: Record = { @@ -352,6 +440,11 @@ const ALLOWED_VALUES: Record = { authToken: null, // free-form secret string (swerex-remote X-API-Key) insecure: ['true', 'false'], // boolean opt-in for plaintext to a non-local host agentTimeoutMs: null, // free-form numeric (positive integer ms; like `port`) + dockerUser: null, // free-form string (host uid:gid, e.g. "1000:1000") + dockerMemory: null, // free-form string (docker --memory, e.g. "2g") + dockerPidsLimit: null, // numeric string (positive integer; like `port`) + dockerCpus: null, // free-form numeric (positive number, may be fractional) + network: null, // free-form string (docker --network mode, e.g. "bridge") }; /** @@ -367,10 +460,22 @@ const ALLOWED_VALUES: Record = { * nearest-wins, `deny` is the UNION of every scope, `allow` is REPLACED by the * nearest scope that defines one, and each agent's `raw` entry is nearest-wins. * Permissions always resolve (the no-config default is the built-in posture). + * + * The repo-committed `manifest` layer is the one repo-author-controlled (not + * operator-controlled) scope, so it is the ONLY scope whose posture raise is + * clamped: when its posture ranks ABOVE the value accumulated from the + * operator-owned (default/user/project) scopes and + * {@link ResolveBatchSettingsOptions.allowManifestEscalation} is not set, the + * raise is skipped — posture keeps the operator value — and the refusal is + * reported via {@link ResolvedBatchSettings.suppressedEscalation} for `batch + * apply` to warn about. The manifest's `deny` additions still land. Pass the + * per-invocation opt-in (`batch apply --allow-manifest-escalation`) to allow a + * manifest raise unchanged. */ export function resolveBatchSettings( projectRoot: string, - manifest?: BatchManifest | null + manifest?: BatchManifest | null, + options: ResolveBatchSettingsOptions = {} ): ResolvedBatchSettings { const settings: BatchSettings = { ...DEFAULT_BATCH_SETTINGS }; const sources: Record = { @@ -387,6 +492,11 @@ export function resolveBatchSettings( permissions: 'default', insecure: 'default', agentTimeoutMs: 'default', + dockerUser: 'default', + dockerMemory: 'default', + dockerPidsLimit: 'default', + dockerCpus: 'default', + network: 'default', }; const writable = settings as { [K in keyof BatchSettings]: BatchSettings[K] }; @@ -456,11 +566,14 @@ export function resolveBatchSettings( { scope: 'project', policy: projectBatch?.permissions }, { scope: 'manifest', policy: manifestOverrides?.permissions }, ]; - const { policy, postureSource } = resolvePermissionsPolicy(permissionLayers); + const { policy, postureSource, suppressedEscalation } = resolvePermissionsPolicy( + permissionLayers, + { allowManifestEscalation: options.allowManifestEscalation } + ); settings.permissions = policy; sources.permissions = postureSource; - return { settings, sources, agentStageScopes }; + return { settings, sources, agentStageScopes, suppressedEscalation }; } /** @@ -649,17 +762,64 @@ export function resolveAgentTimeoutMs( return undefined; } +/** + * A manifest layer's request to raise posture above the operator-owned scopes + * (default/user/project) that was refused because {@link + * ResolvePermissionsPolicyOptions.allowManifestEscalation} was not set. Callers + * surface this so the refusal is visible instead of hidden — `batch apply` prints + * a warning naming the requested posture and the opt-in flag. + */ +export interface SuppressedEscalation { + /** The scope whose posture raise was refused — always `'manifest'`. */ + scope: 'manifest'; + /** The posture the refused layer requested. */ + requested: PermissionPosture; +} + +/** Options for {@link resolvePermissionsPolicy}. */ +export interface ResolvePermissionsPolicyOptions { + /** + * Whether a manifest layer is allowed to RAISE the posture above the value + * accumulated from the operator-owned scopes (default/user/project). Default + * `false`: the repo-committed manifest may only NARROW (lower posture); its + * `deny` additions still union. When `true`, a manifest posture raise applies + * unchanged (the explicit per-invocation opt-in `batch apply + * --allow-manifest-escalation`). + */ + allowManifestEscalation?: boolean; +} + /** * Merge a set of permission layers (ordered low→high precedence) into a single * resolved policy. Posture nearest-wins; deny union; allow replace-by-nearest; * raw per-agent nearest-wins. Returns the source of the winning posture so * `batch config` can annotate where the effective policy came from. + * + * The `manifest` layer is the one repo-author-controlled (not + * operator-controlled) scope, so it is the ONLY scope whose posture raise is + * clamped: when its posture ranks ABOVE the value accumulated from the + * lower-precedence (default/user/project) layers and + * `options.allowManifestEscalation` is not set, the raise is skipped — posture + * and `postureSource` keep the operator-scope values — and the refusal is + * reported via `suppressedEscalation`. A manifest posture at or below the + * accumulated rank applies unchanged (narrowing stays allowed, and + * `postureSource` becomes `manifest`, keeping `batch config` attribution + * truthful). The manifest's `deny` additions still land even when its posture + * raise is refused. Operator-owned `user`/`project` scopes keep their existing + * raise ability (they are the trust boundary #87 draws). */ export function resolvePermissionsPolicy( - layers: { scope: SettingSource; policy: PermissionsPolicy | undefined }[] -): { policy: ResolvedPermissionsPolicy; postureSource: SettingSource } { + layers: { scope: SettingSource; policy: PermissionsPolicy | undefined }[], + options: ResolvePermissionsPolicyOptions = {} +): { + policy: ResolvedPermissionsPolicy; + postureSource: SettingSource; + suppressedEscalation?: SuppressedEscalation; +} { + const allowManifestEscalation = options.allowManifestEscalation ?? false; let posture: PermissionPosture = DEFAULT_PERMISSION_POSTURE; let postureSource: SettingSource = 'default'; + let suppressedEscalation: SuppressedEscalation | undefined; const denySet = new Set(); let allow: string[] = []; const raw: ResolvedPermissionsPolicy['raw'] = {}; @@ -667,8 +827,21 @@ export function resolvePermissionsPolicy( for (const { scope, policy } of layers) { if (!policy) continue; if (policy.posture !== undefined) { - posture = policy.posture; - postureSource = scope; + if ( + scope === 'manifest' && + !allowManifestEscalation && + POSTURE_PRIVILEGE_RANK[policy.posture] > POSTURE_PRIVILEGE_RANK[posture] + ) { + // The repo-committed manifest tried to RAISE posture above the + // operator-owned scopes' accumulated value. Clamp it: keep the operator + // posture and source, and report the refusal so callers can surface it + // instead of hiding it. Deny/allow/raw below still apply (the manifest's + // deny additions still land even when its posture raise is refused). + suppressedEscalation = { scope: 'manifest', requested: policy.posture }; + } else { + posture = policy.posture; + postureSource = scope; + } } // deny: union across every scope (a narrower scope cannot drop a denial). if (policy.deny) { @@ -690,6 +863,7 @@ export function resolvePermissionsPolicy( return { policy: { posture, allow, deny: [...denySet], raw }, postureSource, + suppressedEscalation, }; } @@ -786,6 +960,53 @@ const SETTING_CODECS: Partial> = { insecure: { serialize: (value) => value.trim() === 'true', }, + // Docker-locus hardening knobs (features/docker-locus-hardening). The string + // knobs (`dockerUser`, `dockerMemory`, `network`) must be non-empty — an + // empty value is rejected before the config is written, mirroring `image`. + dockerUser: { + validate: (value, key) => + value.trim().length === 0 + ? { ok: false, error: `Invalid value for '${key}': it must not be empty.` } + : undefined, + }, + dockerMemory: { + validate: (value, key) => + value.trim().length === 0 + ? { ok: false, error: `Invalid value for '${key}': it must not be empty.` } + : undefined, + }, + network: { + validate: (value, key) => + value.trim().length === 0 + ? { ok: false, error: `Invalid value for '${key}': it must not be empty.` } + : undefined, + }, + // `dockerPidsLimit` is a positive integer (persisted numeric, like `port`). + dockerPidsLimit: { + validate: (value, key) => + !isValidPort(value) + ? { + ok: false, + error: `Invalid value for '${key}': it must be a positive integer (got '${value}').`, + } + : undefined, + serialize: (value) => Number(value.trim()), + }, + // `dockerCpus` is a positive number (fractional ok, e.g. "1.5"). Persisted + // numeric so the loader round-trips it without warning. + dockerCpus: { + validate: (value, key) => { + const n = Number(value.trim()); + if (value.trim().length === 0 || !Number.isFinite(n) || n <= 0) { + return { + ok: false, + error: `Invalid value for '${key}': it must be a positive number (got '${value}').`, + }; + } + return undefined; + }, + serialize: (value) => Number(value.trim()), + }, // The `agent` key is validated through the SAME shared schema the loaders use // (AgentSettingSchema's superRefine routes every string position through // parseAgentSpec), so the write path can never persist what the loader diff --git a/src/core/batch/engine/agent-env.ts b/src/core/batch/engine/agent-env.ts new file mode 100644 index 0000000..d6512c5 --- /dev/null +++ b/src/core/batch/engine/agent-env.ts @@ -0,0 +1,153 @@ +/** + * Agent environment allowlist — the single scoping policy applied at both env + * leak sites (the engine spawn request env and the ReX sidecar bootstrap env). + * + * `scopeAgentEnv(hostEnv)` is a pure, deterministic function over an in-memory + * env object: no filesystem, no spawn. It filters the host environment down to + * an allowlist so non-allowlisted host secrets never reach a spawned agent. + * + * The allowlist is the union of: + * - Baseline process vars (PATH, HOME, TMPDIR, locale/terminal, proxy, Windows + * basics) — the minimum an agent's process needs to function. + * - `RATCHET_*` control vars — the operator's ratchet config/overrides. + * - `LC_*` locale vars. + * - Forge auth keys (`GH_TOKEN`, `GITHUB_TOKEN`) — the PR stage drives a forge + * CLI that needs them. + * - The union of every registered adapter's declared `envPassthrough` keys — + * each agent's own API keys / config, collected from the built-in registry + * so the env is identical for every agent (no agent special-cased in shared + * paths). A `PREFIX_*` entry matches any var starting with `PREFIX_`. + * - The `RATCHET_AGENT_ENV_ALLOW` escape hatch — a comma-separated list of + * extra host env names the operator needs, read from the host environment + * (never from a repo-committed manifest, consistent with the phase rule that + * repo-committed config can only narrow, not escalate). + * + * Absent vars are simply skipped — no key is synthesized. + */ + +import { availableAdapters, resolveAdapter } from './agent.js'; + +/** + * The env var that extends the allowlist with operator-named extra host vars. + * Its value is a comma-separated list of env var names. Declared here so the + * scoper, docs, and tests all reference the same literal. + */ +export const AGENT_ENV_ALLOW_VAR = 'RATCHET_AGENT_ENV_ALLOW'; + +/** + * Baseline process variables always on the allowlist. Present vars pass through + * with their host value; absent vars are simply skipped. No package manager, + * test runner, or toolchain is named (generalizable-defaults). + */ +const BASELINE_VARS: readonly string[] = [ + // Command resolution + home + 'PATH', + 'HOME', + 'TMPDIR', + // Locale + terminal + 'LANG', + 'TERM', + // Proxy settings (agents must reach their APIs through corporate proxies) + 'HTTP_PROXY', + 'HTTPS_PROXY', + 'NO_PROXY', + 'http_proxy', + 'https_proxy', + 'no_proxy', + // Windows basics + 'SYSTEMROOT', + 'COMSPEC', + 'PATHEXT', + 'USERPROFILE', + 'TEMP', + 'TMP', + 'APPDATA', + 'LOCALAPPDATA', + 'PROGRAMDATA', +]; + +/** Prefix patterns that match any var starting with the prefix. */ +const BASELINE_PREFIXES: readonly string[] = ['LC_']; + +/** Forge auth keys — the PR stage drives a forge CLI. */ +const FORGE_KEYS: readonly string[] = ['GH_TOKEN', 'GITHUB_TOKEN']; + +/** `RATCHET_*` control vars prefix. */ +const RATCHET_PREFIX = 'RATCHET_'; + +/** + * Collect the union of adapter-declared env passthrough keys/patterns across + * the built-in registry. Each entry is either an exact env var name or a + * `PREFIX_*` glob pattern. The union (not per-active-agent) keeps the scoped + * env identical for every agent: the engine builds the env before the adapter + * is resolved, and the `RATCHET_BATCH_AGENT_CMD` override path has no adapter at + * all — so a per-agent env would leak the active agent's identity into the + * shared path. Iterating the registry (never hard-coding one agent) also means + * a newly added agent's keys are picked up automatically. + */ +export function adapterEnvPassthroughKeys(): readonly string[] { + const keys = new Set(); + for (const name of availableAdapters()) { + const adapter = resolveAdapter(name); + for (const key of adapter.envPassthrough) { + keys.add(key); + } + } + return [...keys].sort(); +} + +/** + * Build the allowlist as a set of exact names + a list of prefixes, combining + * the baseline, forge keys, RATCHET_ prefix, adapter-declared keys, and the + * escape-hatch names. Exported so tests can inspect the policy directly. + */ +export function buildAgentEnvAllowlist( + hostEnv: NodeJS.ProcessEnv +): { exact: Set; prefixes: string[] } { + const exact = new Set([...BASELINE_VARS, ...FORGE_KEYS]); + const prefixes: string[] = [RATCHET_PREFIX, ...BASELINE_PREFIXES]; + + for (const key of adapterEnvPassthroughKeys()) { + if (key.endsWith('_*')) { + // `PREFIX_*` → match any var starting with `PREFIX_` + prefixes.push(key.slice(0, -1)); + } else { + exact.add(key); + } + } + + // Escape hatch: comma-separated extra host env names from the operator. + const allowRaw = hostEnv[AGENT_ENV_ALLOW_VAR]; + if (allowRaw) { + for (const name of allowRaw.split(',')) { + const trimmed = name.trim(); + if (trimmed) exact.add(trimmed); + } + } + + return { exact, prefixes }; +} + +/** + * Scope a host environment down to the allowlist. Pure: returns a new env + * object containing only allowlisted entries with their host values. Absent + * vars and `undefined` values are skipped — no key is synthesized. + */ +export function scopeAgentEnv(hostEnv: NodeJS.ProcessEnv): NodeJS.ProcessEnv { + const { exact, prefixes } = buildAgentEnvAllowlist(hostEnv); + const scoped: NodeJS.ProcessEnv = {}; + for (const [name, value] of Object.entries(hostEnv)) { + if (value === undefined) continue; + if (exact.has(name)) { + scoped[name] = value; + continue; + } + for (const prefix of prefixes) { + if (name.startsWith(prefix)) { + scoped[name] = value; + break; + } + } + } + return scoped; +} diff --git a/src/core/batch/engine/runtime/rex-bootstrap.ts b/src/core/batch/engine/runtime/rex-bootstrap.ts index ec95801..4235c1a 100644 --- a/src/core/batch/engine/runtime/rex-bootstrap.ts +++ b/src/core/batch/engine/runtime/rex-bootstrap.ts @@ -31,7 +31,13 @@ import { rmSync, } from 'node:fs'; import { execFileSync } from 'node:child_process'; -import { DEFAULT_DOCKER_IMAGE } from '../../config.js'; +import { + DEFAULT_DOCKER_IMAGE, + DEFAULT_DOCKER_MEMORY, + DEFAULT_DOCKER_PIDS_LIMIT, + DEFAULT_DOCKER_NETWORK, +} from '../../config.js'; +import { scopeAgentEnv } from '../agent-env.js'; /** * The single pinned swe-rex version. Verified resolvable + importable in the @@ -51,7 +57,7 @@ export const MIN_PYTHON = { major: 3, minor: 10 } as const; * is only reached when `REX_IMAGE` is unset, which Node always threads; the * cross-language sync is noted there. */ -export { DEFAULT_DOCKER_IMAGE }; +export { DEFAULT_DOCKER_IMAGE, DEFAULT_DOCKER_MEMORY, DEFAULT_DOCKER_PIDS_LIMIT, DEFAULT_DOCKER_NETWORK }; /** * The `docker` extra label recorded in the readiness marker. The docker locus @@ -123,6 +129,20 @@ export interface BootstrapOptions { mountHost?: string; /** REX_MOUNT_CONTAINER to pass through (docker locus only): in-container mount point. */ mountContainer?: string; + /** + * REX_DOCKER_USER to pass through (docker locus only): host uid:gid for + * `docker run --user`. When unset, the sidecar resolves the current host + * uid:gid. Ignored for `local`. + */ + dockerUser?: string; + /** REX_DOCKER_MEMORY to pass through (docker locus only): `docker run --memory`. */ + dockerMemory?: string; + /** REX_DOCKER_PIDS_LIMIT to pass through (docker locus only): `docker run --pids-limit`. */ + dockerPidsLimit?: number; + /** REX_DOCKER_CPUS to pass through (docker locus only): `docker run --cpus`. */ + dockerCpus?: number; + /** REX_DOCKER_NETWORK to pass through (docker locus only): `docker run --network`. */ + network?: string; /** Injected seams; defaults to the real fs/child_process. */ deps?: BootstrapDeps; } @@ -488,7 +508,10 @@ export function preflightDockerDaemon(deps: BootstrapDeps): void { * which swe-rex under-declares), which forces a rebuild of a local-only venv on * first docker use. The image + * mount env (`REX_IMAGE`/`REX_MOUNT_HOST`/`REX_MOUNT_CONTAINER`) is threaded to - * the sidecar. `local` is unaffected: no docker probe, no extras, no image/mount. + * the sidecar, along with the docker hardening env (`REX_DOCKER_USER`/ + * `REX_DOCKER_MEMORY`/`REX_DOCKER_PIDS_LIMIT`/`REX_DOCKER_CPUS`/ + * `REX_DOCKER_NETWORK`). `local` is unaffected: no docker probe, no extras, no + * image/mount. */ export function bootstrapRexRuntime(options: BootstrapOptions = {}): ResolvedLaunch { const deps = options.deps ?? defaultDeps; @@ -515,7 +538,7 @@ export function bootstrapRexRuntime(options: BootstrapOptions = {}): ResolvedLau // Prepend the venv bin to PATH so the sidecar's interpreter + tooling resolve. const inheritedPath = process.env.PATH ?? ''; const env: NodeJS.ProcessEnv = { - ...process.env, + ...scopeAgentEnv(process.env), PATH: `${layout.binDir}${path.delimiter}${inheritedPath}`, VIRTUAL_ENV: layout.venvDir, }; @@ -526,6 +549,26 @@ export function bootstrapRexRuntime(options: BootstrapOptions = {}): ResolvedLau env.REX_IMAGE = options.image && options.image.trim() ? options.image : DEFAULT_DOCKER_IMAGE; if (options.mountHost !== undefined) env.REX_MOUNT_HOST = options.mountHost; if (options.mountContainer !== undefined) env.REX_MOUNT_CONTAINER = options.mountContainer; + // Docker hardening knobs. Each is threaded only when the caller supplies a + // value; the Python sidecar applies its OWN unset-fallback for the ones with + // defaults (memory/pids/network), and simply omits the flag when the env is + // absent (cpus/user). Node is the single source of truth for defaults. + if (options.dockerUser !== undefined && options.dockerUser.trim()) { + env.REX_DOCKER_USER = options.dockerUser; + } + env.REX_DOCKER_MEMORY = + options.dockerMemory && options.dockerMemory.trim() + ? options.dockerMemory + : DEFAULT_DOCKER_MEMORY; + env.REX_DOCKER_PIDS_LIMIT = String( + options.dockerPidsLimit ?? DEFAULT_DOCKER_PIDS_LIMIT + ); + env.REX_DOCKER_NETWORK = + options.network && options.network.trim() ? options.network : DEFAULT_DOCKER_NETWORK; + // cpus is opt-in: no default → no flag when unset. + if (options.dockerCpus !== undefined) { + env.REX_DOCKER_CPUS = String(options.dockerCpus); + } } return { diff --git a/src/core/batch/engine/runtime/test_sidecar.py b/src/core/batch/engine/runtime/test_sidecar.py index a837b7c..3951532 100644 --- a/src/core/batch/engine/runtime/test_sidecar.py +++ b/src/core/batch/engine/runtime/test_sidecar.py @@ -185,10 +185,28 @@ def test_surrogateescape_bytes_round_trip_without_drift(self): class MakeDeploymentTests(unittest.TestCase): def setUp(self): - for k in ("REX_IMAGE", "REX_MOUNT_HOST", "REX_MOUNT_CONTAINER"): + for k in ( + "REX_IMAGE", "REX_MOUNT_HOST", "REX_MOUNT_CONTAINER", + "REX_DOCKER_USER", "REX_DOCKER_MEMORY", "REX_DOCKER_PIDS_LIMIT", + "REX_DOCKER_CPUS", "REX_DOCKER_NETWORK", + ): os.environ.pop(k, None) CAPTURED.clear() + def _expected_default_args(self, mount_host="/host/project", mount_container="/workspace"): + """The full default docker_args (mount + hardening defaults). + + ``--user`` defaults to the current host uid:gid (resolved by the sidecar + via os.getuid()/os.getgid()), so the expected value is dynamic. + """ + return [ + "-v", f"{mount_host}:{mount_container}", + "--user", f"{os.getuid()}:{os.getgid()}", + "--memory", sidecar.DEFAULT_DOCKER_MEMORY, + "--pids-limit", sidecar.DEFAULT_DOCKER_PIDS_LIMIT, + "--network", sidecar.DEFAULT_DOCKER_NETWORK, + ] + def test_docker_builds_the_v_mount_argv_from_mount_env(self): os.environ["REX_IMAGE"] = "my/image:tag" os.environ["REX_MOUNT_HOST"] = "/host/project" @@ -197,7 +215,7 @@ def test_docker_builds_the_v_mount_argv_from_mount_env(self): self.assertEqual(CAPTURED["docker"]["image"], "my/image:tag") self.assertEqual( CAPTURED["docker"]["docker_args"], - ["-v", "/host/project:/workspace"], + self._expected_default_args(), ) def test_docker_defaults_image_and_container_mount(self): @@ -207,13 +225,39 @@ def test_docker_defaults_image_and_container_mount(self): self.assertEqual(CAPTURED["docker"]["image"], sidecar.DEFAULT_DOCKER_IMAGE) self.assertEqual( CAPTURED["docker"]["docker_args"], - ["-v", "/host/project:/workspace"], + self._expected_default_args(), ) - def test_docker_omits_docker_args_when_mount_host_unset(self): - # No REX_MOUNT_HOST -> empty docker_args (no `-v` spliced into the run argv). + def test_docker_omits_v_mount_when_mount_host_unset_but_keeps_hardening(self): + # No REX_MOUNT_HOST -> no `-v`, but hardening knobs are still applied. + sidecar._make_deployment("docker") + args = CAPTURED["docker"]["docker_args"] + self.assertNotIn("-v", args) + self.assertIn("--user", args) + self.assertIn("--memory", args) + self.assertIn("--pids-limit", args) + self.assertIn("--network", args) + # cpus is opt-in and unset -> no --cpus flag. + self.assertNotIn("--cpus", args) + + def test_docker_all_hardening_knobs_threaded_together(self): + os.environ["REX_MOUNT_HOST"] = "/host/project" + os.environ["REX_MOUNT_CONTAINER"] = "/workspace" + os.environ["REX_DOCKER_USER"] = "2000:2000" + os.environ["REX_DOCKER_MEMORY"] = "4g" + os.environ["REX_DOCKER_PIDS_LIMIT"] = "1024" + os.environ["REX_DOCKER_CPUS"] = "2" + os.environ["REX_DOCKER_NETWORK"] = "none" sidecar._make_deployment("docker") - self.assertEqual(CAPTURED["docker"]["docker_args"], []) + args = CAPTURED["docker"]["docker_args"] + self.assertEqual(args, [ + "-v", "/host/project:/workspace", + "--user", "2000:2000", + "--memory", "4g", + "--pids-limit", "1024", + "--network", "none", + "--cpus", "2", + ]) def test_local_uses_local_deployment(self): sidecar._make_deployment("local") @@ -221,5 +265,278 @@ def test_local_uses_local_deployment(self): self.assertNotIn("docker", CAPTURED) +class DockerHardeningTests(unittest.TestCase): + """Cover the REX_DOCKER_* knobs (features/docker-locus-hardening).""" + + def setUp(self): + for k in ( + "REX_IMAGE", "REX_MOUNT_HOST", "REX_MOUNT_CONTAINER", + "REX_DOCKER_USER", "REX_DOCKER_MEMORY", "REX_DOCKER_PIDS_LIMIT", + "REX_DOCKER_CPUS", "REX_DOCKER_NETWORK", + ): + os.environ.pop(k, None) + CAPTURED.clear() + + def _args(self): + sidecar._make_deployment("docker") + return CAPTURED["docker"]["docker_args"] + + def test_user_defaults_to_host_uid_gid(self): + args = self._args() + i = args.index("--user") + self.assertEqual(args[i + 1], f"{os.getuid()}:{os.getgid()}") + + def test_configured_user_overrides_host_default(self): + os.environ["REX_DOCKER_USER"] = "0:0" + args = self._args() + i = args.index("--user") + self.assertEqual(args[i + 1], "0:0") + + def test_memory_and_pids_default_applied(self): + args = self._args() + self.assertIn("--memory", args) + self.assertEqual(args[args.index("--memory") + 1], sidecar.DEFAULT_DOCKER_MEMORY) + self.assertIn("--pids-limit", args) + self.assertEqual( + args[args.index("--pids-limit") + 1], sidecar.DEFAULT_DOCKER_PIDS_LIMIT + ) + + def test_configured_memory_and_pids_override_defaults(self): + os.environ["REX_DOCKER_MEMORY"] = "512m" + os.environ["REX_DOCKER_PIDS_LIMIT"] = "128" + args = self._args() + self.assertEqual(args[args.index("--memory") + 1], "512m") + self.assertEqual(args[args.index("--pids-limit") + 1], "128") + + def test_cpus_omitted_when_unset(self): + args = self._args() + self.assertNotIn("--cpus", args) + + def test_configured_cpus_applied(self): + os.environ["REX_DOCKER_CPUS"] = "1.5" + args = self._args() + self.assertEqual(args[args.index("--cpus") + 1], "1.5") + + def test_network_defaults_to_bridge(self): + args = self._args() + self.assertEqual(args[args.index("--network") + 1], "bridge") + + def test_configured_network_applied(self): + os.environ["REX_DOCKER_NETWORK"] = "none" + args = self._args() + self.assertEqual(args[args.index("--network") + 1], "none") + + def test_repo_mount_stays_read_write(self): + os.environ["REX_MOUNT_HOST"] = "/host/project" + os.environ["REX_MOUNT_CONTAINER"] = "/workspace" + args = self._args() + i = args.index("-v") + mount = args[i + 1] + self.assertEqual(mount, "/host/project:/workspace") + self.assertNotIn(":ro", mount) + + +class DockerHardeningValidationTests(unittest.TestCase): + """Fail-before-spawn: a malformed REX_DOCKER_* value raises before docker run.""" + + def setUp(self): + for k in ( + "REX_IMAGE", "REX_MOUNT_HOST", "REX_MOUNT_CONTAINER", + "REX_DOCKER_USER", "REX_DOCKER_MEMORY", "REX_DOCKER_PIDS_LIMIT", + "REX_DOCKER_CPUS", "REX_DOCKER_NETWORK", + ): + os.environ.pop(k, None) + CAPTURED.clear() + + def test_non_integer_pids_limit_raises(self): + os.environ["REX_DOCKER_PIDS_LIMIT"] = "lots" + with self.assertRaises(RuntimeError) as ctx: + sidecar._make_deployment("docker") + self.assertIn("REX_DOCKER_PIDS_LIMIT", str(ctx.exception)) + + def test_non_positive_pids_limit_raises(self): + os.environ["REX_DOCKER_PIDS_LIMIT"] = "0" + with self.assertRaises(RuntimeError): + sidecar._make_deployment("docker") + + def test_non_numeric_cpus_raises(self): + os.environ["REX_DOCKER_CPUS"] = "fast" + with self.assertRaises(RuntimeError) as ctx: + sidecar._make_deployment("docker") + self.assertIn("REX_DOCKER_CPUS", str(ctx.exception)) + + def test_non_positive_cpus_raises(self): + os.environ["REX_DOCKER_CPUS"] = "0" + with self.assertRaises(RuntimeError): + sidecar._make_deployment("docker") + + +class RunDirAndPidfileTests(unittest.TestCase): + """Cover the job-control launcher, run_dir threading, and pidfile tracking + added by the reap-agents-on-teardown change.""" + + def setUp(self): + sidecar.emit = lambda obj: None # swallow + sidecar.POLL_INTERVAL = 0 + + def test_run_dir_threads_into_sentinel_paths(self): + events, runtime, sc = _drive_run_with_run_dir( + ["line\n"], exit_code=0, run_dir="/custom/run" + ) + # The launcher writes the log/done/pid sentinels UNDER run_dir. + launchers = [c for c in runtime.commands if c.startswith("nohup ")] + self.assertTrue(any("/custom/run/ratchet-rex-" in c for c in launchers)) + # The pre-launch rm also targets run_dir. + rms = [c for c in runtime.commands if c.startswith("rm -f")] + self.assertTrue(any("/custom/run/ratchet-rex-" in c for c in rms)) + + def test_launcher_uses_job_control_and_writes_pidfile(self): + events, runtime, sc = _drive_run_with_run_dir( + ["line\n"], exit_code=0, run_dir="/rd" + ) + launchers = [c for c in runtime.commands if c.startswith("nohup ")] + self.assertEqual(len(launchers), 1) + launcher = launchers[0] + # `set -m` makes the backgrounded pipeline its own process-group leader. + self.assertIn("set -m", launcher) + # The pidfile is written (`echo $! > `) so shutdown can find the group. + self.assertIn("echo $! > ", launcher) + self.assertIn(".pid", launcher) + # The exit code is collected into the done sentinel. + self.assertIn("echo $? > ", launcher) + self.assertIn(".done", launcher) + + def test_run_pidfile_cleared_after_run_completes(self): + events, runtime, sc = _drive_run_with_run_dir( + ["line\n"], exit_code=0, run_dir="/rd" + ) + # After a clean run the pidfile is cleared (the run reaped itself). + self.assertIsNone(sc.run_pidfile) + + def test_run_dir_absent_falls_back_to_workdir(self): + events, runtime = _drive_run(["line\n"], exit_code=0) + launchers = [c for c in runtime.commands if c.startswith("nohup ")] + # No run_dir -> sentinels under workdir (/tmp). + self.assertTrue(any("/tmp/ratchet-rex-" in c for c in launchers)) + + +class ReapAgentGroupTests(unittest.TestCase): + """Cover _reap_agent_group: TERM→grace→KILL, idempotency, and edge cases.""" + + def setUp(self): + sidecar.emit = lambda obj: None + sidecar.POLL_INTERVAL = 0 + + def _make_sidecar_with_pidfile(self, pgid_response=""): + """Build a Sidecar whose runtime scripts `cat ` → pgid.""" + sc = sidecar.Sidecar() + sc.workdir = "/tmp" + sc.run_pidfile = "/tmp/ratchet-rex-deadbeef.pid" + sc._shutdown_grace_s = 0 # no real sleeps in the reap sequence + runtime = _ReapFakeRuntime(pgid_response=pgid_response) + sc.runtime = runtime + return sc, runtime + + def test_term_then_grace_then_kill(self): + sc, runtime = self._make_sidecar_with_pidfile(pgid_response="4242") + asyncio.run(sc._reap_agent_group()) + kills = [c for c in runtime.commands if c.startswith("kill ")] + # Exactly TERM then KILL, in order, targeting the negative pgid. + self.assertEqual(len(kills), 2) + self.assertIn("kill -TERM -- -4242", kills[0]) + self.assertIn("kill -KILL -- -4242", kills[1]) + # Pidfile claimed (cleared) so a second call is a no-op. + self.assertIsNone(sc.run_pidfile) + + def test_idempotent_second_call_is_noop(self): + sc, runtime = self._make_sidecar_with_pidfile(pgid_response="4242") + asyncio.run(sc._reap_agent_group()) + n_before = len(runtime.commands) + asyncio.run(sc._reap_agent_group()) + self.assertEqual(len(runtime.commands), n_before) + + def test_missing_pidfile_is_noop(self): + sc = sidecar.Sidecar() + sc.runtime = _ReapFakeRuntime() + sc.run_pidfile = None + runtime = sc.runtime + asyncio.run(sc._reap_agent_group()) + self.assertEqual(runtime.commands, []) + + def test_empty_or_nonnumeric_pgid_skips_kill(self): + sc, runtime = self._make_sidecar_with_pidfile(pgid_response="") + asyncio.run(sc._reap_agent_group()) + kills = [c for c in runtime.commands if c.startswith("kill ")] + self.assertEqual(kills, []) + + def test_sweeps_sentinels_after_reap(self): + sc, runtime = self._make_sidecar_with_pidfile(pgid_response="4242") + asyncio.run(sc._reap_agent_group()) + rms = [c for c in runtime.commands if c.startswith("rm -f")] + self.assertTrue(len(rms) >= 1) + self.assertIn(".pid", rms[0]) + + +class ShutdownReapsBeforeStopTests(unittest.TestCase): + """shutdown() must reap the agent group BEFORE stopping the deployment.""" + + def setUp(self): + sidecar.emit = lambda obj: None + sidecar.POLL_INTERVAL = 0 + + def test_shutdown_reaps_group_then_stops_deployment(self): + sc = sidecar.Sidecar() + sc.workdir = "/tmp" + sc.run_pidfile = "/tmp/ratchet-rex-deadbeef.pid" + sc._shutdown_grace_s = 0 + runtime = _ReapFakeRuntime(pgid_response="4242") + sc.runtime = runtime + stop_order: list[str] = [] + + class FakeDeployment: + async def stop(self): + stop_order.append("stop") + + sc.deployment = FakeDeployment() + emitted: list[dict] = [] + sidecar.emit = lambda obj: emitted.append(obj) + asyncio.run(sc.shutdown()) + # The reap kill commands appear BEFORE the deployment.stop() call. + first_kill_idx = next( + (i for i, c in enumerate(runtime.commands) if c.startswith("kill ")), None + ) + self.assertIsNotNone(first_kill_idx) + self.assertEqual(stop_order, ["stop"]) + self.assertIn({"event": "closed"}, emitted) + + +class _ReapFakeRuntime: + """A minimal runtime for _reap_agent_group/shutdown tests: records every + command and scripts `cat ` to return a pgid string.""" + + def __init__(self, pgid_response: str = ""): + self.commands: list[str] = [] + self._pgid = pgid_response + + async def execute(self, command): + cmd = command.command + self.commands.append(cmd) + if cmd.startswith("cat ") and ".pid" in cmd: + return FakeExecResult(self._pgid + "\n") + return FakeExecResult("") + + +def _drive_run_with_run_dir(log_chunks, exit_code, run_dir): + """Like _drive_run but passes run_dir; returns (events, runtime, sidecar).""" + events: list[dict] = [] + sidecar.emit = lambda obj: events.append(obj) + sidecar.POLL_INTERVAL = 0 + sc = sidecar.Sidecar() + sc.workdir = "/tmp" + sc.runtime = FakeRuntime(log_chunks, exit_code) + asyncio.run(sc.run(run_id=1, command="agent --go", run_dir=run_dir)) + return events, sc.runtime, sc + + if __name__ == "__main__": unittest.main() diff --git a/src/core/batch/permissions-policy.ts b/src/core/batch/permissions-policy.ts index c956555..e376041 100644 --- a/src/core/batch/permissions-policy.ts +++ b/src/core/batch/permissions-policy.ts @@ -29,6 +29,20 @@ export const PERMISSION_POSTURE_VALUES = [ ] as const; export type PermissionPosture = (typeof PERMISSION_POSTURE_VALUES)[number]; +/** + * Privilege ranking of the postures, from least to most privileged. The single + * definition of "raise" vs "narrow" for posture: a layer RAISES posture when its + * value ranks ABOVE the accumulated value, and NARROWS when it ranks at or + * below. `curated-allowlist (0) < repo-sandboxed-permissive (1) < full-autonomy + * (2)`. Pure data, unit-testable; consumed by the merge seam to clamp a + * manifest layer that tries to raise posture above the operator-owned scopes. + */ +export const POSTURE_PRIVILEGE_RANK: Record = { + 'curated-allowlist': 0, + 'repo-sandboxed-permissive': 1, + 'full-autonomy': 2, +}; + /** The agents the per-agent `raw` override escape hatch recognizes. */ export const PERMISSION_RAW_AGENTS = ['claude', 'codex', 'gemini', 'cursor', 'opencode'] as const; export type PermissionRawAgent = (typeof PERMISSION_RAW_AGENTS)[number]; diff --git a/src/core/batch/runtime/agent-permissions.ts b/src/core/batch/runtime/agent-permissions.ts index d1026bb..cf88181 100644 --- a/src/core/batch/runtime/agent-permissions.ts +++ b/src/core/batch/runtime/agent-permissions.ts @@ -196,7 +196,11 @@ function codexFlags(policy: ResolvedPermissionsPolicy): string[] { * silently equivalent to full autonomy. Re-verify exact flags once `cursor-agent` * is on PATH (e.g. a config-file-based allow/deny or a future approval-mode flag). */ -function cursorFlags(policy: ResolvedPermissionsPolicy): string[] { +function cursorFlags( + policy: ResolvedPermissionsPolicy, + _repoRoot: string, + opts?: MapperOptions +): string[] { switch (policy.posture) { case 'full-autonomy': // BYPASS: skip write/command confirmation entirely. Bypass lives here only. @@ -207,7 +211,7 @@ function cursorFlags(policy: ResolvedPermissionsPolicy): string[] { // No `--force` → cursor keeps its default per-action approval gating. argv // cannot carry cursor's allow/deny (config-file only), so warn once that // this posture is bounded only by cursor's own defaults, not by our policy. - warnCursorBestEffort(policy.posture); + if (!opts?.silent) warnCursorBestEffort(policy.posture); return []; } } @@ -243,7 +247,11 @@ function warnCursorBestEffort(posture: string): void { * own default gating, NOT silently equivalent to full autonomy. argv cannot * carry a bounded allow/deny for opencode under the locked argv-only decision. */ -function opencodeFlags(policy: ResolvedPermissionsPolicy): string[] { +function opencodeFlags( + policy: ResolvedPermissionsPolicy, + _repoRoot: string, + opts?: MapperOptions +): string[] { switch (policy.posture) { case 'full-autonomy': return ['--dangerously-skip-permissions']; @@ -253,7 +261,7 @@ function opencodeFlags(policy: ResolvedPermissionsPolicy): string[] { // No bypass flag → opencode keeps its own default per-action approval // gating. argv cannot carry opencode's allow/deny, so warn once that this // posture is bounded only by opencode's own defaults, not by our policy. - warnOpencodeBestEffort(policy.posture); + if (!opts?.silent) warnOpencodeBestEffort(policy.posture); return []; } } @@ -276,16 +284,52 @@ function warnOpencodeBestEffort(posture: string): void { ); } -type Mapper = (policy: ResolvedPermissionsPolicy, repoRoot: string) => string[]; +/** + * Internal options forwarded to per-agent mappers. `silent` suppresses the + * cursor/opencode one-time warnings so a pure query (e.g. + * {@link resolvePostureEnforcement}) can consult the mappers without side + * effects; the spawn path omits it so warnings fire exactly as today. + */ +interface MapperOptions { + silent?: boolean; +} + +type Mapper = ( + policy: ResolvedPermissionsPolicy, + repoRoot: string, + opts?: MapperOptions +) => string[]; const AGENT_MAPPERS: Record = { claude: (policy, repoRoot) => claudeFlags(policy, repoRoot), gemini: (policy) => geminiFlags(policy), codex: (policy) => codexFlags(policy), - cursor: (policy) => cursorFlags(policy), - opencode: (policy) => opencodeFlags(policy), + cursor: (policy, repoRoot, opts) => cursorFlags(policy, repoRoot, opts), + opencode: (policy, repoRoot, opts) => opencodeFlags(policy, repoRoot, opts), }; +/** + * Resolve the posture-derived argv fragment for one agent under a resolved + * policy. This is the posture portion only (the per-agent mapper output), + * WITHOUT the `raw` override escape hatch, so callers that reason about + * whether the posture itself is enforced (e.g. + * {@link resolvePostureEnforcement}) consult exactly the flags the mapping + * emits. Honors `opts.silent` to keep cursor/opencode warnings out of pure + * queries. + * + * Pure: no I/O, no spawning. + */ +function resolvePostureFlags( + agentName: string, + policy: ResolvedPermissionsPolicy, + repoRoot: string, + opts?: MapperOptions +): string[] { + const agent = agentName as PermissionRawAgent; + const mapper = AGENT_MAPPERS[agent]; + return mapper ? mapper(policy, repoRoot, opts) : []; +} + /** * Resolve the permission argv fragment for one agent under a resolved policy. * @@ -295,16 +339,59 @@ const AGENT_MAPPERS: Record = { * yields no posture flags but still honors a `raw` entry if one happens to match, * so an unknown future agent can be driven entirely via `raw`. * - * Pure: no I/O, no spawning. + * Pure: no I/O, no spawning. The cursor/opencode one-time best-effort warnings + * fire on the spawn path (this call) exactly as before. */ export function resolvePermissionFlags( agentName: string, policy: ResolvedPermissionsPolicy, repoRoot: string ): string[] { - const agent = agentName as PermissionRawAgent; - const mapper = AGENT_MAPPERS[agent]; - const postureFlags = mapper ? mapper(policy, repoRoot) : []; - const rawForAgent = policy.raw[agent] ?? []; + const postureFlags = resolvePostureFlags(agentName, policy, repoRoot); + const rawForAgent = policy.raw[agentName as PermissionRawAgent] ?? []; return [...postureFlags, ...rawForAgent]; } + +/** + * The resolved enforcement status for one agent under a posture: whether the + * posture actually reaches the agent as argv flags, or is a no-op that leaves + * the agent's own default gating in charge. Derived from the SAME per-agent + * mappers that build spawn argv so the rendered status can never drift from + * what actually reaches the agent — the exact failure mode #88 names. + */ +export interface PostureEnforcement { + /** The agent name the status was resolved for. */ + agent: string; + /** True when the posture mapping emits a non-empty argv fragment. */ + enforced: boolean; + /** Human-facing phrase: `enforced via flags` / `NOT ENFORCED — agent defaults apply`. */ + detail: string; +} + +/** + * Resolve the per-agent enforcement status for a posture. A posture that yields + * a non-empty posture-flag fragment (or the full-autonomy bypass flag) is + * `enforced via flags`; an empty fragment is `NOT ENFORCED — agent defaults + * apply`. Consults the same per-agent mappers as {@link resolvePermissionFlags} + * but with the cursor/opencode one-time warnings suppressed, so this pure query + * never emits side effects. + * + * Pure: no I/O, no spawning, no warnings. + */ +export function resolvePostureEnforcement( + agentName: string, + policy: ResolvedPermissionsPolicy, + repoRoot: string +): PostureEnforcement { + const postureFlags = resolvePostureFlags(agentName, policy, repoRoot, { + silent: true, + }); + const enforced = postureFlags.length > 0; + return { + agent: agentName, + enforced, + detail: enforced + ? 'enforced via flags' + : 'NOT ENFORCED — agent defaults apply', + }; +} diff --git a/src/core/batch/runtime/isolation.ts b/src/core/batch/runtime/isolation.ts new file mode 100644 index 0000000..c8fe50c --- /dev/null +++ b/src/core/batch/runtime/isolation.ts @@ -0,0 +1,136 @@ +/** + * Locus isolation descriptor — the HONEST per-locus isolation story rendered by + * `batch config` / `batch view` and surfaced to `ratchet doctor`. + * + * This is pure data over resolved {@link BatchSettings}: no filesystem, no spawn, + * no I/O. It states what the resolved locus actually isolates so an operator can + * never mistake an advisory posture (the `local` locus) for a real sandbox. The + * docker description is parameterized by the resolved #85 contract knobs + * (`dockerUser`, `dockerMemory`, `dockerPidsLimit`, `network`) so the docker + * line states the ACTUAL contract, not a generic "container" claim. + * + * DECISION (locked): a posture is NOT containment. The argv denylist + * (`REPO_SANDBOX_DENY_PATTERNS`) is best-effort damage reduction that only the + * docker locus backs with real containment; the `local` locus is advisory with no + * filesystem/network isolation. `remote` is the server operator's boundary, not + * one ratchet enforces. This module is the single rendering source for that + * story; `batch config`, `batch view`, and the doctor nudge all reuse it so the + * wording can never drift between surfaces (#86 posture-honesty half). + */ + +import { + DEFAULT_DOCKER_MEMORY, + DEFAULT_DOCKER_NETWORK, + DEFAULT_DOCKER_PIDS_LIMIT, +} from '../config.js'; +import type { BatchSettings, Locus } from '../config.js'; + +/** + * The resolved docker contract knobs as the docker descriptor renders them: + * each value is resolved to its effective string (applying the runtime defaults + * when unset), so the descriptor states the ACTUAL contract the container runs + * under, not a generic "container" claim. `cpus` is opt-in (absent → `undefined` + * → omitted from the description), mirroring the runtime's no-default behavior. + */ +export interface ResolvedDockerContract { + /** `--user` value, e.g. `"1000:1000"` or `"host uid:gid"` when unset. */ + user: string; + /** `--memory` value, e.g. `"2g"`. Always resolved (runtime applies a default). */ + memory: string; + /** `--pids-limit` value as a string, e.g. `"512"`. */ + pids: string; + /** `--network` value, e.g. `"bridge"` or `"none"`. */ + network: string; + /** `--cpus` value, e.g. `"1.5"`, or `undefined` when unset (no flag emitted). */ + cpus?: string; +} + +/** + * The resolved isolation descriptor: the locus and a short honest description of + * what it actually isolates. Consumed verbatim by `batch config` / `batch view` + * (rendered as an `Isolation:` line) and carried machine-readably by the + * `--json` payload; the doctor nudge keys off `locus` + the posture to decide + * its severity without re-deriving the description. + */ +export interface LocusIsolation { + /** The resolved locus the descriptor was computed for. */ + locus: Locus; + /** + * A short, honest description of what the locus isolates. Stable phrasing the + * features pin: `local` is advisory with no filesystem/network isolation and + * an env allowlist; `docker` is container isolation with the resolved uid / + * memory / pids / network contract and a writable-by-design repo mount; + * `remote` is the server operator's boundary, not ratchet's. + */ + description: string; +} + +/** + * Resolve the effective docker contract knobs from {@link BatchSettings}, + * applying the runtime defaults (memory / pids / network) and the documented + * "host uid:gid" fallback for an unset `dockerUser`. Pure: no filesystem, no + * `process.uid` read — the runtime resolves the real host uid:gid at spawn; the + * descriptor states the documented fallback so the rendered text is honest about + * the unset case ("runs as the host user, not root") without touching the OS. + */ +export function resolveDockerContract(settings: BatchSettings): ResolvedDockerContract { + const user = + settings.dockerUser && settings.dockerUser.trim().length > 0 + ? settings.dockerUser + : 'host uid:gid'; + const memory = + settings.dockerMemory && settings.dockerMemory.trim().length > 0 + ? settings.dockerMemory + : DEFAULT_DOCKER_MEMORY; + const pids = String(settings.dockerPidsLimit ?? DEFAULT_DOCKER_PIDS_LIMIT); + const network = + settings.network && settings.network.trim().length > 0 + ? settings.network + : DEFAULT_DOCKER_NETWORK; + const cpus = + settings.dockerCpus !== undefined ? String(settings.dockerCpus) : undefined; + return { user, memory, pids, network, cpus }; +} + +/** + * Describe the real isolation of the resolved locus in a short, honest line. + * + * - `local`: advisory — no filesystem or network isolation; the agent + * environment is scoped to the env allowlist (phase-mate #86 env-scoping). The + * argv denylist is best-effort damage reduction, NOT containment. + * - `docker`: container isolation with the resolved #85 contract (uid, memory, + * pids, network policy); the repository mount stays writable by design. + * - `remote`: isolation is the remote server operator's boundary, not one + * ratchet enforces. + * + * Pure over the already-resolved {@link BatchSettings}: no I/O, no spawn. The + * docker description is parameterized by {@link resolveDockerContract} so the + * rendered line states the actual contract the container runs under. + */ +export function describeLocusIsolation(settings: BatchSettings): LocusIsolation { + switch (settings.locus) { + case 'docker': { + const c = resolveDockerContract(settings); + const cpusClause = c.cpus !== undefined ? `, cpus ${c.cpus}` : ''; + return { + locus: 'docker', + description: + `Container isolation — uid ${c.user}, memory ${c.memory}, pids ${c.pids}, ` + + `network ${c.network}${cpusClause}. Repository mount stays writable by design.`, + }; + } + case 'remote': + return { + locus: 'remote', + description: + "Isolation is the remote server's boundary, not one ratchet enforces.", + }; + case 'local': + default: + return { + locus: 'local', + description: + 'Advisory — no filesystem or network isolation; agent environment is scoped to the env allowlist.', + }; + } +} diff --git a/src/core/doctor/checks/batch-isolation.ts b/src/core/doctor/checks/batch-isolation.ts new file mode 100644 index 0000000..2b3614e --- /dev/null +++ b/src/core/doctor/checks/batch-isolation.ts @@ -0,0 +1,76 @@ +/** + * Batch-isolation check (optional/informational). + * + * Nudges an operator running permissive / full-autonomy batches on the `local` + * locus toward the `docker` locus that actually contains the run. The `local` + * locus is ADVISORY — no filesystem or network isolation (see + * `runtime/isolation.ts`) — so a posture like `full-autonomy` or the + * `repo-sandboxed-permissive` default running there has no real containment + * behind it. This check surfaces that gap up front, but only when it is + * relevant: it returns `null` (→ omitted from the report entirely, never a + * passing or skipped row) whenever the resolved locus already provides real + * containment (`docker`) or is the server operator's boundary (`remote`), + * mirroring how `pr-remote` is conditionally appended. + * + * `optional` severity so it NEVER fails doctor (`isReportOk` ignores optional + * checks) — the nudge is advisory, see `doctor-local-locus-nudge.feature`. + */ + +import { resolveBatchSettings } from '../../batch/config.js'; +import type { DoctorCheck } from '../types.js'; + +const ID = 'batch-isolation'; +const LABEL = 'Batch locus isolation'; + +/** + * Run the batch-isolation check, returning one `DoctorCheck` only when the + * resolved locus is `local` AND the posture is permissive-or-above; `null` + * otherwise (silent on `docker`/`remote`, which already provide or own the + * containment boundary). Resolves project-level batch settings (no manifest) so + * the nudge reflects the operator's standing configuration. + */ +export function checkBatchIsolation(projectRoot: string): DoctorCheck | null { + const { settings } = resolveBatchSettings(projectRoot); + // Real containment (docker) or the server's boundary (remote) → silent. + if (settings.locus !== 'local') return null; + + const posture = settings.permissions?.posture; + if (posture === 'full-autonomy') { + return { + id: ID, + label: LABEL, + status: 'info', + severity: 'optional', + detail: + 'Batch posture is `full-autonomy` on the `local` locus, which is advisory — ' + + 'no filesystem or network isolation backs the posture. A full-autonomy agent ' + + 'can do anything the launching user can.', + remedy: + 'For real containment, run with `locus: docker` (see `ratchet batch config` for ' + + 'the resolved uid/memory/pids/network contract). The docker locus contains the ' + + 'agent in a container with a writable repo mount and bounded resources.', + }; + } + + // The permissive default (and any permissive-tier posture) on local is + // advisory: informational, not a failure, with a lighter nudge. + if (posture === 'repo-sandboxed-permissive') { + return { + id: ID, + label: LABEL, + status: 'info', + severity: 'optional', + detail: + 'Batch posture is `repo-sandboxed-permissive` on the `local` locus. The `local` ' + + 'locus is advisory — no filesystem or network isolation; the argv denylist is ' + + 'best-effort damage reduction, not containment.', + remedy: + 'For real containment, consider `locus: docker` (the docker locus contains the ' + + 'agent in a container with bounded resources; see `ratchet batch config`).', + }; + } + + // `curated-allowlist` on local is narrow enough to stay silent — the allow + // list bounds what the agent may do. (Any future posture tier resolves here.) + return null; +} diff --git a/src/core/doctor/index.ts b/src/core/doctor/index.ts index e6bcef9..f102ec0 100644 --- a/src/core/doctor/index.ts +++ b/src/core/doctor/index.ts @@ -19,6 +19,7 @@ import { resolveCurrentPlanningHomeSync } from '../planning-home.js'; import { checkAgents } from './checks/agents.js'; import { checkRuntime } from './checks/runtime.js'; import { checkDocker } from './checks/docker.js'; +import { checkBatchIsolation } from './checks/batch-isolation.js'; import { checkPlaywright } from './checks/playwright.js'; import { checkPrRemote } from './checks/pr-remote.js'; import { hasWebBindingInScope } from './web-scope.js'; @@ -42,6 +43,11 @@ export function runDoctorChecks( projectRoot: string = resolveCurrentPlanningHomeSync().root ): DoctorReport { const checks = [checkAgents(deps, projectRoot), checkRuntime(deps), checkDocker(deps)]; + // Batch-isolation nudge (optional): warn on a permissive / full-autonomy + // posture running on the advisory `local` locus, pointing at `locus: docker` + // for real containment. Silent (omitted) on docker / remote. + const batchIsolation = checkBatchIsolation(projectRoot); + if (batchIsolation) checks.push(batchIsolation); if (hasWebBindingInScope(projectRoot)) { checks.push(checkPlaywright(deps)); } diff --git a/src/core/project-config.ts b/src/core/project-config.ts index ec89caa..af0168e 100644 --- a/src/core/project-config.ts +++ b/src/core/project-config.ts @@ -73,6 +73,13 @@ export const ProjectConfigSchema = z.object({ // rejected at config load. When unset, the runtime default (600000ms) // applies. The RATCHET_AGENT_TIMEOUT_MS env var overrides this key. agentTimeoutMs: z.number().int().positive().optional(), + // Docker-locus hardening knobs (features/docker-locus-hardening). + // Mirrored identically to the manifest override scope. + dockerUser: z.string().optional(), + dockerMemory: z.string().optional(), + dockerPidsLimit: z.number().int().positive().optional(), + dockerCpus: z.number().positive().optional(), + network: z.string().optional(), }) .partial() .optional() diff --git a/test/batch-engine/agent-env.test.ts b/test/batch-engine/agent-env.test.ts new file mode 100644 index 0000000..5489776 --- /dev/null +++ b/test/batch-engine/agent-env.test.ts @@ -0,0 +1,180 @@ +import { describe, it, expect } from 'vitest'; +import { + scopeAgentEnv, + adapterEnvPassthroughKeys, + buildAgentEnvAllowlist, + AGENT_ENV_ALLOW_VAR, +} from '../../src/core/batch/engine/agent-env.js'; +import { availableAdapters, resolveAdapter } from '../../src/core/batch/engine/agent.js'; + +/** + * Implements: features/agent-env-scoping/allowlist.feature + * + * The pure scoping helper: a non-allowlisted host secret is dropped, baseline + * process vars pass through, RATCHET_* control vars pass through, every + * registered agent's declared keys pass through, every registered adapter + * declares an env passthrough, and the operator escape hatch extends the + * allowlist. + */ + +describe('scopeAgentEnv — allowlist (allowlist.feature)', () => { + // Scenario: A non-allowlisted host secret is dropped + it('drops a non-allowlisted host secret while keeping PATH and HOME', () => { + const hostEnv: NodeJS.ProcessEnv = { + PATH: '/usr/bin', + HOME: '/home/user', + SUPER_SECRET_TOKEN: 'hunter2', + }; + const scoped = scopeAgentEnv(hostEnv); + expect(scoped).not.toHaveProperty('SUPER_SECRET_TOKEN'); + expect(scoped.PATH).toBe('/usr/bin'); + expect(scoped.HOME).toBe('/home/user'); + }); + + // Scenario: Baseline process variables pass through + it('passes through every baseline process variable with its host value', () => { + const hostEnv: NodeJS.ProcessEnv = { + PATH: '/usr/bin', + HOME: '/home/user', + TMPDIR: '/tmp', + LANG: 'en_US.UTF-8', + TERM: 'xterm-256color', + HTTPS_PROXY: 'http://proxy:8080', + }; + const scoped = scopeAgentEnv(hostEnv); + for (const [key, value] of Object.entries(hostEnv)) { + expect(scoped[key], `baseline var '${key}' must pass through`).toBe(value); + } + }); + + // Scenario: Ratchet control variables pass through + it('passes through RATCHET_* control variables', () => { + const hostEnv: NodeJS.ProcessEnv = { + RATCHET_BATCH_AGENT_CMD: 'echo stub-agent', + }; + const scoped = scopeAgentEnv(hostEnv); + expect(scoped.RATCHET_BATCH_AGENT_CMD).toBe('echo stub-agent'); + }); + + // Scenario Outline: Every registered agent's declared keys pass through + it('every registered agent has at least one declared key that passes through', () => { + for (const name of availableAdapters()) { + const adapter = resolveAdapter(name); + const declared = adapter.envPassthrough; + expect(declared.length, `adapter '${name}' must declare envPassthrough`).toBeGreaterThan(0); + for (const key of declared) { + // Plant a host var matching the declaration (exact or PREFIX_* pattern). + const plantedName = key.endsWith('_*') + ? `${key.slice(0, -1)}TEST_VAR` + : key; + const hostEnv: NodeJS.ProcessEnv = { + [plantedName]: 'the-value', + }; + const scoped = scopeAgentEnv(hostEnv); + expect( + scoped[plantedName], + `adapter '${name}': var '${plantedName}' matching declaration '${key}' must pass through` + ).toBe('the-value'); + } + } + }); + + // Scenario: Every registered adapter declares an env passthrough + it('every registered adapter declares a non-empty envPassthrough list', () => { + for (const name of availableAdapters()) { + const adapter = resolveAdapter(name); + expect( + adapter.envPassthrough, + `adapter '${name}' must declare envPassthrough` + ).toBeDefined(); + expect( + adapter.envPassthrough.length, + `adapter '${name}' must declare a non-empty envPassthrough` + ).toBeGreaterThan(0); + } + }); + + // Scenario: Operator escape hatch extends the allowlist + it('the escape hatch extends the allowlist with operator-named vars', () => { + const hostEnv: NodeJS.ProcessEnv = { + MY_CUSTOM_CA: '/etc/ca.pem', + [AGENT_ENV_ALLOW_VAR]: 'MY_CUSTOM_CA', + ANOTHER_SECRET: 'should-be-dropped', + }; + const scoped = scopeAgentEnv(hostEnv); + expect(scoped.MY_CUSTOM_CA).toBe('/etc/ca.pem'); + // A host secret not named by the escape hatch is still dropped. + expect(scoped).not.toHaveProperty('ANOTHER_SECRET'); + }); + + // Extra: LC_* locale prefix and proxy lowercase variants + it('passes through LC_* locale vars and lowercase proxy variants', () => { + const hostEnv: NodeJS.ProcessEnv = { + LC_ALL: 'en_US.UTF-8', + LC_CTYPE: 'C', + http_proxy: 'http://proxy:8080', + https_proxy: 'http://proxy:8080', + no_proxy: 'localhost', + }; + const scoped = scopeAgentEnv(hostEnv); + expect(scoped.LC_ALL).toBe('en_US.UTF-8'); + expect(scoped.LC_CTYPE).toBe('C'); + expect(scoped.http_proxy).toBe('http://proxy:8080'); + expect(scoped.https_proxy).toBe('http://proxy:8080'); + expect(scoped.no_proxy).toBe('localhost'); + }); + + // Extra: forge keys pass through + it('passes through forge auth keys (GH_TOKEN, GITHUB_TOKEN)', () => { + const hostEnv: NodeJS.ProcessEnv = { + GH_TOKEN: 'ghp_xxx', + GITHUB_TOKEN: 'ghp_yyy', + }; + const scoped = scopeAgentEnv(hostEnv); + expect(scoped.GH_TOKEN).toBe('ghp_xxx'); + expect(scoped.GITHUB_TOKEN).toBe('ghp_yyy'); + }); + + // Extra: absent vars are skipped, undefined values are skipped + it('skips absent and undefined-valued vars without synthesizing keys', () => { + const hostEnv: NodeJS.ProcessEnv = { + PATH: '/usr/bin', + HOME: undefined, + }; + const scoped = scopeAgentEnv(hostEnv); + expect(scoped.PATH).toBe('/usr/bin'); + expect(scoped).not.toHaveProperty('HOME'); + }); + + // Extra: adapterEnvPassthroughKeys returns the union sorted and deduplicated + it('adapterEnvPassthroughKeys returns the deduplicated union across all adapters', () => { + const keys = adapterEnvPassthroughKeys(); + expect(keys.length).toBeGreaterThan(0); + // No duplicates + expect(new Set(keys).size).toBe(keys.length); + // Sorted + const sorted = [...keys].sort(); + expect(keys).toEqual(sorted); + // Contains at least one key per adapter + for (const name of availableAdapters()) { + const adapter = resolveAdapter(name); + for (const key of adapter.envPassthrough) { + expect(keys, `union must contain '${key}' from adapter '${name}'`).toContain(key); + } + } + }); + + // Extra: buildAgentEnvAllowlist exposes exact + prefixes + it('buildAgentEnvAllowlist exposes exact names and prefix patterns', () => { + const { exact, prefixes } = buildAgentEnvAllowlist({ + [AGENT_ENV_ALLOW_VAR]: 'EXTRA_ONE,EXTRA_TWO', + }); + expect(exact.has('PATH')).toBe(true); + expect(exact.has('HOME')).toBe(true); + expect(exact.has('GH_TOKEN')).toBe(true); + expect(exact.has('EXTRA_ONE')).toBe(true); + expect(exact.has('EXTRA_TWO')).toBe(true); + expect(prefixes).toContain('RATCHET_'); + expect(prefixes).toContain('LC_'); + }); +}); diff --git a/test/batch-engine/agent-permissions.test.ts b/test/batch-engine/agent-permissions.test.ts index 2a3818b..7e8753e 100644 --- a/test/batch-engine/agent-permissions.test.ts +++ b/test/batch-engine/agent-permissions.test.ts @@ -1,8 +1,13 @@ -import { describe, it, expect } from 'vitest'; +import { describe, it, expect, vi } from 'vitest'; import { resolvePermissionFlags, + resolvePostureEnforcement, REPO_SANDBOX_DENY_PATTERNS, } from '../../src/core/batch/runtime/agent-permissions.js'; +import { + PERMISSION_POSTURE_VALUES, + PERMISSION_RAW_AGENTS, +} from '../../src/core/batch/permissions-policy.js'; import type { ResolvedPermissionsPolicy, PermissionPosture, @@ -267,3 +272,86 @@ describe('safety invariant — sandboxed/curated must NOT equal full-autonomy', }); } }); + +// Feature: posture-enforcement-rendering.feature +// Proves resolvePostureEnforcement is derived from the real per-agent mappers: +// a posture whose mapping emits a non-empty argv fragment reports enforced, and +// an empty fragment reports NOT ENFORCED — agent defaults apply. Iterates every +// agent in PERMISSION_RAW_AGENTS × every posture so no agent is special-cased. +describe('resolvePostureEnforcement — derived from the real permission translator', () => { + const postures = PERMISSION_POSTURE_VALUES; + + for (const agent of PERMISSION_RAW_AGENTS) { + for (const posture of postures) { + it(`${agent} / ${posture} agrees with resolvePermissionFlags posture output`, () => { + const pol = policy({ posture }); + const status = resolvePostureEnforcement(agent, pol, REPO); + // Derive the posture flags directly from the mapper (silenced) so the + // test asserts the exact derivation the enforcement status claims. + const postureFlags = resolvePermissionFlags(agent, pol, REPO); + const rawForAgent = pol.raw[agent] ?? []; + const mapperOutput = postureFlags.slice(0, postureFlags.length - rawForAgent.length); + expect(status.agent).toBe(agent); + expect(status.enforced).toBe(mapperOutput.length > 0); + expect(status.detail).toBe( + mapperOutput.length > 0 + ? 'enforced via flags' + : 'NOT ENFORCED — agent defaults apply' + ); + }); + } + } + + it('claude / repo-sandboxed-permissive reports enforced via flags', () => { + expect(resolvePostureEnforcement('claude', policy(), REPO)).toEqual({ + agent: 'claude', + enforced: true, + detail: 'enforced via flags', + }); + }); + + it('cursor / repo-sandboxed-permissive reports NOT ENFORCED — agent defaults apply', () => { + expect(resolvePostureEnforcement('cursor', policy(), REPO)).toEqual({ + agent: 'cursor', + enforced: false, + detail: 'NOT ENFORCED — agent defaults apply', + }); + }); + + it('opencode / repo-sandboxed-permissive reports NOT ENFORCED — agent defaults apply', () => { + expect(resolvePostureEnforcement('opencode', policy(), REPO)).toEqual({ + agent: 'opencode', + enforced: false, + detail: 'NOT ENFORCED — agent defaults apply', + }); + }); + + it('full-autonomy is enforced for every agent (the bypass flag)', () => { + for (const agent of PERMISSION_RAW_AGENTS) { + expect( + resolvePostureEnforcement(agent, policy({ posture: 'full-autonomy' }), REPO).enforced + ).toBe(true); + } + }); + + it('is side-effect-free: cursor/opencode best-effort warnings never fire', () => { + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + try { + for (const posture of postures) { + resolvePostureEnforcement('cursor', policy({ posture }), REPO); + resolvePostureEnforcement('opencode', policy({ posture }), REPO); + } + expect(warnSpy).not.toHaveBeenCalled(); + } finally { + warnSpy.mockRestore(); + } + }); + + it('an unknown agent reports NOT ENFORCED with no posture flags', () => { + expect(resolvePostureEnforcement('future-agent', policy(), REPO)).toEqual({ + agent: 'future-agent', + enforced: false, + detail: 'NOT ENFORCED — agent defaults apply', + }); + }); +}); diff --git a/test/batch-engine/engine-spawn-env.test.ts b/test/batch-engine/engine-spawn-env.test.ts new file mode 100644 index 0000000..1c396b0 --- /dev/null +++ b/test/batch-engine/engine-spawn-env.test.ts @@ -0,0 +1,291 @@ +/** + * Engine spawn requests carry a scoped environment. + * + * Implements: features/agent-env-scoping/engine-spawn-env.feature + * + * Every engine spawn site (change-transition, decompose, pr) threads the scoped + * environment into the `AgentSpawnRequest` so no engine path exports the full + * host environment into an agent session. A planted host secret is dropped at + * every site while `RATCHET_BATCH_NAME` rides through, and the `RATCHET_BATCH_AGENT_CMD` + * override still stands in for the coding agent under the scoped environment. + */ + +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { promises as fs } from 'fs'; +import * as fsSync from 'fs'; +import path from 'path'; +import os from 'os'; +import { appendJournal, appendJournalForLocus } from '../../src/core/batch/journal.js'; +import { RatchetBatchEngine, type LinePrinter } from '../../src/core/batch/engine/engine.js'; +import { DEFAULT_AGENT } from '../../src/core/batch/engine/agent.js'; +import type { + AgentAdapter, + Spawner, + AgentSpawnRequest, + AgentRuntime, +} from '../../src/core/batch/engine/agent.js'; +import type { + ResolvedStepContext, + DecompositionStepContext, + PrStepContext, +} from '../../src/core/batch/engine/contract.js'; +import { prJournalKey } from '../../src/core/batch/engine/instructions.js'; +import { + getBatchManifestPath, + type BatchSettings, + type ProofOfWork, +} from '../../src/core/batch/manifest.js'; + +let projectRoot: string; +const SECRET = 'SUPER_SECRET_TOKEN'; +let savedSecret: string | undefined; +const ENV = 'RATCHET_BATCH_AGENT_CMD'; +let savedEnv: string | undefined; + +const POW: ProofOfWork = { kind: 'integration', run: 'echo ok', pass: 'exit 0' }; + +beforeEach(async () => { + projectRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'engine-spawn-env-')); + await fs.mkdir(path.join(projectRoot, '.ratchet', 'changes'), { recursive: true }); + savedSecret = process.env[SECRET]; + savedEnv = process.env[ENV]; + delete process.env[SECRET]; + delete process.env[ENV]; +}); + +afterEach(async () => { + if (savedSecret === undefined) delete process.env[SECRET]; + else process.env[SECRET] = savedSecret; + if (savedEnv === undefined) delete process.env[ENV]; + else process.env[ENV] = savedEnv; + await fs.rm(projectRoot, { recursive: true, force: true }); +}); + +/** Plant the host secret so `scopeAgentEnv(process.env)` sees and drops it. */ +function plantSecret(): void { + process.env[SECRET] = 'hunter2'; +} + +/** Corroborate a propose completion by writing the change dir + plan.md. */ +function corroboratePropose(root: string, change: string): void { + const dir = path.join(root, '.ratchet', 'changes', change); + fsSync.mkdirSync(dir, { recursive: true }); + fsSync.writeFileSync(path.join(dir, 'plan.md'), '## Tasks\n- [ ] do it\n'); +} + +/** Fake adapter that stamps its own name as the spawn command and threads env. */ +function fakeAdapter(name: string): AgentAdapter { + return { + name, + buildRequest(_ctx, instructions, cwd, env): AgentSpawnRequest { + return { command: name, args: [], instructions, cwd, env }; + }, + }; +} + +const fakeAdapters: Record = { + claude: fakeAdapter('claude'), + opencode: fakeAdapter('opencode'), + gemini: fakeAdapter('gemini'), +}; + +function changeSettings(over: Partial = {}): BatchSettings { + return { gate: 'voluntary', strategy: 'vertical-slice', proofOfWork: 'hard-gate', locus: 'local', agent: 'fake', ...over }; +} + +function changeContext(over: Partial = {}): ResolvedStepContext { + return { + batch: 'b', + change: 'add-login-api', + transition: 'propose', + phase: { name: 'p1', goal: 'g', success: 's', proofOfWork: POW }, + settings: changeSettings(), + journal: [], + ...over, + }; +} + +describe('engine-spawn-env — change-transition spawn is scoped', () => { + it('excludes a host secret and carries RATCHET_BATCH_NAME', async () => { + plantSecret(); + const calls: AgentSpawnRequest[] = []; + const spawner: Spawner = async (request) => { + calls.push(request); + corroboratePropose(projectRoot, 'add-login-api'); + appendJournal(projectRoot, 'b', { change: 'add-login-api', kind: 'completion', message: 'proposed', transition: 'propose' }); + return { exitCode: 0, signal: null, stdout: '', stderr: '' }; + }; + const engine = new RatchetBatchEngine({ + spawner, + adapters: { fake: fakeAdapter('fake') }, + projectRoot: () => projectRoot, + }); + + await engine.runStep(changeContext()); + + expect(calls).toHaveLength(1); + const env = calls[0].env ?? {}; + expect(env).not.toHaveProperty(SECRET); + expect(env.RATCHET_BATCH_NAME).toBe('b'); + // A baseline var still passes through. + if (process.env.PATH) expect(env.PATH).toBe(process.env.PATH); + }); +}); + +describe('engine-spawn-env — decompose spawn is scoped', () => { + const BATCH = 'dcmp'; + const UNDECOMPOSED = ` +name: ${BATCH} +phases: + - name: p1 + goal: ship the first slice + success: s + proofOfWork: { kind: integration, run: x, pass: '0' } + changes: + - name: first + done: first is done + - name: p2 + goal: decompose me later + success: s2 + proofOfWork: { kind: integration, run: x, pass: '0' } + changes: [] +`; + const DECOMPOSED = ` +name: ${BATCH} +phases: + - name: p1 + goal: ship the first slice + success: s + proofOfWork: { kind: integration, run: x, pass: '0' } + changes: + - name: first + done: first is done + - name: p2 + goal: decompose me later + success: s2 + proofOfWork: { kind: integration, run: x, pass: '0' } + changes: + - name: second + done: second is done +`; + + beforeEach(async () => { + await fs.mkdir(path.join(projectRoot, '.ratchet', 'batches', BATCH), { recursive: true }); + }); + + function decompositionContext(over: Partial = {}): DecompositionStepContext { + return { + batch: BATCH, + phase: { name: 'p2', goal: 'decompose me later', success: 's2', proofOfWork: POW }, + priorResults: [{ phase: 'p1', changes: [{ name: 'first', done: 'first is done' }] }], + settings: { gate: 'voluntary', strategy: 'vertical-slice', proofOfWork: 'hard-gate', locus: 'local', agent: 'claude' }, + ...over, + }; + } + + it('excludes a host secret and carries RATCHET_BATCH_NAME', async () => { + plantSecret(); + await fs.writeFile(getBatchManifestPath(projectRoot, BATCH), UNDECOMPOSED, 'utf-8'); + // Mark p1's only change done so p2 is the reachable decomposition step. + const dir = path.join(projectRoot, '.ratchet', 'changes', 'first'); + await fs.mkdir(dir, { recursive: true }); + await fs.writeFile(path.join(dir, 'plan.md'), '## Tasks\n- [x] 1.1 done\n', 'utf-8'); + appendJournal(projectRoot, BATCH, { change: 'first', kind: 'completion', message: 'verified', transition: 'verify' }); + + let captured: AgentSpawnRequest | undefined; + const runtime: AgentRuntime = async (request, onEvent) => { + captured = request; + await fs.writeFile(getBatchManifestPath(projectRoot, BATCH), DECOMPOSED, 'utf-8'); + appendJournal(projectRoot, BATCH, { change: 'p2', kind: 'completion', message: 'authored p2', transition: 'decompose' }); + onEvent({ kind: 'exit', exitCode: 0 }); + return { exitCode: 0, signal: null, stdout: '', stderr: '' }; + }; + const engine = new RatchetBatchEngine({ runtime, projectRoot: () => projectRoot, printLine: () => {} }); + + await engine.runDecompositionStep(decompositionContext()); + + expect(captured).toBeDefined(); + const env = captured!.env ?? {}; + expect(env).not.toHaveProperty(SECRET); + expect(env.RATCHET_BATCH_NAME).toBe(BATCH); + }); +}); + +describe('engine-spawn-env — pr spawn is scoped', () => { + const BATCH = 'prb'; + const KEY = prJournalKey(BATCH); + const WORK = 'feature/prb-work'; + const BASE = 'main'; + + beforeEach(async () => { + await fs.mkdir(path.join(projectRoot, '.ratchet', 'batches', BATCH), { recursive: true }); + }); + + function prSettings(over: Partial = {}): BatchSettings { + return { gate: 'voluntary', strategy: 'vertical-slice', proofOfWork: 'hard-gate', locus: 'local', prGrouping: 'whole-batch', ...over }; + } + + function prContext(over: Partial = {}): PrStepContext { + return { + batch: BATCH, + phase: { name: 'terminal', goal: 'open the PR', success: 's', proofOfWork: POW }, + settings: prSettings(), + baseBranch: BASE, + workBranch: WORK, + ...over, + }; + } + + it('excludes a host secret and carries RATCHET_BATCH_NAME', async () => { + plantSecret(); + const calls: AgentSpawnRequest[] = []; + const spawner: Spawner = async (request) => { + calls.push(request); + appendJournalForLocus(projectRoot, { batch: BATCH }, { change: KEY, kind: 'completion', message: 'opened the PR' }); + return { exitCode: 0, signal: null, stdout: 'opened PR', stderr: '' }; + }; + const engine = new RatchetBatchEngine({ + spawner, + adapters: fakeAdapters, + projectRoot: () => projectRoot, + skillLocusDeps: { exists: () => true, writeText: () => {} }, + }); + + await engine.runPrStep(prContext()); + + expect(calls).toHaveLength(1); + expect(calls[0].command).toBe(DEFAULT_AGENT); + const env = calls[0].env ?? {}; + expect(env).not.toHaveProperty(SECRET); + expect(env.RATCHET_BATCH_NAME).toBe(BATCH); + }); +}); + +describe('engine-spawn-env — the agent-cmd override still works under the scoped environment', () => { + it('stands in for the coding agent while the scoped env excludes the host secret', async () => { + process.env[ENV] = 'echo stub-agent'; + plantSecret(); + const calls: AgentSpawnRequest[] = []; + const spawner: Spawner = async (request) => { + calls.push(request); + corroboratePropose(projectRoot, 'add-login-api'); + appendJournal(projectRoot, 'b', { change: 'add-login-api', kind: 'completion', message: 'proposed', transition: 'propose' }); + return { exitCode: 0, signal: null, stdout: '', stderr: '' }; + }; + const engine = new RatchetBatchEngine({ + spawner, + adapters: { fake: fakeAdapter('fake') }, + projectRoot: () => projectRoot, + }); + + const result = await engine.runStep(changeContext()); + + expect(calls).toHaveLength(1); + expect(calls[0].command).toBe('bash'); + expect(calls[0].args).toEqual(['-c', 'echo stub-agent']); + const env = calls[0].env ?? {}; + expect(env).not.toHaveProperty(SECRET); + expect(env.RATCHET_BATCH_NAME).toBe('b'); + expect(result.agentOverride).toBe(true); + }); +}); diff --git a/test/batch-engine/isolation.test.ts b/test/batch-engine/isolation.test.ts new file mode 100644 index 0000000..3a62bab --- /dev/null +++ b/test/batch-engine/isolation.test.ts @@ -0,0 +1,114 @@ +// Feature: config-isolation-per-locus.feature +// Proves describeLocusIsolation states the REAL isolation per locus: local is +// advisory (no filesystem/network isolation, env allowlist), docker is container +// isolation with the resolved #85 contract (uid/memory/pids/network + writable +// repo mount), and remote is the server's boundary. Pure over BatchSettings. + +import { describe, it, expect } from 'vitest'; +import { + describeLocusIsolation, + resolveDockerContract, +} from '../../src/core/batch/runtime/isolation.js'; +import { + DEFAULT_DOCKER_MEMORY, + DEFAULT_DOCKER_NETWORK, + DEFAULT_DOCKER_PIDS_LIMIT, +} from '../../src/core/batch/config.js'; +import type { BatchSettings } from '../../src/core/batch/config.js'; + +function base(over: Partial = {}): BatchSettings { + return { + gate: 'voluntary', + strategy: 'vertical-slice', + proofOfWork: 'hard-gate', + locus: 'local', + prGrouping: 'off', + ...over, + }; +} + +describe('describeLocusIsolation — local locus', () => { + it('states the local locus is advisory with no filesystem or network isolation', () => { + const iso = describeLocusIsolation(base({ locus: 'local' })); + expect(iso.locus).toBe('local'); + expect(iso.description).toContain('Advisory'); + expect(iso.description).toContain('no filesystem or network isolation'); + }); + + it('states the agent environment is scoped to the env allowlist', () => { + const iso = describeLocusIsolation(base({ locus: 'local' })); + expect(iso.description).toContain('env allowlist'); + }); +}); + +describe('describeLocusIsolation — docker locus', () => { + it('states the docker locus provides container isolation with its contract', () => { + const iso = describeLocusIsolation(base({ locus: 'docker' })); + expect(iso.locus).toBe('docker'); + expect(iso.description).toContain('Container isolation'); + expect(iso.description).toContain('uid'); + expect(iso.description).toContain('memory'); + expect(iso.description).toContain('pids'); + expect(iso.description).toContain('network'); + }); + + it('states the repository mount stays writable by design', () => { + const iso = describeLocusIsolation(base({ locus: 'docker' })); + expect(iso.description).toContain('Repository mount stays writable by design'); + }); + + it('applies the runtime defaults for an unset uid/memory/pids/network', () => { + const c = resolveDockerContract(base({ locus: 'docker' })); + expect(c.user).toBe('host uid:gid'); + expect(c.memory).toBe(DEFAULT_DOCKER_MEMORY); + expect(c.pids).toBe(String(DEFAULT_DOCKER_PIDS_LIMIT)); + expect(c.network).toBe(DEFAULT_DOCKER_NETWORK); + expect(c.cpus).toBeUndefined(); + }); + + it('uses the configured knobs when set, including cpus', () => { + const iso = describeLocusIsolation( + base({ + locus: 'docker', + dockerUser: '1000:1000', + dockerMemory: '4g', + dockerPidsLimit: 1024, + dockerCpus: 1.5, + network: 'none', + }) + ); + expect(iso.description).toContain('uid 1000:1000'); + expect(iso.description).toContain('memory 4g'); + expect(iso.description).toContain('pids 1024'); + expect(iso.description).toContain('network none'); + expect(iso.description).toContain('cpus 1.5'); + }); + + it('omits the cpus clause when cpus is unset', () => { + const iso = describeLocusIsolation(base({ locus: 'docker' })); + expect(iso.description).not.toContain('cpus'); + }); + + it('network none is reflected (full isolation contract)', () => { + const iso = describeLocusIsolation(base({ locus: 'docker', network: 'none' })); + expect(iso.description).toContain('network none'); + }); +}); + +describe('describeLocusIsolation — remote locus', () => { + it('states isolation is the remote server boundary, not one ratchet enforces', () => { + const iso = describeLocusIsolation(base({ locus: 'remote', host: 'h', port: 1, authToken: 'x' })); + expect(iso.locus).toBe('remote'); + expect(iso.description).toContain("remote server's boundary"); + expect(iso.description).toContain('not one ratchet enforces'); + }); +}); + +describe('describeLocusIsolation — pure over settings (no I/O)', () => { + it('does not read the filesystem or process to resolve docker defaults', () => { + // No dockerUser set: the descriptor states the documented "host uid:gid" + // fallback rather than reading process.getuid, so it is deterministic. + const iso = describeLocusIsolation(base({ locus: 'docker' })); + expect(iso.description).toContain('uid host uid:gid'); + }); +}); diff --git a/test/batch-engine/manifest-permission-escalation.test.ts b/test/batch-engine/manifest-permission-escalation.test.ts new file mode 100644 index 0000000..bab22e4 --- /dev/null +++ b/test/batch-engine/manifest-permission-escalation.test.ts @@ -0,0 +1,356 @@ +/** + * Manifest permission-escalation clamping (phase proof-of-work for + * restrict-manifest-permission-escalation). + * + * Implements: + * - features/manifest-permission-scope/narrow-only-resolution.feature + * - features/manifest-permission-scope/escalation-opt-in.feature + * - features/manifest-permission-scope/apply-posture-banner.feature + * + * Two layers of coverage: + * 1. Pure unit tests over the in-memory `resolvePermissionsPolicy` / + * `resolveBatchSettings` seam (no filesystem) — the narrow-only contract: + * a repo-committed manifest layer may only NARROW (lower) posture, never + * raise it above the operator-owned (default/user/project) scopes; its + * `deny` additions still union; the opt-in flag re-enables a raise. + * 2. Integration tests over `batchApplyCommand` with the tmpdir fixture + * pattern — the posture banner opens every human run, `--json` suppresses + * it, and a suppressed manifest raise prints a warning naming the flag and + * the operator-owned config scopes. + * + * Phase proof-of-work: `pnpm test test/batch-engine/manifest-permission-escalation.test.ts`. + */ + +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { promises as fs } from 'fs'; +import path from 'path'; +import os from 'os'; +import { + resolvePermissionsPolicy, + resolveBatchSettings, + POSTURE_PRIVILEGE_RANK, + type SuppressedEscalation, +} from '../../src/core/batch/config.js'; +import { saveUserBatchPermissions } from '../../src/core/global-config.js'; +import type { BatchManifest } from '../../src/core/batch/manifest.js'; +import type { StepResult } from '../../src/core/batch/engine/index.js'; + +// --------------------------------------------------------------------------- +// Pure unit tests: narrow-only-resolution.feature + escalation-opt-in.feature +// (in-memory layers, no filesystem) +// --------------------------------------------------------------------------- + +describe('resolvePermissionsPolicy: manifest narrow-only posture', () => { + it('curated-allowlist < repo-sandboxed-permissive < full-autonomy privilege ranking', () => { + expect(POSTURE_PRIVILEGE_RANK['curated-allowlist']).toBeLessThan( + POSTURE_PRIVILEGE_RANK['repo-sandboxed-permissive'] + ); + expect(POSTURE_PRIVILEGE_RANK['repo-sandboxed-permissive']).toBeLessThan( + POSTURE_PRIVILEGE_RANK['full-autonomy'] + ); + }); + + it('manifest raise is CLAMPED by default; operator posture and source are kept', () => { + const { policy, postureSource, suppressedEscalation } = resolvePermissionsPolicy([ + { scope: 'project', policy: { posture: 'repo-sandboxed-permissive' } }, + { scope: 'manifest', policy: { posture: 'full-autonomy' } }, + ]); + expect(policy.posture).toBe('repo-sandboxed-permissive'); + expect(postureSource).toBe('project'); + expect(suppressedEscalation).toEqual({ + scope: 'manifest', + requested: 'full-autonomy', + } satisfies SuppressedEscalation); + }); + + it('manifest NARROW (lower posture) applies unchanged and attributes source to manifest', () => { + const { policy, postureSource, suppressedEscalation } = resolvePermissionsPolicy([ + { scope: 'project', policy: { posture: 'full-autonomy' } }, + { scope: 'manifest', policy: { posture: 'curated-allowlist' } }, + ]); + expect(policy.posture).toBe('curated-allowlist'); + expect(postureSource).toBe('manifest'); + expect(suppressedEscalation).toBeUndefined(); + }); + + it('manifest raise is ALLOWED when allowManifestEscalation is true', () => { + const { policy, postureSource, suppressedEscalation } = resolvePermissionsPolicy( + [ + { scope: 'project', policy: { posture: 'repo-sandboxed-permissive' } }, + { scope: 'manifest', policy: { posture: 'full-autonomy' } }, + ], + { allowManifestEscalation: true } + ); + expect(policy.posture).toBe('full-autonomy'); + expect(postureSource).toBe('manifest'); + expect(suppressedEscalation).toBeUndefined(); + }); + + it('manifest deny additions still UNION even when its posture raise is refused', () => { + const { policy, suppressedEscalation } = resolvePermissionsPolicy([ + { scope: 'project', policy: { posture: 'repo-sandboxed-permissive', deny: ['A'] } }, + { scope: 'manifest', policy: { posture: 'full-autonomy', deny: ['B'] } }, + ]); + expect(policy.deny.sort()).toEqual(['A', 'B']); + expect(suppressedEscalation).toBeDefined(); + }); + + it('manifest posture EQUAL to the operator posture is not a raise (no suppression)', () => { + const { policy, postureSource, suppressedEscalation } = resolvePermissionsPolicy([ + { scope: 'project', policy: { posture: 'repo-sandboxed-permissive' } }, + { scope: 'manifest', policy: { posture: 'repo-sandboxed-permissive' } }, + ]); + expect(policy.posture).toBe('repo-sandboxed-permissive'); + expect(postureSource).toBe('manifest'); + expect(suppressedEscalation).toBeUndefined(); + }); + + it('operator-owned user/project scopes keep their raise ability (NOT clamped)', () => { + const { policy, postureSource, suppressedEscalation } = resolvePermissionsPolicy([ + { scope: 'user', policy: { posture: 'curated-allowlist' } }, + { scope: 'project', policy: { posture: 'full-autonomy' } }, + ]); + expect(policy.posture).toBe('full-autonomy'); + expect(postureSource).toBe('project'); + expect(suppressedEscalation).toBeUndefined(); + }); + + it('the opt-in flag changes nothing when the manifest does not raise the posture', () => { + const { policy, postureSource } = resolvePermissionsPolicy( + [ + { scope: 'project', policy: { posture: 'repo-sandboxed-permissive' } }, + { scope: 'manifest', policy: { posture: 'repo-sandboxed-permissive' } }, + ], + { allowManifestEscalation: true } + ); + expect(policy.posture).toBe('repo-sandboxed-permissive'); + expect(postureSource).toBe('manifest'); + }); +}); + +// --------------------------------------------------------------------------- +// resolveBatchSettings threading (filesystem-backed; the committed batch.yaml +// silent-escalation assertion the issue is about) +// --------------------------------------------------------------------------- + +describe('resolveBatchSettings: manifest escalation threading', () => { + let projectRoot: string; + let userConfigHome: string; + let priorXdg: string | undefined; + + beforeEach(async () => { + projectRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'perm-esc-')); + await fs.mkdir(path.join(projectRoot, '.ratchet'), { recursive: true }); + userConfigHome = await fs.mkdtemp(path.join(os.tmpdir(), 'perm-esc-xdg-')); + priorXdg = process.env.XDG_CONFIG_HOME; + process.env.XDG_CONFIG_HOME = userConfigHome; + }); + + afterEach(async () => { + await fs.rm(projectRoot, { recursive: true, force: true }); + await fs.rm(userConfigHome, { recursive: true, force: true }); + if (priorXdg === undefined) delete process.env.XDG_CONFIG_HOME; + else process.env.XDG_CONFIG_HOME = priorXdg; + }); + + async function writeProject(yaml: string): Promise { + await fs.writeFile(path.join(projectRoot, '.ratchet', 'config.yaml'), yaml, 'utf-8'); + } + + function manifest(permissions: unknown): BatchManifest { + return { + name: 'b', + phases: [], + settings: { permissions }, + } as unknown as BatchManifest; + } + + it('committed batch.yaml full-autonomy cannot silently escalate over project repo-sandboxed-permissive', async () => { + await writeProject( + 'schema: ratchet\nbatch:\n permissions:\n posture: repo-sandboxed-permissive\n' + ); + const { settings, sources, suppressedEscalation } = resolveBatchSettings( + projectRoot, + manifest({ posture: 'full-autonomy' }) + ); + expect(settings.permissions?.posture).toBe('repo-sandboxed-permissive'); + expect(sources.permissions).toBe('project'); + expect(suppressedEscalation).toEqual({ scope: 'manifest', requested: 'full-autonomy' }); + }); + + it('the opt-in lets the committed manifest raise posture to full-autonomy', async () => { + await writeProject( + 'schema: ratchet\nbatch:\n permissions:\n posture: repo-sandboxed-permissive\n' + ); + const { settings, sources, suppressedEscalation } = resolveBatchSettings( + projectRoot, + manifest({ posture: 'full-autonomy' }), + { allowManifestEscalation: true } + ); + expect(settings.permissions?.posture).toBe('full-autonomy'); + expect(sources.permissions).toBe('manifest'); + expect(suppressedEscalation).toBeUndefined(); + }); + + it('a manifest that NARROWS posture applies and attributes source to manifest', async () => { + saveUserBatchPermissions({ posture: 'full-autonomy' }); + await writeProject('schema: ratchet\n'); + const { settings, sources, suppressedEscalation } = resolveBatchSettings( + projectRoot, + manifest({ posture: 'curated-allowlist' }) + ); + expect(settings.permissions?.posture).toBe('curated-allowlist'); + expect(sources.permissions).toBe('manifest'); + expect(suppressedEscalation).toBeUndefined(); + }); +}); + +// --------------------------------------------------------------------------- +// Integration over batchApplyCommand: posture banner + suppressed-escalation +// warning (apply-posture-banner.feature, escalation-opt-in.feature) +// --------------------------------------------------------------------------- + +const { + runStepMock, + computeNextTransitionMock, + readJournalTolerantMock, + resolvePlanningHomeMock, +} = vi.hoisted(() => ({ + runStepMock: vi.fn(), + computeNextTransitionMock: vi.fn(), + readJournalTolerantMock: vi.fn(), + resolvePlanningHomeMock: vi.fn(), +})); + +vi.mock('../../src/core/batch/engine/index.js', () => ({ + RatchetBatchEngine: class { + runStep = runStepMock; + runDecompositionStep = vi.fn(); + runPrStep = vi.fn(); + }, + computeNextTransition: computeNextTransitionMock, + decompositionJournalKey: (phase: string) => phase, + prJournalKey: (batch: string) => `pr:${batch}`, + hasJournaledPr: (journal: { kind: string; transition?: string }[] = []) => + journal.some((e) => e.kind === 'completion' && e.transition === 'pr'), + readJournalTolerant: readJournalTolerantMock, + runProofOfWork: vi.fn(), + agentOverrideNotice: (envVar: string) => `⚠ agent overridden by ${envVar}`, + BATCH_AGENT_CMD_ENV: 'RATCHET_BATCH_AGENT_CMD', +})); + +vi.mock('../../src/core/planning-home.js', () => ({ + resolveCurrentPlanningHomeSync: resolvePlanningHomeMock, +})); + +import { batchApplyCommand } from '../../src/commands/batch/apply.js'; +import { makeBatchFixture, type BatchFixture } from '../commands/batch/batch-fixture.js'; + +const PHASE = { name: 'p1', goal: 'ship', success: 'works' }; + +describe('batchApplyCommand: posture banner + manifest escalation warning', () => { + let fixture: BatchFixture; + let logSpy: ReturnType; + + beforeEach(async () => { + fixture = await makeBatchFixture('ratchet-perm-esc-'); + logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + resolvePlanningHomeMock.mockReturnValue({ root: fixture.root }); + computeNextTransitionMock.mockReturnValue('propose'); + readJournalTolerantMock.mockReturnValue([]); + runStepMock.mockResolvedValue({ + state: 'advanced', + change: 'c1', + transition: 'propose', + message: 'step complete', + } satisfies StepResult); + }); + + afterEach(async () => { + vi.restoreAllMocks(); + vi.clearAllMocks(); + await fixture.cleanup(); + }); + + function output(): string { + return logSpy.mock.calls.map((args) => args.join(' ')).join('\n'); + } + + it('prints the effective posture and its source scope at the start of every human run', async () => { + await fixture.writeProjectConfig( + 'schema: ratchet\nbatch:\n permissions:\n posture: repo-sandboxed-permissive\n' + ); + await fixture.writeBatch('b', { phases: [{ ...PHASE, changes: [{ name: 'c1' }] }] }); + await fixture.writeChangeWithTasks('c1', { done: 0, total: 1 }); + + await batchApplyCommand('b', {}); + + expect(output()).toContain('permissions: repo-sandboxed-permissive (project scope)'); + }); + + it('attributes a manifest-narrowed posture to the manifest scope', async () => { + await fixture.writeProjectConfig( + 'schema: ratchet\nbatch:\n permissions:\n posture: full-autonomy\n' + ); + await fixture.writeBatch('b', { + settings: { permissions: { posture: 'repo-sandboxed-permissive' } }, + phases: [{ ...PHASE, changes: [{ name: 'c1' }] }], + }); + await fixture.writeChangeWithTasks('c1', { done: 0, total: 1 }); + + await batchApplyCommand('b', {}); + + expect(output()).toContain('permissions: repo-sandboxed-permissive (manifest scope)'); + }); + + it('suppresses the human posture banner under --json', async () => { + await fixture.writeProjectConfig( + 'schema: ratchet\nbatch:\n permissions:\n posture: repo-sandboxed-permissive\n' + ); + await fixture.writeBatch('b', { phases: [{ ...PHASE, changes: [{ name: 'c1' }] }] }); + await fixture.writeChangeWithTasks('c1', { done: 0, total: 1 }); + + await batchApplyCommand('b', { json: true }); + + const parsed = JSON.parse(output()) as StepResult; + expect(parsed.state).toBe('advanced'); + expect(output()).not.toContain('permissions:'); + }); + + it('warns when a manifest raise is refused and names the flag and operator-owned scopes', async () => { + await fixture.writeProjectConfig( + 'schema: ratchet\nbatch:\n permissions:\n posture: repo-sandboxed-permissive\n' + ); + await fixture.writeBatch('b', { + settings: { permissions: { posture: 'full-autonomy' } }, + phases: [{ ...PHASE, changes: [{ name: 'c1' }] }], + }); + await fixture.writeChangeWithTasks('c1', { done: 0, total: 1 }); + + await batchApplyCommand('b', {}); + + const out = output(); + expect(out).toContain("manifest requested posture 'full-autonomy'"); + expect(out).toContain('--allow-manifest-escalation'); + expect(out).toContain('user/project config'); + // The run proceeds under the clamped (project) posture. + expect(out).toContain('permissions: repo-sandboxed-permissive (project scope)'); + }); + + it('honors --allow-manifest-escalation: no warning and manifest posture applies', async () => { + await fixture.writeProjectConfig( + 'schema: ratchet\nbatch:\n permissions:\n posture: repo-sandboxed-permissive\n' + ); + await fixture.writeBatch('b', { + settings: { permissions: { posture: 'full-autonomy' } }, + phases: [{ ...PHASE, changes: [{ name: 'c1' }] }], + }); + await fixture.writeChangeWithTasks('c1', { done: 0, total: 1 }); + + await batchApplyCommand('b', { allowManifestEscalation: true }); + + const out = output(); + expect(out).toContain('permissions: full-autonomy (manifest scope)'); + expect(out).not.toContain("manifest requested posture 'full-autonomy'"); + }); +}); diff --git a/test/batch-engine/rex-bootstrap.test.ts b/test/batch-engine/rex-bootstrap.test.ts index 31c5b75..023793e 100644 --- a/test/batch-engine/rex-bootstrap.test.ts +++ b/test/batch-engine/rex-bootstrap.test.ts @@ -1,4 +1,4 @@ -import { describe, it, expect } from 'vitest'; +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; import path from 'node:path'; import { bootstrapRexRuntime, @@ -9,6 +9,9 @@ import { RexBootstrapError, SWE_REX_VERSION, DEFAULT_DOCKER_IMAGE, + DEFAULT_DOCKER_MEMORY, + DEFAULT_DOCKER_PIDS_LIMIT, + DEFAULT_DOCKER_NETWORK, DOCKER_EXTRA, type BootstrapDeps, type RunResult, @@ -444,4 +447,151 @@ describe('bootstrapRexRuntime — docker locus', () => { expect(launch.env.REX_MOUNT_HOST).toBeUndefined(); expect(launch.env.REX_MOUNT_CONTAINER).toBeUndefined(); }); + + // ------------------------------------------------------------------------- + // Docker-locus hardening env (features/docker-locus-hardening): the five + // `REX_DOCKER_*` knobs are threaded only for docker, with the documented + // defaults applied for memory/pids/network when unset, and the flag OMITTED + // for user/cpus when unset (opt-in). Local is untouched. + // ------------------------------------------------------------------------- + it('threads REX_DOCKER_* (configured) into the launch env', () => { + const deps = new FakeDeps(dockerHappy); + deps.toolsOnPath.add('uv'); + const launch = bootstrapRexRuntime({ + cacheHome: CACHE, + deps, + locus: 'docker', + workdir: '/workspace', + dockerUser: '1000:1000', + dockerMemory: '4g', + dockerPidsLimit: 256, + dockerCpus: 1.5, + network: 'none', + }); + expect(launch.env.REX_DOCKER_USER).toBe('1000:1000'); + expect(launch.env.REX_DOCKER_MEMORY).toBe('4g'); + expect(launch.env.REX_DOCKER_PIDS_LIMIT).toBe('256'); + expect(launch.env.REX_DOCKER_CPUS).toBe('1.5'); + expect(launch.env.REX_DOCKER_NETWORK).toBe('none'); + }); + + it('applies the documented defaults for memory/pids/network when unset', () => { + const deps = new FakeDeps(dockerHappy); + deps.toolsOnPath.add('uv'); + const launch = bootstrapRexRuntime({ + cacheHome: CACHE, + deps, + locus: 'docker', + workdir: '/workspace', + }); + expect(launch.env.REX_DOCKER_MEMORY).toBe(DEFAULT_DOCKER_MEMORY); + expect(launch.env.REX_DOCKER_PIDS_LIMIT).toBe(String(DEFAULT_DOCKER_PIDS_LIMIT)); + expect(launch.env.REX_DOCKER_NETWORK).toBe(DEFAULT_DOCKER_NETWORK); + }); + + it('omits REX_DOCKER_USER and REX_DOCKER_CPUS when unset (opt-in)', () => { + const deps = new FakeDeps(dockerHappy); + deps.toolsOnPath.add('uv'); + const launch = bootstrapRexRuntime({ + cacheHome: CACHE, + deps, + locus: 'docker', + workdir: '/workspace', + }); + expect(launch.env.REX_DOCKER_USER).toBeUndefined(); + expect(launch.env.REX_DOCKER_CPUS).toBeUndefined(); + }); + + it('does not set any REX_DOCKER_* env for local', () => { + const deps = new FakeDeps(happyHandler); + deps.toolsOnPath.add('uv'); + const launch = bootstrapRexRuntime({ cacheHome: CACHE, deps, locus: 'local' }); + expect(launch.env.REX_DOCKER_USER).toBeUndefined(); + expect(launch.env.REX_DOCKER_MEMORY).toBeUndefined(); + expect(launch.env.REX_DOCKER_PIDS_LIMIT).toBeUndefined(); + expect(launch.env.REX_DOCKER_CPUS).toBeUndefined(); + expect(launch.env.REX_DOCKER_NETWORK).toBeUndefined(); + }); +}); + +/** + * Implements: features/agent-env-scoping/sidecar-bootstrap-env.feature + * + * The sidecar launch env is built from the scoped host environment so a + * local-locus agent (which inherits the sidecar's env) cannot see non-allowlisted + * host secrets, while the venv wiring and threaded locus vars survive scoping. + */ +describe('bootstrapRexRuntime — scoped launch env (sidecar-bootstrap-env.feature)', () => { + const SECRET = 'SUPER_SECRET_TOKEN'; + let savedSecret: string | undefined; + + beforeEach(() => { + savedSecret = process.env[SECRET]; + delete process.env[SECRET]; + }); + + afterEach(() => { + if (savedSecret === undefined) delete process.env[SECRET]; + else process.env[SECRET] = savedSecret; + }); + + // Scenario: The sidecar launch env excludes a non-allowlisted host secret + it('excludes a non-allowlisted host secret while keeping HOME', () => { + process.env[SECRET] = 'hunter2'; + const deps = new FakeDeps(happyHandler); + deps.toolsOnPath.add('uv'); + const launch = bootstrapRexRuntime({ cacheHome: CACHE, deps, locus: 'local' }); + expect(launch.env).not.toHaveProperty(SECRET); + // HOME is a baseline allowlist var and passes through with its host value. + if (process.env.HOME !== undefined) { + expect(launch.env.HOME).toBe(process.env.HOME); + } + }); + + // Scenario: The venv wiring survives scoping + it('prepends the venv bin to the host PATH and sets VIRTUAL_ENV', () => { + const deps = new FakeDeps(happyHandler); + deps.toolsOnPath.add('uv'); + const launch = bootstrapRexRuntime({ cacheHome: CACHE, deps, locus: 'local' }); + const venvBin = path.join(CACHE, 'ratchet', 'rex', 'venv', 'bin'); + expect(launch.env.PATH?.startsWith(venvBin + path.delimiter)).toBe(true); + // ...followed by the host PATH. + const hostPath = process.env.PATH ?? ''; + if (hostPath) { + expect(launch.env.PATH?.endsWith(hostPath)).toBe(true); + } + expect(launch.env.VIRTUAL_ENV).toBe(path.join(CACHE, 'ratchet', 'rex', 'venv')); + }); + + // Scenario: Locus threading vars survive scoping + it('carries REX_LOCUS, REX_WORKDIR, and REX_IMAGE for the docker locus', () => { + // A handler that makes a docker bootstrap succeed end to end. + const dockerHappy = (command: string, args: string[], self: FakeDeps): RunResult => { + if (command === 'docker' && args[0] === 'info') return ok('Server: ...'); + if (args.includes('--version')) return ok('Python 3.12.1'); + if (command === 'uv' && args[0] === 'venv') { + self.writeText(VENV_PYTHON, '#!/bin/sh'); + return ok(); + } + if (args[0] === '-m' && args[1] === 'venv') { + self.writeText(VENV_PYTHON, '#!/bin/sh'); + return ok(); + } + if (command === 'uv' && args[0] === 'pip') return ok(); + if (args.some((a) => a.includes('import swerex'))) return ok(); + return ok(); + }; + const deps = new FakeDeps(dockerHappy); + deps.toolsOnPath.add('uv'); + const launch = bootstrapRexRuntime({ + cacheHome: CACHE, + deps, + locus: 'docker', + workdir: '/workspace', + image: 'my/image:tag', + }); + expect(launch.env.REX_LOCUS).toBe('docker'); + expect(launch.env.REX_WORKDIR).toBe('/workspace'); + expect(launch.env.REX_IMAGE).toBe('my/image:tag'); + }); }); diff --git a/test/commands/batch/config.test.ts b/test/commands/batch/config.test.ts index ed4bb52..8e4a03d 100644 --- a/test/commands/batch/config.test.ts +++ b/test/commands/batch/config.test.ts @@ -220,3 +220,178 @@ describe('batchConfigCommand permissions block', () => { expect(out).toContain('--allowedTools'); }); }); + +// ========================================================================= +// Feature: config-isolation-per-locus.feature + posture-enforcement-rendering +// Proves `batch config` renders an honest isolation line per locus and a +// per-agent enforcement line per distinct resolved stage agent, and that a +// manifest-sourced posture names its source scope. +// ========================================================================= +describe('batchConfigCommand isolation + enforcement rendering', () => { + let fixture: BatchFixture; + let logSpy: ReturnType; + let xdgConfigHome: string; + let priorXdgConfigHome: string | undefined; + + beforeEach(async () => { + fixture = await makeBatchFixture(); + logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + resolvePlanningHomeMock.mockReturnValue({ root: fixture.root }); + // Isolate the user permission scope so the default posture (not a user-set + // one) resolves, keeping these tests source-deterministic. + xdgConfigHome = await fs.mkdtemp(path.join(os.tmpdir(), 'ratchet-xdg-cfg-')); + priorXdgConfigHome = process.env.XDG_CONFIG_HOME; + process.env.XDG_CONFIG_HOME = xdgConfigHome; + }); + + afterEach(async () => { + if (priorXdgConfigHome === undefined) { + delete process.env.XDG_CONFIG_HOME; + } else { + process.env.XDG_CONFIG_HOME = priorXdgConfigHome; + } + vi.restoreAllMocks(); + vi.clearAllMocks(); + await fixture.cleanup(); + await fs.rm(xdgConfigHome, { recursive: true, force: true, maxRetries: 3, retryDelay: 50 }); + }); + + function output(): string { + return logSpy.mock.calls.map((args) => args.join(' ')).join('\n'); + } + + // --- config-isolation-per-locus.feature --------------------------------- + + it('renders the local locus as advisory with no filesystem/network isolation', async () => { + await fixture.writeProjectConfig('batch:\n locus: local\n'); + + await batchConfigCommand(undefined, {}); + + const out = output(); + expect(out).toContain('isolation'); + expect(out).toContain('local'); + expect(out).toContain('Advisory'); + expect(out).toContain('no filesystem or network isolation'); + expect(out).toContain('env allowlist'); + }); + + it('renders the docker locus as container isolation with its contract', async () => { + await fixture.writeProjectConfig( + 'batch:\n locus: docker\n dockerUser: "1000:1000"\n dockerMemory: "4g"\n dockerPidsLimit: 1024\n network: none\n' + ); + + await batchConfigCommand(undefined, {}); + + const out = output(); + expect(out).toContain('isolation'); + expect(out).toContain('docker'); + expect(out).toContain('Container isolation'); + expect(out).toContain('uid 1000:1000'); + expect(out).toContain('memory 4g'); + expect(out).toContain('pids 1024'); + expect(out).toContain('network none'); + expect(out).toContain('Repository mount stays writable by design'); + }); + + it('renders the remote locus as the server boundary', async () => { + await fixture.writeProjectConfig( + 'batch:\n locus: remote\n host: example.com\n port: 443\n' + ); + + await batchConfigCommand(undefined, {}); + + const out = output(); + expect(out).toContain('isolation'); + expect(out).toContain('remote'); + expect(out).toContain("remote server's boundary"); + expect(out).toContain('not one ratchet enforces'); + }); + + // --- posture-enforcement-rendering.feature ------------------------------ + + it('renders an argv-enforced agent (claude) as enforced via flags', async () => { + await fixture.writeProjectConfig( + 'batch:\n agent: claude\n permissions:\n posture: repo-sandboxed-permissive\n' + ); + + await batchConfigCommand(undefined, {}); + + const out = output(); + expect(out).toContain('permissions'); + expect(out).toContain('repo-sandboxed-permissive'); + expect(out).toContain('claude'); + expect(out).toContain('enforced via flags'); + }); + + it('renders a non-argv-enforced agent (cursor) as NOT ENFORCED', async () => { + await fixture.writeProjectConfig( + 'batch:\n agent: cursor\n permissions:\n posture: repo-sandboxed-permissive\n' + ); + + await batchConfigCommand(undefined, {}); + + const out = output(); + expect(out).toContain('cursor'); + expect(out).toContain('NOT ENFORCED — agent defaults apply'); + }); + + it('names the manifest scope for a manifest-sourced posture', async () => { + await fixture.writeBatch('b', { + settings: { permissions: { posture: 'full-autonomy' } }, + }); + + await batchConfigCommand('b', { allowManifestEscalation: true }); + + const out = output(); + expect(out).toContain('full-autonomy'); + expect(out).toContain('set by batch manifest — repo-controlled'); + }); + + it('renders one enforcement line per distinct stage-mapped agent', async () => { + await fixture.writeProjectConfig( + [ + 'batch:', + ' permissions:', + ' posture: repo-sandboxed-permissive', + ' agent:', + ' propose: claude', + ' apply: cursor', + ' verify: claude', + '', + ].join('\n') + ); + + await batchConfigCommand(undefined, {}); + + const out = output(); + // claude appears once (deduped across propose+verify), cursor once. + expect(out).toContain('claude'); + expect(out).toContain('cursor'); + expect(out).toContain('enforced via flags'); + expect(out).toContain('NOT ENFORCED — agent defaults apply'); + // Only two distinct agents → two enforce lines. + const enforceCount = (out.match(/enforce\s+/g) ?? []).length; + expect(enforceCount).toBe(2); + }); + + // --- JSON output (config-isolation + posture-enforcement JSON) --------- + + it('JSON output carries isolation and per-agent enforcement', async () => { + await fixture.writeProjectConfig( + 'batch:\n agent: cursor\n permissions:\n posture: repo-sandboxed-permissive\n' + ); + + await batchConfigCommand(undefined, { json: true }); + + const parsed = JSON.parse(output()) as { + isolation: { locus: string; description: string }; + enforcement: { agent: string; enforced: boolean; detail: string }[]; + }; + expect(parsed.isolation.locus).toBe('local'); + expect(parsed.isolation.description).toContain('Advisory'); + expect(parsed.enforcement.length).toBe(1); + expect(parsed.enforcement[0].agent).toBe('cursor'); + expect(parsed.enforcement[0].enforced).toBe(false); + expect(parsed.enforcement[0].detail).toContain('NOT ENFORCED'); + }); +}); diff --git a/test/commands/batch/view.test.ts b/test/commands/batch/view.test.ts index 21c355f..88cea53 100644 --- a/test/commands/batch/view.test.ts +++ b/test/commands/batch/view.test.ts @@ -88,6 +88,58 @@ describe('batch view and list', () => { expect(parsed.name).toBe('b'); expect(Array.isArray(parsed.phases)).toBe(true); }); + + // Feature: view-runtime-summary.feature — the dashboard renders an honest + // runtime summary (locus + its real isolation, posture + source + per-agent + // enforcement) reusing the same descriptors as `batch config`. + it('renders a runtime summary naming the locus, isolation, and posture', async () => { + await fixture.writeBatch('b', { + settings: { agent: 'claude', permissions: { posture: 'repo-sandboxed-permissive' } }, + phases: [{ changes: [{ name: 'c1' }] }], + }); + + await batchViewCommand('b', {}); + + const out = output(); + expect(out).toContain('runtime:'); + expect(out).toContain('local'); + expect(out).toContain('Advisory'); + expect(out).toContain('posture:'); + expect(out).toContain('repo-sandboxed-permissive'); + expect(out).toContain('claude: enforced'); + }); + + it('renders the NOT-ENFORCED status for a non-argv agent in the summary', async () => { + await fixture.writeBatch('b', { + settings: { agent: 'cursor', permissions: { posture: 'repo-sandboxed-permissive' } }, + phases: [{ changes: [{ name: 'c1' }] }], + }); + + await batchViewCommand('b', {}); + + const out = output(); + expect(out).toContain('cursor: NOT ENFORCED'); + }); + + it('JSON output carries isolation, posture, and enforcement', async () => { + await fixture.writeBatch('b', { + settings: { agent: 'cursor', permissions: { posture: 'repo-sandboxed-permissive' } }, + phases: [{ changes: [{ name: 'c1' }] }], + }); + + await batchViewCommand('b', { json: true }); + + const parsed = JSON.parse(output()) as { + isolation: { locus: string; description: string }; + posture: string; + enforcement: { agent: string; enforced: boolean; detail: string }[]; + }; + expect(parsed.isolation.locus).toBe('local'); + expect(parsed.isolation.description).toContain('Advisory'); + expect(parsed.posture).toBe('repo-sandboxed-permissive'); + expect(parsed.enforcement[0].agent).toBe('cursor'); + expect(parsed.enforcement[0].enforced).toBe(false); + }); }); describe('batchListCommand', () => { diff --git a/test/core/batch/config.test.ts b/test/core/batch/config.test.ts index e091468..39b10ae 100644 --- a/test/core/batch/config.test.ts +++ b/test/core/batch/config.test.ts @@ -350,6 +350,46 @@ describe('validateSetting', () => { expect(blank.ok).toBe(false); }); + // ------------------------------------------------------------------------- + // Docker-locus hardening knobs (features/docker-locus-hardening): + // `dockerUser`/`dockerMemory`/`network` are non-empty strings (like `image`); + // `dockerPidsLimit` is a positive integer (like `port`); `dockerCpus` is a + // positive number (fractional ok). Each rejects an empty/invalid value naming + // the key, and persists its real typed value so the loader round-trips it. + // ------------------------------------------------------------------------- + it('accepts a non-empty dockerUser/dockerMemory/network and rejects an empty one', () => { + expect(validateSetting('dockerUser', '1000:1000').ok).toBe(true); + expect(validateSetting('dockerMemory', '2g').ok).toBe(true); + expect(validateSetting('network', 'bridge').ok).toBe(true); + for (const key of ['dockerUser', 'dockerMemory', 'network'] as const) { + const empty = validateSetting(key, ''); + expect(empty.ok).toBe(false); + expect(empty.error).toContain(key); + const blank = validateSetting(key, ' '); + expect(blank.ok).toBe(false); + } + }); + + it('accepts a positive integer dockerPidsLimit and rejects non-positive/non-integer', () => { + expect(validateSetting('dockerPidsLimit', '512').ok).toBe(true); + expect(validateSetting('dockerPidsLimit', '1').ok).toBe(true); + expect(validateSetting('dockerPidsLimit', '0').ok).toBe(false); + expect(validateSetting('dockerPidsLimit', '-5').ok).toBe(false); + expect(validateSetting('dockerPidsLimit', '12.5').ok).toBe(false); + expect(validateSetting('dockerPidsLimit', 'abc').ok).toBe(false); + expect(validateSetting('dockerPidsLimit', '').ok).toBe(false); + }); + + it('accepts a positive (possibly fractional) dockerCpus and rejects non-positive', () => { + expect(validateSetting('dockerCpus', '1').ok).toBe(true); + expect(validateSetting('dockerCpus', '1.5').ok).toBe(true); + expect(validateSetting('dockerCpus', '0.25').ok).toBe(true); + expect(validateSetting('dockerCpus', '0').ok).toBe(false); + expect(validateSetting('dockerCpus', '-1').ok).toBe(false); + expect(validateSetting('dockerCpus', 'abc').ok).toBe(false); + expect(validateSetting('dockerCpus', '').ok).toBe(false); + }); + // ------------------------------------------------------------------------- // `agent` write-path validation (write-path-validation.feature): the write // path validates through the SAME shared schema the loaders use @@ -668,3 +708,131 @@ describe('resolveAgentTimeoutMs', () => { ).toBe(1800000); }); }); + +// --------------------------------------------------------------------------- +// Docker-locus hardening knobs (features/docker-locus-hardening): resolution, +// persistence, and manifest-schema acceptance mirror the remote-locus flat +// keys. The five keys cascade project ← manifest like every scalar setting. +// --------------------------------------------------------------------------- +describe('docker-locus hardening knobs', () => { + it('resolves project-level dockerUser/dockerMemory/dockerPidsLimit/dockerCpus/network', async () => { + await writeConfig( + 'schema: ratchet\nbatch:\n' + + ' locus: docker\n' + + ' dockerUser: "1000:1000"\n' + + ' dockerMemory: 2g\n' + + ' dockerPidsLimit: 256\n' + + ' dockerCpus: 1.5\n' + + ' network: none\n' + ); + const { settings, sources } = resolveBatchSettings(projectRoot); + expect(settings.dockerUser).toBe('1000:1000'); + expect(settings.dockerMemory).toBe('2g'); + expect(settings.dockerPidsLimit).toBe(256); + expect(settings.dockerCpus).toBe(1.5); + expect(settings.network).toBe('none'); + expect(sources.dockerUser).toBe('project'); + expect(sources.dockerMemory).toBe('project'); + expect(sources.dockerPidsLimit).toBe('project'); + expect(sources.dockerCpus).toBe('project'); + expect(sources.network).toBe('project'); + }); + + it('lets a manifest override the project-level docker knobs', async () => { + await writeConfig( + 'schema: ratchet\nbatch:\n dockerMemory: 2g\n dockerPidsLimit: 256\n' + ); + const manifest = { + name: 'q', + phases: [], + settings: { + dockerMemory: '4g', + dockerPidsLimit: 512, + dockerUser: '1000:1000', + }, + } as unknown as BatchManifest; + const { settings, sources } = resolveBatchSettings(projectRoot, manifest); + expect(settings.dockerMemory).toBe('4g'); + expect(settings.dockerPidsLimit).toBe(512); + expect(settings.dockerUser).toBe('1000:1000'); + expect(sources.dockerMemory).toBe('manifest'); + expect(sources.dockerPidsLimit).toBe('manifest'); + expect(sources.dockerUser).toBe('manifest'); + }); + + it('leaves the docker knobs unset (default source) when unconfigured', async () => { + await writeConfig('schema: ratchet\n'); + const { settings, sources } = resolveBatchSettings(projectRoot); + expect(settings.dockerUser).toBeUndefined(); + expect(settings.dockerMemory).toBeUndefined(); + expect(settings.dockerPidsLimit).toBeUndefined(); + expect(settings.dockerCpus).toBeUndefined(); + expect(settings.network).toBeUndefined(); + expect(sources.dockerUser).toBe('default'); + expect(sources.dockerMemory).toBe('default'); + }); + + it('persists valid docker knobs into config.yaml with their real types', async () => { + await writeConfig('schema: ratchet\n'); + expect(setProjectBatchSetting(projectRoot, 'dockerUser', '1000:1000').ok).toBe(true); + expect(setProjectBatchSetting(projectRoot, 'dockerMemory', '2g').ok).toBe(true); + expect(setProjectBatchSetting(projectRoot, 'dockerPidsLimit', '512').ok).toBe(true); + expect(setProjectBatchSetting(projectRoot, 'dockerCpus', '1.5').ok).toBe(true); + expect(setProjectBatchSetting(projectRoot, 'network', 'none').ok).toBe(true); + const parsed = parseYaml( + readFileSync(path.join(projectRoot, '.ratchet', 'config.yaml'), 'utf-8') + ); + expect(parsed.batch.dockerUser).toBe('1000:1000'); + expect(parsed.batch.dockerMemory).toBe('2g'); + expect(parsed.batch.dockerPidsLimit).toBe(512); + expect(parsed.batch.dockerCpus).toBe(1.5); + expect(parsed.batch.network).toBe('none'); + }); + + it('leaves the file unchanged when an invalid docker knob is rejected', async () => { + const original = 'schema: ratchet\nbatch:\n gate: voluntary\n'; + await writeConfig(original); + expect(setProjectBatchSetting(projectRoot, 'dockerMemory', '').ok).toBe(false); + expect(setProjectBatchSetting(projectRoot, 'dockerPidsLimit', 'not-a-num').ok).toBe(false); + expect(setProjectBatchSetting(projectRoot, 'dockerCpus', '0').ok).toBe(false); + const after = readFileSync( + path.join(projectRoot, '.ratchet', 'config.yaml'), + 'utf-8' + ); + expect(after).toBe(original); + }); + + it('accepts the docker knobs in the manifest settings schema', () => { + const result = BatchManifestSchema.safeParse({ + name: 'b', + settings: { + locus: 'docker', + dockerUser: '1000:1000', + dockerMemory: '2g', + dockerPidsLimit: 512, + dockerCpus: 1.5, + network: 'none', + }, + phases: [], + }); + expect(result.success).toBe(true); + }); + + it('stays strict — rejects an unknown docker-ish settings key', () => { + const result = BatchManifestSchema.safeParse({ + name: 'b', + settings: { locus: 'docker', dockerDisk: '10g' }, + phases: [], + }); + expect(result.success).toBe(false); + }); + + it('rejects a non-numeric dockerPidsLimit in the manifest schema', () => { + const result = BatchManifestSchema.safeParse({ + name: 'b', + settings: { locus: 'docker', dockerPidsLimit: 'lots' }, + phases: [], + }); + expect(result.success).toBe(false); + }); +}); diff --git a/test/core/batch/permissions-resolution.test.ts b/test/core/batch/permissions-resolution.test.ts index 7a368c2..d9761bb 100644 --- a/test/core/batch/permissions-resolution.test.ts +++ b/test/core/batch/permissions-resolution.test.ts @@ -53,15 +53,46 @@ describe('permission scope layering (user ← project ← manifest)', () => { expect(sources.permissions).toBe('default'); }); - it('per-change manifest posture wins over project and user (scalar nearest-wins)', async () => { + it('per-change manifest posture is CLAMPED when it tries to raise above operator scopes', async () => { saveUserBatchPermissions({ posture: 'curated-allowlist' }); await writeProject('schema: ratchet\nbatch:\n permissions:\n posture: repo-sandboxed-permissive\n'); - const { settings, sources } = resolveBatchSettings( + const { settings, sources, suppressedEscalation } = resolveBatchSettings( projectRoot, manifest({ posture: 'full-autonomy' }) ); + // raise refused: posture held at the operator-scope value, source stays 'project' + expect(settings.permissions?.posture).toBe('repo-sandboxed-permissive'); + expect(sources.permissions).toBe('project'); + expect(suppressedEscalation).toEqual({ + scope: 'manifest', + requested: 'full-autonomy', + }); + }); + + it('per-change manifest posture wins when it NARROWS (lowers) below operator scopes', async () => { + saveUserBatchPermissions({ posture: 'full-autonomy' }); + await writeProject('schema: ratchet\nbatch:\n permissions:\n posture: full-autonomy\n'); + const { settings, sources, suppressedEscalation } = resolveBatchSettings( + projectRoot, + manifest({ posture: 'curated-allowlist' }) + ); + // narrowing allowed: manifest posture applies, source becomes 'manifest' + expect(settings.permissions?.posture).toBe('curated-allowlist'); + expect(sources.permissions).toBe('manifest'); + expect(suppressedEscalation).toBeUndefined(); + }); + + it('per-change manifest posture raise is ALLOWED with allowManifestEscalation opt-in', async () => { + saveUserBatchPermissions({ posture: 'curated-allowlist' }); + await writeProject('schema: ratchet\nbatch:\n permissions:\n posture: repo-sandboxed-permissive\n'); + const { settings, sources, suppressedEscalation } = resolveBatchSettings( + projectRoot, + manifest({ posture: 'full-autonomy' }), + { allowManifestEscalation: true } + ); expect(settings.permissions?.posture).toBe('full-autonomy'); expect(sources.permissions).toBe('manifest'); + expect(suppressedEscalation).toBeUndefined(); }); it('project overrides user when no manifest posture is present', async () => { diff --git a/test/core/doctor/doctor.test.ts b/test/core/doctor/doctor.test.ts index e19cd86..1898559 100644 --- a/test/core/doctor/doctor.test.ts +++ b/test/core/doctor/doctor.test.ts @@ -259,12 +259,12 @@ describe('runDoctorChecks', () => { expect(c).toHaveProperty('severity'); } const ids = parsed.checks.map((c: { id: string }) => c.id).sort(); - expect(ids).toEqual(['agent', 'docker', 'runtime']); + expect(ids).toEqual(['agent', 'batch-isolation', 'docker', 'runtime']); }); }); describe('renderReport (human output)', () => { - it('renders a passing report with a success summary and no remedy arrows', () => { + it('renders a passing report with a success summary and no fail glyphs', () => { const deps = new FakeDeps(() => ok()); deps.toolsOnPath.add(CLAUDE_BIN); deps.toolsOnPath.add('uv'); @@ -273,8 +273,9 @@ describe('renderReport (human output)', () => { expect(out).toContain('Coding-agent CLI'); expect(out).toContain('SWE-ReX runtime'); expect(out).toContain('All required checks passed.'); - // No remedy line (→) when nothing is failing. - expect(out).not.toContain('→'); + // No fail glyph (✗) when no required check is failing. Info nudges may + // carry a remedy arrow (→) even on an all-pass report. + expect(out).not.toContain('✗'); }); it('renders a failing check with the fail glyph, its detail, and a remedy line', () => { @@ -330,7 +331,11 @@ describe('runDoctorChecks — pr-remote conditional row', () => { const report = runDoctorChecks(deps, projectRoot); const ids = report.checks.map((c) => c.id).sort(); - expect(ids).toEqual(['agent', 'docker', 'runtime']); + // pr-remote is absent (no prGrouping); batch-isolation IS present because the + // default `repo-sandboxed-permissive` posture on the `local` locus triggers + // the advisory nudge (see doctor-local-locus-nudge.feature). + expect(ids).toEqual(['agent', 'batch-isolation', 'docker', 'runtime']); + expect(report.checks.find((c) => c.id === 'pr-remote')).toBeUndefined(); }); it('is present under active grouping with no configured remote', async () => { @@ -354,6 +359,128 @@ describe('runDoctorChecks — pr-remote conditional row', () => { }); }); +describe('runDoctorChecks — batch-isolation nudge', () => { + let projectRoot: string; + let userConfigHome: string; + let priorXdg: string | undefined; + + beforeEach(async () => { + projectRoot = await fsp.mkdtemp(path.join(os.tmpdir(), 'doctor-iso-')); + await fsp.mkdir(path.join(projectRoot, '.ratchet'), { recursive: true }); + userConfigHome = await fsp.mkdtemp(path.join(os.tmpdir(), 'doctor-iso-xdg-')); + priorXdg = process.env.XDG_CONFIG_HOME; + process.env.XDG_CONFIG_HOME = userConfigHome; + }); + + afterEach(async () => { + await fsp.rm(projectRoot, { recursive: true, force: true }); + await fsp.rm(userConfigHome, { recursive: true, force: true }); + if (priorXdg === undefined) delete process.env.XDG_CONFIG_HOME; + else process.env.XDG_CONFIG_HOME = priorXdg; + }); + + const writeConfig = (yaml: string) => + fsp.writeFile(path.join(projectRoot, '.ratchet', 'config.yaml'), yaml, 'utf-8'); + + const passing = () => + new FakeDeps((command, args) => { + if (AGENT_BINS.includes(command) && args.includes('--version')) { + return ok(`${command} 1.0.0`); + } + return ok(); + }); + + it('full-autonomy on local → info nudge naming the docker locus', async () => { + await writeConfig( + 'schema: ratchet\nbatch:\n locus: local\n permissions:\n posture: full-autonomy\n' + ); + const deps = passing(); + deps.toolsOnPath.add(CLAUDE_BIN); + deps.toolsOnPath.add('uv'); + + const report = runDoctorChecks(deps, projectRoot); + const iso = report.checks.find((c) => c.id === 'batch-isolation'); + expect(iso).toBeDefined(); + expect(iso!.status).toBe('info'); + expect(iso!.severity).toBe('optional'); + expect(iso!.detail).toContain('full-autonomy'); + expect(iso!.detail).toContain('local'); + expect(iso!.remedy).toContain('docker'); + // Advisory → never fails doctor. + expect(report.ok).toBe(true); + expect(exitCodeFor(report)).toBe(0); + }); + + it('default permissive posture on local → advisory nudge, not a failure', async () => { + await writeConfig('schema: ratchet\nbatch:\n locus: local\n'); + const deps = passing(); + deps.toolsOnPath.add(CLAUDE_BIN); + deps.toolsOnPath.add('uv'); + + const report = runDoctorChecks(deps, projectRoot); + const iso = report.checks.find((c) => c.id === 'batch-isolation'); + expect(iso).toBeDefined(); + expect(iso!.status).toBe('info'); + expect(iso!.severity).toBe('optional'); + expect(iso!.detail).toContain('repo-sandboxed-permissive'); + expect(iso!.detail).toContain('local'); + expect(report.ok).toBe(true); + expect(exitCodeFor(report)).toBe(0); + }); + + it('curated-allowlist on local → silent (omitted from report)', async () => { + await writeConfig( + 'schema: ratchet\nbatch:\n locus: local\n permissions:\n posture: curated-allowlist\n' + ); + const deps = passing(); + deps.toolsOnPath.add(CLAUDE_BIN); + deps.toolsOnPath.add('uv'); + + const report = runDoctorChecks(deps, projectRoot); + expect(report.checks.find((c) => c.id === 'batch-isolation')).toBeUndefined(); + expect(report.ok).toBe(true); + }); + + it('docker locus → silent (already has containment)', async () => { + await writeConfig( + 'schema: ratchet\nbatch:\n locus: docker\n permissions:\n posture: full-autonomy\n' + ); + const deps = passing(); + deps.toolsOnPath.add(CLAUDE_BIN); + deps.toolsOnPath.add('uv'); + + const report = runDoctorChecks(deps, projectRoot); + expect(report.checks.find((c) => c.id === 'batch-isolation')).toBeUndefined(); + expect(report.ok).toBe(true); + }); + + it('remote locus → silent (server owns the boundary)', async () => { + await writeConfig( + 'schema: ratchet\nbatch:\n locus: remote\n host: example.com\n port: 443\n permissions:\n posture: full-autonomy\n' + ); + const deps = passing(); + deps.toolsOnPath.add(CLAUDE_BIN); + deps.toolsOnPath.add('uv'); + + const report = runDoctorChecks(deps, projectRoot); + expect(report.checks.find((c) => c.id === 'batch-isolation')).toBeUndefined(); + expect(report.ok).toBe(true); + }); + + it('the nudge never fails doctor even with full-autonomy on local', async () => { + await writeConfig( + 'schema: ratchet\nbatch:\n locus: local\n permissions:\n posture: full-autonomy\n' + ); + const deps = passing(); + deps.toolsOnPath.add(CLAUDE_BIN); + deps.toolsOnPath.add('uv'); + + const report = runDoctorChecks(deps, projectRoot); + expect(report.ok).toBe(true); + expect(exitCodeFor(report)).toBe(0); + }); +}); + describe('AGENT_BINARIES (single source of truth)', () => { it('covers exactly the coding agents and maps cursor to cursor-agent', () => { // Exact shape: derived from the agentBinary-marked init tools, nothing more.