Skip to content

feat(hooks): a refusal no longer prints its own bypass marker (v1.37.0) - #55

Merged
lapc506 merged 1 commit into
mainfrom
andres/doj-6433-no-bypass-advertising
Jul 31, 2026
Merged

feat(hooks): a refusal no longer prints its own bypass marker (v1.37.0)#55
lapc506 merged 1 commit into
mainfrom
andres/doj-6433-no-bypass-advertising

Conversation

@lapc506

@lapc506 lapc506 commented Jul 31, 2026

Copy link
Copy Markdown
Collaborator

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 every allows-bypass-marker test still asserts expected_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)

count command
rules total 40 jq 'length' hooks/rules/rules.json
with a bypass_marker 35 jq '[.[] | select((.bypass_marker // "") != "")] | length' hooks/rules/rules.json
printing that marker in their own refusal 26 below
jq '[.[] | select((.bypass_marker // "") != "")
         | select(.bypass_marker as $m | (.message // "") | contains($m))] | length' \
  hooks/rules/rules.json      # => 26

The brief's figure was 39 / 35 / 26, measured against the installed v1.35.0. Mine differs on the first number only: origin/main is v1.36.0, which added discard-stderr (PR #53) — rule 40, and the only rule in the manifest that already shipped bypass_marker: null on 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 six inline-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-psql is the clearest case: it listed # hook-bypass: db-mutation-rule in its 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.

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).

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-mutation sentinel: 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-import and ds-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:

A block is a finding. Report it upward rather than routing around it.

Both the markers and the sentinel stay documented in README.md and hooks/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, under Running 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:

  1. its own bypass_marker
  2. its own disable_if_repo_file
  3. the literal hook-bypass in any form — catches the // hook-bypass: <marker> template even when the marker is renamed or elided

Each failure lists every offending rule id. CI already runs bash hooks/test-hooks.sh on every PR touching hooks/**.

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:

# injected "// hook-bypass: prod-ops" into prod-ops-no-approval.message
FAIL  manifest-invariant / message-does-not-name-its-own-bypass-marker  --  prod-ops-no-approval
PASS  manifest-invariant / message-does-not-name-its-own-repo-escape-hatch
FAIL  manifest-invariant / message-does-not-contain-the-hook-bypass-literal  --  prod-ops-no-approval

# separately injected ".no-make-no-mistakes-db-mutation" into inline-db-mutation-psql.message
PASS  manifest-invariant / message-does-not-name-its-own-bypass-marker
FAIL  manifest-invariant / message-does-not-name-its-own-repo-escape-hatch  --  inline-db-mutation-psql
PASS  manifest-invariant / message-does-not-contain-the-hook-bypass-literal

Each assertion discriminates rather than all firing together — red-on-everything would not have measured anything.

Ordinary prose is deliberately untouched: ds-deep-ui-import still says deep imports "bypass the barrel", and discard-stderr still 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

  • Rule count 40, before and after. No rule gained or lost a bypass_marker.
  • All 34 allows-bypass-marker* tests still present, still expected_exit: 0, all passing.
  • rules.json regenerated and verified to be a fixpoint of npm run build-rules (the CI staleness check).

Verification

check result
bash hooks/test-hooks.sh 340 / 340 (baseline 337 / 337, measured on a detached worktree at origin/main @ ee0ba47; +3 invariants)
npm test (vitest) 60 / 60
bash hooks/test-sentinel-walkup.sh 5 / 5
npm run build-rules + fixpoint check in sync

Version

1.36.0 → 1.37.0 across package.json, .claude-plugin/plugin.json, .claude-plugin/marketplace.json (both fields), the README.md version line, and a CHANGELOG.md entry — the same file set as PR #53.

One drive-by: the CHANGELOG link footer still pointed [Unreleased] at v1.35.0...HEAD and had no [1.36.0] line. Both fixed.

9 files, within the 15-file rule.


Created by Claude Code on behalf of @lapc506

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

@dojo-code-reviewer dojo-code-reviewer Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ Approved

Approved — 0 blockers, 1 P3. Confidence: 4.80/5.00.

Walkthrough

⚠️ GOVERNANCE WARNING: This PR targets 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.0
  • CHANGELOG.md / README.md / hooks/rules/README.md — Documentation updates explaining the security reasoning and manifest invariants
  • hooks/rules/rules.yaml / hooks/rules/rules.json — Rule message updates removing the bypass marker self-advertisements
  • hooks/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 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]


Total findings: 1 business context (1 total)

Comment thread hooks/test-hooks.sh
# 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
#

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 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]

lapc506 added a commit that referenced this pull request Jul 31, 2026
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
@lapc506
lapc506 merged commit 810b054 into main Jul 31, 2026
2 checks passed
lapc506 added a commit that referenced this pull request Jul 31, 2026
#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
lapc506 added a commit that referenced this pull request Jul 31, 2026
…(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
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant