Conversation
Adopts the template's development/ tree: the agent-facing docs, ADRs and per-feature work units move out of docs/ and work/, leaving docs/ for user documentation (docs/api.md) as the new convention reserves it. The move is done as a pre-step so _skip_if_exists protects the project-authored docs; a bare `copier update` deletes them and substitutes the template scaffolds, because _skip_if_exists does not cover the delete side of a rename. Harness changes absorbed from v0.6.0: - report.md as a fifth work-unit artifact, owned by the Developer and audited by the Reviewer for honesty. - DECISION-PENDING: escalation marker plus the decision register in development/adr/README.md, seeded with the two decisions already granted (the ADR 0003 GPU-suite waiver and the coverage thresholds). - development/glossary.md, the document-liveness table, and the architecture > spec > plan > tasks authority order. - Role playbook skills (product-owner, architect, developer, reviewer) over a shared design-principles core. - PreToolUse hook fails closed when jq cannot parse the tool input. - .github/PULL_REQUEST_TEMPLATE.md with the definition-of-done checklist. AGENTS.md keeps devmm's stricter rule that a required runtime dependency needs an ADR, rather than the template's softer ADR-bar wording: the empty required-dependency set is a design invariant tests/test_packaging.py enforces. Gate: make verify green — 900 passed, 75 skipped (GPU suites, off hardware). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… contract Review of #3 found the hardened PreToolUse guard bricks a session on any host without jq: it fails closed, jq is not installed by the bootstrap, and the denial covers the `apt-get install jq` that would fix it. Verified — with jq off PATH, `make verify` returns exit 2. The pre-update wiring failed *open* instead, allowing destructive commands through unchecked, so neither form was right. Keep fail-closed and make it self-explanatory: - The guard checks for jq up front and names it as the reason, with the install command; the other two deny paths explain themselves too. - block-destructive.sh reports which deny-list pattern matched, instead of a bare exit 2 that reads as an unexplained refusal and invites a reword-retry loop. - ensure-toolchain.sh warns at SessionStart when jq is absent, so the problem surfaces before the first Bash call rather than as a mystery denial. Warning only — it must not abort the uv bootstrap. - tool-bootstrap.md states jq is required, not merely standard. The decision-register contract was self-defeating: it keyed on the literal `DECISION-PENDING:` text, which this PR itself adds 15 times as documentation, so `/verify` would have raised a dozen fabricated MAJOR defects while a real escalation hid among the quotes. A marker is now defined by location — its own line inside a development/work/*/report.md — with the scan command to match. Also: register rows sourced from an ADR or a human grant are marker-less by construction and no longer read as out-of-scope (both seeded rows are of that kind); the Developer is named owner of the paired row, the only role that can write it; and a PR with no work unit has the spec/plan/register axes marked n/a rather than failed. Remaining: gate-output rules now point at development/testing.md, which is authoritative and records the sanctioned DEVMM_GPU skips; the Developer gets the Architect's scratch.md clobber guard; development/README.md drops a scaffold-marker section describing artifacts this repo no longer has and no longer claims development/ is unpublished (the sdist ships it, the wheel does not); README.md uses absolute links, as it is also the PyPI project page. Gate: make verify green — 900 passed, 75 skipped, unchanged. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Tracks the template's post-v0.6.0 simplification wave (`v0.6.0-3-g1802347`, untagged — a release is imminent, so re-running once it lands should be a near-no-op). Three upstream commits: two are the issues filed from the #3 review (grAItools/harness-copier-template#28 bounds plan.md's Review checklist to add-only; #29 binds and services every hand-back loop), plus the breaking simplification wave itself. Built on the v0.6.0 branch rather than main: the template still uses `development/`, so that migration — the tree move, the reference rewrite, the link-depth fixes — remains correct, and `_skip_if_exists` only protects the project-authored docs because those files now exist. Re-doing it from main would re-trigger the content deletion that migration exists to avoid. Absorbed from upstream: - Eleven copier answers dropped (license, mode, project_slug, pr_merge_strategy, the include_example_* gates, cursor, mcp, copilot_code_review_skill); generate_scripts is now derived from verify_command. 13 questions remain. - The four role playbook skills are folded into their subagent files; `design-principles` is the only remaining skill. The `verify` skill is gone — its capability is not replaced, accepted deliberately. - One hand-back convention, `HANDBACK(<spike|explore|replan>):` with flat per-kind caps that each role states in its own reply, so the bound survives description-match invocation. - harness-usage.md loses its restatement sections (308 -> 187 lines) and is now the single home of the liveness table; the glossary states its promotion rule once. Kept as deliberate divergence, because upstream has not fixed these: - The PreToolUse jq guard and its refusal messages. settings.json.jinja is unchanged upstream, so the session-bricking deadlock from the #3 review is still live there; verified again here that a jq-less PATH denies `make verify` with a reason rather than silently. - The register-row owner, the marker-less-row exemption, the no-work-unit review axes, and the location-scoped `DECISION-PENDING:` definition. ADR 0012 states the register contract was left untouched. - development/README.md's publication accuracy (the sdist ships this tree, the wheel does not) and its scaffold-marker state. - AGENTS.md's BSD-3-Clause line and squash-merge commit guidance, both lost to the deleted questions; and devmm's stricter required-dependency rule. Upstream's developer.md Handoff supersedes the scratch.md clobber guard added here in e245a65 — it carries the same guard plus the serviced explore hand-back — so that patch is dropped in favour of theirs. Gate: make verify green — 900 passed, 75 skipped, unchanged. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…l jq patch Updates to template `v0.6.0-4-gfe487cc`, which fixes the jq deadlock reported from the #3 review (grAItools/harness-copier-template#31, ADR 0013). Upstream took the direction this repo argued for — remove the hard dependency rather than document it — so the local patch is retired in favour of theirs. `.claude/settings.json`, `.agents/hooks/block-destructive.sh` and `.agents/hooks/ensure-toolchain.sh` are now byte-identical to a pristine upstream render; the local jq guard and the SessionStart warning added here in e245a65 are gone, superseded by: - `.agents/hooks/hook-input.sh`, a canonical payload reader that tries `jq` then `python3`, with distinct exit codes (3 no parser, 4 unparseable) so each hook picks its own posture. - Per-hook postures: PreToolUse fails closed naming the real cause, Stop and PostToolUse fail open. Their skips exit 1, not 0 — Claude Code surfaces non-zero stderr, while exit-0 stderr is transcript-only. The local patch got that wrong. - `block-destructive.sh` naming the deny-list on POSIX `grep -qE`. The local version extracted the matched pattern with `grep -o`, which upstream rejected for good reason: `-o` is non-POSIX and GNU grep suppresses its stdout for binary-classified input, so the extraction-as-decision would fail open. Verified across parser scenarios: `python3`-only now allows a benign command where it previously denied every Bash call; no parser at all still denies, with the install remedy; a destructive command is still denied under either backend; and the Stop loop guard fires again under `python3`. `development/tool-bootstrap.md` is `_skip_if_exists`, so upstream's new required-tools bullet does not arrive on update — added by hand, per their upgrade notes, and the stale "jq is required, not optional" wording this repo had is corrected to name the `python3` fallback. Unchanged local divergences, all in territory ADR 0012 states was deliberately left untouched: the register-row owner, the marker-less-row exemption, the no-work-unit review axes, the location-scoped `DECISION-PENDING:` definition, the gate-output pointers to development/testing.md, and the architect's Write-to-create exception for scratch.md. Gate: make verify green — 900 passed, 75 skipped, unchanged. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The simplification wave and the hook-payload fix are now released as v0.7.0, which is the same commit (fe487cc) this branch already tracked — so this records the tag in place of the pseudo-version and changes nothing else. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
egparedes
left a comment
There was a problem hiding this comment.
Review: approving — ready to merge, no blocking findings
Verdict: ready to merge. Three MINOR findings below, all documentation/contract accuracy; none blocks the merge. (Posted as COMMENT because GitHub does not accept APPROVE on your own PR.)
Scope reviewed: 4a2dab3f against origin/main — 6 commits, 89 files, +1230/-492. I re-derived the claims in the description rather than taking them on trust.
Verified independently
- Gate green. Exported the head tree and ran
make verify: exit 0, format/lint/mypy --strictclean, 900 passed, 75 skipped in 9.65s. The 75 are exactly theDEVMM_GPUsuites (36 CUDA + 6 integrations + 33 ROCm), unchanged from1beb326and sanctioned by ADR 0003. Nothing weakened — thetests/diff is prose-only path references, with no marker, tolerance or assertion touched. - Links. Every relative Markdown link in every tracked
.mdat the head commit resolves: 0 broken, checked tree-wide. - No stale paths. No surviving reference to
docs/{architecture,style,testing,tool-bootstrap,harness-usage,adr}or to a top-levelwork/<unit>; the three deleted.agents/*/README.mdfiles have no remaining referrers; removing theverifyskill leaves no dangling mention. - Hook wiring, exercised rather than reasoned about. Ran
hook-input.shunder jq 1.6 and again under aPATHcontaining onlypython3, across: string, boolean true, boolean false, absent key, explicit null, path through a non-object, top-level array, unparseable payload, empty payload. Value and exit code agree on every scalar case, and both failure cases exit 4 under both backends. The wiring posture is right: the PreToolUse Bash guard denies on every non-zero reader exit (fails closed), while PostToolUse-fmt and Stop exit 1 (fail open, stderr surfaced). The old failure mode is not hypothetical — a command issued during this review that merely quoted a deny-listed pattern was refused with literallyNo stderr output, which is exactly what the new message fixes. - sdist claim.
development/README.mdasserts the tree travels in the sdist; I built one — 53development/entries indevmm-0.1.0.tar.gz, wheel unaffected (packages = ["src/devmm"]). - Internal consistency. The hand-back caps in
AGENTS.mdmatch the ones each role restates inarchitect.md,developer.mdandproduct-owner.md; the OpenCode deny globs still mirror all fourblock-destructive.shpatterns.
Findings (MINOR, non-blocking)
.agents/hooks/hook-input.sh— the documented backend-parity contract does not hold for objects, arrays or numbers. Inline below.development/glossary.md— ships empty while this PR wires it up as the vocabulary authority, with no route in for terms the code already uses. Inline below..agents/README.md(not in this diff, so no inline anchor) — the Caveats section still lists.agents/hooks/*among the net-new files that are safe fromcopier update. This PR makesblock-destructive.shandensure-toolchain.shbyte-identical to a pristine v0.7.0 render and addshook-input.shfrom the template, and91d9e4bshowscopier updaterewritingensure-toolchain.shon its own. Hook scripts are template-owned now, so local edits there will be reverted — the opposite of what the caveat tells a contributor. Move.agents/hooks/*into the template-owned sentence above it.
Notes
- Doing the tree move as a separate first commit (
ae0c46d) so_skip_if_existscan see the project-authored docs is the right call, and the preserved line counts back it up. Same for retiring the local hook patches in favour of upstream: thegrep -oreasoning in particular is correct, and failing open on binary-classified input is precisely the case a deny-list exists for. - Losing the
verifyskill is a real capability regression, but it is declared,/verifyandmake verifyboth survive, andCLAUDE.mdwas updated to stop advertising it. Fine as an accepted cost. - The description says
AGENTS.mdis 127 lines; it is 130. Immaterial.
Addresses the two actionable findings from the #4 review. Glossary: it shipped empty while the same PR made it an authority — AGENTS.md lists it under "Where things live" and the Reviewer is told to flag names drifting from it, both pointing at a file with no terms. Worse, there was no route in: entries may only be promoted from a reviewed spec's Glossary section, and none of the 13 shipped work units has one, so the vocabulary already load-bearing in the code could never enter under the stated rule. Seeded from devmm-design.md §3 as an explicit one-time baseline, with the promotion rule left governing everything after it. All 22 code identifiers cited were checked against the package: 21 are in the public API, the rest resolves in src/. This also makes development/README.md's claim that every document in its table carries real devmm content true. .agents/README.md was 63 lines stale — missing the entire Layout section the simplification wave merged in from the four deleted per-directory READMEs. Root cause: copier's `_skip_if_exists` lists a bare `README.md`, which matches every README at any depth rather than only the root one, so this file has not been updated since the initial scaffold. The same glob silently skipped development/README.md during the v0.7.0 update, which is why that one had to be copied from a fresh render by hand. Refreshed from the v0.7.0 render, and corrected the Caveats bullet the review flagged: it listed `.agents/hooks/*` among files safe from `copier update`, while this PR makes block-destructive.sh and ensure-toolchain.sh byte-identical to the template render and takes hook-input.sh from it. Hook scripts are template-owned; local edits there get reverted. Not fixed here: the hook-input.sh backend-parity gap (objects, arrays and non-integral numbers differ between jq and python3). Confirmed but out of scope for a PR whose thesis is retiring local hook patches — reported upstream. Gate: make verify green — 900 passed, 75 skipped, unchanged. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
All three findings handled — pushed F2 (glossary) — fixed here. Seeded with 12 terms from F1 ( F3 ( Chasing it turned up why that file could carry a stale claim at all: our copy was 63 lines behind and had never been updated since the initial scaffold. It was byte-identical to the v0.5.0 render, missing the entire The cause is So this PR now refreshes Gate green at Two notes on your review, both accepted: |
… claims Addresses the locally-owned findings from the xhigh review of #4. The hook-script findings are upstream's and are reported there instead. The decision-register scan command shipped without a revision range, so `git diff` compared the worktree to the index and found nothing on any committed branch. Verified: a `DECISION-PENDING:` line committed to a report was missed by the documented command and caught by the range form. The check that was meant to make an unregistered escalation an automatic MAJOR silently passed instead — and since the report freezes at merge and the register alone records the outcome, the decision would have been lost with nothing marked pending. Register-row ownership was assigned twice, to different actors: `/build` step 5 has the caller add the row while servicing the hand-back, and the local text added in e245a65 claimed the Developer was the only role that could. Upstream's version is right and now covers what that patch was written for, so the local claim is removed rather than reconciled — which also retires its "sole sanctioned exception to leaving development/ alone", contradicted two lines later by the Developer's duty to draft ADRs under development/adr/. Also corrected, all claims this branch made false: - The no-work-unit review carve-out exempted the spec/plan axes and both register contracts but not the scope check, so a repo-layout PR touching every work unit — this one — tripped an automatic MAJOR nothing could clear. - The CHANGELOG said the pre-fix guard denied every Bash call. It failed *open*: the pipeline's status was the matcher's, so commands passed unchecked. The fail-closed behaviour was a v0.6.0 intermediate that never reached main. - harness-usage.md said a non-zero Stop hook blocks the stop. Its skip paths exit 1, which is non-blocking, so a host with no JSON parser can end a session with the gate unrun. - The subagent table presented the reviewer's read-only bash as enforced. The `permission:` map that would enforce it is OpenCode-only — Claude Code honours `tools:` alone, which grants unrestricted Bash. - Register IDs used `2026-07-p12`, while the legend says `<feature-slug>` and the slug is `2026-07-p12-conformance-docs-release`. Gate: make verify green — 900 passed, 75 skipped, unchanged. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
| exit 4 | ||
| fi | ||
|
|
||
| if command -v jq >/dev/null 2>&1; then |
There was a problem hiding this comment.
[CONFIRMED] correctness — ⏭️ Not fixed here
jq is selected by command -v alone and any jq failure exits 4 instead of falling through to the python3 fallback — the exact "probe by running, not command -v" rule the file's own header (lines 15-18) states, applied only to python3. [same root cause also at: .agents/hooks/hook-input.sh:32, .agents/hooks/hook-input.sh:32, /home/enriqueg/.ao/data/worktrees/devmm/devmm-4/.agents/hooks/hook-input.sh:37, .claude/settings.json:31]
Failure scenario
On a host where jq resolves but fails when executed (an asdf/mise shim with no version set, a half-removed package, a wrapper), command -v jq succeeds, the out=$(... | jq ...) substitution returns non-zero, and hook-input.sh exits 4 without ever trying python3. The PreToolUse guard in .claude/settings.json maps rc!=0 to exit 2, so EVERY Bash tool call is denied with "could not read the tool input (hook-input.sh exit 4; payload unreadable ...)" — the agent cannot run a single command for the whole session, and the message blames the payload rather than jq. Reproduced: with a stub jq that exits 126 and a fully working python3 on PATH, the benign command ls -la was denied (hook exit 2); the same payload parses fine when jq is simply absent.
Upstream-owned (v0.7.0 render, byte-identical). Routing to the template rather than re-diverging — this PR's thesis is retiring local hook patches, and the human reviewer endorsed that split. Adding to grAItools/harness-copier-template#34.
| { | ||
| "type": "command", | ||
| "command": "INPUT=$(cat); [ \"$(printf \u0027%s\u0027 \"$INPUT\" | jq -r \u0027.stop_hook_active // false\u0027)\" = \u0027true\u0027 ] \u0026\u0026 exit 0; export PATH=\"$HOME/.local/bin:$PATH\"; command -v uv \u003e/dev/null 2\u003e\u00261 || { echo \u0027verify skipped: uv unavailable (run .agents/hooks/ensure-toolchain.sh; see docs/tool-bootstrap.md)\u0027 \u003e\u00262; exit 0; }; make verify || exit 2" | ||
| "command": "export PATH=\"$HOME/.local/bin:$PATH\"; flag=$(sh \"${CLAUDE_PROJECT_DIR:-.}/.agents/hooks/hook-input.sh\" .stop_hook_active); rc=$?; [ \"$rc\" -eq 3 ] \u0026\u0026 { echo \u0027verify skipped: no JSON parser on PATH (install jq: apt-get install jq / brew install jq)\u0027 \u003e\u00262; exit 1; }; [ \"$rc\" -ne 0 ] \u0026\u0026 { echo \"verify skipped: could not read the hook input (hook-input.sh exit $rc)\" \u003e\u00262; exit 1; }; [ \"$flag\" = \u0027true\u0027 ] \u0026\u0026 exit 0; command -v uv \u003e/dev/null 2\u003e\u00261 || { echo \u0027verify skipped: uv unavailable (run .agents/hooks/ensure-toolchain.sh; see development/tool-bootstrap.md)\u0027 \u003e\u00262; exit 1; }; make verify || exit 2" |
There was a problem hiding this comment.
[CONFIRMED] correctness — ⏭️ Not fixed here
The rewritten Stop hook exits 1 (non-blocking) whenever hook-input.sh cannot be read or run, so make verify never executes — the old hook ran the gate regardless of payload-parse trouble. [same root cause also at: .claude/settings.json:63, .claude/settings.json:63, /home/enriqueg/.ao/data/worktrees/devmm/devmm-4/.claude/settings.json:63, .claude/settings.json:63]
Failure scenario
On a host with neither jq nor a working python3 (the exact macOS-stub case hook-input.sh's own comment calls out), or when CLAUDE_PROJECT_DIR is unset and the agent's cwd is a subdirectory so ./.agents/hooks/hook-input.sh does not resolve, hook-input.sh returns 3 or 127. The hook prints verify skipped: ... and exits 1, which is non-blocking, so the session ends with the agent claiming "done" and make verify never run — unformatted, lint-failing, type-failing or test-failing code is committed and only caught in CI. The previous command had no such dependency: INPUT=$(cat); [ "$(... jq -r '.stop_hook_active // false')" = 'true' ] && exit 0; ... make verify || exit 2 — with jq missing the substitution was empty, the guard fell through, and the gate still ran (and a broken make invocation exited 2, blocking the stop loudly). The sibling PreToolUse hook in the same diff deliberately fails closed (exit 2) for the identical condition; Stop fails open.
Upstream-owned, and a deliberate ADR 0013 choice (fail open on Stop to avoid the loop). The finding is still a fair challenge to it — the old hook ran the gate anyway — so it goes upstream as evidence against that trade, not as a local patch.
| # | ||
| # See .agents/README.md for the single-source-of-truth rationale. | ||
| grep -qE 'rm -rf|push --force|reset --hard|DROP TABLE' && exit 2 || exit 0 | ||
| if grep -qE 'rm -rf|push --force|reset --hard|DROP TABLE'; then |
There was a problem hiding this comment.
[CONFIRMED] correctness — ⏭️ Not fixed here
The deny regex matches anywhere in the command string, including inside quoted arguments and search patterns, and the newly added message asserts the command "matches the destructive deny-list".
Failure scenario
Read-only commands that merely mention a deny-list token are refused: grep -rn 'rm -rf' ., git log --grep='reset --hard', or any command whose arguments quote one of the four patterns. The agent is blocked from grepping or testing its own deny-list, and the new stderr text tells it the command is destructive rather than that a substring matched, so it retries the same class of command. Reproduced twice in this session against the live hook: a grep -rn -E 'rm -rf|push --force|...' repository search and a printf of a JSON test fixture were both denied with "block-destructive: denied - the command matches the destructive deny-list".
Upstream-owned and pre-existing: the regex was substring-matching long before this PR; only the message is new. Hit twice in this session, so it is real. Reporting upstream.
| whole tree: | ||
|
|
||
| ```sh | ||
| git diff --unified=0 "$(git merge-base origin/main HEAD)"...HEAD \ |
There was a problem hiding this comment.
[CONFIRMED] correctness — ✅ Fixed in 17232e3
The decision-register scan command git diff --unified=0 -- 'development/work/*/report.md' has no revision range, so it inspects only unstaged working-tree changes and always finds nothing on a committed branch.
Failure scenario
The Reviewer follows this file's contract ("a marker added in the diff without a row here is a defect") and runs the documented pipeline against the feature branch after the Developer has committed. git diff with no range compares the worktree to the index, which is clean, so the grep exits 1 with no output and the check silently passes. A PR that added a DECISION-PENDING: line to report.md with no register row gets a GO instead of the automatic MAJOR that reviewer.md:180 requires. After merge report.md freezes and, per this same file, "this register alone records the outcome" — so the escalated decision is permanently lost with nothing marked pending. Verified: on the current clean tree the documented command produces no output (grep exit 1), while the range form git diff origin/main...HEAD -- ... is the one that would actually inspect the change.
| # Usage: hook-input.sh <dot.path> (stdin: the hook's JSON payload) | ||
| # e.g. hook-input.sh .tool_input.command | ||
| # | ||
| # Prints the field's value on stdout, identically under either backend: |
There was a problem hiding this comment.
[CONFIRMED] correctness — ⏭️ Not fixed here
The documented contract "Prints the field's value ... identically under either backend" is false: jq and python3 emit different text for objects, arrays and numbers. [same root cause also at: .agents/hooks/hook-input.sh:7, /home/enriqueg/.ao/data/worktrees/devmm/devmm-4/.agents/hooks/hook-input.sh:33, /home/enriqueg/.ao/data/worktrees/devmm/devmm-4/.agents/hooks/hook-input.sh:41, /home/enriqueg/.ao/data/worktrees/devmm/devmm-4/.agents/hooks/hook-input.sh:36, .agents/hooks/hook-input.sh:33]
Failure scenario
Verified on this machine (jq 1.6 vs python3): {"a":{"b":1,"c":[1,2]}} with path .a yields jq's pretty-printed multi-line {\n "b": 1,\n "c": [\n 1,\n 2\n ]\n} but python's compact {"b": 1, "c": [1, 2]}; {"a":[1,2]} yields multi-line vs [1, 2]; {"a":1.0} yields 1 vs 1.0; and {"a":12345678901234567890} yields 12345678901234567000 (jq 1.6 coerces through a C double) vs the exact integer. Any future hook that reads a non-string field — e.g. a numeric id or a .tool_response object — silently gets different values on a jq host than on a python-only host, and the multi-line jq form breaks the v=$(...)/[ "$v" = ... ] single-line comparison idiom every caller in .claude/settings.json uses.
Already filed as grAItools/harness-copier-template#34 before this review ran.
|
|
||
| - Claude Code hooks no longer depend on `jq` alone: they read their payloads via | ||
| `.agents/hooks/hook-input.sh` (`jq`, then `python3`) and branch on its exit | ||
| code. Previously, on a host without `jq`, the destructive-command guard failed |
There was a problem hiding this comment.
[CONFIRMED] correctness — ✅ Fixed in 17232e3
The Fixed entry inverts the pre-fix behaviour of the destructive-command guard: without jq the old hook failed OPEN (allowed everything), not "denied every Bash call", and line 43's "still fails closed" asserts a property the old hook never had. [same root cause also at: CHANGELOG.md:40]
Failure scenario
A maintainer auditing whether jq-less hosts or CI images were protected before this change reads "Previously a host without jq had every Bash call denied" and "the Bash guard still fails closed", concludes the deny-list was enforced on those hosts, and skips any audit or remediation. In reality the old PreToolUse body (jq -r '.tool_input.command // empty' | sh block-destructive.sh) fed empty stdin to grep when jq was missing, so the pipeline exited 0 and rm -rf, push --force, reset --hard and DROP TABLE all ran unblocked. Verified by replaying the origin/main hook body with no JSON parser on PATH against the payload git push --force origin main: exit 0 (ALLOWED); the new body denies the same payload with exit 2.
| @@ -94,20 +81,23 @@ Claude Code only). You cannot prompt around the hooks: | |||
| edited file after every write. | |||
There was a problem hiding this comment.
[CONFIRMED] correctness — ✅ Fixed in 17232e3
The harness guide states the Stop hook's "non-zero blocks the stop", but the Stop hook it documents deliberately uses exit 1 for its skip paths, which is non-blocking in Claude Code. [same root cause also at: development/harness-usage.md:69]
Failure scenario
An agent or contributor reads harness-usage.md:78-79 and concludes that a session which stopped must have had a green gate. In fact .claude/settings.json:63 exits 1 (not 2) when uv is missing or the payload is unreadable, so the session stops normally with make verify never executed. Work is handed off as "done, gate green" on a tree that was never linted, type-checked, or tested, and only a stderr line no one reads records the skip.
| honours what the plan explicitly called for. | ||
| - **Scope check first.** Run `git diff --stat` against the integration | ||
| branch. Every touched file must be plausibly required by the plan. | ||
| Touching another feature's `development/work/` directory, a merged feature's |
There was a problem hiding this comment.
[CONFIRMED] correctness — ✅ Fixed in 17232e3
The new "change with no work unit" carve-out exempts the spec/plan axes and both register contracts but not the scope check, so a repo-layout PR that moves other features' development/work/ directories trips an automatic MAJOR that nothing can clear.
Failure scenario
Running /verify on this very PR: reviewer.md:100-104 says "Touching another feature's development/work/ directory, a merged feature's report.md ... is an automatic MAJOR defect unless the plan explicitly called for it." This diff moves all 13 work/2026-07-p* directories (including merged reports) into development/work/, and reviewer.md:113-117 declares only the conformance axes and register contracts n/a for a work-unit-less change - the scope rule still applies, and with no plan.md there is no way for a plan to have "explicitly called for it." The Reviewer returns NEEDS-WORK with a MAJOR the Developer cannot fix, and the /build -> /verify loop can never reach GO for any layout refactor.
| hand-backs on the same phase, the phase is scoped too wide — stop | ||
| and put it to the user. | ||
| - `DECISION-PENDING:` in `report.md` — put the question to the user, | ||
| add the register row (`development/adr/README.md`), re-invoke the |
There was a problem hiding this comment.
[CONFIRMED] correctness — ✅ Fixed in 17232e3
Ownership of the decision-register row is assigned to two different actors: /build step 5 tells the main agent to add it, while developer.md:83-90 and development/adr/README.md:75 say the row is the Developer's alone to append.
Failure scenario
Developer hits a decision beyond its authority, writes DECISION-PENDING: in report.md plus its register row in development/adr/README.md, and stops. The main agent, following build.md:40-42, appends a second row for the same decision before re-invoking. The register now carries two rows with different IDs for one escalation, so the documented "scan the table for pending rows" procedure double-counts open decisions. In the mirror case each side assumes the other owns the row, no row is written, and the Reviewer's rule (reviewer.md:180-181) makes the missing row an automatic MAJOR -> NEEDS-WORK on a build that was actually correct.
| the marker and its row land in the same change. | ||
| - Work **one phase at a time**. Do not begin phase N+1 until phase N's | ||
| tests pass and its `tasks.md` boxes are ticked. | ||
| - Write the test **first** when the plan calls for behaviour change — |
There was a problem hiding this comment.
[CONFIRMED] correctness — ✅ Fixed in 17232e3
The Developer is told appending one register row is "the sole sanctioned exception to leaving development/ alone", but line 103 of the same file and architect.md:128 both require the Developer to author ADR files under development/adr/.
Failure scenario
A phase needs a test skipped. developer.md:102-104 says "draft an ADR under development/adr/ and ask before proceeding", and architect.md:126-129 hands ADR authoring to "the human or the Developer", but the Constraints block the Developer reads first declares the register row the only permitted write under development/. The Developer either refuses to draft the ADR and stalls the phase, or silently skips the test without the ADR that /verify (verify.md:31-33) expects - and the Reviewer then flags the undeclared skip as a defect. Same collision applies to the Architect's ADR needed: markers, which have no writer that both files agree on.
| | `architect` | yes | no | author `plan.md` + `tasks.md`; stop before code | | ||
| | `developer` | yes | yes | implement phase-by-phase, verify, tick tasks | | ||
| | `reviewer` | no | read-only + verify/test/lint | GO / NEEDS-WORK verdict, file:line defects | | ||
| | `reviewer` | no | read-only + verify/test/lint\* | GO / NEEDS-WORK verdict, file:line defects | |
There was a problem hiding this comment.
[CONFIRMED] correctness — ✅ Fixed in 17232e3
The subagent capability table advertises the reviewer as "read-only + verify/test/lint" bash, but .agents/README.md states in this same diff that the permission: map that enforces that is OpenCode-only and ignored by Claude Code.
Failure scenario
Under Claude Code the reviewer's frontmatter tools: Read, Grep, Glob, Bash grants unrestricted Bash - the bash: allow-map in reviewer.md:14-30 is ignored (per the new .agents/README.md Layout section: "OpenCode only - Claude Code ignores this field"), and .claude/settings.json denies only three git/rm patterns. A reviewer subagent run in an unattended Claude Code session can therefore execute sed -i, git checkout, or uv run against the tree it is meant to judge independently, despite reviewer.md:129-130 ("Never edit files ... Never auto-fix defects yourself") and the table row claiming otherwise. The same gap applies to the explorer row on line 50, which the /build flow invokes routinely.
|
.agents/skills/verify/SKILL.md (file deleted — no inline anchor) [PLAUSIBLE] correctness — ⏭️ Not fixed here Deleting the Failure scenarioThe skill fired on "verify", "is this ready", "ready to commit", "check this", or after any non-trivial edit. Its trigger phrases were not folded into Intended: removing the |
| step 5, which puts the question to the user and appends the row in the same | ||
| change as the marker. The Developer writes the marker and stops; it does not | ||
| append the row itself. Rows whose Source is an ADR or a direct human grant have | ||
| no marker by construction, and are equally valid. |
There was a problem hiding this comment.
[CONFIRMED] correctness — ✅ Fixed in 17232e3
The register's ID legend says ID = <feature-slug>.<k> with the example 2026-07-user-auth.1, but both seeded rows use 2026-07-p12.N while the actual work-unit slug is 2026-07-p12-conformance-docs-release, so the stated convention and the shipped data disagree.
Failure scenario
The Developer appends the next row for that same work unit. Following the legend it writes 2026-07-p12-conformance-docs-release.1, which collides in meaning with the existing 2026-07-p12.1/.2 while sorting and reading as a different feature; following the existing rows it writes 2026-07-p12.3, which contradicts the legend the Reviewer checks against. Either way the register's IDs stop being a reliable key back to a work-unit directory, which is the whole point of the <feature-slug> prefix.
| { | ||
| "type": "command", | ||
| "command": "f=$(jq -r '.tool_input.file_path // empty'); S=\"${CLAUDE_PROJECT_DIR:-.}/scripts/fmt-file.sh\"; [ -n \"$f\" ] && [ -x \"$S\" ] && \"$S\" \"$f\" || true" | ||
| "command": "f=$(sh \"${CLAUDE_PROJECT_DIR:-.}/.agents/hooks/hook-input.sh\" .tool_input.file_path); rc=$?; [ \"$rc\" -eq 3 ] && { echo 'fmt skipped: no JSON parser on PATH (install jq: apt-get install jq / brew install jq)' >&2; exit 1; }; [ \"$rc\" -ne 0 ] && { echo \"fmt skipped: could not read the hook input (hook-input.sh exit $rc)\" >&2; exit 1; }; S=\"${CLAUDE_PROJECT_DIR:-.}/scripts/fmt-file.sh\"; [ -n \"$f\" ] && [ -x \"$S\" ] && \"$S\" \"$f\" || true" |
There was a problem hiding this comment.
[CONFIRMED] cleanup — ⏭️ Not fixed here
The same rc=$? / -eq 3 / -ne 0 dispatch block is copy-pasted into three hooks (lines 42, 53, 63), each re-printing an install-jq message hook-input.sh already wrote to stderr. [same root cause also at: .claude/settings.json:31]
Failure scenario
Every failure prints two messages to the operator (hook-input.sh's "no working JSON parser (jq or python3) on PATH; ... Install jq (apt-get install jq / brew install jq)." plus the hook's own near-identical line), and the three copies must be edited in lockstep: adding an exit code to hook-input.sh means editing three escaped-JSON one-liners, so one gets missed and that hook mis-classifies the new code as a generic failure. Simpler: let hook-input.sh own the diagnostics (it already does) and have each hook branch only on rc to pick its exit code, or move the whole dispatch into a second tiny wrapper script the three hooks share.
Upstream-owned. A local dedupe would re-diverge .claude/settings.json, which this PR just made byte-identical to the template.
| - For a new architectural choice (dependency, framework, persistence, auth), | ||
| add an ADR in `docs/adr/`. ADRs are append-only; supersede with a new file. | ||
| - Subagent hand-back loops are bounded: honour the cap the role states in its | ||
| hand-back reply (three spike hand-backs per plan, three explore hand-backs |
There was a problem hiding this comment.
[CONFIRMED] cleanup — ⏭️ Not fixed here
The four hand-back cap numbers are restated verbatim in nine places across six files instead of having one authoritative home. [same root cause also at: .agents/subagents/reviewer.md:171, development/README.md:25]
Failure scenario
The caps ("three spike hand-backs per plan, three explore hand-backs per phase, three replan hand-backs per feature, five question rounds per spec") appear at AGENTS.md:55-56, development/harness-usage.md:59-60, .agents/commands/plan.md:27, .agents/commands/build.md:37 and :44, .agents/commands/spec.md:29, .agents/subagents/architect.md:205, .agents/subagents/developer.md:163 and :173, .agents/subagents/product-owner.md:152. Raising the explore cap to five means nine coordinated edits; miss two and the developer subagent self-stops at three while /build keeps re-invoking it to five, so the loop the caps exist to bound behaves differently depending on which document the acting agent happened to read. This is the DRY rule the diff's own new skill states (.agents/skills/design-principles/SKILL.md: "Every piece of knowledge — code, schema, doc, config — has one authoritative representation").
Mostly upstream text across six files; a local single-source refactor would diverge from the template in five of them for a DRY win that the template is better placed to make.
|
xhigh multi-agent review: 15 findings — 8 fixed in Fixed here (all locally-owned; the harness-instruction ones were introduced by this branch):
Routed upstream — the hook scripts are byte-identical to the v0.7.0 render, and re-diverging works against this PR's thesis (the same split you recommended for the parity finding):
Intended, not fixed: removing the Gate green at |
Picks up the three upstream fixes this adoption prompted, all now closed:
- _skip_if_exists is root-anchored (#33). A bare "README.md" matched at
every depth under gitignore semantics, silently freezing the template's
own .agents/README.md, .claude/rules/README.md and development/README.md
downstream with no conflict reported in either direction.
- The payload reader probes each backend by running it, and the Stop gate
reports instead of skipping (#34, #35). A jq that resolves but cannot run
now falls through to python3; a gate that cannot be blocked still runs and
says so, rather than ending the session unverified.
- The deny-list matches operations outside quotes (#36), so a read-only
command that merely mentions one is allowed. The SQL pattern still matches
anywhere: it has no unquoted form, so its mention and its use are
indistinguishable.
.agents/README.md takes the template's version wholesale — upstream's
rewrite generalises the local caveat this branch carried (hooks are
template-owned) to all of .agents/, and adds the safe-to-edit list.
development/{harness-usage,tool-bootstrap}.md are _skip_if_exists, so copier
leaves them alone and the new behaviour is ported by hand. The Stop-hook
paragraph is rewritten rather than replaced: upstream covers the reader path
but not the uv-unavailable path, which still skips the gate on exit 1.
Every claim added to those two documents was checked against the scripts
themselves — 13 deny-list cases, and the reader's exit codes for a broken
jq, python3-only, no working parser (3) and an empty payload (4).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Re-pinned to template
|
| Upstream fix | Effect here |
|---|---|
_skip_if_exists root-anchored |
.agents/README.md was frozen by a bare README.md matching at every depth; it now updates from the template |
| Reader probes each backend by running it | A jq that resolves but is broken falls through to python3 instead of failing the read |
Stop gate reports instead of skipping |
A gate that cannot be blocked still runs, rather than the session ending unverified |
| Deny-list matches outside quotes | grep -rn '<pattern>' . passes; cd x && <operation> y is still denied |
Conflict resolution
One conflict, in .agents/README.md — upstream rewrote the exact bullet this
branch had patched. Taken wholesale from the template: upstream's version
generalises the local claim (hooks are template-owned) to all of .agents/,
and adds the safe-to-edit list. Nothing local was lost; the file is now
byte-identical to a pristine render.
development/harness-usage.md and development/tool-bootstrap.md are
_skip_if_exists, so copier left them untouched and the new behaviour was
ported by hand. The Stop-hook paragraph was rewritten, not replaced —
upstream's new text covers the reader path but not the uv-unavailable path,
which still skips the gate on exit 1. Both paths are now stated.
Verification
The dry run happened on a throwaway clone first, and every template-owned file
in the end state was compared byte-for-byte against a pristine render of the
pinned commit before the real branch was touched.
Every claim added to the two hand-ported documents was checked against the
scripts rather than read off the upstream diff — 13 deny-list cases (quoted
mention, bare operation, nested-shell runner, pipe into a shell, \rm alias
bypass, and the push-ends-in-sh non-match) and the reader's exit codes for
broken jq, python3-only, no working parser (3) and empty payload (4). All
13 matched; all four exit codes matched.
One note on the new deny rule, from using it: committing this change was
itself denied, because the commit message quotes the SQL pattern and rule 3
matches that anywhere. That is documented behaviour, not a regression — the
message now lives in a file passed to git commit -F.
Gate: 900 passed, 75 skipped — unchanged. CI 9/9 green.
The template's quote-aware guard rewrite fixed the false positives it aimed at, but eight command shapes the previous matcher denied are now allowed. Confirmed by feeding the same text to both versions; the guard only reads stdin, so nothing was executed. Filed upstream as template#40 rather than patched here, keeping .agents/hooks/* byte-identical to the render. What lands instead is the check that was missing: tests/test_harness_deny_list.py pins the verdict for 24 shapes. The guard is template-owned, so copier update rewrites it wholesale and a regression arrives as a clean, conflict-free update with nothing to review. The nine known-bad shapes are xfail(strict=True) against template#40, so an upstream fix fails the gate as an XPASS instead of passing unnoticed. Three project-owned defects fixed: - The decision-register scan substituted "$(git merge-base origin/main HEAD)", which expands to nothing when origin/main does not resolve — a fresh repo, a shallow CI clone, a fork on another integration branch — degenerating the range to HEAD...HEAD and reporting "no escalations" for any diff. The three-dot form resolves the same merge base (verified byte-identical output) and exits 128 on a ref it cannot find. This is the second defect in this one snippet: it is the register contract's only stated check, and both times it failed by looking clean. - harness-usage.md documented one SessionStart hook; this branch added a second, the payload-reader probe whose warning is what precedes every Bash call being denied. - The wheel-vs-sdist invariant lived only in development/README.md, which is template-owned and unprotected by _skip_if_exists. Moved to architecture.md#distribution-boundary, which is protected; the README now points there and says why. Also filed upstream as template#41: the jq probe proves parsing but not query compatibility, so a jq older than 1.5 denies every Bash call while blaming a damaged reader; and the PreToolUse proxy checks the guard is readable, not that it runs, so a truncated guard exits 0 and allows everything. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
| # The operation has to sit inside what the shell will run — after the runner's | ||
| # opening quote, or before the pipe into a shell — not merely somewhere on the | ||
| # same line as one. | ||
| if matches "${runner}[^'\"]*($operations)" || matches "($operations)$piped"; then |
There was a problem hiding this comment.
Rule 2's [^'\"]* halts at a quote nested inside the runner's script, so an operation preceded by a quoted word is allowed.
Reproduced independently against both versions: bash -c 'git fetch && echo "resetting" && git reset --hard origin/main' → allowed here, denied on main. Rule 1 has already classified the outer span as a mention, so nothing catches it.
Skipped here — filed upstream as template#40. This file is template-owned and byte-identical to the pinned render; patching it locally would re-diverge the file this PR exists to align. The shape is xfail(strict=True) in tests/test_harness_deny_list.py, so the upstream fix will announce itself as an XPASS.
| # argument opens. `[^;&|]*` keeps the runner and the quote in one simple command, | ||
| # so `ssh host uptime && grep '<pattern>' .` is not read as handing the pattern | ||
| # to ssh. | ||
| runner="(^|[^[:alnum:]_./-])((/[a-z/]*)?${shells}[[:space:]]+-[[:alnum:]]*c|(ssh|eval|su)[[:space:]])[^;&|]*['\"]" |
There was a problem hiding this comment.
The -…c flag must be adjacent to the shell name, so a long option or option cluster escapes rule 2.
${shells}[[:space:]]+-[[:alnum:]]*c cannot match a second -. Reproduced: bash -euo pipefail -c '…' and bash --norc -c '…' → allowed here, denied on main; the adjacent sh -c form still denies.
Skipped here — filed upstream as template#40. This file is template-owned and byte-identical to the pinned render; patching it locally would re-diverge the file this PR exists to align. The shape is xfail(strict=True) in tests/test_harness_deny_list.py, so the upstream fix will announce itself as an XPASS.
| # line — grep is line-oriented, and each line of a multi-line command starts a | ||
| # new command. A bare backslash stays an ordinary character here, so the | ||
| # alias-bypass form (\rm -rf) is still read as the operation it is. | ||
| outside_quotes="^([^'\"]|$escaped|$squoted|$dquoted)*" |
There was a problem hiding this comment.
outside_quotes is ^-anchored per line, which is unsound for a command carrying a multi-line quoted argument.
The continuation line begins inside the string, so its closing quote is unmatched and nothing after it is reachable. Reproduced with the ordinary multi-line commit-message form chained to a delete: allowed here, denied on main.
Skipped here — filed upstream as template#40. This file is template-owned and byte-identical to the pinned render; patching it locally would re-diverge the file this PR exists to align. The shape is xfail(strict=True) in tests/test_harness_deny_list.py, so the upstream fix will announce itself as an XPASS.
| # Complete quoted spans, escape-aware: to the shell a backslash-escaped quote is | ||
| # a literal character, not a delimiter, so consuming it as one would flip the | ||
| # in/out-of-quote classification for the rest of the line. | ||
| squoted="'[^']*'" # '…' — POSIX: no escapes inside |
There was a problem hiding this comment.
squoted models POSIX single quotes only; $'…' permits \' inside and desynchronises the parity model.
Reproduced: git commit -m $'fix don\'t break' && rm -rf build → allowed here, denied on main. Every operation later on the line becomes unreachable.
Skipped here — filed upstream as template#40. This file is template-owned and byte-identical to the pinned render; patching it locally would re-diverge the file this PR exists to align. The shape is xfail(strict=True) in tests/test_harness_deny_list.py, so the upstream fix will announce itself as an XPASS.
| # …and shells that take their script from stdin, which the operation reaches by | ||
| # being piped into one. `ssh`/`eval`/`su` are absent here on purpose: they run an | ||
| # argument, not stdin, so `grep '<pattern>' . | ssh host tee f` is a mention. | ||
| piped="[^|]*\\|[[:space:]]*(sudo[[:space:]]+)?(/[a-z/]*)?${shells}([[:space:]]|\$)" |
There was a problem hiding this comment.
piped cannot cross a second pipe and allows only a sudo prefix, so an extra stage lets the operation reach a shell.
Reproduced: | tee /tmp/x | sh and | env sh → allowed here, denied on main; bare | sh still denies. The header claims rule 2 covers an operation ahead of a pipe into a shell.
Skipped here — filed upstream as template#40. This file is template-owned and byte-identical to the pinned render; patching it locally would re-diverge the file this PR exists to align. The shape is xfail(strict=True) in tests/test_harness_deny_list.py, so the upstream fix will announce itself as an XPASS.
| exit 4 | ||
| fi | ||
|
|
||
| if command -v jq >/dev/null 2>&1 && printf '{}' | jq -e . >/dev/null 2>&1; then |
There was a problem hiding this comment.
The printf '{}' | jq -e . probe proves parsing, not query compatibility.
The real query uses try … catch, which jq gained in 1.5. A jq 1.4 passes the probe, then fails on a syntax error that 2>/dev/null swallows → exit 4 → .claude/settings.json maps it to exit 2, denying every Bash call while blaming a damaged reader and never mentioning the working python3 on the same PATH.
Skipped here — filed upstream as template#41. Template-owned; same reasoning as the guard findings.
| { | ||
| "type": "command", | ||
| "command": "S=\"${CLAUDE_PROJECT_DIR:-.}/.agents/hooks/block-destructive.sh\"; [ -r \"$S\" ] || exit 2; jq -r '.tool_input.command // empty' | sh \"$S\"" | ||
| "command": "H=\"${CLAUDE_PROJECT_DIR:-.}/.agents/hooks\"; [ -r \"$H/block-destructive.sh\" ] || { echo 'PreToolUse: block-destructive.sh is missing - denying Bash. Restore .agents/hooks, then retry.' >&2; exit 2; }; c=$(sh \"$H/hook-input.sh\" .tool_input.command); rc=$?; [ \"$rc\" -eq 3 ] && { echo 'PreToolUse: no working JSON parser on PATH, so the destructive-command guard cannot read the tool input - denying Bash. Install jq (apt-get install jq / brew install jq) from a shell outside the agent, then retry.' >&2; exit 2; }; [ \"$rc\" -ne 0 ] && { echo \"PreToolUse: could not read the tool input (hook-input.sh exit $rc; payload unreadable or .agents/hooks/hook-input.sh damaged) - denying Bash.\" >&2; exit 2; }; [ -n \"$c\" ] || { echo 'PreToolUse: no command found in the tool input - denying Bash.' >&2; exit 2; }; printf '%s' \"$c\" | sh \"$H/block-destructive.sh\"" |
There was a problem hiding this comment.
The proxy checks the guard is readable, not that it runs, so a truncated guard exits 0 and allows everything.
[ -r ... ] passes for a present-but-damaged script; running an empty file exits 0 and the hook reports success. The SessionStart probe exercises hook-input.sh only, so nothing signals that the deny-list has stopped working — while .agents/README.md advertises it as failing closed.
Skipped here — filed upstream as template#41. Template-owned; same reasoning as the guard findings.
| exit 2 | ||
| } | ||
|
|
||
| if matches "$outside_quotes($operations)"; then |
There was a problem hiding this comment.
Rule 1 is an anchored regex of bracket expressions, so one byte invalid in the current locale defeats it.
Under LANG=en_US.UTF-8, cd \377x && rm -rf y is allowed; the identical input under LC_ALL=C is denied, and the pre-rewrite one-liner matched it. Locale-dependent, so it will not reproduce on a C-locale CI runner.
Skipped here — filed upstream as template#40. This file is template-owned and byte-identical to the pinned render; patching it locally would re-diverge the file this PR exists to align. The shape is xfail(strict=True) in tests/test_harness_deny_list.py, so the upstream fix will announce itself as an XPASS.
|
|
||
| # --- deny-list (mirror any change into .opencode/opencode.jsonc) ------------- | ||
| # Destructive operations: denied when run, allowed when quoted (rules 1 and 2). | ||
| operations='rm -rf|push --force|reset --hard' |
There was a problem hiding this comment.
push --force prefix-matches push --force-with-lease, hard-denying the safe form.
git push --force-with-lease origin main → exit 2. Unlike a quoted mention there is no rephrase — quoting it stops it being a command — and permissions.deny's Bash(git push --force:*) denies it a second time. Pre-dates this PR; it is not a regression.
Skipped here — filed upstream as template#40. This file is template-owned and byte-identical to the pinned render; patching it locally would re-diverge the file this PR exists to align. The shape is xfail(strict=True) in tests/test_harness_deny_list.py, so the upstream fix will announce itself as an XPASS.
| # | ||
| # Blind spots, unchanged in kind from the earlier plain-substring form: `eval` | ||
| # of a variable, "$(…)" command substitution, aliases, encoded payloads, a | ||
| # heredoc body whose lines read as commands, a shell run from a file it wrote, |
There was a problem hiding this comment.
The header calls "a shell run from a file it wrote" a blind spot "unchanged in kind" — the earlier form denied it.
Reproduced: printf 'rm -rf /tmp/x' > s.sh; sh s.sh → allowed here, denied on main. A maintainer reading the header would believe this exposure pre-dates the change and needs no re-evaluation.
Skipped here — filed upstream as template#40. This file is template-owned and byte-identical to the pinned render; patching it locally would re-diverge the file this PR exists to align. The shape is xfail(strict=True) in tests/test_harness_deny_list.py, so the upstream fix will announce itself as an XPASS.
|
|
||
| cmd=$(cat) | ||
|
|
||
| matches() { printf '%s\n' "$cmd" | grep -qE "$1"; } |
There was a problem hiding this comment.
matches() forks a fresh grep per rule, scanning the same short string four times on every Bash call.
Four call sites, on top of the five processes hook-input.sh already spawns, on the critical path of every Bash invocation. Rule 2's two patterns could be one alternation and rules 1 and 3 could share a pass.
Skipped here — filed upstream as template#40. This file is template-owned and byte-identical to the pinned render; patching it locally would re-diverge the file this PR exists to align. The shape is xfail(strict=True) in tests/test_harness_deny_list.py, so the upstream fix will announce itself as an XPASS.
Review round 3 (xhigh) — 15 findings, 4 fixed here, 11 routed upstreamThe headline: the template's quote-aware guard rewrite fails open on eight
Five distinct causes, all in the one regex grammar. Filed as What that leaves this branchMerging before upstream fixes #40 means accepting a guard weaker than The mitigation that is in scope landed instead — Fixed here
Skipped, one line each
Gate: 915 passed, 75 skipped, 9 xfailed. CI 9/9 green. |
Closes #40. Every claim in the issue reproduced against current `main` before patching: all eight shapes fail open, `--force-with-lease` is hard-denied, and the high-byte case flips between `LANG=en_US.UTF-8` (allow) and `LC_ALL=C` (deny). The same table was run against the v0.7.0 script to confirm its column too. Nothing was already fixed. Rebased onto merged `main` (#49, `3cec00a`). The only conflict was the ADR index table, resolved keeping both B's 0016 row and this PR's 0017 row; ADR 0013's row now also records that its `grep -qE` constraint is retired by 0017. ## Approach: the tokenizer route ([ADR 0017](docs/decisions/0017-deny-list-tokenizer-in-python3.md)) Widening the ERE prefix classes would fix only causes 2 and 5 (non-adjacent `-c`, pipe chains). Causes 1, 3, 4 and the locale case are structural to a line-oriented POSIX-grep grammar: it cannot carry quote state across lines, cannot express "the same rules, one level down", and matches bytes through locale-defined classes. So `block-destructive.sh` keeps its interface, deny-list variables and message contract, but computes the verdict in an embedded `python3` program — a hand-rolled quoting-aware scanner (not `shlex`, which mis-tokenizes `$'…'`, raises on unterminated quotes, and discards the quote structure rule 1 needs). The trade the header used to defend (pure-POSIX `grep -qE`) is given up deliberately: `python3` is already the hooks' mandated fallback JSON parser (ADR 0013), and it is probed here the same way — by running it. The CHANGELOG carries an upgrade note: hosts that ran the hooks on `jq` alone now need python3. **The dependency fails closed in both directions.** A missing python3 denies with an install remedy. So does an interpreter that starts and then cannot reach a verdict — raised in review of #49, and a real fail-open in the first version of this PR: a python2 shim or a stripped standard library fails on the program itself and exits 1, which PreToolUse treats as a non-blocking error and runs the command. The program now prints a token on its allow path; the wrapper accepts an allow only as exit 0 carrying that token, a deny as exit 2, and denies every other status with a diagnostic naming it. No extra process, and the guard's stdout stays empty either way. ## Now denied again (the eight #40 rows, plus generalisations) | Shape | Mechanism | | --- | --- | | `bash -c '… echo "resetting" && git reset --hard …'` | rule 2 recurses into the runner string; the inner quoted word is consumed as a span, the operation after it is reachable | | `bash -euo pipefail -c '…'`, `bash --norc -c '…'` | `-c` is found anywhere among the shell's option tokens | | multi-line `git commit -m "…" && rm -rf build` | quote state carries across lines in the joined pass | | `git commit -m $'fix don\'t break' && rm -rf build` | `$'…'` is modelled, so `\'` no longer desynchronises parity | | `… \| tee /tmp/x \| sh`, `… \| env sh` (also `cat \|`, `nohup`, `timeout 5`) | the pipe rule crosses any number of segments and strips wrapper prefixes | | `printf '…' > s.sh; sh s.sh` | new rule-2 form: an operation anywhere in a command that also runs a shell on a file | | `cd \xffx && rm -rf y` under any locale | stdin decoded with `surrogateescape`; the verdict is byte-deterministic | ## Now allowed (dead-end and false-positive fixes) - `git push --force-with-lease` / `--force-if-includes` — a `push --force` match continued by `-` is a longer, lease-checked flag. Applied **in the guard only**: `.opencode/opencode.jsonc` keeps the substring `*push --force*` glob, because splitting it under-covers `git push --force;` and `(git push --force)`, and an allow rule for the lease flags would un-deny compound commands (OpenCode resolves overlaps by last matching rule). The mirror stays stricter than the script by design — this reverses the hand-off I was given, with the reasoning in ADR 0017 Decision 4 and the review comment on this PR. - `bash -c "grep 'rm -rf' ."` — a mention nested in a runner string recurses to a mention (was a documented ADR-0015 false positive). - The `'…'\''…'` idiom — parses as the single word it is (ditto). **Every false positive #39 fixed stays allowed**, verified case-by-case: quoted grep/rg patterns, `git log --grep`, printf'd fixtures, commit messages naming a pattern, `ssh host uptime && grep …`, `grep … | ssh host tee f`, `grep … | git push`, `git push -c k=v && grep …`, escaped-quote commit messages, `grep -c '…' file`. Two documented *surviving* false positives are kept on purpose and pinned by tests: a multi-line quoted string whose lines read as commands (the per-line pass exists so an unpaired quote on one line can't hide a bare operation on the next), and a quoted mention alongside a shell-on-a-file in the same command (the price of re-denying write-then-run; recovery is splitting into two calls). The header's false claim — that "a shell run from a file it wrote" was a blind spot "unchanged in kind" — is corrected: the same-command form is denied again; the genuinely unchanged blind spot (a file written in an *earlier* tool call) is stated as such. The header also now records `block-destructive: denied` as a cross-file contract, since #49's SessionStart self-test keys on that prefix. ## `permissions.deny` needs no change (corrected) An earlier revision of this PR claimed `.claude/settings.json`'s `Bash(git push --force:*)` had the same lease-flag over-match and was being fixed under #41/#44. That is wrong, and B's revert on #49 is right. Verified independently against the shipped CLI (v2.1.220) rather than taken on faith — the matcher's prefix case is `cmd === prefix || cmd.startsWith(prefix + " ")` (plus the same two forms behind `xargs`), and the permissions reference says a trailing `*` after a space "enforces a word boundary". So the rule already denies `git push --force` and `git push --force <args>` and never matched `--force-with-lease`; writing the space into the rule would end the prefix in a space and deny nothing — an under-deny in the `include_claude_hooks=false` configuration where that list is the only layer. ADR 0017 Decision 4 and the CHANGELOG now say so and point at ADR 0016 Decision 5. ## Tests `tests/test_block_destructive.py` — table-driven, stdlib `unittest`, no dependencies: `python3 -m unittest discover tests`. It pins true positives, the eight #40 shapes, the fail-opens self-review found, the #36-fixed mentions, the ADR-0017 fixes, the documented surviving false positives, the documented blind spots, locale determinism, and the fail-closed postures: python3 absent, an interpreter that fails on the program (exit 1) or exits 0 without a verdict, each deny-list variable emptied, the pre-tokenizer `shells` spelling, and the recursion budget. It lives in **this repo**, not `template/`: the guard ships verbatim (no Jinja), so the template source is byte-identical to the rendered artifact, and generated repos are language-arbitrary — the template cannot assume a Python test runner downstream. The downstream table in grAItools/devmm#4 (whose `xfail(strict=True)` rows will XPASS on the next template bump) was used as the reference. ## Validation - 16 tests green; guard behaviour identical under `dash`, `bash`, `sh`, and BusyBox `sh` (deny 2 / allow 0, deny message on stderr, stdout empty). - Rendered from a clone with `--vcs-ref HEAD` under two answer combinations (defaults; `task_runner=just` + `copilot_code_review=true`): no leftover Jinja, rendered guard byte-identical to the template source and denying/allowing the fixtures, rendered `opencode.jsonc` parses as JSON after comment stripping with the four substring deny globs present. - The 45-case cross-check harness was run against v0.7.0, pre-fix `main`, the new guard, and the rendered output; the only verdict changes are the ones listed above. - B ran #49's SessionStart guard self-test against this guard with and without python3 on PATH: all three probes deny with the expected prefix, so the two PRs are compatible in either merge order. ## Self-review A multi-agent review of this PR's own diff found 15 issues, 14 fixed in `658ee08` (nine of them fail-opens the replaced matcher had caught: compound producers before a pipe into a shell, `2>&-` swallowing the next token, `cat s.sh | sh` and `sh < s.sh`, backticks, assignment prefixes, an unpaired quote disabling rule 2 below it, exponential runner recursion, plus the OpenCode glob and the omitted `TEXT_PATTERNS` posture check). The one not fixed — a word spliced across a quote — is now pinned as a documented blind spot instead of being implied covered. Details in the review comment on this PR. ## Belongs to other workers / follow-ups - `template/development/harness-usage.md` and `tool-bootstrap.md` (another worker's area, and `_skip_if_exists`): the guard description there still reflects ADR 0015's matcher, and "Required tools" should note the guard itself now needs python3 (not just the payload reader's fallback). Needs a follow-up by their owner. - `template/.agents/README.md` (also not this PR's file): its deny-list note describes a two-rule script and omits the nested-shell rule, and its "kept in sync by hand" line undercounts the mirrors. Same owner as the item above. - ~~`AGENTS.md` says "there are no unit tests"~~ — folded into this PR per orchestrator: *Validating changes* now names the suite and its runner (`python3 -m unittest discover tests`) ahead of the render check. - ~~`.claude/settings.json.jinja` `permissions.deny` has the same lease-flag over-match~~ — it does not; see the corrected section above. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Feature
Harness maintenance — no
development/work/unit. Supersedes and closes #3,which took the repo to template
v0.6.0; this branch carries those five commitsforward and lands on template
v0.7.0-3-g0fa3c56— thev0.7.0releaseplus the three post-release fixes this adoption prompted upstream.
docs/andwork/move intodevelopment/, leavingdocs/for userdocumentation (
docs/api.md) as the new convention reserves it, and the harnesstracks template
v0.7.0-3-g0fa3c56.Why one branch instead of merging #3 first
The three upstream releases arrived while #3 was open, and two of them rewrote
the same files #3 touched. Stacking the reconciliation on top of #3 keeps a
single reviewable end state instead of a merge-then-immediately-rewrite pair.
#3's own CI stayed green throughout; nothing in it is discarded except patches
upstream has since superseded (below).
The migration is a separate first commit, deliberately
A bare
copier updateexits 0, reports no conflict, and deletesdocs/architecture.md,docs/style.md,docs/testing.mdanddocs/tool-bootstrap.md, substituting template scaffolds at the new path —_skip_if_existsdoes not cover the delete side of a rename. Moving the treefirst (
ae0c46d) puts those files where_skip_if_existscan see them.Verified both ways on throwaway clones before touching a real branch, and
re-verified after each of the three template bumps.
Line counts against
1beb326:architecture.md78 → 78,style.md133 → 133,tool-bootstrap.md122 → 126,testing.md107 → 121 (grafted sections).Absorbed from v0.6.0 → v0.7.0
report.mdas a fifth work-unit artifact; decision register indevelopment/adr/README.md, seeded with the two decisions this repo hadalready granted;
development/glossary.md; document-liveness and authorityrules.
license,mode,project_slug,pr_merge_strategy, theinclude_example_*gates,cursor,mcp, …),generate_scriptsderived fromverify_command, 13 questionsleft. The four role playbook skills fold into their subagent files, leaving
design-principlesas the only skill. Theverifyskill is gone — capabilitynot replaced, accepted deliberately.
HANDBACK(<spike|explore|replan>):with flatper-kind caps each role restates in its own reply, so the bound survives
description-match invocation.
v0.7.0): all three Claude hooks read input via.agents/hooks/hook-input.sh(jq, thenpython3) and branch on its exitcode.
Eight defects this repo reported upstream, all fixed there
Found while adopting the template; filed rather than patched locally, so the
fixes ship for every downstream repo. The last four were found reviewing this
branch, and their fixes are what this PR now pins to.
plan.md's Review checklist had unbounded authority over the reviewer's verdict rulesjqan undocumented hard requirement: Bash guard denied everything,Stoploop-guard defeated, format-on-edit silently off_skip_if_existsmatched a bareREADME.mdat every depth, freezing the template's own nested READMEs downstreamjqoncommand -valone, so a brokenjqfailed the read; theStophook skipped the gate on any reader failureThe local patches for these are retired in favour of upstream's, which are
better in three specific ways this repo got wrong:
exit 1, not0— Claude Code surfaces onlynon-zero stderr, so the local warnings would never have been seen.
block-destructive.shusedgrep -oto name the matched pattern.-ois non-POSIX and GNU grep suppresses its stdout on binary-classifiedinput, so it could match and report nothing — failing open on exactly the
input a deny-list matters for. Upstream keeps the decision on POSIX
grep -qEand names the whole deny-list instead.hook-input.shprobespython3by running it: stock macOS ships a CLT stubthat passes
command -vbut fails at runtime..claude/settings.json,.opencode/opencode.jsoncand all three.agents/hooks/*scripts are byte-identical to a pristine render of the pinnedtemplate commit — verified file by file, not assumed.
Absorbed from
v0.7.0→v0.7.0-3-g0fa3c56_skip_if_existsis root-anchored. Under gitignore semantics a bareREADME.mdmatched at every depth, so.agents/README.md,.claude/rules/README.mdanddevelopment/README.mdwere silently frozendownstream with no conflict reported in either direction.
.agents/README.mdhere was 63 lines stale because of it.
grep -rn 'rm -rf' .passes while
cd x && rm -rf yis denied, with a fallback for the string anested shell runs (
sh -c,ssh,eval,su, or a pipe into a shell). TheSQL pattern still matches anywhere: it has no unquoted form, so its mention
and its use are indistinguishable.
jqthatresolves but is broken falls through to
python3; and theStopgatereports instead of skipping — a gate that cannot be blocked still runs and
says so, rather than ending the session unverified.
.agents/README.mdtakes the template's version wholesale: upstream's rewritegeneralises the local caveat this branch carried (hooks are template-owned) to
all of
.agents/, and adds the safe-to-edit list.development/harness-usage.mdanddevelopment/tool-bootstrap.mdare_skip_if_exists, so the new behaviour is ported by hand. TheStop-hookparagraph is rewritten rather than replaced — upstream covers the reader path
but not the
uv-unavailable path, which still skips the gate on exit 1.Deliberate divergence (six items)
All in territory ADR 0012
states was left untouched: the decision-register row owner (no role could write
it), the marker-less-row exemption (both seeded rows are that kind), the
no-work-unit review axes (a harness PR like this one had no defined behaviour),
the location-scoped
DECISION-PENDING:definition, gate-output pointers todevelopment/testing.md(which records the sanctionedDEVMM_GPUskips), andthe architect's
Write-to-create exception forscratch.md.Plus
AGENTS.md'sBSD-3-Clauseline and squash-merge guidance — both lost todeleted questions — and devmm's stricter rule that a required runtime
dependency needs an ADR, since the empty required-dependency set is an invariant
tests/test_packaging.pyenforces.Definition of done
make verify, exit 0 — 900 passed, 75 skippedreport.mdwritten — n/a, no work unit; deviations declared hereROCm suites off hardware (ADR 0003), unchanged from
1beb326; thetests/diff is prose-only path referencesDECISION-PENDING:line has a register row — none added; theregister is seeded with two pre-existing accepted decisions
layout rather than deciding one
Also verified: every relative Markdown link in every tracked
.mdresolves;.claude/.opencodesymlinks intact;docs/api.mddoctests green; the hookwiring exercised across all parser scenarios (
jq,python3-only, brokenjq,neither) for allow, deny and the
Stoploop guard;AGENTS.mdat 127 lines.Every claim this branch adds to
harness-usage.mdandtool-bootstrap.mdwaschecked against the scripts themselves rather than read off the upstream diff:
13 deny-list cases (quoted mention, bare operation, nested-shell runner, pipe
into a shell,
\rmalias bypass, thepush-ends-in-shnon-match) and thereader's exit codes for broken
jq(falls through),python3-only, no workingparser (3) and an empty payload (4).
Deviations & notes for the reviewer
.copier-answers.ymlloses ten answers. Expected — those questions nolonger exist upstream.
verifyskill is gone. Gate failures now have no triage helper;make verifyruns the gate and nothing summarises the failure.development/tool-bootstrap.mdanddevelopment/harness-usage.mdare_skip_if_exists, so upstream changes to them never arrive on update. Bothported by hand, twice now.
development/README.mdis template-owned and not_skip_if_exists. Itcarries local content here (the sdist/wheel note). Upstream did not change it
in this bump, so nothing was lost; a future bump that does will surface as a
merge conflict rather than a silent overwrite.
because work units gained a directory level (
../→../../), and the.gitignoremanaged block briefly held a duplicate entry oncepost_gen.pybecame incremental.
🤖 Generated with Claude Code