Files
troupe-standup/recoveries/helm-leg-sandbox.md
T
Merlin 0fb8b2096c Q43: the legs run the rewritten subject -- prevention, not detect-and-restore
Q39's repair 5 is a net, not a fence: live bytes and mtime are read around every
subprocess and around the subject's in-process import, a change restores the
bytes and refuses the run, so the write has already happened when the watch
fires. Q39's own record names the residual and the Q39 closure asks for "the
stronger form (the legs against a rewritten copy)". This is that form, and it is
the Helm lane's next unit rather than a new invention.

Three rules in the one choke point the gate already has -- the leg wrapper
installed on subprocess.run, so every future leg inherits them:

  1. a subject leg carrying --dry-run runs AS THE ORIGINAL, watched: it writes
     nothing by the collector's own rule (helm-collect.py:698), and running the
     original is what lets the resolution leg read the subject's own destination
     out of its banner;
  2. every other subject leg, and the in-process import, runs the REWRITTEN COPY
     -- every SNAPSHOT_PATH = assignment pointing at a per-invocation sandbox
     built after admission and removed at the end of the run -- so module-level
     code can write at most into the sandbox;
  3. a subject leg is REFUSED (exit 2, the leg never runs) if its argv names the
     live snapshot or passes --pin.

Rule 3 is load-bearing, not decoration: the collector refuses only when --out
resolves to its OWN SNAPSHOT_PATH (helm-collect.py:702), so a copy whose
destination is the sandbox would write the live path if a leg named it. The
gate's own "explicit live --out is refused" leg therefore now names the copy's
own destination -- the Q30 discipline applied to the collector. The rule under
test is unchanged; it is measured on a path that is not live, and the semantic
change is stated in the gate and the record rather than left implicit.

Measured, 20 gate invocations, live delta 0 on every one (snapshot 14,039 B /
aa842b0f... / mtime_ns 1791479579475780303; held pin 120 B / 5b6d7071... /
mtime_ns 1791478687099553071):

  - green pair, 5 consecutive runs: rc 0, 166 passed / 0 failed, ALL GATES PASS,
    12,248 B stdout; 4 subject legs redirected, 3 --dry-run legs run as the
    original, 0 refusals; the sandbox holds 3 paths (the copy and its bytecode
    cache, no data/snapshot.json); 18 legs watched, delta 0 on each;
  - the five Q39 negatives refuse under BOTH gates (rc 2, stdout 0 B, no report
    block, live delta 0) -- but they are refused at Q39's admission, before the
    Q43 sandbox exists, so they are a regression control and nothing in the
    five-fixture suite exercises the redirect. Stated, not hidden;
  - the discriminator, on a scratch protected path: argvlive (the gate's own
    pre-Q43 leg shape, rule 3 armed) -> rc 2, the refusal naming the leg's argv,
    the copy's destination and the rule at :702, and the protected file's sha,
    mtime AND ctime unchanged -- the leg never ran. argvlive-norule3 (rule 3
    disarmed) -> rc 2 via Q39's watch, protected ctime MOVED while sha and mtime
    were restored -- the leg ran, the copy wrote a real snapshot over the
    protected file, and only the restore put it back. ctime_ns is the
    discriminator: the stamp a restore cannot undo;
  - the --pin residual: pin_stackchain() writes HELD_DIR, composed from HELM_HOME
    and untouched by the rewrite, so a --pin leg is refused (rc 2, naming --pin
    and /root/.helm/data/held); a copy with HELD_DIR also redirected to scratch,
    run --pin --deploy, writes the scratch held file, so the path is real; bare
    --pin is refused by the destination gate before `if args.pin`, so it is one
    flag combination away, not on the default path;
  - the rewrite is behaviour-preserving: original vs copy, both --dry-run, both
    20,570 B, scrubbed over the gate's full clock set -> diff_keys [].

Three defects of mine, logged first in the record: the redirect keyed on argv[0],
which is the interpreter (the subject is an argv element, and the first run
measured 0 redirects while its legs ran the original -- the Q40 shape); the
behaviour-preservation scrub omitted generated_at and duration_seconds; and the
first --pin measurement ran bare, so the held path was never reached.

