Commit Graph
6 Commits
Author SHA1 Message Date
fred 5cce40ff8b docs(amd1213-d): D1 confirmed by existing controls; correct my own survey error
D1 is confirmed, not assumed. The controls exist and are strong: all ten
mutation seams table-driven with byte-for-byte restoration of six artifacts,
new-seat rollback leaving no residue, and ROLLBACK_INTEGRITY escalation across
three parent-substitution attacks with recovery evidence written.

This corrects a wrong finding of mine. I reported that eleven injectFailure
seams existed in production and zero tests used any of them, and that D1's
rollback path had never executed. The grep behind that searched for the
identifier `injectFailure` in the specs; the specs pass the injector as an
inline lambda, so the controls were present and the search could not see them.
I was one step from writing a duplicate spec. Recorded with the method note --
grep the production seam names, not the parameter name.

One narrow gap left standing rather than papered over: nothing asserts the
in-memory restoration of plan.managedLinks.links to manifestLinksBefore. The
filesystem is checked, the plan object is not. It matters only if a caller
reuses a plan after catching a failure, which nothing does today, so it is
defence-in-depth and is noted for when D2/D4/D6 are confirmed.

Remaining amendment item is now D2/D4/D6 confirmation; D3 and D5 are closed.

Commit-only per scrappy's controlling packet (comms 20260813T212447Z dc43de):
not pushed, PR #1213 not updated, nothing re-authored.
2026-08-15 15:16:17 -05:00
fred be2d14fb6c docs(amd1213-d): D3 closed both halves; record the measured child env and the deliberate HOME residual 2026-08-15 15:06:31 -05:00
fred 3667a7a77f fix(launch): fix the composed-seat locale and measure the environment the runtime actually gets
Closes the environment half of AMD1213-D defect D3. The executable half landed
in 585dac7a; this is the other thing the card asked for -- a capability-minimal
child environment that is measured rather than asserted.

MEASURED FIRST, THEN CHANGED. I ran the real `fleet launch` route with a shim in
place of the runtime binary and had the shim dump its own environment, so the
subject is what arrives at the far end of the chain -- after composition, after
the lease gate in launch-runtime.py -- and not the object the launcher believed
it was building. Those are different sets and only the first one matters.

What the measurement showed is that most of this defect was already closed by
construction and nobody knew, because nothing tested it. `minimalLaunchEnv`
builds from an empty object over a fixed name list, so BASH_ENV, ENV, PYTHON*,
NODE_*, NPM_CONFIG_*, LD_PRELOAD, LD_LIBRARY_PATH and every provider credential
in the operator's environment are already excluded, and they stay excluded
through the lease gate. I planted all sixteen and none reached the child. An
allowlist that no test names is one careless edit from being a denylist, which
is the actual defect here.

Two real gaps, one fixed and one not:

FIXED -- locale was inherited. A seat picked up the operator's LANG and LC_ALL,
so the same runtime doing the same work emitted different message language,
collation, and number and date formatting depending on who started it. Composed
launches now pin C.UTF-8. C.UTF-8 and not C: both are unambiguous, but plain C
is ASCII and would mangle non-ASCII output, trading one defect for another. A
seat that needs a different locale declares LANG or LC_ALL in its profile and
the declared value still wins -- covered by a test, so the escape hatch cannot
be removed silently. The operator path (no declared env) is untouched.

NOT FIXED, AND DELIBERATELY -- HOME is still the operator's. The card asks for
the seat config root instead, and it is right that this is the remaining leak:
the runtime is pointed at its own config directory, but anything it shells out
to (git, ssh, npm) still reads the operator's dotfiles and therefore the
operator's credentials. I am not changing it inside this amendment. A seat whose
HOME is a bare directory has no gitconfig and no ssh key, so it cannot commit or
push, and the fleet MVP's whole proof is a seat carrying a change to a pushed
branch. Moving HOME before the per-agent home is populated would improve the
isolation and break the deliverable. That population is what the harness-homes
design owns, and this is recorded as a residual there rather than half-done
here.

Eight tests. Each one falsified by inverting the property it claims to defend,
and each inversion hit exactly its own test and nothing else:

  - added BASH_ENV to the inherited list  -> permitted-set and loader-hook
                                             killers both red, 2 failed
  - reverted the locale pin               -> locale killer red, 1 failed
  - reused an ambient MOSAIC_LAUNCH_ID    -> launch-id killer red, 1 failed
  - recorded process.env into the ledger  -> ledger-value killer red, 1 failed

