Closes two issues that are one mechanism: the guard meant to keep pytest scripts/tests out of the
shared hook-fire log had a racy oracle (#822) and per-test scope (#809). Both issue bodies
independently arrive at the same answer — make the shared path unreachable rather than watch it — so
fixing them separately would have built the mechanism twice.
What was wrong
#822 — the oracle was global mutable state. The guard snapshotted st_mtime_ns across ~/.cache/ersatztv/hook-fire/ and required no change. That directory is written by every live
Claude Code session on the machine, so an unrelated session firing a hook inside the window failed
the suite with a test run modified the production hook-fire log — an accusation about the suite
when the writer was another process. Seen on three separate branches, green on every re-run.
#809 — the isolation was per test. The autouse fixture runs at test setup, after every module
has been imported, so it never reached a module holding a {**os.environ} snapshot (the #785
defect).
What measuring changed about the diagnosis.#809 proposes a source-text scan for module-level
dicts, flagging in advance that such a predicate costs rounds. Instrumenting subprocess.Popen
across a full run and asking which launches resolve to $HOME/.cache/ersatztv/hook-fire found 83 — 37 during collection and 44 inside module- or session-scoped fixtures, which are set
up outside any single test and so were never covered by the autouse fixture at all; only 2
resembled the route the issue names. A source scan would have reported the suite clean.
The fix — two layers, closing different routes
pytest_configure sets ETV_HOOK_FIRE_LOG_DIRbefore collection. Ordering verified by
execution: a module-level {**os.environ} does carry a value set there.
A subprocess.Popen wrapper fails any launch that does not carry the isolated directory.
A whitelist, not a blacklist, and that is the point. The sink's default has two branches, and a
comparison against a single resolved production path models one and is blind to the other: ${HOME:-/tmp} means a child with no HOME lands in /tmp/.cache/ersatztv/hook-fire, equally
shared and equally persistent — 81 of the 83 from-scratch launches were exactly that shape.
Requiring the variable covers both branches and does not depend on what HOME is on the machine.
Its corollary is that a from-scratch environment must carry the variable, one line each.
The resolver reimplements one shell expansion and fails open, so it is differential-tested
against etv_hook_fire_log_dir every run — including the empty-value cases where dict.get(k, d)
and ${k:-d} disagree, and normalisation cases where shell concatenation and Path spell the same
directory differently, which is why it compares realpath identity rather than bytes.
Verification
PYTHONPATH=. python3 -m pytest scripts/tests -q — 1224 passed / 2 skipped on this branch's
base, 1228 / 2 here (four added tests). Zero unintended guard rejections.
Same two guard files green with HOME unset, HOME=/tmp, and HOME="" (96 passed each) —
the false-positive scenario cold review raised, answered by execution.
Reds witnessed, each by disarming one clause and restoring it: the production-path blacklist
leaves the /tmp branch unguarded; parent-anchored resolution leaves a relative value with a
child cwd unguarded; equality-instead-of-containment clears a subdirectory of a shared log;
dropping the env/cwd boundary normalisation crashes on a legal bytes launch; ignoring the child's cwd leaves the shape test green until the binding assertions were added; removing the
pre-collection assignment leaks an import-time snapshot into a fake HOME; removing the wrapper
install un-guards a launch; breaking the resolver's empty-value semantics reddens the differential;
with both layers off the cross-suite assertion fires on its own.
#822's race reproduced on demand: with a thread touching a file in the shared directory, the
withdrawn mtime oracle goes RED while the replacement stays GREEN and records provably land in the
isolated dir.
ruff check / ruff format --check / pyright clean. (One pre-existing pyright complaint in a
stub HTTP handler is present at origin/main too.)
Review
Six cold adversarial rounds, in isolated worktrees, alternating same-family and cross-family
(Codex / GPT-5.6). Five of them found a real defect, several created by the previous round's fix:
the unguarded /tmp branch; HOME-dependent false positives; a double-counted census (wrapping subprocess.run as well as Popen counted every launch twice); a relative-cwd bypass;
equality-not-containment; a TypeError on a legal bytes cwd; and its twin on a bytes env. Round
six found no code or documentation defects and independently re-derived the disputed launch
count as 65 rather than 345, confirming the 83 = 65+14+2+2 breakdown.
Where two consecutive rounds produced findings created by the previous fix, this applies the
corpus's own STOP-AND-SUBTRACT rule rather than adding another layer: the file-descriptor cwd
branch was deleted (it guarded an input Popen rejects, and was dead anyway), and the last pass
subtracts prose — the durable docs no longer restate refuted figures or carry round-by-round
chronology, which docs.no-session-narrative puts in the commit message. Traps and rejected
approaches stay, per that rule's carve-out.
Found while writing the tests
pytest's assert rewriting printed the whole environment — API keys included — into the failure
output, because the violation was computed inside the assert. Binding it first fixes it;
verified by grepping the output for a key name (0 occurrences).
os.environ["HOME"] would KeyError where HOME is unset — a legal environment the sink has a ${HOME:-/tmp} default for. Fixed at both sites.
The cwd shape test was vacuous: it used an absolute log dir, which short-circuits before cwd
is consulted at all.
Docs
docs/guard-inventory.md — this guard's narrative rewritten to the closed state, with both
withdrawn shapes recorded so neither is re-adopted.
New record testing.suite-isolated-from-production-hook-fire-log, catalog regenerated.
Stale prose corrected in test_worktree_ownership_guard.py, test_guard_populations_derive_from_git.py, scripts/hook-fire-log.sh and docs/README.md.
Closes two issues that are one mechanism: the guard meant to keep `pytest scripts/tests` out of the
shared hook-fire log had a racy oracle (#822) and per-test scope (#809). Both issue bodies
independently arrive at the same answer — make the shared path unreachable rather than watch it — so
fixing them separately would have built the mechanism twice.
## What was wrong
**#822 — the oracle was global mutable state.** The guard snapshotted `st_mtime_ns` across
`~/.cache/ersatztv/hook-fire/` and required no change. That directory is written by every live
Claude Code session on the machine, so an unrelated session firing a hook inside the window failed
the suite with `a test run modified the production hook-fire log` — an accusation about the suite
when the writer was another process. Seen on three separate branches, green on every re-run.
**#809 — the isolation was per test.** The autouse fixture runs at test setup, after every module
has been imported, so it never reached a module holding a `{**os.environ}` snapshot (the #785
defect).
**What measuring changed about the diagnosis.** #809 proposes a source-text scan for module-level
dicts, flagging in advance that such a predicate costs rounds. Instrumenting `subprocess.Popen`
across a full run and asking which launches resolve to `$HOME/.cache/ersatztv/hook-fire` found
**83** — **37** during collection and **44** inside module- or session-scoped fixtures, which are set
up outside any single test and so were never covered by the autouse fixture at all; only **2**
resembled the route the issue names. A source scan would have reported the suite clean.
## The fix — two layers, closing different routes
1. `pytest_configure` sets `ETV_HOOK_FIRE_LOG_DIR` **before collection**. Ordering verified by
execution: a module-level `{**os.environ}` does carry a value set there.
2. A `subprocess.Popen` wrapper fails any launch that does not **carry** the isolated directory.
**A whitelist, not a blacklist, and that is the point.** The sink's default has two branches, and a
comparison against a single resolved production path models one and is blind to the other:
`${HOME:-/tmp}` means a child with no `HOME` lands in `/tmp/.cache/ersatztv/hook-fire`, equally
shared and equally persistent — **81 of the 83** from-scratch launches were exactly that shape.
Requiring the variable covers both branches and does not depend on what `HOME` is on the machine.
Its corollary is that a from-scratch environment must carry the variable, one line each.
The resolver reimplements one shell expansion and **fails open**, so it is differential-tested
against `etv_hook_fire_log_dir` every run — including the empty-value cases where `dict.get(k, d)`
and `${k:-d}` disagree, and normalisation cases where shell concatenation and `Path` spell the same
directory differently, which is why it compares `realpath` identity rather than bytes.
## Verification
- `PYTHONPATH=. python3 -m pytest scripts/tests -q` — **1224 passed / 2 skipped** on this branch's
base, **1228 / 2** here (four added tests). Zero *unintended* guard rejections.
- Same two guard files green with **`HOME` unset, `HOME=/tmp`, and `HOME=""`** (96 passed each) —
the false-positive scenario cold review raised, answered by execution.
- **Reds witnessed, each by disarming one clause and restoring it:** the production-path blacklist
leaves the `/tmp` branch unguarded; parent-anchored resolution leaves a relative value with a
child `cwd` unguarded; equality-instead-of-containment clears a subdirectory of a shared log;
dropping the env/cwd boundary normalisation crashes on a legal bytes launch; ignoring the child's
`cwd` leaves the shape test green until the binding assertions were added; removing the
pre-collection assignment leaks an import-time snapshot into a fake `HOME`; removing the wrapper
install un-guards a launch; breaking the resolver's empty-value semantics reddens the differential;
with both layers off the cross-suite assertion fires on its own.
- **#822's race reproduced on demand:** with a thread touching a file in the shared directory, the
withdrawn mtime oracle goes RED while the replacement stays GREEN and records provably land in the
isolated dir.
- `ruff check` / `ruff format --check` / `pyright` clean. (One pre-existing pyright complaint in a
stub HTTP handler is present at `origin/main` too.)
## Review
**Six cold adversarial rounds**, in isolated worktrees, alternating same-family and cross-family
(Codex / GPT-5.6). Five of them found a real defect, several created by the previous round's fix:
the unguarded `/tmp` branch; `HOME`-dependent false positives; a **double-counted census** (wrapping
`subprocess.run` as well as `Popen` counted every launch twice); a relative-`cwd` bypass;
equality-not-containment; a `TypeError` on a legal bytes `cwd`; and its twin on a bytes `env`. Round
six found **no code or documentation defects** and independently re-derived the disputed launch
count as 65 rather than 345, confirming the `83 = 65+14+2+2` breakdown.
Where two consecutive rounds produced findings created by the previous fix, this applies the
corpus's own STOP-AND-SUBTRACT rule rather than adding another layer: the file-descriptor `cwd`
branch was **deleted** (it guarded an input `Popen` rejects, and was dead anyway), and the last pass
subtracts *prose* — the durable docs no longer restate refuted figures or carry round-by-round
chronology, which `docs.no-session-narrative` puts in the commit message. Traps and rejected
approaches stay, per that rule's carve-out.
## Found while writing the tests
- pytest's assert rewriting printed the **whole environment — API keys included** — into the failure
output, because the violation was computed inside the `assert`. Binding it first fixes it;
verified by grepping the output for a key name (0 occurrences).
- `os.environ["HOME"]` would `KeyError` where `HOME` is unset — a legal environment the sink has a
`${HOME:-/tmp}` default for. Fixed at both sites.
- The cwd shape test was **vacuous**: it used an absolute log dir, which short-circuits before `cwd`
is consulted at all.
## Docs
- `docs/guard-inventory.md` — this guard's narrative rewritten to the closed state, with both
withdrawn shapes recorded so neither is re-adopted.
- New record `testing.suite-isolated-from-production-hook-fire-log`, catalog regenerated.
- Stale prose corrected in `test_worktree_ownership_guard.py`,
`test_guard_populations_derive_from_git.py`, `scripts/hook-fire-log.sh` and `docs/README.md`.
fixes #809
fixes #822
The guard meant to keep `pytest scripts/tests` out of the shared hook-fire log
had two independent defects, and both issue bodies land on the same answer: make
the shared path unreachable rather than watch it.
#822 — its oracle was global mutable state. It snapshotted `st_mtime_ns` across
`~/.cache/ersatztv/hook-fire/` and required no change, but that directory is
written by every live Claude Code session on the machine, so an unrelated
session firing a hook inside the window failed the suite with `a test run
modified the production hook-fire log`. Seen on three separate branches, green
on every immediate re-run. Reproduced on demand while writing this: with a
thread touching a file there, the withdrawn oracle goes RED and the replacement
stays GREEN.
#809 — its isolation was per test. The autouse fixture runs at test setup, after
every module has been imported, so it never reached a module holding a
`{**os.environ}` snapshot (#785, 58 records per run).
Measuring changed the diagnosis twice. #809 proposes a source-text scan for
module-level dicts, flagging in advance that such a predicate costs rounds.
Instrumenting `subprocess.Popen` across a full run and asking which launches
resolve to `$HOME/.cache/ersatztv/hook-fire` found 83 — 37 during collection and
44 inside module- or session-scoped fixtures, which are set up outside any single
test and so were never covered by the autouse fixture at all; only 2 resembled
the route the issue names. A source scan would have reported the suite clean.
Instrument `Popen` ALONE: `subprocess.run` reaches it, so a census wrapping both
counts every launch twice. The first one here did, and published 166/74/88/4
until cold review refuted it as exactly 2x — the figures above are a single
re-measurement on this branch's own base. Every count is now quoted with the
predicate that produced it, because the from-scratch launches also number 83: a
different set of the same size, which is precisely what a later reader
reconciles wrongly.
So: `pytest_configure` sets `ETV_HOOK_FIRE_LOG_DIR` before collection (ordering
verified by execution, not read), and a `Popen` wrapper fails any launch that
does not CARRY the isolated directory.
That second rule is deliberately "carries an isolated dir" and not "is not the
production one" — cold review found the first draft's blacklist modelled one of
the sink's two default branches and was blind to the other. `${HOME:-/tmp}`
means a child with no HOME lands in `/tmp/.cache/ersatztv/hook-fire`, which is
equally shared and equally persistent; measured, 81 of the 83 from-scratch
launches were exactly that shape. The whitelist covers both branches and does not depend
on what HOME is on the machine running the suite — which is also what stops it
firing where HOME is unset or is itself /tmp. A second round then found the same
blind spot surviving in the clause that rejects an explicit WRONG value, which
compared against the $HOME path alone; it now rejects both shared logs. Its
corollary is that a from-scratch environment must carry the variable, one line
each. No count of those sites is kept in the docs: it is a hand-written
population nothing derives, and this commit falsified its own by adding sites in
the tests that prove the rule. The guard enumerates them.
The resolver reimplements one shell expansion and fails OPEN, so it is
differential-tested against `etv_hook_fire_log_dir` every run — the empty-value
cases where `dict.get(k, d)` and `${k:-d}` disagree, and the normalisation cases
where shell concatenation and `Path` spell the same directory differently, which
is why it compares realpath identity rather than bytes.
Proofs, each witnessed red by disarming one clause and restoring it: reverting
to the production-path blacklist leaves the `/tmp` branch unguarded (DID NOT
RAISE); removing the pre-collection assignment makes an import-time snapshot
leak into a fake HOME (both arms nested pytest, launch guard disabled in both so
the comparison is about that clause alone); removing the wrapper install
un-guards a launch; breaking the resolver's empty-value semantics reddens the
differential; and with both layers off the cross-suite assertion fires on its
own. A fourth round, again on two fronts, found the `cwd` plumbing raising
`TypeError` on bytes — a shape `Popen` legally accepts — so a legal launch
crashed the guard instead of being judged. The first fix for that also added a
branch for an int file descriptor, and cold review then measured two things: a
descriptor is NOT a launch shape (`Popen` calls `os.fsencode(cwd)`, which rejects
an int), and the branch was dead anyway because `os.fspath` already raises on
one — deleting it left its own test green. Two consecutive rounds whose finding
was created by the previous round's fix is this corpus's stated trigger to
subtract rather than guard, so the branch and its unreachable fail-closed arm are
gone; `cwd` is normalised across the three shapes that exist, and the premise
that there are three is pinned by a test that reddens if CPython ever adds a
fourth. The same round caught that test being vacuous — it used an ABSOLUTE log
dir, which short-circuits before `cwd` is consulted at all.
A fifth round found the last of that family: `Popen` also accepts a bytes-keyed
or bytes-valued `env` on POSIX, and the guard rejected one such launch as
carrying no isolated dir and crashed on the other — both reds on a correctly
isolated child. Fixed at the boundary rather than per lookup: the environment is
normalised to str once, in one place, and a test pins that the normalisation
does not blunt the guard. That round also showed the cwd shape test proved only
"does not crash": ignoring the child's `cwd` entirely left it green, so it now
judges the same relative value against two different cwds and requires two
different verdicts.
One finding of that round is recorded as REFUTED rather than fixed: it reported
the from-scratch launch count as 391 rather than 83. Re-measured directly —
instrumenting `Popen` and running `test_ci_dropped_step_guard.py` alone gives 65
launches, matching the census artifact, against the 345 claimed for that file.
The 83 = 65 + 14 + 2 + 2 breakdown stands.
Five rounds each falsified another hand-written claim in the prose, so the last
pass subtracts prose rather than adding more: the durable docs no longer restate
refuted figures or carry round-by-round chronology, which is `docs.no-session-
narrative` — that belongs here, in the commit message. The traps and the rejected
approaches stay, per that rule's carve-out. Full suite
green (1224 passed/2 skipped on this branch's base, 1228/2 after — the four added
tests) with zero UNINTENDED guard rejections — the only ones are those the
launch-guard test provokes on purpose —
so the strictness costs nothing on a clean checkout.
A third round, this one cross-family, found the same blind spot once more in a
form neither earlier round reached: a RELATIVE `ETV_HOOK_FIRE_LOG_DIR` resolves
against the CHILD's working directory, so `.cache/ersatztv/hook-fire` with
`cwd=$HOME` lands exactly on the shared log while a guard anchoring it at its own
cwd sees an unrelated path under the repo and clears it. Reproduced, then closed
by anchoring at the launch's `cwd=`; both `env` and `cwd` positions are read from
`inspect.signature` rather than hand-counted. Witnessed red by reverting to
parent-anchored resolution. That round also caught a count left at 82 in one file
where every other copy said 83, and two docstrings still describing records as
leaking into the REAL log when they now land in the session directory.
Two defects found in the new tests while writing them. pytest's assert rewriting
printed the whole environment — API keys included — into the failure output,
because the violation was computed inside the `assert` expression. And
`os.environ["HOME"]` would KeyError where HOME is unset, which is a legal
environment the sink has a `${HOME:-/tmp}` default for.
fixes#809fixes#822
Decisions-Edit: yes
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RK43vYYMULNirkJ824ggud
Six cold rounds (3 cross-family). Round 6: no code or doc defects; independently re-derived the disputed launch count as 65, confirming 83=65+14+2+2. Suite 1228/2, green with HOME unset//tmp/empty. Nine clause mutations witnessed red.
Review-verdict: MERGEABLE @ 1d14161
Six cold rounds (3 cross-family). Round 6: no code or doc defects; independently re-derived the disputed launch count as 65, confirming 83=65+14+2+2. Suite 1228/2, green with HOME unset//tmp/empty. Nine clause mutations witnessed red.
timothy
merged commit b6b3520bdb into main2026-08-29 04:32:27 +02:00
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
Closes two issues that are one mechanism: the guard meant to keep
pytest scripts/testsout of theshared hook-fire log had a racy oracle (#822) and per-test scope (#809). Both issue bodies
independently arrive at the same answer — make the shared path unreachable rather than watch it — so
fixing them separately would have built the mechanism twice.
What was wrong
#822 — the oracle was global mutable state. The guard snapshotted
st_mtime_nsacross~/.cache/ersatztv/hook-fire/and required no change. That directory is written by every liveClaude Code session on the machine, so an unrelated session firing a hook inside the window failed
the suite with
a test run modified the production hook-fire log— an accusation about the suitewhen the writer was another process. Seen on three separate branches, green on every re-run.
#809 — the isolation was per test. The autouse fixture runs at test setup, after every module
has been imported, so it never reached a module holding a
{**os.environ}snapshot (the #785defect).
What measuring changed about the diagnosis. #809 proposes a source-text scan for module-level
dicts, flagging in advance that such a predicate costs rounds. Instrumenting
subprocess.Popenacross a full run and asking which launches resolve to
$HOME/.cache/ersatztv/hook-firefound83 — 37 during collection and 44 inside module- or session-scoped fixtures, which are set
up outside any single test and so were never covered by the autouse fixture at all; only 2
resembled the route the issue names. A source scan would have reported the suite clean.
The fix — two layers, closing different routes
pytest_configuresetsETV_HOOK_FIRE_LOG_DIRbefore collection. Ordering verified byexecution: a module-level
{**os.environ}does carry a value set there.subprocess.Popenwrapper fails any launch that does not carry the isolated directory.A whitelist, not a blacklist, and that is the point. The sink's default has two branches, and a
comparison against a single resolved production path models one and is blind to the other:
${HOME:-/tmp}means a child with noHOMElands in/tmp/.cache/ersatztv/hook-fire, equallyshared and equally persistent — 81 of the 83 from-scratch launches were exactly that shape.
Requiring the variable covers both branches and does not depend on what
HOMEis on the machine.Its corollary is that a from-scratch environment must carry the variable, one line each.
The resolver reimplements one shell expansion and fails open, so it is differential-tested
against
etv_hook_fire_log_direvery run — including the empty-value cases wheredict.get(k, d)and
${k:-d}disagree, and normalisation cases where shell concatenation andPathspell the samedirectory differently, which is why it compares
realpathidentity rather than bytes.Verification
PYTHONPATH=. python3 -m pytest scripts/tests -q— 1224 passed / 2 skipped on this branch'sbase, 1228 / 2 here (four added tests). Zero unintended guard rejections.
HOMEunset,HOME=/tmp, andHOME=""(96 passed each) —the false-positive scenario cold review raised, answered by execution.
leaves the
/tmpbranch unguarded; parent-anchored resolution leaves a relative value with achild
cwdunguarded; equality-instead-of-containment clears a subdirectory of a shared log;dropping the env/cwd boundary normalisation crashes on a legal bytes launch; ignoring the child's
cwdleaves the shape test green until the binding assertions were added; removing thepre-collection assignment leaks an import-time snapshot into a fake
HOME; removing the wrapperinstall un-guards a launch; breaking the resolver's empty-value semantics reddens the differential;
with both layers off the cross-suite assertion fires on its own.
withdrawn mtime oracle goes RED while the replacement stays GREEN and records provably land in the
isolated dir.
ruff check/ruff format --check/pyrightclean. (One pre-existing pyright complaint in astub HTTP handler is present at
origin/maintoo.)Review
Six cold adversarial rounds, in isolated worktrees, alternating same-family and cross-family
(Codex / GPT-5.6). Five of them found a real defect, several created by the previous round's fix:
the unguarded
/tmpbranch;HOME-dependent false positives; a double-counted census (wrappingsubprocess.runas well asPopencounted every launch twice); a relative-cwdbypass;equality-not-containment; a
TypeErroron a legal bytescwd; and its twin on a bytesenv. Roundsix found no code or documentation defects and independently re-derived the disputed launch
count as 65 rather than 345, confirming the
83 = 65+14+2+2breakdown.Where two consecutive rounds produced findings created by the previous fix, this applies the
corpus's own STOP-AND-SUBTRACT rule rather than adding another layer: the file-descriptor
cwdbranch was deleted (it guarded an input
Popenrejects, and was dead anyway), and the last passsubtracts prose — the durable docs no longer restate refuted figures or carry round-by-round
chronology, which
docs.no-session-narrativeputs in the commit message. Traps and rejectedapproaches stay, per that rule's carve-out.
Found while writing the tests
output, because the violation was computed inside the
assert. Binding it first fixes it;verified by grepping the output for a key name (0 occurrences).
os.environ["HOME"]wouldKeyErrorwhereHOMEis unset — a legal environment the sink has a${HOME:-/tmp}default for. Fixed at both sites.cwdis consulted at all.
Docs
docs/guard-inventory.md— this guard's narrative rewritten to the closed state, with bothwithdrawn shapes recorded so neither is re-adopted.
testing.suite-isolated-from-production-hook-fire-log, catalog regenerated.test_worktree_ownership_guard.py,test_guard_populations_derive_from_git.py,scripts/hook-fire-log.shanddocs/README.md.fixes #809
fixes #822
Review-verdict: MERGEABLE @
1d14161Six cold rounds (3 cross-family). Round 6: no code or doc defects; independently re-derived the disputed launch count as 65, confirming 83=65+14+2+2. Suite 1228/2, green with HOME unset//tmp/empty. Nine clause mutations witnessed red.