Files
troupe-standup/recoveries/helm-home-channel-guard.md
T
Merlin c559d33131 helm: the Q45 s9 follow-on closed -- the home-directory channel (collector 4e45021d unchanged; gate 78b290ecf2d93e0c3087e5ede8baef5c10ca52986706d5da69a13a619dbdd9f, 140,365 B / 2,677 lines / 126 registrations, on the Q51 gate e4967da9, diff 6 hunks + 122 - 3 = 2,677 lines; disarmed twin be7b306a one line different)
The last runtime-resolution route in Q45 s7-limit-1's family with neither a literal nor an ast-visible env read: HELM_HOME = Path.home() / ".helm" composes the live root because CPython's posixpath.expanduser reads HOME itself. The channel is live in the shipped subject -- the real collector calls Path.home() twice (its himalaya search). Mechanism: an ast inventory of home-database reads (_q52_home_sites, :786) and, inside Q50's one rebuild function (two callers, probe :977 / legs :1162), a redirect of HOME + USERPROFILE to <target>/q52-home -- a redirect, not a refusal -- with a conditional Q52 NOTE and one new check("Q52", :1204). A --q52-witness extra flag (:1261) runs the copy with --deploy --pin --skip-tests --out <sandbox dest>, the pin shape the suite itself refuses (Q44's rule), because the class's write path is unreachable from the green run.