The permitted-name list in the spec is written out by hand rather than derived
from the launcher. Deriving it would make the test agree with the code by
construction and detect nothing; the cost is that adding a variable means
editing the test, which is the point.

The launch-id test is worth naming separately. recordLaunch overwrites
MOSAIC_LAUNCH_ID in process.env before it is copied to the child, so a seat
launched from an operator session gets a fresh correlation id rather than
inheriting the operator's. That was already true and is now pinned, along with
the requirement that the child's id matches the one in the ledger -- correlation
is by this value and never by pid, because exec makes the runtime a different
process.

Verification: typecheck RC=0. eslint RC=0. prettier clean. Three consecutive
full-package runs under the sanitized lease environment, RC=0, 87 files / 1627
tests passed, 0 failed -- exactly one file and eight tests more than the 86/1619
baseline, so nothing else moved.

Commit-only per scrappy's controlling packet (comms 20260813T212447Z dc43de):
not pushed, PR #1213 not updated, nothing re-authored.
2026-08-15 15:04:44 -05:00
fred d400ec5b9d docs(amd1213-d): record measured defect state, residuals, and remaining D3 environment half
Replaces notes that stopped on 2026-08-13 and understated progress by roughly two
defects. Every row was re-measured against the tree rather than inherited: D1, D2,
D4 and D6 read as substantially implemented; D3 and D5 closed this pass.

Records the three things most likely to be lost or undone: that D2's alias defence
is strictness rather than normalization and breaks if someone normalizes the link
first; that D3 leaves two named residuals (the validation-to-exec window Node
cannot close, and launch-runtime.py re-resolving the binary for claude/pi); and
that the remaining D3 work is the measured child environment, not the executable
resolution already done.

Also records why the full suite needs the sanitized lease env, and the identity
blocker that keeps this commit-only regardless of when the hold lifts.
2026-08-15 14:47:56 -05:00
fred b91b702a53 fix(launch): drop the dead recordLaunch test seam; stop a load-sensitive spec reporting CPU load as a defect
Two changes, both about a test seam that alters production behaviour.

AMD1213-D defect D5 objected that `launchFleetRuntimeForTest` was an exported
production API that also set `recordLaunch:false`, changing a second production
branch beyond the two the card authorized. Most of that is already closed in
this tree: the exported helper is gone, and the specs now enter through the real
`registerFleetLaunchCommand -> apply -> launchFleetRuntime -> launchRuntime`
route on a fixture seat, with the ledger pointed at the fixture and asserted
(`fleet-launch-command.spec.ts` asserts `events.ndjson` contains the record).
The seat-seeded/HOME-empty pass and HOME-seeded/seat-empty fail pair both exist.

What remained was the `recordLaunch?: boolean` context field itself. Nothing in
the package sets it -- it is a dead switch whose only effect was to let a caller
silently disable launch recording on the claude branch while codex, opencode and
pi recorded unconditionally. Removed, so all four branches record the same way
and the asymmetry cannot be reintroduced by passing a flag.

The second change is unrelated to D1-D6 and is called out as such. It is here
because the amend's required evidence includes a green full-package Vitest run,
and one spec made that non-reproducible.

`install-ordering-guard.spec.ts` proves that `guardClaudeSettingsWiring` really
delegates to `leaseEnforcementActivatable()` by comparing the guard's outcome
against its own call to the same predicate. That predicate is not deterministic:
`defaultCapabilityProbe` runs `dist/cli.js` out-of-process with a 2000 ms
timeout. In a full-package run with 86 spec files scheduled at once, one
observation beats that timeout and the next does not, the two disagree, and the
test fails -- reporting machine load as a wiring defect. It passed in isolation
every time, which is why it read as a flake.

Measured rather than assumed. The failure reproduced in three consecutive full
runs and passed 3/3 in isolation. It was NOT caused by the recordLaunch removal
above: reverting only that edit and re-running the full suite still failed, which
is what ruled my own change out.

The guard call is now bracketed by two observations of the predicate, and only a
pair that agrees is used as ground truth; a disagreeing pair is retried, up to
three attempts, and never holding still is itself a failure rather than a skip.
This does not weaken the assertion -- a real delegation failure is stable and
survives every attempt while load noise is not.

Falsified: inverting the guard's default to `!leaseEnforcementActivatable()`
turns the test red (1 failed / 18 passed), so the retry did not blunt what the
test detects. The inversion was reverted and the file confirmed clean.

