From dcee1f7fc9c16756b0a722c1835d3a9c41ae3fd8 Mon Sep 17 00:00:00 2001 From: Ori Nachum Date: Tue, 28 Jul 2026 09:52:45 +0300 Subject: [PATCH 01/20] spec: m1-leftovers (devague /think) The converged frame for the post-Milestone-1 leftovers arc: 27 confirmed claims, 24 confirmed honesty conditions, 15 scope entries with provenance. Re-captures the 12 deliberately uncovered prior-frame targets verbatim (operator decision q1) so the next plan leg computes coverage natively. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01EkC4zsVUmYrRCwSWSe4aLp --- .devague/current | 2 +- .devague/frames/m1-leftovers.json | 618 ++++++++++++++++++++++++++ docs/specs/2026-07-28-m1-leftovers.md | 134 ++++++ 3 files changed, 753 insertions(+), 1 deletion(-) create mode 100644 .devague/frames/m1-leftovers.json create mode 100644 docs/specs/2026-07-28-m1-leftovers.md diff --git a/.devague/current b/.devague/current index 0236707..278e8ba 100644 --- a/.devague/current +++ b/.devague/current @@ -1 +1 @@ -guarded-local-operations-plane +m1-leftovers diff --git a/.devague/frames/m1-leftovers.json b/.devague/frames/m1-leftovers.json new file mode 100644 index 0000000..bb135a9 --- /dev/null +++ b/.devague/frames/m1-leftovers.json @@ -0,0 +1,618 @@ +{ + "slug": "m1-leftovers", + "title": "m1-leftovers", + "schema_version": 3, + "status": "exported", + "created": "2026-07-28T06:30:12Z", + "updated": "2026-07-28T06:48:29Z", + "claims": [ + { + "id": "c1", + "kind": "announcement", + "text": "Every Milestone 1 leftover is tracked to an owner and a sequence: the gap between M1-done and the issue 1 definition of done is closed, with colleague holding its migration proposal, the deviation ledger adjudicated, guidance surfaces honest again, the scanner ceiling stated and pinned per-site, the remaining CLI groups and milestones planned as their own arcs, and the environment lifecycle shaped to fit every consumer we serve", + "origin": "user", + "status": "confirmed", + "honesty_conditions": [ + { + "id": "h17", + "text": "Every section of issue 8 is either delivered by a claim in this frame, explicitly re-owned by another repo, or recorded as a chosen limit; no issue 1 definition-of-done item is left unmapped.", + "status": "confirmed", + "instruction": "" + } + ], + "hard_questions": [], + "links": [], + "instruction": "Deliver as reviewed slices: docs refresh, ledger adjudication, cross-repo filings (colleague proposal, devex bug, issue 1 reply), the scanner hardening PR, and separate next-arc plans for M3/M4 and the CLI groups - each slice maps back to a claim here." + }, + { + "id": "c2", + "kind": "requirement", + "text": "File the colleague migration proposal issue (issue 1 definition of done): colleague composes the shell-cli executor for the six tools without subclassing (t86), runs differential parity then removes its legacy handlers (t87), and allow-lists shell-cli in its tests/test_zero_deps.py. A gh search of agentculture/colleague shows no such issue exists today. Propose, do not push.", + "origin": "llm", + "status": "confirmed", + "honesty_conditions": [ + { + "id": "h7", + "text": "A migration-proposal issue exists on agentculture/colleague describing the t86 composition seam, t87 differential parity then legacy removal, and the test_zero_deps allow-listing - filed as a proposal, with the decision left to colleague.", + "status": "confirmed", + "instruction": "" + } + ], + "hard_questions": [], + "links": [], + "instruction": "File via the communicate skill on agentculture/colleague, signed: the t86 composition seam, t87 differential parity then legacy removal, and allow-listing shell-cli in tests/test_zero_deps.py. Propose, do not push." + }, + { + "id": "c3", + "kind": "requirement", + "text": "Refresh the stale guidance surfaces in one docs slice: README.md lines 49-54 still say 'Scaffold ... No operation, environment, policy, or runner has been built yet' and lines 77-81 say neither runner exists; CLAUDE.md lines 64-68 list t84/t85 as not built and say the CLI exposes only introspection verbs. All false at 0.13.1 - CHANGELOG 0.13.0 shipped process.exec, process.shell, policy check/explain and operation show. The learn/explain catalog was already refreshed; README and CLAUDE.md were missed.", + "origin": "llm", + "status": "confirmed", + "honesty_conditions": [ + { + "id": "h8", + "text": "After the docs slice merges, README's Status section and CLAUDE.md's current-state stanza describe the 0.13.1 surface accurately: operation core, both process kinds, and the policy/operation CLI nouns built; only ContainerRunner, colleague delegation, and the env/fs/process/git CLI groups remain listed as not built.", + "status": "confirmed", + "instruction": "" + } + ], + "hard_questions": [], + "links": [], + "instruction": "One docs-only PR (version bump per repo rule): rewrite README Status and the axes-matrix Built column, and the CLAUDE.md current-state stanza, against the 0.13.1 CHANGELOG; markdownlint and test_honesty must stay green." + }, + { + "id": "c4", + "kind": "requirement", + "text": "Build the remaining canonical CLI noun groups env, fs, process, git from issue 1 section 6: shell/cli/_commands/ holds only cli, doctor, explain, learn, operation, overview, policy, whoami, so no CLI verb yet executes an operation - the library carries all semantics, and each group is a front-end slice over the same engine plus per-slice catalog and doc authoring.", + "origin": "llm", + "status": "confirmed", + "honesty_conditions": [ + { + "id": "h9", + "text": "Each new noun group fronts the same operation engine the library exposes (no second implementation), mutation and execution verbs preview by default and require --apply, and every registered path resolves in the explain catalog - pinned by test_every_catalog_path_resolves and the teken rubric gate.", + "status": "confirmed", + "instruction": "" + } + ], + "hard_questions": [], + "links": [], + "instruction": "One PR per noun group (env, fs, process, git) following the _commands register(sub) pattern; preview-by-default with --apply on mutation/execution verbs; catalog + explain docs authored per slice; teken cli doctor --strict green." + }, + { + "id": "c5", + "kind": "requirement", + "text": "Adjudicate the deviation ledger: devague deviate --list shows d1 through d10 with nine still proposed (only d9 approved), while issue 4 documents only d1-d3 and misses d4, which corrects d1 - the zero-step colleague failure is intermittent, not deterministic. Operator confirms the ledger; the public record gets updated to match.", + "origin": "llm", + "status": "confirmed", + "honesty_conditions": [ + { + "id": "h10", + "text": "devague deviate --list shows no entry still in proposed state, and issue 4 gains a comment recording d4 through d10 so the public record matches the ledger.", + "status": "confirmed", + "instruction": "" + } + ], + "hard_questions": [], + "links": [], + "instruction": "Operator runs devague deviate --confirm for the nine proposed entries after review, then a comment lands on issue 4 documenting d4-d10 so the public record matches the ledger." + }, + { + "id": "c6", + "kind": "requirement", + "text": "File the devex bug upstream: devex pr open --body-file - raises an unhandled FileNotFoundError instead of reading stdin (recorded at the tail of issue 4, worked around with a temp file); a gh search of agentculture/devex finds no matching issue, so it is still unfiled.", + "origin": "llm", + "status": "confirmed", + "honesty_conditions": [ + { + "id": "h11", + "text": "An issue exists on agentculture/devex reproducing pr open --body-file - with the FileNotFoundError traceback and citing the no-tracebacks convention.", + "status": "confirmed", + "instruction": "" + } + ], + "hard_questions": [], + "links": [], + "instruction": "gh issue create on agentculture/devex quoting the open_.py line 22 traceback and the no-tracebacks convention; sign per posting convention." + }, + { + "id": "c7", + "kind": "requirement", + "text": "Schedule the issue 7 scanner slice as its own reviewed PR: per-site pinning of spawn-site counts per module (the one architectural fix), widening the detected-call set with module-prefix resolution, and scope-aware bindings that drop the unbound-base false positives. The honest relabeling (item 1) already landed; the stated ceiling stays: detects accidental and careless drift from a pinned baseline.", + "origin": "llm", + "status": "confirmed", + "honesty_conditions": [ + { + "id": "h12", + "text": "A reviewed PR pins per-module spawn-site counts so that a new spawn added to an already-allow-listed module fails --check (proven by fixture), widens the detected-call set with module-prefix resolution, and clears the six false-positive fixtures from issue 7.", + "status": "confirmed", + "instruction": "" + } + ], + "hard_questions": [], + "links": [], + "instruction": "Single reviewed PR on scripts/colleague_inventory.py: per-site pinning of spawn-site counts, module-prefix call resolution, scope-aware bindings; regenerate the pinned baseline at SHA 28fee29; add the issue 7 evasion and false-positive fixtures as tests." + }, + { + "id": "c8", + "kind": "requirement", + "text": "Milestone 3 drives the colleague known-debt allow-list from 13 modules to 0 (prior-frame c40): project execution (run_tests, lint, hooks) and control execution (git, worktrees, AgentCulture CLIs) route through shell-cli; lint ruff --fix mutations must surface as changed-path effects (prior-frame c25/h13) and worktree administration must respect the existing per-repo fcntl.flock serialization (prior-frame c47/h35).", + "origin": "llm", + "status": "confirmed", + "honesty_conditions": [ + { + "id": "h13", + "text": "debt_remaining is derived from the committed ALLOWLIST on every CI run, lint mutations appear as changed-path effects, worktree serialization holds under the migrated mechanism, and Milestone 3 closes only at 0 with --check exiting 0 against colleague's then-current commit.", + "status": "confirmed", + "instruction": "" + } + ], + "hard_questions": [], + "links": [], + "instruction": "Plan Milestone 3 as its own devague arc over the re-captured targets (c23, c25, c26, c27) plus the 13-module debt allow-list; no M3 implementation inside this leftovers arc." + }, + { + "id": "c9", + "kind": "requirement", + "text": "Milestone 4 ships ContainerRunner with the per-runner threat-model split in the same PR (prior-frame c8: docs/threat-model.md is written entirely around the six-tool host guard today) and decides the linked-worktree git-metadata question explicitly - never by mounting the host common .git writable.", + "origin": "llm", + "status": "confirmed", + "honesty_conditions": [ + { + "id": "h14", + "text": "The PR that introduces ContainerRunner contains the per-runner threat-model split with no sentence covering both guarantees, and records the linked-worktree git-metadata decision explicitly; the host common .git is never mounted writable.", + "status": "confirmed", + "instruction": "" + } + ], + "hard_questions": [], + "links": [], + "instruction": "Plan Milestone 4 as its own arc; the PR introducing ContainerRunner carries the per-runner threat-model split (c22) and the linked-worktree git-metadata decision; its design review includes the second-consumer fit (c10)." + }, + { + "id": "c10", + "kind": "requirement", + "text": "The environment-lifecycle design (env create and destroy, ContainerRunner, evidence) must fit a second consumer now in progress: an ephemeral computational workspace CLI where a model offloads execution and calculation and receives compact evidence-bearing results. Needs read from its checkout: workspace lifecycle verbs over a provider seam (capabilities, create, run, inspect, read, remove), closed-by-default network, digest-pinned runtime profiles, enforced-versus-measured resource honesty, declared-artifact export with digest verification, bounded renderings with retrieval markers.", + "origin": "llm", + "status": "confirmed", + "honesty_conditions": [ + { + "id": "h15", + "text": "The Milestone 4 environment-lifecycle design review demonstrates the second consumer's six-verb provider protocol is implementable over shell-cli environments without naming the consumer and without adding any runtime dependency to pyproject.", + "status": "confirmed", + "instruction": "" + } + ], + "hard_questions": [], + "links": [], + "instruction": "During the M4 design review, map the six provider verbs onto operations and environment lifecycle on paper before code; verify pyproject dependencies remains empty." + }, + { + "id": "c11", + "kind": "boundary", + "text": "Public shell-cli surfaces - issues, docs, specs, and this committed frame - do not name the second consumer, per operator instruction; its needs are recorded generically. shell-cli never imports it and it never appears in signatures; artifacts cross as plain files, the same peer posture the WebGlass seam already documents.", + "origin": "llm", + "status": "confirmed", + "honesty_conditions": [ + { + "id": "h18", + "text": "A search of the public shell-cli surfaces - repo files, issues, the exported spec, and committed .devague state - finds no occurrence of the second consumer's name; the boundary import guard stays green.", + "status": "confirmed", + "instruction": "" + } + ], + "hard_questions": [], + "links": [], + "instruction": "" + }, + { + "id": "c12", + "kind": "boundary", + "text": "Fitting the second consumer must not add a container-SDK dependency: that consumer pins a docker SDK itself, but the shell-cli core stays pure-stdlib (pyproject dependencies list is empty) and ContainerRunner drives docker or podman as PATH executables via subprocess.", + "origin": "llm", + "status": "confirmed", + "honesty_conditions": [ + { + "id": "h19", + "text": "pyproject.toml dependencies stays an empty list and tests/test_zero_deps.py keeps failing on any non-stdlib import in the core; ContainerRunner invokes docker or podman only as PATH executables via subprocess.", + "status": "confirmed", + "instruction": "" + } + ], + "hard_questions": [], + "links": [], + "instruction": "" + }, + { + "id": "c13", + "kind": "boundary", + "text": "t69 and t70 stay colleague-owned, filed privately as GHSA-6pfg-9vcp-hqw4 and GHSA-2m42-prxw-43w6 (ledger d5); the t74b policy-composition characterization fixtures stay blocked until the fixed colleague release ships (ledger d6) - characterization must never encode pre-hardening composition as the parity target.", + "origin": "llm", + "status": "confirmed", + "honesty_conditions": [ + { + "id": "h20", + "text": "t74b fixtures are captured only after a colleague release containing both advisory fixes ships, and no characterization fixture in this repo encodes pre-hardening composition semantics.", + "status": "confirmed", + "instruction": "" + } + ], + "hard_questions": [], + "links": [], + "instruction": "" + }, + { + "id": "c14", + "kind": "boundary", + "text": "The five known limits in issue 8 section 6 - undeclared secrets recorded verbatim, the freeze escape, dual integrity digests, three-valued applied, and the 8 characterization tests outside CI behind the KNOWN GAP marker at tests.yml line 185 - are chosen contracts asserted by tests; this leftovers arc must not fix them.", + "origin": "llm", + "status": "confirmed", + "honesty_conditions": [ + { + "id": "h21", + "text": "The five limits remain asserted by their existing tests, and no PR in this arc alters those tests or the documented contracts they pin.", + "status": "confirmed", + "instruction": "" + } + ], + "hard_questions": [], + "links": [], + "instruction": "" + }, + { + "id": "c15", + "kind": "assumption", + "text": "The issue 8 section 8 sequencing decision is already executed: PyPI serves 0.13.1 (containing the gate-bypass fix) and origin/main sits at the Milestone 1 merge 1f4b616; what remains of section 8 is colleague-side advisory disclosure timing, which is not a shell-cli push decision.", + "origin": "llm", + "status": "confirmed", + "honesty_conditions": [], + "hard_questions": [], + "links": [], + "instruction": "" + }, + { + "id": "c16", + "kind": "non_goal", + "text": "Milestone 5 (webglass-cli as a peer provider) stays planned in its own repository - no task in this leftovers arc delivers it; tests/test_boundaries.py keeps the import graph clean meanwhile.", + "origin": "llm", + "status": "confirmed", + "honesty_conditions": [], + "hard_questions": [], + "links": [], + "instruction": "" + }, + { + "id": "c17", + "kind": "assumption", + "text": "The external minimum-record list posted on issue 1 (requested action, resolved targets, approval scope, environment identity, execution result, emitted artifacts, cleanup and finalization state) is already carried by the evidence contract except emitted artifacts and cleanup/finalization state, which become first-class only when environment lifecycle operations land.", + "origin": "llm", + "status": "confirmed", + "honesty_conditions": [], + "hard_questions": [], + "links": [], + "instruction": "" + }, + { + "id": "c18", + "kind": "audience", + "text": "shell-cli maintainers and its consuming agents: colleague as first consumer, the operator-disclosed unnamed second consumer, and every agent that reads the guidance surfaces (learn, explain, README, CLAUDE.md) before touching the tool.", + "origin": "llm", + "status": "confirmed", + "honesty_conditions": [ + { + "id": "h22", + "text": "Each audience member has a surface this arc improves for them: colleague gets the migration proposal, the second consumer gets the M4 fit review, and agent readers get accurate README, CLAUDE.md, and learn output.", + "status": "confirmed", + "instruction": "" + } + ], + "hard_questions": [], + "links": [], + "instruction": "" + }, + { + "id": "c19", + "kind": "before_state", + "text": "Milestone 1 is merged and 0.13.1 is on PyPI, but the issue 1 definition of done is blocked: no migration proposal is filed on colleague, README and CLAUDE.md still describe the pre-M1 scaffold, the env/fs/process/git CLI groups do not exist, nine ledger deviations await adjudication, the scanner hardening is unscheduled, and M3-M5 have no plans.", + "origin": "llm", + "status": "confirmed", + "honesty_conditions": [ + { + "id": "h23", + "text": "Each blocked item in the before-state is a reproducible read recorded in scope entries s1-s15: the gh searches, the stale line numbers, the missing _commands modules, and the nine proposed ledger entries.", + "status": "confirmed", + "instruction": "" + } + ], + "hard_questions": [], + "links": [], + "instruction": "" + }, + { + "id": "c20", + "kind": "why_it_matters", + "text": "The guidance surfaces are the first thing an agent consumer reads - stale ones make every consumer mis-model the tool; and leftovers without named owners rot silently into drift between the issue 1 contract and what the repo actually does.", + "origin": "llm", + "status": "confirmed", + "honesty_conditions": [ + { + "id": "h24", + "text": "The staleness is not hypothetical: README lines 49-54 asserted no operation core exists while 0.13.1 had shipped it - a consumer reading main was actively misinformed when this frame was drafted.", + "status": "confirmed", + "instruction": "" + } + ], + "hard_questions": [], + "links": [], + "instruction": "" + }, + { + "id": "c21", + "kind": "requirement", + "text": "Post a signed reply on issue 1 mapping the external minimum-record list to the evidence contract, and fold the two not-yet-first-class fields (emitted artifacts, cleanup/finalization state) into the Milestone 4 environment-lifecycle design review. (Operator decision q2, 2026-07-28.)", + "origin": "user", + "status": "confirmed", + "honesty_conditions": [ + { + "id": "h16", + "text": "The reply is posted and signed, maps all seven listed record fields to their evidence-contract locations, and names the two pending fields together with the milestone that makes them first-class.", + "status": "confirmed", + "instruction": "" + } + ], + "hard_questions": [], + "links": [], + "instruction": "Draft the reply mapping the seven listed fields to their locations in the evidence contract (shell/evidence.py, docs/evidence-contract.md); sign '- shell-cli (Claude)'; post on issue 1; record the two pending fields in the M4 design notes." + }, + { + "id": "c22", + "kind": "boundary", + "text": "docs/threat-model.md already satisfies the 'documented threat model that does not claim to be a sandbox' bar for the host guard, but it is written entirely around the six primitives and a single host path. The two-axis environment model (workspace x runner) means it must gain a per-runner section that keeps the host-guard and container-isolation claims textually separate, per issue #1 \u00a77. [re-captured verbatim from prior frame guarded-local-operations-plane c8, operator decision q1]", + "origin": "user", + "status": "confirmed", + "honesty_conditions": [ + { + "id": "h1", + "text": "docs/threat-model.md gains its per-runner split in the same PR that introduces ContainerRunner, and no single sentence in it covers both the host guard and container isolation. [re-captured verbatim from prior frame h51]", + "status": "confirmed", + "instruction": "" + } + ], + "hard_questions": [], + "links": [], + "instruction": "Write the per-runner threat-model section inside the ContainerRunner PR itself; h1 is the check." + }, + { + "id": "c23", + "kind": "requirement", + "text": "colleague/lint.py:100-102 runs 'ruff check --fix' and 'ruff format' \u2014 it MUTATES tracked source in the target workspace while being classified as a gate. This is the highest-value ambiguity in the inventory, and it contrasts deliberately with tools.py:1213-1219 which goes out of its way to keep run_tests byte-neutral. Under issue #1 \u00a75 lint maps to process.exec profile project, so its mutations must surface in OperationResult's changed-paths effects rather than being invisible because 'it is only a linter'. [re-captured verbatim from prior frame guarded-local-operations-plane c25, operator decision q1]", + "origin": "user", + "status": "confirmed", + "honesty_conditions": [ + { + "id": "h2", + "text": "A lint operation that rewrites files reports those files in OperationResult's changed-paths effects; a test runs a formatter over a deliberately misformatted file and asserts the path appears. [re-captured verbatim from prior frame h13]", + "status": "confirmed", + "instruction": "" + } + ], + "hard_questions": [], + "links": [], + "instruction": "Covered by the M3 arc: lint routes through process.exec profile project and its rewrites land in changed-path effects; h2 names the test." + }, + { + "id": "c24", + "kind": "after_state", + "text": "Every work-affecting local operation is normalized into one Operation, evaluated against an operator policy snapshot, executed in an explicitly selected environment, and returned with structured evidence including an honest completeness marker. colleague imports shell-cli and has no unclassified direct subprocess path; shell-cli never imports colleague. [re-captured verbatim from prior frame guarded-local-operations-plane c29, operator decision q1]", + "origin": "user", + "status": "confirmed", + "honesty_conditions": [ + { + "id": "h3", + "text": "A boundary test proves the negative: colleague has no direct subprocess path outside the permitted module set, and shell-cli's import graph contains no colleague module. [re-captured verbatim from prior frame h17]", + "status": "confirmed", + "instruction": "" + } + ], + "hard_questions": [], + "links": [], + "instruction": "The issue 1 end-state: closes only when M2 and M3 complete - carry it into those arcs' plans as the after-state target, verified by the boundary tests in h3." + }, + { + "id": "c25", + "kind": "success_signal", + "text": "Milestone 3 closes when scripts/colleague_inventory.py reports debt_remaining = 0 against colleague's then-current commit, with --check still exiting 0 \u2014 i.e. every one of the 13 debt-tagged modules routes through shell-cli and no new unclassified spawn path has appeared. Progress is a countdown from 13 that CI publishes on every run, not a subjective judgement at the end. [re-captured verbatim from prior frame guarded-local-operations-plane c40, operator decision q1]", + "origin": "user", + "status": "confirmed", + "honesty_conditions": [ + { + "id": "h4", + "text": "debt_remaining is computed from the committed ALLOWLIST by the scanner, so the countdown cannot be asserted in prose or drift from reality \u2014 it is derived from source every CI run. [re-captured verbatim from prior frame h27]", + "status": "confirmed", + "instruction": "" + } + ], + "hard_questions": [], + "links": [], + "instruction": "Keep the CI debt_remaining publication wired; the M3 arc closes at 0 with --check exit 0 (h4)." + }, + { + "id": "c26", + "kind": "requirement", + "text": "CONCURRENCY: colleague serializes worktree administration behind an advisory exclusive fcntl.flock over a per-repo lock file (worktrees.py:138-170, _ADMIN_LOCK_NAME in the runtime state dir). Issue #1 section 5 moves generic worktree add/remove/prune to shell-cli, so that lock must travel with the mechanism \u2014 otherwise parallel colleague children racing worktree add/remove against a shared repo lose their mutual exclusion the moment the operation is 'just' delegated. Three properties of the existing lock must be preserved deliberately, not reimplemented casually: it degrades rather than raising when fcntl is unavailable (non-POSIX) or the lock file cannot be opened, it never blocks a run that has no lock available, and it is explicitly NOT reentrant (teardown_all calls the already-guarded worktree_remove sequentially, never while holding its own lock). A reentrant-by-accident redesign in shell-cli would deadlock colleague's teardown path. [re-captured verbatim from prior frame guarded-local-operations-plane c47, operator decision q1]", + "origin": "user", + "status": "confirmed", + "honesty_conditions": [ + { + "id": "h5", + "text": "Two concurrent shell-cli worktree operations against the same repo serialize, a non-POSIX host degrades to unlocked rather than crashing, and colleague's teardown_all path completes without deadlock under the new implementation. [re-captured verbatim from prior frame h35]", + "status": "confirmed", + "instruction": "" + } + ], + "hard_questions": [], + "links": [], + "instruction": "Port the flock semantics together with the worktree migration in M3; add the three-property test h5 describes (serialize, degrade, no reentrant deadlock)." + }, + { + "id": "c27", + "kind": "requirement", + "text": "HOOKS NEED A DELIBERATE HOOK-SPECIFIC POLICY \u2014 this is a STANDALONE finding, explicitly NOT part of migration parity. Today hooks are trusted operator configuration that lacks runtime command gating: hooks.py:405-407 runs shell=True on every tool call, hooks are subject only to check_file and never to check_run_command, and hook_approval_verdict returns Verdict(True) when no token resolves to an existing repo file (hooks.py:310-316), so an INLINE hook command is ungated even with a hooks section present. hooks.json is untracked, so the isolation worktree voids the hooks approval section by the same mechanism as c49. The correct remedy is NOT to silently apply run_command's allow/deny rules to hooks \u2014 that would retroactively gate operator-authored configuration under rules written for model-authored commands, changing behaviour operators rely on. Introduce a hook-specific policy surface deliberately, as its own reviewed change with its own migration story. ONE THING TO PRESERVE REGARDLESS: loop.py:953-955 lets a hook rewrite ARGUMENTS ONLY \u2014 call.name passes unchanged to execute() at :966 \u2014 and the policy gate runs after the rewrite. [re-captured verbatim from prior frame guarded-local-operations-plane c60, operator decision q1]", + "origin": "user", + "status": "confirmed", + "honesty_conditions": [ + { + "id": "h6", + "text": "Hook gating ships as its own deliberate change with operator-visible migration notes, and no shell-cli milestone silently subjects existing hooks to run_command's allow/deny lists. The arguments-only rewrite contract and the rewrite-then-gate ordering hold in the extracted implementation. [re-captured verbatim from prior frame h45]", + "status": "confirmed", + "instruction": "" + } + ], + "hard_questions": [], + "links": [], + "instruction": "Design the hook-specific policy surface as its own reviewed change inside M3 planning; preserve the arguments-only rewrite and rewrite-then-gate ordering (h6)." + }, + { + "id": "c28", + "kind": "decision", + "text": "Operator decision q1 (2026-07-28): do not wait for devague issue 85 - the 12 deliberately uncovered prior-frame targets (c8, c25, c29, c40, c47, c60 and honesty conditions h13, h17, h27, h35, h45, h51 of frame guarded-local-operations-plane) are re-captured verbatim as fresh claims in this frame and planned over natively.", + "origin": "user", + "status": "confirmed", + "honesty_conditions": [], + "hard_questions": [], + "links": [], + "instruction": "" + } + ], + "open_vagueness": [], + "scope_entries": [ + { + "id": "s1", + "surface": "agentculture/shell-cli issue 8 (the M1-gap tracker)", + "finding": "Partitions the M1-to-definition-of-done gap into 8 sections: colleague-owned work, advisory-blocked fixtures, milestones M2-M5, 12 deliberately uncovered targets, the missing CLI groups, chosen limits, the open scanner issue, and publish sequencing. Sections 6 and 8 describe settled states, not pending work.", + "seeds": [ + "c2", + "c4", + "c8", + "c9", + "c13", + "c14", + "c15" + ] + }, + { + "id": "s2", + "surface": "agentculture/colleague issue tracker (gh search: shell-cli)", + "finding": "No migration-proposal issue exists; the only relevant open issue is colleague issue 353 (vllm zero-step drives, tool calls emitted as text), which is the already-filed root cause behind ledger deviation d1.", + "seeds": [ + "c2" + ] + }, + { + "id": "s3", + "surface": "README.md Status + axes matrix; CLAUDE.md Current state; shell/explain/catalog.py", + "finding": "README lines 49-54 and 77-81 plus CLAUDE.md lines 64-68 still describe the pre-M1 scaffold; the CLI catalog Status sections were refreshed in the M1 merge but the two markdown surfaces were not - they are the first thing an agent consumer reads.", + "seeds": [ + "c3" + ] + }, + { + "id": "s4", + "surface": "shell/cli/_commands/ directory + CHANGELOG 0.13.0", + "finding": "Eight command modules exist (cli, doctor, explain, learn, operation, overview, policy, whoami); policy check/explain and operation show landed in 0.13.0, but env, fs, process, git do not exist and no CLI verb executes an operation.", + "seeds": [ + "c4" + ] + }, + { + "id": "s5", + "surface": "devague deviate --list output (devague 0.20.1)", + "finding": "Ten deviations d1-d10; nine proposed awaiting operator confirm, d9 approved; d4 corrects d1 from deterministic to intermittent; issue 4 records only d1-d3, so the human-readable record lags the ledger.", + "seeds": [ + "c5" + ] + }, + { + "id": "s6", + "surface": "agentculture/devex issue tracker (gh search: body-file)", + "finding": "21 hits, none matching the pr open --body-file - FileNotFoundError recorded in issue 4; the upstream filing is still owed.", + "seeds": [ + "c6" + ] + }, + { + "id": "s7", + "surface": "agentculture/shell-cli issue 7 (proposed resolution)", + "finding": "Five-item resolution: item 1 (relabel as drift detector) already done in PR 6; items 2-4 (per-site pinning, wider call set, scope-aware bindings) are the unscheduled reviewed slice; item 5 accepts the residual ceiling.", + "seeds": [ + "c7" + ] + }, + { + "id": "s8", + "surface": "prior frame claims c25, c40, c47 in .devague/frames/guarded-local-operations-plane.json + scripts/colleague_inventory.py", + "finding": "M3 closes at debt_remaining 0 against the then-current colleague commit; lint mutates tracked source while classified as a gate; worktree admin serializes behind an advisory fcntl.flock that migration must respect.", + "seeds": [ + "c8" + ] + }, + { + "id": "s9", + "surface": "prior frame claim c8 + docs/threat-model.md", + "finding": "The committed threat model is written entirely around the six-tool host guard, so the per-runner threat-model split must land in the same PR that introduces ContainerRunner, alongside an explicit linked-worktree git-metadata decision.", + "seeds": [ + "c9" + ] + }, + { + "id": "s10", + "surface": "second consumer checkout (operator-disclosed; deliberately unnamed in public artifacts)", + "finding": "An ephemeral computational workspace CLI at a working version: five lifecycle verbs over a six-verb provider protocol, closed-by-default policy with enforced-versus-measured views, digest-pinned profiles, declared-artifact export, bounded nine-section results with retrieval markers; its one runtime dep is a container SDK that shell-cli must not adopt.", + "seeds": [ + "c10", + "c11", + "c12" + ] + }, + { + "id": "s11", + "surface": "pypi.org shell-cli release listing + origin/main", + "finding": "0.13.1 is the latest published release and origin/main is at the M1 merge commit 1f4b616 - the publish half of the sequencing note is done; disclosure timing for the two private advisories is owned by colleague.", + "seeds": [ + "c15" + ] + }, + { + "id": "s12", + "surface": ".github/workflows/tests.yml line 185 + issue 8 section 6", + "finding": "The KNOWN GAP comment is present in CI as documented: the characterization suite's live-colleague tests do not run there; the five section-6 limits are all test-asserted contracts.", + "seeds": [ + "c14" + ] + }, + { + "id": "s13", + "surface": "issue 1 comment thread (external comment, 2026-07-20)", + "finding": "An outside maintainer endorses the intent/lifecycle boundary and lists a minimum durable per-operation record; mapping it against shell/evidence.py shows two fields not yet first-class: emitted artifacts and cleanup/finalization state - both arrive with environment lifecycle operations.", + "seeds": [ + "c17" + ] + }, + { + "id": "s14", + "surface": "tests/test_boundaries.py + the WebGlass peer seam in CLAUDE.md", + "finding": "The import-graph guard covers both colleague and webglass; the peer posture generalizes to any consumer - which is exactly how the unnamed second consumer must be treated.", + "seeds": [ + "c16", + "c11" + ] + }, + { + "id": "s15", + "surface": "issue 8 sections 1-2 + ledger entries d5, d6", + "finding": "t69/t70 are filed as private GitHub security advisories and t74b is deliberately excluded from the captured fixtures; both stay blocked on colleague shipping its fixes, and nothing in this repo can unblock them.", + "seeds": [ + "c13" + ] + } + ] +} diff --git a/docs/specs/2026-07-28-m1-leftovers.md b/docs/specs/2026-07-28-m1-leftovers.md new file mode 100644 index 0000000..e185379 --- /dev/null +++ b/docs/specs/2026-07-28-m1-leftovers.md @@ -0,0 +1,134 @@ +# m1-leftovers + +> Every Milestone 1 leftover is tracked to an owner and a sequence: the gap between M1-done and the issue 1 definition of done is closed, with colleague holding its migration proposal, the deviation ledger adjudicated, guidance surfaces honest again, the scanner ceiling stated and pinned per-site, the remaining CLI groups and milestones planned as their own arcs, and the environment lifecycle shaped to fit every consumer we serve +> instruction: Deliver as reviewed slices: docs refresh, ledger adjudication, cross-repo filings (colleague proposal, devex bug, issue 1 reply), the scanner hardening PR, and separate next-arc plans for M3/M4 and the CLI groups - each slice maps back to a claim here. + +## Audience + +- shell-cli maintainers and its consuming agents: colleague as first consumer, the operator-disclosed unnamed second consumer, and every agent that reads the guidance surfaces (learn, explain, README, CLAUDE.md) before touching the tool. + +## Before → After + +- Before: Milestone 1 is merged and 0.13.1 is on PyPI, but the issue 1 definition of done is blocked: no migration proposal is filed on colleague, README and CLAUDE.md still describe the pre-M1 scaffold, the env/fs/process/git CLI groups do not exist, nine ledger deviations await adjudication, the scanner hardening is unscheduled, and M3-M5 have no plans. +- After: Every work-affecting local operation is normalized into one Operation, evaluated against an operator policy snapshot, executed in an explicitly selected environment, and returned with structured evidence including an honest completeness marker. colleague imports shell-cli and has no unclassified direct subprocess path; shell-cli never imports colleague. [re-captured verbatim from prior frame guarded-local-operations-plane c29, operator decision q1] + - instruction: The issue 1 end-state: closes only when M2 and M3 complete - carry it into those arcs' plans as the after-state target, verified by the boundary tests in h3. + +## Why it matters + +- The guidance surfaces are the first thing an agent consumer reads - stale ones make every consumer mis-model the tool; and leftovers without named owners rot silently into drift between the issue 1 contract and what the repo actually does. + +## Requirements + +- File the colleague migration proposal issue (issue 1 definition of done): colleague composes the shell-cli executor for the six tools without subclassing (t86), runs differential parity then removes its legacy handlers (t87), and allow-lists shell-cli in its tests/test_zero_deps.py. A gh search of agentculture/colleague shows no such issue exists today. Propose, do not push. + - instruction: File via the communicate skill on agentculture/colleague, signed: the t86 composition seam, t87 differential parity then legacy removal, and allow-listing shell-cli in tests/test_zero_deps.py. Propose, do not push. + - honesty: A migration-proposal issue exists on agentculture/colleague describing the t86 composition seam, t87 differential parity then legacy removal, and the test_zero_deps allow-listing - filed as a proposal, with the decision left to colleague. +- Refresh the stale guidance surfaces in one docs slice: README.md lines 49-54 still say 'Scaffold ... No operation, environment, policy, or runner has been built yet' and lines 77-81 say neither runner exists; CLAUDE.md lines 64-68 list t84/t85 as not built and say the CLI exposes only introspection verbs. All false at 0.13.1 - CHANGELOG 0.13.0 shipped process.exec, process.shell, policy check/explain and operation show. The learn/explain catalog was already refreshed; README and CLAUDE.md were missed. + - instruction: One docs-only PR (version bump per repo rule): rewrite README Status and the axes-matrix Built column, and the CLAUDE.md current-state stanza, against the 0.13.1 CHANGELOG; markdownlint and test_honesty must stay green. + - honesty: After the docs slice merges, README's Status section and CLAUDE.md's current-state stanza describe the 0.13.1 surface accurately: operation core, both process kinds, and the policy/operation CLI nouns built; only ContainerRunner, colleague delegation, and the env/fs/process/git CLI groups remain listed as not built. +- Build the remaining canonical CLI noun groups env, fs, process, git from issue 1 section 6: shell/cli/_commands/ holds only cli, doctor, explain, learn, operation, overview, policy, whoami, so no CLI verb yet executes an operation - the library carries all semantics, and each group is a front-end slice over the same engine plus per-slice catalog and doc authoring. + - instruction: One PR per noun group (env, fs, process, git) following the _commands register(sub) pattern; preview-by-default with --apply on mutation/execution verbs; catalog + explain docs authored per slice; teken cli doctor --strict green. + - honesty: Each new noun group fronts the same operation engine the library exposes (no second implementation), mutation and execution verbs preview by default and require --apply, and every registered path resolves in the explain catalog - pinned by test_every_catalog_path_resolves and the teken rubric gate. +- Adjudicate the deviation ledger: devague deviate --list shows d1 through d10 with nine still proposed (only d9 approved), while issue 4 documents only d1-d3 and misses d4, which corrects d1 - the zero-step colleague failure is intermittent, not deterministic. Operator confirms the ledger; the public record gets updated to match. + - instruction: Operator runs devague deviate --confirm for the nine proposed entries after review, then a comment lands on issue 4 documenting d4-d10 so the public record matches the ledger. + - honesty: devague deviate --list shows no entry still in proposed state, and issue 4 gains a comment recording d4 through d10 so the public record matches the ledger. +- File the devex bug upstream: devex pr open --body-file - raises an unhandled FileNotFoundError instead of reading stdin (recorded at the tail of issue 4, worked around with a temp file); a gh search of agentculture/devex finds no matching issue, so it is still unfiled. + - instruction: gh issue create on agentculture/devex quoting the open_.py line 22 traceback and the no-tracebacks convention; sign per posting convention. + - honesty: An issue exists on agentculture/devex reproducing pr open --body-file - with the FileNotFoundError traceback and citing the no-tracebacks convention. +- Schedule the issue 7 scanner slice as its own reviewed PR: per-site pinning of spawn-site counts per module (the one architectural fix), widening the detected-call set with module-prefix resolution, and scope-aware bindings that drop the unbound-base false positives. The honest relabeling (item 1) already landed; the stated ceiling stays: detects accidental and careless drift from a pinned baseline. + - instruction: Single reviewed PR on scripts/colleague_inventory.py: per-site pinning of spawn-site counts, module-prefix call resolution, scope-aware bindings; regenerate the pinned baseline at SHA 28fee29; add the issue 7 evasion and false-positive fixtures as tests. + - honesty: A reviewed PR pins per-module spawn-site counts so that a new spawn added to an already-allow-listed module fails --check (proven by fixture), widens the detected-call set with module-prefix resolution, and clears the six false-positive fixtures from issue 7. +- Milestone 3 drives the colleague known-debt allow-list from 13 modules to 0 (prior-frame c40): project execution (run_tests, lint, hooks) and control execution (git, worktrees, AgentCulture CLIs) route through shell-cli; lint ruff --fix mutations must surface as changed-path effects (prior-frame c25/h13) and worktree administration must respect the existing per-repo fcntl.flock serialization (prior-frame c47/h35). + - instruction: Plan Milestone 3 as its own devague arc over the re-captured targets (c23, c25, c26, c27) plus the 13-module debt allow-list; no M3 implementation inside this leftovers arc. + - honesty: debt_remaining is derived from the committed ALLOWLIST on every CI run, lint mutations appear as changed-path effects, worktree serialization holds under the migrated mechanism, and Milestone 3 closes only at 0 with --check exiting 0 against colleague's then-current commit. +- Milestone 4 ships ContainerRunner with the per-runner threat-model split in the same PR (prior-frame c8: docs/threat-model.md is written entirely around the six-tool host guard today) and decides the linked-worktree git-metadata question explicitly - never by mounting the host common .git writable. + - instruction: Plan Milestone 4 as its own arc; the PR introducing ContainerRunner carries the per-runner threat-model split (c22) and the linked-worktree git-metadata decision; its design review includes the second-consumer fit (c10). + - honesty: The PR that introduces ContainerRunner contains the per-runner threat-model split with no sentence covering both guarantees, and records the linked-worktree git-metadata decision explicitly; the host common .git is never mounted writable. +- The environment-lifecycle design (env create and destroy, ContainerRunner, evidence) must fit a second consumer now in progress: an ephemeral computational workspace CLI where a model offloads execution and calculation and receives compact evidence-bearing results. Needs read from its checkout: workspace lifecycle verbs over a provider seam (capabilities, create, run, inspect, read, remove), closed-by-default network, digest-pinned runtime profiles, enforced-versus-measured resource honesty, declared-artifact export with digest verification, bounded renderings with retrieval markers. + - instruction: During the M4 design review, map the six provider verbs onto operations and environment lifecycle on paper before code; verify pyproject dependencies remains empty. + - honesty: The Milestone 4 environment-lifecycle design review demonstrates the second consumer's six-verb provider protocol is implementable over shell-cli environments without naming the consumer and without adding any runtime dependency to pyproject. +- Post a signed reply on issue 1 mapping the external minimum-record list to the evidence contract, and fold the two not-yet-first-class fields (emitted artifacts, cleanup/finalization state) into the Milestone 4 environment-lifecycle design review. (Operator decision q2, 2026-07-28.) + - instruction: Draft the reply mapping the seven listed fields to their locations in the evidence contract (shell/evidence.py, docs/evidence-contract.md); sign '- shell-cli (Claude)'; post on issue 1; record the two pending fields in the M4 design notes. + - honesty: The reply is posted and signed, maps all seven listed record fields to their evidence-contract locations, and names the two pending fields together with the milestone that makes them first-class. +- colleague/lint.py:100-102 runs 'ruff check --fix' and 'ruff format' — it MUTATES tracked source in the target workspace while being classified as a gate. This is the highest-value ambiguity in the inventory, and it contrasts deliberately with tools.py:1213-1219 which goes out of its way to keep run_tests byte-neutral. Under issue #1 §5 lint maps to process.exec profile project, so its mutations must surface in OperationResult's changed-paths effects rather than being invisible because 'it is only a linter'. [re-captured verbatim from prior frame guarded-local-operations-plane c25, operator decision q1] + - instruction: Covered by the M3 arc: lint routes through process.exec profile project and its rewrites land in changed-path effects; h2 names the test. + - honesty: A lint operation that rewrites files reports those files in OperationResult's changed-paths effects; a test runs a formatter over a deliberately misformatted file and asserts the path appears. [re-captured verbatim from prior frame h13] +- CONCURRENCY: colleague serializes worktree administration behind an advisory exclusive fcntl.flock over a per-repo lock file (worktrees.py:138-170, _ADMIN_LOCK_NAME in the runtime state dir). Issue #1 section 5 moves generic worktree add/remove/prune to shell-cli, so that lock must travel with the mechanism — otherwise parallel colleague children racing worktree add/remove against a shared repo lose their mutual exclusion the moment the operation is 'just' delegated. Three properties of the existing lock must be preserved deliberately, not reimplemented casually: it degrades rather than raising when fcntl is unavailable (non-POSIX) or the lock file cannot be opened, it never blocks a run that has no lock available, and it is explicitly NOT reentrant (teardown_all calls the already-guarded worktree_remove sequentially, never while holding its own lock). A reentrant-by-accident redesign in shell-cli would deadlock colleague's teardown path. [re-captured verbatim from prior frame guarded-local-operations-plane c47, operator decision q1] + - instruction: Port the flock semantics together with the worktree migration in M3; add the three-property test h5 describes (serialize, degrade, no reentrant deadlock). + - honesty: Two concurrent shell-cli worktree operations against the same repo serialize, a non-POSIX host degrades to unlocked rather than crashing, and colleague's teardown_all path completes without deadlock under the new implementation. [re-captured verbatim from prior frame h35] +- HOOKS NEED A DELIBERATE HOOK-SPECIFIC POLICY — this is a STANDALONE finding, explicitly NOT part of migration parity. Today hooks are trusted operator configuration that lacks runtime command gating: hooks.py:405-407 runs shell=True on every tool call, hooks are subject only to check_file and never to check_run_command, and hook_approval_verdict returns Verdict(True) when no token resolves to an existing repo file (hooks.py:310-316), so an INLINE hook command is ungated even with a hooks section present. hooks.json is untracked, so the isolation worktree voids the hooks approval section by the same mechanism as c49. The correct remedy is NOT to silently apply run_command's allow/deny rules to hooks — that would retroactively gate operator-authored configuration under rules written for model-authored commands, changing behaviour operators rely on. Introduce a hook-specific policy surface deliberately, as its own reviewed change with its own migration story. ONE THING TO PRESERVE REGARDLESS: loop.py:953-955 lets a hook rewrite ARGUMENTS ONLY — call.name passes unchanged to execute() at :966 — and the policy gate runs after the rewrite. [re-captured verbatim from prior frame guarded-local-operations-plane c60, operator decision q1] + - instruction: Design the hook-specific policy surface as its own reviewed change inside M3 planning; preserve the arguments-only rewrite and rewrite-then-gate ordering (h6). + - honesty: Hook gating ships as its own deliberate change with operator-visible migration notes, and no shell-cli milestone silently subjects existing hooks to run_command's allow/deny lists. The arguments-only rewrite contract and the rewrite-then-gate ordering hold in the extracted implementation. [re-captured verbatim from prior frame h45] + +## Honesty conditions + +- Every section of issue 8 is either delivered by a claim in this frame, explicitly re-owned by another repo, or recorded as a chosen limit; no issue 1 definition-of-done item is left unmapped. +- A search of the public shell-cli surfaces - repo files, issues, the exported spec, and committed .devague state - finds no occurrence of the second consumer's name; the boundary import guard stays green. +- pyproject.toml dependencies stays an empty list and tests/test_zero_deps.py keeps failing on any non-stdlib import in the core; ContainerRunner invokes docker or podman only as PATH executables via subprocess. +- t74b fixtures are captured only after a colleague release containing both advisory fixes ships, and no characterization fixture in this repo encodes pre-hardening composition semantics. +- The five limits remain asserted by their existing tests, and no PR in this arc alters those tests or the documented contracts they pin. +- Each audience member has a surface this arc improves for them: colleague gets the migration proposal, the second consumer gets the M4 fit review, and agent readers get accurate README, CLAUDE.md, and learn output. +- Each blocked item in the before-state is a reproducible read recorded in scope entries s1-s15: the gh searches, the stale line numbers, the missing _commands modules, and the nine proposed ledger entries. +- The staleness is not hypothetical: README lines 49-54 asserted no operation core exists while 0.13.1 had shipped it - a consumer reading main was actively misinformed when this frame was drafted. +- docs/threat-model.md gains its per-runner split in the same PR that introduces ContainerRunner, and no single sentence in it covers both the host guard and container isolation. [re-captured verbatim from prior frame h51] +- A boundary test proves the negative: colleague has no direct subprocess path outside the permitted module set, and shell-cli's import graph contains no colleague module. [re-captured verbatim from prior frame h17] +- debt_remaining is computed from the committed ALLOWLIST by the scanner, so the countdown cannot be asserted in prose or drift from reality — it is derived from source every CI run. [re-captured verbatim from prior frame h27] + +## Success signals + +- Milestone 3 closes when scripts/colleague_inventory.py reports debt_remaining = 0 against colleague's then-current commit, with --check still exiting 0 — i.e. every one of the 13 debt-tagged modules routes through shell-cli and no new unclassified spawn path has appeared. Progress is a countdown from 13 that CI publishes on every run, not a subjective judgement at the end. [re-captured verbatim from prior frame guarded-local-operations-plane c40, operator decision q1] + - instruction: Keep the CI debt_remaining publication wired; the M3 arc closes at 0 with --check exit 0 (h4). + +## Scope / boundaries + +- Public shell-cli surfaces - issues, docs, specs, and this committed frame - do not name the second consumer, per operator instruction; its needs are recorded generically. shell-cli never imports it and it never appears in signatures; artifacts cross as plain files, the same peer posture the WebGlass seam already documents. +- Fitting the second consumer must not add a container-SDK dependency: that consumer pins a docker SDK itself, but the shell-cli core stays pure-stdlib (pyproject dependencies list is empty) and ContainerRunner drives docker or podman as PATH executables via subprocess. +- t69 and t70 stay colleague-owned, filed privately as GHSA-6pfg-9vcp-hqw4 and GHSA-2m42-prxw-43w6 (ledger d5); the t74b policy-composition characterization fixtures stay blocked until the fixed colleague release ships (ledger d6) - characterization must never encode pre-hardening composition as the parity target. +- The five known limits in issue 8 section 6 - undeclared secrets recorded verbatim, the freeze escape, dual integrity digests, three-valued applied, and the 8 characterization tests outside CI behind the KNOWN GAP marker at tests.yml line 185 - are chosen contracts asserted by tests; this leftovers arc must not fix them. +- docs/threat-model.md already satisfies the 'documented threat model that does not claim to be a sandbox' bar for the host guard, but it is written entirely around the six primitives and a single host path. The two-axis environment model (workspace x runner) means it must gain a per-runner section that keeps the host-guard and container-isolation claims textually separate, per issue #1 §7. [re-captured verbatim from prior frame guarded-local-operations-plane c8, operator decision q1] + - instruction: Write the per-runner threat-model section inside the ContainerRunner PR itself; h1 is the check. + +## Non-goals + +- Milestone 5 (webglass-cli as a peer provider) stays planned in its own repository - no task in this leftovers arc delivers it; tests/test_boundaries.py keeps the import graph clean meanwhile. + +## Assumptions + +- The issue 8 section 8 sequencing decision is already executed: PyPI serves 0.13.1 (containing the gate-bypass fix) and origin/main sits at the Milestone 1 merge 1f4b616; what remains of section 8 is colleague-side advisory disclosure timing, which is not a shell-cli push decision. +- The external minimum-record list posted on issue 1 (requested action, resolved targets, approval scope, environment identity, execution result, emitted artifacts, cleanup and finalization state) is already carried by the evidence contract except emitted artifacts and cleanup/finalization state, which become first-class only when environment lifecycle operations land. + +## Scope exploration + +- `s1` — `agentculture/shell-cli issue 8 (the M1-gap tracker)`: Partitions the M1-to-definition-of-done gap into 8 sections: colleague-owned work, advisory-blocked fixtures, milestones M2-M5, 12 deliberately uncovered targets, the missing CLI groups, chosen limits, the open scanner issue, and publish sequencing. Sections 6 and 8 describe settled states, not pending work. + - seeds: `c2`, `c4`, `c8`, `c9`, `c13`, `c14`, `c15` +- `s2` — `agentculture/colleague issue tracker (gh search: shell-cli)`: No migration-proposal issue exists; the only relevant open issue is colleague issue 353 (vllm zero-step drives, tool calls emitted as text), which is the already-filed root cause behind ledger deviation d1. + - seeds: `c2` +- `s3` — `README.md Status + axes matrix; CLAUDE.md Current state; shell/explain/catalog.py`: README lines 49-54 and 77-81 plus CLAUDE.md lines 64-68 still describe the pre-M1 scaffold; the CLI catalog Status sections were refreshed in the M1 merge but the two markdown surfaces were not - they are the first thing an agent consumer reads. + - seeds: `c3` +- `s4` — `shell/cli/_commands/ directory + CHANGELOG 0.13.0`: Eight command modules exist (cli, doctor, explain, learn, operation, overview, policy, whoami); policy check/explain and operation show landed in 0.13.0, but env, fs, process, git do not exist and no CLI verb executes an operation. + - seeds: `c4` +- `s5` — `devague deviate --list output (devague 0.20.1)`: Ten deviations d1-d10; nine proposed awaiting operator confirm, d9 approved; d4 corrects d1 from deterministic to intermittent; issue 4 records only d1-d3, so the human-readable record lags the ledger. + - seeds: `c5` +- `s6` — `agentculture/devex issue tracker (gh search: body-file)`: 21 hits, none matching the pr open --body-file - FileNotFoundError recorded in issue 4; the upstream filing is still owed. + - seeds: `c6` +- `s7` — `agentculture/shell-cli issue 7 (proposed resolution)`: Five-item resolution: item 1 (relabel as drift detector) already done in PR 6; items 2-4 (per-site pinning, wider call set, scope-aware bindings) are the unscheduled reviewed slice; item 5 accepts the residual ceiling. + - seeds: `c7` +- `s8` — `prior frame claims c25, c40, c47 in .devague/frames/guarded-local-operations-plane.json + scripts/colleague_inventory.py`: M3 closes at debt_remaining 0 against the then-current colleague commit; lint mutates tracked source while classified as a gate; worktree admin serializes behind an advisory fcntl.flock that migration must respect. + - seeds: `c8` +- `s9` — `prior frame claim c8 + docs/threat-model.md`: The committed threat model is written entirely around the six-tool host guard, so the per-runner threat-model split must land in the same PR that introduces ContainerRunner, alongside an explicit linked-worktree git-metadata decision. + - seeds: `c9` +- `s10` — `second consumer checkout (operator-disclosed; deliberately unnamed in public artifacts)`: An ephemeral computational workspace CLI at a working version: five lifecycle verbs over a six-verb provider protocol, closed-by-default policy with enforced-versus-measured views, digest-pinned profiles, declared-artifact export, bounded nine-section results with retrieval markers; its one runtime dep is a container SDK that shell-cli must not adopt. + - seeds: `c10`, `c11`, `c12` +- `s11` — `pypi.org shell-cli release listing + origin/main`: 0.13.1 is the latest published release and origin/main is at the M1 merge commit 1f4b616 - the publish half of the sequencing note is done; disclosure timing for the two private advisories is owned by colleague. + - seeds: `c15` +- `s12` — `.github/workflows/tests.yml line 185 + issue 8 section 6`: The KNOWN GAP comment is present in CI as documented: the characterization suite's live-colleague tests do not run there; the five section-6 limits are all test-asserted contracts. + - seeds: `c14` +- `s13` — `issue 1 comment thread (external comment, 2026-07-20)`: An outside maintainer endorses the intent/lifecycle boundary and lists a minimum durable per-operation record; mapping it against shell/evidence.py shows two fields not yet first-class: emitted artifacts and cleanup/finalization state - both arrive with environment lifecycle operations. + - seeds: `c17` +- `s14` — `tests/test_boundaries.py + the WebGlass peer seam in CLAUDE.md`: The import-graph guard covers both colleague and webglass; the peer posture generalizes to any consumer - which is exactly how the unnamed second consumer must be treated. + - seeds: `c16`, `c11` +- `s15` — `issue 8 sections 1-2 + ledger entries d5, d6`: t69/t70 are filed as private GitHub security advisories and t74b is deliberately excluded from the captured fixtures; both stay blocked on colleague shipping its fixes, and nothing in this repo can unblock them. + - seeds: `c13` + +## Decisions + +- Operator decision q1 (2026-07-28): do not wait for devague issue 85 - the 12 deliberately uncovered prior-frame targets (c8, c25, c29, c40, c47, c60 and honesty conditions h13, h17, h27, h35, h45, h51 of frame guarded-local-operations-plane) are re-captured verbatim as fresh claims in this frame and planned over natively. From 9a6fcc98f494f6bf068aecbe3bbe07361a5fce72 Mon Sep 17 00:00:00 2001 From: Ori Nachum Date: Tue, 28 Jul 2026 09:57:05 +0300 Subject: [PATCH 02/20] challenge: rigorous pass over the m1-leftovers frame Three c19 escalation signals (security-sensitive work, hard-to-reverse publishes/posts, planned concurrency work) made the rigorous form mandatory. Findings adjudicated by the operator: tracker hygiene (c29), benign-argv fixture boundary for the scanner slice (c30), re-verify the second consumer's seam at M4 (c31). One non-blocking park: merge-order vs version-bump collisions, to mirror as a plan risk. Nine lens/surface scope entries (s16-s24) record what was examined, including the clean lenses; probes verified the 12 re-captures byte-identical and no consumer-name leak in any committed artifact. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01EkC4zsVUmYrRCwSWSe4aLp --- .devague/frames/m1-leftovers.json | 131 +++++++++++++++++++++++++- docs/specs/2026-07-28-m1-leftovers.md | 22 +++++ 2 files changed, 151 insertions(+), 2 deletions(-) diff --git a/.devague/frames/m1-leftovers.json b/.devague/frames/m1-leftovers.json index bb135a9..71ac4fb 100644 --- a/.devague/frames/m1-leftovers.json +++ b/.devague/frames/m1-leftovers.json @@ -4,7 +4,7 @@ "schema_version": 3, "status": "exported", "created": "2026-07-28T06:30:12Z", - "updated": "2026-07-28T06:48:29Z", + "updated": "2026-07-28T06:56:54Z", "claims": [ { "id": "c1", @@ -481,9 +481,66 @@ "hard_questions": [], "links": [], "instruction": "" + }, + { + "id": "c29", + "kind": "requirement", + "text": "Tracker hygiene: issues 4, 7, and 8 each close when their remaining content is delivered or re-owned; a section that moves to another arc gets a pointer comment before this arc ends, and the fate of the colleague migration proposal is tracked from issue 8 rather than assumed.", + "origin": "llm", + "status": "confirmed", + "honesty_conditions": [ + { + "id": "h25", + "text": "At arc end, issues 4, 7, and 8 are each closed or carry a current pointer comment naming where their remaining content lives; none is silently stale.", + "status": "confirmed", + "instruction": "" + } + ], + "hard_questions": [], + "links": [], + "instruction": "" + }, + { + "id": "c30", + "kind": "boundary", + "text": "The scanner slice's evasion and false-positive fixtures spawn only benign argv (true, echo) in scratch space; no committed fixture reproduces the dangerous illustrative payloads from issue 7 (rm -rf, /etc/shadow reads), and no fixture requires network access.", + "origin": "llm", + "status": "confirmed", + "honesty_conditions": [ + { + "id": "h26", + "text": "Review of the scanner-slice PR verifies every fixture argv against the benign list, and grep of the fixture tree finds none of the dangerous example payloads from issue 7.", + "status": "confirmed", + "instruction": "" + } + ], + "hard_questions": [], + "links": [], + "instruction": "" + }, + { + "id": "c31", + "kind": "assumption", + "text": "The second consumer's provider seam is as read at its current checkout; because it is in progress, the Milestone 4 design review re-verifies its needs against its then-current state before freezing the environment API.", + "origin": "llm", + "status": "confirmed", + "honesty_conditions": [], + "hard_questions": [], + "links": [], + "instruction": "" + } + ], + "open_vagueness": [ + { + "id": "v1", + "text": "Whether this arc's PR slices must merge strictly serially again (publish-on-merge plus version-bump collisions, cf. deviations d3/d8) or can parallelize with merge-time bumps - decide when /spec-to-plan sequences the waves; mirror as a plan risk.", + "kind": "unknown_nonblocking", + "claim_id": null, + "resolved": false, + "resolution": "", + "resolution_claim_id": null } ], - "open_vagueness": [], "scope_entries": [ { "id": "s1", @@ -613,6 +670,76 @@ "seeds": [ "c13" ] + }, + { + "id": "s16", + "surface": "challenge pass / adjacent-systems lens: pyproject.toml readme field + the live PyPI 0.13.1 page", + "finding": "readme = README.md makes the stale Scaffold README the live PyPI long description today; the docs slice's mandatory version bump republishes it, so no work beyond c3 is needed - but the staleness is public until that slice merges.", + "seeds": [ + "c3" + ] + }, + { + "id": "s17", + "surface": "challenge pass / adjacent-systems lens: AGENTS.colleague.md", + "finding": "Pattern probe (not built / introspection / scaffold / not yet) finds no stale current-state text; only README and CLAUDE.md drifted, so the c3 slice's file list is complete. Probe was grep-based, not a full read.", + "seeds": [ + "c3" + ] + }, + { + "id": "s18", + "surface": "challenge pass / counter-evidence lens: prior frame JSON vs m1-leftovers frame JSON", + "finding": "All 12 re-captures verified byte-identical to the prior frame after stripping the provenance tag - the verbatim claim in c22-c27/h1-h6 is now checked, not asserted.", + "seeds": [ + "c28" + ] + }, + { + "id": "s19", + "surface": "challenge pass / security-containment lens: committed spec, frame JSON, and the dcee1f7 diff", + "finding": "Zero occurrences of the second consumer's name in any committed artifact; h18 holds at the commit under challenge.", + "seeds": [ + "c11" + ] + }, + { + "id": "s20", + "surface": "challenge pass / operations lens: markdownlint-cli2 config vs docs/specs", + "finding": "The lint gate excludes docs/specs and docs/plans by configuration, so machine re-exports of the spec cannot break CI lint; no claim needed.", + "seeds": [] + }, + { + "id": "s21", + "surface": "challenge pass / reversibility lens: docs/release-runbook.md + PyPI release listing", + "finding": "Yank-and-fix-forward is documented, and the intermediate versions 0.11.0-0.13.0 were never published (single push at arc end) - counter-evidence checked, c15's sequencing-executed assumption survives.", + "seeds": [ + "c15" + ] + }, + { + "id": "s22", + "surface": "challenge pass / overlooked-lifecycle lens: issues 4, 7, 8 as living trackers", + "finding": "The frame delivered their content but never said who closes them or watches the colleague proposal's fate; seeded the tracker-hygiene requirement.", + "seeds": [ + "c29" + ] + }, + { + "id": "s23", + "surface": "challenge pass / security lens: issue 7 fixtures as future CI tests", + "finding": "The adversarial evasions each created a real process; promoting them to committed fixtures needs an explicit benign-argv boundary so CI never reproduces the dangerous examples.", + "seeds": [ + "c30" + ] + }, + { + "id": "s24", + "surface": "challenge pass / unstated-assumption lens: the second consumer's in-progress seam", + "finding": "The frame froze its needs from one read of a moving checkout; seeded the re-verify-at-M4 assumption.", + "seeds": [ + "c31" + ] } ] } diff --git a/docs/specs/2026-07-28-m1-leftovers.md b/docs/specs/2026-07-28-m1-leftovers.md index e185379..33d162e 100644 --- a/docs/specs/2026-07-28-m1-leftovers.md +++ b/docs/specs/2026-07-28-m1-leftovers.md @@ -58,6 +58,8 @@ - HOOKS NEED A DELIBERATE HOOK-SPECIFIC POLICY — this is a STANDALONE finding, explicitly NOT part of migration parity. Today hooks are trusted operator configuration that lacks runtime command gating: hooks.py:405-407 runs shell=True on every tool call, hooks are subject only to check_file and never to check_run_command, and hook_approval_verdict returns Verdict(True) when no token resolves to an existing repo file (hooks.py:310-316), so an INLINE hook command is ungated even with a hooks section present. hooks.json is untracked, so the isolation worktree voids the hooks approval section by the same mechanism as c49. The correct remedy is NOT to silently apply run_command's allow/deny rules to hooks — that would retroactively gate operator-authored configuration under rules written for model-authored commands, changing behaviour operators rely on. Introduce a hook-specific policy surface deliberately, as its own reviewed change with its own migration story. ONE THING TO PRESERVE REGARDLESS: loop.py:953-955 lets a hook rewrite ARGUMENTS ONLY — call.name passes unchanged to execute() at :966 — and the policy gate runs after the rewrite. [re-captured verbatim from prior frame guarded-local-operations-plane c60, operator decision q1] - instruction: Design the hook-specific policy surface as its own reviewed change inside M3 planning; preserve the arguments-only rewrite and rewrite-then-gate ordering (h6). - honesty: Hook gating ships as its own deliberate change with operator-visible migration notes, and no shell-cli milestone silently subjects existing hooks to run_command's allow/deny lists. The arguments-only rewrite contract and the rewrite-then-gate ordering hold in the extracted implementation. [re-captured verbatim from prior frame h45] +- Tracker hygiene: issues 4, 7, and 8 each close when their remaining content is delivered or re-owned; a section that moves to another arc gets a pointer comment before this arc ends, and the fate of the colleague migration proposal is tracked from issue 8 rather than assumed. + - honesty: At arc end, issues 4, 7, and 8 are each closed or carry a current pointer comment naming where their remaining content lives; none is silently stale. ## Honesty conditions @@ -72,6 +74,7 @@ - docs/threat-model.md gains its per-runner split in the same PR that introduces ContainerRunner, and no single sentence in it covers both the host guard and container isolation. [re-captured verbatim from prior frame h51] - A boundary test proves the negative: colleague has no direct subprocess path outside the permitted module set, and shell-cli's import graph contains no colleague module. [re-captured verbatim from prior frame h17] - debt_remaining is computed from the committed ALLOWLIST by the scanner, so the countdown cannot be asserted in prose or drift from reality — it is derived from source every CI run. [re-captured verbatim from prior frame h27] +- Review of the scanner-slice PR verifies every fixture argv against the benign list, and grep of the fixture tree finds none of the dangerous example payloads from issue 7. ## Success signals @@ -86,6 +89,7 @@ - The five known limits in issue 8 section 6 - undeclared secrets recorded verbatim, the freeze escape, dual integrity digests, three-valued applied, and the 8 characterization tests outside CI behind the KNOWN GAP marker at tests.yml line 185 - are chosen contracts asserted by tests; this leftovers arc must not fix them. - docs/threat-model.md already satisfies the 'documented threat model that does not claim to be a sandbox' bar for the host guard, but it is written entirely around the six primitives and a single host path. The two-axis environment model (workspace x runner) means it must gain a per-runner section that keeps the host-guard and container-isolation claims textually separate, per issue #1 §7. [re-captured verbatim from prior frame guarded-local-operations-plane c8, operator decision q1] - instruction: Write the per-runner threat-model section inside the ContainerRunner PR itself; h1 is the check. +- The scanner slice's evasion and false-positive fixtures spawn only benign argv (true, echo) in scratch space; no committed fixture reproduces the dangerous illustrative payloads from issue 7 (rm -rf, /etc/shadow reads), and no fixture requires network access. ## Non-goals @@ -95,6 +99,7 @@ - The issue 8 section 8 sequencing decision is already executed: PyPI serves 0.13.1 (containing the gate-bypass fix) and origin/main sits at the Milestone 1 merge 1f4b616; what remains of section 8 is colleague-side advisory disclosure timing, which is not a shell-cli push decision. - The external minimum-record list posted on issue 1 (requested action, resolved targets, approval scope, environment identity, execution result, emitted artifacts, cleanup and finalization state) is already carried by the evidence contract except emitted artifacts and cleanup/finalization state, which become first-class only when environment lifecycle operations land. +- The second consumer's provider seam is as read at its current checkout; because it is in progress, the Milestone 4 design review re-verifies its needs against its then-current state before freezing the environment API. ## Scope exploration @@ -128,6 +133,23 @@ - seeds: `c16`, `c11` - `s15` — `issue 8 sections 1-2 + ledger entries d5, d6`: t69/t70 are filed as private GitHub security advisories and t74b is deliberately excluded from the captured fixtures; both stay blocked on colleague shipping its fixes, and nothing in this repo can unblock them. - seeds: `c13` +- `s16` — `challenge pass / adjacent-systems lens: pyproject.toml readme field + the live PyPI 0.13.1 page`: readme = README.md makes the stale Scaffold README the live PyPI long description today; the docs slice's mandatory version bump republishes it, so no work beyond c3 is needed - but the staleness is public until that slice merges. + - seeds: `c3` +- `s17` — `challenge pass / adjacent-systems lens: AGENTS.colleague.md`: Pattern probe (not built / introspection / scaffold / not yet) finds no stale current-state text; only README and CLAUDE.md drifted, so the c3 slice's file list is complete. Probe was grep-based, not a full read. + - seeds: `c3` +- `s18` — `challenge pass / counter-evidence lens: prior frame JSON vs m1-leftovers frame JSON`: All 12 re-captures verified byte-identical to the prior frame after stripping the provenance tag - the verbatim claim in c22-c27/h1-h6 is now checked, not asserted. + - seeds: `c28` +- `s19` — `challenge pass / security-containment lens: committed spec, frame JSON, and the dcee1f7 diff`: Zero occurrences of the second consumer's name in any committed artifact; h18 holds at the commit under challenge. + - seeds: `c11` +- `s20` — `challenge pass / operations lens: markdownlint-cli2 config vs docs/specs`: The lint gate excludes docs/specs and docs/plans by configuration, so machine re-exports of the spec cannot break CI lint; no claim needed. +- `s21` — `challenge pass / reversibility lens: docs/release-runbook.md + PyPI release listing`: Yank-and-fix-forward is documented, and the intermediate versions 0.11.0-0.13.0 were never published (single push at arc end) - counter-evidence checked, c15's sequencing-executed assumption survives. + - seeds: `c15` +- `s22` — `challenge pass / overlooked-lifecycle lens: issues 4, 7, 8 as living trackers`: The frame delivered their content but never said who closes them or watches the colleague proposal's fate; seeded the tracker-hygiene requirement. + - seeds: `c29` +- `s23` — `challenge pass / security lens: issue 7 fixtures as future CI tests`: The adversarial evasions each created a real process; promoting them to committed fixtures needs an explicit benign-argv boundary so CI never reproduces the dangerous examples. + - seeds: `c30` +- `s24` — `challenge pass / unstated-assumption lens: the second consumer's in-progress seam`: The frame froze its needs from one read of a moving checkout; seeded the re-verify-at-M4 assumption. + - seeds: `c31` ## Decisions From 9cfd080287263079fec60d3666bb7bd4819b1253 Mon Sep 17 00:00:00 2001 From: Ori Nachum Date: Tue, 28 Jul 2026 10:10:05 +0300 Subject: [PATCH 03/20] plan: m1-leftovers (devague /spec-to-plan) 13 confirmed tasks covering all 54 targets of the challenged frame, with per-task operator instructions, acceptance criteria, and an honest dependency graph (waves: 9-wide opening, then the shared-file CLI chain, boundary verification, close-out). Four first-class risks, including the d3/d8 merge-order mirror of park v1. Frame amendment c32/h27 records that git and env-lifecycle CLI verbs ride their M3/M4 engines; this arc ships fs, process, and env describe. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01EkC4zsVUmYrRCwSWSe4aLp --- .devague/current_plan | 2 +- .devague/frames/m1-leftovers.json | 20 +- .devague/plans/m1-leftovers.json | 567 ++++++++++++++++++++++++++ docs/plans/2026-07-28-m1-leftovers.md | 111 +++++ docs/specs/2026-07-28-m1-leftovers.md | 2 + 5 files changed, 700 insertions(+), 2 deletions(-) create mode 100644 .devague/plans/m1-leftovers.json create mode 100644 docs/plans/2026-07-28-m1-leftovers.md diff --git a/.devague/current_plan b/.devague/current_plan index 0236707..278e8ba 100644 --- a/.devague/current_plan +++ b/.devague/current_plan @@ -1 +1 @@ -guarded-local-operations-plane +m1-leftovers diff --git a/.devague/frames/m1-leftovers.json b/.devague/frames/m1-leftovers.json index 71ac4fb..4660b46 100644 --- a/.devague/frames/m1-leftovers.json +++ b/.devague/frames/m1-leftovers.json @@ -4,7 +4,7 @@ "schema_version": 3, "status": "exported", "created": "2026-07-28T06:30:12Z", - "updated": "2026-07-28T06:56:54Z", + "updated": "2026-07-28T07:06:38Z", "claims": [ { "id": "c1", @@ -528,6 +528,24 @@ "hard_questions": [], "links": [], "instruction": "" + }, + { + "id": "c32", + "kind": "boundary", + "text": "This arc's CLI slices ship only what the library already backs: the fs group (adding a confined fs.stat handler), the process group, and env describe. The git noun group and env create/destroy have no engine yet - the operation registry holds only fs.* and process.* kinds and shell/git/ does not exist - so those verbs land inside the M3 (git/worktree engine) and M4 (environment lifecycle engine) arcs, never as CLI-only facades ahead of their semantics.", + "origin": "llm", + "status": "confirmed", + "honesty_conditions": [ + { + "id": "h27", + "text": "At arc end, shell fs, shell process, and shell env describe exist and front execute(); shell git and env create/destroy do not exist, and the exported M3/M4 plans name them as deliverables riding their engines.", + "status": "confirmed", + "instruction": "" + } + ], + "hard_questions": [], + "links": [], + "instruction": "" } ], "open_vagueness": [ diff --git a/.devague/plans/m1-leftovers.json b/.devague/plans/m1-leftovers.json new file mode 100644 index 0000000..0e21f49 --- /dev/null +++ b/.devague/plans/m1-leftovers.json @@ -0,0 +1,567 @@ +{ + "slug": "m1-leftovers", + "title": "m1-leftovers", + "frame_slug": "m1-leftovers", + "schema_version": 3, + "status": "exported", + "created": "2026-07-28T07:04:00Z", + "updated": "2026-07-28T07:09:45Z", + "targets": [ + { + "id": "c1", + "kind": "announcement", + "text": "Every Milestone 1 leftover is tracked to an owner and a sequence: the gap between M1-done and the issue 1 definition of done is closed, with colleague holding its migration proposal, the deviation ledger adjudicated, guidance surfaces honest again, the scanner ceiling stated and pinned per-site, the remaining CLI groups and milestones planned as their own arcs, and the environment lifecycle shaped to fit every consumer we serve" + }, + { + "id": "h17", + "kind": "honesty", + "text": "Every section of issue 8 is either delivered by a claim in this frame, explicitly re-owned by another repo, or recorded as a chosen limit; no issue 1 definition-of-done item is left unmapped." + }, + { + "id": "c2", + "kind": "requirement", + "text": "File the colleague migration proposal issue (issue 1 definition of done): colleague composes the shell-cli executor for the six tools without subclassing (t86), runs differential parity then removes its legacy handlers (t87), and allow-lists shell-cli in its tests/test_zero_deps.py. A gh search of agentculture/colleague shows no such issue exists today. Propose, do not push." + }, + { + "id": "h7", + "kind": "honesty", + "text": "A migration-proposal issue exists on agentculture/colleague describing the t86 composition seam, t87 differential parity then legacy removal, and the test_zero_deps allow-listing - filed as a proposal, with the decision left to colleague." + }, + { + "id": "c3", + "kind": "requirement", + "text": "Refresh the stale guidance surfaces in one docs slice: README.md lines 49-54 still say 'Scaffold ... No operation, environment, policy, or runner has been built yet' and lines 77-81 say neither runner exists; CLAUDE.md lines 64-68 list t84/t85 as not built and say the CLI exposes only introspection verbs. All false at 0.13.1 - CHANGELOG 0.13.0 shipped process.exec, process.shell, policy check/explain and operation show. The learn/explain catalog was already refreshed; README and CLAUDE.md were missed." + }, + { + "id": "h8", + "kind": "honesty", + "text": "After the docs slice merges, README's Status section and CLAUDE.md's current-state stanza describe the 0.13.1 surface accurately: operation core, both process kinds, and the policy/operation CLI nouns built; only ContainerRunner, colleague delegation, and the env/fs/process/git CLI groups remain listed as not built." + }, + { + "id": "c4", + "kind": "requirement", + "text": "Build the remaining canonical CLI noun groups env, fs, process, git from issue 1 section 6: shell/cli/_commands/ holds only cli, doctor, explain, learn, operation, overview, policy, whoami, so no CLI verb yet executes an operation - the library carries all semantics, and each group is a front-end slice over the same engine plus per-slice catalog and doc authoring." + }, + { + "id": "h9", + "kind": "honesty", + "text": "Each new noun group fronts the same operation engine the library exposes (no second implementation), mutation and execution verbs preview by default and require --apply, and every registered path resolves in the explain catalog - pinned by test_every_catalog_path_resolves and the teken rubric gate." + }, + { + "id": "c5", + "kind": "requirement", + "text": "Adjudicate the deviation ledger: devague deviate --list shows d1 through d10 with nine still proposed (only d9 approved), while issue 4 documents only d1-d3 and misses d4, which corrects d1 - the zero-step colleague failure is intermittent, not deterministic. Operator confirms the ledger; the public record gets updated to match." + }, + { + "id": "h10", + "kind": "honesty", + "text": "devague deviate --list shows no entry still in proposed state, and issue 4 gains a comment recording d4 through d10 so the public record matches the ledger." + }, + { + "id": "c6", + "kind": "requirement", + "text": "File the devex bug upstream: devex pr open --body-file - raises an unhandled FileNotFoundError instead of reading stdin (recorded at the tail of issue 4, worked around with a temp file); a gh search of agentculture/devex finds no matching issue, so it is still unfiled." + }, + { + "id": "h11", + "kind": "honesty", + "text": "An issue exists on agentculture/devex reproducing pr open --body-file - with the FileNotFoundError traceback and citing the no-tracebacks convention." + }, + { + "id": "c7", + "kind": "requirement", + "text": "Schedule the issue 7 scanner slice as its own reviewed PR: per-site pinning of spawn-site counts per module (the one architectural fix), widening the detected-call set with module-prefix resolution, and scope-aware bindings that drop the unbound-base false positives. The honest relabeling (item 1) already landed; the stated ceiling stays: detects accidental and careless drift from a pinned baseline." + }, + { + "id": "h12", + "kind": "honesty", + "text": "A reviewed PR pins per-module spawn-site counts so that a new spawn added to an already-allow-listed module fails --check (proven by fixture), widens the detected-call set with module-prefix resolution, and clears the six false-positive fixtures from issue 7." + }, + { + "id": "c8", + "kind": "requirement", + "text": "Milestone 3 drives the colleague known-debt allow-list from 13 modules to 0 (prior-frame c40): project execution (run_tests, lint, hooks) and control execution (git, worktrees, AgentCulture CLIs) route through shell-cli; lint ruff --fix mutations must surface as changed-path effects (prior-frame c25/h13) and worktree administration must respect the existing per-repo fcntl.flock serialization (prior-frame c47/h35)." + }, + { + "id": "h13", + "kind": "honesty", + "text": "debt_remaining is derived from the committed ALLOWLIST on every CI run, lint mutations appear as changed-path effects, worktree serialization holds under the migrated mechanism, and Milestone 3 closes only at 0 with --check exiting 0 against colleague's then-current commit." + }, + { + "id": "c9", + "kind": "requirement", + "text": "Milestone 4 ships ContainerRunner with the per-runner threat-model split in the same PR (prior-frame c8: docs/threat-model.md is written entirely around the six-tool host guard today) and decides the linked-worktree git-metadata question explicitly - never by mounting the host common .git writable." + }, + { + "id": "h14", + "kind": "honesty", + "text": "The PR that introduces ContainerRunner contains the per-runner threat-model split with no sentence covering both guarantees, and records the linked-worktree git-metadata decision explicitly; the host common .git is never mounted writable." + }, + { + "id": "c10", + "kind": "requirement", + "text": "The environment-lifecycle design (env create and destroy, ContainerRunner, evidence) must fit a second consumer now in progress: an ephemeral computational workspace CLI where a model offloads execution and calculation and receives compact evidence-bearing results. Needs read from its checkout: workspace lifecycle verbs over a provider seam (capabilities, create, run, inspect, read, remove), closed-by-default network, digest-pinned runtime profiles, enforced-versus-measured resource honesty, declared-artifact export with digest verification, bounded renderings with retrieval markers." + }, + { + "id": "h15", + "kind": "honesty", + "text": "The Milestone 4 environment-lifecycle design review demonstrates the second consumer's six-verb provider protocol is implementable over shell-cli environments without naming the consumer and without adding any runtime dependency to pyproject." + }, + { + "id": "c11", + "kind": "boundary", + "text": "Public shell-cli surfaces - issues, docs, specs, and this committed frame - do not name the second consumer, per operator instruction; its needs are recorded generically. shell-cli never imports it and it never appears in signatures; artifacts cross as plain files, the same peer posture the WebGlass seam already documents." + }, + { + "id": "h18", + "kind": "honesty", + "text": "A search of the public shell-cli surfaces - repo files, issues, the exported spec, and committed .devague state - finds no occurrence of the second consumer's name; the boundary import guard stays green." + }, + { + "id": "c12", + "kind": "boundary", + "text": "Fitting the second consumer must not add a container-SDK dependency: that consumer pins a docker SDK itself, but the shell-cli core stays pure-stdlib (pyproject dependencies list is empty) and ContainerRunner drives docker or podman as PATH executables via subprocess." + }, + { + "id": "h19", + "kind": "honesty", + "text": "pyproject.toml dependencies stays an empty list and tests/test_zero_deps.py keeps failing on any non-stdlib import in the core; ContainerRunner invokes docker or podman only as PATH executables via subprocess." + }, + { + "id": "c13", + "kind": "boundary", + "text": "t69 and t70 stay colleague-owned, filed privately as GHSA-6pfg-9vcp-hqw4 and GHSA-2m42-prxw-43w6 (ledger d5); the t74b policy-composition characterization fixtures stay blocked until the fixed colleague release ships (ledger d6) - characterization must never encode pre-hardening composition as the parity target." + }, + { + "id": "h20", + "kind": "honesty", + "text": "t74b fixtures are captured only after a colleague release containing both advisory fixes ships, and no characterization fixture in this repo encodes pre-hardening composition semantics." + }, + { + "id": "c14", + "kind": "boundary", + "text": "The five known limits in issue 8 section 6 - undeclared secrets recorded verbatim, the freeze escape, dual integrity digests, three-valued applied, and the 8 characterization tests outside CI behind the KNOWN GAP marker at tests.yml line 185 - are chosen contracts asserted by tests; this leftovers arc must not fix them." + }, + { + "id": "h21", + "kind": "honesty", + "text": "The five limits remain asserted by their existing tests, and no PR in this arc alters those tests or the documented contracts they pin." + }, + { + "id": "c18", + "kind": "audience", + "text": "shell-cli maintainers and its consuming agents: colleague as first consumer, the operator-disclosed unnamed second consumer, and every agent that reads the guidance surfaces (learn, explain, README, CLAUDE.md) before touching the tool." + }, + { + "id": "h22", + "kind": "honesty", + "text": "Each audience member has a surface this arc improves for them: colleague gets the migration proposal, the second consumer gets the M4 fit review, and agent readers get accurate README, CLAUDE.md, and learn output." + }, + { + "id": "c19", + "kind": "before_state", + "text": "Milestone 1 is merged and 0.13.1 is on PyPI, but the issue 1 definition of done is blocked: no migration proposal is filed on colleague, README and CLAUDE.md still describe the pre-M1 scaffold, the env/fs/process/git CLI groups do not exist, nine ledger deviations await adjudication, the scanner hardening is unscheduled, and M3-M5 have no plans." + }, + { + "id": "h23", + "kind": "honesty", + "text": "Each blocked item in the before-state is a reproducible read recorded in scope entries s1-s15: the gh searches, the stale line numbers, the missing _commands modules, and the nine proposed ledger entries." + }, + { + "id": "c20", + "kind": "why_it_matters", + "text": "The guidance surfaces are the first thing an agent consumer reads - stale ones make every consumer mis-model the tool; and leftovers without named owners rot silently into drift between the issue 1 contract and what the repo actually does." + }, + { + "id": "h24", + "kind": "honesty", + "text": "The staleness is not hypothetical: README lines 49-54 asserted no operation core exists while 0.13.1 had shipped it - a consumer reading main was actively misinformed when this frame was drafted." + }, + { + "id": "c21", + "kind": "requirement", + "text": "Post a signed reply on issue 1 mapping the external minimum-record list to the evidence contract, and fold the two not-yet-first-class fields (emitted artifacts, cleanup/finalization state) into the Milestone 4 environment-lifecycle design review. (Operator decision q2, 2026-07-28.)" + }, + { + "id": "h16", + "kind": "honesty", + "text": "The reply is posted and signed, maps all seven listed record fields to their evidence-contract locations, and names the two pending fields together with the milestone that makes them first-class." + }, + { + "id": "c22", + "kind": "boundary", + "text": "docs/threat-model.md already satisfies the 'documented threat model that does not claim to be a sandbox' bar for the host guard, but it is written entirely around the six primitives and a single host path. The two-axis environment model (workspace x runner) means it must gain a per-runner section that keeps the host-guard and container-isolation claims textually separate, per issue #1 \u00a77. [re-captured verbatim from prior frame guarded-local-operations-plane c8, operator decision q1]" + }, + { + "id": "h1", + "kind": "honesty", + "text": "docs/threat-model.md gains its per-runner split in the same PR that introduces ContainerRunner, and no single sentence in it covers both the host guard and container isolation. [re-captured verbatim from prior frame h51]" + }, + { + "id": "c23", + "kind": "requirement", + "text": "colleague/lint.py:100-102 runs 'ruff check --fix' and 'ruff format' \u2014 it MUTATES tracked source in the target workspace while being classified as a gate. This is the highest-value ambiguity in the inventory, and it contrasts deliberately with tools.py:1213-1219 which goes out of its way to keep run_tests byte-neutral. Under issue #1 \u00a75 lint maps to process.exec profile project, so its mutations must surface in OperationResult's changed-paths effects rather than being invisible because 'it is only a linter'. [re-captured verbatim from prior frame guarded-local-operations-plane c25, operator decision q1]" + }, + { + "id": "h2", + "kind": "honesty", + "text": "A lint operation that rewrites files reports those files in OperationResult's changed-paths effects; a test runs a formatter over a deliberately misformatted file and asserts the path appears. [re-captured verbatim from prior frame h13]" + }, + { + "id": "c24", + "kind": "after_state", + "text": "Every work-affecting local operation is normalized into one Operation, evaluated against an operator policy snapshot, executed in an explicitly selected environment, and returned with structured evidence including an honest completeness marker. colleague imports shell-cli and has no unclassified direct subprocess path; shell-cli never imports colleague. [re-captured verbatim from prior frame guarded-local-operations-plane c29, operator decision q1]" + }, + { + "id": "h3", + "kind": "honesty", + "text": "A boundary test proves the negative: colleague has no direct subprocess path outside the permitted module set, and shell-cli's import graph contains no colleague module. [re-captured verbatim from prior frame h17]" + }, + { + "id": "c25", + "kind": "success_signal", + "text": "Milestone 3 closes when scripts/colleague_inventory.py reports debt_remaining = 0 against colleague's then-current commit, with --check still exiting 0 \u2014 i.e. every one of the 13 debt-tagged modules routes through shell-cli and no new unclassified spawn path has appeared. Progress is a countdown from 13 that CI publishes on every run, not a subjective judgement at the end. [re-captured verbatim from prior frame guarded-local-operations-plane c40, operator decision q1]" + }, + { + "id": "h4", + "kind": "honesty", + "text": "debt_remaining is computed from the committed ALLOWLIST by the scanner, so the countdown cannot be asserted in prose or drift from reality \u2014 it is derived from source every CI run. [re-captured verbatim from prior frame h27]" + }, + { + "id": "c26", + "kind": "requirement", + "text": "CONCURRENCY: colleague serializes worktree administration behind an advisory exclusive fcntl.flock over a per-repo lock file (worktrees.py:138-170, _ADMIN_LOCK_NAME in the runtime state dir). Issue #1 section 5 moves generic worktree add/remove/prune to shell-cli, so that lock must travel with the mechanism \u2014 otherwise parallel colleague children racing worktree add/remove against a shared repo lose their mutual exclusion the moment the operation is 'just' delegated. Three properties of the existing lock must be preserved deliberately, not reimplemented casually: it degrades rather than raising when fcntl is unavailable (non-POSIX) or the lock file cannot be opened, it never blocks a run that has no lock available, and it is explicitly NOT reentrant (teardown_all calls the already-guarded worktree_remove sequentially, never while holding its own lock). A reentrant-by-accident redesign in shell-cli would deadlock colleague's teardown path. [re-captured verbatim from prior frame guarded-local-operations-plane c47, operator decision q1]" + }, + { + "id": "h5", + "kind": "honesty", + "text": "Two concurrent shell-cli worktree operations against the same repo serialize, a non-POSIX host degrades to unlocked rather than crashing, and colleague's teardown_all path completes without deadlock under the new implementation. [re-captured verbatim from prior frame h35]" + }, + { + "id": "c27", + "kind": "requirement", + "text": "HOOKS NEED A DELIBERATE HOOK-SPECIFIC POLICY \u2014 this is a STANDALONE finding, explicitly NOT part of migration parity. Today hooks are trusted operator configuration that lacks runtime command gating: hooks.py:405-407 runs shell=True on every tool call, hooks are subject only to check_file and never to check_run_command, and hook_approval_verdict returns Verdict(True) when no token resolves to an existing repo file (hooks.py:310-316), so an INLINE hook command is ungated even with a hooks section present. hooks.json is untracked, so the isolation worktree voids the hooks approval section by the same mechanism as c49. The correct remedy is NOT to silently apply run_command's allow/deny rules to hooks \u2014 that would retroactively gate operator-authored configuration under rules written for model-authored commands, changing behaviour operators rely on. Introduce a hook-specific policy surface deliberately, as its own reviewed change with its own migration story. ONE THING TO PRESERVE REGARDLESS: loop.py:953-955 lets a hook rewrite ARGUMENTS ONLY \u2014 call.name passes unchanged to execute() at :966 \u2014 and the policy gate runs after the rewrite. [re-captured verbatim from prior frame guarded-local-operations-plane c60, operator decision q1]" + }, + { + "id": "h6", + "kind": "honesty", + "text": "Hook gating ships as its own deliberate change with operator-visible migration notes, and no shell-cli milestone silently subjects existing hooks to run_command's allow/deny lists. The arguments-only rewrite contract and the rewrite-then-gate ordering hold in the extracted implementation. [re-captured verbatim from prior frame h45]" + }, + { + "id": "c29", + "kind": "requirement", + "text": "Tracker hygiene: issues 4, 7, and 8 each close when their remaining content is delivered or re-owned; a section that moves to another arc gets a pointer comment before this arc ends, and the fate of the colleague migration proposal is tracked from issue 8 rather than assumed." + }, + { + "id": "h25", + "kind": "honesty", + "text": "At arc end, issues 4, 7, and 8 are each closed or carry a current pointer comment naming where their remaining content lives; none is silently stale." + }, + { + "id": "c30", + "kind": "boundary", + "text": "The scanner slice's evasion and false-positive fixtures spawn only benign argv (true, echo) in scratch space; no committed fixture reproduces the dangerous illustrative payloads from issue 7 (rm -rf, /etc/shadow reads), and no fixture requires network access." + }, + { + "id": "h26", + "kind": "honesty", + "text": "Review of the scanner-slice PR verifies every fixture argv against the benign list, and grep of the fixture tree finds none of the dangerous example payloads from issue 7." + }, + { + "id": "c32", + "kind": "boundary", + "text": "This arc's CLI slices ship only what the library already backs: the fs group (adding a confined fs.stat handler), the process group, and env describe. The git noun group and env create/destroy have no engine yet - the operation registry holds only fs.* and process.* kinds and shell/git/ does not exist - so those verbs land inside the M3 (git/worktree engine) and M4 (environment lifecycle engine) arcs, never as CLI-only facades ahead of their semantics." + }, + { + "id": "h27", + "kind": "honesty", + "text": "At arc end, shell fs, shell process, and shell env describe exist and front execute(); shell git and env create/destroy do not exist, and the exported M3/M4 plans name them as deliverables riding their engines." + } + ], + "tasks": [ + { + "id": "t1", + "summary": "Docs-honesty refresh: README + CLAUDE.md against the 0.13.1 surface", + "origin": "llm", + "status": "confirmed", + "acceptance_criteria": [ + "README Status and the axes matrix describe the shipped operation core and HostRunner; CLAUDE.md's not-built list contains only ContainerRunner, colleague delegation, and the missing CLI groups; the stale lines quoted in the before-state no longer appear anywhere", + "markdownlint and tests/test_honesty.py pass; the version is bumped so the next publish replaces the stale PyPI long description" + ], + "deps": [], + "covers": [ + "c3", + "h8", + "c19", + "h23", + "c20", + "h24" + ], + "instruction": "One docs-only PR via the cicd skill; version-bump patch; quote the CHANGELOG 0.13.0/0.13.1 entries as the source of truth for what is built." + }, + { + "id": "t2", + "summary": "File the colleague migration proposal issue (propose, don't push)", + "origin": "llm", + "status": "confirmed", + "acceptance_criteria": [ + "An issue exists on agentculture/colleague describing t86 composition without subclassing, t87 differential parity then legacy-handler removal, and allow-listing shell-cli in tests/test_zero_deps.py; signed per convention; no accompanying PR" + ], + "deps": [], + "covers": [ + "c2", + "h7" + ], + "instruction": "Use the communicate skill (auto-signs '- shell-cli (Claude)'); the body cites colleague's tools.py handler seam, the six-schema byte-equivalence contract, and pins the tested shell-cli floor; link issue 8 section 1." + }, + { + "id": "t3", + "summary": "Reply on issue 1: map the minimum-record list to the evidence contract", + "origin": "llm", + "status": "confirmed", + "acceptance_criteria": [ + "A signed reply maps all seven listed fields to their locations in shell/evidence.py and docs/evidence-contract.md, and names emitted artifacts + cleanup/finalization state as arriving with environment lifecycle; both fields are recorded in the M4 design notes" + ], + "deps": [], + "covers": [ + "c21", + "h16" + ], + "instruction": "Draft from shell/evidence.py and docs/evidence-contract.md field names; sign '- shell-cli (Claude)'; post via gh; record the two pending fields in the M4 design notes." + }, + { + "id": "t4", + "summary": "File the devex --body-file stdin bug upstream", + "origin": "llm", + "status": "confirmed", + "acceptance_criteria": [ + "An issue on agentculture/devex reproduces pr open --body-file - with the FileNotFoundError traceback at open_.py line 22 and cites the no-tracebacks convention; signed" + ], + "deps": [], + "covers": [ + "c6", + "h11" + ], + "instruction": "gh issue create on agentculture/devex with the repro command, the traceback at open_.py line 22, and the cicd SKILL.md sentence documenting stdin support; sign '- shell-cli (Claude)'." + }, + { + "id": "t5", + "summary": "Adjudicate the deviation ledger and update issue 4", + "origin": "llm", + "status": "confirmed", + "acceptance_criteria": [ + "devague deviate --list shows zero entries still proposed after operator review; issue 4 carries a comment documenting d4-d10, including d4's correction of d1 to intermittent" + ], + "deps": [], + "covers": [ + "c5", + "h10" + ], + "instruction": "Operator reviews each dN before --confirm; the issue 4 comment quotes d4's correction of d1 verbatim." + }, + { + "id": "t6", + "summary": "Scanner hardening PR: per-site pinning, wider call set, scope-aware bindings", + "origin": "llm", + "status": "confirmed", + "acceptance_criteria": [ + "A new spawn added to an already-allow-listed module makes --check exit non-zero, proven by fixture; the six false-positive fixtures from issue 7 pass clean; the baseline is regenerated at SHA 28fee29", + "Every committed fixture argv is benign (true/echo); grep of the fixture tree finds none of issue 7's dangerous example payloads; no fixture requires network" + ], + "deps": [], + "covers": [ + "c7", + "h12", + "c30", + "h26" + ], + "instruction": "Branch from main; regenerate the pinned baseline at SHA 28fee29; fixtures live under tests/ with benign argv only; full lint plus bandit suite green; version-bump patch." + }, + { + "id": "t7", + "summary": "CLI fs noun group: read, list, stat, write, edit, media", + "origin": "llm", + "status": "confirmed", + "acceptance_criteria": [ + "shell fs verbs front the registered fs.* operations through the same execute() pipeline, adding a confined fs.stat handler; write and edit preview by default and require --apply; every registered path resolves in the explain catalog and teken cli doctor --strict stays green" + ], + "deps": [], + "covers": [ + "c4", + "h9" + ], + "instruction": "Follow the _commands register(sub) scaffold with parser_class=_CliArgumentParser; add fs.stat as a confined observe handler in shell/fs/; catalog entry per verb; version-bump minor." + }, + { + "id": "t8", + "summary": "CLI process noun group: exec, shell", + "origin": "llm", + "status": "confirmed", + "acceptance_criteria": [ + "shell process exec and shell process shell front the process.* kinds through execute(); both preview by default and require --apply; the CLI refuses profile control for process.shell exactly as the library does; catalog and rubric gates green" + ], + "deps": [ + "t7" + ], + "covers": [ + "c4", + "h9" + ], + "instruction": "Same scaffold conventions; both verbs preview by default and require --apply; refuse profile control for process.shell with a CliError, matching library semantics." + }, + { + "id": "t9", + "summary": "CLI env noun group: describe only (lifecycle verbs ride their engines)", + "origin": "llm", + "status": "confirmed", + "acceptance_criteria": [ + "shell env describe renders the two-axis environment structurally in text and --json; create and destroy are absent and documented as arriving with their engines; catalog and rubric gates green" + ], + "deps": [ + "t8" + ], + "covers": [ + "c4", + "h9", + "c32", + "h27" + ], + "instruction": "env describe renders the Environment defaults; the catalog entry text states the create/destroy deferral so explain output carries the c32 boundary." + }, + { + "id": "t10", + "summary": "Plan the Milestone 3 arc (scope, think, challenge over the re-captured targets)", + "origin": "llm", + "status": "confirmed", + "acceptance_criteria": [ + "A converged M3 frame and plan exist covering the debt countdown 13-to-0, lint changed-path effects with the h2 test, the worktree flock's three preserved properties, the hook-specific policy surface with its preserved rewrite-then-gate contract, and the git noun group riding the git/worktree engine; no M3 implementation lands in this arc" + ], + "deps": [], + "covers": [ + "c8", + "h13", + "c23", + "h2", + "c25", + "h4", + "c26", + "h5", + "c27", + "h6", + "c24", + "h3", + "c32" + ], + "instruction": "Run the full scope-think-challenge-plan arc for M3 in this repo, seeded from issue 8 section 3 and the re-captured targets; include the git noun group riding the git/worktree engine." + }, + { + "id": "t11", + "summary": "Plan the Milestone 4 arc + environment-lifecycle design review", + "origin": "llm", + "status": "confirmed", + "acceptance_criteria": [ + "A design review maps the second consumer's six-verb provider seam onto environment lifecycle operations without naming it and confirms pyproject dependencies stays empty; the M4 plan places the per-runner threat-model split inside the ContainerRunner PR and records the linked-worktree git-metadata decision, never mounting the host common .git writable; env create/destroy CLI verbs are scheduled with the runner" + ], + "deps": [], + "covers": [ + "c9", + "h14", + "c10", + "h15", + "c22", + "h1" + ], + "instruction": "Same arc for M4; the design review is a committed doc mapping the provider seam generically; re-read the second consumer's checkout at review time per frame c31." + }, + { + "id": "t12", + "summary": "Arc-wide boundary verification", + "origin": "llm", + "status": "confirmed", + "acceptance_criteria": [ + "grep across every arc deliverable (PR text, filed issues, spec and plan artifacts) finds no occurrence of the second consumer's name; pyproject dependencies is an empty list and test_zero_deps is green; no t74b fixture exists in the repo; the five chosen-limit tests are byte-unchanged by this arc" + ], + "deps": [ + "t1", + "t2", + "t3", + "t6", + "t9", + "t11" + ], + "covers": [ + "c11", + "h18", + "c12", + "h19", + "c13", + "h20", + "c14", + "h21" + ], + "instruction": "Script the checks: consumer-name grep across deliverables, pyproject dependencies empty, no t74b fixture present, git diff clean on the five chosen-limit test files; attach output to the close-out." + }, + { + "id": "t13", + "summary": "Close the loop: tracker hygiene + arc record", + "origin": "llm", + "status": "confirmed", + "acceptance_criteria": [ + "Issues 4, 7, and 8 are each closed or carry a current pointer comment naming where their remaining content lives; the colleague proposal's fate is linked from issue 8; every issue-8 section maps to a delivered slice, a filed issue, or a recorded decision; each audience member's improvement is checkable" + ], + "deps": [ + "t2", + "t3", + "t4", + "t5", + "t6", + "t10", + "t11", + "t12" + ], + "covers": [ + "c29", + "h25", + "c1", + "h17", + "c18", + "h22" + ], + "instruction": "Close or pointer-comment issues 4, 7, and 8 via gh; link the colleague proposal from issue 8; the summarize-delivery skill produces the arc record." + } + ], + "risks": [ + { + "id": "r1", + "text": "Merge ordering: every merge to main publishes to PyPI and every PR bumps the version, so parallel slices collide on the version field (cf. deviations d3/d8). Default to serial merges with merge-time bumps unless the operator decides otherwise. Mirrors frame park v1.", + "kind": "unknown_nonblocking", + "task_id": null, + "resolved": false, + "resolution": "" + }, + { + "id": "r2", + "text": "colleague may not act on the migration proposal on this arc's timeline; M2 (t86/t87) is colleague's to schedule - this plan only files and tracks it.", + "kind": "unknown_nonblocking", + "task_id": "t2", + "resolved": false, + "resolution": "" + }, + { + "id": "r3", + "text": "t74b policy-composition fixtures and advisory disclosure timing stay colleague-owned (ledger d5/d6); nothing in this plan schedules them.", + "kind": "out_of_scope", + "task_id": "t12", + "resolved": false, + "resolution": "" + }, + { + "id": "r4", + "text": "The git noun group and env create/destroy CLI verbs follow their engines into the M3/M4 arcs - see the c32 scope boundary on the frame.", + "kind": "follow_up", + "task_id": "t10", + "resolved": false, + "resolution": "" + } + ] +} diff --git a/docs/plans/2026-07-28-m1-leftovers.md b/docs/plans/2026-07-28-m1-leftovers.md new file mode 100644 index 0000000..cf891d3 --- /dev/null +++ b/docs/plans/2026-07-28-m1-leftovers.md @@ -0,0 +1,111 @@ +# Build Plan — m1-leftovers + +slug: `m1-leftovers` · status: `exported` · from frame: `m1-leftovers` + +> Every Milestone 1 leftover is tracked to an owner and a sequence: the gap between M1-done and the issue 1 definition of done is closed, with colleague holding its migration proposal, the deviation ledger adjudicated, guidance surfaces honest again, the scanner ceiling stated and pinned per-site, the remaining CLI groups and milestones planned as their own arcs, and the environment lifecycle shaped to fit every consumer we serve + +## Tasks + +### t1 — Docs-honesty refresh: README + CLAUDE.md against the 0.13.1 surface + +- instruction: One docs-only PR via the cicd skill; version-bump patch; quote the CHANGELOG 0.13.0/0.13.1 entries as the source of truth for what is built. +- covers: c3, h8, c19, h23, c20, h24 +- acceptance: + - README Status and the axes matrix describe the shipped operation core and HostRunner; CLAUDE.md's not-built list contains only ContainerRunner, colleague delegation, and the missing CLI groups; the stale lines quoted in the before-state no longer appear anywhere + - markdownlint and tests/test_honesty.py pass; the version is bumped so the next publish replaces the stale PyPI long description + +### t2 — File the colleague migration proposal issue (propose, don't push) + +- instruction: Use the communicate skill (auto-signs '- shell-cli (Claude)'); the body cites colleague's tools.py handler seam, the six-schema byte-equivalence contract, and pins the tested shell-cli floor; link issue 8 section 1. +- covers: c2, h7 +- acceptance: + - An issue exists on agentculture/colleague describing t86 composition without subclassing, t87 differential parity then legacy-handler removal, and allow-listing shell-cli in tests/test_zero_deps.py; signed per convention; no accompanying PR + +### t3 — Reply on issue 1: map the minimum-record list to the evidence contract + +- instruction: Draft from shell/evidence.py and docs/evidence-contract.md field names; sign '- shell-cli (Claude)'; post via gh; record the two pending fields in the M4 design notes. +- covers: c21, h16 +- acceptance: + - A signed reply maps all seven listed fields to their locations in shell/evidence.py and docs/evidence-contract.md, and names emitted artifacts + cleanup/finalization state as arriving with environment lifecycle; both fields are recorded in the M4 design notes + +### t4 — File the devex --body-file stdin bug upstream + +- instruction: gh issue create on agentculture/devex with the repro command, the traceback at open_.py line 22, and the cicd SKILL.md sentence documenting stdin support; sign '- shell-cli (Claude)'. +- covers: c6, h11 +- acceptance: + - An issue on agentculture/devex reproduces pr open --body-file - with the FileNotFoundError traceback at open_.py line 22 and cites the no-tracebacks convention; signed + +### t5 — Adjudicate the deviation ledger and update issue 4 + +- instruction: Operator reviews each dN before --confirm; the issue 4 comment quotes d4's correction of d1 verbatim. +- covers: c5, h10 +- acceptance: + - devague deviate --list shows zero entries still proposed after operator review; issue 4 carries a comment documenting d4-d10, including d4's correction of d1 to intermittent + +### t6 — Scanner hardening PR: per-site pinning, wider call set, scope-aware bindings + +- instruction: Branch from main; regenerate the pinned baseline at SHA 28fee29; fixtures live under tests/ with benign argv only; full lint plus bandit suite green; version-bump patch. +- covers: c7, h12, c30, h26 +- acceptance: + - A new spawn added to an already-allow-listed module makes --check exit non-zero, proven by fixture; the six false-positive fixtures from issue 7 pass clean; the baseline is regenerated at SHA 28fee29 + - Every committed fixture argv is benign (true/echo); grep of the fixture tree finds none of issue 7's dangerous example payloads; no fixture requires network + +### t7 — CLI fs noun group: read, list, stat, write, edit, media + +- instruction: Follow the _commands register(sub) scaffold with parser_class=_CliArgumentParser; add fs.stat as a confined observe handler in shell/fs/; catalog entry per verb; version-bump minor. +- covers: c4, h9 +- acceptance: + - shell fs verbs front the registered fs.* operations through the same execute() pipeline, adding a confined fs.stat handler; write and edit preview by default and require --apply; every registered path resolves in the explain catalog and teken cli doctor --strict stays green + +### t8 — CLI process noun group: exec, shell + +- instruction: Same scaffold conventions; both verbs preview by default and require --apply; refuse profile control for process.shell with a CliError, matching library semantics. +- depends on: t7 +- covers: c4, h9 +- acceptance: + - shell process exec and shell process shell front the process.* kinds through execute(); both preview by default and require --apply; the CLI refuses profile control for process.shell exactly as the library does; catalog and rubric gates green + +### t9 — CLI env noun group: describe only (lifecycle verbs ride their engines) + +- instruction: env describe renders the Environment defaults; the catalog entry text states the create/destroy deferral so explain output carries the c32 boundary. +- depends on: t8 +- covers: c4, h9, c32, h27 +- acceptance: + - shell env describe renders the two-axis environment structurally in text and --json; create and destroy are absent and documented as arriving with their engines; catalog and rubric gates green + +### t10 — Plan the Milestone 3 arc (scope, think, challenge over the re-captured targets) + +- instruction: Run the full scope-think-challenge-plan arc for M3 in this repo, seeded from issue 8 section 3 and the re-captured targets; include the git noun group riding the git/worktree engine. +- covers: c8, h13, c23, h2, c25, h4, c26, h5, c27, h6, c24, h3, c32 +- acceptance: + - A converged M3 frame and plan exist covering the debt countdown 13-to-0, lint changed-path effects with the h2 test, the worktree flock's three preserved properties, the hook-specific policy surface with its preserved rewrite-then-gate contract, and the git noun group riding the git/worktree engine; no M3 implementation lands in this arc + +### t11 — Plan the Milestone 4 arc + environment-lifecycle design review + +- instruction: Same arc for M4; the design review is a committed doc mapping the provider seam generically; re-read the second consumer's checkout at review time per frame c31. +- covers: c9, h14, c10, h15, c22, h1 +- acceptance: + - A design review maps the second consumer's six-verb provider seam onto environment lifecycle operations without naming it and confirms pyproject dependencies stays empty; the M4 plan places the per-runner threat-model split inside the ContainerRunner PR and records the linked-worktree git-metadata decision, never mounting the host common .git writable; env create/destroy CLI verbs are scheduled with the runner + +### t12 — Arc-wide boundary verification + +- instruction: Script the checks: consumer-name grep across deliverables, pyproject dependencies empty, no t74b fixture present, git diff clean on the five chosen-limit test files; attach output to the close-out. +- depends on: t1, t2, t3, t6, t9, t11 +- covers: c11, h18, c12, h19, c13, h20, c14, h21 +- acceptance: + - grep across every arc deliverable (PR text, filed issues, spec and plan artifacts) finds no occurrence of the second consumer's name; pyproject dependencies is an empty list and test_zero_deps is green; no t74b fixture exists in the repo; the five chosen-limit tests are byte-unchanged by this arc + +### t13 — Close the loop: tracker hygiene + arc record + +- instruction: Close or pointer-comment issues 4, 7, and 8 via gh; link the colleague proposal from issue 8; the summarize-delivery skill produces the arc record. +- depends on: t2, t3, t4, t5, t6, t10, t11, t12 +- covers: c29, h25, c1, h17, c18, h22 +- acceptance: + - Issues 4, 7, and 8 are each closed or carry a current pointer comment naming where their remaining content lives; the colleague proposal's fate is linked from issue 8; every issue-8 section maps to a delivered slice, a filed issue, or a recorded decision; each audience member's improvement is checkable + +## Risks + +- [unknown_nonblocking] Merge ordering: every merge to main publishes to PyPI and every PR bumps the version, so parallel slices collide on the version field (cf. deviations d3/d8). Default to serial merges with merge-time bumps unless the operator decides otherwise. Mirrors frame park v1. +- [unknown_nonblocking] colleague may not act on the migration proposal on this arc's timeline; M2 (t86/t87) is colleague's to schedule - this plan only files and tracks it. (task t2) +- [out_of_scope] t74b policy-composition fixtures and advisory disclosure timing stay colleague-owned (ledger d5/d6); nothing in this plan schedules them. (task t12) +- [follow_up] The git noun group and env create/destroy CLI verbs follow their engines into the M3/M4 arcs - see the c32 scope boundary on the frame. (task t10) diff --git a/docs/specs/2026-07-28-m1-leftovers.md b/docs/specs/2026-07-28-m1-leftovers.md index 33d162e..2ea2875 100644 --- a/docs/specs/2026-07-28-m1-leftovers.md +++ b/docs/specs/2026-07-28-m1-leftovers.md @@ -75,6 +75,7 @@ - A boundary test proves the negative: colleague has no direct subprocess path outside the permitted module set, and shell-cli's import graph contains no colleague module. [re-captured verbatim from prior frame h17] - debt_remaining is computed from the committed ALLOWLIST by the scanner, so the countdown cannot be asserted in prose or drift from reality — it is derived from source every CI run. [re-captured verbatim from prior frame h27] - Review of the scanner-slice PR verifies every fixture argv against the benign list, and grep of the fixture tree finds none of the dangerous example payloads from issue 7. +- At arc end, shell fs, shell process, and shell env describe exist and front execute(); shell git and env create/destroy do not exist, and the exported M3/M4 plans name them as deliverables riding their engines. ## Success signals @@ -90,6 +91,7 @@ - docs/threat-model.md already satisfies the 'documented threat model that does not claim to be a sandbox' bar for the host guard, but it is written entirely around the six primitives and a single host path. The two-axis environment model (workspace x runner) means it must gain a per-runner section that keeps the host-guard and container-isolation claims textually separate, per issue #1 §7. [re-captured verbatim from prior frame guarded-local-operations-plane c8, operator decision q1] - instruction: Write the per-runner threat-model section inside the ContainerRunner PR itself; h1 is the check. - The scanner slice's evasion and false-positive fixtures spawn only benign argv (true, echo) in scratch space; no committed fixture reproduces the dangerous illustrative payloads from issue 7 (rm -rf, /etc/shadow reads), and no fixture requires network access. +- This arc's CLI slices ship only what the library already backs: the fs group (adding a confined fs.stat handler), the process group, and env describe. The git noun group and env create/destroy have no engine yet - the operation registry holds only fs.* and process.* kinds and shell/git/ does not exist - so those verbs land inside the M3 (git/worktree engine) and M4 (environment lifecycle engine) arcs, never as CLI-only facades ahead of their semantics. ## Non-goals From bd8d62d7c98e0588e9dadfc102c5acae92acabd8 Mon Sep 17 00:00:00 2001 From: Ori Nachum Date: Tue, 28 Jul 2026 10:20:36 +0300 Subject: [PATCH 04/20] docs: refresh README/CLAUDE status sections to match shipped 0.13.1 surface Both files still described the pre-Milestone-1 scaffold. README's Status section claimed no operation/environment/policy/runner had been built and the axes matrix claimed neither runner existed; CLAUDE.md's "Not built yet" list still named process.exec/process.shell (t84) and the policy/operation CLI nouns (t85) as unbuilt. All false since 0.12.0-0.13.0 shipped HostRunner execution, fs.*/process.* handlers, and the policy/operation CLI nouns. Rewrite both to state what is actually built (the operation core, HostRunner, fs.*/process.* handlers, and the policy/operation CLI nouns) versus what genuinely is not (ContainerRunner, colleague delegation, and the env/fs/ process/git CLI noun groups), and flip the environment matrix's Built? column for the Host rows to Yes. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01EkC4zsVUmYrRCwSWSe4aLp --- CLAUDE.md | 36 ++++++++++++++++++++++++------------ README.md | 35 ++++++++++++++++++++++------------- 2 files changed, 46 insertions(+), 25 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index e7046b8..50e8517 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -47,7 +47,7 @@ Identity is settled and must not drift: repo token `shell-cli`, console command **The operation core is built; the CLI has not caught up to it yet.** Keep those two facts separate — conflating them is how this section goes stale. -Built and green in the **library** (Milestone 1, v0.11.0+): +Built and green in the **library** (Milestone 1, v0.11.0-0.13.1): - `shell/operations.py` — `Operation`, the handler registry, and the lifecycle pipeline: `normalize -> rewrite -> policy gate -> preview branch -> handler -> @@ -60,17 +60,29 @@ Built and green in the **library** (Milestone 1, v0.11.0+): - `shell/runners/host.py` — `HostRunner` with real execution: process groups, timeout/cancel escalation, per-platform orphan-prevention honesty. - `shell/fs/` — `fs.read`, `fs.list`, `fs.write`, `fs.edit`, `fs.media`. - -**Not built yet:** `process.exec` / `process.shell` handlers (t84); the CLI noun -groups `env` / `fs` / `process` / `git` / `policy` / `operation` (t85); -`ContainerRunner` (Milestone 4); colleague delegation (Milestone 2). - -So the **CLI still exposes only the introspection verbs** — `whoami`, `learn`, -`explain`, `overview`, `doctor`, `cli`. That is why `learn`, `overview`, and the -`explain` root still carry a Status section saying so, and it remains accurate -*about the CLI*. What is no longer true anywhere is "the primitives are not -extracted yet." Keep those surfaces honest as t84/t85 land; they are the first -thing an agent consumer reads. +- `shell/process/` — `process.exec` (argv, `project` or `control`) and + `process.shell` (a model-authored string, `project` only, since a shell + re-interprets it after the gate reads it) (t84). + +Built and green in the **CLI**: the introspection verbs (`whoami`, `learn`, +`explain`, `overview`, `doctor`, `cli`) plus two operation-aware nouns — +`shell policy check` / `shell policy explain`, which call the exact gate +function `execute()` uses rather than a re-derivation, and `shell operation +show`, which retrieves a persisted evidence record (t85). + +**Not built yet:** the CLI noun groups `env` / `fs` / `process` / `git` — +there is still no CLI verb that itself *executes* an `fs.*` or `process.*` +operation, only ones that introspect the policy/evidence around that +execution; `ContainerRunner` (Milestone 4); colleague delegation (Milestone 2). + +So the CLI's operation-facing surface is introspection-only: it can evaluate a +policy decision and retrieve evidence for an operation that already ran, but it +cannot yet run one itself. That is why `learn`, `overview`, and the `explain` +root still carry a Status section saying so, and it remains accurate *about the +CLI*. What is no longer true anywhere is "the primitives are not extracted +yet," or that the CLI exposes *only* introspection verbs — `policy` and +`operation` are real nouns now. Keep those surfaces honest as the remaining CLI +noun groups land; they are the first thing an agent consumer reads. ## Commands diff --git a/README.md b/README.md index 94f39a6..21e3846 100644 --- a/README.md +++ b/README.md @@ -46,12 +46,20 @@ surface drops the disclaimer or claims isolation. ## Status -**Scaffold, with the release pipeline hardened.** The agent-first CLI skeleton -(`whoami`, `learn`, `explain`, `overview`, `doctor`, `cli`) is real and green, -and publishing is gated on the full quality suite plus a built-wheel smoke test. - -**No operation, environment, policy, or runner has been built yet.** The -introspection verbs below are the entire implemented surface today. +**The operation core is built; the CLI has not caught up to it yet.** The +library has `Operation`, `OperationResult`, `Environment`, the ported policy +evaluator, the evidence contract, and a `HostRunner` with real execution — +`fs.read`, `fs.list`, `fs.write`, `fs.edit`, `fs.media`, `process.exec`, and +`process.shell` are all built and green. + +The CLI exposes the introspection verbs (`whoami`, `learn`, `explain`, +`overview`, `doctor`, `cli`) plus two operation-aware nouns: `policy +check`/`policy explain` evaluate the same gate `execute()` uses, and +`operation show` retrieves persisted evidence. **No CLI verb yet executes an +`fs.*` or `process.*` operation** — the `env`, `fs`, `process`, and `git` noun +groups do not exist yet, and neither does a container runner or colleague +delegation. That surface is library-only today. Publishing is gated on the +full quality suite plus a built-wheel smoke test. [Issue #1](https://github.com/agentculture/shell-cli/issues/1) is the source of truth for scope; the converged spec and the buildable plan live in @@ -74,16 +82,17 @@ intent -> Operation -> policy + preview -> environment backend selects what may be observed and changed; a runner axis selects what executes it. -**Every row below is a design target, not a description of shipped behaviour.** -Neither runner exists yet. The `Intended guarantee` column says what each -combination is *meant* to provide once built; today it provides nothing, because -there is nothing there. Read it as a specification, and do not rely on any row -as a present-tense security property. +**The Container rows are a design target, not a description of shipped +behaviour.** `HostRunner` is built and is what every shipped operation runs +through today; `ContainerRunner` does not exist yet. The `Intended guarantee` +column says what each combination provides for the Host rows now, and what the +Container rows are *meant* to provide once built — read the Container rows as a +specification and do not rely on them as a present-tense security property. | Workspace | Runner | Intended guarantee | Built? | |---|---|---|---| -| Checkout | Host | Guarded host execution; no isolation | No | -| Worktree | Host | Reviewable/recoverable changes; no process isolation | No | +| Checkout | Host | Guarded host execution; no isolation | Yes | +| Worktree | Host | Reviewable/recoverable changes; no process isolation | Yes | | Checkout | Container | Execution isolation against the selected checkout | No | | Worktree | Container | Preferred autonomous mode | No | From bc21bdb18be6d5ce0d5f681cbac093bfcf579996 Mon Sep 17 00:00:00 2001 From: Ori Nachum Date: Tue, 28 Jul 2026 10:28:06 +0300 Subject: [PATCH 05/20] docs: fix the inverted sanctioned-dependency sentence and stale pin example in the release runbook MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The runbook said 'colleague is the second sanctioned base dependency', which inverts the relationship — colleague cannot be a dependency of itself. shell-cli is the second name colleague's zero-deps guard must allow-list. The example pin floor was also stale at >=0.8,<0.9 against a current 0.13.1 release. Found while drafting the colleague migration proposal, which points colleague at this runbook. Recorded as deviation d3 of the m1-leftovers plan. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01EkC4zsVUmYrRCwSWSe4aLp --- docs/release-runbook.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/release-runbook.md b/docs/release-runbook.md index 93466e9..2d7ce7b 100644 --- a/docs/release-runbook.md +++ b/docs/release-runbook.md @@ -104,7 +104,7 @@ see the boundary note below. colleague pins shell-cli with an **upper bound until 1.0**: ```toml -dependencies = ["agentfront>=0.20.0", "shell-cli>=0.8,<0.9"] +dependencies = ["agentfront>=0.20.0", "shell-cli>=0.13,<0.14"] ``` Pre-1.0 semver gives no compatibility promise across minor versions, and @@ -113,8 +113,8 @@ let a breaking 0.9 reach colleague through an unrelated `uv sync`. The bound is raised deliberately, in a colleague PR that runs colleague's own suite — never implicitly. -**colleague is the second sanctioned base dependency**, alongside `agentfront`. -Its zero-dependency guard allow-lists exactly one name today. The colleague PR +**shell-cli is the second sanctioned base dependency**, alongside `agentfront`. +colleague's zero-dependency guard allow-lists exactly one name today. The colleague PR that adds the shell-cli dependency **must update `test_base_dependency_is_exactly_agentfront` and its docstring in the same commit** — the test name and its prose both encode "exactly one", and a change From 44cfacc650ad36638e74eceb630428a3606bc64a Mon Sep 17 00:00:00 2001 From: Ori Nachum Date: Tue, 28 Jul 2026 10:36:07 +0300 Subject: [PATCH 06/20] t6: per-site pinning, module-prefix spawn resolution, scope-aware bindings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes three of the four findings from issue #7's adversarial live test of scripts/colleague_inventory.py. 1. Per-site pinning (finding 2, the architectural one). ALLOWLIST answers "may this module spawn at all"; it could not see three brand-new spawns added to the already-allow-listed tools.py, and --check returned exit 0. SITE_BASELINE now pins each module's spawn-site *shape* at PINNED_SHA — the sorted multiset of canonical call names, with a :shell marker for a literal shell=True — and any delta fails --check. Line numbers are deliberately not part of the signature, so an unrelated edit above a spawn is not a false alarm. --emit-baseline regenerates the literal, and refuses to emit from a partial scan. 2. Module-prefix resolution (finding 1). The 12-entry literal set became a per-module table matched on the module prefix, so subprocess.getoutput, getstatusoutput, the os.exec*/spawn*/posix_spawn*/fork family, pty, the multiprocessing and process-pool constructors and runpy all resolve — and asyncio.subprocess.create_subprocess_exec resolves the same as asyncio.create_subprocess_exec, which was the sharpest evasion in the report. The prefix test is dotted-boundary aware: ostrich.system is not os.system. 3. Scope-aware bindings (finding 6). Name resolution now walks real Python scopes (module/function/class/comprehension, with class bodies invisible to nested functions) instead of a flat ast.walk, and an unbound base resolves to nothing instead of to itself. A parameter named os, os = self.os, for os in hosts, from mypkg.compat import subprocess, and a local named run beside a real from subprocess import run all stop being findings. A gate that cries wolf on a variable named os gets disabled, which is its own failure mode. Verified against the pinned colleague checkout (28fee29): the old and new scanners produce byte-identical findings — 21 sites, 15 modules, 13 debt, two shell=True — so the widened set and the scope rewrite change nothing about real colleague, and --check still exits 0. With one benign subprocess.run appended to that checkout's tools.py, the old scanner exits 0 and the new one exits 1 naming the delta. debt_remaining is still derived from ALLOWLIST intersected with what the scan observed; the site baseline is not allowed to become its source, and a test pins that. The honest ceiling is unchanged: this detects accidental and careless drift from a pinned baseline. Indirection (sp = subprocess, getattr, dynamic import), sibling re-export inside colleague/, exec() of a source string, and non-literal shell= spellings remain open and are listed in the module docstring rather than papered over. Every committed fixture is source text for the AST parser and is never executed; argv is only `true` or `echo`. test_fixture_sources_stay_benign keeps issue #7's illustrative payloads out of the repository. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01EkC4zsVUmYrRCwSWSe4aLp --- .github/workflows/tests.yml | 18 +- scripts/colleague_inventory.py | 645 +++++++++++++++++++++++++----- tests/test_colleague_inventory.py | 588 ++++++++++++++++++++++++++- 3 files changed, 1144 insertions(+), 107 deletions(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index c772729..999a6e4 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -88,11 +88,14 @@ jobs: # # WHAT THIS JOB DOES NOT DO: it is not an enforcement boundary and must not # be described as one. An adversarial live test (issue #7) landed 30 - # executed evasions at exit 0 — most importantly, ALLOWLIST is keyed per - # MODULE, so a new spawn added to any of the 15 already-allow-listed - # modules passes green. Read a green run as "the inventory has not drifted - # in the ways this scanner can see", never as "no new unmediated spawn path - # was added". + # executed evasions at exit 0. The architectural one is closed — SITE_BASELINE + # now pins each module's spawn-site shape, so a new spawn added to an + # already-allow-listed module (tools.py included) fails this job. What + # remains open is indirection (`sp = subprocess`, getattr, dynamic import), + # sibling re-export inside colleague/, exec() of a source string, and + # non-literal `shell=` spellings. Read a green run as "the inventory has not + # drifted in the ways this scanner can see", never as "no new unmediated + # spawn path was added". # # agentculture/colleague is public, so this clones it at the pinned SHA # rather than depending on a local checkout. If the clone fails the job @@ -174,7 +177,10 @@ jobs: echo '```' } >> "$GITHUB_STEP_SUMMARY" - - name: Gate — no unclassified spawn paths + # Two independent checks behind one exit code: a spawn in a module the + # ALLOWLIST does not classify, and a per-module delta from SITE_BASELINE + # (the pinned spawn-site shapes). Either one fails the job. + - name: Gate — unclassified spawn paths and per-site baseline drift run: python3 scripts/colleague_inventory.py .colleague-src --check - name: Drift gate — handler source hashes diff --git a/scripts/colleague_inventory.py b/scripts/colleague_inventory.py index 5a6a567..61f9f02 100644 --- a/scripts/colleague_inventory.py +++ b/scripts/colleague_inventory.py @@ -18,12 +18,13 @@ python3 scripts/colleague_inventory.py /home/spark/git/colleague python3 scripts/colleague_inventory.py /path/to/colleague --json python3 scripts/colleague_inventory.py /path/to/colleague --check + python3 scripts/colleague_inventory.py /path/to/colleague --emit-baseline -``--check`` exits 1 when an unclassified spawn path exists. Exit 2 is reserved -for a scanner/environment error (a missing or unreadable checkout, or a file -that could not be parsed) so CI can distinguish "a new unclassified path -landed" from "the scan itself cannot be trusted" — a check that silently -no-ops is worse than no check. +``--check`` exits 1 when an unclassified spawn path exists, or when a module's +observed spawn sites differ from ``SITE_BASELINE``. Exit 2 is reserved for a +scanner/environment error (a missing or unreadable checkout, or a file that +could not be parsed) so CI can distinguish "the inventory moved" from "the scan +itself cannot be trusted" — a check that silently no-ops is worse than no check. WHAT THIS IS, AND WHAT IT IS NOT -------------------------------- @@ -32,16 +33,42 @@ enforcement boundary, and nothing here should be described as one. An adversarial live test (issue #7) landed **30 executed evasions at exit 0**. -Three limits are worth knowing before you rely on any number this prints: - -* ``ALLOWLIST`` is keyed per **module**, not per **site**. A brand-new spawn - added to an already-allow-listed module — including ``shell=True`` — is - invisible by design. 15 of colleague's modules are already allow-listed. -* ``_SPAWN_CALLS`` is a literal set. ``subprocess.getoutput`` and ~14 other - real spawn APIs are not in it and are not detected. -* Resolution follows *import* bindings only. Assignment aliasing - (``sp = subprocess``), ``getattr``, dynamic import, and sibling re-export all - defeat it. +Three of the four findings behind that number are addressed here; the residue +is real and is listed below rather than papered over. + +Closed since that report: + +* ``ALLOWLIST`` was keyed per **module**, so a brand-new spawn added to an + already-allow-listed module was invisible by design. ``SITE_BASELINE`` now + pins the *shape* of every module's spawn sites at ``PINNED_SHA``, and any + delta — added, removed, or an argv call turning into ``shell=True`` — fails + ``--check``. +* The detected call set was 12 literal names, so ``subprocess.getoutput`` alone + defeated the scan. It is now organised per module with **module-prefix + matching**, so ``asyncio.subprocess.create_subprocess_exec`` resolves the + same as ``asyncio.create_subprocess_exec``. +* Name resolution walked the module flat and fell through on an unbound base, + which reported a *parameter* named ``os`` as a spawn and let one + ``from subprocess import run`` turn every later local named ``run`` into a + finding. Resolution is now **scope-aware**, and an unbound base resolves to + nothing instead of to itself. + +Still open, and accepted: + +* **Indirection.** ``sp = subprocess``, ``runner = subprocess.run``, + ``getattr(subprocess, "run")``, ``importlib.import_module("subprocess")``, a + dict of callables, ``functools.partial`` and a class attribute all defeat + resolution. Only ``import`` statements bind. +* **Structure.** A sibling re-export inside ``colleague/`` (``from .shim import + run``), ``exec()`` of a source string, a spawn in a file outside the package, + and a symlinked subdirectory ``rglob`` will not follow. +* **``shell=True`` is a literal-spelling artifact.** Detection requires an + ``ast.Constant``; ``shell=SH`` and ``**{"shell": True}`` shell out while + reporting nothing. +* **Shadowing cuts both ways.** Because a non-import binding now resolves to + nothing, rebinding ``subprocess`` inside a function hides every spawn in it. + That trade buys the false-positive fix and is the right way round: a gate + that cries wolf on a variable named ``os`` gets disabled. What it does well is reproduce a known inventory at an exact commit and notice when that inventory moves. That is genuinely useful and it is the whole claim. @@ -57,6 +84,7 @@ import json import subprocess # nosec B404 - reads git metadata from a local checkout import sys +from collections import Counter from dataclasses import dataclass, field from pathlib import Path, PurePath @@ -65,30 +93,80 @@ PINNED_SHA = "28fee290c51fc4310b9fc576981809ad5c3132c6" PINNED_VERSION = "1.51.0" -# Callables that create a process. Matched on the *resolved* dotted path: call -# targets are first mapped back through the module's own import statements, so -# ``sp.run`` (after ``import subprocess as sp``) and a bare ``run`` (after -# ``from subprocess import run``) both resolve to ``subprocess.run``, while a -# locally defined ``run`` — bound by no import — does not. -_SPAWN_CALLS = { - "subprocess.run", - "subprocess.Popen", - "subprocess.call", - "subprocess.check_call", - "subprocess.check_output", - "os.system", - "os.popen", - "os.execv", - "os.execvp", - "os.spawnv", - "asyncio.create_subprocess_exec", - "asyncio.create_subprocess_shell", +# Callables that create a process, or execute code as if one had been created, +# grouped by the module that owns them. +# +# Matching is on the *resolved* dotted path, and on the MODULE PREFIX rather +# than the whole literal: call targets are first mapped back through the +# enclosing scope's import statements, then the trailing attribute is looked up +# against the owning module. So ``sp.run`` (after ``import subprocess as sp``), +# a bare ``run`` (after ``from subprocess import run``) and +# ``asyncio.subprocess.create_subprocess_exec`` all resolve, while a locally +# defined ``run`` — bound by no import — does not. +# +# ``runpy`` and the pool executors do not fork on the calling line, but they are +# unmediated ways to start executing other code, which is what this inventory +# is for. They are zero in colleague today; they are listed so they cannot +# arrive unnoticed. +_SPAWN_APIS: dict[str, frozenset[str]] = { + "subprocess": frozenset( + { + "run", + "Popen", + "call", + "check_call", + "check_output", + "getoutput", + "getstatusoutput", + } + ), + "os": frozenset( + { + "system", + "popen", + # popen2/3/4 are Python 2 spelling. Listed so a vendored shim that + # re-exports them is not a silent hole. + "popen2", + "popen3", + "popen4", + "startfile", + "fork", + "forkpty", + "posix_spawn", + "posix_spawnp", + "execl", + "execle", + "execlp", + "execlpe", + "execv", + "execve", + "execvp", + "execvpe", + "spawnl", + "spawnle", + "spawnlp", + "spawnlpe", + "spawnv", + "spawnve", + "spawnvp", + "spawnvpe", + } + ), + "asyncio": frozenset({"create_subprocess_exec", "create_subprocess_shell"}), + "pty": frozenset({"spawn", "fork"}), + "multiprocessing": frozenset({"Process", "Pool"}), + "concurrent.futures": frozenset({"ProcessPoolExecutor"}), + "runpy": frozenset({"run_module", "run_path"}), } +# The canonical names a Finding can carry, derived from the table above. +_SPAWN_CALLS = frozenset( + f"{module}.{attr}" for module, attrs in _SPAWN_APIS.items() for attr in attrs +) + # Modules permitted to spawn a process, with the profile each one operates -# under. This is the KNOWN-DEBT ALLOWLIST: it records what exists today so a -# NEW unclassified path fails immediately, while the already-known paths are -# tracked as debt that must reach zero by Milestone 3. +# under. This is the KNOWN-DEBT ALLOWLIST: it records which modules may spawn at +# all, while SITE_BASELINE records how many times and in what shape. # # `debt=True` -> a path shell-cli must take over; must be empty by M3. # `debt=False` -> stays in colleague permanently (domain orchestration). @@ -111,6 +189,42 @@ "experiment.py": ("control", False), } +# Per-SITE pin, recorded at PINNED_SHA. ALLOWLIST answers "may this module +# spawn at all"; this answers "how many times, and in what shape" — which is the +# question issue #7 finding 2 showed nobody was asking. Each entry is the sorted +# multiset of that module's spawn-site signatures, where a signature is the +# canonical call name plus a `:shell` marker when the call passes a literal +# `shell=True`. +# +# Line numbers are deliberately NOT part of the signature: they move whenever an +# unrelated edit lands above, which would turn every colleague refactor into a +# false alarm. +# +# REGENERATE, NEVER HAND-EDIT: run `--emit-baseline` against a checkout at +# PINNED_SHA and paste the literal. Moving the pin and moving this table are the +# same reviewed change. +# +# An EMPTY baseline means "not pinned" and makes the site comparison inert. That +# is a real state (a fresh port, a synthetic fixture) so it is reported as +# `site_baseline_pinned: false` rather than silently reading as a match. +SITE_BASELINE: dict[str, tuple[str, ...]] = { + "affectedtests.py": ("subprocess.run",), + "background.py": ("subprocess.Popen",), + "coherence.py": ("subprocess.run",), + "culture.py": ("subprocess.run",), + "devague.py": ("subprocess.run",), + "experiment.py": ("subprocess.Popen", "subprocess.run"), + "handoff.py": ("subprocess.run", "subprocess.run", "subprocess.run"), + "hooks.py": ("subprocess.run:shell",), + "lint.py": ("subprocess.run",), + "livecheck.py": ("subprocess.run",), + "memory.py": ("subprocess.run", "subprocess.run"), + "neighbours.py": ("subprocess.run", "subprocess.run"), + "resident/steward.py": ("subprocess.run",), + "tools.py": ("subprocess.run", "subprocess.run:shell"), + "worktrees.py": ("subprocess.run",), +} + class InventoryError(Exception): """An environment problem: the checkout is missing or unreadable. @@ -131,6 +245,11 @@ class Finding: call: str uses_shell: bool = False + @property + def signature(self) -> str: + """The line-independent shape recorded in ``SITE_BASELINE``.""" + return f"{self.call}:shell" if self.uses_shell else self.call + @dataclass class Inventory: @@ -145,6 +264,13 @@ class Inventory: def debt_modules(self) -> set[str]: return {m for m in self.modules if ALLOWLIST.get(m, ("", False))[1]} + def site_map(self) -> dict[str, tuple[str, ...]]: + """Observed per-module site signatures, in ``SITE_BASELINE`` form.""" + grouped: dict[str, list[str]] = {} + for finding in self.findings: + grouped.setdefault(finding.module, []).append(finding.signature) + return {module: tuple(sorted(sigs)) for module, sigs in sorted(grouped.items())} + class ScanSkipped(Exception): """A single file could not be parsed. Recorded, never swallowed.""" @@ -162,6 +288,27 @@ def module_key(path: PurePath, pkg: PurePath) -> str: return path.relative_to(pkg).as_posix() +def canonical_spawn(dotted: str) -> str: + """Canonical spawn name for a resolved dotted path, or '' if it is not one. + + Matching is on the module PREFIX, not the whole literal string. That is what + makes ``asyncio.subprocess.create_subprocess_exec`` resolve to + ``asyncio.create_subprocess_exec`` — the exact case issue #7 called the + sharpest instance, where a call the author explicitly tried to cover slipped + through on its fully-qualified submodule path. + + The prefix test is dotted-boundary aware, so ``ostrich.system`` is not + ``os.system`` and ``os.path.join`` is not a spawn. + """ + prefix, _, attr = dotted.rpartition(".") + if not prefix: + return "" + for module, attrs in _SPAWN_APIS.items(): + if attr in attrs and (prefix == module or prefix.startswith(f"{module}.")): + return f"{module}.{attr}" + return "" + + def _dotted(node: ast.AST) -> str: """Render an attribute/name chain as a dotted string, else ''.""" parts: list[str] = [] @@ -174,67 +321,268 @@ def _dotted(node: ast.AST) -> str: return "" -def _import_bindings(tree: ast.Module) -> tuple[dict[str, str], dict[str, str]]: - """Map local names back to the module paths their imports bound them to. +# --- scope-aware name binding ---------------------------------------------- +# +# Issue #7 finding 6: walking the module flat made one legitimate +# `from subprocess import run` turn every later local named `run` into a +# finding, and failing closed on an unbound base reported a *parameter* named +# `os` as a spawn. Both are fixed by resolving names in the scope they are +# actually evaluated in. + +_COMPREHENSIONS = (ast.ListComp, ast.SetComp, ast.DictComp, ast.GeneratorExp) +_NESTED_SCOPES = ( + ast.FunctionDef, + ast.AsyncFunctionDef, + ast.ClassDef, + ast.Lambda, +) + _COMPREHENSIONS + + +class _Scope: + """One Python name-binding scope. + + ``names`` maps a local name either to the dotted module path an *import* + bound it to, or to ``None`` meaning "bound by something that is not an + import" — a parameter, an assignment, a loop target, a ``def``. ``None`` is + a positive statement that the name is NOT the stdlib module it is spelled + like, which is exactly the false positive that made the gate cry wolf. + """ - Returns ``(module_aliases, direct_names)``: + __slots__ = ("kind", "parent", "names", "star_modules") + + def __init__(self, kind: str, parent: _Scope | None = None) -> None: + self.kind = kind # "module" | "function" | "class" + self.parent = parent + self.names: dict[str, str | None] = {} + self.star_modules: list[str] = [] + + def bind(self, name: str, target: str | None) -> None: + self.names[name] = target + + def lookup(self, name: str) -> tuple[str | None, bool]: + """Return ``(target, found)``. + + ``found`` distinguishes "bound to something that is not an import" + (``(None, True)``) from "not bound in any enclosing scope" + (``(None, False)``). A class body's names are invisible to functions + nested inside it, matching Python's own rule. + """ + scope: _Scope | None = self + innermost = True + while scope is not None: + if (innermost or scope.kind != "class") and name in scope.names: + return scope.names[name], True + scope = scope.parent + innermost = False + return None, False + + def star_chain(self) -> list[str]: + """Modules star-imported into this scope or an enclosing one.""" + modules: list[str] = [] + scope: _Scope | None = self + innermost = True + while scope is not None: + if innermost or scope.kind != "class": + modules.extend(scope.star_modules) + scope = scope.parent + innermost = False + return modules + + +def _bind_target(node: ast.AST, scope: _Scope) -> None: + """Shadow every name an assignment/loop/with/match target binds.""" + if isinstance(node, ast.Name): + scope.bind(node.id, None) + elif isinstance(node, (ast.Tuple, ast.List)): + for element in node.elts: + _bind_target(element, scope) + elif isinstance(node, ast.Starred): + _bind_target(node.value, scope) + # Attribute and Subscript targets bind no local name. + + +def _bind_params(args: ast.arguments, scope: _Scope) -> None: + for arg in [*args.posonlyargs, *args.args, *args.kwonlyargs]: + scope.bind(arg.arg, None) + for arg in (args.vararg, args.kwarg): + if arg is not None: + scope.bind(arg.arg, None) + + +def _bind_import(node: ast.Import, scope: _Scope) -> None: + for alias in node.names: + # `import os.path` binds the top-level name `os`. + bound = alias.asname or alias.name.split(".")[0] + target = alias.name if alias.asname else alias.name.split(".")[0] + scope.bind(bound, target) + + +def _bind_import_from(node: ast.ImportFrom, scope: _Scope) -> None: + if node.level or not node.module: + # A relative import names a module inside colleague, never the stdlib + # one it may be spelled like. Bind it as shadowed so `subprocess.run` + # after `from . import subprocess` is not read as a stdlib spawn. + for alias in node.names: + if alias.name != "*": + scope.bind(alias.asname or alias.name, None) + return + for alias in node.names: + if alias.name == "*": + scope.star_modules.append(node.module) + continue + scope.bind(alias.asname or alias.name, f"{node.module}.{alias.name}") - * ``module_aliases`` — ``import subprocess as sp`` -> ``{"sp": "subprocess"}`` - (and ``import subprocess`` -> ``{"subprocess": "subprocess"}``). - * ``direct_names`` — ``from subprocess import run as r`` -> - ``{"r": "subprocess.run"}``. - Only real ``import``/``from`` statements contribute, which is exactly what - keeps a locally defined ``def run(...)`` out of the map — and therefore out - of the findings. +def _collect_bindings(body: list[ast.stmt], scope: _Scope) -> None: + """Pre-pass: bind every name this scope binds anywhere in its body. - Relative imports (``from .subprocess import run``) are ignored: they name a - module inside colleague, not the stdlib one. + Python decides a name is local for the whole scope, not from the assignment + onwards, so the bindings must be known before any call in the scope is + resolved. + """ + for statement in body: + _collect_node_bindings(statement, scope) + + +def _collect_node_bindings(node: ast.AST, scope: _Scope) -> None: + if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)): + # The name is bound here; the body belongs to its own scope. + scope.bind(node.name, None) + return + if isinstance(node, ast.Import): + _bind_import(node, scope) + return + if isinstance(node, ast.ImportFrom): + _bind_import_from(node, scope) + return + + if isinstance(node, ast.Assign): + for target in node.targets: + _bind_target(target, scope) + elif isinstance(node, (ast.AnnAssign, ast.AugAssign, ast.NamedExpr)): + _bind_target(node.target, scope) + elif isinstance(node, (ast.For, ast.AsyncFor)): + _bind_target(node.target, scope) + elif isinstance(node, (ast.With, ast.AsyncWith)): + for item in node.items: + if item.optional_vars is not None: + _bind_target(item.optional_vars, scope) + elif isinstance(node, ast.ExceptHandler): + if node.name: + scope.bind(node.name, None) + elif isinstance(node, (ast.Global, ast.Nonlocal)): + # Conservative: treat the name as not-an-import locally rather than + # modelling cross-scope rebinding. + for name in node.names: + scope.bind(name, None) + elif isinstance(node, (ast.MatchAs, ast.MatchStar)): + if node.name: + scope.bind(node.name, None) + elif isinstance(node, ast.MatchMapping): + if node.rest: + scope.bind(node.rest, None) + + for child in ast.iter_child_nodes(node): + if isinstance(child, _NESTED_SCOPES): + continue + _collect_node_bindings(child, scope) + + +class _CallWalker: + """Pair every ``ast.Call`` with the scope it is evaluated in.""" + + def __init__(self) -> None: + self.calls: list[tuple[ast.Call, _Scope]] = [] + + def walk_module(self, tree: ast.Module) -> list[tuple[ast.Call, _Scope]]: + scope = _Scope("module") + _collect_bindings(tree.body, scope) + self._visit_all(tree.body, scope) + return self.calls + + def _visit_all(self, nodes: list, scope: _Scope) -> None: + for node in nodes: + if node is not None: + self._visit(node, scope) + + def _visit(self, node: ast.AST, scope: _Scope) -> None: + if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef, ast.Lambda)): + # Decorators and defaults evaluate in the ENCLOSING scope. + # Annotations are not walked at all: a spawn inside a type + # annotation is not a shape colleague has, or plausibly could. + self._visit_all(getattr(node, "decorator_list", []), scope) + self._visit_all(list(node.args.defaults), scope) + self._visit_all(list(node.args.kw_defaults), scope) + child = _Scope("function", scope) + _bind_params(node.args, child) + if isinstance(node, ast.Lambda): + self._visit(node.body, child) + else: + _collect_bindings(node.body, child) + self._visit_all(node.body, child) + return + + if isinstance(node, ast.ClassDef): + self._visit_all(node.decorator_list, scope) + self._visit_all(node.bases, scope) + self._visit_all([kw.value for kw in node.keywords], scope) + child = _Scope("class", scope) + _collect_bindings(node.body, child) + self._visit_all(node.body, child) + return + + if isinstance(node, _COMPREHENSIONS): + child = _Scope("function", scope) + for generator in node.generators: + _bind_target(generator.target, child) + # Only the outermost iterable evaluates in the enclosing scope. + self._visit(node.generators[0].iter, scope) + for generator in node.generators[1:]: + self._visit(generator.iter, child) + for generator in node.generators: + self._visit_all(generator.ifs, child) + if isinstance(node, ast.DictComp): + self._visit(node.key, child) + self._visit(node.value, child) + else: + self._visit(node.elt, child) + return + + if isinstance(node, ast.Call): + self.calls.append((node, scope)) + + for child_node in ast.iter_child_nodes(node): + self._visit(child_node, scope) + + +def _resolve_call(func: ast.AST, scope: _Scope) -> str: + """Resolve a call target to its dotted module path, or '' if unresolvable. + + There is no unbound-base fall-through. A base name that no import bound in + any enclosing scope resolves to nothing, so a parameter named ``os`` is not + a spawn. The cost is a false negative for `subprocess.run(...)` in a module + that never imported subprocess — which is a NameError at runtime, i.e. not + real code — and the benefit is that ordinary code stops failing the check. """ - module_aliases: dict[str, str] = {} - direct_names: dict[str, str] = {} - - for node in ast.walk(tree): - if isinstance(node, ast.Import): - for alias in node.names: - # `import os.path` binds the top-level name `os`. - bound = alias.asname or alias.name.split(".")[0] - target = alias.name if alias.asname else alias.name.split(".")[0] - module_aliases[bound] = target - elif isinstance(node, ast.ImportFrom): - if node.level or not node.module: - continue - for alias in node.names: - direct_names[alias.asname or alias.name] = f"{node.module}.{alias.name}" - - return module_aliases, direct_names - - -def _resolve_call(func: ast.AST, module_aliases: dict[str, str], direct: dict[str, str]) -> str: - """Resolve a call target to its dotted stdlib path, or '' if unresolvable.""" if isinstance(func, ast.Name): - # A bare call is a spawn only if an import bound that name to one. - return direct.get(func.id, "") + target, found = scope.lookup(func.id) + if found: + return target or "" + for module in scope.star_chain(): + candidate = f"{module}.{func.id}" + if canonical_spawn(candidate): + return candidate + return "" if isinstance(func, ast.Attribute): dotted = _dotted(func) if not dotted: return "" base, _, rest = dotted.partition(".") - # A BOUND base resolves through the alias map, so `import mything as os` - # followed by `os.system(...)` correctly reads as `mything.system` and is - # not counted. An UNBOUND base falls through as itself, so a literal - # `subprocess.run` in a module that never imported subprocess still - # reads as a spawn. - # - # That fall-through biases toward over-reporting, and the cost is real: - # a *parameter* named `os` calling `os.system()` on some object is - # reported as a spawn although it creates no process (issue #7). The two - # behaviours are not in tension — one is the bound case and the other - # the unbound case — but do not read this as a general "fail closed" - # guarantee. The scanner is a drift detector; over-reporting here is a - # known false-positive source, not a safety property. - return f"{module_aliases.get(base, base)}.{rest}" + target, found = scope.lookup(base) + if not found or target is None: + return "" + return f"{target}.{rest}" return "" @@ -246,20 +594,19 @@ def scan_file(path: Path, rel: str) -> list[Finding]: except (SyntaxError, UnicodeDecodeError) as exc: raise ScanSkipped(f"{type(exc).__name__}: {exc}") from exc - module_aliases, direct = _import_bindings(tree) - found: list[Finding] = [] - for node in ast.walk(tree): - if not isinstance(node, ast.Call): - continue - name = _resolve_call(node.func, module_aliases, direct) - if name not in _SPAWN_CALLS: + for call, scope in _CallWalker().walk_module(tree): + name = canonical_spawn(_resolve_call(call.func, scope)) + if not name: continue uses_shell = any( kw.arg == "shell" and isinstance(kw.value, ast.Constant) and kw.value.value is True - for kw in node.keywords + for kw in call.keywords ) - found.append(Finding(module=rel, line=node.lineno, call=name, uses_shell=uses_shell)) + found.append(Finding(module=rel, line=call.lineno, call=name, uses_shell=uses_shell)) + + # Source order, not AST traversal order: the report has to be reproducible. + found.sort(key=lambda f: (f.line, f.call)) return found @@ -291,6 +638,47 @@ def scan(root: Path) -> Inventory: return inv +def baseline_drift( + observed: dict[str, tuple[str, ...]], baseline: dict[str, tuple[str, ...]] +) -> list[str]: + """Per-module deltas between the observed sites and the pinned baseline. + + An empty baseline is the documented "not pinned" state and yields no + deltas — the caller reports that as ``site_baseline_pinned: false`` so an + inert comparison never reads as a match. + """ + if not baseline: + return [] + + drift: list[str] = [] + for module in sorted(set(observed) | set(baseline)): + expected = tuple(baseline.get(module, ())) + actual = tuple(observed.get(module, ())) + if expected == actual: + continue + added = Counter(actual) - Counter(expected) + removed = Counter(expected) - Counter(actual) + detail = ", ".join( + [f"+{sig}×{n}" for sig, n in sorted(added.items())] + + [f"-{sig}×{n}" for sig, n in sorted(removed.items())] + ) + drift.append( + f"{module}: baseline {len(expected)} site(s), observed {len(actual)} ({detail})" + ) + return drift + + +def render_baseline(observed: dict[str, tuple[str, ...]]) -> str: + """The observed site map as a paste-ready ``SITE_BASELINE`` literal.""" + lines = ["SITE_BASELINE: dict[str, tuple[str, ...]] = {"] + for module, sigs in sorted(observed.items()): + rendered = ", ".join(f'"{sig}"' for sig in sigs) + trailing = "," if len(sigs) == 1 else "" + lines.append(f' "{module}": ({rendered}{trailing}),') + lines.append("}") + return "\n".join(lines) + + def head_sha(root: Path) -> str: try: out = subprocess.run( # nosec B603,B607 - fixed argv, no shell, git from PATH @@ -312,7 +700,12 @@ def main(argv: list[str] | None = None) -> int: ap.add_argument( "--check", action="store_true", - help="exit non-zero if an unclassified spawn path exists", + help="exit non-zero on an unclassified spawn path or a per-site baseline delta", + ) + ap.add_argument( + "--emit-baseline", + action="store_true", + help="print the observed site map as a paste-ready SITE_BASELINE literal", ) args = ap.parse_args(argv) @@ -328,6 +721,29 @@ def main(argv: list[str] | None = None) -> int: return 2 sha = head_sha(args.root) + observed_sites = inv.site_map() + drift = baseline_drift(observed_sites, SITE_BASELINE) + + if args.emit_baseline: + if inv.skipped: + print( + f"error: {len(inv.skipped)} file(s) could not be scanned; " + "refusing to emit a baseline from a partial scan", + file=sys.stderr, + ) + print( + "hint: fix or exclude the unparseable file, then re-run --emit-baseline", + file=sys.stderr, + ) + return 2 + if sha != PINNED_SHA: + print( + f"warning: emitting from {sha[:12]}, not the pinned {PINNED_SHA[:12]}; " + "update PINNED_SHA in the same change", + file=sys.stderr, + ) + print(render_baseline(observed_sites)) + return 0 by_profile: dict[str, int] = {} for f in inv.findings: @@ -345,6 +761,11 @@ def main(argv: list[str] | None = None) -> int: "unclassified": [f"{f.module}:{f.line} ({f.call})" for f in inv.unclassified], "debt_modules": sorted(inv.debt_modules), "debt_remaining": len(inv.debt_modules), + # False means the per-site pin is not configured, so `baseline_drift` + # below is empty because nothing was compared — not because it matched. + "site_baseline_pinned": bool(SITE_BASELINE), + "baseline_matches": not drift, + "baseline_drift": drift, # A non-empty list means the scan was partial: every count above is a # lower bound, not a measurement. "skipped": [f"{module} ({reason})" for module, reason in sorted(inv.skipped.items())], @@ -362,10 +783,16 @@ def main(argv: list[str] | None = None) -> int: print(f" {profile:14s}: {count}") print(f" shell=True : {', '.join(payload['shell_true_sites']) or 'none'}") print(f" debt modules: {payload['debt_remaining']} (must reach 0 by Milestone 3)") + if not SITE_BASELINE: + print(" site pin : NOT PINNED (SITE_BASELINE is empty; nothing was compared)") if inv.unclassified: print(" UNCLASSIFIED (fails --check):") for entry in payload["unclassified"]: print(f" {entry}") + if drift: + print(" BASELINE DRIFT (fails --check):") + for entry in drift: + print(f" {entry}") if inv.skipped: print(" SKIPPED — scan is PARTIAL, counts above are a lower bound:") for entry in payload["skipped"]: @@ -387,17 +814,41 @@ def main(argv: list[str] | None = None) -> int: ) return 2 + failed = False + if inv.unclassified: print( f"\nerror: {len(inv.unclassified)} unclassified spawn path(s)", file=sys.stderr, ) + for entry in payload["unclassified"]: + print(f" {entry}", file=sys.stderr) print( "hint: classify the module in ALLOWLIST, or route it through shell-cli", file=sys.stderr, ) - return 1 - return 0 + failed = True + + if drift: + print( + f"\nerror: {len(drift)} module(s) drifted from the pinned site baseline", + file=sys.stderr, + ) + for entry in drift: + print(f" {entry}", file=sys.stderr) + if sha != PINNED_SHA: + print( + f"note: scanned {sha[:12]}, baseline pinned at {PINNED_SHA[:12]}", + file=sys.stderr, + ) + print( + "hint: if the change is intended, re-run with --emit-baseline and " + "review the delta in the same PR", + file=sys.stderr, + ) + failed = True + + return 1 if failed else 0 if __name__ == "__main__": diff --git a/tests/test_colleague_inventory.py b/tests/test_colleague_inventory.py index f934e2c..6be9eb3 100644 --- a/tests/test_colleague_inventory.py +++ b/tests/test_colleague_inventory.py @@ -1,17 +1,25 @@ -"""The known-debt gate: `colleague_inventory.py --check`. +"""The known-debt drift detector: `colleague_inventory.py --check`. -The gate exists so a *new* unclassified process-spawn path in colleague fails -CI immediately, while the already-known paths are tracked as scheduled -migrations that must reach zero by Milestone 3. +The scanner reproduces colleague's process-spawn inventory at a pinned commit +and notices when it moves, while the already-known paths are tracked as +scheduled migrations that must reach zero by Milestone 3. It is a drift +detector, not an enforcement boundary (issue #7) — these tests pin what it can +actually see, in both directions. A gate that only ever runs against a checkout where it passes is not a tested gate. These tests drive both directions against **synthetic fixtures**, so they prove the failing direction without needing a real colleague checkout — which CI does not have when this file runs. + +FIXTURE HYGIENE: every fixture below is *source text handed to an AST parser*. +Nothing here is ever executed, and no fixture argv is anything but `true`, +`echo`, or an equally inert placeholder. Issue #7's illustrative payloads are +deliberately absent, and `test_fixture_sources_stay_benign` keeps them out. """ from __future__ import annotations +import ast import importlib.util import json import sys @@ -34,6 +42,29 @@ def _load_scanner(): inventory = _load_scanner() +# Captured at import, BEFORE any test patches the module global. The committed +# baseline describes real colleague at PINNED_SHA; the assertions about it must +# read this copy, not whatever a test installed. +COMMITTED_SITE_BASELINE = dict(inventory.SITE_BASELINE) + + +@pytest.fixture(autouse=True) +def _unpinned_site_baseline(monkeypatch): + """Synthetic fixtures are not the pinned colleague checkout. + + ``SITE_BASELINE`` pins per-module spawn-site *shapes* at ``PINNED_SHA``, so + comparing a two-file ``tmp_path`` tree against it is meaningless — every + fixture would read as drift. An empty baseline is the documented "not + pinned" state, which makes the site comparison inert; the tests that + exercise per-site pinning install a baseline of their own on top of this. + """ + monkeypatch.setattr(inventory, "SITE_BASELINE", {}) + + +def _pin_sites(monkeypatch, baseline: dict[str, tuple[str, ...]]) -> None: + """Install a per-site baseline for one test.""" + monkeypatch.setattr(inventory, "SITE_BASELINE", baseline) + def _fixture(tmp_path: Path, modules: dict[str, str]) -> Path: """Build a synthetic colleague checkout: /colleague/.py.""" @@ -52,6 +83,17 @@ def _fixture(tmp_path: Path, modules: dict[str, str]) -> Path: _INERT = "def go():\n return 'subprocess.run is only a string here'\n" _BROKEN = "def (:\n" +# Two spawn sites in one module: the shape issue #7 finding 2 slipped past a +# per-MODULE allow-list. Both argvs are inert. +_SPAWNS_TWICE = ( + "import subprocess\n" + "\n" + "\n" + "def go():\n" + " subprocess.run(['true'], check=False)\n" + " subprocess.run(['echo', 'hello'], check=False)\n" +) + # --- the failing direction: a NEW unclassified spawn path ------------------ @@ -372,3 +414,541 @@ def test_module_key_is_stable_on_posix_paths(): def test_allowlist_keys_are_posix_form(): """The ALLOWLIST is the contract module_key normalizes toward.""" assert all("\\" not in key for key in inventory.ALLOWLIST) + + +# --- per-site pinning: issue #7 finding 2, the architectural one ----------- +# +# ALLOWLIST is keyed per MODULE. Three brand-new spawns were added to the +# already-allow-listed tools.py and `--check` returned exit 0 with no signal, +# because "tools.py may spawn" was already true. SITE_BASELINE pins the *shape* +# of each module's spawn sites at the pinned commit, so a new one is a delta. + + +def test_a_new_spawn_in_an_already_allowlisted_module_fails_check(tmp_path, monkeypatch, capsys): + """The headline regression from issue #7 finding 2. + + tools.py is allow-listed, so the unclassified check has nothing to say. The + per-site baseline is what notices that the module grew a second spawn. + """ + root = _fixture(tmp_path, {"tools.py": _SPAWNS_TWICE}) + _pin_sites(monkeypatch, {"tools.py": ("subprocess.run",)}) + + assert inventory.main([str(root), "--check"]) == 1 + + err = capsys.readouterr().err + assert "error:" in err + assert "hint:" in err + assert "tools.py" in err + assert "Traceback" not in err + + +def test_the_same_fixture_passes_the_unclassified_check_alone(tmp_path, monkeypatch): + """Without the site baseline the extra spawn is invisible — that was the bug. + + This is the *before* picture, kept as a test so the value of the site + baseline is not folded silently into a green suite: the ALLOWLIST check + genuinely cannot see this, and never could. + """ + root = _fixture(tmp_path, {"tools.py": _SPAWNS_TWICE}) + _pin_sites(monkeypatch, {}) + + inv = inventory.scan(root) + + assert inv.unclassified == [] + assert inventory.main([str(root), "--check"]) == 0 + + +def test_a_matching_site_baseline_passes(tmp_path, monkeypatch): + """The passing direction: observed shape == pinned shape.""" + root = _fixture(tmp_path, {"tools.py": _SPAWNS, "worktrees.py": _SPAWNS}) + _pin_sites( + monkeypatch, + {"tools.py": ("subprocess.run",), "worktrees.py": ("subprocess.run",)}, + ) + + assert inventory.main([str(root), "--check"]) == 0 + + +def test_an_argv_spawn_becoming_shell_true_is_drift(tmp_path, monkeypatch): + """Count alone is too weak: the shape carries the shell=True flag. + + One site before, one site after — but a shell string is a different risk + story from an argv vector, so the baseline records it. + """ + root = _fixture(tmp_path, {"tools.py": _SPAWNS_SHELL}) + _pin_sites(monkeypatch, {"tools.py": ("subprocess.run",)}) + + assert inventory.main([str(root), "--check"]) == 1 + + +def test_swapping_one_spawn_api_for_another_is_drift(tmp_path, monkeypatch): + """Same count, different API — still a reviewable change.""" + root = _fixture(tmp_path, {"tools.py": "import os\nos.system('true')\n"}) + _pin_sites(monkeypatch, {"tools.py": ("subprocess.run",)}) + + assert inventory.main([str(root), "--check"]) == 1 + + +def test_a_module_that_stops_spawning_is_also_drift(tmp_path, monkeypatch): + """Migrating a module is progress — and still a baseline change to review. + + Debt going down must move the pinned baseline in the same reviewed diff, + otherwise the baseline stops describing the checkout it names. + """ + root = _fixture(tmp_path, {"tools.py": _INERT}) + _pin_sites(monkeypatch, {"tools.py": ("subprocess.run",)}) + + assert inventory.main([str(root), "--check"]) == 1 + + +def test_baseline_drift_is_published_in_json_and_text(tmp_path, monkeypatch, capsys): + """CI must be able to read the delta, not just the exit code.""" + root = _fixture(tmp_path, {"tools.py": _SPAWNS_TWICE}) + _pin_sites(monkeypatch, {"tools.py": ("subprocess.run",)}) + + assert inventory.main([str(root), "--json"]) == 0 + payload = json.loads(capsys.readouterr().out) + assert payload["baseline_matches"] is False + assert len(payload["baseline_drift"]) == 1 + assert "tools.py" in payload["baseline_drift"][0] + + assert inventory.main([str(root)]) == 0 + assert "BASELINE DRIFT" in capsys.readouterr().out + + +def test_report_mode_never_fails_on_drift(tmp_path, monkeypatch): + """Only --check gates. The report is a report.""" + root = _fixture(tmp_path, {"tools.py": _SPAWNS_TWICE}) + _pin_sites(monkeypatch, {"tools.py": ("subprocess.run",)}) + + assert inventory.main([str(root)]) == 0 + assert inventory.main([str(root), "--json"]) == 0 + + +def test_an_empty_site_baseline_reports_itself_as_unpinned(tmp_path, monkeypatch, capsys): + """An empty baseline is inert — so it must say so, not read as 'matches'. + + A silently inert pin is the same failure mode as a silently no-oping gate. + """ + root = _fixture(tmp_path, {"tools.py": _SPAWNS}) + _pin_sites(monkeypatch, {}) + + assert inventory.main([str(root), "--json"]) == 0 + payload = json.loads(capsys.readouterr().out) + + assert payload["site_baseline_pinned"] is False + assert payload["baseline_drift"] == [] + + +def test_unclassified_and_drift_are_both_reported(tmp_path, monkeypatch, capsys): + """Two independent findings, one exit code, both named on stderr.""" + root = _fixture(tmp_path, {"tools.py": _SPAWNS_TWICE, "rogue.py": _SPAWNS}) + _pin_sites(monkeypatch, {"tools.py": ("subprocess.run",)}) + + assert inventory.main([str(root), "--check"]) == 1 + + err = capsys.readouterr().err + assert "rogue.py" in err + assert "tools.py" in err + + +def test_a_partial_scan_outranks_a_drift_verdict(tmp_path, monkeypatch): + """An untrustworthy scan has no verdict to give — exit 2 wins over exit 1.""" + root = _fixture(tmp_path, {"broken.py": _BROKEN, "tools.py": _SPAWNS_TWICE}) + _pin_sites(monkeypatch, {"tools.py": ("subprocess.run",)}) + + assert inventory.main([str(root), "--check"]) == 2 + + +# --- regenerating the baseline is mechanical, never hand-typed ------------- + + +def test_emit_baseline_prints_a_paste_ready_literal(tmp_path, monkeypatch, capsys): + """`--emit-baseline` is how the pin moves: regenerate, review the diff.""" + root = _fixture(tmp_path, {"tools.py": _SPAWNS_TWICE, "hooks.py": _SPAWNS_SHELL}) + _pin_sites(monkeypatch, {}) + + assert inventory.main([str(root), "--emit-baseline"]) == 0 + + emitted = capsys.readouterr().out + tree = ast.parse(emitted) + assert len(tree.body) == 1 + assert isinstance(tree.body[0], ast.AnnAssign) + assert tree.body[0].target.id == "SITE_BASELINE" + + parsed = ast.literal_eval(tree.body[0].value) + assert parsed == { + "hooks.py": ("subprocess.run:shell",), + "tools.py": ("subprocess.run", "subprocess.run"), + } + + +def test_emitted_baseline_round_trips_to_a_clean_check(tmp_path, monkeypatch, capsys): + """What --emit-baseline prints is exactly what makes --check pass.""" + root = _fixture(tmp_path, {"tools.py": _SPAWNS_TWICE, "hooks.py": _SPAWNS_SHELL}) + _pin_sites(monkeypatch, {}) + + assert inventory.main([str(root), "--emit-baseline"]) == 0 + emitted = ast.literal_eval(ast.parse(capsys.readouterr().out).body[0].value) + + _pin_sites(monkeypatch, emitted) + assert inventory.main([str(root), "--check"]) == 0 + + +def test_emit_baseline_refuses_a_partial_scan(tmp_path, monkeypatch, capsys): + """Regenerating from a scan that skipped a file would pin a lie.""" + root = _fixture(tmp_path, {"broken.py": _BROKEN, "tools.py": _SPAWNS}) + _pin_sites(monkeypatch, {}) + + assert inventory.main([str(root), "--emit-baseline"]) == 2 + + err = capsys.readouterr().err + assert "error:" in err + assert "hint:" in err + assert "Traceback" not in err + + +# --- the committed baseline itself ----------------------------------------- + + +def test_committed_site_baseline_is_pinned_and_classified(): + """An empty committed baseline would make the site check silently inert.""" + assert COMMITTED_SITE_BASELINE, "SITE_BASELINE is empty — per-site pinning is off" + assert set(COMMITTED_SITE_BASELINE) <= set(inventory.ALLOWLIST) + + +def test_committed_site_baseline_reproduces_the_pinned_totals(): + """The 21/15/2 figures live in the committed data, not in prose. + + CLAUDE.md quotes these numbers; they are derived from the scanner, so the + baseline is where they have to hold. + """ + sites = [sig for sigs in COMMITTED_SITE_BASELINE.values() for sig in sigs] + + assert len(sites) == 21 + assert len(COMMITTED_SITE_BASELINE) == 15 + assert len([s for s in sites if s.endswith(":shell")]) == 2 + + +def test_committed_site_baseline_signatures_are_known_spawn_calls(): + """A typo in the baseline must not quietly pin a call nobody detects.""" + for sigs in COMMITTED_SITE_BASELINE.values(): + for sig in sigs: + call = sig[: -len(":shell")] if sig.endswith(":shell") else sig + assert call in inventory._SPAWN_CALLS, f"{call} is not a detected spawn API" + + +def test_debt_remaining_is_derived_from_the_allowlist_not_the_baseline(tmp_path, monkeypatch): + """The published counter must keep coming from the observed scan. + + Per-site pinning adds a second baseline; it must not become the source of + `debt_remaining`, which is ALLOWLIST ∩ what the scan actually observed. + """ + root = _fixture(tmp_path, {"tools.py": _SPAWNS, "background.py": _SPAWNS}) + _pin_sites(monkeypatch, {"tools.py": ("subprocess.run",) * 99}) + + inv = inventory.scan(root) + + assert inv.debt_modules == {"tools.py"} + + +# --- issue #7 finding 1: the spawn set was a 12-entry literal -------------- + + +@pytest.mark.parametrize( + ("source", "call"), + [ + # The one-liner that defeated the whole gate. + ("import subprocess\nsubprocess.getoutput('true')\n", "subprocess.getoutput"), + ( + "import subprocess\nsubprocess.getstatusoutput('true')\n", + "subprocess.getstatusoutput", + ), + ("import os\nos.posix_spawn('/bin/true', ['true'], {})\n", "os.posix_spawn"), + ("import os\nos.posix_spawnp('true', ['true'], {})\n", "os.posix_spawnp"), + ("import os\nos.execve('/bin/true', ['true'], {})\n", "os.execve"), + ("import os\nos.execlp('true', 'true')\n", "os.execlp"), + ("import os\nos.spawnl(os.P_WAIT, '/bin/true', 'true')\n", "os.spawnl"), + ("import os\nos.spawnvp(os.P_WAIT, 'true', ['true'])\n", "os.spawnvp"), + ("import os\nos.fork()\n", "os.fork"), + ("import os\nos.forkpty()\n", "os.forkpty"), + ("import os\nos.popen2('true')\n", "os.popen2"), + ("import pty\npty.spawn(['true'])\n", "pty.spawn"), + ("import pty\npty.fork()\n", "pty.fork"), + ( + "import multiprocessing\nmultiprocessing.Process(target=len)\n", + "multiprocessing.Process", + ), + ("import runpy\nrunpy.run_module('this')\n", "runpy.run_module"), + ("import runpy\nrunpy.run_path('setup.py')\n", "runpy.run_path"), + ( + "import concurrent.futures\nconcurrent.futures.ProcessPoolExecutor()\n", + "concurrent.futures.ProcessPoolExecutor", + ), + ], +) +def test_widened_spawn_apis_are_detected(tmp_path, source, call): + """Every API named in issue #7 finding 1 now resolves to a finding.""" + root = _fixture(tmp_path, {"rogue.py": source}) + + inv = inventory.scan(root) + + assert [f.call for f in inv.findings] == [call] + assert inventory.main([str(root), "--check"]) == 1 + + +@pytest.mark.parametrize( + ("source", "call"), + [ + # The sharpest instance: a call the author explicitly tried to cover, + # slipping through on its fully-qualified submodule path. + ( + "import asyncio.subprocess\n" "asyncio.subprocess.create_subprocess_exec('true')\n", + "asyncio.create_subprocess_exec", + ), + ( + "from asyncio import subprocess as aiosp\naiosp.create_subprocess_shell('true')\n", + "asyncio.create_subprocess_shell", + ), + ( + "import multiprocessing.pool\nmultiprocessing.pool.Pool()\n", + "multiprocessing.Pool", + ), + ( + "from concurrent import futures\nfutures.ProcessPoolExecutor()\n", + "concurrent.futures.ProcessPoolExecutor", + ), + ], +) +def test_submodule_paths_resolve_by_module_prefix(tmp_path, source, call): + """Matching is on the module prefix, so submodule spellings still land.""" + root = _fixture(tmp_path, {"rogue.py": source}) + + assert [f.call for f in inventory.scan(root).findings] == [call] + + +@pytest.mark.parametrize( + "source", + [ + # A same-named attribute on a module that is not a spawn source. + "import os\nos.path.join('a', 'b')\n", + "import os\nos.getcwd()\n", + "import os\nos.environ.get('PATH')\n", + # A package whose name merely starts with a spawn module's name. + "import ostrich\nostrich.system('true')\n", + "import subprocesses\nsubprocesses.run(['true'])\n", + ], +) +def test_module_prefix_matching_does_not_over_reach(tmp_path, source): + """Prefix matching is dotted-boundary aware — `ostrich` is not `os`.""" + assert inventory.scan(_fixture(tmp_path, {"rogue.py": source})).findings == [] + + +def test_star_imported_spawn_names_still_resolve(tmp_path): + """`from subprocess import *` then a bare `run(...)` is still a spawn.""" + root = _fixture(tmp_path, {"rogue.py": "from subprocess import *\nrun(['true'])\n"}) + + assert [f.call for f in inventory.scan(root).findings] == ["subprocess.run"] + + +# --- issue #7 finding 6: the gate cried wolf on ordinary code -------------- +# +# "A gate that cries wolf on a variable named `os` gets disabled, which is its +# own failure mode." Each fixture below was reported as a spawn and creates no +# process. None of them may produce a finding. + + +_FALSE_POSITIVES = { + # 1. A *parameter* named `os`, on an object that merely has a .system(). + "parameter named os": ( + "def probe(os):\n" + " return os.system\n" + "\n" + "\n" + "def probe_call(os):\n" + " return os.system('linux')\n" + ), + # 2. The same, in a module that legitimately imports os elsewhere: the + # parameter shadows the import inside that function. + "parameter shadows a real import": ( + "import os\n" + "\n" + "\n" + "def cwd():\n" + " return os.getcwd()\n" + "\n" + "\n" + "def probe(os):\n" + " return os.system('linux')\n" + ), + # 3. `os = self.os` — an attribute copied into a local. + "local assigned from an attribute": ( + "class Host:\n" + " def probe(self):\n" + " os = self.os\n" + " return os.system('linux')\n" + ), + # 4. A loop variable named `os`. + "loop variable named os": ( + "def probe(hosts):\n" " for os in hosts:\n" " os.system('linux')\n" + ), + # 5. A vendored shim that happens to be spelled `subprocess`. + "vendored compat shim": ("from mypkg.compat import subprocess\n\nsubprocess.run(['true'])\n"), + # 6. A context manager bound to a spawn-module name. + "with-target named subprocess": ( + "def probe(conn):\n" + " with conn.session() as subprocess:\n" + " subprocess.run(['true'])\n" + ), + # 7. A nested def shadowing a real `from subprocess import run`. + "nested def shadows the import": ( + "from subprocess import run\n" + "\n" + "\n" + "def outer():\n" + " def run(cmd):\n" + " return cmd\n" + "\n" + " return run(['true'])\n" + ), + # 8. An except-target named after a spawn call. + "except-target named run": ( + "from subprocess import run\n" + "\n" + "\n" + "def probe(step):\n" + " try:\n" + " step()\n" + " except ValueError as run:\n" + " run(['true'])\n" + ), +} + + +@pytest.mark.parametrize("source", _FALSE_POSITIVES.values(), ids=_FALSE_POSITIVES.keys()) +def test_ordinary_code_is_not_a_finding(tmp_path, source): + """Issue #7 finding 6: none of these create a process.""" + root = _fixture(tmp_path, {"rogue.py": source}) + + inv = inventory.scan(root) + + assert inv.findings == [] + assert inventory.main([str(root), "--check"]) == 0 + + +def test_scope_blind_bindings_no_longer_inflate_the_count(tmp_path): + """The reported case: 2 sites where only 1 was real. + + One legitimate `from subprocess import run` made *every* later local named + `run` a finding — inflating spawn_sites, and with it `debt_remaining`. + """ + source = ( + "from subprocess import run\n" + "\n" + "\n" + "def real():\n" + " return run(['true'])\n" + "\n" + "\n" + "def fake(steps):\n" + " run = steps.pop()\n" + " return run(['true'])\n" + ) + root = _fixture(tmp_path, {"tools.py": source}) + + inv = inventory.scan(root) + + assert [(f.line, f.call) for f in inv.findings] == [(5, "subprocess.run")] + + +def test_a_function_local_import_still_binds_inside_that_function(tmp_path): + """Scope awareness must not lose the nested-import case #6 fixed.""" + source = "def go():\n import subprocess\n\n subprocess.run(['true'])\n" + root = _fixture(tmp_path, {"tools.py": source}) + + assert [f.call for f in inventory.scan(root).findings] == ["subprocess.run"] + + +def test_a_function_local_import_does_not_leak_to_its_siblings(tmp_path): + """...and it binds only inside that function.""" + source = ( + "def go():\n" + " import subprocess\n" + "\n" + " return subprocess\n" + "\n" + "\n" + "def elsewhere(subprocess):\n" + " return subprocess.run(['true'])\n" + ) + root = _fixture(tmp_path, {"tools.py": source}) + + assert inventory.scan(root).findings == [] + + +def test_an_unbound_base_is_no_longer_a_finding(tmp_path): + """The fall-through is gone: resolution comes from real bindings only. + + This is a deliberate false-NEGATIVE trade. `subprocess.run(...)` in a module + that never imported `subprocess` is a NameError at runtime, so the case it + used to catch is not real code — while the false positives it produced were. + """ + root = _fixture(tmp_path, {"rogue.py": "subprocess.run(['true'])\n"}) + + assert inventory.scan(root).findings == [] + + +def test_a_class_body_binding_does_not_reach_its_methods(tmp_path): + """Class scope is not enclosing scope — Python's rule, and the scanner's.""" + source = ( + "import subprocess\n" + "\n" + "\n" + "class Runner:\n" + " subprocess = None\n" + "\n" + " def go(self):\n" + " return subprocess.run(['true'])\n" + ) + root = _fixture(tmp_path, {"tools.py": source}) + + assert [f.call for f in inventory.scan(root).findings] == ["subprocess.run"] + + +def test_comprehension_targets_do_not_shadow_the_enclosing_module(tmp_path): + """A comprehension has its own scope; `os` outside it is still `os`.""" + source = "import os\n\nnames = [os for os in ('a', 'b')]\nos.system('true')\n" + root = _fixture(tmp_path, {"tools.py": source}) + + assert [f.call for f in inventory.scan(root).findings] == ["os.system"] + + +def test_findings_are_ordered_by_line_within_a_module(tmp_path): + """Reproducibility: the report must not depend on AST walk order.""" + root = _fixture(tmp_path, {"tools.py": _SPAWNS_TWICE}) + + assert [f.line for f in inventory.scan(root).findings] == [5, 6] + + +# --- fixture hygiene ------------------------------------------------------- + + +def test_fixture_sources_stay_benign(): + """No fixture in this file may carry a destructive or networked payload. + + These fixtures are source text for an AST parser and are never executed, so + a dangerous argv would buy nothing and cost a reader's trust. Issue #7's + illustrative payloads stay out of the repository. The needles are assembled + from pieces so this assertion does not match itself. + """ + text = Path(__file__).read_text(encoding="utf-8") + banned = [ + "rm" + " -rf", + "/etc/" + "shadow", + "shutil." + "rmtree", + "http" + "://", + "curl" + " ", + "sudo" + " ", + ] + + assert [needle for needle in banned if needle in text] == [] From 2b07270a921af82ba13b156a210c8d330e0fe8b4 Mon Sep 17 00:00:00 2001 From: Ori Nachum Date: Tue, 28 Jul 2026 10:38:00 +0300 Subject: [PATCH 07/20] t7: the `shell fs` CLI noun group, plus a confined fs.stat handler MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Six verbs — read, list, stat, write, edit, media — fronting the registered fs.* operations through `shell.operations.execute()`. The CLI is a front end over the identical engine, not a second implementation: this module opens no file, resolves no path, and decides nothing about the root. Two tests enforce that rather than trusting it — each verb's structured output is compared against a result the test produced by calling execute() itself, and the operation ids the CLI reports are retrieved through `shell operation show`, which reads the evidence record only execute() writes. New handler `shell/fs/stat.py` (`fs.stat`, intent=OBSERVE): existence, kind, size_bytes, mtime and permission bits. Two decisions are deliberate and pinned by tests: * a missing path is a SUCCEEDED observation reporting `exists: false`, not a failure — absence is one of the answers a stat exists to give. This diverges from fs.read on purpose, and the divergence is pinned so nobody "fixes" it; * confinement resolves, description does not. `_safe_path` resolves (following symlinks) and refuses anything landing outside the work root, with a byte-identical message to fs.read's — asserted as equality, so the two ports cannot drift. `lstat` on the literal path then describes what is actually there, so an in-root symlink is reported as a symlink rather than silently described as its target. Doing either half the other way round would be a real defect. Preview by default: write and edit carry apply=True only when --apply is given. A preview reports status `previewed`, never `succeeded`; text mode adds a stderr diagnostic naming the remedy. Exit code is 0 because the CLI did what was asked — "did the write happen" is the status field's question, not the exit code's, and the two are not conflated. Streams: in --json mode the full structured result reaches stdout on every path including failures, so an agent never has to re-run a mutation to learn its outcome. In text mode a failure writes nothing to stdout — its rendering IS the error message, and `shell fs read missing.txt > out.txt` must not land "no such file" in out.txt looking like file content. Also: `KIND` constant added to shell/fs/media.py so the CLI references each handler's own kind instead of re-spelling a literal; catalog entries for all eight fs paths; and the Status/Verbs prose in the explain root, `learn` and `overview` updated — they claimed the fs group was not exposed, which this commit makes false. README.md and CLAUDE.md carry the same stale claim and are owned by a sibling task this wave; they are deliberately untouched here. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01EkC4zsVUmYrRCwSWSe4aLp --- shell/cli/__init__.py | 2 + shell/cli/_commands/fs.py | 488 +++++++++++++++++++++ shell/cli/_commands/learn.py | 43 +- shell/cli/_commands/overview.py | 9 +- shell/cli/_commands/policy.py | 5 +- shell/explain/catalog.py | 131 +++++- shell/fs/media.py | 7 +- shell/fs/stat.py | 211 +++++++++ tests/test_cli_fs.py | 736 ++++++++++++++++++++++++++++++++ tests/test_fs_stat.py | 325 ++++++++++++++ 10 files changed, 1932 insertions(+), 25 deletions(-) create mode 100644 shell/cli/_commands/fs.py create mode 100644 shell/fs/stat.py create mode 100644 tests/test_cli_fs.py create mode 100644 tests/test_fs_stat.py diff --git a/shell/cli/__init__.py b/shell/cli/__init__.py index cd5c931..bc70023 100644 --- a/shell/cli/__init__.py +++ b/shell/cli/__init__.py @@ -65,6 +65,7 @@ def _build_parser() -> argparse.ArgumentParser: from shell.cli._commands import cli as _cli_group from shell.cli._commands import doctor as _doctor_cmd from shell.cli._commands import explain as _explain_cmd + from shell.cli._commands import fs as _fs_group from shell.cli._commands import learn as _learn_cmd from shell.cli._commands import operation as _operation_group from shell.cli._commands import overview as _overview_cmd @@ -94,6 +95,7 @@ def _build_parser() -> argparse.ArgumentParser: _overview_cmd.register(sub) _doctor_cmd.register(sub) _cli_group.register(sub) + _fs_group.register(sub) _policy_group.register(sub) _operation_group.register(sub) # Register your own noun groups here: diff --git a/shell/cli/_commands/fs.py b/shell/cli/_commands/fs.py new file mode 100644 index 0000000..7324abd --- /dev/null +++ b/shell/cli/_commands/fs.py @@ -0,0 +1,488 @@ +"""``shell fs`` — the confined filesystem verbs, fronting the operation engine. + +Six verbs: ``read``, ``list``, ``stat``, ``write``, ``edit``, ``media``. Each +one builds an :class:`~shell.operations.Operation` and hands it to +:func:`shell.operations.execute` — **the same function an importing consumer +calls, with no second code path**. This module contains no filesystem logic +whatsoever: it does not open a file, resolve a path, or decide what is inside +the root. Every one of those questions is answered by the registered ``fs.*`` +handler, through the one lifecycle pipeline, exactly as it would be for +colleague. + +That is the whole point of this noun group, and it is checked mechanically +rather than trusted: ``tests/test_cli_fs.py`` compares each verb's structured +output against a result the test produced by calling ``execute()`` itself, and +retrieves the CLI's own operation ids through ``shell operation show`` — which +reads the evidence record only ``execute()`` writes. A verb that shortcut to a +handler would produce output but leave no record to find. + +Preview by default +------------------ + +``write`` and ``edit`` declare ``intent=MUTATE``, so the pipeline previews them +unless the operation carries ``apply=True``. The CLI's ``--apply`` flag is the +only thing that sets it. **A preview is reported with its own status, +``previewed``, never as success** — the status field says so, the rendering +says so, and in text mode a diagnostic naming the remedy goes to stderr. The +process exit code is ``0`` because the CLI did exactly what was asked; a +consumer deciding "did the write happen" reads ``status``, not the exit code, +and the two questions are deliberately not conflated. ``read``, ``list``, +``stat`` and ``media`` are observations and execute immediately — previewing a +read would be theatre. + +Exit codes +---------- + +``0`` for a succeeded or previewed operation, ``1`` for one the engine reported +``failed`` or ``denied`` (a bad path, a missing file, a write into a declared +read-only subtree), ``2`` for an environment problem — a ``--root`` that is not +a directory. In ``--json`` mode a failed operation still emits its full +structured result on stdout before the error goes to stderr, so an agent never +has to choose between the exit code and the outcome. In text mode a failure +writes nothing to stdout: its rendering is the error message, and that belongs +on stderr only. + +Confinement, stated exactly +--------------------------- + +Every ``path`` argument is confined to the work root by the handler: a ``..`` +escape and a symlink whose target lands outside are both refused. That +confinement is real for these structured operations. It is not process +isolation, and nothing in this noun group spawns a process. + +One input channel is deliberately outside it and is called out here rather than +left for a reader to notice: ``--content-stdin`` takes the content of a +``write`` from the operator's own stdin. That is an operator channel, the same +one a shell redirect uses, and it is not a path the confinement applies to. It +is stdin specifically, and not a ``--content-file PATH`` flag, so that this +surface offers no way to name an arbitrary host file and copy it into the work +root. + +No operator policy is loaded +---------------------------- + +These verbs configure no :class:`~shell.policy.Policy`, and that is not an +omission. The ``run_command`` gate has jurisdiction only over kinds under +:data:`shell.operations.GATED_KIND_PREFIXES` (``process.``); ``fs.*`` is +deliberately outside it, so a policy passed here could not change any verdict +in this noun group. See ``shell policy explain``, which reports every ``fs.*`` +kind as ``ungated`` explicitly. The confinement that *does* apply to a write is +:func:`shell.policy.check_write` against the environment's declared read-only +subtrees, which ``--read-only-path`` populates. +""" + +from __future__ import annotations + +import argparse +import sys +from pathlib import Path +from typing import Any + +from shell import operations +from shell.cli._commands.overview import emit_overview +from shell.cli._errors import EXIT_ENV_ERROR, EXIT_USER_ERROR, CliError +from shell.cli._output import emit_diagnostic, emit_result +from shell.environment import Environment +from shell.evidence import EvidenceStore +from shell.operations import Operation +from shell.results import OperationResult, OperationStatus +from shell.runners.host import HostRunner + +# Importing each handler module is what registers its kind (see +# ``shell/fs/__init__.py`` — the package marker is deliberately empty and +# consumers import handlers explicitly). Registration is idempotent per +# interpreter, so importing the same module from a test as well is never a +# double-registration error. +import shell.fs.list # noqa: E402 isort:skip +import shell.fs.media # noqa: E402 isort:skip +import shell.fs.read # noqa: E402 isort:skip +import shell.fs.stat # noqa: E402 isort:skip +import shell.fs.write # noqa: E402 isort:skip + +_JSON_HELP = "Emit structured JSON." + +#: Verb -> the operation kind it dispatches. Taken from each handler module's +#: own constant rather than re-spelled here, so a kind cannot be renamed in one +#: place and left stale in the other. ``tests/test_cli_fs.py`` additionally +#: asserts every value is a registered kind. +KINDS = { + "read": shell.fs.read.KIND, + "list": shell.fs.list.KIND, + "stat": shell.fs.stat.KIND, + "write": shell.fs.write.WRITE_KIND, + "edit": shell.fs.write.EDIT_KIND, + "media": shell.fs.media.KIND, +} + +_NOUN_SECTIONS = [ + { + "title": "Verbs", + "items": [ + "fs read — read a UTF-8 text file, line-numbered and bounded", + "fs list [path] — list a directory's entries (default: the work root)", + "fs stat — describe one path: existence, kind, size, mtime, mode", + "fs write --content STR [--apply] — write a file (previews by default)", + "fs edit --old-string S --new-string S [--apply] — replace text " + "in an existing file (previews by default)", + "fs media — load a workspace image as a neutral media part", + "fs overview — describe the fs noun (this command)", + ], + }, + { + "title": "Semantics", + "items": [ + "every verb dispatches through shell.operations.execute() — the same " + "pipeline an importing consumer uses, never a second implementation", + "write and edit preview by default; --apply is the only thing that " + "sets apply=True on the operation", + "a preview reports status 'previewed', never 'succeeded' — read the " + "status field, not the exit code, to learn whether it happened", + "reads (read/list/stat/media) execute immediately; previewing an " + "observation would be theatre", + "--root selects the work root every path is confined to (default: " + "the current working directory)", + "--evidence-dir opts into a persisted evidence record, retrievable " + "with 'shell operation show '", + ], + }, + { + "title": "Confinement", + "items": [ + "paths are confined to the work root: a '..' escape and a symlink " + "whose target lands outside are both refused", + "that confinement is real for these structured operations; it is " + "not process isolation, and no verb here spawns a process", + "--read-only-path declares a subtree inside the root that may be " + "read but never written (fs.write/fs.edit are denied there)", + "fs.* is outside the run_command policy's jurisdiction, so these " + "verbs load no operator policy — see 'shell policy explain'", + ], + }, +] + + +# --- environment + evidence --------------------------------------------------- + + +def _environment(args: argparse.Namespace) -> Environment: + """Build the :class:`~shell.environment.Environment` these verbs operate in. + + ``source_root`` and ``work_root`` are the same directory. The separation + between them exists so that an operation cannot rewrite its own active + authorization by editing a file it is allowed to write — and it buys + nothing here, because these verbs read no policy from the source root at + all. Claiming a separation that carries no guarantee would be worse than + not having one: :attr:`~shell.environment.Environment.roots_are_separate` + reports ``False`` and every evidence record says so. + """ + raw = getattr(args, "root", None) + root = Path(raw).expanduser() if raw is not None else Path.cwd() + if not root.is_dir(): + raise CliError( + code=EXIT_ENV_ERROR, + message=f"--root is not an existing directory: {root}", + remediation="point --root at a directory that exists (default: the current directory)", + ) + + # A relative --read-only-path is resolved against the WORK ROOT, not the + # process cwd. Read-only paths name subtrees inside the root being + # operated on, and resolving them anywhere else would silently protect a + # directory the caller never meant. + declared = getattr(args, "read_only_path", None) or () + read_only = tuple(p if Path(p).is_absolute() else root / p for p in declared) + + return Environment( + source_root=root, + work_root=root, + runner=HostRunner(), + read_only_paths=read_only, + ) + + +def _store(args: argparse.Namespace) -> EvidenceStore | None: + """The evidence store, when the caller opted into one. + + Persistence stays opt-in here for the same reason it is opt-in in the + library: writing files under someone's tree on the first invocation is a + side effect nobody asked for. + """ + directory = getattr(args, "evidence_dir", None) + return None if directory is None else EvidenceStore(directory=Path(directory)) + + +# --- dispatch ----------------------------------------------------------------- + + +def _emit(result: OperationResult, *, json_mode: bool) -> int: + """Render *result*, then translate its status into an exit code. + + **In JSON mode the structured result goes to stdout on every path, failures + included.** An agent that asked for ``--json`` and received only + ``{"code": 1, ...}`` on stderr would have to re-run the operation to learn + what actually happened, and for a mutation that is not a safe thing to ask + of it. + + **In text mode a failure writes nothing to stdout.** Its rendering *is* the + error message, which is already going to stderr, and ``shell fs read + missing.txt > out.txt`` must not land ``no such file: missing.txt`` in + ``out.txt`` looking exactly like file content. Text stdout carries the + thing the caller asked for, or nothing. + """ + terminal = result.succeeded or result.status is OperationStatus.PREVIEWED + if json_mode: + emit_result(result.to_dict(), json_mode=True) + elif terminal: + emit_result(result.rendering, json_mode=False) + + if result.succeeded: + return 0 + + if result.status is OperationStatus.PREVIEWED: + if not json_mode: + # Diagnostics belong on stderr, so `shell fs read x > out` and its + # siblings stay clean. In JSON mode the `status` field already + # carries this and a prose line would only be noise. + emit_diagnostic( + "note: previewed — nothing was applied. Re-run with --apply to carry it out." + ) + return 0 + + if result.status is OperationStatus.DENIED: + raise CliError( + code=EXIT_USER_ERROR, + message=result.error or "the operation was denied", + remediation=( + "the operation was refused, not attempted; see the policy verdict in " + "the --json result. A write inside a declared --read-only-path is " + "never permitted." + ), + ) + + raise CliError( + code=EXIT_USER_ERROR, + message=result.error or f"the operation reported status {result.status.value!r}", + remediation=( + "check the path and --root; add --json for the full structured result " + "(status, verdict, effects) on stdout" + ), + ) + + +def _run(args: argparse.Namespace, verb: str, arguments: dict[str, Any]) -> int: + """Build the operation and put it through the one pipeline.""" + environment = _environment(args) + operation = Operation( + kind=KINDS[verb], + arguments=arguments, + # Only --apply sets this. A mutation without it previews; an + # observation ignores it (its intent exempts it from the gate). + apply=bool(getattr(args, "apply", False)), + caller={"agent": "shell-cli", "tool": f"shell fs {verb}"}, + ) + result = operations.execute(operation, environment, evidence_store=_store(args)) + return _emit(result, json_mode=bool(getattr(args, "json", False))) + + +# --- verbs --------------------------------------------------------------------- + + +def cmd_fs_read(args: argparse.Namespace) -> int: + return _run(args, "read", {"path": args.path}) + + +def cmd_fs_list(args: argparse.Namespace) -> int: + return _run(args, "list", {"path": args.path}) + + +def cmd_fs_stat(args: argparse.Namespace) -> int: + return _run(args, "stat", {"path": args.path}) + + +def _content(args: argparse.Namespace) -> str: + """The content for a ``write``: a literal string, stdin, or empty. + + Empty is the default rather than an error, matching the handler's own + ``arguments.get("content", "")`` and the first consumer's ``write_file``: + creating an empty file is a legitimate thing to ask for. + """ + if args.content is not None: + return args.content + if args.content_stdin: + return sys.stdin.read() + return "" + + +def cmd_fs_write(args: argparse.Namespace) -> int: + return _run(args, "write", {"path": args.path, "content": _content(args)}) + + +def cmd_fs_edit(args: argparse.Namespace) -> int: + return _run( + args, + "edit", + { + "path": args.path, + "old_string": args.old_string, + "new_string": args.new_string, + "replace_all": bool(args.replace_all), + }, + ) + + +def cmd_fs_media(args: argparse.Namespace) -> int: + return _run(args, "media", {"path": args.path}) + + +def cmd_fs_overview(args: argparse.Namespace) -> int: + emit_overview( + "shell fs", + _NOUN_SECTIONS, + json_mode=bool(getattr(args, "json", False)), + ) + return 0 + + +def _no_verb(args: argparse.Namespace) -> int: + return cmd_fs_overview(args) + + +# --- wiring --------------------------------------------------------------------- + + +def _add_common_args(p: argparse.ArgumentParser) -> None: + p.add_argument( + "--root", + type=Path, + default=None, + metavar="DIR", + help="Work root every path is confined to (default: the current directory).", + ) + p.add_argument( + "--evidence-dir", + type=Path, + default=None, + metavar="DIR", + help=( + "Persist this operation's evidence record into DIR (opt-in; retrieve it " + "with 'shell operation show ')." + ), + ) + p.add_argument("--json", action="store_true", help=_JSON_HELP) + + +def _add_mutation_args(p: argparse.ArgumentParser) -> None: + p.add_argument( + "--apply", + action="store_true", + help="Carry the mutation out. Without it the operation is previewed and nothing changes.", + ) + p.add_argument( + "--read-only-path", + action="append", + metavar="PATH", + help=( + "Declare a subtree that may be read but never written (repeatable). " + "Relative paths resolve against --root." + ), + ) + + +def register(sub: argparse._SubParsersAction) -> None: + p = sub.add_parser( + "fs", + help="Confined filesystem operations (see 'shell fs overview').", + ) + p.add_argument("--json", action="store_true", help=_JSON_HELP) + p.set_defaults(func=_no_verb, json=False) + # `p` is a _CliArgumentParser (the top-level subparsers were built with that + # parser_class); propagate it so every verb under `fs` routes parse errors + # through the structured error contract instead of argparse's raw stderr + # and exit 2. + noun_sub = p.add_subparsers(dest="fs_command", parser_class=type(p)) + + ov = noun_sub.add_parser("overview", help="Describe the fs noun.") + ov.add_argument("--json", action="store_true", help=_JSON_HELP) + ov.set_defaults(func=cmd_fs_overview) + + read = noun_sub.add_parser( + "read", + help="Read a UTF-8 text file, line-numbered and bounded (fs.read).", + ) + read.add_argument("path", help="Path to read, relative to --root.") + _add_common_args(read) + read.set_defaults(func=cmd_fs_read) + + listing = noun_sub.add_parser( + "list", + help="List a directory's entries (fs.list).", + ) + listing.add_argument( + "path", + nargs="?", + default=".", + help="Directory to list, relative to --root (default: the work root).", + ) + _add_common_args(listing) + listing.set_defaults(func=cmd_fs_list) + + stat = noun_sub.add_parser( + "stat", + help="Describe one path: existence, kind, size, mtime, mode (fs.stat).", + ) + stat.add_argument("path", help="Path to describe, relative to --root.") + _add_common_args(stat) + stat.set_defaults(func=cmd_fs_stat) + + write = noun_sub.add_parser( + "write", + help="Write a file — previews unless --apply is given (fs.write).", + ) + write.add_argument("path", help="Path to write, relative to --root.") + content_group = write.add_mutually_exclusive_group() + content_group.add_argument( + "--content", + default=None, + help="File content as a literal string (default: empty).", + ) + content_group.add_argument( + "--content-stdin", + action="store_true", + help="Read the file content from stdin — the operator channel for multi-line content.", + ) + _add_mutation_args(write) + _add_common_args(write) + write.set_defaults(func=cmd_fs_write) + + edit = noun_sub.add_parser( + "edit", + help="Replace text in an existing file — previews unless --apply (fs.edit).", + ) + edit.add_argument("path", help="Path to edit, relative to --root.") + edit.add_argument( + "--old-string", + required=True, + dest="old_string", + help="Exact text to replace, including whitespace and indentation.", + ) + edit.add_argument( + "--new-string", + required=True, + dest="new_string", + help="Replacement text.", + ) + edit.add_argument( + "--replace-all", + action="store_true", + help="Replace every occurrence. Without it a non-unique old-string is refused.", + ) + _add_mutation_args(edit) + _add_common_args(edit) + edit.set_defaults(func=cmd_fs_edit) + + media = noun_sub.add_parser( + "media", + help="Load a workspace image as a neutral media part (fs.media).", + ) + media.add_argument("path", help="Image to load, relative to --root.") + _add_common_args(media) + media.set_defaults(func=cmd_fs_media) diff --git a/shell/cli/_commands/learn.py b/shell/cli/_commands/learn.py index e22b2c2..f1a6527 100644 --- a/shell/cli/_commands/learn.py +++ b/shell/cli/_commands/learn.py @@ -31,13 +31,14 @@ Status ------ -The library has the operation core: fs.read/list/write/edit/media, the policy -evaluator, the evidence contract, and HostRunner execution are built and green. -The CLI is catching up: `policy` and `operation` are now real CLI nouns -- -`policy check`/`policy explain` evaluate the same policy gate execute() uses, -`operation show` retrieves persisted evidence. The env/fs/process/git verb -groups are not exposed yet -- no CLI verb executes fs.read or a shell command -itself, only ones that introspect the policy/evidence around that execution. +The library has the operation core: fs.read/list/stat/write/edit/media, the +policy evaluator, the evidence contract, and HostRunner execution are built and +green. The CLI is catching up: `fs`, `policy` and `operation` are real CLI +nouns -- `shell fs` executes the confined filesystem operations through the same +execute() pipeline the library uses, `policy check`/`policy explain` evaluate +the same policy gate that pipeline gates through, and `operation show` +retrieves persisted evidence. The env/process/git verb groups are not exposed +yet -- no CLI verb runs a shell command. See https://github.com/agentculture/shell-cli/issues/1 Commands @@ -48,10 +49,19 @@ shell overview Descriptive snapshot of the agent. shell doctor Check the agent-identity invariants. shell cli overview Describe the CLI surface itself. + shell fs read|list|stat Confined filesystem observations. + shell fs write|edit Confined mutations -- preview unless --apply. + shell fs media Load a workspace image as a neutral media part. shell policy check Evaluate the run_command policy gate. shell policy explain List gated/ungated operation kinds. shell operation show Retrieve persisted operation evidence. +Mutation and execution preview by default +----------------------------------------- +`shell fs write` and `shell fs edit` preview unless `--apply` is given. A +preview reports status "previewed", never "succeeded" -- read the status field, +not the exit code, to learn whether the change happened. + Machine-readable output ----------------------- Every command supports --json. Errors in JSON mode emit @@ -88,8 +98,8 @@ def _as_json_payload() -> dict[str, object]: ), "status": ( "the operation core is built in the library (fs primitives, policy, " - "evidence, HostRunner); the CLI now also exposes policy and operation " - "as real nouns -- env/fs/process/git verb groups are still library-only" + "evidence, HostRunner); the CLI exposes fs, policy and operation as " + "real nouns -- env/process/git verb groups are still library-only" ), "commands": [ {"path": ["whoami"], "summary": "Identity probe from culture.yaml."}, @@ -98,6 +108,21 @@ def _as_json_payload() -> dict[str, object]: {"path": ["overview"], "summary": "Descriptive snapshot of the agent."}, {"path": ["doctor"], "summary": "Check the agent-identity invariants."}, {"path": ["cli", "overview"], "summary": "Describe the CLI surface."}, + {"path": ["fs", "read"], "summary": "Read a confined UTF-8 text file."}, + {"path": ["fs", "list"], "summary": "List a confined directory's entries."}, + { + "path": ["fs", "stat"], + "summary": "Describe one confined path: existence, kind, size, mtime, mode.", + }, + { + "path": ["fs", "write"], + "summary": "Write a confined file. Previews unless --apply.", + }, + { + "path": ["fs", "edit"], + "summary": "Replace text in a confined file. Previews unless --apply.", + }, + {"path": ["fs", "media"], "summary": "Load a workspace image as a media part."}, { "path": ["policy", "check"], "summary": "Evaluate the run_command policy gate for one operation.", diff --git a/shell/cli/_commands/overview.py b/shell/cli/_commands/overview.py index c8d63f0..69648ff 100644 --- a/shell/cli/_commands/overview.py +++ b/shell/cli/_commands/overview.py @@ -32,6 +32,9 @@ "explain — markdown docs for a topic", "overview — this descriptive snapshot", "doctor — check the agent-identity invariants", + "fs read / list / stat — confined filesystem observations", + "fs write / edit — confined mutations; preview unless --apply", + "fs media — load a workspace image as a neutral media part", "policy check / policy explain — evaluate and explain the operation-policy gate", "operation show — retrieve persisted evidence for an operation id", ] @@ -45,8 +48,10 @@ ] _STATUS = [ - "policy and operation are real CLI nouns; env/fs/process/git are still " - "library-only — no CLI verb executes fs.read or a shell command itself", + "fs, policy and operation are real CLI nouns; env/process/git are still " + "library-only — no CLI verb runs a shell command yet", + "shell fs executes the confined filesystem operations through the same " + "shell.operations.execute() pipeline the library uses", "the library has the fs primitives, confinement, policy, evidence and HostRunner", "tracking issue: https://github.com/agentculture/shell-cli/issues/1", ] diff --git a/shell/cli/_commands/policy.py b/shell/cli/_commands/policy.py index a51e787..918279a 100644 --- a/shell/cli/_commands/policy.py +++ b/shell/cli/_commands/policy.py @@ -50,8 +50,8 @@ from shell.operations import Operation from shell.policy import FILE_CATEGORIES, Policy, load_policy -# Importing these registers fs.read / fs.list / fs.write / fs.edit / fs.media -# as a side effect (see shell/fs/__init__.py — the package marker is +# Importing these registers fs.read / fs.list / fs.stat / fs.write / fs.edit / +# fs.media as a side effect (see shell/fs/__init__.py — the package marker is # deliberately empty; consumers import each handler module explicitly). This # guarantees `policy explain` reports the real, currently-built kind set # regardless of what else this pytest worker happened to import first — the @@ -61,6 +61,7 @@ import shell.fs.list # noqa: E402,F401 isort:skip import shell.fs.media # noqa: E402,F401 isort:skip import shell.fs.read # noqa: E402,F401 isort:skip +import shell.fs.stat # noqa: E402,F401 isort:skip import shell.fs.write # noqa: E402,F401 isort:skip _JSON_HELP = "Emit structured JSON." diff --git a/shell/explain/catalog.py b/shell/explain/catalog.py index f6b0df5..a69aad5 100644 --- a/shell/explain/catalog.py +++ b/shell/explain/catalog.py @@ -25,17 +25,17 @@ ## Status -The library has the operation core: `fs.read`, `fs.list`, `fs.write`, `fs.edit` -and `fs.media`, path confinement, the policy evaluator, the evidence contract, -and `HostRunner` execution are built and green. - -**The CLI is catching up.** `policy` and `operation` are now real CLI nouns — -`policy check` / `policy explain` evaluate the same policy gate -`shell.operations.execute()` uses, and `operation show` retrieves persisted -evidence. The `env` / `fs` / `process` / `git` verb groups are still not -exposed — there is no CLI verb yet that itself *executes* `fs.read` or a -shell command, only ones that introspect the policy and evidence around that -execution. See . +The library has the operation core: `fs.read`, `fs.list`, `fs.stat`, +`fs.write`, `fs.edit` and `fs.media`, path confinement, the policy evaluator, +the evidence contract, and `HostRunner` execution are built and green. + +**The CLI is catching up.** `fs`, `policy` and `operation` are real CLI nouns. +`shell fs` executes the confined filesystem operations through the same +`shell.operations.execute()` pipeline the library uses; `policy check` / +`policy explain` evaluate the same policy gate that pipeline gates through; and +`operation show` retrieves persisted evidence. The `env` / `process` / `git` +verb groups are still not exposed — no CLI verb runs a shell command yet. See +. ## Safety posture @@ -52,6 +52,9 @@ - `shell overview` — descriptive snapshot of the agent. - `shell doctor` — check the agent-identity invariants. - `shell cli overview` — describe the CLI surface. +- `shell fs read|list|stat|write|edit|media` — confined filesystem operations, + through the same `execute()` pipeline the library uses. `write` and `edit` + preview by default and need `--apply`. - `shell policy check ` — evaluate the run_command policy gate for one operation, through the same evaluator `execute()` uses. - `shell policy explain` — list gated kind prefixes and every known operation @@ -193,6 +196,104 @@ shell cli overview --json """ +_FS = """\ +# shell fs + +Confined filesystem operations: `read`, `list`, `stat`, `write`, `edit`, +`media`. Every verb builds an `Operation` and dispatches it through +`shell.operations.execute()` — the same pipeline an importing consumer uses. +This noun group contains no filesystem logic of its own; it opens no file and +resolves no path. It is a front end, never a second implementation. + +## Verbs + +- `shell fs read ` — read a UTF-8 text file. Lines are numbered + `cat -n` style *before* the output is bounded, so a cited line number is the + file's real one. +- `shell fs list [path]` — list a directory's entries, sorted, with a trailing + `/` on directories. `path` defaults to the work root. +- `shell fs stat ` — describe one path: `exists`, `kind` + (`file`/`directory`/`symlink`/`other`), `size_bytes`, `mtime`, `mode` + (permission bits, octal). **A missing path is a successful observation + reporting `exists: false`, not a failure** — absence is one of the answers + stat exists to give. This is a deliberate divergence from `fs read`, which + fails on a missing file because it has nothing to return. +- `shell fs write [--content STR | --content-stdin] [--apply]` — write a + file. Previews by default. +- `shell fs edit --old-string S --new-string S [--replace-all] + [--apply]` — replace exact text in an existing file. Previews by default. A + non-unique `old-string` is refused unless `--replace-all` is given. +- `shell fs media ` — load a workspace image as a provider-neutral media + part (base64 data URI in the `--json` output; a one-line summary in text + mode). +- `shell fs overview` — describe this noun. + +## Preview by default + +`write` and `edit` are mutations, so the pipeline previews them unless the +operation carries `apply=True`. `--apply` is the only thing that sets it. + +**A preview is reported with its own status, `previewed`, never as success.** +The exit code is `0` because the CLI did what was asked; the question "did the +write happen" is answered by the `status` field, not by the exit code, and the +two are deliberately not conflated. In text mode a diagnostic naming the remedy +goes to stderr. A preview describes what *would* run — it does not predict +effects, so `effects.complete` is `false` and the changed-path list is empty. + +Reads (`read`, `list`, `stat`, `media`) execute immediately; previewing an +observation would be theatre. + +## Confinement + +Every `path` argument is confined to the work root (`--root`, default: the +current directory). A `..` escape and a symlink whose target lands outside the +root are both refused, because the check runs on the *resolved* path. That +confinement is a real guarantee of these structured operations. It is not +process isolation — see `shell explain safety` — and no verb here spawns a +process. + +`--read-only-path PATH` (repeatable, relative to `--root`) declares a subtree +that may be read but never written; `write`/`edit` into it come back `denied`. + +`--content-stdin` takes a write's content from the operator's own stdin. That +is an operator channel, like a shell redirect, and the path confinement does +not apply to it. It is stdin specifically, and not a `--content-file PATH` +flag, so this surface offers no way to name an arbitrary host file and copy it +into the work root. + +## No operator policy is loaded + +These verbs configure no policy, and that is not an omission: the `run_command` +gate has jurisdiction only over `process.*` kinds, so a policy passed here +could change no verdict. `shell policy explain` reports every `fs.*` kind as +`ungated` explicitly. + +## Exit codes + +- `0` — the operation `succeeded`, or was `previewed` +- `1` — the operation came back `failed` or `denied` (bad path, missing file, + a write into a read-only subtree). Under `--json` the full structured result + is still written to stdout first, so it never has to be re-run to learn what + happened; in text mode stdout stays empty and the message goes to stderr + only, so a redirect never captures an error as if it were file content. +- `2` — `--root` is not an existing directory + +## Usage + + shell fs read src/main.py --root . --json + shell fs stat build/artifact.tar.gz --json + shell fs write notes.md --content 'hello' # previews + shell fs write notes.md --content 'hello' --apply # writes + shell fs edit notes.md --old-string hello --new-string hi --apply + shell fs read a.txt --evidence-dir .shell/evidence --json + +## See also + +- `shell explain safety` +- `shell policy explain` +- `shell operation show` — retrieve the record written by `--evidence-dir` +""" + _POLICY = """\ # shell policy @@ -280,6 +381,14 @@ ("doctor",): _DOCTOR, ("cli",): _CLI, ("cli", "overview"): _CLI, + ("fs",): _FS, + ("fs", "overview"): _FS, + ("fs", "read"): _FS, + ("fs", "list"): _FS, + ("fs", "stat"): _FS, + ("fs", "write"): _FS, + ("fs", "edit"): _FS, + ("fs", "media"): _FS, ("policy",): _POLICY, ("policy", "overview"): _POLICY, ("policy", "check"): _POLICY, diff --git a/shell/fs/media.py b/shell/fs/media.py index 9895766..587a591 100644 --- a/shell/fs/media.py +++ b/shell/fs/media.py @@ -21,6 +21,11 @@ __all__ = ["read_media"] +#: The operation kind this module registers. Named the same as every sibling +#: handler's constant so a consumer (the ``shell fs`` CLI group, for one) can +#: reference the kind it dispatches instead of re-spelling the literal. +KIND = "fs.media" + #: Size cap for view_media tool — bounds wire + context cost; a typical #: screenshot is well under it. MAX_MEDIA_BYTES = 4 * 1024 * 1024 @@ -231,7 +236,7 @@ def read_media(operation: Operation, environment: Environment) -> OperationResul # Register the handler on module import using module-level registration. # This ensures the handler is available when the module is imported. _spec = register( - "fs.media", + KIND, intent=OperationIntent.OBSERVE, default_profile=ExecutionProfile.OBSERVE, run=read_media, diff --git a/shell/fs/stat.py b/shell/fs/stat.py new file mode 100644 index 0000000..fff2127 --- /dev/null +++ b/shell/fs/stat.py @@ -0,0 +1,211 @@ +"""``fs.stat``: describe one path's metadata, confined to the environment's work root. + +The structured answer to "what is at this path" — existence, kind, size, last +modification time, permission bits — without reading a byte of content. It has +no counterpart among the six colleague primitives; it is new here, and is the +cheap probe that keeps a caller from having to attempt a read just to discover +whether a file exists or how large it is before spending context on it. + +Three decisions in this module are deliberate and are pinned by +``tests/test_fs_stat.py`` rather than merely described here. + +**A missing path is a SUCCEEDED observation reporting ``exists: false``.** This +is the one place ``fs.stat`` diverges from :mod:`shell.fs.read`, which fails on +a missing file. The divergence follows from what each operation is *for*: a read +has nothing to return when the file is absent, whereas absence is precisely one +of the answers a stat exists to give. Collapsing the two would force every +caller wanting an existence check to parse an error string. + +**Confinement resolves; description does not.** Two different questions are +being asked of the same path, and they need different link behaviour: + +* *May the caller observe this?* — :func:`_safe_path` resolves the path (which + follows every symlink that exists on disk) and refuses anything landing + outside :attr:`~shell.environment.Environment.work_root`. A symlink inside + the root pointing outside it is refused for exactly the same reason a literal + ``../`` escape is, and with exactly the same message ``fs.read`` produces. +* *What is actually here?* — :meth:`~pathlib.Path.lstat` on the **literal** + path, so a symlink that stays inside the root is reported as a ``symlink`` + rather than silently described as whatever it points at. That is safe only + because the confinement check already ran: if resolution landed inside the + root, every link in the chain does too. + +Doing it the other way round in either half would be a real defect — +describing an unresolved path would let a link escape the root, and describing +a resolved one would quietly lie about symlinks. + +**Confinement is not process isolation.** The confinement above is a genuine +guarantee of this handler. It says nothing about the runner's posture, which is +recorded separately on every result's evidence and is ``"none"`` for +:class:`~shell.runners.host.HostRunner`. ``fs.stat`` never spawns a process at +all. + +A malformed path — e.g. one carrying an embedded NUL byte, which makes +``Path.resolve()`` raise a bare ``ValueError`` — is deliberately not modelled +here. It propagates and is converted into a recoverable ``FAILED`` result by +``shell.operations.execute``'s handler-crash wrap, exactly as it is for every +sibling handler. +""" + +from __future__ import annotations + +import os +from pathlib import Path +from stat import S_ISDIR, S_ISLNK, S_ISREG +from typing import Any, Mapping + +from shell.environment import Environment +from shell.operations import ExecutionProfile, Operation, OperationIntent, register +from shell.results import Effects, OperationResult, OperationStatus + +__all__ = ["KIND"] + +#: The operation kind this module registers. +KIND = "fs.stat" + + +class _StatError(Exception): + """A recoverable, model-visible ``fs.stat`` failure. + + Raised only for the error cases this module explicitly models (missing + argument, path escape, an unreadable path). Caught in :func:`_stat` and + turned into a ``FAILED`` result. Anything genuinely unexpected is left to + propagate so the pipeline's handler-crash wrap catches it instead. + """ + + +def _require_path(arguments: Mapping[str, Any]) -> str: + """Fetch the required ``path`` argument or raise a self-correcting error. + + Mirrors colleague's ``_require`` (``tools.py:624-637``) and this package's + other handlers: a missing required argument is a model error, not a harness + bug, and must cost one model-visible ``FAILED`` step rather than a crash. + """ + if "path" not in arguments: + raise _StatError(f"{KIND} requires 'path'") + return str(arguments["path"]) + + +def _safe_path(root: Path, rel: str) -> Path: + """Resolve *rel* under *root*, refusing any path that escapes it. + + The same port of colleague's ``_safe_path`` (``tools.py:730-735``) that + :mod:`shell.fs.read`, :mod:`shell.fs.list`, :mod:`shell.fs.write` and + :mod:`shell.fs.media` each carry, kept here for the reason + ``shell/fs/__init__.py`` states: this package is an empty marker, consumers + import each handler module explicitly, and the confinement helper is + re-ported per handler rather than factored into a shared internal module + that no consumer has asked for. The refusal message is byte-identical to + ``fs.read``'s, and ``tests/test_fs_stat.py`` asserts that equality directly + so the two ports cannot drift apart unnoticed. + + ``Path.resolve()`` follows symlinks that exist on disk before this function + compares the result to *root*, so a symlink inside the root pointing + outside it fails the same containment check as a literal ``../`` escape. + """ + candidate = (root / rel).resolve() + if candidate != root and root not in candidate.parents: + raise _StatError(f"path '{rel}' escapes the work root") + return candidate + + +def _kind(mode: int) -> str: + """Classify a mode word from ``lstat`` — the link itself, never its target.""" + if S_ISLNK(mode): + return "symlink" + if S_ISDIR(mode): + return "directory" + if S_ISREG(mode): + return "file" + return "other" + + +def _absent(rel: str) -> dict[str, Any]: + """The answer for a path that is not there. + + Every metadata key is present and ``None`` rather than omitted, so a + consumer reads one stable shape and never has to distinguish "absent field" + from "absent file". + """ + return { + "path": rel, + "exists": False, + "kind": None, + "size_bytes": None, + "mtime": None, + "mode": None, + } + + +def _present(rel: str, info: os.stat_result) -> dict[str, Any]: + return { + "path": rel, + "exists": True, + "kind": _kind(info.st_mode), + "size_bytes": info.st_size, + "mtime": info.st_mtime, + # Permission bits only, zero-padded octal. The file-type bits are + # already reported, structurally, as ``kind``; repeating them inside a + # mode string would give a consumer two encodings of the same fact. + "mode": format(info.st_mode & 0o7777, "04o"), + } + + +def _observe(root: Path, rel: str) -> dict[str, Any]: + """Confine, then describe. Raises :class:`_StatError` for a refusal.""" + # Confinement first, on the RESOLVED path: a symlink whose target lands + # outside the work root is refused before anything is described. + _safe_path(root, rel) + + # Description second, on the LITERAL path: lstat does not follow the final + # link, so a symlink that stays inside the root is reported as one. Safe + # only because the check above already proved the whole chain resolves + # inside the root. + try: + info = (root / rel).lstat() + except FileNotFoundError: + return _absent(rel) + except OSError as exc: + raise _StatError(f"cannot stat {rel}: {exc}") from exc + return _present(rel, info) + + +def _render(payload: Mapping[str, Any]) -> str: + """A readable field list. Absent paths render two lines, not six null ones.""" + lines = [f"path: {payload['path']}", f"exists: {str(payload['exists']).lower()}"] + if payload["exists"]: + lines += [ + f"kind: {payload['kind']}", + f"size_bytes: {payload['size_bytes']}", + f"mtime: {payload['mtime']}", + f"mode: {payload['mode']}", + ] + return "\n".join(lines) + + +def _stat(operation: Operation, environment: Environment) -> OperationResult: + try: + rel = _require_path(operation.arguments) + payload = _observe(Path(environment.work_root), rel) + except _StatError as exc: + message = str(exc) + return OperationResult( + operation_id=operation.id, + status=OperationStatus.FAILED, + error=message, + rendering=message, + # A refused or failed observation changed nothing, and that empty + # effect list is exhaustively known rather than merely unclaimed. + effects=Effects(complete=True), + ) + + return OperationResult( + operation_id=operation.id, + status=OperationStatus.SUCCEEDED, + output=payload, + rendering=_render(payload), + effects=Effects(complete=True), + ) + + +register(KIND, intent=OperationIntent.OBSERVE, default_profile=ExecutionProfile.OBSERVE, run=_stat) diff --git a/tests/test_cli_fs.py b/tests/test_cli_fs.py new file mode 100644 index 0000000..2214452 --- /dev/null +++ b/tests/test_cli_fs.py @@ -0,0 +1,736 @@ +"""Tests for ``shell fs`` — ``read``, ``list``, ``stat``, ``write``, ``edit``, ``media``. + +The property this file exists to prove is not "does the CLI print something" +but **the CLI is a front end over the same operation engine, not a second +implementation of it**. Two things enforce that here: + +* every verb's structured output is compared against a + :func:`shell.operations.execute` result produced independently by the test, + on the fields that are stable between two runs (status, output payload, + verdict, effects) -- so a CLI that re-derived the behaviour instead of + dispatching would have to reproduce it exactly by accident; +* the operation ids the CLI reports are looked up through ``shell operation + show``, which reads the evidence record ``execute()`` itself wrote. A CLI + bypassing the pipeline would leave no record to find. + +The second load-bearing property is **preview-by-default**: ``write`` and +``edit`` must not touch the filesystem without ``--apply``, and the preview +must report its own status rather than reading as success. + +Every test passes ``--root`` explicitly. A verb defaulting to the process cwd +is correct behaviour for an operator at a terminal and completely wrong for a +test suite, which would otherwise write into the repository checkout. +""" + +from __future__ import annotations + +import json +import os +from pathlib import Path + +import pytest + +import shell.fs.stat # noqa: F401 (registers fs.stat as a real operation kind) +from shell import operations +from shell.cli import main +from shell.cli._commands import fs as fs_group +from shell.environment import Environment +from shell.operations import Operation +from shell.runners.host import HostRunner + +# A byte string with a PNG signature. ``fs.media`` validates by extension and +# base64-encodes whatever bytes it finds -- it never decodes the image -- so a +# real encoder is not needed and would only add a dependency. +_PNG_BYTES = b"\x89PNG\r\n\x1a\n" + b"not-really-an-image" + + +@pytest.fixture +def work(tmp_path: Path) -> Path: + root = tmp_path / "work" + root.mkdir() + return root + + +def _library_result(work: Path, kind: str, arguments: dict, *, apply_: bool = False): + """Run the same operation through the library, for CLI/library comparison.""" + environment = Environment(source_root=work, work_root=work, runner=HostRunner()) + return operations.execute(Operation(kind=kind, arguments=arguments, apply=apply_), environment) + + +# --- overview and noun wiring ------------------------------------------------ + + +def test_fs_bare_prints_overview(capsys: pytest.CaptureFixture[str]) -> None: + rc = main(["fs"]) + assert rc == 0 + out = capsys.readouterr().out + assert "# shell fs" in out + for verb in ("read", "list", "stat", "write", "edit", "media"): + assert verb in out + + +def test_fs_overview_json_shape(capsys: pytest.CaptureFixture[str]) -> None: + rc = main(["fs", "overview", "--json"]) + assert rc == 0 + payload = json.loads(capsys.readouterr().out) + assert payload["subject"] == "shell fs" + assert isinstance(payload["sections"], list) + assert payload["sections"] + + +def test_every_kind_the_cli_fronts_is_a_registered_operation() -> None: + """The CLI must never name a kind the engine does not dispatch.""" + registered = operations.registered_kinds() + for kind in fs_group.KINDS.values(): + assert kind in registered, f"{kind} is not a registered operation kind" + + +# --- read -------------------------------------------------------------------- + + +def test_fs_read_text_emits_the_numbered_rendering( + work: Path, capsys: pytest.CaptureFixture[str] +) -> None: + (work / "a.txt").write_text("alpha\nbeta\n", encoding="utf-8") + + rc = main(["fs", "read", "a.txt", "--root", str(work)]) + assert rc == 0 + captured = capsys.readouterr() + assert captured.out == " 1\talpha\n 2\tbeta\n" + assert captured.err == "", "results go to stdout; stderr stays clean on success" + + +def test_fs_read_json_matches_the_library_result( + work: Path, capsys: pytest.CaptureFixture[str] +) -> None: + (work / "a.txt").write_text("alpha\nbeta\n", encoding="utf-8") + expected = _library_result(work, "fs.read", {"path": "a.txt"}) + + rc = main(["fs", "read", "a.txt", "--root", str(work), "--json"]) + assert rc == 0 + payload = json.loads(capsys.readouterr().out) + + assert payload["status"] == expected.status.value == "succeeded" + assert payload["output"] == dict(expected.output) + assert payload["rendering"] == expected.rendering + assert payload["verdict"] == expected.verdict.to_dict() + assert payload["effects"] == expected.effects.to_dict() + assert payload["operation_id"] + + +def test_fs_read_of_a_missing_file_is_a_structured_user_error( + work: Path, capsys: pytest.CaptureFixture[str] +) -> None: + rc = main(["fs", "read", "nope.txt", "--root", str(work)]) + assert rc == 1 + captured = capsys.readouterr() + assert "no such file" in captured.err + assert captured.err.startswith("error:") + assert "hint:" in captured.err + assert "Traceback" not in captured.err + + +def test_a_failed_operation_still_emits_its_structured_result_in_json_mode( + work: Path, capsys: pytest.CaptureFixture[str] +) -> None: + """A non-zero exit must not cost an agent the structured outcome.""" + rc = main(["fs", "read", "nope.txt", "--root", str(work), "--json"]) + assert rc == 1 + captured = capsys.readouterr() + payload = json.loads(captured.out) + assert payload["status"] == "failed" + assert "no such file" in payload["error"] + # And the error contract still holds on the other stream. + assert json.loads(captured.err)["code"] == 1 + + +def test_a_text_mode_failure_writes_nothing_to_stdout( + work: Path, capsys: pytest.CaptureFixture[str] +) -> None: + """``shell fs read missing.txt > out.txt`` must not land the error in out.txt. + + A failed read's rendering *is* its error message; writing it to stdout as + well would make a redirect capture prose that looks exactly like file + content. Text stdout carries what was asked for, or nothing. + """ + rc = main(["fs", "read", "nope.txt", "--root", str(work)]) + assert rc == 1 + captured = capsys.readouterr() + assert captured.out == "" + assert "no such file" in captured.err + + +# --- list -------------------------------------------------------------------- + + +def test_fs_list_text_lists_entries(work: Path, capsys: pytest.CaptureFixture[str]) -> None: + (work / "a.txt").write_text("x", encoding="utf-8") + (work / "sub").mkdir() + + rc = main(["fs", "list", "--root", str(work)]) + assert rc == 0 + assert capsys.readouterr().out == "a.txt\nsub/\n" + + +def test_fs_list_json_matches_the_library_result( + work: Path, capsys: pytest.CaptureFixture[str] +) -> None: + (work / "a.txt").write_text("x", encoding="utf-8") + (work / "sub").mkdir() + expected = _library_result(work, "fs.list", {"path": "."}) + + rc = main(["fs", "list", "--root", str(work), "--json"]) + assert rc == 0 + payload = json.loads(capsys.readouterr().out) + assert payload["status"] == "succeeded" + assert payload["output"] == dict(expected.output) + assert payload["output"]["entries"] == ["a.txt", "sub/"] + + +def test_fs_list_takes_an_optional_subdirectory( + work: Path, capsys: pytest.CaptureFixture[str] +) -> None: + (work / "sub").mkdir() + (work / "sub" / "inner.txt").write_text("x", encoding="utf-8") + + rc = main(["fs", "list", "sub", "--root", str(work), "--json"]) + assert rc == 0 + payload = json.loads(capsys.readouterr().out) + assert payload["output"]["entries"] == ["inner.txt"] + + +# --- stat -------------------------------------------------------------------- + + +def test_fs_stat_text_reports_metadata(work: Path, capsys: pytest.CaptureFixture[str]) -> None: + (work / "a.txt").write_text("abc", encoding="utf-8") + + rc = main(["fs", "stat", "a.txt", "--root", str(work)]) + assert rc == 0 + out = capsys.readouterr().out + assert "path: a.txt" in out + assert "kind: file" in out + assert "size_bytes: 3" in out + + +def test_fs_stat_json_matches_the_library_result( + work: Path, capsys: pytest.CaptureFixture[str] +) -> None: + (work / "a.txt").write_text("abc", encoding="utf-8") + expected = _library_result(work, "fs.stat", {"path": "a.txt"}) + + rc = main(["fs", "stat", "a.txt", "--root", str(work), "--json"]) + assert rc == 0 + payload = json.loads(capsys.readouterr().out) + assert payload["status"] == "succeeded" + assert payload["output"]["kind"] == expected.output["kind"] == "file" + assert payload["output"]["size_bytes"] == expected.output["size_bytes"] == 3 + assert payload["output"]["mode"] == expected.output["mode"] + + +def test_fs_stat_of_a_missing_path_is_a_successful_exists_false( + work: Path, capsys: pytest.CaptureFixture[str] +) -> None: + rc = main(["fs", "stat", "nope.txt", "--root", str(work), "--json"]) + assert rc == 0, "absence is an answer fs.stat exists to give, not a failure" + payload = json.loads(capsys.readouterr().out) + assert payload["status"] == "succeeded" + assert payload["output"]["exists"] is False + + +@pytest.mark.skipif(os.name == "nt", reason="POSIX symlink semantics") +def test_fs_stat_refuses_a_symlink_that_escapes_the_root( + work: Path, tmp_path: Path, capsys: pytest.CaptureFixture[str] +) -> None: + """The confinement case the literal path string cannot reveal. + + ``link.txt`` names nothing outside the root; resolving it lands outside. + The CLI must surface that refusal as a structured user error, never as a + successful description of a file the caller may not observe. + """ + outside = tmp_path / "outside" + outside.mkdir() + secret = outside / "secret.txt" + secret.write_text("outside content", encoding="utf-8") + os.symlink(secret, work / "link.txt") + + rc = main(["fs", "stat", "link.txt", "--root", str(work)]) + assert rc == 1 + err = capsys.readouterr().err + assert "escapes the work root" in err + assert err.startswith("error:") + assert "hint:" in err + + +@pytest.mark.skipif(os.name == "nt", reason="POSIX symlink semantics") +def test_fs_stat_reports_an_in_root_symlink_as_a_symlink( + work: Path, capsys: pytest.CaptureFixture[str] +) -> None: + (work / "real.txt").write_text("x", encoding="utf-8") + os.symlink(work / "real.txt", work / "link.txt") + + rc = main(["fs", "stat", "link.txt", "--root", str(work), "--json"]) + assert rc == 0 + assert json.loads(capsys.readouterr().out)["output"]["kind"] == "symlink" + + +# --- path confinement, at the CLI seam --------------------------------------- + + +@pytest.mark.parametrize("verb", ["read", "stat"]) +def test_a_relative_escape_is_refused( + verb: str, work: Path, tmp_path: Path, capsys: pytest.CaptureFixture[str] +) -> None: + (tmp_path / "outside.txt").write_text("nope", encoding="utf-8") + + rc = main(["fs", verb, "../outside.txt", "--root", str(work)]) + assert rc == 1 + assert "escapes the work root" in capsys.readouterr().err + + +def test_a_write_escape_is_refused_and_writes_nothing( + work: Path, tmp_path: Path, capsys: pytest.CaptureFixture[str] +) -> None: + target = tmp_path / "outside.txt" + + rc = main(["fs", "write", "../outside.txt", "--content", "x", "--apply", "--root", str(work)]) + assert rc == 1 + assert "escapes the confined root" in capsys.readouterr().err + assert not target.exists() + + +# --- write: preview by default ----------------------------------------------- + + +def test_fs_write_previews_by_default_and_writes_nothing( + work: Path, capsys: pytest.CaptureFixture[str] +) -> None: + target = work / "new.txt" + + rc = main(["fs", "write", "new.txt", "--content", "hello", "--root", str(work), "--json"]) + assert rc == 0 + payload = json.loads(capsys.readouterr().out) + + assert payload["status"] == "previewed" + assert payload["status"] != "succeeded", "a preview is never reported as success" + assert payload["effects"]["complete"] is False + assert payload["effects"]["changed_paths"] == [] + assert not target.exists(), "preview must not touch the filesystem" + + +def test_fs_write_preview_says_so_on_stderr_in_text_mode( + work: Path, capsys: pytest.CaptureFixture[str] +) -> None: + rc = main(["fs", "write", "new.txt", "--content", "hello", "--root", str(work)]) + assert rc == 0 + captured = capsys.readouterr() + assert "preview" in captured.out.lower() + assert "--apply" in captured.err, "the diagnostic naming the remedy goes to stderr" + + +def test_fs_write_applies_with_the_apply_flag( + work: Path, capsys: pytest.CaptureFixture[str] +) -> None: + rc = main( + ["fs", "write", "new.txt", "--content", "hello", "--apply", "--root", str(work), "--json"] + ) + assert rc == 0 + payload = json.loads(capsys.readouterr().out) + + assert payload["status"] == "succeeded" + assert payload["output"]["bytes_written"] == 5 + assert payload["effects"]["changed_paths"] == ["new.txt"] + assert payload["effects"]["complete"] is True + assert (work / "new.txt").read_text(encoding="utf-8") == "hello" + + +def test_fs_write_text_mode_reports_the_byte_count( + work: Path, capsys: pytest.CaptureFixture[str] +) -> None: + rc = main(["fs", "write", "new.txt", "--content", "hello", "--apply", "--root", str(work)]) + assert rc == 0 + assert capsys.readouterr().out.strip() == "wrote 5 bytes to new.txt" + + +def test_fs_write_defaults_to_empty_content(work: Path, capsys: pytest.CaptureFixture[str]) -> None: + rc = main(["fs", "write", "empty.txt", "--apply", "--root", str(work), "--json"]) + assert rc == 0 + assert json.loads(capsys.readouterr().out)["output"]["bytes_written"] == 0 + assert (work / "empty.txt").read_text(encoding="utf-8") == "" + + +def test_fs_write_reads_content_from_stdin( + work: Path, capsys: pytest.CaptureFixture[str], monkeypatch: pytest.MonkeyPatch +) -> None: + import io + + monkeypatch.setattr("sys.stdin", io.StringIO("line one\nline two\n")) + + rc = main(["fs", "write", "multi.txt", "--content-stdin", "--apply", "--root", str(work)]) + assert rc == 0 + capsys.readouterr() + assert (work / "multi.txt").read_text(encoding="utf-8") == "line one\nline two\n" + + +def test_fs_write_content_and_stdin_are_mutually_exclusive( + work: Path, capsys: pytest.CaptureFixture[str] +) -> None: + with pytest.raises(SystemExit) as exc: + main(["fs", "write", "x.txt", "--content", "a", "--content-stdin", "--root", str(work)]) + assert exc.value.code == 1 + err = capsys.readouterr().err + assert err.startswith("error:") + assert "hint:" in err + + +def test_fs_write_into_a_read_only_path_is_denied( + work: Path, capsys: pytest.CaptureFixture[str] +) -> None: + protected = work / "vendor" + protected.mkdir() + + rc = main( + [ + "fs", + "write", + "vendor/x.txt", + "--content", + "nope", + "--apply", + "--read-only-path", + "vendor", + "--root", + str(work), + "--json", + ] + ) + assert rc == 1 + payload = json.loads(capsys.readouterr().out) + assert payload["status"] == "denied" + assert "read-only" in payload["error"] + assert not (protected / "x.txt").exists() + + +# --- edit: preview by default ------------------------------------------------ + + +def test_fs_edit_previews_by_default_and_changes_nothing( + work: Path, capsys: pytest.CaptureFixture[str] +) -> None: + target = work / "a.txt" + target.write_text("alpha beta\n", encoding="utf-8") + + rc = main( + [ + "fs", + "edit", + "a.txt", + "--old-string", + "alpha", + "--new-string", + "gamma", + "--root", + str(work), + "--json", + ] + ) + assert rc == 0 + payload = json.loads(capsys.readouterr().out) + + assert payload["status"] == "previewed" + assert payload["status"] != "succeeded" + assert target.read_text(encoding="utf-8") == "alpha beta\n", "preview must not edit" + + +def test_fs_edit_applies_with_the_apply_flag( + work: Path, capsys: pytest.CaptureFixture[str] +) -> None: + target = work / "a.txt" + target.write_text("alpha beta\n", encoding="utf-8") + + rc = main( + [ + "fs", + "edit", + "a.txt", + "--old-string", + "alpha", + "--new-string", + "gamma", + "--apply", + "--root", + str(work), + "--json", + ] + ) + assert rc == 0 + payload = json.loads(capsys.readouterr().out) + + assert payload["status"] == "succeeded" + assert payload["output"]["replacements"] == 1 + assert target.read_text(encoding="utf-8") == "gamma beta\n" + + +def test_fs_edit_replace_all(work: Path, capsys: pytest.CaptureFixture[str]) -> None: + target = work / "a.txt" + target.write_text("x x x\n", encoding="utf-8") + + rc = main( + [ + "fs", + "edit", + "a.txt", + "--old-string", + "x", + "--new-string", + "y", + "--replace-all", + "--apply", + "--root", + str(work), + "--json", + ] + ) + assert rc == 0 + payload = json.loads(capsys.readouterr().out) + assert payload["output"]["replacements"] == 3 + assert target.read_text(encoding="utf-8") == "y y y\n" + + +def test_fs_edit_text_mode_reports_the_replacement_count( + work: Path, capsys: pytest.CaptureFixture[str] +) -> None: + (work / "a.txt").write_text("alpha\n", encoding="utf-8") + + rc = main( + [ + "fs", + "edit", + "a.txt", + "--old-string", + "alpha", + "--new-string", + "gamma", + "--apply", + "--root", + str(work), + ] + ) + assert rc == 0 + assert capsys.readouterr().out.strip() == "edited a.txt: replaced 1 occurrence" + + +def test_fs_edit_ambiguous_old_string_is_a_structured_user_error( + work: Path, capsys: pytest.CaptureFixture[str] +) -> None: + (work / "a.txt").write_text("x x\n", encoding="utf-8") + + rc = main( + [ + "fs", + "edit", + "a.txt", + "--old-string", + "x", + "--new-string", + "y", + "--apply", + "--root", + str(work), + ] + ) + assert rc == 1 + err = capsys.readouterr().err + assert "not unique" in err + assert "hint:" in err + + +# --- media ------------------------------------------------------------------- + + +def test_fs_media_text_reports_a_summary(work: Path, capsys: pytest.CaptureFixture[str]) -> None: + (work / "shot.png").write_bytes(_PNG_BYTES) + + rc = main(["fs", "media", "shot.png", "--root", str(work)]) + assert rc == 0 + out = capsys.readouterr().out + assert "loaded image shot.png" in out + assert "base64" not in out, "text mode renders the summary, never the payload" + + +def test_fs_media_json_carries_the_media_part( + work: Path, capsys: pytest.CaptureFixture[str] +) -> None: + (work / "shot.png").write_bytes(_PNG_BYTES) + expected = _library_result(work, "fs.media", {"path": "shot.png"}) + + rc = main(["fs", "media", "shot.png", "--root", str(work), "--json"]) + assert rc == 0 + payload = json.loads(capsys.readouterr().out) + + assert payload["status"] == "succeeded" + assert payload["output"]["media_part"]["type"] == "image_url" + assert payload["output"]["media_part"] == expected.output["media_part"] + assert payload["output"]["media_part"]["image_url"]["url"].startswith("data:image/png;base64,") + + +def test_fs_media_rejects_a_non_media_extension( + work: Path, capsys: pytest.CaptureFixture[str] +) -> None: + (work / "notes.txt").write_text("x", encoding="utf-8") + + rc = main(["fs", "media", "notes.txt", "--root", str(work)]) + assert rc == 1 + assert "Unknown attachment extension" in capsys.readouterr().err + + +# --- the evidence seam: the CLI really did go through execute() -------------- + + +def test_the_cli_writes_an_evidence_record_operation_show_can_retrieve( + work: Path, tmp_path: Path, capsys: pytest.CaptureFixture[str] +) -> None: + """End-to-end proof the CLI dispatched through the pipeline. + + ``shell.operations.execute()`` is what writes an evidence record. A verb + that called the handler directly would produce output but leave nothing for + ``shell operation show`` to find. + """ + (work / "a.txt").write_text("alpha\n", encoding="utf-8") + evidence_dir = tmp_path / "evidence" + + rc = main( + ["fs", "read", "a.txt", "--root", str(work), "--evidence-dir", str(evidence_dir), "--json"] + ) + assert rc == 0 + operation_id = json.loads(capsys.readouterr().out)["operation_id"] + + rc = main(["operation", "show", operation_id, "--evidence-dir", str(evidence_dir), "--json"]) + assert rc == 0 + record = json.loads(capsys.readouterr().out) + assert record["operation_id"] == operation_id + assert record["operation"]["kind"] == "fs.read" + assert record["status"] == "succeeded" + + +def test_a_previewed_write_is_recorded_as_previewed_not_applied( + work: Path, tmp_path: Path, capsys: pytest.CaptureFixture[str] +) -> None: + evidence_dir = tmp_path / "evidence" + + rc = main( + [ + "fs", + "write", + "new.txt", + "--content", + "hello", + "--root", + str(work), + "--evidence-dir", + str(evidence_dir), + "--json", + ] + ) + assert rc == 0 + operation_id = json.loads(capsys.readouterr().out)["operation_id"] + + rc = main(["operation", "show", operation_id, "--evidence-dir", str(evidence_dir), "--json"]) + assert rc == 0 + record = json.loads(capsys.readouterr().out) + assert record["status"] == "previewed" + assert record["execution"]["applied"] is False + + +def test_the_cli_records_its_own_provenance_as_the_caller( + work: Path, tmp_path: Path, capsys: pytest.CaptureFixture[str] +) -> None: + (work / "a.txt").write_text("alpha\n", encoding="utf-8") + evidence_dir = tmp_path / "evidence" + + rc = main( + ["fs", "read", "a.txt", "--root", str(work), "--evidence-dir", str(evidence_dir), "--json"] + ) + assert rc == 0 + operation_id = json.loads(capsys.readouterr().out)["operation_id"] + + main(["operation", "show", operation_id, "--evidence-dir", str(evidence_dir), "--json"]) + record = json.loads(capsys.readouterr().out) + assert record["caller"]["tool"] == "shell fs read" + + +# --- environment errors ------------------------------------------------------ + + +def test_a_missing_root_is_an_environment_error( + tmp_path: Path, capsys: pytest.CaptureFixture[str] +) -> None: + rc = main(["fs", "list", "--root", str(tmp_path / "no-such-dir")]) + assert rc == 2, "a broken --root is an environment problem, not a typo in a path argument" + err = capsys.readouterr().err + assert err.startswith("error:") + assert "hint:" in err + + +# --- structured error contract ----------------------------------------------- + + +def test_fs_bogus_subcommand_is_a_structured_error(capsys: pytest.CaptureFixture[str]) -> None: + with pytest.raises(SystemExit) as exc: + main(["fs", "bogus-verb"]) + assert exc.value.code == 1 + err = capsys.readouterr().err + assert err.startswith("error:") + assert "hint:" in err + assert "Traceback" not in err + + +@pytest.mark.parametrize("verb", ["read", "stat", "write", "edit", "media"]) +def test_a_verb_missing_its_path_is_a_structured_error( + verb: str, capsys: pytest.CaptureFixture[str] +) -> None: + with pytest.raises(SystemExit) as exc: + main(["fs", verb]) + assert exc.value.code == 1 + err = capsys.readouterr().err + assert err.startswith("error:") + assert "hint:" in err + + +def test_fs_edit_missing_new_string_is_a_structured_error( + work: Path, capsys: pytest.CaptureFixture[str] +) -> None: + with pytest.raises(SystemExit) as exc: + main(["fs", "edit", "a.txt", "--old-string", "x", "--root", str(work)]) + assert exc.value.code == 1 + assert "hint:" in capsys.readouterr().err + + +def test_an_unknown_flag_routes_through_the_structured_error_contract( + capsys: pytest.CaptureFixture[str], +) -> None: + """Proves the subparsers were built with ``parser_class=_CliArgumentParser``.""" + with pytest.raises(SystemExit) as exc: + main(["fs", "read", "a.txt", "--bogus"]) + assert exc.value.code == 1, "argparse's own handler would exit 2 with raw output" + err = capsys.readouterr().err + assert err.startswith("error:") + assert "hint:" in err + + +def test_parse_errors_honour_json_mode(capsys: pytest.CaptureFixture[str]) -> None: + with pytest.raises(SystemExit) as exc: + main(["fs", "read", "--json", "--bogus"]) + assert exc.value.code == 1 + assert json.loads(capsys.readouterr().err)["code"] == 1 + + +# --- catalog integration ------------------------------------------------------ + + +def test_every_fs_path_resolves_via_explain(capsys: pytest.CaptureFixture[str]) -> None: + for verb in ("", "overview", "read", "list", "stat", "write", "edit", "media"): + path = ["fs"] + ([verb] if verb else []) + rc = main(["explain", *path]) + assert rc == 0, f"explain {' '.join(path)} failed" + assert "# shell fs" in capsys.readouterr().out diff --git a/tests/test_fs_stat.py b/tests/test_fs_stat.py new file mode 100644 index 0000000..0c23c0d --- /dev/null +++ b/tests/test_fs_stat.py @@ -0,0 +1,325 @@ +"""Tests for ``fs.stat`` (``shell/fs/stat.py``). + +Four properties carry the weight of this file: + +* **A missing path is a successful observation, not a failure.** ``fs.stat`` + answers "what is here", and "nothing" is a real answer. This is the one place + ``fs.stat`` deliberately diverges from ``fs.read``, which fails on a missing + file because it has nothing to return. +* **Confinement is evaluated on the RESOLVED path.** A symlink inside the work + root whose target lands outside it is refused with the same message + ``fs.read`` produces -- pinned here by asserting both handlers refuse the + *same* input identically, so the two cannot drift apart silently. +* **The description is taken with ``lstat``, on the literal path.** A symlink + that stays inside the root is reported as a ``symlink``, not silently + described as whatever it points at. Confinement follows links (to refuse + escapes); description does not (to tell the truth about what is there). +* **An observation changes nothing, and knows it.** ``Effects(complete=True)`` + with an empty changed-path list is an earned claim for a read. +""" + +from __future__ import annotations + +import os +from pathlib import Path + +import pytest + +from shell import operations +from shell.environment import Environment, WorkspaceKind +from shell.fs import read as fs_read +from shell.fs import stat as fs_stat +from shell.operations import ExecutionProfile, Operation, OperationIntent +from shell.results import OperationResult, OperationStatus +from shell.runners.host import HostRunner + + +@pytest.fixture +def env(tmp_path: Path) -> Environment: + source = tmp_path / "source" + work = tmp_path / "work" + source.mkdir() + work.mkdir() + return Environment( + source_root=source, + work_root=work, + runner=HostRunner(), + workspace=WorkspaceKind.WORKTREE, + ) + + +def _stat(env: Environment, arguments: dict, **kwargs) -> OperationResult: + operation = Operation(kind=fs_stat.KIND, arguments=arguments, **kwargs) + return operations.execute(operation, env) + + +# --- registration ----------------------------------------------------------- + + +def test_fs_stat_self_registers_on_import() -> None: + assert fs_stat.KIND in operations.registered_kinds() + assert fs_stat.KIND == "fs.stat" + + +def test_fs_stat_is_an_observe_intent_operation() -> None: + normalized = operations.normalize(Operation(kind=fs_stat.KIND, arguments={"path": "x"})) + assert normalized.intent is OperationIntent.OBSERVE + assert normalized.profile is ExecutionProfile.OBSERVE + # Observations never require --apply/apply=True; a read executes immediately. + assert normalized.requires_apply is False + + +# --- describing what is there ----------------------------------------------- + + +def test_stat_of_a_regular_file_reports_its_metadata(env: Environment) -> None: + target = env.work_root / "a.txt" + target.write_text("alpha\n", encoding="utf-8") + + result = _stat(env, {"path": "a.txt"}) + + assert result.succeeded + assert result.output["path"] == "a.txt" + assert result.output["exists"] is True + assert result.output["kind"] == "file" + assert result.output["size_bytes"] == len("alpha\n") + assert result.output["mtime"] == pytest.approx(target.stat().st_mtime) + assert result.output["mode"] == format(target.stat().st_mode & 0o7777, "04o") + + +def test_stat_of_a_directory_reports_kind_directory(env: Environment) -> None: + (env.work_root / "sub").mkdir() + + result = _stat(env, {"path": "sub"}) + + assert result.succeeded + assert result.output["kind"] == "directory" + assert result.output["exists"] is True + + +def test_stat_of_the_work_root_itself(env: Environment) -> None: + result = _stat(env, {"path": "."}) + + assert result.succeeded + assert result.output["kind"] == "directory" + + +def test_stat_mode_reflects_the_permission_bits(env: Environment) -> None: + target = env.work_root / "script.sh" + target.write_text("#!/bin/sh\n", encoding="utf-8") + target.chmod(0o755) + + result = _stat(env, {"path": "script.sh"}) + + assert result.output["mode"] == "0755" + + +# --- a missing path is an answer, not a failure ------------------------------ + + +def test_stat_of_a_missing_path_succeeds_and_reports_exists_false(env: Environment) -> None: + result = _stat(env, {"path": "nope.txt"}) + + assert result.succeeded, "fs.stat answers 'what is here'; 'nothing' is a real answer" + assert result.output["exists"] is False + assert result.output["kind"] is None + assert result.output["size_bytes"] is None + assert result.output["mtime"] is None + assert result.output["mode"] is None + + +def test_stat_and_read_disagree_about_a_missing_file_on_purpose(env: Environment) -> None: + """The divergence is deliberate; pin it so nobody 'fixes' it into agreement.""" + stat_result = _stat(env, {"path": "nope.txt"}) + read_result = operations.execute( + Operation(kind=fs_read.KIND, arguments={"path": "nope.txt"}), env + ) + + assert stat_result.succeeded + assert read_result.status is OperationStatus.FAILED + assert "no such file" in read_result.error + + +# --- symlinks: described honestly, never followed out of the root ------------ + + +def test_symlink_inside_the_root_is_reported_as_a_symlink(env: Environment) -> None: + target = env.work_root / "real.txt" + target.write_text("content", encoding="utf-8") + os.symlink(target, env.work_root / "link.txt") + + result = _stat(env, {"path": "link.txt"}) + + assert result.succeeded + assert result.output["kind"] == "symlink", ( + "description uses lstat on the literal path: a link inside the root is a " + "link, not silently whatever it points at" + ) + + +def test_broken_symlink_inside_the_root_is_still_a_symlink(env: Environment) -> None: + os.symlink(env.work_root / "does-not-exist", env.work_root / "dangling") + + result = _stat(env, {"path": "dangling"}) + + assert result.succeeded + assert result.output["exists"] is True + assert result.output["kind"] == "symlink" + + +def test_symlink_inside_root_pointing_outside_it_is_refused(env: Environment) -> None: + """The interesting confinement case, and the reason resolution runs first. + + The literal path string (``link.txt``) never mentions anything outside the + root; resolving the symlink lands outside it. A check that only inspected + the literal string -- or that only ever called ``lstat`` -- would miss this + and describe a file the caller may not observe. + """ + outside_dir = env.work_root.parent / "outside" + outside_dir.mkdir() + secret = outside_dir / "secret.txt" + secret.write_text("outside content", encoding="utf-8") + + os.symlink(secret, env.work_root / "link.txt") + + result = _stat(env, {"path": "link.txt"}) + + assert result.status is OperationStatus.FAILED + assert "escapes the work root" in result.error + + +def test_stat_refuses_a_symlink_escape_exactly_as_fs_read_does(env: Environment) -> None: + """Cross-handler parity: the two confinement ports must not drift apart.""" + outside_dir = env.work_root.parent / "outside" + outside_dir.mkdir() + (outside_dir / "secret.txt").write_text("nope", encoding="utf-8") + os.symlink(outside_dir / "secret.txt", env.work_root / "link.txt") + + stat_result = _stat(env, {"path": "link.txt"}) + read_result = operations.execute( + Operation(kind=fs_read.KIND, arguments={"path": "link.txt"}), env + ) + + assert stat_result.status is read_result.status is OperationStatus.FAILED + # Byte-identical, not merely similar: the refusal message names the path and + # the root, never the handler, so the two ports have nothing to differ over. + assert stat_result.error == read_result.error + assert "escapes the work root" in stat_result.error + + +# --- everything that is not a file, directory or link ------------------------ + + +@pytest.mark.skipif(not hasattr(os, "mkfifo"), reason="POSIX FIFOs only") +def test_a_fifo_is_reported_as_other_not_mislabelled_as_a_file(env: Environment) -> None: + """``kind`` has a fourth value on purpose: not everything is one of three things.""" + os.mkfifo(env.work_root / "pipe") + + result = _stat(env, {"path": "pipe"}) + + assert result.succeeded + assert result.output["kind"] == "other" + + +@pytest.mark.skipif(os.name == "nt", reason="POSIX permission bits only") +@pytest.mark.skipif( + hasattr(os, "geteuid") and os.geteuid() == 0, + reason="root bypasses permission bits; the unreadable-parent path is untestable as root", +) +def test_an_unreadable_parent_directory_is_a_failed_result(env: Environment) -> None: + """A path inside the root that cannot be inspected is a modelled failure. + + Distinct from both "not there" and "escapes the root": the caller may ask + about it and the answer is unavailable, which is neither absence nor a + refusal. + """ + blocked = env.work_root / "blocked" + blocked.mkdir() + (blocked / "inner.txt").write_text("x", encoding="utf-8") + blocked.chmod(0o000) + try: + result = _stat(env, {"path": "blocked/inner.txt"}) + finally: + blocked.chmod(0o755) + + assert result.status is OperationStatus.FAILED + assert "cannot stat blocked/inner.txt" in result.error + + +# --- literal escapes --------------------------------------------------------- + + +def test_relative_escape_is_refused(env: Environment) -> None: + (env.work_root.parent / "outside.txt").write_text("nope", encoding="utf-8") + + result = _stat(env, {"path": "../outside.txt"}) + + assert result.status is OperationStatus.FAILED + assert "escapes the work root" in result.error + assert result.effects.complete is True + + +def test_absolute_path_outside_the_root_is_refused(env: Environment, tmp_path: Path) -> None: + outside = tmp_path / "elsewhere.txt" + outside.write_text("nope", encoding="utf-8") + + result = _stat(env, {"path": str(outside)}) + + assert result.status is OperationStatus.FAILED + assert "escapes the work root" in result.error + + +# --- argument errors are recoverable, never crashes -------------------------- + + +def test_missing_path_argument_is_a_failed_result(env: Environment) -> None: + result = _stat(env, {}) + + assert result.status is OperationStatus.FAILED + assert result.error == "fs.stat requires 'path'" + + +def test_a_nul_byte_in_the_path_is_a_failed_result_not_an_exception(env: Environment) -> None: + """``Path.resolve()`` raises a bare ``ValueError`` on an embedded NUL. + + Deliberately not modelled by this handler, so it propagates and is wrapped + by ``shell.operations.execute``'s handler-crash guard into a recoverable, + model-visible ``FAILED`` step -- never an aborted run. + """ + result = _stat(env, {"path": "a\x00b"}) + + assert result.status is OperationStatus.FAILED + assert "fs.stat failed" in result.error + + +# --- effects and rendering --------------------------------------------------- + + +def test_an_observation_declares_a_complete_empty_effect_list(env: Environment) -> None: + (env.work_root / "a.txt").write_text("x", encoding="utf-8") + + result = _stat(env, {"path": "a.txt"}) + + assert result.effects.changed_paths == () + assert result.effects.bytes_written == 0 + assert result.effects.complete is True + + +def test_rendering_is_a_readable_field_list(env: Environment) -> None: + (env.work_root / "a.txt").write_text("xy", encoding="utf-8") + + result = _stat(env, {"path": "a.txt"}) + + assert "path: a.txt" in result.rendering + assert "exists: true" in result.rendering + assert "kind: file" in result.rendering + assert "size_bytes: 2" in result.rendering + + +def test_rendering_of_a_missing_path_says_so_without_inventing_fields( + env: Environment, +) -> None: + result = _stat(env, {"path": "nope.txt"}) + + assert "exists: false" in result.rendering + assert "size_bytes" not in result.rendering From 51a9bd6a3f9a1287b73851f0038dedf7b583b45e Mon Sep 17 00:00:00 2001 From: Ori Nachum Date: Tue, 28 Jul 2026 10:38:53 +0300 Subject: [PATCH 08/20] docs: CLAUDE.md records the closed per-site hole and the residual evasion classes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit t6 closed the architectural finding from issue #7 — SITE_BASELINE pins each module's spawn-site shape, so a new spawn in an already-allow-listed module now fails --check. The survey section still asserted that hole as live. Rewritten to state what closed, what remains open (indirection, structure, non-literal shell= spellings), and the two deliberate trades the scope-aware rewrite introduces. The drift-detector framing and the 'read a green --check as...' sentence are unchanged, because they stay true. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01EkC4zsVUmYrRCwSWSe4aLp --- CLAUDE.md | 24 +++++++++++++++++++----- 1 file changed, 19 insertions(+), 5 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 50e8517..ac9cab5 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -574,11 +574,25 @@ as one.** An earlier version of this file, of `t73`, and of the issue #1 §17 comment all claimed a new unclassified path would fail CI immediately. An adversarial live test landed **30 executed evasions at exit 0** and that claim is retracted; the full inventory is -[issue #7](https://github.com/agentculture/shell-cli/issues/7). The one that -matters architecturally: `ALLOWLIST` is keyed per **module**, not per **site**, -so a brand-new `shell=True` spawn added to any of the 15 already-allow-listed -modules — `tools.py` included — passes green with no signal. Widening the -detected-call set does not fix that; per-site pinning would. +[issue #7](https://github.com/agentculture/shell-cli/issues/7). + +The architectural hole in that report is **closed**. `ALLOWLIST` is keyed per +module, but `SITE_BASELINE` now pins each module's spawn-site *shape* — the +sorted multiset of canonical call names, with a `:shell` suffix for a literal +`shell=True` — so a new spawn added to an already-allow-listed module, +`tools.py` included, fails `--check`. Line numbers are deliberately excluded +from the shape, or every refactor above a spawn would be a false alarm. The +baseline is generated by `--emit-baseline`, never hand-typed, so `PINNED_SHA` +and the baseline move in one reviewed diff. + +What remains open, and is documented in the module docstring: **indirection** +(`sp = subprocess`, `getattr`, `importlib`, `functools.partial` — only `import` +statements bind), **structure** (sibling re-export inside `colleague/`, `exec()` +of a source string, a spawn outside the package, a symlinked subdir), and +**non-literal `shell=` spellings** (`shell=SH`, `**{"shell": True}`). Two +consequences of the scope-aware binding rewrite are deliberate trades: rebinding +`subprocess` inside a function hides every spawn in it, and removing one spawn +while adding another of the same shape in the same module is invisible. Read a green `--check` as *"the inventory has not drifted in the ways this scanner can see"*, never as *"no new unmediated spawn path was added"*. From f72e918a1771b2b4e7c8531a4c87cccbdb628df3 Mon Sep 17 00:00:00 2001 From: Ori Nachum Date: Tue, 28 Jul 2026 10:39:42 +0300 Subject: [PATCH 09/20] t7: pin the --apply flag to the engine's own preview decision The CLI hand-picks which fs verbs carry --apply; the engine decides which operations preview, from the intent each handler declared at registration. Nothing structural ties those two together, so a handler whose intent changed would leave a verb that previews forever with no flag to escape it, or a mutation that applies with no way to preview it first. Asserts the correspondence over every verb, in both directions -- four verbs report requires_apply=False and carry no flag, two report True and carry one -- so the guard is not vacuously green. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01EkC4zsVUmYrRCwSWSe4aLp --- tests/test_cli_fs.py | 35 ++++++++++++++++++++++++++++++++++- 1 file changed, 34 insertions(+), 1 deletion(-) diff --git a/tests/test_cli_fs.py b/tests/test_cli_fs.py index 2214452..083fc9d 100644 --- a/tests/test_cli_fs.py +++ b/tests/test_cli_fs.py @@ -24,6 +24,7 @@ from __future__ import annotations +import argparse import json import os from pathlib import Path @@ -32,7 +33,7 @@ import shell.fs.stat # noqa: F401 (registers fs.stat as a real operation kind) from shell import operations -from shell.cli import main +from shell.cli import _build_parser, main from shell.cli._commands import fs as fs_group from shell.environment import Environment from shell.operations import Operation @@ -85,6 +86,38 @@ def test_every_kind_the_cli_fronts_is_a_registered_operation() -> None: assert kind in registered, f"{kind} is not a registered operation kind" +def _fs_verb_parsers() -> dict[str, argparse.ArgumentParser]: + """The argparse parser behind each ``fs`` verb, for surface-level assertions.""" + parser = _build_parser() + [top] = [a for a in parser._actions if isinstance(a, argparse._SubParsersAction)] + fs_parser = top.choices["fs"] + [noun] = [a for a in fs_parser._actions if isinstance(a, argparse._SubParsersAction)] + return dict(noun.choices) + + +def test_apply_exists_exactly_where_the_engine_requires_it() -> None: + """Two independent decisions that must agree, pinned so they cannot drift. + + The CLI hand-picks which verbs carry ``--apply``; the *engine* decides + which operations preview, from the intent each handler declared at + registration. Nothing structural ties those together, so a handler whose + intent changed would leave a verb that previews forever with no flag to + escape it -- or a mutation verb that applies with no way to preview. This + test is what makes that a failure instead of a surprise. + """ + parsers = _fs_verb_parsers() + for verb, kind in fs_group.KINDS.items(): + requires_apply = operations.normalize(Operation(kind=kind)).requires_apply + has_apply_flag = any( + "--apply" in action.option_strings for action in parsers[verb]._actions + ) + assert has_apply_flag == requires_apply, ( + f"'shell fs {verb}' dispatches {kind}, which the engine reports " + f"requires_apply={requires_apply}, but the verb " + f"{'has' if has_apply_flag else 'has no'} --apply flag" + ) + + # --- read -------------------------------------------------------------------- From b92cb157c8cffe0614efdacad5f1be42f9897e47 Mon Sep 17 00:00:00 2001 From: Ori Nachum Date: Tue, 28 Jul 2026 10:42:50 +0300 Subject: [PATCH 10/20] docs: record the shipped fs CLI group and fs.stat handler t7 made three surfaces false at once: README and CLAUDE.md both said no CLI verb executes an fs operation, and the handler inventory omitted fs.stat. The learn/overview/catalog text was corrected inside t7 itself; these two files were outside its scope by the wave's file-ownership rule. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01EkC4zsVUmYrRCwSWSe4aLp --- CLAUDE.md | 14 +++++++++----- README.md | 24 ++++++++++++++---------- 2 files changed, 23 insertions(+), 15 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index ac9cab5..93a2821 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -59,7 +59,7 @@ Built and green in the **library** (Milestone 1, v0.11.0-0.13.1): - `shell/evidence.py` — the evidence contract, wired into `execute`. - `shell/runners/host.py` — `HostRunner` with real execution: process groups, timeout/cancel escalation, per-platform orphan-prevention honesty. -- `shell/fs/` — `fs.read`, `fs.list`, `fs.write`, `fs.edit`, `fs.media`. +- `shell/fs/` — `fs.read`, `fs.list`, `fs.stat`, `fs.write`, `fs.edit`, `fs.media`. - `shell/process/` — `process.exec` (argv, `project` or `control`) and `process.shell` (a model-authored string, `project` only, since a shell re-interprets it after the gate reads it) (t84). @@ -70,10 +70,14 @@ Built and green in the **CLI**: the introspection verbs (`whoami`, `learn`, function `execute()` uses rather than a re-derivation, and `shell operation show`, which retrieves a persisted evidence record (t85). -**Not built yet:** the CLI noun groups `env` / `fs` / `process` / `git` — -there is still no CLI verb that itself *executes* an `fs.*` or `process.*` -operation, only ones that introspect the policy/evidence around that -execution; `ContainerRunner` (Milestone 4); colleague delegation (Milestone 2). +`shell fs read|list|stat|write|edit|media` is the first CLI group that +*executes* operations rather than introspecting them. It holds no filesystem +logic of its own — every verb goes through `operations.execute()`, so the CLI +and the library share one engine. `write` and `edit` preview by default and +require `--apply`. + +**Not built yet:** the CLI noun groups `env` / `process` / `git`; +`ContainerRunner` (Milestone 4); colleague delegation (Milestone 2). So the CLI's operation-facing surface is introspection-only: it can evaluate a policy decision and retrieve evidence for an operation that already ran, but it diff --git a/README.md b/README.md index 21e3846..70f2cda 100644 --- a/README.md +++ b/README.md @@ -46,20 +46,24 @@ surface drops the disclaimer or claims isolation. ## Status -**The operation core is built; the CLI has not caught up to it yet.** The +**The operation core is built, and the CLI has started catching up to it.** The library has `Operation`, `OperationResult`, `Environment`, the ported policy evaluator, the evidence contract, and a `HostRunner` with real execution — -`fs.read`, `fs.list`, `fs.write`, `fs.edit`, `fs.media`, `process.exec`, and -`process.shell` are all built and green. +`fs.read`, `fs.list`, `fs.stat`, `fs.write`, `fs.edit`, `fs.media`, +`process.exec`, and `process.shell` are all built and green. The CLI exposes the introspection verbs (`whoami`, `learn`, `explain`, -`overview`, `doctor`, `cli`) plus two operation-aware nouns: `policy -check`/`policy explain` evaluate the same gate `execute()` uses, and -`operation show` retrieves persisted evidence. **No CLI verb yet executes an -`fs.*` or `process.*` operation** — the `env`, `fs`, `process`, and `git` noun -groups do not exist yet, and neither does a container runner or colleague -delegation. That surface is library-only today. Publishing is gated on the -full quality suite plus a built-wheel smoke test. +`overview`, `doctor`, `cli`), two operation-aware nouns — `policy +check`/`policy explain` evaluate the same gate `execute()` uses, and `operation +show` retrieves persisted evidence — and `shell fs read|list|stat|write|edit| +media`, the first group whose verbs **execute** operations. They front +`operations.execute()` rather than reimplementing anything, so the CLI and the +library share one engine; `write` and `edit` preview by default and require +`--apply`. + +**Still library-only:** the `env`, `process`, and `git` noun groups do not +exist yet, and neither does a container runner or colleague delegation. +Publishing is gated on the full quality suite plus a built-wheel smoke test. [Issue #1](https://github.com/agentculture/shell-cli/issues/1) is the source of truth for scope; the converged spec and the buildable plan live in From 5eb1fe3855897b7630f16073a5f225958c63941b Mon Sep 17 00:00:00 2001 From: Ori Nachum Date: Tue, 28 Jul 2026 11:06:57 +0300 Subject: [PATCH 11/20] t8: the `shell process` CLI noun group, and a registry-derived --apply flag Part A -- `shell process exec` and `shell process shell`, the first CLI verbs that run a process. Both build an Operation and dispatch it through shell.operations.execute(): no second implementation, no reaching around the pipeline. Both declare intent=EXECUTE, so both preview by default and need --apply; a preview reports `previewed`, starts nothing, and predicts nothing -- no effects, no exit code. The profile asymmetry is enforced in the CLI, and enforced *early*. `exec` accepts project and control; `shell` accepts project only, because a shell re-interprets a string after the gate has read it, so which program runs is not settled by what was gated. The handlers already refuse this, but the pipeline's preview branch sits between the gate and the handler -- so an unapplied `process shell --profile control` would have come back `previewed`, telling the caller it was fine and merely unapplied. The CLI therefore refuses before building the operation, and it does so by calling the handler's own require_profile() with the kind's own accepted-profile tuple and hint constant, so the refusal is the library's sentence rather than a paraphrase of it. That required promoting _EXEC_PROFILES / _SHELL_PROFILES and the two inline hint strings to public module constants. Nothing here is path-confined and the surfaces say so rather than leaving it to be inferred: output.confinement keeps path_confined / uses_shell / gate_inspects_reinterpreted_string as separate keys on an applied result, every result including a preview carries the runner's evidence.isolation_note, and a text-mode preview names the missing confinement on stderr alongside --apply. A test writes a file outside --root and watches the operation succeed, so the claim cannot be quietly upgraded later. The command's own exit code is not passed through. 1 and 2 already mean user and environment error in this CLI and 3+ is reserved, so a command exiting 1 would be indistinguishable from a bad flag -- and a non-zero exit is not a failure of the operation, which was "run this and observe it". It lives in output.exit_code, with a stderr diagnostic in text mode so 0 is never read as approval. Part B -- --apply is now derived from the operation registry for both noun groups. The CLI used to hand-pick which verbs carried the flag while the engine independently decided which operations preview; t7 pinned the correspondence but could not prevent a divergence, and a second group was about to repeat the pattern. shell/cli/_operation.py:add_apply_flag() asks normalize() the same question execute() asks, and is now the only place in shell/ that spells --apply -- asserted by a source scan, so a future noun group cannot reintroduce the list. tests/test_cli_fs.py passes unmodified; its correspondence assertion is now trivially true and is kept because a regression would make it load-bearing again. The derivation itself is tested against handlers registered for the test alone. The policy-source flags moved into the same shared module: `policy` evaluates a snapshot and `process` executes under one, and the two must not be able to resolve one differently. `shell process` is consequently the first CLI verb the run_command gate actually governs. learn, overview and the explain root said "no CLI verb runs a shell command yet". That is now false, so they were corrected -- and still name env and git as unexposed. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01EkC4zsVUmYrRCwSWSe4aLp --- shell/cli/__init__.py | 2 + shell/cli/_commands/fs.py | 92 +-- shell/cli/_commands/learn.py | 57 +- shell/cli/_commands/overview.py | 12 +- shell/cli/_commands/policy.py | 92 +-- shell/cli/_commands/process.py | 568 ++++++++++++++ shell/cli/_operation.py | 228 ++++++ shell/explain/catalog.py | 137 +++- shell/process/exec.py | 28 +- shell/process/shell.py | 25 +- tests/test_cli_apply_flag.py | 160 ++++ tests/test_cli_process.py | 1236 +++++++++++++++++++++++++++++++ 12 files changed, 2467 insertions(+), 170 deletions(-) create mode 100644 shell/cli/_commands/process.py create mode 100644 shell/cli/_operation.py create mode 100644 tests/test_cli_apply_flag.py create mode 100644 tests/test_cli_process.py diff --git a/shell/cli/__init__.py b/shell/cli/__init__.py index bc70023..c6b8e0b 100644 --- a/shell/cli/__init__.py +++ b/shell/cli/__init__.py @@ -70,6 +70,7 @@ def _build_parser() -> argparse.ArgumentParser: from shell.cli._commands import operation as _operation_group from shell.cli._commands import overview as _overview_cmd from shell.cli._commands import policy as _policy_group + from shell.cli._commands import process as _process_group from shell.cli._commands import whoami as _whoami_cmd parser = _CliArgumentParser( @@ -96,6 +97,7 @@ def _build_parser() -> argparse.ArgumentParser: _doctor_cmd.register(sub) _cli_group.register(sub) _fs_group.register(sub) + _process_group.register(sub) _policy_group.register(sub) _operation_group.register(sub) # Register your own noun groups here: diff --git a/shell/cli/_commands/fs.py b/shell/cli/_commands/fs.py index 7324abd..6e005d4 100644 --- a/shell/cli/_commands/fs.py +++ b/shell/cli/_commands/fs.py @@ -21,9 +21,13 @@ ``write`` and ``edit`` declare ``intent=MUTATE``, so the pipeline previews them unless the operation carries ``apply=True``. The CLI's ``--apply`` flag is the -only thing that sets it. **A preview is reported with its own status, -``previewed``, never as success** — the status field says so, the rendering -says so, and in text mode a diagnostic naming the remedy goes to stderr. The +only thing that sets it — and the flag itself is *derived* from that +registration rather than attached by hand, so a verb cannot disagree with the +engine about whether it previews (see :mod:`shell.cli._operation`). + +**A preview is reported with its own status, ``previewed``, never as success** +— the status field says so, the rendering says so, and in text mode a +diagnostic naming the remedy goes to stderr. The process exit code is ``0`` because the CLI did exactly what was asked; a consumer deciding "did the write happen" reads ``status``, not the exit code, and the two questions are deliberately not conflated. ``read``, ``list``, @@ -80,10 +84,16 @@ from shell import operations from shell.cli._commands.overview import emit_overview -from shell.cli._errors import EXIT_ENV_ERROR, EXIT_USER_ERROR, CliError +from shell.cli._errors import EXIT_USER_ERROR, CliError +from shell.cli._operation import ( + JSON_HELP, + add_apply_flag, + add_common_args, + evidence_store, + resolve_root, +) from shell.cli._output import emit_diagnostic, emit_result from shell.environment import Environment -from shell.evidence import EvidenceStore from shell.operations import Operation from shell.results import OperationResult, OperationStatus from shell.runners.host import HostRunner @@ -99,8 +109,6 @@ import shell.fs.stat # noqa: E402 isort:skip import shell.fs.write # noqa: E402 isort:skip -_JSON_HELP = "Emit structured JSON." - #: Verb -> the operation kind it dispatches. Taken from each handler module's #: own constant rather than re-spelled here, so a kind cannot be renamed in one #: place and left stale in the other. ``tests/test_cli_fs.py`` additionally @@ -175,14 +183,7 @@ def _environment(args: argparse.Namespace) -> Environment: not having one: :attr:`~shell.environment.Environment.roots_are_separate` reports ``False`` and every evidence record says so. """ - raw = getattr(args, "root", None) - root = Path(raw).expanduser() if raw is not None else Path.cwd() - if not root.is_dir(): - raise CliError( - code=EXIT_ENV_ERROR, - message=f"--root is not an existing directory: {root}", - remediation="point --root at a directory that exists (default: the current directory)", - ) + root = resolve_root(args) # A relative --read-only-path is resolved against the WORK ROOT, not the # process cwd. Read-only paths name subtrees inside the root being @@ -199,17 +200,6 @@ def _environment(args: argparse.Namespace) -> Environment: ) -def _store(args: argparse.Namespace) -> EvidenceStore | None: - """The evidence store, when the caller opted into one. - - Persistence stays opt-in here for the same reason it is opt-in in the - library: writing files under someone's tree on the first invocation is a - side effect nobody asked for. - """ - directory = getattr(args, "evidence_dir", None) - return None if directory is None else EvidenceStore(directory=Path(directory)) - - # --- dispatch ----------------------------------------------------------------- @@ -279,7 +269,7 @@ def _run(args: argparse.Namespace, verb: str, arguments: dict[str, Any]) -> int: apply=bool(getattr(args, "apply", False)), caller={"agent": "shell-cli", "tool": f"shell fs {verb}"}, ) - result = operations.execute(operation, environment, evidence_store=_store(args)) + result = operations.execute(operation, environment, evidence_store=evidence_store(args)) return _emit(result, json_mode=bool(getattr(args, "json", False))) @@ -350,31 +340,27 @@ def _no_verb(args: argparse.Namespace) -> int: def _add_common_args(p: argparse.ArgumentParser) -> None: - p.add_argument( - "--root", - type=Path, - default=None, - metavar="DIR", - help="Work root every path is confined to (default: the current directory).", + add_common_args( + p, + root_help="Work root every path is confined to (default: the current directory).", ) - p.add_argument( - "--evidence-dir", - type=Path, - default=None, - metavar="DIR", - help=( - "Persist this operation's evidence record into DIR (opt-in; retrieve it " - "with 'shell operation show ')." - ), - ) - p.add_argument("--json", action="store_true", help=_JSON_HELP) -def _add_mutation_args(p: argparse.ArgumentParser) -> None: - p.add_argument( - "--apply", - action="store_true", - help="Carry the mutation out. Without it the operation is previewed and nothing changes.", +def _add_mutation_args(p: argparse.ArgumentParser, kind: str) -> None: + """The flags a mutating verb carries. + + ``--apply`` is *derived* from *kind*'s registered intent rather than added + unconditionally — see :mod:`shell.cli._operation`. ``--read-only-path`` + stays hand-attached here because it is genuinely filesystem-specific: it + declares subtrees :func:`shell.policy.check_write` refuses to write, which + is a question no ``process.*`` verb can ask. + """ + add_apply_flag( + p, + kind, + help_text=( + "Carry the mutation out. Without it the operation is previewed and nothing changes." + ), ) p.add_argument( "--read-only-path", @@ -392,7 +378,7 @@ def register(sub: argparse._SubParsersAction) -> None: "fs", help="Confined filesystem operations (see 'shell fs overview').", ) - p.add_argument("--json", action="store_true", help=_JSON_HELP) + p.add_argument("--json", action="store_true", help=JSON_HELP) p.set_defaults(func=_no_verb, json=False) # `p` is a _CliArgumentParser (the top-level subparsers were built with that # parser_class); propagate it so every verb under `fs` routes parse errors @@ -401,7 +387,7 @@ def register(sub: argparse._SubParsersAction) -> None: noun_sub = p.add_subparsers(dest="fs_command", parser_class=type(p)) ov = noun_sub.add_parser("overview", help="Describe the fs noun.") - ov.add_argument("--json", action="store_true", help=_JSON_HELP) + ov.add_argument("--json", action="store_true", help=JSON_HELP) ov.set_defaults(func=cmd_fs_overview) read = noun_sub.add_parser( @@ -449,7 +435,7 @@ def register(sub: argparse._SubParsersAction) -> None: action="store_true", help="Read the file content from stdin — the operator channel for multi-line content.", ) - _add_mutation_args(write) + _add_mutation_args(write, KINDS["write"]) _add_common_args(write) write.set_defaults(func=cmd_fs_write) @@ -475,7 +461,7 @@ def register(sub: argparse._SubParsersAction) -> None: action="store_true", help="Replace every occurrence. Without it a non-unique old-string is refused.", ) - _add_mutation_args(edit) + _add_mutation_args(edit, KINDS["edit"]) _add_common_args(edit) edit.set_defaults(func=cmd_fs_edit) diff --git a/shell/cli/_commands/learn.py b/shell/cli/_commands/learn.py index f1a6527..0fef027 100644 --- a/shell/cli/_commands/learn.py +++ b/shell/cli/_commands/learn.py @@ -31,14 +31,15 @@ Status ------ -The library has the operation core: fs.read/list/stat/write/edit/media, the -policy evaluator, the evidence contract, and HostRunner execution are built and -green. The CLI is catching up: `fs`, `policy` and `operation` are real CLI -nouns -- `shell fs` executes the confined filesystem operations through the same -execute() pipeline the library uses, `policy check`/`policy explain` evaluate -the same policy gate that pipeline gates through, and `operation show` -retrieves persisted evidence. The env/process/git verb groups are not exposed -yet -- no CLI verb runs a shell command. +The library has the operation core: fs.read/list/stat/write/edit/media, +process.exec/process.shell, the policy evaluator, the evidence contract, and +HostRunner execution are built and green. The CLI is catching up: `fs`, +`process`, `policy` and `operation` are real CLI nouns -- `shell fs` executes +the confined filesystem operations and `shell process` runs argv vectors and +shell command strings, both through the same execute() pipeline the library +uses; `policy check`/`policy explain` evaluate the same policy gate that +pipeline gates through, and `operation show` retrieves persisted evidence. The +env and git verb groups are not exposed yet. See https://github.com/agentculture/shell-cli/issues/1 Commands @@ -52,15 +53,28 @@ shell fs read|list|stat Confined filesystem observations. shell fs write|edit Confined mutations -- preview unless --apply. shell fs media Load a workspace image as a neutral media part. + shell process exec Run an argv vector -- previews unless --apply. + shell process shell Run a shell command string -- previews unless --apply. shell policy check Evaluate the run_command policy gate. shell policy explain List gated/ungated operation kinds. shell operation show Retrieve persisted operation evidence. Mutation and execution preview by default ----------------------------------------- -`shell fs write` and `shell fs edit` preview unless `--apply` is given. A -preview reports status "previewed", never "succeeded" -- read the status field, -not the exit code, to learn whether the change happened. +`shell fs write`, `shell fs edit`, `shell process exec` and `shell process +shell` preview unless `--apply` is given. A preview reports status "previewed", +never "succeeded" -- read the status field, not the exit code, to learn whether +it happened. A process preview also predicts nothing: no effects, no exit code. + +What `shell process` does NOT do +-------------------------------- +It is not path-confined. `--root` is the directory the command starts in, not a +boundary it is held inside: a process can leave through interpreters, absolute +paths, network calls, or child processes. `shell fs` confines its paths; this +does not, and the two must never be described in one sentence. `process exec` +accepts profile project or control; `process shell` accepts project only, +because a shell re-interprets a string after the gate read it. The command's +own exit code is data in the result (output.exit_code), never shell's own. Machine-readable output ----------------------- @@ -97,9 +111,10 @@ def _as_json_payload() -> dict[str, object]: "and careless behaviour, not an adversarial one." ), "status": ( - "the operation core is built in the library (fs primitives, policy, " - "evidence, HostRunner); the CLI exposes fs, policy and operation as " - "real nouns -- env/process/git verb groups are still library-only" + "the operation core is built in the library (fs primitives, process " + "exec/shell, policy, evidence, HostRunner); the CLI exposes fs, " + "process, policy and operation as real nouns -- the env and git verb " + "groups are still library-only" ), "commands": [ {"path": ["whoami"], "summary": "Identity probe from culture.yaml."}, @@ -123,6 +138,20 @@ def _as_json_payload() -> dict[str, object]: "summary": "Replace text in a confined file. Previews unless --apply.", }, {"path": ["fs", "media"], "summary": "Load a workspace image as a media part."}, + { + "path": ["process", "exec"], + "summary": ( + "Run an explicit argv vector. Previews unless --apply. Not " + "path-confined. Profile project or control." + ), + }, + { + "path": ["process", "shell"], + "summary": ( + "Run a shell command string. Previews unless --apply. Not " + "path-confined. Project profile only." + ), + }, { "path": ["policy", "check"], "summary": "Evaluate the run_command policy gate for one operation.", diff --git a/shell/cli/_commands/overview.py b/shell/cli/_commands/overview.py index 69648ff..235ee00 100644 --- a/shell/cli/_commands/overview.py +++ b/shell/cli/_commands/overview.py @@ -35,6 +35,8 @@ "fs read / list / stat — confined filesystem observations", "fs write / edit — confined mutations; preview unless --apply", "fs media — load a workspace image as a neutral media part", + "process exec — run an argv vector; previews unless --apply; not path-confined", + "process shell — run a command string; previews unless --apply; project profile only", "policy check / policy explain — evaluate and explain the operation-policy gate", "operation show — retrieve persisted evidence for an operation id", ] @@ -48,11 +50,13 @@ ] _STATUS = [ - "fs, policy and operation are real CLI nouns; env/process/git are still " - "library-only — no CLI verb runs a shell command yet", - "shell fs executes the confined filesystem operations through the same " + "fs, process, policy and operation are real CLI nouns; env and git are still library-only", + "shell fs and shell process execute through the same " "shell.operations.execute() pipeline the library uses", - "the library has the fs primitives, confinement, policy, evidence and HostRunner", + "shell process is NOT path-confined — --root is where a command starts, " + "not a boundary it is held inside", + "the library has the fs primitives, process exec/shell, confinement, " + "policy, evidence and HostRunner", "tracking issue: https://github.com/agentculture/shell-cli/issues/1", ] diff --git a/shell/cli/_commands/policy.py b/shell/cli/_commands/policy.py index 918279a..e1183e2 100644 --- a/shell/cli/_commands/policy.py +++ b/shell/cli/_commands/policy.py @@ -29,26 +29,28 @@ Neither verb requires the checked operation kind to be a *registered* handler. ``_policy_gate`` only reads ``operation.kind`` and ``operation.arguments``; it -never looks the kind up in the handler registry. This is what lets -``policy check --kind process.shell ...`` answer honestly even before the -``process.*`` handlers exist (Milestone 1 t84, not yet merged into this -worktree) — the gate's jurisdiction is a property of the kind *string*, not of -whether a handler happens to be registered for it. +never looks the kind up in the handler registry. That is what let +``policy check process.shell ...`` answer honestly before the ``process.*`` +handlers existed, and it is still what lets it answer for a kind no slice will +ever register — the gate's jurisdiction is a property of the kind *string*, not +of whether a handler happens to be registered for it. + +The flags that select a policy snapshot live in :mod:`shell.cli._operation`, +shared with ``shell process``: one noun evaluates a snapshot and the other +executes under it, and the two must not be able to resolve one differently. """ from __future__ import annotations import argparse -import json as _json -from pathlib import Path from typing import Any from shell import operations from shell.cli._commands.overview import emit_overview -from shell.cli._errors import EXIT_USER_ERROR, CliError +from shell.cli._operation import JSON_HELP, add_policy_source_args, load_policy_from_args from shell.cli._output import emit_result from shell.operations import Operation -from shell.policy import FILE_CATEGORIES, Policy, load_policy +from shell.policy import FILE_CATEGORIES, Policy # Importing these registers fs.read / fs.list / fs.stat / fs.write / fs.edit / # fs.media as a side effect (see shell/fs/__init__.py — the package marker is @@ -64,8 +66,6 @@ import shell.fs.stat # noqa: E402,F401 isort:skip import shell.fs.write # noqa: E402,F401 isort:skip -_JSON_HELP = "Emit structured JSON." - _NOUN_SECTIONS = [ { "title": "Verbs", @@ -89,39 +89,6 @@ ] -def _load_policy_from_args(args: argparse.Namespace) -> Policy: - """Build the :class:`Policy` snapshot ``check``/``explain`` evaluate against. - - Mirrors :func:`shell.policy.load_policy`'s own contract: no candidate files - means the empty policy, which is exactly what ``execute()`` substitutes - when a caller passes no policy at all. A malformed ``--policy-json`` value - is the one case this module raises on — the CLI is the authoring side here - (a human typed the flag), and :func:`shell.policy.load_policy` itself never - raises for a bad *file*, only for a bad inline ``data`` mapping shape it - cannot recover from silently. - """ - candidates: list[Path] = list(getattr(args, "policy_file", None) or []) - data: dict[str, Any] | None = None - raw = getattr(args, "policy_json", None) - if raw: - try: - parsed = _json.loads(raw) - except _json.JSONDecodeError as exc: - raise CliError( - code=EXIT_USER_ERROR, - message=f"--policy-json is not valid JSON: {exc}", - remediation='pass a JSON object, e.g. \'{"run_command": {"deny": ["rm"]}}\'', - ) from exc - if not isinstance(parsed, dict): - raise CliError( - code=EXIT_USER_ERROR, - message="--policy-json must be a JSON object", - remediation="pass an object, e.g. '{\"run_command\": {...}}'", - ) - data = parsed - return load_policy(candidates, data=data) - - def _build_operation(args: argparse.Namespace) -> Operation: arguments: dict[str, Any] = {} command = getattr(args, "raw_command", None) @@ -137,7 +104,7 @@ def _build_operation(args: argparse.Namespace) -> Operation: def cmd_policy_check(args: argparse.Namespace) -> int: - policy = _load_policy_from_args(args) + policy = load_policy_from_args(args) operation = _build_operation(args) # THE call: shell.operations.execute() gates through this exact function. @@ -181,7 +148,7 @@ def _kind_report(kind: str, policy: Policy) -> dict[str, Any]: def cmd_policy_explain(args: argparse.Namespace) -> int: - policy = _load_policy_from_args(args) + policy = load_policy_from_args(args) kinds = [_kind_report(kind, policy) for kind in operations.registered_kinds()] payload = { @@ -233,33 +200,12 @@ def _no_verb(args: argparse.Namespace) -> int: return cmd_policy_overview(args) -def _add_policy_source_args(p: argparse.ArgumentParser) -> None: - p.add_argument( - "--policy-file", - action="append", - type=Path, - metavar="PATH", - help=( - "A pre-resolved approvals.json candidate (repeatable; later " - "candidates win per-section). Omit for the empty policy -- the " - "same default shell.operations.execute() uses when no policy is " - "supplied." - ), - ) - p.add_argument( - "--policy-json", - metavar="JSON", - help='Inline policy document, e.g. \'{"run_command": {"deny": ["rm"]}}\'. ' - "Takes precedence over --policy-file for the sections it defines.", - ) - - def register(sub: argparse._SubParsersAction) -> None: p = sub.add_parser( "policy", help="Evaluate and explain the operation-policy gate (see 'shell policy overview').", ) - p.add_argument("--json", action="store_true", help=_JSON_HELP) + p.add_argument("--json", action="store_true", help=JSON_HELP) p.set_defaults(func=_no_verb, json=False) # `p` is a _CliArgumentParser (the top-level subparsers were built with that # parser_class); propagate it so every verb under `policy` routes parse @@ -268,7 +214,7 @@ def register(sub: argparse._SubParsersAction) -> None: noun_sub = p.add_subparsers(dest="policy_command", parser_class=type(p)) ov = noun_sub.add_parser("overview", help="Describe the policy noun.") - ov.add_argument("--json", action="store_true", help=_JSON_HELP) + ov.add_argument("--json", action="store_true", help=JSON_HELP) ov.set_defaults(func=cmd_policy_overview) check = noun_sub.add_parser( @@ -297,14 +243,14 @@ def register(sub: argparse._SubParsersAction) -> None: metavar="TOKEN", help="One argv token (repeatable, in order): --argv git --argv status.", ) - _add_policy_source_args(check) - check.add_argument("--json", action="store_true", help=_JSON_HELP) + add_policy_source_args(check) + check.add_argument("--json", action="store_true", help=JSON_HELP) check.set_defaults(func=cmd_policy_check) explain = noun_sub.add_parser( "explain", help="List gated kind prefixes and every known operation kind's policy status.", ) - _add_policy_source_args(explain) - explain.add_argument("--json", action="store_true", help=_JSON_HELP) + add_policy_source_args(explain) + explain.add_argument("--json", action="store_true", help=JSON_HELP) explain.set_defaults(func=cmd_policy_explain) diff --git a/shell/cli/_commands/process.py b/shell/cli/_commands/process.py new file mode 100644 index 0000000..def1139 --- /dev/null +++ b/shell/cli/_commands/process.py @@ -0,0 +1,568 @@ +"""``shell process`` — the two execution verbs, fronting the operation engine. + +``exec`` runs an explicit **argv vector**; ``shell`` runs a model-authored +**command string** through a fresh shell. Both build an +:class:`~shell.operations.Operation` and hand it to +:func:`shell.operations.execute` — the same function an importing consumer +calls, with no second code path. This module starts no process, opens no pipe, +and interprets no exit code: the registered ``process.*`` handlers and the +environment's runner answer all of that. ``tests/test_cli_process.py`` checks it +mechanically, by comparing each verb's structured output against a result the +test obtained from ``execute()`` and by retrieving the CLI's own operation ids +through ``shell operation show``, which reads the record only ``execute()`` +writes. + +Nothing here is confined +------------------------ + +Say this before anything else, because ``shell fs`` sits next door and *is* +path-confined. A process started by either verb is not. Its cwd is the work +root, but a cwd is a starting directory and not a boundary: the command can +leave through interpreters, absolute paths, network calls, or child processes. +``tests/test_cli_process.py`` pins that as a property of the product — a test +writes a file outside ``--root`` and the operation succeeds — so that nobody can +describe these verbs as confined without deleting the test that says otherwise. + +The policy gate is not containment either. It reads the first token of a string +a shell then re-tokenizes, so ``sh -c``, pipelines, here-docs and an absolute +path to a renamed binary all step around it. It records operator intent against +accidental and careless behaviour, never an adversarial one. + +Both facts travel in the payload as well as in this docstring. An applied result +carries ``output["confinement"]`` with ``path_confined: false``; every result, +previews included, carries the runner's own ``evidence.isolation`` and +``isolation_note``. A consumer that reads no documentation still receives the +posture. + +Preview by default +------------------ + +Both kinds declare ``intent=EXECUTE``, so the pipeline previews them unless the +operation carries ``apply=True``, and ``--apply`` is the only thing that sets +it. The flag itself is derived from the registry (see +:mod:`shell.cli._operation`), not from a list kept here. + +**A preview is reported with its own status, ``previewed``, never as success**, +and it describes what *would* run without predicting what it would change: +``effects.complete`` is ``false`` and the changed-path list is empty. The +handler never runs, so there is no exit code to report and none is invented. + +The profile asymmetry, and why the CLI enforces it early +--------------------------------------------------------- + +``exec`` accepts ``project`` and ``control``. ``shell`` accepts ``project`` +only. That is not a style rule: a control operation runs a trusted +control-plane program, and *which* program runs must be settled by the argv +vector the caller assembled rather than by a string a shell reinterprets after +the gate has read it. + +The handlers already refuse a rejected profile. This module refuses it too, +**before** the operation is built — because the pipeline's preview branch comes +after the gate but before the handler, so an unapplied +``process shell --profile control`` would otherwise come back ``previewed``, +telling the caller the operation is fine and merely unapplied. That is the same +implication the engine avoids by gating before previewing. + +Refusing in two places would normally mean two sentences that drift apart. It +does not here: the refusal is produced by calling +:func:`shell.process.exec.require_profile` with the kind's own +``*_PROFILES`` tuple and ``*_PROFILE_HINT`` constant, so the CLI emits the +handler's own words rather than a paraphrase of them. + +Exit codes, and the one thing they do not report +------------------------------------------------- + +``0`` for a succeeded or previewed operation, ``1`` for ``denied``, ``failed`` +or ``timed_out``, ``2`` for a ``--root`` that is not a directory. + +**The command's own exit code is not passed through.** ``1`` and ``2`` already +mean user error and environment error in this CLI, so a command exiting ``1`` +would be indistinguishable from a bad flag, and ``3+`` is reserved. A non-zero +exit is also not a failure of the *operation*: the operation was "run this and +observe it", and it did. So the command's verdict lives in +``output["exit_code"]`` and ``evidence.exit_code``, the operation's verdict is +the exit code of ``shell`` itself, and in text mode a diagnostic on stderr names +the command's code so an operator is never left reading ``0`` as approval. + +The environment is an allow-list +-------------------------------- + +``--env NAME`` names a host variable the process may inherit. The default is +**nothing** — not ``PATH``, not ``HOME`` — matching +:func:`shell.process.exec.build_env`, which never restores a variable the caller +did not name. A CLI that quietly added ``PATH`` back would make the declared +policy a fiction, and the honest cost is that ``--env PATH`` is usually the +first flag a real command needs. +""" + +from __future__ import annotations + +import argparse +from typing import Any, Mapping + +from shell import operations +from shell.cli._commands.overview import emit_overview +from shell.cli._errors import EXIT_USER_ERROR, CliError +from shell.cli._operation import ( + JSON_HELP, + add_apply_flag, + add_common_args, + add_policy_source_args, + evidence_store, + load_policy_from_args, + resolve_root, +) +from shell.cli._output import emit_diagnostic, emit_result +from shell.environment import Environment +from shell.operations import ExecutionProfile, Operation +from shell.process import exec as process_exec +from shell.process import shell as process_shell +from shell.results import OperationResult, OperationStatus +from shell.runners.host import HostRunner + +#: Verb -> the operation kind it dispatches. Taken from each handler module's +#: own constant rather than re-spelled here. Importing those modules is also +#: what registers the kinds; registration is idempotent per interpreter, so a +#: test importing them as well is never a double-registration error. +KINDS = { + "exec": process_exec.KIND, + "shell": process_shell.KIND, +} + +#: Verb -> the profiles its kind accepts, read from the handler module rather +#: than restated. See the module docstring on why the CLI needs this at all. +ACCEPTED_PROFILES: dict[str, tuple[ExecutionProfile, ...]] = { + "exec": process_exec.EXEC_PROFILES, + "shell": process_shell.SHELL_PROFILES, +} + +#: Verb -> the handler's own explanation of why the other profiles are refused. +PROFILE_HINTS = { + "exec": process_exec.EXEC_PROFILE_HINT, + "shell": process_shell.SHELL_PROFILE_HINT, +} + +_PROFILE_REMEDIATION = { + "exec": ( + "pass --profile project (repository-controlled code) or --profile control " + "(a trusted control-plane program); 'observe' belongs to 'shell fs'" + ), + "shell": ( + "run it as 'shell process exec --profile control -- ...' — a " + "control operation must name its program in an argv vector, not in a string" + ), +} + +_NOT_CONFINED = ( + "the command is not path-confined: --root is the directory it starts in, " + "not a boundary it is held inside" +) + +_NOUN_SECTIONS = [ + { + "title": "Verbs", + "items": [ + "process exec [--profile project|control] [--apply] -- ARGV... — run an " + "explicit argv vector (previews by default)", + "process shell COMMAND [--apply] — run a shell command string, project " + "profile only (previews by default)", + "process overview — describe the process noun (this command)", + ], + }, + { + "title": "Semantics", + "items": [ + "both verbs dispatch through shell.operations.execute() — the same " + "pipeline an importing consumer uses, never a second implementation", + "both preview by default; --apply is the only thing that sets " + "apply=True on the operation", + "a preview reports status 'previewed', never 'succeeded', and predicts " + "no effects and no exit code — it describes what would run", + "the command's own exit code is data in the result (output.exit_code), " + "never shell's exit code: 1 and 2 already mean user and environment error", + "a non-zero exit is a SUCCEEDED operation — the process ran and was " + "observed, which is what was asked for", + "--policy-file / --policy-json supply the operator's approvals snapshot; " + "process.* is the only kind prefix the run_command gate governs", + "--env NAME allow-lists one host variable (repeatable); the default is " + "nothing at all, not even PATH", + ], + }, + { + "title": "Profiles", + "items": [ + "exec accepts 'project' and 'control'; shell accepts 'project' only", + "a control operation must name its program in an argv vector: a shell " + "re-interprets a string after the gate read it, so which program runs " + "is not settled by what was gated", + "the profile is recorded on every evidence record, so control-plane " + "execution and model-authored execution are separable after the fact", + ], + }, + { + "title": "Confinement", + "items": [ + _NOT_CONFINED, + "a process can leave the work root through interpreters, absolute " + "paths, network calls, or child processes; nothing here prevents it", + "the policy gate reads the first token of a string a shell then " + "re-tokenizes — it records operator intent, it does not contain", + "changed paths are never enumerated here: effects.complete is false and " + "an empty list means 'not observed', not 'nothing happened'", + "every result carries the runner's isolation posture in " + "evidence.isolation / evidence.isolation_note — see 'shell explain safety'", + ], + }, +] + + +# --- environment --------------------------------------------------------------- + + +def _environment(args: argparse.Namespace) -> Environment: + """Build the :class:`~shell.environment.Environment` these verbs run in. + + ``source_root`` and ``work_root`` are the same directory. The separation + exists so an operation cannot rewrite its own active authorization by + editing a file it may write — and it buys nothing when both are one + directory, which is why + :attr:`~shell.environment.Environment.roots_are_separate` reports ``False`` + and every evidence record says so. The policy snapshot is nonetheless read + from files the *operator* named on the command line, never from anything the + command being run could have produced first. + """ + root = resolve_root(args) + return Environment( + source_root=root, + work_root=root, + runner=HostRunner(), + env_passthrough=tuple(getattr(args, "env", None) or ()), + ) + + +def _resources(args: argparse.Namespace) -> dict[str, Any]: + """The operation's resource request, validated. + + ``None`` means "use the environment's default", which is what both flags + default to. A zero or negative timeout is refused rather than passed on: it + would terminate the command before it could do anything, which is never what + someone typing a number meant. + """ + timeout = getattr(args, "timeout", None) + if timeout is not None and timeout <= 0: + raise CliError( + code=EXIT_USER_ERROR, + message=f"--timeout must be greater than zero, got {timeout}", + remediation="omit --timeout for the environment default, or pass a positive number", + ) + + limit = getattr(args, "max_output_bytes", None) + if limit is not None and limit < 0: + raise CliError( + code=EXIT_USER_ERROR, + message=f"--max-output-bytes must not be negative, got {limit}", + remediation="omit --max-output-bytes for the environment default, or pass 0 or more", + ) + + return {"timeout_seconds": timeout, "max_output_bytes": limit} + + +# --- the profile gate, before the operation exists ------------------------------ + + +def _profile(args: argparse.Namespace, verb: str) -> ExecutionProfile | None: + """Resolve ``--profile``, refusing one this verb's kind does not accept. + + ``None`` — the default — leaves the profile unset so + :func:`shell.operations.normalize` fills it from the handler's registration. + The CLI deliberately keeps no default of its own: one would silently win + over the registration if a handler's default ever changed, which is the + drift the derived ``--apply`` flag exists to remove. + + The refusal message is :func:`shell.process.exec.require_profile`'s, not a + restatement of it. + """ + raw = getattr(args, "profile", None) + if raw is None: + return None + + profile = ExecutionProfile(raw) + problem = _profile_problem(verb, profile) + if problem is not None: + raise CliError( + code=EXIT_USER_ERROR, + message=problem, + remediation=_PROFILE_REMEDIATION[verb], + ) + return profile + + +def _profile_problem(verb: str, profile: ExecutionProfile) -> str | None: + """The handler's own refusal text for *profile*, or ``None`` if acceptable. + + The throwaway :class:`~shell.operations.Operation` exists only because + :func:`shell.process.exec.require_profile` reads a profile off one; nothing + is executed, gated or recorded here. What matters is that the sentence + comes back from the handler's own function, given the handler's own + accepted-profile tuple and hint. + """ + kind = KINDS[verb] + return process_exec.require_profile( + Operation(kind=kind, profile=profile), + kind, + ACCEPTED_PROFILES[verb], + PROFILE_HINTS[verb], + ) + + +# --- dispatch -------------------------------------------------------------------- + + +def _exit_code_note(result: OperationResult) -> str | None: + """The stderr diagnostic for a command that ran and exited non-zero.""" + code = result.output.get("exit_code") + if not code: + return None + return ( + f"note: the command exited {code}. shell's own exit code reports whether the " + "operation ran, not what the command returned — read output.exit_code." + ) + + +def _emit(result: OperationResult, *, json_mode: bool) -> int: + """Render *result*, then translate its status into an exit code. + + **In JSON mode the structured result goes to stdout on every path**, so an + agent that asked for ``--json`` never has to re-run a command to learn what + happened — which, for an execution verb, is not a safe thing to ask of it. + + **In text mode a non-terminal outcome writes nothing to stdout.** Its + rendering is the error message, which is already going to stderr, and + ``shell process shell 'make' > build.log`` must not land a policy denial in + ``build.log`` looking like build output. + """ + terminal = result.succeeded or result.status is OperationStatus.PREVIEWED + if json_mode: + emit_result(result.to_dict(), json_mode=True) + elif terminal: + emit_result(result.rendering, json_mode=False) + + if result.succeeded: + if not json_mode: + note = _exit_code_note(result) + if note is not None: + emit_diagnostic(note) + return 0 + + if result.status is OperationStatus.PREVIEWED: + if not json_mode: + # In JSON mode `status` already carries this and `evidence.isolation` + # already carries the posture; a prose line would only be noise. + emit_diagnostic( + "note: previewed — nothing was run. Re-run with --apply to carry it out. " + f"When it runs, {_NOT_CONFINED}." + ) + return 0 + + if result.status is OperationStatus.DENIED: + raise CliError( + code=EXIT_USER_ERROR, + message=result.error or "the operation was denied", + remediation=( + "the operator's policy refused this command and nothing was started; " + "see the verdict in the --json result. --policy-file / --policy-json " + "select the snapshot that was evaluated." + ), + ) + + if result.status is OperationStatus.TIMED_OUT: + raise CliError( + code=EXIT_USER_ERROR, + message=result.error or "the command exceeded its timeout", + remediation=( + "the command was terminated and reported nothing about its work; raise " + "--timeout or narrow the command. Termination reaches the process group, " + "which is cleanup and not containment: a descendant that called setsid " + "may still be running." + ), + ) + + raise CliError( + code=EXIT_USER_ERROR, + message=result.error or f"the operation reported status {result.status.value!r}", + remediation=( + "the process did not run as asked; add --json for the full structured " + "result (status, verdict, evidence) on stdout" + ), + ) + + +def _run(args: argparse.Namespace, verb: str, arguments: Mapping[str, Any]) -> int: + """Build the operation and put it through the one pipeline.""" + profile = _profile(args, verb) + resources = _resources(args) + environment = _environment(args) + + operation = Operation( + kind=KINDS[verb], + arguments=dict(arguments), + profile=profile, + # Only --apply sets this. Without it the pipeline previews, because both + # kinds registered intent=EXECUTE. + apply=bool(getattr(args, "apply", False)), + caller={"agent": "shell-cli", "tool": f"shell process {verb}"}, + **resources, + ) + result = operations.execute( + operation, + environment, + policy=load_policy_from_args(args), + evidence_store=evidence_store(args), + ) + return _emit(result, json_mode=bool(getattr(args, "json", False))) + + +# --- verbs ------------------------------------------------------------------------- + + +def cmd_process_exec(args: argparse.Namespace) -> int: + if not args.argv: + raise CliError( + code=EXIT_USER_ERROR, + message="process exec requires an argv vector", + remediation=( + "put shell's own flags first and the program after '--', e.g. " + "'shell process exec --apply -- git status'" + ), + ) + return _run(args, "exec", {"argv": list(args.argv)}) + + +def cmd_process_shell(args: argparse.Namespace) -> int: + return _run(args, "shell", {"command": args.shell_command}) + + +def cmd_process_overview(args: argparse.Namespace) -> int: + emit_overview( + "shell process", + _NOUN_SECTIONS, + json_mode=bool(getattr(args, "json", False)), + ) + return 0 + + +def _no_verb(args: argparse.Namespace) -> int: + return cmd_process_overview(args) + + +# --- wiring ------------------------------------------------------------------------- + + +def _add_execution_args(p: argparse.ArgumentParser, verb: str) -> None: + """Every flag both execution verbs share, in one place. + + ``--profile`` offers all three profiles even where the kind accepts one, so + that a rejected profile produces this module's explanation rather than + argparse's "invalid choice". Being told *why* ``control`` is unavailable to + a shell string is the whole point. + """ + add_apply_flag( + p, + KINDS[verb], + help_text=( + "Actually run the command. Without it the operation is previewed and " + "no process is started." + ), + ) + p.add_argument( + "--profile", + choices=[profile.value for profile in ExecutionProfile], + default=None, + help=( + "Why this subprocess is running. Default: the kind's registered default " + "(currently 'project'). 'control' is accepted by exec only." + ), + ) + p.add_argument( + "--env", + action="append", + metavar="NAME", + help=( + "Allow one host environment variable through to the process (repeatable). " + "The default is an empty environment — not even PATH." + ), + ) + p.add_argument( + "--timeout", + type=float, + default=None, + metavar="SECONDS", + help="Wall-clock bound (default: the environment's, currently 120s).", + ) + p.add_argument( + "--max-output-bytes", + type=int, + default=None, + dest="max_output_bytes", + metavar="N", + help="Bytes retained per stream; overflow is counted, then discarded (default: 25000).", + ) + add_policy_source_args(p) + add_common_args( + p, + root_help=( + "Directory the process starts in (default: the current directory). This is " + "a starting point, never a boundary — see 'shell process overview'." + ), + ) + + +def register(sub: argparse._SubParsersAction) -> None: + p = sub.add_parser( + "process", + help="Run processes through the gated operation pipeline (see 'shell process overview').", + ) + p.add_argument("--json", action="store_true", help=JSON_HELP) + p.set_defaults(func=_no_verb, json=False) + # `p` is a _CliArgumentParser (the top-level subparsers were built with that + # parser_class); propagate it so every verb under `process` routes parse + # errors through the structured error contract instead of argparse's raw + # stderr and exit 2. + noun_sub = p.add_subparsers(dest="process_command", parser_class=type(p)) + + ov = noun_sub.add_parser("overview", help="Describe the process noun.") + ov.add_argument("--json", action="store_true", help=JSON_HELP) + ov.set_defaults(func=cmd_process_overview) + + exec_parser = noun_sub.add_parser( + "exec", + help="Run an explicit argv vector — previews unless --apply (process.exec).", + ) + exec_parser.add_argument( + "argv", + nargs="*", + metavar="ARGV", + help=( + "The program and its arguments, unsplit and unexpanded. Put '--' before it " + "so a leading dash is not read as one of shell's own flags." + ), + ) + _add_execution_args(exec_parser, "exec") + exec_parser.set_defaults(func=cmd_process_exec) + + shell_parser = noun_sub.add_parser( + "shell", + help="Run a shell command string — previews unless --apply (process.shell).", + ) + # dest is `shell_command`, not `command`: the top-level subparsers action in + # shell/cli/__init__.py already owns dest="command", and argparse merges every + # parser's namespace into one. A positional named `command` here would + # overwrite the noun the dispatcher selected. `shell policy check --command` + # hit the same collision from the other direction and documents it too. + shell_parser.add_argument( + "shell_command", + metavar="COMMAND", + help="The command string, handed to a fresh shell that re-interprets it.", + ) + _add_execution_args(shell_parser, "shell") + shell_parser.set_defaults(func=cmd_process_shell) diff --git a/shell/cli/_operation.py b/shell/cli/_operation.py new file mode 100644 index 0000000..846e0e3 --- /dev/null +++ b/shell/cli/_operation.py @@ -0,0 +1,228 @@ +"""Shared machinery for the CLI noun groups that front the operation engine. + +``fs`` and ``process`` are two front ends over one pipeline, and everything in +here exists because a fact they both need must have exactly one home. Nothing +in this module performs an operation; it builds the pieces a verb hands to +:func:`shell.operations.execute`. + +Why ``--apply`` is derived rather than declared +----------------------------------------------- + +Whether an operation previews is decided by the **intent its handler declared at +registration** — :func:`shell.operations.execute` reads +:attr:`~shell.operations.Operation.requires_apply`, which comes from the +registry, and previews anything that needs applying and was not applied. + +A CLI that also keeps a list of "verbs that get an ``--apply`` flag" has written +down a second answer to that question, and the two answers are only equal for as +long as someone keeps them equal. The failure is quiet and one-directional: flip +a handler's intent from ``OBSERVE`` to ``MUTATE`` and the verb previews forever +with no flag to escape it, printing ``previewed`` at a caller who has no way to +proceed. The reverse — a flag on a verb the engine never previews — is merely +useless, which is why it is the version that tends to get noticed. + +So the flag is not declared anywhere. :func:`add_apply_flag` asks the registry +the same question ``execute`` will ask, and adds the flag only when the answer +is yes. A verb cannot disagree with the engine about this, because it no longer +holds an opinion. + +The consequence for tests: the correspondence assertions in +``tests/test_cli_fs.py`` and ``tests/test_cli_apply_flag.py`` compare the +derivation against itself and are now trivially true. They are kept +deliberately — they were the thing that made the divergence visible, and a +regression to a hand-maintained list is exactly what would make them +load-bearing again. ``tests/test_cli_apply_flag.py`` adds the checks that can +still fail: the rule against handlers registered for the test alone, and a +source scan asserting no other module in ``shell/`` spells ``--apply``. + +An unregistered kind raises :class:`~shell.operations.UnknownOperationKind` +rather than defaulting. There is no honest answer for a kind with no declared +intent, and either default would ship a verb whose flag contradicts the engine. +""" + +from __future__ import annotations + +import argparse +import json as _json +from pathlib import Path +from typing import Any + +from shell import operations +from shell.cli._errors import EXIT_ENV_ERROR, EXIT_USER_ERROR, CliError +from shell.evidence import EvidenceStore +from shell.operations import Operation +from shell.policy import Policy, load_policy + +__all__ = [ + "JSON_HELP", + "add_apply_flag", + "add_common_args", + "add_policy_source_args", + "evidence_store", + "load_policy_from_args", + "requires_apply", + "resolve_root", +] + +JSON_HELP = "Emit structured JSON." + +_DEFAULT_APPLY_HELP = ( + "Carry the operation out. Without it the operation is previewed and nothing happens." +) + + +# --- the preview gate, asked of the registry ---------------------------------- + + +def requires_apply(kind: str) -> bool: + """Whether the engine previews *kind* unless the caller applied. + + Answered by :func:`shell.operations.normalize`, which is the same call + :func:`shell.operations.execute` makes before consulting + :attr:`~shell.operations.Operation.requires_apply`. Raises + :class:`~shell.operations.UnknownOperationKind` for a kind no handler has + registered. + """ + return operations.normalize(Operation(kind=kind)).requires_apply + + +def add_apply_flag( + parser: argparse.ArgumentParser, + kind: str, + *, + help_text: str = _DEFAULT_APPLY_HELP, +) -> bool: + """Add ``--apply`` to *parser* iff the engine previews *kind*. + + Returns whether the flag was added, so a caller can assert on it. This is + the only place in the package that spells ``--apply``; see the module + docstring for why that is a rule rather than a coincidence. + """ + if not requires_apply(kind): + return False + parser.add_argument("--apply", action="store_true", help=help_text) + return True + + +# --- environment plumbing every operation front end needs --------------------- + + +def resolve_root(args: argparse.Namespace) -> Path: + """The directory ``--root`` selected, or the process cwd. + + A ``--root`` that is not a directory is an *environment* error (exit 2), not + a user error: the caller's operation may be perfectly well formed and the + place it was asked to happen simply is not there. + """ + raw = getattr(args, "root", None) + root = Path(raw).expanduser() if raw is not None else Path.cwd() + if not root.is_dir(): + raise CliError( + code=EXIT_ENV_ERROR, + message=f"--root is not an existing directory: {root}", + remediation="point --root at a directory that exists (default: the current directory)", + ) + return root + + +def evidence_store(args: argparse.Namespace) -> EvidenceStore | None: + """The evidence store, when ``--evidence-dir`` opted into one. + + Persistence stays opt-in here for the same reason it is opt-in in the + library: writing files under someone's tree on the first invocation is a + side effect nobody asked for. + """ + directory = getattr(args, "evidence_dir", None) + return None if directory is None else EvidenceStore(directory=Path(directory)) + + +def add_common_args(parser: argparse.ArgumentParser, *, root_help: str) -> None: + """The three flags every operation verb carries. + + ``root_help`` is a parameter rather than a shared string because the root + does not mean the same thing to every noun. For ``fs`` it is the boundary + every path is confined to; for ``process`` it is the directory a command + starts in and nothing more. Writing one sentence for both would make the + weaker guarantee read like the stronger one. + """ + parser.add_argument( + "--root", + type=Path, + default=None, + metavar="DIR", + help=root_help, + ) + parser.add_argument( + "--evidence-dir", + type=Path, + default=None, + metavar="DIR", + help=( + "Persist this operation's evidence record into DIR (opt-in; retrieve it " + "with 'shell operation show ')." + ), + ) + parser.add_argument("--json", action="store_true", help=JSON_HELP) + + +# --- the operator's policy snapshot ------------------------------------------- + + +def add_policy_source_args(parser: argparse.ArgumentParser) -> None: + """The flags that select an operator policy snapshot. + + Shared by ``shell policy`` (which evaluates a snapshot) and ``shell + process`` (which executes under one), so the two nouns cannot end up + resolving a policy differently. + """ + parser.add_argument( + "--policy-file", + action="append", + type=Path, + metavar="PATH", + help=( + "A pre-resolved approvals.json candidate (repeatable; later " + "candidates win per-section). Omit for the empty policy -- the " + "same default shell.operations.execute() uses when no policy is " + "supplied." + ), + ) + parser.add_argument( + "--policy-json", + metavar="JSON", + help='Inline policy document, e.g. \'{"run_command": {"deny": ["rm"]}}\'. ' + "Takes precedence over --policy-file for the sections it defines.", + ) + + +def load_policy_from_args(args: argparse.Namespace) -> Policy: + """Build the :class:`~shell.policy.Policy` snapshot the verb evaluates against. + + Mirrors :func:`shell.policy.load_policy`'s own contract: no candidate files + means the empty policy, which is exactly what ``execute()`` substitutes when + a caller passes no policy at all. A malformed ``--policy-json`` value is the + one case this raises on — the CLI is the authoring side there (a human typed + the flag), whereas :func:`shell.policy.load_policy` never raises for a bad + *file*, because a malformed policy file degrades to an untrustworthy + snapshot that fails closed rather than to an exception. + """ + candidates: list[Path] = list(getattr(args, "policy_file", None) or []) + data: dict[str, Any] | None = None + raw = getattr(args, "policy_json", None) + if raw: + try: + parsed = _json.loads(raw) + except _json.JSONDecodeError as exc: + raise CliError( + code=EXIT_USER_ERROR, + message=f"--policy-json is not valid JSON: {exc}", + remediation='pass a JSON object, e.g. \'{"run_command": {"deny": ["rm"]}}\'', + ) from exc + if not isinstance(parsed, dict): + raise CliError( + code=EXIT_USER_ERROR, + message="--policy-json must be a JSON object", + remediation="pass an object, e.g. '{\"run_command\": {...}}'", + ) + data = parsed + return load_policy(candidates, data=data) diff --git a/shell/explain/catalog.py b/shell/explain/catalog.py index a69aad5..93ad27e 100644 --- a/shell/explain/catalog.py +++ b/shell/explain/catalog.py @@ -26,15 +26,17 @@ ## Status The library has the operation core: `fs.read`, `fs.list`, `fs.stat`, -`fs.write`, `fs.edit` and `fs.media`, path confinement, the policy evaluator, -the evidence contract, and `HostRunner` execution are built and green. - -**The CLI is catching up.** `fs`, `policy` and `operation` are real CLI nouns. -`shell fs` executes the confined filesystem operations through the same -`shell.operations.execute()` pipeline the library uses; `policy check` / +`fs.write`, `fs.edit`, `fs.media`, `process.exec` and `process.shell`, path +confinement, the policy evaluator, the evidence contract, and `HostRunner` +execution are built and green. + +**The CLI is catching up.** `fs`, `process`, `policy` and `operation` are real +CLI nouns. `shell fs` executes the confined filesystem operations and +`shell process` runs argv vectors and shell command strings, both through the +same `shell.operations.execute()` pipeline the library uses; `policy check` / `policy explain` evaluate the same policy gate that pipeline gates through; and -`operation show` retrieves persisted evidence. The `env` / `process` / `git` -verb groups are still not exposed — no CLI verb runs a shell command yet. See +`operation show` retrieves persisted evidence. The `env` and `git` verb groups +are still not exposed. See . ## Safety posture @@ -55,6 +57,9 @@ - `shell fs read|list|stat|write|edit|media` — confined filesystem operations, through the same `execute()` pipeline the library uses. `write` and `edit` preview by default and need `--apply`. +- `shell process exec|shell` — run an argv vector or a shell command string + through the same pipeline, under the operator's `run_command` policy. Both + preview by default and need `--apply`. Neither is path-confined. - `shell policy check ` — evaluate the run_command policy gate for one operation, through the same evaluator `execute()` uses. - `shell policy explain` — list gated kind prefixes and every known operation @@ -294,6 +299,118 @@ - `shell operation show` — retrieve the record written by `--evidence-dir` """ +_PROCESS = """\ +# shell process + +Run processes through the gated operation pipeline. Two verbs, both +executions, both previewing by default: + +- `shell process exec [--profile project|control] [--apply] -- ARGV...` — run + an explicit argv vector. Nothing splits, expands or substitutes into it: the + program and its arguments are exactly the tuple you assembled. Put `--` + before the program so a leading dash is not read as one of `shell`'s own + flags. +- `shell process shell COMMAND [--apply]` — run a model-authored command string + through a fresh shell. Project profile **only**. +- `shell process overview` — describe this noun. + +Both build an `Operation` and dispatch it through +`shell.operations.execute()` — the same pipeline an importing consumer uses. +This noun group starts no process itself; the registered `process.*` handlers +and the environment's runner do that. + +## This is not confined, and the distinction from `shell fs` matters + +`shell fs` resolves every path against the work root and refuses to leave it. +Nothing here does anything comparable. `--root` is the directory the command +starts in — a starting point, not a boundary — and the command can leave it +through interpreters, absolute paths, network calls, or child processes. + +The policy gate is not containment either: it reads the first token of a string +the shell then re-tokenizes, so `sh -c`, pipelines, here-docs, command +substitution and an absolute path to a renamed binary all step around it. It +records operator intent against accidental and careless behaviour, never an +adversarial one. See `shell explain safety`. + +Both facts ride in the payload, not only in this page. An applied result +carries `output.confinement` with `path_confined: false` alongside +`uses_shell` and `gate_inspects_reinterpreted_string` — kept as separate keys +because they are separate facts, so nobody infers "argv, therefore confined". +Every result, previews included, carries the runner's own `evidence.isolation` +(`none` for the host runner) and `evidence.isolation_note`. + +Changed paths are never enumerated: `effects.complete` is `false` and an empty +changed-path list means "not observed", never "nothing happened". + +## Profiles: why `exec` and `shell` differ + +`exec` accepts `project` and `control`. `shell` accepts `project` only, and +asking for `--profile control` there is refused with an error explaining why. + +A `control` operation runs a trusted control-plane program — git mechanics, a +capability CLI, the container runtime. Which program that is must be settled by +the argv vector the caller assembled, not by a string a shell reinterprets +after the gate has already read it. Refusing here is what stops a raw string +from acquiring control-plane trust by asking for it. + +The refusal happens before the preview branch, not after: reporting `previewed` +for an operation that could never be applied would say it is fine and merely +unapplied. + +`--profile` defaults to the kind's registered default (currently `project`), +resolved by the engine rather than by a default kept in the CLI. + +## Preview by default + +Both kinds declare `intent=execute`, so the pipeline previews them unless the +operation carries `apply=True`, and `--apply` is the only thing that sets it. +The flag is derived from the operation registry, so a verb cannot disagree with +the engine about whether it previews. + +**A preview reports status `previewed`, never `succeeded`.** It describes what +*would* run and predicts nothing: no effects, no exit code, no process started. + +## Exit codes, and what they do not report + +- `0` — the operation `succeeded` or was `previewed` +- `1` — `denied` (the operator's policy refused it, and nothing started), + `failed` (the process could not be started, or was cancelled), or `timed_out` +- `2` — `--root` is not an existing directory + +**The command's own exit code is not passed through.** `1` and `2` already mean +user error and environment error here, and `3+` is reserved, so a command +exiting `1` would be indistinguishable from a bad flag. A non-zero exit is also +not a failure of the *operation*: the operation was "run this and observe it", +and it did. Read `output.exit_code` (or `evidence.exit_code`); in text mode a +diagnostic on stderr names it so `0` is never read as approval. + +Under `--json` a non-terminal outcome still writes its full structured result +to stdout before the error goes to stderr. In text mode stdout stays empty, so +a redirect never captures a policy denial as if it were command output. + +## The environment is an allow-list + +`--env NAME` (repeatable) lets one host variable through. The default is an +empty environment — not even `PATH`. Nothing quietly restores a variable you +did not name, because that would make the declared policy a fiction. Only the +*names* reach a result or an evidence record; values never do. + +## Usage + + shell process exec -- git status # previews + shell process exec --apply --env PATH -- git status # runs + shell process exec --profile control --apply -- git worktree list + shell process shell 'make test 2>&1 | tail -20' --apply --env PATH + shell process shell 'rm -rf build' --policy-file .colleague/approvals.json + shell process exec --apply --timeout 30 --evidence-dir .shell/evidence -- pytest -q + +## See also + +- `shell explain safety` +- `shell policy explain` — what the `run_command` gate does and does not govern +- `shell operation show` — retrieve the record written by `--evidence-dir` +""" + _POLICY = """\ # shell policy @@ -389,6 +506,10 @@ ("fs", "write"): _FS, ("fs", "edit"): _FS, ("fs", "media"): _FS, + ("process",): _PROCESS, + ("process", "overview"): _PROCESS, + ("process", "exec"): _PROCESS, + ("process", "shell"): _PROCESS, ("policy",): _POLICY, ("policy", "overview"): _POLICY, ("policy", "check"): _POLICY, diff --git a/shell/process/exec.py b/shell/process/exec.py index 5b3e97b..5b0a2dc 100644 --- a/shell/process/exec.py +++ b/shell/process/exec.py @@ -76,7 +76,13 @@ from shell.results import Effects, Evidence, OperationResult, OperationStatus from shell.runners.types import ProcessOutcome, ProcessRequest -__all__ = ["EXEC_CONFINEMENT_NOTE", "KIND"] +__all__ = [ + "EXEC_CONFINEMENT_NOTE", + "EXEC_PROFILES", + "EXEC_PROFILE_HINT", + "KIND", + "require_profile", +] #: The operation kind this module registers. KIND = "process.exec" @@ -94,7 +100,18 @@ "runner axis, and the host runner declares none." ) -_EXEC_PROFILES = (ExecutionProfile.PROJECT, ExecutionProfile.CONTROL) +#: The profiles this kind accepts. **Public, and read by the CLI.** A front end +#: has to refuse a rejected profile *before* the preview branch — a preview of +#: an operation that could never be applied implies it would otherwise have run +#: — so the accepted set cannot live only inside the handler. Exporting it (with +#: :data:`EXEC_PROFILE_HINT` and :func:`require_profile`) is what lets the CLI +#: reach that decision early while still producing this module's own answer, +#: rather than a paraphrase that can drift away from it. +EXEC_PROFILES = (ExecutionProfile.PROJECT, ExecutionProfile.CONTROL) + +#: Why ``observe`` is refused here, stated once. The handler and the CLI quote +#: this constant; neither writes its own sentence. +EXEC_PROFILE_HINT = "The 'observe' profile is for structured reads that spawn no process." # dataclasses.replace preserves the concrete Evidence type at runtime. @@ -354,12 +371,7 @@ def _exec(operation: Operation, environment: Environment) -> OperationResult: if not isinstance(argv, (list, tuple)) or not argv: return failed(operation, f"{KIND} requires 'argv': a non-empty list of strings") - problem = require_profile( - operation, - KIND, - _EXEC_PROFILES, - "The 'observe' profile is for structured reads that spawn no process.", - ) + problem = require_profile(operation, KIND, EXEC_PROFILES, EXEC_PROFILE_HINT) if problem is not None: return failed(operation, problem) diff --git a/shell/process/shell.py b/shell/process/shell.py index f776db8..a61bce1 100644 --- a/shell/process/shell.py +++ b/shell/process/shell.py @@ -76,7 +76,7 @@ from shell.results import OperationResult from shell.runners.types import ProcessRequest -__all__ = ["KIND", "SHELL_CONFINEMENT_NOTE"] +__all__ = ["KIND", "SHELL_CONFINEMENT_NOTE", "SHELL_PROFILES", "SHELL_PROFILE_HINT"] #: The operation kind this module registers. KIND = "process.shell" @@ -97,7 +97,19 @@ "to this operation." ) -_SHELL_PROFILES = (ExecutionProfile.PROJECT,) +#: The single profile this kind accepts. Public for the same reason +#: :data:`shell.process.exec.EXEC_PROFILES` is: the CLI must refuse a rejected +#: profile before the preview branch, and the accepted set is this module's +#: answer to give. +SHELL_PROFILES = (ExecutionProfile.PROJECT,) + +#: Why every other profile is refused here, stated once. The handler and the +#: CLI quote this constant rather than each writing the sentence. +SHELL_PROFILE_HINT = ( + "A raw shell string may not carry control-plane trust: the shell " + "re-interprets it after the gate read it, so which program runs is not " + "settled by what was gated. Use process.exec with an argv vector." +) def _shell(operation: Operation, environment: Environment) -> OperationResult: @@ -115,14 +127,7 @@ def _shell(operation: Operation, environment: Environment) -> OperationResult: if not isinstance(command, str) or not command.strip(): return failed(operation, f"{KIND} requires 'command': a non-empty shell command string") - problem = require_profile( - operation, - KIND, - _SHELL_PROFILES, - "A raw shell string may not carry control-plane trust: the shell " - "re-interprets it after the gate read it, so which program runs is not " - "settled by what was gated. Use process.exec with an argv vector.", - ) + problem = require_profile(operation, KIND, SHELL_PROFILES, SHELL_PROFILE_HINT) if problem is not None: return failed(operation, problem) diff --git a/tests/test_cli_apply_flag.py b/tests/test_cli_apply_flag.py new file mode 100644 index 0000000..b1f23b2 --- /dev/null +++ b/tests/test_cli_apply_flag.py @@ -0,0 +1,160 @@ +"""The ``--apply`` flag is derived from the operation registry, not hand-picked. + +Two decisions used to be made independently: the CLI chose which verbs carried +``--apply``, and the *engine* decided which operations preview, from the intent +each handler declared at registration. ``tests/test_cli_fs.py`` pinned the +correspondence with an assertion, which caught a divergence but could not +prevent one -- and the pin covered one noun group, so a second group repeating +the pattern would need a second pin. + +The flag is now built by :func:`shell.cli._operation.add_apply_flag`, which asks +:func:`shell.operations.normalize` the same question ``execute()`` asks before +it decides to preview. The correspondence assertion in ``test_cli_fs.py`` is +therefore trivially true today -- it compares the derivation against itself -- +and it is kept because a *regression* to a hand-maintained list would make it +load-bearing again on the same day it started mattering. + +What is checked here is the derivation itself, in the two ways that would +actually catch it breaking: + +* it follows a handler's declared intent, proved against handlers registered + for this test alone (so the assertion is about the rule, not about the six + ``fs.*`` kinds happening to be classified correctly today); +* nothing else in the CLI adds an ``--apply`` flag, so a future noun group + cannot re-introduce the hand-maintained list this refactor removed. +""" + +from __future__ import annotations + +import argparse +import re +from pathlib import Path + +import pytest + +from shell import operations +from shell.cli import _build_parser, _operation +from shell.cli._commands import fs as fs_group +from shell.cli._commands import process as process_group +from shell.operations import ExecutionProfile, Operation, OperationIntent +from shell.results import OperationResult, OperationStatus + +_REPO_ROOT = Path(__file__).resolve().parents[1] + + +def _noop(operation: Operation, environment: object) -> OperationResult: + return OperationResult(operation_id=operation.id, status=OperationStatus.SUCCEEDED) + + +@pytest.fixture +def registered_kind(request: pytest.FixtureRequest): + """Register a throwaway kind with a chosen intent, and clean it up.""" + created: list[str] = [] + + def _register(intent: OperationIntent) -> str: + kind = f"test.apply-derivation.{intent.value}.{id(request)}" + operations.register( + kind, + intent=intent, + default_profile=ExecutionProfile.OBSERVE, + run=_noop, + ) + created.append(kind) + return kind + + yield _register + + for kind in created: + operations.unregister(kind) + + +@pytest.mark.parametrize( + ("intent", "expected"), + [ + (OperationIntent.OBSERVE, False), + (OperationIntent.MUTATE, True), + (OperationIntent.EXECUTE, True), + (OperationIntent.LIFECYCLE, True), + ], +) +def test_the_flag_follows_the_handlers_declared_intent( + intent: OperationIntent, expected: bool, registered_kind +) -> None: + """The rule, checked against handlers that exist only for this test. + + Asserting over ``fs.*`` alone would pass just as well if the derivation + hard-coded those six kinds. These four handlers are registered here, so the + only way to satisfy this is to ask the registry. + """ + kind = registered_kind(intent) + parser = argparse.ArgumentParser() + + added = _operation.add_apply_flag(parser, kind) + + assert added is expected + has_flag = any("--apply" in action.option_strings for action in parser._actions) + assert has_flag is expected + assert _operation.requires_apply(kind) is expected + + +def test_an_unregistered_kind_cannot_have_its_flag_derived() -> None: + """Failing loudly beats guessing. + + A kind with no handler has no declared intent, so there is no honest answer + to "does this preview". Defaulting either way would silently ship a verb + whose flag contradicts the engine, which is the whole failure mode this + derivation exists to remove. + """ + with pytest.raises(operations.UnknownOperationKind): + _operation.requires_apply("test.no-handler-will-ever-be-registered") + + +def _verb_parsers(noun: str) -> dict[str, argparse.ArgumentParser]: + parser = _build_parser() + [top] = [a for a in parser._actions if isinstance(a, argparse._SubParsersAction)] + noun_parser = top.choices[noun] + [sub] = [a for a in noun_parser._actions if isinstance(a, argparse._SubParsersAction)] + return dict(sub.choices) + + +@pytest.mark.parametrize( + ("noun", "kinds"), + [("fs", fs_group.KINDS), ("process", process_group.KINDS)], +) +def test_every_shipped_verb_agrees_with_the_engine(noun: str, kinds: dict[str, str]) -> None: + """Both groups, one rule. Trivially true by construction -- and kept anyway. + + This is the cross-group version of ``test_cli_fs.py``'s correspondence + assertion. It cannot fail while the flag is derived; it exists so that a + reintroduced hand-maintained list is caught by the same assertion that made + the problem visible in the first place. + """ + parsers = _verb_parsers(noun) + for verb, kind in kinds.items(): + expected = operations.normalize(Operation(kind=kind)).requires_apply + has_flag = any("--apply" in action.option_strings for action in parsers[verb]._actions) + assert has_flag == expected, ( + f"'shell {noun} {verb}' dispatches {kind}, which the engine reports " + f"requires_apply={expected}, but the verb " + f"{'has' if has_flag else 'has no'} --apply flag" + ) + + +def test_no_noun_group_adds_an_apply_flag_of_its_own() -> None: + """The structural guard: exactly one place in the CLI spells ``--apply``. + + A hand-maintained list is not wrong because of what it contains today; it + is wrong because it is a second answer to a question the engine already + answers. Scanning the source is the only check that survives a *new* noun + group being added by someone who never read this file. + """ + pattern = re.compile(r"""add_argument\(\s*["']--apply["']""") + offenders = [ + str(path.relative_to(_REPO_ROOT)) + for path in sorted((_REPO_ROOT / "shell").rglob("*.py")) + if pattern.search(path.read_text(encoding="utf-8")) + ] + assert offenders == ["shell/cli/_operation.py"], ( + "--apply must be added only by the registry-derived helper in " + f"shell/cli/_operation.py; found it in: {offenders}" + ) diff --git a/tests/test_cli_process.py b/tests/test_cli_process.py new file mode 100644 index 0000000..4d08672 --- /dev/null +++ b/tests/test_cli_process.py @@ -0,0 +1,1236 @@ +"""Tests for ``shell process`` — ``exec`` and ``shell``. + +Every test here runs a **real** process, for the reason +``tests/test_process_exec.py`` gives: a fake runner would pin the shape of a +result and none of the behaviour the result claims. + +Three properties this file exists to prove, in order of how badly it would hurt +to lose them: + +* **the CLI is a front end over the same engine, not a second implementation.** + Each verb's structured output is compared against a + :func:`shell.operations.execute` result the test produced itself, and the + operation ids the CLI reports are looked up through ``shell operation show`` + -- which reads the evidence record only ``execute()`` writes. A verb that + called a handler directly would print output and leave no record to find. +* **execution previews by default.** Neither verb may start a process without + ``--apply``, and a preview reports its own status rather than reading as + success. +* **the profile asymmetry is enforced, and enforced early.** ``process exec`` + takes an argv vector and accepts ``project`` or ``control``; ``process + shell`` takes a model-authored string and accepts ``project`` only. The + refusal must precede the preview branch, because a preview of an operation + that could never be applied implies it would otherwise have run. + +``--root`` is passed explicitly everywhere. A verb defaulting to the process cwd +is right for an operator at a terminal and completely wrong for a test suite, +which would otherwise run commands inside the repository checkout. +""" + +from __future__ import annotations + +import argparse +import json +import sys +from pathlib import Path +from typing import Any + +import pytest + +from shell import operations +from shell.cli import _build_parser, main +from shell.cli._commands import process as process_group +from shell.environment import Environment +from shell.operations import ExecutionProfile, Operation +from shell.policy import load_policy +from shell.process import exec as process_exec +from shell.process import shell as process_shell +from shell.results import OperationStatus +from shell.runners.host import HostRunner + +PYTHON = sys.executable + +# ``process_duration_ms`` is wall-clock: two runs of the same command never +# agree on it, and demanding they did would make this suite flaky rather than +# strict. Every other key in the payload is compared. +_VOLATILE_OUTPUT_KEYS = {"process_duration_ms"} + + +@pytest.fixture +def work(tmp_path: Path) -> Path: + root = tmp_path / "work" + root.mkdir() + return root + + +def _library_result( + work: Path, + kind: str, + arguments: dict[str, Any], + *, + apply_: bool = True, + env_passthrough: tuple[str, ...] = ("PATH",), + **operation_kwargs: Any, +): + """Run the same operation through the library, for CLI/library comparison.""" + environment = Environment( + source_root=work, + work_root=work, + runner=HostRunner(), + env_passthrough=env_passthrough, + ) + operation = Operation(kind=kind, arguments=arguments, apply=apply_, **operation_kwargs) + return operations.execute(operation, environment) + + +def _stable(output: dict[str, Any]) -> dict[str, Any]: + return {k: v for k, v in output.items() if k not in _VOLATILE_OUTPUT_KEYS} + + +# --- overview and noun wiring ------------------------------------------------- + + +def test_process_bare_prints_overview(capsys: pytest.CaptureFixture[str]) -> None: + rc = main(["process"]) + assert rc == 0 + out = capsys.readouterr().out + assert "# shell process" in out + for verb in ("exec", "shell"): + assert verb in out + + +def test_process_overview_json_shape(capsys: pytest.CaptureFixture[str]) -> None: + rc = main(["process", "overview", "--json"]) + assert rc == 0 + payload = json.loads(capsys.readouterr().out) + assert payload["subject"] == "shell process" + assert isinstance(payload["sections"], list) + assert payload["sections"] + + +def test_process_overview_states_the_profile_asymmetry( + capsys: pytest.CaptureFixture[str], +) -> None: + """The one rule a reader must not have to discover by being refused.""" + rc = main(["process", "overview"]) + assert rc == 0 + out = capsys.readouterr().out.lower() + assert "control" in out + assert "argv" in out + assert "not path-confined" in out + + +def test_every_kind_the_cli_fronts_is_a_registered_operation() -> None: + registered = operations.registered_kinds() + for kind in process_group.KINDS.values(): + assert kind in registered, f"{kind} is not a registered operation kind" + + +def _process_verb_parsers() -> dict[str, argparse.ArgumentParser]: + parser = _build_parser() + [top] = [a for a in parser._actions if isinstance(a, argparse._SubParsersAction)] + process_parser = top.choices["process"] + [noun] = [a for a in process_parser._actions if isinstance(a, argparse._SubParsersAction)] + return dict(noun.choices) + + +def test_both_verbs_carry_an_apply_flag() -> None: + """Both kinds declare ``intent=EXECUTE``, so both must be escapable.""" + parsers = _process_verb_parsers() + for verb in ("exec", "shell"): + assert any("--apply" in a.option_strings for a in parsers[verb]._actions), verb + + +# --- exec: through the engine ------------------------------------------------ + + +def test_exec_json_matches_the_library_result( + work: Path, capsys: pytest.CaptureFixture[str] +) -> None: + argv = [PYTHON, "-c", "print('hello')"] + expected = _library_result(work, "process.exec", {"argv": argv}) + + rc = main( + [ + "process", + "exec", + "--apply", + "--env", + "PATH", + "--root", + str(work), + "--json", + "--", + *argv, + ] + ) + assert rc == 0 + payload = json.loads(capsys.readouterr().out) + + assert payload["status"] == expected.status.value == "succeeded" + assert _stable(payload["output"]) == _stable(dict(expected.output)) + assert payload["rendering"] == expected.rendering + assert payload["verdict"] == expected.verdict.to_dict() + assert payload["effects"] == expected.effects.to_dict() + assert payload["evidence"]["stdout"] == expected.evidence.stdout == "hello\n" + assert payload["operation_id"] + + +def test_exec_text_mode_renders_the_labelled_output( + work: Path, capsys: pytest.CaptureFixture[str] +) -> None: + rc = main( + [ + "process", + "exec", + "--apply", + "--env", + "PATH", + "--root", + str(work), + "--", + PYTHON, + "-c", + "import sys; sys.stdout.write('OUT'); sys.stderr.write('ERR')", + ] + ) + assert rc == 0 + out = capsys.readouterr().out + assert "process.exec exit=0" in out + assert "--- stdout ---\nOUT" in out + assert "--- stderr ---\nERR" in out + + +def test_exec_argv_is_not_re_split_by_a_shell( + work: Path, capsys: pytest.CaptureFixture[str] +) -> None: + """An argument full of shell metacharacters arrives at the program verbatim.""" + hostile = "a b; echo pwned > owned.txt && $(whoami)" + rc = main( + [ + "process", + "exec", + "--apply", + "--root", + str(work), + "--json", + "--", + PYTHON, + "-c", + "import sys; sys.stdout.write(sys.argv[1])", + hostile, + ] + ) + assert rc == 0 + payload = json.loads(capsys.readouterr().out) + assert payload["evidence"]["stdout"] == hostile + assert not (work / "owned.txt").exists() + + +def test_exec_accepts_the_control_profile(work: Path, capsys: pytest.CaptureFixture[str]) -> None: + """An argv vector is the only way to ask for control-plane trust.""" + rc = main( + [ + "process", + "exec", + "--profile", + "control", + "--apply", + "--root", + str(work), + "--json", + "--", + PYTHON, + "-c", + "print('ok')", + ] + ) + assert rc == 0 + payload = json.loads(capsys.readouterr().out) + assert payload["status"] == "succeeded" + assert payload["output"]["profile"] == "control" + + +def test_exec_refuses_the_observe_profile(work: Path, capsys: pytest.CaptureFixture[str]) -> None: + rc = main(["process", "exec", "--profile", "observe", "--root", str(work), "--", PYTHON, "-V"]) + assert rc == 1 + err = capsys.readouterr().err + assert "spawn no process" in err + assert err.startswith("error:") + assert "hint:" in err + + +def test_exec_without_argv_is_a_structured_error( + work: Path, capsys: pytest.CaptureFixture[str] +) -> None: + rc = main(["process", "exec", "--root", str(work)]) + assert rc == 1 + err = capsys.readouterr().err + assert err.startswith("error:") + assert "hint:" in err + assert "--" in err + + +# --- shell: through the engine ----------------------------------------------- + + +def test_shell_json_matches_the_library_result( + work: Path, capsys: pytest.CaptureFixture[str] +) -> None: + command = "echo hello" + expected = _library_result(work, "process.shell", {"command": command}) + + rc = main( + ["process", "shell", command, "--apply", "--env", "PATH", "--root", str(work), "--json"] + ) + assert rc == 0 + payload = json.loads(capsys.readouterr().out) + + assert payload["status"] == expected.status.value == "succeeded" + assert _stable(payload["output"]) == _stable(dict(expected.output)) + assert payload["rendering"] == expected.rendering + assert payload["effects"] == expected.effects.to_dict() + assert payload["evidence"]["stdout"] == "hello\n" + + +def test_shell_runs_the_string_through_a_shell( + work: Path, capsys: pytest.CaptureFixture[str] +) -> None: + """A pipeline only a shell can interpret. This is the point of the kind.""" + rc = main( + [ + "process", + "shell", + "echo one two three | wc -w", + "--apply", + "--env", + "PATH", + "--root", + str(work), + "--json", + ] + ) + assert rc == 0 + payload = json.loads(capsys.readouterr().out) + assert payload["status"] == "succeeded" + assert payload["evidence"]["stdout"].strip() == "3" + + +# --- the profile asymmetry, enforced in the CLI ------------------------------ + + +def test_shell_refuses_the_control_profile(work: Path, capsys: pytest.CaptureFixture[str]) -> None: + rc = main( + ["process", "shell", "git status", "--profile", "control", "--apply", "--root", str(work)] + ) + assert rc == 1 + err = capsys.readouterr().err + assert err.startswith("error:") + assert "hint:" in err + assert "control-plane trust" in err + assert "process.exec" in err + + +def test_the_refusal_is_the_librarys_own_words( + work: Path, capsys: pytest.CaptureFixture[str] +) -> None: + """Not a paraphrase: the CLI calls the same check the handler calls. + + Two enforcement points spelling the same rule in two strings is how one of + them ends up saying something the other does not. The CLI's message is + produced by :func:`shell.process.exec.require_profile` with the kind's own + accepted-profile tuple and its own hint constant, so there is one sentence. + """ + expected = process_exec.require_profile( + Operation(kind=process_shell.KIND, profile=ExecutionProfile.CONTROL), + process_shell.KIND, + process_shell.SHELL_PROFILES, + process_shell.SHELL_PROFILE_HINT, + ) + assert expected is not None + + rc = main(["process", "shell", "git status", "--profile", "control", "--root", str(work)]) + assert rc == 1 + assert f"error: {expected}\n" in capsys.readouterr().err + + +def test_the_profile_refusal_precedes_the_preview( + work: Path, capsys: pytest.CaptureFixture[str] +) -> None: + """Without ``--apply`` the refusal must still be a refusal. + + Reporting ``previewed`` here would tell the caller the operation is fine + and merely unapplied -- an implication the engine itself refuses to make, + which is why its policy gate runs before its preview branch. + """ + rc = main( + ["process", "shell", "git status", "--profile", "control", "--root", str(work), "--json"] + ) + assert rc == 1 + captured = capsys.readouterr() + assert captured.out == "", "nothing was evaluated, so there is no result to report" + assert "control-plane trust" in captured.err + + +def test_exec_and_shell_disagree_about_control_on_purpose( + work: Path, capsys: pytest.CaptureFixture[str] +) -> None: + """The asymmetry itself, asserted as one fact rather than two.""" + accepted = { + "exec": process_exec.EXEC_PROFILES, + "shell": process_shell.SHELL_PROFILES, + } + assert ExecutionProfile.CONTROL in accepted["exec"] + assert ExecutionProfile.CONTROL not in accepted["shell"] + + rc = main( + [ + "process", + "exec", + "--profile", + "control", + "--apply", + "--root", + str(work), + "--json", + "--", + PYTHON, + "-V", + ] + ) + assert rc == 0 + capsys.readouterr() + + rc = main(["process", "shell", "true", "--profile", "control", "--apply", "--root", str(work)]) + assert rc == 1 + capsys.readouterr() + + +# --- preview by default ------------------------------------------------------- + + +@pytest.mark.parametrize("verb", ["exec", "shell"]) +def test_previews_by_default_and_starts_nothing( + verb: str, work: Path, capsys: pytest.CaptureFixture[str] +) -> None: + marker = work / "ran.txt" + script = f"open({str(marker)!r}, 'w').write('x')" + if verb == "exec": + argv = ["process", "exec", "--root", str(work), "--json", "--", PYTHON, "-c", script] + else: + argv = [ + "process", + "shell", + f"{PYTHON} -c {script!r}", + "--env", + "PATH", + "--root", + str(work), + "--json", + ] + + rc = main(argv) + assert rc == 0 + payload = json.loads(capsys.readouterr().out) + + assert payload["status"] == "previewed" + assert payload["status"] != "succeeded", "a preview is never reported as success" + assert not marker.exists(), "a preview must not start the process" + + +@pytest.mark.parametrize("verb", ["exec", "shell"]) +def test_a_preview_predicts_no_effects( + verb: str, work: Path, capsys: pytest.CaptureFixture[str] +) -> None: + """A preview describes what *would* run. It claims nothing about effects.""" + argv = ( + ["process", "exec", "--root", str(work), "--json", "--", PYTHON, "-V"] + if verb == "exec" + else ["process", "shell", "true", "--root", str(work), "--json"] + ) + rc = main(argv) + assert rc == 0 + payload = json.loads(capsys.readouterr().out) + + assert payload["effects"]["complete"] is False + assert payload["effects"]["changed_paths"] == [] + assert payload["output"].get("exit_code") is None, "a preview predicts no exit code" + + +def test_a_preview_still_carries_the_runners_posture( + work: Path, capsys: pytest.CaptureFixture[str] +) -> None: + """The honest posture reaches a preview too, via evidence rather than output. + + A handler never runs on a preview, so the ``output["confinement"]`` block + both process kinds build is genuinely absent there. What the pipeline + stamps on *every* result -- previews included -- is the runner's own + self-description, so a consumer reading only JSON still learns that nothing + here is isolated. + """ + rc = main(["process", "shell", "true", "--root", str(work), "--json"]) + assert rc == 0 + evidence = json.loads(capsys.readouterr().out)["evidence"] + assert evidence["isolation"] == "none" + assert "not a sandbox" in evidence["isolation_note"].lower() + + +@pytest.mark.parametrize("verb", ["exec", "shell"]) +def test_the_text_mode_preview_diagnostic_names_apply_and_the_missing_confinement( + verb: str, work: Path, capsys: pytest.CaptureFixture[str] +) -> None: + argv = ( + ["process", "exec", "--root", str(work), "--", PYTHON, "-V"] + if verb == "exec" + else ["process", "shell", "true", "--root", str(work)] + ) + rc = main(argv) + assert rc == 0 + captured = capsys.readouterr() + assert "preview" in captured.out.lower() + assert "--apply" in captured.err, "the remedy goes to stderr, never into the output stream" + assert "not path-confined" in captured.err + + +@pytest.mark.parametrize("verb", ["exec", "shell"]) +def test_apply_actually_runs_it(verb: str, work: Path, capsys: pytest.CaptureFixture[str]) -> None: + marker = work / "ran.txt" + script = f"open({str(marker)!r}, 'w').write('x')" + argv = ( + ["process", "exec", "--apply", "--root", str(work), "--json", "--", PYTHON, "-c", script] + if verb == "exec" + else [ + "process", + "shell", + f"{PYTHON} -c {script!r}", + "--apply", + "--env", + "PATH", + "--root", + str(work), + "--json", + ] + ) + rc = main(argv) + assert rc == 0 + payload = json.loads(capsys.readouterr().out) + assert payload["status"] == "succeeded" + assert marker.read_text(encoding="utf-8") == "x" + + +# --- confinement, stated rather than implied --------------------------------- + + +@pytest.mark.parametrize( + ("verb", "uses_shell"), + [("exec", False), ("shell", True)], +) +def test_an_applied_result_reports_that_it_was_not_path_confined( + verb: str, uses_shell: bool, work: Path, capsys: pytest.CaptureFixture[str] +) -> None: + """``path_confined`` is ``False`` for both kinds, and stays a separate fact. + + An argv vector removes shell re-interpretation and nothing else, so the two + facts are reported as two keys. Folding them together would let a reader + infer that an argv vector is confined merely because it is not re-parsed. + """ + argv = ( + ["process", "exec", "--apply", "--root", str(work), "--json", "--", PYTHON, "-V"] + if verb == "exec" + else ["process", "shell", "true", "--apply", "--env", "PATH", "--root", str(work), "--json"] + ) + rc = main(argv) + assert rc == 0 + confinement = json.loads(capsys.readouterr().out)["output"]["confinement"] + + assert confinement["path_confined"] is False + assert confinement["uses_shell"] is uses_shell + assert confinement["gate_inspects_reinterpreted_string"] is uses_shell + assert confinement["note"] + + +def test_the_cwd_is_a_starting_directory_not_a_boundary( + work: Path, tmp_path: Path, capsys: pytest.CaptureFixture[str] +) -> None: + """Pinned as a *fact about the product*, not as a defect to be fixed later. + + A process started here writes outside the work root without complaint, and + the changed-path list stays empty and incomplete. Anyone tempted to + describe these verbs as confined has to delete this test first. + """ + outside = tmp_path / "escaped.txt" + rc = main( + [ + "process", + "exec", + "--apply", + "--root", + str(work), + "--json", + "--", + PYTHON, + "-c", + f"open({str(outside)!r}, 'w').write('x')", + ] + ) + assert rc == 0 + payload = json.loads(capsys.readouterr().out) + + assert payload["status"] == "succeeded" + assert outside.read_text(encoding="utf-8") == "x", "nothing stopped the write" + assert payload["effects"]["changed_paths"] == [] + assert ( + payload["effects"]["complete"] is False + ), "an empty changed-path list here means 'not observed', never 'nothing happened'" + + +# --- the policy gate reaches the CLI ------------------------------------------ + + +def test_a_denied_command_is_refused_and_never_starts( + work: Path, capsys: pytest.CaptureFixture[str] +) -> None: + marker = work / "ran.txt" + policy = json.dumps({"run_command": {"deny": [PYTHON]}}) + + rc = main( + [ + "process", + "exec", + "--apply", + "--policy-json", + policy, + "--root", + str(work), + "--json", + "--", + PYTHON, + "-c", + f"open({str(marker)!r}, 'w').write('x')", + ] + ) + assert rc == 1 + payload = json.loads(capsys.readouterr().out) + assert payload["status"] == "denied" + assert payload["verdict"]["decision"] == "denied" + assert not marker.exists() + + +def test_denial_precedes_the_preview(work: Path, capsys: pytest.CaptureFixture[str]) -> None: + """Denied, not previewed -- the engine's ordering, visible at the CLI.""" + policy = json.dumps({"run_command": {"deny": ["rm"]}}) + rc = main( + ["process", "shell", "rm -rf /", "--policy-json", policy, "--root", str(work), "--json"] + ) + assert rc == 1 + payload = json.loads(capsys.readouterr().out) + assert payload["status"] == "denied" + assert payload["status"] != "previewed" + + +def test_the_cli_verdict_is_the_one_the_engine_would_apply( + work: Path, capsys: pytest.CaptureFixture[str] +) -> None: + policy_doc = {"run_command": {"allow": ["echo"], "deny": ["rm"]}} + policy = load_policy([], data=policy_doc) + expected = operations._policy_gate( + Operation(kind=process_shell.KIND, arguments={"command": "rm -rf /"}), policy + ) + + rc = main( + [ + "process", + "shell", + "rm -rf /", + "--policy-json", + json.dumps(policy_doc), + "--root", + str(work), + "--json", + ] + ) + assert rc == 1 + payload = json.loads(capsys.readouterr().out) + assert payload["verdict"] == expected.to_dict() + + +def test_an_allowed_command_runs(work: Path, capsys: pytest.CaptureFixture[str]) -> None: + policy = json.dumps({"run_command": {"allow": ["echo"]}}) + rc = main( + [ + "process", + "shell", + "echo fine", + "--apply", + "--policy-json", + policy, + "--env", + "PATH", + "--root", + str(work), + "--json", + ] + ) + assert rc == 0 + payload = json.loads(capsys.readouterr().out) + assert payload["status"] == "succeeded" + assert payload["verdict"]["decision"] == "allowed" + + +def test_a_policy_file_is_read( + work: Path, tmp_path: Path, capsys: pytest.CaptureFixture[str] +) -> None: + policy_file = tmp_path / "approvals.json" + policy_file.write_text(json.dumps({"run_command": {"deny": ["curl"]}}), encoding="utf-8") + + rc = main( + [ + "process", + "shell", + "curl evil.example", + "--policy-file", + str(policy_file), + "--root", + str(work), + "--json", + ] + ) + assert rc == 1 + assert json.loads(capsys.readouterr().out)["status"] == "denied" + + +def test_malformed_inline_policy_is_a_structured_error( + work: Path, capsys: pytest.CaptureFixture[str] +) -> None: + rc = main(["process", "shell", "true", "--policy-json", "{not json", "--root", str(work)]) + assert rc == 1 + err = capsys.readouterr().err + assert "not valid JSON" in err + assert "hint:" in err + + +# --- exit codes --------------------------------------------------------------- + + +def test_a_nonzero_exit_is_a_succeeded_operation( + work: Path, capsys: pytest.CaptureFixture[str] +) -> None: + """The operation was "run this and observe it", and it did. + + shell's own exit code answers whether the operation ran; the command's exit + code is data in the result. Passing the child's code through is not + available: ``1`` and ``2`` already mean user error and environment error in + this CLI, so a command exiting 1 would be indistinguishable from a bad flag. + """ + rc = main( + [ + "process", + "exec", + "--apply", + "--root", + str(work), + "--json", + "--", + PYTHON, + "-c", + "import sys; sys.exit(3)", + ] + ) + assert rc == 0, "the operation succeeded; the command's verdict is in the payload" + payload = json.loads(capsys.readouterr().out) + assert payload["status"] == "succeeded" + assert payload["output"]["exit_code"] == 3 + assert payload["evidence"]["exit_code"] == 3 + + +def test_text_mode_says_so_on_stderr_when_the_command_exited_nonzero( + work: Path, capsys: pytest.CaptureFixture[str] +) -> None: + """An operator must not read exit 0 as "the command was happy".""" + rc = main( + [ + "process", + "exec", + "--apply", + "--root", + str(work), + "--", + PYTHON, + "-c", + "import sys; sys.exit(3)", + ] + ) + assert rc == 0 + captured = capsys.readouterr() + assert "exit=3" in captured.out + assert "exited 3" in captured.err + + +def test_a_timeout_is_its_own_status(work: Path, capsys: pytest.CaptureFixture[str]) -> None: + rc = main( + [ + "process", + "exec", + "--apply", + "--timeout", + "0.3", + "--root", + str(work), + "--json", + "--", + PYTHON, + "-c", + "import time; time.sleep(30)", + ] + ) + assert rc == 1 + captured = capsys.readouterr() + payload = json.loads(captured.out) + assert payload["status"] == "timed_out" + assert payload["status"] != "failed", "a cut-off command is not a failed one" + error = json.loads(captured.err) + assert error["code"] == 1 + assert "--timeout" in error["remediation"] + + +def test_a_command_that_cannot_start_is_a_failed_operation( + work: Path, capsys: pytest.CaptureFixture[str] +) -> None: + rc = main( + [ + "process", + "exec", + "--apply", + "--root", + str(work), + "--json", + "--", + str(work / "no-such-program"), + ] + ) + assert rc == 1 + payload = json.loads(capsys.readouterr().out) + assert payload["status"] == "failed" + assert "could not start the process" in payload["error"] + # No exit code, and that is distinct from an exit code of zero: nothing ran. + assert payload["output"]["exit_code"] is None + assert payload["evidence"]["exit_code"] is None + + +def test_a_text_mode_failure_writes_nothing_to_stdout( + work: Path, capsys: pytest.CaptureFixture[str] +) -> None: + rc = main( + [ + "process", + "exec", + "--apply", + "--root", + str(work), + "--", + str(work / "no-such-program"), + ] + ) + assert rc == 1 + captured = capsys.readouterr() + assert captured.out == "" + assert captured.err.startswith("error:") + + +def test_a_missing_root_is_an_environment_error( + tmp_path: Path, capsys: pytest.CaptureFixture[str] +) -> None: + rc = main(["process", "shell", "true", "--root", str(tmp_path / "no-such-dir")]) + assert rc == 2 + err = capsys.readouterr().err + assert err.startswith("error:") + assert "hint:" in err + + +# --- the environment allow-list ----------------------------------------------- + + +def test_no_host_variable_is_inherited_unless_named( + work: Path, capsys: pytest.CaptureFixture[str], monkeypatch: pytest.MonkeyPatch +) -> None: + """An allow-list, never a deny-list -- the library's rule, kept at the CLI.""" + monkeypatch.setenv("SHELL_CLI_CLI_MARKER", "leaked") + rc = main( + [ + "process", + "exec", + "--apply", + "--root", + str(work), + "--json", + "--", + PYTHON, + "-c", + "import os; print(os.environ.get('SHELL_CLI_CLI_MARKER', 'absent'))", + ] + ) + assert rc == 0 + payload = json.loads(capsys.readouterr().out) + assert payload["evidence"]["stdout"].strip() == "absent" + assert payload["output"]["env_names"] == [] + + +def test_a_named_variable_is_passed_through( + work: Path, capsys: pytest.CaptureFixture[str], monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.setenv("SHELL_CLI_CLI_MARKER", "carried") + rc = main( + [ + "process", + "exec", + "--apply", + "--env", + "SHELL_CLI_CLI_MARKER", + "--root", + str(work), + "--json", + "--", + PYTHON, + "-c", + "import os; print(os.environ['SHELL_CLI_CLI_MARKER'])", + ] + ) + assert rc == 0 + payload = json.loads(capsys.readouterr().out) + assert payload["evidence"]["stdout"].strip() == "carried" + assert payload["output"]["env_names"] == ["SHELL_CLI_CLI_MARKER"] + assert "carried" not in json.dumps(payload["output"]), "names only; never values" + + +# --- resource requests --------------------------------------------------------- + + +def test_max_output_bytes_bounds_each_stream( + work: Path, capsys: pytest.CaptureFixture[str] +) -> None: + rc = main( + [ + "process", + "exec", + "--apply", + "--max-output-bytes", + "10", + "--root", + str(work), + "--json", + "--", + PYTHON, + "-c", + "print('x' * 500)", + ] + ) + assert rc == 0 + payload = json.loads(capsys.readouterr().out) + assert payload["output"]["stdout_truncated"] is True + assert payload["output"]["stdout_bytes"] == 501 + assert len(payload["evidence"]["stdout"]) == 10 + + +@pytest.mark.parametrize( + "flags", + [["--timeout", "0"], ["--timeout", "-1"], ["--max-output-bytes", "-1"]], +) +def test_a_nonsensical_resource_request_is_refused( + flags: list[str], work: Path, capsys: pytest.CaptureFixture[str] +) -> None: + rc = main(["process", "shell", "true", *flags, "--root", str(work)]) + assert rc == 1 + err = capsys.readouterr().err + assert err.startswith("error:") + assert "hint:" in err + + +# --- the evidence seam: the CLI really did go through execute() --------------- + + +@pytest.mark.parametrize("verb", ["exec", "shell"]) +def test_the_cli_writes_an_evidence_record_operation_show_can_retrieve( + verb: str, work: Path, tmp_path: Path, capsys: pytest.CaptureFixture[str] +) -> None: + evidence_dir = tmp_path / "evidence" + argv = ( + [ + "process", + "exec", + "--apply", + "--root", + str(work), + "--evidence-dir", + str(evidence_dir), + "--json", + "--", + PYTHON, + "-V", + ] + if verb == "exec" + else [ + "process", + "shell", + "true", + "--apply", + "--env", + "PATH", + "--root", + str(work), + "--evidence-dir", + str(evidence_dir), + "--json", + ] + ) + rc = main(argv) + assert rc == 0 + operation_id = json.loads(capsys.readouterr().out)["operation_id"] + + rc = main(["operation", "show", operation_id, "--evidence-dir", str(evidence_dir), "--json"]) + assert rc == 0 + record = json.loads(capsys.readouterr().out) + assert record["operation_id"] == operation_id + assert record["operation"]["kind"] == process_group.KINDS[verb] + assert record["status"] == "succeeded" + assert record["caller"]["tool"] == f"shell process {verb}" + + +def test_a_preview_is_recorded_as_previewed_not_applied( + work: Path, tmp_path: Path, capsys: pytest.CaptureFixture[str] +) -> None: + evidence_dir = tmp_path / "evidence" + rc = main( + [ + "process", + "shell", + "true", + "--root", + str(work), + "--evidence-dir", + str(evidence_dir), + "--json", + ] + ) + assert rc == 0 + operation_id = json.loads(capsys.readouterr().out)["operation_id"] + + rc = main(["operation", "show", operation_id, "--evidence-dir", str(evidence_dir), "--json"]) + assert rc == 0 + record = json.loads(capsys.readouterr().out) + assert record["status"] == "previewed" + assert record["execution"]["applied"] is False + + +def test_a_denied_command_still_leaves_a_record( + work: Path, tmp_path: Path, capsys: pytest.CaptureFixture[str] +) -> None: + """An operation the gate refused that leaves no trace is the audit gap.""" + evidence_dir = tmp_path / "evidence" + rc = main( + [ + "process", + "shell", + "rm -rf /", + "--apply", + "--policy-json", + json.dumps({"run_command": {"deny": ["rm"]}}), + "--root", + str(work), + "--evidence-dir", + str(evidence_dir), + "--json", + ] + ) + assert rc == 1 + operation_id = json.loads(capsys.readouterr().out)["operation_id"] + + rc = main(["operation", "show", operation_id, "--evidence-dir", str(evidence_dir), "--json"]) + assert rc == 0 + record = json.loads(capsys.readouterr().out) + assert record["status"] == "denied" + + +def test_the_profile_reaches_the_evidence_record( + work: Path, tmp_path: Path, capsys: pytest.CaptureFixture[str] +) -> None: + """An auditor separating control-plane from model-authored execution reads this.""" + evidence_dir = tmp_path / "evidence" + rc = main( + [ + "process", + "exec", + "--profile", + "control", + "--apply", + "--root", + str(work), + "--evidence-dir", + str(evidence_dir), + "--json", + "--", + PYTHON, + "-V", + ] + ) + assert rc == 0 + operation_id = json.loads(capsys.readouterr().out)["operation_id"] + + main(["operation", "show", operation_id, "--evidence-dir", str(evidence_dir), "--json"]) + record = json.loads(capsys.readouterr().out) + assert record["operation"]["kind"] == "process.exec" + # The *normalized* operation is the one the gate and the handler saw. + assert record["operation"]["normalized"]["profile"] == "control" + assert record["operation"]["requested"]["profile"] == "control" + + +def test_the_registered_default_profile_is_what_an_unlabelled_call_gets( + work: Path, tmp_path: Path, capsys: pytest.CaptureFixture[str] +) -> None: + """The CLI does not keep its own default -- ``normalize`` fills it in. + + A CLI-side default of "project" would silently win over the registration if + a handler's default ever changed, which is the same drift the ``--apply`` + derivation exists to remove. + """ + expected = operations.handler_for("process.exec").default_profile.value + rc = main(["process", "exec", "--apply", "--root", str(work), "--json", "--", PYTHON, "-V"]) + assert rc == 0 + payload = json.loads(capsys.readouterr().out) + assert payload["output"]["profile"] == expected == "project" + + +# --- structured error contract ------------------------------------------------- + + +def test_process_bogus_subcommand_is_a_structured_error( + capsys: pytest.CaptureFixture[str], +) -> None: + with pytest.raises(SystemExit) as exc: + main(["process", "bogus-verb"]) + assert exc.value.code == 1 + err = capsys.readouterr().err + assert err.startswith("error:") + assert "hint:" in err + assert "Traceback" not in err + + +def test_shell_without_a_command_is_a_structured_error( + capsys: pytest.CaptureFixture[str], +) -> None: + with pytest.raises(SystemExit) as exc: + main(["process", "shell"]) + assert exc.value.code == 1 + err = capsys.readouterr().err + assert err.startswith("error:") + assert "hint:" in err + + +def test_a_blank_command_is_a_structured_error( + work: Path, capsys: pytest.CaptureFixture[str] +) -> None: + rc = main(["process", "shell", " ", "--apply", "--root", str(work)]) + assert rc == 1 + assert "hint:" in capsys.readouterr().err + + +def test_an_unknown_flag_routes_through_the_structured_error_contract( + capsys: pytest.CaptureFixture[str], +) -> None: + """Proves the subparsers were built with ``parser_class=_CliArgumentParser``.""" + with pytest.raises(SystemExit) as exc: + main(["process", "shell", "true", "--bogus"]) + assert exc.value.code == 1, "argparse's own handler would exit 2 with raw output" + err = capsys.readouterr().err + assert err.startswith("error:") + assert "hint:" in err + + +def test_parse_errors_honour_json_mode(capsys: pytest.CaptureFixture[str]) -> None: + with pytest.raises(SystemExit) as exc: + main(["process", "shell", "--json", "--bogus"]) + assert exc.value.code == 1 + assert json.loads(capsys.readouterr().err)["code"] == 1 + + +def test_an_invalid_profile_is_a_structured_error( + work: Path, capsys: pytest.CaptureFixture[str] +) -> None: + with pytest.raises(SystemExit) as exc: + main(["process", "shell", "true", "--profile", "nonsense", "--root", str(work)]) + assert exc.value.code == 1 + assert "hint:" in capsys.readouterr().err + + +# --- a failed operation still hands back its structured result ---------------- + + +def test_a_failed_operation_still_emits_its_structured_result_in_json_mode( + work: Path, capsys: pytest.CaptureFixture[str] +) -> None: + rc = main( + [ + "process", + "exec", + "--apply", + "--root", + str(work), + "--json", + "--", + str(work / "no-such-program"), + ] + ) + assert rc == 1 + captured = capsys.readouterr() + assert json.loads(captured.out)["status"] == "failed" + assert json.loads(captured.err)["code"] == 1 + + +# --- catalog integration -------------------------------------------------------- + + +def test_every_process_path_resolves_via_explain(capsys: pytest.CaptureFixture[str]) -> None: + for verb in ("", "overview", "exec", "shell"): + path = ["process"] + ([verb] if verb else []) + rc = main(["explain", *path]) + assert rc == 0, f"explain {' '.join(path)} failed" + assert "# shell process" in capsys.readouterr().out + + +def test_the_status_surfaces_no_longer_claim_the_cli_runs_nothing( + capsys: pytest.CaptureFixture[str], +) -> None: + """``learn`` and the ``explain`` root are the first thing an agent reads. + + Both said "no CLI verb runs a shell command yet" while this group was + unbuilt. That sentence is now false, and a false status line in the + learnability surface is worse than no status line at all. + """ + for argv in (["learn"], ["explain"], ["overview"]): + rc = main(argv) + assert rc == 0 + out = capsys.readouterr().out.lower() + assert "no cli verb runs a shell command" not in out + assert "process" in out + + +def test_the_status_still_names_what_is_not_built(capsys: pytest.CaptureFixture[str]) -> None: + """Honesty runs both ways: ``env`` and ``git`` remain unexposed.""" + rc = main(["learn"]) + assert rc == 0 + out = capsys.readouterr().out.lower() + assert "env" in out and "git" in out + + +# --- the status enum, end to end ------------------------------------------------- + + +def test_every_terminal_status_this_group_can_produce_is_reachable() -> None: + """A sanity net over the statuses asserted above, named in one place.""" + reached = { + OperationStatus.SUCCEEDED, + OperationStatus.PREVIEWED, + OperationStatus.DENIED, + OperationStatus.FAILED, + OperationStatus.TIMED_OUT, + } + assert reached == set(OperationStatus) From c57d96759058621887ef16d1ae51723f488b60ad Mon Sep 17 00:00:00 2001 From: Ori Nachum Date: Tue, 28 Jul 2026 11:09:50 +0300 Subject: [PATCH 12/20] docs: record the shipped process CLI group and registry-derived --apply --- CLAUDE.md | 37 ++++++++++++++++++++----------------- README.md | 19 +++++++++++-------- 2 files changed, 31 insertions(+), 25 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 93a2821..1551b9a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -70,23 +70,26 @@ Built and green in the **CLI**: the introspection verbs (`whoami`, `learn`, function `execute()` uses rather than a re-derivation, and `shell operation show`, which retrieves a persisted evidence record (t85). -`shell fs read|list|stat|write|edit|media` is the first CLI group that -*executes* operations rather than introspecting them. It holds no filesystem -logic of its own — every verb goes through `operations.execute()`, so the CLI -and the library share one engine. `write` and `edit` preview by default and -require `--apply`. - -**Not built yet:** the CLI noun groups `env` / `process` / `git`; -`ContainerRunner` (Milestone 4); colleague delegation (Milestone 2). - -So the CLI's operation-facing surface is introspection-only: it can evaluate a -policy decision and retrieve evidence for an operation that already ran, but it -cannot yet run one itself. That is why `learn`, `overview`, and the `explain` -root still carry a Status section saying so, and it remains accurate *about the -CLI*. What is no longer true anywhere is "the primitives are not extracted -yet," or that the CLI exposes *only* introspection verbs — `policy` and -`operation` are real nouns now. Keep those surfaces honest as the remaining CLI -noun groups land; they are the first thing an agent consumer reads. +`shell fs read|list|stat|write|edit|media` and `shell process exec|shell` are +the CLI groups that *execute* operations rather than introspecting them. +Neither holds operation logic of its own — every verb goes through +`operations.execute()`, so the CLI and the library share one engine. +`process shell` accepts `project` only, refusing `control` in the library's own +words. The `--apply` flag is **derived from each kind's registered intent** at +parser-build time (`shell/cli/_operation.py` is the only module that spells +`--apply`), so a handler whose intent changes cannot leave a verb previewing +forever with no flag to escape it. + +**Not built yet:** the CLI noun groups `env` / `git`; `ContainerRunner` +(Milestone 4); colleague delegation (Milestone 2). + +So the CLI both runs operations and introspects them. What is no longer true +anywhere: "the primitives are not extracted yet", "the CLI exposes only the +introspection verbs", and "no CLI verb executes an `fs.*` or `process.*` +operation" — all three were accurate at some point and none is now. Keep +`learn`, `overview`, the `explain` catalog, this section, and README's Status +honest as `env` and `git` land; they are the first thing an agent consumer +reads, and every wave of this work has falsified at least one of them. ## Commands diff --git a/README.md b/README.md index 70f2cda..af2673a 100644 --- a/README.md +++ b/README.md @@ -55,14 +55,17 @@ evaluator, the evidence contract, and a `HostRunner` with real execution — The CLI exposes the introspection verbs (`whoami`, `learn`, `explain`, `overview`, `doctor`, `cli`), two operation-aware nouns — `policy check`/`policy explain` evaluate the same gate `execute()` uses, and `operation -show` retrieves persisted evidence — and `shell fs read|list|stat|write|edit| -media`, the first group whose verbs **execute** operations. They front -`operations.execute()` rather than reimplementing anything, so the CLI and the -library share one engine; `write` and `edit` preview by default and require -`--apply`. - -**Still library-only:** the `env`, `process`, and `git` noun groups do not -exist yet, and neither does a container runner or colleague delegation. +show` retrieves persisted evidence — and two groups whose verbs **execute** +operations: `shell fs read|list|stat|write|edit|media` and `shell process +exec|shell`. They front `operations.execute()` rather than reimplementing +anything, so the CLI and the library share one engine. Mutation and execution +verbs preview by default and require `--apply`, and the flag is derived from +each kind's registered intent rather than hand-maintained. `process shell` +accepts the `project` profile only — a raw string is re-interpreted by a shell +after the gate read it, so it cannot honestly carry control-plane trust. + +**Still library-only:** the `env` and `git` noun groups do not exist yet, and +neither does a container runner or colleague delegation. Publishing is gated on the full quality suite plus a built-wheel smoke test. [Issue #1](https://github.com/agentculture/shell-cli/issues/1) is the source of From a8312d83859b66dccee1d860177d33a237c51cff Mon Sep 17 00:00:00 2001 From: Ori Nachum Date: Tue, 28 Jul 2026 11:20:02 +0300 Subject: [PATCH 13/20] t9: the `shell env describe` CLI verb, and the deferral of create/destroy Adds shell/cli/_commands/env.py with one real verb, `describe`, plus the required `overview`. It renders the two-axis Environment (workspace vs runner, never flattened into one "mode") that `shell fs`/`shell process` would build from the same --root/--read-only-path/--env flags, as Environment.to_dict() verbatim, in text and --json. The host runner's no-isolation posture (isolation="none", isolation_note) surfaces verbatim in both renderings. `env create`/`env destroy` are deliberately absent: no OperationIntent .LIFECYCLE handler is registered and there is no ContainerRunner, so either verb would front nothing. The catalog entry, the global overview, and the noun's own overview all say so explicitly and name Milestone 4 as where they land. `shell env create`/`destroy` fail as ordinary unrecognized subcommands through the existing CliError path (exit 1, error:/hint:, no traceback) -- pinned by test, not left to accident. wired into shell/cli/__init__.py; catalog entries added to shell/explain/catalog.py; shell/cli/_commands/overview.py's global verb list and status notes updated to mention env (describe only). Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01EkC4zsVUmYrRCwSWSe4aLp --- shell/cli/__init__.py | 2 + shell/cli/_commands/env.py | 288 +++++++++++++++++++++++++++++++ shell/cli/_commands/overview.py | 7 +- shell/explain/catalog.py | 79 ++++++++- tests/test_cli_env.py | 292 ++++++++++++++++++++++++++++++++ 5 files changed, 660 insertions(+), 8 deletions(-) create mode 100644 shell/cli/_commands/env.py create mode 100644 tests/test_cli_env.py diff --git a/shell/cli/__init__.py b/shell/cli/__init__.py index c6b8e0b..cd53a5a 100644 --- a/shell/cli/__init__.py +++ b/shell/cli/__init__.py @@ -64,6 +64,7 @@ def _argv_has_json(argv: list[str] | None) -> bool: def _build_parser() -> argparse.ArgumentParser: from shell.cli._commands import cli as _cli_group from shell.cli._commands import doctor as _doctor_cmd + from shell.cli._commands import env as _env_group from shell.cli._commands import explain as _explain_cmd from shell.cli._commands import fs as _fs_group from shell.cli._commands import learn as _learn_cmd @@ -96,6 +97,7 @@ def _build_parser() -> argparse.ArgumentParser: _overview_cmd.register(sub) _doctor_cmd.register(sub) _cli_group.register(sub) + _env_group.register(sub) _fs_group.register(sub) _process_group.register(sub) _policy_group.register(sub) diff --git a/shell/cli/_commands/env.py b/shell/cli/_commands/env.py new file mode 100644 index 0000000..bed6a02 --- /dev/null +++ b/shell/cli/_commands/env.py @@ -0,0 +1,288 @@ +"""``shell env`` — describe the environment an operation would run in. + +One verb: ``describe``. That is deliberate, not a first slice of a larger +surface landing later in this same file. + +Why ``create`` and ``destroy`` are not here +-------------------------------------------- + +The canonical CLI surface names ``shell env describe|create|destroy``, and only +the first third of that is built. ``create`` and ``destroy`` have **no engine +behind them**: the operation registry holds only ``fs.*`` and ``process.*`` +kinds, :attr:`~shell.operations.OperationIntent.LIFECYCLE` exists in the intent +enum but no handler is registered against it, and there is no +:class:`~shell.runners.container` runner — there is no +``shell/runners/container.py`` at all yet. Shipping ``env create`` or +``env destroy`` today would be a CLI verb in front of nothing: it could not +build a worktree, could not start a container, and any implementation would +have to fake success or raise on every call. Both are worse than not shipping +the verb. + +They land with the Milestone 4 container runner and the environment-lifecycle +engine that comes with it (see ``CLAUDE.md``, "Delivery plan" — Milestone 4). +Until then this noun answers exactly one question: *given these flags, what +environment would ``shell fs`` / ``shell process`` build to run an operation in* +— the same question ``shell policy explain`` answers for the policy gate, and +by the same method: it calls the real construction path, not a paraphrase of +it. + +What ``describe`` actually renders +----------------------------------- + +:func:`_environment` builds a real :class:`~shell.environment.Environment` +using the exact fields ``shell fs`` and ``shell process`` populate from CLI +flags today — ``--root`` (both ``source_root`` and ``work_root``), +``--read-only-path`` (mirroring ``shell fs``'s flag of the same name), and +``--env`` (mirroring ``shell process``'s environment allow-list flag) — and a +real :class:`~shell.runners.host.HostRunner`, because that is the only runner +that exists to select. Nothing here executes an operation, so there is no +``--apply`` (a read has nothing to preview) and no ``--evidence-dir`` (no +operation runs, so there is nothing for an :class:`~shell.evidence.EvidenceStore` +to record). + +The payload is :meth:`~shell.environment.Environment.to_dict` verbatim, not a +hand-rolled reshaping — the same discipline ``shell policy explain`` applies to +:class:`~shell.policy.Policy`. A consumer reading ``--json`` output gets the +library's own structured form. + +The two axes stay two axes +---------------------------- + +``shell.environment`` is explicit that "host", "worktree" and "container" are +not three values of one setting — they are points on two independent axes, the +**workspace axis** (which roots exist, :attr:`~shell.environment.Environment.workspace`) +and the **runner axis** (what carries the work out, +:attr:`~shell.environment.Environment.runner`). Flattening them into a single +overloaded "mode" string is exactly the mistake that module exists to prevent, +so the text rendering here keeps them in separate labelled sections, and the +JSON form keeps them as separate top-level keys (``workspace`` is a string, +``runner`` is its own object) rather than merging them into one field. + +The runner's posture is stated, not implied +---------------------------------------------- + +Today the only runner is :class:`~shell.runners.host.HostRunner`, and it +provides no isolation of any kind: it is a guard against accidental and +careless behaviour, never against an adversarial one, and there is no +namespace, container, or seccomp boundary under it. That is not left for a +reader to infer from the runner's name — ``HostRunner.describe()`` states it in +its own ``isolation`` / ``isolation_note`` fields, and both the text and +``--json`` renderings below surface them verbatim rather than summarising them +into something softer. +""" + +from __future__ import annotations + +import argparse +from pathlib import Path +from typing import Any + +from shell.cli._commands.overview import emit_overview +from shell.cli._operation import JSON_HELP, resolve_root +from shell.cli._output import emit_result +from shell.environment import Environment +from shell.runners.host import HostRunner + +_NOUN_SECTIONS = [ + { + "title": "Verbs", + "items": [ + "env describe [--root DIR] [--read-only-path PATH]... [--env NAME]... " + "— render the two-axis environment that would be selected for an " + "operation, structurally, in text or --json", + "env overview — describe the env noun (this command)", + ], + }, + { + "title": "create / destroy are deliberately absent", + "items": [ + "the operation registry holds only fs.* and process.* kinds; " + "OperationIntent.LIFECYCLE exists in the enum but no handler is " + "registered against it", + "no ContainerRunner exists — there is nothing 'env create' could " + "hand a worktree or a container to, and nothing 'env destroy' could " + "tear down", + "both verbs land with the Milestone 4 container runner and its " + "environment-lifecycle engine, not before", + ], + }, + { + "title": "The two axes", + "items": [ + "workspace (which roots exist, which may change) and runner (what " + "carries the work out) vary independently — see shell.environment", + "the rendering below keeps them in separate sections/keys; neither " + "text nor --json ever collapses them into one 'mode' value", + "today's runner is always HostRunner; describe does not invent a " + "container runner selection that does not exist", + ], + }, + { + "title": "Posture", + "items": [ + "the host runner provides no isolation: it guards against " + "accidental and careless behaviour, not an adversarial one", + "isolation / isolation_note come from HostRunner.describe() " + "verbatim — see 'shell explain safety'", + "no operation executes here, so there is no --apply and no " + "--evidence-dir: nothing is previewed and nothing is recorded", + ], + }, +] + + +# --- environment construction -------------------------------------------------- + + +def _environment(args: argparse.Namespace) -> Environment: + """Build the :class:`~shell.environment.Environment` these flags describe. + + Deliberately the same construction ``shell fs`` and ``shell process`` do: + ``source_root`` and ``work_root`` are the same ``--root`` directory (so + :attr:`~shell.environment.Environment.roots_are_separate` reports ``False``, + exactly as it does for those two groups today), read-only paths resolve + against the root the same way ``shell fs`` resolves them, and the runner is + always :class:`~shell.runners.host.HostRunner` because no other runner is + selectable yet. + """ + root = resolve_root(args) + + declared = getattr(args, "read_only_path", None) or () + read_only = tuple(p if Path(p).is_absolute() else root / p for p in declared) + + return Environment( + source_root=root, + work_root=root, + runner=HostRunner(), + read_only_paths=read_only, + env_passthrough=tuple(getattr(args, "env", None) or ()), + ) + + +# --- rendering ------------------------------------------------------------------- + + +def _listing(label: str, items: list[str]) -> str: + return f" {label}: {', '.join(items) if items else '(none)'}" + + +def _render_text(data: dict[str, Any]) -> str: + runner = data["runner"] + lines = [ + f"environment {data['id']}", + "", + "workspace axis — which roots exist, and which may change:", + f" workspace: {data['workspace']}", + f" source_root: {data['source_root']}", + f" work_root: {data['work_root']}", + f" roots_are_separate: {data['roots_are_separate']}", + _listing("read_only_paths", data["read_only_paths"]), + "", + "runner axis — what carries the work out:", + f" runner: {runner['name']}", + f" isolation: {runner['isolation']}", + f" isolation_note: {runner['isolation_note']}", + f" platform: {runner['platform']}", + f" process_groups: {runner['process_groups']}", + f" orphan_prevention: {runner['orphan_prevention']} — " + f"{runner['orphan_prevention_note']}", + "", + "declared posture — recorded always, enforced only under a runner that can:", + f" network: {data['network']} (network_enforced={data['network_enforced']})", + _listing("mounts", data["mounts"]), + _listing("env_passthrough", data["env_passthrough"]), + _listing("secret_names", data["secret_names"]), + f" user: {data['user'] if data['user'] is not None else '(unset)'}", + "", + "resource defaults:", + f" default_timeout_seconds: {data['default_timeout_seconds']}", + f" max_output_bytes: {data['max_output_bytes']}", + ] + return "\n".join(lines) + + +# --- dispatch ----------------------------------------------------------------- + + +def cmd_env_describe(args: argparse.Namespace) -> int: + environment = _environment(args) + data = environment.to_dict() + json_mode = bool(getattr(args, "json", False)) + if json_mode: + emit_result(data, json_mode=True) + else: + emit_result(_render_text(data), json_mode=False) + return 0 + + +def cmd_env_overview(args: argparse.Namespace) -> int: + emit_overview( + "shell env", + _NOUN_SECTIONS, + json_mode=bool(getattr(args, "json", False)), + ) + return 0 + + +def _no_verb(args: argparse.Namespace) -> int: + return cmd_env_overview(args) + + +# --- wiring --------------------------------------------------------------------- + + +def register(sub: argparse._SubParsersAction) -> None: + p = sub.add_parser( + "env", + help="Describe the operation environment (see 'shell env overview').", + ) + p.add_argument("--json", action="store_true", help=JSON_HELP) + p.set_defaults(func=_no_verb, json=False) + # `p` is a _CliArgumentParser (the top-level subparsers were built with that + # parser_class); propagate it so every verb under `env` routes parse errors + # through the structured error contract instead of argparse's raw stderr + # and exit 2. + noun_sub = p.add_subparsers(dest="env_command", parser_class=type(p)) + + ov = noun_sub.add_parser("overview", help="Describe the env noun.") + ov.add_argument("--json", action="store_true", help=JSON_HELP) + ov.set_defaults(func=cmd_env_overview) + + describe = noun_sub.add_parser( + "describe", + help=( + "Render the two-axis environment that would be selected for an " + "operation. No --apply, no --evidence-dir: nothing executes here." + ), + ) + describe.add_argument( + "--root", + type=Path, + default=None, + metavar="DIR", + help=( + "Directory used as both source_root and work_root (default: the " + "current directory) — the same root 'shell fs' and 'shell process' " + "build their environment from." + ), + ) + describe.add_argument( + "--read-only-path", + action="append", + metavar="PATH", + help=( + "Declare a subtree that may be read but never written (repeatable). " + "Relative paths resolve against --root — mirrors 'shell fs'." + ), + ) + describe.add_argument( + "--env", + action="append", + metavar="NAME", + help=( + "Allow-list one host environment variable name (repeatable) — mirrors " + "'shell process'. The default is an empty allow-list." + ), + ) + describe.add_argument("--json", action="store_true", help=JSON_HELP) + describe.set_defaults(func=cmd_env_describe) diff --git a/shell/cli/_commands/overview.py b/shell/cli/_commands/overview.py index 235ee00..90a103c 100644 --- a/shell/cli/_commands/overview.py +++ b/shell/cli/_commands/overview.py @@ -39,6 +39,8 @@ "process shell — run a command string; previews unless --apply; project profile only", "policy check / policy explain — evaluate and explain the operation-policy gate", "operation show — retrieve persisted evidence for an operation id", + "env describe — render the two-axis environment (workspace + runner) " + "that would be selected for an operation", ] _MISSION = [ @@ -50,11 +52,14 @@ ] _STATUS = [ - "fs, process, policy and operation are real CLI nouns; env and git are still library-only", + "fs, process, policy, operation and env (describe only) are real CLI " + "nouns; git is still library-only", "shell fs and shell process execute through the same " "shell.operations.execute() pipeline the library uses", "shell process is NOT path-confined — --root is where a command starts, " "not a boundary it is held inside", + "env create/destroy do not exist — no environment-lifecycle handler is " + "registered and there is no container runner; they land with Milestone 4", "the library has the fs primitives, process exec/shell, confinement, " "policy, evidence and HostRunner", "tracking issue: https://github.com/agentculture/shell-cli/issues/1", diff --git a/shell/explain/catalog.py b/shell/explain/catalog.py index 93ad27e..19a0158 100644 --- a/shell/explain/catalog.py +++ b/shell/explain/catalog.py @@ -30,13 +30,16 @@ confinement, the policy evaluator, the evidence contract, and `HostRunner` execution are built and green. -**The CLI is catching up.** `fs`, `process`, `policy` and `operation` are real -CLI nouns. `shell fs` executes the confined filesystem operations and -`shell process` runs argv vectors and shell command strings, both through the -same `shell.operations.execute()` pipeline the library uses; `policy check` / -`policy explain` evaluate the same policy gate that pipeline gates through; and -`operation show` retrieves persisted evidence. The `env` and `git` verb groups -are still not exposed. See +**The CLI is catching up.** `fs`, `process`, `policy`, `operation` and `env` +(describe only) are real CLI nouns. `shell fs` executes the confined filesystem +operations and `shell process` runs argv vectors and shell command strings, +both through the same `shell.operations.execute()` pipeline the library uses; +`policy check` / `policy explain` evaluate the same policy gate that pipeline +gates through; `operation show` retrieves persisted evidence; and +`env describe` renders the two-axis environment those groups would build. +`env create` / `env destroy` do not exist — there is no environment-lifecycle +engine or container runner behind them yet; they land with Milestone 4. The +`git` verb group is still not exposed at all. See . ## Safety posture @@ -67,6 +70,10 @@ - `shell operation show ` — retrieve a persisted evidence record (requires an evidence store to have been configured; see `docs/evidence-contract.md`). +- `shell env describe [--root DIR] [--read-only-path PATH]... [--env NAME]...` + — render the two-axis environment (workspace + runner) that `shell fs` / + `shell process` would build for those flags. `env create` / `env destroy` + do not exist yet — see `shell explain env`. ## Exit-code policy @@ -486,6 +493,61 @@ """ +_ENV = """\ +# shell env + +Describe the environment an operation would run in. One verb: `describe`. + +## Why `create` and `destroy` are not here + +The canonical CLI surface names `shell env describe|create|destroy`, and only +the first third exists. `create` and `destroy` have **no engine behind them**: +the operation registry holds only `fs.*` and `process.*` kinds, +`OperationIntent.LIFECYCLE` exists in the enum but no handler is registered +against it, and there is no `ContainerRunner`. Shipping either verb now would +be a CLI front end over nothing — it could not build a worktree or start a +container, and any implementation would have to fake success or raise on every +call. Both land with the Milestone 4 container runner and the +environment-lifecycle engine that comes with it (see `CLAUDE.md`, "Delivery +plan"). + +## `env describe` + + shell env describe [--root DIR] [--read-only-path PATH]... [--env NAME]... [--json] + +Builds a real `Environment` using the exact fields `shell fs` and +`shell process` populate from CLI flags today — `--root` (both `source_root` +and `work_root`), `--read-only-path` (mirrors `shell fs`), `--env` (mirrors +`shell process`'s environment allow-list) — with a real `HostRunner`, because +that is the only runner that exists to select. Nothing executes, so there is +no `--apply` and no `--evidence-dir`. + +The `--json` payload is `Environment.to_dict()` verbatim, not a hand-rolled +reshaping — the same discipline `shell policy explain` applies to `Policy`. + +## The two axes stay two axes + +"Host", "worktree" and "container" are not three values of one setting — they +are points on two independent axes: the **workspace axis** (which roots exist, +`Environment.workspace`) and the **runner axis** (what carries the work out, +`Environment.runner`). The rendering keeps them in separate sections in text +and separate top-level keys in `--json`; neither ever collapses them into one +"mode" value. + +## Posture + +Today's only runner is `HostRunner`, and it provides no isolation of any kind +— a guard against accidental and careless behaviour, never an adversarial one. +That is not left for a reader to infer: `isolation` / `isolation_note` come +from `HostRunner.describe()` verbatim. See `shell explain safety`. + +## See also + +- `shell explain safety` +- `shell fs overview`, `shell process overview` — the two groups whose + environment construction this noun mirrors +""" + ENTRIES: dict[tuple[str, ...], str] = { (): _ROOT, ("shell",): _ROOT, @@ -498,6 +560,9 @@ ("doctor",): _DOCTOR, ("cli",): _CLI, ("cli", "overview"): _CLI, + ("env",): _ENV, + ("env", "overview"): _ENV, + ("env", "describe"): _ENV, ("fs",): _FS, ("fs", "overview"): _FS, ("fs", "read"): _FS, diff --git a/tests/test_cli_env.py b/tests/test_cli_env.py new file mode 100644 index 0000000..87e5ff6 --- /dev/null +++ b/tests/test_cli_env.py @@ -0,0 +1,292 @@ +"""Tests for ``shell env`` — ``describe``, ``overview``, and the absence of +``create``/``destroy``. + +The property that matters most here is not "does the CLI print something" but +"is it the environment the library would actually build" — every equivalence +test below constructs an :class:`~shell.environment.Environment` directly, the +same way :mod:`shell.cli._commands.env` does internally, and compares against +``.to_dict()`` byte-for-byte rather than re-deriving a shape by hand. + +A second cluster of tests pins the scope boundary this task is as much about as +the verb itself: ``shell env create`` and ``shell env destroy`` must fail as +*unrecognized verbs* through the ordinary structured-error contract (exit 1, +``error:``/``hint:`` on stderr, no traceback) — not silently, not by accident, +and not with some half-built behaviour. +""" + +from __future__ import annotations + +import json +from pathlib import Path + +import pytest + +from shell.cli import main +from shell.environment import Environment +from shell.runners.host import HostRunner + +# --- overview --------------------------------------------------------------- + + +def test_env_bare_prints_overview(capsys: pytest.CaptureFixture[str]) -> None: + rc = main(["env"]) + assert rc == 0 + out = capsys.readouterr().out + assert "# shell env" in out + assert "describe" in out + + +def test_env_overview_json_shape(capsys: pytest.CaptureFixture[str]) -> None: + rc = main(["env", "overview", "--json"]) + assert rc == 0 + payload = json.loads(capsys.readouterr().out) + assert payload["subject"] == "shell env" + assert isinstance(payload["sections"], list) + assert payload["sections"] + + +def test_env_overview_names_the_deferred_verbs_and_the_milestone( + capsys: pytest.CaptureFixture[str], +) -> None: + """Acceptance criterion 2: the catalog/overview must say *why* create and + destroy are absent, not just that they are — and name the milestone.""" + rc = main(["env", "overview", "--json"]) + assert rc == 0 + payload = json.loads(capsys.readouterr().out) + text = json.dumps(payload) + assert "Milestone 4" in text + assert "create" in text + assert "destroy" in text + assert "ContainerRunner" in text or "container runner" in text + + +# --- describe: library/CLI equivalence --------------------------------------- + + +def test_env_describe_json_matches_a_directly_built_environment( + tmp_path: Path, capsys: pytest.CaptureFixture[str] +) -> None: + expected = Environment(source_root=tmp_path, work_root=tmp_path, runner=HostRunner()) + + rc = main(["env", "describe", "--root", str(tmp_path), "--json"]) + assert rc == 0 + payload = json.loads(capsys.readouterr().out) + + # id is fresh per construction (uuid4 default) — compare everything else. + expected_dict = expected.to_dict() + del expected_dict["id"] + del payload["id"] + assert payload == expected_dict + + +def test_env_describe_defaults_to_the_current_directory( + capsys: pytest.CaptureFixture[str], +) -> None: + rc = main(["env", "describe", "--json"]) + assert rc == 0 + payload = json.loads(capsys.readouterr().out) + assert payload["source_root"] == str(Path.cwd().resolve()) + assert payload["work_root"] == payload["source_root"] + assert payload["roots_are_separate"] is False + + +def test_env_describe_read_only_path_matches_fs_group_resolution( + tmp_path: Path, capsys: pytest.CaptureFixture[str] +) -> None: + """Relative --read-only-path resolves against --root, mirroring 'shell fs'.""" + (tmp_path / "vendor").mkdir() + expected = Environment( + source_root=tmp_path, + work_root=tmp_path, + runner=HostRunner(), + read_only_paths=(tmp_path / "vendor",), + ) + + rc = main(["env", "describe", "--root", str(tmp_path), "--read-only-path", "vendor", "--json"]) + assert rc == 0 + payload = json.loads(capsys.readouterr().out) + assert payload["read_only_paths"] == expected.to_dict()["read_only_paths"] + + +def test_env_describe_env_flag_populates_env_passthrough( + tmp_path: Path, capsys: pytest.CaptureFixture[str] +) -> None: + rc = main( + [ + "env", + "describe", + "--root", + str(tmp_path), + "--env", + "PATH", + "--env", + "HOME", + "--json", + ] + ) + assert rc == 0 + payload = json.loads(capsys.readouterr().out) + assert payload["env_passthrough"] == ["PATH", "HOME"] + + +def test_env_describe_default_env_passthrough_is_empty( + tmp_path: Path, capsys: pytest.CaptureFixture[str] +) -> None: + """The default is an empty allow-list, matching 'shell process' -- not PATH, + not HOME, not anything the caller did not name.""" + rc = main(["env", "describe", "--root", str(tmp_path), "--json"]) + assert rc == 0 + payload = json.loads(capsys.readouterr().out) + assert payload["env_passthrough"] == [] + + +def test_env_describe_uses_the_real_host_runner_description( + tmp_path: Path, capsys: pytest.CaptureFixture[str] +) -> None: + rc = main(["env", "describe", "--root", str(tmp_path), "--json"]) + assert rc == 0 + payload = json.loads(capsys.readouterr().out) + assert payload["runner"] == HostRunner().describe() + + +# --- describe: environment error on a bad root ------------------------------- + + +def test_env_describe_root_not_a_directory_is_an_environment_error( + tmp_path: Path, capsys: pytest.CaptureFixture[str] +) -> None: + bogus = tmp_path / "does-not-exist" + rc = main(["env", "describe", "--root", str(bogus)]) + assert rc == 2 + err = capsys.readouterr().err + assert err.startswith("error:") + assert "hint:" in err + + +# --- describe: the two axes stay two axes ------------------------------------- + + +def test_env_describe_json_keeps_workspace_and_runner_as_separate_keys( + tmp_path: Path, capsys: pytest.CaptureFixture[str] +) -> None: + """Acceptance criterion 1: workspace and runner must never be flattened + into one overloaded 'mode' value.""" + rc = main(["env", "describe", "--root", str(tmp_path), "--json"]) + assert rc == 0 + payload = json.loads(capsys.readouterr().out) + + # workspace is its own scalar; runner is its own nested object. Neither one + # embeds the other's data, and there is no single combined 'mode' field. + assert isinstance(payload["workspace"], str) + assert payload["workspace"] == "checkout" + assert isinstance(payload["runner"], dict) + assert "mode" not in payload + assert payload["runner"]["name"] == "host" + + +def test_env_describe_text_keeps_workspace_and_runner_in_separate_sections( + tmp_path: Path, capsys: pytest.CaptureFixture[str] +) -> None: + rc = main(["env", "describe", "--root", str(tmp_path)]) + assert rc == 0 + out = capsys.readouterr().out + + workspace_at = out.index("workspace axis") + runner_at = out.index("runner axis") + assert workspace_at < runner_at, "workspace section must be rendered before runner section" + + workspace_section = out[workspace_at:runner_at] + runner_section = out[runner_at:] + assert "workspace: checkout" in workspace_section + assert "runner: host" in runner_section + # The runner's own facts must not leak into the workspace section, or the + # two axes are effectively merged even if the headers look separate. + assert "isolation" not in workspace_section + + +# --- describe: the runner's no-isolation posture is visible ------------------- + + +def test_env_describe_json_states_the_no_isolation_posture( + tmp_path: Path, capsys: pytest.CaptureFixture[str] +) -> None: + rc = main(["env", "describe", "--root", str(tmp_path), "--json"]) + assert rc == 0 + payload = json.loads(capsys.readouterr().out) + assert payload["runner"]["isolation"] == "none" + assert "not a sandbox" in payload["runner"]["isolation_note"].lower() + + +def test_env_describe_text_states_the_no_isolation_posture( + tmp_path: Path, capsys: pytest.CaptureFixture[str] +) -> None: + rc = main(["env", "describe", "--root", str(tmp_path)]) + assert rc == 0 + out = capsys.readouterr().out + assert "isolation: none" in out + assert "not a sandbox" in out.lower() + + +# --- create / destroy are absent, and fail the ordinary way ------------------- + + +@pytest.mark.parametrize("verb", ["create", "destroy"]) +def test_env_create_and_destroy_are_unrecognized_verbs( + verb: str, capsys: pytest.CaptureFixture[str] +) -> None: + """They must fail as an ordinary unrecognized subcommand -- the structured + CliError path, exit 1, no traceback -- never silently succeed and never + crash with a raw Python traceback.""" + with pytest.raises(SystemExit) as exc: + main(["env", verb]) + assert exc.value.code == 1 + err = capsys.readouterr().err + assert err.startswith("error:") + assert "hint:" in err + assert "Traceback" not in err + + +@pytest.mark.parametrize("verb", ["create", "destroy"]) +def test_env_create_and_destroy_are_unrecognized_verbs_in_json_mode( + verb: str, capsys: pytest.CaptureFixture[str] +) -> None: + with pytest.raises(SystemExit) as exc: + main(["env", verb, "--json"]) + assert exc.value.code == 1 + err = capsys.readouterr().err + payload = json.loads(err) + assert payload["code"] == 1 + assert "Traceback" not in err + + +# --- structured error contract ------------------------------------------------ + + +def test_env_bogus_subcommand_is_a_structured_error( + capsys: pytest.CaptureFixture[str], +) -> None: + with pytest.raises(SystemExit) as exc: + main(["env", "bogus-verb"]) + assert exc.value.code == 1 + err = capsys.readouterr().err + assert err.startswith("error:") + assert "hint:" in err + assert "Traceback" not in err + + +# --- catalog integration ----------------------------------------------------- + + +def test_env_paths_resolve_via_explain(capsys: pytest.CaptureFixture[str]) -> None: + for path in (["env"], ["env", "overview"], ["env", "describe"]): + rc = main(["explain", *path]) + assert rc == 0, f"explain {' '.join(path)} failed" + assert "# shell env" in capsys.readouterr().out + + +def test_explain_env_names_the_deferred_verbs(capsys: pytest.CaptureFixture[str]) -> None: + rc = main(["explain", "env"]) + assert rc == 0 + out = capsys.readouterr().out + assert "Milestone 4" in out + assert "create" in out and "destroy" in out From e817747d3e600878c3b1f4e6a5d1d8f11e39c5ae Mon Sep 17 00:00:00 2001 From: Ori Nachum Date: Tue, 28 Jul 2026 11:22:04 +0300 Subject: [PATCH 14/20] docs: record shell env describe and the deliberate create/destroy deferral --- CLAUDE.md | 12 ++++++++++-- README.md | 8 ++++++-- 2 files changed, 16 insertions(+), 4 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 1551b9a..3de285e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -80,8 +80,16 @@ parser-build time (`shell/cli/_operation.py` is the only module that spells `--apply`), so a handler whose intent changes cannot leave a verb previewing forever with no flag to escape it. -**Not built yet:** the CLI noun groups `env` / `git`; `ContainerRunner` -(Milestone 4); colleague delegation (Milestone 2). +`shell env describe` renders the selected two-axis environment (workspace and +runner as separate facts, never one overloaded mode) and surfaces the host +runner's no-isolation posture. `env create` / `env destroy` are deliberately +**absent**, not overlooked: `OperationIntent.LIFECYCLE` has no registered +handler and there is no `ContainerRunner`, so they would be a facade in front +of nothing. They land with their engine in Milestone 4, and the catalog says +so. + +**Not built yet:** the `git` noun group and `env create` / `env destroy`; +`ContainerRunner` (Milestone 4); colleague delegation (Milestone 2). So the CLI both runs operations and introspects them. What is no longer true anywhere: "the primitives are not extracted yet", "the CLI exposes only the diff --git a/README.md b/README.md index af2673a..fb0bad9 100644 --- a/README.md +++ b/README.md @@ -64,8 +64,12 @@ each kind's registered intent rather than hand-maintained. `process shell` accepts the `project` profile only — a raw string is re-interpreted by a shell after the gate read it, so it cannot honestly carry control-plane trust. -**Still library-only:** the `env` and `git` noun groups do not exist yet, and -neither does a container runner or colleague delegation. +`shell env describe` renders the selected environment's two axes and the +runner's posture. `env create`/`env destroy` are deliberately absent until the +lifecycle engine exists, rather than shipped as a facade. + +**Still library-only:** the `git` noun group does not exist yet, and neither +does a container runner or colleague delegation. Publishing is gated on the full quality suite plus a built-wheel smoke test. [Issue #1](https://github.com/agentculture/shell-cli/issues/1) is the source of From 5337159d1be92dc0084ff284a915574cc28c96ee Mon Sep 17 00:00:00 2001 From: Ori Nachum Date: Tue, 28 Jul 2026 11:23:26 +0300 Subject: [PATCH 15/20] chore: bump to 0.14.0 for the m1-leftovers arc --- CHANGELOG.md | 21 +++++++++++++++++++++ pyproject.toml | 2 +- 2 files changed, 22 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b443f5f..6568fdf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,27 @@ All notable changes to this project will be documented in this file. Format follows [Keep a Changelog](https://keepachangelog.com/). This project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.14.0] - 2026-07-28 + +### Added + +- `shell fs read|list|stat|write|edit|media` — the first CLI group whose verbs EXECUTE operations rather than introspect them. It holds no filesystem logic: every verb goes through `operations.execute()`, so the CLI and the library share one engine. Proven, not asserted — each verb's `--json` is compared field-by-field against a result the test obtained from `execute()` itself, and the CLI's operation id is round-tripped through `shell operation show`, which reads the evidence record only `execute()` writes (t7). +- `fs.stat` — a confined structured-metadata handler. A missing path is `SUCCEEDED` with `exists: false`, deliberately diverging from `fs.read`'s failure and pinned so nobody reconciles them by accident. Description uses `lstat`, so an in-root symlink reports as `symlink` rather than as its target (t7). +- `shell process exec|shell` — both preview by default. `process shell` refuses the `control` profile in the library's own words: a raw string is re-interpreted by a shell after the gate read it, so which program runs is not settled by what was gated. The refusal precedes the preview, so it cannot be masked by previewing (t8). +- `shell env describe` — renders the selected environment's two axes as separate facts, never one overloaded mode, and surfaces the host runner's no-isolation posture verbatim. `env create`/`env destroy` are deliberately absent: `OperationIntent.LIFECYCLE` has no registered handler and no `ContainerRunner` exists, so they would be a facade. The catalog states the deferral and names Milestone 4 (t9). +- `SITE_BASELINE` in `scripts/colleague_inventory.py` — pins each module's spawn-site SHAPE (sorted multiset of canonical call names, `:shell` suffix for a literal `shell=True`). Line numbers are excluded deliberately, or any refactor above a spawn would be a false alarm. Generated by the new `--emit-baseline`, never hand-typed, so `PINNED_SHA` and the baseline move in one reviewed diff (t6). + +### Changed + +- The `--apply` flag is now DERIVED from each kind's registered intent at parser-build time via `shell/cli/_operation.py`, the only module in `shell/` that spells `--apply`. Previously the CLI hand-picked which verbs got the flag while the engine independently decided which operations preview — nothing structural tied them together, so flipping a handler's intent would have left a verb previewing forever with no flag to escape it. `tests/test_cli_fs.py` passes byte-unmodified, which is the proof the refactor changed no behaviour (t8). +- `scripts/colleague_inventory.py` closes the architectural finding of issue #7: a new spawn added to an ALREADY-ALLOW-LISTED module now fails `--check`. Previously `ALLOWLIST` was keyed per module, so three new spawns in `tools.py` — two of them `shell=True` — returned exit 0 with no signal. The widened call set (module-prefix resolution, so `asyncio.subprocess.create_subprocess_exec` no longer slips past) and the scope-aware binding rewrite are behaviour-neutral on real colleague: old and new findings are byte-identical over the pinned checkout, same 21 sites / 15 modules / 13 debt (t6). +- The unbound-base fall-through is dropped rather than downgraded to a warning, clearing the six false positives from issue #7 §6 — a gate that flags a parameter named `os` gets disabled, which is its own failure mode. Scope-aware bindings also stop one legitimate `from subprocess import run` from making every later local named `run` a finding (t6). +- README.md and CLAUDE.md describe the shipped surface again. Both had claimed no operation core, no runner, and an introspection-only CLI — all false since 0.13.0. Each wave of this arc falsified at least one guidance surface, so they were rewritten as each landed (t1, plus merge-time updates). + +### Fixed + +- `docs/release-runbook.md` asserted "colleague is the second sanctioned base dependency, alongside agentfront" — inverted, since colleague cannot be a dependency of itself. shell-cli is the second name colleague's zero-deps guard must allow-list. The example pin floor was also stale at `>=0.8,<0.9` against a 0.13.1 release. Found while drafting the colleague migration proposal, which points colleague at this very runbook. + ## [0.13.1] - 2026-07-19 ### Fixed diff --git a/pyproject.toml b/pyproject.toml index 4843f3a..05ded83 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "shell-cli" -version = "0.13.1" +version = "0.14.0" description = "Agent-first CLI and agent owning the file-and-shell tool surface for AI coding agents: read, write, edit, list, view media, and gated shell execution, with path confinement and an operator approval policy. Pure-stdlib core, extracted from colleague so any host imports one safe execution layer." readme = "README.md" license = "Apache-2.0" From 746182b63fb0d8124816156edfbb9f4027ab5971 Mon Sep 17 00:00:00 2001 From: Ori Nachum Date: Tue, 28 Jul 2026 11:27:43 +0300 Subject: [PATCH 16/20] delivery: m1-leftovers arc summary (partial run, plan-versus-actual) Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01EkC4zsVUmYrRCwSWSe4aLp --- docs/deliveries/2026-07-28-m1-leftovers.md | 186 +++++++++++++++++++++ 1 file changed, 186 insertions(+) create mode 100644 docs/deliveries/2026-07-28-m1-leftovers.md diff --git a/docs/deliveries/2026-07-28-m1-leftovers.md b/docs/deliveries/2026-07-28-m1-leftovers.md new file mode 100644 index 0000000..68102ad --- /dev/null +++ b/docs/deliveries/2026-07-28-m1-leftovers.md @@ -0,0 +1,186 @@ +# Delivery Summary — m1-leftovers + +plan: `m1-leftovers` · run: `partial` · date: `2026-07-28` +baseline: `devague summary skeleton` + +## Intent + +Close the gap between "Milestone 1 done" and the issue #1 definition of done. +Milestone 1 shipped the operation core and 0.13.1 reached PyPI, but the +definition of done was blocked on work nobody owned: no migration proposal +filed on colleague, guidance surfaces still describing a pre-M1 scaffold, four +canonical CLI noun groups missing, nine deviation-ledger entries unadjudicated, +the issue #7 scanner hardening unscheduled, and Milestones 3–5 unplanned. The +run executed the 13-task plan at `docs/plans/2026-07-28-m1-leftovers.md`, +fanned out by `/assign-to-workforce` across five waves. + +The run is **partial by design, not by failure**: two tasks were lifted out at +the gate-2 review and one is awaiting operator posting. Every code task +merged; no task failed. + +## Planned Work + +Quoted verbatim from the `devague summary` skeleton: + +- `t1` — Docs-honesty refresh: README + CLAUDE.md against the 0.13.1 surface +- `t2` — File the colleague migration proposal issue (propose, don't push) +- `t3` — Reply on issue 1: map the minimum-record list to the evidence contract +- `t4` — File the devex --body-file stdin bug upstream +- `t5` — Adjudicate the deviation ledger and update issue 4 +- `t6` — Scanner hardening PR: per-site pinning, wider call set, scope-aware + bindings +- `t7` — CLI fs noun group: read, list, stat, write, edit, media +- `t8` — CLI process noun group: exec, shell +- `t9` — CLI env noun group: describe only (lifecycle verbs ride their engines) +- `t10` — Plan the Milestone 3 arc (scope, think, challenge over the + re-captured targets) +- `t11` — Plan the Milestone 4 arc + environment-lifecycle design review +- `t12` — Arc-wide boundary verification +- `t13` — Close the loop: tracker hygiene + arc record + +## Actual Delivery + +| Plan task | Status | What actually landed | +|-----------|--------|----------------------| +| `t1` | delivered | README Status + axes matrix and CLAUDE.md current-state rewritten; merged `fe2a20e`. Extended at merge time three further times as later waves falsified them again. | +| `t2` | partial | Draft complete and verified against colleague v1.52.1. **Not posted** — awaiting operator review (`d2`). | +| `t3` | partial | Draft complete; seven-field mapping produced against `shell/evidence.py`. **Not posted** (`d2`). | +| `t4` | partial | Draft complete; bug reproduced live at devex 0.32.0. **Not posted** (`d2`). | +| `t5` | partial | Draft of the d4–d10 catch-up complete. **Not posted**, and the ledger `--confirm` is operator-only, so the task cannot complete without the operator (`d2`). | +| `t6` | delivered | Per-site `SITE_BASELINE`, module-prefix call resolution, scope-aware bindings, `--emit-baseline`; merged `b005830`. +61 tests. | +| `t7` | delivered | `shell fs read\|list\|stat\|write\|edit\|media` + confined `fs.stat` handler; merged `511e272`. +71 tests. | +| `t8` | delivered | `shell process exec\|shell` + registry-derived `--apply`; merged `10244ce`. +72 tests. | +| `t9` | delivered | `shell env describe`, create/destroy deferred with stated reason; merged `72bfca5`. +21 tests. | +| `t10` | dropped | Lifted out of the fan-out at gate 2 (`d1`). Runs as its own session. | +| `t11` | dropped | Lifted out of the fan-out at gate 2 (`d1`). Runs as its own session. | +| `t12` | delivered | All four boundary criteria verified by the main agent directly rather than delegated; see Evidence. | +| `t13` | partial | Four follow-up issues filed (#10–#13). Close-out comments on issues #4/#7/#8 **not posted** — awaiting operator review (`d2`). | + +## Mid-work Decisions + +- `d1` — t10 and t11 lifted out of the fan-out — both are full devague arcs + whose `converge`/`confirm` moves are user-only gates, and running devague + moves inside parallel task worktrees would write conflicting `.devague` + state, which the `/assign-to-workforce` contract forbids. +- `d2` — t2, t3, t4 and t13 produce reviewed drafts; posting happens after + operator review — filing issues and comments on colleague, devex and this + repo are irreversible outward-facing actions. +- `d3` — `docs/release-runbook.md` corrected in-arc (proposed, awaiting + operator confirmation) — the file was outside t1's scope, but leaving a + factual inversion in the document the migration proposal cites would + undercut the proposal. +- **The three drafting tasks needed no worktree.** t2/t3/t4/t5 mutate no repo + file, so they were run without git worktrees or merges — four unnecessary + merge cycles avoided. Not covered by any deviation record; a scheduling + refinement inside the approved split, not a change to it. +- **The local test baseline was red before any task ran, for an environmental + reason.** Three `test_regeneration_reproducible.py` tests require the + colleague checkout to *be* at the pinned SHA; the operator's checkout had + moved to v1.52.1. Resolved by cloning colleague at the pin into scratch space + and pointing `SHELL_CLI_COLLEAGUE_ROOT` at it, leaving the operator's + checkout untouched. Every TDD gate in this run used that clone. +- **Doc updates were folded into merge time rather than left to t1.** Each of + t7/t8/t9 falsified a guidance surface it was forbidden to edit under the + wave's file-ownership rule. Each reported the staleness; the merging agent + fixed it in the merge commit's wake (`b92cb15`, `c57d967`, `e817747`). +- **t8 absorbed an architectural recommendation t7 made.** t7 found the CLI + hand-picked which verbs got `--apply` while the engine independently decided + which operations preview, with only a test tying them. Rather than file it, + the recommendation went into t8's brief and was closed structurally. + +## Drift From Plan + +| Plan item | Reason for divergence | Classification | +|-----------|-----------------------|----------------| +| `t10` (`d1`) | Full devague arc whose converge/confirm moves are user-only gates; devague moves in parallel worktrees would write conflicting `.devague` state | acceptable | +| `t11` (`d1`) | Same as `t10` | acceptable | +| `t2` (`d2`) | Irreversible outward-facing post; acceptance completes at post time, not merge time | acceptable | +| `t3` (`d2`) | Same as `t2` | acceptable | +| `t4` (`d2`) | Same as `t2` | acceptable | +| `t5` (`d2`) | Same as `t2`, plus `devague deviate --confirm` is an operator-only decision the fan-out cannot make | acceptable | +| `t13` (`d2`) | Follow-up issues filed; close-out comments on #4/#7/#8 await review | needs-follow-up | +| `t1` (`d3`) | Scope widened by two lines to `docs/release-runbook.md`, outside the confirmed task | acceptable | +| `t9` | The plan's task text says "CLI env noun group: describe only" and that is exactly what shipped — recorded here because the *canonical* surface in issue #1 §6 names `env describe\|create\|destroy`, and this run deliberately ships one third of it. Covered by the confirmed frame boundary `c32`/`h27`, so it is planned scope, not silent truncation. | acceptable | + +No other plan task diverged. `t6`, `t7`, `t8`, `t12` delivered against their +acceptance criteria as written. + +## Evidence + +- tests: full suite `860 passed, 8 skipped` — the 8 skips are the documented + `KNOWN GAP` (colleague present but not importable), unchanged by this run +- tests: baseline at arc start was `635 passed`; net `+225` tests +- lint: `black --check shell tests scripts` — 79 files unchanged +- lint: `isort --check-only shell tests scripts` — clean +- lint: `flake8 shell tests scripts` — clean +- lint: `bandit -c pyproject.toml -r shell` — no issues identified +- lint: `markdownlint-cli2 "**/*.md" …` — 0 errors +- rubric: `teken cli doctor . --strict` — exit 0, 26 checks passing +- scanner: `colleague_inventory.py --check` — exit 0 +- scanner, adversarial: appending one benign `subprocess.run(["true"])` to the + already-allow-listed `tools.py` in a pinned clone yields + `tools.py: baseline 2 site(s), observed 3 (+subprocess.run×1)`, exit 1 — + the case that returned exit 0 in issue #7's live test +- boundary (`t12`): consumer-name grep across repo tree, arc diff, drafts and + `.devague` state — **0 hits in all four** +- boundary (`t12`): `pyproject.toml` `dependencies = []`; + `tests/test_zero_deps.py` + `tests/test_boundaries.py` — 12 passed +- boundary (`t12`): no `t74b` policy-composition fixture exists in `tests/` +- boundary (`t12`): the five chosen-limit test files are **byte-unchanged** + across `1f4b616..HEAD` +- CLI, verified by hand: `fs write` previews without writing and writes under + `--apply`; `process exec -- touch` creates nothing on preview; + `process shell --profile control` exits 1 with the library's own refusal; + `env describe --json` keeps `workspace` and `runner` as separate keys with + `isolation: none`; `env create` fails through the normal `CliError` path +- commits: `1f4b616..HEAD` — 21 commits, 32 files, +7867/−260 +- issues filed: #10, #11, #12, #13 +- version: `0.13.1 → 0.14.0` + +## Delivery Claims + +| Claim | Confidence | Evidence | +|-------|------------|----------| +| The CLI executes operations for the first time, through the same engine as the library | high | commits `511e272`, `10244ce`, `72bfca5`; `shell/cli/_commands/{fs,process,env}.py`; hand-verified preview/apply cycles | +| Mutation and execution verbs preview by default, and the `--apply` flag is derived from each kind's registered intent | high | `shell/cli/_operation.py`; `tests/test_cli_apply_flag.py`; `tests/test_cli_fs.py` passes byte-unmodified across the refactor | +| The issue #7 architectural finding is closed — a new spawn in an already-allow-listed module fails `--check` | high | adversarial probe above; `scripts/colleague_inventory.py` `SITE_BASELINE`; +61 tests in `tests/test_colleague_inventory.py` | +| The scanner rewrite is behaviour-neutral on real colleague | high | old-vs-new findings byte-identical over the pinned checkout: 21 sites / 15 modules / 13 debt | +| The six issue-#7 false positives no longer fire | high | `_FALSE_POSITIVES` fixtures in `tests/test_colleague_inventory.py` | +| README, CLAUDE.md and the release runbook describe the shipped surface | high | commits `fe2a20e`, `bc21bdb`, `51a9bd6`, `b92cb15`, `c57d967`, `e817747`; `tests/test_honesty.py` 32 passed | +| The second consumer is named in no committed artifact | high | `t12` grep — 0 hits across repo tree, arc diff, drafts, `.devague` state | +| The pure-stdlib constraint holds | high | `dependencies = []`; `tests/test_zero_deps.py` green | +| CLAUDE.md's recorded colleague survey is still accurate at colleague v1.52.1 | high | independent re-verification of every cited line number during `t2` drafting | +| The four cross-repo posts say what they should | medium | drafts written and self-reviewed, but **not posted** and not operator-reviewed | +| colleague will act on the migration proposal | unverified | not filed yet; and the decision is colleague's, not ours | +| The remaining scanner evasion classes are closed | unverified | explicitly **not** claimed — indirection, structure and non-literal `shell=` spellings remain open, tracked in #13 | +| Milestones 3 and 4 are planned | unverified | `t10`/`t11` lifted out (`d1`); no M3/M4 frame or plan exists | + +## Remaining Work / Follow-up + +- **`t2`, `t3`, `t4`, `t5`, `t13` — post the four reviewed drafts.** Drafts are + at `scratchpad/drafts/`. Operator reviews, then the posts go to + agentculture/colleague, shell-cli issue #1, agentculture/devex, and shell-cli + issue #4. Owner: operator review, then main agent posts. +- **`t5` — confirm the deviation ledger.** Nine of ten entries on the + `guarded-local-operations-plane` ledger remain `proposed`; `devague deviate + --confirm` is operator-only. `d3` on this plan's ledger is likewise + `proposed`. +- **`t10` — plan the Milestone 3 arc.** Its own scope→think→challenge→plan + session: the debt countdown 13→0, lint changed-path effects, the worktree + flock's three preserved properties, the hook-specific policy surface, and the + `git` noun group riding the git/worktree engine. +- **`t11` — plan the Milestone 4 arc.** Same, plus the environment-lifecycle + design review that must re-read the second consumer's seam at review time + (frame `c31`) and place the per-runner threat-model split inside the + `ContainerRunner` PR. +- **`t13` — close or pointer-comment issues #4, #7, #8.** #7 is now partially + addressable: findings 1, 2 and 6 are closed, 3, 4 and 5 continue in #13. +- **Newly discovered, filed during this run:** #10 (the pinned colleague SHA is + not on colleague's `main` — fragile provenance, and the reason a fresh + developer checkout has a red baseline), #11 (four evidence-record gaps), + #12 (preview misses knowable confinement refusals; the registry lacks + `accepted_profiles`; five copies of `_safe_path`), #13 (pin the remaining + scanner evasion classes as expected-to-evade fixtures). +- **Not yet addressed and worth a decision:** `shell process`'s `--env` + defaults to an empty allow-list — not even `PATH` — matching the library + default. Honest, but a reviewer may want the trade-off revisited (#12). From a72325ae6c03a26aaffe2b4ba57a62ef75013dd6 Mon Sep 17 00:00:00 2001 From: Ori Nachum Date: Tue, 28 Jul 2026 11:28:02 +0300 Subject: [PATCH 17/20] chore: sync uv.lock to 0.14.0 and commit the deviation ledger Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01EkC4zsVUmYrRCwSWSe4aLp --- .devague/deliveries/m1-leftovers.json | 45 +++++++++++++++++++++++++++ uv.lock | 2 +- 2 files changed, 46 insertions(+), 1 deletion(-) create mode 100644 .devague/deliveries/m1-leftovers.json diff --git a/.devague/deliveries/m1-leftovers.json b/.devague/deliveries/m1-leftovers.json new file mode 100644 index 0000000..b922336 --- /dev/null +++ b/.devague/deliveries/m1-leftovers.json @@ -0,0 +1,45 @@ +{ + "plan_slug": "m1-leftovers", + "schema_version": 1, + "created": "2026-07-28T07:16:34Z", + "updated": "2026-07-28T07:27:51Z", + "deviations": [ + { + "id": "d1", + "what": "t10 and t11 are lifted out of the fan-out and will run as their own scope-think-challenge-plan sessions with the operator after this arc lands", + "task_ref": "t10", + "reason": "Both are full devague arcs whose converge/confirm moves are user-only gates, and running devague moves inside parallel task worktrees would write conflicting .devague state - which the assign-to-workforce contract forbids. Operator decision at gate 2, 2026-07-28.", + "affects": [ + "t11", + "t13" + ], + "origin": "user", + "status": "approved", + "classification": "acceptable" + }, + { + "id": "d2", + "what": "t2, t3, t4 and t13 produce reviewed DRAFTS during the fan-out; the actual public posting happens after operator review, so their acceptance criteria complete at post time rather than at merge time", + "task_ref": "t2", + "reason": "Filing issues and comments on colleague, devex and this repo are irreversible outward-facing actions; the operator chose draft-then-review at gate 2, 2026-07-28. These tasks therefore produce no repo mutation and need no worktree.", + "affects": [ + "t3", + "t4", + "t13" + ], + "origin": "user", + "status": "approved", + "classification": "acceptable" + }, + { + "id": "d3", + "what": "docs/release-runbook.md is corrected in-arc: the inverted sentence 'colleague is the second sanctioned base dependency' becomes 'shell-cli is...', and the stale example floor shell-cli>=0.8,<0.9 becomes >=0.13,<0.14", + "task_ref": "t1", + "reason": "Found by the t2 drafting agent while sourcing the colleague migration proposal, which points colleague at this very runbook. The plan scoped the docs-honesty slice (c3/t1) to README and CLAUDE.md only, so this file was out of scope; leaving a factual inversion in the document the proposal cites would undercut the proposal. Two-line factual fix, no behaviour change.", + "affects": [], + "origin": "llm", + "status": "proposed", + "classification": "acceptable" + } + ] +} diff --git a/uv.lock b/uv.lock index c8708a7..24a8c9b 100644 --- a/uv.lock +++ b/uv.lock @@ -430,7 +430,7 @@ wheels = [ [[package]] name = "shell-cli" -version = "0.13.1" +version = "0.14.0" source = { editable = "." } [package.dev-dependencies] From b3fd9738de1443b7d6c235f89705db54d948cf14 Mon Sep 17 00:00:00 2001 From: Ori Nachum Date: Tue, 28 Jul 2026 11:46:03 +0300 Subject: [PATCH 18/20] fix(sonar): fs._emit no longer returns a meaningless int (S3516), one call per pytest.raises block (S5778) _emit always returned 0 on every non-raising path, so the -> int signature misled a reader into thinking it computed a variable exit code. Extract the terminal raise ladder (DENIED + catch-all) into _raise_for_status(result) -> NoReturn, and change _emit to -> None; the docstring's denial explanation travels with the branch that moved. The call site in _run() now calls _emit() then returns EXIT_SUCCESS explicitly. Mirrors the parallel refactor of process.py's _emit so the two modules stay structurally aligned. Also hoist the argv list above two `with pytest.raises(SystemExit)` blocks in test_cli_fs.py that called str(work) inside the block, leaving exactly one invocation (main(argv)) that can raise. Swept the whole file for the same pattern; these were the only two instances. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01EkC4zsVUmYrRCwSWSe4aLp --- shell/cli/_commands/fs.py | 69 ++++++++++++++++++++++++--------------- tests/test_cli_fs.py | 6 ++-- 2 files changed, 47 insertions(+), 28 deletions(-) diff --git a/shell/cli/_commands/fs.py b/shell/cli/_commands/fs.py index 6e005d4..1129f9c 100644 --- a/shell/cli/_commands/fs.py +++ b/shell/cli/_commands/fs.py @@ -80,11 +80,11 @@ import argparse import sys from pathlib import Path -from typing import Any +from typing import Any, NoReturn from shell import operations from shell.cli._commands.overview import emit_overview -from shell.cli._errors import EXIT_USER_ERROR, CliError +from shell.cli._errors import EXIT_SUCCESS, EXIT_USER_ERROR, CliError from shell.cli._operation import ( JSON_HELP, add_apply_flag, @@ -203,8 +203,39 @@ def _environment(args: argparse.Namespace) -> Environment: # --- dispatch ----------------------------------------------------------------- -def _emit(result: OperationResult, *, json_mode: bool) -> int: - """Render *result*, then translate its status into an exit code. +def _raise_for_status(result: OperationResult) -> NoReturn: + """Raise the :class:`CliError` for a *result* that is neither success nor preview. + + A ``DENIED`` result was refused, not attempted — the remediation points at + the policy verdict in the ``--json`` result and names the one rule this + noun group enforces itself: a write inside a declared ``--read-only-path`` + is never permitted. Every other non-terminal status falls through to the + catch-all, which points back at ``--json`` for the full structured result + rather than guessing at the cause. + """ + if result.status is OperationStatus.DENIED: + raise CliError( + code=EXIT_USER_ERROR, + message=result.error or "the operation was denied", + remediation=( + "the operation was refused, not attempted; see the policy verdict in " + "the --json result. A write inside a declared --read-only-path is " + "never permitted." + ), + ) + + raise CliError( + code=EXIT_USER_ERROR, + message=result.error or f"the operation reported status {result.status.value!r}", + remediation=( + "check the path and --root; add --json for the full structured result " + "(status, verdict, effects) on stdout" + ), + ) + + +def _emit(result: OperationResult, *, json_mode: bool) -> None: + """Render *result*; raise :class:`CliError` for anything not succeeded/previewed. **In JSON mode the structured result goes to stdout on every path, failures included.** An agent that asked for ``--json`` and received only @@ -217,6 +248,9 @@ def _emit(result: OperationResult, *, json_mode: bool) -> int: missing.txt > out.txt`` must not land ``no such file: missing.txt`` in ``out.txt`` looking exactly like file content. Text stdout carries the thing the caller asked for, or nothing. + + Returns normally for a succeeded or previewed result; every other status is + delegated to :func:`_raise_for_status`, which never returns. """ terminal = result.succeeded or result.status is OperationStatus.PREVIEWED if json_mode: @@ -225,7 +259,7 @@ def _emit(result: OperationResult, *, json_mode: bool) -> int: emit_result(result.rendering, json_mode=False) if result.succeeded: - return 0 + return if result.status is OperationStatus.PREVIEWED: if not json_mode: @@ -235,27 +269,9 @@ def _emit(result: OperationResult, *, json_mode: bool) -> int: emit_diagnostic( "note: previewed — nothing was applied. Re-run with --apply to carry it out." ) - return 0 + return - if result.status is OperationStatus.DENIED: - raise CliError( - code=EXIT_USER_ERROR, - message=result.error or "the operation was denied", - remediation=( - "the operation was refused, not attempted; see the policy verdict in " - "the --json result. A write inside a declared --read-only-path is " - "never permitted." - ), - ) - - raise CliError( - code=EXIT_USER_ERROR, - message=result.error or f"the operation reported status {result.status.value!r}", - remediation=( - "check the path and --root; add --json for the full structured result " - "(status, verdict, effects) on stdout" - ), - ) + _raise_for_status(result) def _run(args: argparse.Namespace, verb: str, arguments: dict[str, Any]) -> int: @@ -270,7 +286,8 @@ def _run(args: argparse.Namespace, verb: str, arguments: dict[str, Any]) -> int: caller={"agent": "shell-cli", "tool": f"shell fs {verb}"}, ) result = operations.execute(operation, environment, evidence_store=evidence_store(args)) - return _emit(result, json_mode=bool(getattr(args, "json", False))) + _emit(result, json_mode=bool(getattr(args, "json", False))) + return EXIT_SUCCESS # --- verbs --------------------------------------------------------------------- diff --git a/tests/test_cli_fs.py b/tests/test_cli_fs.py index 083fc9d..ef840a9 100644 --- a/tests/test_cli_fs.py +++ b/tests/test_cli_fs.py @@ -408,8 +408,9 @@ def test_fs_write_reads_content_from_stdin( def test_fs_write_content_and_stdin_are_mutually_exclusive( work: Path, capsys: pytest.CaptureFixture[str] ) -> None: + argv = ["fs", "write", "x.txt", "--content", "a", "--content-stdin", "--root", str(work)] with pytest.raises(SystemExit) as exc: - main(["fs", "write", "x.txt", "--content", "a", "--content-stdin", "--root", str(work)]) + main(argv) assert exc.value.code == 1 err = capsys.readouterr().err assert err.startswith("error:") @@ -733,8 +734,9 @@ def test_a_verb_missing_its_path_is_a_structured_error( def test_fs_edit_missing_new_string_is_a_structured_error( work: Path, capsys: pytest.CaptureFixture[str] ) -> None: + argv = ["fs", "edit", "a.txt", "--old-string", "x", "--root", str(work)] with pytest.raises(SystemExit) as exc: - main(["fs", "edit", "a.txt", "--old-string", "x", "--root", str(work)]) + main(argv) assert exc.value.code == 1 assert "hint:" in capsys.readouterr().err From 9726bf6ce1dea8c66dec3f056ae4b18b4648f3d7 Mon Sep 17 00:00:00 2001 From: Ori Nachum Date: Tue, 28 Jul 2026 11:47:37 +0300 Subject: [PATCH 19/20] fix(sonar): split process._emit's raise ladder into _raise_for_status MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit S3516/S3776 (process.py:332): _emit was typed -> int but every path either returned 0 or raised CliError, so the return value carried no information and the branching pushed cognitive complexity to 17. Extract the DENIED / TIMED_OUT / catch-all raise ladder into a new `_raise_for_status(result) -> NoReturn` helper; _emit now returns None and delegates to it. The docstring sentence about a policy denial landing in a redirected build.log travels with the ladder into _raise_for_status, since that is the code it now describes. The call site in _run replaces `return _emit(...)` with `_emit(...)` followed by `return EXIT_SUCCESS`. Verified with the `cognitive_complexity` package: _emit drops from 17 to 12 (_raise_for_status is 5), both under the 15 threshold. S5778 (tests/test_cli_process.py:1158): hoisted the argv list for test_an_invalid_profile_is_a_structured_error above the `pytest.raises` block so only `main(argv)` remains inside it. shell/cli/_commands/fs.py and tests/test_cli_fs.py are untouched — a sibling agent owns the identical S3516 fix there. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01EkC4zsVUmYrRCwSWSe4aLp --- shell/cli/_commands/process.py | 86 ++++++++++++++++++++-------------- tests/test_cli_process.py | 3 +- 2 files changed, 52 insertions(+), 37 deletions(-) diff --git a/shell/cli/_commands/process.py b/shell/cli/_commands/process.py index def1139..dd27137 100644 --- a/shell/cli/_commands/process.py +++ b/shell/cli/_commands/process.py @@ -98,11 +98,11 @@ from __future__ import annotations import argparse -from typing import Any, Mapping +from typing import Any, Mapping, NoReturn from shell import operations from shell.cli._commands.overview import emit_overview -from shell.cli._errors import EXIT_USER_ERROR, CliError +from shell.cli._errors import EXIT_SUCCESS, EXIT_USER_ERROR, CliError from shell.cli._operation import ( JSON_HELP, add_apply_flag, @@ -329,41 +329,17 @@ def _exit_code_note(result: OperationResult) -> str | None: ) -def _emit(result: OperationResult, *, json_mode: bool) -> int: - """Render *result*, then translate its status into an exit code. +def _raise_for_status(result: OperationResult) -> NoReturn: + """Raise the :class:`CliError` for a denied, timed-out, or otherwise + non-terminal *result*. - **In JSON mode the structured result goes to stdout on every path**, so an - agent that asked for ``--json`` never has to re-run a command to learn what - happened — which, for an execution verb, is not a safe thing to ask of it. - - **In text mode a non-terminal outcome writes nothing to stdout.** Its - rendering is the error message, which is already going to stderr, and - ``shell process shell 'make' > build.log`` must not land a policy denial in - ``build.log`` looking like build output. + Called only after :func:`_emit` has already rendered (or, in text mode, + deliberately withheld) *result*'s own output, so this function's message + is the only thing left to report that outcome — and it goes to stderr, + never stdout: ``shell process shell 'make' > build.log`` must not land a + policy denial, or a timeout report, in ``build.log`` looking like build + output. """ - terminal = result.succeeded or result.status is OperationStatus.PREVIEWED - if json_mode: - emit_result(result.to_dict(), json_mode=True) - elif terminal: - emit_result(result.rendering, json_mode=False) - - if result.succeeded: - if not json_mode: - note = _exit_code_note(result) - if note is not None: - emit_diagnostic(note) - return 0 - - if result.status is OperationStatus.PREVIEWED: - if not json_mode: - # In JSON mode `status` already carries this and `evidence.isolation` - # already carries the posture; a prose line would only be noise. - emit_diagnostic( - "note: previewed — nothing was run. Re-run with --apply to carry it out. " - f"When it runs, {_NOT_CONFINED}." - ) - return 0 - if result.status is OperationStatus.DENIED: raise CliError( code=EXIT_USER_ERROR, @@ -397,6 +373,43 @@ def _emit(result: OperationResult, *, json_mode: bool) -> int: ) +def _emit(result: OperationResult, *, json_mode: bool) -> None: + """Render *result*, then raise unless it succeeded or was previewed. + + **In JSON mode the structured result goes to stdout on every path**, so an + agent that asked for ``--json`` never has to re-run a command to learn what + happened — which, for an execution verb, is not a safe thing to ask of it. + + **In text mode a non-terminal outcome writes nothing to stdout.** Its + rendering is the error message that :func:`_raise_for_status` raises, + which goes to stderr — see that function for why that matters. + """ + terminal = result.succeeded or result.status is OperationStatus.PREVIEWED + if json_mode: + emit_result(result.to_dict(), json_mode=True) + elif terminal: + emit_result(result.rendering, json_mode=False) + + if result.succeeded: + if not json_mode: + note = _exit_code_note(result) + if note is not None: + emit_diagnostic(note) + return + + if result.status is OperationStatus.PREVIEWED: + if not json_mode: + # In JSON mode `status` already carries this and `evidence.isolation` + # already carries the posture; a prose line would only be noise. + emit_diagnostic( + "note: previewed — nothing was run. Re-run with --apply to carry it out. " + f"When it runs, {_NOT_CONFINED}." + ) + return + + _raise_for_status(result) + + def _run(args: argparse.Namespace, verb: str, arguments: Mapping[str, Any]) -> int: """Build the operation and put it through the one pipeline.""" profile = _profile(args, verb) @@ -419,7 +432,8 @@ def _run(args: argparse.Namespace, verb: str, arguments: Mapping[str, Any]) -> i policy=load_policy_from_args(args), evidence_store=evidence_store(args), ) - return _emit(result, json_mode=bool(getattr(args, "json", False))) + _emit(result, json_mode=bool(getattr(args, "json", False))) + return EXIT_SUCCESS # --- verbs ------------------------------------------------------------------------- diff --git a/tests/test_cli_process.py b/tests/test_cli_process.py index 4d08672..d53b340 100644 --- a/tests/test_cli_process.py +++ b/tests/test_cli_process.py @@ -1155,8 +1155,9 @@ def test_parse_errors_honour_json_mode(capsys: pytest.CaptureFixture[str]) -> No def test_an_invalid_profile_is_a_structured_error( work: Path, capsys: pytest.CaptureFixture[str] ) -> None: + argv = ["process", "shell", "true", "--profile", "nonsense", "--root", str(work)] with pytest.raises(SystemExit) as exc: - main(["process", "shell", "true", "--profile", "nonsense", "--root", str(work)]) + main(argv) assert exc.value.code == 1 assert "hint:" in capsys.readouterr().err From 91da217851a10e51ae4a91a540e718df09d3d12c Mon Sep 17 00:00:00 2001 From: Ori Nachum Date: Tue, 28 Jul 2026 11:49:14 +0300 Subject: [PATCH 20/20] docs: record the Sonar-driven _emit refactor in the 0.14.0 entry Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01EkC4zsVUmYrRCwSWSe4aLp --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6568fdf..91c5894 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -25,6 +25,7 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ### Fixed - `docs/release-runbook.md` asserted "colleague is the second sanctioned base dependency, alongside agentfront" — inverted, since colleague cannot be a dependency of itself. shell-cli is the second name colleague's zero-deps guard must allow-list. The example pin floor was also stale at `>=0.8,<0.9` against a 0.13.1 release. Found while drafting the colleague migration proposal, which points colleague at this very runbook. +- `_emit` in both CLI operation groups was annotated `-> int` while every `return` in it returned `0` and every other path raised — a signature that told a reader it computed a variable exit code when it did not. The terminal raise ladder is now a `_raise_for_status(...) -> NoReturn` helper in each module, `_emit` is honestly `-> None`, and each call site returns the existing `EXIT_SUCCESS`. `process`'s cognitive complexity drops 17 -> 12 as a consequence, measured against SonarSource's own algorithm before and after. No behaviour change: the suite held at 860 with no test modified beyond three `pytest.raises` blocks that had a second throwing call (`str(work)`) inside them (SonarCloud S3516, S3776, S5778 on PR #14). ## [0.13.1] - 2026-07-19