step.15.txt -- b3ubot U3.2: Tool surface + policy gates (allowlist shell, git ops, approval for destructive -- the no-`--force` posture). Grows U1.3's minimal app/tools.py into the app/tools/ package and delivers the REST of design.md §2.7's surface, standalone: directory listing, allowlist shell execution, git status/diff/add/commit, destructive refuse-stubs, oracle invocation V0, and the unconditional tool-call trail. Stops here -- does NOT continue into U3.3 or any later P3 UoW. Step: 15 Parent: end_to_end.md §5 U3.2 bullet verbatim ("Tool surface + policy gates (allowlist shell, git ops, approval for destructive -- the no-`--force` posture)"). design.md §2.7 (the tool surface) is the section this step implements directly; §2.10 (standing locks B-1..B-6) and §5 d6 (egress allowlist LEAN -- consistent-with, NOT resolved here) are the policy frame. app/tools.py (U1.3, C-4-B) is the module family this extends; app/egress.py (U1.3) is the trail-precursor framing precedent this step's tool-call trail mirrors; app/config/loader.py (U2.2, step.11) is the config mechanism the allowlist rides; scripts/p1_gates.sh (U1.4, step.9) is the one real, runnable gate script that makes oracle-invocation V0 honest. UoW: U3.2 Date: 2026-07-21 Origin: Operator directive 2026-07-21 (same dispatch shape as step.14's): draft AND execute steps/step.15.txt for U3.2 in a single pass (the step.7/9/10/11/14 convention, NOT a skeleton-split), with the SAME explicit scope boundary -- stop after U3.2, do not continue into U3.3 or any later UoW; the operator is directing P3 one UoW at a time with per-UoW checkpoints (an earlier agent this session autonomously overran its dispatched scope; this rule exists because of that). ## (0) Status EXECUTED in this pass. Not a skeleton-then-later-execute split -- app/tools/ (the package: __init__.py, errors.py, workspace.py, shell.py, gitops.py, trail.py), the config.txt allowlist key, the four tool test files + tests/conftest.py, docs/tool_surface.md, the design.md/end_to_end.md edits, and this step file were all built together, per the operator's explicit "draft AND execute (single pass)" direction. Per the operator's explicit scope boundary (Origin, above), this session STOPS after U3.2 -- U3.3 (PAL-drafted skeletons) is the next UoW but is NOT started here. ## (1) Ground truth probed at drafting (outputs -> named writers) READ design.md §2.7 in full (located by heading text, NOT by line number -- the operator's own brief warned the file has grown and old line references have drifted; confirmed: §2.7 sits at line 269 today). The delivered surface maps 1:1 onto its four bullets: file read/write + directory listing; shell execution (allowlist + workspace-scoped, timeouts, output capture); git operations (status/diff/add/commit on the working branch); oracle invocation (run a named gate tier). Plus the closing paragraph: every call policy-gated, logged, replayable; destructive ops (delete, history rewrite, pushes) require explicit human approval -- the b3u launcher's "refuse with a named next action, no --force" posture. READ design.md §2.10 (standing locks B-1..B-6) and §5 d6 (egress allowlist, LEANING -- "nothing leaves unless named"). Nothing here resolves d6 (it is an EGRESS-policy lean; this step's allowlist governs LOCAL execution) -- but the shape is deliberately consistent with it: nothing runs unless named, named in exactly one visible place. READ app/tools.py (U1.3) in full: read_file/write_file, _resolve_within, WorkspaceError, LOCK C-4-B (unconditional scope enforcement, no flag bypasses). Its own docstring names U3.2 as the step that builds the rest ("shell execution, git operations, directory listing, oracle invocation, delete") -- this step is that named extension, not a parallel invention. READ app/egress.py (U1.3) in full: record_run()'s unconditional posture (LOCK C-4-C), data_home()'s $B3UBOT_HOME convention, and the docstring's exact interim framing -- "the named file-based precursor the U1.3 done-when itself asks for, not a placeholder hiding a gap (design.md §1.2, honest interims)". The tool-call trail reuses that framing verbatim in spirit and imports data_home() DIRECTLY (C-14-E: no second data home). READ app/orchestrator/ (U3.1, step.14): the execution DAG's tool_call node kind is what this surface will one day serve -- but compiler.py sets `status = pending` and nothing schedules; confirmed NO wiring point exists yet. U3.2 delivers the surface STANDALONE; wiring is U3.5, and no orchestrator file is touched in this step. READ app/config/loader.py + config.txt (U2.2, step.11): the general `key = "value"` reader ("later we may add more b3ubot/app config parameters" -- operator, step.11). The shell allowlist is exactly such a parameter; the `shell_allowlist` key rides this existing mechanism, and the default-duplicated-in-code shape follows the banner/DEFAULT_BANNER_PATH precedent already in that file. GREP b3u sibling scripts/b3u_local.sh for the cited launcher posture: the literal wording lives in cmd_start()'s preflight comment -- "refuse with the NAMED fix; nothing is created before every check passes" -- with die() messages that each name the concrete next action ("install uv ..., then re-run", "run: make -C ..."). Every refusal message in this step's surface follows that message shape: what was refused, that nothing happened, and the named next action. READ scripts/p1_gates.sh (step.9) header: a real, runnable, bash, exit-code-is-the-verdict gate script (exits 1 on FAIL by design) -- the concrete artifact that makes oracle-invocation V0 honest, and the reason a non-zero exit must be REPORTED data, not a raised exception. READ tests/test_tools.py (U1.3) + tests/test_egress.py + tests/test_cli.py for the injected-I/O discipline (C-7-B lineage) and the B3UBOT_HOME handling patterns. Confirmed NO conftest.py existed, and that test_cli.py sets B3UBOT_HOME in only SOME tests -- see the LIVE FINDING below (what forced tests/conftest.py into scope). READ tests/test_ledger_table_roundtrip.py:: test_structural_round_trip_after_mutating_a_row + step.14 .diff.txt finding 2: the test's hardcoded todo-row example (U3.2, as of step.14) breaks the moment THIS step's own ledger dogfooding moves U3.2 to done -- the THIRD consecutive occurrence, and step.14's retro explicitly named the remedy for whoever hit it a third time (pick by STATUS query). Applied here -- see (2.9). READ app/ledger/parser.py's schema constraints (KNOWN_HEADER_ FIELDS, section-(0)-must-be-'Status', free section titles beyond (0)) -- because THIS file becomes corpus data the moment it lands (the parser's/emitter's self-inclusive corpus parametrization, +2 tests, the steps 10-14 growth pattern); its shape mirrors step.14.txt's exactly. LIVE FINDING (folded into (2.8), not silently worked around): making trail recording UNCONDITIONAL means the test suite ITSELF appends trail entries on every tools call -- and tests/test_tools.py (7 U1.3 tests) plus several tests/test_cli.py arcs call the tools layer WITHOUT setting $B3UBOT_HOME, so a plain `pytest` run would have appended dozens of entries to the developer's REAL ~/.b3ubot/tool_calls.jsonl -- test noise polluting a live audit trail. Surfaced immediately by the first full-suite run (a list_dir test also saw the data home appear inside its own workspace listing when the first conftest draft nested the home under tmp_path). Fixed structurally: tests/conftest.py (new) with an autouse fixture pointing $B3UBOT_HOME at a per-test tmp tree OUTSIDE the test's own tmp_path; per-test monkeypatch overrides (test_egress.py's env-resolution probes) still win. CONFIRMED `git log --oneline -30` for exact commit-message phrasing (the `b3ubot step.N: Ux.y -- summary` / `b3ubot step.N: add step.N.gdiff.txt via scripts/step_gdiff` two-commit shape, step.14's own precedent, f66b53e/b1a3071). CONFIRMED baseline: full suite 329 passed / 11 skipped before any edit (matches step.14's own reported final). ## (2) What U3.2 delivers 2.1 **app/tools/ becomes a package** (module -> package growth, the DECISION the operator's brief asked to be justified): U1.3's single app/tools.py held 2 operations in 46 lines; §2.7's full surface is ~6 concerns and would have made one module a grab-bag. app/ledger/ (5 modules) and app/orchestrator/ (4) already established module-per-concern packages as this repo's shape for exactly this growth; app/tools/__init__.py re-exports the entire public surface, so every existing call site (`from app.tools import WorkspaceError`, `tools.read_file(...)` in app/cli.py, tests/test_tools.py's imports) works UNCHANGED -- verified by the U1.3 tests passing without edits. app/cli.py: zero changes. 2.2 **app/tools/errors.py**: the exception family (the app/ledger/ errors.py shape). WorkspaceError MOVED here unchanged (identity, message, C-4-B contract intact); new: ToolPolicyError (base for policy refusals), ShellNotAllowlistedError, ApprovalRequiredError (destructive), GitError (a failed non-destructive git op -- RAISED, unlike shell exits, because git ops' results are data with no usable partial form). 2.3 **app/tools/workspace.py**: read_file/write_file moved intact; NEW list_dir (workspace-scoped via the same _resolve_within, sorted names, dirs suffixed '/'); NEW delete_file as the FIRST destructive refuse-stub (see 2.5's posture decision -- delete is on §2.7's own destructive list). Scope check runs FIRST, so an out-of-workspace delete refuses as the more fundamental WorkspaceError, not merely "unapproved". 2.4 **app/tools/shell.py**: `run_shell(root, argv, *, timeout=120, cwd_rel=".")` -- the NAMED allowlist (STARTING SET, justified: bash for gate scripts, pytest as the T0-oracle-in-waiting, make for this repo's Makefile, python/python3 for the venv interpreter whose basename is `python` plus the system one; git deliberately EXCLUDED so gitops.py's operation-level policy cannot be bypassed; nothing network-shaped). WHERE IT LIVES (decision): hardcoded frozen default + `shell_allowlist` key in app/config/ config.txt via loader.py's existing mechanism (step.11's precedent; one place the operator already knows) -- the override REPLACES the default so it can also NARROW. Matching on the BASENAME of argv[0] (allowlists name executables, not paths); argv is a vector, no `sh -c` string splicing surface. Mandatory positive timeout (kill + timed_out=True + captured partial output on expiry). NON-ZERO EXIT REPORTED NOT RAISED (decision, justified): p1_gates.sh's exit code IS its verdict, and U3.5's scheduler needs verdicts as data; exceptions are reserved for policy refusals, which mean the call never ran. Refusal message names the fix verbatim-style: add '' to the shell_allowlist key in app/config/config.txt. 2.5 **app/tools/gitops.py**: git_status (--porcelain) / git_diff (optional rel_path + staged=) / git_add (named paths only -- no add-everything verb) / git_commit (message-as-data, returns the new hash) -- all on the CURRENT working branch; no branch-switch verb exists. TWO-LAYER SCOPE: rel paths through _resolve_within AND the root must itself be a git repo top (root/.git exists), refusing git's own walk-up behavior (a nested non-repo workspace would otherwise silently stage into its ENCLOSING repository -- found by reasoning about git's discovery rules, proven by test). DESTRUCTIVE POSTURE (decision, justified): push / history rewrite / branch deletion exist as NAMED REFUSE-STUBS (git_push, git_rewrite_history, git_delete_branch) raising ApprovalRequiredError unconditionally -- present-but-refusing chosen over absent-by-design because §2.7's own wording ("destructive operations ... REQUIRE EXPLICIT HUMAN APPROVAL") describes gated members of the surface, not non-members; stubs make every attempt a trail-recorded event (exactly what an audit trail is for) and refuse with the named next action ("the approval flow is U3.4's territory"; for this repo push is additionally forbidden outright, B-3). The approval FLOW itself is NOT built -- U3.4's territory, no prompt exists anywhere in this step. 2.6 **app/tools/shell.py::run_gate(root, name, *, timeout=600)** -- oracle invocation V0, the brief's proposed reading CONFIRMED: T0/T1 tiers don't exist (U3.6), but scripts/p1_gates.sh (step.9) is a real, runnable gate script, so "run a named gate" = run scripts/ under the SAME allowlist/timeout/capture machinery (via bash, itself allowlist-checked -- gates get no side door). `name` must be bare (no '/', no '..'); unknown names refuse and NAME the known gates. Nothing more -- tiers, the verification DAG, and repair loops stay U3.6. 2.7 **app/tools/trail.py** -- the unconditional tool-call trail. WHERE IT LANDS (decision, justified): a SIBLING of app/egress.py, NOT an extension -- the egress trail answers B-5's "what left this machine" and tool calls are local actions that never leave; folding them in would muddy the one file whose completeness IS the B-5 answer. Same data_home() imported directly (C-14-E, no second home), different file: /tool_calls.jsonl, append-only JSONL (ts/tool/args-as-given-workspace-relative/ outcome/exit_code/detail; outcome vocabulary ok|nonzero|timeout| refused|error), egress.py's exact "named file-based precursor of the Track B ProviderRun row, not a placeholder hiding a gap" framing restated in its docstring. RECORDING IS UNCONDITIONAL (LOCK C-15-B, the C-4-C posture): every call through the surface records -- successes, non-zero exits, timeouts, errors, AND refusals (refusals are exactly what an audit trail is for); implemented once in trail.logged_call() and used by every public op, including U1.3's read_file/write_file retroactively ("every tool call is logged" means every). read_trail() is the audit/ replay reader that makes "replayable" a tested property. 2.8 **tests/conftest.py** (new): autouse per-test $B3UBOT_HOME isolation -- REQUIRED once recording became unconditional (the LIVE FINDING in (1): a plain pytest run would otherwise pollute the developer's real ~/.b3ubot trail with test noise). Also restates the C-7-B lineage: U3.2 introduces NO prompt anywhere, so nothing needed injectable input this step. 2.9 **tests** (51 new): test_tools.py +7 (list_dir x4, delete_file stub x3 -- U1.3's 7 untouched and passing); test_tools_shell.py (20: permitted runs + capture + cwd scoping, allowlist refusal with the named fix, basename-not-path matching, config override REPLACES/narrows + absent-key fallback, empty-argv/non-positive- timeout/cwd-escape refusals, timeout kills a hanging sleep in <10s, run_gate pass/fail-verdict/workspace-cwd/unknown-name/ path-shaped-name cases + the real p1_gates.sh nameability); test_tools_git.py (11: scratch-repo fixture -- git init in tmp_path, NEVER this repo -- status/diff/add/commit round-trip with hash shape + clean-tree checks, non-repo and nested- workspace walk-up refusals, add-escape refusal, empty-message/ empty-paths/nothing-staged errors, all three destructive stubs refuse + history-untouched proof + the no---force message); test_tools_trail.py (12: ordering, JSONL shape, shared data home, refused-workspace-escape/refused-allowlist/refused- destructive all RECORDED, nonzero exit code recorded, timeout recorded, run_gate logged once under its own name, no-disable- parameter introspection (C-15-B), empty-trail-reads-empty). PLUS the thrice-broken tests/test_ledger_table_roundtrip.py:: test_structural_round_trip_after_mutating_a_row converted from hardcoded-id to STATUS-QUERY pick (first row where status == 'todo') -- step.14 retro finding 2's own named remedy for whoever hit it a third time, which this step's own dogfooding (2.11) did; the break class is now impossible, not merely fixable, and the test still mutates a REAL live-table row. 2.10 **docs/tool_surface.md** (new): the live-inventory write-up -- package diagram (ASCII), the allowlist table + config mechanics, the posture summary (timeouts, exit-as-data, git two-layer scope, destructive stubs, trail format), test-coverage map. 2.11 **design.md §2.7**: a short *Delivered state* paragraph appended (the section's four bullets and closing paragraph untouched -- they still describe the design accurately); it names the two honest interims (trail = file-based ProviderRun precursor until Track B; approval = unconditional refuse-stubs until U3.4). **end_to_end.md**: §5 U3.2 row gains the DONE step.15 clause in U3.1's exact row style; §11 ledger row -> done/step.15 THROUGH the existing `ledger` CLI (dogfooding: `ledger transition U3.2 active --step step.15` then `ledger close U3.2 PENDING`; hash backfilled in the gdiff commit, step.14's shape). EXPLICITLY OUT OF SCOPE (named here per the operator's brief, not silently built -- and NOT continued into after this step, per the operator's explicit stop-here scope boundary): - U3.3 SKELETON flow: PAL-drafted step files. -> next - U3.4 Ratify gate + amendment loop -- INCLUDING the interactive destructive-op approval UI; today's stubs refuse unconditionally and prompt for nothing. -> later - U3.5 EXECUTING loop / scheduler -- wiring these tools to the execution DAG's tool_call nodes. The surface is standalone. -> later - U3.6 VERIFYING: real T0/T1 gate tiers, the verification-kind DAG, the bounded repair loop -- run_gate stays a named-script runner until then. -> later - U3.7/U3.8, Track B / P4 (the real ProviderRun row). -> later - Resolving d6: the egress policy remains a LEAN; this step's allowlist governs local execution and is merely consistent with d6's shape. -> open ## (3) Verification gates G1 All U1.3 tests in tests/test_tools.py pass UNCHANGED against the package (the module-to-package growth broke no call site; app/cli.py needed zero edits) -- verified live. G2 tests/test_tools_shell.py 20/20 PASS live: allowlisted runs capture stdout/stderr with exit-code-as-data; 'curl' refuses naming shell_allowlist + app/config/config.txt; the config override replaces AND narrows; a 30s sleep under timeout=0.5 is killed and reported timed_out in well under 10s; run_gate runs a named scripts/ gate, reports a failing gate's exit-1 verdict without raising, and refuses path-shaped names. G3 tests/test_tools_git.py 11/11 PASS live on a SCRATCH repo (git init under tmp_path -- this repo's .git untouched): status/diff/add/commit round-trip with a 40-hex hash and a clean tree after; the nested-workspace walk-up refusal; all three destructive stubs refuse with the U3.4-naming, no---force message and touch nothing (HEAD compared before/after). G4 tests/test_tools_trail.py 12/12 PASS live: every outcome kind recorded (ok, nonzero+exit_code, timeout, refused x3 flavors); refusals carry the refusal text in detail; run_gate logs once under its own name; the no-disable-parameter introspection gate (C-15-B); JSONL one-object-per-line under the SHARED data_home(). G5 design.md §2.7's own closing sentence, LITERALLY: every tool call policy-gated (G2/G3's refusal legs), logged (G4), replayable (read_trail round-trips every recorded entry); destructive ops require approval (G3's stubs -- 'require' realized as unconditional-refusal-until-U3.4, the honest interim named in the delivered-state paragraph). G6 Full suite before this step's edits: 329 passed / 11 skipped (confirmed live, matching step.14's reported final). After: 380p/11s pre-step-file; 382p/11s once this step.15.txt lands in the corpus (+51 tool tests, +2 self-inclusive corpus pickups -- the steps 10-14 growth pattern). Zero regressions; ONE pre-existing test intentionally changed (not loosened): the thrice-broken roundtrip example converted to the status- query pick its own history demanded (2.9) -- it still exercises a real live-table row every run. G7 Scope boundary held: `grep -rn "U3\.[3-8]" app/tools/` -- every hit is a scope-boundary annotation (naming U3.4/U3.5/U3.6 as the owner of what is NOT built here), never an implementation: no PAL-drafting code, no prompt/approval UI (no input() call anywhere in app/tools/), no scheduler wiring (no orchestrator import anywhere in app/tools/), no gate-tier vocabulary -- confirmed live before committing. This session stops here; U3.3 is not started. G8 §11 ledger row updated THROUGH the existing `ledger` CLI (transition -> active --step step.15, close -> done/PENDING), against the REAL end_to_end.md -- dogfooding, step.13/14's precedent. G9 b3ubot porcelain clean after each commit; retro pair via the SHARED v3_claude/scripts/step_gdiff (steps/step.15.gdiff.txt); exactly 2 commits (work + gdiff-with-hash-backfill-folded-in), step.7/9/10/11/13/14's precedent. ## (4) LOCKs C-15-A b3ubot porcelain clean after each commit. C-15-B TOOL-CALL RECORDING IS UNCONDITIONAL (the C-4-C posture, applied to the tool surface): every call through app/tools/ -- successes, non-zero exits, timeouts, errors, AND refusals -- lands in the trail; no caller, flag, or future extension may skip it (G4's introspection gate is the regression detector). C-15-C NO SILENT SCOPE CREEP: U3.3-U3.8 named out of scope in (2) and confirmed not started (G7); the operator's explicit "stop after U3.2" direction is honored. d6 remains a LEAN. C-15-D DESTRUCTIVE MEANS REFUSED, NOT ABSENT, NOT PROMPTED: delete_file / git_push / git_rewrite_history / git_delete_branch raise ApprovalRequiredError unconditionally and touch nothing; NO interactive approval path exists in this step (that is U3.4's lock to replace, not extend around). No input() anywhere in app/tools/. C-15-E ONE POLICY HOME PER CONCERN: the shell allowlist lives in code-default + app/config/config.txt's `shell_allowlist` key ONLY (no env var, no per-call allowlist= parameter -- a call-site override would be a policy bypass); git's only path is gitops.py's named operations (git deliberately not on the shell allowlist). C-15-F C-4-B inherited intact: _resolve_within guards EVERY path-taking op in the package (old and new), and the git root must itself be a repo top (no walk-up). C-14-E inherited: data_home() imported from app/egress.py, never reimplemented -- one data home for the whole repo. C-15-G standing locks inherited: B-3 (no disclosure/no push -- and git_push refuses unconditionally anyway); B-6 (deterministic pytest oracles gate this step); B-4/B-5 untouched (no provider call, no egress-path change; the trail is local-actions-only by design). ## (5) Open questions Q-15-A Should the trail rotate/segment (per-day files, size caps)? LEAN: not yet -- volumes are tiny (a full suite run writes a few hundred small lines into a throwaway test home), and the real answer arrives with Track B's ProviderRun row, which replaces this file's role wholesale. Revisit if a long-lived dev home's trail measurably grows before P4. Q-15-B Should run_shell env-filter (pass a scrubbed environment rather than inheriting)? B-4 says credentials are env-only -- inherited env is how the venv/pytest legitimately work, but it also hands any child process the provider keys. LEAN: revisit at U3.5 (when PROVIDER-DRIVEN tool_call nodes, rather than the operator's own gates, start running commands) -- that is the moment inherited env becomes a provider-reachable surface and B-5-adjacent; today every caller is the developer or the test suite. Q-15-C Should git_commit enforce the repo's own message conventions (the step-trail phrasing)? LEAN: no -- conventions are the orchestrator's/operator's voice, not the tool layer's; the tool stays mechanism (C-15-E's one-concern-per-home logic applied to prose). ## (6) Acceptance - [x] app/tools/ package; every U1.3 call site works unchanged (app/cli.py zero edits) (2.1, G1). - [x] list_dir + delete_file refuse-stub, C-4-B-scoped (2.3, G1). - [x] run_shell: named allowlist (default bash/make/pytest/python/ python3; config.txt `shell_allowlist` override), mandatory timeouts, capture, exit-as-data, named-fix refusals (2.4, G2). - [x] gitops: status/diff/add/commit on a verified repo root; push/rewrite/branch-delete as unconditional refuse-stubs naming U3.4 (2.5, G3, C-15-D). - [x] run_gate: named-gate-script V0 under the same machinery (2.6, G2). - [x] trail.py: unconditional JSONL trail, refusals included, sibling-of-egress, read_trail replay reader (2.7, G4, C-15-B). - [x] tests/conftest.py hermetic $B3UBOT_HOME (the live finding) (2.8). - [x] 51 new tests across 4 files; thrice-broken roundtrip test converted to status-query pick; zero regressions (329p/11s -> 380p/11s -> 382p/11s with this file) (2.9, G6). - [x] docs/tool_surface.md; design.md §2.7 delivered-state note; end_to_end.md §5 row + §11 ledger via the `ledger` CLI (2.10, 2.11, G8). - [x] Scope boundary held: U3.3-U3.8 not started, d6 unresolved (G7, C-15-C) -- this session stops after U3.2. - [x] Retro pair: this file + step.15.diff.txt + step.15.gdiff.txt via the SHARED scripts/step_gdiff (G9). - [x] Exactly 2 commits (work + gdiff-with-backfill), not 3 (G9). ## (7) Hash backfill WORK commit (app/tools/ + config + tests + docs + design.md/end_to_end.md + this file): e6544fa GDIFF commit (step.15.gdiff.txt via scripts/step_gdiff): (HEAD) End of step.