Verification: typecheck RC=0. Three consecutive full-package runs, RC=0,
86 files / 1619 tests passed, 0 failed, under the sanitized lease environment
(MOSAIC_LEASE_* and MOSAIC_RUNTIME_GENERATION stripped).

Commit-only per scrappy's controlling packet (comms 20260813T212447Z dc43de):
not pushed, PR #1213 not updated, nothing re-authored.
2026-08-15 14:46:47 -05:00
fred 585dac7a5d fix(launch): resolve the runtime binary once and execute the object that was checked
AMD1213-D defect D3. The fleet launch path asked `which` whether a runtime was
reachable and then spawned the bare name, letting the OS resolve it a second
time against an ambient PATH at a later moment. Two independent resolutions of
an attacker-influenced name with a gap in between is not a check.

Measured against the old code before changing it. A world-writable shim named
`codex` prepended to PATH:

    OLD checkRuntime  -> PASSED (which found it)
    OLD execRuntime   -> "SHIM EXECUTED -- this is not the real runtime"

The probe satisfied the check and then supplied the thing that ran.

Three call sites were exposed, not one: `checkRuntime`'s `which`; `execRuntime`
spawning 'codex'/'opencode' by name; and `execLeaseGatedRuntime` spawning
'python3' by name -- the interpreter that starts the lease gate itself, where a
shim does not bypass one check, it replaces the process that enforces all of
them. `minimalLaunchEnv` copies ambient PATH straight through, so the child
inherits the same search.

The fix: `resolveExecutableFromPath` searches only the PATH the child will
actually receive, validates the object the search lands on (regular file,
executable, not group/other-writable, owned by the launching user or root, with
no group/world-writable non-sticky directory and no foreign-owned directory on
its resolved path), and returns that path pinned to its dev/ino. Callers execute
the returned path, never the name again. Rules that are each a hole if dropped:
a relative PATH entry is skipped, since it resolves against wherever the
launcher was started; the first name match decides the outcome and an unsafe
first match is a refusal rather than a reason to keep looking, because falling
through would let a planted binary silently downgrade the search to whatever
came after it; a symlink is followed and the real file is what gets validated
and executed, since validating the link and executing the name repeats the
original bug one level down.

`checkRuntime` is kept unchanged on the operator path. `which` proves
reachability from the operator's own shell, which is the right question there
and the wrong one for a seat. The fleet lease-gate interpreter now comes from
the root-owned `trustedCapability('python3')` the helper already requires.

Two residuals, stated rather than engineered around:

  * `assertUnchangedSinceValidation` re-confirms dev/ino immediately before
    spawn. That narrows the validation-to-exec window; it does not close it.
    Closing it means executing a held descriptor and Node has no portable way to
    exec by descriptor. A same-UID replacement landing inside the remaining
    window is the same accepted boundary already documented for the fleet
    helper.
  * For claude and pi the runtime binary is still re-resolved inside
    launch-runtime.py after the trusted interpreter starts it. This change does
    not cover that path.

Twelve tests in launch.spec.ts, each written against a specific hole: safe
resolution; world-writable binary; safe binary under a world-writable
directory; no fall-through past an unsafe first match; relative PATH entry
ignored; symlink followed and real file validated; symlink to an unsafe target
refused; non-executable refused; directory sharing the name refused; a path
rather than a name refused; no PATH declared; not-found reported as not-found
rather than resolving something else.

One of those tests was written wrong first and is worth recording: creating the
open directory with `mkdirSync(path, { mode: 0o777 })` gets masked by the umask
to 0o755, so the case passed while testing nothing. It creates at 0o755 and
chmods after.

Verification: typecheck RC=0. Full package suite 1615 passed / 4 failed / 1619.
The four failures are the pre-existing host lease-identity leak into spawned
hooks, not this change -- the same spec re-run with only the five MOSAIC_LEASE_*
and MOSAIC_RUNTIME_GENERATION variables stripped from the environment, with no
code change, is 20/20.

Scope note: this commit carries the uncommitted D1/D4/D6 work already present in
the tree alongside D3, because it is interleaved in the same files and is one
amend package. D2 and D5 are not yet assessed.

Commit-only per scrappy's controlling packet (comms 20260813T212447Z dc43de):
not pushed, PR #1213 not updated, nothing re-authored.
2026-08-15 14:07:57 -05:00