Limits stated in the record: prevention is total for the SNAPSHOT_PATH class, not
for every class; the legs still execute the subject's code, and damage to
anything other than the snapshot is out of this gate's scope; the five-fixture
suite no longer reaches Q43's machinery, so the two variants and the pin residual
are the coverage.

Nothing deployed: no cp into /root/.helm/bin/, the deployed bin is unmoved
(ee0b36a8... / 7ed1bca8... / 5c67473d...), decisions/ and inbox/ untouched, no
another-party file edited, no secret printed. Adoption stays Alexander's, and Q40
measured that the deployed gate has no subject-selection path at all, so the cp
is the only thing that puts any of Q31-Q43's protection into production.
2026-10-12 18:33:44 +00:00

13 KiB
Raw Blame History

The leg sandbox: the gate's legs run the rewritten subject

Item: Q43 (Helm lane). Author: Merlin. Run: 9 of 2026-10-12. Scratch tree: /root/crucible/q43/. Nothing was deployed: no cp into /root/.helm/bin/, no production path touched, decisions/ and inbox/ untouched.

Identities

artifact lines bytes sha256
gate helm-selftest.py (Q43) 1,612 88,468 c22601ecea4192d98c1140370b448c3d10924c1b4dfac438665f447014ade599
subject helm-collect.py 720 29,954 4e45021d80395d3f03fc58d7e99610e3c1c0d2fcf9c85f1db50b16cfd2043c6c
gate (Q39, the predecessor) 1,412 77,704 9b09f3283008e695bb0634bc2d5492f2c24067b9eeb9ee2100099b7cbfcd1429
deployed helm-collect.py — 27,907 ee0b36a82f23fcf3c4742de95a205f824715b931fdb80947a897eb88999c4df6
deployed helm-selftest.py 609 33,647 7ed1bca8f9da624f69b03313506097a0ce9b1130cbad9511908776e5015a0f46

The subject collector is byte-identical to Q39's (4e45021d…): every change is in the gate. diff -u of the two gates is 281 lines = 4 header + **210 added

  • 10 removed**, and 1,412 + 210 − 10 = 1,612 exactly (out/DIFF-vs-q39.txt). Live snapshot at every reading in this record: 14,039 B / aa842b0fd88037f8… / mtime_ns 1791479579475780303; live held pin 120 B / 5b6d70716153c3f4… / mtime_ns 1791478687099553071.

What Q39 left open, and what this does about it

Q39's repair 5 is detection: live bytes and mtime are read around every subprocess and around the subject's in-process import, a change restores the bytes and refuses the run. That is a net, not a fence — the write has already happened when the watch fires, and the file was wrong on disk for the length of the leg. Q39's own record says so ("the legs still run the ORIGINAL subject, so repair 5 detects-and-restores rather than prevents") and the Q39 closure asks for "the stronger form (the legs against a rewritten copy)". This is that form.

Three rules, applied in one choke point — the leg wrapper that Q39 already installed on subprocess.run, so every future leg inherits them:

  1. A subject leg carrying --dry-run runs as the ORIGINAL, watched. --dry-run writes nothing by the collector's own rule (helm-collect.py:698), so there is nothing to prevent — and running the original is what lets the resolution leg read the subject's own destination out of its banner.
  2. Any other subject leg runs the REWRITTEN COPY — the subject's own source with every SNAPSHOT_PATH = assignment pointing at a per-invocation sandbox, built after admission and removed at the end of the run. The most a leg can write through the module constant is a sandbox file.
  3. A subject leg is REFUSED (exit 2, the leg never runs) if its argv names the live snapshot or passes --pin, because neither is redirectable.

Rule 3 exists because the rule under test is narrower than it looks. The collector refuses when Path(args.out).resolve() == SNAPSHOT_PATH.resolve() (helm-collect.py:702) — its own default destination, and nothing else. Move that destination and the protection evaporates for an explicit --out: a copy whose destination is the sandbox would happily write the live path if a leg named it. So the gate's own "explicit live --out is refused" leg now names the copy's own destination instead — the Q30 discipline (test the deploy branch against a patched constant, never the real one) applied to the collector. The rule under test is unchanged; it is measured on a path that is not live.

The in-process import (hcg = _load_collector(...)) loads the copy too, so module-level code — which runs before any argv is parsed and which Q39 could only watch — can write at most into the sandbox. LIVE_SNAPSHOT is the deployed collector's destination, read before any subject code runs, and a new resolution leg proves the subject's own default destination is that same path.