Measured: green 5/5 at 176 passed / 0 failed (= 175 + 1), 14,044 B stdout, live delta {}; the real collector's own .home() read makes the check non-vacuous. Discriminator, five cells under the digest-verified guard: armed x collector-home.py (d62888c4) -- the copy resolves inside the sandbox and the pin lands at q52-home/.helm/data/held/stackchain-home.html.sha256 in the sandbox, live delta 0; armed x collector-expanduser.py (99c49d39) -- same; disarmed x home -- HOME stays /root, the copy resolves HELM_HOME=/root/.helm and the live pin moves to sha256 caff1f504855264a / mtime_ns 1791907100434371499, restored byte-exact; disarmed without the witness flag -- the foot's own check fails (FAIL Q52, home_reads=['.home()'] HOME='/root', 174 passed / 2 failed); direct invocation, ambient env -- the live pin moves (the class's floor). Regressions 11/11 hold; live delta 0 on bytes/sha256/mtime across all 21 invocations (snapshot 14,039 B / aa842b0f, held pin 120 B / 5b6d7071); ctime moved on both objects (1791907371554631194), the guard restores of the two deliberately-live rows -- disclosed. Final tree 0 sandboxes; deployed /root/.helm/bin untouched (7ed1bca8, grep -c HELM_COLLECTOR = 0 -- Q40 stands, so none of Q31-Q52 is in production); disk 46 GiB free of 154.

Five defects of mine logged first in the deliverable: the witness's missing local subprocess import; the witness anchored before the leg copy was written; the witness's live census reading the held directory (IsADirectoryError); the NOTE claiming a redirect that had not fired (the disarmed twin's own FAIL contradicted it -- the NOTE is now derived from the actual env); and the first matrix measuring nothing because the real collector pins only under --pin, which the suite refuses. Limits stated as the item's own: the original-subject legs inherit the ambient environment (Q48's residual); pwd.getpwuid().pw_dir bypasses HOME (the inventory names it, the redirect cannot relocate it); the inventory is static (Q46's limit).

Files: recoveries/helm-home-channel-guard.md (13,173 B / sha256 3f813ca23202590c675f7ac7587f0d5e7d8b0ff9ee9f4f0711996530a2d6d7db), WORK-QUEUE.md (897,771 B), checkins/2026-10-13-merlin.md (Run 18). Scratch tree /root/crucible/q52/ not committed. No deployment, decisions/ and inbox/ untouched, no another-party file edited, no secret printed. The Helm lane's executable set is empty again; the adoption cp (collector 4e45021d + gate 78b290ec, superseding e4967da9) remains Alexander's.
2026-10-13 16:08:17 +00:00

13 KiB
Raw Blame History

Q52 — the home-directory channel

Merlin, Helm lane, 2026-10-13. The last route in Q45's §7-limit-1 family that carries neither a literal nor an ast-visible environment read. Scratch tree /root/crucible/q52/; nothing deployed, no cp into /root/.helm/bin/, decisions/ and inbox/ untouched, no another-party file edited, no secret.

Screen. Every claim below carries a path, a hash, a URL, or a command with its output, or the word unverified.


1. The class

The subject resolves its root from the user database:

HELM_HOME = Path.home() / ".helm"

CPython's posixpath.expanduser reads HOME from os.environ itself, and Path.home() calls it, so:

  • Q45's literal relocation sees no absolute live-root literal — the only string is ".helm", which does not resolve inside the live root on its own;
  • Q48's environment inventory walks the subject's own AST for os.environ accesses and finds none — the read happens inside the interpreter;
  • Q49's default redirect keys on the same inventory;
  • Q48's argv rule sees no root argument;
  • __file__ needs no mechanism (Q48's note) but is not this class;

yet the composed path is the live root /root/.helm, and the subject pins the live held directory. The channel is live in the shipped subject, not hypothetical: the real collector calls Path.home() twice — its himalaya config search, /root/crucible/q48/helm-collect.py lines 297–298 (grep -n 'Path.home()' → 2 hits), on the collector byte-identical since Q33 (4e45021d80395d3f…, 720 lines / 29,954 B, sha256 measured this run).

2. The pair

Collector untouched — every change is in the gate, as the item requires (4e45021d… re-measured by sha256).

bytes / lines registrations sha256
Q51 gate (base) 134,427 / 2,558 125 e4967da9bdc45378…
Q52 gate (shipped) 140,365 / 2,677 126 78b290ecf2d93e0c…
Q52 disarmed twin 140,390 / 2,677 126 be7b306afa7c5b3a3…

out/DIFF-vs-q51.txt = 169 lines = 6 hunks + 122 added − 3 removed, and 2,558 + 122 − 3 = 2,677 exactly (counted by grep -c, not asserted). Registration count grep -cE '^[[:space:]]*check\(' = 126; the disarmed twin differs from the shipped gate in exactly one line (the redirect cut), as in Q50's and Q51's pairs.

The mechanism, one function, two call sites, one check.

  1. The inventory — _q52_home_sites() (gate line 786): every home-database read in the subject's own AST — Path.home(), a bare .home(), expanduser (attribute or bare), pwd.*. Module-level _q52_home_reads holds the result for both rebuild callers.
  2. The redirect — inside _q50_rebuild_env (line 815), the same function that already serves the admission probe (Q50) and the legs: when the subject reads the home database, HOME and USERPROFILE are redirected to <target>/q52-home. A redirect, not a refusal; the collector's own Path.home() reads keep working, against the sandbox's home. Probe caller at :977, legs caller at :1162 — both take the new four-tuple, so the two paths cannot drift.
  3. The NOTE — one Q52 NOTE on stderr, derived from the actual environment, so it cannot claim a redirect that did not fire (see defect 4).
  4. The check — check("Q52", …) at :1204, registered at the legs' site: when the subject reads the home database, the legs' HOME must be the sandbox home; with no home-database read it passes vacuously and the NOTE says so.
  5. The witness — --q52-witness (line 1261, after the leg-copy write): runs the copy the way the suite never does — --deploy --pin --skip-tests --out <sandbox dest> — under the rebuilt environment, prints one JSON object and exits before the foot. The green run never passes the flag, so the block cannot affect it. It exists because the suite itself refuses a --pin leg (Q44's rule), so the pin shape is unreachable from the suite (defect 5).

3. The fixtures, verified against the interpreter first

Two transforms of the real collector (make_fixtures_q52.py, asserted count 1 each):

fixture transform bytes sha256
collector-home.py HELM_HOME = Path.home() / ".helm" 29,956 d62888c4c9470d30…
collector-expanduser.py HELM_HOME = Path(os.path.expanduser("~")) / ".helm" 29,974 99c49d395467709d…

The interpreter's own verdict, measured by make_fixtures_q52.py's probe (runpy + a scratch HOME): both fixtures rc 0 with HELM_HOME /tmp/<scratch>/.helm and HELD_DIR /tmp/<scratch>/.helm/data/held — each resolves inside a scratch HOME, and with the ambient HOME (/root) the same composition is the live root. That is the class, measured rather than asserted.

4. The green pair, five consecutive invocations

run_green_q52.py (subject /root/crucible/q48/helm-collect.py, Q48_ROOT unset; logs out/q52-green-{1..5}.{out,err}, artifact out/Q52-GREEN.json): rc 0 each, 176 passed / 0 failed, VERDICT: ALL GATES PASS, 14,044 B stdout / 1,216 B stderr, 12.34–12.73 s, live delta {} on all five. 176 = 175 + 1; no pre-existing check moved.

The control that matters: the real collector reads the home database, so the redirect fires on every green run and the check is non-vacuous —

PASS  Q52 the subject's home-directory reads resolve inside the sandbox when the subject reads the home database
Q52 NOTE: the subject reads the home database at 1 site(s) (.home()); HOME redirected to
  /root/crucible/q52/helm-legs-abis_h7c/q52-home (/root -> /root/crucible/q52/helm-legs-abis_h7c/q52-home)

(out/q52-green-1.out, out/q52-green-1.err). The himalaya source is UNREACHABLE either way — the binary is absent from this host — so the redirect moves an already-empty search, and Q44/Q50 stay green: PASS Q50 the admission probe's spawn moved neither live object, PASS Q44 the live held directory is unchanged by every leg in the run.

5. The discriminator, five cells, every one under the guard

run_matrix_q52.py → out/Q52-MATRIX.json (guard: q52_guard.py, refuses to restore unless the backup digest matches the recorded 5b6d7071…).

row gate subject rc live moved what the witness shows
q52-armed-home Q52 78b290ec… collector-home.py 0 no HOME_witness_env = <sandbox>/q52-home; the copy's HELM_HOME=<sandbox>/q52-home/.helm, HELD_DIR=<sandbox>/q52-home/.helm/data/held; the pin landed at q52-home/.helm/data/held/stackchain-home.html.sha256 inside the sandbox; live snapshot 14,039 B / aa842b0f…, live pin 120 B / 5b6d7071… — unchanged
q52-armed-expand Q52 78b290ec… collector-expanduser.py 0 no same, on the expanduser channel (2 inventory sites: .home(), expanduser — the collector's own himalaya search plus the transform)
q52-dis-home Q52 disarmed be7b306a… collector-home.py 0 YES HOME_witness_env = /root; the copy resolves HELM_HOME=/root/.helm — the LIVE root; the live pin moved to sha256 caff1f504855264a… / mtime_ns 1791907100434371499, restored byte-exact by the guard
q52-dis-check Q52 disarmed be7b306a… collector-home.py 1 no the foot's own check fails: FAIL Q52 … home_reads=['.home()'] HOME='/root' expected='<sandbox>/q52-home', 174 passed, 2 failed
q52-raw-home none — direct invocation collector-home.py 0 YES the class with nothing in the path: --deploy --pin --skip-tests --out <scratch>, ambient env — the live pin moves, guard restores

Prevention and detection are measured separately: the armed rows redirect and the pin lands in the sandbox; the disarmed row, same subject, writes live; the check fails when the redirect is cut. The class is real, one mechanism away, and the mechanism closes it.

6. Regressions, 11/11

run_regressions_q52.py → out/Q52-REGRESSIONS.json: n1–n5, noparse-with-markers.py, dep-unparseable.py, collector-undecodable.py, broken_dep.py all rc 2 (Q39 refusals / Q46 parse guard / repair 3 / Q47 read path / Q35 source precondition); collector-latin1.py and collector-bom.py green at 14,044 B — the read-path fix's subjects unaffected. Live delta {} on all eleven. VERDICT: ALL REGRESSIONS HOLD.

7. Live state

Delta 0 on bytes / sha256 / mtime_ns across all 21 invocations (5 green + 5 matrix + 11 regressions): snapshot /root/.helm/data/snapshot.json 14,039 B / aa842b0fd88037f83115b4a54d617f27666f4de06ef84f9a64c8fd0a5f04656d / mtime_ns 1791479579475780303; held pin /root/.helm/data/held/stackchain-home.html.sha256 120 B / 5b6d70716153c3f4bd82045d5d71edf88f793a240ce1275c3dfca5759d0ee297 / mtime_ns 1791478687099553071.

ctime moved on both objects — 1791907371554631194 — the in-gate and guard restores of the q52-dis-home and q52-raw-home rows, which wrote live by design. The stamp a restore cannot undo (Q43); disclosed, not hidden.

Final tree: 0 sandboxes (glob for helm-legs-* / helm-q39-* → []). Deployed /root/.helm/bin/ untouched (helm-selftest.py 33,647 B / 7ed1bca8… / 2026-10-11 11:13:31Z, grep -c HELM_COLLECTOR = 0 — Q40's finding stands, so none of Q31–Q52 is in production). Disk 46 GiB free of 154 (71 % used) (df -h /).

8. Five defects of mine, logged first

  1. The witness crashed on a missing import. subprocess is imported at module level below the witness block (repair 5), so the first witness run died with NameError: name 'subprocess' is not defined after the copy had already been built. Fixed with a local import in the block, asserted in the patcher.
  2. The witness was anchored before the copy was written. The first placement ran subject-legs.py before _q43_subject.write_text(...), so it died on FileNotFoundError. Re-anchored on the copy write; the patcher asserts the ordering (inventory < rebuild < callers < check < copy write < witness).
  3. The witness's live census read a directory. live_held_after used _q44_held_live.read_bytes(), which is the held directory in this lineage — IsADirectoryError. Fixed to _q50_public(_q50_census()), the same both-objects census the window detector uses.
  4. The NOTE claimed a redirect that had not fired. The first version printed "HOME redirected to …" whenever the subject read the home database — and the disarmed twin's own FAIL Q52 line contradicted it. The NOTE is now derived from the actual environment, so the disarmed twin prints HOME NOT redirected -- HOME in the legs' environment is '/root'. The same defect class as Q50's disarmed twin NOTE; caught here before the pipe could.
  5. The first matrix measured nothing. The real collector pins only under --pin, and the suite refuses a --pin leg (Q44's rule), so the first four rows — subjects run without the pin shape — moved nothing and proved nothing. The --q52-witness flag exists because of this: the class's write path is structurally unreachable from the green suite, the same shape Q48's witness was built for.

9. Limits, stated as the item's first limits

  • The original-subject legs still inherit the ambient environment (Q43's resolution leg, G8's dry-run leg, G8's in-process deploy leg): a subject whose root resolves at import time in the gate's own process writes live from a leg the redirect does not cover. Q48's residual, unchanged by this item. The witness covers the copy's environment only.
  • pwd reads bypass HOME. posixpath.expanduser prefers HOME when set (so the redirect covers it), but a subject calling pwd.getpwuid(os.getuid()).pw_dir directly reads the user database and the redirect cannot see it. The inventory names such a site; the redirect does not relocate it. Stated, not fixed.
  • The inventory is static. Home reads behind indirection — getattr, aliasing, a helper module — are outside every static inventory's reach (carried from Q46).
  • USERPROFILE is redirected alongside HOME on this host although it is a Windows variable and unset here; the redirect is a no-op for it, and the pair keeps the mechanism portable rather than POSIX-specific.

10. Ask

Alexander's, unchanged: (a) the §4 adoption cp — collector 4e45021d… + gate 78b290ec…, the pair that makes python3 helm-selftest.py green with no environment and puts Q31–Q52's protection into production (the deployed gate today has no subject-selection path at all); (b) the manifest_match pin decision (stale since 2026-10-08); (c) the Q13 publication call (item 13's decided note — escalate, do not edit the live page — still controls over its proposed_action). This run added no new ask, and wrote and restored the live held directory twice, by design, under the guard (disclosed in §7).

— Merlin