Skip to content

Instantly share code, notes, and snippets.

@The1nk
Created August 30, 2026 14:48
Show Gist options
  • Select an option

  • Save The1nk/bed463897ce9fc83b41db61a4bb2f085 to your computer and use it in GitHub Desktop.

Select an option

Save The1nk/bed463897ce9fc83b41db61a4bb2f085 to your computer and use it in GitHub Desktop.
Claude Code PreToolUse hook: refuse 'bd update --notes', which replaces the notes field instead of appending (gastownhall/beads#4541)

bd update --notes clobber guard

A Claude Code PreToolUse(Bash) hook that refuses bd update --notes, which replaces the notes field instead of appending to it (gastownhall/beads#4541).

Posted in reply to gastownhall/beads#4541 — it is a fence around the bug, not a fix for it. The semantics are still the thing worth changing upstream.

Why a hook and not the existing warning

bd does warn. The warning is immediately followed by a success line, so it reads as success and gets sailed past. Measured across one store: 309 note-writes, 33 destroyed another actor's note, and 23 of those were one agent's scoping or ruling wiped by the next agent that picked the item up. Single losses of 13.7 KB, 11.8 KB, 9.7 KB. The ban was written in three separate convention files and agents sailed past it all 33 times.

A PreToolUse hook is the only channel that can refuse rather than advise.

What it blocks and allows

blocked   bd update <id> --notes ...          gc bd update <id> --notes ...
          bd -C <dir> update <id> --notes=    env FOO=1 bd update ... --notes
          bd update <id> --notes ""           echo "$(bd update x --notes y)"
allowed   bd update <id> --append-notes ...   bd note <id> --file <path>
          any command that merely MENTIONS the flag in prose or a heredoc

The escape hatch is a different command, not a repeated one:

BD_DESTROY_ALL_PRIOR_NOTES=1 bd update <id> --notes "..."

Retrying the identical command does not work, deliberately. A block-once-allow-the-retry gate is opened by the most reflexive action an agent has, so it selects for repetition rather than intent. The token appears only in the refusal text, so using it is evidence the message was read — and every use is logged.

Wiring

// ~/.claude/settings.json
{ "hooks": { "PreToolUse": [ { "matcher": "Bash",
    "hooks": [ { "type": "command",
                 "command": "~/.claude/hooks/bd-notes-clobber-guard" } ] } ] } }

chmod +x it. It exits 0 always and fails open — a guard that wedges every Bash call in a fleet is worse than the clobber it prevents.

Tests

python3 bd-notes-clobber-guard.test.py          # 55/55
python3 bd-notes-clobber-guard.test.py --self-check

The --self-check mutates the guard and requires the suite to go red, so a suite that cannot fail is caught.

Two things worth stealing even if you do not want the hook

Matching is a scan, not a regex. An earlier version split on ;&&|| over the raw text and was wrong in both directions: it refused an entirely additive command whose message text explained this ban and contained a ;, and it allowed echo "$(bd update x --notes y)", which really does destroy the notes. A regex that must pair delimiters cannot parse nesting. Quoted spans are opaque, heredoc bodies are data, and the body of every $( ) is re-scanned as command text.

Recovery is often not in Dolt history. Of 62 values lost this way, only 12 were in the versioned plane — the other 50, including every one of the largest, existed only in the store's events table. A notes value survives in history only if it was current when bd flatten last ran, and a clobber victim is superseded by definition.

select actor, created_at,
       json_unquote(json_extract(new_value,'$.notes'))
from events where issue_id='<id>' order by created_at;
#!/usr/bin/env python3
"""PreToolUse(Bash) guard: refuse `bd update --notes`, which REPLACES the
notes field instead of appending to it.
WHY A HOOK AND NOT A WARNING. `bd` already warns:
warning: ta-m4l5k: --notes replaced existing notes (use --append-notes ...)
✓ Updated issue: ta-m4l5k — ...
and it is not enough. A warning immediately followed by a ✓ success line
reads as success. Measured 2026-08-20 across the whole `ta` store: 309
note-writes, 33 of them destroyed another actor's note, 23 of those were the
mayor's own scoping or ruling wiped by the polecat that then picked the bead
up. The ban is written in three files and agents sailed past it 33 times.
Upstream gastownhall/beads#4541 is CLOSED on exactly that warning, so a
rebuild does not help. The only channel left that an agent cannot ignore is
one that refuses the call.
Blocks: bd update <id> --notes ... gc bd update <id> --notes ...
bd -C <dir> update <id> --notes= env FOO=1 bd update ... --notes
Allows: --append-notes (the literal '--notes' is not a substring of it)
bd note <id> --file <path>
any command that merely MENTIONS the flag - see PRECISION below.
PRECISION. This city's CLAUDE.md, its commit messages and this very file all
contain the literal text `bd update --notes`, so a substring match would block
editing the documentation that carries the ban. Two rules keep that from
happening, and both matter:
1. Heredoc BODIES are stripped before scanning. Documentation is written
with `cat > f <<'EOF' ... EOF` and python heredocs, and those bodies are
data, not commands.
2. A statement only counts if `bd` is the command being INVOKED - the first
token after any env prefix - not merely a word inside it. `git commit
-m "never use bd update --notes"` is a git call, not a bd call.
3. Statements are found by a SCAN, not a regex, so a separator inside a
QUOTED ARGUMENT is prose and does not start a new statement. Rule 1 is
the same argument about a heredoc body; both say DATA IS NOT COMMANDS.
The scan also reads INTO every `$( )` and backtick, because a command
substitution is the opposite case - command text wearing an argument's
costume. Added 2026-08-28 after this guard refused an additive `gc bd
comment` over a semicolon and allowed a real `echo "$(bd update x
--notes y)"`; the scanner's own header carries both measurements.
Splitting on ; && || | and newlines OUTSIDE QUOTES means `foo && bd update
--notes` is still caught: each statement is judged on its own.
Exit 0 always; the decision travels in the JSON, per the PreToolUse contract.
A crash here must not wedge every Bash call in the city, so the whole body is
wrapped and fails OPEN - a guard that bricks the fleet is worse than the
clobber it prevents.
"""
import json
import os
import re
import sys
import time
LOG = os.environ.get(
"BD_NOTES_CLOBBER_GUARD_LOG",
os.path.expanduser("~/.claude/hooks/bd-notes-clobber-guard.log"))
# THE ESCAPE HATCH IS A DIFFERENT COMMAND, NOT A REPEATED ONE.
# Deliberate design choice, 2026-08-21, with the owner. A "block once, allow
# the identical retry" gate is opened by the most reflexive action available
# to an agent, so it would select for repetition rather than intent — and it
# would need per-session state in a hook that is user-level and therefore
# shared by every agent on this box. An env prefix cannot be reached by
# retrying: the token appears ONLY in the block message, so using it is
# evidence the message was read. It is also countable, which "second attempt"
# is not.
#
# The name states the ACT and its CONSEQUENCE. That is the point of it: you
# have to write "destroy all prior notes" to destroy all prior notes.
OVERRIDE = "BD_DESTROY_ALL_PRIOR_NOTES"
_TRUTHY = {"1", "true", "yes", "on"}
REASON = """BLOCKED: `--notes` REPLACES the notes field, destroying every prior note.
This is not a style preference. Measured across the `ta` store: 309
note-writes, 33 destroyed another actor's note, and 23 of those were a
mayor's scoping or ruling wiped by the polecat that picked the bead up.
Single losses of 13.7 KB, 11.8 KB, 9.7 KB. `bd` does warn and the warning
is followed by a ✓, so it reads as success and gets sailed past.
Use one of these instead:
gc bd note <id> --file <path> # appends; --file also protects
# backticks and $VAR from the shell
gc bd update <id> --append-notes "..." # appends
If you meant to state CURRENT STATUS rather than add to the audit trail,
rewrite the description instead — that is the field that survives:
gc bd update <id> --description "..."
Recovering something already destroyed: try Dolt history first, and when it
comes back empty that is NOT proof the bytes are gone — check the store's
`events` table, which holds the only copy for most losses here.
IF YOU GENUINELY INTEND TO DESTROY EVERY PRIOR NOTE — rare, and almost
never what you want — you can proceed by naming the act, on the SAME
command line:
BD_DESTROY_ALL_PRIOR_NOTES=1 gc bd update <id> --notes "..."
Retrying the identical command will NOT work and is not meant to. The
prefix exists so that destroying the thread is something you state on
purpose, not something you fall into by repeating yourself. Every use is
logged. If you cannot say why the prior notes must go, use --append-notes."""
# ---------------------------------------------------------------- helpers
_HEREDOC = re.compile(r"<<-?\s*(['\"]?)([A-Za-z_][A-Za-z0-9_]*)\1")
def strip_heredocs(cmd):
"""Remove heredoc bodies. They are data — docs, SQL, note text — not calls."""
lines = cmd.split("\n")
out, i = [], 0
while i < len(lines):
line = lines[i]
out.append(line)
markers = _HEREDOC.findall(line)
i += 1
for _, delim in markers:
while i < len(lines) and lines[i].strip() != delim:
i += 1
if i < len(lines): # drop the terminator line too
i += 1
return "\n".join(out)
# ── SPLITTING A COMMAND LINE INTO STATEMENTS, AND WHY IT IS NOT A REGEX ──────
#
# `cmd1 && cmd2 | cmd3` — judge each statement on its own. The obvious
# spelling, one regex over the RAW text, was this file's until 2026-08-28, and
# it is wrong in BOTH directions at once:
#
# FALSE POSITIVE. A separator inside a QUOTED ARGUMENT is prose, not a
# separator, so whatever followed it was promoted into a statement of its
# own and judged as a command. Measured on the zero-day rehearsal city at
# 2026-08-28T20:38:34Z: an entirely additive `gc bd comment` was REFUSED
# because its message explained this very ban and contained "...; bd update
# <id> --notes ...". Reproduced here with live controls, both wrong:
#
# gc bd comment x 'foo; bd update y --notes z' DENY
# gc bd comment x "a && bd update y --notes z" DENY
#
# This file's header already makes the argument for the other half of the
# same rule — heredoc bodies are stripped because DATA IS NOT COMMANDS — and
# a quoted argument is data by exactly that argument.
#
# FALSE NEGATIVE, which is the worse half, and it was found only by asking
# the splitter the opposite question. A command SUBSTITUTION is command text
# wearing an argument's costume, and a raw split cannot see into one:
#
# echo "$(bd update x --notes y)" allow
#
# That destroys the notes. It has been allowed for as long as this guard has
# existed. No tuning of a separator regex finds it: A REGEX THAT MUST PAIR
# DELIMITERS CANNOT PARSE NESTING — the same correction this city made to
# its prose filter when a nested $( ) defeated a delimiter-pairing key.
#
# So scan rather than split: a separator divides only at COMMAND LEVEL, a
# quoted span is opaque, and the body of every `$( )` and backtick is
# re-scanned as command text and judged in its own right.
#
# WHICH BRANCH THE ERROR TAKES. On unbalanced quoting — a fragment, a
# half-written line, a `'` inside a word — the scan gives up and the caller
# falls back to the raw regex split. That is deliberate and it is the
# conservative direction: the raw split can over-refuse, loudly and with a
# message naming the way through, and never under-refuses. A quote-aware scan
# that guessed instead would swallow the rest of the line into one opaque
# span and let a real clobber past in silence.
_RAW_SPLIT = re.compile(r"(?:\|\||&&|[;\n|&])")
# A REDIRECTION IS NOT A SEPARATOR. `2>&1` carries a bare `&`, so without this
# `bd update x 2>&1 --notes y` splits mid-redirect. Mask the `&` inside a
# redirection operator before scanning and restore it after; the sentinel is a
# control character no real command line carries. Same fix, same reason, as the
# sibling bash-footgun-guard.
_REDIR_AMP = re.compile(r"[0-9]*>&[0-9-]+|[0-9]*<&[0-9-]+|&>>|&>")
_AMP = "\x01"
def _skip_single(text, i):
"""text[i] is `'`. Index past the close, or -1 if it never closes."""
j = text.find("'", i + 1)
return j + 1 if j != -1 else -1
def _skip_ansi(text, i):
"""text[i:i+2] is `$'` — ANSI-C quoting, where a backslash escapes."""
j, n = i + 2, len(text)
while j < n:
if text[j] == "\\":
j += 2
continue
if text[j] == "'":
return j + 1
j += 1
return -1
def _skip_double(text, i, subs):
"""text[i] is `"`. Substitutions inside it are still COMMANDS: collect
their bodies into subs so the caller can judge each one."""
j, n = i + 1, len(text)
while j < n:
c = text[j]
if c == "\\":
j += 2
continue
if c == "$" and text[j + 1:j + 2] == "(":
j = _skip_subst(text, j + 2, ")", subs)
elif c == "`":
j = _skip_subst(text, j + 1, "`", subs)
elif c == '"':
return j + 1
else:
j += 1
continue
if j == -1:
return -1
return -1
def _skip_subst(text, i, closer, subs):
"""i is just past the opener of a `$( )` or backtick substitution.
Records the body in subs and returns the index past the closer, or -1.
Quoting restarts inside a substitution, so this recurses the same way the
top-level scan does.
"""
start, j, n = i, i, len(text)
while j < n:
c = text[j]
if c == "\\":
j += 2
continue
if c == closer:
subs.append(text[start:j])
return j + 1
if c == "$" and text[j + 1:j + 2] == "'":
j = _skip_ansi(text, j)
elif c == "'":
j = _skip_single(text, j)
elif c == '"':
j = _skip_double(text, j, subs)
elif c == "$" and text[j + 1:j + 2] == "(":
j = _skip_subst(text, j + 2, ")", subs)
elif c == "`":
j = _skip_subst(text, j + 1, "`", subs)
else:
j += 1
continue
if j == -1:
return -1
return -1
def scan_statements(text):
"""Statements of TEXT, split on separators at command level only.
Returns None — never a guess — when quoting does not close. See WHICH
BRANCH THE ERROR TAKES above.
"""
out, subs, buf = [], [], []
i, n = 0, len(text)
def flush():
s = "".join(buf).strip()
del buf[:]
if s:
out.append(s)
while i < n:
c = text[i]
if c == "\\" and i + 1 < n:
buf.append(text[i:i + 2])
i += 2
continue
j = None
if c == "$" and text[i + 1:i + 2] == "'":
j = _skip_ansi(text, i)
elif c == "'":
j = _skip_single(text, i)
elif c == '"':
j = _skip_double(text, i, subs)
elif c == "$" and text[i + 1:i + 2] == "(":
j = _skip_subst(text, i + 2, ")", subs)
elif c == "`":
j = _skip_subst(text, i + 1, "`", subs)
if j is not None:
if j == -1:
return None
buf.append(text[i:j])
i = j
continue
if text[i:i + 2] in ("&&", "||"):
flush()
i += 2
continue
if c in ";\n|&":
flush()
i += 1
continue
buf.append(c)
i += 1
flush()
# A substitution body is command text in its own right. Judge it too —
# this is the half that closes the false negative above.
for s in subs:
inner = scan_statements(s)
if inner is None:
inner = [p.strip() for p in _RAW_SPLIT.split(s) if p.strip()]
out.extend(inner)
return out
def statements(cmd):
"""Every statement in CMD, heredoc bodies dropped, quoting honoured."""
text = _REDIR_AMP.sub(lambda m: m.group(0).replace("&", _AMP),
strip_heredocs(cmd))
parts = scan_statements(text)
if parts is None:
parts = _RAW_SPLIT.split(text)
return [p.replace(_AMP, "&").strip() for p in parts if p.strip()]
# env prefixes that can legitimately sit in front of the real command
_ENV_ASSIGN = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*=\S*$")
def invokes_bd(tokens, env_out=None):
"""True when bd is the command being invoked, not just a word in the text.
Leading `VAR=value` assignments are collected into env_out when given, so
the caller can see an override prefix that applies to THIS statement only.
"""
i = 0
while i < len(tokens):
t = tokens[i]
if _ENV_ASSIGN.match(t):
if env_out is not None:
k, _, v = t.partition("=")
env_out[k] = v
i += 1
continue
if t == "env": # env -u BEADS_DIR -u GC_RIG bd ...
i += 1
while i < len(tokens) and (
tokens[i].startswith("-") or _ENV_ASSIGN.match(tokens[i])
):
# -u takes a value
if tokens[i] in ("-u", "--unset") and i + 1 < len(tokens):
i += 1
elif env_out is not None and _ENV_ASSIGN.match(tokens[i]):
k, _, v = tokens[i].partition("=")
env_out[k] = v
i += 1
continue
if t in ("timeout", "nohup", "nice", "stdbuf"):
i += 1
while i < len(tokens) and tokens[i].startswith("-"):
i += 1
if t == "timeout" and i < len(tokens):
i += 1 # the duration
continue
break
rest = tokens[i:]
if not rest:
return None
if rest[0] == "bd":
return rest[1:]
if rest[0] == "gc" and len(rest) > 1 and rest[1] == "bd":
return rest[2:]
return None
def offending(cmd):
"""Return (verdict, statement) for the first destructive bd update.
verdict is "block", or "override" when this statement carries the
BD_DESTROY_ALL_PRIOR_NOTES prefix. Returns None when nothing matches.
The override is scoped to the statement it prefixes: setting it in front
of one command does not license a --notes in a later one.
"""
for stmt in statements(cmd):
if "--notes" not in stmt:
continue
try:
import shlex
tokens = shlex.split(stmt, comments=False)
except ValueError:
tokens = stmt.split()
env = {}
args = invokes_bd(tokens, env)
if args is None:
continue
# `--notes` only replaces on `update`; `bd note` is the safe command.
if "update" not in args:
continue
for a in args:
if a == "--notes" or a.startswith("--notes="):
if env.get(OVERRIDE, "").lower() in _TRUTHY:
return ("override", stmt)
return ("block", stmt)
return None
# ── WRITING THE AUDIT RECORD ────────────────────────────────────────────────
#
# Our adoption checklist tells a reader to audit this log for false refusals.
# It was written with `hit[:400]`, and on 2026-08-28 that cap is what stopped
# the audit answering: the one refusal anybody has ever questioned was cut at
# "skip claiming..." mid-sentence, and the mechanism had to be recovered from
# an agent's TRANSCRIPT instead. AN AUDIT LOG THAT TRUNCATES THE THING YOU
# AUDIT CANNOT SETTLE THE QUESTION IT EXISTS FOR. A refusal is precisely when
# the full text matters, and this log grows at ~1101 lines over weeks, so
# there was never a volume argument for the cap.
#
# Two properties the old spelling did not have:
#
# THE RECORD IS ONE LINE, WHATEVER THE DATA. A statement can now carry an
# embedded newline — that is the point of the quote-aware scan above — and a
# raw write would split one record across lines, so every reader that counts
# BLOCKED lines would over-count and every `grep -c` would drift from the
# truth. Escape, do not hope.
#
# A CUT RECORD SAYS IT WAS CUT. The cap stays, because one pathological
# command must not be able to write a megabyte into an audit trail, but it
# is 20x larger and truncation is now announced with the count of what was
# dropped. A partial record that looks complete is the failure this whole
# change is about: silence about a cut is what cost the last investigation a
# session.
LOG_MAX = 8000
def for_log(hit):
"""One line, escaped, and honest about any truncation."""
esc = hit.replace("\\", "\\\\").replace("\n", "\\n").replace("\r", "\\r")
if len(esc) <= LOG_MAX:
return esc
return "%s [TRUNCATED, %d more chars]" % (esc[:LOG_MAX], len(esc) - LOG_MAX)
def main():
raw = sys.stdin.read()
try:
payload = json.loads(raw) if raw.strip() else {}
except ValueError:
return
cmd = (payload.get("tool_input") or {}).get("command") or ""
if not cmd:
return
found = offending(cmd)
if not found:
return
verdict, hit = found
try:
# Our adoption checklist says "audit that trail", so a missing
# parent directory must not silently cost the trail. Inside the
# existing try: a guard that cannot log still refuses correctly.
os.makedirs(os.path.dirname(LOG) or ".", exist_ok=True)
with open(LOG, "a") as fh:
fh.write("%s %s %s\n" % (
time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
"OVERRIDE" if verdict == "override" else "BLOCKED",
for_log(hit)))
except OSError:
pass
# An override is recorded and then allowed through. Staying silent here is
# deliberate: the agent already read the reason, and a second lecture on a
# call it deliberately authorised is noise.
if verdict == "override":
return
print(json.dumps({
"hookSpecificOutput": {
"hookEventName": "PreToolUse",
"permissionDecision": "deny",
"permissionDecisionReason": REASON,
}
}))
if __name__ == "__main__":
try:
main()
except Exception:
# Fail OPEN. A guard that wedges every Bash call is worse than the
# clobber it prevents.
pass
sys.exit(0)
#!/usr/bin/env python3
"""Matrix for bd-notes-clobber-guard.
WHY THIS FILE EXISTS, WRITTEN 2026-08-28. It did not, for the whole life of
the guard. The only thing exercising this hook was a FOUR-ROW inline matrix
inside check-notes-clobber.sh — real clobber denies, --append-notes allows,
the override works, and it is wired. That is a liveness check, not a matrix,
and it could not see either of the two defects found on 2026-08-28: an
additive `gc bd comment` REFUSED because its message contained a semicolon,
and a real `echo "$(bd update x --notes y)"` ALLOWED because a raw split
cannot see into a substitution. The sibling had 260 rows and found neither in
its own copy of the same code, for the same reason.
Asserts the DENY you expect, never the absence of one: an ALLOW row asserts
empty output, and a DENY row asserts the guard's own reason text. A suite that
only checked "not deny" would pass against a hook that never fires at all —
this city has shipped a selector mutated to match NOTHING that still passed
36/36.
"""
import json, os, re, subprocess, sys, tempfile
# Overridable so --self-check can point the whole matrix at a MUTANT without
# ever writing over the live guard, which is the file every Bash call in this
# city goes through.
HOOK = os.environ.get("NOTES_GUARD_UNDER_TEST") or os.path.join(
os.path.dirname(os.path.abspath(__file__)), "bd-notes-clobber-guard")
# PRECONDITION, NOT A SCORED ROW. run() EXECS the hook the way the harness
# does, so a stripped +x bit means nothing below was measured.
if not os.access(HOOK, os.X_OK):
print("PRECONDITION FAILED: %s is not executable, so the harness could not\n"
"run it either. Nothing below would have been measured.\n"
"Fix with: chmod +x %s" % (HOOK, HOOK))
sys.exit(2)
_FX = tempfile.mkdtemp(prefix="notes-guard-test-")
# NEVER THE LIVE LOG. The guard appends a record on every DENY and OVERRIDE,
# and this suite drives dozens; without this the audit trail our adoption
# tells a reader to read would be mostly this suite's fixtures.
_LOG = os.path.join(_FX, "guard.log")
def run(cmd, hook=None, log=None):
env = dict(os.environ, BD_NOTES_CLOBBER_GUARD_LOG=(log or _LOG))
p = subprocess.run([hook or HOOK],
input=json.dumps({"tool_name": "Bash",
"tool_input": {"command": cmd}}),
capture_output=True, text=True, env=env)
return p.stdout
def is_deny(out):
"""Parse it. A guard whose permissionDecision is flipped to "allow" still
emits JSON containing the word "deny" in its reason text, so a substring
test would score a neutered guard as working."""
out = out.strip()
if not out:
return False
try:
d = json.loads(out)
except ValueError:
return False
return ((d.get("hookSpecificOutput") or {}).get("permissionDecision") == "deny")
# A distinctive span of REASON, so a deny that is somehow some other text
# scores as a failure rather than a pass.
MARK = "REPLACES the notes field"
# ─── SELF-CHECK: PROVE THIS MATRIX CAN GO RED ───────────────────────────────
#
# Without this the rows below are unfalsified. Four properties, each learned by
# getting it wrong first in this city (toolkit/patterns/self-check-harness.sh
# carries the general form):
# 1. one mutant must expect GREEN — a table where every row expects RED
# cannot tell a working guard from a suite that stopped running;
# 2. a mutation that did not apply is a FAILURE, not a pass;
# 3. a missing tally is its own failure, never scored — that is how a
# vacuous RED hides;
# 4. the live file is never written; the mutant arrives by env var.
_SELF_CHECK_MUTANTS = [
("deny-neutered", "red",
'"permissionDecision": "deny",',
'"permissionDecision": "allow",'),
("offending-blinded", "red",
" found = offending(cmd)\n",
" found = None\n"),
# THE GREEN CONTROL. Perturbs the file enough to satisfy the did-it-apply
# check, changes no behaviour, and MUST stay green. A control that goes red
# under every treatment is not a flaky control, it is a dead harness.
("harmless-docstring-edit", "green",
'A crash here must not wedge every Bash call in the city',
'A crash here must NOT wedge every Bash call in the city'),
]
SELF_CHECK_RC = 0
if "--self-check" in sys.argv:
src = open(HOOK).read()
for name, expect, frm, to in _SELF_CHECK_MUTANTS:
if src.count(frm) != 1:
print(" self-check FAIL %-24s anchor matched %d times, not 1 — "
"the mutation is vacuous" % (name, src.count(frm)))
SELF_CHECK_RC = 1
continue
mp = os.path.join(_FX, "selfcheck-" + name)
with open(mp, "w") as fh:
fh.write(src.replace(frm, to))
os.chmod(mp, 0o755)
env = dict(os.environ, NOTES_GUARD_UNDER_TEST=mp)
r = subprocess.run([sys.executable, os.path.abspath(__file__)],
capture_output=True, text=True, env=env)
m = re.search(r"(\d+)/(\d+) passed", r.stdout)
if not m:
print(" self-check FAIL %-24s no tally line — the mutant did not "
"RUN, so nothing was measured" % name)
SELF_CHECK_RC = 1
continue
red = r.returncode != 0
want_red = (expect == "red")
ok = (red == want_red)
if not ok:
SELF_CHECK_RC = 1
print(" self-check %-4s %-24s suite went %s as expected (%s passed)"
% ("ok" if ok else "FAIL", name,
"red" if red else "green", m.group(0).split()[0]))
print()
total = fails = 0
def check(want_deny, cmd, label):
global total, fails
total += 1
out = run(cmd)
denied = is_deny(out)
if denied and MARK not in out:
got, ok = "deny:WRONG-TEXT", False
else:
got = "DENY " if denied else "allow"
ok = (denied == want_deny)
if not ok:
fails += 1
print("%-4s %-52s %s" % ("ok" if ok else "FAIL", label, got))
for want, cmd, label in (
# ---- the thing the guard exists for ----
(True, 'bd update bl-x --notes "y"', "bare bd update --notes"),
(True, 'gc bd update bl-x --notes "y"', "gc bd passthrough"),
(True, 'bd update bl-x --notes=y', "--notes= joined form"),
(True, 'bd -C /home/gascity/rigs/examplerig update ta-x --notes y', "bd -C <dir>"),
(True, 'env -u BEADS_DIR bd update bl-x --notes y', "behind env -u"),
(True, 'timeout 60 bd update bl-x --notes y', "behind a wrapper"),
(True, 'ls && bd update bl-x --notes y', "second statement in a chain"),
# ---- the safe spellings, which are the whole reason the rule is narrow ----
(False, 'bd update bl-x --append-notes "y"', "ctrl: --append-notes"),
(False, 'gc bd note bl-x --file /tmp/n.md', "ctrl: bd note --file"),
(False, 'gc bd update bl-x --description "y"', "ctrl: --description"),
(False, 'bd note bl-x -- "---"', "ctrl: bd note"),
(False, 'gc bd comment bl-x "hello"', "ctrl: bd comment"),
(False, 'bd show bl-x', "ctrl: an unrelated bd call"),
# ---- INVOCATION, NOT MENTION. This file, CLAUDE.md and every commit
# message about the ban carry the literal string.
(False, 'git commit -m "never use bd update --notes"', "ctrl: prose in a commit message"),
(False, 'grep -n "bd update --notes" CLAUDE.md', "ctrl: grepping for the ban"),
(False, 'echo bd update bl-x --notes y', "ctrl: an UNQUOTED mention after echo"),
(False, "cat > f <<'EOF'\nbd update x --notes y\nEOF", "ctrl: heredoc body is data"),
# ---- THE STATEMENT SCANNER, BOTH DIRECTIONS. Added 2026-08-28 with the
# quote-aware scan that fixed them; every row below was wrong before.
#
# FALSE POSITIVES: a separator inside a quoted argument is prose. The live
# instance was a `gc bd comment` on the zero-day rehearsal city refused at
# 2026-08-28T20:38:34Z for explaining this very ban.
(False, "gc bd comment bl-x 'foo; bd update y --notes z'", "scan fp: `;` in a single-quoted arg"),
(False, 'gc bd comment bl-x "foo; bd update y --notes z"', "scan fp: `;` in a double-quoted arg"),
(False, 'gc bd comment bl-x "a && bd update y --notes z"', "scan fp: `&&` in a quoted arg"),
(False, 'gc bd comment bl-x "a | bd update y --notes z"', "scan fp: `|` in a quoted arg"),
(False, 'gc bd comment bl-x "a\nbd update y --notes z"', "scan fp: newline in a quoted arg"),
(False, "gc bd comment bl-x $'a; bd update y --notes z'", "scan fp: ANSI-C $'...' quoting"),
# AND THIS ONE IS A CONTROL, NOT A DEMONSTRATION, WHICH IS WORTH SAYING.
# It is the closest reconstruction of the live 2026-08-28 refusal, and it
# was ALLOWED BY THE OLD GUARD TOO — a bare `--notes` after a separator is
# not enough on its own, so the live line must have presented a bd-initial
# fragment in the remainder, and the remainder is long and was truncated at
# 400 characters. The CLASS reproduces in the six rows above; this exact
# byte sequence does not, and the bead says so rather than claiming it.
(False, 'gc bd comment bl-x "close it; --notes replaces existing notes"',
"scan ctrl: the live shape, still not reproduced"),
#
# FALSE NEGATIVES, the worse half: a command SUBSTITUTION is command text
# wearing an argument's costume. Every one of these really does clobber.
(True, 'echo "$(bd update bl-x --notes y)"', "scan fn: $( ) inside double quotes"),
(True, 'echo $(bd update bl-x --notes y)', "scan fn: bare $( )"),
(True, 'echo "`bd update bl-x --notes y`"', "scan fn: backticks"),
(True, 'X=$(ls; bd update bl-x --notes y)', "scan ctrl: separator inside $( ) was already caught"),
(True, 'echo "$(echo "$(bd update bl-x --notes y)")"', "scan fn: nested substitution"),
#
# EVERY ROW ABOVE WAS CHECKED AGAINST BOTH GUARDS, and three labels were
# wrong until it was. Each `fp`/`fn` row must give a DIFFERENT verdict on
# the pre-fix guard than on this one; each `ctrl` row must give the SAME.
# Run it before adding a row here — a row that scores identically on both
# is not testing the change, however well it reads:
#
# git show <pre-fix-rev>:assets/claude-hooks/bd-notes-clobber-guard > /tmp/old
#
# It found: a "live shape" row allowed by both, a substitution row the raw
# split already caught, and — the useful direction — a row filed as a
# control that turned out to be a real FALSE NEGATIVE the fix closes.
#
# AND THE SCAN MUST NOT COST THE OLD ANSWERS.
(True, 'ls; bd update bl-x --notes y', "scan ctrl: real `;` still splits"),
(True, 'echo "quoted" && bd update bl-x --notes y', "scan ctrl: separator after a closed quote"),
(True, 'bd update bl-x --notes "a; b"', "scan ctrl: separator INSIDE the note text"),
(True, 'bd update bl-x 2>&1 --notes y', "scan fn: a redirect is not a separator"),
#
# UNBALANCED QUOTING FALLS BACK TO THE RAW SPLIT — the conservative
# direction: it can over-refuse, loudly, and never under-refuses.
(True, "echo 'unclosed && bd update bl-x --notes y", "scan ctrl: unclosed quote still catches a real one"),
(True, "echo it's fine; bd update bl-x --notes y", "scan ctrl: bare apostrophe unbalances, raw split refuses"),
(False, 'echo "it\'s fine; not a bd update --notes call"', "scan ctrl: apostrophe INSIDE double quotes is fine"),
# ---- THE ESCAPE HATCH IS PART OF THE CONTRACT. If it stops working,
# agents are hard-walled and the block message is lying to them.
(False, 'BD_DESTROY_ALL_PRIOR_NOTES=1 bd update bl-x --notes y', "override: named on the same line"),
(False, 'BD_DESTROY_ALL_PRIOR_NOTES=true gc bd update bl-x --notes y', "override: =true"),
(True, 'BD_DESTROY_ALL_PRIOR_NOTES=0 bd update bl-x --notes y', "override: =0 is not truthy"),
(True, 'BD_DESTROY_ALL_PRIOR_NOTES=1 ls; bd update bl-x --notes y',
"override is scoped to ITS statement"),
(False, 'env BD_DESTROY_ALL_PRIOR_NOTES=1 bd update bl-x --notes y', "override through env"),
):
check(want, cmd, label)
# ─── THE AUDIT RECORD ───────────────────────────────────────────────────────
#
# Our adoption checklist tells a reader to audit this log for false refusals,
# so the log's SHAPE is part of the contract, not decoration. All three
# properties below were absent until 2026-08-28, and the first is what cost
# the bl-l88il investigation a session: the one refusal anybody ever questioned
# was cut at 400 characters, mid-sentence, and had to be recovered from an
# agent's transcript instead.
_ALOG = os.path.join(_FX, "audit.log")
long_note = "z" * 12000
for cmd in ('bd update bl-x --notes "line one\nline two"',
'bd update bl-y --notes "%s"' % long_note,
'BD_DESTROY_ALL_PRIOR_NOTES=1 bd update bl-z --notes "ok"'):
run(cmd, log=_ALOG)
# READ IT DEFENSIVELY, BECAUSE A BLINDED GUARD WRITES NOTHING AT ALL. The
# self-check's `offending-blinded` mutant refuses nothing, so this file does
# not exist — and an unguarded open() there raises, kills the process before
# the tally, and the self-check can only report "the mutant did not RUN". That
# is a true statement about a suite that ran fine and simply crashed, i.e. the
# harness lying about its own subject. Missing reads as empty, and the rows
# below then fail for the reason they are written to fail for.
lines = open(_ALOG).read().splitlines() if os.path.exists(_ALOG) else []
def _kind(i):
"""Field 2 of record i, or "" — never an IndexError that eats the tally."""
if i >= len(lines):
return ""
parts = lines[i].split()
return parts[1] if len(parts) > 1 else ""
def _rec(i):
return lines[i] if i < len(lines) else ""
for want, got, label in (
(3, len(lines), "one line per record, even with an embedded newline"),
(1, sum(1 for i in range(len(lines)) if _kind(i) == "OVERRIDE"), "the override is recorded, not silent"),
(2, sum(1 for i in range(len(lines)) if _kind(i) == "BLOCKED"), "both refusals are recorded"),
):
total += 1
ok = (want == got)
if not ok:
fails += 1
print("%-4s %-52s %s" % ("ok" if ok else "FAIL", "log: " + label,
"%s" % got if ok else "got %r, want %r" % (got, want)))
for want, got, label in (
(True, "line one\\nline two" in _rec(0),
"an embedded newline is ESCAPED, not written raw"),
(True, "[TRUNCATED," in _rec(1),
"a cut record SAYS it was cut — a partial that looks whole is the bug"),
(True, len(_rec(1)) > 4000,
"the cap is far past the 400 that lost the last investigation"),
(False, "[TRUNCATED," in _rec(0),
"a short record is not marked as cut"),
):
total += 1
ok = (want == got)
if not ok:
fails += 1
print("%-4s %-52s %s" % ("ok" if ok else "FAIL", "log: " + label, "ok" if ok else "WRONG"))
# ─── MUTATION: PROVE THE SCANNER AND THE INVOCATION TEST ARE LOAD-BEARING ───
#
# The rows above are ALLOWs for the most part, and an ALLOW is silence: a guard
# that never fires scores identically. These four mutants each remove one thing
# and require the matrix to move in a NAMED direction.
src = open(HOOK).read()
for label, frm, to, probes, want_deny in (
# The scan is what stops the false positive: force the raw fallback and the
# refusal that started all this comes straight back.
("scan-off", " parts = scan_statements(text)\n", " parts = None\n",
(("gc bd comment bl-x 'foo; bd update y --notes z'", "raw split re-refuses prose"),
('gc bd comment bl-x "a && bd update y --notes z"', "raw split, double quotes")),
True),
# Harvesting substitution bodies is what stops the false negative.
("subs-off", " for s in subs:\n", " for s in []:\n",
(('echo "$(bd update bl-x --notes y)"', "substitution goes unjudged"),
('echo "`bd update bl-x --notes y`"', "backticks go unjudged")),
False),
# The invocation test is what keeps documentation editable. Note which
# probes can show that and which cannot: a mention inside a QUOTED argument
# is protected twice over, by this test AND by tokenisation, so it stays
# allowed under the mutant and proves nothing. Written that way first, and
# the two rows passed while testing nothing. The probes have to be UNQUOTED
# mentions, where the invocation test is the only thing standing there.
("invocation-off",
" args = invokes_bd(tokens, env)\n if args is None:\n continue\n",
" args = invokes_bd(tokens, env)\n if args is None:\n args = tokens\n",
(("echo bd update bl-x --notes y", "an unquoted mention after echo"),
("printf bd update bl-x --notes y", "and after printf")),
True),
# And the heredoc strip, for the same reason, by a different route.
("heredoc-strip-off", " for stmt in statements(cmd):\n",
" for stmt in statements(cmd.replace('<<', '< <')):\n",
(("cat > f <<'EOF'\nbd update x --notes y\nEOF", "heredoc body judged as commands"),),
True),
):
if src.count(frm) != 1:
print("FAIL %-52s %s" % (label + " anchor",
"matched %d times, not 1 — vacuous" % src.count(frm)))
fails += 1
total += 1
continue
mp = os.path.join(_FX, "mutant-" + label)
with open(mp, "w") as fh:
fh.write(src.replace(frm, to))
os.chmod(mp, 0o755)
for cmd, what in probes:
total += 1
out = run(cmd, hook=mp)
denied = is_deny(out)
ok = (denied == want_deny)
if not ok:
fails += 1
print("%-4s %-52s %s" % ("ok" if ok else "FAIL", "%s: %s" % (label, what),
"guard is load-bearing" if ok
else ("STILL DENIES — fires without it" if denied
else "NO LONGER DENIES — it was dead code")))
print("\n%d/%d passed" % (total - fails, total))
sys.exit(1 if (fails or SELF_CHECK_RC) else 0)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment