feat(hooks): a refusal no longer prints its own bypass marker (v1.37.0) - #55
Conversation
The marker still WORKS. What disappears is the hook announcing it. Measured on origin/main @ ee0ba47: 40 rules, 35 carrying a bypass_marker, and 26 of those quoting that marker inside their own refusal text -- prod-ops-no-approval, destructive-db-ops, secrets-hardcoded and block-git-force-push-no-lease among them. jq 'length' hooks/rules/rules.json # 40 jq '[.[] | select((.bypass_marker // "") != "")] | length' ... # 35 jq '[.[] | select((.bypass_marker // "") != "") | select(.bypass_marker as $m | (.message // "") | contains($m))] | length' ... # 26 inline-db-mutation-psql was the clearest case: it listed the marker in the refusal AND shipped an allows-bypass-marker test with expected_exit: 0. The bypass was a tested feature of the refusal text, not a slip of wording. Why that is a defect and not a convenience: the thing meant to stop you hands you the password. Reading the refusal IS the authorization, and typing the marker becomes a reflex rather than a decision. dojo-os records the same shape in its non-negotiable 2 with an incident -- pre-bash-block-main-target.sh accepted DOJO_HOTFIX_TO_MAIN=1 AND printed that literal in its own refusal, and on 2026-07-28 two agents filed two false P0-hotfix claims under a human's GitHub account with no trace but their own self-report (DOJ-6247). discard-stderr, added one version earlier, already refused on exactly this reasoning; this applies it to the other 39 rules. 33 messages changed (26 that named the marker + 7 that said "use the bypass marker" without naming it -- a refusal announcing an exit you then look up is the same invitation with an extra step). The six inline-db-mutation-* rules also stop naming their .no-make-no-mistakes-db-mutation sentinel, the same door one level up. Both stay documented in README.md, a file a human opens on purpose. ds-deep-ui-import and ds-arbitrary-fixed-width-in-ds-component never invited a bypass and are untouched. Each refusal now closes with "A block is a finding. Report it upward rather than routing around it." The control that makes this stick: three generic assertions in hooks/test-hooks.sh, over the whole manifest rather than rule by rule, because rule by rule is what drifted. A message may not contain (1) its own bypass_marker, (2) its own disable_if_repo_file, or (3) the literal "hook-bypass" in any form -- the third catches the template even when the marker is renamed. Failures list every offending rule id. Both controls exercised, not assumed: clean manifest passes all three; with a leak injected into rules.json the matching assertion fails and names the rule while the other two stay green, so each discriminates instead of firing together. Ordinary prose is untouched -- ds-deep-ui-import still says deep imports "bypass the barrel", discard-stderr still says it has no marker. All 34 allows-bypass-marker tests (expected_exit: 0) still pass: the mechanic is unchanged. Suite: 340/340 hooks (337 baseline + 3 invariants), 60/60 vitest, 5/5 sentinel walk-up. rules.json regenerated and a fixpoint of build-rules. Rule count unchanged at 40; no rule gained or lost a bypass_marker. Created by Claude Code on behalf of @lapc506
There was a problem hiding this comment.
✅ Approved
Approved — 0 blockers, 1 P3. Confidence: 4.80/5.00.
Walkthrough
main directly. Under GitFlow, changes are expected to target develop first (feature → develop → main). This is an informative-only warning; the merge verdict is based on code safety.
Walkthrough
This PR implements DOJ-6433 by removing the bypass markers and repo escape hatches from all 33 affected rule refusal messages. It also introduces manifest invariants in hooks/test-hooks.sh to mechanically enforce that no future rule messages contain their own bypass_marker, disable_if_repo_file, or the literal hook-bypass string.
Files Reviewed
.claude-plugin/marketplace.json/.claude-plugin/plugin.json/package.json— Version bumps from 1.36.0 to 1.37.0CHANGELOG.md/README.md/hooks/rules/README.md— Documentation updates explaining the security reasoning and manifest invariantshooks/rules/rules.yaml/hooks/rules/rules.json— Rule message updates removing the bypass marker self-advertisementshooks/test-hooks.sh— Test suite updates with the three generic manifest invariant checks
Safety Rationale
The mechanics of the bypass markers and per-repo escape hatches themselves remain fully operational and verified by existing unit tests, meaning this is a safe, output-only message cleanup that prevents reflex bypasses without breaking compatibility.
Approved — 0 blockers, 1 P3.
🔵 P3 — Minor
hooks/test-hooks.sh:115— 🔵 P3 (minor) — A failure in thejqorpastecommand inside the pipeline (e.g., due to syntax errors or environment differences) will result in an empty stdout. Sinceset -eis not active, the script will silently assumeoffendersis empty and output a false positivePASS. Checking the exit status of the pipeline immediately after assignment prevents silent failures in test execution.
[pass 1]
Total findings: 1 business context (1 total)
| # 3. message must not contain the literal `hook-bypass` — catches the | ||
| # template form (`// hook-bypass: <marker>`) even when the marker itself | ||
| # was renamed or elided | ||
| # |
There was a problem hiding this comment.
🔵 P3 (minor) — A failure in the jq or paste command inside the pipeline (e.g., due to syntax errors or environment differences) will result in an empty stdout. Since set -e is not active, the script will silently assume offenders is empty and output a false positive PASS. Checking the exit status of the pipeline immediately after assignment prevents silent failures in test execution.
[pass 1]
Measured on origin/main @ ee0ba47: the three version files all read 1.36.0, and `gh pr diff 55 | grep -E '^\+.*"version"'` shows #55 already bumps to 1.37.0 in package.json, plugin.json and marketplace.json. Taking 1.37.0 here collides with it in all three. The same probe over #52, #46, #44 and #41 returns nothing, so 1.38.0 is free. Skipping a version costs nothing; colliding does. Minor and not major, decided by reading. A skill auto-activates on its `description` rather than being invoked by name like a command, so a renamed `name:` changes no call site. The reference search returns nothing: grep -rniI "rebase.advisor" . --exclude-dir=node_modules \ --exclude-dir=.git --exclude=CHANGELOG.md -> no hits That negative is real and not a broken search -- the same grep for `spike-recommend` returns 10 files, so cross-references of this shape are found when they exist. One surface does break and is stated rather than folded in: a user who typed `/make-no-mistakes:rebase-advisor` explicitly (README:137 documents that skills can be invoked that way) now gets an unknown skill. It fails loudly, the replacement is one row away in the same table, and the installer prunes the old file rather than leaving both live. The `[1.37.0]:` CHANGELOG reference slot is deliberately left for #55 to fill. Suite: 337/337 hooks, 60/60 vitest. Created by Claude Code on behalf of @lapc506
#55 merged first, which is exactly the collision the trial merge predicted: five conflicting files, not three, because README and CHANGELOG carry the version line and the release entry alongside the three manifests. Resolved by reading each block rather than by taking a side wholesale. `git checkout --ours` would have been wrong here: #55 added a ~38-line README section and a 78-line CHANGELOG entry, and taking the whole file from HEAD discards every hunk git had already auto-merged. Instead each conflict block was resolved individually, after first confirming every block contained only a version line (an awk pass over the block interiors, zero non-version lines). - package.json, plugin.json, marketplace.json (x2), README.md line 3 -> 1.38.0. 1.37.0 belongs to #55 and is now on main; 1.38.0 is next. - CHANGELOG entries -> BOTH kept, 1.38.0 above 1.37.0. - CHANGELOG reference links -> keeping both sides produced a duplicate `[Unreleased]:` line, caught by re-reading the resolved region rather than trusting the resolver. One `[Unreleased]` now, pointing at v1.38.0, with 1.38.0 / 1.37.0 / 1.36.0 below it. Verified nothing of #55's was dropped: `git diff origin/main -- hooks/` is empty, and its README section and CHANGELOG entry are both present. Every `## [1.3x.0]` heading has exactly one matching reference link, and the three manifests parse with version 1.38.0. Suite on the merged tree: 340/340 hooks (up from 337 — #55 added three), 60/60 vitest. Created by Claude Code on behalf of @lapc506
…(v1.38.0) (#56) * feat(skills): sync-advisor — measure the drift before naming the fix (v1.37.0) Renames `rebase-advisor` to `sync-advisor` and turns a blind router into an advisor that measures. The old skill was 43 lines and measured nothing. Its step 1 was "Confirm the user wants a full team sync" -- a question back to the user about something three git commands answer. Its description then over-routed: it triggered on "align with develop" and "branches are behind", both of which are `git pull`, and sent them to /make-no-mistakes:rebase, which stashes every worktree, rebases every local branch and auto-merges PRs. Between `git pull` and that the toolkit offered nothing, and nothing read-only at all. Six read-only predicates now run before anything is named: distance, fast-forward possible, dirty tree split by stage, untracked files the ref already tracks, worktrees behind, and branches with unpushed commits. The fifth is the threshold between a plain pull and the team command. The fourth is the one nothing else reports, and it was verified on a throwaway pair of repos rather than asserted. An untracked local file at a path the ref tracks aborts the pull outright while being invisible everywhere else: `git status` shows a plain `?? newfile.txt`, distance reports a clean 0 ahead 1 behind, and `merge-base --is-ancestor` says a fast-forward is possible. The pull then exits 1 with "The following untracked working tree files would be overwritten by merge ... Please move or remove them before you merge" -- the message names the user's own file and offers deletion as the remedy, which is the one irreversible move available. The skill reports these by name and recommends copying them out of the repo, never deleting them. Both controls were run: the pipeline printed nothing before the collision existed and named the file after. It never acts. Every fix is printed for the user to run. The single write is `git fetch origin --quiet`, which touches remote-tracking refs and nothing else, and the skill says so out loud -- without it every measurement is taken against a stale origin/<base> and reports a drift that stopped being true days ago, which is the failure this skill exists to catch. Adds `syncAdvisor.governedPaths` to make-no-mistakes.config.json: the paths whose changes get reported by name, turning "you are 12 behind" into "three hooks changed, two of them fix defects you may be looking at right now". No default -- with the key unset the skill drops the consequence line rather than falling back to a built-in list, which would be wrong in every repo but the one it was copied from and would read as measured. commands/rebase.md is untouched and stays a real destination. What changed is who decides when it applies. Origin (2026-07-31, as reported): a developer filed two bug reports against a hook with clean reproductions. One was a real defect; the other described behaviour fixed days earlier against a stale checkout, and nothing in the report separated them. Suite: 337/337 hooks, 60/60 vitest. Created by Claude Code on behalf of @lapc506 * chore(release): take 1.38.0 instead of 1.37.0 — #55 owns 1.37.0 Measured on origin/main @ ee0ba47: the three version files all read 1.36.0, and `gh pr diff 55 | grep -E '^\+.*"version"'` shows #55 already bumps to 1.37.0 in package.json, plugin.json and marketplace.json. Taking 1.37.0 here collides with it in all three. The same probe over #52, #46, #44 and #41 returns nothing, so 1.38.0 is free. Skipping a version costs nothing; colliding does. Minor and not major, decided by reading. A skill auto-activates on its `description` rather than being invoked by name like a command, so a renamed `name:` changes no call site. The reference search returns nothing: grep -rniI "rebase.advisor" . --exclude-dir=node_modules \ --exclude-dir=.git --exclude=CHANGELOG.md -> no hits That negative is real and not a broken search -- the same grep for `spike-recommend` returns 10 files, so cross-references of this shape are found when they exist. One surface does break and is stated rather than folded in: a user who typed `/make-no-mistakes:rebase-advisor` explicitly (README:137 documents that skills can be invoked that way) now gets an unknown skill. It fails loudly, the replacement is one row away in the same table, and the installer prunes the old file rather than leaving both live. The `[1.37.0]:` CHANGELOG reference slot is deliberately left for #55 to fill. Suite: 337/337 hooks, 60/60 vitest. Created by Claude Code on behalf of @lapc506 * fix(sync-advisor): make the collision predicate correct from any subdirectory Reviewer P3 on `ea8c46e` claimed `git ls-tree` "always outputs repository-relative paths" while `git ls-files` is prefix-relative, so the two would fail to match from a subdirectory, and proposed `--full-name` on `ls-files`. Measured: the premise is false and the proposed fix introduces the bug it claims to prevent. From `sub/`, `ls-tree -r --name-only` prints `newfile.txt`, not `sub/newfile.txt` -- it strips the prefix exactly like `ls-files` does, so the original command matched fine and found the collision. Adding `--full-name` alone then makes `ls-files` emit `sub/newfile.txt` against `ls-tree`'s `newfile.txt`, they stop matching, and `comm -12` returns empty -- a clean bill of health for a tree about to abort the pull. But it pointed at a real weakness of a different kind. From a subdirectory both commands are SCOPED to that subtree, so a collision at the repo root is not seen at all. That is scope, not format, and no combination of format flags fixes it: `--full-name` changes how a path prints, never which paths are considered. The pathspec does. Shipped: `--full-name -- :/` on ls-files, `--full-tree` on ls-tree. Four cases run, with the command extracted verbatim from SKILL.md so the test cannot drift from the doc -- negative control from the root and from `sub/` (both empty), positive from the root and from `sub/` with one collision in each location (both list `rootfile.txt` and `sub/newfile.txt`). Suite: 340/340 hooks, 60/60 vitest. Created by Claude Code on behalf of @lapc506 * fix(sync-advisor): resolve the base ref correctly, and in a bare form Reviewer P2 on `00e2c08`, and it is right. Step 0 returned values carrying the remote prefix while every predicate interpolates `origin/$BASE`, so the run died on `origin/origin/develop`: $ git rev-list --left-right --count "HEAD...origin/origin/main" fatal: ambiguous argument … unknown revision exit=128 Fixed by normalising unconditionally (`${BASE#refs/remotes/}`, `${BASE#origin/}`) whichever branch of the resolution produced the value. Verified both branches with a control that must fail: where `origin/HEAD` IS set it returns `origin/main`, normalises to `main`, and `origin/main` resolves; the un-normalised form exits 128. The P2 understated it. Step 0's first command resolved the base from `@{upstream}`, which on a feature branch is that branch's OWN remote copy -- here `origin/andres/sync-advisor`. That answers "am I pushed?", which is predicate 6's question, and would report 0 behind on a branch far behind the real base. Stripping a prefix would have left it pointing at the wrong ref, so the command is gone from base resolution rather than patched. Also measured while there: `git symbolic-ref --short refs/remotes/origin/HEAD` fails outright in this repo (`not a symbolic ref`), so it is documented as a fall-through into the develop/main/master/trunk probe rather than as a step that is expected to succeed. P4 (`--abbrev-ref` and `--symbolic-full-name` redundant) is also correct -- both forms return `origin/andres/sync-advisor` here -- and is moot: that command no longer appears. Suite: 340/340 hooks, 60/60 vitest. Created by Claude Code on behalf of @lapc506
What
A hook's refusal no longer prints its own
bypass_marker. The marker still works — a human who knows one exists can type it deliberately, and everyallows-bypass-markertest still assertsexpected_exit: 0. What disappears is the hook announcing it.Closes DOJ-6433.
The measurement
ref:
origin/main@ee0ba47| delta: none (worktree cut from that ref, measured before any edit)jq 'length' hooks/rules/rules.jsonbypass_markerjq '[.[] | select((.bypass_marker // "") != "")] | length' hooks/rules/rules.jsonThe brief's figure was 39 / 35 / 26, measured against the installed v1.35.0. Mine differs on the first number only:
origin/mainis v1.36.0, which addeddiscard-stderr(PR #53) — rule 40, and the only rule in the manifest that already shippedbypass_marker: nullon precisely this reasoning. The 35 and the 26 match exactly.The 26, by id:
ssh-db-mutation·prod-ops-no-approval·destructive-db-ops·manual-edge-fn-deploy·minified-build-output·secrets-hardcoded·ds-arbitrary-breakpoint·warn-curl-mutating-supabase-rest·block-playwright-headless·block-git-force-push-no-lease·block-git-rebase-skip·warn-deletes-console-log·warn-time-estimates-in-plans·warn-pr-create-many-files·warn-posthog-in-alerts·block-sibling-suffix-storage·warn-localhost-in-pr-body·warn-bash-mutation-without-leading-cd·warn-version-readme-changelog-sync·warn-greptile-review-extraction-by-created-at· the sixinline-db-mutation-*Why this is a defect, not a convenience
The thing meant to stop you hands you the password. Reading the refusal is the authorization, and typing the marker becomes a reflex instead of a decision.
inline-db-mutation-psqlis the clearest case: it listed# hook-bypass: db-mutation-rulein its refusal and shipped anallows-bypass-markertest withexpected_exit: 0. The bypass was a tested feature of the refusal text, not a slip of wording.dojo-os records the same shape in its non-negotiable 2, with an incident:
pre-bash-block-main-target.shacceptedDOJO_HOTFIX_TO_MAIN=1and printed that literal in its own refusal, and on 2026-07-28 two agents filed two false P0-hotfix claims under a human's GitHub account with no trace but their own self-report (DOJ-6247).While preparing this PR the installed v1.36.0 hook blocked a probe command and printed
"// hook-bypass: prod-ops"back at me — the defect firing on the author of its own fix. It was reported here rather than routed around.What changed
33 rule messages — the 26 that named the marker, plus 7 that said "use the bypass marker" without naming it. A refusal announcing an exit you then go look up is the same invitation with an extra step.
The six
inline-db-mutation-*rules also stop naming their.no-make-no-mistakes-db-mutationsentinel: the per-repo escape hatch is the same door one level up, and removing the marker while leaving the sentinel would just move traffic to the other door.Untouched:
ds-deep-ui-importandds-arbitrary-fixed-width-in-ds-component, the two marker-bearing rules that never invited a bypass.Each refusal now says what was blocked, why, what to do instead, and closes with:
Both the markers and the sentinel stay documented in
README.mdandhooks/rules/README.md— files a human opens on purpose, which is a different act from being handed one at the moment you are trying to get past something.The control that makes it stick
Three generic assertions in
hooks/test-hooks.sh, underRunning manifest invariants…. They hold over every rule in the manifest rather than case by case, because case by case is what drifted the first time. A message may not contain:bypass_markerdisable_if_repo_filehook-bypassin any form — catches the// hook-bypass: <marker>template even when the marker is renamed or elidedEach failure lists every offending rule id. CI already runs
bash hooks/test-hooks.shon every PR touchinghooks/**.Both controls exercised, not assumed. Clean manifest → all three PASS. Leak injected into
rules.json→ the matching assertion FAILs and names the rule, while the others stay green:Each assertion discriminates rather than all firing together — red-on-everything would not have measured anything.
Ordinary prose is deliberately untouched:
ds-deep-ui-importstill says deep imports "bypass the barrel", anddiscard-stderrstill says outright that it has no bypass marker. Both keep passing.What this does NOT enforce
The invariant is mechanical, so it only catches a message that names its marker or sentinel. A future rule could still write "use the bypass marker" without naming it and no assertion would fire — that is exactly the class removed by hand in the 7 rules above, and it stays a review question, not a gate. Saying so rather than implying the check is total.
Mechanics unchanged
bypass_marker.allows-bypass-marker*tests still present, stillexpected_exit: 0, all passing.rules.jsonregenerated and verified to be a fixpoint ofnpm run build-rules(the CI staleness check).Verification
bash hooks/test-hooks.shorigin/main@ee0ba47; +3 invariants)npm test(vitest)bash hooks/test-sentinel-walkup.shnpm run build-rules+ fixpoint checkVersion
1.36.0 → 1.37.0acrosspackage.json,.claude-plugin/plugin.json,.claude-plugin/marketplace.json(both fields), theREADME.mdversion line, and aCHANGELOG.mdentry — the same file set as PR #53.One drive-by: the CHANGELOG link footer still pointed
[Unreleased]atv1.35.0...HEADand had no[1.36.0]line. Both fixed.9 files, within the 15-file rule.
Created by Claude Code on behalf of @lapc506