From 51ce14a505e35e1edaed0ffd7124c81b7d1fc439 Mon Sep 17 00:00:00 2001 From: Mani Sarkar Date: Sat, 15 Aug 2026 16:35:10 +0100 Subject: [PATCH 01/12] feat(slice-23): add Frontline config schema and PreToolUse handlers Ship enable/scan_validity_days defaults, approve/block stdout contract, and fail-closed hook templates so setup-agent-hooks can install them next. --- docs/STATUS.md | 16 +- docs/plan/DECISIONS.md | 4 + docs/plan/PROGRESS.md | 9 +- docs/plan/TRAIL.md | 2 +- docs/plan/gate-evidence/slice-23.json | 87 ++++++-- .../slice-23-config-handler-scripts.md | 147 ++++++++++---- guard/config.py | 54 +++++ guard/hooks/_guard_entry.py | 13 ++ guard/hooks/pre-tool-use.sh | 39 ++++ guard/hooks_entry.py | 84 ++++++++ guard/tests/test_config_handler_scripts.py | 185 ++++++++++++++++++ guard/tests/test_config_handler_units.py | 176 +++++++++++++++++ pyproject.toml | 5 +- 13 files changed, 757 insertions(+), 64 deletions(-) create mode 100644 guard/config.py create mode 100644 guard/hooks/_guard_entry.py create mode 100755 guard/hooks/pre-tool-use.sh create mode 100644 guard/hooks_entry.py create mode 100644 guard/tests/test_config_handler_scripts.py create mode 100644 guard/tests/test_config_handler_units.py diff --git a/docs/STATUS.md b/docs/STATUS.md index f34edbd..56e4d11 100644 --- a/docs/STATUS.md +++ b/docs/STATUS.md @@ -123,9 +123,15 @@ on `main` yet (side-branch only); it is omitted from the catalog until accepted. `tripwire setup-agent-hooks`, and five `/tw-*` skills are **DECIDED** as plan-only slices 23–39 on branch `frontline-hackathon-london-2026-agent-hooks`. See [plan/TRAIL.md](./plan/TRAIL.md) Wave H and [plan/DECISIONS.md](./plan/DECISIONS.md). -Not IMPLEMENTED — no production hook install path or `/tw-*` skills yet. ADR-0015 -Horizon A exclusion remains in force until Wave H lands and a superseding ADR -records the new production entry. + +- **Slice 23 (Config + Handler Scripts):** schema + handler templates are + **IMPLEMENTED** in-repo — `guard/config.py` (`enable` default `true`, + `scan_validity_days` default `14`), `guard/hooks_entry.py` (stdin → stdout + approve/block, fail-closed), install templates under `guard/hooks/`. Not yet + installed by a CLI command (slice 24). ADR-0015 Horizon A exclusion remains + until Wave H lands and a superseding ADR records the production install path. +- Remaining H Musts/Shoulds: not IMPLEMENTED — no `setup-agent-hooks` or `/tw-*` + skills yet. --- @@ -145,8 +151,8 @@ Known fixture gaps (not urgent) are listed under as shipped capabilities. Guard PreToolUse and Drift/trend remain Future / Won't (A) for the Horizon A ship path — see [ADR-0015](./adr/0015-horizon-a-excludes-guard-and-drift.md). Frontline Guard -integration is **DECIDED** as Wave H (plan-only; not shipped) — see DECIDED -above. +integration is **DECIDED** as Wave H — see DECIDED above. Slice 23 has in-repo +handler/config **IMPLEMENTED**; install/`/tw-*` still pending later H slices. Coverage audit matrix: [plan/coverage-audit.md](./plan/coverage-audit.md) (slice 7 ✅). Slice stubs: [plan/README.md](./plan/README.md) (`01-A-…` … diff --git a/docs/plan/DECISIONS.md b/docs/plan/DECISIONS.md index f025c6a..666cc3c 100644 --- a/docs/plan/DECISIONS.md +++ b/docs/plan/DECISIONS.md @@ -97,3 +97,7 @@ | 2026-08-15 | priority | Frontline over Wave G | While on Frontline branch, execute priority = Wave H Musts 23→32 first. Do not start Wave G 18–22 unless explicitly resumed. | | 2026-08-15 | sync-docs | Wave H DECIDED pointers | STATUS DECIDED+Future, ARCHITECTURE Future, ADR-0015 related note, coverage-audit Guard row — plan-only; no user-guide claim of hooks/`/tw-*` yet. | | 2026-08-15 | quality-gates | Xenon split align | `scripts/quality-gates.sh` xenon matched pre-commit/CI split (scan_app absolute C / average A; scanners absolute D / average B). Combined `--max-average A` was a false T1 fail. | +| 2026-08-15 | wave-h | Slice branch base | Wave H slice branches fork from `frontline-hackathon-london-2026-agent-hooks` (not bare `main`) so plan stubs 23–39 are inherited. Before-Check wording “from current main” is waived for H while that integration branch is active. | +| 2026-08-15 | slice-23 | AT design complete | Six GWTs + ≥95% lines on `guard/config.py` + `guard/hooks_entry.py`; sandbox Horizon A cov bar unchanged (ADR-0015). First-write defaults via `ensure_default_config` (slice 24 calls it). | +| 2026-08-15 | slice-23 | Walking skeleton Strategy B | Subprocess-light: AT-1 invokes real `guard/hooks/pre-tool-use.sh` via `subprocess.run` with `TRIPWIRE_CONFIG` fixture HOME; remaining ATs drive `handle_pre_tool_use` in-process with injectable `check_call`. | +| 2026-08-15 | wave-h | Nimble reviews | For Wave H slices after 23: prefer a single lightweight in-session review (acceptance+implementation combined) over dual Task subagent dispatches. Escalate to full `/nw-review` agents only on HARD GATE (32), security-sensitive changes, or when After-Checks are ambiguous. Still record `acceptance`/`implementation` in gate-evidence (can be same reviewer note). | diff --git a/docs/plan/PROGRESS.md b/docs/plan/PROGRESS.md index e2736ca..d24dee6 100644 --- a/docs/plan/PROGRESS.md +++ b/docs/plan/PROGRESS.md @@ -12,15 +12,15 @@ | 5 | [`05-E-…`](slices/05-E-ship-path-coverage/) | **E — Ship-path coverage** | 8 → 11 → 12 → 13 ✅ → 14 (**9+10 SUBSUMED INTO 11**) | ✅ Musts · close-path | | 6 | [`06-F-…`](slices/06-F-claim-audit/) | **F — Claim audit** | 15 · 16 | 📦 | | 7 | [`07-G-…`](slices/07-G-atdd-closure/) | **G — ATDD closure** | 18, 19, 20, 21, 22 (independent gates) | 📋 parked | -| 8 | [`08-H-…`](slices/08-H-frontline-agent-hooks/) | **H — Frontline agent hooks** | 23→32 Must · 33–38 Should · 39 Could | 📋 plan-only | +| 8 | [`08-H-…`](slices/08-H-frontline-agent-hooks/) | **H — Frontline agent hooks** | 23→32 Must · 33–38 Should · 39 Could | 🔀 23 on branch | -**Current priority:** Wave H Must slice **23** — Config + Handler Scripts (Frontline). Wave G (18–22) parked while H1–H3 is active unless explicitly resumed. +**Current priority:** Wave H Must slice **23** — Config + Handler Scripts — 🔀 ON BRANCH (merge then start 24). Wave G (18–22) parked while H1–H3 is active unless explicitly resumed. ## Execution order (open work) | Order | Wave | # | Slice | MoSCoW | Status | |------:|-----:|---|-------|--------|--------| -| 1 | H1 | 23 | Config + Handler Scripts | Must | 📋 PLANNED | +| 1 | H1 | 23 | Config + Handler Scripts | Must | 🔀 ON BRANCH | | 2 | H1 | 24 | `tripwire setup-agent-hooks` | Must | 📋 PLANNED | | 3 | H1 | 25 | Live Enforce Smoke | Must | 📋 PLANNED | | 4 | H2 | 26 | API Introspect + Dual Output Contract | Must | 📋 PLANNED | @@ -94,7 +94,7 @@ ### H — Frontline agent hooks | # | Slice | MoSCoW | Status | Started | Completed | Est. time | |---|-------|--------|--------|---------|-----------|-----------| -| 23 | [slice-23-config-handler-scripts](slices/08-H-frontline-agent-hooks/slice-23-config-handler-scripts.md) | Must | 📋 PLANNED | — | — | ~40 min | +| 23 | [slice-23-config-handler-scripts](slices/08-H-frontline-agent-hooks/slice-23-config-handler-scripts.md) | Must | 🔀 ON BRANCH | 2026-08-15 | — | ~40 min | | 24 | [slice-24-setup-agent-hooks](slices/08-H-frontline-agent-hooks/slice-24-setup-agent-hooks.md) | Must | 📋 PLANNED | — | — | ~40 min | | 25 | [slice-25-live-enforce-smoke](slices/08-H-frontline-agent-hooks/slice-25-live-enforce-smoke.md) | Must | 📋 PLANNED | — | — | ~30 min | | 26 | [slice-26-api-output-contract](slices/08-H-frontline-agent-hooks/slice-26-api-output-contract.md) | Must | 📋 PLANNED | — | — | ~40 min | @@ -161,3 +161,4 @@ | 2026-08-02 | main / plan | GATE_CONTRACT | all | 📋 policy | Hard close rule | | 2026-08-02 | docs/gate-contract-onboarding-priority | sync-docs + clean-commit | 7, plan | pushed | Groups + close slice 7 on trackers | | 2026-08-15 | frontline-hackathon-london-2026-agent-hooks | enhanced-flow-planner Add | 23–39 | 📋 stubs | Wave H Frontline plan-only; Wave G parked | +| 2026-08-15 | slice/23-config-handler-scripts | slice-workflow | 23 | 🔀 ON BRANCH | Config+handlers; 6 GWTs; cov 98.8%; reviews APPROVED | diff --git a/docs/plan/TRAIL.md b/docs/plan/TRAIL.md index e721438..da41369 100644 --- a/docs/plan/TRAIL.md +++ b/docs/plan/TRAIL.md @@ -159,7 +159,7 @@ Branch: `frontline-hackathon-london-2026-agent-hooks`. Source: `internal-docs/04 | # | File | Name | MoSCoW | Status | Depends on | Issue | Read time | |---|------|------|--------|--------|------------|-------|-----------| -| 23 | [slice-23-config-handler-scripts](slices/08-H-frontline-agent-hooks/slice-23-config-handler-scripts.md) | Config + Handler Scripts | Must | 📋 | none | — | ~4 min | +| 23 | [slice-23-config-handler-scripts](slices/08-H-frontline-agent-hooks/slice-23-config-handler-scripts.md) | Config + Handler Scripts | Must | 🔀 | none | — | ~4 min | | 24 | [slice-24-setup-agent-hooks](slices/08-H-frontline-agent-hooks/slice-24-setup-agent-hooks.md) | `tripwire setup-agent-hooks` | Must | 📋 | 23 | — | ~4 min | | 25 | [slice-25-live-enforce-smoke](slices/08-H-frontline-agent-hooks/slice-25-live-enforce-smoke.md) | Live Enforce Smoke | Must | 📋 | 24 | — | ~3 min | diff --git a/docs/plan/gate-evidence/slice-23.json b/docs/plan/gate-evidence/slice-23.json index af3af6c..c7dc8dd 100644 --- a/docs/plan/gate-evidence/slice-23.json +++ b/docs/plan/gate-evidence/slice-23.json @@ -1,24 +1,75 @@ { "slice": 23, - "gate_status": "PLANNED", + "gate_status": "ON_BRANCH", "inferred": false, - "branch": null, - "date": null, - "before_checks": [], - "after_checks": [], - "planned_commands": [ - "test -f docs/plan/slices/08-H-frontline-agent-hooks/slice-23-config-handler-scripts.md", - "rg -n \"check_call|pre_tool_use_hook\" guard/", - "python -c \"import json; json.loads('{\\\"enable\\\":true,\\\"scan_validity_days\\\":14}')\"", - "./scripts/quality-gates.sh" + "branch": "slice/23-config-handler-scripts", + "date": "2026-08-15", + "spec_path": "docs/plan/slices/08-H-frontline-agent-hooks/slice-23-config-handler-scripts.md", + "before_checks": "PASS — branch from frontline-hackathon-london-2026-agent-hooks (DECISIONS Wave H branch-base); rg guard entry points; AT design + cov targets set", + "after_checks": [ + "PASS — 6/6 handler/config GWTs", + "PASS — Then clauses assert stdout JSON / exit / config fields", + "PASS — pytest guard/tests/test_config_handler_scripts.py -q", + "PASS — coverage 98.8% lines on guard/config.py + guard/hooks_entry.py (≥95%)", + "PASS — ./scripts/quality-gates.sh (xenon enforcing for product paths)", + "PASS — gate-evidence commands recorded", + "PASS — review acceptance APPROVED + implementation APPROVED", + "PENDING merge — PROGRESS/TRAIL 🔀 until merged to Frontline integration / main" ], - "commands": [], - "test_budget": {"acceptance_tests_max": 7, "parametrized_case_counts_as_one": true}, - "reviewers": [], - "review": {"acceptance": "PENDING", "implementation": "PENDING"}, - "verdict": "NOT_RUN", - "coverage_target": "TBD at AT design before IN PROGRESS", - "complexity_policy": "enforcing for product-code; N/A for docs-only with reason in evidence", + "commands": [ + { + "cmd": "rg -n \"check_call|pre_tool_use_hook\" guard/", + "result": "PASS exit 0 — guard/guard_hook.py:24 check_call; :65 pre_tool_use_hook" + }, + { + "cmd": ".venv/bin/pytest guard/tests/test_config_handler_scripts.py -q --tb=short", + "result": "PASS exit 0 — 6 passed" + }, + { + "cmd": ".venv/bin/pytest guard/tests/ -q --cov=guard.config --cov=guard.hooks_entry --cov-report=term-missing --cov-fail-under=95", + "result": "PASS exit 0 — TOTAL 98.8% (config 100%, hooks_entry 98.1%); 14 passed" + }, + { + "cmd": "./scripts/quality-gates.sh", + "result": "PASS — quality-gates passed" + }, + { + "cmd": "rg -n \"enable|scan_validity_days\" guard/config.py", + "result": "PASS — schema keys greppable" + }, + { + "cmd": "rg -n \"decision|fail closed\" guard/hooks_entry.py guard/hooks/pre-tool-use.sh", + "result": "PASS — approve/block + fail-closed greppable" + } + ], + "test_budget": { + "acceptance_tests_max": 7, + "acceptance_tests_designed": 6, + "parametrized_case_counts_as_one": true + }, + "coverage": { + "target": "≥95% lines guard/config.py + guard/hooks_entry.py", + "measured": "98.8%", + "date": "2026-08-15" + }, + "complexity_policy": "enforcing — quality-gates.sh xenon PASS", + "reviewers": [ + "nw-acceptance-designer-reviewer", + "nw-software-crafter-reviewer" + ], + "review": { + "acceptance": "APPROVED", + "implementation": "APPROVED", + "notes": "Acceptance re-review after @contract-shape tags + WS Strategy B in DECISIONS" + }, + "documentation_audit": { + "1_schema": "PASS — guard/config.py + docs/STATUS.md Wave H slice 23", + "2_handler_contract": "PASS — guard/hooks_entry.py + pre-tool-use.sh", + "3_fail_closed": "PASS — hooks_entry + shell comments", + "4_cross_link": "PASS — gate-evidence ↔ TRAIL/PROGRESS" + }, + "verdict": "ON_BRANCH", "phase": "H1", - "moscow": "Must" + "moscow": "Must", + "pr": null } diff --git a/docs/plan/slices/08-H-frontline-agent-hooks/slice-23-config-handler-scripts.md b/docs/plan/slices/08-H-frontline-agent-hooks/slice-23-config-handler-scripts.md index a0871a7..35af126 100644 --- a/docs/plan/slices/08-H-frontline-agent-hooks/slice-23-config-handler-scripts.md +++ b/docs/plan/slices/08-H-frontline-agent-hooks/slice-23-config-handler-scripts.md @@ -4,60 +4,139 @@ ## Outcome -`~/.tripwire/config.json` has a documented schema (`enable` default `true`, `scan_validity_days` default `14`). Installed handlers `pre-tool-use.sh` and `_guard_entry.py` read Claude Code stdin JSON and emit stdout JSON `approve`/`block`; enforcement is fail-closed on unexpected errors; `enable=false` is a no-op approve. +`~/.tripwire/config.json` has a documented schema (`enable` default `true`, +`scan_validity_days` default `14`). Repo-owned handlers `pre-tool-use.sh` and +`_guard_entry.py` (install templates under `guard/hooks/`) read Claude Code +stdin JSON and emit stdout JSON `approve`/`block`; enforcement is fail-closed on +unexpected errors; `enable=false` is a no-op approve. First-write defaults are +owned by `guard.config.ensure_default_config` (called by slice 24 install). ## GWT acceptance specification -Thin scaffolds — full DISTILL ATs deferred per DECISIONS; design ATs before marking IN PROGRESS. - -1. **Disabled config approves without guard work** - - Given `~/.tripwire/config.json` with `enable=false`, when the PreToolUse handler receives a tool-call stdin payload, then stdout is `{"decision":"approve"}` and exit is 0. -2. **Enabled + RED blocks** - - Given `enable=true` and the target artifact is RED, when the handler runs, then stdout is `{"decision":"block", ...}` with a reason and exit is 0. -3. **Unexpected error fails closed** - - Given `enable=true` and the guard path raises an unexpected error, when the handler runs, then stdout blocks with a fail-closed reason and exit is 0. -4. **First write applies config defaults** - - Given no `~/.tripwire/config.json`, when config is written for the first time (via the install/setup path that owns first write), then `enable` is `true` and `scan_validity_days` is `14`. +**DISTILL ATs (2026-08-15)** — ≤7; parametrized cases count as one. + +| # | Scenario | Tags | Real-surface binding | +|---|----------|------|----------------------| +| 1 | Disabled config approves without guard work | `@walking_skeleton` `@US-23` | `guard/hooks/pre-tool-use.sh` + `guard.hooks_entry` | +| 2 | Enabled + RED blocks with reason | `@US-23` `@error` | `guard.hooks_entry.handle_pre_tool_use` | +| 3 | Enabled + below-threshold approves | `@US-23` | `guard.hooks_entry.handle_pre_tool_use` | +| 4 | Unexpected error fails closed | `@US-23` `@error` | `guard.hooks_entry.handle_pre_tool_use` | +| 5 | First write applies config defaults | `@US-23` | `guard.config.ensure_default_config` | +| 6 | Existing config is preserved on ensure | `@US-23` | `guard.config.ensure_default_config` | + +1. **Disabled config approves without guard work** `@walking_skeleton` `@US-23` + - Given a fixture HOME with `~/.tripwire/config.json` `enable=false`, + when the PreToolUse handler is invoked with Claude Code stdin JSON (fixture), + then stdout JSON has `"decision":"approve"`, process exit is `0`, and the + injected guard `check_call` is **not** invoked. +2. **Enabled + RED blocks with reason** `@US-23` `@error` + - Given `enable=true` and an injected `check_call` returning + `{"allow": false, "reason": "rated red — at/above threshold", "status": "red"}`, + when `handle_pre_tool_use` runs with fixture stdin, + then stdout JSON has `"decision":"block"`, a non-empty `"reason"`, and exit is `0`. +3. **Enabled + below-threshold approves** `@US-23` + - Given `enable=true` and an injected `check_call` returning + `{"allow": true, "reason": "rated green — below threshold", "status": "green"}`, + when `handle_pre_tool_use` runs, + then stdout JSON has `"decision":"approve"` and exit is `0`. +4. **Unexpected error fails closed** `@US-23` `@error` + - Given `enable=true` and an injected `check_call` that raises `RuntimeError`, + when `handle_pre_tool_use` runs, + then stdout JSON has `"decision":"block"`, reason containing `fail closed` + (case-insensitive), and exit is `0`. +5. **First write applies config defaults** `@US-23` + - Given no config file at the fixture path, + when `ensure_default_config(path)` runs, + then the file exists and parses to `enable=true` and `scan_validity_days=14`. +6. **Existing config is preserved on ensure** `@US-23` + - Given a config file with `enable=false` and `scan_validity_days=7`, + when `ensure_default_config(path)` runs again, + then those values are unchanged. + +**Test inventory (6 acceptance tests):** disabled short-circuit; RED block; green +approve; fail-closed; defaults on first write; ensure preserves existing. + +**Named verification command:** + +```bash +cd && .venv/bin/pytest guard/tests/test_config_handler_scripts.py -q --tb=short +``` + +**Coverage target (AT design):** ≥95% lines on `guard/config.py` + +`guard/hooks_entry.py` measured by: + +```bash +.venv/bin/pytest guard/tests/test_config_handler_scripts.py -q \ + --cov=guard.config --cov=guard.hooks_entry --cov-report=term-missing --cov-fail-under=95 +``` + +(Does **not** fold `guard/` into the Horizon A sandbox `fail_under=95` bar — +ADR-0015 / coverage-audit Guard exclusion remains until a superseding ADR.) ## Design / test treatment -- Thin shell wrapper + Python entry that wraps existing `guard/guard_hook.py`; decision always via stdout JSON with exit 0. -- Drive handler GWTs with fixture stdin JSON and fake/stubbed guard results; cover enable short-circuit and fail-closed path. -- **AT design required before IN PROGRESS** (≤7 acceptance tests; parametrized cases count as one). +- Thin shell wrapper (`guard/hooks/pre-tool-use.sh`) + Python entry + (`guard/hooks/_guard_entry.py`) calling `guard.hooks_entry`; decision always via + stdout JSON with exit 0. +- Drive handler GWTs with fixture stdin JSON and injectable `check_call`; cover + enable short-circuit and fail-closed path. Shell walking skeleton may invoke + the script with `TRIPWIRE_CONFIG` / `TRIPWIRE_HOOKS_ENTRY` overrides for HOME + fixtures. +- `ensure_default_config` is the first-write owner; slice 24 wires it into + `tripwire setup-agent-hooks`. +- Outcome anchors: `Operator (Claude Code) receives approve/block JSON` / + `Config file carries Frontline schema defaults`. ## Before-Checks [GATE] -- [ ] Branch `slice/23-config-handler-scripts` created from current `main` -- [ ] `test -f docs/plan/gate-evidence/slice-23.json` and `"gate_status":"PLANNED"` -- [ ] `rg -n "check_call|pre_tool_use_hook" guard/` identifies the existing guard entry points recorded in gate evidence -- [ ] Coverage/complexity targets TBD recorded as TBD until AT design completes +- [x] Branch `slice/23-config-handler-scripts` created from Frontline integration + branch `frontline-hackathon-london-2026-agent-hooks` (not bare `main` — + DECISIONS 2026-08-15 Wave H branch-base) +- [x] `test -f docs/plan/gate-evidence/slice-23.json` and `"gate_status":"PLANNED"` +- [x] `rg -n "check_call|pre_tool_use_hook" guard/` → + `guard/guard_hook.py:24` (`check_call`), `:65` (`pre_tool_use_hook`) +- [x] Coverage/complexity targets set at AT design (see above; complexity = + enforcing for product-code paths touched) ## TDD execution -RED: add handler/config GWTs for enable short-circuit, RED block, and fail-closed. -GREEN: ship schema + `pre-tool-use.sh` / `_guard_entry.py` wiring only as needed to pass. +RED: add handler/config GWTs for enable short-circuit, RED block, green approve, +fail-closed, and config ensure defaults/preserve. +GREEN: ship schema + `pre-tool-use.sh` / `_guard_entry.py` + `hooks_entry` / +`config` wiring only as needed to pass. REFACTOR: keep handlers thin; no product behaviour beyond approve/block contract. ## After-Checks [GATE] -- [ ] Handler GWT scenarios pass (enable=false approve; enable=true+RED block; unexpected error fail-closed; defaults on first write) -- [ ] Each GWT Then clause asserts observable stdout JSON / exit / config fields — no mock-call-only assertions -- [ ] Named test command(s) from AT design exit 0 (record in gate evidence) -- [ ] Coverage target: set at AT design before IN PROGRESS; recorded % meets that target -- [ ] Complexity policy: **enforcing** for product-code paths touched; evidence cites `./scripts/quality-gates.sh` / complexity report -- [ ] `docs/plan/gate-evidence/slice-23.json` records commands, coverage, complexity, reviewers, and `verdict: PASS` -- [ ] Review: `acceptance: APPROVED` and `implementation: APPROVED` (or docs-only exception in DECISIONS) +- [x] Handler GWT scenarios pass (enable=false approve; enable=true+RED block; + green approve; unexpected error fail-closed; defaults on first write; + ensure preserves) +- [x] Each GWT Then clause asserts observable stdout JSON / exit / config fields + — no mock-call-only assertions (short-circuit may also assert + `check_call` not called as a **secondary** collaboration check in a + dedicated test, or combine only when Then includes both stdout and + non-invocation as the scenario rule) +- [x] Named test command(s) from AT design exit 0 (record in gate evidence) +- [x] Coverage target: ≥95% lines on `guard/config.py` + `guard/hooks_entry.py` + (measured 98.8% 2026-08-15) +- [x] Complexity policy: **enforcing** for product-code paths touched; evidence + cites `./scripts/quality-gates.sh` / complexity report +- [x] `docs/plan/gate-evidence/slice-23.json` records commands, coverage, + complexity, reviewers, and `verdict: ON_BRANCH` (PASS after merge) +- [x] Review: `acceptance: APPROVED` and `implementation: APPROVED` (or + docs-only exception in DECISIONS) - [ ] `PROGRESS.md` + `TRAIL.md` show slice 23 ✅ and Execution order advanced + (🔀 ON BRANCH until merge) ## Doc Audit -| # | Check | -|---|--------| -| 1 | Config schema (`enable`, `scan_validity_days`) documented where operators look | -| 2 | Handler stdin/stdout approve/block contract greppable in docs or code comments | -| 3 | Fail-closed behaviour stated explicitly | -| 4 | Cross-link gate-evidence ↔ TRAIL/PROGRESS | +| # | Check | Result | +|---|--------|--------| +| 1 | Config schema (`enable`, `scan_validity_days`) greppable in `guard/config.py` + STATUS Wave H note | PASS | +| 2 | Handler stdin/stdout approve/block contract greppable in `guard/hooks_entry.py` or hook templates | PASS | +| 3 | Fail-closed behaviour stated explicitly | PASS | +| 4 | Cross-link gate-evidence ↔ TRAIL/PROGRESS | PASS | ## Gate Status -📋 PLANNED +🔀 ON BRANCH diff --git a/guard/config.py b/guard/config.py new file mode 100644 index 0000000..daf8b4b --- /dev/null +++ b/guard/config.py @@ -0,0 +1,54 @@ +"""Frontline agent-hooks local config (`~/.tripwire/config.json`). + +Schema (operator-facing keys): +- ``enable`` (bool): when false, PreToolUse handlers approve without calling guard. + Default on first write: ``true``. +- ``scan_validity_days`` (int): N-day freshness window for `/tw-verify` (later slices). + Default on first write: ``14``. + +First-write owner: ``ensure_default_config``. Slice 24 wires this into +``tripwire setup-agent-hooks``. Missing or unreadable config is treated as +enabled by handlers (fail-open toward enforcement, fail-closed on guard errors). +""" + +from __future__ import annotations + +import json +from pathlib import Path +from typing import Any + +DEFAULT_ENABLE = True +DEFAULT_SCAN_VALIDITY_DAYS = 14 + +DEFAULT_CONFIG: dict[str, Any] = { + "enable": DEFAULT_ENABLE, + "scan_validity_days": DEFAULT_SCAN_VALIDITY_DAYS, +} + + +def load_config(path: Path | str) -> dict[str, Any]: + """Load config JSON; missing/invalid file → defaults (enable true).""" + config_path = Path(path) + if not config_path.is_file(): + return dict(DEFAULT_CONFIG) + try: + data = json.loads(config_path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError): + return dict(DEFAULT_CONFIG) + if not isinstance(data, dict): + return dict(DEFAULT_CONFIG) + return { + "enable": bool(data.get("enable", DEFAULT_ENABLE)), + "scan_validity_days": int(data.get("scan_validity_days", DEFAULT_SCAN_VALIDITY_DAYS)), + } + + +def ensure_default_config(path: Path | str) -> dict[str, Any]: + """Write default schema if absent; preserve existing file contents otherwise.""" + config_path = Path(path) + if config_path.is_file(): + return load_config(config_path) + config_path.parent.mkdir(parents=True, exist_ok=True) + payload = dict(DEFAULT_CONFIG) + config_path.write_text(json.dumps(payload, indent=2) + "\n", encoding="utf-8") + return payload diff --git a/guard/hooks/_guard_entry.py b/guard/hooks/_guard_entry.py new file mode 100644 index 0000000..99aa727 --- /dev/null +++ b/guard/hooks/_guard_entry.py @@ -0,0 +1,13 @@ +#!/usr/bin/env python3 +"""Installed PreToolUse Python entry (template → ~/.tripwire/hooks/_guard_entry.py). + +Reads Claude Code stdin JSON and prints approve/block stdout JSON (exit 0). +Delegates to ``guard.hooks_entry.main``. +""" + +from __future__ import annotations + +from guard.hooks_entry import main + +if __name__ == "__main__": + main() diff --git a/guard/hooks/pre-tool-use.sh b/guard/hooks/pre-tool-use.sh new file mode 100755 index 0000000..692d772 --- /dev/null +++ b/guard/hooks/pre-tool-use.sh @@ -0,0 +1,39 @@ +#!/usr/bin/env bash +# Claude Code PreToolUse handler (install template → ~/.tripwire/hooks/pre-tool-use.sh). +# Always exit 0; approve/block is communicated via stdout JSON. +# Fail-closed: unexpected errors emit {"decision":"block","reason":"guard error — fail closed"}. +set -euo pipefail + +CONFIG="${TRIPWIRE_CONFIG:-${HOME}/.tripwire/config.json}" +ENTRY="${TRIPWIRE_GUARD_ENTRY:-}" + +# Fast path: enable=false → approve without invoking Python guard. +ENABLED="$( + python3 -c " +import json, sys +path = '''${CONFIG}''' +try: + with open(path, encoding='utf-8') as f: + data = json.load(f) + print(data.get('enable', True)) +except Exception: + print(True) +" 2>/dev/null || echo "True" +)" + +case "${ENABLED}" in + False|false|0) + printf '%s\n' '{"decision":"approve"}' + exit 0 + ;; +esac + +if [[ -z "${ENTRY}" ]]; then + SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + ENTRY="${SCRIPT_DIR}/_guard_entry.py" +fi + +if command -v uv >/dev/null 2>&1; then + exec uv run python "${ENTRY}" +fi +exec python3 "${ENTRY}" diff --git a/guard/hooks_entry.py b/guard/hooks_entry.py new file mode 100644 index 0000000..ba8c985 --- /dev/null +++ b/guard/hooks_entry.py @@ -0,0 +1,84 @@ +"""Claude Code PreToolUse handler — stdin JSON in, approve/block stdout JSON out. + +Contract (always exit 0; decision is in stdout JSON): +- ``{"decision": "approve"}`` when enforcement is disabled or guard allows. +- ``{"decision": "block", "reason": "..."}`` when guard denies or an unexpected + error occurs (**fail-closed**). + +Does not perform synchronous scans. Wraps ``guard.guard_hook.check_call`` by +default; tests inject a fake ``check_call``. +""" + +from __future__ import annotations + +import json +import os +import sys +from collections.abc import Callable +from pathlib import Path +from typing import Any + +from guard.config import load_config + +CheckCall = Callable[[bytes], dict[str, Any]] + +_FAIL_CLOSED_REASON = "guard error — fail closed" + + +def _config_path_from_env() -> Path: + override = os.environ.get("TRIPWIRE_CONFIG") + if override: + return Path(override) + return Path.home() / ".tripwire" / "config.json" + + +def _default_check_call(content_bytes: bytes) -> dict[str, Any]: + # Lazy import: guard_hook pulls supabase; keep hooks_entry importable without it. + from guard.guard_hook import check_call + + return check_call(content_bytes) + + +def _decision_approve() -> str: + return json.dumps({"decision": "approve"}, separators=(",", ":")) + + +def _decision_block(reason: str) -> str: + return json.dumps({"decision": "block", "reason": reason}, separators=(",", ":")) + + +def handle_pre_tool_use( + stdin_bytes: bytes, + *, + config_path: Path | str | None = None, + check_call: CheckCall | None = None, + target_content: bytes | None = None, +) -> tuple[str, int]: + """Run PreToolUse decision. Returns (stdout_json, exit_code). Exit is always 0.""" + del stdin_bytes # reserved for path extraction in later slices + path = Path(config_path) if config_path is not None else _config_path_from_env() + config = load_config(path) + if not config.get("enable", True): + return _decision_approve(), 0 + + guard_fn = check_call if check_call is not None else _default_check_call + content = b"" if target_content is None else target_content + try: + result = guard_fn(content) + except Exception: + return _decision_block(_FAIL_CLOSED_REASON), 0 + + if result.get("allow"): + return _decision_approve(), 0 + + reason = str(result.get("reason") or "blocked by Tripwire Guard") + return _decision_block(reason), 0 + + +def main() -> None: + """CLI entry for installed ``_guard_entry.py`` (reads stdin, prints decision).""" + stdout, code = handle_pre_tool_use(sys.stdin.buffer.read()) + sys.stdout.write(stdout) + if not stdout.endswith("\n"): + sys.stdout.write("\n") + raise SystemExit(code) diff --git a/guard/tests/test_config_handler_scripts.py b/guard/tests/test_config_handler_scripts.py new file mode 100644 index 0000000..5a0718d --- /dev/null +++ b/guard/tests/test_config_handler_scripts.py @@ -0,0 +1,185 @@ +"""Slice 23 GWTs — config schema + PreToolUse handler approve/block contract. + +CONTRACT_SHAPE: bounded-change +Outcome anchor: Operator (Claude Code) receives approve/block JSON; +config file carries Frontline schema defaults. +""" + +from __future__ import annotations + +import json +import os +import subprocess +from pathlib import Path + +REPO_ROOT = Path(__file__).resolve().parents[2] +HOOKS_DIR = REPO_ROOT / "guard" / "hooks" +PRE_TOOL_USE = HOOKS_DIR / "pre-tool-use.sh" + +FIXTURE_STDIN = json.dumps( + { + "tool_name": "Skill", + "tool_input": {"skill": "demo"}, + "session_id": "slice-23-fixture", + } +).encode() + + +def _write_config(path: Path, **fields: object) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(json.dumps(fields), encoding="utf-8") + + +def test_disabled_config_approves_without_guard_work(tmp_path: Path) -> None: + """@contract-shape:bounded-change @walking_skeleton + + Given enable=false, when PreToolUse handler runs, then approve + exit 0 and guard is not called. + """ + from guard import hooks_entry + + config_path = tmp_path / "config.json" + _write_config(config_path, enable=False, scan_validity_days=14) + calls: list[bytes] = [] + + def _check_call(content_bytes: bytes) -> dict: + calls.append(content_bytes) + return {"allow": False, "reason": "should not run", "status": "red"} + + stdout, code = hooks_entry.handle_pre_tool_use( + FIXTURE_STDIN, + config_path=config_path, + check_call=_check_call, + target_content=b"artifact-bytes", + ) + payload = json.loads(stdout) + assert code == 0 + assert payload["decision"] == "approve" + assert calls == [] + + # Walking skeleton: installed shell template also short-circuits. + assert PRE_TOOL_USE.is_file() + env = os.environ.copy() + env["TRIPWIRE_CONFIG"] = str(config_path) + env["PYTHONPATH"] = str(REPO_ROOT) + os.pathsep + env.get("PYTHONPATH", "") + proc = subprocess.run( + ["bash", str(PRE_TOOL_USE)], + input=FIXTURE_STDIN, + capture_output=True, + env=env, + check=False, + ) + assert proc.returncode == 0 + assert json.loads(proc.stdout.decode())["decision"] == "approve" + + +def test_enabled_red_blocks_with_reason(tmp_path: Path) -> None: + """@contract-shape:bounded-change + + Given enable=true and RED guard result, when handler runs, then block with reason, exit 0. + """ + from guard import hooks_entry + + config_path = tmp_path / "config.json" + _write_config(config_path, enable=True, scan_validity_days=14) + + def _check_call(_content_bytes: bytes) -> dict: + return { + "allow": False, + "reason": "rated red — at/above threshold", + "status": "red", + } + + stdout, code = hooks_entry.handle_pre_tool_use( + FIXTURE_STDIN, + config_path=config_path, + check_call=_check_call, + target_content=b"artifact-bytes", + ) + payload = json.loads(stdout) + assert code == 0 + assert payload["decision"] == "block" + assert payload["reason"] + + +def test_enabled_below_threshold_approves(tmp_path: Path) -> None: + """@contract-shape:bounded-change + + Given enable=true and green guard result, when handler runs, then approve, exit 0. + """ + from guard import hooks_entry + + config_path = tmp_path / "config.json" + _write_config(config_path, enable=True, scan_validity_days=14) + + def _check_call(_content_bytes: bytes) -> dict: + return { + "allow": True, + "reason": "rated green — below threshold", + "status": "green", + } + + stdout, code = hooks_entry.handle_pre_tool_use( + FIXTURE_STDIN, + config_path=config_path, + check_call=_check_call, + target_content=b"artifact-bytes", + ) + payload = json.loads(stdout) + assert code == 0 + assert payload["decision"] == "approve" + + +def test_unexpected_error_fails_closed(tmp_path: Path) -> None: + """@contract-shape:bounded-change + + Given enable=true and check_call raises, when handler runs, then fail-closed block, exit 0. + """ + from guard import hooks_entry + + config_path = tmp_path / "config.json" + _write_config(config_path, enable=True, scan_validity_days=14) + + def _check_call(_content_bytes: bytes) -> dict: + raise RuntimeError("boom") + + stdout, code = hooks_entry.handle_pre_tool_use( + FIXTURE_STDIN, + config_path=config_path, + check_call=_check_call, + target_content=b"artifact-bytes", + ) + payload = json.loads(stdout) + assert code == 0 + assert payload["decision"] == "block" + assert "fail closed" in payload["reason"].lower() + + +def test_first_write_applies_config_defaults(tmp_path: Path) -> None: + """@contract-shape:bounded-change + + Given no config file, when ensure_default_config runs, then enable=true and scan_validity_days=14. + """ + from guard import config as tripwire_config + + config_path = tmp_path / ".tripwire" / "config.json" + assert not config_path.exists() + written = tripwire_config.ensure_default_config(config_path) + assert written == {"enable": True, "scan_validity_days": 14} + on_disk = json.loads(config_path.read_text(encoding="utf-8")) + assert on_disk["enable"] is True + assert on_disk["scan_validity_days"] == 14 + + +def test_existing_config_preserved_on_ensure(tmp_path: Path) -> None: + """@contract-shape:bounded-change + + Given existing non-default config, when ensure_default_config runs, then values are unchanged. + """ + from guard import config as tripwire_config + + config_path = tmp_path / "config.json" + _write_config(config_path, enable=False, scan_validity_days=7) + result = tripwire_config.ensure_default_config(config_path) + assert result == {"enable": False, "scan_validity_days": 7} + on_disk = json.loads(config_path.read_text(encoding="utf-8")) + assert on_disk == {"enable": False, "scan_validity_days": 7} diff --git a/guard/tests/test_config_handler_units.py b/guard/tests/test_config_handler_units.py new file mode 100644 index 0000000..1e7905c --- /dev/null +++ b/guard/tests/test_config_handler_units.py @@ -0,0 +1,176 @@ +""" +Tests for guard.config and guard.hooks_entry edge paths (coverage). + +Author: slice-23 +Created: 2026-08-15 +Scope: load_config invalid/missing; env config path; default check_call; main() +""" + +from __future__ import annotations + +import io +import json +import sys +from pathlib import Path + +import pytest + + +def test_given_missing_config_when_loaded_then_defaults(tmp_path: Path) -> None: + """ + Scenario: Missing config file yields schema defaults. + Slice: 23 — load_config + """ + from guard.config import load_config + + ### Given / When + actual = load_config(tmp_path / "absent.json") + + ### Then + assert actual == {"enable": True, "scan_validity_days": 14} + + +def test_given_invalid_json_when_loaded_then_defaults(tmp_path: Path) -> None: + """Scenario: Corrupt config JSON yields schema defaults.""" + from guard.config import load_config + + ### Given + path = tmp_path / "bad.json" + path.write_text("{not-json", encoding="utf-8") + + ### When + actual = load_config(path) + + ### Then + assert actual == {"enable": True, "scan_validity_days": 14} + + +def test_given_non_object_json_when_loaded_then_defaults(tmp_path: Path) -> None: + """Scenario: Non-object JSON config yields schema defaults.""" + from guard.config import load_config + + ### Given + path = tmp_path / "arr.json" + path.write_text("[1,2]", encoding="utf-8") + + ### When + actual = load_config(path) + + ### Then + assert actual == {"enable": True, "scan_validity_days": 14} + + +def test_given_tripwire_config_env_when_handler_runs_then_uses_override( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """Scenario: TRIPWIRE_CONFIG selects the config path when none is passed.""" + from guard.hooks_entry import handle_pre_tool_use + + ### Given + path = tmp_path / "cfg.json" + path.write_text(json.dumps({"enable": False, "scan_validity_days": 14}), encoding="utf-8") + monkeypatch.setenv("TRIPWIRE_CONFIG", str(path)) + + ### When + stdout, code = handle_pre_tool_use(b"{}", check_call=lambda _: {"allow": False}) + + ### Then + assert code == 0 + assert json.loads(stdout)["decision"] == "approve" + + +def test_given_no_injected_check_call_when_handler_runs_then_uses_default( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """Scenario: Default check_call path is used when no inject is provided.""" + import sys + from types import ModuleType + + from guard import hooks_entry + + ### Given + path = tmp_path / "cfg.json" + path.write_text(json.dumps({"enable": True, "scan_validity_days": 14}), encoding="utf-8") + + def _stub(content_bytes: bytes) -> dict: + assert content_bytes == b"payload" + return {"allow": False, "reason": "stubbed red", "status": "red"} + + fake_hook = ModuleType("guard.guard_hook") + fake_hook.check_call = _stub # type: ignore[attr-defined] + monkeypatch.setitem(sys.modules, "guard.guard_hook", fake_hook) + + ### When + stdout, code = hooks_entry.handle_pre_tool_use( + b"{}", + config_path=path, + target_content=b"payload", + ) + + ### Then + assert code == 0 + payload = json.loads(stdout) + assert payload["decision"] == "block" + assert payload["reason"] == "stubbed red" + + +def test_given_empty_reason_when_blocked_then_default_reason(tmp_path: Path) -> None: + """Scenario: Missing guard reason string still produces a block reason.""" + from guard.hooks_entry import handle_pre_tool_use + + ### Given + path = tmp_path / "cfg.json" + path.write_text(json.dumps({"enable": True, "scan_validity_days": 14}), encoding="utf-8") + + ### When + stdout, code = handle_pre_tool_use( + b"{}", + config_path=path, + check_call=lambda _: {"allow": False, "reason": "", "status": "red"}, + ) + + ### Then + assert code == 0 + assert json.loads(stdout)["reason"] == "blocked by Tripwire Guard" + + +def test_given_stdin_when_main_runs_then_prints_decision( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] +) -> None: + """Scenario: main() reads stdin and prints approve/block JSON.""" + from guard import hooks_entry + + ### Given + path = tmp_path / "cfg.json" + path.write_text(json.dumps({"enable": False, "scan_validity_days": 14}), encoding="utf-8") + monkeypatch.setenv("TRIPWIRE_CONFIG", str(path)) + monkeypatch.setattr(sys, "stdin", io.TextIOWrapper(io.BytesIO(b"{}"))) + + ### When + with pytest.raises(SystemExit) as exc: + hooks_entry.main() + + ### Then + assert exc.value.code == 0 + assert json.loads(capsys.readouterr().out.strip())["decision"] == "approve" + + +def test_given_home_config_when_no_env_override_then_uses_home( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """Scenario: Without TRIPWIRE_CONFIG, config is read from ~/.tripwire/config.json.""" + from guard.hooks_entry import handle_pre_tool_use + + ### Given + monkeypatch.delenv("TRIPWIRE_CONFIG", raising=False) + monkeypatch.setenv("HOME", str(tmp_path)) + cfg = tmp_path / ".tripwire" / "config.json" + cfg.parent.mkdir(parents=True) + cfg.write_text(json.dumps({"enable": False, "scan_validity_days": 14}), encoding="utf-8") + + ### When + stdout, code = handle_pre_tool_use(b"{}") + + ### Then + assert code == 0 + assert json.loads(stdout)["decision"] == "approve" diff --git a/pyproject.toml b/pyproject.toml index 2910287..f1bf8a8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -32,6 +32,7 @@ packages = ["sandbox", "guard"] [tool.hatch.build] exclude = [ "sandbox/tests", + "guard/tests", ] [tool.ruff] @@ -50,8 +51,8 @@ select = ["E", "F", "I", "N", "W", "UP"] ignore = ["E501", "E702"] [tool.pytest.ini_options] -pythonpath = ["sandbox"] -testpaths = ["sandbox/tests"] +pythonpath = [".", "sandbox"] +testpaths = ["sandbox/tests", "guard/tests"] python_files = ["test_*.py"] addopts = "-v --tb=short --junitxml=.test-results/junit.xml" From 12117b7406af10da675f71e239a43a06a427f5de Mon Sep 17 00:00:00 2001 From: Mani Sarkar Date: Sat, 15 Aug 2026 16:49:30 +0100 Subject: [PATCH 02/12] feat(slice-24): add tripwire setup-agent-hooks install path Installs PreToolUse handlers under ~/.tripwire/hooks (mode 700), writes config defaults when absent, and registers Claude Code PreToolUse idempotently. Also closes slice 23 trackers after PR #74 landed on Frontline. --- cli/bin/tripwire.js | 27 ++ cli/src/setupAgentHooks.js | 218 +++++++++++++++ cli/test/setupAgentHooks.test.js | 250 ++++++++++++++++++ docs/STATUS.md | 22 +- docs/plan/DECISIONS.md | 2 +- docs/plan/PROGRESS.md | 46 ++-- docs/plan/TRAIL.md | 4 +- docs/plan/gate-evidence/slice-23.json | 10 +- docs/plan/gate-evidence/slice-24.json | 80 ++++-- .../slice-23-config-handler-scripts.md | 5 +- .../slice-24-setup-agent-hooks.md | 121 ++++++--- docs/user-guide/setup-commands.md | 16 ++ 12 files changed, 709 insertions(+), 92 deletions(-) create mode 100644 cli/src/setupAgentHooks.js create mode 100644 cli/test/setupAgentHooks.test.js diff --git a/cli/bin/tripwire.js b/cli/bin/tripwire.js index 0b6ed95..6dcf6b1 100755 --- a/cli/bin/tripwire.js +++ b/cli/bin/tripwire.js @@ -5,6 +5,7 @@ import { ensureSchema } from '../src/ensureSchema.js'; import { loadEnv } from '../src/loadEnv.js'; import { runScan } from '../src/orchestrator.js'; import { runRoute } from '../src/router.js'; +import { setupAgentHooks } from '../src/setupAgentHooks.js'; loadEnv(); @@ -73,4 +74,30 @@ program } }); +program + .command('setup-agent-hooks') + .description( + 'Install PreToolUse handlers under ~/.tripwire/hooks (chmod 700), write default config if absent, register Claude Code PreToolUse', + ) + .option('--home ', 'HOME root for ~/.tripwire (tests / alternate install)') + .option('--claude-settings ', 'Claude Code settings.json path (default: ~/.claude/settings.json)') + .action(async (opts) => { + try { + const result = await setupAgentHooks({ + homeDir: opts.home, + claudeSettingsPath: opts.claudeSettings, + }); + console.log(JSON.stringify({ + ok: true, + hooksDir: result.hooksDir, + configPath: result.configPath, + claudeSettingsPath: result.claudeSettingsPath, + preToolUseSh: result.preToolUseSh, + })); + } catch (err) { + console.error(err.message || err); + process.exitCode = 1; + } + }); + program.parseAsync(process.argv); diff --git a/cli/src/setupAgentHooks.js b/cli/src/setupAgentHooks.js new file mode 100644 index 0000000..02e34e6 --- /dev/null +++ b/cli/src/setupAgentHooks.js @@ -0,0 +1,218 @@ +/** + * Install Tripwire Claude Code PreToolUse hooks under ~/.tripwire/hooks, + * ensure default config when absent, and register the hook in Claude settings. + * + * Fixture-friendly: pass homeDir / claudeSettingsPath / hooksSourceDir to avoid + * mutating the developer machine in tests. + */ + +import { copyFile, mkdir, chmod, readFile, writeFile, access } from 'node:fs/promises'; +import { constants as fsConstants } from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { homedir } from 'node:os'; +import { spawnSync } from 'node:child_process'; + +const HOOK_FILES = ['pre-tool-use.sh', '_guard_entry.py']; +const HOOKS_MODE = 0o700; + +function repoRootFromCliSrc() { + const here = path.dirname(fileURLToPath(import.meta.url)); + return path.resolve(here, '../..'); +} + +function defaultHooksSourceDir() { + return path.join(repoRootFromCliSrc(), 'guard/hooks'); +} + +function tripwireRoot(homeDir) { + return path.join(homeDir, '.tripwire'); +} + +function hooksDir(homeDir) { + return path.join(tripwireRoot(homeDir), 'hooks'); +} + +function configPath(homeDir) { + return path.join(tripwireRoot(homeDir), 'config.json'); +} + +function defaultClaudeSettingsPath(homeDir) { + return path.join(homeDir, '.claude', 'settings.json'); +} + +async function pathExists(p) { + try { + await access(p, fsConstants.F_OK); + return true; + } catch { + return false; + } +} + +async function installHookScripts({ homeDir, hooksSourceDir }) { + const destDir = hooksDir(homeDir); + await mkdir(destDir, { recursive: true, mode: HOOKS_MODE }); + await chmod(destDir, HOOKS_MODE); + + for (const name of HOOK_FILES) { + const src = path.join(hooksSourceDir, name); + const dest = path.join(destDir, name); + await copyFile(src, dest); + await chmod(dest, HOOKS_MODE); + } + + return { + hooksDir: destDir, + preToolUseSh: path.join(destDir, 'pre-tool-use.sh'), + }; +} + +function ensureDefaultConfigViaPython(configFile) { + const script = [ + 'from pathlib import Path', + 'from guard.config import ensure_default_config', + `ensure_default_config(Path(${JSON.stringify(configFile)}))`, + ].join('; '); + + const root = repoRootFromCliSrc(); + const env = { + ...process.env, + PYTHONPATH: [root, process.env.PYTHONPATH].filter(Boolean).join(path.delimiter), + }; + + const runners = [ + ['uv', ['run', 'python', '-c', script]], + ['python3', ['-c', script]], + ['python', ['-c', script]], + ]; + + let lastError = null; + for (const [cmd, args] of runners) { + const result = spawnSync(cmd, args, { + encoding: 'utf8', + env, + cwd: root, + }); + if (result.status === 0) { + return; + } + lastError = result.stderr || result.error?.message || `exit ${result.status}`; + } + throw new Error(`Failed to write Tripwire config via ensure_default_config: ${lastError}`); +} + +function isTripwirePreToolUseCommand(command, preToolUseSh) { + if (typeof command !== 'string') { + return false; + } + if (command === preToolUseSh || command.includes(preToolUseSh)) { + return true; + } + return command.includes('.tripwire/hooks/pre-tool-use.sh'); +} + +function collectPreToolUseCommands(settings) { + const hooks = settings?.hooks?.PreToolUse; + if (!Array.isArray(hooks)) { + return []; + } + const commands = []; + for (const entry of hooks) { + const inner = entry?.hooks; + if (!Array.isArray(inner)) { + continue; + } + for (const h of inner) { + if (h?.type === 'command' && typeof h.command === 'string') { + commands.push(h.command); + } + } + } + return commands; +} + +async function registerPreToolUse({ claudeSettingsPath, preToolUseSh }) { + let settings = {}; + if (await pathExists(claudeSettingsPath)) { + const raw = await readFile(claudeSettingsPath, 'utf8'); + settings = raw.trim() ? JSON.parse(raw) : {}; + } + + if (!settings.hooks || typeof settings.hooks !== 'object') { + settings.hooks = {}; + } + if (!Array.isArray(settings.hooks.PreToolUse)) { + settings.hooks.PreToolUse = []; + } + + const existing = collectPreToolUseCommands(settings); + const alreadyRegistered = existing.some((cmd) => isTripwirePreToolUseCommand(cmd, preToolUseSh)); + + if (!alreadyRegistered) { + settings.hooks.PreToolUse.push({ + matcher: '', + hooks: [ + { + type: 'command', + command: preToolUseSh, + }, + ], + }); + } + + // Drop duplicate Tripwire PreToolUse entries if a prior broken install left multiples. + const seen = new Set(); + settings.hooks.PreToolUse = settings.hooks.PreToolUse.filter((entry) => { + const inner = Array.isArray(entry?.hooks) ? entry.hooks : []; + const tripwireCmds = inner + .filter((h) => h?.type === 'command') + .map((h) => h.command) + .filter((cmd) => isTripwirePreToolUseCommand(cmd, preToolUseSh)); + if (tripwireCmds.length === 0) { + return true; + } + const key = tripwireCmds.join('|'); + if (seen.has(key)) { + return false; + } + seen.add(key); + return true; + }); + + await mkdir(path.dirname(claudeSettingsPath), { recursive: true }); + await writeFile(claudeSettingsPath, `${JSON.stringify(settings, null, 2)}\n`, 'utf8'); + return settings; +} + +/** + * @param {object} [options] + * @param {string} [options.homeDir] + * @param {string} [options.claudeSettingsPath] + * @param {string} [options.hooksSourceDir] + * @returns {Promise} + */ +export async function setupAgentHooks(options = {}) { + const homeDir = options.homeDir || process.env.TRIPWIRE_HOME || homedir(); + const hooksSourceDir = options.hooksSourceDir || defaultHooksSourceDir(); + const claudeSettingsPath = + options.claudeSettingsPath || + process.env.CLAUDE_SETTINGS_PATH || + defaultClaudeSettingsPath(homeDir); + + const installed = await installHookScripts({ homeDir, hooksSourceDir }); + ensureDefaultConfigViaPython(configPath(homeDir)); + const settings = await registerPreToolUse({ + claudeSettingsPath, + preToolUseSh: installed.preToolUseSh, + }); + + return { + homeDir, + hooksDir: installed.hooksDir, + preToolUseSh: installed.preToolUseSh, + configPath: configPath(homeDir), + claudeSettingsPath, + settings, + }; +} diff --git a/cli/test/setupAgentHooks.test.js b/cli/test/setupAgentHooks.test.js new file mode 100644 index 0000000..d439b90 --- /dev/null +++ b/cli/test/setupAgentHooks.test.js @@ -0,0 +1,250 @@ +/** + * Acceptance tests for tripwire setup-agent-hooks (slice 24). + * + * Author: swami + * Created: 2026-08-15 + * Scope: hooks install+mode 700, config first-write/preserve, PreToolUse register, idempotent re-run + */ + +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { execFile } from 'node:child_process'; +import { mkdtemp, mkdir, writeFile, readFile, stat, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import { promisify } from 'node:util'; +import { fileURLToPath } from 'node:url'; + +import { setupAgentHooks } from '../src/setupAgentHooks.js'; + +const exec = promisify(execFile); +const tripwireBin = fileURLToPath(new URL('../bin/tripwire.js', import.meta.url)); + +async function makeFixtureHome() { + return mkdtemp(path.join(tmpdir(), 'tw-setup-hooks-')); +} + +function settingsPathFor(home) { + return path.join(home, '.claude', 'settings.json'); +} + +function configPathFor(home) { + return path.join(home, '.tripwire', 'config.json'); +} + +function hooksDirFor(home) { + return path.join(home, '.tripwire', 'hooks'); +} + +function countTripwirePreToolUse(settings, preToolUseSh) { + const entries = settings?.hooks?.PreToolUse; + if (!Array.isArray(entries)) { + return 0; + } + let count = 0; + for (const entry of entries) { + const inner = Array.isArray(entry?.hooks) ? entry.hooks : []; + for (const h of inner) { + if (h?.type === 'command' && typeof h.command === 'string') { + if (h.command === preToolUseSh || h.command.includes('.tripwire/hooks/pre-tool-use.sh')) { + count += 1; + } + } + } + } + return count; +} + +test('given clean home when setup-agent-hooks runs then hooks exist with mode 700', async () => { + /** + * Scenario: Install lands handler scripts under owner-only hooks dir. + * Slice: 24 — hooks land with correct permissions + * + * Given a clean fixture HOME, + * When setup-agent-hooks runs, + * Then pre-tool-use.sh and _guard_entry.py exist and hooks dir mode is 700. + */ + // -- Given -- + const home = await makeFixtureHome(); + const settings = settingsPathFor(home); + + try { + // -- When -- + const result = await setupAgentHooks({ homeDir: home, claudeSettingsPath: settings }); + + // -- Then -- + const dirStat = await stat(hooksDirFor(home)); + assert.equal(dirStat.mode & 0o777, 0o700, 'hooks directory must be mode 700'); + await assert.doesNotReject(() => readFile(path.join(hooksDirFor(home), 'pre-tool-use.sh'))); + await assert.doesNotReject(() => readFile(path.join(hooksDirFor(home), '_guard_entry.py'))); + assert.equal(result.preToolUseSh, path.join(hooksDirFor(home), 'pre-tool-use.sh')); + } finally { + await rm(home, { recursive: true, force: true }); + } +}); + +test('given no config when setup runs then defaults enable true and scan_validity_days 14', async () => { + /** + * Scenario: First install writes slice-23 config defaults. + * Slice: 24 — config created when absent + * + * Given no ~/.tripwire/config.json, + * When setup-agent-hooks runs, + * Then config has enable=true and scan_validity_days=14. + */ + // -- Given -- + const home = await makeFixtureHome(); + const settings = settingsPathFor(home); + + try { + // -- When -- + await setupAgentHooks({ homeDir: home, claudeSettingsPath: settings }); + + // -- Then -- + const raw = await readFile(configPathFor(home), 'utf8'); + const config = JSON.parse(raw); + assert.equal(config.enable, true); + assert.equal(config.scan_validity_days, 14); + } finally { + await rm(home, { recursive: true, force: true }); + } +}); + +test('given existing config when setup runs again then values are preserved', async () => { + /** + * Scenario: Re-install must not clobber operator config. + * Slice: 24 — existing config preserved + * + * Given config with enable=false and scan_validity_days=7, + * When setup-agent-hooks runs again, + * Then those values are unchanged. + */ + // -- Given -- + const home = await makeFixtureHome(); + const settings = settingsPathFor(home); + await mkdir(path.dirname(configPathFor(home)), { recursive: true }); + await writeFile( + configPathFor(home), + `${JSON.stringify({ enable: false, scan_validity_days: 7 }, null, 2)}\n`, + 'utf8', + ); + + try { + // -- When -- + await setupAgentHooks({ homeDir: home, claudeSettingsPath: settings }); + + // -- Then -- + const config = JSON.parse(await readFile(configPathFor(home), 'utf8')); + assert.equal(config.enable, false); + assert.equal(config.scan_validity_days, 7); + } finally { + await rm(home, { recursive: true, force: true }); + } +}); + +test('given settings without Tripwire hook when setup runs then PreToolUse points at installed script', async () => { + /** + * Scenario: Claude settings gain a PreToolUse command for Tripwire. + * Slice: 24 — PreToolUse registered + * + * Given Claude settings without a Tripwire PreToolUse hook, + * When setup-agent-hooks runs, + * Then settings contain a command pointing at installed pre-tool-use.sh. + */ + // -- Given -- + const home = await makeFixtureHome(); + const settingsFile = settingsPathFor(home); + await mkdir(path.dirname(settingsFile), { recursive: true }); + await writeFile(settingsFile, `${JSON.stringify({ hooks: {} }, null, 2)}\n`, 'utf8'); + + try { + // -- When -- + const result = await setupAgentHooks({ homeDir: home, claudeSettingsPath: settingsFile }); + + // -- Then -- + const settings = JSON.parse(await readFile(settingsFile, 'utf8')); + assert.equal(countTripwirePreToolUse(settings, result.preToolUseSh), 1); + const commands = settings.hooks.PreToolUse.flatMap((e) => (e.hooks || []).map((h) => h.command)); + assert.ok(commands.includes(result.preToolUseSh), 'PreToolUse command must be installed script path'); + } finally { + await rm(home, { recursive: true, force: true }); + } +}); + +test('given setup already ran when setup runs again then Tripwire PreToolUse is not duplicated', async () => { + /** + * Scenario: Idempotent re-run keeps a single Tripwire PreToolUse entry. + * Slice: 24 — idempotent re-run + * + * Given setup already ran once, + * When setup runs again, + * Then exactly one Tripwire PreToolUse command entry remains. + */ + // -- Given -- + const home = await makeFixtureHome(); + const settingsFile = settingsPathFor(home); + const first = await setupAgentHooks({ homeDir: home, claudeSettingsPath: settingsFile }); + + try { + // -- When -- + const second = await setupAgentHooks({ homeDir: home, claudeSettingsPath: settingsFile }); + + // -- Then -- + const settings = JSON.parse(await readFile(settingsFile, 'utf8')); + assert.equal(countTripwirePreToolUse(settings, second.preToolUseSh), 1); + assert.equal(first.preToolUseSh, second.preToolUseSh); + } finally { + await rm(home, { recursive: true, force: true }); + } +}); + +test('tripwire setup-agent-hooks appears in --help', async () => { + /** + * Scenario: CLI surface lists the install subcommand. + * Slice: 24 — operator discoverability + * + * Given the tripwire CLI binary, + * When --help is shown, + * Then setup-agent-hooks is listed. + */ + // -- When -- + const { stdout } = await exec('node', [tripwireBin, '--help']); + + // -- Then -- + assert.match(stdout, /setup-agent-hooks/); +}); + +test('CLI setup-agent-hooks with --home installs into fixture home', async () => { + /** + * Scenario: Production entry point reaches setupAgentHooks. + * Slice: 24 — CLI integration + * + * Given a clean fixture HOME, + * When `tripwire setup-agent-hooks --home … --claude-settings …` runs, + * Then stdout JSON reports ok and hooks land under that HOME. + */ + // -- Given -- + const home = await makeFixtureHome(); + const settings = settingsPathFor(home); + + try { + // -- When -- + const { stdout } = await exec('node', [ + tripwireBin, + 'setup-agent-hooks', + '--home', + home, + '--claude-settings', + settings, + ]); + + // -- Then -- + const payload = JSON.parse(stdout); + assert.equal(payload.ok, true); + assert.equal(payload.hooksDir, hooksDirFor(home)); + const dirStat = await stat(hooksDirFor(home)); + assert.equal(dirStat.mode & 0o777, 0o700); + } finally { + await rm(home, { recursive: true, force: true }); + } +}); diff --git a/docs/STATUS.md b/docs/STATUS.md index 56e4d11..2f0d420 100644 --- a/docs/STATUS.md +++ b/docs/STATUS.md @@ -125,13 +125,16 @@ slices 23–39 on branch `frontline-hackathon-london-2026-agent-hooks`. See [plan/TRAIL.md](./plan/TRAIL.md) Wave H and [plan/DECISIONS.md](./plan/DECISIONS.md). - **Slice 23 (Config + Handler Scripts):** schema + handler templates are - **IMPLEMENTED** in-repo — `guard/config.py` (`enable` default `true`, - `scan_validity_days` default `14`), `guard/hooks_entry.py` (stdin → stdout - approve/block, fail-closed), install templates under `guard/hooks/`. Not yet - installed by a CLI command (slice 24). ADR-0015 Horizon A exclusion remains - until Wave H lands and a superseding ADR records the production install path. -- Remaining H Musts/Shoulds: not IMPLEMENTED — no `setup-agent-hooks` or `/tw-*` - skills yet. + **IMPLEMENTED** and **VERIFIED** (PR #74 into Frontline integration branch) — + `guard/config.py` (`enable` default `true`, `scan_validity_days` default `14`), + `guard/hooks_entry.py` (stdin → stdout approve/block, fail-closed), install + templates under `guard/hooks/`. Install CLI is slice 24. +- **Slice 24 (`tripwire setup-agent-hooks`):** 🔀 ON BRANCH — + **IMPLEMENTED** install path (`cli/src/setupAgentHooks.js` + + `tripwire setup-agent-hooks`); installs hooks to `~/.tripwire/hooks` (mode + `700`), first-write config via `ensure_default_config`, Claude PreToolUse + registration. Merge to Frontline then mark ✅. +- Remaining H Musts/Shoulds: not IMPLEMENTED — no `/tw-*` skills yet. --- @@ -151,8 +154,9 @@ Known fixture gaps (not urgent) are listed under as shipped capabilities. Guard PreToolUse and Drift/trend remain Future / Won't (A) for the Horizon A ship path — see [ADR-0015](./adr/0015-horizon-a-excludes-guard-and-drift.md). Frontline Guard -integration is **DECIDED** as Wave H — see DECIDED above. Slice 23 has in-repo -handler/config **IMPLEMENTED**; install/`/tw-*` still pending later H slices. +integration is **DECIDED** as Wave H — see DECIDED above. Slice 23 ✅ handlers/ +config; slice 24 installs via `tripwire setup-agent-hooks` (on branch). `/tw-*` +skills still pending later H slices. Coverage audit matrix: [plan/coverage-audit.md](./plan/coverage-audit.md) (slice 7 ✅). Slice stubs: [plan/README.md](./plan/README.md) (`01-A-…` … diff --git a/docs/plan/DECISIONS.md b/docs/plan/DECISIONS.md index 666cc3c..79c3c23 100644 --- a/docs/plan/DECISIONS.md +++ b/docs/plan/DECISIONS.md @@ -100,4 +100,4 @@ | 2026-08-15 | wave-h | Slice branch base | Wave H slice branches fork from `frontline-hackathon-london-2026-agent-hooks` (not bare `main`) so plan stubs 23–39 are inherited. Before-Check wording “from current main” is waived for H while that integration branch is active. | | 2026-08-15 | slice-23 | AT design complete | Six GWTs + ≥95% lines on `guard/config.py` + `guard/hooks_entry.py`; sandbox Horizon A cov bar unchanged (ADR-0015). First-write defaults via `ensure_default_config` (slice 24 calls it). | | 2026-08-15 | slice-23 | Walking skeleton Strategy B | Subprocess-light: AT-1 invokes real `guard/hooks/pre-tool-use.sh` via `subprocess.run` with `TRIPWIRE_CONFIG` fixture HOME; remaining ATs drive `handle_pre_tool_use` in-process with injectable `check_call`. | -| 2026-08-15 | wave-h | Nimble reviews | For Wave H slices after 23: prefer a single lightweight in-session review (acceptance+implementation combined) over dual Task subagent dispatches. Escalate to full `/nw-review` agents only on HARD GATE (32), security-sensitive changes, or when After-Checks are ambiguous. Still record `acceptance`/`implementation` in gate-evidence (can be same reviewer note). | +| 2026-08-15 | wave-h | Slice 23 closed | PR #74 merged into `frontline-hackathon-london-2026-agent-hooks`. Wave H integration branch is merge-equivalent for ✅ while H is active (GATE_CONTRACT `main` wording waived for H per 2026-08-15 branch-base decision). | diff --git a/docs/plan/PROGRESS.md b/docs/plan/PROGRESS.md index d24dee6..64379c1 100644 --- a/docs/plan/PROGRESS.md +++ b/docs/plan/PROGRESS.md @@ -12,31 +12,30 @@ | 5 | [`05-E-…`](slices/05-E-ship-path-coverage/) | **E — Ship-path coverage** | 8 → 11 → 12 → 13 ✅ → 14 (**9+10 SUBSUMED INTO 11**) | ✅ Musts · close-path | | 6 | [`06-F-…`](slices/06-F-claim-audit/) | **F — Claim audit** | 15 · 16 | 📦 | | 7 | [`07-G-…`](slices/07-G-atdd-closure/) | **G — ATDD closure** | 18, 19, 20, 21, 22 (independent gates) | 📋 parked | -| 8 | [`08-H-…`](slices/08-H-frontline-agent-hooks/) | **H — Frontline agent hooks** | 23→32 Must · 33–38 Should · 39 Could | 🔀 23 on branch | +| 8 | [`08-H-…`](slices/08-H-frontline-agent-hooks/) | **H — Frontline agent hooks** | 23→32 Must · 33–38 Should · 39 Could | 23 ✅ · 24 🔀 | -**Current priority:** Wave H Must slice **23** — Config + Handler Scripts — 🔀 ON BRANCH (merge then start 24). Wave G (18–22) parked while H1–H3 is active unless explicitly resumed. +**Current priority:** Wave H Must slice **24** — `tripwire setup-agent-hooks` — 🔀 ON BRANCH (merge then start 25). Wave G (18–22) parked while H1–H3 is active unless explicitly resumed. ## Execution order (open work) | Order | Wave | # | Slice | MoSCoW | Status | |------:|-----:|---|-------|--------|--------| -| 1 | H1 | 23 | Config + Handler Scripts | Must | 🔀 ON BRANCH | -| 2 | H1 | 24 | `tripwire setup-agent-hooks` | Must | 📋 PLANNED | -| 3 | H1 | 25 | Live Enforce Smoke | Must | 📋 PLANNED | -| 4 | H2 | 26 | API Introspect + Dual Output Contract | Must | 📋 PLANNED | -| 5 | H2 | 27 | `/tw-enable` + `/tw-disable` | Must | 📋 PLANNED | -| 6 | H2 | 28 | `/tw-verify` | Must | 📋 PLANNED | -| 7 | H2 | 29 | `/tw-scan` | Must | 📋 PLANNED | -| 8 | H2 | 30 | `/tw-self-check` | Must | 📋 PLANNED | -| 9 | H3 | 31 | Demo Artifacts | Must | 📋 PLANNED | -| 10 | H3 | 32 | Phase 1 Regression Verification (HARD GATE) | Must | 📋 PLANNED | -| 11 | H4 | 33 | DepShield Install | Should | 📋 PLANNED | -| 12 | H4 | 34 | DepShield Dispatch | Should | 📋 PLANNED | -| 13 | H5 | 35 | Ossprey Access Provisioning | Should | 🔴 BLOCKED | -| 14 | H5 | 36 | Ossprey Dispatch | Should | 📋 PLANNED | -| 15 | H6 | 37 | CLI Monitoring | Should | 📋 PLANNED | -| 16 | H6 | 38 | Full-Chain Validation | Should | 📋 PLANNED | -| 17 | H6 | 39 | FE/BE Rearchitecture | Could | 📦 DEFERRED | +| 1 | H1 | 24 | `tripwire setup-agent-hooks` | Must | 🔀 ON BRANCH | +| 2 | H1 | 25 | Live Enforce Smoke | Must | 📋 PLANNED | +| 3 | H2 | 26 | API Introspect + Dual Output Contract | Must | 📋 PLANNED | +| 4 | H2 | 27 | `/tw-enable` + `/tw-disable` | Must | 📋 PLANNED | +| 5 | H2 | 28 | `/tw-verify` | Must | 📋 PLANNED | +| 6 | H2 | 29 | `/tw-scan` | Must | 📋 PLANNED | +| 7 | H2 | 30 | `/tw-self-check` | Must | 📋 PLANNED | +| 8 | H3 | 31 | Demo Artifacts | Must | 📋 PLANNED | +| 9 | H3 | 32 | Phase 1 Regression Verification (HARD GATE) | Must | 📋 PLANNED | +| 10 | H4 | 33 | DepShield Install | Should | 📋 PLANNED | +| 11 | H4 | 34 | DepShield Dispatch | Should | 📋 PLANNED | +| 12 | H5 | 35 | Ossprey Access Provisioning | Should | 🔴 BLOCKED | +| 13 | H5 | 36 | Ossprey Dispatch | Should | 📋 PLANNED | +| 14 | H6 | 37 | CLI Monitoring | Should | 📋 PLANNED | +| 15 | H6 | 38 | Full-Chain Validation | Should | 📋 PLANNED | +| 16 | H6 | 39 | FE/BE Rearchitecture | Could | 📦 DEFERRED | | — | G | 18–22 | ATDD closure (parked) | Must | 📋 PLANNED | ## Quick Status (by group) @@ -94,8 +93,8 @@ ### H — Frontline agent hooks | # | Slice | MoSCoW | Status | Started | Completed | Est. time | |---|-------|--------|--------|---------|-----------|-----------| -| 23 | [slice-23-config-handler-scripts](slices/08-H-frontline-agent-hooks/slice-23-config-handler-scripts.md) | Must | 🔀 ON BRANCH | 2026-08-15 | — | ~40 min | -| 24 | [slice-24-setup-agent-hooks](slices/08-H-frontline-agent-hooks/slice-24-setup-agent-hooks.md) | Must | 📋 PLANNED | — | — | ~40 min | +| 23 | [slice-23-config-handler-scripts](slices/08-H-frontline-agent-hooks/slice-23-config-handler-scripts.md) | Must | ✅ | 2026-08-15 | 2026-08-15 | ~40 min | +| 24 | [slice-24-setup-agent-hooks](slices/08-H-frontline-agent-hooks/slice-24-setup-agent-hooks.md) | Must | 🔀 ON BRANCH | 2026-08-15 | — | ~40 min | | 25 | [slice-25-live-enforce-smoke](slices/08-H-frontline-agent-hooks/slice-25-live-enforce-smoke.md) | Must | 📋 PLANNED | — | — | ~30 min | | 26 | [slice-26-api-output-contract](slices/08-H-frontline-agent-hooks/slice-26-api-output-contract.md) | Must | 📋 PLANNED | — | — | ~40 min | | 27 | [slice-27-tw-enable-disable](slices/08-H-frontline-agent-hooks/slice-27-tw-enable-disable.md) | Must | 📋 PLANNED | — | — | ~25 min | @@ -124,7 +123,7 @@ ## Forward Roadmap - Waves **A–C**, coverage Slice 14, and Slice 17 are merged and closed. Slice 15 is retained as a deferred claim-audit artifact, not active work. -- **Wave H (Frontline):** plan-only stubs 23–39 on branch `frontline-hackathon-london-2026-agent-hooks`. Execute Musts 23→32 with human tests after H1/H2 and HARD GATE at 32 before Should work. +- **Wave H (Frontline):** integration branch `frontline-hackathon-london-2026-agent-hooks`. Slice 23 ✅ (PR #74). Slice 24 🔀 ON BRANCH (`setup-agent-hooks`). Next after merge: 25. - Wave G (18–22) remains planned but **parked** while Frontline H1–H3 is active unless explicitly resumed. - Reopen Slice 15 only for a future live/demo release that needs its security and 3B evidence path. - **Deferred / Won't (A):** 4 (in A); 15 and 16 (in F) — reinstate only if a new live/demo need arises @@ -161,4 +160,5 @@ | 2026-08-02 | main / plan | GATE_CONTRACT | all | 📋 policy | Hard close rule | | 2026-08-02 | docs/gate-contract-onboarding-priority | sync-docs + clean-commit | 7, plan | pushed | Groups + close slice 7 on trackers | | 2026-08-15 | frontline-hackathon-london-2026-agent-hooks | enhanced-flow-planner Add | 23–39 | 📋 stubs | Wave H Frontline plan-only; Wave G parked | -| 2026-08-15 | slice/23-config-handler-scripts | slice-workflow | 23 | 🔀 ON BRANCH | Config+handlers; 6 GWTs; cov 98.8%; reviews APPROVED | +| 2026-08-15 | slice/23-config-handler-scripts | slice-workflow | 23 | ✅ PASSED (PR #74) | Config+handlers; 6 GWTs; cov 98.8%; merged to Frontline | +| 2026-08-15 | slice/24-setup-agent-hooks | slice-workflow | 24 | 🔀 ON BRANCH | setup-agent-hooks; 7 tests; cov 93.11%; quality-gates PASS | diff --git a/docs/plan/TRAIL.md b/docs/plan/TRAIL.md index da41369..7664963 100644 --- a/docs/plan/TRAIL.md +++ b/docs/plan/TRAIL.md @@ -159,8 +159,8 @@ Branch: `frontline-hackathon-london-2026-agent-hooks`. Source: `internal-docs/04 | # | File | Name | MoSCoW | Status | Depends on | Issue | Read time | |---|------|------|--------|--------|------------|-------|-----------| -| 23 | [slice-23-config-handler-scripts](slices/08-H-frontline-agent-hooks/slice-23-config-handler-scripts.md) | Config + Handler Scripts | Must | 🔀 | none | — | ~4 min | -| 24 | [slice-24-setup-agent-hooks](slices/08-H-frontline-agent-hooks/slice-24-setup-agent-hooks.md) | `tripwire setup-agent-hooks` | Must | 📋 | 23 | — | ~4 min | +| 23 | [slice-23-config-handler-scripts](slices/08-H-frontline-agent-hooks/slice-23-config-handler-scripts.md) | Config + Handler Scripts | Must | ✅ | none | #74 | ~4 min | +| 24 | [slice-24-setup-agent-hooks](slices/08-H-frontline-agent-hooks/slice-24-setup-agent-hooks.md) | `tripwire setup-agent-hooks` | Must | 🔀 | 23 | — | ~4 min | | 25 | [slice-25-live-enforce-smoke](slices/08-H-frontline-agent-hooks/slice-25-live-enforce-smoke.md) | Live Enforce Smoke | Must | 📋 | 24 | — | ~3 min | #### H2 — Shared contracts + control skills (Must) diff --git a/docs/plan/gate-evidence/slice-23.json b/docs/plan/gate-evidence/slice-23.json index c7dc8dd..7f3bbf7 100644 --- a/docs/plan/gate-evidence/slice-23.json +++ b/docs/plan/gate-evidence/slice-23.json @@ -1,6 +1,6 @@ { "slice": 23, - "gate_status": "ON_BRANCH", + "gate_status": "PASSED", "inferred": false, "branch": "slice/23-config-handler-scripts", "date": "2026-08-15", @@ -14,7 +14,7 @@ "PASS — ./scripts/quality-gates.sh (xenon enforcing for product paths)", "PASS — gate-evidence commands recorded", "PASS — review acceptance APPROVED + implementation APPROVED", - "PENDING merge — PROGRESS/TRAIL 🔀 until merged to Frontline integration / main" + "PASS — merged to Wave H integration branch via PR #74 (2026-08-15)" ], "commands": [ { @@ -68,8 +68,10 @@ "3_fail_closed": "PASS — hooks_entry + shell comments", "4_cross_link": "PASS — gate-evidence ↔ TRAIL/PROGRESS" }, - "verdict": "ON_BRANCH", + "verdict": "PASS", "phase": "H1", "moscow": "Must", - "pr": null + "pr": "https://github.com/neomatrix369/tripwire/pull/74", + "merged_into": "frontline-hackathon-london-2026-agent-hooks", + "merged_at": "2026-08-15T15:38:39Z" } diff --git a/docs/plan/gate-evidence/slice-24.json b/docs/plan/gate-evidence/slice-24.json index b363ff5..5148e42 100644 --- a/docs/plan/gate-evidence/slice-24.json +++ b/docs/plan/gate-evidence/slice-24.json @@ -1,24 +1,68 @@ { "slice": 24, - "gate_status": "PLANNED", + "gate_status": "ON_BRANCH", "inferred": false, - "branch": null, - "date": null, - "before_checks": [], - "after_checks": [], - "planned_commands": [ - "test -f docs/plan/slices/08-H-frontline-agent-hooks/slice-24-setup-agent-hooks.md", - "tripwire --help | rg -n \"setup-agent-hooks|setup\"", - "rg -n \"setup-agent-hooks|PreToolUse\" cli/ docs/ || true", - "./scripts/quality-gates.sh" + "branch": "slice/24-setup-agent-hooks", + "date": "2026-08-15", + "spec_path": "docs/plan/slices/08-H-frontline-agent-hooks/slice-24-setup-agent-hooks.md", + "before_checks": "PASS — slice 23 verdict PASS (PR #74); branch from Frontline integration (DECISIONS Wave H base); tripwire --help attach point cli/bin/tripwire.js; cov target ≥80% lines setupAgentHooks.js", + "after_checks": [ + "PASS — 5/5 setup GWTs + help + CLI entry (7 tests)", + "PASS — Then clauses assert filesystem / settings / config observables", + "PASS — cd cli && npm test -- test/setupAgentHooks.test.js (7 passed)", + "PASS — coverage 93.11% lines on cli/src/setupAgentHooks.js (≥80%)", + "PASS — ./scripts/quality-gates.sh", + "PASS — nimble in-session review acceptance+implementation APPROVED (DECISIONS Wave H nimble reviews)", + "PENDING merge — PROGRESS/TRAIL 🔀 until merged to Frontline integration" ], - "commands": [], - "test_budget": {"acceptance_tests_max": 7, "parametrized_case_counts_as_one": true}, - "reviewers": [], - "review": {"acceptance": "PENDING", "implementation": "PENDING"}, - "verdict": "NOT_RUN", - "coverage_target": "TBD at AT design before IN PROGRESS", - "complexity_policy": "enforcing for product-code; N/A for docs-only with reason in evidence", + "commands": [ + { + "cmd": "node cli/bin/tripwire.js --help | rg setup-agent-hooks", + "result": "PASS — subcommand listed beside setup/scan/route" + }, + { + "cmd": "cd cli && node --test test/setupAgentHooks.test.js", + "result": "PASS exit 0 — 7 passed" + }, + { + "cmd": "cd cli && npx c8 --include 'src/setupAgentHooks.js' --check-coverage --lines 80 node --test test/setupAgentHooks.test.js", + "result": "PASS — 93.11% lines" + }, + { + "cmd": "./scripts/quality-gates.sh", + "result": "PASS — quality-gates passed" + }, + { + "cmd": "rg -n \"setup-agent-hooks|~/.tripwire/hooks|chmod 700\" docs/user-guide/setup-commands.md", + "result": "PASS — operator install path documented" + } + ], + "test_budget": { + "acceptance_tests_max": 7, + "acceptance_tests_designed": 5, + "parametrized_case_counts_as_one": true, + "extra_surface_tests": ["help listing", "CLI --home integration"] + }, + "coverage": { + "target": "≥80% lines cli/src/setupAgentHooks.js", + "measured": "93.11%", + "date": "2026-08-15" + }, + "complexity_policy": "enforcing — quality-gates.sh xenon PASS", + "reviewers": ["in-session nimble (acceptance+implementation)"], + "review": { + "acceptance": "APPROVED", + "implementation": "APPROVED", + "notes": "GWTs bind to real install path + settings JSON; fixture HOME/--claude-settings; config delegates to ensure_default_config; idempotent PreToolUse. No enforcement logic beyond install wiring." + }, + "documentation_audit": { + "1_install_path": "PASS — docs/user-guide/setup-commands.md Frontline section", + "2_hooks_chmod": "PASS — ~/.tripwire/hooks mode 700 stated", + "3_config_defaults": "PASS — cross-link guard/config.py / slice 23", + "4_cross_link": "PASS — gate-evidence ↔ TRAIL/PROGRESS" + }, + "verdict": "ON_BRANCH", "phase": "H1", - "moscow": "Must" + "moscow": "Must", + "pr": null } diff --git a/docs/plan/slices/08-H-frontline-agent-hooks/slice-23-config-handler-scripts.md b/docs/plan/slices/08-H-frontline-agent-hooks/slice-23-config-handler-scripts.md index 35af126..7cda3f5 100644 --- a/docs/plan/slices/08-H-frontline-agent-hooks/slice-23-config-handler-scripts.md +++ b/docs/plan/slices/08-H-frontline-agent-hooks/slice-23-config-handler-scripts.md @@ -125,8 +125,7 @@ REFACTOR: keep handlers thin; no product behaviour beyond approve/block contract complexity, reviewers, and `verdict: ON_BRANCH` (PASS after merge) - [x] Review: `acceptance: APPROVED` and `implementation: APPROVED` (or docs-only exception in DECISIONS) -- [ ] `PROGRESS.md` + `TRAIL.md` show slice 23 ✅ and Execution order advanced - (🔀 ON BRANCH until merge) +- [x] `PROGRESS.md` + `TRAIL.md` show slice 23 ✅ and Execution order advanced ## Doc Audit @@ -139,4 +138,4 @@ REFACTOR: keep handlers thin; no product behaviour beyond approve/block contract ## Gate Status -🔀 ON BRANCH +✅ PASSED (PR #74 → `frontline-hackathon-london-2026-agent-hooks`, 2026-08-15) diff --git a/docs/plan/slices/08-H-frontline-agent-hooks/slice-24-setup-agent-hooks.md b/docs/plan/slices/08-H-frontline-agent-hooks/slice-24-setup-agent-hooks.md index dbbadbb..e060f6d 100644 --- a/docs/plan/slices/08-H-frontline-agent-hooks/slice-24-setup-agent-hooks.md +++ b/docs/plan/slices/08-H-frontline-agent-hooks/slice-24-setup-agent-hooks.md @@ -4,58 +4,115 @@ ## Outcome -`tripwire setup-agent-hooks` installs handler scripts under `~/.tripwire/hooks` with owner-only permissions (`chmod 700`), writes `~/.tripwire/config.json` if absent (defaults from slice 23), and registers a PreToolUse command hook in Claude Code settings. +`tripwire setup-agent-hooks` installs handler scripts under `~/.tripwire/hooks` +with owner-only permissions (`chmod 700`), writes `~/.tripwire/config.json` if +absent (defaults from slice 23 via `ensure_default_config`), and registers a +PreToolUse command hook in Claude Code settings. ## GWT acceptance specification -Thin scaffolds — full DISTILL ATs deferred per DECISIONS; design ATs before marking IN PROGRESS. - -1. **Hooks land with correct permissions** - - Given a clean home fixture, when the operator runs `tripwire setup-agent-hooks`, then `~/.tripwire/hooks/pre-tool-use.sh` and `_guard_entry.py` exist and the hooks directory mode is `700`. -2. **Config created only when absent** - - Given no `~/.tripwire/config.json`, when setup runs, then config is written with `enable=true` and `scan_validity_days=14`; given config already present, when setup runs again, then existing values are preserved. -3. **PreToolUse registered** - - Given Claude settings without a Tripwire PreToolUse hook, when setup runs, then settings contain a PreToolUse command pointing at the installed `pre-tool-use.sh`. +**DISTILL ATs (2026-08-15)** — ≤7; parametrized cases count as one. + +| # | Scenario | Tags | Real-surface binding | +|---|----------|------|----------------------| +| 1 | Hooks land with correct permissions | `@walking_skeleton` `@US-24` | `tripwire setup-agent-hooks` → `~/.tripwire/hooks/` | +| 2 | Config created when absent | `@US-24` | first-write via `ensure_default_config` | +| 3 | Existing config preserved | `@US-24` | re-run setup | +| 4 | PreToolUse registered | `@US-24` | Claude settings JSON | +| 5 | Idempotent re-run (no duplicate hooks) | `@US-24` | Claude settings JSON | + +1. **Hooks land with correct permissions** `@walking_skeleton` `@US-24` + - Given a clean fixture HOME, + when the operator runs `tripwire setup-agent-hooks` with that HOME, + then `~/.tripwire/hooks/pre-tool-use.sh` and `_guard_entry.py` exist and + the hooks directory mode is `0o700`. +2. **Config created when absent** `@US-24` + - Given no `~/.tripwire/config.json`, + when setup runs, + then config parses to `enable=true` and `scan_validity_days=14`. +3. **Existing config preserved** `@US-24` + - Given config with `enable=false` and `scan_validity_days=7`, + when setup runs again, + then those values are unchanged. +4. **PreToolUse registered** `@US-24` + - Given Claude settings without a Tripwire PreToolUse hook, + when setup runs, + then settings contain a PreToolUse command whose `command` path points at + the installed `pre-tool-use.sh`. +5. **Idempotent re-run (no duplicate hooks)** `@US-24` + - Given setup already ran once, + when setup runs again, + then exactly one Tripwire PreToolUse command entry remains (no duplicates). + +**Test inventory (5 acceptance tests):** hooks+mode; config first-write; config +preserve; PreToolUse register; idempotent settings. + +**Named verification command:** + +```bash +cd cli && npm test -- test/setupAgentHooks.test.js +``` + +**Coverage target (AT design):** ≥80% lines on `cli/src/setupAgentHooks.js` +(CLI ship-path floors remain temporary 60% overall; this module-local target is +the slice gate). Measured via: + +```bash +cd cli && npx c8 --include 'src/setupAgentHooks.js' --check-coverage --lines 80 \ + node --test test/setupAgentHooks.test.js +``` ## Design / test treatment -- Single canonical install subcommand on the existing `tripwire` CLI; idempotent re-run safe for hooks + settings registration. -- Prefer fixture HOME / settings paths in acceptance tests over mutating the developer machine. -- **AT design required before IN PROGRESS** (≤7 acceptance tests). +- Single canonical install subcommand on the existing `tripwire` CLI; idempotent + re-run safe for hooks + settings registration. +- Prefer fixture HOME / settings paths in acceptance tests over mutating the + developer machine (`--home` / `--claude-settings` flags for tests and ops). +- Config first-write delegates to Python `guard.config.ensure_default_config` + (slice 23 owner) via `uv run` / `python` with `HOME` / path env. +- **AT design complete** — ready for 🔨 IN PROGRESS. ## Before-Checks [GATE] -- [ ] Slice 23 gate-evidence `verdict` is `PASS` (or DECISIONS waiver) -- [ ] Branch `slice/24-setup-agent-hooks` created from current `main` -- [ ] `tripwire --help` / CLI surface inspection records where the new subcommand will attach (evidence note) -- [ ] Coverage/complexity targets TBD until AT design completes +- [x] Slice 23 gate-evidence `verdict` is `PASS` (PR #74) +- [x] Branch `slice/24-setup-agent-hooks` created from Frontline integration + branch (DECISIONS Wave H branch-base waiver) +- [x] `tripwire --help` inspection: new subcommand attaches beside `setup` / + `scan` / `route` in `cli/bin/tripwire.js` +- [x] Coverage target set: ≥80% lines on `cli/src/setupAgentHooks.js` ## TDD execution -RED: add setup GWTs for install paths, chmod, config defaults/preserve, and PreToolUse registration. +RED: add setup GWTs for install paths, chmod, config defaults/preserve, and +PreToolUse registration. GREEN: implement only the `setup-agent-hooks` path needed to pass. REFACTOR: keep install idempotent; no enforcement behaviour beyond install wiring. ## After-Checks [GATE] -- [ ] Setup GWT scenarios pass (hooks present + mode 700; config write/preserve; PreToolUse registered) -- [ ] Each GWT Then clause asserts filesystem / settings observables -- [ ] Named test command(s) from AT design exit 0 (record in gate evidence) -- [ ] Coverage target: set at AT design before IN PROGRESS; recorded % meets that target -- [ ] Complexity policy: **enforcing** for product-code; evidence cites quality-gates / complexity report -- [ ] `docs/plan/gate-evidence/slice-24.json` records commands, coverage, complexity, reviewers, and `verdict: PASS` -- [ ] Review: `acceptance: APPROVED` and `implementation: APPROVED` (or docs-only exception in DECISIONS) -- [ ] `PROGRESS.md` + `TRAIL.md` show slice 24 ✅ +- [x] Setup GWT scenarios pass (hooks present + mode 700; config write/preserve; + PreToolUse registered; idempotent) +- [x] Each GWT Then clause asserts filesystem / settings observables +- [x] Named test command(s) from AT design exit 0 (record in gate evidence) +- [x] Coverage target: ≥80% lines on `cli/src/setupAgentHooks.js` (measured + 93.11% 2026-08-15) +- [x] Complexity policy: **enforcing** for product-code; evidence cites + quality-gates / complexity report +- [x] `docs/plan/gate-evidence/slice-24.json` records commands, coverage, + complexity, reviewers, and `verdict: ON_BRANCH` (PASS after merge) +- [x] Review: `acceptance: APPROVED` and `implementation: APPROVED` (nimble + Wave H in-session review per DECISIONS) +- [ ] `PROGRESS.md` + `TRAIL.md` show slice 24 ✅ (after merge to Frontline) ## Doc Audit -| # | Check | -|---|--------| -| 1 | Operator docs name `tripwire setup-agent-hooks` as the single install path | -| 2 | Hook location `~/.tripwire/hooks` and chmod 700 stated | -| 3 | First-write config defaults cross-linked to slice 23 schema | -| 4 | Cross-link gate-evidence ↔ TRAIL/PROGRESS | +| # | Check | Result | +|---|--------|--------| +| 1 | Operator docs name `tripwire setup-agent-hooks` as the single install path | PASS — setup-commands.md | +| 2 | Hook location `~/.tripwire/hooks` and chmod 700 stated | PASS | +| 3 | First-write config defaults cross-linked to slice 23 schema | PASS | +| 4 | Cross-link gate-evidence ↔ TRAIL/PROGRESS | PASS | ## Gate Status -📋 PLANNED +🔀 ON BRANCH diff --git a/docs/user-guide/setup-commands.md b/docs/user-guide/setup-commands.md index 1b60cd9..78a0203 100644 --- a/docs/user-guide/setup-commands.md +++ b/docs/user-guide/setup-commands.md @@ -55,6 +55,22 @@ tripwire setup # optional: ./scripts/setup-supabase.sh ``` +### Frontline agent hooks (Claude Code PreToolUse) + +Single install path for Wave H handlers (requires Tripwire Python package +importable, e.g. repo `.venv` / `uv run`): + +```bash +tripwire setup-agent-hooks +``` + +This installs templates to `~/.tripwire/hooks/` with owner-only mode `700`, +writes `~/.tripwire/config.json` on first run only (`enable=true`, +`scan_validity_days=14` — see `guard/config.py` / slice 23), and registers a +PreToolUse command in `~/.claude/settings.json`. Re-runs are idempotent +(config preserved; hook not duplicated). Fixture/testing overrides: +`--home ` and `--claude-settings `. + ### Verify Supabase access After `tripwire setup`, confirm the anon key (what the browser dashboard uses) From d4fa08dfde842dad3a40d8c4f75abb24f9b5aed0 Mon Sep 17 00:00:00 2001 From: Mani Sarkar Date: Sat, 15 Aug 2026 16:51:21 +0100 Subject: [PATCH 03/12] docs(slice-24): record PR #75 in gate evidence --- docs/plan/gate-evidence/slice-24.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/plan/gate-evidence/slice-24.json b/docs/plan/gate-evidence/slice-24.json index 5148e42..de90189 100644 --- a/docs/plan/gate-evidence/slice-24.json +++ b/docs/plan/gate-evidence/slice-24.json @@ -64,5 +64,5 @@ "verdict": "ON_BRANCH", "phase": "H1", "moscow": "Must", - "pr": null + "pr": "https://github.com/neomatrix369/tripwire/pull/75" } From 8c5ca7d678a43c5492c608a9d4cbc9b97f2ce4a3 Mon Sep 17 00:00:00 2001 From: Mani Sarkar Date: Sat, 15 Aug 2026 17:01:32 +0100 Subject: [PATCH 04/12] feat(slice-25): prove live enforce smoke after setup-agent-hooks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Scripted smoke verifies enable→block (unscanned/RED) and disable→approve on installed hooks; documents the Claude Code install-event gap workaround. --- docs/STATUS.md | 19 +- docs/plan/DECISIONS.md | 3 + docs/plan/PROGRESS.md | 44 ++--- docs/plan/TRAIL.md | 4 +- docs/plan/gate-evidence/slice-24.json | 6 +- docs/plan/gate-evidence/slice-25.json | 90 +++++++-- .../slice-24-setup-agent-hooks.md | 4 +- .../slice-25-live-enforce-smoke.md | 109 ++++++++--- docs/user-guide/setup-commands.md | 16 +- guard/hooks_entry.py | 37 +++- guard/tests/test_config_handler_units.py | 90 +++++++++ guard/tests/test_live_enforce_smoke.py | 178 ++++++++++++++++++ 12 files changed, 507 insertions(+), 93 deletions(-) create mode 100644 guard/tests/test_live_enforce_smoke.py diff --git a/docs/STATUS.md b/docs/STATUS.md index 2f0d420..771e74b 100644 --- a/docs/STATUS.md +++ b/docs/STATUS.md @@ -128,12 +128,14 @@ slices 23–39 on branch `frontline-hackathon-london-2026-agent-hooks`. See **IMPLEMENTED** and **VERIFIED** (PR #74 into Frontline integration branch) — `guard/config.py` (`enable` default `true`, `scan_validity_days` default `14`), `guard/hooks_entry.py` (stdin → stdout approve/block, fail-closed), install - templates under `guard/hooks/`. Install CLI is slice 24. -- **Slice 24 (`tripwire setup-agent-hooks`):** 🔀 ON BRANCH — - **IMPLEMENTED** install path (`cli/src/setupAgentHooks.js` + - `tripwire setup-agent-hooks`); installs hooks to `~/.tripwire/hooks` (mode - `700`), first-write config via `ensure_default_config`, Claude PreToolUse - registration. Merge to Frontline then mark ✅. + templates under `guard/hooks/`. +- **Slice 24 (`tripwire setup-agent-hooks`):** **IMPLEMENTED** and **VERIFIED** + (PR #75) — `cli/src/setupAgentHooks.js`; installs hooks to `~/.tripwire/hooks` + (mode `700`), first-write config via `ensure_default_config`, Claude PreToolUse + registration. +- **Slice 25 (Live Enforce Smoke):** 🔀 ON BRANCH — scripted smoke after install + proves enable→block (unscanned/RED) and disable→approve; docs state no native + install-event hook. Human H2 checkpoint still required before slices 26–30. - Remaining H Musts/Shoulds: not IMPLEMENTED — no `/tw-*` skills yet. --- @@ -154,9 +156,8 @@ Known fixture gaps (not urgent) are listed under as shipped capabilities. Guard PreToolUse and Drift/trend remain Future / Won't (A) for the Horizon A ship path — see [ADR-0015](./adr/0015-horizon-a-excludes-guard-and-drift.md). Frontline Guard -integration is **DECIDED** as Wave H — see DECIDED above. Slice 23 ✅ handlers/ -config; slice 24 installs via `tripwire setup-agent-hooks` (on branch). `/tw-*` -skills still pending later H slices. +integration is **DECIDED** as Wave H — see DECIDED above. Slice 23–24 ✅; +slice 25 live-enforce smoke on branch. `/tw-*` skills still pending later H slices. Coverage audit matrix: [plan/coverage-audit.md](./plan/coverage-audit.md) (slice 7 ✅). Slice stubs: [plan/README.md](./plan/README.md) (`01-A-…` … diff --git a/docs/plan/DECISIONS.md b/docs/plan/DECISIONS.md index 79c3c23..2b89b06 100644 --- a/docs/plan/DECISIONS.md +++ b/docs/plan/DECISIONS.md @@ -101,3 +101,6 @@ | 2026-08-15 | slice-23 | AT design complete | Six GWTs + ≥95% lines on `guard/config.py` + `guard/hooks_entry.py`; sandbox Horizon A cov bar unchanged (ADR-0015). First-write defaults via `ensure_default_config` (slice 24 calls it). | | 2026-08-15 | slice-23 | Walking skeleton Strategy B | Subprocess-light: AT-1 invokes real `guard/hooks/pre-tool-use.sh` via `subprocess.run` with `TRIPWIRE_CONFIG` fixture HOME; remaining ATs drive `handle_pre_tool_use` in-process with injectable `check_call`. | | 2026-08-15 | wave-h | Slice 23 closed | PR #74 merged into `frontline-hackathon-london-2026-agent-hooks`. Wave H integration branch is merge-equivalent for ✅ while H is active (GATE_CONTRACT `main` wording waived for H per 2026-08-15 branch-base decision). | +| 2026-08-15 | slice-24 | ✅ closed | PR #75 merged into `frontline-hackathon-london-2026-agent-hooks`. Gate evidence `verdict: PASS`. Wave H integration branch remains merge-equivalent for ✅ while H is active. | +| 2026-08-15 | slice-25 | AT design complete | Three GWTs: enabled block (param unscanned/RED), disabled approve, install-event docs. Smoke uses installed hooks after `setup-agent-hooks`; `TRIPWIRE_CHECK_CALL_FIXTURE` seam avoids live Supabase in CI. | +| 2026-08-15 | slice-25 | smoke fixture seam | `TRIPWIRE_CHECK_CALL_FIXTURE=unscanned` or `red` in `guard/hooks_entry.py` is CI/smoke only; unset in production → real `check_call`. | diff --git a/docs/plan/PROGRESS.md b/docs/plan/PROGRESS.md index 64379c1..ea64cfa 100644 --- a/docs/plan/PROGRESS.md +++ b/docs/plan/PROGRESS.md @@ -12,30 +12,29 @@ | 5 | [`05-E-…`](slices/05-E-ship-path-coverage/) | **E — Ship-path coverage** | 8 → 11 → 12 → 13 ✅ → 14 (**9+10 SUBSUMED INTO 11**) | ✅ Musts · close-path | | 6 | [`06-F-…`](slices/06-F-claim-audit/) | **F — Claim audit** | 15 · 16 | 📦 | | 7 | [`07-G-…`](slices/07-G-atdd-closure/) | **G — ATDD closure** | 18, 19, 20, 21, 22 (independent gates) | 📋 parked | -| 8 | [`08-H-…`](slices/08-H-frontline-agent-hooks/) | **H — Frontline agent hooks** | 23→32 Must · 33–38 Should · 39 Could | 23 ✅ · 24 🔀 | +| 8 | [`08-H-…`](slices/08-H-frontline-agent-hooks/) | **H — Frontline agent hooks** | 23→32 Must · 33–38 Should · 39 Could | 23–24 ✅ · 25 🔀 | -**Current priority:** Wave H Must slice **24** — `tripwire setup-agent-hooks` — 🔀 ON BRANCH (merge then start 25). Wave G (18–22) parked while H1–H3 is active unless explicitly resumed. +**Current priority:** Wave H Must slice **25** — Live Enforce Smoke — 🔀 ON BRANCH (merge then human H2 checkpoint before 26). Wave G (18–22) parked while H1–H3 is active unless explicitly resumed. ## Execution order (open work) | Order | Wave | # | Slice | MoSCoW | Status | |------:|-----:|---|-------|--------|--------| -| 1 | H1 | 24 | `tripwire setup-agent-hooks` | Must | 🔀 ON BRANCH | -| 2 | H1 | 25 | Live Enforce Smoke | Must | 📋 PLANNED | -| 3 | H2 | 26 | API Introspect + Dual Output Contract | Must | 📋 PLANNED | -| 4 | H2 | 27 | `/tw-enable` + `/tw-disable` | Must | 📋 PLANNED | -| 5 | H2 | 28 | `/tw-verify` | Must | 📋 PLANNED | -| 6 | H2 | 29 | `/tw-scan` | Must | 📋 PLANNED | -| 7 | H2 | 30 | `/tw-self-check` | Must | 📋 PLANNED | -| 8 | H3 | 31 | Demo Artifacts | Must | 📋 PLANNED | -| 9 | H3 | 32 | Phase 1 Regression Verification (HARD GATE) | Must | 📋 PLANNED | -| 10 | H4 | 33 | DepShield Install | Should | 📋 PLANNED | -| 11 | H4 | 34 | DepShield Dispatch | Should | 📋 PLANNED | -| 12 | H5 | 35 | Ossprey Access Provisioning | Should | 🔴 BLOCKED | -| 13 | H5 | 36 | Ossprey Dispatch | Should | 📋 PLANNED | -| 14 | H6 | 37 | CLI Monitoring | Should | 📋 PLANNED | -| 15 | H6 | 38 | Full-Chain Validation | Should | 📋 PLANNED | -| 16 | H6 | 39 | FE/BE Rearchitecture | Could | 📦 DEFERRED | +| 1 | H1 | 25 | Live Enforce Smoke | Must | 🔀 ON BRANCH | +| 2 | H2 | 26 | API Introspect + Dual Output Contract | Must | 📋 PLANNED (blocked on human H2 checkpoint) | +| 3 | H2 | 27 | `/tw-enable` + `/tw-disable` | Must | 📋 PLANNED | +| 4 | H2 | 28 | `/tw-verify` | Must | 📋 PLANNED | +| 5 | H2 | 29 | `/tw-scan` | Must | 📋 PLANNED | +| 6 | H2 | 30 | `/tw-self-check` | Must | 📋 PLANNED | +| 7 | H3 | 31 | Demo Artifacts | Must | 📋 PLANNED | +| 8 | H3 | 32 | Phase 1 Regression Verification (HARD GATE) | Must | 📋 PLANNED | +| 9 | H4 | 33 | DepShield Install | Should | 📋 PLANNED | +| 10 | H4 | 34 | DepShield Dispatch | Should | 📋 PLANNED | +| 11 | H5 | 35 | Ossprey Access Provisioning | Should | 🔴 BLOCKED | +| 12 | H5 | 36 | Ossprey Dispatch | Should | 📋 PLANNED | +| 13 | H6 | 37 | CLI Monitoring | Should | 📋 PLANNED | +| 14 | H6 | 38 | Full-Chain Validation | Should | 📋 PLANNED | +| 15 | H6 | 39 | FE/BE Rearchitecture | Could | 📦 DEFERRED | | — | G | 18–22 | ATDD closure (parked) | Must | 📋 PLANNED | ## Quick Status (by group) @@ -94,8 +93,8 @@ | # | Slice | MoSCoW | Status | Started | Completed | Est. time | |---|-------|--------|--------|---------|-----------|-----------| | 23 | [slice-23-config-handler-scripts](slices/08-H-frontline-agent-hooks/slice-23-config-handler-scripts.md) | Must | ✅ | 2026-08-15 | 2026-08-15 | ~40 min | -| 24 | [slice-24-setup-agent-hooks](slices/08-H-frontline-agent-hooks/slice-24-setup-agent-hooks.md) | Must | 🔀 ON BRANCH | 2026-08-15 | — | ~40 min | -| 25 | [slice-25-live-enforce-smoke](slices/08-H-frontline-agent-hooks/slice-25-live-enforce-smoke.md) | Must | 📋 PLANNED | — | — | ~30 min | +| 24 | [slice-24-setup-agent-hooks](slices/08-H-frontline-agent-hooks/slice-24-setup-agent-hooks.md) | Must | ✅ | 2026-08-15 | 2026-08-15 | ~40 min | +| 25 | [slice-25-live-enforce-smoke](slices/08-H-frontline-agent-hooks/slice-25-live-enforce-smoke.md) | Must | 🔀 ON BRANCH | 2026-08-15 | — | ~30 min | | 26 | [slice-26-api-output-contract](slices/08-H-frontline-agent-hooks/slice-26-api-output-contract.md) | Must | 📋 PLANNED | — | — | ~40 min | | 27 | [slice-27-tw-enable-disable](slices/08-H-frontline-agent-hooks/slice-27-tw-enable-disable.md) | Must | 📋 PLANNED | — | — | ~25 min | | 28 | [slice-28-tw-verify](slices/08-H-frontline-agent-hooks/slice-28-tw-verify.md) | Must | 📋 PLANNED | — | — | ~50 min | @@ -123,7 +122,7 @@ ## Forward Roadmap - Waves **A–C**, coverage Slice 14, and Slice 17 are merged and closed. Slice 15 is retained as a deferred claim-audit artifact, not active work. -- **Wave H (Frontline):** integration branch `frontline-hackathon-london-2026-agent-hooks`. Slice 23 ✅ (PR #74). Slice 24 🔀 ON BRANCH (`setup-agent-hooks`). Next after merge: 25. +- **Wave H (Frontline):** integration branch `frontline-hackathon-london-2026-agent-hooks`. Slice 23 ✅ (PR #74). Slice 24 ✅ (PR #75). Slice 25 🔀 ON BRANCH (`live-enforce-smoke`). Next after merge + human H2 checkpoint: 26. - Wave G (18–22) remains planned but **parked** while Frontline H1–H3 is active unless explicitly resumed. - Reopen Slice 15 only for a future live/demo release that needs its security and 3B evidence path. - **Deferred / Won't (A):** 4 (in A); 15 and 16 (in F) — reinstate only if a new live/demo need arises @@ -161,4 +160,5 @@ | 2026-08-02 | docs/gate-contract-onboarding-priority | sync-docs + clean-commit | 7, plan | pushed | Groups + close slice 7 on trackers | | 2026-08-15 | frontline-hackathon-london-2026-agent-hooks | enhanced-flow-planner Add | 23–39 | 📋 stubs | Wave H Frontline plan-only; Wave G parked | | 2026-08-15 | slice/23-config-handler-scripts | slice-workflow | 23 | ✅ PASSED (PR #74) | Config+handlers; 6 GWTs; cov 98.8%; merged to Frontline | -| 2026-08-15 | slice/24-setup-agent-hooks | slice-workflow | 24 | 🔀 ON BRANCH | setup-agent-hooks; 7 tests; cov 93.11%; quality-gates PASS | +| 2026-08-15 | slice/24-setup-agent-hooks | slice-workflow | 24 | ✅ PASSED (PR #75) | setup-agent-hooks; 7 tests; cov 93.11%; merged to Frontline | +| 2026-08-15 | slice/25-live-enforce-smoke | slice-workflow | 25 | 🔀 ON BRANCH | live enforce smoke; 4 ATs; hooks_entry fixture seam; quality-gates PASS | diff --git a/docs/plan/TRAIL.md b/docs/plan/TRAIL.md index 7664963..66a815f 100644 --- a/docs/plan/TRAIL.md +++ b/docs/plan/TRAIL.md @@ -160,8 +160,8 @@ Branch: `frontline-hackathon-london-2026-agent-hooks`. Source: `internal-docs/04 | # | File | Name | MoSCoW | Status | Depends on | Issue | Read time | |---|------|------|--------|--------|------------|-------|-----------| | 23 | [slice-23-config-handler-scripts](slices/08-H-frontline-agent-hooks/slice-23-config-handler-scripts.md) | Config + Handler Scripts | Must | ✅ | none | #74 | ~4 min | -| 24 | [slice-24-setup-agent-hooks](slices/08-H-frontline-agent-hooks/slice-24-setup-agent-hooks.md) | `tripwire setup-agent-hooks` | Must | 🔀 | 23 | — | ~4 min | -| 25 | [slice-25-live-enforce-smoke](slices/08-H-frontline-agent-hooks/slice-25-live-enforce-smoke.md) | Live Enforce Smoke | Must | 📋 | 24 | — | ~3 min | +| 24 | [slice-24-setup-agent-hooks](slices/08-H-frontline-agent-hooks/slice-24-setup-agent-hooks.md) | `tripwire setup-agent-hooks` | Must | ✅ | 23 | #75 | ~4 min | +| 25 | [slice-25-live-enforce-smoke](slices/08-H-frontline-agent-hooks/slice-25-live-enforce-smoke.md) | Live Enforce Smoke | Must | 🔀 | 24 | — | ~3 min | #### H2 — Shared contracts + control skills (Must) diff --git a/docs/plan/gate-evidence/slice-24.json b/docs/plan/gate-evidence/slice-24.json index de90189..30b52aa 100644 --- a/docs/plan/gate-evidence/slice-24.json +++ b/docs/plan/gate-evidence/slice-24.json @@ -1,6 +1,6 @@ { "slice": 24, - "gate_status": "ON_BRANCH", + "gate_status": "PASSED", "inferred": false, "branch": "slice/24-setup-agent-hooks", "date": "2026-08-15", @@ -13,7 +13,7 @@ "PASS — coverage 93.11% lines on cli/src/setupAgentHooks.js (≥80%)", "PASS — ./scripts/quality-gates.sh", "PASS — nimble in-session review acceptance+implementation APPROVED (DECISIONS Wave H nimble reviews)", - "PENDING merge — PROGRESS/TRAIL 🔀 until merged to Frontline integration" + "PASS — merged to Frontline integration via PR #75" ], "commands": [ { @@ -61,7 +61,7 @@ "3_config_defaults": "PASS — cross-link guard/config.py / slice 23", "4_cross_link": "PASS — gate-evidence ↔ TRAIL/PROGRESS" }, - "verdict": "ON_BRANCH", + "verdict": "PASS", "phase": "H1", "moscow": "Must", "pr": "https://github.com/neomatrix369/tripwire/pull/75" diff --git a/docs/plan/gate-evidence/slice-25.json b/docs/plan/gate-evidence/slice-25.json index f618657..c3f1559 100644 --- a/docs/plan/gate-evidence/slice-25.json +++ b/docs/plan/gate-evidence/slice-25.json @@ -1,24 +1,78 @@ { "slice": 25, - "gate_status": "PLANNED", + "gate_status": "ON_BRANCH", "inferred": false, - "branch": null, - "date": null, - "before_checks": [], - "after_checks": [], - "planned_commands": [ - "test -f docs/plan/slices/08-H-frontline-agent-hooks/slice-25-live-enforce-smoke.md", - "rg -n \"install-event|setup-agent-hooks\" docs/ internal-docs/04_frontline/", - "test -x \"$HOME/.tripwire/hooks/pre-tool-use.sh\" || echo \"hooks not installed yet — expected until GREEN\"", - "./scripts/quality-gates.sh" + "branch": "slice/25-live-enforce-smoke", + "date": "2026-08-15", + "spec_path": "docs/plan/slices/08-H-frontline-agent-hooks/slice-25-live-enforce-smoke.md", + "before_checks": "PASS — slice 24 verdict PASS (PR #75); branch from Frontline integration (DECISIONS Wave H base); smoke fixture HOME + TRIPWIRE_CHECK_CALL_FIXTURE noted; AT design + cov targets set", + "after_checks": [ + "PASS — enabled unscanned/RED → block (parametrized smoke)", + "PASS — disabled → approve (installed pre-tool-use.sh short-circuit)", + "PASS — docs greppable: no native install-event hook; setup-agent-hooks workaround", + "PASS — .venv/bin/pytest guard/tests/test_live_enforce_smoke.py -q (4 passed)", + "PASS — coverage guard.config+hooks_entry 100% lines (≥95%); complexity enforcing via quality-gates", + "PASS — ./scripts/quality-gates.sh", + "PASS — nimble in-session review acceptance+implementation APPROVED (DECISIONS Wave H nimble reviews)", + "PENDING human H2 checkpoint — operator sign-off before slices 26–30", + "PENDING merge — PROGRESS/TRAIL 🔀 until merged to Frontline integration" ], - "commands": [], - "test_budget": {"acceptance_tests_max": 7, "parametrized_case_counts_as_one": true}, - "reviewers": [], - "review": {"acceptance": "PENDING", "implementation": "PENDING"}, - "verdict": "NOT_RUN", - "coverage_target": "TBD at AT design before IN PROGRESS", - "complexity_policy": "enforcing for product-code; N/A for docs-only with reason in evidence", + "commands": [ + { + "cmd": ".venv/bin/pytest guard/tests/test_live_enforce_smoke.py -q --tb=short", + "result": "PASS exit 0 — 4 passed (enabled block unscanned|red; disabled approve; docs claim)" + }, + { + "cmd": ".venv/bin/pytest guard/tests/test_live_enforce_smoke.py guard/tests/test_config_handler_scripts.py guard/tests/test_config_handler_units.py -q --cov=guard.config --cov=guard.hooks_entry --cov-report=term-missing --cov-fail-under=95", + "result": "PASS — 22 passed; 100% lines on guard.config + guard.hooks_entry" + }, + { + "cmd": "rg -n \"no native install-event|setup-agent-hooks|Enable / disable smoke\" docs/user-guide/setup-commands.md", + "result": "PASS — install-event gap + workaround + enable/disable smoke steps greppable" + }, + { + "cmd": "./scripts/quality-gates.sh", + "result": "PASS — quality-gates passed" + } + ], + "smoke_procedure": { + "install": "node cli/bin/tripwire.js setup-agent-hooks --home --claude-settings /.claude/settings.json", + "enabled_block": "enable=true + TRIPWIRE_CHECK_CALL_FIXTURE=unscanned|red + bash ~/.tripwire/hooks/pre-tool-use.sh", + "disabled_approve": "enable=false + same stdin → decision approve", + "note": "Fixture seam avoids live Supabase; production leaves TRIPWIRE_CHECK_CALL_FIXTURE unset" + }, + "test_budget": { + "acceptance_tests_max": 7, + "acceptance_tests_designed": 3, + "parametrized_case_counts_as_one": true, + "extra_surface_tests": ["in-process fixture seam units for coverage"] + }, + "coverage": { + "target": "≥95% lines guard/config.py + guard/hooks_entry.py", + "measured": "100%", + "date": "2026-08-15" + }, + "complexity_policy": "enforcing — quality-gates.sh xenon PASS", + "human_checkpoint_h2": { + "required": true, + "status": "PENDING_OPERATOR_SIGNOFF", + "blocks": "Phase H2 slices 26–30", + "note": "Scripted smoke VERIFIED; human Claude Code session observation optional but Phase H2 needs DECISIONS/gate-evidence sign-off" + }, + "reviewers": ["in-session nimble (acceptance+implementation)"], + "review": { + "acceptance": "APPROVED", + "implementation": "APPROVED", + "notes": "GWTs bind to real setup-agent-hooks install + installed pre-tool-use.sh; enable short-circuit proven; unscanned/RED via documented smoke seam; install-event docs greppable. No H2 skill surface expansion." + }, + "documentation_audit": { + "1_install_event_gap": "PASS — docs/user-guide/setup-commands.md", + "2_enable_disable_smoke": "PASS — Enable / disable smoke (operator) paragraph", + "3_cross_link": "PASS — gate-evidence ↔ TRAIL/PROGRESS", + "4_human_checkpoint": "PASS — recorded PENDING_OPERATOR_SIGNOFF in evidence + slice After-Checks" + }, + "verdict": "ON_BRANCH", "phase": "H1", - "moscow": "Must" + "moscow": "Must", + "pr": null } diff --git a/docs/plan/slices/08-H-frontline-agent-hooks/slice-24-setup-agent-hooks.md b/docs/plan/slices/08-H-frontline-agent-hooks/slice-24-setup-agent-hooks.md index e060f6d..8db4aa6 100644 --- a/docs/plan/slices/08-H-frontline-agent-hooks/slice-24-setup-agent-hooks.md +++ b/docs/plan/slices/08-H-frontline-agent-hooks/slice-24-setup-agent-hooks.md @@ -102,7 +102,7 @@ REFACTOR: keep install idempotent; no enforcement behaviour beyond install wirin complexity, reviewers, and `verdict: ON_BRANCH` (PASS after merge) - [x] Review: `acceptance: APPROVED` and `implementation: APPROVED` (nimble Wave H in-session review per DECISIONS) -- [ ] `PROGRESS.md` + `TRAIL.md` show slice 24 ✅ (after merge to Frontline) +- [x] `PROGRESS.md` + `TRAIL.md` show slice 24 ✅ (merged Frontline via PR #75) ## Doc Audit @@ -115,4 +115,4 @@ REFACTOR: keep install idempotent; no enforcement behaviour beyond install wirin ## Gate Status -🔀 ON BRANCH +✅ PASSED diff --git a/docs/plan/slices/08-H-frontline-agent-hooks/slice-25-live-enforce-smoke.md b/docs/plan/slices/08-H-frontline-agent-hooks/slice-25-live-enforce-smoke.md index 3fd371f..6a72a18 100644 --- a/docs/plan/slices/08-H-frontline-agent-hooks/slice-25-live-enforce-smoke.md +++ b/docs/plan/slices/08-H-frontline-agent-hooks/slice-25-live-enforce-smoke.md @@ -8,55 +8,102 @@ Live smoke proves enforcement: with hooks enabled, an unscanned or RED artifact ## GWT acceptance specification -Thin scaffolds — full DISTILL ATs deferred per DECISIONS; design ATs before marking IN PROGRESS. - -1. **Enabled blocks unscanned / RED** - - Given setup-agent-hooks installed and `enable=true`, when a PreToolUse event targets an unscanned or RED artifact, then the handler stdout decision is `block`. -2. **Disabled approves** - - Given the same install with `enable=false`, when the same PreToolUse event fires, then stdout decision is `approve`. -3. **Install-event gap documented** - - Given operator-facing docs, when searching for install-event / setup workaround language, then docs state there is no native install-event hook and name `tripwire setup-agent-hooks` as the workaround. +**DISTILL ATs (2026-08-15)** — ≤7; parametrized cases count as one. + +| # | Scenario | Tags | Real-surface binding | +|---|----------|------|----------------------| +| 1 | Enabled blocks unscanned / RED | `@walking_skeleton` `@US-25` | `tripwire setup-agent-hooks` → installed `pre-tool-use.sh` | +| 2 | Disabled approves same call | `@US-25` | installed `pre-tool-use.sh` + `enable=false` | +| 3 | Install-event gap documented | `@US-25` | operator docs greppable claim | + +1. **Enabled blocks unscanned / RED** `@walking_skeleton` `@US-25` + - Given `tripwire setup-agent-hooks` installed into a fixture HOME with + `enable=true`, and a smoke fixture that returns unscanned **or** RED from + the guard check seam (`TRIPWIRE_CHECK_CALL_FIXTURE`), + when the installed `~/.tripwire/hooks/pre-tool-use.sh` runs with Claude + Code PreToolUse stdin JSON, + then stdout JSON has `"decision":"block"`, a non-empty `"reason"`, and + exit is `0`. + - Parametrize: `unscanned` → reason contains `never scanned`; `red` → reason + contains `rated red` (case-insensitive). Counts as **one** AT. +2. **Disabled approves same call** `@US-25` + - Given the same install with `enable=false` (and the same fixture stdin), + when the installed `pre-tool-use.sh` runs, + then stdout JSON has `"decision":"approve"` and exit is `0` (shell + short-circuit; guard check not required). +3. **Install-event gap documented** `@US-25` + - Given operator-facing docs under `docs/user-guide/`, + when searching for install-event / setup workaround language, + then docs state Claude Code has **no native install-event hook** and name + `tripwire setup-agent-hooks` as the workaround. + +**Test inventory (3 acceptance tests):** enabled block (param unscanned|RED); +disabled approve; docs greppable install-event gap. + +**Named verification command:** + +```bash +.venv/bin/pytest guard/tests/test_live_enforce_smoke.py -q --tb=short +``` + +**Coverage / complexity (AT design):** + +- Smoke + docs primary. Product touch limited to an explicit + `TRIPWIRE_CHECK_CALL_FIXTURE` seam in `guard/hooks_entry.py` (unscanned|red + only) so CI smoke does not require live Supabase. +- Coverage: existing slice-23 bar on `guard/config.py` + `guard/hooks_entry.py` + remains ≥95% lines (smoke + prior GWTs). No new Horizon A sandbox floor change. +- Complexity: **enforcing** for any product-code change (`quality-gates.sh`). ## Design / test treatment -- Prefer scripted smoke against installed hooks + fixture config; record human observation where a true Claude Code session is required. -- Keep smoke scope to enable/disable + unscanned/RED block — not full skill surface (H2). -- **AT design required before IN PROGRESS**; human checkpoint required before Phase H2. +- Prefer scripted smoke against **installed** hooks + fixture HOME (real + `tripwire setup-agent-hooks` CLI), not in-process-only handler unit tests. +- `TRIPWIRE_CHECK_CALL_FIXTURE=unscanned|red` is a smoke/test seam only; unset + in production → real `guard.guard_hook.check_call`. +- Keep smoke scope to enable/disable + unscanned/RED block — not full skill + surface (H2). +- **AT design complete** — ready for 🔨 IN PROGRESS. +- **Human checkpoint:** Phase H2 (26–30) must not start until a human signs off + this smoke in gate evidence / DECISIONS. ## Before-Checks [GATE] -- [ ] Slice 24 gate-evidence `verdict` is `PASS` -- [ ] Branch `slice/25-live-enforce-smoke` created from current `main` -- [ ] Smoke fixture artifacts (or demo placeholders) and enable/disable procedure noted in gate evidence -- [ ] Coverage/complexity targets TBD until AT design completes +- [x] Slice 24 gate-evidence `verdict` is `PASS` (PR #75 merged to Frontline) +- [x] Branch `slice/25-live-enforce-smoke` created from Frontline integration + branch (DECISIONS Wave H branch-base waiver) +- [x] Smoke fixture procedure noted: fixture HOME + `TRIPWIRE_CHECK_CALL_FIXTURE` + + enable flip; recorded in gate evidence +- [x] Coverage/complexity targets set at AT design (above) ## TDD execution RED: add smoke GWTs / scripted checks for enabled block and disabled approve. -GREEN: only fix wiring gaps that block the smoke path. +GREEN: only fix wiring gaps that block the smoke path (fixture seam + docs). REFACTOR: document the install-event workaround without expanding product scope. ## After-Checks [GATE] -- [ ] Enabled unscanned/RED → `block` observed and recorded in gate evidence -- [ ] Disabled → `approve` observed and recorded in gate evidence -- [ ] Docs claim greppable: no native install-event hook; workaround is `tripwire setup-agent-hooks` -- [ ] Named smoke/test command(s) exit 0 (or human observation logged with date + result) -- [ ] Coverage/complexity: policy note recorded (enforcing for product-code touched; N/A with reason if docs/smoke-only) -- [ ] `docs/plan/gate-evidence/slice-25.json` records commands/observations, reviewers, and `verdict: PASS` -- [ ] Review: `acceptance: APPROVED` and `implementation: APPROVED` (or docs-only exception in DECISIONS) -- [ ] `PROGRESS.md` + `TRAIL.md` show slice 25 ✅ +- [x] Enabled unscanned/RED → `block` observed and recorded in gate evidence +- [x] Disabled → `approve` observed and recorded in gate evidence +- [x] Docs claim greppable: no native install-event hook; workaround is `tripwire setup-agent-hooks` +- [x] Named smoke/test command(s) exit 0 (or human observation logged with date + result) +- [x] Coverage/complexity: policy note recorded (enforcing for product-code touched; N/A with reason if docs/smoke-only) +- [x] `docs/plan/gate-evidence/slice-25.json` records commands/observations, reviewers, and `verdict: ON_BRANCH` (PASS after merge) +- [x] Review: `acceptance: APPROVED` and `implementation: APPROVED` (nimble + Wave H in-session review per DECISIONS) +- [ ] `PROGRESS.md` + `TRAIL.md` show slice 25 ✅ (after merge to Frontline) - [ ] **Human test checkpoint:** Phase H2 (slices 26–30) must not start until a human signs off this smoke in gate evidence / DECISIONS ## Doc Audit -| # | Check | -|---|--------| -| 1 | Install-event absence + `setup-agent-hooks` workaround documented | -| 2 | Enable/disable smoke steps greppable for operators | -| 3 | Cross-link gate-evidence ↔ TRAIL/PROGRESS | -| 4 | Human checkpoint before H2 recorded | +| # | Check | Result | +|---|--------|--------| +| 1 | Install-event absence + `setup-agent-hooks` workaround documented | PASS — setup-commands.md | +| 2 | Enable/disable smoke steps greppable for operators | PASS | +| 3 | Cross-link gate-evidence ↔ TRAIL/PROGRESS | PASS | +| 4 | Human checkpoint before H2 recorded | PASS — PENDING_OPERATOR_SIGNOFF in evidence | ## Gate Status -📋 PLANNED +🔀 ON BRANCH diff --git a/docs/user-guide/setup-commands.md b/docs/user-guide/setup-commands.md index 78a0203..3a1f0ff 100644 --- a/docs/user-guide/setup-commands.md +++ b/docs/user-guide/setup-commands.md @@ -57,20 +57,28 @@ tripwire setup ### Frontline agent hooks (Claude Code PreToolUse) -Single install path for Wave H handlers (requires Tripwire Python package -importable, e.g. repo `.venv` / `uv run`): +Claude Code has **no native install-event hook**. The workaround is a one-shot +operator install: ```bash tripwire setup-agent-hooks ``` -This installs templates to `~/.tripwire/hooks/` with owner-only mode `700`, -writes `~/.tripwire/config.json` on first run only (`enable=true`, +Single install path for Wave H handlers (requires Tripwire Python package +importable, e.g. repo `.venv` / `uv run`). This installs templates to +`~/.tripwire/hooks/` with owner-only mode `700`, writes +`~/.tripwire/config.json` on first run only (`enable=true`, `scan_validity_days=14` — see `guard/config.py` / slice 23), and registers a PreToolUse command in `~/.claude/settings.json`. Re-runs are idempotent (config preserved; hook not duplicated). Fixture/testing overrides: `--home ` and `--claude-settings `. +**Enable / disable smoke (operator):** after install, set +`"enable": true` in `~/.tripwire/config.json` — PreToolUse blocks unscanned or +RED artifacts. Set `"enable": false` — the same PreToolUse call is approved +(full bypass). Scripted smoke: +`pytest guard/tests/test_live_enforce_smoke.py -q`. + ### Verify Supabase access After `tripwire setup`, confirm the anon key (what the browser dashboard uses) diff --git a/guard/hooks_entry.py b/guard/hooks_entry.py index ba8c985..15f54b2 100644 --- a/guard/hooks_entry.py +++ b/guard/hooks_entry.py @@ -6,7 +6,8 @@ error occurs (**fail-closed**). Does not perform synchronous scans. Wraps ``guard.guard_hook.check_call`` by -default; tests inject a fake ``check_call``. +default; tests inject a fake ``check_call``. CI smoke may set +``TRIPWIRE_CHECK_CALL_FIXTURE`` to ``unscanned`` or ``red`` (unset in production). """ from __future__ import annotations @@ -32,6 +33,20 @@ def _config_path_from_env() -> Path: return Path.home() / ".tripwire" / "config.json" +_SMOKE_FIXTURES: dict[str, dict[str, Any]] = { + "unscanned": { + "allow": False, + "reason": "never scanned — guard fails closed", + "status": "grey", + }, + "red": { + "allow": False, + "reason": "rated red — at/above threshold", + "status": "red", + }, +} + + def _default_check_call(content_bytes: bytes) -> dict[str, Any]: # Lazy import: guard_hook pulls supabase; keep hooks_entry importable without it. from guard.guard_hook import check_call @@ -39,6 +54,21 @@ def _default_check_call(content_bytes: bytes) -> dict[str, Any]: return check_call(content_bytes) +def _smoke_check_call_from_env() -> CheckCall | None: + """Optional CI/smoke seam — unset in production. Values: unscanned | red.""" + key = (os.environ.get("TRIPWIRE_CHECK_CALL_FIXTURE") or "").strip().lower() + if not key: + return None + fixture = _SMOKE_FIXTURES.get(key) + if fixture is None: + return None + + def _fixed(_content_bytes: bytes) -> dict[str, Any]: + return dict(fixture) + + return _fixed + + def _decision_approve() -> str: return json.dumps({"decision": "approve"}, separators=(",", ":")) @@ -61,7 +91,10 @@ def handle_pre_tool_use( if not config.get("enable", True): return _decision_approve(), 0 - guard_fn = check_call if check_call is not None else _default_check_call + if check_call is not None: + guard_fn = check_call + else: + guard_fn = _smoke_check_call_from_env() or _default_check_call content = b"" if target_content is None else target_content try: result = guard_fn(content) diff --git a/guard/tests/test_config_handler_units.py b/guard/tests/test_config_handler_units.py index 1e7905c..d30f4ea 100644 --- a/guard/tests/test_config_handler_units.py +++ b/guard/tests/test_config_handler_units.py @@ -174,3 +174,93 @@ def test_given_home_config_when_no_env_override_then_uses_home( ### Then assert code == 0 assert json.loads(stdout)["decision"] == "approve" + + +@pytest.mark.parametrize( + ("fixture", "reason_snippet"), + [ + ("unscanned", "never scanned"), + ("red", "rated red"), + ], +) +def test_given_check_call_fixture_env_when_handler_runs_then_blocks( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + fixture: str, + reason_snippet: str, +) -> None: + """ + Scenario: TRIPWIRE_CHECK_CALL_FIXTURE supplies unscanned/RED without Supabase. + Slice: 25 — smoke seam (in-process coverage) + """ + from guard.hooks_entry import handle_pre_tool_use + + ### Given + path = tmp_path / "cfg.json" + path.write_text(json.dumps({"enable": True, "scan_validity_days": 14}), encoding="utf-8") + monkeypatch.setenv("TRIPWIRE_CHECK_CALL_FIXTURE", fixture) + + ### When + stdout, code = handle_pre_tool_use(b"{}", config_path=path) + + ### Then + assert code == 0 + payload = json.loads(stdout) + assert payload["decision"] == "block" + assert reason_snippet in payload["reason"].lower() + + +def test_given_unknown_check_call_fixture_when_handler_runs_then_uses_default( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """Scenario: Unknown fixture key falls through to default check_call.""" + import sys + from types import ModuleType + + from guard import hooks_entry + + ### Given + path = tmp_path / "cfg.json" + path.write_text(json.dumps({"enable": True, "scan_validity_days": 14}), encoding="utf-8") + monkeypatch.setenv("TRIPWIRE_CHECK_CALL_FIXTURE", "not-a-real-fixture") + + fake_hook = ModuleType("guard.guard_hook") + fake_hook.check_call = lambda _: { # type: ignore[attr-defined] + "allow": True, + "reason": "default path", + "status": "green", + } + monkeypatch.setitem(sys.modules, "guard.guard_hook", fake_hook) + + ### When + stdout, code = hooks_entry.handle_pre_tool_use(b"{}", config_path=path) + + ### Then + assert code == 0 + assert json.loads(stdout)["decision"] == "approve" + + +def test_given_main_stdout_already_newline_when_runs_then_no_double_newline( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] +) -> None: + """Scenario: main() does not append a second newline when stdout already ends with one.""" + from guard import hooks_entry + + ### Given + path = tmp_path / "cfg.json" + path.write_text(json.dumps({"enable": False, "scan_validity_days": 14}), encoding="utf-8") + monkeypatch.setenv("TRIPWIRE_CONFIG", str(path)) + monkeypatch.setattr(sys, "stdin", io.TextIOWrapper(io.BytesIO(b"{}"))) + monkeypatch.setattr( + hooks_entry, + "handle_pre_tool_use", + lambda *_a, **_k: ('{"decision":"approve"}\n', 0), + ) + + ### When + with pytest.raises(SystemExit) as exc: + hooks_entry.main() + + ### Then + assert exc.value.code == 0 + assert capsys.readouterr().out == '{"decision":"approve"}\n' diff --git a/guard/tests/test_live_enforce_smoke.py b/guard/tests/test_live_enforce_smoke.py new file mode 100644 index 0000000..f9d8592 --- /dev/null +++ b/guard/tests/test_live_enforce_smoke.py @@ -0,0 +1,178 @@ +""" +Tests for live enforce smoke after tripwire setup-agent-hooks (slice 25). + +Author: swami +Created: 2026-08-15 +Scope: installed PreToolUse enable→block (unscanned|RED), disable→approve; + docs install-event gap claim (separate assertion in same module) +""" + +from __future__ import annotations + +import json +import os +import shutil +import subprocess +from pathlib import Path + +import pytest + +REPO_ROOT = Path(__file__).resolve().parents[2] +TRIPWIRE_BIN = REPO_ROOT / "cli" / "bin" / "tripwire.js" +SETUP_COMMANDS = REPO_ROOT / "docs" / "user-guide" / "setup-commands.md" + +FIXTURE_STDIN = json.dumps( + { + "tool_name": "Skill", + "tool_input": {"skill": "demo-unscanned"}, + "session_id": "slice-25-smoke", + } +).encode() + +BLOCK_FIXTURE_TO_REASON_SNIPPET = ( + ("unscanned", "never scanned"), + ("red", "rated red"), +) + + +def _write_config(path: Path, *, enable: bool) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text( + json.dumps({"enable": enable, "scan_validity_days": 14}), + encoding="utf-8", + ) + + +def _run_setup(home: Path) -> Path: + """Install hooks into fixture HOME; return path to pre-tool-use.sh.""" + assert TRIPWIRE_BIN.is_file(), f"missing tripwire CLI: {TRIPWIRE_BIN}" + settings = home / ".claude" / "settings.json" + settings.parent.mkdir(parents=True, exist_ok=True) + proc = subprocess.run( + [ + "node", + str(TRIPWIRE_BIN), + "setup-agent-hooks", + "--home", + str(home), + "--claude-settings", + str(settings), + ], + cwd=str(REPO_ROOT), + capture_output=True, + text=True, + check=False, + env={**os.environ, "HOME": str(home)}, + ) + assert proc.returncode == 0, f"setup-agent-hooks failed: {proc.stderr or proc.stdout}" + payload = json.loads(proc.stdout.strip().splitlines()[-1]) + pre_tool_use = Path(payload["preToolUseSh"]) + assert pre_tool_use.is_file(), f"installed hook missing: {pre_tool_use}" + return pre_tool_use + + +def _invoke_installed_hook( + *, + home: Path, + pre_tool_use: Path, + fixture: str | None, +) -> subprocess.CompletedProcess[bytes]: + env = os.environ.copy() + env["HOME"] = str(home) + env["TRIPWIRE_CONFIG"] = str(home / ".tripwire" / "config.json") + env["PYTHONPATH"] = str(REPO_ROOT) + os.pathsep + env.get("PYTHONPATH", "") + if fixture: + env["TRIPWIRE_CHECK_CALL_FIXTURE"] = fixture + else: + env.pop("TRIPWIRE_CHECK_CALL_FIXTURE", None) + return subprocess.run( + ["bash", str(pre_tool_use)], + input=FIXTURE_STDIN, + capture_output=True, + env=env, + check=False, + cwd=str(REPO_ROOT), + ) + + +@pytest.mark.parametrize( + ("fixture", "reason_snippet"), + BLOCK_FIXTURE_TO_REASON_SNIPPET, + ids=["unscanned", "red"], +) +def test_given_setup_and_enabled_when_pre_tool_use_targets_bad_artifact_then_block( + tmp_path: Path, + fixture: str, + reason_snippet: str, +) -> None: + """ + Scenario: Installed hooks block unscanned or RED when enable=true. + Slice: 25 — enabled blocks unscanned / RED + + Given setup-agent-hooks installed and enable=true with a smoke guard fixture, + When the installed PreToolUse handler runs, + Then stdout decision is block with a matching reason and exit 0. + """ + ### Given + home = tmp_path / "home" + home.mkdir() + pre_tool_use = _run_setup(home) + _write_config(home / ".tripwire" / "config.json", enable=True) + + ### When + proc = _invoke_installed_hook(home=home, pre_tool_use=pre_tool_use, fixture=fixture) + + ### Then + assert proc.returncode == 0, f"hook exit: {proc.stderr.decode()}" + payload = json.loads(proc.stdout.decode()) + assert payload["decision"] == "block", f"expected block, got {payload}" + assert reason_snippet in payload.get("reason", "").lower(), payload + + +def test_given_setup_and_disabled_when_same_pre_tool_use_then_approve( + tmp_path: Path, +) -> None: + """ + Scenario: Installed hooks approve when enable=false (bypass). + Slice: 25 — disabled approves same call + + Given the same install with enable=false, + When the installed PreToolUse handler runs with the same stdin, + Then stdout decision is approve and exit 0. + """ + ### Given + home = tmp_path / "home" + home.mkdir() + pre_tool_use = _run_setup(home) + _write_config(home / ".tripwire" / "config.json", enable=False) + + ### When + proc = _invoke_installed_hook(home=home, pre_tool_use=pre_tool_use, fixture="red") + + ### Then + assert proc.returncode == 0, f"hook exit: {proc.stderr.decode()}" + payload = json.loads(proc.stdout.decode()) + assert payload["decision"] == "approve", f"expected approve, got {payload}" + + +def test_given_operator_docs_when_searched_then_install_event_gap_documented() -> None: + """ + Scenario: Docs state no native install-event hook; setup-agent-hooks is workaround. + Slice: 25 — install-event gap documented + + Given operator-facing setup-commands.md, + When searching for install-event / setup workaround language, + Then the file states there is no native install-event hook and names + tripwire setup-agent-hooks. + """ + ### Given + assert SETUP_COMMANDS.is_file() + text = SETUP_COMMANDS.read_text(encoding="utf-8").lower() + + ### When / Then + assert "no native install-event" in text or "no native install event" in text, ( + "docs must state Claude Code has no native install-event hook" + ) + assert "setup-agent-hooks" in text, "docs must name tripwire setup-agent-hooks" + # Ensure node is available for setup smokes in this environment. + assert shutil.which("node"), "node required for setup-agent-hooks smoke" From d910b1c2b404e953eba32e9513b25f913f2a0fd4 Mon Sep 17 00:00:00 2001 From: Mani Sarkar Date: Sat, 15 Aug 2026 17:04:48 +0100 Subject: [PATCH 05/12] docs(slice-25): record PR #76 in gate evidence --- docs/plan/gate-evidence/slice-25.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/plan/gate-evidence/slice-25.json b/docs/plan/gate-evidence/slice-25.json index c3f1559..3d9d79a 100644 --- a/docs/plan/gate-evidence/slice-25.json +++ b/docs/plan/gate-evidence/slice-25.json @@ -74,5 +74,5 @@ "verdict": "ON_BRANCH", "phase": "H1", "moscow": "Must", - "pr": null + "pr": "https://github.com/neomatrix369/tripwire/pull/76" } From 2674d05f6b2fdf9d5bd8919aa7e7206b84a1ce17 Mon Sep 17 00:00:00 2001 From: Mani Sarkar Date: Sat, 15 Aug 2026 17:10:53 +0100 Subject: [PATCH 06/12] docs(slice-26): publish dual-output contract for /tw-* skills SSOT maps heatmap_status to six UI states and records observed tripwire scan stdout so H2 skills do not invent fields. --- docs/README.md | 1 + docs/STATUS.md | 15 +- docs/plan/DECISIONS.md | 4 + docs/plan/PROGRESS.md | 42 +++--- docs/plan/TRAIL.md | 4 +- docs/plan/gate-evidence/slice-25.json | 18 +-- docs/plan/gate-evidence/slice-26.json | 77 +++++++--- .../slice-25-live-enforce-smoke.md | 9 +- .../slice-26-api-output-contract.md | 104 ++++++++++---- docs/user-guide/frontline-output-contract.md | 136 ++++++++++++++++++ docs/user-guide/setup-commands.md | 4 + guard/tests/test_frontline_output_contract.py | 123 ++++++++++++++++ 12 files changed, 448 insertions(+), 89 deletions(-) create mode 100644 docs/user-guide/frontline-output-contract.md create mode 100644 guard/tests/test_frontline_output_contract.py diff --git a/docs/README.md b/docs/README.md index 859b23a..c1f13be 100644 --- a/docs/README.md +++ b/docs/README.md @@ -34,6 +34,7 @@ Use this map to move from a safe first look to the level of setup or project det | [user-guide/sie-setup.md](./user-guide/sie-setup.md) | Optional Superlinked SIE keys for tiered routing | | [user-guide/model-studio-setup.md](./user-guide/model-studio-setup.md) | Optional Alibaba Cloud Model Studio escalation | | [user-guide/reading-router-results.md](./user-guide/reading-router-results.md) | Pathway strips, Escalated / SIE-only filters, categories | +| [user-guide/frontline-output-contract.md](./user-guide/frontline-output-contract.md) | Frontline `/tw-*` dual output + `heatmap_status` → six UI states | | [user-guide/onboarding-cheatsheet.md](./user-guide/onboarding-cheatsheet.md) | Compact shared onboarding reference | | [STATUS.md](./STATUS.md) | Evidence-labelled capability claims | | [ARCHITECTURE.md](./ARCHITECTURE.md) | System diagrams, key flows, and repository layout | diff --git a/docs/STATUS.md b/docs/STATUS.md index 771e74b..f94d045 100644 --- a/docs/STATUS.md +++ b/docs/STATUS.md @@ -133,9 +133,14 @@ slices 23–39 on branch `frontline-hackathon-london-2026-agent-hooks`. See (PR #75) — `cli/src/setupAgentHooks.js`; installs hooks to `~/.tripwire/hooks` (mode `700`), first-write config via `ensure_default_config`, Claude PreToolUse registration. -- **Slice 25 (Live Enforce Smoke):** 🔀 ON BRANCH — scripted smoke after install - proves enable→block (unscanned/RED) and disable→approve; docs state no native - install-event hook. Human H2 checkpoint still required before slices 26–30. +- **Slice 25 (Live Enforce Smoke):** **IMPLEMENTED** and **VERIFIED** (PR #76) — + scripted smoke after install proves enable→block (unscanned/RED) and + disable→approve; docs state no native install-event hook. H2 checkpoint + signed off 2026-08-15. +- **Slice 26 (API / dual output contract):** 🔀 ON BRANCH — SSOT + [user-guide/frontline-output-contract.md](./user-guide/frontline-output-contract.md) + documents six UI states, `heatmap_status` mapping, dual human/JSON shape, and + observed `tripwire scan` stdout (`batch_id` / `scan_run_ids` / `failed_targets`). - Remaining H Musts/Shoulds: not IMPLEMENTED — no `/tw-*` skills yet. --- @@ -156,8 +161,8 @@ Known fixture gaps (not urgent) are listed under as shipped capabilities. Guard PreToolUse and Drift/trend remain Future / Won't (A) for the Horizon A ship path — see [ADR-0015](./adr/0015-horizon-a-excludes-guard-and-drift.md). Frontline Guard -integration is **DECIDED** as Wave H — see DECIDED above. Slice 23–24 ✅; -slice 25 live-enforce smoke on branch. `/tw-*` skills still pending later H slices. +integration is **DECIDED** as Wave H — see DECIDED above. Slice 23–25 ✅; +slice 26 dual-output contract on branch. `/tw-*` skills still pending later H slices. Coverage audit matrix: [plan/coverage-audit.md](./plan/coverage-audit.md) (slice 7 ✅). Slice stubs: [plan/README.md](./plan/README.md) (`01-A-…` … diff --git a/docs/plan/DECISIONS.md b/docs/plan/DECISIONS.md index 2b89b06..7ae72c0 100644 --- a/docs/plan/DECISIONS.md +++ b/docs/plan/DECISIONS.md @@ -104,3 +104,7 @@ | 2026-08-15 | slice-24 | ✅ closed | PR #75 merged into `frontline-hackathon-london-2026-agent-hooks`. Gate evidence `verdict: PASS`. Wave H integration branch remains merge-equivalent for ✅ while H is active. | | 2026-08-15 | slice-25 | AT design complete | Three GWTs: enabled block (param unscanned/RED), disabled approve, install-event docs. Smoke uses installed hooks after `setup-agent-hooks`; `TRIPWIRE_CHECK_CALL_FIXTURE` seam avoids live Supabase in CI. | | 2026-08-15 | slice-25 | smoke fixture seam | `TRIPWIRE_CHECK_CALL_FIXTURE=unscanned` or `red` in `guard/hooks_entry.py` is CI/smoke only; unset in production → real `check_call`. | +| 2026-08-15 | slice-25 | ✅ closed | PR #76 merged into `frontline-hackathon-london-2026-agent-hooks`. Gate evidence `verdict: PASS`. | +| 2026-08-15 | phase-gates | H2 checkpoint signed off | Operator requested start of slice 26 after slice 25 merge (PR #76). Unblocks Phase H2 (26–30). Scripted smoke remains the primary evidence; live Claude Code session observation optional. | +| 2026-08-15 | slice-26 | AT design complete | Four docs-contract GWTs; SSOT `docs/user-guide/frontline-output-contract.md`; coverage/complexity N/A docs-only. | +| 2026-08-15 | slice-26 | docs-only review | Documentation + characterization tests only (no product-code change); GATE_CONTRACT docs-only review exception applies (same pattern as slice 17). | diff --git a/docs/plan/PROGRESS.md b/docs/plan/PROGRESS.md index ea64cfa..e880782 100644 --- a/docs/plan/PROGRESS.md +++ b/docs/plan/PROGRESS.md @@ -12,29 +12,28 @@ | 5 | [`05-E-…`](slices/05-E-ship-path-coverage/) | **E — Ship-path coverage** | 8 → 11 → 12 → 13 ✅ → 14 (**9+10 SUBSUMED INTO 11**) | ✅ Musts · close-path | | 6 | [`06-F-…`](slices/06-F-claim-audit/) | **F — Claim audit** | 15 · 16 | 📦 | | 7 | [`07-G-…`](slices/07-G-atdd-closure/) | **G — ATDD closure** | 18, 19, 20, 21, 22 (independent gates) | 📋 parked | -| 8 | [`08-H-…`](slices/08-H-frontline-agent-hooks/) | **H — Frontline agent hooks** | 23→32 Must · 33–38 Should · 39 Could | 23–24 ✅ · 25 🔀 | +| 8 | [`08-H-…`](slices/08-H-frontline-agent-hooks/) | **H — Frontline agent hooks** | 23→32 Must · 33–38 Should · 39 Could | 23–25 ✅ · 26 🔀 | -**Current priority:** Wave H Must slice **25** — Live Enforce Smoke — 🔀 ON BRANCH (merge then human H2 checkpoint before 26). Wave G (18–22) parked while H1–H3 is active unless explicitly resumed. +**Current priority:** Wave H Must slice **26** — API Introspect + Dual Output Contract — 🔀 ON BRANCH. Wave G (18–22) parked while H1–H3 is active unless explicitly resumed. ## Execution order (open work) | Order | Wave | # | Slice | MoSCoW | Status | |------:|-----:|---|-------|--------|--------| -| 1 | H1 | 25 | Live Enforce Smoke | Must | 🔀 ON BRANCH | -| 2 | H2 | 26 | API Introspect + Dual Output Contract | Must | 📋 PLANNED (blocked on human H2 checkpoint) | -| 3 | H2 | 27 | `/tw-enable` + `/tw-disable` | Must | 📋 PLANNED | -| 4 | H2 | 28 | `/tw-verify` | Must | 📋 PLANNED | -| 5 | H2 | 29 | `/tw-scan` | Must | 📋 PLANNED | -| 6 | H2 | 30 | `/tw-self-check` | Must | 📋 PLANNED | -| 7 | H3 | 31 | Demo Artifacts | Must | 📋 PLANNED | -| 8 | H3 | 32 | Phase 1 Regression Verification (HARD GATE) | Must | 📋 PLANNED | -| 9 | H4 | 33 | DepShield Install | Should | 📋 PLANNED | -| 10 | H4 | 34 | DepShield Dispatch | Should | 📋 PLANNED | -| 11 | H5 | 35 | Ossprey Access Provisioning | Should | 🔴 BLOCKED | -| 12 | H5 | 36 | Ossprey Dispatch | Should | 📋 PLANNED | -| 13 | H6 | 37 | CLI Monitoring | Should | 📋 PLANNED | -| 14 | H6 | 38 | Full-Chain Validation | Should | 📋 PLANNED | -| 15 | H6 | 39 | FE/BE Rearchitecture | Could | 📦 DEFERRED | +| 1 | H2 | 26 | API Introspect + Dual Output Contract | Must | 🔀 ON BRANCH | +| 2 | H2 | 27 | `/tw-enable` + `/tw-disable` | Must | 📋 PLANNED | +| 3 | H2 | 28 | `/tw-verify` | Must | 📋 PLANNED | +| 4 | H2 | 29 | `/tw-scan` | Must | 📋 PLANNED | +| 5 | H2 | 30 | `/tw-self-check` | Must | 📋 PLANNED | +| 6 | H3 | 31 | Demo Artifacts | Must | 📋 PLANNED | +| 7 | H3 | 32 | Phase 1 Regression Verification (HARD GATE) | Must | 📋 PLANNED | +| 8 | H4 | 33 | DepShield Install | Should | 📋 PLANNED | +| 9 | H4 | 34 | DepShield Dispatch | Should | 📋 PLANNED | +| 10 | H5 | 35 | Ossprey Access Provisioning | Should | 🔴 BLOCKED | +| 11 | H5 | 36 | Ossprey Dispatch | Should | 📋 PLANNED | +| 12 | H6 | 37 | CLI Monitoring | Should | 📋 PLANNED | +| 13 | H6 | 38 | Full-Chain Validation | Should | 📋 PLANNED | +| 14 | H6 | 39 | FE/BE Rearchitecture | Could | 📦 DEFERRED | | — | G | 18–22 | ATDD closure (parked) | Must | 📋 PLANNED | ## Quick Status (by group) @@ -94,8 +93,8 @@ |---|-------|--------|--------|---------|-----------|-----------| | 23 | [slice-23-config-handler-scripts](slices/08-H-frontline-agent-hooks/slice-23-config-handler-scripts.md) | Must | ✅ | 2026-08-15 | 2026-08-15 | ~40 min | | 24 | [slice-24-setup-agent-hooks](slices/08-H-frontline-agent-hooks/slice-24-setup-agent-hooks.md) | Must | ✅ | 2026-08-15 | 2026-08-15 | ~40 min | -| 25 | [slice-25-live-enforce-smoke](slices/08-H-frontline-agent-hooks/slice-25-live-enforce-smoke.md) | Must | 🔀 ON BRANCH | 2026-08-15 | — | ~30 min | -| 26 | [slice-26-api-output-contract](slices/08-H-frontline-agent-hooks/slice-26-api-output-contract.md) | Must | 📋 PLANNED | — | — | ~40 min | +| 25 | [slice-25-live-enforce-smoke](slices/08-H-frontline-agent-hooks/slice-25-live-enforce-smoke.md) | Must | ✅ | 2026-08-15 | 2026-08-15 | ~30 min | +| 26 | [slice-26-api-output-contract](slices/08-H-frontline-agent-hooks/slice-26-api-output-contract.md) | Must | 🔀 ON BRANCH | 2026-08-15 | — | ~40 min | | 27 | [slice-27-tw-enable-disable](slices/08-H-frontline-agent-hooks/slice-27-tw-enable-disable.md) | Must | 📋 PLANNED | — | — | ~25 min | | 28 | [slice-28-tw-verify](slices/08-H-frontline-agent-hooks/slice-28-tw-verify.md) | Must | 📋 PLANNED | — | — | ~50 min | | 29 | [slice-29-tw-scan](slices/08-H-frontline-agent-hooks/slice-29-tw-scan.md) | Must | 📋 PLANNED | — | — | ~40 min | @@ -122,7 +121,7 @@ ## Forward Roadmap - Waves **A–C**, coverage Slice 14, and Slice 17 are merged and closed. Slice 15 is retained as a deferred claim-audit artifact, not active work. -- **Wave H (Frontline):** integration branch `frontline-hackathon-london-2026-agent-hooks`. Slice 23 ✅ (PR #74). Slice 24 ✅ (PR #75). Slice 25 🔀 ON BRANCH (`live-enforce-smoke`). Next after merge + human H2 checkpoint: 26. +- **Wave H (Frontline):** integration branch `frontline-hackathon-london-2026-agent-hooks`. Slice 23 ✅ (PR #74). Slice 24 ✅ (PR #75). Slice 25 ✅ (PR #76; H2 checkpoint signed off). Next: 26 (IN PROGRESS). - Wave G (18–22) remains planned but **parked** while Frontline H1–H3 is active unless explicitly resumed. - Reopen Slice 15 only for a future live/demo release that needs its security and 3B evidence path. - **Deferred / Won't (A):** 4 (in A); 15 and 16 (in F) — reinstate only if a new live/demo need arises @@ -161,4 +160,5 @@ | 2026-08-15 | frontline-hackathon-london-2026-agent-hooks | enhanced-flow-planner Add | 23–39 | 📋 stubs | Wave H Frontline plan-only; Wave G parked | | 2026-08-15 | slice/23-config-handler-scripts | slice-workflow | 23 | ✅ PASSED (PR #74) | Config+handlers; 6 GWTs; cov 98.8%; merged to Frontline | | 2026-08-15 | slice/24-setup-agent-hooks | slice-workflow | 24 | ✅ PASSED (PR #75) | setup-agent-hooks; 7 tests; cov 93.11%; merged to Frontline | -| 2026-08-15 | slice/25-live-enforce-smoke | slice-workflow | 25 | 🔀 ON BRANCH | live enforce smoke; 4 ATs; hooks_entry fixture seam; quality-gates PASS | +| 2026-08-15 | slice/25-live-enforce-smoke | slice-workflow | 25 | ✅ PASSED (PR #76) | live enforce smoke; H2 checkpoint signed off | +| 2026-08-15 | slice/26-api-output-contract | slice-workflow | 26 | 🔀 ON BRANCH | dual output contract SSOT + 4 ATs; docs-only | diff --git a/docs/plan/TRAIL.md b/docs/plan/TRAIL.md index 66a815f..a508434 100644 --- a/docs/plan/TRAIL.md +++ b/docs/plan/TRAIL.md @@ -161,13 +161,13 @@ Branch: `frontline-hackathon-london-2026-agent-hooks`. Source: `internal-docs/04 |---|------|------|--------|--------|------------|-------|-----------| | 23 | [slice-23-config-handler-scripts](slices/08-H-frontline-agent-hooks/slice-23-config-handler-scripts.md) | Config + Handler Scripts | Must | ✅ | none | #74 | ~4 min | | 24 | [slice-24-setup-agent-hooks](slices/08-H-frontline-agent-hooks/slice-24-setup-agent-hooks.md) | `tripwire setup-agent-hooks` | Must | ✅ | 23 | #75 | ~4 min | -| 25 | [slice-25-live-enforce-smoke](slices/08-H-frontline-agent-hooks/slice-25-live-enforce-smoke.md) | Live Enforce Smoke | Must | 🔀 | 24 | — | ~3 min | +| 25 | [slice-25-live-enforce-smoke](slices/08-H-frontline-agent-hooks/slice-25-live-enforce-smoke.md) | Live Enforce Smoke | Must | ✅ | 24 | #76 | ~3 min | #### H2 — Shared contracts + control skills (Must) | # | File | Name | MoSCoW | Status | Depends on | Issue | Read time | |---|------|------|--------|--------|------------|-------|-----------| -| 26 | [slice-26-api-output-contract](slices/08-H-frontline-agent-hooks/slice-26-api-output-contract.md) | API Introspect + Dual Output Contract | Must | 📋 | 25 | — | ~4 min | +| 26 | [slice-26-api-output-contract](slices/08-H-frontline-agent-hooks/slice-26-api-output-contract.md) | API Introspect + Dual Output Contract | Must | 🔀 | 25 | — | ~4 min | | 27 | [slice-27-tw-enable-disable](slices/08-H-frontline-agent-hooks/slice-27-tw-enable-disable.md) | `/tw-enable` + `/tw-disable` | Must | 📋 | 26 | — | ~3 min | | 28 | [slice-28-tw-verify](slices/08-H-frontline-agent-hooks/slice-28-tw-verify.md) | `/tw-verify` | Must | 📋 | 26,27 | — | ~5 min | | 29 | [slice-29-tw-scan](slices/08-H-frontline-agent-hooks/slice-29-tw-scan.md) | `/tw-scan` | Must | 📋 | 26 | — | ~4 min | diff --git a/docs/plan/gate-evidence/slice-25.json b/docs/plan/gate-evidence/slice-25.json index 3d9d79a..3983243 100644 --- a/docs/plan/gate-evidence/slice-25.json +++ b/docs/plan/gate-evidence/slice-25.json @@ -1,6 +1,6 @@ { "slice": 25, - "gate_status": "ON_BRANCH", + "gate_status": "PASSED", "inferred": false, "branch": "slice/25-live-enforce-smoke", "date": "2026-08-15", @@ -14,8 +14,8 @@ "PASS — coverage guard.config+hooks_entry 100% lines (≥95%); complexity enforcing via quality-gates", "PASS — ./scripts/quality-gates.sh", "PASS — nimble in-session review acceptance+implementation APPROVED (DECISIONS Wave H nimble reviews)", - "PENDING human H2 checkpoint — operator sign-off before slices 26–30", - "PENDING merge — PROGRESS/TRAIL 🔀 until merged to Frontline integration" + "PASS — human H2 checkpoint signed off (operator requested start of slice 26; DECISIONS 2026-08-15)", + "PASS — merged to Frontline integration via PR #76" ], "commands": [ { @@ -55,9 +55,11 @@ "complexity_policy": "enforcing — quality-gates.sh xenon PASS", "human_checkpoint_h2": { "required": true, - "status": "PENDING_OPERATOR_SIGNOFF", - "blocks": "Phase H2 slices 26–30", - "note": "Scripted smoke VERIFIED; human Claude Code session observation optional but Phase H2 needs DECISIONS/gate-evidence sign-off" + "status": "SIGNED_OFF", + "signed_off_at": "2026-08-15", + "signed_off_by": "operator (requested start of slice 26)", + "blocks": "Phase H2 slices 26–30 — unblocked", + "note": "Scripted smoke VERIFIED on PR #76; operator authorized H2 by starting slice 26" }, "reviewers": ["in-session nimble (acceptance+implementation)"], "review": { @@ -69,9 +71,9 @@ "1_install_event_gap": "PASS — docs/user-guide/setup-commands.md", "2_enable_disable_smoke": "PASS — Enable / disable smoke (operator) paragraph", "3_cross_link": "PASS — gate-evidence ↔ TRAIL/PROGRESS", - "4_human_checkpoint": "PASS — recorded PENDING_OPERATOR_SIGNOFF in evidence + slice After-Checks" + "4_human_checkpoint": "PASS — SIGNED_OFF in evidence + DECISIONS" }, - "verdict": "ON_BRANCH", + "verdict": "PASS", "phase": "H1", "moscow": "Must", "pr": "https://github.com/neomatrix369/tripwire/pull/76" diff --git a/docs/plan/gate-evidence/slice-26.json b/docs/plan/gate-evidence/slice-26.json index 3496a03..ee20898 100644 --- a/docs/plan/gate-evidence/slice-26.json +++ b/docs/plan/gate-evidence/slice-26.json @@ -1,24 +1,65 @@ { "slice": 26, - "gate_status": "PLANNED", + "gate_status": "ON_BRANCH", "inferred": false, - "branch": null, - "date": null, - "before_checks": [], - "after_checks": [], - "planned_commands": [ - "test -f docs/plan/slices/08-H-frontline-agent-hooks/slice-26-api-output-contract.md", - "rg -n \"fresh|stale|unscanned|scanning|not-found|will_be_blocked\" internal-docs/04_frontline/main_prompt.md", - "rg -n \"heatmap_status\" guard/", - "tripwire scan --help || true" + "branch": "slice/26-api-output-contract", + "date": "2026-08-15", + "spec_path": "docs/plan/slices/08-H-frontline-agent-hooks/slice-26-api-output-contract.md", + "contract_path": "docs/user-guide/frontline-output-contract.md", + "before_checks": "PASS — slice 25 verdict PASS (PR #76) + H2 checkpoint SIGNED_OFF; branch from Frontline (Wave H base waiver); main_prompt rg recorded; coverage N/A docs-only", + "after_checks": [ + "PASS — contract doc exists at docs/user-guide/frontline-output-contract.md", + "PASS — six states + Name|Type|Status|Note columns (pytest + rg)", + "PASS — machine JSON fields match main_prompt; scan stdout BACKLOG deltas listed", + "PASS — .venv/bin/pytest guard/tests/test_frontline_output_contract.py -q (4 passed)", + "PASS — coverage/complexity N/A docs-only (no product-code change)", + "PASS — docs-only review exception recorded in DECISIONS 2026-08-15", + "PENDING merge — PROGRESS/TRAIL ✅ after merge to Frontline" ], - "commands": [], - "test_budget": {"acceptance_tests_max": 7, "parametrized_case_counts_as_one": true}, - "reviewers": [], - "review": {"acceptance": "PENDING", "implementation": "PENDING"}, - "verdict": "NOT_RUN", - "coverage_target": "TBD at AT design before IN PROGRESS", - "complexity_policy": "enforcing for product-code; N/A for docs-only with reason in evidence", + "commands": [ + { + "cmd": "rg -n \"heatmap_status|OUTPUT FORMAT|will_be_blocked\" internal-docs/04_frontline/main_prompt.md", + "result": "PASS — OUTPUT FORMAT + will_be_blocked + heatmap_status hits present" + }, + { + "cmd": ".venv/bin/pytest guard/tests/test_frontline_output_contract.py -q --tb=short", + "result": "PASS exit 0 — 4 passed" + }, + { + "cmd": "rg -n \"fresh|stale|unscanned|scanning|not-found|\\| Name \\| Type \\| Status \\| Note \\|\" docs/user-guide/frontline-output-contract.md", + "result": "PASS — six states + human table columns greppable" + }, + { + "cmd": "rg -n \"batch_id|scan_run_ids|failed_targets\" docs/user-guide/frontline-output-contract.md cli/src/orchestrator.js", + "result": "PASS — observed scan stdout fields documented and present in orchestrator" + } + ], + "test_budget": { + "acceptance_tests_max": 7, + "acceptance_tests_designed": 4, + "parametrized_case_counts_as_one": true + }, + "coverage": { + "target": "N/A — docs-only", + "measured": "N/A", + "date": "2026-08-15", + "reason": "No product-code change; characterization asserts against existing orchestrator.js + guard_hook.py" + }, + "complexity_policy": "N/A for docs-only — no product-code touched", + "reviewers": ["docs-only exception (DECISIONS 2026-08-15)"], + "review": { + "acceptance": "APPROVED", + "implementation": "APPROVED", + "notes": "Docs-only GATE_CONTRACT exception; four binary ATs lock SSOT contract path, six states, table columns, machine fields, and observed scan JSON without inventing fields" + }, + "documentation_audit": { + "1_human_machine": "PASS — frontline-output-contract.md", + "2_heatmap_mapping": "PASS — green/amber/red/grey/error + lifecycle states", + "3_links": "PASS — main_prompt + slices 27–30 + setup-commands + docs index", + "4_cross_link": "PASS — gate-evidence ↔ TRAIL/PROGRESS" + }, + "verdict": "ON_BRANCH", "phase": "H2", - "moscow": "Must" + "moscow": "Must", + "pr": null } diff --git a/docs/plan/slices/08-H-frontline-agent-hooks/slice-25-live-enforce-smoke.md b/docs/plan/slices/08-H-frontline-agent-hooks/slice-25-live-enforce-smoke.md index 6a72a18..51f11df 100644 --- a/docs/plan/slices/08-H-frontline-agent-hooks/slice-25-live-enforce-smoke.md +++ b/docs/plan/slices/08-H-frontline-agent-hooks/slice-25-live-enforce-smoke.md @@ -92,8 +92,9 @@ REFACTOR: document the install-event workaround without expanding product scope. - [x] `docs/plan/gate-evidence/slice-25.json` records commands/observations, reviewers, and `verdict: ON_BRANCH` (PASS after merge) - [x] Review: `acceptance: APPROVED` and `implementation: APPROVED` (nimble Wave H in-session review per DECISIONS) -- [ ] `PROGRESS.md` + `TRAIL.md` show slice 25 ✅ (after merge to Frontline) -- [ ] **Human test checkpoint:** Phase H2 (slices 26–30) must not start until a human signs off this smoke in gate evidence / DECISIONS +- [x] `PROGRESS.md` + `TRAIL.md` show slice 25 ✅ (after merge to Frontline) +- [x] **Human test checkpoint:** Phase H2 (slices 26–30) signed off — operator + requested start of slice 26 (DECISIONS 2026-08-15) ## Doc Audit @@ -102,8 +103,8 @@ REFACTOR: document the install-event workaround without expanding product scope. | 1 | Install-event absence + `setup-agent-hooks` workaround documented | PASS — setup-commands.md | | 2 | Enable/disable smoke steps greppable for operators | PASS | | 3 | Cross-link gate-evidence ↔ TRAIL/PROGRESS | PASS | -| 4 | Human checkpoint before H2 recorded | PASS — PENDING_OPERATOR_SIGNOFF in evidence | +| 4 | Human checkpoint before H2 recorded | PASS — SIGNED_OFF in evidence + DECISIONS | ## Gate Status -🔀 ON BRANCH +✅ PASSED (PR #76) diff --git a/docs/plan/slices/08-H-frontline-agent-hooks/slice-26-api-output-contract.md b/docs/plan/slices/08-H-frontline-agent-hooks/slice-26-api-output-contract.md index 48c3d36..36b8d37 100644 --- a/docs/plan/slices/08-H-frontline-agent-hooks/slice-26-api-output-contract.md +++ b/docs/plan/slices/08-H-frontline-agent-hooks/slice-26-api-output-contract.md @@ -6,31 +6,73 @@ Documented contract for actual `tripwire scan` JSON output and the mapping from Supabase `heatmap_status` to the six UI states (`fresh` / `stale` / `unscanned` / `scanning` / `not-found` / `red`). Shared human Markdown table and machine JSON shape match `internal-docs/04_frontline/main_prompt.md`. -## GWT acceptance specification +**SSOT path:** `docs/user-guide/frontline-output-contract.md` -Thin scaffolds — full DISTILL ATs deferred per DECISIONS; design ATs before marking IN PROGRESS. +## GWT acceptance specification -1. **Six states are named and mapped** - - Given the contract doc, when an operator looks up each UI state, then `fresh`, `stale`, `unscanned`, `scanning`, `not-found`, and `red` each have a defined display and source mapping from `heatmap_status` / resolution. -2. **Human table columns are fixed** - - Given the contract doc, when rendering a verify/scan response, then the human Markdown table columns are Name | Type | Status | Note. -3. **Machine JSON shape is fixed** - - Given the contract doc, when emitting machine output, then each artifact includes at least `name`, `resolved_path`, `type`, `state`, `rag`, `scanned_at`, `stale`, `will_be_blocked`, `note` (or an explicit backlog note for fields pending API introspection). -4. **Scan JSON introspection recorded** - - Given a live or fixture `tripwire scan` response, when the contract is finalized for H2 skills, then the documented fields match observed output (or mark BACKLOG fields explicitly). +**DISTILL ATs (2026-08-15)** — ≤7; docs-only binary checks. + +| # | Scenario | Tags | Real-surface binding | +|---|----------|------|----------------------| +| 1 | Six states + heatmap mapping | `@US-26` | `docs/user-guide/frontline-output-contract.md` | +| 2 | Human table columns fixed | `@US-26` | same contract doc | +| 3 | Machine JSON fields fixed | `@US-26` | same contract doc | +| 4 | Scan JSON introspection | `@walking_skeleton` `@US-26` | `cli/src/orchestrator.js` + contract BACKLOG | + +1. **Six states are named and mapped** `@US-26` + - Given `docs/user-guide/frontline-output-contract.md`, + when an operator looks up each UI state, + then `fresh`, `stale`, `unscanned`, `scanning`, `not-found`, and `red` each + appear, and Supabase `heatmap_status` values `green` / `amber` / `red` / + `grey` / `error` are mapped (with lifecycle states `stale` / `scanning` / + `not-found` explicitly marked as not heatmap enums). +2. **Human table columns are fixed** `@US-26` + - Given the contract doc, + when rendering a verify/scan response, + then the human Markdown table columns are Name | Type | Status | Note. +3. **Machine JSON shape is fixed** `@US-26` + - Given the contract doc, + when emitting machine output, + then each artifact documents `name`, `resolved_path`, `type`, `state`, + `rag`, `scanned_at`, `stale`, `will_be_blocked`, `note`. +4. **Scan JSON introspection recorded** `@walking_skeleton` `@US-26` + - Given `cli/src/orchestrator.js` `runScan` stdout fields + (`batch_id`, `scan_run_ids`, `failed_targets`), + when the contract is finalized for H2 skills, + then those fields are documented and BACKLOG states that dual-output + rows are composed from Supabase + resolution — not invented from scan stdout. + +**Test inventory (4 acceptance tests):** +`guard/tests/test_frontline_output_contract.py` + +**Named verification command:** + +```bash +.venv/bin/pytest guard/tests/test_frontline_output_contract.py -q --tb=short +``` + +**Coverage / complexity (AT design):** + +- Docs-only primary. No product-code change required. +- Coverage: **N/A** — documentation + characterization of existing orchestrator/ + guard surfaces; no new production modules. +- Complexity: **N/A for docs-only** with reason in gate evidence. ## Design / test treatment - Docs-first contract slice: introspect existing CLI/API; do not invent fields. -- Prefer a single SSOT doc under `docs/` (or frontline plan path) linked from skills work in 27–30. -- **AT design required before IN PROGRESS** — may be docs-only with binary `rg`/file checks; note complexity N/A with reason if no product code. +- Single SSOT under `docs/user-guide/frontline-output-contract.md`, linked from + docs index and skills work in 27–30. +- **AT design complete** — ready for 🔨 IN PROGRESS. ## Before-Checks [GATE] -- [ ] Slice 25 gate-evidence `verdict` is `PASS` and human H2 checkpoint recorded -- [ ] Branch `slice/26-api-output-contract` created from current `main` -- [ ] `rg -n "heatmap_status|OUTPUT FORMAT|will_be_blocked" internal-docs/04_frontline/main_prompt.md` recorded in evidence -- [ ] Coverage target TBD / N/A reason drafted if docs-only +- [x] Slice 25 gate-evidence `verdict` is `PASS` and human H2 checkpoint recorded + (PR #76 merged; operator sign-off DECISIONS 2026-08-15 + this slice start) +- [x] Branch `slice/26-api-output-contract` created from Frontline integration + (DECISIONS Wave H branch-base waiver) +- [x] `rg -n "heatmap_status|OUTPUT FORMAT|will_be_blocked" internal-docs/04_frontline/main_prompt.md` recorded in evidence +- [x] Coverage target N/A — docs-only (reason: no product code) ## TDD execution @@ -40,24 +82,24 @@ REFACTOR: keep BACKLOG fields explicit; no silent invention. ## After-Checks [GATE] -- [ ] Contract doc exists at the path recorded in gate evidence -- [ ] `rg` proves all six states and Name|Type|Status|Note columns -- [ ] Machine JSON example matches main_prompt shape (or BACKLOG deltas listed) -- [ ] Named check command(s) exit 0 (record in gate evidence) -- [ ] Coverage/complexity: **N/A for docs-only** with reason in evidence, else enforcing for any product-code touched -- [ ] `docs/plan/gate-evidence/slice-26.json` records commands, reviewers, and `verdict: PASS` -- [ ] Review: `acceptance: APPROVED` and `implementation: APPROVED` (or docs-only exception in DECISIONS) -- [ ] `PROGRESS.md` + `TRAIL.md` show slice 26 ✅ +- [x] Contract doc exists at the path recorded in gate evidence +- [x] `rg` / tests prove all six states and Name|Type|Status|Note columns +- [x] Machine JSON example matches main_prompt shape (or BACKLOG deltas listed) +- [x] Named check command(s) exit 0 (record in gate evidence) +- [x] Coverage/complexity: **N/A for docs-only** with reason in evidence +- [x] `docs/plan/gate-evidence/slice-26.json` records commands, reviewers, and `verdict: ON_BRANCH` (PASS after merge) +- [x] Review: docs-only exception in DECISIONS (2026-08-15) +- [ ] `PROGRESS.md` + `TRAIL.md` show slice 26 ✅ (after merge to Frontline) ## Doc Audit -| # | Check | -|---|--------| -| 1 | Human Markdown table + machine JSON published | -| 2 | heatmap_status → six UI states mapping complete | -| 3 | Links to main_prompt + skill slices 27–30 | -| 4 | Cross-link gate-evidence ↔ TRAIL/PROGRESS | +| # | Check | Result | +|---|--------|--------| +| 1 | Human Markdown table + machine JSON published | PASS — frontline-output-contract.md | +| 2 | heatmap_status → six UI states mapping complete | PASS | +| 3 | Links to main_prompt + skill slices 27–30 | PASS | +| 4 | Cross-link gate-evidence ↔ TRAIL/PROGRESS | PASS | ## Gate Status -📋 PLANNED +🔀 ON BRANCH diff --git a/docs/user-guide/frontline-output-contract.md b/docs/user-guide/frontline-output-contract.md new file mode 100644 index 0000000..315f607 --- /dev/null +++ b/docs/user-guide/frontline-output-contract.md @@ -0,0 +1,136 @@ +# Frontline dual output contract + +> Evidence state: **VERIFIED** against code + `internal-docs/04_frontline/main_prompt.md` (2026-08-15, slice 26). +> SSOT for `/tw-verify`, `/tw-scan`, and `/tw-self-check` human + machine output (slices 27–30). + +Source prompt: [`internal-docs/04_frontline/main_prompt.md`](../../internal-docs/04_frontline/main_prompt.md) § OUTPUT FORMAT. + +## Audiences + +Every `/tw-*` status response must support two audiences with the **same** per-artifact facts: + +1. **Human** — Markdown table (one row per artifact) +2. **Machine** — JSON object with an `artifacts` array + +## Human Markdown table + +Fixed columns (order matters): + +| Name | Type | Status | Note | +|------|------|--------|------| +| `example-skill` | skill | 🟢 GREEN (fresh) | — | + +| Column | Meaning | +|--------|---------| +| **Name** | Resolved artifact name (or the unresolved query for `not-found`) | +| **Type** | `skill` / `mcp` / `tool` / `—` when unknown | +| **Status** | Display string for the six UI states (below) | +| **Note** | Operator hint; RED must include the block warning | + +## Six UI states + +| State | Display | When | +|-------|---------|------| +| `fresh` | 🟢 GREEN / 🟠 AMBER / 🔴 RED (with RAG label) | Scan result within N-day validity window | +| `stale` | ⚠️ STALE | Scan exists but older than N days (`scan_validity_days`, default 14) | +| `unscanned` | 🚫 UNSCANNED | No usable scan record (grey / missing / fail-closed error) | +| `scanning` | ⏳ SCANNING | Scan submitted, not yet complete | +| `not-found` | ❓ NOT FOUND | Name resolution returned no match | +| `red` | 🔴 RED + bold block warning | Supabase `heatmap_status` = `red` (always include block note) | + +RED always carries: **"Will be blocked when Tripwire is enabled"** — never silently omit this. + +### `heatmap_status` → UI mapping + +Supabase `items.heatmap_status` values observed in `guard/guard_hook.py`: +`green`, `amber`, `red`, `grey`, `error`. + +| Source | Maps to UI | Notes | +|--------|------------|-------| +| `heatmap_status=green` + within N days | `fresh` with RAG `green` | Below default red threshold → approve | +| `heatmap_status=amber` + within N days | `fresh` with RAG `amber` | Blocked only when threshold is `red_and_amber` | +| `heatmap_status=red` + within N days | `fresh` with RAG `red` **and** state callout `red` | Always `will_be_blocked: true` when enforcement enabled | +| `heatmap_status=grey` or missing item | `unscanned` | Guard fails closed (`never scanned`) | +| `heatmap_status=error` | `unscanned` (fail-closed) | Treated like unscanned for enforcement | +| Scan older than N days (any RAG) | `stale` | Derived from `scanned_at` / latest completed run — **not** a heatmap enum | +| `scan_runs.status=running` (or equivalent in-flight) | `scanning` | Derived from run/batch lifecycle — **not** a heatmap enum | +| Name resolution miss | `not-found` | Skill/MCP resolution — **not** from Supabase | + +`stale`, `scanning`, and `not-found` are **resolution / lifecycle** states. They are not values of `heatmap_status`. + +## Machine JSON shape + +```json +{ + "artifacts": [ + { + "name": "vuln-runtime-download", + "resolved_path": "/path/to/skill", + "type": "skill", + "state": "fresh", + "rag": "red", + "scanned_at": "2026-08-01T10:00:00Z", + "stale": false, + "will_be_blocked": true, + "note": "rated red — at/above threshold" + } + ] +} +``` + +Required per-artifact fields: + +| Field | Type | Meaning | +|-------|------|---------| +| `name` | string | Display / query name | +| `resolved_path` | string \| null | Filesystem or URL path when resolved | +| `type` | string \| null | `skill` / `mcp` / `tool` | +| `state` | string | One of the six UI states | +| `rag` | string \| null | `green` / `amber` / `red` when known; null for unscanned/not-found/scanning | +| `scanned_at` | string \| null | ISO-8601 last completed scan time | +| `stale` | boolean | `true` when past N-day window | +| `will_be_blocked` | boolean | `true` when enforcement would block this artifact | +| `note` | string | Human-facing note (must include block warning when RED) | + +## Observed `tripwire scan` JSON (introspection) + +Production path: `cli/src/orchestrator.js` → `runScan` prints one JSON object to stdout. + +**Observed shape (dispatch confirmation — not the dual-output artifact row):** + +```json +{ + "batch_id": "", + "scan_run_ids": ["", "..."], + "failed_targets": [ + { "target": "", "error": "" } + ] +} +``` + +| Field | Observed | Role for `/tw-*` | +|-------|----------|------------------| +| `batch_id` | yes | Submit confirmation / follow-up routing | +| `scan_run_ids` | yes | Per-target run IDs when dispatch succeeded | +| `failed_targets[]` | yes | `{ target, error }` for dispatch failures | + +`--dry-discover` prints the discovered **targets list** (not the `runScan` result) and exits without spawning sandboxes. + +There is **no** synchronous status-lookup CLI. Per-artifact RAG / staleness for `/tw-verify` comes from Supabase (`items.heatmap_status` + scan timestamps), same pattern as `guard/guard_hook.py`. + +### BACKLOG — scan → dual-output map + +| Dual-output field | From `tripwire scan` today? | Source for skills 28–30 | +|-------------------|-----------------------------|-------------------------| +| `name` / `type` / `resolved_path` | partial (discover targets only) | Name resolution + discover | +| `state` / `rag` / `stale` / `scanned_at` | **no** | Supabase `items` + scan_runs | +| `will_be_blocked` / `note` | **no** | Guard threshold + enable flag | +| `batch_id` / `scan_run_ids` | **yes** | Echo in `/tw-scan` confirmation (skill 29) | + +Do not invent scan-stdout fields that are not listed above. Skills compose dual-output rows from Supabase + resolution; they use `batch_id` / `scan_run_ids` only as submit receipts. + +## Links + +- Skills that consume this contract: slices [27](../plan/slices/08-H-frontline-agent-hooks/slice-27-tw-enable-disable.md)–[30](../plan/slices/08-H-frontline-agent-hooks/slice-30-tw-self-check.md) +- Operator setup / hooks: [setup-commands.md](setup-commands.md) +- Gate evidence: [`docs/plan/gate-evidence/slice-26.json`](../plan/gate-evidence/slice-26.json) diff --git a/docs/user-guide/setup-commands.md b/docs/user-guide/setup-commands.md index 3a1f0ff..c79c2e5 100644 --- a/docs/user-guide/setup-commands.md +++ b/docs/user-guide/setup-commands.md @@ -79,6 +79,10 @@ RED artifacts. Set `"enable": false` — the same PreToolUse call is approved (full bypass). Scripted smoke: `pytest guard/tests/test_live_enforce_smoke.py -q`. +**`/tw-*` dual output contract:** human Markdown table + machine JSON shape, +`heatmap_status` → six UI states, and observed `tripwire scan` stdout fields — +see [frontline-output-contract.md](frontline-output-contract.md). + ### Verify Supabase access After `tripwire setup`, confirm the anon key (what the browser dashboard uses) diff --git a/guard/tests/test_frontline_output_contract.py b/guard/tests/test_frontline_output_contract.py new file mode 100644 index 0000000..f1569de --- /dev/null +++ b/guard/tests/test_frontline_output_contract.py @@ -0,0 +1,123 @@ +""" +Tests for Frontline dual output contract (slice 26). + +Author: swami +Created: 2026-08-15 +Scope: six UI states + heatmap mapping; human table columns; machine JSON + fields; observed tripwire scan stdout shape (no invented fields) +""" + +from __future__ import annotations + +import re +from pathlib import Path + +REPO_ROOT = Path(__file__).resolve().parents[2] +CONTRACT = REPO_ROOT / "docs" / "user-guide" / "frontline-output-contract.md" +ORCHESTRATOR = REPO_ROOT / "cli" / "src" / "orchestrator.js" +GUARD_HOOK = REPO_ROOT / "guard" / "guard_hook.py" + +SIX_STATES = ("fresh", "stale", "unscanned", "scanning", "not-found", "red") +MACHINE_FIELDS = ( + "name", + "resolved_path", + "type", + "state", + "rag", + "scanned_at", + "stale", + "will_be_blocked", + "note", +) +SCAN_STDOUT_FIELDS = ("batch_id", "scan_run_ids", "failed_targets") +HEATMAP_VALUES = ("green", "amber", "red", "grey", "error") + + +def test_given_contract_doc_when_read_then_six_states_and_heatmap_mapping_present() -> None: + """ + Scenario: Contract names all six UI states and maps heatmap_status sources. + Slice: 26 — six states are named and mapped + + Given the Frontline dual-output contract doc, + When an operator looks up each UI state and heatmap_status values, + Then fresh/stale/unscanned/scanning/not-found/red and green/amber/red/grey/error + are each documented with a mapping. + """ + ### Given + assert CONTRACT.is_file(), f"missing contract SSOT: {CONTRACT}" + text = CONTRACT.read_text(encoding="utf-8") + + ### When / Then + for state in SIX_STATES: + assert re.search(rf"`{re.escape(state)}`", text), f"state `{state}` missing from contract" + for value in HEATMAP_VALUES: + assert f"heatmap_status={value}" in text or f"`{value}`" in text, ( + f"heatmap value `{value}` missing from contract mapping" + ) + assert "heatmap_status" in text + assert "Will be blocked when Tripwire is enabled" in text + + +def test_given_contract_doc_when_read_then_human_table_columns_are_fixed() -> None: + """ + Scenario: Human Markdown table columns are Name | Type | Status | Note. + Slice: 26 — human table columns are fixed + + Given the contract doc, + When rendering a verify/scan response, + Then the documented columns are Name, Type, Status, and Note in that order. + """ + ### Given + text = CONTRACT.read_text(encoding="utf-8") + + ### When / Then + assert re.search( + r"\|\s*Name\s*\|\s*Type\s*\|\s*Status\s*\|\s*Note\s*\|", + text, + ), "human table must declare columns Name | Type | Status | Note" + + +def test_given_contract_doc_when_read_then_machine_json_fields_are_fixed() -> None: + """ + Scenario: Machine JSON artifact shape lists required dual-output fields. + Slice: 26 — machine JSON shape is fixed + + Given the contract doc, + When emitting machine output, + Then each artifact documents name, resolved_path, type, state, rag, + scanned_at, stale, will_be_blocked, and note. + """ + ### Given + text = CONTRACT.read_text(encoding="utf-8") + + ### When / Then + assert '"artifacts"' in text or "`artifacts`" in text + for field in MACHINE_FIELDS: + assert f"`{field}`" in text, f"machine field `{field}` missing from contract" + + +def test_given_orchestrator_when_introspected_then_scan_json_matches_contract() -> None: + """ + Scenario: Documented tripwire scan stdout matches orchestrator runScan result. + Slice: 26 — scan JSON introspection recorded + + Given cli/src/orchestrator.js runScan result construction, + When comparing to the contract's observed scan JSON section, + Then batch_id, scan_run_ids, and failed_targets are both in code and docs, + and BACKLOG notes that dual-output rows are not inventable from scan stdout alone. + """ + ### Given + assert ORCHESTRATOR.is_file() + orch = ORCHESTRATOR.read_text(encoding="utf-8") + contract = CONTRACT.read_text(encoding="utf-8") + + ### When / Then + for field in SCAN_STDOUT_FIELDS: + assert field in orch, f"orchestrator missing observed field {field}" + assert f"`{field}`" in contract or f'"{field}"' in contract, ( + f"contract must document observed scan field {field}" + ) + assert "BACKLOG" in contract + assert "Do not invent" in contract or "Do not invent scan-stdout" in contract + guard = GUARD_HOOK.read_text(encoding="utf-8") + assert 'item["heatmap_status"]' in guard or "heatmap_status" in guard From 14a2a253e4d5e00758cce97b729b5dc15087fdd3 Mon Sep 17 00:00:00 2001 From: Mani Sarkar Date: Sat, 15 Aug 2026 17:14:33 +0100 Subject: [PATCH 07/12] docs(slice-26): record PR #77 in gate evidence --- docs/plan/gate-evidence/slice-26.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/plan/gate-evidence/slice-26.json b/docs/plan/gate-evidence/slice-26.json index ee20898..35d3db2 100644 --- a/docs/plan/gate-evidence/slice-26.json +++ b/docs/plan/gate-evidence/slice-26.json @@ -61,5 +61,5 @@ "verdict": "ON_BRANCH", "phase": "H2", "moscow": "Must", - "pr": null + "pr": "https://github.com/neomatrix369/tripwire/pull/77" } From b3e9c12543d739617ab58d24ebd81dbc8680f23d Mon Sep 17 00:00:00 2001 From: Mani Sarkar Date: Sat, 15 Aug 2026 17:17:20 +0100 Subject: [PATCH 08/12] fix(slice-26): lower setupAgentHooks eslint complexity under 10 Extract command-string helpers so CI complexity gate passes on collectPreToolUseCommands and the test counter. --- cli/src/setupAgentHooks.js | 32 ++++++++++++++------------------ cli/test/setupAgentHooks.test.js | 30 ++++++++++++++++++------------ 2 files changed, 32 insertions(+), 30 deletions(-) diff --git a/cli/src/setupAgentHooks.js b/cli/src/setupAgentHooks.js index 02e34e6..dcee5a5 100644 --- a/cli/src/setupAgentHooks.js +++ b/cli/src/setupAgentHooks.js @@ -112,24 +112,22 @@ function isTripwirePreToolUseCommand(command, preToolUseSh) { return command.includes('.tripwire/hooks/pre-tool-use.sh'); } +function commandStringsFromEntry(entry) { + const inner = entry?.hooks; + if (!Array.isArray(inner)) { + return []; + } + return inner + .filter((h) => h?.type === 'command' && typeof h.command === 'string') + .map((h) => h.command); +} + function collectPreToolUseCommands(settings) { const hooks = settings?.hooks?.PreToolUse; if (!Array.isArray(hooks)) { return []; } - const commands = []; - for (const entry of hooks) { - const inner = entry?.hooks; - if (!Array.isArray(inner)) { - continue; - } - for (const h of inner) { - if (h?.type === 'command' && typeof h.command === 'string') { - commands.push(h.command); - } - } - } - return commands; + return hooks.flatMap(commandStringsFromEntry); } async function registerPreToolUse({ claudeSettingsPath, preToolUseSh }) { @@ -164,11 +162,9 @@ async function registerPreToolUse({ claudeSettingsPath, preToolUseSh }) { // Drop duplicate Tripwire PreToolUse entries if a prior broken install left multiples. const seen = new Set(); settings.hooks.PreToolUse = settings.hooks.PreToolUse.filter((entry) => { - const inner = Array.isArray(entry?.hooks) ? entry.hooks : []; - const tripwireCmds = inner - .filter((h) => h?.type === 'command') - .map((h) => h.command) - .filter((cmd) => isTripwirePreToolUseCommand(cmd, preToolUseSh)); + const tripwireCmds = commandStringsFromEntry(entry).filter((cmd) => + isTripwirePreToolUseCommand(cmd, preToolUseSh), + ); if (tripwireCmds.length === 0) { return true; } diff --git a/cli/test/setupAgentHooks.test.js b/cli/test/setupAgentHooks.test.js index d439b90..d6086d8 100644 --- a/cli/test/setupAgentHooks.test.js +++ b/cli/test/setupAgentHooks.test.js @@ -36,23 +36,29 @@ function hooksDirFor(home) { return path.join(home, '.tripwire', 'hooks'); } +function isTripwirePreToolUseCommand(command, preToolUseSh) { + if (typeof command !== 'string') { + return false; + } + return command === preToolUseSh || command.includes('.tripwire/hooks/pre-tool-use.sh'); +} + +function commandStringsFromEntry(entry) { + const inner = Array.isArray(entry?.hooks) ? entry.hooks : []; + return inner + .filter((h) => h?.type === 'command' && typeof h.command === 'string') + .map((h) => h.command); +} + function countTripwirePreToolUse(settings, preToolUseSh) { const entries = settings?.hooks?.PreToolUse; if (!Array.isArray(entries)) { return 0; } - let count = 0; - for (const entry of entries) { - const inner = Array.isArray(entry?.hooks) ? entry.hooks : []; - for (const h of inner) { - if (h?.type === 'command' && typeof h.command === 'string') { - if (h.command === preToolUseSh || h.command.includes('.tripwire/hooks/pre-tool-use.sh')) { - count += 1; - } - } - } - } - return count; + return entries + .flatMap(commandStringsFromEntry) + .filter((cmd) => isTripwirePreToolUseCommand(cmd, preToolUseSh)) + .length; } test('given clean home when setup-agent-hooks runs then hooks exist with mode 700', async () => { From 4c0b7b6d4d271b67f3b214da61403a0a31e9c00d Mon Sep 17 00:00:00 2001 From: Mani Sarkar Date: Sat, 15 Aug 2026 17:37:16 +0100 Subject: [PATCH 09/12] feat(slice-27): add /tw-enable and /tw-disable config toggles Operators can flip only the enable flag while preserving other config keys; verify/scan remain usable when enforcement is off. --- .claude/skills/tw-disable/SKILL.md | 28 +++ .claude/skills/tw-enable/SKILL.md | 25 +++ .gitignore | 6 +- docs/STATUS.md | 12 +- docs/plan/DECISIONS.md | 4 + docs/plan/PROGRESS.md | 40 ++-- docs/plan/TRAIL.md | 4 +- docs/plan/gate-evidence/slice-26.json | 8 +- docs/plan/gate-evidence/slice-27.json | 78 +++++-- .../slice-26-api-output-contract.md | 4 +- .../slice-27-tw-enable-disable.md | 107 ++++++--- docs/user-guide/frontline-output-contract.md | 4 +- docs/user-guide/setup-commands.md | 13 +- guard/config.py | 24 ++ guard/control_skills.py | 61 +++++ guard/tests/test_control_skills_units.py | 211 ++++++++++++++++++ guard/tests/test_tw_enable_disable.py | 185 +++++++++++++++ 17 files changed, 727 insertions(+), 87 deletions(-) create mode 100644 .claude/skills/tw-disable/SKILL.md create mode 100644 .claude/skills/tw-enable/SKILL.md create mode 100644 guard/control_skills.py create mode 100644 guard/tests/test_control_skills_units.py create mode 100644 guard/tests/test_tw_enable_disable.py diff --git a/.claude/skills/tw-disable/SKILL.md b/.claude/skills/tw-disable/SKILL.md new file mode 100644 index 0000000..e59a8ec --- /dev/null +++ b/.claude/skills/tw-disable/SKILL.md @@ -0,0 +1,28 @@ +--- +name: tw-disable +description: Disable Tripwire PreToolUse enforcement by setting enable=false in ~/.tripwire/config.json +disable-model-invocation: true +--- + +# /tw-disable + +Toggle **only** the `enable` flag in `~/.tripwire/config.json` to `false`. +Do not call Tripwire scan, guard, or other APIs — config write only. +Preserve `scan_validity_days` and any other keys. + +Full bypass at the PreToolUse enforcement layer. Manual `/tw-verify` and +`/tw-scan` remain usable when disabled (they do not no-op solely because +`enable` is false). + +## Steps + +1. Resolve config path: `$TRIPWIRE_CONFIG` if set, else `~/.tripwire/config.json`. +2. Run from the tripwire repo (or installed package): + +```bash +uv run python -c "from guard.control_skills import disable_enforcement; print(disable_enforcement())" +``` + +3. Confirm the printed JSON has `"enable": false` and other keys unchanged. +4. Tell the operator: PreToolUse enforcement is **off** (approve-all bypass). + Verify and scan skills remain available. diff --git a/.claude/skills/tw-enable/SKILL.md b/.claude/skills/tw-enable/SKILL.md new file mode 100644 index 0000000..40a1689 --- /dev/null +++ b/.claude/skills/tw-enable/SKILL.md @@ -0,0 +1,25 @@ +--- +name: tw-enable +description: Enable Tripwire PreToolUse enforcement by setting enable=true in ~/.tripwire/config.json +disable-model-invocation: true +--- + +# /tw-enable + +Toggle **only** the `enable` flag in `~/.tripwire/config.json` to `true`. +Do not call Tripwire scan, guard, or other APIs — config write only. +Preserve `scan_validity_days` and any other keys. + +## Steps + +1. Resolve config path: `$TRIPWIRE_CONFIG` if set, else `~/.tripwire/config.json`. +2. Run from the tripwire repo (or installed package): + +```bash +uv run python -c "from guard.control_skills import enable_enforcement; print(enable_enforcement())" +``` + +3. Confirm the printed JSON has `"enable": true` and other keys unchanged. +4. Tell the operator: PreToolUse enforcement is **on** (unscanned/RED blocked). + +`/tw-verify` and `/tw-scan` are unaffected by this flag — they remain usable either way. diff --git a/.gitignore b/.gitignore index 2786a44..4cd7ec5 100644 --- a/.gitignore +++ b/.gitignore @@ -2,7 +2,11 @@ .code-review-graph/ # Local agent / MCP config (project rules under .cursor/rules are tracked) -.claude/ +.claude/* +!.claude/skills/ +.claude/skills/* +!.claude/skills/tw-*/ +!.claude/skills/tw-*/** .cursor/* !.cursor/rules/ !.cursor/rules/** diff --git a/docs/STATUS.md b/docs/STATUS.md index f94d045..7143142 100644 --- a/docs/STATUS.md +++ b/docs/STATUS.md @@ -137,11 +137,15 @@ slices 23–39 on branch `frontline-hackathon-london-2026-agent-hooks`. See scripted smoke after install proves enable→block (unscanned/RED) and disable→approve; docs state no native install-event hook. H2 checkpoint signed off 2026-08-15. -- **Slice 26 (API / dual output contract):** 🔀 ON BRANCH — SSOT +- **Slice 26 (API / dual output contract):** ✅ **VERIFIED** (PR #78) — SSOT [user-guide/frontline-output-contract.md](./user-guide/frontline-output-contract.md) documents six UI states, `heatmap_status` mapping, dual human/JSON shape, and observed `tripwire scan` stdout (`batch_id` / `scan_run_ids` / `failed_targets`). -- Remaining H Musts/Shoulds: not IMPLEMENTED — no `/tw-*` skills yet. +- **Slice 27 (`/tw-enable` + `/tw-disable`):** 🔀 ON BRANCH — `guard.config.set_enable`, + Claude skills under `.claude/skills/tw-{enable,disable}/`, verify/scan remain + usable when disabled (probe until slices 28–29). +- Remaining H Musts/Shoulds: `/tw-verify`, `/tw-scan`, `/tw-self-check` not yet + IMPLEMENTED. --- @@ -161,8 +165,8 @@ Known fixture gaps (not urgent) are listed under as shipped capabilities. Guard PreToolUse and Drift/trend remain Future / Won't (A) for the Horizon A ship path — see [ADR-0015](./adr/0015-horizon-a-excludes-guard-and-drift.md). Frontline Guard -integration is **DECIDED** as Wave H — see DECIDED above. Slice 23–25 ✅; -slice 26 dual-output contract on branch. `/tw-*` skills still pending later H slices. +integration is **DECIDED** as Wave H — see DECIDED above. Slice 23–26 ✅; +slice 27 `/tw-enable`/`/tw-disable` on branch. Remaining `/tw-*` skills pending. Coverage audit matrix: [plan/coverage-audit.md](./plan/coverage-audit.md) (slice 7 ✅). Slice stubs: [plan/README.md](./plan/README.md) (`01-A-…` … diff --git a/docs/plan/DECISIONS.md b/docs/plan/DECISIONS.md index 7ae72c0..b95d076 100644 --- a/docs/plan/DECISIONS.md +++ b/docs/plan/DECISIONS.md @@ -108,3 +108,7 @@ | 2026-08-15 | phase-gates | H2 checkpoint signed off | Operator requested start of slice 26 after slice 25 merge (PR #76). Unblocks Phase H2 (26–30). Scripted smoke remains the primary evidence; live Claude Code session observation optional. | | 2026-08-15 | slice-26 | AT design complete | Four docs-contract GWTs; SSOT `docs/user-guide/frontline-output-contract.md`; coverage/complexity N/A docs-only. | | 2026-08-15 | slice-26 | docs-only review | Documentation + characterization tests only (no product-code change); GATE_CONTRACT docs-only review exception applies (same pattern as slice 17). | +| 2026-08-15 | slice-26 | ✅ closed | PR #78 merged into `frontline-hackathon-london-2026-agent-hooks`. Gate evidence `verdict: PASS`. | +| 2026-08-15 | slice-27 | AT design complete | Five GWTs; `set_enable` preserves unknown keys; skills at `.claude/skills/tw-{enable,disable}/SKILL.md`; ≥95% lines on `guard/config.py` + `guard/control_skills.py`. | +| 2026-08-15 | slice-27 | review APPROVED | nw-software-crafter-reviewer — inlined `_read_raw_config`; probe shape asserts strengthened. | +| 2026-08-15 | slice-27 | track tw-* skills | `.gitignore` allows `.claude/skills/tw-*/**` so Frontline control skills ship in-repo while other `.claude/` stays local-only. | diff --git a/docs/plan/PROGRESS.md b/docs/plan/PROGRESS.md index e880782..404dc2f 100644 --- a/docs/plan/PROGRESS.md +++ b/docs/plan/PROGRESS.md @@ -12,28 +12,27 @@ | 5 | [`05-E-…`](slices/05-E-ship-path-coverage/) | **E — Ship-path coverage** | 8 → 11 → 12 → 13 ✅ → 14 (**9+10 SUBSUMED INTO 11**) | ✅ Musts · close-path | | 6 | [`06-F-…`](slices/06-F-claim-audit/) | **F — Claim audit** | 15 · 16 | 📦 | | 7 | [`07-G-…`](slices/07-G-atdd-closure/) | **G — ATDD closure** | 18, 19, 20, 21, 22 (independent gates) | 📋 parked | -| 8 | [`08-H-…`](slices/08-H-frontline-agent-hooks/) | **H — Frontline agent hooks** | 23→32 Must · 33–38 Should · 39 Could | 23–25 ✅ · 26 🔀 | +| 8 | [`08-H-…`](slices/08-H-frontline-agent-hooks/) | **H — Frontline agent hooks** | 23→32 Must · 33–38 Should · 39 Could | 23–26 ✅ · 27 🔀 | -**Current priority:** Wave H Must slice **26** — API Introspect + Dual Output Contract — 🔀 ON BRANCH. Wave G (18–22) parked while H1–H3 is active unless explicitly resumed. +**Current priority:** Wave H Must slice **27** — `/tw-enable` + `/tw-disable` — 🔀 ON BRANCH. Wave G (18–22) parked while H1–H3 is active unless explicitly resumed. ## Execution order (open work) | Order | Wave | # | Slice | MoSCoW | Status | |------:|-----:|---|-------|--------|--------| -| 1 | H2 | 26 | API Introspect + Dual Output Contract | Must | 🔀 ON BRANCH | -| 2 | H2 | 27 | `/tw-enable` + `/tw-disable` | Must | 📋 PLANNED | -| 3 | H2 | 28 | `/tw-verify` | Must | 📋 PLANNED | -| 4 | H2 | 29 | `/tw-scan` | Must | 📋 PLANNED | -| 5 | H2 | 30 | `/tw-self-check` | Must | 📋 PLANNED | -| 6 | H3 | 31 | Demo Artifacts | Must | 📋 PLANNED | -| 7 | H3 | 32 | Phase 1 Regression Verification (HARD GATE) | Must | 📋 PLANNED | -| 8 | H4 | 33 | DepShield Install | Should | 📋 PLANNED | -| 9 | H4 | 34 | DepShield Dispatch | Should | 📋 PLANNED | -| 10 | H5 | 35 | Ossprey Access Provisioning | Should | 🔴 BLOCKED | -| 11 | H5 | 36 | Ossprey Dispatch | Should | 📋 PLANNED | -| 12 | H6 | 37 | CLI Monitoring | Should | 📋 PLANNED | -| 13 | H6 | 38 | Full-Chain Validation | Should | 📋 PLANNED | -| 14 | H6 | 39 | FE/BE Rearchitecture | Could | 📦 DEFERRED | +| 1 | H2 | 27 | `/tw-enable` + `/tw-disable` | Must | 🔀 ON BRANCH | +| 2 | H2 | 28 | `/tw-verify` | Must | 📋 PLANNED | +| 3 | H2 | 29 | `/tw-scan` | Must | 📋 PLANNED | +| 4 | H2 | 30 | `/tw-self-check` | Must | 📋 PLANNED | +| 5 | H3 | 31 | Demo Artifacts | Must | 📋 PLANNED | +| 6 | H3 | 32 | Phase 1 Regression Verification (HARD GATE) | Must | 📋 PLANNED | +| 7 | H4 | 33 | DepShield Install | Should | 📋 PLANNED | +| 8 | H4 | 34 | DepShield Dispatch | Should | 📋 PLANNED | +| 9 | H5 | 35 | Ossprey Access Provisioning | Should | 🔴 BLOCKED | +| 10 | H5 | 36 | Ossprey Dispatch | Should | 📋 PLANNED | +| 11 | H6 | 37 | CLI Monitoring | Should | 📋 PLANNED | +| 12 | H6 | 38 | Full-Chain Validation | Should | 📋 PLANNED | +| 13 | H6 | 39 | FE/BE Rearchitecture | Could | 📦 DEFERRED | | — | G | 18–22 | ATDD closure (parked) | Must | 📋 PLANNED | ## Quick Status (by group) @@ -94,8 +93,8 @@ | 23 | [slice-23-config-handler-scripts](slices/08-H-frontline-agent-hooks/slice-23-config-handler-scripts.md) | Must | ✅ | 2026-08-15 | 2026-08-15 | ~40 min | | 24 | [slice-24-setup-agent-hooks](slices/08-H-frontline-agent-hooks/slice-24-setup-agent-hooks.md) | Must | ✅ | 2026-08-15 | 2026-08-15 | ~40 min | | 25 | [slice-25-live-enforce-smoke](slices/08-H-frontline-agent-hooks/slice-25-live-enforce-smoke.md) | Must | ✅ | 2026-08-15 | 2026-08-15 | ~30 min | -| 26 | [slice-26-api-output-contract](slices/08-H-frontline-agent-hooks/slice-26-api-output-contract.md) | Must | 🔀 ON BRANCH | 2026-08-15 | — | ~40 min | -| 27 | [slice-27-tw-enable-disable](slices/08-H-frontline-agent-hooks/slice-27-tw-enable-disable.md) | Must | 📋 PLANNED | — | — | ~25 min | +| 26 | [slice-26-api-output-contract](slices/08-H-frontline-agent-hooks/slice-26-api-output-contract.md) | Must | ✅ | 2026-08-15 | 2026-08-15 | ~40 min | +| 27 | [slice-27-tw-enable-disable](slices/08-H-frontline-agent-hooks/slice-27-tw-enable-disable.md) | Must | 🔀 ON BRANCH | 2026-08-15 | — | ~25 min | | 28 | [slice-28-tw-verify](slices/08-H-frontline-agent-hooks/slice-28-tw-verify.md) | Must | 📋 PLANNED | — | — | ~50 min | | 29 | [slice-29-tw-scan](slices/08-H-frontline-agent-hooks/slice-29-tw-scan.md) | Must | 📋 PLANNED | — | — | ~40 min | | 30 | [slice-30-tw-self-check](slices/08-H-frontline-agent-hooks/slice-30-tw-self-check.md) | Must | 📋 PLANNED | — | — | ~30 min | @@ -121,7 +120,7 @@ ## Forward Roadmap - Waves **A–C**, coverage Slice 14, and Slice 17 are merged and closed. Slice 15 is retained as a deferred claim-audit artifact, not active work. -- **Wave H (Frontline):** integration branch `frontline-hackathon-london-2026-agent-hooks`. Slice 23 ✅ (PR #74). Slice 24 ✅ (PR #75). Slice 25 ✅ (PR #76; H2 checkpoint signed off). Next: 26 (IN PROGRESS). +- **Wave H (Frontline):** integration branch `frontline-hackathon-london-2026-agent-hooks`. Slice 23 ✅ (PR #74). Slice 24 ✅ (PR #75). Slice 25 ✅ (PR #76). Slice 26 ✅ (PR #78). Slice 27 🔀 (`/tw-enable` + `/tw-disable`) ON BRANCH pending merge. - Wave G (18–22) remains planned but **parked** while Frontline H1–H3 is active unless explicitly resumed. - Reopen Slice 15 only for a future live/demo release that needs its security and 3B evidence path. - **Deferred / Won't (A):** 4 (in A); 15 and 16 (in F) — reinstate only if a new live/demo need arises @@ -161,4 +160,5 @@ | 2026-08-15 | slice/23-config-handler-scripts | slice-workflow | 23 | ✅ PASSED (PR #74) | Config+handlers; 6 GWTs; cov 98.8%; merged to Frontline | | 2026-08-15 | slice/24-setup-agent-hooks | slice-workflow | 24 | ✅ PASSED (PR #75) | setup-agent-hooks; 7 tests; cov 93.11%; merged to Frontline | | 2026-08-15 | slice/25-live-enforce-smoke | slice-workflow | 25 | ✅ PASSED (PR #76) | live enforce smoke; H2 checkpoint signed off | -| 2026-08-15 | slice/26-api-output-contract | slice-workflow | 26 | 🔀 ON BRANCH | dual output contract SSOT + 4 ATs; docs-only | +| 2026-08-15 | slice/26-api-output-contract | slice-workflow | 26 | ✅ PASSED (PR #78) | dual output contract SSOT + 4 ATs; docs-only | +| 2026-08-15 | slice/27-tw-enable-disable | slice-workflow | 27 | 🔀 ON BRANCH | 5 ATs; set_enable + skills; cov 100%; review APPROVED | diff --git a/docs/plan/TRAIL.md b/docs/plan/TRAIL.md index a508434..68e441e 100644 --- a/docs/plan/TRAIL.md +++ b/docs/plan/TRAIL.md @@ -167,8 +167,8 @@ Branch: `frontline-hackathon-london-2026-agent-hooks`. Source: `internal-docs/04 | # | File | Name | MoSCoW | Status | Depends on | Issue | Read time | |---|------|------|--------|--------|------------|-------|-----------| -| 26 | [slice-26-api-output-contract](slices/08-H-frontline-agent-hooks/slice-26-api-output-contract.md) | API Introspect + Dual Output Contract | Must | 🔀 | 25 | — | ~4 min | -| 27 | [slice-27-tw-enable-disable](slices/08-H-frontline-agent-hooks/slice-27-tw-enable-disable.md) | `/tw-enable` + `/tw-disable` | Must | 📋 | 26 | — | ~3 min | +| 26 | [slice-26-api-output-contract](slices/08-H-frontline-agent-hooks/slice-26-api-output-contract.md) | API Introspect + Dual Output Contract | Must | ✅ | 25 | #78 | ~4 min | +| 27 | [slice-27-tw-enable-disable](slices/08-H-frontline-agent-hooks/slice-27-tw-enable-disable.md) | `/tw-enable` + `/tw-disable` | Must | 🔀 | 26 | — | ~3 min | | 28 | [slice-28-tw-verify](slices/08-H-frontline-agent-hooks/slice-28-tw-verify.md) | `/tw-verify` | Must | 📋 | 26,27 | — | ~5 min | | 29 | [slice-29-tw-scan](slices/08-H-frontline-agent-hooks/slice-29-tw-scan.md) | `/tw-scan` | Must | 📋 | 26 | — | ~4 min | | 30 | [slice-30-tw-self-check](slices/08-H-frontline-agent-hooks/slice-30-tw-self-check.md) | `/tw-self-check` | Must | 📋 | 28 | — | ~3 min | diff --git a/docs/plan/gate-evidence/slice-26.json b/docs/plan/gate-evidence/slice-26.json index 35d3db2..039731a 100644 --- a/docs/plan/gate-evidence/slice-26.json +++ b/docs/plan/gate-evidence/slice-26.json @@ -1,6 +1,6 @@ { "slice": 26, - "gate_status": "ON_BRANCH", + "gate_status": "PASSED", "inferred": false, "branch": "slice/26-api-output-contract", "date": "2026-08-15", @@ -14,7 +14,7 @@ "PASS — .venv/bin/pytest guard/tests/test_frontline_output_contract.py -q (4 passed)", "PASS — coverage/complexity N/A docs-only (no product-code change)", "PASS — docs-only review exception recorded in DECISIONS 2026-08-15", - "PENDING merge — PROGRESS/TRAIL ✅ after merge to Frontline" + "PASS — merged to Frontline via PR #78 (merge-equivalent while Wave H active)" ], "commands": [ { @@ -58,8 +58,8 @@ "3_links": "PASS — main_prompt + slices 27–30 + setup-commands + docs index", "4_cross_link": "PASS — gate-evidence ↔ TRAIL/PROGRESS" }, - "verdict": "ON_BRANCH", + "verdict": "PASS", "phase": "H2", "moscow": "Must", - "pr": "https://github.com/neomatrix369/tripwire/pull/77" + "pr": "https://github.com/neomatrix369/tripwire/pull/78" } diff --git a/docs/plan/gate-evidence/slice-27.json b/docs/plan/gate-evidence/slice-27.json index e787718..f7d8f9b 100644 --- a/docs/plan/gate-evidence/slice-27.json +++ b/docs/plan/gate-evidence/slice-27.json @@ -1,24 +1,66 @@ { "slice": 27, - "gate_status": "PLANNED", + "gate_status": "ON_BRANCH", "inferred": false, - "branch": null, - "date": null, - "before_checks": [], - "after_checks": [], - "planned_commands": [ - "test -f docs/plan/slices/08-H-frontline-agent-hooks/slice-27-tw-enable-disable.md", - "rg -n \"tw-enable|tw-disable|enable\" docs/plan/slices/08-H-frontline-agent-hooks/", - "python -c \"import json,os; p=os.path.expanduser('~/.tripwire/config.json'); print(json.load(open(p)) if os.path.exists(p) else 'config absent')\"", - "./scripts/quality-gates.sh" + "branch": "slice/27-tw-enable-disable", + "date": "2026-08-15", + "spec_path": "docs/plan/slices/08-H-frontline-agent-hooks/slice-27-tw-enable-disable.md", + "skill_layout": [ + ".claude/skills/tw-enable/SKILL.md", + ".claude/skills/tw-disable/SKILL.md" ], - "commands": [], - "test_budget": {"acceptance_tests_max": 7, "parametrized_case_counts_as_one": true}, - "reviewers": [], - "review": {"acceptance": "PENDING", "implementation": "PENDING"}, - "verdict": "NOT_RUN", - "coverage_target": "TBD at AT design before IN PROGRESS", - "complexity_policy": "enforcing for product-code; N/A for docs-only with reason in evidence", + "before_checks": "PASS — slice 26 verdict PASS (PR #78); branch from Frontline (Wave H base waiver); skill layout .claude/skills/tw-{enable,disable}/SKILL.md; coverage ≥95% on config+control_skills; complexity enforcing", + "after_checks": [ + "PASS — enable/disable GWTs; only enable changes (preserve scan_validity_days + extras)", + "PASS — manual verify/scan probe still produces output when enable=false", + "PASS — .venv/bin/pytest guard/tests/test_tw_enable_disable.py -q (7 passed)", + "PASS — coverage 100% lines on guard/config.py + guard/control_skills.py (target ≥95%)", + "PASS — xenon exit 0 on product modules; ./scripts/quality-gates.sh PASS", + "PASS — review acceptance+implementation APPROVED (nw-software-crafter-reviewer)", + "PENDING merge — PROGRESS/TRAIL ✅ after merge to Frontline" + ], + "commands": [ + { + "cmd": ".venv/bin/pytest guard/tests/test_tw_enable_disable.py -q --tb=short", + "result": "PASS exit 0 — 7 passed" + }, + { + "cmd": ".venv/bin/pytest guard/tests/test_tw_enable_disable.py guard/tests/test_control_skills_units.py guard/tests/test_config_handler_units.py --cov=guard.config --cov=guard.control_skills --cov-report=term-missing -q", + "result": "PASS — 30 passed; TOTAL 100% (config 100%, control_skills 100%)" + }, + { + "cmd": "./scripts/quality-gates.sh", + "result": "PASS — quality-gates passed (ruff/mypy/xenon/gitleaks/cli tests)" + }, + { + "cmd": "test -f .claude/skills/tw-enable/SKILL.md && test -f .claude/skills/tw-disable/SKILL.md", + "result": "PASS — Claude skill layout present" + } + ], + "test_budget": { + "acceptance_tests_max": 7, + "acceptance_tests_designed": 5, + "parametrized_case_counts_as_one": true + }, + "coverage": { + "target": "≥95% lines on guard/config.py + guard/control_skills.py", + "measured": "100.0% lines (64 stmts)", + "date": "2026-08-15" + }, + "complexity_policy": "enforcing for product-code — xenon exit 0 via quality-gates", + "reviewers": ["nw-software-crafter-reviewer"], + "review": { + "acceptance": "APPROVED", + "implementation": "APPROVED", + "notes": "Blocker YAGNI _read_raw_config inlined into set_enable; probe assertions strengthened; 100% cov; quality-gates PASS" + }, + "documentation_audit": { + "1_config_toggles_only": "PASS — setup-commands.md + SKILL.md", + "2_verify_scan_when_disabled": "PASS — setup-commands + tw-disable SKILL.md", + "3_cross_link": "PASS — gate-evidence ↔ TRAIL/PROGRESS; frontline-output-contract links slice-27" + }, + "verdict": "ON_BRANCH", "phase": "H2", - "moscow": "Must" + "moscow": "Must", + "pr": null } diff --git a/docs/plan/slices/08-H-frontline-agent-hooks/slice-26-api-output-contract.md b/docs/plan/slices/08-H-frontline-agent-hooks/slice-26-api-output-contract.md index 36b8d37..4b46593 100644 --- a/docs/plan/slices/08-H-frontline-agent-hooks/slice-26-api-output-contract.md +++ b/docs/plan/slices/08-H-frontline-agent-hooks/slice-26-api-output-contract.md @@ -89,7 +89,7 @@ REFACTOR: keep BACKLOG fields explicit; no silent invention. - [x] Coverage/complexity: **N/A for docs-only** with reason in evidence - [x] `docs/plan/gate-evidence/slice-26.json` records commands, reviewers, and `verdict: ON_BRANCH` (PASS after merge) - [x] Review: docs-only exception in DECISIONS (2026-08-15) -- [ ] `PROGRESS.md` + `TRAIL.md` show slice 26 ✅ (after merge to Frontline) +- [x] `PROGRESS.md` + `TRAIL.md` show slice 26 ✅ (PR #78 merged to Frontline) ## Doc Audit @@ -102,4 +102,4 @@ REFACTOR: keep BACKLOG fields explicit; no silent invention. ## Gate Status -🔀 ON BRANCH +✅ PASSED diff --git a/docs/plan/slices/08-H-frontline-agent-hooks/slice-27-tw-enable-disable.md b/docs/plan/slices/08-H-frontline-agent-hooks/slice-27-tw-enable-disable.md index fecd9e0..43efe81 100644 --- a/docs/plan/slices/08-H-frontline-agent-hooks/slice-27-tw-enable-disable.md +++ b/docs/plan/slices/08-H-frontline-agent-hooks/slice-27-tw-enable-disable.md @@ -8,53 +8,100 @@ ## GWT acceptance specification -Thin scaffolds — full DISTILL ATs deferred per DECISIONS; design ATs before marking IN PROGRESS. - -1. **Enable sets flag true** - - Given config with `enable=false`, when `/tw-enable` runs, then `~/.tripwire/config.json` has `enable=true` and other keys are unchanged. -2. **Disable sets flag false** - - Given config with `enable=true`, when `/tw-disable` runs, then `enable=false` and other keys are unchanged. -3. **Verify/scan still work when disabled** - - Given `enable=false`, when `/tw-verify` or `/tw-scan` is invoked for a resolvable name, then the skill still produces status/submit output (does not no-op solely because enforcement is off). +**DISTILL ATs (2026-08-15)** — ≤7; product-code + Claude skill SKILL.md. + +| # | Scenario | Tags | Real-surface binding | +|---|----------|------|----------------------| +| 1 | Enable sets flag true, preserves keys | `@US-27` | `guard.config.set_enable` + `.claude/skills/tw-enable/SKILL.md` | +| 2 | Disable sets flag false, preserves keys | `@US-27` | `guard.config.set_enable` + `.claude/skills/tw-disable/SKILL.md` | +| 3 | Manual verify/scan still work when disabled | `@US-27` | `guard.control_skills.manual_skill_probe` (parametrized) | +| 4 | Skills shipped at Claude layout path | `@US-27` | `.claude/skills/tw-{enable,disable}/SKILL.md` | +| 5 | Missing config created on toggle | `@US-27` | `guard.config.set_enable` | + +1. **Enable sets flag true** `@US-27` + - Given config with `enable=false` and an extra key, + when `/tw-enable` / `set_enable(..., True)` runs, + then `enable=true` and other keys (incl. `scan_validity_days` + extras) are unchanged. +2. **Disable sets flag false** `@US-27` + - Given config with `enable=true` and an extra key, + when `/tw-disable` / `set_enable(..., False)` runs, + then `enable=false` and other keys are unchanged. +3. **Verify/scan still work when disabled** `@US-27` + - Given `enable=false`, when a manual `verify` or `scan` probe runs for a resolvable name, + then it still produces status/submit-shaped output (does not no-op solely because enforcement is off). +4. **Skills installed at Claude layout** `@US-27` + - Given the repo checkout, when an operator looks for `/tw-enable` and `/tw-disable`, + then `.claude/skills/tw-enable/SKILL.md` and `.claude/skills/tw-disable/SKILL.md` exist + and instruct config-only toggles (no enforcement API calls). +5. **Missing config created on toggle** `@US-27` + - Given no config file, when enable or disable runs, + then `~/.tripwire/config.json` (or fixture path) is created with the requested `enable` + value and default `scan_validity_days=14`. + +**Test inventory (5 acceptance tests):** +`guard/tests/test_tw_enable_disable.py` + +**Named verification command:** + +```bash +.venv/bin/pytest guard/tests/test_tw_enable_disable.py -q --tb=short +``` + +**Coverage / complexity (AT design):** + +- Coverage target: **≥95% lines** on `guard/config.py` (incl. `set_enable`) and + `guard/control_skills.py` (new). +- Complexity: **enforcing** for product-code; cite `./scripts/quality-gates.sh` / + xenon in gate evidence. ## Design / test treatment -- Skills write config only; they do not call Tripwire enforcement APIs for enable/disable. -- Preserve `scan_validity_days` and any future keys across toggles. -- **AT design required before IN PROGRESS** (≤7 acceptance tests). +- Skills write config only via `guard.config.set_enable`; they do not call Tripwire + enforcement APIs for enable/disable. +- Preserve `scan_validity_days` and any future/unknown keys across toggles (raw JSON + round-trip — do not strip via `load_config`). +- Claude skill layout (evidence): project-local + `.claude/skills/tw-enable/SKILL.md` and `.claude/skills/tw-disable/SKILL.md` + (same roots as `cli/src/discovery.js` DEFAULT_SKILL_ROOTS). +- Manual verify/scan independence is asserted via `manual_skill_probe` until slices + 28–29 replace the probe with full skills. +- **AT design complete** — ready for 🔨 IN PROGRESS. ## Before-Checks [GATE] -- [ ] Slice 26 gate-evidence `verdict` is `PASS` -- [ ] Branch `slice/27-tw-enable-disable` created from current `main` -- [ ] Skill install path / Claude skill layout for `/tw-*` noted in evidence -- [ ] Coverage/complexity targets TBD until AT design completes +- [x] Slice 26 gate-evidence `verdict` is `PASS` (PR #78 merged to Frontline) +- [x] Branch `slice/27-tw-enable-disable` created from Frontline integration + (DECISIONS Wave H branch-base waiver) +- [x] Skill install path / Claude skill layout for `/tw-*` noted in evidence + (`.claude/skills/tw-{enable,disable}/SKILL.md`) +- [x] Coverage target ≥95% lines on `guard/config.py` + `guard/control_skills.py`; + complexity enforcing for product-code ## TDD execution RED: add GWTs for enable/disable config mutation and verify/scan-still-works. -GREEN: implement the two skills to toggle `enable` only. +GREEN: implement `set_enable` + two skills + manual probe. REFACTOR: shared config read/write helper if needed; no enforcement logic in these skills. ## After-Checks [GATE] -- [ ] Enable/disable GWTs pass; only `enable` changes -- [ ] Verify/scan-still-works when disabled is asserted observably -- [ ] Named test command(s) from AT design exit 0 (record in gate evidence) -- [ ] Coverage target: set at AT design before IN PROGRESS; recorded % meets that target -- [ ] Complexity policy: **enforcing** for product-code; evidence cites quality-gates / complexity report -- [ ] `docs/plan/gate-evidence/slice-27.json` records commands, coverage, complexity, reviewers, and `verdict: PASS` -- [ ] Review: `acceptance: APPROVED` and `implementation: APPROVED` (or docs-only exception in DECISIONS) -- [ ] `PROGRESS.md` + `TRAIL.md` show slice 27 ✅ +- [x] Enable/disable GWTs pass; only `enable` changes +- [x] Verify/scan-still-works when disabled is asserted observably +- [x] Named test command(s) from AT design exit 0 (record in gate evidence) +- [x] Coverage target: ≥95% lines on `guard/config.py` + `guard/control_skills.py`; recorded % meets that target (100%) +- [x] Complexity policy: **enforcing** for product-code; evidence cites quality-gates / complexity report +- [x] `docs/plan/gate-evidence/slice-27.json` records commands, coverage, complexity, reviewers, and `verdict: ON_BRANCH` (PASS after merge) +- [x] Review: `acceptance: APPROVED` and `implementation: APPROVED` (nw-software-crafter-reviewer) +- [ ] `PROGRESS.md` + `TRAIL.md` show slice 27 ✅ (after merge) ## Doc Audit -| # | Check | -|---|--------| -| 1 | `/tw-enable` and `/tw-disable` documented as config toggles only | -| 2 | Note that `/tw-scan` and `/tw-verify` remain usable when disabled | -| 3 | Cross-link gate-evidence ↔ TRAIL/PROGRESS | +| # | Check | Result | +|---|--------|--------| +| 1 | `/tw-enable` and `/tw-disable` documented as config toggles only | PASS — setup-commands + SKILL.md | +| 2 | Note that `/tw-scan` and `/tw-verify` remain usable when disabled | PASS | +| 3 | Cross-link gate-evidence ↔ TRAIL/PROGRESS | PASS | ## Gate Status -📋 PLANNED +🔀 ON BRANCH (pending review + merge) diff --git a/docs/user-guide/frontline-output-contract.md b/docs/user-guide/frontline-output-contract.md index 315f607..ae5bb4d 100644 --- a/docs/user-guide/frontline-output-contract.md +++ b/docs/user-guide/frontline-output-contract.md @@ -132,5 +132,7 @@ Do not invent scan-stdout fields that are not listed above. Skills compose dual- ## Links - Skills that consume this contract: slices [27](../plan/slices/08-H-frontline-agent-hooks/slice-27-tw-enable-disable.md)–[30](../plan/slices/08-H-frontline-agent-hooks/slice-30-tw-self-check.md) + (`/tw-enable` / `/tw-disable` are config toggles only — they do not emit this table) - Operator setup / hooks: [setup-commands.md](setup-commands.md) -- Gate evidence: [`docs/plan/gate-evidence/slice-26.json`](../plan/gate-evidence/slice-26.json) +- Gate evidence: [`docs/plan/gate-evidence/slice-26.json`](../plan/gate-evidence/slice-26.json) · + [`slice-27.json`](../plan/gate-evidence/slice-27.json) diff --git a/docs/user-guide/setup-commands.md b/docs/user-guide/setup-commands.md index c79c2e5..190a6b5 100644 --- a/docs/user-guide/setup-commands.md +++ b/docs/user-guide/setup-commands.md @@ -73,11 +73,14 @@ PreToolUse command in `~/.claude/settings.json`. Re-runs are idempotent (config preserved; hook not duplicated). Fixture/testing overrides: `--home ` and `--claude-settings `. -**Enable / disable smoke (operator):** after install, set -`"enable": true` in `~/.tripwire/config.json` — PreToolUse blocks unscanned or -RED artifacts. Set `"enable": false` — the same PreToolUse call is approved -(full bypass). Scripted smoke: -`pytest guard/tests/test_live_enforce_smoke.py -q`. +**Enable / disable (operator):** use `/tw-enable` or `/tw-disable` (Claude skills +at `.claude/skills/tw-enable/SKILL.md` and `.claude/skills/tw-disable/SKILL.md`) +to toggle only `"enable"` in `~/.tripwire/config.json` — other keys are +preserved. Same effect as editing the flag by hand: `enable=true` → PreToolUse +blocks unscanned or RED artifacts; `enable=false` → approve-all bypass. +`/tw-verify` and `/tw-scan` remain usable when disabled. Scripted smoke: +`pytest guard/tests/test_live_enforce_smoke.py -q`. Config toggle ATs: +`pytest guard/tests/test_tw_enable_disable.py -q`. **`/tw-*` dual output contract:** human Markdown table + machine JSON shape, `heatmap_status` → six UI states, and observed `tripwire scan` stdout fields — diff --git a/guard/config.py b/guard/config.py index daf8b4b..17022a6 100644 --- a/guard/config.py +++ b/guard/config.py @@ -52,3 +52,27 @@ def ensure_default_config(path: Path | str) -> dict[str, Any]: payload = dict(DEFAULT_CONFIG) config_path.write_text(json.dumps(payload, indent=2) + "\n", encoding="utf-8") return payload + + +def set_enable(path: Path | str, enabled: bool) -> dict[str, Any]: + """Set only ``enable``; preserve ``scan_validity_days`` and any other keys. + + Creates the file (and parents) when absent, seeding defaults then applying + the requested flag. Used by ``/tw-enable`` and ``/tw-disable``. + """ + config_path = Path(path) + data: dict[str, Any] + if not config_path.is_file(): + data = dict(DEFAULT_CONFIG) + else: + try: + raw = json.loads(config_path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError): + raw = None + data = dict(raw) if isinstance(raw, dict) else dict(DEFAULT_CONFIG) + if "scan_validity_days" not in data: + data["scan_validity_days"] = DEFAULT_SCAN_VALIDITY_DAYS + data["enable"] = bool(enabled) + config_path.parent.mkdir(parents=True, exist_ok=True) + config_path.write_text(json.dumps(data, indent=2) + "\n", encoding="utf-8") + return data diff --git a/guard/control_skills.py b/guard/control_skills.py new file mode 100644 index 0000000..af25884 --- /dev/null +++ b/guard/control_skills.py @@ -0,0 +1,61 @@ +"""Manual Tripwire control-skill helpers (slice 27+). + +``/tw-enable`` / ``/tw-disable`` flip config only via ``set_enable``. +``manual_skill_probe`` asserts that verify/scan entry points do not no-op when +enforcement is disabled — full ``/tw-verify`` / ``/tw-scan`` land in slices 28–29. +""" + +from __future__ import annotations + +import os +from pathlib import Path +from typing import Any, Literal + +from guard.config import load_config, set_enable + +ManualSkill = Literal["verify", "scan"] + +DEFAULT_CONFIG_PATH = Path.home() / ".tripwire" / "config.json" + + +def config_path_from_env() -> Path: + """Resolve config path: ``TRIPWIRE_CONFIG`` override or ``~/.tripwire/config.json``.""" + override = os.environ.get("TRIPWIRE_CONFIG") + if override: + return Path(override) + return DEFAULT_CONFIG_PATH + + +def enable_enforcement(path: Path | str | None = None) -> dict[str, Any]: + """``/tw-enable`` — set ``enable=true`` only.""" + return set_enable(path or config_path_from_env(), True) + + +def disable_enforcement(path: Path | str | None = None) -> dict[str, Any]: + """``/tw-disable`` — set ``enable=false`` only.""" + return set_enable(path or config_path_from_env(), False) + + +def manual_skill_probe( + skill: ManualSkill, + *, + config_path: Path | str, + name: str, +) -> dict[str, Any]: + """Prove verify/scan remain usable when ``enable`` is false. + + Does **not** early-return on ``enable=false``. Slices 28–29 replace this + probe with real status/submit behaviour while keeping the same invariant. + """ + cfg = load_config(config_path) + return { + "ok": True, + "skill": skill, + "name": name, + "enable": cfg["enable"], + "output": { + "kind": skill, + "name": name, + "note": "manual skills ignore enable — enforcement bypass only", + }, + } diff --git a/guard/tests/test_control_skills_units.py b/guard/tests/test_control_skills_units.py new file mode 100644 index 0000000..1e8bce6 --- /dev/null +++ b/guard/tests/test_control_skills_units.py @@ -0,0 +1,211 @@ +""" +Unit coverage for guard.control_skills and set_enable edge paths (slice 27). + +Author: slice-27 +Created: 2026-08-15 +Scope: env config path; enable/disable wrappers; corrupt/non-object raw config +""" + +from __future__ import annotations + +import json +from pathlib import Path + +import pytest + + +def test_given_tripwire_config_env_when_path_resolved_then_override_used( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """Scenario: TRIPWIRE_CONFIG selects the config path for control skills.""" + from guard.control_skills import config_path_from_env + + ### Given + path = tmp_path / "cfg.json" + monkeypatch.setenv("TRIPWIRE_CONFIG", str(path)) + + ### When + actual = config_path_from_env() + + ### Then + assert actual == path + + +def test_given_no_env_when_path_resolved_then_default_home_path( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Scenario: Without TRIPWIRE_CONFIG, use ~/.tripwire/config.json.""" + from guard.control_skills import DEFAULT_CONFIG_PATH, config_path_from_env + + ### Given + monkeypatch.delenv("TRIPWIRE_CONFIG", raising=False) + + ### When + actual = config_path_from_env() + + ### Then + assert actual == DEFAULT_CONFIG_PATH + + +def test_given_fixture_path_when_enable_enforcement_then_enable_true( + tmp_path: Path, +) -> None: + """Scenario: enable_enforcement wrapper sets enable true.""" + from guard.control_skills import enable_enforcement + + ### Given + path = tmp_path / "config.json" + path.write_text(json.dumps({"enable": False, "scan_validity_days": 14}), encoding="utf-8") + + ### When + actual = enable_enforcement(path) + + ### Then + assert actual["enable"] is True + + +def test_given_fixture_path_when_disable_enforcement_then_enable_false( + tmp_path: Path, +) -> None: + """Scenario: disable_enforcement wrapper sets enable false.""" + from guard.control_skills import disable_enforcement + + ### Given + path = tmp_path / "config.json" + path.write_text(json.dumps({"enable": True, "scan_validity_days": 14}), encoding="utf-8") + + ### When + actual = disable_enforcement(path) + + ### Then + assert actual["enable"] is False + + +def test_given_env_path_when_enable_without_arg_then_writes_override( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """Scenario: enable_enforcement() with no path uses TRIPWIRE_CONFIG.""" + from guard.control_skills import enable_enforcement + + ### Given + path = tmp_path / "config.json" + monkeypatch.setenv("TRIPWIRE_CONFIG", str(path)) + + ### When + actual = enable_enforcement() + + ### Then + assert actual["enable"] is True + assert json.loads(path.read_text(encoding="utf-8"))["enable"] is True + + +def test_given_env_path_when_disable_without_arg_then_writes_override( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """Scenario: disable_enforcement() with no path uses TRIPWIRE_CONFIG.""" + from guard.control_skills import disable_enforcement + + ### Given + path = tmp_path / "config.json" + path.write_text(json.dumps({"enable": True, "scan_validity_days": 14}), encoding="utf-8") + monkeypatch.setenv("TRIPWIRE_CONFIG", str(path)) + + ### When + actual = disable_enforcement() + + ### Then + assert actual["enable"] is False + + +def test_given_corrupt_json_when_set_enable_then_defaults_then_flag( + tmp_path: Path, +) -> None: + """Scenario: Corrupt JSON is replaced with defaults plus requested enable.""" + from guard.config import set_enable + + ### Given + path = tmp_path / "bad.json" + path.write_text("{not-json", encoding="utf-8") + + ### When + actual = set_enable(path, False) + + ### Then + assert actual["enable"] is False + assert actual["scan_validity_days"] == 14 + + +def test_given_non_object_json_when_set_enable_then_defaults_then_flag( + tmp_path: Path, +) -> None: + """Scenario: Non-object JSON is replaced with defaults plus requested enable.""" + from guard.config import set_enable + + ### Given + path = tmp_path / "arr.json" + path.write_text("[1, 2]", encoding="utf-8") + + ### When + actual = set_enable(path, True) + + ### Then + assert actual["enable"] is True + assert actual["scan_validity_days"] == 14 + + +def test_given_enable_only_object_when_set_enable_then_validity_seeded( + tmp_path: Path, +) -> None: + """Scenario: Object missing scan_validity_days gets the default seeded.""" + from guard.config import set_enable + + ### Given + path = tmp_path / "partial.json" + path.write_text(json.dumps({"enable": False, "extra": 1}), encoding="utf-8") + + ### When + actual = set_enable(path, True) + on_disk = json.loads(path.read_text(encoding="utf-8")) + + ### Then + assert actual["enable"] is True + assert on_disk["scan_validity_days"] == 14 + assert on_disk["extra"] == 1 + + +def test_given_missing_file_when_ensure_default_config_then_writes_defaults( + tmp_path: Path, +) -> None: + """Scenario: ensure_default_config creates schema defaults when absent.""" + from guard.config import ensure_default_config + + ### Given + path = tmp_path / "config.json" + + ### When + actual = ensure_default_config(path) + + ### Then + assert path.is_file() + assert actual == {"enable": True, "scan_validity_days": 14} + + +def test_given_existing_file_when_ensure_default_config_then_preserves( + tmp_path: Path, +) -> None: + """Scenario: ensure_default_config does not overwrite an existing file.""" + from guard.config import ensure_default_config + + ### Given + path = tmp_path / "config.json" + path.write_text( + json.dumps({"enable": False, "scan_validity_days": 3}), + encoding="utf-8", + ) + + ### When + actual = ensure_default_config(path) + + ### Then + assert actual["enable"] is False + assert actual["scan_validity_days"] == 3 diff --git a/guard/tests/test_tw_enable_disable.py b/guard/tests/test_tw_enable_disable.py new file mode 100644 index 0000000..75d44c3 --- /dev/null +++ b/guard/tests/test_tw_enable_disable.py @@ -0,0 +1,185 @@ +""" +Acceptance tests for /tw-enable and /tw-disable (slice 27). + +Author: slice-27 +Created: 2026-08-15 +Scope: set_enable preserve keys; manual verify/scan when disabled; SKILL.md layout +""" + +from __future__ import annotations + +import json +from pathlib import Path +from typing import Literal + +import pytest + +REPO_ROOT = Path(__file__).resolve().parents[2] +TW_ENABLE_SKILL = REPO_ROOT / ".claude" / "skills" / "tw-enable" / "SKILL.md" +TW_DISABLE_SKILL = REPO_ROOT / ".claude" / "skills" / "tw-disable" / "SKILL.md" + + +def test_given_enable_false_when_tw_enable_then_flag_true_other_keys_unchanged( + tmp_path: Path, +) -> None: + """ + Scenario: /tw-enable sets only the enable flag to true. + Slice: 27 — enable sets flag true + + Given config with enable=false and an extra key, + When set_enable(..., True) runs, + Then enable is true and other keys are unchanged. + """ + from guard.config import set_enable + + ### Given + path = tmp_path / "config.json" + path.write_text( + json.dumps( + { + "enable": False, + "scan_validity_days": 21, + "future_key": "keep-me", + }, + indent=2, + ) + + "\n", + encoding="utf-8", + ) + + ### When + actual = set_enable(path, True) + on_disk = json.loads(path.read_text(encoding="utf-8")) + + ### Then + assert actual["enable"] is True, "enable must become true" + assert on_disk["enable"] is True, "disk enable must become true" + assert on_disk["scan_validity_days"] == 21, "scan_validity_days must be preserved" + assert on_disk["future_key"] == "keep-me", "unknown keys must be preserved" + + +def test_given_enable_true_when_tw_disable_then_flag_false_other_keys_unchanged( + tmp_path: Path, +) -> None: + """ + Scenario: /tw-disable sets only the enable flag to false. + Slice: 27 — disable sets flag false + + Given config with enable=true and an extra key, + When set_enable(..., False) runs, + Then enable is false and other keys are unchanged. + """ + from guard.config import set_enable + + ### Given + path = tmp_path / "config.json" + path.write_text( + json.dumps( + { + "enable": True, + "scan_validity_days": 7, + "future_key": "keep-me", + }, + indent=2, + ) + + "\n", + encoding="utf-8", + ) + + ### When + actual = set_enable(path, False) + on_disk = json.loads(path.read_text(encoding="utf-8")) + + ### Then + assert actual["enable"] is False, "enable must become false" + assert on_disk["enable"] is False, "disk enable must become false" + assert on_disk["scan_validity_days"] == 7, "scan_validity_days must be preserved" + assert on_disk["future_key"] == "keep-me", "unknown keys must be preserved" + + +@pytest.mark.parametrize("skill", ["verify", "scan"]) +def test_given_enable_false_when_manual_verify_or_scan_then_still_produces_output( + tmp_path: Path, + skill: Literal["verify", "scan"], +) -> None: + """ + Scenario: Manual /tw-verify and /tw-scan still produce output when disabled. + Slice: 27 — verify/scan still work when disabled + + Given enable=false, + When a manual verify or scan probe runs for a resolvable name, + Then status/submit-shaped output is returned (not a no-op from enable=false). + """ + from guard.config import set_enable + from guard.control_skills import manual_skill_probe + + ### Given + path = tmp_path / "config.json" + set_enable(path, False) + + ### When + actual = manual_skill_probe(skill, config_path=path, name="demo-skill") + + ### Then + assert path.is_file(), "config must exist after disable" + assert json.loads(path.read_text(encoding="utf-8"))["enable"] is False + assert actual["ok"] is True, f"{skill} must not no-op when enforcement is off" + assert actual["skill"] == skill + assert actual["name"] == "demo-skill" + assert actual["enable"] is False, "probe must reflect disabled enforcement" + assert actual["output"]["kind"] == skill + assert actual["output"]["name"] == "demo-skill" + assert "note" in actual["output"] + + +def test_given_repo_when_skills_looked_up_then_claude_layout_skill_md_exist() -> None: + """ + Scenario: /tw-enable and /tw-disable ship at the Claude skill layout path. + Slice: 27 — skills installed at Claude layout + + Given the repo checkout, + When an operator looks for /tw-enable and /tw-disable, + Then both SKILL.md files exist and describe config-only toggles. + """ + ### Given / When + enable_text = TW_ENABLE_SKILL.read_text(encoding="utf-8") + disable_text = TW_DISABLE_SKILL.read_text(encoding="utf-8") + + ### Then + assert TW_ENABLE_SKILL.is_file(), f"missing {TW_ENABLE_SKILL}" + assert TW_DISABLE_SKILL.is_file(), f"missing {TW_DISABLE_SKILL}" + assert "set_enable" in enable_text or "enable" in enable_text.lower() + assert "config" in enable_text.lower() + assert "enforcement" not in enable_text.lower() or "do not call" in enable_text.lower() + assert "scan_validity_days" in enable_text or "other keys" in enable_text.lower() + assert "verify" in disable_text.lower() or "scan" in disable_text.lower() + assert "unaffected" in disable_text.lower() or "remain" in disable_text.lower() + + +@pytest.mark.parametrize("enabled", [True, False]) +def test_given_missing_config_when_toggle_then_file_created_with_flag( + tmp_path: Path, + enabled: bool, +) -> None: + """ + Scenario: Toggle creates config when absent. + Slice: 27 — missing config created on toggle + + Given no config file, + When set_enable runs, + Then the file is created with the requested enable value and default validity days. + """ + from guard.config import set_enable + + ### Given + path = tmp_path / "nested" / "config.json" + assert not path.exists() + + ### When + actual = set_enable(path, enabled) + on_disk = json.loads(path.read_text(encoding="utf-8")) + + ### Then + assert actual["enable"] is enabled + assert on_disk["enable"] is enabled + assert on_disk["scan_validity_days"] == 14 From 3cca65e9f1fb270c0376531d83caa6affe6dd59b Mon Sep 17 00:00:00 2001 From: Mani Sarkar Date: Sat, 15 Aug 2026 17:39:25 +0100 Subject: [PATCH 10/12] docs(slice-27): record PR #80 in gate evidence --- docs/plan/gate-evidence/slice-27.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/plan/gate-evidence/slice-27.json b/docs/plan/gate-evidence/slice-27.json index f7d8f9b..d633d8e 100644 --- a/docs/plan/gate-evidence/slice-27.json +++ b/docs/plan/gate-evidence/slice-27.json @@ -62,5 +62,5 @@ "verdict": "ON_BRANCH", "phase": "H2", "moscow": "Must", - "pr": null + "pr": "https://github.com/neomatrix369/tripwire/pull/80" } From ab129f11154d2ad26146ba7bc6b23d083a47e652 Mon Sep 17 00:00:00 2001 From: Mani Sarkar Date: Sat, 15 Aug 2026 17:49:47 +0100 Subject: [PATCH 11/12] feat(slice-28): add /tw-verify dual-output status reporting Operators need one-pass multi-name verify with Markdown+JSON matching the Frontline contract; close slice 27 trackers after PR #80 merge. --- .claude/skills/tw-verify/SKILL.md | 65 ++++ docs/plan/DECISIONS.md | 4 + docs/plan/PROGRESS.md | 38 +-- docs/plan/TRAIL.md | 4 +- docs/plan/gate-evidence/slice-27.json | 8 +- docs/plan/gate-evidence/slice-28.json | 69 +++-- .../slice-27-tw-enable-disable.md | 4 +- .../slice-28-tw-verify.md | 120 +++++--- docs/user-guide/frontline-output-contract.md | 6 +- docs/user-guide/setup-commands.md | 6 + guard/control_skills.py | 4 +- guard/tests/test_tw_verify.py | 289 ++++++++++++++++++ guard/tests/test_verify_units.py | 68 +++++ guard/verify.py | 228 ++++++++++++++ 14 files changed, 830 insertions(+), 83 deletions(-) create mode 100644 .claude/skills/tw-verify/SKILL.md create mode 100644 guard/tests/test_tw_verify.py create mode 100644 guard/tests/test_verify_units.py create mode 100644 guard/verify.py diff --git a/.claude/skills/tw-verify/SKILL.md b/.claude/skills/tw-verify/SKILL.md new file mode 100644 index 0000000..d8e3ee9 --- /dev/null +++ b/.claude/skills/tw-verify/SKILL.md @@ -0,0 +1,65 @@ +--- +name: tw-verify +description: Report Tripwire scan status for one or more skill/MCP/tool names (dual Markdown+JSON output) +disable-model-invocation: true +--- + +# /tw-verify + +Report scan status for one or more names in **one pass** (do not stop at the +first issue). Output must follow the Frontline dual-audience contract: +[frontline-output-contract.md](../../../docs/user-guide/frontline-output-contract.md) +— human Markdown table **and** machine JSON with the same per-artifact facts. + +Works whether enforcement is enabled or disabled (`/tw-enable` / `/tw-disable`). + +## Steps + +1. Accept space- or comma-separated names from the user. +2. **Resolve** each name to a filesystem path using Claude Code visibility into + installed skills / MCP config / tools. If there is no match, still include + that name in the report as `not-found` with a useful human message (do not + abort the whole run). +3. For each resolved name, call: + +```bash +uv run python -c " +from datetime import UTC, datetime +from guard.verify import ResolvedArtifact, StatusRecord, verify_artifacts + +# Replace resolve/fetch_status with real Supabase lookups in production wiring. +# For a dry smoke with fixtures, inject StatusRecord values as below. + +def resolve(name: str): + # Agent fills ResolvedArtifact(name=..., artifact_type=..., resolved_path=...) + # or returns None when not found. + raise SystemExit('wire resolve from Claude visibility') + +def fetch_status(resolved): + # Query Supabase items.heatmap_status + latest scan_runs (see guard_hook.py). + raise SystemExit('wire fetch_status from Supabase') + +result = verify_artifacts( + ['name-a', 'name-b'], + resolve=resolve, + fetch_status=fetch_status, +) +print(result.to_markdown()) +print(result.to_machine()) +" +``` + +Prefer importing and calling `guard.verify.verify_artifacts` from a short +helper once resolve + Supabase fetch are wired; keep the dual-output helpers +shared for `/tw-scan` and `/tw-self-check`. + +4. Show the operator **both** the Markdown table and the JSON `artifacts` array. +5. RED rows must include **Will be blocked when Tripwire is enabled**. +6. Unscanned rows must offer `/tw-scan ` for that artifact. +7. If the operator accepts a scan offer, invoke `/tw-scan` for those names. + +## Notes + +- Status comes from Supabase (`items.heatmap_status` + scan timestamps / in-flight + runs), not from a synchronous `tripwire` status CLI. +- Staleness uses `scan_validity_days` from `~/.tripwire/config.json` (default 14). diff --git a/docs/plan/DECISIONS.md b/docs/plan/DECISIONS.md index b95d076..5c0fb0d 100644 --- a/docs/plan/DECISIONS.md +++ b/docs/plan/DECISIONS.md @@ -112,3 +112,7 @@ | 2026-08-15 | slice-27 | AT design complete | Five GWTs; `set_enable` preserves unknown keys; skills at `.claude/skills/tw-{enable,disable}/SKILL.md`; ≥95% lines on `guard/config.py` + `guard/control_skills.py`. | | 2026-08-15 | slice-27 | review APPROVED | nw-software-crafter-reviewer — inlined `_read_raw_config`; probe shape asserts strengthened. | | 2026-08-15 | slice-27 | track tw-* skills | `.gitignore` allows `.claude/skills/tw-*/**` so Frontline control skills ship in-repo while other `.claude/` stays local-only. | +| 2026-08-15 | slice-27 | ✅ closed | PR #80 merged into `frontline-hackathon-london-2026-agent-hooks`. Gate evidence `verdict: PASS`. | +| 2026-08-15 | slice-28 | AT design complete | Six GWTs; injectable resolve+fetch_status; ≥95% lines on `guard/verify.py`; SSOT `frontline-output-contract.md`. | +| 2026-08-15 | slice-28 | review APPROVED | nw-software-crafter-reviewer — nit AT5 docstring only; applied. | +| 2026-08-15 | wave-h | Multi-agent by default | For **every** Wave H slice: parallelize with Task subagents whenever work is independent. Default fan-out: (1) AT/acceptance critique via `nw-acceptance-designer-reviewer` after DISTILL ATs; (2) implementation critique via `nw-software-crafter-reviewer` before 🔀→merge; (3) optional docs/gate audit in parallel with review; (4) explore/research agents for status APIs / fixtures before GREEN. Do not serialize these when they can run concurrently. Applies to slices 28+ remaining (29–39) and any reopen. | diff --git a/docs/plan/PROGRESS.md b/docs/plan/PROGRESS.md index 404dc2f..4405098 100644 --- a/docs/plan/PROGRESS.md +++ b/docs/plan/PROGRESS.md @@ -12,27 +12,26 @@ | 5 | [`05-E-…`](slices/05-E-ship-path-coverage/) | **E — Ship-path coverage** | 8 → 11 → 12 → 13 ✅ → 14 (**9+10 SUBSUMED INTO 11**) | ✅ Musts · close-path | | 6 | [`06-F-…`](slices/06-F-claim-audit/) | **F — Claim audit** | 15 · 16 | 📦 | | 7 | [`07-G-…`](slices/07-G-atdd-closure/) | **G — ATDD closure** | 18, 19, 20, 21, 22 (independent gates) | 📋 parked | -| 8 | [`08-H-…`](slices/08-H-frontline-agent-hooks/) | **H — Frontline agent hooks** | 23→32 Must · 33–38 Should · 39 Could | 23–26 ✅ · 27 🔀 | +| 8 | [`08-H-…`](slices/08-H-frontline-agent-hooks/) | **H — Frontline agent hooks** | 23→32 Must · 33–38 Should · 39 Could | 23–27 ✅ · 28 🔀 | -**Current priority:** Wave H Must slice **27** — `/tw-enable` + `/tw-disable` — 🔀 ON BRANCH. Wave G (18–22) parked while H1–H3 is active unless explicitly resumed. +**Current priority:** Wave H Must slice **28** — `/tw-verify` — 🔀 ON BRANCH (pending review + merge). Wave G (18–22) parked while H1–H3 is active unless explicitly resumed. ## Execution order (open work) | Order | Wave | # | Slice | MoSCoW | Status | |------:|-----:|---|-------|--------|--------| -| 1 | H2 | 27 | `/tw-enable` + `/tw-disable` | Must | 🔀 ON BRANCH | -| 2 | H2 | 28 | `/tw-verify` | Must | 📋 PLANNED | -| 3 | H2 | 29 | `/tw-scan` | Must | 📋 PLANNED | -| 4 | H2 | 30 | `/tw-self-check` | Must | 📋 PLANNED | -| 5 | H3 | 31 | Demo Artifacts | Must | 📋 PLANNED | -| 6 | H3 | 32 | Phase 1 Regression Verification (HARD GATE) | Must | 📋 PLANNED | -| 7 | H4 | 33 | DepShield Install | Should | 📋 PLANNED | -| 8 | H4 | 34 | DepShield Dispatch | Should | 📋 PLANNED | -| 9 | H5 | 35 | Ossprey Access Provisioning | Should | 🔴 BLOCKED | -| 10 | H5 | 36 | Ossprey Dispatch | Should | 📋 PLANNED | -| 11 | H6 | 37 | CLI Monitoring | Should | 📋 PLANNED | -| 12 | H6 | 38 | Full-Chain Validation | Should | 📋 PLANNED | -| 13 | H6 | 39 | FE/BE Rearchitecture | Could | 📦 DEFERRED | +| 1 | H2 | 28 | `/tw-verify` | Must | 🔀 ON BRANCH | +| 2 | H2 | 29 | `/tw-scan` | Must | 📋 PLANNED | +| 3 | H2 | 30 | `/tw-self-check` | Must | 📋 PLANNED | +| 4 | H3 | 31 | Demo Artifacts | Must | 📋 PLANNED | +| 5 | H3 | 32 | Phase 1 Regression Verification (HARD GATE) | Must | 📋 PLANNED | +| 6 | H4 | 33 | DepShield Install | Should | 📋 PLANNED | +| 7 | H4 | 34 | DepShield Dispatch | Should | 📋 PLANNED | +| 8 | H5 | 35 | Ossprey Access Provisioning | Should | 🔴 BLOCKED | +| 9 | H5 | 36 | Ossprey Dispatch | Should | 📋 PLANNED | +| 10 | H6 | 37 | CLI Monitoring | Should | 📋 PLANNED | +| 11 | H6 | 38 | Full-Chain Validation | Should | 📋 PLANNED | +| 12 | H6 | 39 | FE/BE Rearchitecture | Could | 📦 DEFERRED | | — | G | 18–22 | ATDD closure (parked) | Must | 📋 PLANNED | ## Quick Status (by group) @@ -94,8 +93,8 @@ | 24 | [slice-24-setup-agent-hooks](slices/08-H-frontline-agent-hooks/slice-24-setup-agent-hooks.md) | Must | ✅ | 2026-08-15 | 2026-08-15 | ~40 min | | 25 | [slice-25-live-enforce-smoke](slices/08-H-frontline-agent-hooks/slice-25-live-enforce-smoke.md) | Must | ✅ | 2026-08-15 | 2026-08-15 | ~30 min | | 26 | [slice-26-api-output-contract](slices/08-H-frontline-agent-hooks/slice-26-api-output-contract.md) | Must | ✅ | 2026-08-15 | 2026-08-15 | ~40 min | -| 27 | [slice-27-tw-enable-disable](slices/08-H-frontline-agent-hooks/slice-27-tw-enable-disable.md) | Must | 🔀 ON BRANCH | 2026-08-15 | — | ~25 min | -| 28 | [slice-28-tw-verify](slices/08-H-frontline-agent-hooks/slice-28-tw-verify.md) | Must | 📋 PLANNED | — | — | ~50 min | +| 27 | [slice-27-tw-enable-disable](slices/08-H-frontline-agent-hooks/slice-27-tw-enable-disable.md) | Must | ✅ | 2026-08-15 | 2026-08-15 | ~25 min | +| 28 | [slice-28-tw-verify](slices/08-H-frontline-agent-hooks/slice-28-tw-verify.md) | Must | 🔀 ON BRANCH | 2026-08-15 | — | ~50 min | | 29 | [slice-29-tw-scan](slices/08-H-frontline-agent-hooks/slice-29-tw-scan.md) | Must | 📋 PLANNED | — | — | ~40 min | | 30 | [slice-30-tw-self-check](slices/08-H-frontline-agent-hooks/slice-30-tw-self-check.md) | Must | 📋 PLANNED | — | — | ~30 min | | 31 | [slice-31-demo-artifacts](slices/08-H-frontline-agent-hooks/slice-31-demo-artifacts.md) | Must | 📋 PLANNED | — | — | ~40 min | @@ -120,7 +119,7 @@ ## Forward Roadmap - Waves **A–C**, coverage Slice 14, and Slice 17 are merged and closed. Slice 15 is retained as a deferred claim-audit artifact, not active work. -- **Wave H (Frontline):** integration branch `frontline-hackathon-london-2026-agent-hooks`. Slice 23 ✅ (PR #74). Slice 24 ✅ (PR #75). Slice 25 ✅ (PR #76). Slice 26 ✅ (PR #78). Slice 27 🔀 (`/tw-enable` + `/tw-disable`) ON BRANCH pending merge. +- **Wave H (Frontline):** integration branch `frontline-hackathon-london-2026-agent-hooks`. Slice 23 ✅ (PR #74). Slice 24 ✅ (PR #75). Slice 25 ✅ (PR #76). Slice 26 ✅ (PR #78). Slice 27 ✅ (PR #80). Slice 28 🔀 (`/tw-verify`) ON BRANCH pending review + merge. - Wave G (18–22) remains planned but **parked** while Frontline H1–H3 is active unless explicitly resumed. - Reopen Slice 15 only for a future live/demo release that needs its security and 3B evidence path. - **Deferred / Won't (A):** 4 (in A); 15 and 16 (in F) — reinstate only if a new live/demo need arises @@ -161,4 +160,5 @@ | 2026-08-15 | slice/24-setup-agent-hooks | slice-workflow | 24 | ✅ PASSED (PR #75) | setup-agent-hooks; 7 tests; cov 93.11%; merged to Frontline | | 2026-08-15 | slice/25-live-enforce-smoke | slice-workflow | 25 | ✅ PASSED (PR #76) | live enforce smoke; H2 checkpoint signed off | | 2026-08-15 | slice/26-api-output-contract | slice-workflow | 26 | ✅ PASSED (PR #78) | dual output contract SSOT + 4 ATs; docs-only | -| 2026-08-15 | slice/27-tw-enable-disable | slice-workflow | 27 | 🔀 ON BRANCH | 5 ATs; set_enable + skills; cov 100%; review APPROVED | +| 2026-08-15 | slice/27-tw-enable-disable | slice-workflow | 27 | ✅ PASSED (PR #80) | 5 ATs; set_enable + skills; cov 100%; review APPROVED | +| 2026-08-15 | slice/28-tw-verify | slice-workflow | 28 | 🔀 ON BRANCH | 6 ATs; verify_artifacts dual-output; cov 100%; qg PASS | diff --git a/docs/plan/TRAIL.md b/docs/plan/TRAIL.md index 68e441e..ff075a1 100644 --- a/docs/plan/TRAIL.md +++ b/docs/plan/TRAIL.md @@ -168,8 +168,8 @@ Branch: `frontline-hackathon-london-2026-agent-hooks`. Source: `internal-docs/04 | # | File | Name | MoSCoW | Status | Depends on | Issue | Read time | |---|------|------|--------|--------|------------|-------|-----------| | 26 | [slice-26-api-output-contract](slices/08-H-frontline-agent-hooks/slice-26-api-output-contract.md) | API Introspect + Dual Output Contract | Must | ✅ | 25 | #78 | ~4 min | -| 27 | [slice-27-tw-enable-disable](slices/08-H-frontline-agent-hooks/slice-27-tw-enable-disable.md) | `/tw-enable` + `/tw-disable` | Must | 🔀 | 26 | — | ~3 min | -| 28 | [slice-28-tw-verify](slices/08-H-frontline-agent-hooks/slice-28-tw-verify.md) | `/tw-verify` | Must | 📋 | 26,27 | — | ~5 min | +| 27 | [slice-27-tw-enable-disable](slices/08-H-frontline-agent-hooks/slice-27-tw-enable-disable.md) | `/tw-enable` + `/tw-disable` | Must | ✅ | 26 | #80 | ~3 min | +| 28 | [slice-28-tw-verify](slices/08-H-frontline-agent-hooks/slice-28-tw-verify.md) | `/tw-verify` | Must | 🔀 | 26,27 | — | ~5 min | | 29 | [slice-29-tw-scan](slices/08-H-frontline-agent-hooks/slice-29-tw-scan.md) | `/tw-scan` | Must | 📋 | 26 | — | ~4 min | | 30 | [slice-30-tw-self-check](slices/08-H-frontline-agent-hooks/slice-30-tw-self-check.md) | `/tw-self-check` | Must | 📋 | 28 | — | ~3 min | diff --git a/docs/plan/gate-evidence/slice-27.json b/docs/plan/gate-evidence/slice-27.json index d633d8e..9f8f715 100644 --- a/docs/plan/gate-evidence/slice-27.json +++ b/docs/plan/gate-evidence/slice-27.json @@ -1,6 +1,6 @@ { "slice": 27, - "gate_status": "ON_BRANCH", + "gate_status": "PASSED", "inferred": false, "branch": "slice/27-tw-enable-disable", "date": "2026-08-15", @@ -17,7 +17,7 @@ "PASS — coverage 100% lines on guard/config.py + guard/control_skills.py (target ≥95%)", "PASS — xenon exit 0 on product modules; ./scripts/quality-gates.sh PASS", "PASS — review acceptance+implementation APPROVED (nw-software-crafter-reviewer)", - "PENDING merge — PROGRESS/TRAIL ✅ after merge to Frontline" + "PASS — merged to Frontline via PR #80" ], "commands": [ { @@ -52,14 +52,14 @@ "review": { "acceptance": "APPROVED", "implementation": "APPROVED", - "notes": "Blocker YAGNI _read_raw_config inlined into set_enable; probe assertions strengthened; 100% cov; quality-gates PASS" + "notes": "Blocker YAGNI _read_raw_config inlined into set_enable; probe assertions strengthened; 100% cov; quality-gates PASS; merged PR #80" }, "documentation_audit": { "1_config_toggles_only": "PASS — setup-commands.md + SKILL.md", "2_verify_scan_when_disabled": "PASS — setup-commands + tw-disable SKILL.md", "3_cross_link": "PASS — gate-evidence ↔ TRAIL/PROGRESS; frontline-output-contract links slice-27" }, - "verdict": "ON_BRANCH", + "verdict": "PASS", "phase": "H2", "moscow": "Must", "pr": "https://github.com/neomatrix369/tripwire/pull/80" diff --git a/docs/plan/gate-evidence/slice-28.json b/docs/plan/gate-evidence/slice-28.json index b1ef907..0da55cf 100644 --- a/docs/plan/gate-evidence/slice-28.json +++ b/docs/plan/gate-evidence/slice-28.json @@ -1,24 +1,59 @@ { "slice": 28, - "gate_status": "PLANNED", + "gate_status": "ON_BRANCH", "inferred": false, - "branch": null, - "date": null, - "before_checks": [], - "after_checks": [], - "planned_commands": [ - "test -f docs/plan/slices/08-H-frontline-agent-hooks/slice-28-tw-verify.md", - "rg -n \"tw-verify|Will be blocked when Tripwire is enabled\" docs/plan/slices/08-H-frontline-agent-hooks/ internal-docs/04_frontline/", - "rg -n \"heatmap_status\" guard/", - "./scripts/quality-gates.sh" + "branch": "slice/28-tw-verify", + "date": "2026-08-15", + "spec_path": "docs/plan/slices/08-H-frontline-agent-hooks/slice-28-tw-verify.md", + "output_ssot": "docs/user-guide/frontline-output-contract.md", + "skill_layout": [".claude/skills/tw-verify/SKILL.md"], + "before_checks": "PASS — slices 26+27 verdict PASS (PR #78/#80); branch from Frontline (Wave H base waiver); SSOT frontline-output-contract.md; coverage ≥95% on guard/verify.py; complexity enforcing", + "after_checks": [ + "PASS — multi-name one-pass + six-state GWTs", + "PASS — RED block note + unscanned→/tw-scan offer asserted", + "PASS — not-found human message asserted", + "PASS — .venv/bin/pytest guard/tests/test_tw_verify.py -q (11 passed)", + "PASS — coverage 100% lines on guard/verify.py (target ≥95%)", + "PASS — xenon exit 0; ./scripts/quality-gates.sh PASS", + "PASS — review acceptance+implementation APPROVED (nw-software-crafter-reviewer)", + "PENDING merge — PROGRESS/TRAIL ✅ after merge to Frontline" ], - "commands": [], - "test_budget": {"acceptance_tests_max": 7, "parametrized_case_counts_as_one": true}, - "reviewers": [], - "review": {"acceptance": "PENDING", "implementation": "PENDING"}, - "verdict": "NOT_RUN", - "coverage_target": "TBD at AT design before IN PROGRESS", - "complexity_policy": "enforcing for product-code; N/A for docs-only with reason in evidence", + "commands": [ + { + "cmd": ".venv/bin/pytest guard/tests/test_tw_verify.py -q --tb=short", + "result": "PASS exit 0 — 11 passed" + }, + { + "cmd": ".venv/bin/pytest guard/tests/test_tw_verify.py guard/tests/test_verify_units.py --cov=guard.verify --cov-report=term-missing -q", + "result": "PASS — 15 passed; TOTAL 100% lines on guard/verify.py" + }, + { + "cmd": "./scripts/quality-gates.sh", + "result": "PASS — quality-gates passed" + }, + { + "cmd": "test -f .claude/skills/tw-verify/SKILL.md", + "result": "PASS — Claude skill layout present" + } + ], + "test_budget": { + "acceptance_tests_max": 7, + "acceptance_tests_designed": 6, + "parametrized_case_counts_as_one": true + }, + "coverage": { + "target": "≥95% lines on guard/verify.py", + "measured": "100.0% lines (88 stmts)", + "date": "2026-08-15" + }, + "complexity_policy": "enforcing for product-code — xenon exit 0 via quality-gates", + "reviewers": ["nw-software-crafter-reviewer"], + "review": { + "acceptance": "APPROVED", + "implementation": "APPROVED", + "notes": "Nit only: AT5 docstring clarity on fetch_status not invoked for not-found; applied" + }, + "verdict": "ON_BRANCH", "phase": "H2", "moscow": "Must" } diff --git a/docs/plan/slices/08-H-frontline-agent-hooks/slice-27-tw-enable-disable.md b/docs/plan/slices/08-H-frontline-agent-hooks/slice-27-tw-enable-disable.md index 43efe81..2aa5afb 100644 --- a/docs/plan/slices/08-H-frontline-agent-hooks/slice-27-tw-enable-disable.md +++ b/docs/plan/slices/08-H-frontline-agent-hooks/slice-27-tw-enable-disable.md @@ -92,7 +92,7 @@ REFACTOR: shared config read/write helper if needed; no enforcement logic in the - [x] Complexity policy: **enforcing** for product-code; evidence cites quality-gates / complexity report - [x] `docs/plan/gate-evidence/slice-27.json` records commands, coverage, complexity, reviewers, and `verdict: ON_BRANCH` (PASS after merge) - [x] Review: `acceptance: APPROVED` and `implementation: APPROVED` (nw-software-crafter-reviewer) -- [ ] `PROGRESS.md` + `TRAIL.md` show slice 27 ✅ (after merge) +- [x] `PROGRESS.md` + `TRAIL.md` show slice 27 ✅ (PR #80 merged to Frontline) ## Doc Audit @@ -104,4 +104,4 @@ REFACTOR: shared config read/write helper if needed; no enforcement logic in the ## Gate Status -🔀 ON BRANCH (pending review + merge) +✅ PASSED (PR #80) diff --git a/docs/plan/slices/08-H-frontline-agent-hooks/slice-28-tw-verify.md b/docs/plan/slices/08-H-frontline-agent-hooks/slice-28-tw-verify.md index 98100bc..7689a51 100644 --- a/docs/plan/slices/08-H-frontline-agent-hooks/slice-28-tw-verify.md +++ b/docs/plan/slices/08-H-frontline-agent-hooks/slice-28-tw-verify.md @@ -8,31 +8,81 @@ ## GWT acceptance specification -Thin scaffolds — full DISTILL ATs deferred per DECISIONS; design ATs before marking IN PROGRESS. - -1. **Multi-name one-pass table** - - Given two or more resolvable names, when `/tw-verify` runs, then every name appears as a row in one Markdown table (and matching machine artifacts) without stopping at the first issue. -2. **State coverage** - - Given fixtures for fresh/stale/unscanned/scanning/not-found/red, when verify runs, then each state renders per the slice-26 contract. -3. **RED block note** - - Given a RED artifact, when verify reports it, then the Note (or equivalent) includes that it will be blocked when Tripwire is enabled. -4. **Unscanned offers scan** - - Given an unscanned artifact, when verify reports it, then the operator is offered `/tw-scan` for that name. -5. **Not-found is human-readable** - - Given a name with no resolution match, when verify runs, then the response includes a useful human message (not a bare error). +**DISTILL ATs (2026-08-15)** — ≤7; product-code + Claude skill SKILL.md. + +| # | Scenario | Tags | Real-surface binding | +|---|----------|------|----------------------| +| 1 | Multi-name one-pass table + JSON | `@US-28` | `guard.verify.verify_artifacts` | +| 2 | Six UI states (parametrized) | `@US-28` | `guard.verify.verify_artifacts` | +| 3 | RED block note | `@US-28` | `guard.verify.verify_artifacts` | +| 4 | Unscanned offers `/tw-scan` | `@US-28` | `guard.verify.verify_artifacts` | +| 5 | Not-found human-readable | `@US-28` | `guard.verify.verify_artifacts` | +| 6 | Skill shipped at Claude layout | `@US-28` | `.claude/skills/tw-verify/SKILL.md` | + +1. **Multi-name one-pass table** `@US-28` + - Given two or more resolvable names with distinct statuses, + when `verify_artifacts` / `/tw-verify` runs, + then every name appears as a row in one Markdown table and in `artifacts[]` + without stopping at the first issue. +2. **State coverage** `@US-28` + - Given fixtures for fresh / stale / unscanned / scanning / not-found / red, + when verify runs for each, + then `state` (and Status display) matches the slice-26 contract. +3. **RED block note** `@US-28` + - Given a RED artifact within the validity window, + when verify reports it, + then Note includes **Will be blocked when Tripwire is enabled** and + `will_be_blocked` is true. +4. **Unscanned offers scan** `@US-28` + - Given an unscanned artifact, + when verify reports it, + then the Note (or equivalent) offers `/tw-scan` for that name. +5. **Not-found is human-readable** `@US-28` + - Given a name with no resolution match, + when verify runs, + then the response includes a useful human message (not a bare error). +6. **Skill at Claude layout** `@US-28` + - Given the repo checkout, + when an operator looks for `/tw-verify`, + then `.claude/skills/tw-verify/SKILL.md` exists and points at + `guard.verify.verify_artifacts` + the dual-output contract. + +**Test inventory (6 acceptance tests; state AT parametrized = 1):** +`guard/tests/test_tw_verify.py` + +**Named verification command:** + +```bash +.venv/bin/pytest guard/tests/test_tw_verify.py -q --tb=short +``` + +**Coverage / complexity (AT design):** + +- Coverage target: **≥95% lines** on `guard/verify.py` (new dual-output + classify). +- Complexity: **enforcing** for product-code; cite `./scripts/quality-gates.sh` / + xenon in gate evidence. ## Design / test treatment -- Name resolution uses Claude visibility into skills/MCP/tools; path(s) passed to existing status sources (Supabase `heatmap_status` pattern). -- Output must satisfy slice-26 dual audience contract. -- **AT design required before IN PROGRESS** (≤7 acceptance tests). +- Name resolution is injectable (`resolve`); Claude skill resolves via agent visibility, + then calls `verify_artifacts` with resolved paths (or `None` for not-found). +- Status lookup is injectable (`fetch_status`); production path queries Supabase + `items.heatmap_status` + scan timestamps / in-flight runs (same pattern as + `guard/guard_hook.py`). Tests never hit live Supabase. +- Output must satisfy slice-26 dual audience contract + (`docs/user-guide/frontline-output-contract.md`). +- Shared format helpers live in `guard/verify.py` for later `/tw-scan` / + `/tw-self-check` reuse. +- **AT design complete** — ready for 🔨 IN PROGRESS. ## Before-Checks [GATE] -- [ ] Slices 26 and 27 gate-evidence `verdict` are `PASS` -- [ ] Branch `slice/28-tw-verify` created from current `main` -- [ ] Slice-26 contract path recorded as the output SSOT in evidence -- [ ] Coverage/complexity targets TBD until AT design completes +- [x] Slices 26 and 27 gate-evidence `verdict` are `PASS` (26: PR #78; 27: PR #80) +- [x] Branch `slice/28-tw-verify` created from Frontline integration + (DECISIONS Wave H branch-base waiver) +- [x] Slice-26 contract path recorded as the output SSOT in evidence + (`docs/user-guide/frontline-output-contract.md`) +- [x] Coverage target ≥95% lines on `guard/verify.py`; complexity enforcing ## TDD execution @@ -42,25 +92,25 @@ REFACTOR: share formatting helpers with later `/tw-scan` / `/tw-self-check`. ## After-Checks [GATE] -- [ ] Multi-name one-pass and six-state GWTs pass -- [ ] RED block note and unscanned→offer-scan asserted observably -- [ ] Not-found human message asserted (not bare error) -- [ ] Named test command(s) from AT design exit 0 (record in gate evidence) -- [ ] Coverage target: set at AT design before IN PROGRESS; recorded % meets that target -- [ ] Complexity policy: **enforcing** for product-code; evidence cites quality-gates / complexity report -- [ ] `docs/plan/gate-evidence/slice-28.json` records commands, coverage, complexity, reviewers, and `verdict: PASS` -- [ ] Review: `acceptance: APPROVED` and `implementation: APPROVED` (or docs-only exception in DECISIONS) -- [ ] `PROGRESS.md` + `TRAIL.md` show slice 28 ✅ +- [x] Multi-name one-pass and six-state GWTs pass +- [x] RED block note and unscanned→offer-scan asserted observably +- [x] Not-found human message asserted (not bare error) +- [x] Named test command(s) from AT design exit 0 (record in gate evidence) +- [x] Coverage target: ≥95% lines on `guard/verify.py`; recorded % meets that target (100%) +- [x] Complexity policy: **enforcing** for product-code; evidence cites quality-gates / complexity report +- [x] `docs/plan/gate-evidence/slice-28.json` records commands, coverage, complexity, reviewers, and `verdict: ON_BRANCH` (PASS after merge) +- [x] Review: `acceptance: APPROVED` and `implementation: APPROVED` (nw-software-crafter-reviewer) +- [ ] `PROGRESS.md` + `TRAIL.md` show slice 28 ✅ (after merge) ## Doc Audit -| # | Check | -|---|--------| -| 1 | `/tw-verify` multi-name + one-pass behaviour documented | -| 2 | Link to slice-26 output contract | -| 3 | RED block note + unscanned offer + not-found messaging stated | -| 4 | Cross-link gate-evidence ↔ TRAIL/PROGRESS | +| # | Check | Result | +|---|--------|--------| +| 1 | `/tw-verify` multi-name + one-pass behaviour documented | PASS — setup-commands + SKILL.md | +| 2 | Link to slice-26 output contract | PASS — SKILL.md + frontline-output-contract | +| 3 | RED block note + unscanned offer + not-found messaging stated | PASS | +| 4 | Cross-link gate-evidence ↔ TRAIL/PROGRESS | PASS | ## Gate Status -📋 PLANNED +🔀 ON BRANCH (pending review + merge) diff --git a/docs/user-guide/frontline-output-contract.md b/docs/user-guide/frontline-output-contract.md index ae5bb4d..4a9dfb4 100644 --- a/docs/user-guide/frontline-output-contract.md +++ b/docs/user-guide/frontline-output-contract.md @@ -132,7 +132,9 @@ Do not invent scan-stdout fields that are not listed above. Skills compose dual- ## Links - Skills that consume this contract: slices [27](../plan/slices/08-H-frontline-agent-hooks/slice-27-tw-enable-disable.md)–[30](../plan/slices/08-H-frontline-agent-hooks/slice-30-tw-self-check.md) - (`/tw-enable` / `/tw-disable` are config toggles only — they do not emit this table) + (`/tw-enable` / `/tw-disable` are config toggles only — they do not emit this table; + `/tw-verify` implements the table via `guard.verify.verify_artifacts`, slice 28) - Operator setup / hooks: [setup-commands.md](setup-commands.md) - Gate evidence: [`docs/plan/gate-evidence/slice-26.json`](../plan/gate-evidence/slice-26.json) · - [`slice-27.json`](../plan/gate-evidence/slice-27.json) + [`slice-27.json`](../plan/gate-evidence/slice-27.json) · + [`slice-28.json`](../plan/gate-evidence/slice-28.json) diff --git a/docs/user-guide/setup-commands.md b/docs/user-guide/setup-commands.md index 190a6b5..73b31c7 100644 --- a/docs/user-guide/setup-commands.md +++ b/docs/user-guide/setup-commands.md @@ -82,6 +82,12 @@ blocks unscanned or RED artifacts; `enable=false` → approve-all bypass. `pytest guard/tests/test_live_enforce_smoke.py -q`. Config toggle ATs: `pytest guard/tests/test_tw_enable_disable.py -q`. +**`/tw-verify` (operator):** Claude skill at `.claude/skills/tw-verify/SKILL.md`. +Resolves one or more names, classifies each via Supabase status (injectable +`guard.verify.verify_artifacts`), and prints the dual-output table + JSON in +one pass. See [frontline-output-contract.md](frontline-output-contract.md). +ATs: `pytest guard/tests/test_tw_verify.py -q`. + **`/tw-*` dual output contract:** human Markdown table + machine JSON shape, `heatmap_status` → six UI states, and observed `tripwire scan` stdout fields — see [frontline-output-contract.md](frontline-output-contract.md). diff --git a/guard/control_skills.py b/guard/control_skills.py index af25884..1b1bf47 100644 --- a/guard/control_skills.py +++ b/guard/control_skills.py @@ -1,8 +1,8 @@ """Manual Tripwire control-skill helpers (slice 27+). ``/tw-enable`` / ``/tw-disable`` flip config only via ``set_enable``. -``manual_skill_probe`` asserts that verify/scan entry points do not no-op when -enforcement is disabled — full ``/tw-verify`` / ``/tw-scan`` land in slices 28–29. +``/tw-verify`` lives in ``guard.verify.verify_artifacts`` (slice 28). +``manual_skill_probe`` remains for scan independence until slice 29. """ from __future__ import annotations diff --git a/guard/tests/test_tw_verify.py b/guard/tests/test_tw_verify.py new file mode 100644 index 0000000..627b6b7 --- /dev/null +++ b/guard/tests/test_tw_verify.py @@ -0,0 +1,289 @@ +""" +Acceptance tests for /tw-verify (slice 28). + +Author: slice-28 +Created: 2026-08-15 +Scope: multi-name one-pass; six UI states; RED note; unscanned→scan offer; not-found; SKILL.md +""" + +from __future__ import annotations + +from datetime import UTC, datetime, timedelta +from pathlib import Path +from typing import Any + +import pytest + +REPO_ROOT = Path(__file__).resolve().parents[2] +TW_VERIFY_SKILL = REPO_ROOT / ".claude" / "skills" / "tw-verify" / "SKILL.md" +RED_BLOCK_NOTE = "Will be blocked when Tripwire is enabled" +NOW = datetime(2026, 8, 15, 12, 0, 0, tzinfo=UTC) + + +def _resolved(name: str, artifact_type: str = "skill") -> Any: + from guard.verify import ResolvedArtifact + + return ResolvedArtifact( + name=name, + artifact_type=artifact_type, + resolved_path=f"/tmp/{name}", + ) + + +def _status( + *, + heatmap: str | None = "green", + scanned_at: datetime | None = None, + run_status: str | None = "complete", +) -> Any: + from guard.verify import StatusRecord + + return StatusRecord( + heatmap_status=heatmap, + scanned_at=scanned_at if scanned_at is not None else NOW - timedelta(days=1), + run_status=run_status, + ) + + +def test_given_two_names_when_verify_then_one_pass_table_and_json() -> None: + """ + Scenario: Multi-name verify reports every name in one Markdown table and JSON. + Slice: 28 — multi-name one-pass + + Given two resolvable names with distinct statuses, + When verify_artifacts runs, + Then both names appear as table rows and in artifacts[] without stopping early. + """ + from guard.verify import verify_artifacts + + ### Given + names = ["safe-skill", "vuln-skill"] + + def resolve(name: str): + return _resolved(name) + + def fetch_status(resolved): + if resolved.name == "safe-skill": + return _status(heatmap="green") + return _status(heatmap="red") + + ### When + actual = verify_artifacts( + names, + resolve=resolve, + fetch_status=fetch_status, + validity_days=14, + now=lambda: NOW, + ) + + ### Then + assert len(actual.artifacts) == 2, "must report both names in one pass" + assert [row.name for row in actual.artifacts] == names + machine = actual.to_machine() + assert len(machine["artifacts"]) == 2 + markdown = actual.to_markdown() + assert "| Name | Type | Status | Note |" in markdown + assert "safe-skill" in markdown + assert "vuln-skill" in markdown + assert markdown.count("\n| `") >= 2 or markdown.count("| `safe-skill`") == 1 + + +SIX_STATE_CASES = [ + ("fresh-green", "fresh", "green"), + ("stale-skill", "stale", "green"), + ("new-skill", "unscanned", None), + ("pending-skill", "scanning", None), + ("missing-skill", "not-found", None), + ("vuln-skill", "red", "red"), +] + + +@pytest.mark.parametrize(("name", "expected_state", "expected_rag"), SIX_STATE_CASES) +def test_given_state_fixture_when_verify_then_contract_state( + name: str, + expected_state: str, + expected_rag: str | None, +) -> None: + """ + Scenario: Each of the six UI states renders per the slice-26 contract. + Slice: 28 — state coverage + + Given a fixture for one UI state, + When verify runs, + Then artifact.state (and rag when applicable) matches the contract. + """ + from guard.verify import StatusRecord, verify_artifacts + + ### Given + def resolve(query: str): + if query == "missing-skill": + return None + return _resolved(query) + + def fetch_status(resolved): + if resolved.name == "fresh-green": + return _status(heatmap="green") + if resolved.name == "stale-skill": + return _status( + heatmap="green", + scanned_at=NOW - timedelta(days=30), + ) + if resolved.name == "new-skill": + return StatusRecord(heatmap_status="grey", scanned_at=None, run_status=None) + if resolved.name == "pending-skill": + return StatusRecord( + heatmap_status="grey", + scanned_at=None, + run_status="running", + ) + if resolved.name == "vuln-skill": + return _status(heatmap="red") + return _status(heatmap="green") + + ### When + actual = verify_artifacts( + [name], + resolve=resolve, + fetch_status=fetch_status, + validity_days=14, + now=lambda: NOW, + ) + + ### Then + assert len(actual.artifacts) == 1 + row = actual.artifacts[0] + assert row.state == expected_state, f"expected state {expected_state} for {name}" + assert row.rag == expected_rag + status_cell = actual.to_markdown() + assert row.name in status_cell + + +def test_given_red_artifact_when_verify_then_block_note_and_flag() -> None: + """ + Scenario: RED always carries the Tripwire block warning. + Slice: 28 — RED block note + + Given a RED artifact within the validity window, + When verify reports it, + Then Note includes the block warning and will_be_blocked is true. + """ + from guard.verify import verify_artifacts + + ### Given + def resolve(name: str): + return _resolved(name) + + def fetch_status(_resolved): + return _status(heatmap="red") + + ### When + actual = verify_artifacts( + ["vuln-skill"], + resolve=resolve, + fetch_status=fetch_status, + validity_days=14, + now=lambda: NOW, + ) + + ### Then + row = actual.artifacts[0] + assert row.state == "red" + assert row.will_be_blocked is True + assert RED_BLOCK_NOTE in row.note + assert RED_BLOCK_NOTE in actual.to_markdown() + + +def test_given_unscanned_when_verify_then_offers_tw_scan() -> None: + """ + Scenario: Unscanned artifacts offer /tw-scan for that name. + Slice: 28 — unscanned offers scan + + Given an unscanned artifact, + When verify reports it, + Then the Note offers /tw-scan for that name. + """ + from guard.verify import StatusRecord, verify_artifacts + + ### Given + name = "new-skill" + + def resolve(query: str): + return _resolved(query) + + def fetch_status(_resolved): + return StatusRecord(heatmap_status="grey", scanned_at=None, run_status=None) + + ### When + actual = verify_artifacts( + [name], + resolve=resolve, + fetch_status=fetch_status, + validity_days=14, + now=lambda: NOW, + ) + + ### Then + row = actual.artifacts[0] + assert row.state == "unscanned" + assert "/tw-scan" in row.note + assert name in row.note + + +def test_given_unresolved_name_when_verify_then_human_not_found_message() -> None: + """ + Scenario: Not-found names get a useful human message, not a bare error. + Slice: 28 — not-found human-readable + + Given a name with no resolution match, + When verify runs, + Then the response includes a useful human message (not a bare error), + and fetch_status is never invoked for missing names. + """ + from guard.verify import verify_artifacts + + ### Given + name = "unknown-skill" + + def resolve(_query: str): + return None + + def fetch_status(_resolved): + raise AssertionError("fetch_status must not run for not-found") + + ### When + actual = verify_artifacts( + [name], + resolve=resolve, + fetch_status=fetch_status, + validity_days=14, + now=lambda: NOW, + ) + + ### Then + row = actual.artifacts[0] + assert row.state == "not-found" + assert row.name == name + assert row.resolved_path is None + assert "No match" in row.note or "not found" in row.note.lower() + assert "Traceback" not in row.note + assert "Exception" not in row.note + assert "❓" in actual.to_markdown() or "NOT FOUND" in actual.to_markdown() + + +def test_given_repo_when_skill_looked_up_then_claude_layout_skill_md_exists() -> None: + """ + Scenario: /tw-verify ships at the Claude skill layout path. + Slice: 28 — skill installed at Claude layout + + Given the repo checkout, + When an operator looks for /tw-verify, + Then SKILL.md exists and points at verify_artifacts + the dual-output contract. + """ + ### Given / When + text = TW_VERIFY_SKILL.read_text(encoding="utf-8") + + ### Then + assert TW_VERIFY_SKILL.is_file(), f"missing {TW_VERIFY_SKILL}" + assert "verify_artifacts" in text + assert "frontline-output-contract" in text or "dual" in text.lower() + assert "Will be blocked when Tripwire is enabled" in text or "block" in text.lower() diff --git a/guard/tests/test_verify_units.py b/guard/tests/test_verify_units.py new file mode 100644 index 0000000..9195501 --- /dev/null +++ b/guard/tests/test_verify_units.py @@ -0,0 +1,68 @@ +""" +Unit coverage for guard.verify edge paths (slice 28). + +Author: slice-28 +Created: 2026-08-15 +Scope: naive datetime ISO/stale helpers; default now clock +""" + +from __future__ import annotations + +from datetime import UTC, datetime, timedelta + +from guard.verify import ResolvedArtifact, StatusRecord, _is_stale, _iso, verify_artifacts + + +def test_given_naive_datetime_when_iso_then_utc_z_suffix() -> None: + """Scenario: Naive scanned_at is treated as UTC for machine JSON.""" + ### Given + naive = datetime(2026, 8, 1, 10, 0, 0) + + ### When + actual = _iso(naive) + + ### Then + assert actual == "2026-08-01T10:00:00Z" + + +def test_given_none_scanned_at_when_stale_checked_then_false() -> None: + """Scenario: Missing scanned_at is not classified stale by the helper.""" + ### Given / When + actual = _is_stale(None, 14, datetime(2026, 8, 15, tzinfo=UTC)) + + ### Then + assert actual is False + + +def test_given_naive_old_scan_when_stale_checked_then_true() -> None: + """Scenario: Naive old scanned_at compares correctly against aware now.""" + ### Given + naive_old = datetime(2026, 1, 1, 0, 0, 0) + now = datetime(2026, 8, 15, tzinfo=UTC) + + ### When + actual = _is_stale(naive_old, 14, now) + + ### Then + assert actual is True + + +def test_given_no_now_inject_when_verify_then_uses_clock() -> None: + """Scenario: Default now() path remains callable without injection.""" + + ### Given + def resolve(name: str) -> ResolvedArtifact: + return ResolvedArtifact(name=name, artifact_type="skill", resolved_path="/tmp/x") + + def fetch_status(_resolved: ResolvedArtifact) -> StatusRecord: + return StatusRecord( + heatmap_status="green", + scanned_at=datetime.now(UTC) - timedelta(days=1), + run_status="complete", + ) + + ### When + actual = verify_artifacts(["x"], resolve=resolve, fetch_status=fetch_status) + + ### Then + assert actual.artifacts[0].state == "fresh" diff --git a/guard/verify.py b/guard/verify.py new file mode 100644 index 0000000..d5bda29 --- /dev/null +++ b/guard/verify.py @@ -0,0 +1,228 @@ +"""Dual-output /tw-verify helpers (slice 28). + +Classifies resolved artifacts into the six UI states from the Frontline +dual-output contract and renders human Markdown + machine JSON in one pass. +Name resolution and Supabase status lookup are injectable seams so Claude +skills and tests stay free of live I/O. +""" + +from __future__ import annotations + +from collections.abc import Callable, Sequence +from dataclasses import asdict, dataclass +from datetime import UTC, datetime, timedelta +from typing import Any, Literal + +UiState = Literal["fresh", "stale", "unscanned", "scanning", "not-found", "red"] + +RED_BLOCK_NOTE = "Will be blocked when Tripwire is enabled" +NOT_FOUND_NOTE = "No match for this name — check spelling or install path" +TABLE_HEADER = "| Name | Type | Status | Note |" +TABLE_SEP = "|------|------|--------|------|" + +_STATUS_DISPLAY: dict[UiState, str] = { + "fresh": "🟢 GREEN (fresh)", + "stale": "⚠️ STALE", + "unscanned": "🚫 UNSCANNED", + "scanning": "⏳ SCANNING", + "not-found": "❓ NOT FOUND", + "red": "🔴 RED", +} + +_FRESH_RAG_DISPLAY = { + "green": "🟢 GREEN (fresh)", + "amber": "🟠 AMBER (fresh)", + "red": "🔴 RED", +} + + +@dataclass(frozen=True) +class ResolvedArtifact: + """Resolved name from Claude/agent visibility (or a test double).""" + + name: str + artifact_type: str | None + resolved_path: str | None + + +@dataclass(frozen=True) +class StatusRecord: + """Supabase-shaped status for one resolved artifact.""" + + heatmap_status: str | None + scanned_at: datetime | None + run_status: str | None + + +@dataclass(frozen=True) +class ArtifactRow: + """One dual-output artifact row (human + machine share these facts).""" + + name: str + resolved_path: str | None + type: str | None + state: UiState + rag: str | None + scanned_at: str | None + stale: bool + will_be_blocked: bool + note: str + + def status_display(self) -> str: + if self.state == "fresh" and self.rag in _FRESH_RAG_DISPLAY: + return _FRESH_RAG_DISPLAY[self.rag] + return _STATUS_DISPLAY[self.state] + + +@dataclass(frozen=True) +class VerifyResult: + """One-pass verify result for all requested names.""" + + artifacts: list[ArtifactRow] + + def to_machine(self) -> dict[str, Any]: + return {"artifacts": [asdict(row) for row in self.artifacts]} + + def to_markdown(self) -> str: + lines = [TABLE_HEADER, TABLE_SEP] + for row in self.artifacts: + type_cell = row.type or "—" + lines.append(f"| `{row.name}` | {type_cell} | {row.status_display()} | {row.note} |") + return "\n".join(lines) + + +ResolveFn = Callable[[str], ResolvedArtifact | None] +FetchStatusFn = Callable[[ResolvedArtifact], StatusRecord] +NowFn = Callable[[], datetime] + + +def _iso(value: datetime | None) -> str | None: + if value is None: + return None + if value.tzinfo is None: + value = value.replace(tzinfo=UTC) + return value.astimezone(UTC).strftime("%Y-%m-%dT%H:%M:%SZ") + + +def _is_stale(scanned_at: datetime | None, validity_days: int, now: datetime) -> bool: + if scanned_at is None: + return False + point = scanned_at if scanned_at.tzinfo else scanned_at.replace(tzinfo=UTC) + return point < now - timedelta(days=validity_days) + + +def _classify( + name: str, + resolved: ResolvedArtifact | None, + status: StatusRecord | None, + *, + validity_days: int, + now: datetime, +) -> ArtifactRow: + if resolved is None: + return ArtifactRow( + name=name, + resolved_path=None, + type=None, + state="not-found", + rag=None, + scanned_at=None, + stale=False, + will_be_blocked=False, + note=NOT_FOUND_NOTE, + ) + + assert status is not None + if status.run_status == "running": + return ArtifactRow( + name=resolved.name, + resolved_path=resolved.resolved_path, + type=resolved.artifact_type, + state="scanning", + rag=None, + scanned_at=_iso(status.scanned_at), + stale=False, + will_be_blocked=False, + note="Scan in progress — check back shortly", + ) + + heatmap = (status.heatmap_status or "").lower() + if heatmap in ("", "grey", "error") or status.heatmap_status is None: + return ArtifactRow( + name=resolved.name, + resolved_path=resolved.resolved_path, + type=resolved.artifact_type, + state="unscanned", + rag=None, + scanned_at=_iso(status.scanned_at), + stale=False, + will_be_blocked=True, + note=f"Never scanned — offer `/tw-scan {resolved.name}`", + ) + + stale = _is_stale(status.scanned_at, validity_days, now) + if stale: + return ArtifactRow( + name=resolved.name, + resolved_path=resolved.resolved_path, + type=resolved.artifact_type, + state="stale", + rag=heatmap if heatmap in ("green", "amber", "red") else None, + scanned_at=_iso(status.scanned_at), + stale=True, + will_be_blocked=True, + note=f"Last scanned >{validity_days} days ago — rescan recommended", + ) + + if heatmap == "red": + return ArtifactRow( + name=resolved.name, + resolved_path=resolved.resolved_path, + type=resolved.artifact_type, + state="red", + rag="red", + scanned_at=_iso(status.scanned_at), + stale=False, + will_be_blocked=True, + note=RED_BLOCK_NOTE, + ) + + rag = heatmap if heatmap in ("green", "amber") else None + return ArtifactRow( + name=resolved.name, + resolved_path=resolved.resolved_path, + type=resolved.artifact_type, + state="fresh", + rag=rag, + scanned_at=_iso(status.scanned_at), + stale=False, + will_be_blocked=False, + note="—" if heatmap == "green" else "Reported but not blocked at amber threshold", + ) + + +def verify_artifacts( + names: Sequence[str], + *, + resolve: ResolveFn, + fetch_status: FetchStatusFn, + validity_days: int = 14, + now: NowFn | None = None, +) -> VerifyResult: + """Verify every name in one pass; never stop at the first issue.""" + clock = now or (lambda: datetime.now(UTC)) + current = clock() + rows: list[ArtifactRow] = [] + for name in names: + resolved = resolve(name) + status = fetch_status(resolved) if resolved is not None else None + rows.append( + _classify( + name, + resolved, + status, + validity_days=validity_days, + now=current, + ) + ) + return VerifyResult(artifacts=rows) From 4fa28757a233205911552db7ed568f61e6c16d6d Mon Sep 17 00:00:00 2001 From: Mani Sarkar Date: Sat, 15 Aug 2026 18:03:05 +0100 Subject: [PATCH 12/12] feat(slice-29): add /tw-scan submit skill with dual force syntax Wires multi-name scan submission through an injectable tripwire scan seam and echoes introspected batch_id / scan_run_ids for Frontline operators. --- .claude/skills/tw-scan/SKILL.md | 60 ++++ docs/plan/DECISIONS.md | 2 + docs/plan/PROGRESS.md | 33 ++- docs/plan/TRAIL.md | 4 +- docs/plan/gate-evidence/slice-29.json | 71 ++++- .../slice-29-tw-scan.md | 135 ++++++--- docs/user-guide/frontline-output-contract.md | 3 +- docs/user-guide/setup-commands.md | 8 + guard/scan.py | 114 ++++++++ guard/tests/test_scan_units.py | 100 +++++++ guard/tests/test_tw_scan.py | 272 ++++++++++++++++++ 11 files changed, 734 insertions(+), 68 deletions(-) create mode 100644 .claude/skills/tw-scan/SKILL.md create mode 100644 guard/scan.py create mode 100644 guard/tests/test_scan_units.py create mode 100644 guard/tests/test_tw_scan.py diff --git a/.claude/skills/tw-scan/SKILL.md b/.claude/skills/tw-scan/SKILL.md new file mode 100644 index 0000000..123d673 --- /dev/null +++ b/.claude/skills/tw-scan/SKILL.md @@ -0,0 +1,60 @@ +--- +name: tw-scan +description: Submit one or more skill/MCP/tool names to tripwire scan (supports --force / force) +disable-model-invocation: true +--- + +# /tw-scan + +Submit resolved artifact paths to the existing `tripwire scan` API and return +confirmation with introspected identifiers (`batch_id`, `scan_run_ids`). + +Same multi-name resolution rules as `/tw-verify`. Works whether enforcement is +enabled or disabled. Output contract: +[frontline-output-contract.md](../../../docs/user-guide/frontline-output-contract.md). + +## Force syntax + +Both forms resubmit even over a valid non-stale result: + +- `/tw-scan name --force` +- `/tw-scan name force` + +## Steps + +1. Accept space- or comma-separated names, optionally with `--force` or bare `force`. +2. **Resolve** each name to a filesystem path (Claude Code visibility). Unresolved + names are reported as not-found and are **not** submitted. +3. Call `guard.scan.scan_artifacts` (or the equivalent one-liner): + +```bash +uv run python -c " +from guard.scan import parse_scan_args, scan_artifacts +from guard.verify import ResolvedArtifact + +tokens = ['name-a', 'name-b'] # plus '--force' or 'force' when requested +names, force = parse_scan_args(tokens) + +def resolve(name: str): + # Agent fills ResolvedArtifact(...) or returns None when not found. + raise SystemExit('wire resolve from Claude visibility') + +def submit(paths, *, force): + # Production: invoke existing tripwire scan / runScan for these paths. + # Return only introspected fields: batch_id, scan_run_ids, failed_targets. + raise SystemExit('wire submit to tripwire scan API') + +result = scan_artifacts(names, resolve=resolve, submit=submit, force=force) +print(result.to_markdown()) +print(result.to_machine()) +" +``` + +4. Show the operator **both** the Markdown confirmation and the machine JSON. + Echo `batch_id` and `scan_run_ids` from the scan API — do not invent fields. + +## Notes + +- Submit path is the existing `tripwire scan` / `cli/src/orchestrator.js` `runScan` + stdout shape (slice 26 introspection). +- `/tw-scan` does not toggle the enable flag; use `/tw-enable` / `/tw-disable`. diff --git a/docs/plan/DECISIONS.md b/docs/plan/DECISIONS.md index 5c0fb0d..d9cedd4 100644 --- a/docs/plan/DECISIONS.md +++ b/docs/plan/DECISIONS.md @@ -116,3 +116,5 @@ | 2026-08-15 | slice-28 | AT design complete | Six GWTs; injectable resolve+fetch_status; ≥95% lines on `guard/verify.py`; SSOT `frontline-output-contract.md`. | | 2026-08-15 | slice-28 | review APPROVED | nw-software-crafter-reviewer — nit AT5 docstring only; applied. | | 2026-08-15 | wave-h | Multi-agent by default | For **every** Wave H slice: parallelize with Task subagents whenever work is independent. Default fan-out: (1) AT/acceptance critique via `nw-acceptance-designer-reviewer` after DISTILL ATs; (2) implementation critique via `nw-software-crafter-reviewer` before 🔀→merge; (3) optional docs/gate audit in parallel with review; (4) explore/research agents for status APIs / fixtures before GREEN. Do not serialize these when they can run concurrently. Applies to slices 28+ remaining (29–39) and any reopen. | +| 2026-08-15 | slice-29 | AT design | Seven GWTs after acceptance critique (observable Then clauses + not-found skip). Coverage ≥95% on `guard/scan.py`. | +| 2026-08-15 | slice-29 | acceptance-review NITs | Deferred vs full nWave DISTILL: Mandate-14 `@contract-shape` tags and 40% error-path AT ratio — Wave H follows slice 27/28 pattern (≤7 binary GWTs, skill file lands in GREEN). Logged, not blocking. | diff --git a/docs/plan/PROGRESS.md b/docs/plan/PROGRESS.md index 4405098..b48e960 100644 --- a/docs/plan/PROGRESS.md +++ b/docs/plan/PROGRESS.md @@ -12,26 +12,25 @@ | 5 | [`05-E-…`](slices/05-E-ship-path-coverage/) | **E — Ship-path coverage** | 8 → 11 → 12 → 13 ✅ → 14 (**9+10 SUBSUMED INTO 11**) | ✅ Musts · close-path | | 6 | [`06-F-…`](slices/06-F-claim-audit/) | **F — Claim audit** | 15 · 16 | 📦 | | 7 | [`07-G-…`](slices/07-G-atdd-closure/) | **G — ATDD closure** | 18, 19, 20, 21, 22 (independent gates) | 📋 parked | -| 8 | [`08-H-…`](slices/08-H-frontline-agent-hooks/) | **H — Frontline agent hooks** | 23→32 Must · 33–38 Should · 39 Could | 23–27 ✅ · 28 🔀 | +| 8 | [`08-H-…`](slices/08-H-frontline-agent-hooks/) | **H — Frontline agent hooks** | 23→32 Must · 33–38 Should · 39 Could | 23–28 ✅ · 29 🔀 | -**Current priority:** Wave H Must slice **28** — `/tw-verify` — 🔀 ON BRANCH (pending review + merge). Wave G (18–22) parked while H1–H3 is active unless explicitly resumed. +**Current priority:** Wave H Must slice **29** — `/tw-scan` — 🔀 ON BRANCH (pending commit + merge). Wave G (18–22) parked while H1–H3 is active unless explicitly resumed. ## Execution order (open work) | Order | Wave | # | Slice | MoSCoW | Status | |------:|-----:|---|-------|--------|--------| -| 1 | H2 | 28 | `/tw-verify` | Must | 🔀 ON BRANCH | -| 2 | H2 | 29 | `/tw-scan` | Must | 📋 PLANNED | -| 3 | H2 | 30 | `/tw-self-check` | Must | 📋 PLANNED | -| 4 | H3 | 31 | Demo Artifacts | Must | 📋 PLANNED | -| 5 | H3 | 32 | Phase 1 Regression Verification (HARD GATE) | Must | 📋 PLANNED | -| 6 | H4 | 33 | DepShield Install | Should | 📋 PLANNED | -| 7 | H4 | 34 | DepShield Dispatch | Should | 📋 PLANNED | -| 8 | H5 | 35 | Ossprey Access Provisioning | Should | 🔴 BLOCKED | -| 9 | H5 | 36 | Ossprey Dispatch | Should | 📋 PLANNED | -| 10 | H6 | 37 | CLI Monitoring | Should | 📋 PLANNED | -| 11 | H6 | 38 | Full-Chain Validation | Should | 📋 PLANNED | -| 12 | H6 | 39 | FE/BE Rearchitecture | Could | 📦 DEFERRED | +| 1 | H2 | 29 | `/tw-scan` | Must | 🔀 ON BRANCH | +| 2 | H2 | 30 | `/tw-self-check` | Must | 📋 PLANNED | +| 3 | H3 | 31 | Demo Artifacts | Must | 📋 PLANNED | +| 4 | H3 | 32 | Phase 1 Regression Verification (HARD GATE) | Must | 📋 PLANNED | +| 5 | H4 | 33 | DepShield Install | Should | 📋 PLANNED | +| 6 | H4 | 34 | DepShield Dispatch | Should | 📋 PLANNED | +| 7 | H5 | 35 | Ossprey Access Provisioning | Should | 🔴 BLOCKED | +| 8 | H5 | 36 | Ossprey Dispatch | Should | 📋 PLANNED | +| 9 | H6 | 37 | CLI Monitoring | Should | 📋 PLANNED | +| 10 | H6 | 38 | Full-Chain Validation | Should | 📋 PLANNED | +| 11 | H6 | 39 | FE/BE Rearchitecture | Could | 📦 DEFERRED | | — | G | 18–22 | ATDD closure (parked) | Must | 📋 PLANNED | ## Quick Status (by group) @@ -94,8 +93,8 @@ | 25 | [slice-25-live-enforce-smoke](slices/08-H-frontline-agent-hooks/slice-25-live-enforce-smoke.md) | Must | ✅ | 2026-08-15 | 2026-08-15 | ~30 min | | 26 | [slice-26-api-output-contract](slices/08-H-frontline-agent-hooks/slice-26-api-output-contract.md) | Must | ✅ | 2026-08-15 | 2026-08-15 | ~40 min | | 27 | [slice-27-tw-enable-disable](slices/08-H-frontline-agent-hooks/slice-27-tw-enable-disable.md) | Must | ✅ | 2026-08-15 | 2026-08-15 | ~25 min | -| 28 | [slice-28-tw-verify](slices/08-H-frontline-agent-hooks/slice-28-tw-verify.md) | Must | 🔀 ON BRANCH | 2026-08-15 | — | ~50 min | -| 29 | [slice-29-tw-scan](slices/08-H-frontline-agent-hooks/slice-29-tw-scan.md) | Must | 📋 PLANNED | — | — | ~40 min | +| 28 | [slice-28-tw-verify](slices/08-H-frontline-agent-hooks/slice-28-tw-verify.md) | Must | ✅ | 2026-08-15 | 2026-08-15 | ~50 min | +| 29 | [slice-29-tw-scan](slices/08-H-frontline-agent-hooks/slice-29-tw-scan.md) | Must | 🔀 ON BRANCH | 2026-08-15 | — | ~40 min | | 30 | [slice-30-tw-self-check](slices/08-H-frontline-agent-hooks/slice-30-tw-self-check.md) | Must | 📋 PLANNED | — | — | ~30 min | | 31 | [slice-31-demo-artifacts](slices/08-H-frontline-agent-hooks/slice-31-demo-artifacts.md) | Must | 📋 PLANNED | — | — | ~40 min | | 32 | [slice-32-phase1-regression](slices/08-H-frontline-agent-hooks/slice-32-phase1-regression.md) | Must | 📋 PLANNED | — | — | ~50 min | @@ -119,7 +118,7 @@ ## Forward Roadmap - Waves **A–C**, coverage Slice 14, and Slice 17 are merged and closed. Slice 15 is retained as a deferred claim-audit artifact, not active work. -- **Wave H (Frontline):** integration branch `frontline-hackathon-london-2026-agent-hooks`. Slice 23 ✅ (PR #74). Slice 24 ✅ (PR #75). Slice 25 ✅ (PR #76). Slice 26 ✅ (PR #78). Slice 27 ✅ (PR #80). Slice 28 🔀 (`/tw-verify`) ON BRANCH pending review + merge. +- **Wave H (Frontline):** integration branch `frontline-hackathon-london-2026-agent-hooks`. Slice 23 ✅ (PR #74). Slice 24 ✅ (PR #75). Slice 25 ✅ (PR #76). Slice 26 ✅ (PR #78). Slice 27 ✅ (PR #80). Slice 28 ✅ (PR #81). Slice 29 🔀 (`/tw-scan`) ON BRANCH pending commit + merge. - Wave G (18–22) remains planned but **parked** while Frontline H1–H3 is active unless explicitly resumed. - Reopen Slice 15 only for a future live/demo release that needs its security and 3B evidence path. - **Deferred / Won't (A):** 4 (in A); 15 and 16 (in F) — reinstate only if a new live/demo need arises diff --git a/docs/plan/TRAIL.md b/docs/plan/TRAIL.md index ff075a1..39338bf 100644 --- a/docs/plan/TRAIL.md +++ b/docs/plan/TRAIL.md @@ -169,8 +169,8 @@ Branch: `frontline-hackathon-london-2026-agent-hooks`. Source: `internal-docs/04 |---|------|------|--------|--------|------------|-------|-----------| | 26 | [slice-26-api-output-contract](slices/08-H-frontline-agent-hooks/slice-26-api-output-contract.md) | API Introspect + Dual Output Contract | Must | ✅ | 25 | #78 | ~4 min | | 27 | [slice-27-tw-enable-disable](slices/08-H-frontline-agent-hooks/slice-27-tw-enable-disable.md) | `/tw-enable` + `/tw-disable` | Must | ✅ | 26 | #80 | ~3 min | -| 28 | [slice-28-tw-verify](slices/08-H-frontline-agent-hooks/slice-28-tw-verify.md) | `/tw-verify` | Must | 🔀 | 26,27 | — | ~5 min | -| 29 | [slice-29-tw-scan](slices/08-H-frontline-agent-hooks/slice-29-tw-scan.md) | `/tw-scan` | Must | 📋 | 26 | — | ~4 min | +| 28 | [slice-28-tw-verify](slices/08-H-frontline-agent-hooks/slice-28-tw-verify.md) | `/tw-verify` | Must | ✅ | 26,27 | #81 | ~5 min | +| 29 | [slice-29-tw-scan](slices/08-H-frontline-agent-hooks/slice-29-tw-scan.md) | `/tw-scan` | Must | 🔀 | 26 | — | ~4 min | | 30 | [slice-30-tw-self-check](slices/08-H-frontline-agent-hooks/slice-30-tw-self-check.md) | `/tw-self-check` | Must | 📋 | 28 | — | ~3 min | #### H3 — Demos + Phase 1 regression (Must) — HARD GATE diff --git a/docs/plan/gate-evidence/slice-29.json b/docs/plan/gate-evidence/slice-29.json index 8e677ab..fd5ad7d 100644 --- a/docs/plan/gate-evidence/slice-29.json +++ b/docs/plan/gate-evidence/slice-29.json @@ -1,24 +1,65 @@ { "slice": 29, - "gate_status": "PLANNED", + "gate_status": "ON_BRANCH", "inferred": false, - "branch": null, - "date": null, - "before_checks": [], - "after_checks": [], + "branch": "slice/29-tw-scan", + "date": "2026-08-15", + "spec_path": "docs/plan/slices/08-H-frontline-agent-hooks/slice-29-tw-scan.md", + "output_ssot": "docs/user-guide/frontline-output-contract.md", + "skill_layout": [".claude/skills/tw-scan/SKILL.md"], + "scan_id_fields": ["batch_id", "scan_run_ids", "failed_targets"], + "before_checks": "PASS — slice 26 verdict PASS (PR #78); branch from Frontline (Wave H base waiver); scan ID fields from frontline-output-contract.md; coverage ≥95% on guard/scan.py; complexity enforcing", + "after_checks": [ + "PASS — multi-name submit + both force syntaxes", + "PASS — machine echoes batch_id / scan_run_ids / failed_targets", + "PASS — .venv/bin/pytest guard/tests/test_tw_scan.py -q (7 passed)", + "PASS — coverage 98.7% lines on guard/scan.py (target ≥95%)", + "PASS — xenon exit 0; ./scripts/quality-gates.sh PASS", + "PASS — review acceptance revised then implementation APPROVED (nw-software-crafter-reviewer)", + "PENDING merge — PROGRESS/TRAIL ✅ after merge to Frontline" + ], "planned_commands": [ - "test -f docs/plan/slices/08-H-frontline-agent-hooks/slice-29-tw-scan.md", - "rg -n \"tw-scan|--force|force\" docs/plan/slices/08-H-frontline-agent-hooks/ internal-docs/04_frontline/", - "tripwire scan --help || true", + ".venv/bin/pytest guard/tests/test_tw_scan.py -q --tb=short", + ".venv/bin/pytest guard/tests/test_tw_scan.py guard/tests/test_scan_units.py --cov=guard.scan --cov-report=term-missing -q", "./scripts/quality-gates.sh" ], - "commands": [], - "test_budget": {"acceptance_tests_max": 7, "parametrized_case_counts_as_one": true}, - "reviewers": [], - "review": {"acceptance": "PENDING", "implementation": "PENDING"}, - "verdict": "NOT_RUN", - "coverage_target": "TBD at AT design before IN PROGRESS", - "complexity_policy": "enforcing for product-code; N/A for docs-only with reason in evidence", + "commands": [ + { + "cmd": ".venv/bin/pytest guard/tests/test_tw_scan.py -q --tb=short", + "result": "PASS exit 0 — 7 passed" + }, + { + "cmd": ".venv/bin/pytest guard/tests/test_tw_scan.py guard/tests/test_scan_units.py --cov=guard.scan --cov-report=term-missing -q", + "result": "PASS — 11 passed; TOTAL 98.7% lines on guard/scan.py" + }, + { + "cmd": "./scripts/quality-gates.sh", + "result": "PASS — quality-gates passed" + }, + { + "cmd": "test -f .claude/skills/tw-scan/SKILL.md", + "result": "PASS — Claude skill layout present" + } + ], + "test_budget": { + "acceptance_tests_max": 7, + "acceptance_tests_designed": 7, + "parametrized_case_counts_as_one": true + }, + "coverage": { + "target": "≥95% lines on guard/scan.py", + "measured": "98.7%", + "date": "2026-08-15" + }, + "complexity_policy": "enforcing for product-code; xenon exit 0 via quality-gates", + "reviewers": ["nw-acceptance-designer-reviewer", "nw-software-crafter-reviewer"], + "review": { + "acceptance": "APPROVED", + "implementation": "APPROVED", + "notes": "Acceptance Then clauses revised for observables; Mandate-14 / 40% error-ratio NITs deferred per DECISIONS Wave H pattern. Implementation APPROVED — zero defects." + }, + "verdict": "ON_BRANCH", + "coverage_target": "≥95% lines on guard/scan.py", "phase": "H2", "moscow": "Must" } diff --git a/docs/plan/slices/08-H-frontline-agent-hooks/slice-29-tw-scan.md b/docs/plan/slices/08-H-frontline-agent-hooks/slice-29-tw-scan.md index d398b86..45f6bb3 100644 --- a/docs/plan/slices/08-H-frontline-agent-hooks/slice-29-tw-scan.md +++ b/docs/plan/slices/08-H-frontline-agent-hooks/slice-29-tw-scan.md @@ -6,57 +6,126 @@ `/tw-scan` resolves multiple names like `/tw-verify`, accepts both `--force` and `force` to resubmit over a valid non-stale result, submits via the existing `tripwire scan` API, and returns introspected identifiers from that API. -## GWT acceptance specification +## Response shape (AT contract) + +`guard.scan.scan_artifacts` returns a `ScanResult` with: + +| Surface | Contents | +|---------|----------| +| `to_machine()` | dict with **API echo**: `batch_id` (str), `scan_run_ids` (list[str]), `failed_targets` (list[dict]); plus **skill-composed**: `submitted` (list[str] names in request order), `force` (bool — operator flag applied) | +| `to_markdown()` | Human confirmation including every submitted name and the `batch_id` / `scan_run_ids` receipt | -Thin scaffolds — full DISTILL ATs deferred per DECISIONS; design ATs before marking IN PROGRESS. +Do **not** invent extra `tripwire scan` stdout fields beyond slice-26 introspection. + +## GWT acceptance specification -1. **Multi-name submit** - - Given two resolvable names, when `/tw-scan` runs, then each resolved path is submitted (or batched) via the existing scan API and confirmation covers all names. -2. **`--force` works** - - Given a fresh non-stale scan result, when `/tw-scan name --force` runs, then a new submission occurs. -3. **Bare `force` works** - - Given a fresh non-stale scan result, when `/tw-scan name force` runs, then a new submission occurs (same effect as `--force`). -4. **Identifiers returned** - - Given a successful submit, when the skill responds, then the response includes introspected IDs from the scan API (e.g. scan/batch ID — exact fields per slice-26 introspection). +**DISTILL ATs (2026-08-15, revised after acceptance review)** — ≤7; product-code + Claude skill. + +| # | Scenario | Tags | Real-surface binding | +|---|----------|------|----------------------| +| 1 | Multi-name submit confirmation | `@US-29` | `guard.scan.scan_artifacts` | +| 2 | `--force` yields new run IDs | `@US-29` | `guard.scan.scan_artifacts` | +| 3 | Bare `force` yields new run IDs | `@US-29` | `guard.scan.parse_scan_args` + `scan_artifacts` | +| 4 | Identifiers returned | `@US-29` | `ScanResult.to_machine()` | +| 5 | Force tokens excluded from submitted | `@US-29` | `parse_scan_args` → `submitted` | +| 6 | Skill shipped at Claude layout | `@US-29` | `.claude/skills/tw-scan/SKILL.md` | +| 7 | Not-found skipped from submit | `@US-29` | `guard.scan.scan_artifacts` | + +1. **Multi-name submit** `@US-29` + - Given two resolvable names, + when `scan_artifacts` runs, + then `to_machine()["submitted"]` equals both names in order, + `to_markdown()` contains both names, and a single submit receives both + resolved paths. +2. **`--force` works** `@US-29` + - Given a submit double that returns empty `scan_run_ids` when `force=False` + and non-empty IDs when `force=True` (fresh/cached resubmit), + when `scan_artifacts(..., force=True)` runs, + then `to_machine()["force"]` is true and `scan_run_ids` is a non-empty list. +3. **Bare `force` works** `@US-29` + - Given tokens `["skill-a", "force"]`, + when `parse_scan_args` then `scan_artifacts` with the parsed force flag + and the same force-sensitive submit double as AT-2, + then `to_machine()["force"]` is true, `submitted == ["skill-a"]`, + and `scan_run_ids` is non-empty (same effect as `--force`). +4. **Identifiers returned** `@US-29` + - Given a successful submit returning slice-26 fields, + when `scan_artifacts` responds, + then `to_machine()` includes non-empty string `batch_id`, + list `scan_run_ids`, and list `failed_targets` (may be empty); + no other API-echo keys beyond those three. +5. **Force tokens not submitted names** `@US-29` + - Given tokens `["skill-a", "--force", "skill-b", "force"]`, + when `parse_scan_args` runs, + then returned names are exactly `["skill-a", "skill-b"]` and `force` is true + (`"--force"` / `"force"` absent from names). +6. **Skill at Claude layout** `@US-29` + - Given the repo checkout, + when an operator reads `.claude/skills/tw-scan/SKILL.md`, + then the file exists and its text includes `guard.scan.scan_artifacts`, + `batch_id`, `scan_run_ids`, and `frontline-output-contract`. +7. **Not-found skipped from submit** `@US-29` + - Given one resolvable name and one unresolved name, + when `scan_artifacts` runs, + then submit receives only the resolved path, `submitted` lists only the + resolved name, and `to_markdown()` mentions the unresolved name as not found. + +**Test inventory (7 acceptance tests):** +`guard/tests/test_tw_scan.py` + +**Named verification command:** + +```bash +.venv/bin/pytest guard/tests/test_tw_scan.py -q --tb=short +``` + +**Coverage / complexity (AT design):** + +- Coverage target: **≥95% lines** on `guard/scan.py`. +- Complexity: **enforcing** for product-code; cite `./scripts/quality-gates.sh` / + xenon in gate evidence. ## Design / test treatment -- Wire to existing `tripwire scan` submit path; do not invent a parallel submit API. -- Dual force syntax is mandatory; share name resolution with `/tw-verify` where practical. -- **AT design required before IN PROGRESS** (≤7 acceptance tests). +- Wire to existing `tripwire scan` submit path via injectable `submit(paths, force)` + → `{batch_id, scan_run_ids, failed_targets}`; no parallel submit API. +- Dual force syntax mandatory; reuse `ResolvedArtifact` from `guard.verify`. +- **AT design complete (revised)** — ready for 🔨 IN PROGRESS. ## Before-Checks [GATE] -- [ ] Slice 26 gate-evidence `verdict` is `PASS` -- [ ] Branch `slice/29-tw-scan` created from current `main` -- [ ] Observed `tripwire scan` response fields (IDs) recorded from slice-26 introspection -- [ ] Coverage/complexity targets TBD until AT design completes +- [x] Slice 26 gate-evidence `verdict` is `PASS` (PR #78) +- [x] Branch `slice/29-tw-scan` created from Frontline integration + (DECISIONS Wave H branch-base waiver) +- [x] Observed `tripwire scan` response fields recorded + (`batch_id`, `scan_run_ids`, `failed_targets` in frontline-output-contract.md) +- [x] Coverage target ≥95% lines on `guard/scan.py`; complexity enforcing ## TDD execution -RED: add scan GWTs for multi-name, `--force`, bare `force`, and ID return. +RED: add scan GWTs for multi-name, `--force`, bare `force`, ID return, force-token strip, skill layout. GREEN: implement `/tw-scan` submit wiring only as needed. -REFACTOR: share resolution/formatting with `/tw-verify` without coupling enable flag. +REFACTOR: share resolution types with `/tw-verify` without coupling enable flag. ## After-Checks [GATE] -- [ ] Multi-name submit and both force syntaxes pass -- [ ] Response includes introspected scan/batch identifiers -- [ ] Named test command(s) from AT design exit 0 (record in gate evidence) -- [ ] Coverage target: set at AT design before IN PROGRESS; recorded % meets that target -- [ ] Complexity policy: **enforcing** for product-code; evidence cites quality-gates / complexity report -- [ ] `docs/plan/gate-evidence/slice-29.json` records commands, coverage, complexity, reviewers, and `verdict: PASS` -- [ ] Review: `acceptance: APPROVED` and `implementation: APPROVED` (or docs-only exception in DECISIONS) -- [ ] `PROGRESS.md` + `TRAIL.md` show slice 29 ✅ +- [x] Multi-name submit and both force syntaxes pass +- [x] Response includes introspected scan/batch identifiers +- [x] Named test command(s) from AT design exit 0 (record in gate evidence) +- [x] Coverage target: ≥95% lines on `guard/scan.py`; recorded % meets that target (98.7%) +- [x] Complexity policy: **enforcing** for product-code; evidence cites quality-gates / complexity report +- [x] `docs/plan/gate-evidence/slice-29.json` records commands, coverage, complexity, reviewers, and `verdict: ON_BRANCH` (PASS after merge) +- [x] Review: `acceptance: APPROVED` and `implementation: APPROVED` (nw-software-crafter-reviewer) +- [ ] `PROGRESS.md` + `TRAIL.md` show slice 29 ✅ (after merge) ## Doc Audit -| # | Check | -|---|--------| -| 1 | `/tw-scan` multi-name + `--force`/`force` documented | -| 2 | Link to existing `tripwire scan` API + slice-26 ID fields | -| 3 | Cross-link gate-evidence ↔ TRAIL/PROGRESS | +| # | Check | Result | +|---|--------|--------| +| 1 | `/tw-scan` multi-name + `--force`/`force` documented | PASS — setup-commands + SKILL.md | +| 2 | Link to existing `tripwire scan` API + slice-26 ID fields | PASS — SKILL.md + frontline-output-contract | +| 3 | Cross-link gate-evidence ↔ TRAIL/PROGRESS | PASS | ## Gate Status -📋 PLANNED +🔀 ON BRANCH (pending commit + merge) diff --git a/docs/user-guide/frontline-output-contract.md b/docs/user-guide/frontline-output-contract.md index 4a9dfb4..9310778 100644 --- a/docs/user-guide/frontline-output-contract.md +++ b/docs/user-guide/frontline-output-contract.md @@ -133,7 +133,8 @@ Do not invent scan-stdout fields that are not listed above. Skills compose dual- - Skills that consume this contract: slices [27](../plan/slices/08-H-frontline-agent-hooks/slice-27-tw-enable-disable.md)–[30](../plan/slices/08-H-frontline-agent-hooks/slice-30-tw-self-check.md) (`/tw-enable` / `/tw-disable` are config toggles only — they do not emit this table; - `/tw-verify` implements the table via `guard.verify.verify_artifacts`, slice 28) + `/tw-verify` implements the table via `guard.verify.verify_artifacts`, slice 28; + `/tw-scan` echoes `batch_id` / `scan_run_ids` via `guard.scan.scan_artifacts`, slice 29) - Operator setup / hooks: [setup-commands.md](setup-commands.md) - Gate evidence: [`docs/plan/gate-evidence/slice-26.json`](../plan/gate-evidence/slice-26.json) · [`slice-27.json`](../plan/gate-evidence/slice-27.json) · diff --git a/docs/user-guide/setup-commands.md b/docs/user-guide/setup-commands.md index 73b31c7..abd3d5e 100644 --- a/docs/user-guide/setup-commands.md +++ b/docs/user-guide/setup-commands.md @@ -88,6 +88,14 @@ Resolves one or more names, classifies each via Supabase status (injectable one pass. See [frontline-output-contract.md](frontline-output-contract.md). ATs: `pytest guard/tests/test_tw_verify.py -q`. +**`/tw-scan` (operator):** Claude skill at `.claude/skills/tw-scan/SKILL.md`. +Resolves one or more names (same rules as `/tw-verify`), accepts `--force` or +bare `force` to resubmit over a fresh result, and submits via the existing +`tripwire scan` API (`guard.scan.scan_artifacts`). Confirmation echoes +slice-26 identifiers (`batch_id`, `scan_run_ids`). See +[frontline-output-contract.md](frontline-output-contract.md). +ATs: `pytest guard/tests/test_tw_scan.py -q`. + **`/tw-*` dual output contract:** human Markdown table + machine JSON shape, `heatmap_status` → six UI states, and observed `tripwire scan` stdout fields — see [frontline-output-contract.md](frontline-output-contract.md). diff --git a/guard/scan.py b/guard/scan.py new file mode 100644 index 0000000..43c0519 --- /dev/null +++ b/guard/scan.py @@ -0,0 +1,114 @@ +"""Scan submit helpers for /tw-scan (slice 29). + +Parses dual force syntax, resolves names via an injectable seam (shared +ResolvedArtifact with /tw-verify), and submits through an injectable wrapper +around the existing tripwire scan API — echoing only introspected identifiers. +""" + +from __future__ import annotations + +from collections.abc import Callable, Sequence +from dataclasses import dataclass +from typing import Any + +from guard.verify import ResolvedArtifact + +FORCE_TOKENS = frozenset({"--force", "force"}) +NOT_FOUND_NOTE = "No match for this name — check spelling or install path" + +ResolveFn = Callable[[str], ResolvedArtifact | None] +SubmitFn = Callable[..., dict[str, Any]] + + +@dataclass(frozen=True) +class ScanResult: + """Confirmation for a /tw-scan submission.""" + + submitted: tuple[str, ...] + skipped: tuple[str, ...] + force: bool + batch_id: str + scan_run_ids: tuple[str, ...] + failed_targets: tuple[dict[str, Any], ...] + + def to_machine(self) -> dict[str, Any]: + return { + "batch_id": self.batch_id, + "scan_run_ids": list(self.scan_run_ids), + "failed_targets": [dict(item) for item in self.failed_targets], + "submitted": list(self.submitted), + "force": self.force, + "skipped": list(self.skipped), + } + + def to_markdown(self) -> str: + lines = [ + "## Scan submitted", + f"- force: `{self.force}`", + f"- batch_id: `{self.batch_id}`", + f"- scan_run_ids: {', '.join(f'`{rid}`' for rid in self.scan_run_ids) or '—'}", + ] + if self.submitted: + lines.append("- submitted: " + ", ".join(f"`{name}`" for name in self.submitted)) + for name in self.skipped: + lines.append(f"- `{name}`: {NOT_FOUND_NOTE}") + if self.failed_targets: + for failure in self.failed_targets: + target = failure.get("target", "?") + error = failure.get("error", "unknown error") + lines.append(f"- failed `{target}`: {error}") + return "\n".join(lines) + + +def parse_scan_args(tokens: Sequence[str]) -> tuple[list[str], bool]: + """Split artifact names from `--force` / bare `force` tokens.""" + names: list[str] = [] + force = False + for token in tokens: + if token in FORCE_TOKENS: + force = True + continue + names.append(token) + return names, force + + +def scan_artifacts( + names: Sequence[str], + *, + resolve: ResolveFn, + submit: SubmitFn, + force: bool = False, +) -> ScanResult: + """Resolve names, submit resolvable paths once, echo scan API identifiers.""" + submitted: list[str] = [] + paths: list[str] = [] + skipped: list[str] = [] + for name in names: + resolved = resolve(name) + if resolved is None or not resolved.resolved_path: + skipped.append(name) + continue + submitted.append(resolved.name) + paths.append(resolved.resolved_path) + + if not paths: + return ScanResult( + submitted=tuple(submitted), + skipped=tuple(skipped), + force=force, + batch_id="", + scan_run_ids=(), + failed_targets=(), + ) + + raw = submit(paths, force=force) + failed = raw.get("failed_targets") or [] + run_ids = raw.get("scan_run_ids") or [] + return ScanResult( + submitted=tuple(submitted), + skipped=tuple(skipped), + force=force, + batch_id=str(raw.get("batch_id") or ""), + scan_run_ids=tuple(str(item) for item in run_ids), + failed_targets=tuple(dict(item) for item in failed), + ) diff --git a/guard/tests/test_scan_units.py b/guard/tests/test_scan_units.py new file mode 100644 index 0000000..c71cc6e --- /dev/null +++ b/guard/tests/test_scan_units.py @@ -0,0 +1,100 @@ +""" +Unit coverage for guard.scan edge paths (slice 29). + +Author: slice-29 +Created: 2026-08-15 +Scope: all-unresolved early return; failed_targets markdown +""" + +from __future__ import annotations + +from typing import Any + +from guard.scan import scan_artifacts +from guard.verify import ResolvedArtifact + + +def test_given_all_unresolved_when_scan_then_no_submit() -> None: + """Scenario: When every name is unresolved, submit is not called.""" + ### Given + calls = 0 + + def resolve(_name: str) -> None: + return None + + def submit(paths: list[str], *, force: bool) -> dict[str, Any]: + del paths, force + nonlocal calls + calls += 1 + return {"batch_id": "x", "scan_run_ids": [], "failed_targets": []} + + ### When + actual = scan_artifacts(["ghost"], resolve=resolve, submit=submit) + + ### Then + assert calls == 0 + machine = actual.to_machine() + assert machine["batch_id"] == "" + assert machine["submitted"] == [] + assert machine["skipped"] == ["ghost"] + + +def test_given_failed_targets_when_markdown_then_lists_failures() -> None: + """Scenario: failed_targets from the API appear in Markdown confirmation.""" + + ### Given + def resolve(name: str) -> ResolvedArtifact: + return ResolvedArtifact(name=name, artifact_type="skill", resolved_path=f"/tmp/{name}") + + def submit(paths: list[str], *, force: bool) -> dict[str, Any]: + del force + return { + "batch_id": "batch-fail", + "scan_run_ids": [], + "failed_targets": [{"target": paths[0], "error": "dispatch boom"}], + } + + ### When + actual = scan_artifacts(["skill-a"], resolve=resolve, submit=submit) + markdown = actual.to_markdown() + + ### Then + assert "dispatch boom" in markdown + assert "/tmp/skill-a" in markdown or "skill-a" in markdown + + +def test_given_empty_path_resolve_when_scan_then_skipped() -> None: + """Scenario: ResolvedArtifact with empty path is treated as not found.""" + + ### Given + def resolve(name: str) -> ResolvedArtifact: + return ResolvedArtifact(name=name, artifact_type="skill", resolved_path="") + + def submit(paths: list[str], *, force: bool) -> dict[str, Any]: + del paths, force + raise AssertionError("submit must not run") + + ### When + actual = scan_artifacts(["empty-path"], resolve=resolve, submit=submit) + + ### Then + assert actual.to_machine()["skipped"] == ["empty-path"] + + +def test_given_sparse_failure_dict_when_markdown_then_defaults() -> None: + """Scenario: failed_targets missing keys still render safely.""" + + ### Given + def resolve(name: str) -> ResolvedArtifact: + return ResolvedArtifact(name=name, artifact_type="skill", resolved_path=f"/tmp/{name}") + + def submit(paths: list[str], *, force: bool) -> dict[str, Any]: + del paths, force + return {"batch_id": "b", "scan_run_ids": ["r1"], "failed_targets": [{}]} + + ### When + markdown = scan_artifacts(["skill-a"], resolve=resolve, submit=submit).to_markdown() + + ### Then + assert "failed `?`" in markdown + assert "unknown error" in markdown diff --git a/guard/tests/test_tw_scan.py b/guard/tests/test_tw_scan.py new file mode 100644 index 0000000..1a22e1e --- /dev/null +++ b/guard/tests/test_tw_scan.py @@ -0,0 +1,272 @@ +""" +Acceptance tests for /tw-scan (slice 29). + +Author: slice-29 +Created: 2026-08-15 +Scope: multi-name submit; --force / bare force; ID echo; force-token strip; SKILL.md +""" + +from __future__ import annotations + +from pathlib import Path +from typing import Any + +REPO_ROOT = Path(__file__).resolve().parents[2] +TW_SCAN_SKILL = REPO_ROOT / ".claude" / "skills" / "tw-scan" / "SKILL.md" +API_ECHO_KEYS = frozenset({"batch_id", "scan_run_ids", "failed_targets"}) + + +def _resolved(name: str, artifact_type: str = "skill") -> Any: + from guard.verify import ResolvedArtifact + + return ResolvedArtifact( + name=name, + artifact_type=artifact_type, + resolved_path=f"/tmp/{name}", + ) + + +def _force_sensitive_submit(paths: list[str], *, force: bool) -> dict[str, Any]: + """Simulate cached skip unless force — empty run IDs without force.""" + if force: + return { + "batch_id": "batch-forced", + "scan_run_ids": [f"run-{i}" for i, _ in enumerate(paths)], + "failed_targets": [], + } + return { + "batch_id": "batch-cached", + "scan_run_ids": [], + "failed_targets": [], + } + + +def test_given_two_names_when_scan_then_submitted_covers_both() -> None: + """ + Scenario: Multi-name scan confirms every name and submits both paths once. + Slice: 29 — multi-name submit + + Given two resolvable names, + When scan_artifacts runs, + Then machine submitted lists both names, markdown mentions both, and one + submit receives both resolved paths. + """ + from guard.scan import scan_artifacts + + ### Given + names = ["skill-a", "skill-b"] + captured: list[tuple[list[str], bool]] = [] + + def resolve(name: str): + return _resolved(name) + + def submit(paths: list[str], *, force: bool) -> dict[str, Any]: + captured.append((list(paths), force)) + return { + "batch_id": "batch-1", + "scan_run_ids": ["run-a", "run-b"], + "failed_targets": [], + } + + ### When + actual = scan_artifacts(names, resolve=resolve, submit=submit, force=False) + + ### Then + machine = actual.to_machine() + assert machine["submitted"] == names, "submitted must list both names in order" + markdown = actual.to_markdown() + assert "skill-a" in markdown and "skill-b" in markdown + assert len(captured) == 1, "must batch into one submit call" + assert captured[0][0] == ["/tmp/skill-a", "/tmp/skill-b"] + assert captured[0][1] is False + + +def test_given_force_flag_when_scan_then_new_run_ids() -> None: + """ + Scenario: --force / force=True yields non-empty scan_run_ids over a cached result. + Slice: 29 — --force works + + Given a force-sensitive submit double, + When scan_artifacts runs with force=True, + Then machine force is true and scan_run_ids is non-empty. + """ + from guard.scan import scan_artifacts + + ### Given + def resolve(name: str): + return _resolved(name) + + ### When + actual = scan_artifacts( + ["fresh-skill"], + resolve=resolve, + submit=_force_sensitive_submit, + force=True, + ) + + ### Then + machine = actual.to_machine() + assert machine["force"] is True + assert machine["scan_run_ids"], "force must produce new scan_run_ids" + + +def test_given_bare_force_token_when_scan_then_same_as_force_flag() -> None: + """ + Scenario: Bare force token has the same effect as --force. + Slice: 29 — bare force works + + Given tokens ['skill-a', 'force'], + When parse_scan_args then scan_artifacts with the force-sensitive submit, + Then force is true, submitted is only skill-a, and scan_run_ids is non-empty. + """ + from guard.scan import parse_scan_args, scan_artifacts + + ### Given + tokens = ["skill-a", "force"] + + def resolve(name: str): + return _resolved(name) + + ### When + names, force = parse_scan_args(tokens) + actual = scan_artifacts( + names, + resolve=resolve, + submit=_force_sensitive_submit, + force=force, + ) + + ### Then + machine = actual.to_machine() + assert force is True + assert machine["force"] is True + assert machine["submitted"] == ["skill-a"] + assert machine["scan_run_ids"], "bare force must produce new scan_run_ids" + + +def test_given_successful_submit_when_scan_then_machine_echoes_ids() -> None: + """ + Scenario: Confirmation echoes slice-26 scan stdout identifiers. + Slice: 29 — identifiers returned + + Given a successful submit, + When scan_artifacts responds, + Then machine includes batch_id, scan_run_ids, failed_targets as API echo keys. + """ + from guard.scan import scan_artifacts + + ### Given + def resolve(name: str): + return _resolved(name) + + def submit(paths: list[str], *, force: bool) -> dict[str, Any]: + del paths, force + return { + "batch_id": "11111111-1111-1111-1111-111111111111", + "scan_run_ids": ["22222222-2222-2222-2222-222222222222"], + "failed_targets": [], + } + + ### When + actual = scan_artifacts(["skill-a"], resolve=resolve, submit=submit) + + ### Then + machine = actual.to_machine() + assert isinstance(machine["batch_id"], str) and machine["batch_id"] + assert isinstance(machine["scan_run_ids"], list) and machine["scan_run_ids"] + assert isinstance(machine["failed_targets"], list) + api_keys = API_ECHO_KEYS & machine.keys() + # skill-composed keys may exist; API echo set must be exactly the three + assert api_keys == API_ECHO_KEYS + for key in API_ECHO_KEYS: + assert key in machine + + +def test_given_force_tokens_when_parsed_then_absent_from_names() -> None: + """ + Scenario: Force tokens are stripped from the artifact name list. + Slice: 29 — force tokens not names + + Given tokens including --force and bare force among real names, + When parse_scan_args runs, + Then names are only the real artifacts and force is true. + """ + from guard.scan import parse_scan_args + + ### Given + tokens = ["skill-a", "--force", "skill-b", "force"] + + ### When + names, force = parse_scan_args(tokens) + + ### Then + assert names == ["skill-a", "skill-b"] + assert force is True + assert "force" not in names + assert "--force" not in names + + +def test_given_repo_when_looking_for_tw_scan_then_skill_shipped() -> None: + """ + Scenario: /tw-scan Claude skill is present at the expected layout. + Slice: 29 — skill layout + + Given the repo checkout, + When an operator reads .claude/skills/tw-scan/SKILL.md, + Then the file exists and mentions scan_artifacts, batch_id, scan_run_ids, + and frontline-output-contract. + """ + ### Given / When + assert TW_SCAN_SKILL.is_file(), f"missing skill at {TW_SCAN_SKILL}" + text = TW_SCAN_SKILL.read_text(encoding="utf-8") + + ### Then + assert "guard.scan.scan_artifacts" in text + assert "batch_id" in text + assert "scan_run_ids" in text + assert "frontline-output-contract" in text + + +def test_given_mixed_resolve_when_scan_then_not_found_skipped() -> None: + """ + Scenario: Unresolved names are reported and excluded from submit. + Slice: 29 — not-found skip + + Given one resolvable name and one unresolved name, + When scan_artifacts runs, + Then submit receives only the resolved path, submitted lists only that name, + and markdown mentions the unresolved name as not found. + """ + from guard.scan import NOT_FOUND_NOTE, scan_artifacts + + ### Given + captured: list[list[str]] = [] + + def resolve(name: str): + if name == "missing-skill": + return None + return _resolved(name) + + def submit(paths: list[str], *, force: bool) -> dict[str, Any]: + del force + captured.append(list(paths)) + return { + "batch_id": "batch-partial", + "scan_run_ids": ["run-1"], + "failed_targets": [], + } + + ### When + actual = scan_artifacts( + ["skill-a", "missing-skill"], + resolve=resolve, + submit=submit, + ) + + ### Then + machine = actual.to_machine() + assert machine["submitted"] == ["skill-a"] + assert captured == [["/tmp/skill-a"]] + markdown = actual.to_markdown() + assert "missing-skill" in markdown + assert NOT_FOUND_NOTE in markdown