fix(609): close two false-arm holes found in cross-family review

Codex review of the first attempt found both, and both were in the git plumbing
that my unit tests never touched -- they only exercised the pure predicate.

1. HIGH: git's `%s` is the first PARAGRAPH, not the first line. It joins
   consecutive non-blank lines with spaces, so
     `fix: harmless subject`
     `This explains [decisions-edit] on line two.`
   came back as ONE line containing the token and armed it -- the exact
   false-arm this change exists to prevent. The first line is now taken from
   `%B` via `subject_of()`.

2. HIGH: the in-band `\x1f`/`\x1e` field separators were injectable. A subject
   containing a literal `\x1f` was split at the wrong place and its tail read as
   a trailer, arming the token. Framing is now NUL, which git forbids inside a
   commit message and which therefore cannot be injected, with exact-arity
   parsing (fields must be a multiple of three) that refuses to arm otherwise.

Also from the same review:
- Refuse to arm on a `%(trailers:...)` atom echoed literally by a git older than
  2.22, which would otherwise read as a non-empty trailer (exit 0, so `_run`
  returns it rather than None).
- Record corrected: 38 subject-tokened commits in ancestry, not "twenty"; and it
  no longer claims a blanket fail-safe -- `_token_armed` failing is safe, but the
  surrounding `_diff_findings` fails open earlier on an unresolvable merge-base,
  skipping every check. That predates this change.

Adds 8 integration tests that drive `_token_armed` against a real throwaway git
repo -- the gap that let both defects pass. Verified non-vacuous by
reconstructing the old implementation in memory: it arms on both inputs, the new
one does not.

Note `--format` uses git's `%x00` escape, not a literal NUL: a NUL in argv raises
ValueError from subprocess, which broke every diff-engine test until fixed.
This commit is contained in:
2026-07-25 18:25:26 +02:00
parent 4596603020
commit 243bec708d
3 changed files with 147 additions and 16 deletions
+35 -9
View File
@@ -232,7 +232,14 @@ def _archive_records_at(ref: str):
_TRAILER_KEY = "Decisions-Edit"
_REC_SEP, _FIELD_SEP = "\x1e", "\x1f"
# NUL framing, not \x1f/\x1e. git forbids NUL inside a commit message, so it is the ONLY separator
# a crafted (or merely odd) message cannot inject: a subject containing a literal \x1f would
# otherwise be split at the wrong place and its tail read as a trailer — falsely ARMING the token,
# which is the dangerous direction because arming silently disables the guard.
_NUL = "\x00" # what we SPLIT the output on
_NUL_FMT = "%x00" # what goes in the --format string; a literal NUL in argv raises ValueError
_FIELDS_PER_COMMIT = 3
_UNEXPANDED_ATOM = "%(trailers"
def token_armed_in(subject: str, trailer: str) -> bool:
@@ -253,25 +260,44 @@ def token_armed_in(subject: str, trailer: str) -> bool:
return bool(trailer.strip())
def subject_of(body: str) -> str:
"""The commit's true first line.
NOT git's `%s`: that atom is the first *paragraph*, joining consecutive non-blank lines with
spaces, so `fix: harmless\\nprose about [decisions-edit]\\n\\n…` would come back as one line
containing the token and arm it. Take the first line ourselves from `%B`.
"""
return body.lstrip("\n").split("\n", 1)[0].strip()
def _token_armed(mb: str, head: str) -> bool:
"""True if ANY commit in mb..head carries the token. Unresolvable git → False (guard runs)."""
"""True if ANY commit in mb..head carries the token. Anything unexpected → False (guard runs)."""
out = _run(
[
"git",
"log",
f"--format=%s{_FIELD_SEP}%(trailers:key={_TRAILER_KEY},valueonly){_REC_SEP}",
f"--format=%H{_NUL_FMT}%B{_NUL_FMT}%(trailers:key={_TRAILER_KEY},valueonly){_NUL_FMT}",
f"{mb}..{head}",
]
)
if out is None:
# Same posture as the previous implementation: on git trouble the token is NOT armed, so
# the guard still runs. Failing the other way would silently disable it.
# On git trouble the token is NOT armed, so the guard still runs. Failing the other way
# would silently disable it — the bug this whole function exists to fix.
return False
for entry in out.split(_REC_SEP):
if not entry.strip():
parts = out.split(_NUL)
if parts and not parts[-1].strip():
parts.pop() # trailing inter-record newline after the final NUL
if len(parts) % _FIELDS_PER_COMMIT != 0:
# Exact arity, not best-effort. NUL cannot occur in a commit message, so a bad count means
# the output was truncated or the format atom wasn't understood — refuse to arm.
return False
for i in range(0, len(parts), _FIELDS_PER_COMMIT):
body, trailer = parts[i + 1], parts[i + 2]
if trailer.strip().startswith(_UNEXPANDED_ATOM):
# git too old to expand %(trailers:...): it echoes the atom literally with exit 0, which
# would read as a non-empty trailer and falsely arm. Treat as unarmed.
continue
subject, _, trailer = entry.partition(_FIELD_SEP)
if token_armed_in(subject.strip(), trailer):
if token_armed_in(subject_of(body), trailer):
return True
return False