step.15.diff.txt -- narrative retro for step.15 (U3.2: Tool surface + policy gates -- allowlist shell, git ops, approval for destructive, the no-`--force` posture) Executed: 2026-07-21, same session as the draft (single pass, the step.7/9/10/11/14 convention -- app/tools/'s six modules, the config key, five test files' worth of changes, docs/tool_surface.md, the design.md/end_to_end.md edits, and the step file all built together). Companion artifact: step.15.gdiff.txt (automated, scripts/step_gdiff). Per the operator's explicit per-UoW checkpoint rule (same as step.14's dispatch), this session STOPS after U3.2 -- U3.3 is not started. ## Plan vs actual ``` +---+----------------------------------+------------------------------------------+ | # | Planned (brief) | Actual | +---+----------------------------------+------------------------------------------+ | 1 | Decide module-vs-package for the | Package (app/tools/), the app/ledger/ + | | | U1.3 tools family; existing | app/orchestrator/ module-per-concern | | | call sites keep working | precedent. __init__.py re-exports the | | | | whole surface; app/cli.py and the 7 U1.3 | | | | tests needed ZERO edits -- the growth is | | | | invisible to every existing caller. | | 2 | Allowlist shell: minimal honest | bash/make/pytest/python/python3 (gate | | | starting set + config home | scripts, Makefile, the T0-oracle-in- | | | | waiting, both interpreter basenames). | | | | git deliberately EXCLUDED (gitops.py is | | | | git's only path). Config: shell_allowlist| | | | key in app/config/config.txt through | | | | step.11's existing loader; the override | | | | REPLACES the default so it can narrow. | | | | Basename matching; argv-vector only, no | | | | sh -c splicing surface. | | 3 | Destructive ops: absent-by- | Present-but-refusing stubs (delete_file, | | | design vs refuse-stub -- | git_push, git_rewrite_history, | | | pick the honest option | git_delete_branch): §2.7's own wording | | | | ("REQUIRE explicit human approval") | | | | describes gated members of the surface, | | | | not non-members, and stubs make every | | | | attempt a trail-recorded event. All | | | | refuse unconditionally naming U3.4; no | | | | prompt exists anywhere (C-15-D). | | 4 | Oracle invocation V0 -- confirm | Confirmed as proposed: run_gate(root, | | | the named-gate-script reading | name) runs scripts/ (bare names | | | or propose tighter | only) under the same allowlist/timeout/ | | | | capture machinery; exit code = verdict, | | | | reported not raised. Tiers stay U3.6. | | 5 | Tool-call logging: egress | Sibling (app/tools/trail.py), NOT an | | | extension vs sibling | egress extension: tool calls are LOCAL | | | | actions; folding them into egress would | | | | muddy the one file whose completeness IS | | | | the B-5 "what left this machine" answer. | | | | Same data_home() (C-14-E), different | | | | file: tool_calls.jsonl, append-only, | | | | unconditional incl. refusals (C-15-B), | | | | read_trail() as the replay reader. | | 6 | (not in the brief -- forced in | Unconditional recording means the SUITE | | | live by the build) | ITSELF writes trail entries, and several | | | | existing tests call the tools layer with | | | | no $B3UBOT_HOME set -- a plain pytest | | | | run would have polluted the developer's | | | | real ~/.b3ubot audit trail with test | | | | noise. tests/conftest.py (new): autouse | | | | per-test $B3UBOT_HOME. First draft | | | | nested the home under tmp_path and a | | | | list_dir test promptly saw its own data | | | | home in the workspace listing -- moved | | | | to a tmp_path_factory tree of its own. | | 7 | Ledger dogfooding will break the | Broke it for the THIRD time -- exactly | | | roundtrip test's hardcoded | the recurrence step.14's retro predicted | | | todo-row example again | and named the remedy for: converted the | | | | pick to a STATUS QUERY (first row where | | | | status=='todo') instead of repointing at | | | | U3.3 for a fourth round. The break class | | | | is now impossible, and the test still | | | | mutates a real live-table row each run. | +---+----------------------------------+------------------------------------------+ ``` Everything else held to plan: the b3u launcher grep found the cited posture's literal wording in cmd_start()'s preflight ("refuse with the NAMED fix; nothing is created before every check passes") and every refusal message in the surface follows that shape; the git walk-up hazard (a nested non-repo workspace would stage into its ENCLOSING repo, because git discovers upward) was caught at design time and locked with a test; step.15.txt parsed into the self-inclusive corpus on the first try (its shape mirrors step.14.txt's). ## Gate evidence ``` +------+---------------------------------------------------------------------+ | G1 | The 7 U1.3 tests in tests/test_tools.py pass UNCHANGED against the | | | package; app/cli.py zero edits. Verified live. | | G2 | tests/test_tools_shell.py 20/20 PASS live -- incl. 'curl' refusing | | | with the named fix (shell_allowlist + app/config/config.txt in the | | | message), the config override narrowing bash away, and a 30s sleep | | | killed at timeout=0.5 with timed_out=True in <10s. | | G3 | tests/test_tools_git.py 11/11 PASS live on a SCRATCH repo (git init | | | in tmp_path -- this repo's .git untouched): round-trip to a 40-hex | | | hash, walk-up refusal, three destructive stubs refuse + HEAD | | | compared before/after (history untouched). | | G4 | tests/test_tools_trail.py 12/12 PASS live: ok/nonzero/timeout/ | | | refused all recorded; run_gate logs once under its own name; the | | | no-disable-parameter introspection gate (C-15-B). | | G5 | §2.7's closing sentence literally: policy-gated (G2/G3 refusal | | | legs), logged (G4), replayable (read_trail), destructive-requires- | | | approval (G3 stubs, the honest refuse-until-U3.4 interim). | | G6 | 329p/11s baseline confirmed live before edits -> 380p/11s after the | | | build -> 382p/11s once step.15.txt joined the corpus (+51 tool | | | tests, +2 self-inclusive pickups). Zero regressions; one test | | | intentionally converted (status-query pick), not loosened. | | G7 | grep -rn "U3\.[3-8]" app/tools/ -- every hit a scope-boundary | | | annotation naming U3.4/U3.5/U3.6 as owner; no input() anywhere in | | | app/tools/; no orchestrator import anywhere in app/tools/. | | G8 | §11 ledger row updated THROUGH the `ledger` CLI (transition U3.2 | | | active --step step.15; close U3.2 PENDING) against the real | | | end_to_end.md. | | G9 | Porcelain clean per commit; retro pair via the SHARED scripts/ | | | step_gdiff; exactly 2 commits (work + gdiff-with-backfill). | +------+---------------------------------------------------------------------+ ``` ## Findings for later steps 1. **Unconditional audit recording and a hermetic test suite are one decision, not two.** The moment recording became unconditional (C-15-B), the suite itself became a trail writer, and the absence of a global $B3UBOT_HOME isolation fixture turned from harmless to a live-pollution bug in the same commit. Any future unconditional side-channel (Track B's real ProviderRun row, U3.5's evidence writes) should land WITH its suite-wide isolation in the same step, not discover the need afterward. tests/conftest.py now exists as the place to extend. 2. **git's upward repo discovery is a workspace-scope hazard of its own class.** _resolve_within guards paths INSIDE the root, but git would happily operate on a repository ABOVE it -- a nested non-repo workspace silently staging into its parent. The two-layer guard (root/.git must exist) is now locked by test; anything else that shells out to a tool with its own discovery rules (make -C?, future package managers) deserves the same "does this tool look upward?" question at design time. 3. **The thrice-broken todo-row example is closed the way step.14's retro prescribed** (status-query pick). The general lesson stands for future test authors: a test that names a live ledger row by id is a dogfooding step away from breaking; pick by status/shape query when the example only needs to be "some real row of kind X". 4. **run_shell inherits the environment, and Q-15-B names the moment that stops being fine**: U3.5, when provider-driven tool_call nodes (not the developer) start running commands -- inherited env then hands child processes the B-4 provider keys. Flagged now so U3.5's author finds it as a named question, not a surprise. ## Numbers ``` +----------------------------------------+----------------------------+ | new files | 10 (app/tools/{__init__, | | | errors,workspace,shell, | | | gitops,trail}.py; tests/ | | | conftest.py; tests/test_ | | | tools_{shell,git,trail}.py)| | + docs/step pair | 3 (docs/tool_surface.md, | | | steps/step.15.txt, | | | step.15.diff.txt) | | deleted | 1 (app/tools.py -- grown | | | into the package) | | files edited | 5 (app/config/config.txt, | | | tests/test_tools.py, | | | tests/test_ledger_table_ | | | roundtrip.py, design.md, | | | end_to_end.md) | | app/tools/ total LOC | ~800 across 6 modules | | new tests | 51 (+7 tools, 20 shell, | | | 11 git, 12 trail, +1 net | | | on the converted roundtrip | | | test... counted: 50 new + | | | 1 converted) + 2 corpus | | | pickups from this step's | | | own step.15.txt | | suite before -> after | 329p/11s -> 382p/11s | | regressions | 0 | | prompts introduced | 0 (C-15-D; approval UI is | | | U3.4) | +----------------------------------------+----------------------------+ ``` U3.2 done -- design.md §2.7's tool surface exists as concrete, policy-gated, unconditionally-audited code: the allowlist shell with its config-named executable set, the four non-destructive git operations behind a walk-up-proof scope guard, directory listing, the named-gate-script oracle V0, and four destructive operations that exist precisely so their refusals are on the record. Wiring any of it to the execution DAG is U3.5's job and is untouched. Per the operator's explicit scope boundary, this session STOPS here -- U3.3 (PAL-drafted skeletons) is next but not started. Ledger updated through the `ledger` CLI in the work commit; the work commit's hash is backfilled into end_to_end.md §11 and step.15.txt §7 in the gdiff commit -- the same 2-commit resolution steps 7-14 used. End of retro.