Measurements

Every run below is one invocation of the gate with HELM_COLLECTOR naming its subject. 20 gate invocations in this record; live delta 0 on all of them. Side effects are detected from outside the run, never asserted (run_negatives.py, run_residuals.py, live_state.py; logs and Q43-NEGATIVES.json / Q43-RESIDUALS.json under out/).

The green pair — 5 consecutive invocations

HELM_COLLECTOR=/root/crucible/q43/helm-collect.py python3 /root/crucible/q43/helm-selftest.py:

exit code 0 on all five
report helm-selftest: 166 passed, 0 failed / VERDICT: ALL GATES PASS
stdout 12,248 B on all five (identical digest per run, in Q43-NEGATIVES.json)
Q39 line 18 legs watched … delta 0 on each
Q43 line 4 subject legs redirected … 3 --dry-run legs run as the original … 0 refusals; the sandbox holds 3 paths: __pycache__, __pycache__/subject-legs.cpython-311.pyc, subject-legs.py
live delta 0 (bytes, sha256, mtime_ns) on each
seconds 12.01–12.39

166 = Q39's 164 + the two new Q43 checks. The sandbox census is the load-bearing row: the sandbox holds the copy and its bytecode cache and nothing else — no data/snapshot.json — so on a green run no leg wrote even the sandbox destination. The four writing legs are --out <scratch> ×2 (they write their own scratch path), the bare leg (refused by the copy's own rule) and the explicit---out leg (refused likewise).

The five Q39 negatives — refused under BOTH gates

n1-incident-shape, n2-traceback, n3-decoy, n4-comment-only, n5-live-literal, each as HELM_COLLECTOR:

gate rc stdout report block live delta fixture tree
Q43 2 0 B absent 0 unchanged (census before = after)
Q39 2 0 B absent 0 unchanged

They are refused at Q39's admission, before the Q43 sandbox exists, so they are a regression control on the admission, not evidence about Q43. Stated plainly: nothing in the five-fixture suite exercises the redirect. That is what the two variants below are for.

The discriminator — rule 3, on a scratch protected path

Two variants of the gate, both protecting a scratch file (/root/crucible/q43/protected/protected-snapshot.json) instead of the live snapshot, so a false admission during measurement cannot clobber live state:

variant what it is rc report protected sha protected mtime protected ctime
argvlive the gate's own pre-Q43 leg shape: the explicit---out leg names the protected path, rule 3 armed 2 absent unchanged unchanged unchanged
argvlive-norule3 the same, with rule 3's live-argv refusal disabled 2 absent unchanged unchanged moved
  • Armed: the refusal fires before the leg — Q43 REFUSAL: a subject leg names the live snapshot in its argv (/root/crucible/q43/protected/protected-snapshot.json), and the leg runs the rewritten copy whose own destination is /root/crucible/q43/variants/helm-legs-uxm6uxze/data/snapshot.json, so the rule at helm-collect.py:702 would not refuse it and the leg would write live — naming the leg's argv, the copy's destination and the rule. The protected file's inode is untouched.
  • Disarmed: the leg runs and the copy writes the protected file, and only Q39's watch stops it — Q39 REFUSAL: a leg's subprocess (… --out /root/crucible/q43/protected/protected-snapshot.json --skip-tests) changed the live snapshot - sha256 aa77115031aa8546… before, sha256 c9f9401454b9fb89… after; the before-bytes were restored and the run is refused.

The discriminator is ctime_ns. The watch's restore rewrites the bytes and re-stamps the mtime, so both are back where they started and neither can show that a write occurred; the inode change time is the residue a restore cannot undo. Armed: ctime unmoved, the write never happened. Disarmed: ctime moved, the write happened and was repaired. That is the difference between prevention and detection, measured rather than argued.

The --pin residual — a second live path the rewrite cannot redirect

pin_stackchain() writes HELD_DIR / "stackchain-home.html.sha256" (helm-collect.py:664-680), and HELD_DIR = HELM_HOME / "data" / "held" (:31) is composed from HELM_HOME, not from SNAPSHOT_PATH — so the rewrite does not touch it, and Q39's absolute-literal scan admits it because /root/.helm is not inside the data root. Measured:

run subject rc result
refusal the real gate with a --pin leg 2 Q43 REFUSAL: a subject leg passes --pin, and --pin writes /root/.helm/data/held (HELD_DIR, composed from HELM_HOME) which the SNAPSHOT_PATH rewrite cannot redirect (helm-collect.py:664-680); report block absent; live snapshot and held pin unchanged
the write a copy with HELD_DIR also redirected to scratch, --pin --deploy 0 pinned 5e5cd6d26a7004239c0ff8362b2f54c1697d848a602b35de7aa5580268c018eb -> /root/crucible/q43/residuals/held/stackchain-home.html.sha256; live held pin and live snapshot unchanged
the default path the same copy, bare --pin 2 refusing to write the live snapshot … without --deploy — the destination gate at :702 fires before if args.pin (:707)

So the second live path is one flag combination away, not on the default path: --pin alone is refused by the destination gate, --pin --deploy reaches it. Rule 3 refuses --pin on any subject leg regardless.

Side observation, evidence attached: the scratch pin's digest 5e5cd6d26a70… is the text-mode hash of the live body, because run() calls subprocess.run(..., text=True) (helm-collect.py:123) and universal-newline translation applies — the same normalisation my 2026-10-11 measurement recorded. The live held pin on disk carries f27326e8b13d1dec…, pinned 2026-10-08T16:58:07.100Z. Both are the same code path's hash of the same URL, so the live Stackchain body's normalised digest has changed since the pin was taken. Corroborated for the two digests and the code path; this host cannot prove which revision wrote the held file, so the drift reading rests on pin_stackchain() being its only writer.

The rewrite is behaviour-preserving

The original and the rewritten copy, both --dry-run --skip-tests, both 20,570 B of stdout, scrubbed over the gate's own full clock set (observed_at, rtt_seconds, age_seconds, last_active_at, last_checkin_at, latest_active_path, newest_checkin_file, generated_at, duration_seconds): diff_keys: []. That is what licenses "the legs run the same collector at a different destination" as a measurement rather than an assumption. Independently, all 166 checks pass with the copy as the subject, including the two-run byte-agreement check and every Q33/Q34 in-process check.

Three defects of mine, logged first

  1. The redirect keyed on argv[0], which is the interpreter. The gate's legs are [sys.executable, str(COLLECTOR), …], so the subject is an argv element. First run measured: 0 subject legs redirected, the bare leg ran the ORIGINAL against the live default (refused by the collector's own rule, rc 2, live delta 0) and the explicit---out leg wrote the sandbox path through the original's argv handling. Fixed by _q43_subject_index(), which scans the argv. Both first-run effects were benign, and the run is the evidence: a gate that reports 0 redirects while its legs run the original is exactly the Q40 shape.
  2. The scrub in my behaviour-preservation check omitted generated_at and collector.duration_seconds — the gate's own `TOP_SCRUB_KEYS — so the first reading reported the rewrite as behaviour-changing when the only differing key was a clock. Re-measured with the full set: identical.
  3. The first --pin measurement ran the collector bare, so the destination gate fired before pin_stackchain() and the held path was never exercised. The finding (that --pin needs --deploy to be reachable) came out of fixing it.

Limits

  • The static scan Q39 added is literal-based; a subject composing its live destination from a constant other than SNAPSHOT_PATH or HELM_HOME is not covered by the redirect, and rule 3 covers only the two shapes measured here (--out at the live path, --pin).
  • Rule 2 redirects the module constant. A subject that writes live through a path built at runtime from anything else is still caught only by Q39's watch — prevention is total for the SNAPSHOT_PATH class, not for every class.
  • The legs still execute the subject's code; what changed is which destination that code can reach. A subject that damages something other than the snapshot is out of scope for this gate entirely.
  • The five-fixture suite is not a census, and it no longer reaches Q43's machinery at all. The two variants and the pin residual are the coverage.
  • 20 invocations on one host, one collector revision.

Adoption

The pair is subject 4e45021d… + gate c22601ec…. The deployed bin is unmoved (ee0b36a8… / 7ed1bca8… / 5c67473d…), the live snapshot is unchanged, and nothing was published. Adoption stays Alexander's — the cp is the only thing that puts any of Q31–Q43's protection into production, and Q40 measured that the deployed gate has no subject-selection path at all.