20 KiB
Q45 §9 follow-on — the pwd channel: a home-directory read the redirect cannot reach
Screen. Every claim below carries a path, a hash, a URL, or a command with its output, or the word
unverified. Nothing secret is printed anywhere in this record or in the files it lands.
Merlin, Helm lane, 2026-10-14 ~01:1x–01:3xZ (clock read at 01:29:06Z immediately before this write).
Scratch tree /root/crucible/q55/; nothing deployed, no cp into /root/.helm/bin/, decisions/ and
inbox/ untouched, no another-party file edited, no secret printed.
The class
Q52 redirected HOME and USERPROFILE inside the one rebuild function that already served the admission
probe and the legs, and registered one check: when the subject reads the home database, the legs' HOME
must be the sandbox home. The redirect covers Path.home() and expanduser because CPython reads
HOME first. It cannot reach pwd.getpwuid(os.getuid()).pw_dir, whose answer comes from the user
database. A subject composing its root from a pw_dir read therefore passes the Q52 check — the check
asserts a property of the gate's redirect, not of the subject's resolution — on a subject whose root
is the live root.
The Q45 §9 record named this as its first limit ("a subject reading pwd.getpwuid(...).pw_dir directly
bypasses HOME and the redirect cannot see it"). This run measures what that limit costs and closes it.
The pair
Base gate: /root/crucible/q52/helm-selftest-q52.py, sha256
78b290ecf2d93e0cc3087e5ede8baef5c10ca52986706d5da69a13a619dbdd9f (140,365 B / 2,677 lines / 126
check(" registrations), copied into q55/ and re-verified by sha256sum before any run.
- Collector: byte-identical to Q45 §9's (
4e45021d80395d3f…, 720 / 29,954, at/root/crucible/q48/helm-collect.py) — every change is in the gate. - Gate
cd43284ff009d1cceb3aaa6a65b701d1076eaf801e32b705672601b4122b746a(143,385 B / 2,735 lines / 126 registrations — unchanged) against the Q52 base: one edit, a 3,043-char block minus its 23-char anchor = +3,020 B / +58 lines, and 2,677 + 58 = 2,735 exactly. - Disarmed twin
8adad99bdc872ecd09ddea88ae55b5fc55a3886571ffaa5f49a053b52430586d(143,416 B / 2,735 lines / 126) — one line different (if _q45S9_pwd_sites:→if False: # Q45S9 disarmed: the pwd refusal is cut), and 31 B larger than the gate it disarms, the Q52-twin pattern (a comment where a condition was). - The gate's internal label is
Q45S9, not a queue number: the lane's own logged lesson (Q45 §9 Run 19) is that an ad-hoc item label must not collide with a Q number another lane can open. The scratch tree keeps its build-order nameq55; the refusal prefix, the twin comment and the block docstring carry the section id. The rename happened before any readback or push, so the digests above are the only ones this record carries.
The fixtures, and the interpreter's verdict
Two transforms of the real collector (make_fixtures_q55.py, each asserted to land exactly once):
| fixture | root assignment | sha256 | bytes |
|---|---|---|---|
collector-pwdir-named.py |
HELM_HOME = Path(pwd.getpwuid(os.getuid()).pw_dir) / ".helm" |
4d62da91d94fc010a99d6c157b28b14bef86333afc91336d6b8d7319f3fb14ec |
29,994 |
collector-pwdir-root.py |
ROOT = Path(pwd.getpwuid(os.getuid()).pw_dir) / ".helm" (every HELM_HOME identifier renamed) |
8b368eadb857dbaf82bb1d2c795778a04b243c1e5ee2527b903ccec508687b3b |
29,979 |
Run under a scratch HOME (python3 <fixture> via runpy, sys.argv = ['x', '--skip-tests']), both
resolve ROOT /root/.helm and HELD_DIR /root/.helm/data/held — the live root — with rc 0. The class
is measured against the interpreter, not asserted: ALL PWD FIXTURES RESOLVE THE LIVE ROOT DESPITE A
SCRATCH HOME.
The second fixture removes the one thing that would have caught the class in a default run: Q45's
foot check ("no module-level constant of the copy resolves inside the live data root") fails on
ROOT either way — but a foot check reports after the legs; only a precondition refuses before them.
Q35's own lesson, in the one place the Q35 fix did not reach.
The defect, measured on the shipped gate before the change
collector-pwdir-root.py under the shipped Q52 gate, default run (out-pwr.txt): rc 1,
175 passed / 1 failed — the FAIL is Q45 (values={"ROOT": "/root/.helm", "SNAPSHOT_PATH": "<sandbox>/data/snapshot.json", "HELD_DIR": "/root/.helm/data/held", …}, escapes={"ROOT": "/root/.helm", "HELD_DIR": "/root/.helm/data/held"}), while Q52 PASSES: the stderr NOTE reads
"the subject reads the home database at 2 site(s) (.home(), pwd.getpwuid); HOME redirected to
/q52-home" — the shipped collector's own Path.home() himalaya search plus the injected
pwd.getpwuid, and the check is satisfied by a mechanism the pw_dir read never uses.
The same subject under the gate's own --q52-witness flag — the flag that exists to measure this
class's write path (out-pwr-wit.txt): rc 0. The copy's probe prints HELM_HOME=None,
HELD_DIR=/root/.helm/data/held; copy_held_files is [] and
copy_pin_files_anywhere_in_sandbox is [] — the pin is not in the sandbox because it is in the live
tree: /root/.helm/data/held/stackchain-home.html.sha256 moved from
5b6d70716153c3f4bd82045d5d71edf88f793a240ce1275c3dfca5759d0ee297 / mtime_ns 1791478687099553071 to
fdd45f74f59dc7de869d290205e8ed3dc19139e3fbbae9e88f8c6b295ca25dca / mtime_ns 1791940472306970986
(guard q55_guard.py compare after-pwr-witness: "moved": true), restored byte-exact by
q55_guard.py restore (rc 0).
collector-pwdir-named.py under the same witness (out-pwn-wit.txt): rc 0, the same shape — the
live pin moves to 302bfda56b2d858c907345b6c9e1afaee8ac3849d3e45ca7d68ae24428dbe21d / mtime_ns
1791940506504841121, restored byte-exact (rc 0). The defect does not depend on the constant's name;
the rename only decides which check catches it.
The fix
A precondition, not a check: after Q46's parse guard (so an unparseable subject is still refused by
Q46 first) and after the import ast as _q39_ast line, before the Q39 sandbox is created, before the
admission probe, and before any leg. If the subject's own AST reads the pwd module, the run is
refused: exit 2, a dated message naming the subject, every pwd site with its line number, why the
redirect cannot reach it, and the live snapshot read before and after. No check() is registered — a
refusal fails the RUN, not a suite result, so the green pair still reports exactly 176 passed / 0 failed.
The refusal text (cell a, verbatim opening): "Q45S9 REFUSAL: the subject collector /root/crucible/q55/collector-pwdir-root.py reads the home directory from the pwd module (pwd.getpwuid at line 30), a channel the Q52 HOME/USERPROFILE redirect cannot reach: pwd.getpwuid answers from the user database, not from HOME, so the Q52 home-channel check is satisfied by a mechanism the subject never uses and the composed root can be the live root (measured: the --q52-witness run writes the live held pin from an admitted pwd subject). Refused at 2026-10-14T01:24:23.743346+00:00, after the Q46 parse guard, before the Q39 sandbox is created, before the admission probe, and before any leg."
The matrix, all under the final gate digest
25 cells (run_matrix_q55.py → out/Q55-MATRIX.json, 12 cells; run_matrix2_q55.py →
out/Q55-MATRIX2.json, 13 cells; every cell run with the live-object guard around it, compare before
and after, restore byte-exact on a move):
| cell | gate × subject | rc | result |
|---|---|---|---|
| a | armed × collector-pwdir-root.py |
2 | refusal, stdout 0 B (e3b0c442…), live delta {} |
| b | armed × collector-pwdir-root.py --q52-witness |
2 | the same refusal — the witness never runs |
| c | armed × collector-pwdir-named.py |
2 | refusal (pwd.getpwuid at line 30), live delta {} |
| d | armed × collector-home.py |
1 | 175/1, Q43 FAIL — identical to the shipped gate on the same fixture (see below) |
| e | armed × collector-expanduser.py |
1 | 175/1, Q43 FAIL — identical |
| f | disarmed × collector-pwdir-root.py --q52-witness |
0 | the escape reproduces: probe HELD_DIR=/root/.helm/data/held, pin in sandbox [], live pin 5b6d7071… → ced810a9ca9f47eabcc3d0429bd903b35dfb9f4cde43a8cca4bd73698545d437 / mtime_ns 1791941106156792591, restored byte-exact |
| g | disarmed × collector-pwdir-root.py |
1 | 175/1, Q52 PASS, Q45 FAIL — the shipped behaviour, reproduced |
| h | armed × the real collector ×5 | 0 | 176 passed / 0 failed each, 14,044 B stdout / 1,216 B stderr, live delta {} each, 20.5–24.1 s |
| r01–r09 | the nine refusal fixtures (n1–n5, noparse, dep-unparseable, undecodable, broken_dep) | 2 | refused each, live delta {} |
| r10–r11 | collector-latin1.py, collector-bom.py |
0 | 176/0 at 14,044 B each, live delta {} |
Regressions 11/11; the green pair is 176/0 — the Q52 count exactly, no check added.
Cells d/e are the honest result, not a defect of the fix: the two covered-shape fixtures fail Q43
("the subject's own default destination resolves to the live snapshot the gate protects") in a default
run, and they fail it identically under the shipped Q52 gate — measured side by side this run
(out/shipped-home.txt vs out/fixed-home.txt, rc 1 / 175/1 both, the PASS/FAIL lists identical
modulo the per-invocation sandbox directory names). Their green path is the witness run, which the fix
does not touch: armed × collector-home.py --q52-witness under the final gate puts the pin at
q52-home/.helm/data/held/stackchain-home.html.sha256 inside the sandbox
(copy_pin_files_anywhere_in_sandbox non-empty) with the live pin unmoved (5b6d7071…, mtime_ns
1791478687099553071). The covered shapes are not over-refused and stay protected; the pwd shapes are
refused before anything runs.
Live state, sandboxes, cleanup
Live, delta 0 on bytes/sha256/mtime across all 25 cells (q55_guard.py compare final2, "moved": false): snapshot /root/.helm/data/snapshot.json 14,039 B /
aa842b0fd88037f83115b4a54d617f27666f4de06ef84f9a64c8fd0a5f04656d / mtime_ns 1791479579475780303;
held pin 120 B / 5b6d70716153c3f4bd82045d5d71edf88f793a240ce1275c3dfca5759d0ee297 / mtime_ns
1791478687099553071. ctime moved on the pin across the two deliberately-live witness cells (the
guard restores of cells f and the shipped-gate witness runs) — the durable trace a restore cannot undo,
disclosed, not hidden.
Sandboxes, both ways per row (census_q55.py → out/Q55-CENSUS.json): the q55 tree 0 / 0
(shallow glob / recursive rglob) — the refusal exits before the sandbox is created, so it leaves
nothing. Across /root/crucible the pre-existing E14 leak was re-measured at 26 (19 helm-legs-*
- 7
helm-q39-*, the same distribution as Run 20's census) and all 26 were removed bycleanup_sandboxes.py(out/Q55-CLEANUP.json: 26 removed, 0 failed, 842,401 bytes reclaimed, after-count 0 both ways). Theq55tree's own__pycache__(the C32 class) was removed in the same pass. These are per-invocation sandboxes from crashed or early-exited runs; no gate run needs them and none sat in a live path.
Deployed /root/.helm/bin/ untouched (measured after the matrix): helm-collect.py ee0b36a82f23…,
helm-render.py 5c67473d8928…, helm-selftest.py 7ed1bca8f9da…, helm-validate.py 12570786ba70…,
summarize.py 738699986778…; grep -c HELM_COLLECTOR on the deployed gate = 0 — Q40 stands, so
none of Q31–Q45 §9 is in production. Disk 45 G avail of 154 (71 % used).
Adoption
The §4 cp is collector 4e45021d80395d3f… + gate
cd43284ff009d1cceb3aaa6a65b701d1076eaf801e32b705672601b4122b746a, superseding 78b290ec…
(e4967da9…, a951dbe8…). Alexander's, unchanged.
Limits
- The refusal is a refusal, not coverage: a subject that reads
pwd.getpwuidfor an innocent reason (a username lookup, say) is refused with the covered shapes. The conservative direction, and correct for a detector — the same trade the Q35 marker check makes. - The inventory is static (Q46's limit): a subject that computes its home at runtime from a source the
AST cannot see — reading
/etc/passwddirectly,os.stat-ing a crafted path — is outside it. - The covered set is exactly the redirect's reach:
.home(),expanduser(both forms). - The general shape stays open: any home channel the redirect cannot reach is the class. The pwd instance is closed by this precondition; the class is not.
Defects of mine, logged first
- Matrix part 1's d/e rows were void. They pointed
HELM_COLLECTORatcollector-home.py/collector-expanduser.pybefore those fixtures were copied into theq55tree, so both rows are a Q35 refusal against a path that did not exist — 0.2 s, rc 2, "carries no destination rule". Re-run correctly in part 2 after thecp; the part-1 rows are kept inout/Q55-MATRIX.jsonwith this note rather than deleted. - Part 1's
restoredfield compared a 12-character sha prefix against the full digest, so it printedfalseon every restored row including the unmoved ones. Part 2 compares full digests (restored_ok: trueon all 13 cells) and the durable evidence is the finalcompare final2("moved": false). - Part 1's Q45 filter was wrong: it matched
startswith("FAIL Q45")against the two-space renderingFAIL Q45 …, so it missed the one row it existed to find. The FAIL line is inout/g-disarmed-pwr.outand is quoted above. - My d/e expectation of 176/0 was assumed, not measured. I expected the covered shapes to run green under the fixed gate; the shipped-gate baseline I should have taken first shows they fail Q43 identically. The measurement was taken after, side by side, and the expectation was wrong — logged, not smoothed.
- The gate label was first written
Q55and renamed toQ45S9before any readback or push — the lane's own collision lesson, applied to my own fresh artifact rather than discovered later. The digests above are post-rename; the matrix logs were re-run in full under them.
What this owes
Nothing from any counterparty. The next Helm unit remains a decision, not a task: the §4 adoption cp
above, the manifest_match pin (stale since 2026-10-08), Q13's publication call, Q9's checksum
handover, Q19's onboarding — all Alexander's. Q42 is zephyrus's; Q52 is Mercury's; Q53 is the career
lane, held for Alexander. A task invented to fill the lane would be invented work.
Run 23 -- the four numbers corrected, and the readback made durable (2026-10-14)
Why this append exists. reply 44 (0f9ac82) and reply 45 (1ccf944) returned four byte figures in
this record that are wrong, and one identifier. The cause is mechanical and it is mine: the Q55 ->
Q45S9 rename (rename_q55.py, t.replace("Q55", "Q45S9"), +2 chars per occurrence) changed the
gate's bytes after the byte figures above were taken and before the digests were computed, so the
pair published above is a post-rename digest beside pre-rename bytes. The pipe already carried the
correction into WORK-QUEUE.md; this file, which is the artifact Alexander would cp from, did not have
it. Measured this run from disk and the interpreter, not from either reply: instrument
/root/crucible/q55/run23_verify.py, artifact out/R23-VERIFY.json (2,685 B / sha256
1bb9cd78e394d25ccea46a5d38c67b598d694c49462269911f357c3e858fc0c5, generated_at 2026-10-14T03:36:23.456605839Z).
| figure | this record said | measured (out/R23-VERIFY.json) |
status |
|---|---|---|---|
armed gate helm-selftest-q55.py bytes |
143,385 | 143,389 | corrected |
| armed gate sha256 | cd43284ff009d1cceb3aaa6a65b701d1076eaf801e32b705672601b4122b746a |
identical | holds -- the §4 cp cite is sound |
| disarmed twin bytes | 143,416 | 143,422 | corrected |
| twin sha256 | 8adad99bdc872ecd09ddea88ae55b5fc55a3886571ffaa5f49a053b52430586d |
identical | holds |
| twin - armed | 31 B | 33 B | corrected |
| insertion block | +3,020 B / +58 lines | +3,024 B / +58 lines (armed_delta_bytes_vs_base_126) |
corrected |
| the differing line's identifier | if _q45S9_pwd_sites: |
if _q55_pwd_sites: (line 442) |
corrected |
Both gates: 2,735 lines / 126 check(" registrations / trailing newline true, and the rename is total
in the only sense that matters here -- Q55 (upper) occurs 0 times in both, Q45S9 occurs 2 in the
armed gate and 3 in the twin (the third is the twin's own cut comment). _q45S9_pwd_sites occurs 0
times in both; _q55_pwd_sites sits at :435, :442, :451 in the armed gate and :435, :451 in the
twin -- 3 vs 2 sites, which is the twin's single cut line and nothing else. The twin's line 442 verbatim:
if False: # Q45S9 disarmed: the pwd refusal is cut. The 3,043 minus 23 derivation in the reply is
wrong in kind, not wrong by arithmetic: the anchor is the insertion point, so the block is 3,024 B
post-rename and 3,020 B pre-rename, and the subtraction only appears to explain it.
The gate was re-run once on the real collector, with a four-stamp live guard around it
(HELM_COLLECTOR=/root/crucible/q48/helm-collect.py, subject sha256 4e45021d80395d3f…, 29,954 B):
rc 0, helm-selftest: 176 passed, 0 failed, ALL GATES PASS, 14,044 B stdout / sha256
585e0e4b0508b565a18ca52cde3c464b9f92673b8fad538a12a33fd8a07131c1 / 1,216 B stderr, 12.2 s, and
PASS Q52 the subject's home-directory reads resolve inside the sandbox when the subject reads the home database -- two spaces, which is the rendering the filter must match. Live delta {} on bytes, sha256,
mtime_ns AND ctime_ns for both live objects (snapshot 14,039 B / aa842b0fd88037f8… /
1791479579475780303; held pin 120 B / 5b6d70716153c3f4… / 1791478687099553071; ctime
1791941985122465693 / 1791941985123465776 on both, unmoved -- the restore trace from Run 22's
witness cells, disclosed above, not re-created here).
The readback is now durable, which closes reply 45 §5.1. out/Q55-READBACK.txt on disk is the
8d105e9 read, so the c4addca read it was written to hold could not be re-derived from its own
artifact. New instrument /root/crucible/q55/readback_r23.py, artifact out/R23-READBACK.json, reading
/raw/commit/<rev>/<path> so the served body is the body at that revision forever:
| rev | file | HTTP / bytes | sha256 | served = git | served = disk |
|---|---|---|---|---|---|
c4addca |
recoveries/helm-pwdir-channel-refusal.md |
200 / 14,808 | d4c381f713e9fe95beefbd14a93220ab4049625f280c116e656419f3e11b7b89 |
true | true |
c4addca |
WORK-QUEUE.md |
200 / 951,756 | d8f54086edbcbbc96e39533d7b6dcf0e9effff108b596ed18685b03d1045e1db |
true | false (the pipe's three appends since) |
c4addca |
checkins/2026-10-14-merlin.md |
200 / 9,279 | 2d815c65542f7ea2722865b47fd19b0fbfcfe688ecfba97e03c84ea8c31ad132 |
true | false (same) |
b31c727 (head at read) |
record | 200 / 14,808 | d4c381f7… |
true | true |
b31c727 |
WORK-QUEUE.md |
200 / 957,453 | d60d6dee32376ef2… |
true | true |
b31c727 |
checkins/2026-10-14-merlin.md |
200 / 10,499 | eae1c6b2c62983f0444b44ba1cc2b9233537374e4382a243c3981636fa451731 |
true | true |
head_at_read = b31c727f77d1c50cdb9019b915170a47684351c8. 6 rows, 6 × served = git; the two
served = disk false rows are the pipe's own appends, named and dated, not a serve failure. The record
itself is byte-identical at both revisions (d4c381f7…), which is what makes the corrected figures
above the first change to this file since Run 22 landed.
One defect of mine, logged here rather than in the reply, because I hit it inside this run and fixed
it: the first version of run23_verify.py extracted the Q52 line with startswith("PASS Q52"), which
never matches the two-space rendering -- the lane's own logged defect, reproduced by its author on a fresh
artifact and corrected to a regex before the run was recorded. The measured line is quoted above.
Nothing else moved. No gate deployed (HELM_COLLECTOR count 0 in /root/.helm/bin/helm-selftest.py,
so Q40 stands and none of Q31-Q45 §9 is in production), decisions/ and inbox/ untouched, no
another-party file edited, no secret printed. This block does not cite this record's own post-append
digest; the readback artifact above is the durable row and it names its revision.