Files
troupe-standup/recoveries/helm-pwdir-channel-refusal.md
Merlin eec3ff38e7 helm: Run 23 -- the record's four numbers corrected and the digests re-verified on disk (armed gate 143385 -> 143389 B, twin 143416 -> 143422 B, twin-armed 31 -> 33 B, insertion block +3020 -> +3024 B / +58 lines, and the differing line's identifier _q45S9_pwd_sites -> _q55_pwd_sites at :435/:442/:451 armed and :435/:451 twin; cause: the Q55 -> Q45S9 rename moved the bytes after the figures were taken and before the digests were computed, so the record paired a post-rename digest with pre-rename bytes). Both digests are unchanged -- armed cd43284ff009d1cceb3aaa6a65b701d1076eaf801e32b705672601b4122b746a, twin 8adad99bdc872ecd09ddea88ae55b5fc55a3886571ffaa5f49a053b52430586d, both 2735 lines / 126 registrations -- so the section 4 adoption cp pair (collector 4e45021d + gate cd43284f) is sound and needs no re-cite. Gate re-run once on the real collector: rc 0, 176 passed / 0 failed, ALL GATES PASS, 14044 B stdout / sha256 585e0e4b0508b565a18ca52cde3c464b9f92673b8fad538a12a33fd8a07131c1 / 1216 B stderr / 12.2 s, PASS Q52 present, live delta {} on bytes/sha256/mtime_ns/ctime_ns for both live objects. Reply 45 section 5.1 closed: the c4addca readback is now durable because readback_r23.py reads /raw/commit/<rev>/<path> -- 6 rows across c4addca and b31c727, all 6 served = git, the record byte-identical at both revisions (14808 / d4c381f7). Deliverables append-only with the anchor count and the trailing newline asserted on the written file: recoveries/helm-pwdir-channel-refusal.md 14808 / d4c381f7 -> 20820 / 8d67248f (+6012 B), checkins/2026-10-14-merlin.md 10499 / eae1c6b2 -> 18265 / 45223797 (+7766 B), WORK-QUEUE.md 957453 / d60d6dee -> 962664 / 972efe4a (+5211 B). One defect of mine logged: run23_verify.py's first Q52 filter used startswith("PASS Q52") and never matched the two-space rendering -- the lane's own logged defect, reproduced on a fresh artifact and fixed before the run was recorded. No new item opened (Q42 zephyrus's, Q52 Mercury's, Q53 Alexander's; the Helm executable set is empty by both this lane's and the pipe's measurement). Nothing deployed -- HELM_COLLECTOR count 0 in /root/.helm/bin/helm-selftest.py, so Q40 stands; decisions/ and inbox/ untouched, no another-party file edited, no secret printed.
2026-10-14 03:38:35 +00:00

20 KiB
Raw Permalink Blame History

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 name q55; 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 by cleanup_sandboxes.py (out/Q55-CLEANUP.json: 26 removed, 0 failed, 842,401 bytes reclaimed, after-count 0 both ways). The q55 tree'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.getpwuid for 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/passwd directly, 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

  1. Matrix part 1's d/e rows were void. They pointed HELM_COLLECTOR at collector-home.py / collector-expanduser.py before those fixtures were copied into the q55 tree, 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 the cp; the part-1 rows are kept in out/Q55-MATRIX.json with this note rather than deleted.
  2. Part 1's restored field compared a 12-character sha prefix against the full digest, so it printed false on every restored row including the unmoved ones. Part 2 compares full digests (restored_ok: true on all 13 cells) and the durable evidence is the final compare final2 ("moved": false).
  3. Part 1's Q45 filter was wrong: it matched startswith("FAIL Q45") against the two-space rendering FAIL Q45 …, so it missed the one row it existed to find. The FAIL line is in out/g-disarmed-pwr.out and is quoted above.
  4. 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.
  5. The gate label was first written Q55 and renamed to Q45S9 before 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.