From 4d1ba6923b9af97d6cc31f4e0681a23850533d8a Mon Sep 17 00:00:00 2001 From: YEONGUK LEE Date: Sun, 18 Oct 2026 12:46:41 +0900 Subject: [PATCH 1/8] test: separate Linux attribution causes and guard-gap models --- .github/workflows/linux-gates-research.yml | 28 ++ experimental/126-linux-gates/README.md | 400 ++++++++++++++++++ experimental/126-linux-gates/fixtures/codex.c | 119 ++++++ .../126-linux-gates/fixtures/node/codex | 54 +++ .../g1-identity-visibility/argv-check.mjs | 44 ++ .../g1-identity-visibility/expectations.mjs | 319 ++++++++++++++ .../expectations.test.mjs | 272 ++++++++++++ .../g1-identity-visibility/inspect-child.mjs | 133 ++++++ .../g1-identity-visibility/lifecycle.mjs | 82 ++++ .../g1-identity-visibility/lifecycle.test.mjs | 75 ++++ .../g1-identity-visibility/protocol.mjs | 100 +++++ .../g1-identity-visibility/run.mjs | 302 +++++++++++++ .../g1-identity-visibility/variants.mjs | 118 ++++++ .../126-linux-gates/g2-guard-gaps/designs.mjs | 330 +++++++++++++++ .../g2-guard-gaps/designs.test.mjs | 205 +++++++++ .../g2-guard-gaps/proc-locks.linux.test.mjs | 181 ++++++++ 16 files changed, 2762 insertions(+) create mode 100644 .github/workflows/linux-gates-research.yml create mode 100644 experimental/126-linux-gates/README.md create mode 100644 experimental/126-linux-gates/fixtures/codex.c create mode 100644 experimental/126-linux-gates/fixtures/node/codex create mode 100644 experimental/126-linux-gates/g1-identity-visibility/argv-check.mjs create mode 100644 experimental/126-linux-gates/g1-identity-visibility/expectations.mjs create mode 100644 experimental/126-linux-gates/g1-identity-visibility/expectations.test.mjs create mode 100644 experimental/126-linux-gates/g1-identity-visibility/inspect-child.mjs create mode 100644 experimental/126-linux-gates/g1-identity-visibility/lifecycle.mjs create mode 100644 experimental/126-linux-gates/g1-identity-visibility/lifecycle.test.mjs create mode 100644 experimental/126-linux-gates/g1-identity-visibility/protocol.mjs create mode 100644 experimental/126-linux-gates/g1-identity-visibility/run.mjs create mode 100644 experimental/126-linux-gates/g1-identity-visibility/variants.mjs create mode 100644 experimental/126-linux-gates/g2-guard-gaps/designs.mjs create mode 100644 experimental/126-linux-gates/g2-guard-gaps/designs.test.mjs create mode 100644 experimental/126-linux-gates/g2-guard-gaps/proc-locks.linux.test.mjs diff --git a/.github/workflows/linux-gates-research.yml b/.github/workflows/linux-gates-research.yml new file mode 100644 index 0000000..f1ad4af --- /dev/null +++ b/.github/workflows/linux-gates-research.yml @@ -0,0 +1,28 @@ +name: Linux attribution research gates + +on: + pull_request: + paths: + - '.github/workflows/linux-gates-research.yml' + - 'experimental/126-linux-gates/**' + - 'experimental/126-guard-liveness/**' + workflow_dispatch: + +permissions: + contents: read + +jobs: + research-models-and-kernel: + name: Models and synthetic Linux lock observations + runs-on: ubuntu-24.04 + timeout-minutes: 10 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7 + with: + node-version: 24 + # Only built-in Node modules and Python stdlib. These tests do not load + # a Codex binary or the product, and do not establish unlock eligibility. + - run: node --test experimental/126-linux-gates/*/*.test.mjs diff --git a/experimental/126-linux-gates/README.md b/experimental/126-linux-gates/README.md new file mode 100644 index 0000000..7fc88bb --- /dev/null +++ b/experimental/126-linux-gates/README.md @@ -0,0 +1,400 @@ +# Linux gates for #137: cause separation and guard-liveness designs (research, #126) + +Research only. Nothing here changes `src/`, policy, eligibility, the +allowlist, platform support, dependencies or the JSON contract, and nothing is +published by `npm pack` (`experimental/` is outside `files`). No experiment +calls `unlockInspectedThread`. No code here signals a holder, a lock owner or +a Codex process, or uses an unlock path. Every holder is a **synthetic +fixture**. **No result here is evidence about a native Codex binary.** Its +argv, image and dumpability on Linux remain unmeasured. + +It builds on `../126-guard-liveness/`, which is read-only here. That +directory's shared lifecycle helpers, isolated observer and trusted build +script are imported. #137 source was read from an isolated `git archive` of +`b483cd587c62329243366e64aaaac477edd36adc`. + +| Part | Question | Status at this revision | +| --- | --- | --- | +| G1 `g1-identity-visibility/` | Which of the two causes failed the 2026-10-16 F2 positive control, and does each fail independently? | Implemented. Argv predictions checked locally against the product's pure function; macOS plumbing smoke. **Not run on Linux.** | +| G2 `g2-guard-gaps/` | fdinfo FD reuse, lock file system stalls, evidence aging, shared descriptions | Pure models with deterministic counterexamples. **G4 is no-go** (binding unproven). 3 Linux-only kernel checks **skipped on macOS** | + +## Signals and bounds + +| Process | Signal behaviour | +| --- | --- | +| Holders, inherited-lock fixtures, lock owners, unlock paths | None. Stop = stdin EOF or a runner-owned release file, waited for with a bound; an expired bound is `incomplete`, never escalated | +| Inspector child (`runIsolated`) | None from the runner; past its bound it gets stdin EOF and is left running (`incomplete`) | +| Bounded `spawnSync` diagnostics: compiler, `--version`, `lsof -v`, build `git`/`npm`, the test lock prober | Node sends SIGTERM to that diagnostic process if its timeout expires | +| Product diagnostics inside the inspector (`lsof`, `ps`) | The product's timeout sends SIGTERM then SIGKILL to the timed-out diagnostic's own process group | + +No recorded run proves that every one of these paths was signal-free. The +result files record diagnostic status and signals where this code captures +them, but they do not instrument product diagnostics. + +## G1: separating argv identity from lsof visibility + +### Why the F2 control failed + +The F2 isolated run on the Linux test host (2026-10-16) failed rows V, E1 and E4 +of the dumpable=1 control. Two causes were mixed: + +1. **Executable/argv identity.** The F2 fixture is a `#!/usr/bin/env python3` + script named `codex`. `env` execs `python3`, so the kernel argv is + `[python3, /codex]`. +2. **lsof visibility.** That host's lsof wrote a warning that information + may be incomplete, because overlay/nsfs mounts were inaccessible. + +### Owner lock-set availability is a separate measured cause + +On the US host, the first nominal run collected every row but did not match +the original E5 prediction. In #137, a failed owner lock-set lookup returns +an empty array; policy also emits `owner_holds_other_thread_locks` for that +array. This does not establish that the owner holds another lock. G1 now +records `ownerLockFiles` and attributes this combination to +`owner_lockset_unavailable` only when the lookup-failure blocker and the +observed empty array are both present. A nonempty or unavailable array does +not receive that explanation. The E5 mount-warning predictions include this +downstream availability cause; the product refusal remains unchanged. + +### Product conditions (#137 `b483cd5`, source) + +| Check | Location | Condition | +| --- | --- | --- | +| Interpreter/argv shape | `process.ts` `codexArgumentOffset` | `basename(argv[0])` is `codex` or `codex-`, or `node`/`nodejs` with `basename(argv[1])` `codex`. Otherwise `arguments_unverified:unsupported executable argv` | +| Argument agreement and grammar | `reconcileArguments`, `classifyCodexMode` | `ps -o args=` equals the space-joined kernel argv; every later token passes the bounded grammar | +| `isCodex` | `inspectProcess` | lsof `c`, ps `comm` basename `codex`/`codex-*`, or verified arguments matching `(^\|/)codex(\s\|$)`. An argv error nulls the arguments | +| Instance on Linux | `inspectProcess` | start ticks and the command fingerprint (the `/proc/PID/exe` readlink **string** plus cmdline) read twice and equal, else `linux_process_instance_unverified` | +| Opener lookup | `findLockOpeners` | **any** lsof stderr is `error`, even at exit 0 | +| Participation in inspection | `inspectThread` | attempted only with no opener error and exactly one process | +| Owner lock set | `lockFilesOpenedByProcess` | any lsof stderr is `owner_lock_file_lookup_failed` | +| Eligibility | `policy.ts` | opener error: `lock_opener_lookup_failed`; `classify` returns `unknown`; missing participation: `linux_participation_unproven` | + +So the lsof warning can block E4 and eligibility for **any** fixture. The F2 R2 +"pass" at dumpable=0 also came through the opener path, not from an +attributed permission denial. The product's lsof refusal is unchanged here. + +### Variants (all synthetic) + +| Variant | Kernel argv | Image | Dumpable | Purpose | +| --- | --- | --- | --- | --- | +| `script` | `python3 /codex` | python3 | 1, 0 (prctl) | reproduce F2's identity failure | +| `argv0` | `codex /codex` (spawn `argv0`) | python3 | 1, 0 (prctl) | identity passes on argv alone: the check is argv-shaped, not image-based | +| `native` | `/native/codex` | `fixtures/codex.c`, compiled on the run host | 1, 0 (prctl) | a native image named `codex` with no arguments | +| `node` | ` /node/codex` | node | 1 (inferred from `/proc/PID/fd` ownership) | the interpreter form the product grammar names | + +The runner refuses a run whose `/proc/PID/cmdline` differs from the variant. +`native` needs `/usr/bin/cc`, `gcc` or `clang`. Without one it is +`not_executed:no_compiler`, never success; nothing is installed or changed. +`G1_VARIANTS` must be nonempty, known and duplicate-free. Deselected variants +are recorded `not_selected`. + +### Provenance + +The runner takes the build manifest SHA-256 once, before any holder starts. +It passes that value to each inspector, and the inspector refuses to import +product code unless its own re-hash matches it. The protocol then checks +the inspector's reported hash against the runner's value. The runner +re-verifies the build after every run, and drift fails that run. The report +records the SHA-256 of every research input file (runner, inspector, +protocol, expectations, variants, lifecycle, fixtures, the shared helpers it +imports) and of the node and python interpreter binaries, at start and end, +with a combined input hash. **Coverage limits:** build hashing covers `dist/` +and native addons only. It does not cover dependency JavaScript, package +metadata, shared libraries or the Python standard library, and it does not +prove upstream identity. + +### Stages and typed evidence + +`dist_identity`, `module_load`, `lsof_control`, `openers`, `identity_direct`, +`prepare`, `guarded_verify`, `held_probe`, `inspect_thread`: once each, in +order, each typed. `LockProbe` values are checked against #137 `types.ts`. +`held` needs an acquired guard, and participation is only allowed with +`held`. A failed `prepare` must be followed by the harness skip, and only then. +`identity_direct` and `prepare` must name the runner's holder PID. + +- `lsof_control`: product `findLockOpeners` on a regular file nobody opens. + It must report `processCount` 0. +- `identity_direct`: product `inspectProcess` on the holder PID even when lsof + did not list it. +- `held_probe`: product `guardedProbeOnce` without attribution. Denial rows + require this typed `held`/`acquired` observation. + +Every start-ticks sample attributed to the holder PID must equal the +runner's readiness sample, or collection fails. These are openers, +`identity_direct`, `prepare`, guarded participation, inspection owner and +participation. A `null` sample stays unavailable and never matches. + +### Narrow lsof classification + +Only stderr consisting of pairs of `lsof: WARNING: can't stat() file +system /` and the indented `Output information may be incomplete.` +counts as the mount-visibility cause (`lsof_mount_warning`). A missing tool, a +timeout, an output limit, an exit status or any other text is +`lsof_infrastructure_error`. Host classes: `clean` (no control stderr), +`mount_warning`, or `unclassified` (anything else, or a control with +openers). An unclassified host has no prediction and never matches. A +lock-path mount warning that differs from the control is +`unexplained:lsof_mount_warning_differs`. Raw lsof text stays in the private +result; the printed summary carries classes and codes only. + +### Collected, measured, predicted + +- **collected**: protocol complete and typed, build manifest equal to the + runner's, holder PID and tick bindings equal, same holder instance from + readiness to after inspection, and every stop outcome clean; +- **measured** rows (`basis: measured`): `pass`, `fail` or `blocked`, with + causes from the same run; +- **source prediction** rows (`basis: source_prediction`, some + `confidence: source_only`) compared with the measured rows; +- **coverage**: exactly one run per required variant/dumpable. No + duplicates, no extras, and no run for a not-executed variant. Coverage is + checked before `complete`, `controlPassed` or `refusalCounted` is computed; +- **controlPassed** (every dumpable=1 row `pass`) and **refusalCounted** (H0, + R1, R2, R3 `pass` with that variant's control passed). + +A matched prediction of a failure is **not** a passing control. A `blocked`, +`fail`, missing or `not_executed` row never counts as success. At dumpable=0, +R1–R3 are `blocked` unless H0 observed `held`/`acquired`. R2 requires the +inspection probe to be `held` without participation. R3 requires the +product's typed `unknown`/`linux_participation_unproven` probe with an +acquired guard for a listed owner. + +### Predicted tables (source predictions, not measurements) + +Mount-warning host (the 2026-10-16 host class). H0 is predicted `pass` +everywhere. + +| Variant | d | V1 | V2 | I1 | I2 | E2 | E3 | E4 | E5 | +| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | +| script | 1 | pass | fail (lsof) | fail (argv) | fail (argv) | pass | pass | blocked (lsof) | fail (argv + lsof) | +| argv0, native, node | 1 | pass | fail (lsof) | pass | pass | pass | pass | blocked (lsof) | fail (lsof) | + +| Variant | d | V1 | V2 | I1 | R1 | R2 | R3 | +| --- | --- | --- | --- | --- | --- | --- | --- | +| script | 0 | fail (hidden, source_only) | fail (lsof) | fail (argv + instance) | pass | pass | blocked (lsof + hidden, source_only) | +| argv0, native | 0 | fail (hidden, source_only) | fail (lsof) | fail (instance) | pass | pass | blocked (lsof + hidden, source_only) | + +`lsof` = `lsof_mount_warning`, `argv` = `argv_identity`, `instance` = +`instance_unverified`, `hidden` = `owner_hidden`. + +On a clean host, V2 passes, E4 passes for every dumpable=1 variant, and E5 +passes for `argv0`, `native` and `node`. At dumpable=0, R3 stays blocked +(hidden), so **no refusal is counted on either host class** unless lsof turns +out to list non-dumpable owners. That would appear as an unmatched row. + +Expected on the mount-warning host: `predictionsMatched: true`, +`controlPassed` false everywhere, `refusalCounted` false (`not_applicable` +for node). Exit 0 if a compiler exists, 2 if `native` was not executed. + +### Exit status + +`0`: collected, coverage valid, every prediction matched, all variants ran. +`2`: the same, but a variant was not executed (partial). `1`: anything else, +including any `incomplete`, cleanup fault, build or input drift. None of +these is a product success. + +## G2: guard-liveness gaps, designs and refusal rules + +`g2-guard-gaps/designs.mjs` models each gap as a pure function. +`designs.test.mjs` holds deterministic counterexamples. Their schedules are +**models**, not executions and not claims about Codex runtime paths. Every +proposed decision function validates its input types and refuses malformed or +missing fields without throwing. Comparison models named `*B483` require their +documented fixture shapes and are not general validators. `proc-locks.linux.test.mjs` has three +real-kernel checks that run only on Linux. **On macOS they are skipped and +prove nothing.** Kernel statements come from pinned Linux v6.17 +(`e5f0a698b34ed76002dc5cff3804a61c80233a7a`) `fs/locks.c` and +`fs/proc/fd.c`. This was a targeted reading, not an audit. + +### Gap 1: fdinfo FD reuse and `show_fdinfo` under the guard + +In #137 and F4, the guard reads `/proc/PID/fdinfo/N` after a matching +readlink. If N is reused in between, `seq_show` runs the new file's +`show_fdinfo`; for epoll, `ep_show_fdinfo` takes `ep->mtx`. The later readlink +refuses the result, but only after the wait. Node has no `pidfd_getfd`, and +Yama `ptrace_scope=1` would refuse it for non-descendants. + +Candidate design **G4** reads no per-descriptor fdinfo in the guard: + +| Sample | Reads | +| --- | --- | +| before guard | `statfs` gate (gap 2), fd list, readlinks, start ticks, fdinfo row on the one candidate FD | +| inside guard | held probe, `stat` lock path, `/proc/PID` uid, mount and PID namespace readlinks, ticks, fd list, readlink, `/proc/locks`, readlink | +| after guard | fdinfo row, fd list, readlinks, ticks, command fingerprint | + +`/proc/locks` calls no file-type callback. `locks_start` takes +`percpu_down_write(&file_rwsem)`. That is a source argument for a finite +wait, not a bound. + +**G4 is no-go.** Its best result is `consistent_unproven`: the samples did +not refute participation, but they do not prove it. The `/proc/locks` PID is +`flc_pid`, the tgid that called `flock()`. It names a process, not a +description, and has no instance binding. A historical PID can be reused. The +**hidden-transfer model schedule** satisfies every G4 check while the +sampled description is unlocked inside the guard: + +1. In `pre`, candidate P holds description A at FD7. +2. P unlocks A, flocks a new description B at a temporary FD, passes B to a + hidden recipient and closes the temporary FD. +3. In the guard, FD7 is unlocked and the recipient alone holds B. + `/proc/locks` shows B with acquirer P. +4. The recipient releases B, and P reacquires A before `post`. + +Every sampled FD list, readlink and start tick matches. The pre/post fdinfo +rows match, and the guard row PID is P. Until the description binding is +solved, an equal acquirer PID is a necessary check only, never proof that the +candidate's current description participates. The test asserts the model's +outcome. It does not claim that any Codex path behaves this way, and it +asserts no continuously-held WRITE ABA execution. + +Still useful refusals: another acquirer, an inherited or passed description +(`acquirer_pid_mismatch`), omitted rows, ambiguity, size, overflow, unsafe +PIDs and identity drift. The review's pure repro is now an +`evidence_malformed` refusal: all three samples without descriptors, +readlink or ticks, which previously matched as `undefined === undefined`. + +Remaining options: accept the in-guard `show_fdinfo` wait as a documented +residual, find a description binding (none is available to unprivileged +Node), or refuse Linux unlock (diagnostics only). The user decides. + +### Gap 2: file system I/O stalls under the guard + +#137 calls `statfs`/`stat` on the lock path inside the participation guard, +and the allowlist (ext, tmpfs, XFS) applies only after `statfs` returns. +**fs-gate** moves `statfs` before the participation guard and refuses +non-allowlisted file systems without taking it. **Residual**: main's guarded +probe still opens and flocks the lock file under the guard. Refusing before +any guard also changes diagnostics on such homes, which is a policy +decision. Allowlisted local file systems can still stall, unbounded. + +### Gap 3: evidence aging between observation and signal + +`finalBoundary` decides at `checkAt` and reports `hardBound: false` for +every input. Timestamps must be finite and monotonically ordered. A +`killAt` before `checkAt` and a soft limit that is not a finite nonnegative +number refuse. The review's descending-time repro (1000…600) now refuses +instead of returning negative ages. **F4's 25 ms check is not a hard bound.** +It measures only the post-release confirm step, and a stale observation with +a fast confirm passes it. The proposed soft rule adds evidence age at the +check, which still bounds nothing about the signal: there is no pidfd, and +the thread can be descheduled before `process.kill`. Post-signal exit, +lock-release and transcript-hash verification stay mandatory. No output or +document may call these checks a staleness or release-to-signal bound. + +### Gap 4: inherited-description participation versus sole control + +A fork-without-exec child or an SCM_RIGHTS recipient shares the description, +because O_CLOEXEC only affects exec. #137 treats descendants as a warning; the +test shows such a child is eligible there. The proposal +`soleControlDecision` refuses in these cases: + +- any lsof error; +- an opener set other than the candidate; +- an acquirer other than the candidate (necessary only, see G4); +- descendants that are unavailable, or any descendant; +- in `strict` mode, any unobservable same-user process. + +Unknown modes and malformed PID arrays refuse; the review's `'strcit'` repro +no longer falls through to `standard`. The best outcome is `not_refused` with +`soleControlProven: false` and residuals (`description_binding_unproven`, plus +`hidden_sharer_not_excluded` in standard mode). A lock that is still held +after a signal must be reported, never escalated. + +### Linux-only kernel checks + +| Check | Fixture | Bound and confirmation | +| --- | --- | --- | +| direct holder | Python flock holder | ready/stop bounded; exit 0 with no signal and no stdin error required | +| inherited description | Parent takes the flock, starts a grandchild with the FD and **no inherited stdio**, then exits. The grandchild's control channel is a runner-owned directory: it writes `ready.json` (PID, start ticks, FD), holds until `release` exists or its own 20 s lifetime, closes the FD and writes `exit.json` | parent exit bounded (5 s); ready, exit record and instance end each bounded (5 s). The grandchild's start ticks are checked before and after the measurements. Exit is confirmed by its own record plus the instance no longer being live with those ticks. Lock `free` after release. Any expired bound fails as incomplete | +| epoll fdinfo | this Node process's epoll FD | reads `tfd:` lines; shows the read path reaches `show_fdinfo`, not a wait | + +The earlier inheritance test kept the grandchild on the parent's stdin pipe. +It could therefore have seen EOF when that pipe closed. That race was +suspected from source, not reproduced on Linux. The handshake above removes +the dependency. On macOS only the Python handshake logic was exercised: it +reached ready, held after parent exit and closed stdin, then released and +freed. `/proc` parts are absent on macOS. + +## Running + +```sh +npm ci && npm run build +export RESEARCH_TMP_ROOT="" +node --test experimental/126-linux-gates/*/*.test.mjs +node experimental/126-guard-liveness/f2-linux-nondumpable/build.mjs \ + --repo --commit b483cd587c62329243366e64aaaac477edd36adc --out "$BUILD" +G1_BUILD="$BUILD" node experimental/126-linux-gates/g1-identity-visibility/argv-check.mjs +G1_BUILD="$BUILD" node experimental/126-linux-gates/g1-identity-visibility/run.mjs # Linux only +``` + +### Linux test host procedure (for the reviewer) + +Use an unprivileged same-UID user. Make no privilege, mount, package or +host-policy change, and use no real Codex process or home. Send no holder, +owner or unlock signal. Diagnostic timeouts behave as described under +"Signals and bounds". Run from a frozen archive containing `package.json`, +`experimental/126-guard-liveness/` and `experimental/126-linux-gates/`. +`package.json` makes the extensionless Node fixture ESM. Compare its +SHA-256 with the integrating host's value before extraction. + +```sh +umask 077 +R="$(mktemp -d /tmp/codex-126g-run-XXXXXX)"; mkdir "$R/src" +sha256sum g1-input.tar # must equal the integrating host's value +tar -xf g1-input.tar -C "$R/src" && cd "$R/src" +node experimental/126-guard-liveness/f2-linux-nondumpable/build.mjs \ + --repo --commit b483cd587c62329243366e64aaaac477edd36adc --out "$R/build"; echo "build=$?" +G1_BUILD="$R/build" node experimental/126-linux-gates/g1-identity-visibility/argv-check.mjs > "$R/argv-check.json"; echo "argv=$?" # expect 0 +RESEARCH_TMP_ROOT="$R" node --test experimental/126-linux-gates/*/*.test.mjs > "$R/tests.tap" 2>&1; echo "tests=$?" # expect 0, 37 tests, 0 skipped on Linux +RESEARCH_TMP_ROOT="$R" G1_BUILD="$R/build" node experimental/126-linux-gates/g1-identity-visibility/run.mjs > "$R/g1-nominal.json"; echo "g1=$?" # expect 0 (compiler) or 2 (no compiler) +RESEARCH_TMP_ROOT="$R" G1_BUILD="$R/build" G1_VARIANTS=argv0 G1_CHILD_FAULT=stall:identity_direct \ + node experimental/126-linux-gates/g1-identity-visibility/run.mjs > "$R/g1-stall.json"; echo "stall=$?" # expect 1, incompleteCount >= 1 +sha256sum "$R"/*.json "$R/tests.tap" "$R"/codex-126-g1-*/g1-results.json +jq '{exitCode, collected, inputsSha256, manifestSha256, hostClass, coverageValid, predictionsMatched, complete, notExecuted, controlPassed, refusalCounted, rows}' "$R/g1-nominal.json" +nominal_dir="$(jq -r '.resultDirectory' "$R/g1-nominal.json")" +jq '[.expectations.comparisons[] | select(.matched | not) | {variant, dumpable, reason, rows: [.rows[] | select(.matched | not)]}]' "$R/$nominal_dir/g1-results.json" +``` + +The stall run selects only `argv0`, so its `notExecuted` lists the others as +`not_selected`. Expected nominal `rows` on a mount-warning host are the +tables above. Any unmatched row or `unclassified` host is a finding to +report, not something to repair. The private result files contain local +paths, PIDs and raw lsof text. Publish only hashes and sanitized outcomes. + +## Integrating Linux validation + +The integrating reviewer ran the revised harness on the approved US Linux +host (arm64, Node 24.18.0, local ext filesystem). All 37 research tests passed, +with no skips. The source-derived argv checks passed. The build came from +the pinned #137 commit above, with no patch applied. + +The first nominal run collected successfully but exited 1 because the owner +lock-set availability cause described above was absent from the prediction. +Its raw SHA-256 was +`6fc60cfb4b574a536c32ee124e7211329c41a76eb9d91fa189405da7d7e107bb`. +After adding the observed lock-set field, a source-checked cause rule and a +counterexample test for nonempty lock sets, the repeated run exited 0: +`collected=true`, `predictionsMatched=true`, no incomplete observations. +All positive controls and counted refusals remained false on this +mount-warning host. This validates cause separation, not unlock readiness. +Raw SHA-256: +`03df43dd9948d81f8e359f33d8e82b4e477c8ac0d6fa03cde5a0d700058de9b0`. + +The revised `stall:identity_direct` run exited 1 as required and recorded two +incomplete inspections; it did not promote any control or refusal. Raw SHA-256: +`d451e31974538e234d5a089e8f1aa98112dac128188fc629f29c160bfc40bcd3`. +Both runs recorded research-input SHA-256 +`2453ef543248d01d05bb2a9113c8b8df4627b2528b5071246036cefa6715d60b`. +Every holder was synthetic; native Codex dumpability and argv remain unmeasured. + +## Open decisions (no-go for readiness) + +| Item | Options | Owner | +| --- | --- | --- | +| lsof stderr blocks every Linux positive control on hosts with inaccessible mounts | keep (fail closed; Linux unlock unavailable there), or a reviewed policy for known mount warnings | user/reviewer | +| Identity is argv-shaped, not image-based (`argv0`) | accept and document, or require an image check (no native dependency available) | user/reviewer | +| Native Codex argv/dumpability on Linux | needs a real binary run | user | +| Gap 1 fdinfo in guard | G4 is no-go (binding unproven); accept the residual, find a binding, or diagnostics only | user/reviewer | +| Gap 2 lock file system | fs-gate (participation only), or refuse before any guard | user/reviewer | +| Gap 3 evidence aging | accept the unbounded residual with post-signal verification, or no Linux unlock | user | +| Gap 4 sole control | standard or strict rule; descendants from warning to blocker on Linux | user/reviewer | diff --git a/experimental/126-linux-gates/fixtures/codex.c b/experimental/126-linux-gates/fixtures/codex.c new file mode 100644 index 0000000..4f98ffb --- /dev/null +++ b/experimental/126-linux-gates/fixtures/codex.c @@ -0,0 +1,119 @@ +/* + * Synthetic native holder named `codex` (#126 research, Linux gates G1). + * + * SYNTHETIC. This is not Codex and says nothing about a deployed Codex + * binary. It exists only so that the product's executable/argv checks see a + * native image whose argv[0] basename is `codex`, with no arguments. + * + * Behaviour mirrors ../../126-guard-liveness/f2-linux-nondumpable/codex: + * refuse root and differing real/effective UIDs, set and verify the requested + * dumpable value with prctl, take `.coordination.lock` then the thread lock + * with bounded nonblocking polling, release coordination, print one ready + * JSON line, then hold until stdin EOF (exit 0) or the lifetime (exit 3). + * + * Environment: FIXTURE_LOCK_PATH, FIXTURE_DUMPABLE (0 or 1), + * FIXTURE_LIFETIME_SECONDS (1-120, default 20). + * + * It sends no signal to any process. Build: cc -std=c11 -O1 -Wall -Wextra. + */ +#define _GNU_SOURCE +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#ifdef __linux__ +#include +#endif + +static double now_s(void) { + struct timespec ts; + clock_gettime(CLOCK_MONOTONIC, &ts); + return (double)ts.tv_sec + (double)ts.tv_nsec / 1e9; +} + +static int fail(const char *what) { + fprintf(stderr, "codex fixture: %s: %s\n", what, errno ? strerror(errno) : "invalid"); + return 1; +} + +static int acquire(int fd, double deadline) { + double until = now_s() + 2.0; + if (until > deadline) until = deadline; + for (;;) { + if (flock(fd, LOCK_EX | LOCK_NB) == 0) return 0; + if (errno != EWOULDBLOCK && errno != EAGAIN && errno != EINTR) return -1; + if (now_s() >= until) { errno = ETIMEDOUT; return -1; } + struct timespec pause = { 0, 10 * 1000 * 1000 }; + nanosleep(&pause, NULL); + } +} + +static long parse_long(const char *text, long lo, long hi, long fallback) { + if (!text) return fallback; + char *end = NULL; + errno = 0; + long value = strtol(text, &end, 10); + if (errno || !end || *end || end == text || value < lo || value > hi) return LONG_MIN; + return value; +} + +int main(void) { +#ifndef __linux__ + errno = 0; + return fail("Linux only"); +#else + if (getuid() == 0 || getuid() != geteuid()) { errno = 0; return fail("unprivileged same-UID process required"); } + const char *lock_path = getenv("FIXTURE_LOCK_PATH"); + long want = parse_long(getenv("FIXTURE_DUMPABLE"), 0, 1, LONG_MIN); + long lifetime = parse_long(getenv("FIXTURE_LIFETIME_SECONDS"), 1, 120, 20); + if (!lock_path || lock_path[0] != '/' || want == LONG_MIN || lifetime == LONG_MIN) { errno = 0; return fail("environment"); } + double deadline = now_s() + (double)lifetime; + if (prctl(PR_SET_DUMPABLE, (unsigned long)want, 0UL, 0UL, 0UL) != 0) return fail("prctl(PR_SET_DUMPABLE)"); + int dumpable = prctl(PR_GET_DUMPABLE, 0UL, 0UL, 0UL, 0UL); + if (dumpable < 0) return fail("prctl(PR_GET_DUMPABLE)"); + if (dumpable != (int)want) { errno = 0; return fail("dumpable mismatch"); } + + char guard_path[PATH_MAX]; + const char *slash = strrchr(lock_path, '/'); + size_t dir_len = (size_t)(slash - lock_path); + if (dir_len == 0 || dir_len + sizeof("/.coordination.lock") > sizeof(guard_path)) { errno = 0; return fail("lock path"); } + memcpy(guard_path, lock_path, dir_len); + memcpy(guard_path + dir_len, "/.coordination.lock", sizeof("/.coordination.lock")); + + int guard = open(guard_path, O_RDONLY | O_CLOEXEC | O_NOFOLLOW); + if (guard < 0) return fail("open coordination"); + if (acquire(guard, deadline) != 0) { int saved = errno; close(guard); errno = saved; return fail("acquire coordination"); } + int thread = open(lock_path, O_RDWR | O_CLOEXEC | O_NOFOLLOW); + if (thread < 0) { int saved = errno; close(guard); errno = saved; return fail("open thread lock"); } + if (acquire(thread, deadline) != 0) { int saved = errno; close(thread); close(guard); errno = saved; return fail("acquire thread lock"); } + flock(guard, LOCK_UN); + close(guard); + printf("{\"ready\":true,\"pid\":%ld,\"fd\":%d,\"dumpable\":%d,\"uid\":%ld,\"euid\":%ld,\"lifetime_seconds\":%ld,\"fixture\":\"native_c_synthetic\"}\n", + (long)getpid(), thread, dumpable, (long)getuid(), (long)geteuid(), lifetime); + fflush(stdout); + + int eof = 0; + char buffer[4096]; + while (!eof) { + double left = deadline - now_s(); + if (left <= 0) break; + struct pollfd p = { STDIN_FILENO, POLLIN, 0 }; + int ready = poll(&p, 1, (int)(left * 1000.0) + 1); + if (ready < 0 && errno == EINTR) continue; + if (ready < 0) break; + if (ready == 0) continue; + ssize_t count = read(STDIN_FILENO, buffer, sizeof(buffer)); + if (count == 0) eof = 1; + else if (count < 0 && errno != EINTR && errno != EAGAIN) break; + } + close(thread); + /* Exit 0 on stdin EOF, 3 when the lifetime ended first or stdin failed. */ + return eof ? 0 : 3; +#endif +} diff --git a/experimental/126-linux-gates/fixtures/node/codex b/experimental/126-linux-gates/fixtures/node/codex new file mode 100644 index 0000000..5b3bba4 --- /dev/null +++ b/experimental/126-linux-gates/fixtures/node/codex @@ -0,0 +1,54 @@ +#!/usr/bin/env node +// Synthetic Node holder named `codex` (#126 research, Linux gates G1). +// +// SYNTHETIC, not Codex. It is started as ` `, the one +// interpreter form the product's argv grammar accepts for its own test +// fixture (basename(argv[0]) is node, basename(argv[1]) is codex). Node cannot +// call prctl, so this holder never changes dumpability; the runner infers the +// inherited value from /proc ownership and records it as inferred. +// +// Takes `.coordination.lock` then the thread lock with bounded nonblocking +// polling, releases coordination, prints one ready JSON line and holds until +// stdin EOF (exit 0) or FIXTURE_LIFETIME_SECONDS (exit 3). Sends no signal. +import fs from 'node:fs' +import { createRequire } from 'node:module' +import path from 'node:path' +import process from 'node:process' +import { clearTimeout, setTimeout } from 'node:timers' + +if (process.platform !== 'linux' || process.getuid() === 0 || process.getuid() !== process.geteuid()) { + throw new Error('fixture requires an unprivileged same-user Linux process') +} +const require = createRequire(process.env.FIXTURE_REQUIRE_FROM) +const { flockSync } = require('fs-ext-extra-prebuilt') +const lifetime = Number(process.env.FIXTURE_LIFETIME_SECONDS ?? '20') +if (!Number.isInteger(lifetime) || lifetime < 1 || lifetime > 120) throw new Error('FIXTURE_LIFETIME_SECONDS must be 1-120') +const sleeper = new Int32Array(new SharedArrayBuffer(4)) + +function acquire(fd) { + const until = Date.now() + 2000 + while (true) { + try { + flockSync(fd, 'exnb') + return + } catch (error) { + if (error.code !== 'EAGAIN' && error.code !== 'EWOULDBLOCK') throw error + } + if (Date.now() > until) throw new Error('fixture lock acquisition timed out') + Atomics.wait(sleeper, 0, 0, 10) + } +} + +const lockPath = process.env.FIXTURE_LOCK_PATH +const flags = fs.constants.O_CLOEXEC | fs.constants.O_NOFOLLOW +const guard = fs.openSync(path.join(path.dirname(lockPath), '.coordination.lock'), fs.constants.O_RDONLY | flags) +acquire(guard) +const thread = fs.openSync(lockPath, fs.constants.O_RDWR | flags) +acquire(thread) +flockSync(guard, 'un') +fs.closeSync(guard) +process.stdout.write(JSON.stringify({ ready: true, pid: process.pid, fd: thread, dumpable: null, + uid: process.getuid(), euid: process.geteuid(), lifetime_seconds: lifetime, fixture: 'node_synthetic' }) + '\n') +const timer = setTimeout(() => { fs.closeSync(thread); process.exitCode = 3; process.stdin.destroy() }, lifetime * 1000) +process.stdin.on('end', () => { clearTimeout(timer); fs.closeSync(thread); process.exitCode = 0 }) +process.stdin.resume() diff --git a/experimental/126-linux-gates/g1-identity-visibility/argv-check.mjs b/experimental/126-linux-gates/g1-identity-visibility/argv-check.mjs new file mode 100644 index 0000000..aa7b46a --- /dev/null +++ b/experimental/126-linux-gates/g1-identity-visibility/argv-check.mjs @@ -0,0 +1,44 @@ +#!/usr/bin/env node +// Cross-platform check of the G1 argv predictions against the product's +// pure `reconcileArguments` (#126 research). +// +// G1_BUILD= node argv-check.mjs +// +// It imports only the build's process.js (verified against its manifest +// first) and calls one pure function with each variant's expected kernel +// argv and an equal `ps -o args=` string. No process is inspected or +// signalled. This checks the argv part of the prediction only; real ps/proc +// behaviour is measured by run.mjs on Linux. +import { log } from 'node:console' +import fs from 'node:fs' +import path from 'node:path' +import process from 'node:process' +import { pathToFileURL } from 'node:url' +import { reachableModules, verifyBuild } from '../../126-guard-liveness/f2-linux-nondumpable/build.mjs' +import { expectedArgv, PRODUCT_ARGV_ERROR, VARIANT_ORDER, VARIANTS } from './variants.mjs' + +if (!process.env.G1_BUILD) throw new Error('G1_BUILD is required') +const build = fs.realpathSync(process.env.G1_BUILD) +const verified = verifyBuild(build) +if (!verified.ok) throw new Error(`build does not match its manifest: ${verified.faults.join(',')}`) +const dist = path.join(build, 'src', 'dist') +if (reachableModules(dist, ['process.js']).includes('unlock.js')) throw new Error('unlock.js reachable') +const { reconcileArguments } = await import(pathToFileURL(path.join(dist, 'process.js')).href) + +// Example paths with the same shape as the run host's. +const paths = { scriptPath: '/srv/research/126-guard-liveness/f2-linux-nondumpable/codex', + nativePath: '/srv/scratch/codex-126-g1-x/native/codex', nodeExec: '/usr/bin/node', + nodeFixture: '/srv/research/126-linux-gates/fixtures/node/codex' } +const results = VARIANT_ORDER.map((variant) => { + const argv = expectedArgv(variant, paths) + const evidence = reconcileArguments(argv.join(' '), { status: 'present', argv }) + const expectedError = VARIANTS[variant].argvClean ? undefined : PRODUCT_ARGV_ERROR + return { variant, argv, evidence, ok: evidence.error === expectedError && evidence.isSharedService === false && + (expectedError ? evidence.arguments === null : evidence.arguments === argv.join(' ')) } +}) +// The product accepts only node/nodejs as an interpreter basename. +const renamedNode = reconcileArguments('/usr/bin/node24 /x/codex', { status: 'present', argv: ['/usr/bin/node24', '/x/codex'] }) +results.push({ variant: 'node_renamed_binary', evidence: renamedNode, ok: renamedNode.error === PRODUCT_ARGV_ERROR }) +const ok = results.every((r) => r.ok) +log(JSON.stringify({ build: { manifestSha256: verified.manifestSha256, commit: verified.source.commit }, ok, results }, null, 2)) +process.exitCode = ok ? 0 : 1 diff --git a/experimental/126-linux-gates/g1-identity-visibility/expectations.mjs b/experimental/126-linux-gates/g1-identity-visibility/expectations.mjs new file mode 100644 index 0000000..d43efe1 --- /dev/null +++ b/experimental/126-linux-gates/g1-identity-visibility/expectations.mjs @@ -0,0 +1,319 @@ +// G1 expected-table evaluation (#126 research). Pure functions only. +// +// Results that are reported separately and never merged: +// - `collected`: the inspector completed the G1 protocol (typed stages, +// matching build manifest, holder PID) without faults, and every sampled +// start-tick value claimed for the holder equals the runner's own samples. +// Unavailable evidence stays unavailable; it never matches. +// - `rows`: MEASURED status per row: 'pass', 'fail' or 'blocked' (a stated +// precondition did not hold, so the row is not evaluable), with causes +// attributed from the same run. Only 'pass' satisfies a row. +// - `comparison`: measured rows against the SOURCE PREDICTION +// (variants.mjs) for the measured host class. An unclassified host has no +// prediction and never matches. +// - report-level `controlPassed`/`refusalCounted`, computed only after exact +// run coverage is validated. A matched prediction of a failure is not a +// passing control and is never counted as success. +// Nothing here is a native Codex result, a fail-closed proof or a signal +// decision. Raw lsof text stays in the private result; only its class is +// used here. +import { isTicks } from './protocol.mjs' +import { HOST_CLASSES, predict, VARIANT_ORDER, VARIANTS } from './variants.mjs' + +export const sameTicks = (a, b) => isTicks(a) && isTicks(b) && BigInt(a) === BigInt(b) +export const PERMISSION_CODES = new Set(['EACCES', 'EPERM']) +export const PARTICIPATION_BLOCKER = 'linux_participation_unproven' +const sorted = (values) => [...new Set(values.filter(Boolean))].sort() + +// The only lsof stderr treated as mount-visibility evidence: one or more +// pairs of `lsof: WARNING: can't stat() file system ` +// followed by the indented `Output information may be incomplete.` line, as +// lsof 4.9x prints them. The product's own failure texts (missing tool, +// timeout, output limit, exit status) and anything else are `other`. +const STAT_WARNING = /^lsof: WARNING: can't stat\(\) [A-Za-z0-9_.+-]{1,64} file system \/\S.{0,4095}$/ +const INCOMPLETE = /^\s+Output information may be incomplete\.$/ +export function classifyLsofError(text) { + if (text === null) return { kind: 'none' } + if (typeof text !== 'string' || text === '') return { kind: 'other' } + const lines = text.split('\n') + if (lines.length % 2 !== 0) return { kind: 'other' } + for (let i = 0; i < lines.length; i += 2) { + if (!STAT_WARNING.test(lines[i]) || !INCOMPLETE.test(lines[i + 1])) return { kind: 'other' } + } + return { kind: 'mount_warning', warnings: lines.length / 2 } +} + +export function hostClassOf(control) { + if (control.processCount !== 0) return { hostClass: 'unclassified', reason: 'lsof_control_has_openers' } + const kind = classifyLsofError(control.error).kind + if (kind === 'none') return { hostClass: 'clean' } + if (kind === 'mount_warning') return { hostClass: 'mount_warning' } + return { hostClass: 'unclassified', reason: 'lsof_control_infrastructure' } +} + +export function lsofCauseOf(openerError, control) { + const kind = classifyLsofError(openerError).kind + if (kind === 'none') return null + if (kind === 'other') return 'lsof_infrastructure_error' + return classifyLsofError(control.error).kind === 'mount_warning' && control.error === openerError + ? 'lsof_mount_warning' : 'unexplained:lsof_mount_warning_differs' +} + +export function identityCauses(identity, readyTicks) { + const causes = [] + for (const error of identity.errors) { + if (error.startsWith('arguments_unverified:') || error.startsWith('arguments_truncated:')) causes.push('argv_identity') + else if (error === 'linux_process_instance_unverified') causes.push('instance_unverified') + // Only the error code; raw diagnostic text stays in the private record. + else causes.push(`unexplained:${error.split(':')[0]}`) + } + if (!identity.identityComplete && !causes.includes('argv_identity')) causes.push('unexplained:identity_incomplete') + if (identity.startTicks !== null && !sameTicks(identity.startTicks, readyTicks)) causes.push('unexplained:start_ticks_differ') + if (identity.errors.length === 0 && (identity.startTicks === null || identity.commandFingerprint === null)) { + causes.push('unexplained:instance_evidence_missing') + } + return sorted(causes) +} + +// Maps each eligibility blocker to causes already measured in this run. +export function blockerCauses(blockers, measured) { + const present = (names) => names.filter((n) => measured.includes(n)) + const lsof = present(['lsof_mount_warning', 'lsof_infrastructure_error']) + const explain = { + lock_opener_lookup_failed: lsof, + owner_lock_file_lookup_failed: present(['owner_lockset_unavailable']), + owner_holds_other_thread_locks: present(['owner_lockset_unavailable']), + classification_unknown: present(['lsof_mount_warning', 'lsof_infrastructure_error', 'argv_identity', 'owner_hidden', 'instance_unverified']), + [PARTICIPATION_BLOCKER]: present(['lsof_mount_warning', 'lsof_infrastructure_error', 'owner_hidden', 'instance_unverified']), + lock_owner_identity_incomplete: present(['argv_identity']), + lock_owner_is_not_codex: present(['argv_identity']), + lock_owner_unavailable: present(['owner_hidden']), + lock_owner_not_unique: present(['owner_hidden']), + owner_lock_files_unavailable: present(['owner_hidden']), + lock_owner_identity_not_stable: present(['owner_hidden', 'instance_unverified']), + } + const causes = [] + for (const blocker of blockers) { + const found = explain[blocker] ?? [] + if (found.length) causes.push(...found) + else causes.push(`unexplained:${blocker}`) + } + return sorted(causes) +} + +// Every start-ticks sample attributed to the holder PID must equal the +// runner's readiness sample. Null samples are unavailable, not equal. +export function holderBinding(s, holder, readyTicks) { + const samples = [] + const add = (where, pid, ticks) => { if (pid === holder.pid) samples.push({ where, ticks: ticks ?? null }) } + for (const p of s.openers.result.processes) add('openers', p.pid, p.startTicks) + add('identity_direct', s.identity_direct.result.pid, s.identity_direct.result.startTicks) + if (s.prepare.result) add('prepare', s.prepare.result.pid, s.prepare.result.startTicks) + const verify = s.guarded_verify.result + if (verify.participation) add('guarded_participation', verify.participation.pid, verify.participation.startTicks) + const inspect = s.inspect_thread.result + if (inspect.owner) add('inspect_owner', inspect.owner.pid, inspect.owner.startTicks) + if (inspect.probe.participation) add('inspect_participation', inspect.probe.participation.pid, inspect.probe.participation.startTicks) + const faults = samples.filter((x) => x.ticks !== null && !sameTicks(x.ticks, readyTicks)).map((x) => `holder_ticks_differ:${x.where}`) + return { samples, faults, unavailable: samples.filter((x) => x.ticks === null).map((x) => x.where) } +} + +export function evaluateRun({ variant, dumpable, holder, ticks, observation, protocol }) { + const collectedFaults = [...(observation.incomplete ? [`incomplete:${observation.incompletePhase}`] : []), + ...observation.faults, ...protocol.faults] + if (!isTicks(ticks.ready)) collectedFaults.push('holder_ticks_unavailable') + if (!sameTicks(ticks.ready, ticks.afterInspect)) collectedFaults.push('holder_instance_changed_or_gone') + if (ticks.holderExitedDuringInspection) collectedFaults.push('holder_exited_during_inspection') + const base = { variant, dumpable, collectedFaults } + // Payload fields are read only after protocol validation. + if (protocol.faults.length > 0) return { ...base, collected: false, hostClass: null, rows: {}, recorded: null, manifestSha256: null } + const s = protocol.byStage + const binding = holderBinding(s, holder, ticks.ready) + collectedFaults.push(...binding.faults) + const collected = collectedFaults.length === 0 + const control = s.lsof_control.result + const openers = s.openers.result + const identity = s.identity_direct.result + const prepare = s.prepare + const verify = s.guarded_verify.result + const held = s.held_probe.result + const inspect = s.inspect_thread.result + const fd = String(holder.fd) + const host = hostClassOf(control) + const lsofCause = lsofCauseOf(openers.error, control) + const listed = openers.processes.filter((p) => p.pid === holder.pid) + const onlyHolder = openers.processes.length === 1 && listed.length === 1 + const hiddenCause = listed.length === 0 ? 'owner_hidden' : null + const idCauses = identityCauses(identity, ticks.ready) + const heldOk = held.status === 'held' && held.guard?.status === 'acquired' && held.participation === undefined + const rows = {} + const row = (id, status, causes = [], detail) => { + rows[id] = { basis: 'measured', status, causes: status === 'pass' ? [] : sorted(causes), + ...(status === 'pass' || detail === undefined ? {} : { detail }) } + } + row('H0_held_observed', heldOk ? 'pass' : 'fail', ['held_observation_missing'], { status: held.status, guard: held.guard?.status ?? null }) + row('V1_owner_listed', onlyHolder ? 'pass' : 'fail', [hiddenCause ?? 'unexplained:other_openers'], + openers.processes.map((p) => p.pid === holder.pid ? 'holder' : 'other')) + row('V2_opener_clean', openers.error === null ? 'pass' : 'fail', [lsofCause]) + const identityOk = idCauses.length === 0 && identity.identityComplete + row('I1_identity_direct', identityOk ? 'pass' : 'fail', idCauses.length ? idCauses : ['unexplained:identity']) + const ownerLocksetUnavailable = inspect.blockers.includes('owner_lock_file_lookup_failed') && + Array.isArray(inspect.ownerLockFiles) && inspect.ownerLockFiles.length === 0 + const measured = sorted([lsofCause, hiddenCause, ...idCauses, ownerLocksetUnavailable ? 'owner_lockset_unavailable' : null]) + const blocked = (id, causes) => row(id, 'blocked', causes) + if (dumpable === 1) { + row('I2_is_codex', identity.isCodex ? 'pass' : 'fail', + idCauses.includes('argv_identity') ? ['argv_identity'] : ['unexplained:not_codex']) + const p = prepare.result + row('E2_prepare_candidate', p && p.pid === holder.pid && sameTicks(p.startTicks, ticks.ready) && + p.descriptors.includes(fd) && p.candidates.includes(fd) ? 'pass' : 'fail', ['unexplained:prepare']) + row('E3_guarded_participation', verify.status === 'held' && verify.guard?.status === 'acquired' && + verify.participation?.pid === holder.pid && verify.participation.descriptor === fd && + sameTicks(verify.participation.startTicks, ticks.ready) ? 'pass' : 'fail', ['unexplained:guarded_participation']) + // inspectThread attempts participation only with a clean, unique opener set. + if (lsofCause || !onlyHolder) { + blocked('E4_inspection_participation', [lsofCause, hiddenCause ?? (onlyHolder ? null : 'unexplained:other_openers')]) + } else { + const part = inspect.probe.participation + row('E4_inspection_participation', inspect.probe.status === 'held' && part?.pid === holder.pid && + part.descriptor === fd && sameTicks(part.startTicks, ticks.ready) && inspect.owner?.pid === holder.pid && + sameTicks(inspect.owner.startTicks, ticks.ready) && !inspect.blockers.includes(PARTICIPATION_BLOCKER) + ? 'pass' : 'fail', ['unexplained:inspection_participation']) + } + row('E5_eligible', inspect.safeToUnlock && inspect.blockers.length === 0 ? 'pass' : 'fail', + blockerCauses(inspect.blockers, measured), inspect.blockers) + } else if (!heldOk) { + // A denial is reproduced only for a lock observed held in this inspector. + for (const id of ['R1_prepare_permission_denied', 'R2_refused_with_participation_blocker', 'R3_refusal_attributed_to_denial']) { + blocked(id, ['held_observation_missing']) + } + } else { + row('R1_prepare_permission_denied', prepare.error && PERMISSION_CODES.has(prepare.error.code) && verify.skipped === 'prepare_failed' + ? 'pass' : 'fail', ['unexplained:prepare_not_denied'], prepare.error?.code ?? 'no_error') + const refusalProbe = inspect.probe.status === 'held' || + (inspect.probe.status === 'unknown' && inspect.probe.error === PARTICIPATION_BLOCKER && + inspect.probe.guard?.status === 'acquired') + row('R2_refused_with_participation_blocker', !inspect.safeToUnlock && inspect.blockers.includes(PARTICIPATION_BLOCKER) && + refusalProbe && inspect.probe.participation === undefined ? 'pass' : 'fail', + ['unexplained:refusal'], { probe: inspect.probe.status }) + // Denial is the attributed reason only if the owner was visible without + // an opener error, so that inspectThread reached participation and + // reported the product's typed participation refusal. + if (lsofCause || !onlyHolder) { + blocked('R3_refusal_attributed_to_denial', [lsofCause, hiddenCause ?? (onlyHolder ? null : 'unexplained:other_openers')]) + } else { + row('R3_refusal_attributed_to_denial', inspect.probe.status === 'unknown' && inspect.probe.error === PARTICIPATION_BLOCKER && + inspect.probe.guard?.status === 'acquired' && inspect.owner?.pid === holder.pid && + inspect.owner.errors.includes('linux_process_instance_unverified') && inspect.blockers.includes(PARTICIPATION_BLOCKER) + ? 'pass' : 'fail', ['unexplained:refusal_not_attributed']) + } + } + return { + ...base, collected, hostClass: host.hostClass, hostReason: host.reason ?? null, rows, + manifestSha256: s.dist_identity.result.manifestSha256, + recorded: { + lsofControlClass: classifyLsofError(control.error).kind, openerErrorClass: classifyLsofError(openers.error).kind, + identityErrors: identity.errors, isCodex: identity.isCodex, binding, + eligibility: { safeToUnlock: inspect.safeToUnlock, blockers: inspect.blockers }, + classification: inspect.classification, probe: inspect.probe, heldProbe: held, ownerLockFiles: inspect.ownerLockFiles, + prepareErrorCode: prepare.error?.code ?? null, guardedAttempted: verify.skipped !== 'prepare_failed', + }, + } +} + +export function compareRun(run) { + if (!run.collected) return { matched: false, rows: [], reason: 'not_collected' } + if (!HOST_CLASSES.includes(run.hostClass)) return { matched: false, rows: [], reason: `host_${run.hostClass}` } + const expected = predict(run.variant, run.dumpable, run.hostClass) + const rows = Object.entries(expected).map(([id, want]) => { + const got = run.rows[id] + const matched = Boolean(got) && got.status === want.status && JSON.stringify(got.causes) === JSON.stringify(want.causes) + return { id, matched, expected: want, measured: got ?? null } + }) + const extra = Object.keys(run.rows).filter((id) => !Object.hasOwn(expected, id)) + return { matched: rows.every((r) => r.matched) && extra.length === 0, rows, extra } +} + +// Exact coverage: every variant is either in `notExecuted` exactly once (with +// no runs) or has exactly one run per required dumpable value, and nothing +// else. `selected` is the runner's parsed selection. +export function checkCoverage(runs, { selected, notExecuted }) { + const faults = [] + if (!Array.isArray(selected) || selected.length === 0) faults.push('selection_empty') + else { + if (new Set(selected).size !== selected.length) faults.push('selection_duplicate') + for (const v of selected) if (!Object.hasOwn(VARIANTS, v)) faults.push(`selection_unknown:${v}`) + } + const skipped = new Map() + for (const n of notExecuted ?? []) { + if (!n || !Object.hasOwn(VARIANTS, n.variant) || typeof n.reason !== 'string' || n.reason === '') { faults.push('not_executed_malformed'); continue } + if (skipped.has(n.variant)) faults.push(`not_executed_duplicate:${n.variant}`) + skipped.set(n.variant, n.reason) + const selectedHere = Array.isArray(selected) && selected.includes(n.variant) + if ((n.reason === 'not_selected') === selectedHere) faults.push(`not_executed_inconsistent:${n.variant}`) + } + const seen = new Map() + for (const r of runs) { + const key = `${r?.variant}:${r?.dumpable}` + seen.set(key, (seen.get(key) ?? 0) + 1) + if (!r || !Object.hasOwn(VARIANTS, r.variant) || !VARIANTS[r.variant].dumpables.includes(r.dumpable)) faults.push(`run_unexpected:${key}`) + else if (skipped.has(r.variant)) faults.push(`run_for_not_executed:${key}`) + } + for (const [key, count] of seen) if (count > 1) faults.push(`run_duplicate:${key}`) + for (const v of VARIANT_ORDER) { + if (!selected?.includes(v) && !skipped.has(v)) faults.push(`not_selected_missing:${v}`) + if (skipped.has(v)) continue + for (const d of VARIANTS[v].dumpables) if (!seen.has(`${v}:${d}`)) faults.push(`run_missing:${v}:${d}`) + } + return { ok: faults.length === 0, faults, skipped } +} + +export function evaluateReport(runs, { selected = VARIANT_ORDER, notExecuted = [], expectedManifestSha256, collectionValid = true } = {}) { + const coverage = checkCoverage(runs, { selected, notExecuted }) + const failures = [...coverage.faults] + if (!collectionValid) failures.push('collection_invalid') + const controlPassed = {} + const refusalCounted = {} + const comparisons = [] + const unavailable = (value) => Object.fromEntries(VARIANT_ORDER.map((v) => [v, value])) + if (!coverage.ok) { + return { coverageValid: false, predictionsMatched: false, complete: false, failures, notExecuted, hostClass: null, + controlPassed: unavailable(false), refusalCounted: unavailable(false), comparisons, + nativeCodexEvidence: 'absent: every variant is synthetic; native Codex argv, image and dumpability are unmeasured' } + } + const byKey = new Map(runs.map((r) => [`${r.variant}:${r.dumpable}`, r])) + const validRun = (run) => Boolean(collectionValid && run?.collected && run.manifestSha256 === expectedManifestSha256) + for (const r of runs) { + if (r.manifestSha256 !== expectedManifestSha256) failures.push(`build_manifest_mismatch:${r.variant}:${r.dumpable}`) + } + for (const variant of VARIANT_ORDER) { + if (coverage.skipped.has(variant)) { + controlPassed[variant] = 'not_executed'; refusalCounted[variant] = 'not_executed'; continue + } + for (const dumpable of VARIANTS[variant].dumpables) { + const run = byKey.get(`${variant}:${dumpable}`) + if (!run.collected) failures.push(`not_collected:${variant}:${dumpable}`) + const comparison = compareRun(run) + comparisons.push({ variant, dumpable, hostClass: run.hostClass, ...comparison }) + if (!comparison.matched) failures.push(`prediction_unmatched:${variant}:${dumpable}`) + } + const control = byKey.get(`${variant}:1`) + const refusal = byKey.get(`${variant}:0`) + controlPassed[variant] = Boolean(validRun(control) && Object.keys(control.rows).length > 0 && + Object.values(control.rows).every((r) => r.status === 'pass')) + refusalCounted[variant] = VARIANTS[variant].dumpables.includes(0) + ? Boolean(controlPassed[variant] === true && validRun(refusal) && + ['H0_held_observed', 'R1_prepare_permission_denied', 'R2_refused_with_participation_blocker', 'R3_refusal_attributed_to_denial'] + .every((id) => refusal.rows[id]?.status === 'pass')) + : 'not_applicable' + } + const hostClasses = sorted(runs.map((r) => r.hostClass)) + if (hostClasses.length > 1) failures.push(`host_class_changed:${hostClasses.join(',')}`) + return { + // `predictionsMatched` means the cause separation reproduced as predicted. + // It is not a product success and not a passing control. + coverageValid: true, predictionsMatched: failures.length === 0, complete: coverage.skipped.size === 0, failures, notExecuted, + hostClass: hostClasses.length === 1 ? hostClasses[0] : null, controlPassed, refusalCounted, comparisons, + nativeCodexEvidence: 'absent: every variant is synthetic; native Codex argv, image and dumpability are unmeasured', + } +} diff --git a/experimental/126-linux-gates/g1-identity-visibility/expectations.test.mjs b/experimental/126-linux-gates/g1-identity-visibility/expectations.test.mjs new file mode 100644 index 0000000..9dae743 --- /dev/null +++ b/experimental/126-linux-gates/g1-identity-visibility/expectations.test.mjs @@ -0,0 +1,272 @@ +// Pure tests for the G1 expected table: typed collection, build binding, +// holder binding, narrow lsof classification, denial preconditions, exact +// coverage, and that no failure, block, skip or matched failure prediction is +// ever counted as a passing control. +import assert from 'node:assert/strict' +import { test } from 'node:test' +import { + blockerCauses, checkCoverage, classifyLsofError, compareRun, evaluateReport, evaluateRun, hostClassOf, identityCauses, lsofCauseOf, +} from './expectations.mjs' +import { G1_STAGES, isProbe, parseChildFault, validateG1 } from './protocol.mjs' +import { parseVariantSelection, predict, PRODUCT_ARGV_ERROR, VARIANT_ORDER } from './variants.mjs' + +const MANIFEST = 'a'.repeat(64) +const holder = { pid: 42, fd: 5 } +const ticks = { ready: '900', afterInspect: '900', holderExitedDuringInspection: false } +const FP = 'f'.repeat(64) +const WARN = 'lsof: WARNING: can\'t stat() overlay file system /var/lib/x/merged\n Output information may be incomplete.\n' + + 'lsof: WARNING: can\'t stat() nsfs file system /run/x/netns/y\n Output information may be incomplete.' +const M = 'flock_exclusive_nonblocking' +const HELD = { status: 'held', method: M, guard: { status: 'acquired', attempts: 1 } } +const PART = { pid: 42, startTicks: '900', descriptor: '5' } +const identity = (over = {}) => ({ pid: 42, isCodex: true, isSharedService: false, identityComplete: true, errors: [], + startTicks: '900', commandFingerprint: FP, command: 'codex', arguments: 'codex', lsofCommand: 'codex', ...over }) +const argvBad = { isCodex: false, identityComplete: false, errors: [PRODUCT_ARGV_ERROR], arguments: null } + +// Builds the nine stage records for one synthetic run, following #137 source. +function stages({ host = 'clean', dumpable = 1, argv = true, listed = dumpable === 1, blockers, held = HELD, inspectProbe }) { + const warn = host === 'mount_warning' ? WARN : host === 'infra' ? 'lsof timed out after 10000 ms' : null + const errors = [...(argv ? [] : [PRODUCT_ARGV_ERROR]), ...(dumpable === 0 ? ['linux_process_instance_unverified'] : [])] + const id = identity({ ...(argv ? {} : argvBad), errors, + ...(dumpable === 0 ? { startTicks: null, commandFingerprint: null } : {}) }) + const result = (stage, value) => ({ stage, result: value }) + const list = [ + result('dist_identity', { ok: true, faults: [], manifestSha256: MANIFEST, expectedManifestSha256: MANIFEST, manifestMatchesExpected: true }), + result('module_load', { modules: ['inspection.js'], unlockReachable: false }), + result('lsof_control', { error: warn, processCount: 0 }), + result('openers', { error: warn, processes: listed ? [id] : [] }), + result('identity_direct', id), + ] + if (dumpable === 1) { + list.push(result('prepare', { pid: 42, startTicks: '900', candidates: ['5'], descriptors: ['0', '5'] })) + list.push(result('guarded_verify', { ...HELD, participation: PART })) + } else { + list.push({ stage: 'prepare', error: { code: 'EACCES', name: 'Error', message: 'denied' } }) + list.push(result('guarded_verify', { skipped: 'prepare_failed' })) + } + list.push(result('held_probe', held)) + const reached = (host === 'clean') && listed + const clean = reached && argv && dumpable === 1 + const computed = blockers ?? (clean ? [] : [ + ...(dumpable === 1 && reached ? [] : ['linux_participation_unproven']), + ...(host !== 'clean' || !argv || !listed ? ['classification_unknown'] : []), + ...(host !== 'clean' ? ['lock_opener_lookup_failed', ...(listed ? ['owner_lock_file_lookup_failed', 'owner_holds_other_thread_locks'] : [])] : []), + ...(listed && !argv ? ['lock_owner_identity_incomplete', 'lock_owner_is_not_codex'] : []), + ...(!listed ? ['lock_owner_not_unique', 'lock_owner_identity_not_stable', 'lock_owner_unavailable', 'owner_lock_files_unavailable'] : []), + ]) + // inspectThread attempts participation whenever openers are clean and unique. + const probe = inspectProbe ?? (reached && dumpable === 1 ? { ...HELD, participation: PART } + : reached ? { status: 'unknown', method: M, guard: { status: 'acquired', attempts: 1 }, error: 'linux_participation_unproven' } : HELD) + list.push(result('inspect_thread', { classification: clean ? 'live_owner' : 'unknown', safeToUnlock: computed.length === 0, + blockers: computed, warnings: [], probe, openers: listed ? 1 : 0, + owner: listed ? { pid: 42, isCodex: id.isCodex, identityComplete: id.identityComplete, errors: id.errors, startTicks: id.startTicks } : null, + transcript: 'found', ownerLockFiles: listed ? host === 'clean' ? ['/fixture/thread.lock'] : [] : null })) + return list +} +const observe = (list, extra = {}) => ({ incomplete: false, faults: [], stages: list, begun: list.map((s) => s.stage), ...extra }) +const context = { expectedManifestSha256: MANIFEST, holderPid: 42 } +const evaluate = (variant, dumpable, list, t = ticks, extra = {}) => { + const observation = observe(list, extra) + return evaluateRun({ variant, dumpable, holder, ticks: t, observation, protocol: validateG1(observation, context) }) +} +const run = (variant, dumpable, opts = {}, t = ticks, extra = {}) => + evaluate(variant, dumpable, stages({ dumpable, argv: variant !== 'script', ...opts }), t, extra) +const allRuns = (host, skip = []) => VARIANT_ORDER.filter((v) => !skip.includes(v)) + .flatMap((v) => (v === 'node' ? [1] : [1, 0]).map((d) => run(v, d, { host }))) +const report = (runs, options = {}) => evaluateReport(runs, { expectedManifestSha256: MANIFEST, ...options }) + +test('protocol: exact order, typed probes, manifest and PID binding, prepare/skip consistency', () => { + assert.equal(G1_STAGES.length, 9) + assert.equal(validateG1(observe(stages({})), context).ok, true) + const swapped = stages({}); [swapped[2], swapped[3]] = [swapped[3], swapped[2]] + assert.ok(validateG1(observe(swapped), context).faults.some((f) => f.startsWith('stage_order:'))) + assert.ok(validateG1(observe(stages({})), { ...context, expectedManifestSha256: 'b'.repeat(64) }).faults.includes('build_manifest_mismatch')) + assert.ok(validateG1(observe(stages({})), { holderPid: 42 }).faults.includes('expected_manifest_missing')) + assert.ok(validateG1(observe(stages({})), { ...context, holderPid: 43 }).faults.includes('identity_direct_pid_mismatch')) + const childClaims = stages({}); childClaims[0].result.manifestMatchesExpected = false + assert.ok(validateG1(observe(childClaims), context).faults.includes('stage_result_malformed:dist_identity')) + const inconsistent = stages({ dumpable: 0 }); inconsistent[6] = { stage: 'guarded_verify', result: HELD } + assert.ok(validateG1(observe(inconsistent), context).faults.includes('prepare_guarded_inconsistent')) + const control = stages({}); control[2].result.processCount = -1 + assert.ok(validateG1(observe(control), context).faults.includes('stage_result_malformed:lsof_control')) + assert.equal(parseChildFault('stall:held_probe'), 'stall:held_probe') + assert.throws(() => parseChildFault('stall:nope'), /G1_CHILD_FAULT/) +}) + +test('typed probes: empty, free-with-guard, held-without-guard and stray participation are rejected', () => { + assert.equal(isProbe(HELD), true) + assert.equal(isProbe({}), false) + assert.equal(isProbe({ status: 'held', method: M }), false) + assert.equal(isProbe({ status: 'held', method: M, guard: { status: 'busy' } }), false) + assert.equal(isProbe({ status: 'free', method: M, participation: PART }), false) + assert.equal(isProbe({ status: 'held', method: 'other', guard: { status: 'acquired' } }), false) + assert.equal(isProbe({ status: 'free', method: M }), true) + const empty = stages({ dumpable: 0, inspectProbe: {} }) + assert.ok(validateG1(observe(empty), context).faults.includes('stage_result_malformed:inspect_thread')) +}) + +test('denial needs a held observation: free, unknown or missing held evidence blocks R1-R3', () => { + for (const held of [{ status: 'free', method: M }, { status: 'unknown', method: M, guard: { status: 'busy', attempts: 1 }, error: 'x' }]) { + const r = run('native', 0, { host: 'mount_warning', held }) + assert.equal(r.collected, true) + assert.equal(r.rows.H0_held_observed.status, 'fail') + for (const id of ['R1_prepare_permission_denied', 'R2_refused_with_participation_blocker', 'R3_refusal_attributed_to_denial']) { + assert.deepEqual([r.rows[id].status, r.rows[id].causes], ['blocked', ['held_observation_missing']], id) + } + assert.equal(compareRun(r).matched, false) + } + // A free inspection probe next to a held H0 is a contradiction: R2 fails. + const contradiction = run('native', 0, { host: 'mount_warning', inspectProbe: { status: 'free', method: M } }) + assert.equal(contradiction.rows.R2_refused_with_participation_blocker.status, 'fail') + assert.equal(compareRun(contradiction).matched, false) +}) + +test('holder binding: any claimed holder tick sample that differs is a collection fault; null stays unavailable', () => { + const list = stages({}); list[5].result.startTicks = '901' + const r = evaluate('native', 1, list) + assert.equal(r.collected, false) + assert.ok(r.collectedFaults.includes('holder_ticks_differ:prepare')) + const d0 = run('native', 0, { host: 'mount_warning' }) + assert.equal(d0.collected, true) + assert.deepEqual(d0.recorded.binding.unavailable, ['identity_direct']) + const owner = stages({ dumpable: 0, host: 'clean', listed: true }); owner[8].result.owner.startTicks = '1' + assert.ok(evaluate('native', 0, owner).collectedFaults.includes('holder_ticks_differ:inspect_owner')) +}) + +test('narrow lsof classification: only paired mount warnings count; tool, timeout and other text are infrastructure', () => { + assert.deepEqual(classifyLsofError(WARN), { kind: 'mount_warning', warnings: 2 }) + assert.deepEqual(classifyLsofError(null), { kind: 'none' }) + for (const text of ['lsof is not installed at /usr/sbin/lsof or /usr/bin/lsof', 'lsof timed out after 10000 ms', 'lsof exited with status 2', + 'lsof: WARNING: can\'t stat() overlay file system /x', `${WARN}\nextra`, 'lsof: WARNING: something else\n Output information may be incomplete.', '']) { + assert.equal(classifyLsofError(text).kind, 'other', text) + } + assert.deepEqual(hostClassOf({ error: WARN, processCount: 0 }), { hostClass: 'mount_warning' }) + assert.deepEqual(hostClassOf({ error: WARN, processCount: 1 }), { hostClass: 'unclassified', reason: 'lsof_control_has_openers' }) + assert.deepEqual(hostClassOf({ error: 'lsof timed out', processCount: 0 }), { hostClass: 'unclassified', reason: 'lsof_control_infrastructure' }) + const control = { error: WARN, processCount: 0 } + assert.equal(lsofCauseOf(WARN, control), 'lsof_mount_warning') + assert.equal(lsofCauseOf('lsof timed out', control), 'lsof_infrastructure_error') + assert.equal(lsofCauseOf(WARN.split('\n').slice(0, 2).join('\n'), control), 'unexplained:lsof_mount_warning_differs') + // Equal arbitrary errors on the lock path and the control are not a mount warning. + assert.equal(lsofCauseOf('lsof timed out', { error: 'lsof timed out', processCount: 0 }), 'lsof_infrastructure_error') + const infra = run('native', 1, { host: 'infra' }) + assert.equal(infra.hostClass, 'unclassified') + assert.deepEqual(compareRun(infra), { matched: false, rows: [], reason: 'host_unclassified' }) +}) + +test('cause attribution from identity errors and blockers keeps codes only', () => { + assert.deepEqual(identityCauses(identity({ ...argvBad }), '900'), ['argv_identity']) + assert.deepEqual(identityCauses(identity({ errors: ['linux_process_instance_unverified'], startTicks: null, commandFingerprint: null }), '900'), ['instance_unverified']) + assert.deepEqual(identityCauses(identity({ errors: ['ppid_unknown:ps failed at /some/path'] }), '900'), ['unexplained:ppid_unknown']) + assert.deepEqual(blockerCauses(['lock_opener_lookup_failed'], []), ['unexplained:lock_opener_lookup_failed']) + assert.deepEqual(blockerCauses(['transcript_missing', 'lock_opener_lookup_failed'], ['lsof_mount_warning']), + ['lsof_mount_warning', 'unexplained:transcript_missing']) +}) + +test('clean host, argv-clean variant: every measured row passes and matches the source prediction', () => { + const r = run('native', 1, { host: 'clean' }) + assert.equal(r.collected, true) + assert.ok(Object.values(r.rows).every((x) => x.status === 'pass' && x.basis === 'measured'), JSON.stringify(r.rows)) + const c = compareRun(r) + assert.equal(c.matched, true) + assert.ok(c.rows.every((x) => x.expected.basis === 'source_prediction')) +}) + +test('mount-warning host separates causes; script adds argv identity; clean host script fails only on argv', () => { + const clean = run('argv0', 1, { host: 'mount_warning' }) + assert.deepEqual(clean.rows.V2_opener_clean.causes, ['lsof_mount_warning']) + assert.equal(clean.rows.I1_identity_direct.status, 'pass') + assert.deepEqual([clean.rows.E4_inspection_participation.status, clean.rows.E4_inspection_participation.causes], ['blocked', ['lsof_mount_warning']]) + assert.equal(compareRun(clean).matched, true) + const script = run('script', 1, { host: 'mount_warning' }) + assert.deepEqual(script.rows.E5_eligible.causes, ['argv_identity', 'lsof_mount_warning', 'owner_lockset_unavailable']) + assert.equal(compareRun(script).matched, true) + const scriptClean = run('script', 1, { host: 'clean' }) + assert.equal(scriptClean.rows.E4_inspection_participation.status, 'pass') + assert.equal(compareRun(scriptClean).matched, true) + const otherLocks = stages({ host: 'mount_warning' }) + otherLocks[8].result.ownerLockFiles = ['/fixture/one.lock', '/fixture/two.lock'] + const unprovenCause = evaluate('native', 1, otherLocks) + assert.ok(unprovenCause.rows.E5_eligible.causes.includes('unexplained:owner_holds_other_thread_locks')) + assert.equal(compareRun(unprovenCause).matched, false) +}) + +test('dumpable=0: hidden owner blocks R3; a listed owner with the typed participation refusal passes R3 but contradicts the prediction', () => { + const r = run('native', 0, { host: 'mount_warning' }) + assert.equal(r.rows.R1_prepare_permission_denied.status, 'pass') + assert.equal(r.rows.R2_refused_with_participation_blocker.status, 'pass') + assert.deepEqual(r.rows.R3_refusal_attributed_to_denial.causes, ['lsof_mount_warning', 'owner_hidden']) + assert.equal(compareRun(r).matched, true) + const listed = run('native', 0, { host: 'clean', listed: true }) + for (const id of ['R1_prepare_permission_denied', 'R2_refused_with_participation_blocker', 'R3_refusal_attributed_to_denial']) { + assert.equal(listed.rows[id].status, 'pass', id) + } + assert.equal(compareRun(listed).matched, false) +}) + +test('full reports: predictions matched, controls only on a clean host, no refusal counted', () => { + const warning = report(allRuns('mount_warning')) + assert.equal(warning.predictionsMatched, true, JSON.stringify(warning.failures)) + assert.equal(warning.hostClass, 'mount_warning') + for (const v of VARIANT_ORDER) assert.equal(warning.controlPassed[v], false, v) + assert.deepEqual(warning.refusalCounted, { script: false, argv0: false, native: false, node: 'not_applicable' }) + const clean = report(allRuns('clean')) + assert.equal(clean.predictionsMatched, true, JSON.stringify(clean.failures)) + assert.deepEqual(clean.controlPassed, { script: false, argv0: true, native: true, node: true }) + assert.deepEqual(clean.refusalCounted, { script: false, argv0: false, native: false, node: 'not_applicable' }) + assert.match(clean.nativeCodexEvidence, /^absent/) +}) + +test('coverage: duplicate, extra, missing and inconsistent runs invalidate the report before any control is computed', () => { + const runs = allRuns('clean') + const dup = report([...runs, runs[2]]) + assert.equal(dup.coverageValid, false) + assert.ok(dup.failures.includes('run_duplicate:argv0:1')) + assert.deepEqual(dup.controlPassed, { script: false, argv0: false, native: false, node: false }) + const extra = report([...runs, { ...runs[6], dumpable: 0 }]) + assert.ok(extra.failures.includes('run_unexpected:node:0')) + assert.ok(report(runs.slice(1)).failures.includes('run_missing:script:1')) + const skipped = report(allRuns('clean', ['native']), { notExecuted: [{ variant: 'native', reason: 'no_compiler' }] }) + assert.equal(skipped.coverageValid, true) + assert.equal(skipped.complete, false) + assert.equal(skipped.controlPassed.native, 'not_executed') + assert.ok(report(runs, { notExecuted: [{ variant: 'native', reason: 'no_compiler' }] }).failures.includes('run_for_not_executed:native:1')) + assert.ok(checkCoverage(runs, { selected: VARIANT_ORDER, notExecuted: [{ variant: 'native', reason: 'not_selected' }] }).faults + .includes('not_executed_inconsistent:native')) + assert.ok(checkCoverage([], { selected: [], notExecuted: [] }).faults.includes('selection_empty')) + assert.ok(checkCoverage([], { selected: ['native', 'native'], notExecuted: [] }).faults.includes('selection_duplicate')) + const wrongBuild = report(runs.map((r, i) => (i === 0 ? { ...r, manifestSha256: 'c'.repeat(64) } : r))) + assert.ok(wrongBuild.failures.includes('build_manifest_mismatch:script:1')) + assert.equal(wrongBuild.predictionsMatched, false) + assert.ok(checkCoverage(runs, { selected: ['native'], notExecuted: [] }).faults.includes('not_selected_missing:script')) + const argvBuildDrift = report(runs.map((r) => r.variant === 'argv0' ? { ...r, manifestSha256: 'c'.repeat(64) } : r)) + assert.equal(argvBuildDrift.controlPassed.argv0, false) + const cleanupFailed = report(runs, { collectionValid: false }) + assert.equal(cleanupFailed.predictionsMatched, false) + assert.ok(Object.values(cleanupFailed.controlPassed).every((v) => v === false)) +}) + +test('variant selection is strict', () => { + assert.deepEqual(parseVariantSelection(undefined), VARIANT_ORDER) + assert.deepEqual(parseVariantSelection('node,script'), ['script', 'node']) + for (const bad of ['', ' ', 'native,native', 'native,', 'nativ', 'script,,node']) assert.throws(() => parseVariantSelection(bad), /G1_VARIANTS/, bad) +}) + +test('uncollected, incomplete and unmatched runs never count; a matched failure is not a control', () => { + const changed = run('native', 1, { host: 'clean' }, { ...ticks, afterInspect: '901' }) + assert.equal(changed.collected, false) + assert.equal(compareRun(changed).matched, false) + assert.equal(run('native', 1, { host: 'clean' }, ticks, { incomplete: true, incompletePhase: 'stages' }).collected, false) + const extra = run('native', 1, { host: 'mount_warning', blockers: ['lock_opener_lookup_failed', 'transcript_missing'] }) + assert.ok(extra.rows.E5_eligible.causes.includes('unexplained:transcript_missing')) + assert.equal(compareRun(extra).matched, false) + const malformed = stages({}); malformed[8].result.blockers = 'x' + const bad = evaluate('native', 1, malformed) + assert.equal(bad.collected, false) + assert.deepEqual(bad.rows, {}) + const script = run('script', 1, { host: 'mount_warning' }) + assert.equal(compareRun(script).matched, true) + assert.equal(Object.values(script.rows).every((x) => x.status === 'pass'), false) + assert.equal(predict('native', 1, 'clean').E5_eligible.basis, 'source_prediction') + assert.throws(() => predict('native', 1, 'unclassified'), /no prediction/) +}) diff --git a/experimental/126-linux-gates/g1-identity-visibility/inspect-child.mjs b/experimental/126-linux-gates/g1-identity-visibility/inspect-child.mjs new file mode 100644 index 0000000..41fa0ed --- /dev/null +++ b/experimental/126-linux-gates/g1-identity-visibility/inspect-child.mjs @@ -0,0 +1,133 @@ +#!/usr/bin/env node +// G1 product-inspection child (#126 research). Run only by run.mjs through +// ../../126-guard-liveness/lib/isolated-observer.mjs, never in the runner. +// +// Usage: node inspect-child.mjs BUILD MANIFEST_SHA256 PID FD LOCK_PATH CODEX_HOME THREAD_ID CONTROL_PATH +// +// BUILD is a directory made by ../../126-guard-liveness/f2-linux-nondumpable/build.mjs. +// It is re-hashed against its manifest before import, the manifest's own +// SHA-256 must equal MANIFEST_SHA256 (the runner's value, taken before any +// holder starts), and the static import graph of the imported modules must +// not reach unlock.js. Hashing covers dist/ and native addons only, not +// dependency JavaScript or package metadata. +// +// Stages beyond F2: +// - `lsof_control`: product `findLockOpeners` on CONTROL_PATH, a regular file +// nobody opens. Its `error` is the host's lsof stderr independent of the +// holder, which classifies the host as `clean` or `warning`. +// - `held_probe`: product `guardedProbeOnce` with no attribution, right +// before `inspect_thread`. A denial result counts only next to a typed +// `held` observation with an acquired guard. +// - `identity_direct`: product `inspectProcess` on the holder PID even when +// lsof did not list it, so identity is measured apart from lsof visibility. +// The lsof `c` field is passed through only if lsof listed the PID. +// +// Signals: none from this child to the holder, any lock owner or any unlock +// path. Product diagnostics (`lsof`, `ps`) keep the product's own timeout, +// which signals only the timed-out diagnostic's own process group. No native +// file is created or removed. Errors are recorded, never converted. +import path from 'node:path' +import process from 'node:process' +import { pathToFileURL } from 'node:url' +import { StopStages, childStages } from '../../126-guard-liveness/lib/isolated-observer.mjs' +import { reachableModules, verifyBuild } from '../../126-guard-liveness/f2-linux-nondumpable/build.mjs' +import { parseChildFault } from './protocol.mjs' + +const [build, expectedManifest, pidText, fdText, lockPath, codexHome, threadId, controlPath] = process.argv.slice(2) +const pid = Number(pidText) +const fd = Number(fdText) +if (!build || !/^[0-9a-f]{64}$/.test(expectedManifest ?? '') || !Number.isSafeInteger(pid) || pid <= 0 || !Number.isSafeInteger(fd) || fd < 0 || + !lockPath || !codexHome || !threadId || !controlPath) { + throw new Error('usage: inspect-child.mjs BUILD MANIFEST_SHA256 PID FD LOCK_PATH CODEX_HOME THREAD_ID CONTROL_PATH') +} +const fault = parseChildFault(process.env.G1_CHILD_FAULT) +const dist = path.join(build, 'src', 'dist') +const ENTRIES = ['linux-attribution.js', 'native-coordination.js', 'lock.js', 'process.js', 'inspection.js'] +const plain = (value) => JSON.parse(JSON.stringify(value, (_key, v) => (typeof v === 'bigint' ? `${v}n` : v))) +const stall = (name) => { + // Injected finite synchronous stall, for validating the parent bound only. + if (fault === `stall:${name}`) Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 15000) +} +const identityRecord = (p) => ({ + pid: p.pid, isCodex: p.isCodex, isSharedService: p.isSharedService, identityComplete: p.identityComplete, + errors: p.errors, startTicks: p.startTicks ?? null, commandFingerprint: p.commandFingerprint ?? null, + command: p.command, arguments: p.arguments, lsofCommand: p.lsofCommand, +}) + +try { + const c = childStages({ lifetimeMs: 30000 }) + const identity = await c.stage('dist_identity', () => { + stall('dist_identity') + const verified = verifyBuild(build) + return plain({ ok: verified.ok, faults: verified.faults, manifestSha256: verified.manifestSha256, + expectedManifestSha256: expectedManifest, manifestMatchesExpected: verified.manifestSha256 === expectedManifest, + source: verified.source, patch: verified.patch, distFiles: verified.distFiles, nativeFiles: verified.nativeFiles }) + }) + if (identity.result?.ok !== true || identity.result.manifestMatchesExpected !== true) throw new StopStages('build_unverified') + let product + const loaded = await c.stage('module_load', async () => { + stall('module_load') + const modules = reachableModules(dist, ENTRIES) + if (modules.includes('unlock.js')) throw new Error('unlock_reachable') + const load = (name) => import(pathToFileURL(path.join(dist, name)).href) + const [attribution, coordination, lock, proc, inspection] = await Promise.all(ENTRIES.map(load)) + product = { ...attribution, ...coordination, ...lock, ...proc, ...inspection } + return { modules, unlockReachable: false } + }) + if (loaded.error) throw new StopStages('module_load_failed') + + await c.stage('lsof_control', async () => { + stall('lsof_control') + const control = await product.findLockOpeners(controlPath) + return { error: control.error ?? null, processCount: control.processes.length } + }) + + let lsofCommand = null + await c.stage('openers', async () => { + stall('openers') + const openers = await product.inspectLockOpeners(lockPath) + lsofCommand = openers.processes.find((p) => p.pid === pid)?.lsofCommand ?? null + return plain({ error: openers.error ?? null, processes: openers.processes.map(identityRecord) }) + }) + + await c.stage('identity_direct', async () => { + stall('identity_direct') + return plain(identityRecord(await product.inspectProcess({ pid, command: lsofCommand, uid: null }))) + }) + + let prepared + await c.stage('prepare', () => { + stall('prepare') + prepared = product.prepareLinuxParticipation(pid, lockPath) + return plain(prepared) + }) + + await c.stage('guarded_verify', () => { + // Harness bookkeeping, not a product refusal. + if (!prepared) return { skipped: 'prepare_failed' } + stall('guarded_verify') + return plain(product.guardedProbeOnce(lockPath, product.observeLockFile(lockPath), undefined, + () => product.verifyLinuxParticipation(lockPath, prepared))) + }) + + await c.stage('held_probe', () => { + stall('held_probe') + return plain(product.guardedProbeOnce(lockPath, product.observeLockFile(lockPath))) + }) + + await c.stage('inspect_thread', async () => { + stall('inspect_thread') + const r = await product.inspectThread(threadId, { codexHome, stabilityMs: 200, terminationTimeoutMs: 5000 }) + return plain({ + classification: r.classification, safeToUnlock: r.safeToUnlock, blockers: r.blockers, warnings: r.warnings, + probe: r.lock.probe, owner: r.owner && { pid: r.owner.pid, isCodex: r.owner.isCodex, identityComplete: r.owner.identityComplete, + errors: r.owner.errors, startTicks: r.owner.startTicks ?? null }, + openers: r.openers.length, transcript: r.transcript.status, ownerLockFiles: r.ownerLockFiles, + }) + }) + await c.finish() +} catch (error) { + if (!(error instanceof StopStages)) throw error + if (!process.exitCode) process.exitCode = 5 + process.stdin.destroy() +} diff --git a/experimental/126-linux-gates/g1-identity-visibility/lifecycle.mjs b/experimental/126-linux-gates/g1-identity-visibility/lifecycle.mjs new file mode 100644 index 0000000..3c5ee97 --- /dev/null +++ b/experimental/126-linux-gates/g1-identity-visibility/lifecycle.mjs @@ -0,0 +1,82 @@ +// Holder supervision for the G1 runner (#126 research). +// +// Wraps ../../126-guard-liveness/lib/common.mjs (read-only) so that every +// started child is stopped exactly once and every stop outcome is turned into +// faults. Stopping only closes stdin and waits with a bound. Nothing here +// sends a signal; an exceeded bound is `incomplete` and the child is left to +// its own lifetime. +import { startChild, stopChild } from '../../126-guard-liveness/lib/common.mjs' + +// Faults for one stop outcome. A holder is expected to exit 0 on stdin EOF. +export function stopFaults(role, outcome, expectedExit = 0) { + if (!outcome || typeof outcome !== 'object') return [`${role}:stop_outcome_missing`] + const faults = [] + if (outcome.incomplete) faults.push(`${role}:incomplete`) + if (outcome.error) faults.push(`${role}:error:${outcome.error}`) + if (Array.isArray(outcome.stdinErrors) && outcome.stdinErrors.length) faults.push(`${role}:stdin_errors:${outcome.stdinErrors.length}`) + if (!outcome.incomplete) { + if (outcome.signal) faults.push(`${role}:signal:${outcome.signal}`) + if (outcome.exitCode !== expectedExit) faults.push(`${role}:exit:${outcome.exitCode}`) + } + return faults +} + +// Tracks every child it starts. `stop` is memoized per handle, so a child's +// outcome is recorded once even if both the per-run cleanup and the final +// sweep reach it. +export function createSupervisor() { + const live = new Set() + const outcomes = new Map() + return { + start(command, args, options, readyMs) { return startChild(command, args, options, live, readyMs) }, + stop(handle, budgetMs) { + if (!outcomes.has(handle)) outcomes.set(handle, stopChild(handle, budgetMs)) + return outcomes.get(handle) + }, + async sweep(budgetMs) { + const swept = [] + for (const handle of live) if (!outcomes.has(handle)) swept.push({ pid: handle.pid, outcome: await this.stop(handle, budgetMs) }) + return swept + }, + started: () => live.size, + stopped: () => outcomes.size, + } +} + +const readyFailure = (error) => { + const message = String(error?.message ?? error) + if (message.startsWith('ready timeout')) return 'timeout' + if (message.startsWith('exited before ready')) return 'exited' + if (message.startsWith('stdout ended before ready')) return 'stdout_ended' + return 'other' +} + +// One holder lifetime: start, parse readiness, run `during(ready)`, stop. +// Returns every observation; `faults` covers readiness, `during` errors and +// the stop outcome. A failure in any phase never skips the stop. +export async function superviseOnce(supervisor, { role = 'holder', command, args, options, readyMs = 5000, stopMs = 5000, + validateReady = () => null, during = async () => null }) { + const faults = [] + const result = { role, ready: null, duringResult: null, error: null, outcome: null, faults } + let handle + try { + handle = supervisor.start(command, args, options, readyMs) + result.pid = handle.pid + let line + try { line = await handle.ready } catch (error) { faults.push(`${role}:ready_failed:${readyFailure(error)}`); throw error } + try { result.ready = JSON.parse(line) } catch (error) { faults.push(`${role}:ready_unparseable`); throw error } + const invalid = validateReady(result.ready, handle) + if (invalid) { faults.push(`${role}:ready_invalid:${invalid}`); throw new Error(invalid) } + result.duringResult = await during(result.ready, handle) + } catch (error) { + result.error = String(error?.message ?? error) + if (!faults.length) faults.push(`${role}:during_failed`) + } finally { + if (handle) { + result.outcome = await supervisor.stop(handle, stopMs) + result.stderr = handle.stderr + faults.push(...stopFaults(role, result.outcome)) + } + } + return result +} diff --git a/experimental/126-linux-gates/g1-identity-visibility/lifecycle.test.mjs b/experimental/126-linux-gates/g1-identity-visibility/lifecycle.test.mjs new file mode 100644 index 0000000..14442ae --- /dev/null +++ b/experimental/126-linux-gates/g1-identity-visibility/lifecycle.test.mjs @@ -0,0 +1,75 @@ +// Runner-level negative tests for holder supervision (cross-platform). +// The children are tiny Node scripts that end on their own; none is +// signalled. Each test waits for every child's exit with a bound. +import assert from 'node:assert/strict' +import process from 'node:process' +import { setTimeout as delay } from 'node:timers/promises' +import { test } from 'node:test' +import { createSupervisor, stopFaults, superviseOnce } from './lifecycle.mjs' + +const node = (source) => ({ command: process.execPath, args: ['-e', source], options: { env: {} } }) +const READY = 'process.stdout.write(JSON.stringify({ ready: true, pid: process.pid }) + "\\n");' +// Exits 0 on stdin EOF; a 5 s lifetime guards against a stuck test. +const ON_EOF = (code) => `${READY} process.stdin.on("end", () => process.exit(${code})); process.stdin.resume(); setTimeout(() => process.exit(3), 5000).unref()` +const settled = async (handleExit, ms = 6000) => Promise.race([handleExit, delay(ms).then(() => 'unconfirmed')]) + +test('good holder: ready, during, exit 0 on EOF, no faults, stopped once', async () => { + const s = createSupervisor() + const r = await superviseOnce(s, { ...node(ON_EOF(0)), during: async (ready) => ready.pid }) + assert.deepEqual(r.faults, []) + assert.equal(r.duringResult, r.pid) + assert.equal(r.outcome.exitCode, 0) + assert.deepEqual(await s.sweep(1000), []) + assert.equal(s.started(), 1) + assert.equal(s.stopped(), 1) +}) + +test('readiness timeout is a fault and the holder is still stopped', async () => { + const s = createSupervisor() + const r = await superviseOnce(s, { ...node('process.stdin.on("end", () => process.exit(0)); process.stdin.resume(); setTimeout(() => process.exit(3), 5000).unref()'), readyMs: 200 }) + assert.deepEqual(r.faults, ['holder:ready_failed:timeout']) + assert.equal(r.outcome.exitCode, 0) +}) + +test('early exit before readiness is a fault, and its nonzero exit is a separate fault', async () => { + const s = createSupervisor() + const r = await superviseOnce(s, node('process.exit(1)')) + assert.equal(r.faults.length, 2, JSON.stringify(r.faults)) + assert.match(r.faults[0], /^holder:ready_failed:(exited|stdout_ended)$/) + assert.equal(r.faults[1], 'holder:exit:1') +}) + +test('invalid or unparseable ready records are faults', async () => { + const s = createSupervisor() + const bad = await superviseOnce(s, { ...node(ON_EOF(0)), validateReady: (ready) => (ready.pid === -1 ? null : 'pid_mismatch') }) + assert.deepEqual(bad.faults, ['holder:ready_invalid:pid_mismatch']) + const text = await superviseOnce(s, node('process.stdout.write("not json\\n"); process.stdin.on("end", () => process.exit(0)); process.stdin.resume(); setTimeout(() => process.exit(3), 5000).unref()')) + assert.deepEqual(text.faults, ['holder:ready_unparseable']) +}) + +test('EOF failure: a holder that ignores EOF is incomplete, never signalled, and exits on its own lifetime', async () => { + const s = createSupervisor() + let handle + const r = await superviseOnce(s, { ...node(`${READY} process.stdin.resume(); setTimeout(() => process.exit(0), 1200)`), stopMs: 200, + during: async (_ready, h) => { handle = h } }) + assert.deepEqual(r.faults, ['holder:incomplete']) + assert.deepEqual(await settled(handle.exited), { exitCode: 0, signal: null }) +}) + +test('a nonzero exit on EOF and a failing `during` are faults', async () => { + const s = createSupervisor() + const r = await superviseOnce(s, node(ON_EOF(2))) + assert.deepEqual(r.faults, ['holder:exit:2']) + const d = await superviseOnce(s, { ...node(ON_EOF(0)), during: async () => { throw new Error('inspection failed') } }) + assert.deepEqual(d.faults, ['holder:during_failed']) + assert.equal(d.error, 'inspection failed') +}) + +test('stop outcomes: every error field becomes a fault', () => { + assert.deepEqual(stopFaults('h', { exitCode: 0, signal: null, stdinErrors: [] }), []) + assert.deepEqual(stopFaults('h', { exitCode: 0, signal: null, stdinErrors: ['EPIPE'], error: 'stdin_io_failed' }), + ['h:error:stdin_io_failed', 'h:stdin_errors:1']) + assert.deepEqual(stopFaults('h', { exitCode: null, signal: 'SIGTERM', stdinErrors: [] }), ['h:signal:SIGTERM', 'h:exit:null']) + assert.deepEqual(stopFaults('h', { incomplete: true, pid: 1, stdinErrors: [] }), ['h:incomplete']) + assert.deepEqual(stopFaults('h', undefined), ['h:stop_outcome_missing']) +}) diff --git a/experimental/126-linux-gates/g1-identity-visibility/protocol.mjs b/experimental/126-linux-gates/g1-identity-visibility/protocol.mjs new file mode 100644 index 0000000..a77898e --- /dev/null +++ b/experimental/126-linux-gates/g1-identity-visibility/protocol.mjs @@ -0,0 +1,100 @@ +// G1 stage protocol (#126 research), on top of +// ../../126-guard-liveness/lib/isolated-observer.mjs. +// +// Exactly these stages, once each, in this order, each with a typed result. +// Cross-stage consistency is checked here too. A protocol fault is a +// collection failure; it is never read as a product outcome. +export const G1_STAGES = ['dist_identity', 'module_load', 'lsof_control', 'openers', 'identity_direct', + 'prepare', 'guarded_verify', 'held_probe', 'inspect_thread'] +// Only `prepare` may end in an error that counts as an observed product +// outcome (R1). Every other stage error is a collection fault. +const ERROR_ALLOWED = new Set(['prepare']) +const PROBE_STATUS = new Set(['held', 'free', 'unknown']) +const GUARD_STATUS = new Set(['acquired', 'busy', 'absent', 'unsafe', 'changed']) + +const isObject = (v) => v !== null && typeof v === 'object' && !Array.isArray(v) +export const isTicks = (v) => typeof v === 'string' && /^\d{1,20}$/.test(v) +const isStrings = (v) => Array.isArray(v) && v.every((x) => typeof x === 'string') +const isFdNames = (v) => Array.isArray(v) && v.every((x) => typeof x === 'string' && /^\d{1,10}$/.test(x)) +export const isPid = (v) => Number.isSafeInteger(v) && v > 0 +const isHash = (v) => typeof v === 'string' && /^[0-9a-f]{64}$/.test(v) +const isError = (v) => v === null || typeof v === 'string' +const isIdentity = (p) => isObject(p) && isPid(p.pid) && typeof p.isCodex === 'boolean' && typeof p.isSharedService === 'boolean' && + typeof p.identityComplete === 'boolean' && isStrings(p.errors) && (p.startTicks === null || isTicks(p.startTicks)) && + (p.commandFingerprint === null || isHash(p.commandFingerprint)) +export const isParticipation = (p) => isObject(p) && isPid(p.pid) && isTicks(p.startTicks) && typeof p.descriptor === 'string' && + /^\d{1,10}$/.test(p.descriptor) +// LockProbe as #137 types.ts defines it. `held` requires an acquired guard. +export function isProbe(p) { + if (!isObject(p) || !PROBE_STATUS.has(p.status) || p.method !== 'flock_exclusive_nonblocking') return false + if (p.error !== undefined && typeof p.error !== 'string') return false + if (p.guard !== undefined && !(isObject(p.guard) && GUARD_STATUS.has(p.guard.status) && + (p.guard.attempts === undefined || (Number.isSafeInteger(p.guard.attempts) && p.guard.attempts > 0)))) return false + if (p.participation !== undefined && !isParticipation(p.participation)) return false + if (p.status === 'held' && p.guard?.status !== 'acquired') return false + if (p.participation !== undefined && p.status !== 'held') return false + return true +} + +const RESULT_SHAPES = { + dist_identity: (r) => isObject(r) && r.ok === true && isHash(r.manifestSha256) && r.manifestMatchesExpected === true && + r.expectedManifestSha256 === r.manifestSha256 && Array.isArray(r.faults) && r.faults.length === 0, + module_load: (r) => isObject(r) && isStrings(r.modules) && r.unlockReachable === false, + lsof_control: (r) => isObject(r) && isError(r.error) && Number.isSafeInteger(r.processCount) && r.processCount >= 0, + openers: (r) => isObject(r) && isError(r.error) && Array.isArray(r.processes) && r.processes.every(isIdentity), + identity_direct: (r) => isIdentity(r), + prepare: (r) => isObject(r) && isPid(r.pid) && isTicks(r.startTicks) && isFdNames(r.candidates) && isFdNames(r.descriptors), + guarded_verify: (r) => isObject(r) && (r.skipped === 'prepare_failed' || isProbe(r)), + held_probe: (r) => isProbe(r) && r.participation === undefined, + inspect_thread: (r) => isObject(r) && typeof r.safeToUnlock === 'boolean' && isStrings(r.blockers) && + (r.ownerLockFiles === null || isStrings(r.ownerLockFiles)) && + isStrings(r.warnings) && isProbe(r.probe) && typeof r.classification === 'string' && Number.isSafeInteger(r.openers) && + (r.owner === null || (isObject(r.owner) && isPid(r.owner.pid) && isStrings(r.owner.errors) && + (r.owner.startTicks === null || isTicks(r.owner.startTicks)))), +} +const ERROR_SHAPE = (e) => isObject(e) && (e.code === null || typeof e.code === 'string') && typeof e.message === 'string' + +// `context.expectedManifestSha256` and `context.holderPid` are the runner's +// own values, never the child's. +export function validateG1(observation, context = {}) { + const faults = [] + const byStage = {} + for (const [index, name] of (observation.begun ?? []).entries()) { + if (G1_STAGES[index] !== name) faults.push(`stage_order:${index}:${name}`) + } + for (const s of observation.stages ?? []) { + if (Object.hasOwn(byStage, s.stage)) { faults.push(`stage_duplicate:${s.stage}`); continue } + byStage[s.stage] = s + if (!G1_STAGES.includes(s.stage)) { faults.push(`stage_unknown:${s.stage}`); continue } + if (Object.hasOwn(s, 'error')) { + if (!ERROR_ALLOWED.has(s.stage)) faults.push(`stage_failed:${s.stage}`) + else if (!ERROR_SHAPE(s.error)) faults.push(`stage_error_malformed:${s.stage}`) + } else if (!RESULT_SHAPES[s.stage](s.result)) { + faults.push(`stage_result_malformed:${s.stage}`) + } + } + const missing = G1_STAGES.filter((name) => !Object.hasOwn(byStage, name)) + if (missing.length) faults.push(`stages_missing:${missing.join(',')}`) + if (faults.length === 0) { + if (!isHash(context.expectedManifestSha256)) faults.push('expected_manifest_missing') + else if (byStage.dist_identity.result.manifestSha256 !== context.expectedManifestSha256) faults.push('build_manifest_mismatch') + if (!isPid(context.holderPid)) faults.push('holder_pid_missing') + else { + if (byStage.identity_direct.result.pid !== context.holderPid) faults.push('identity_direct_pid_mismatch') + if (byStage.prepare.result && byStage.prepare.result.pid !== context.holderPid) faults.push('prepare_pid_mismatch') + } + // A failed prepare must be followed by the harness skip, and only then. + const prepareFailed = Object.hasOwn(byStage.prepare, 'error') + const skipped = byStage.guarded_verify.result.skipped === 'prepare_failed' + if (prepareFailed !== skipped) faults.push('prepare_guarded_inconsistent') + } + return { ok: faults.length === 0, faults, byStage, missing } +} + +export const CHILD_FAULTS = new Set(G1_STAGES.map((s) => `stall:${s}`)) + +export function parseChildFault(value) { + if (value === undefined || value === '') return null + if (!CHILD_FAULTS.has(value)) throw new Error(`G1_CHILD_FAULT must be one of ${[...CHILD_FAULTS].join(', ')}`) + return value +} diff --git a/experimental/126-linux-gates/g1-identity-visibility/run.mjs b/experimental/126-linux-gates/g1-identity-visibility/run.mjs new file mode 100644 index 0000000..6d624e8 --- /dev/null +++ b/experimental/126-linux-gates/g1-identity-visibility/run.mjs @@ -0,0 +1,302 @@ +#!/usr/bin/env node +// G1 runner: separate argv/executable identity from lsof visibility (#126 research). +// +// G1_BUILD= [G1_VARIANTS=script,argv0,native,node] [G1_CHILD_FAULT=stall:] \ +// node experimental/126-linux-gates/g1-identity-visibility/run.mjs +// +// Linux only, unprivileged, same real/effective UID. Every holder is a +// SYNTHETIC fixture in a fresh private scratch CODEX_HOME; no real Codex +// process or home is used. For each variant and dumpable value: start the +// holder, check its ready record and its kernel argv against the variant, +// then run inspect-child.mjs in a separate process. Stages are validated by +// protocol.mjs, rows by expectations.mjs. +// +// Provenance: the build manifest SHA-256 is taken once before any holder +// starts. The inspector must report the same value before it imports product +// code, and the runner re-verifies the build after every run (drift fails the +// run). Hashes cover dist/ and native addons only, not dependency JavaScript +// or package metadata; they do not prove upstream identity. The runner also +// records the SHA-256 of every research input file and of the interpreters. +// +// Signals: this runner sends no signal to any holder, inspector, lock owner +// or unlock path. Holders end on stdin EOF or their own lifetime; an +// inspector past its bound gets stdin EOF and is left running, recorded +// `incomplete`. Bounded diagnostic spawnSync calls here (compiler, +// `--version`, `lsof -v`) receive SIGTERM from Node if their bound expires; +// product diagnostics inside the inspector signal only their own timed-out +// process groups. +// +// Exit: 0 only if every run was collected, every prediction matched and every +// variant ran; 2 if collected and matched but a variant was not executed +// (partial); 1 otherwise. Exit 0 is a reproduced cause separation, NOT a +// passing positive control (see `controlPassed`) and not a native Codex +// result. Scratch is always kept; raw evidence stays in the 0600 result file +// and is not printed. +import { createHash } from 'node:crypto' +import { spawnSync } from 'node:child_process' +import { log } from 'node:console' +import fs from 'node:fs' +import os from 'node:os' +import path from 'node:path' +import { performance } from 'node:perf_hooks' +import process from 'node:process' +import { fileURLToPath } from 'node:url' +import { freshPrivateDir, writeNew } from '../../126-guard-liveness/lib/common.mjs' +import { lateExitStatus, processInstance, runIsolated } from '../../126-guard-liveness/lib/isolated-observer.mjs' +import { verifyBuild } from '../../126-guard-liveness/f2-linux-nondumpable/build.mjs' +import { evaluateReport, evaluateRun } from './expectations.mjs' +import { createSupervisor, stopFaults, superviseOnce } from './lifecycle.mjs' +import { parseChildFault, validateG1 } from './protocol.mjs' +import { expectedArgv, parseVariantSelection, VARIANT_ORDER, VARIANTS } from './variants.mjs' + +if (process.platform !== 'linux') throw new Error('G1 runs on Linux only') +if (process.getuid() === 0 || process.getuid() !== process.geteuid()) throw new Error('G1 requires an unprivileged same-UID runner') +if (!process.env.G1_BUILD) throw new Error('G1_BUILD (a build.mjs output directory) is required') +const DIR = path.dirname(fileURLToPath(import.meta.url)) +const ROOT = path.resolve(DIR, '..') +const SHARED = path.resolve(ROOT, '..', '126-guard-liveness') +const SCRIPT_FIXTURE = path.join(SHARED, 'f2-linux-nondumpable', 'codex') +const NODE_FIXTURE = path.join(ROOT, 'fixtures', 'node', 'codex') +const C_SOURCE = path.join(ROOT, 'fixtures', 'codex.c') +const PYTHON = '/usr/bin/python3' +const COMPILERS = ['/usr/bin/cc', '/usr/bin/gcc', '/usr/bin/clang'] +const THREAD_ID = '01a089e8-3731-7202-ba68-0f4b0a3b2711' +const INSPECT_STARTUP_MS = 10000 +const INSPECT_STAGES_MS = 15000 +const HOLDER_LIFETIME_S = 60 +const NEEDED_MS = INSPECT_STARTUP_MS + INSPECT_STAGES_MS + 5000 +const BUILD = fs.realpathSync(process.env.G1_BUILD) +const CHILD_FAULT = parseChildFault(process.env.G1_CHILD_FAULT) +const SELECTED = parseVariantSelection(process.env.G1_VARIANTS) +const buildCheck = verifyBuild(BUILD) +if (!buildCheck.ok) throw new Error(`G1_BUILD does not match its manifest: ${buildCheck.faults.join(',')}`) +const EXPECTED_MANIFEST = buildCheck.manifestSha256 + +const sha256 = (data) => createHash('sha256').update(data).digest('hex') +const fileSha = (file) => { try { return sha256(fs.readFileSync(file)) } catch (error) { return `unreadable:${error.code}` } } +const read = (file) => { try { return fs.readFileSync(file, 'utf8').trim() } catch (error) { return `error:${error.code}` } } +const bounded = (command, args, timeout) => { + const r = spawnSync(command, args, { encoding: 'utf8', timeout, maxBuffer: 1 << 20, stdio: ['ignore', 'pipe', 'pipe'] }) + return { status: r.status, signal: r.signal, error: r.error?.message ?? null, stdout: r.stdout ?? '', stderr: r.stderr ?? '' } +} +// Research inputs, relative to the experimental/ directory, plus interpreters. +const INPUTS = [ + ...['run.mjs', 'inspect-child.mjs', 'protocol.mjs', 'expectations.mjs', 'variants.mjs', 'lifecycle.mjs'] + .map((f) => path.join(DIR, f)), + NODE_FIXTURE, C_SOURCE, SCRIPT_FIXTURE, + path.join(SHARED, 'lib', 'common.mjs'), path.join(SHARED, 'lib', 'isolated-observer.mjs'), + path.join(SHARED, 'f2-linux-nondumpable', 'build.mjs'), + path.resolve(ROOT, '..', '..', 'package.json'), +] +function inputIdentity() { + const base = path.resolve(ROOT, '..') + const files = INPUTS.map((f) => ({ path: path.relative(base, f), sha256: fileSha(f) })).sort((a, b) => (a.path < b.path ? -1 : 1)) + const interpreters = [process.execPath, ...(fs.existsSync(PYTHON) ? [fs.realpathSync(PYTHON)] : [])] + .map((f) => ({ basename: path.basename(f), sha256: fileSha(f) })) + return { files, interpreters, combinedSha256: sha256(files.map((f) => `${f.path}\t${f.sha256}\n`).join('')), + limits: 'covers these research files and interpreter binaries only; not shared libraries, Python stdlib or dependency JavaScript' } +} + +const out = freshPrivateDir('126-g1') +const supervisor = createSupervisor() +const failures = [] +const incomplete = [] +const lateExits = [] +const notExecuted = VARIANT_ORDER.filter((v) => !SELECTED.includes(v)).map((variant) => ({ variant, reason: 'not_selected' })) +const lsofVersion = bounded(fs.existsSync('/usr/sbin/lsof') ? '/usr/sbin/lsof' : '/usr/bin/lsof', ['-v'], 5000) +const identityAtStart = inputIdentity() +const report = { + environment: { + kernel: os.release(), arch: process.arch, node: process.version, nodeExecBasename: path.basename(process.execPath), + uid: process.getuid(), ptraceScope: read('/proc/sys/kernel/yama/ptrace_scope'), + lsofVersion: (lsofVersion.stderr.match(/revision:\s*(\S+)/) ?? [])[1] ?? null, + lsofVersionCall: { status: lsofVersion.status, signal: lsofVersion.signal, error: lsofVersion.error }, + build: { manifestSha256: EXPECTED_MANIFEST, source: buildCheck.source, patch: buildCheck.patch, + coverage: 'dist/ and native addons re-hashed; dependency JavaScript and package metadata are not covered' }, + inputs: identityAtStart, childFault: CHILD_FAULT, selected: SELECTED, + }, + native: null, + runs: [], +} + +// Native fixture: compile on this host, or record not_executed. +let nativeBin = null +if (SELECTED.includes('native')) { + const compiler = COMPILERS.find((c) => { try { fs.accessSync(c, fs.constants.X_OK); return true } catch { return false } }) + if (!compiler) { + notExecuted.push({ variant: 'native', reason: 'no_compiler' }) + report.native = { status: 'not_executed', reason: 'no_compiler', searched: COMPILERS } + } else { + const dir = path.join(out, 'native') + fs.mkdirSync(dir, { mode: 0o700 }) + nativeBin = path.join(dir, 'codex') + const sourceSha256 = fileSha(C_SOURCE) + const compiled = bounded(compiler, ['-std=c11', '-O1', '-Wall', '-Wextra', '-Werror', '-o', nativeBin, C_SOURCE], 60000) + const version = bounded(compiler, ['--version'], 5000) + report.native = { compiler, version: version.stdout.split('\n')[0], versionCall: { status: version.status, signal: version.signal }, + sourceSha256, status: compiled.status, signal: compiled.signal, error: compiled.error, stderr: compiled.stderr.slice(0, 4000) } + if (compiled.status !== 0 || fileSha(C_SOURCE) !== sourceSha256) { + failures.push('native_compile_failed') + notExecuted.push({ variant: 'native', reason: 'compile_failed' }) + nativeBin = null + } else { + report.native.binarySha256 = fileSha(nativeBin) + } + } +} +if (SELECTED.includes('argv0') && !fs.existsSync(PYTHON)) failures.push('python3_missing_for_argv0') + +function holderCommand(variant, env) { + if (variant === 'script') return [SCRIPT_FIXTURE, [], { env }] + if (variant === 'argv0') return [PYTHON, [SCRIPT_FIXTURE], { env, argv0: 'codex' }] + if (variant === 'native') return [nativeBin, [], { env }] + if (variant === 'node') return [process.execPath, [NODE_FIXTURE], { env: { ...env, FIXTURE_REQUIRE_FROM: path.join(BUILD, 'src', 'package.json') } }] + throw new Error(variant) +} + +// Same parsing as the product's parseProcCommandLine: drop trailing empties. +const procArgv = (pid) => { + try { + const argv = fs.readFileSync(`/proc/${pid}/cmdline`).toString('utf8').split('\0') + while (argv.length > 0 && argv[argv.length - 1] === '') argv.pop() + return argv + } catch (error) { return `error:${error.code}` } +} + +function validateReady(variant, dumpable) { + return (ready, handle) => { + if (ready?.ready !== true || ready.pid !== handle.pid || !Number.isSafeInteger(ready.fd) || ready.fd < 3 || + ready.uid !== process.getuid() || ready.euid !== process.getuid() || ready.lifetime_seconds !== HOLDER_LIFETIME_S) return 'fields' + if (variant === 'node') { + if (ready.dumpable !== null) return 'node_dumpable_claimed' + } else if (ready.dumpable !== dumpable) return 'dumpable' + return null + } +} + +try { +for (const variant of SELECTED) { + if (notExecuted.some((n) => n.variant === variant)) continue + for (const dumpable of VARIANTS[variant].dumpables) { + const home = path.join(out, `home-${variant}-${dumpable}`) + const lockDir = path.join(home, 'thread-writer-locks') + const sessions = path.join(home, 'sessions', '2026', '10', '18') + fs.mkdirSync(lockDir, { recursive: true, mode: 0o700 }) + fs.mkdirSync(sessions, { recursive: true, mode: 0o700 }) + writeNew(path.join(lockDir, '.coordination.lock'), '') + const lockPath = path.join(lockDir, `${THREAD_ID}.lock`) + writeNew(lockPath, '') + const controlPath = path.join(home, 'lsof-control') + writeNew(controlPath, '') + writeNew(path.join(sessions, `rollout-2026-10-18T00-00-00-${THREAD_ID}.jsonl`), + JSON.stringify({ timestamp: '2026-10-18T00:00:00.000Z', type: 'event_msg', payload: { type: 'task_complete' } }) + '\n') + const run = { variant, requestedDumpable: dumpable, synthetic: true } + const startedAt = performance.now() + const env = { PATH: '/usr/bin:/bin', FIXTURE_LOCK_PATH: lockPath, FIXTURE_DUMPABLE: String(dumpable), + FIXTURE_LIFETIME_SECONDS: String(HOLDER_LIFETIME_S) } + const [command, args, options] = holderCommand(variant, env) + const supervised = await superviseOnce(supervisor, { + command, args, options, readyMs: 5000, stopMs: 5000, validateReady: validateReady(variant, dumpable), + during: async (ready, handle) => { + let holderExited = false + handle.exited.then(() => { holderExited = true }) + run.holderInstanceAtReady = processInstance(ready.pid) + if (variant === 'node') { + // Node cannot prctl; infer from /proc/PID/fd ownership (root when non-dumpable). + run.dumpableInferred = fs.statSync(`/proc/${ready.pid}/fd`).uid === process.getuid() ? 1 : 0 + if (run.dumpableInferred !== dumpable) throw new Error('node fixture dumpability differs') + } + run.kernelArgv = procArgv(ready.pid) + run.expectedArgv = expectedArgv(variant, { scriptPath: SCRIPT_FIXTURE, nativePath: nativeBin, nodeExec: process.execPath, nodeFixture: NODE_FIXTURE }) + if (JSON.stringify(run.kernelArgv) !== JSON.stringify(run.expectedArgv)) throw new Error('kernel argv differs from the variant definition') + if (performance.now() - startedAt + NEEDED_MS > HOLDER_LIFETIME_S * 1000) throw new Error('holder lifetime insufficient for inspection') + const inspected = await runIsolated(process.execPath, + [path.join(DIR, 'inspect-child.mjs'), BUILD, EXPECTED_MANIFEST, String(ready.pid), String(ready.fd), lockPath, home, THREAD_ID, controlPath], { + startupBudgetMs: INSPECT_STARTUP_MS, budgetMs: INSPECT_STAGES_MS, + env: { PATH: '/usr/sbin:/usr/bin:/bin', ...(CHILD_FAULT ? { G1_CHILD_FAULT: CHILD_FAULT } : {}) }, + }) + const { lateExit, ...record } = inspected + run.inspect = record + run.holderInstanceAfterInspect = processInstance(ready.pid) + if (inspected.incomplete) { + incomplete.push({ role: 'inspector', variant, dumpable, phase: inspected.incompletePhase, openStage: inspected.openStage }) + lateExits.push({ label: { variant, dumpable }, instance: inspected.instance, lateExit }) + } + const protocol = validateG1(inspected, { expectedManifestSha256: EXPECTED_MANIFEST, holderPid: ready.pid }) + run.protocolFaults = protocol.faults + run.evaluation = evaluateRun({ variant, dumpable, holder: ready, observation: inspected, protocol, + ticks: { ready: run.holderInstanceAtReady.startTicks, afterInspect: run.holderInstanceAfterInspect.startTicks, + holderExitedDuringInspection: holderExited } }) + if (!run.evaluation.collected) failures.push(`inspector:${variant}:${dumpable}:not_collected`) + }, + }) + run.holder = supervised.ready + run.holderExit = supervised.outcome + run.holderFaults = supervised.faults + run.holderError = supervised.error + run.holderStderr = supervised.stderr ?? '' + for (const fault of supervised.faults) { + if (fault.endsWith(':incomplete')) incomplete.push({ role: 'holder', variant, dumpable }) + failures.push(`${variant}:${dumpable}:${fault}`) + } + // Build drift between runs fails this run's collection. + let recheck + try { + recheck = verifyBuild(BUILD) + run.buildRecheck = { ok: recheck.ok, manifestSha256: recheck.manifestSha256 } + } catch (error) { + recheck = { ok: false } + run.buildRecheck = { ok: false, error: String(error?.message ?? error) } + } + const finalFaults = [...supervised.faults] + if (!recheck.ok || recheck.manifestSha256 !== EXPECTED_MANIFEST) { + failures.push(`${variant}:${dumpable}:build_drift`) + finalFaults.push('build_drift') + } + if (run.evaluation && finalFaults.length) { + run.evaluation.collected = false + run.evaluation.collectedFaults.push(...finalFaults.map((fault) => `final:${fault}`)) + } + report.runs.push(run) + } +} +} catch (error) { + failures.push('runner_exception') + report.runnerError = String(error?.stack ?? error) +} finally { +// Any child not stopped above is stopped once here and its outcome counted. +report.sweep = await supervisor.sweep(5000) +for (const { outcome } of report.sweep) { + for (const fault of stopFaults('swept', outcome)) { + if (fault.endsWith(':incomplete')) incomplete.push({ role: 'swept' }) + failures.push(fault) + } +} +} +report.lateInspectorExits = await lateExitStatus(lateExits, 2000) +for (const late of report.lateInspectorExits) { + if (late.status !== 'exited') failures.push(`inspector_late_exit_unconfirmed:${late.variant}:${late.dumpable}`) +} +const identityAtEnd = inputIdentity() +if (identityAtEnd.combinedSha256 !== identityAtStart.combinedSha256 || + JSON.stringify(identityAtEnd.interpreters) !== JSON.stringify(identityAtStart.interpreters)) failures.push('input_drift') +const collected = failures.length === 0 && incomplete.length === 0 +report.expectations = evaluateReport(report.runs.map((r) => r.evaluation).filter(Boolean), + { selected: SELECTED, notExecuted, expectedManifestSha256: EXPECTED_MANIFEST, collectionValid: collected }) +const exitCode = !collected || !report.expectations.predictionsMatched ? 1 : report.expectations.complete ? 0 : 2 +Object.assign(report, { collected, failures, incomplete, exitCode }) +const resultPath = path.join(out, 'g1-results.json') +const body = JSON.stringify(report, null, 2) + '\n' +writeNew(resultPath, body) +// Sanitized summary: classes, statuses and fault codes; no raw errors, paths or PIDs. +const publicCauses = new Set(['argv_identity', 'instance_unverified', 'owner_hidden', 'lsof_mount_warning', + 'lsof_infrastructure_error', 'held_observation_missing', 'owner_lockset_unavailable']) +log(JSON.stringify({ resultDirectory: path.basename(out), resultSha256: sha256(body), inputsSha256: identityAtStart.combinedSha256, manifestSha256: EXPECTED_MANIFEST, + exitCode, collected, failureCount: failures.length, incompleteCount: incomplete.length, + hostClass: report.expectations.hostClass, coverageValid: report.expectations.coverageValid, + predictionsMatched: report.expectations.predictionsMatched, complete: report.expectations.complete, notExecuted, + controlPassed: report.expectations.controlPassed, refusalCounted: report.expectations.refusalCounted, + rows: report.runs.map((r) => ({ v: r.variant, d: r.requestedDumpable, holderFaults: r.holderFaults.length, + rows: r.evaluation && Object.entries(r.evaluation.rows).map(([id, x]) => `${id}:${x.status}${x.causes.length ? `(${x.causes.map((c) => publicCauses.has(c) ? c : 'unrecognized_cause').join('+')})` : ''}`) })), +}, null, 2)) +process.exitCode = exitCode diff --git a/experimental/126-linux-gates/g1-identity-visibility/variants.mjs b/experimental/126-linux-gates/g1-identity-visibility/variants.mjs new file mode 100644 index 0000000..fb138a0 --- /dev/null +++ b/experimental/126-linux-gates/g1-identity-visibility/variants.mjs @@ -0,0 +1,118 @@ +// G1 fixture variants and source-derived predictions (#126 research). +// +// Every variant is SYNTHETIC. None is Codex, and no result here says anything +// about a deployed native Codex binary's argv, image or dumpability. +// +// The variants separate the two causes that failed the 2026-10-16 F2 +// positive control on the Linux test host: +// (a) the product's executable/argv identity checks, and +// (b) an lsof stderr warning about incomplete visibility. +// Cause (a) is varied by the fixture's kernel argv. Cause (b) is a property +// of the host, measured in every inspection by a control lsof call on an +// unopened file (`lsof_control` stage). Only the narrow mount-visibility +// warning format counts as cause (b) (expectations.mjs `classifyLsofError`); +// a missing tool, a timeout or any other text is infrastructure, not (b). +// Nothing suppresses the warning and nothing changes the product allowlist +// or policy. +// +// Product conditions, read from #137 `b483cd5` (isolated `git archive` copy): +// - process.ts `codexArgumentOffset`: argv[0] basename `codex` or +// `codex-` (offset 1), or argv[0] basename `node`/`nodejs` with argv[1] +// basename `codex` (offset 2). Anything else is `unsupported executable argv`. +// - `reconcileArguments`: `ps -o args=` must equal the space-joined kernel argv, +// then `classifyCodexMode` must accept every token after the offset +// (interactive mode allows one positional operand; unknown options refuse). +// - `isCodex`: lsof `c`, ps `comm` basename `codex`/`codex-*`, or the verified +// argument text matching `(^|/)codex(\s|$)`. Arguments are null after an +// argv error, so the last test cannot rescue a refused argv. +// - `inspectProcess` on Linux: start ticks and the command fingerprint +// (`/proc/PID/exe` readlink + cmdline) must be read twice and agree, else +// `linux_process_instance_unverified` (exe readlink needs ptrace access). +// - `findLockOpeners`: ANY lsof stderr becomes `error`, even with exit 0. +// - `inspectThread`: guarded participation is attempted only when the +// opener lookup has no error and exactly one process; otherwise the probe +// keeps `held` without participation, and `evaluateSafety` adds +// `linux_participation_unproven`, `lock_opener_lookup_failed`, and +// `classification_unknown` (classify requires no opener error). +// - `lockFilesOpenedByProcess`: any lsof stderr is `owner_lock_file_lookup_failed`. + +export const VARIANTS = Object.freeze({ + // The 2026-10-16 fixture: `#!/usr/bin/env python3` script named codex. + // env execs python3 as argv[0], so the kernel argv is [python3, ]. + script: { dumpables: [1, 0], argvClean: false, label: 'python script named codex (as measured 2026-10-16)' }, + // Same Python file, started with argv[0] = 'codex' (spawn argv0). Image is + // python3. Shows that the product's identity check is argv-shaped: it does + // not verify the executable image. + argv0: { dumpables: [1, 0], argvClean: true, label: 'python image with argv[0]=codex (argv-only identity)' }, + // Synthetic C program compiled on the run host and named codex. No args. + native: { dumpables: [1, 0], argvClean: true, label: 'synthetic native C binary named codex' }, + // Node shebang fixture started as /codex; dumpability inherited. + node: { dumpables: [1], argvClean: true, label: 'synthetic node fixture named codex (dumpable inferred)' }, +}) + +export const VARIANT_ORDER = ['script', 'argv0', 'native', 'node'] + +// Kernel argv each variant is expected to have, for a local check against the +// product's pure `reconcileArguments` (argv-check.mjs). Paths are examples. +export function expectedArgv(variant, { scriptPath, nativePath, nodeExec, nodeFixture }) { + if (variant === 'script') return ['python3', scriptPath] + if (variant === 'argv0') return ['codex', scriptPath] + if (variant === 'native') return [nativePath] + if (variant === 'node') return [nodeExec, nodeFixture] + throw new Error(`unknown variant ${variant}`) +} + +export const PRODUCT_ARGV_ERROR = 'arguments_unverified:unsupported executable argv' + +// Strict variant selection: nonempty, known, unique. Order is canonical. +export function parseVariantSelection(text) { + if (text === undefined) return [...VARIANT_ORDER] + if (typeof text !== 'string' || text.trim() === '') throw new Error('G1_VARIANTS is empty') + const names = text.split(',') + for (const name of names) if (!Object.hasOwn(VARIANTS, name)) throw new Error(`G1_VARIANTS has an unknown variant: ${name || '(empty)'}`) + if (new Set(names).size !== names.length) throw new Error('G1_VARIANTS has a duplicate variant') + return VARIANT_ORDER.filter((v) => names.includes(v)) +} + +export const HOST_CLASSES = ['clean', 'mount_warning'] + +// SOURCE PREDICTION for one run, never a measurement. `host` is the measured +// host class: 'clean' (the control lsof wrote no stderr) or 'mount_warning' +// (it wrote only the narrow mount-visibility warning). An unclassified host +// has no prediction. Causes are sorted sets. `confidence: 'source_only'` +// marks rows whose source reading was not checked by any execution. +export function predict(variant, dumpable, host) { + const v = VARIANTS[variant] + if (!v) throw new Error(`unknown variant ${variant}`) + if (!HOST_CLASSES.includes(host)) throw new Error(`no prediction for host class ${host}`) + const lsof = host === 'mount_warning' ? ['lsof_mount_warning'] : [] + const argv = v.argvClean ? [] : ['argv_identity'] + const out = {} + const set = (id, status, causes = [], extra = {}) => { out[id] = { basis: 'source_prediction', status, causes: [...new Set(causes)].sort(), ...extra } } + // Independent guarded probe of the held lock, with no attribution. + set('H0_held_observed', 'pass') + if (dumpable === 1) { + set('V1_owner_listed', 'pass') + set('V2_opener_clean', lsof.length ? 'fail' : 'pass', lsof) + set('I1_identity_direct', argv.length ? 'fail' : 'pass', argv) + set('I2_is_codex', v.argvClean ? 'pass' : 'fail', argv) + set('E2_prepare_candidate', 'pass') + set('E3_guarded_participation', 'pass') + // Guarded participation is skipped by inspectThread on any opener error. + set('E4_inspection_participation', lsof.length ? 'blocked' : 'pass', lsof) + const eligibilityCauses = [...lsof, ...argv, ...(lsof.length ? ['owner_lockset_unavailable'] : [])] + set('E5_eligible', eligibilityCauses.length ? 'fail' : 'pass', eligibilityCauses) + } else { + // Source-derived, unexecuted: lsof cannot read the fd directory of a + // non-dumpable process, so a path-filtered lsof omits it. + set('V1_owner_listed', 'fail', ['owner_hidden'], { confidence: 'source_only' }) + set('V2_opener_clean', lsof.length ? 'fail' : 'pass', lsof) + set('I1_identity_direct', 'fail', [...argv, 'instance_unverified']) + set('R1_prepare_permission_denied', 'pass') + set('R2_refused_with_participation_blocker', 'pass') + // Refusal is attributed to denial only when the owner was listed without + // an opener error; otherwise the refusal comes from visibility. + set('R3_refusal_attributed_to_denial', 'blocked', ['owner_hidden', ...lsof], { confidence: 'source_only' }) + } + return out +} diff --git a/experimental/126-linux-gates/g2-guard-gaps/designs.mjs b/experimental/126-linux-gates/g2-guard-gaps/designs.mjs new file mode 100644 index 0000000..268fec9 --- /dev/null +++ b/experimental/126-linux-gates/g2-guard-gaps/designs.mjs @@ -0,0 +1,330 @@ +// G2: guard-liveness gaps of #137 as pure models (#126 research). +import { Buffer } from 'node:buffer' +// +// Nothing here is product code or a measurement. Each export is a pure +// function over explicit evidence or a schedule, so that counterexamples are +// deterministic. Kernel behaviour is source-derived from pinned Linux v6.17 +// (`e5f0a698b34ed76002dc5cff3804a61c80233a7a`): fs/locks.c (`locks_start`, +// `locks_show`, `lock_get_status`, `locks_translate_pid`, flock `flc_pid = +// current->tgid`) and fs/proc/fd.c (`seq_show` calls `f_op->show_fdinfo`). +// It is a targeted reading, not an exhaustive audit, and no claim is made +// for other kernels. + +// --------------------------------------------------------------------------- +// Gap 1: in-guard reads and what they can wait on. +// +// Wait classes. `target_*` classes depend on the candidate's memory, file +// type or file system and have no bound the observer controls. `lock_fs` is +// the lock file's own file system, which main's guarded probe already opens +// and flocks (parity). `proc_spin` covers task/files spinlocks and RCU. +// `global_file_rwsem` is /proc/locks: `locks_start` takes +// `percpu_down_write(&file_rwsem)` and `blocked_lock_lock`; readers of that +// rwsem (flock/posix insertion) hold it only around list updates and do not +// sleep there in the read v6.17 paths. That is a source argument, not a bound. +export const UNBOUNDED_TARGET_CLASSES = new Set(['target_task_mm', 'target_file_getattr', 'target_file_show_fdinfo']) + +const READS = { + 'b483cd5': [ + ['statfs/stat lock path', 'lock_fs'], ['stat /proc/PID, ns/mnt readlink', 'proc_spin'], + ['/proc/PID/stat', 'proc_spin'], ['/proc/PID/exe readlink + cmdline', 'target_task_mm'], + ['/proc/PID/fd list', 'proc_spin'], ['fd/N readlink', 'proc_spin'], ['stat fd/N', 'target_file_getattr'], + ['fdinfo/N', 'target_file_show_fdinfo'], ['stat fd/N', 'target_file_getattr'], ['fd/N readlink', 'proc_spin'], + ], + // F4 proposal (../../126-guard-liveness/f4-guard-scope): cmdline/exe and + // stat via FD moved out; fdinfo stays. + f4: [ + ['statfs/stat lock path', 'lock_fs'], ['stat /proc/PID, ns/mnt readlink', 'proc_spin'], + ['/proc/PID/stat', 'proc_spin'], ['/proc/PID/fd list', 'proc_spin'], ['fd/N readlink', 'proc_spin'], + ['fdinfo/N', 'target_file_show_fdinfo'], ['fd/N readlink', 'proc_spin'], + ], + // G4 proposal below: no per-descriptor fdinfo in the guard; /proc/locks + // instead. statfs moved before the guard (gap 2). + g4: [ + ['stat lock path', 'lock_fs'], ['stat /proc/PID, ns/mnt+pid readlink', 'proc_spin'], + ['/proc/PID/stat', 'proc_spin'], ['/proc/PID/fd list', 'proc_spin'], ['fd/N readlink', 'proc_spin'], + ['/proc/locks', 'global_file_rwsem'], ['fd/N readlink', 'proc_spin'], + ], +} + +export function inGuardReads(design) { + const reads = READS[design] + if (!reads) throw new Error(`unknown design ${design}`) + return reads.map(([read, waitClass]) => ({ read, waitClass })) +} + +// What a design's guard can wait on under a schedule. `fd_reused` models FD +// N being closed and reused by a file whose show_fdinfo takes a sleeping lock +// (for example epoll's `ep->mtx` in ep_show_fdinfo) between the in-guard +// readlink and the fdinfo read. The later readlink refuses the result, but the +// wait has already happened while coordination is held. +export function guardWaits(design, schedule = {}) { + const waits = [] + for (const { read, waitClass } of inGuardReads(design)) { + if (waitClass === 'target_file_show_fdinfo') { + waits.push({ read, waitClass, on: schedule.fdReusedBy ? `${schedule.fdReusedBy}.show_fdinfo` : 'lock file (no show_fdinfo on ext4/tmpfs/xfs regular files)' }) + } else if (waitClass === 'target_task_mm') { + waits.push({ read, waitClass, on: 'target mmap_lock (access_remote_vm)' }) + } else if (waitClass === 'target_file_getattr') { + waits.push({ read, waitClass, on: schedule.fdReusedBy ? `${schedule.fdReusedBy}.getattr` : 'lock file getattr' }) + } else { + waits.push({ read, waitClass, on: waitClass }) + } + } + const unbounded = waits.filter((w) => UNBOUNDED_TARGET_CLASSES.has(w.waitClass) && + (w.waitClass !== 'target_file_show_fdinfo' || schedule.fdReusedBy)) + return { waits, unboundedTargetWaits: unbounded, lockFsParity: waits.some((w) => w.waitClass === 'lock_fs') } +} + +// --------------------------------------------------------------------------- +// /proc/locks parser (G4). Each line: `: [-> ] ... +// :: `. Rows on the target inode must be a plain +// granted or blocked FLOCK ADVISORY WRITE/READ; anything else on the target is +// unsupported and refuses. Rows on other inodes are only located, not judged. +// Field widths follow lock_get_status: MAJOR is 12 bits and MINOR 20 bits of +// the kernel dev_t, the inode an unsigned long. Anything wider is overflow. +const TARGET_ROW = /^(\d{1,20}): (?:\s*-> )?FLOCK\s+ADVISORY\s+(WRITE|READ)\s+(-?\d{1,20})\s+([0-9a-f]{1,8}):([0-9a-f]{1,8}):(\d{1,20})\s+0\s+EOF$/i +const ANY_ROW = /^(\d{1,20}): (\s*-> )?.*?\s(-?\d{1,20})\s+(?:([0-9a-f]{1,8}):([0-9a-f]{1,8}):(\d{1,20})|:0)\s+\S+\s+\S+$/i +const MAX_INODE = (1n << 64n) - 1n + +export function parseProcLocks(text, target) { + if (typeof text !== 'string' || (text !== '' && !text.endsWith('\n'))) throw new Error('proc_locks_truncated') + const rows = [] + for (const line of text.split('\n')) { + if (line === '') continue + const any = ANY_ROW.exec(line) + if (!any) throw new Error('proc_locks_line_unsupported') + if (any[4] === undefined) continue + const major = BigInt(`0x${any[4]}`); const minor = BigInt(`0x${any[5]}`); const inode = BigInt(any[6]) + if (major > 0xfffn || minor > 0xfffffn || inode > MAX_INODE) throw new Error('proc_locks_field_overflow') + if (major !== target.major || minor !== target.minor || inode !== target.inode) continue + const strict = TARGET_ROW.exec(line) + if (!strict) throw new Error('proc_locks_target_row_unsupported') + const pid = Number(strict[3]) + if (!Number.isSafeInteger(pid)) throw new Error('proc_locks_pid_unsafe') + rows.push({ blocked: Boolean(any[2]), access: strict[2].toUpperCase(), pid }) + } + return rows +} + +// Evidence validation for decideG4. Returns a refusal reason or null. Missing +// or malformed fields refuse; nothing here throws or defaults. +const isObject = (v) => v !== null && typeof v === 'object' && !Array.isArray(v) +const isPid = (v) => Number.isSafeInteger(v) && v > 0 +const isFdName = (v) => typeof v === 'string' && /^\d{1,10}$/.test(v) +const isTicks = (v) => typeof v === 'string' && /^\d{1,20}$/.test(v) +const isDevIno = (v) => isObject(v) && typeof v.major === 'bigint' && typeof v.minor === 'bigint' && typeof v.inode === 'bigint' && + v.major >= 0n && v.major <= 0xfffn && v.minor >= 0n && v.minor <= 0xfffffn && v.inode > 0n && v.inode <= MAX_INODE +function malformed(e) { + if (!isObject(e)) return 'evidence' + if (!isPid(e.candidatePid)) return 'candidatePid' + if (!isFdName(e.candidateFd)) return 'candidateFd' + if (!isDevIno(e.target)) return 'target' + if (!Array.isArray(e.candidates) || !e.candidates.every(isFdName)) return 'candidates' + for (const name of ['pre', 'guard', 'post']) { + const sample = e[name] + if (!isObject(sample)) return name + if (!Array.isArray(sample.descriptors) || sample.descriptors.length === 0 || !sample.descriptors.every(isFdName) || + new Set(sample.descriptors).size !== sample.descriptors.length) return `${name}.descriptors` + if (!sample.descriptors.includes(e.candidateFd)) return `${name}.descriptors_without_candidate` + if (typeof sample.readlink !== 'string' || !sample.readlink.startsWith('/') || sample.readlink.endsWith(' (deleted)')) return `${name}.readlink` + if (!isTicks(sample.startTicks)) return `${name}.startTicks` + } + for (const name of ['pre', 'post']) { + if (!Array.isArray(e[name].fdinfoRows) || !e[name].fdinfoRows.every(isDevIno)) return `${name}.fdinfoRows` + } + if (!['held', 'free', 'unknown'].includes(e.guard.probe)) return 'guard.probe' + if (typeof e.guard.pidNamespaceSame !== 'boolean' || typeof e.guard.mountNamespaceSame !== 'boolean') return 'guard.namespaces' + if (e.guard.procLocks !== null && typeof e.guard.procLocks !== 'string') return 'guard.procLocks' + if (typeof e.post.commandUnchanged !== 'boolean') return 'post.commandUnchanged' + return null +} + +// G4 decision for one candidate description. Evidence is three samples: +// `pre` (before the guard), `guard` (inside), `post` (after release). +// +// The result is at best `consistent_unproven`: the samples did not refute +// participation, but they do NOT prove that the candidate's sampled +// description participated inside the guard. The /proc/locks acquirer PID +// names the process that called flock(), not a description, and has no +// instance binding (a historical PID can be reused). The hidden-transfer +// schedule in designs.test.mjs satisfies every check here while the sampled +// description was unlocked inside the guard. G4 therefore stays no-go until +// that binding problem is solved. Never sole control. +export function decideG4(e) { + const refuse = (reason) => ({ decision: 'refuse', reason }) + const bad = malformed(e) + if (bad) return refuse(`evidence_malformed:${bad}`) + if (e.candidates.length !== 1 || e.candidates[0] !== e.candidateFd) return refuse('candidate_descriptor_not_unique') + if (e.guard.pidNamespaceSame !== true || e.guard.mountNamespaceSame !== true) return refuse('namespace_mismatch') + for (const name of ['pre', 'post']) { + const rows = e[name].fdinfoRows + if (rows.length === 0) return refuse(`fdinfo_row_absent_${name}`) + if (rows.length !== 1) return refuse(`fdinfo_rows_ambiguous_${name}`) + if (rows[0].major !== e.target.major || rows[0].minor !== e.target.minor || rows[0].inode !== e.target.inode) return refuse(`fdinfo_row_mismatch_${name}`) + } + const same = (key) => JSON.stringify(e.pre[key]) === JSON.stringify(e.guard[key]) && JSON.stringify(e.guard[key]) === JSON.stringify(e.post[key]) + for (const key of ['descriptors', 'readlink', 'startTicks']) if (!same(key)) return refuse(`identity_changed:${key}`) + if (e.guard.probe !== 'held') return refuse(`probe_${e.guard.probe}`) + if (e.guard.procLocks === null) return refuse('proc_locks_unavailable') + if (Buffer.byteLength(e.guard.procLocks) > 65536) return refuse('proc_locks_over_limit') + let rows + try { rows = parseProcLocks(e.guard.procLocks, e.target) } catch (error) { return refuse(error.message) } + const granted = rows.filter((r) => !r.blocked) + if (granted.length === 0) return refuse('proc_locks_no_granted_row') + if (granted.length !== 1 || granted[0].access !== 'WRITE') return refuse('proc_locks_rows_ambiguous') + if (granted[0].pid <= 0) return refuse('acquirer_not_visible') + // flc_pid is the tgid that called flock(). A different acquirer refuses; + // an equal one is not proof (see above). + if (granted[0].pid !== e.candidatePid) return refuse('acquirer_pid_mismatch') + if (e.post.commandUnchanged !== true) return refuse('command_changed_after_guard') + return { decision: 'consistent_unproven', participationProven: false, soleControl: false, + gaps: ['sampled_description_not_bound_inside_guard', 'acquirer_pid_has_no_instance_binding'], + waiters: rows.filter((r) => r.blocked).length } +} + +// Model of the hidden-transfer schedule (designs.test.mjs). Returns the three +// samples a G4 observer would collect and the model's ground truth. It is a +// model schedule, not a claim about any Codex runtime path. +export function hiddenTransferSchedule({ candidatePid = 42, fd = '7', target, readlink = '/h/thread-writer-locks/x.lock', startTicks = '900' }) { + const descriptors = ['0', '1', '2', fd] + const base = { descriptors, readlink, startTicks } + const row = (pid) => `1: FLOCK ADVISORY WRITE ${pid} ${target.major.toString(16).padStart(2, '0')}:${target.minor.toString(16).padStart(2, '0')}:${target.inode} 0 EOF\n` + return { + steps: [ + 'pre: P holds description A at FD7', + 'P unlocks A, flocks description B at a temporary FD, passes B to a hidden recipient, closes the temporary FD', + 'guard: FD7 (A) is unlocked; the recipient alone holds B; /proc/locks shows B with acquirer P', + 'the recipient releases B; P flocks A again', + 'post: P holds A at FD7', + ], + truth: { sampledDescriptionParticipatedInGuard: false, guardHolder: 'hidden_recipient' }, + evidence: { + candidatePid, candidateFd: fd, candidates: [fd], target, + pre: { ...base, fdinfoRows: [{ ...target }] }, + guard: { ...base, probe: 'held', pidNamespaceSame: true, mountNamespaceSame: true, procLocks: row(candidatePid) }, + post: { ...base, fdinfoRows: [{ ...target }], commandUnchanged: true }, + }, + } +} + +// The #137 b483cd5 / F4 acceptance, modelled with the same evidence shape: +// one in-guard fdinfo row on the candidate description, without an acquirer +// check. Used only to show counterexamples. +export function decideB483(e) { + if (e.candidates.length < 1) return { decision: 'refuse', reason: 'no_candidate' } + if (e.guard.probe !== 'held') return { decision: 'refuse', reason: `probe_${e.guard.probe}` } + const rows = e.guard.fdinfoRows ?? [] + if (rows.length !== 1) return { decision: 'refuse', reason: rows.length ? 'linux_rows_ambiguous' : 'linux_participation_unproven' } + return { decision: 'participation', soleControl: false } +} + +// --------------------------------------------------------------------------- +// Gap 2: lock file system stalls under the native guard. +export const ALLOWLISTED_FS = new Set([0xef53n, 0x01021994n, 0x58465342n]) // ext, tmpfs, XFS + +// Ordered plan for one Linux participation attempt. `fs-gate` checks statfs +// before any participation guard and refuses non-allowlisted file systems +// without taking it. The main probe's own guarded open/flock (`mainProbe`) +// is unchanged by both: refusing that too is a separate policy choice +// (`refuse-before-any-guard`), which also changes diagnostics on such homes. +export function participationPlan(design, lockFsType) { + const allowed = ALLOWLISTED_FS.has(lockFsType) + const mainProbe = { guarded: true, touchesLockFs: true } + if (design === 'b483cd5') { + return { mainProbe, participationGuardTaken: true, inGuardLockFsOps: ['statfs', 'stat', 'stat'], + refusedBeforeGuard: false, refusedInGuard: !allowed } + } + if (design === 'fs-gate') { + if (!allowed) return { mainProbe, participationGuardTaken: false, inGuardLockFsOps: [], refusedBeforeGuard: true, refusedInGuard: false } + return { mainProbe, participationGuardTaken: true, inGuardLockFsOps: ['stat', 'stat'], refusedBeforeGuard: false, refusedInGuard: false } + } + if (design === 'refuse-before-any-guard') { + if (!allowed) return { mainProbe: { guarded: false, touchesLockFs: false, probe: 'unknown' }, participationGuardTaken: false, + inGuardLockFsOps: [], refusedBeforeGuard: true, refusedInGuard: false } + return { mainProbe, participationGuardTaken: true, inGuardLockFsOps: ['stat', 'stat'], refusedBeforeGuard: false, refusedInGuard: false } + } + throw new Error(`unknown design ${design}`) +} + +// --------------------------------------------------------------------------- +// Gap 3: evidence aging between the in-guard observation and the signal. +// +// The decision runs at `checkAt`. The signal happens at `killAt`, which the +// decision cannot observe or bound: between the check and `process.kill` the +// thread can be descheduled, and Node exposes no pidfd. A soft check refuses +// slow paths it can see; it never yields a hard bound. F4's 25 ms check is +// `confirmMs` only. +const TIME_ORDER = ['guardObservedAt', 'guardReleasedAt', 'probeReturnedAt', 'confirmEndedAt', 'checkAt'] +export function finalBoundary(t, { softLimitMs = 25 } = {}) { + const refuse = (reason) => ({ allowed: false, reason, hardBound: false }) + if (t === null || typeof t !== 'object') return refuse('timestamps_missing') + if (typeof softLimitMs !== 'number' || !Number.isFinite(softLimitMs) || softLimitMs < 0) return refuse('soft_limit_invalid') + for (const key of TIME_ORDER) if (typeof t[key] !== 'number' || !Number.isFinite(t[key])) return refuse(`timestamp_missing:${key}`) + // Monotonic clock readings must be ordered; anything else is a broken + // observation, never a fast one. + for (let i = 1; i < TIME_ORDER.length; i += 1) { + if (t[TIME_ORDER[i]] < t[TIME_ORDER[i - 1]]) return refuse(`timestamps_not_monotonic:${TIME_ORDER[i]}`) + } + if (t.killAt !== undefined && (typeof t.killAt !== 'number' || !Number.isFinite(t.killAt) || t.killAt < t.checkAt)) { + return refuse('kill_timestamp_invalid') + } + const confirmMs = t.confirmEndedAt - t.probeReturnedAt + const evidenceAgeAtCheckMs = t.checkAt - t.guardObservedAt + const allowed = confirmMs <= softLimitMs && evidenceAgeAtCheckMs <= softLimitMs + return { + allowed, reason: allowed ? null : 'soft_freshness_exceeded', confirmMs, evidenceAgeAtCheckMs, + // Observed only after the fact, never an input to the decision. + evidenceAgeAtSignalMs: t.killAt === undefined ? null : t.killAt - t.guardObservedAt, + hardBound: false, + requires: ['post_signal_exit_verification', 'post_signal_lock_release_verification', 'transcript_hash_invariance'], + } +} + +// --------------------------------------------------------------------------- +// Gap 4: inherited-description participation versus sole control. +// +// A granted row proves that one open file description participates in the +// lock. It does not prove that SIGTERM to the candidate releases it: a +// fork-without-exec child or an SCM_RIGHTS recipient shares the description +// (O_CLOEXEC affects exec only). `standard` refuses everything observable; +// `strict` also refuses when any same-user process is unobservable (its +// /proc/PID/fd is unreadable, for example a non-dumpable agent), because such a +// process could hold the description. Neither mode proves sole control. +const pidList = (v) => Array.isArray(v) && v.every((x) => Number.isSafeInteger(x) && x > 0) && new Set(v).size === v.length +export const SOLE_CONTROL_MODES = new Set(['standard', 'strict']) +export function soleControlDecision(e, mode = 'standard') { + const refuse = (reason) => ({ decision: 'refuse', reason, soleControlProven: false }) + if (!SOLE_CONTROL_MODES.has(mode)) return refuse(`mode_unknown:${String(mode)}`) + if (e === null || typeof e !== 'object') return refuse('evidence_malformed') + if (!Number.isSafeInteger(e.candidatePid) || e.candidatePid <= 0) return refuse('evidence_malformed:candidatePid') + if (e.lsofError !== null && typeof e.lsofError !== 'string') return refuse('evidence_malformed:lsofError') + if (!pidList(e.openers)) return refuse('evidence_malformed:openers') + if (!Number.isSafeInteger(e.acquirerPid) || e.acquirerPid <= 0) return refuse('evidence_malformed:acquirerPid') + if (e.descendants !== null && !pidList(e.descendants)) return refuse('evidence_malformed:descendants') + if (e.unobservableSameUser !== null && !pidList(e.unobservableSameUser)) return refuse('evidence_malformed:unobservableSameUser') + if (e.lsofError !== null) return refuse('opener_lookup_incomplete') + if (e.openers.length !== 1 || e.openers[0] !== e.candidatePid) return refuse('opener_set_not_candidate_only') + // Necessary, not sufficient: an equal acquirer PID does not bind the + // description (see decideG4). + if (e.acquirerPid !== e.candidatePid) return refuse('description_acquired_by_other_process') + if (e.descendants === null) return refuse('descendants_unavailable') + if (e.descendants.length > 0) return refuse('descendants_may_share_description') + const residuals = ['description_binding_unproven', 'post_signal_release_verification_required'] + if (mode === 'strict') { + if (e.unobservableSameUser === null) return refuse('unobservable_scan_unavailable') + if (e.unobservableSameUser.length > 0) return refuse('unobservable_same_user_process') + } else { + residuals.unshift('hidden_sharer_not_excluded') + } + return { decision: 'not_refused', soleControlProven: false, residuals } +} + +// #137 b483cd5 policy for the same facts: descendants are a warning only and +// the acquirer is not checked. Used to show the counterexample. +export function soleControlB483(e) { + if (e.lsofError) return { decision: 'refuse', reason: 'lock_opener_lookup_failed' } + if (e.openers.length !== 1 || e.openers[0] !== e.candidatePid) return { decision: 'refuse', reason: 'lock_owner_not_unique' } + const warnings = e.descendants === null ? ['owner_descendants_unavailable'] + : e.descendants.length ? [`owner_has_descendants:${e.descendants.join(',')}`] : [] + return { decision: 'eligible', warnings } +} diff --git a/experimental/126-linux-gates/g2-guard-gaps/designs.test.mjs b/experimental/126-linux-gates/g2-guard-gaps/designs.test.mjs new file mode 100644 index 0000000..fb550fa --- /dev/null +++ b/experimental/126-linux-gates/g2-guard-gaps/designs.test.mjs @@ -0,0 +1,205 @@ +// Deterministic counterexamples for the G2 gap models. Pure functions only; +// these run on every platform. They test the models and model schedules, not +// the kernel and not any Codex runtime behaviour. +import assert from 'node:assert/strict' +import { test } from 'node:test' +import { + decideB483, decideG4, finalBoundary, guardWaits, hiddenTransferSchedule, inGuardReads, parseProcLocks, participationPlan, + soleControlB483, soleControlDecision, +} from './designs.mjs' + +const target = { major: 0xfdn, minor: 0x1n, inode: 5678n } +const row = { ...target } +const locks = (lines) => lines.join('\n') + '\n' +const holderLine = (pid) => `1: FLOCK ADVISORY WRITE ${pid} fd:01:5678 0 EOF` +const sample = (extra = {}) => ({ descriptors: ['0', '1', '2', '7'], readlink: '/h/thread-writer-locks/x.lock', startTicks: '900', ...extra }) +const evidence = (over = {}) => ({ + candidatePid: 42, candidateFd: '7', candidates: ['7'], target, + pre: sample({ fdinfoRows: [row] }), + guard: sample({ probe: 'held', pidNamespaceSame: true, mountNamespaceSame: true, + procLocks: locks(['3: POSIX ADVISORY WRITE 11 08:02:131 0 EOF', holderLine(42), '4: FLOCK ADVISORY WRITE 99 fd:01:9999 0 EOF']) }), + post: sample({ fdinfoRows: [row], commandUnchanged: true }), + ...over, +}) +const UNPROVEN = { decision: 'consistent_unproven', participationProven: false, soleControl: false, + gaps: ['sampled_description_not_bound_inside_guard', 'acquirer_pid_has_no_instance_binding'], waiters: 0 } + +test('gap 1: an FD reused by epoll makes b483cd5 and F4 wait on show_fdinfo inside the guard; G4 does not read fdinfo there', () => { + const schedule = { fdReusedBy: 'epoll' } + for (const design of ['b483cd5', 'f4']) { + const w = guardWaits(design, schedule) + assert.ok(w.unboundedTargetWaits.some((x) => x.waitClass === 'target_file_show_fdinfo' && x.on === 'epoll.show_fdinfo'), design) + } + assert.ok(guardWaits('b483cd5').unboundedTargetWaits.some((x) => x.waitClass === 'target_task_mm')) + assert.equal(guardWaits('f4').unboundedTargetWaits.length, 0) + const g4 = guardWaits('g4', schedule) + assert.deepEqual(g4.unboundedTargetWaits, []) + assert.equal(inGuardReads('g4').some((r) => /fdinfo|cmdline|exe|stat fd/.test(r.read)), false) + assert.equal(g4.lockFsParity, true) + assert.ok(inGuardReads('g4').some((r) => r.waitClass === 'global_file_rwsem')) +}) + +test('/proc/locks parser: strict on the target inode, locate-only elsewhere, overflow and unsafe PIDs refused', () => { + assert.deepEqual(parseProcLocks(locks([holderLine(42), '1: -> FLOCK ADVISORY WRITE 43 fd:01:5678 0 EOF']), target), + [{ blocked: false, access: 'WRITE', pid: 42 }, { blocked: true, access: 'WRITE', pid: 43 }]) + assert.deepEqual(parseProcLocks(locks(['2: LEASE ACTIVE READ 5 00:2e:12 0 EOF', '3: OFDLCK ADVISORY READ -1 fd:01:77 0 EOF']), target), []) + assert.throws(() => parseProcLocks(locks(['2: POSIX ADVISORY WRITE 42 fd:01:5678 0 EOF']), target), /target_row_unsupported/) + assert.throws(() => parseProcLocks(holderLine(42), target), /truncated/) + assert.throws(() => parseProcLocks(locks(['garbage']), target), /line_unsupported/) + assert.throws(() => parseProcLocks(locks(['1: FLOCK ADVISORY WRITE 99999999999999999999 fd:01:5678 0 EOF']), target), /pid_unsafe/) + assert.throws(() => parseProcLocks(locks(['1: FLOCK ADVISORY WRITE 42 1000:01:5678 0 EOF']), target), /field_overflow/) + assert.throws(() => parseProcLocks(locks(['1: FLOCK ADVISORY WRITE 42 fd:100000:5678 0 EOF']), target), /field_overflow/) + assert.throws(() => parseProcLocks(locks(['1: FLOCK ADVISORY WRITE 42 fd:01:18446744073709551616 0 EOF']), target), /field_overflow/) + assert.throws(() => parseProcLocks(locks([`1: FLOCK ADVISORY WRITE 42 fd:01:${'9'.repeat(21)} 0 EOF`]), target), /line_unsupported/) + assert.deepEqual(parseProcLocks('', target), []) +}) + +test('G4 at best reports consistent_unproven; it never claims participation', () => { + assert.deepEqual(decideG4(evidence()), UNPROVEN) +}) + +test('counterexample (model schedule): hidden transfer satisfies every G4 check while the sampled description was unlocked in the guard', () => { + const schedule = hiddenTransferSchedule({ target }) + assert.equal(schedule.truth.sampledDescriptionParticipatedInGuard, false) + const decision = decideG4(schedule.evidence) + // Every check passes, so G4 cannot tell this apart from real participation. + assert.equal(decision.decision, 'consistent_unproven') + assert.equal(decision.participationProven, false) + assert.ok(decision.gaps.includes('sampled_description_not_bound_inside_guard')) + assert.notEqual(decision.decision, 'participation') +}) + +test('model schedule: another acquirer during the guard is refused (necessary check only)', () => { + const e = evidence({ guard: { ...evidence().guard, procLocks: locks([holderLine(77)]) } }) + assert.deepEqual(decideG4(e), { decision: 'refuse', reason: 'acquirer_pid_mismatch' }) +}) + +test('model schedule: inherited description passes the b483cd5 shape, G4 refuses', () => { + const inherited = evidence({ candidatePid: 50, guard: { ...evidence().guard, fdinfoRows: [row], procLocks: locks([holderLine(42)]) } }) + assert.equal(decideB483(inherited).decision, 'participation') + assert.deepEqual(decideG4(inherited), { decision: 'refuse', reason: 'acquirer_pid_mismatch' }) + const omitted = evidence({ candidatePid: 50, guard: { ...evidence().guard, procLocks: locks([]) } }) + assert.deepEqual(decideG4(omitted), { decision: 'refuse', reason: 'proc_locks_no_granted_row' }) +}) + +test('pure repro: deleting descriptors, readlink and startTicks from all samples refuses instead of matching undefined', () => { + const strip = ({ descriptors: _d, readlink: _r, startTicks: _t, ...rest }) => rest + const e = evidence() + const stripped = { ...e, pre: strip(e.pre), guard: strip(e.guard), post: strip(e.post) } + assert.deepEqual(decideG4(stripped), { decision: 'refuse', reason: 'evidence_malformed:pre.descriptors' }) +}) + +test('G4 rejects malformed and missing fields without throwing', () => { + const g = evidence().guard + const cases = [ + [null, 'evidence'], [{ ...evidence(), candidatePid: 0 }, 'candidatePid'], [{ ...evidence(), candidatePid: 2 ** 53 }, 'candidatePid'], + [{ ...evidence(), candidatePid: '42' }, 'candidatePid'], [{ ...evidence(), candidateFd: 7 }, 'candidateFd'], + [{ ...evidence(), target: { major: 253, minor: 1, inode: 5678 } }, 'target'], [{ ...evidence(), target: { ...target, inode: 0n } }, 'target'], + [{ ...evidence(), candidates: [7] }, 'candidates'], [{ ...evidence(), guard: undefined }, 'guard'], + [{ ...evidence(), pre: sample({ descriptors: [], fdinfoRows: [row] }) }, 'pre.descriptors'], + [{ ...evidence(), pre: sample({ descriptors: ['7', '7'], fdinfoRows: [row] }) }, 'pre.descriptors'], + [{ ...evidence(), post: { ...evidence().post, descriptors: ['0', '1'] } }, 'post.descriptors_without_candidate'], + [{ ...evidence(), guard: { ...g, readlink: '' } }, 'guard.readlink'], [{ ...evidence(), guard: { ...g, readlink: '/x (deleted)' } }, 'guard.readlink'], + [{ ...evidence(), guard: { ...g, startTicks: 900 } }, 'guard.startTicks'], + [{ ...evidence(), pre: sample({ fdinfoRows: [{ major: 253, minor: 1, inode: 5678 }] }) }, 'pre.fdinfoRows'], + [{ ...evidence(), guard: { ...g, probe: undefined } }, 'guard.probe'], [{ ...evidence(), guard: { ...g, pidNamespaceSame: 1 } }, 'guard.namespaces'], + [{ ...evidence(), guard: { ...g, procLocks: 5 } }, 'guard.procLocks'], [{ ...evidence(), post: { ...evidence().post, commandUnchanged: undefined } }, 'post.commandUnchanged'], + ] + for (const [input, field] of cases) { + assert.deepEqual(decideG4(input), { decision: 'refuse', reason: `evidence_malformed:${field}` }, field) + } +}) + +test('G4 refusals: identity drift, namespaces, ambiguity, limits, read lock, command change', () => { + const g = evidence().guard + assert.equal(decideG4(evidence({ candidates: ['7', '9'] })).reason, 'candidate_descriptor_not_unique') + assert.equal(decideG4(evidence({ candidates: ['9'] })).reason, 'candidate_descriptor_not_unique') + assert.equal(decideG4(evidence({ guard: { ...g, pidNamespaceSame: false } })).reason, 'namespace_mismatch') + assert.equal(decideG4(evidence({ post: { ...evidence().post, startTicks: '901' } })).reason, 'identity_changed:startTicks') + assert.equal(decideG4(evidence({ guard: { ...g, readlink: '/other' } })).reason, 'identity_changed:readlink') + assert.equal(decideG4(evidence({ guard: { ...g, probe: 'unknown' } })).reason, 'probe_unknown') + assert.equal(decideG4(evidence({ guard: { ...g, procLocks: null } })).reason, 'proc_locks_unavailable') + assert.equal(decideG4(evidence({ guard: { ...g, procLocks: 'x'.repeat(65537) } })).reason, 'proc_locks_over_limit') + assert.equal(decideG4(evidence({ guard: { ...g, procLocks: locks([holderLine(42), holderLine(42)]) } })).reason, 'proc_locks_rows_ambiguous') + assert.equal(decideG4(evidence({ guard: { ...g, procLocks: locks(['1: FLOCK ADVISORY READ 42 fd:01:5678 0 EOF']) } })).reason, 'proc_locks_rows_ambiguous') + assert.equal(decideG4(evidence({ guard: { ...g, procLocks: locks([holderLine(0)]) } })).reason, 'acquirer_not_visible') + assert.equal(decideG4(evidence({ guard: { ...g, procLocks: locks(['1: FLOCK ADVISORY WRITE 99999999999999999999 fd:01:5678 0 EOF']) } })).reason, 'proc_locks_pid_unsafe') + assert.equal(decideG4(evidence({ pre: sample({ fdinfoRows: [] }) })).reason, 'fdinfo_row_absent_pre') + assert.equal(decideG4(evidence({ post: { ...evidence().post, fdinfoRows: [{ ...row, inode: 1n }] } })).reason, 'fdinfo_row_mismatch_post') + assert.equal(decideG4(evidence({ post: { ...evidence().post, commandUnchanged: false } })).reason, 'command_changed_after_guard') +}) + +test('gap 2: fs-gate refuses a non-allowlisted lock file system before the participation guard; main probe is unchanged', () => { + const nfs = 0x6969n + const before = participationPlan('b483cd5', nfs) + assert.equal(before.participationGuardTaken, true) + assert.equal(before.refusedInGuard, true) + assert.ok(before.inGuardLockFsOps.includes('statfs')) + const gated = participationPlan('fs-gate', nfs) + assert.equal(gated.participationGuardTaken, false) + assert.equal(gated.refusedBeforeGuard, true) + assert.deepEqual(gated.mainProbe, { guarded: true, touchesLockFs: true }) + assert.equal(participationPlan('refuse-before-any-guard', nfs).mainProbe.guarded, false) + assert.deepEqual(participationPlan('fs-gate', 0xef53n).inGuardLockFsOps, ['stat', 'stat']) +}) + +test('gap 3: a passing soft check never yields a bound; disordered or invalid inputs refuse', () => { + const t = { guardObservedAt: 0, guardReleasedAt: 1, probeReturnedAt: 1.2, confirmEndedAt: 5, checkAt: 6, killAt: 10000 } + const r = finalBoundary(t) + assert.equal(r.allowed, true) + assert.equal(r.hardBound, false) + assert.equal(r.evidenceAgeAtSignalMs, 10000) + // F4's check covers confirm only: a stale observation with a fast confirm passes it. + const stale = { ...t, guardObservedAt: -500 } + assert.ok(stale.confirmEndedAt - stale.probeReturnedAt <= 25) + assert.equal(finalBoundary(stale).reason, 'soft_freshness_exceeded') + // Pure repro from review: descending times previously produced negative ages and `allowed`. + const reversed = finalBoundary({ guardObservedAt: 1000, guardReleasedAt: 900, probeReturnedAt: 800, confirmEndedAt: 700, checkAt: 600 }) + assert.deepEqual(reversed, { allowed: false, reason: 'timestamps_not_monotonic:guardReleasedAt', hardBound: false }) + assert.equal(finalBoundary({ ...t, killAt: 5 }).reason, 'kill_timestamp_invalid') + assert.equal(finalBoundary({ ...t, checkAt: undefined }).reason, 'timestamp_missing:checkAt') + assert.equal(finalBoundary({ ...t, checkAt: '6' }).reason, 'timestamp_missing:checkAt') + for (const softLimitMs of [-1, Number.NaN, Infinity, '25']) assert.equal(finalBoundary(t, { softLimitMs }).reason, 'soft_limit_invalid') + assert.equal(finalBoundary(null).reason, 'timestamps_missing') + for (const x of [r, finalBoundary(stale), reversed]) assert.equal(x.hardBound, false) + assert.ok(r.requires.includes('post_signal_lock_release_verification')) +}) + +const facts = (over = {}) => ({ lsofError: null, openers: [42], candidatePid: 42, acquirerPid: 42, descendants: [], unobservableSameUser: [], ...over }) + +test('gap 4: fork-without-exec child sharing the description is eligible under b483cd5 policy but refused here', () => { + const shared = facts({ descendants: [43] }) + assert.equal(soleControlB483(shared).decision, 'eligible') + assert.deepEqual(soleControlB483(shared).warnings, ['owner_has_descendants:43']) + assert.equal(soleControlDecision(shared).reason, 'descendants_may_share_description') + assert.equal(soleControlDecision(facts({ descendants: null })).reason, 'descendants_unavailable') + assert.equal(soleControlDecision(facts({ acquirerPid: 41 })).reason, 'description_acquired_by_other_process') + assert.equal(soleControlDecision(facts({ lsofError: 'WARNING: incomplete' })).reason, 'opener_lookup_incomplete') +}) + +test('gap 4: hidden sharer is a residual in standard mode, a refusal in strict mode; sole control and binding are never proven', () => { + const hidden = facts({ unobservableSameUser: [77] }) + const standard = soleControlDecision(hidden) + assert.equal(standard.decision, 'not_refused') + assert.ok(standard.residuals.includes('hidden_sharer_not_excluded')) + assert.ok(standard.residuals.includes('description_binding_unproven')) + assert.equal(soleControlDecision(hidden, 'strict').reason, 'unobservable_same_user_process') + assert.equal(soleControlDecision(facts({ unobservableSameUser: null }), 'strict').reason, 'unobservable_scan_unavailable') + const clean = soleControlDecision(facts(), 'strict') + assert.equal(clean.decision, 'not_refused') + for (const r of [standard, clean]) assert.equal(r.soleControlProven, false) +}) + +test('gap 4: unknown modes and malformed arrays refuse instead of defaulting', () => { + // Pure repro from review: a misspelled mode previously fell through to standard. + assert.equal(soleControlDecision(facts({ unobservableSameUser: [77] }), 'strcit').reason, 'mode_unknown:strcit') + assert.equal(soleControlDecision(facts(), undefined).decision, 'not_refused') + const cases = [ + [facts({ descendants: 'none' }), 'descendants'], [facts({ descendants: [0] }), 'descendants'], [facts({ descendants: [43, 43] }), 'descendants'], + [facts({ unobservableSameUser: [1.5] }), 'unobservableSameUser'], [facts({ unobservableSameUser: {} }), 'unobservableSameUser'], + [facts({ openers: '42' }), 'openers'], [facts({ candidatePid: -1 }), 'candidatePid'], [facts({ acquirerPid: undefined }), 'acquirerPid'], + [facts({ lsofError: undefined }), 'lsofError'], + ] + for (const [input, field] of cases) assert.equal(soleControlDecision(input).reason, `evidence_malformed:${field}`, field) + assert.equal(soleControlDecision(null).reason, 'evidence_malformed') +}) diff --git a/experimental/126-linux-gates/g2-guard-gaps/proc-locks.linux.test.mjs b/experimental/126-linux-gates/g2-guard-gaps/proc-locks.linux.test.mjs new file mode 100644 index 0000000..f14aaeb --- /dev/null +++ b/experimental/126-linux-gates/g2-guard-gaps/proc-locks.linux.test.mjs @@ -0,0 +1,181 @@ +// Linux-only real-kernel checks for the G2 models (#126 research). On other +// platforms every test is skipped and proves nothing. +// +// Same-user synthetic Python holders in a fresh private scratch directory +// (RESEARCH_TMP_ROOT, or /tmp on Linux). No holder is signalled: holders end +// on stdin EOF, a runner-owned release file, or their own lifetime, and every +// wait is bounded. A bound that expires fails the test as incomplete; it is +// never escalated. The `spawnSync` lock prober has a 5 s timeout, after which +// Node would send SIGTERM to that prober only. These tests measure +// /proc/locks and fdinfo semantics only; they do not run the product or any +// Codex process. +import assert from 'node:assert/strict' +import { spawnSync } from 'node:child_process' +import fs from 'node:fs' +import path from 'node:path' +import process from 'node:process' +import { setTimeout as delay } from 'node:timers/promises' +import { test } from 'node:test' +import { freshPrivateDir, startChild, stopAll } from '../../126-guard-liveness/lib/common.mjs' +import { decideG4, parseProcLocks } from './designs.mjs' + +const linux = process.platform === 'linux' +const skip = linux ? false : 'Linux-only real /proc test; skipped on this platform' +const HOLD = ` +import fcntl, json, os, select, sys, time +fd = os.open(sys.argv[1], os.O_RDWR | os.O_CLOEXEC | os.O_NOFOLLOW) +fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB) +print(json.dumps({"pid": os.getpid(), "fd": fd}), flush=True) +deadline = time.monotonic() + 20 +while time.monotonic() < deadline: + r, _, _ = select.select([0], [], [], max(0, deadline - time.monotonic())) + if r and not os.read(0, 4096): + break +` +// Grandchild: holds the inherited description FD. Its control channel is the +// runner-owned directory CTL, independent of any pipe: it writes ready.json +// (exclusive), holds until CTL/release exists or its own 20 s lifetime ends, +// closes the FD, writes exit.json and exits (0 on release, 3 on lifetime). +const GRANDCHILD = ` +import json, os, sys, time +fd = int(sys.argv[1]); ctl = sys.argv[2] +deadline = time.monotonic() + 20 +def ticks(): + try: + text = open("/proc/self/stat").read() + return text[text.rindex(") ") + 2:].split()[19] + except OSError: + return None +def record(name, value): + out = os.open(os.path.join(ctl, name), os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600) + os.write(out, json.dumps(value).encode()); os.close(out) +record("ready.json", {"pid": os.getpid(), "ticks": ticks(), "fd": fd}) +released = False +while time.monotonic() < deadline: + if os.path.exists(os.path.join(ctl, "release")): + released = True + break + time.sleep(0.02) +os.close(fd) +record("exit.json", {"pid": os.getpid(), "ticks": ticks(), "released": released}) +sys.exit(0 if released else 3) +` +// Parent: takes the flock, starts the grandchild with the description and +// no inherited stdio, prints both PIDs and exits without closing the lock +// (the grandchild's copy keeps the description). +const PARENT = ` +import fcntl, json, os, subprocess, sys +fd = os.open(sys.argv[1], os.O_RDWR | os.O_NOFOLLOW) +fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB) +child = subprocess.Popen([sys.executable, "-I", "-c", ${JSON.stringify(GRANDCHILD)}, str(fd), sys.argv[2]], pass_fds=(fd,), + stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) +print(json.dumps({"parent": os.getpid(), "child": child.pid, "fd": fd}), flush=True) +os._exit(0) +` +const PROBE = 'import fcntl, os, sys\nfd = os.open(sys.argv[1], os.O_RDONLY)\ntry:\n fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB)\n print("free")\nexcept BlockingIOError:\n print("held")\n' + +const devParts = (dev) => ({ major: ((dev >> 8n) & 0xfffn) | ((dev >> 32n) & 0xfffff000n), minor: (dev & 0xffn) | ((dev >> 12n) & 0xffffff00n) }) +const targetOf = (file) => { const s = fs.statSync(file, { bigint: true }); return { ...devParts(s.dev), inode: s.ino } } +const fdinfoRows = (pid, fd) => fs.readFileSync(`/proc/${pid}/fdinfo/${fd}`, 'utf8').split('\n') + .map((l) => /^lock:\s+\d+:\s+FLOCK\s+ADVISORY\s+WRITE\s+-?\d+\s+([0-9a-f]+):([0-9a-f]+):(\d+)\s+0\s+EOF$/i.exec(l)) + .filter(Boolean).map((m) => ({ major: BigInt(`0x${m[1]}`), minor: BigInt(`0x${m[2]}`), inode: BigInt(m[3]) })) +const probe = (file) => { + const r = spawnSync('python3', ['-I', '-c', PROBE, file], { encoding: 'utf8', timeout: 5000 }) + return r.status === 0 && !r.signal ? r.stdout.trim() : `probe_failed:${r.status}:${r.signal}` +} +// Start ticks of a live, non-zombie instance, or null. +const liveTicks = (pid) => { + try { + const text = fs.readFileSync(`/proc/${pid}/stat`, 'utf8') + const fields = text.slice(text.lastIndexOf(') ') + 2).trim().split(/\s+/) + return fields[0] === 'Z' || fields[0] === 'X' ? null : fields[19] + } catch { return null } +} +const within = (promise, ms) => Promise.race([promise, delay(ms).then(() => 'incomplete')]) +async function waitFor(check, ms) { + const until = Date.now() + ms + while (Date.now() < until) { const v = check(); if (v) return v; await delay(20) } + return null +} +const readJson = (file) => { try { return JSON.parse(fs.readFileSync(file, 'utf8')) } catch { return null } } +function scratch() { + const dir = freshPrivateDir('126-g2-locks') + const file = path.join(dir, 'thread.lock') + fs.writeFileSync(file, '', { flag: 'wx', mode: 0o600 }) + const ctl = path.join(dir, 'ctl') + fs.mkdirSync(ctl, { mode: 0o700 }) + return { file, ctl } +} + +test('direct holder: the granted /proc/locks row carries the holder PID and the target dev:ino', { skip }, async () => { + const { file } = scratch() + const live = new Set() + try { + const holder = startChild('python3', ['-I', '-c', HOLD, file], { env: { PATH: '/usr/bin:/bin' } }, live) + const ready = JSON.parse(await holder.ready) + const rows = parseProcLocks(fs.readFileSync('/proc/locks', 'utf8'), targetOf(file)) + assert.deepEqual(rows.filter((r) => !r.blocked), [{ blocked: false, access: 'WRITE', pid: ready.pid }]) + assert.equal(fdinfoRows(ready.pid, ready.fd).length, 1) + } finally { + for (const r of await stopAll(live, 5000)) { + assert.equal(r.incomplete, undefined, 'holder exit incomplete (not signalled)') + assert.deepEqual([r.exitCode, r.signal, r.stdinErrors.length], [0, null, 0]) + } + } +}) + +test('inherited description: the grandchild keeps the lock, /proc/locks keeps the acquirer, G4 refuses the grandchild', { skip }, async () => { + const { file, ctl } = scratch() + const live = new Set() + let grand = null + try { + const parent = startChild('python3', ['-I', '-c', PARENT, file, ctl], { env: { PATH: '/usr/bin:/bin' } }, live) + const ids = JSON.parse(await parent.ready) + const parentExit = await within(parent.exited, 5000) + assert.notEqual(parentExit, 'incomplete', 'parent exit unconfirmed within 5 s') + assert.deepEqual(parentExit, { exitCode: 0, signal: null }) + grand = await waitFor(() => readJson(path.join(ctl, 'ready.json')), 5000) + assert.ok(grand, 'grandchild ready record missing within 5 s') + assert.equal(grand.pid, ids.child) + assert.match(String(grand.ticks), /^\d+$/) + // Instance binding before and after every measurement. + assert.equal(liveTicks(grand.pid), grand.ticks, 'grandchild instance not live at measurement start') + assert.equal(probe(file), 'held') + const target = targetOf(file) + const procLocks = fs.readFileSync('/proc/locks', 'utf8') + const granted = parseProcLocks(procLocks, target).filter((r) => !r.blocked) + // Init PID namespace: the exited acquirer's PID. Child namespace: omitted. + assert.ok(granted.length === 0 || (granted.length === 1 && granted[0].pid === ids.parent), JSON.stringify(granted)) + const rows = fdinfoRows(grand.pid, grand.fd) + assert.equal(rows.length, 1) + const link = fs.readlinkSync(`/proc/${grand.pid}/fd/${grand.fd}`) + const descriptors = fs.readdirSync(`/proc/${grand.pid}/fd`).sort((a, b) => Number(a) - Number(b)) + const s = { descriptors, readlink: link, startTicks: grand.ticks } + const decision = decideG4({ candidatePid: grand.pid, candidateFd: String(grand.fd), candidates: [String(grand.fd)], target, + pre: { ...s, fdinfoRows: rows }, guard: { ...s, probe: 'held', pidNamespaceSame: true, mountNamespaceSame: true, procLocks }, + post: { ...s, fdinfoRows: rows, commandUnchanged: true } }) + assert.equal(decision.decision, 'refuse') + assert.ok(['acquirer_pid_mismatch', 'proc_locks_no_granted_row'].includes(decision.reason), decision.reason) + assert.equal(liveTicks(grand.pid), grand.ticks, 'grandchild instance changed during measurement') + } finally { + // Runner-owned release; never a signal. + fs.writeFileSync(path.join(ctl, 'release'), '', { flag: 'wx', mode: 0o600 }) + for (const r of await stopAll(live, 5000)) assert.equal(r.incomplete, undefined, 'parent exit incomplete') + if (grand) { + const exit = await waitFor(() => readJson(path.join(ctl, 'exit.json')), 5000) + assert.ok(exit, 'grandchild exit record missing within 5 s (incomplete, not signalled)') + assert.deepEqual([exit.pid, exit.ticks, exit.released], [grand.pid, grand.ticks, true]) + const ended = await waitFor(() => liveTicks(grand.pid) !== grand.ticks, 5000) + assert.ok(ended, 'grandchild instance still live 5 s after its exit record (incomplete)') + assert.equal(probe(file), 'free') + } + } +}) + +test('epoll fdinfo reaches the file type show_fdinfo callback (tfd lines)', { skip }, (t) => { + const epoll = fs.readdirSync('/proc/self/fd').find((n) => { try { return fs.readlinkSync(`/proc/self/fd/${n}`) === 'anon_inode:[eventpoll]' } catch { return false } }) + if (!epoll) { t.skip('no epoll descriptor in this process'); return } + const text = fs.readFileSync(`/proc/self/fdinfo/${epoll}`, 'utf8') + // ep_show_fdinfo prints one tfd line per watched descriptor while holding ep->mtx. + assert.match(text, /^tfd:\s+\d+/m) +}) From 0b7b9335d739f3e0796a6d30afbc82eb1daf3228 Mon Sep 17 00:00:00 2001 From: YEONGUK LEE Date: Sun, 18 Oct 2026 13:01:42 +0900 Subject: [PATCH 2/8] docs: align Linux research overview with measured results --- experimental/126-linux-gates/README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/experimental/126-linux-gates/README.md b/experimental/126-linux-gates/README.md index 7fc88bb..30f8e3e 100644 --- a/experimental/126-linux-gates/README.md +++ b/experimental/126-linux-gates/README.md @@ -15,8 +15,8 @@ script are imported. #137 source was read from an isolated `git archive` of | Part | Question | Status at this revision | | --- | --- | --- | -| G1 `g1-identity-visibility/` | Which of the two causes failed the 2026-10-16 F2 positive control, and does each fail independently? | Implemented. Argv predictions checked locally against the product's pure function; macOS plumbing smoke. **Not run on Linux.** | -| G2 `g2-guard-gaps/` | fdinfo FD reuse, lock file system stalls, evidence aging, shared descriptions | Pure models with deterministic counterexamples. **G4 is no-go** (binding unproven). 3 Linux-only kernel checks **skipped on macOS** | +| G1 `g1-identity-visibility/` | Which of the two causes failed the 2026-10-16 F2 positive control, and does each fail independently? | Local argv/plumbing checks and reviewer US Linux nominal/stall runs completed; see Integrating Linux validation. Positive controls remain blocked on that mount-warning host. | +| G2 `g2-guard-gaps/` | fdinfo FD reuse, lock file system stalls, evidence aging, shared descriptions | Pure models with deterministic counterexamples. **G4 is no-go** (binding unproven). The 3 kernel checks skip on macOS and passed in the US Linux run. | ## Signals and bounds From f409327213269fed985da3594b24fe0c33e29c4b Mon Sep 17 00:00:00 2001 From: YEONGUK LEE Date: Sun, 18 Oct 2026 15:29:49 +0900 Subject: [PATCH 3/8] test: enforce pinned Linux research build provenance --- experimental/126-linux-gates/README.md | 23 +++++++++++++++---- .../g1-identity-visibility/argv-check.mjs | 3 ++- .../expectations.test.mjs | 8 ++++++- .../g1-identity-visibility/inspect-child.mjs | 2 ++ .../g1-identity-visibility/run.mjs | 3 ++- .../g1-identity-visibility/variants.mjs | 6 +++++ 6 files changed, 37 insertions(+), 8 deletions(-) diff --git a/experimental/126-linux-gates/README.md b/experimental/126-linux-gates/README.md index 30f8e3e..55cdd0f 100644 --- a/experimental/126-linux-gates/README.md +++ b/experimental/126-linux-gates/README.md @@ -155,7 +155,9 @@ result; the printed summary carries classes and codes only. A matched prediction of a failure is **not** a passing control. A `blocked`, `fail`, missing or `not_executed` row never counts as success. At dumpable=0, R1–R3 are `blocked` unless H0 observed `held`/`acquired`. R2 requires the -inspection probe to be `held` without participation. R3 requires the +inspection probe to be `held`, or the typed acquired-guard `unknown` with +`linux_participation_unproven`, in both cases without participation. H0 still +independently requires `held`/`acquired`. R3 requires the product's typed `unknown`/`linux_participation_unproven` probe with an acquired guard for a listed owner. @@ -166,8 +168,8 @@ everywhere. | Variant | d | V1 | V2 | I1 | I2 | E2 | E3 | E4 | E5 | | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | -| script | 1 | pass | fail (lsof) | fail (argv) | fail (argv) | pass | pass | blocked (lsof) | fail (argv + lsof) | -| argv0, native, node | 1 | pass | fail (lsof) | pass | pass | pass | pass | blocked (lsof) | fail (lsof) | +| script | 1 | pass | fail (lsof) | fail (argv) | fail (argv) | pass | pass | blocked (lsof) | fail (argv + lsof + owner-set) | +| argv0, native, node | 1 | pass | fail (lsof) | pass | pass | pass | pass | blocked (lsof) | fail (lsof + owner-set) | | Variant | d | V1 | V2 | I1 | R1 | R2 | R3 | | --- | --- | --- | --- | --- | --- | --- | --- | @@ -175,7 +177,8 @@ everywhere. | argv0, native | 0 | fail (hidden, source_only) | fail (lsof) | fail (instance) | pass | pass | blocked (lsof + hidden, source_only) | `lsof` = `lsof_mount_warning`, `argv` = `argv_identity`, `instance` = -`instance_unverified`, `hidden` = `owner_hidden`. +`instance_unverified`, `hidden` = `owner_hidden`, `owner-set` = +`owner_lockset_unavailable` (a failed lookup and an observed empty lock set). On a clean host, V2 passes, E4 passes for every dumpable=1 variant, and E5 passes for `argv0`, `native` and `node`. At dumpable=0, R3 stays blocked @@ -320,6 +323,8 @@ freed. `/proc` parts are absent on macOS. ```sh npm ci && npm run build export RESEARCH_TMP_ROOT="" +g1_build_root="$(mktemp -d "$RESEARCH_TMP_ROOT/codex-126g-build-XXXXXX")" +BUILD="$g1_build_root/product" node --test experimental/126-linux-gates/*/*.test.mjs node experimental/126-guard-liveness/f2-linux-nondumpable/build.mjs \ --repo --commit b483cd587c62329243366e64aaaac477edd36adc --out "$BUILD" @@ -345,7 +350,7 @@ tar -xf g1-input.tar -C "$R/src" && cd "$R/src" node experimental/126-guard-liveness/f2-linux-nondumpable/build.mjs \ --repo --commit b483cd587c62329243366e64aaaac477edd36adc --out "$R/build"; echo "build=$?" G1_BUILD="$R/build" node experimental/126-linux-gates/g1-identity-visibility/argv-check.mjs > "$R/argv-check.json"; echo "argv=$?" # expect 0 -RESEARCH_TMP_ROOT="$R" node --test experimental/126-linux-gates/*/*.test.mjs > "$R/tests.tap" 2>&1; echo "tests=$?" # expect 0, 37 tests, 0 skipped on Linux +RESEARCH_TMP_ROOT="$R" node --test experimental/126-linux-gates/*/*.test.mjs > "$R/tests.tap" 2>&1; echo "tests=$?" # expect 0, 38 tests, 0 skipped on Linux RESEARCH_TMP_ROOT="$R" G1_BUILD="$R/build" node experimental/126-linux-gates/g1-identity-visibility/run.mjs > "$R/g1-nominal.json"; echo "g1=$?" # expect 0 (compiler) or 2 (no compiler) RESEARCH_TMP_ROOT="$R" G1_BUILD="$R/build" G1_VARIANTS=argv0 G1_CHILD_FAULT=stall:identity_direct \ node experimental/126-linux-gates/g1-identity-visibility/run.mjs > "$R/g1-stall.json"; echo "stall=$?" # expect 1, incompleteCount >= 1 @@ -387,6 +392,14 @@ Both runs recorded research-input SHA-256 `2453ef543248d01d05bb2a9113c8b8df4627b2528b5071246036cefa6715d60b`. Every holder was synthetic; native Codex dumpability and argv remain unmeasured. +After those measurements, a review follow-up added an explicit source-commit +gate before product imports in the runner, inspection child and argv checker. +Self-consistency with a build's own manifest is insufficient: its resolved +source must be exactly `b483cd587c62329243366e64aaaac477edd36adc`. +The added negative test raises the current research test count to 38. The US +measurement hashes above refer to the earlier 37-test revision; they do not +claim to exercise the added wrong-source rejection. + ## Open decisions (no-go for readiness) | Item | Options | Owner | diff --git a/experimental/126-linux-gates/g1-identity-visibility/argv-check.mjs b/experimental/126-linux-gates/g1-identity-visibility/argv-check.mjs index aa7b46a..6c17793 100644 --- a/experimental/126-linux-gates/g1-identity-visibility/argv-check.mjs +++ b/experimental/126-linux-gates/g1-identity-visibility/argv-check.mjs @@ -15,12 +15,13 @@ import path from 'node:path' import process from 'node:process' import { pathToFileURL } from 'node:url' import { reachableModules, verifyBuild } from '../../126-guard-liveness/f2-linux-nondumpable/build.mjs' -import { expectedArgv, PRODUCT_ARGV_ERROR, VARIANT_ORDER, VARIANTS } from './variants.mjs' +import { assertPinnedBuildSource, expectedArgv, PRODUCT_ARGV_ERROR, VARIANT_ORDER, VARIANTS } from './variants.mjs' if (!process.env.G1_BUILD) throw new Error('G1_BUILD is required') const build = fs.realpathSync(process.env.G1_BUILD) const verified = verifyBuild(build) if (!verified.ok) throw new Error(`build does not match its manifest: ${verified.faults.join(',')}`) +assertPinnedBuildSource(verified.source) const dist = path.join(build, 'src', 'dist') if (reachableModules(dist, ['process.js']).includes('unlock.js')) throw new Error('unlock.js reachable') const { reconcileArguments } = await import(pathToFileURL(path.join(dist, 'process.js')).href) diff --git a/experimental/126-linux-gates/g1-identity-visibility/expectations.test.mjs b/experimental/126-linux-gates/g1-identity-visibility/expectations.test.mjs index 9dae743..2a62bfc 100644 --- a/experimental/126-linux-gates/g1-identity-visibility/expectations.test.mjs +++ b/experimental/126-linux-gates/g1-identity-visibility/expectations.test.mjs @@ -8,9 +8,15 @@ import { blockerCauses, checkCoverage, classifyLsofError, compareRun, evaluateReport, evaluateRun, hostClassOf, identityCauses, lsofCauseOf, } from './expectations.mjs' import { G1_STAGES, isProbe, parseChildFault, validateG1 } from './protocol.mjs' -import { parseVariantSelection, predict, PRODUCT_ARGV_ERROR, VARIANT_ORDER } from './variants.mjs' +import { assertPinnedBuildSource, parseVariantSelection, PINNED_SOURCE_COMMIT, predict, PRODUCT_ARGV_ERROR, VARIANT_ORDER } from './variants.mjs' const MANIFEST = 'a'.repeat(64) +test('build source identity must equal the pinned full commit', () => { + assert.doesNotThrow(() => assertPinnedBuildSource({ commit: PINNED_SOURCE_COMMIT })) + for (const source of [null, {}, { commit: 'b483cd5' }, { commit: 'f'.repeat(40) }]) { + assert.throws(() => assertPinnedBuildSource(source), /G1 build source must be/) + } +}) const holder = { pid: 42, fd: 5 } const ticks = { ready: '900', afterInspect: '900', holderExitedDuringInspection: false } const FP = 'f'.repeat(64) diff --git a/experimental/126-linux-gates/g1-identity-visibility/inspect-child.mjs b/experimental/126-linux-gates/g1-identity-visibility/inspect-child.mjs index 41fa0ed..392dbdb 100644 --- a/experimental/126-linux-gates/g1-identity-visibility/inspect-child.mjs +++ b/experimental/126-linux-gates/g1-identity-visibility/inspect-child.mjs @@ -32,6 +32,7 @@ import { pathToFileURL } from 'node:url' import { StopStages, childStages } from '../../126-guard-liveness/lib/isolated-observer.mjs' import { reachableModules, verifyBuild } from '../../126-guard-liveness/f2-linux-nondumpable/build.mjs' import { parseChildFault } from './protocol.mjs' +import { assertPinnedBuildSource } from './variants.mjs' const [build, expectedManifest, pidText, fdText, lockPath, codexHome, threadId, controlPath] = process.argv.slice(2) const pid = Number(pidText) @@ -59,6 +60,7 @@ try { const identity = await c.stage('dist_identity', () => { stall('dist_identity') const verified = verifyBuild(build) + assertPinnedBuildSource(verified.source) return plain({ ok: verified.ok, faults: verified.faults, manifestSha256: verified.manifestSha256, expectedManifestSha256: expectedManifest, manifestMatchesExpected: verified.manifestSha256 === expectedManifest, source: verified.source, patch: verified.patch, distFiles: verified.distFiles, nativeFiles: verified.nativeFiles }) diff --git a/experimental/126-linux-gates/g1-identity-visibility/run.mjs b/experimental/126-linux-gates/g1-identity-visibility/run.mjs index 6d624e8..d9edba5 100644 --- a/experimental/126-linux-gates/g1-identity-visibility/run.mjs +++ b/experimental/126-linux-gates/g1-identity-visibility/run.mjs @@ -47,7 +47,7 @@ import { verifyBuild } from '../../126-guard-liveness/f2-linux-nondumpable/build import { evaluateReport, evaluateRun } from './expectations.mjs' import { createSupervisor, stopFaults, superviseOnce } from './lifecycle.mjs' import { parseChildFault, validateG1 } from './protocol.mjs' -import { expectedArgv, parseVariantSelection, VARIANT_ORDER, VARIANTS } from './variants.mjs' +import { assertPinnedBuildSource, expectedArgv, parseVariantSelection, VARIANT_ORDER, VARIANTS } from './variants.mjs' if (process.platform !== 'linux') throw new Error('G1 runs on Linux only') if (process.getuid() === 0 || process.getuid() !== process.geteuid()) throw new Error('G1 requires an unprivileged same-UID runner') @@ -70,6 +70,7 @@ const CHILD_FAULT = parseChildFault(process.env.G1_CHILD_FAULT) const SELECTED = parseVariantSelection(process.env.G1_VARIANTS) const buildCheck = verifyBuild(BUILD) if (!buildCheck.ok) throw new Error(`G1_BUILD does not match its manifest: ${buildCheck.faults.join(',')}`) +assertPinnedBuildSource(buildCheck.source) const EXPECTED_MANIFEST = buildCheck.manifestSha256 const sha256 = (data) => createHash('sha256').update(data).digest('hex') diff --git a/experimental/126-linux-gates/g1-identity-visibility/variants.mjs b/experimental/126-linux-gates/g1-identity-visibility/variants.mjs index fb138a0..7c6b436 100644 --- a/experimental/126-linux-gates/g1-identity-visibility/variants.mjs +++ b/experimental/126-linux-gates/g1-identity-visibility/variants.mjs @@ -36,6 +36,12 @@ // `classification_unknown` (classify requires no opener error). // - `lockFilesOpenedByProcess`: any lsof stderr is `owner_lock_file_lookup_failed`. +export const PINNED_SOURCE_COMMIT = 'b483cd587c62329243366e64aaaac477edd36adc' + +export function assertPinnedBuildSource(source) { + if (source?.commit !== PINNED_SOURCE_COMMIT) throw new Error(`G1 build source must be ${PINNED_SOURCE_COMMIT}`) +} + export const VARIANTS = Object.freeze({ // The 2026-10-16 fixture: `#!/usr/bin/env python3` script named codex. // env execs python3 as argv[0], so the kernel argv is [python3, ]. From 25d1d8384ec65c54fdfaeee2702b49917e7dc4b6 Mon Sep 17 00:00:00 2001 From: YEONGUK LEE Date: Sun, 18 Oct 2026 19:23:48 +0900 Subject: [PATCH 4/8] test: isolate Linux research build consumers in verified snapshots --- .github/workflows/linux-gates-research.yml | 12 +- experimental/126-linux-gates/README.md | 48 ++++++- .../g1-identity-visibility/argv-check.mjs | 9 +- .../g1-identity-visibility/build-input.mjs | 12 ++ .../build-input.test.mjs | 124 ++++++++++++++++++ .../g1-identity-visibility/inspect-child.mjs | 28 ++-- .../g1-identity-visibility/run.mjs | 20 ++- 7 files changed, 224 insertions(+), 29 deletions(-) create mode 100644 experimental/126-linux-gates/g1-identity-visibility/build-input.mjs create mode 100644 experimental/126-linux-gates/g1-identity-visibility/build-input.test.mjs diff --git a/.github/workflows/linux-gates-research.yml b/.github/workflows/linux-gates-research.yml index f1ad4af..8b8f731 100644 --- a/.github/workflows/linux-gates-research.yml +++ b/.github/workflows/linux-gates-research.yml @@ -23,6 +23,12 @@ jobs: - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7 with: node-version: 24 - # Only built-in Node modules and Python stdlib. These tests do not load - # a Codex binary or the product, and do not establish unlock eligibility. - - run: node --test experimental/126-linux-gates/*/*.test.mjs + # The import-graph parser uses the existing TypeScript dev dependency. + # Skip lifecycle scripts: synthetic snapshot tests do not load the + # product or a Codex binary and establish no unlock eligibility. + - run: npm ci --ignore-scripts --no-audit --no-fund + - run: >- + node --test experimental/126-linux-gates/*/*.test.mjs + experimental/126-guard-liveness/f2-linux-nondumpable/build.test.mjs + experimental/126-guard-liveness/f2-linux-nondumpable/build-binding.test.mjs + experimental/126-guard-liveness/f2-linux-nondumpable/build-snapshot.test.mjs diff --git a/experimental/126-linux-gates/README.md b/experimental/126-linux-gates/README.md index 55cdd0f..6953f6f 100644 --- a/experimental/126-linux-gates/README.md +++ b/experimental/126-linux-gates/README.md @@ -92,7 +92,8 @@ are recorded `not_selected`. The runner takes the build manifest SHA-256 once, before any holder starts. It passes that value to each inspector, and the inspector refuses to import -product code unless its own re-hash matches it. The protocol then checks +product code unless its own re-hash of a private copied build matches it and +the copied source retains the exact pinned commit. The protocol then checks the inspector's reported hash against the runner's value. The runner re-verifies the build after every run, and drift fails that run. The report records the SHA-256 of every research input file (runner, inspector, @@ -103,6 +104,31 @@ and native addons only. It does not cover dependency JavaScript, package metadata, shared libraries or the Python standard library, and it does not prove upstream identity. +Before starting any holder, the runner creates a private independent runtime +snapshot using the shared F2 helper. The Node holder resolves its JS/native +dependency from this copy, and inspectors receive that copy as their input; +each inspector makes its own verified private copy before walking/importing. +The cross-platform `argv-check` also imports only its verified private copy. +The original input remains subject to the post-run drift check. + +The closed copy includes `manifest.json`, `source.tar`, optional `source.patch`, +`src/package.json`, `src/package-lock.json`, `src/dist/` and the full +`src/node_modules/` tree, omitting unused npm `.bin` shims. Other links and +special files are refused, and copied files do not share input inodes. The +private wrapper stays 0700, copied directories become 0500, and files 0400. +Snapshots, including partial failed copies, are retained under the approved +`RESEARCH_TMP_ROOT` (forwarded to inspection children). This isolates later +input mutation; a malicious same-UID process can chmod/replace its snapshot. +Copying dependency JS does not authenticate it against the startup manifest. +The canonical import graph rejects external paths and absolute/URL aliases, +but nonliteral imports, require/eval and bare-package resolution remain outside +its bounded analysis. Existing TypeScript dev dependencies must be installed +for the shared parser (`npm ci`; research CI uses `--ignore-scripts`). + +The historical US measurements below predate this snapshot wiring and the +new shared-helper input hashes. They are not execution evidence for this +integration revision. None of these changes closes G4 or #137's NO-GO gates. + ### Stages and typed evidence `dist_identity`, `module_load`, `lsof_control`, `openers`, `identity_direct`, @@ -334,12 +360,16 @@ G1_BUILD="$BUILD" node experimental/126-linux-gates/g1-identity-visibility/run.m ### Linux test host procedure (for the reviewer) -Use an unprivileged same-UID user. Make no privilege, mount, package or -host-policy change, and use no real Codex process or home. Send no holder, +Use an unprivileged same-UID user. Make no privilege, mount, host/global package +or host-policy change, and use no real Codex process or home. The dependency +installation below is confined to the private archive workspace; stop if that +installation is not authorized. Send no holder, owner or unlock signal. Diagnostic timeouts behave as described under -"Signals and bounds". Run from a frozen archive containing `package.json`, +"Signals and bounds". Run from a frozen archive containing `package.json`, `package-lock.json`, `experimental/126-guard-liveness/` and `experimental/126-linux-gates/`. -`package.json` makes the extensionless Node fixture ESM. Compare its +`package.json` makes the extensionless Node fixture ESM, and the lockfile is +required to install the existing TypeScript parser without lifecycle scripts. +Compare the archive's SHA-256 with the integrating host's value before extraction. ```sh @@ -347,10 +377,16 @@ umask 077 R="$(mktemp -d /tmp/codex-126g-run-XXXXXX)"; mkdir "$R/src" sha256sum g1-input.tar # must equal the integrating host's value tar -xf g1-input.tar -C "$R/src" && cd "$R/src" +export RESEARCH_TMP_ROOT="$R" +npm ci --ignore-scripts --no-audit --no-fund # private research dependencies only node experimental/126-guard-liveness/f2-linux-nondumpable/build.mjs \ --repo --commit b483cd587c62329243366e64aaaac477edd36adc --out "$R/build"; echo "build=$?" G1_BUILD="$R/build" node experimental/126-linux-gates/g1-identity-visibility/argv-check.mjs > "$R/argv-check.json"; echo "argv=$?" # expect 0 -RESEARCH_TMP_ROOT="$R" node --test experimental/126-linux-gates/*/*.test.mjs > "$R/tests.tap" 2>&1; echo "tests=$?" # expect 0, 38 tests, 0 skipped on Linux +node --test experimental/126-linux-gates/*/*.test.mjs \ + experimental/126-guard-liveness/f2-linux-nondumpable/build.test.mjs \ + experimental/126-guard-liveness/f2-linux-nondumpable/build-binding.test.mjs \ + experimental/126-guard-liveness/f2-linux-nondumpable/build-snapshot.test.mjs \ + > "$R/tests.tap" 2>&1; echo "tests=$?" # expect 0, 62 tests, 0 skipped on Linux RESEARCH_TMP_ROOT="$R" G1_BUILD="$R/build" node experimental/126-linux-gates/g1-identity-visibility/run.mjs > "$R/g1-nominal.json"; echo "g1=$?" # expect 0 (compiler) or 2 (no compiler) RESEARCH_TMP_ROOT="$R" G1_BUILD="$R/build" G1_VARIANTS=argv0 G1_CHILD_FAULT=stall:identity_direct \ node experimental/126-linux-gates/g1-identity-visibility/run.mjs > "$R/g1-stall.json"; echo "stall=$?" # expect 1, incompleteCount >= 1 diff --git a/experimental/126-linux-gates/g1-identity-visibility/argv-check.mjs b/experimental/126-linux-gates/g1-identity-visibility/argv-check.mjs index 6c17793..91586e7 100644 --- a/experimental/126-linux-gates/g1-identity-visibility/argv-check.mjs +++ b/experimental/126-linux-gates/g1-identity-visibility/argv-check.mjs @@ -16,13 +16,16 @@ import process from 'node:process' import { pathToFileURL } from 'node:url' import { reachableModules, verifyBuild } from '../../126-guard-liveness/f2-linux-nondumpable/build.mjs' import { assertPinnedBuildSource, expectedArgv, PRODUCT_ARGV_ERROR, VARIANT_ORDER, VARIANTS } from './variants.mjs' +import { createPinnedBuildSnapshot } from './build-input.mjs' if (!process.env.G1_BUILD) throw new Error('G1_BUILD is required') const build = fs.realpathSync(process.env.G1_BUILD) -const verified = verifyBuild(build) -if (!verified.ok) throw new Error(`build does not match its manifest: ${verified.faults.join(',')}`) +const startup = verifyBuild(build) +if (!startup.ok) throw new Error(`build does not match its manifest: ${startup.faults.join(',')}`) +const snapshot = createPinnedBuildSnapshot(build, startup.manifestSha256) +const { verified } = snapshot assertPinnedBuildSource(verified.source) -const dist = path.join(build, 'src', 'dist') +const dist = snapshot.dist if (reachableModules(dist, ['process.js']).includes('unlock.js')) throw new Error('unlock.js reachable') const { reconcileArguments } = await import(pathToFileURL(path.join(dist, 'process.js')).href) diff --git a/experimental/126-linux-gates/g1-identity-visibility/build-input.mjs b/experimental/126-linux-gates/g1-identity-visibility/build-input.mjs new file mode 100644 index 0000000..c16ee0d --- /dev/null +++ b/experimental/126-linux-gates/g1-identity-visibility/build-input.mjs @@ -0,0 +1,12 @@ +// Keep the source pin and startup manifest binding on the copied bytes used +// by every G1 build-code consumer, rather than on a mutable input directory. +import path from 'node:path' +import { createBuildSnapshot } from '../../126-guard-liveness/f2-linux-nondumpable/build-snapshot.mjs' +import { assertPinnedBuildSource } from './variants.mjs' + +export function createPinnedBuildSnapshot(build, expectedManifestSha256) { + const snapshot = createBuildSnapshot(build, expectedManifestSha256) + if (!snapshot.verified.ok) throw new Error(`G1_snapshot_unverified:${path.basename(snapshot.directory)}:${snapshot.verified.faults.join(',')}`) + assertPinnedBuildSource(snapshot.verified.source) + return snapshot +} diff --git a/experimental/126-linux-gates/g1-identity-visibility/build-input.test.mjs b/experimental/126-linux-gates/g1-identity-visibility/build-input.test.mjs new file mode 100644 index 0000000..87ddf7b --- /dev/null +++ b/experimental/126-linux-gates/g1-identity-visibility/build-input.test.mjs @@ -0,0 +1,124 @@ +// Synthetic module trees only. No product, native addon or holder is run. +import assert from 'node:assert/strict' +import { createHash } from 'node:crypto' +import fs from 'node:fs' +import { createRequire } from 'node:module' +import path from 'node:path' +import process from 'node:process' +import { test } from 'node:test' +import { pathToFileURL, URL } from 'node:url' +import { freshPrivateDir } from '../../126-guard-liveness/lib/common.mjs' +import { contentOf, MANIFEST_SCHEMA, verifyBuild } from '../../126-guard-liveness/f2-linux-nondumpable/build.mjs' +import { createPinnedBuildSnapshot } from './build-input.mjs' +import { PINNED_SOURCE_COMMIT } from './variants.mjs' + +const sha256 = (bytes) => createHash('sha256').update(bytes).digest('hex') + +function manifest(build, commit = PINNED_SOURCE_COMMIT) { + const content = contentOf(build) + fs.writeFileSync(path.join(build, 'manifest.json'), JSON.stringify({ + schema: MANIFEST_SCHEMA, + source: { commit, tree: 'b'.repeat(40), archiveSha256: sha256('archive') }, + patch: { applied: false }, lockfileSha256: sha256('{}'), dist: content.dist, native: content.native, + })) +} + +function fakeBuild() { + const build = freshPrivateDir('126-g1-build-input-test') + const dependency = path.join(build, 'src', 'node_modules', 'fixture-runtime') + fs.mkdirSync(path.join(build, 'src', 'dist'), { recursive: true }) + fs.mkdirSync(dependency, { recursive: true }) + fs.writeFileSync(path.join(build, 'src', 'package.json'), '{"type":"module"}') + fs.writeFileSync(path.join(build, 'src', 'package-lock.json'), '{}') + fs.writeFileSync(path.join(build, 'src', 'dist', 'entry.js'), "import runtime from 'fixture-runtime'; export const value = runtime.value\n") + fs.writeFileSync(path.join(dependency, 'package.json'), '{"main":"index.cjs"}') + fs.writeFileSync(path.join(dependency, 'index.cjs'), "module.exports = { value: 'original' }\n") + fs.writeFileSync(path.join(dependency, 'addon.node'), 'synthetic native bytes, never loaded') + fs.writeFileSync(path.join(build, 'source.tar'), 'archive') + manifest(build) + return build +} + +function dispose(directory) { + const writable = (file) => { + const stat = fs.lstatSync(file) + if (stat.isDirectory()) { + fs.chmodSync(file, 0o700) + for (const entry of fs.readdirSync(file)) writable(path.join(file, entry)) + } else if (stat.isFile()) fs.chmodSync(file, 0o600) + } + writable(directory) + fs.rmSync(directory, { recursive: true }) +} + +test('G1 runner, Node dependency, inspector and argv consumer keep copied bytes after coherent input mutation', async () => { + const build = fakeBuild() + const copies = [] + try { + const startup = verifyBuild(build).manifestSha256 + const runner = createPinnedBuildSnapshot(build, startup); copies.push(runner) + const argv = createPinnedBuildSnapshot(build, startup); copies.push(argv) + fs.writeFileSync(path.join(build, 'src', 'dist', 'entry.js'), "export const value = 'replacement'\n") + fs.writeFileSync(path.join(build, 'src', 'node_modules', 'fixture-runtime', 'index.cjs'), "module.exports = { value: 'replacement' }\n") + fs.writeFileSync(path.join(build, 'src', 'node_modules', 'fixture-runtime', 'addon.node'), 'replacement native bytes') + manifest(build) + assert.equal(verifyBuild(build).ok, true) + assert.notEqual(verifyBuild(build).manifestSha256, startup) + const inspector = createPinnedBuildSnapshot(runner.build, startup); copies.push(inspector) + const require = createRequire(path.join(runner.build, 'src', 'package.json')) + assert.equal(require('fixture-runtime').value, 'original') + for (const snapshot of [argv, inspector]) { + assert.equal(snapshot.verified.manifestSha256, startup) + assert.equal(snapshot.verified.source.commit, PINNED_SOURCE_COMMIT) + assert.equal((await import(pathToFileURL(path.join(snapshot.dist, 'entry.js')).href)).value, 'original') + } + } finally { + for (const snapshot of copies) dispose(snapshot.directory) + dispose(build) + } +}) + +test('G1 copied-build binding refuses a valid manifest from the wrong source pin', () => { + const root = freshPrivateDir('126-g1-build-refusal-test') + const build = fakeBuild() + const previous = process.env.RESEARCH_TMP_ROOT + try { + process.env.RESEARCH_TMP_ROOT = root + manifest(build, 'f'.repeat(40)) + const startup = verifyBuild(build) + assert.equal(startup.ok, true) + assert.throws(() => createPinnedBuildSnapshot(build, startup.manifestSha256), /G1 build source must be/) + assert.throws(() => createPinnedBuildSnapshot(build, '0'.repeat(64)), /startup_manifest_sha256_changed/) + } finally { + if (previous === undefined) delete process.env.RESEARCH_TMP_ROOT + else process.env.RESEARCH_TMP_ROOT = previous + dispose(root) + dispose(build) + } +}) + +test('G1 build consumers use pinned snapshots before imports and preserve manifest protocol fields', () => { + const child = fs.readFileSync(new URL('./inspect-child.mjs', import.meta.url), 'utf8') + const identity = child.indexOf('snapshot = createPinnedBuildSnapshot(build, expectedManifest)') + const refusal = child.indexOf("throw new StopStages('build_unverified')") + const load = child.indexOf("c.stage('module_load'") + assert.ok(identity >= 0 && identity < refusal && refusal < load) + assert.ok(child.includes('reachableModules(snapshot.dist, ENTRIES)')) + assert.ok(child.includes('path.join(snapshot.dist, name)')) + assert.ok(child.includes('expectedManifestSha256: expectedManifest, manifestMatchesExpected: verified.manifestSha256 === expectedManifest')) + assert.equal(child.includes("path.join(build, 'src', 'dist')"), false) + + const runner = fs.readFileSync(new URL('./run.mjs', import.meta.url), 'utf8') + const startupSnapshot = runner.indexOf('createPinnedBuildSnapshot(BUILD, EXPECTED_MANIFEST)') + assert.ok(startupSnapshot >= 0 && startupSnapshot < runner.indexOf("const out = freshPrivateDir('126-g1')")) + assert.ok(runner.includes("FIXTURE_REQUIRE_FROM: path.join(runtimeSnapshot.build, 'src', 'package.json')")) + assert.ok(runner.includes("'inspect-child.mjs'), runtimeSnapshot.build, EXPECTED_MANIFEST")) + assert.ok(runner.includes('RESEARCH_TMP_ROOT: process.env.RESEARCH_TMP_ROOT')) + assert.ok(runner.includes('recheck = verifyBuild(BUILD)')) + for (const file of ['build-input.mjs', 'build-binding.mjs', 'build-snapshot.mjs']) assert.ok(runner.includes(`'${file}'`)) + + const argv = fs.readFileSync(new URL('./argv-check.mjs', import.meta.url), 'utf8') + const argvSnapshot = argv.indexOf('createPinnedBuildSnapshot(build, startup.manifestSha256)') + assert.ok(argvSnapshot >= 0 && argvSnapshot < argv.indexOf('await import(')) + assert.ok(argv.includes('const dist = snapshot.dist')) +}) diff --git a/experimental/126-linux-gates/g1-identity-visibility/inspect-child.mjs b/experimental/126-linux-gates/g1-identity-visibility/inspect-child.mjs index 392dbdb..942eb4d 100644 --- a/experimental/126-linux-gates/g1-identity-visibility/inspect-child.mjs +++ b/experimental/126-linux-gates/g1-identity-visibility/inspect-child.mjs @@ -5,11 +5,13 @@ // Usage: node inspect-child.mjs BUILD MANIFEST_SHA256 PID FD LOCK_PATH CODEX_HOME THREAD_ID CONTROL_PATH // // BUILD is a directory made by ../../126-guard-liveness/f2-linux-nondumpable/build.mjs. -// It is re-hashed against its manifest before import, the manifest's own +// A private independent runtime copy is re-hashed before import; its manifest // SHA-256 must equal MANIFEST_SHA256 (the runner's value, taken before any // holder starts), and the static import graph of the imported modules must // not reach unlock.js. Hashing covers dist/ and native addons only, not -// dependency JavaScript or package metadata. +// dependency JavaScript or package metadata. Only the read-only copy is +// walked/imported. It isolates later input mutation, not malicious same-UID +// chmod/replacement of the snapshot; private snapshots are retained. // // Stages beyond F2: // - `lsof_control`: product `findLockOpeners` on CONTROL_PATH, a regular file @@ -24,15 +26,15 @@ // // Signals: none from this child to the holder, any lock owner or any unlock // path. Product diagnostics (`lsof`, `ps`) keep the product's own timeout, -// which signals only the timed-out diagnostic's own process group. No native -// file is created or removed. Errors are recorded, never converted. +// which signals only the timed-out diagnostic's own process group. No Codex +// native lock file is created or removed. Errors are recorded, never converted. import path from 'node:path' import process from 'node:process' import { pathToFileURL } from 'node:url' import { StopStages, childStages } from '../../126-guard-liveness/lib/isolated-observer.mjs' -import { reachableModules, verifyBuild } from '../../126-guard-liveness/f2-linux-nondumpable/build.mjs' +import { reachableModules } from '../../126-guard-liveness/f2-linux-nondumpable/build.mjs' import { parseChildFault } from './protocol.mjs' -import { assertPinnedBuildSource } from './variants.mjs' +import { createPinnedBuildSnapshot } from './build-input.mjs' const [build, expectedManifest, pidText, fdText, lockPath, codexHome, threadId, controlPath] = process.argv.slice(2) const pid = Number(pidText) @@ -42,7 +44,6 @@ if (!build || !/^[0-9a-f]{64}$/.test(expectedManifest ?? '') || !Number.isSafeIn throw new Error('usage: inspect-child.mjs BUILD MANIFEST_SHA256 PID FD LOCK_PATH CODEX_HOME THREAD_ID CONTROL_PATH') } const fault = parseChildFault(process.env.G1_CHILD_FAULT) -const dist = path.join(build, 'src', 'dist') const ENTRIES = ['linux-attribution.js', 'native-coordination.js', 'lock.js', 'process.js', 'inspection.js'] const plain = (value) => JSON.parse(JSON.stringify(value, (_key, v) => (typeof v === 'bigint' ? `${v}n` : v))) const stall = (name) => { @@ -57,21 +58,24 @@ const identityRecord = (p) => ({ try { const c = childStages({ lifetimeMs: 30000 }) + let snapshot const identity = await c.stage('dist_identity', () => { stall('dist_identity') - const verified = verifyBuild(build) - assertPinnedBuildSource(verified.source) + snapshot = createPinnedBuildSnapshot(build, expectedManifest) + const { verified } = snapshot return plain({ ok: verified.ok, faults: verified.faults, manifestSha256: verified.manifestSha256, expectedManifestSha256: expectedManifest, manifestMatchesExpected: verified.manifestSha256 === expectedManifest, - source: verified.source, patch: verified.patch, distFiles: verified.distFiles, nativeFiles: verified.nativeFiles }) + source: verified.source, patch: verified.patch, distFiles: verified.distFiles, nativeFiles: verified.nativeFiles, + snapshot: { directory: snapshot.directory, copiedContentSha256: snapshot.copiedContentSha256, + fileCount: snapshot.fileCount, omittedBinDirectories: snapshot.omittedBinDirectories } }) }) if (identity.result?.ok !== true || identity.result.manifestMatchesExpected !== true) throw new StopStages('build_unverified') let product const loaded = await c.stage('module_load', async () => { stall('module_load') - const modules = reachableModules(dist, ENTRIES) + const modules = reachableModules(snapshot.dist, ENTRIES) if (modules.includes('unlock.js')) throw new Error('unlock_reachable') - const load = (name) => import(pathToFileURL(path.join(dist, name)).href) + const load = (name) => import(pathToFileURL(path.join(snapshot.dist, name)).href) const [attribution, coordination, lock, proc, inspection] = await Promise.all(ENTRIES.map(load)) product = { ...attribution, ...coordination, ...lock, ...proc, ...inspection } return { modules, unlockReachable: false } diff --git a/experimental/126-linux-gates/g1-identity-visibility/run.mjs b/experimental/126-linux-gates/g1-identity-visibility/run.mjs index d9edba5..0a136af 100644 --- a/experimental/126-linux-gates/g1-identity-visibility/run.mjs +++ b/experimental/126-linux-gates/g1-identity-visibility/run.mjs @@ -48,6 +48,7 @@ import { evaluateReport, evaluateRun } from './expectations.mjs' import { createSupervisor, stopFaults, superviseOnce } from './lifecycle.mjs' import { parseChildFault, validateG1 } from './protocol.mjs' import { assertPinnedBuildSource, expectedArgv, parseVariantSelection, VARIANT_ORDER, VARIANTS } from './variants.mjs' +import { createPinnedBuildSnapshot } from './build-input.mjs' if (process.platform !== 'linux') throw new Error('G1 runs on Linux only') if (process.getuid() === 0 || process.getuid() !== process.geteuid()) throw new Error('G1 requires an unprivileged same-UID runner') @@ -72,6 +73,9 @@ const buildCheck = verifyBuild(BUILD) if (!buildCheck.ok) throw new Error(`G1_BUILD does not match its manifest: ${buildCheck.faults.join(',')}`) assertPinnedBuildSource(buildCheck.source) const EXPECTED_MANIFEST = buildCheck.manifestSha256 +// Node holders and inspectors receive copied runtime bytes. Keep BUILD only +// for the original input's conservative post-run drift check. +const runtimeSnapshot = createPinnedBuildSnapshot(BUILD, EXPECTED_MANIFEST) const sha256 = (data) => createHash('sha256').update(data).digest('hex') const fileSha = (file) => { try { return sha256(fs.readFileSync(file)) } catch (error) { return `unreadable:${error.code}` } } @@ -82,11 +86,13 @@ const bounded = (command, args, timeout) => { } // Research inputs, relative to the experimental/ directory, plus interpreters. const INPUTS = [ - ...['run.mjs', 'inspect-child.mjs', 'protocol.mjs', 'expectations.mjs', 'variants.mjs', 'lifecycle.mjs'] + ...['run.mjs', 'inspect-child.mjs', 'protocol.mjs', 'expectations.mjs', 'variants.mjs', 'lifecycle.mjs', 'build-input.mjs'] .map((f) => path.join(DIR, f)), NODE_FIXTURE, C_SOURCE, SCRIPT_FIXTURE, path.join(SHARED, 'lib', 'common.mjs'), path.join(SHARED, 'lib', 'isolated-observer.mjs'), path.join(SHARED, 'f2-linux-nondumpable', 'build.mjs'), + path.join(SHARED, 'f2-linux-nondumpable', 'build-binding.mjs'), + path.join(SHARED, 'f2-linux-nondumpable', 'build-snapshot.mjs'), path.resolve(ROOT, '..', '..', 'package.json'), ] function inputIdentity() { @@ -113,7 +119,9 @@ const report = { lsofVersion: (lsofVersion.stderr.match(/revision:\s*(\S+)/) ?? [])[1] ?? null, lsofVersionCall: { status: lsofVersion.status, signal: lsofVersion.signal, error: lsofVersion.error }, build: { manifestSha256: EXPECTED_MANIFEST, source: buildCheck.source, patch: buildCheck.patch, - coverage: 'dist/ and native addons re-hashed; dependency JavaScript and package metadata are not covered' }, + coverage: 'dist/ and native addons re-hashed; dependency JavaScript and package metadata are not covered', + snapshot: { directory: runtimeSnapshot.directory, copiedContentSha256: runtimeSnapshot.copiedContentSha256, + fileCount: runtimeSnapshot.fileCount, omittedBinDirectories: runtimeSnapshot.omittedBinDirectories } }, inputs: identityAtStart, childFault: CHILD_FAULT, selected: SELECTED, }, native: null, @@ -151,7 +159,7 @@ function holderCommand(variant, env) { if (variant === 'script') return [SCRIPT_FIXTURE, [], { env }] if (variant === 'argv0') return [PYTHON, [SCRIPT_FIXTURE], { env, argv0: 'codex' }] if (variant === 'native') return [nativeBin, [], { env }] - if (variant === 'node') return [process.execPath, [NODE_FIXTURE], { env: { ...env, FIXTURE_REQUIRE_FROM: path.join(BUILD, 'src', 'package.json') } }] + if (variant === 'node') return [process.execPath, [NODE_FIXTURE], { env: { ...env, FIXTURE_REQUIRE_FROM: path.join(runtimeSnapshot.build, 'src', 'package.json') } }] throw new Error(variant) } @@ -212,9 +220,11 @@ for (const variant of SELECTED) { if (JSON.stringify(run.kernelArgv) !== JSON.stringify(run.expectedArgv)) throw new Error('kernel argv differs from the variant definition') if (performance.now() - startedAt + NEEDED_MS > HOLDER_LIFETIME_S * 1000) throw new Error('holder lifetime insufficient for inspection') const inspected = await runIsolated(process.execPath, - [path.join(DIR, 'inspect-child.mjs'), BUILD, EXPECTED_MANIFEST, String(ready.pid), String(ready.fd), lockPath, home, THREAD_ID, controlPath], { + [path.join(DIR, 'inspect-child.mjs'), runtimeSnapshot.build, EXPECTED_MANIFEST, String(ready.pid), String(ready.fd), lockPath, home, THREAD_ID, controlPath], { startupBudgetMs: INSPECT_STARTUP_MS, budgetMs: INSPECT_STAGES_MS, - env: { PATH: '/usr/sbin:/usr/bin:/bin', ...(CHILD_FAULT ? { G1_CHILD_FAULT: CHILD_FAULT } : {}) }, + env: { PATH: '/usr/sbin:/usr/bin:/bin', + ...(process.env.RESEARCH_TMP_ROOT !== undefined ? { RESEARCH_TMP_ROOT: process.env.RESEARCH_TMP_ROOT } : {}), + ...(CHILD_FAULT ? { G1_CHILD_FAULT: CHILD_FAULT } : {}) }, }) const { lateExit, ...record } = inspected run.inspect = record From c7ce2e41fb702e48b21f8c8f304360d7752ff191 Mon Sep 17 00:00:00 2001 From: YEONGUK LEE Date: Sun, 18 Oct 2026 19:24:43 +0900 Subject: [PATCH 5/8] docs: record snapshot-based Linux research validation --- experimental/126-linux-gates/README.md | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/experimental/126-linux-gates/README.md b/experimental/126-linux-gates/README.md index 6953f6f..a7d6373 100644 --- a/experimental/126-linux-gates/README.md +++ b/experimental/126-linux-gates/README.md @@ -129,6 +129,17 @@ The historical US measurements below predate this snapshot wiring and the new shared-helper input hashes. They are not execution evidence for this integration revision. None of these changes closes G4 or #137's NO-GO gates. +Root validation at runner source +`25d1d8384ec65c54fdfaeee2702b49917e7dc4b6` passed all five repository checks +(266 passing tests, three Linux-only skips) and the combined focused suite +(59 passed, three Linux-only skips). The post-rebase tree was byte-identical +to the checked tree. The snapshot-based cross-platform argv check also exited +0: all four fixture variants and the renamed-Node negative control matched +their predictions against unpatched source +`b483cd587c62329243366e64aaaac477edd36adc`, manifest SHA-256 +`15ffaada53a40b8dc77dc81bed4f7f0433bc05bfc5730e103269b32b996138a0`. +This pure-function check is not a new Linux G1 run or native Codex evidence. + ### Stages and typed evidence `dist_identity`, `module_load`, `lsof_control`, `openers`, `identity_direct`, From b19b3a0ec797f97d3a2a7412ebbc246f96f1ebcf Mon Sep 17 00:00:00 2001 From: YEONGUK LEE Date: Sun, 18 Oct 2026 19:35:01 +0900 Subject: [PATCH 6/8] test: refuse patched builds in pinned Linux predictions --- experimental/126-linux-gates/README.md | 15 +++++++++-- .../g1-identity-visibility/argv-check.mjs | 4 +-- .../g1-identity-visibility/build-input.mjs | 4 +-- .../build-input.test.mjs | 25 +++++++++++++++++-- .../expectations.test.mjs | 13 +++++++++- .../g1-identity-visibility/run.mjs | 4 +-- .../g1-identity-visibility/variants.mjs | 12 +++++++++ 7 files changed, 66 insertions(+), 11 deletions(-) diff --git a/experimental/126-linux-gates/README.md b/experimental/126-linux-gates/README.md index a7d6373..a7cc76a 100644 --- a/experimental/126-linux-gates/README.md +++ b/experimental/126-linux-gates/README.md @@ -93,7 +93,12 @@ are recorded `not_selected`. The runner takes the build manifest SHA-256 once, before any holder starts. It passes that value to each inspector, and the inspector refuses to import product code unless its own re-hash of a private copied build matches it and -the copied source retains the exact pinned commit. The protocol then checks +the copied source retains the exact pinned commit with an explicitly +unapplied patch record (`patch.applied === false`). Patched builds, including +no-op patches, and missing/malformed patch records are refused before any +product import: the predictions describe only the unpatched baseline. This +restriction applies to the runner, inspector and argv checker; the generic +shared F2 builder still supports separate patch experiments. The protocol then checks the inspector's reported hash against the runner's value. The runner re-verifies the build after every run, and drift fails that run. The report records the SHA-256 of every research input file (runner, inspector, @@ -140,6 +145,12 @@ their predictions against unpatched source `15ffaada53a40b8dc77dc81bed4f7f0433bc05bfc5730e103269b32b996138a0`. This pure-function check is not a new Linux G1 run or native Codex evidence. +The subsequent unpatched-build gate correction passed the five repository +checks again (266 passing tests, three Linux-only skips), 61 focused tests +(three Linux-only skips), and all five pure argv cases against that same +unpatched manifest. A valid patched manifest from the correct commit is now +an explicit negative test; generic F2 patch support is unchanged. + ### Stages and typed evidence `dist_identity`, `module_load`, `lsof_control`, `openers`, `identity_direct`, @@ -397,7 +408,7 @@ node --test experimental/126-linux-gates/*/*.test.mjs \ experimental/126-guard-liveness/f2-linux-nondumpable/build.test.mjs \ experimental/126-guard-liveness/f2-linux-nondumpable/build-binding.test.mjs \ experimental/126-guard-liveness/f2-linux-nondumpable/build-snapshot.test.mjs \ - > "$R/tests.tap" 2>&1; echo "tests=$?" # expect 0, 62 tests, 0 skipped on Linux + > "$R/tests.tap" 2>&1; echo "tests=$?" # expect 0, 64 tests, 0 skipped on Linux RESEARCH_TMP_ROOT="$R" G1_BUILD="$R/build" node experimental/126-linux-gates/g1-identity-visibility/run.mjs > "$R/g1-nominal.json"; echo "g1=$?" # expect 0 (compiler) or 2 (no compiler) RESEARCH_TMP_ROOT="$R" G1_BUILD="$R/build" G1_VARIANTS=argv0 G1_CHILD_FAULT=stall:identity_direct \ node experimental/126-linux-gates/g1-identity-visibility/run.mjs > "$R/g1-stall.json"; echo "stall=$?" # expect 1, incompleteCount >= 1 diff --git a/experimental/126-linux-gates/g1-identity-visibility/argv-check.mjs b/experimental/126-linux-gates/g1-identity-visibility/argv-check.mjs index 91586e7..198e1d2 100644 --- a/experimental/126-linux-gates/g1-identity-visibility/argv-check.mjs +++ b/experimental/126-linux-gates/g1-identity-visibility/argv-check.mjs @@ -15,7 +15,7 @@ import path from 'node:path' import process from 'node:process' import { pathToFileURL } from 'node:url' import { reachableModules, verifyBuild } from '../../126-guard-liveness/f2-linux-nondumpable/build.mjs' -import { assertPinnedBuildSource, expectedArgv, PRODUCT_ARGV_ERROR, VARIANT_ORDER, VARIANTS } from './variants.mjs' +import { assertPinnedUnpatchedBuild, expectedArgv, PRODUCT_ARGV_ERROR, VARIANT_ORDER, VARIANTS } from './variants.mjs' import { createPinnedBuildSnapshot } from './build-input.mjs' if (!process.env.G1_BUILD) throw new Error('G1_BUILD is required') @@ -24,7 +24,7 @@ const startup = verifyBuild(build) if (!startup.ok) throw new Error(`build does not match its manifest: ${startup.faults.join(',')}`) const snapshot = createPinnedBuildSnapshot(build, startup.manifestSha256) const { verified } = snapshot -assertPinnedBuildSource(verified.source) +assertPinnedUnpatchedBuild(verified) const dist = snapshot.dist if (reachableModules(dist, ['process.js']).includes('unlock.js')) throw new Error('unlock.js reachable') const { reconcileArguments } = await import(pathToFileURL(path.join(dist, 'process.js')).href) diff --git a/experimental/126-linux-gates/g1-identity-visibility/build-input.mjs b/experimental/126-linux-gates/g1-identity-visibility/build-input.mjs index c16ee0d..625fb21 100644 --- a/experimental/126-linux-gates/g1-identity-visibility/build-input.mjs +++ b/experimental/126-linux-gates/g1-identity-visibility/build-input.mjs @@ -2,11 +2,11 @@ // by every G1 build-code consumer, rather than on a mutable input directory. import path from 'node:path' import { createBuildSnapshot } from '../../126-guard-liveness/f2-linux-nondumpable/build-snapshot.mjs' -import { assertPinnedBuildSource } from './variants.mjs' +import { assertPinnedUnpatchedBuild } from './variants.mjs' export function createPinnedBuildSnapshot(build, expectedManifestSha256) { const snapshot = createBuildSnapshot(build, expectedManifestSha256) if (!snapshot.verified.ok) throw new Error(`G1_snapshot_unverified:${path.basename(snapshot.directory)}:${snapshot.verified.faults.join(',')}`) - assertPinnedBuildSource(snapshot.verified.source) + assertPinnedUnpatchedBuild(snapshot.verified) return snapshot } diff --git a/experimental/126-linux-gates/g1-identity-visibility/build-input.test.mjs b/experimental/126-linux-gates/g1-identity-visibility/build-input.test.mjs index 87ddf7b..e493faf 100644 --- a/experimental/126-linux-gates/g1-identity-visibility/build-input.test.mjs +++ b/experimental/126-linux-gates/g1-identity-visibility/build-input.test.mjs @@ -14,12 +14,12 @@ import { PINNED_SOURCE_COMMIT } from './variants.mjs' const sha256 = (bytes) => createHash('sha256').update(bytes).digest('hex') -function manifest(build, commit = PINNED_SOURCE_COMMIT) { +function manifest(build, commit = PINNED_SOURCE_COMMIT, patch = { applied: false }) { const content = contentOf(build) fs.writeFileSync(path.join(build, 'manifest.json'), JSON.stringify({ schema: MANIFEST_SCHEMA, source: { commit, tree: 'b'.repeat(40), archiveSha256: sha256('archive') }, - patch: { applied: false }, lockfileSha256: sha256('{}'), dist: content.dist, native: content.native, + patch, lockfileSha256: sha256('{}'), dist: content.dist, native: content.native, })) } @@ -97,6 +97,27 @@ test('G1 copied-build binding refuses a valid manifest from the wrong source pin } }) +test('G1 consumer wrapper refuses a coherent correctly pinned patched build before returning an importable snapshot', () => { + const root = freshPrivateDir('126-g1-patched-build-test') + const build = fakeBuild() + const previous = process.env.RESEARCH_TMP_ROOT + try { + process.env.RESEARCH_TMP_ROOT = root + fs.writeFileSync(path.join(build, 'source.patch'), 'synthetic patch') + manifest(build, PINNED_SOURCE_COMMIT, { applied: true, sha256: sha256('synthetic patch') }) + const startup = verifyBuild(build) + assert.equal(startup.ok, true) + assert.equal(startup.source.commit, PINNED_SOURCE_COMMIT) + assert.equal(startup.patch.applied, true) + assert.throws(() => createPinnedBuildSnapshot(build, startup.manifestSha256), /explicitly record an unpatched build/) + } finally { + if (previous === undefined) delete process.env.RESEARCH_TMP_ROOT + else process.env.RESEARCH_TMP_ROOT = previous + dispose(root) + dispose(build) + } +}) + test('G1 build consumers use pinned snapshots before imports and preserve manifest protocol fields', () => { const child = fs.readFileSync(new URL('./inspect-child.mjs', import.meta.url), 'utf8') const identity = child.indexOf('snapshot = createPinnedBuildSnapshot(build, expectedManifest)') diff --git a/experimental/126-linux-gates/g1-identity-visibility/expectations.test.mjs b/experimental/126-linux-gates/g1-identity-visibility/expectations.test.mjs index 2a62bfc..cdd51ce 100644 --- a/experimental/126-linux-gates/g1-identity-visibility/expectations.test.mjs +++ b/experimental/126-linux-gates/g1-identity-visibility/expectations.test.mjs @@ -8,7 +8,7 @@ import { blockerCauses, checkCoverage, classifyLsofError, compareRun, evaluateReport, evaluateRun, hostClassOf, identityCauses, lsofCauseOf, } from './expectations.mjs' import { G1_STAGES, isProbe, parseChildFault, validateG1 } from './protocol.mjs' -import { assertPinnedBuildSource, parseVariantSelection, PINNED_SOURCE_COMMIT, predict, PRODUCT_ARGV_ERROR, VARIANT_ORDER } from './variants.mjs' +import { assertPinnedBuildSource, assertPinnedUnpatchedBuild, parseVariantSelection, PINNED_SOURCE_COMMIT, predict, PRODUCT_ARGV_ERROR, VARIANT_ORDER } from './variants.mjs' const MANIFEST = 'a'.repeat(64) test('build source identity must equal the pinned full commit', () => { @@ -17,6 +17,17 @@ test('build source identity must equal the pinned full commit', () => { assert.throws(() => assertPinnedBuildSource(source), /G1 build source must be/) } }) +test('G1 build gate requires verified pinned source and an explicit valid unpatched record', () => { + const verified = { ok: true, source: { commit: PINNED_SOURCE_COMMIT }, patch: { applied: false } } + assert.doesNotThrow(() => assertPinnedUnpatchedBuild(verified)) + for (const patch of [undefined, null, true, false, 'false', [], {}, { applied: true }, { applied: 'false' }, { applied: 0 }]) { + assert.throws(() => assertPinnedUnpatchedBuild({ ...verified, patch }), /explicitly record an unpatched build/) + } + assert.throws(() => assertPinnedUnpatchedBuild({ ...verified, source: { commit: 'f'.repeat(40) } }), /G1 build source must be/) + for (const invalid of [undefined, null, {}, { ...verified, ok: false }]) { + assert.throws(() => assertPinnedUnpatchedBuild(invalid), /G1 build must be verified/) + } +}) const holder = { pid: 42, fd: 5 } const ticks = { ready: '900', afterInspect: '900', holderExitedDuringInspection: false } const FP = 'f'.repeat(64) diff --git a/experimental/126-linux-gates/g1-identity-visibility/run.mjs b/experimental/126-linux-gates/g1-identity-visibility/run.mjs index 0a136af..320cf3d 100644 --- a/experimental/126-linux-gates/g1-identity-visibility/run.mjs +++ b/experimental/126-linux-gates/g1-identity-visibility/run.mjs @@ -47,7 +47,7 @@ import { verifyBuild } from '../../126-guard-liveness/f2-linux-nondumpable/build import { evaluateReport, evaluateRun } from './expectations.mjs' import { createSupervisor, stopFaults, superviseOnce } from './lifecycle.mjs' import { parseChildFault, validateG1 } from './protocol.mjs' -import { assertPinnedBuildSource, expectedArgv, parseVariantSelection, VARIANT_ORDER, VARIANTS } from './variants.mjs' +import { assertPinnedUnpatchedBuild, expectedArgv, parseVariantSelection, VARIANT_ORDER, VARIANTS } from './variants.mjs' import { createPinnedBuildSnapshot } from './build-input.mjs' if (process.platform !== 'linux') throw new Error('G1 runs on Linux only') @@ -71,7 +71,7 @@ const CHILD_FAULT = parseChildFault(process.env.G1_CHILD_FAULT) const SELECTED = parseVariantSelection(process.env.G1_VARIANTS) const buildCheck = verifyBuild(BUILD) if (!buildCheck.ok) throw new Error(`G1_BUILD does not match its manifest: ${buildCheck.faults.join(',')}`) -assertPinnedBuildSource(buildCheck.source) +assertPinnedUnpatchedBuild(buildCheck) const EXPECTED_MANIFEST = buildCheck.manifestSha256 // Node holders and inspectors receive copied runtime bytes. Keep BUILD only // for the original input's conservative post-run drift check. diff --git a/experimental/126-linux-gates/g1-identity-visibility/variants.mjs b/experimental/126-linux-gates/g1-identity-visibility/variants.mjs index 7c6b436..ade1110 100644 --- a/experimental/126-linux-gates/g1-identity-visibility/variants.mjs +++ b/experimental/126-linux-gates/g1-identity-visibility/variants.mjs @@ -42,6 +42,18 @@ export function assertPinnedBuildSource(source) { if (source?.commit !== PINNED_SOURCE_COMMIT) throw new Error(`G1 build source must be ${PINNED_SOURCE_COMMIT}`) } +// Predictions describe the unpatched pinned source. A matching source commit +// alone does not authorize a build whose manifest records an applied patch. +export function assertPinnedUnpatchedBuild(verified) { + if (verified?.ok !== true) throw new Error('G1 build must be verified') + assertPinnedBuildSource(verified.source) + const patch = verified.patch + if (patch === null || typeof patch !== 'object' || Array.isArray(patch) || + !Object.hasOwn(patch, 'applied') || patch.applied !== false) { + throw new Error('G1 build must explicitly record an unpatched build (patch.applied === false)') + } +} + export const VARIANTS = Object.freeze({ // The 2026-10-16 fixture: `#!/usr/bin/env python3` script named codex. // env execs python3 as argv[0], so the kernel argv is [python3, ]. From 080f0d938b638e82266e7a0196ac6ab03e028d76 Mon Sep 17 00:00:00 2001 From: YEONGUK LEE Date: Sun, 18 Oct 2026 19:43:11 +0900 Subject: [PATCH 7/8] ci: rerun Linux research gates for dependency changes --- .github/workflows/linux-gates-research.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/linux-gates-research.yml b/.github/workflows/linux-gates-research.yml index 8b8f731..be80f8a 100644 --- a/.github/workflows/linux-gates-research.yml +++ b/.github/workflows/linux-gates-research.yml @@ -4,6 +4,8 @@ on: pull_request: paths: - '.github/workflows/linux-gates-research.yml' + - 'package.json' + - 'package-lock.json' - 'experimental/126-linux-gates/**' - 'experimental/126-guard-liveness/**' workflow_dispatch: From ea724d3ef8e6ba45be914293f70be5bbe7d460fd Mon Sep 17 00:00:00 2001 From: YEONGUK LEE Date: Sun, 18 Oct 2026 19:57:27 +0900 Subject: [PATCH 8/8] test: bind Linux research fingerprints and success evidence --- experimental/126-linux-gates/README.md | 25 +++++++- .../expectations.test.mjs | 60 ++++++++++++++++++ .../g1-identity-visibility/input-identity.mjs | 27 ++++++++ .../input-identity.test.mjs | 62 +++++++++++++++++++ .../g1-identity-visibility/protocol.mjs | 29 +++++++-- .../g1-identity-visibility/run.mjs | 8 ++- 6 files changed, 200 insertions(+), 11 deletions(-) create mode 100644 experimental/126-linux-gates/g1-identity-visibility/input-identity.mjs create mode 100644 experimental/126-linux-gates/g1-identity-visibility/input-identity.test.mjs diff --git a/experimental/126-linux-gates/README.md b/experimental/126-linux-gates/README.md index a7cc76a..29137b6 100644 --- a/experimental/126-linux-gates/README.md +++ b/experimental/126-linux-gates/README.md @@ -104,7 +104,14 @@ re-verifies the build after every run, and drift fails that run. The report records the SHA-256 of every research input file (runner, inspector, protocol, expectations, variants, lifecycle, fixtures, the shared helpers it imports) and of the node and python interpreter binaries, at start and end, -with a combined input hash. **Coverage limits:** build hashing covers `dist/` +with a combined input hash. Schema `codex-unlock-g1-input-identity/2` hashes +canonical JSON containing both file and interpreter hash records, using +separate domains. Raw `inputs.schema` and public `inputsSchema` identify this +format. Missing/malformed hash values and unreadable/error markers cannot +produce a trusted fingerprint. Historical unversioned combined hashes covered +research files only; their separate interpreter records must not be mistaken +for inclusion in that older digest. No historical hash is reinterpreted as v2. +**Coverage limits:** build hashing covers `dist/` and native addons only. It does not cover dependency JavaScript, package metadata, shared libraries or the Python standard library, and it does not prove upstream identity. @@ -151,8 +158,20 @@ checks again (266 passing tests, three Linux-only skips), 61 focused tests unpatched manifest. A valid patched manifest from the correct commit is now an explicit negative test; generic F2 patch support is unchanged. +The versioned interpreter-inclusive digest and inspection self-consistency +corrections passed all five repository checks again (266 passing tests, +three Linux-only skips) and 68 focused tests (three Linux-only skips). These +checks do not add a Linux G1 or native Codex measurement for this revision. + ### Stages and typed evidence +Claimed successful inspection payloads must be self-consistent with the fields +actually exposed by the pinned product: a live Codex owner, one opener and +owner lock file, found transcript, held guarded probe and matching participant +instance. Contradictory success is a collection fault. This is not a duplicate +implementation of the complete product policy or a native safety proof; +well-typed refusals, including mount-warning outcomes, remain observations. + `dist_identity`, `module_load`, `lsof_control`, `openers`, `identity_direct`, `prepare`, `guarded_verify`, `held_probe`, `inspect_thread`: once each, in order, each typed. `LockProbe` values are checked against #137 `types.ts`. @@ -408,12 +427,12 @@ node --test experimental/126-linux-gates/*/*.test.mjs \ experimental/126-guard-liveness/f2-linux-nondumpable/build.test.mjs \ experimental/126-guard-liveness/f2-linux-nondumpable/build-binding.test.mjs \ experimental/126-guard-liveness/f2-linux-nondumpable/build-snapshot.test.mjs \ - > "$R/tests.tap" 2>&1; echo "tests=$?" # expect 0, 64 tests, 0 skipped on Linux + > "$R/tests.tap" 2>&1; echo "tests=$?" # expect 0, 71 tests, 0 skipped on Linux RESEARCH_TMP_ROOT="$R" G1_BUILD="$R/build" node experimental/126-linux-gates/g1-identity-visibility/run.mjs > "$R/g1-nominal.json"; echo "g1=$?" # expect 0 (compiler) or 2 (no compiler) RESEARCH_TMP_ROOT="$R" G1_BUILD="$R/build" G1_VARIANTS=argv0 G1_CHILD_FAULT=stall:identity_direct \ node experimental/126-linux-gates/g1-identity-visibility/run.mjs > "$R/g1-stall.json"; echo "stall=$?" # expect 1, incompleteCount >= 1 sha256sum "$R"/*.json "$R/tests.tap" "$R"/codex-126-g1-*/g1-results.json -jq '{exitCode, collected, inputsSha256, manifestSha256, hostClass, coverageValid, predictionsMatched, complete, notExecuted, controlPassed, refusalCounted, rows}' "$R/g1-nominal.json" +jq '{exitCode, collected, inputsSchema, inputsSha256, manifestSha256, hostClass, coverageValid, predictionsMatched, complete, notExecuted, controlPassed, refusalCounted, rows}' "$R/g1-nominal.json" nominal_dir="$(jq -r '.resultDirectory' "$R/g1-nominal.json")" jq '[.expectations.comparisons[] | select(.matched | not) | {variant, dumpable, reason, rows: [.rows[] | select(.matched | not)]}]' "$R/$nominal_dir/g1-results.json" ``` diff --git a/experimental/126-linux-gates/g1-identity-visibility/expectations.test.mjs b/experimental/126-linux-gates/g1-identity-visibility/expectations.test.mjs index cdd51ce..960c767 100644 --- a/experimental/126-linux-gates/g1-identity-visibility/expectations.test.mjs +++ b/experimental/126-linux-gates/g1-identity-visibility/expectations.test.mjs @@ -92,6 +92,66 @@ const allRuns = (host, skip = []) => VARIANT_ORDER.filter((v) => !skip.includes( .flatMap((v) => (v === 'node' ? [1] : [1, 0]).map((d) => run(v, d, { host }))) const report = (runs, options = {}) => evaluateReport(runs, { expectedManifestSha256: MANIFEST, ...options }) +test('eligible inspection must agree with its exposed policy evidence', () => { + const mutations = [ + (r) => { r.classification = 'unknown' }, + (r) => { r.openers = 0 }, + (r) => { r.ownerLockFiles = [] }, + (r) => { r.transcript = 'missing' }, + (r) => { r.owner.isCodex = false }, + (r) => { r.owner.identityComplete = false }, + (r) => { r.ownerLockFiles = null }, + (r) => { r.ownerLockFiles = ['/first.lock', '/second.lock'] }, + (r) => { r.owner = null }, + (r) => { r.blockers = ['classification_unknown'] }, + (r) => { r.probe = { status: 'free', method: M } }, + (r) => { delete r.probe.participation }, + (r) => { r.probe = { ...r.probe, participation: { ...PART, pid: 99 } } }, + ] + for (const mutate of mutations) { + const list = stages({}) + mutate(list[8].result) + const protocol = validateG1(observe(list), context) + assert.ok(protocol.faults.includes('stage_result_malformed:inspect_thread')) + const measured = evaluate('native', 1, list) + assert.equal(measured.collected, false) + const runs = allRuns('clean') + runs[runs.findIndex((r) => r.variant === 'native' && r.dumpable === 1)] = measured + assert.equal(report(runs).controlPassed.native, false) + assert.equal(report(runs).refusalCounted.native, false) + } +}) + +test('inspection fields are typed even when the product refused', () => { + const mutations = [ + (r) => { r.classification = 'not-a-classification' }, + (r) => { r.openers = -1 }, + (r) => { r.openers = '1' }, + (r) => { delete r.openers }, + (r) => { delete r.transcript }, + (r) => { r.transcript = 'invalid' }, + (r) => { delete r.owner.isCodex }, + (r) => { delete r.owner.identityComplete }, + (r) => { r.owner.identityComplete = 'false' }, + ] + for (const mutate of mutations) { + const list = stages({ host: 'mount_warning', dumpable: 1 }) + mutate(list[8].result) + assert.ok(validateG1(observe(list), context).faults.includes('stage_result_malformed:inspect_thread')) + } + // Typed refusal evidence remains collectible: missing transcript or + // incomplete owner identity is a product outcome, not a broken protocol. + for (const host of ['clean', 'mount_warning']) { + for (const dumpable of [1, 0]) { + const list = stages({ host, dumpable, argv: false }) + assert.equal(validateG1(observe(list), context).ok, true) + list[8].result.transcript = 'missing' + list[8].result.blockers.push('transcript_missing') + assert.equal(validateG1(observe(list), context).ok, true) + } + } +}) + test('protocol: exact order, typed probes, manifest and PID binding, prepare/skip consistency', () => { assert.equal(G1_STAGES.length, 9) assert.equal(validateG1(observe(stages({})), context).ok, true) diff --git a/experimental/126-linux-gates/g1-identity-visibility/input-identity.mjs b/experimental/126-linux-gates/g1-identity-visibility/input-identity.mjs new file mode 100644 index 0000000..b3db0a7 --- /dev/null +++ b/experimental/126-linux-gates/g1-identity-visibility/input-identity.mjs @@ -0,0 +1,27 @@ +import { createHash } from 'node:crypto' + +// Version 2 replaces the unversioned files-only digest. JSON domain names, +// record boundaries and fixed field names keep files/interpreters distinct. +export const INPUT_IDENTITY_SCHEMA = 'codex-unlock-g1-input-identity/2' + +export function combinedInputDigest(files, interpreters) { + const compare = (a, b) => a < b ? -1 : a > b ? 1 : 0 + const canonical = (records, name) => { + if (!Array.isArray(records)) throw new Error(`input_identity_records_invalid:${name}`) + return records.map((record) => { + if (record === null || typeof record !== 'object' || Array.isArray(record) || + typeof record[name] !== 'string' || record[name].length === 0 || + typeof record.sha256 !== 'string' || !/^[a-f0-9]{64}$/.test(record.sha256)) { + // Error markers are failed collection, never hashes of covered bytes. + throw new Error(`input_identity_record_invalid:${name}`) + } + return { [name]: record[name], sha256: record.sha256 } + }).sort((a, b) => compare(a[name], b[name]) || compare(a.sha256, b.sha256)) + } + const serialized = JSON.stringify({ + schema: INPUT_IDENTITY_SCHEMA, + files: canonical(files, 'path'), + interpreters: canonical(interpreters, 'basename'), + }) + return createHash('sha256').update(serialized, 'utf8').digest('hex') +} diff --git a/experimental/126-linux-gates/g1-identity-visibility/input-identity.test.mjs b/experimental/126-linux-gates/g1-identity-visibility/input-identity.test.mjs new file mode 100644 index 0000000..37f70bf --- /dev/null +++ b/experimental/126-linux-gates/g1-identity-visibility/input-identity.test.mjs @@ -0,0 +1,62 @@ +import assert from 'node:assert/strict' +import { readFileSync } from 'node:fs' +import { test } from 'node:test' +import { URL } from 'node:url' +import { combinedInputDigest, INPUT_IDENTITY_SCHEMA } from './input-identity.mjs' + +const a = 'a'.repeat(64) +const b = 'b'.repeat(64) +const c = 'c'.repeat(64) +const files = [{ path: 'g1/run.mjs', sha256: a }, { path: 'g1/variants.mjs', sha256: b }] +const interpreters = [{ basename: 'node', sha256: a }, { basename: 'python3', sha256: b }] + +test('combined input digest changes when either Node or Python bytes change with identical files', () => { + const original = combinedInputDigest(files, interpreters) + assert.equal(combinedInputDigest(files, interpreters), original) + for (const basename of ['node', 'python3']) { + const changed = interpreters.map((entry) => entry.basename === basename ? { ...entry, sha256: c } : entry) + assert.notEqual(combinedInputDigest(files, changed), original, basename) + } +}) + +test('combined input serialization canonically orders both record domains without mutating them', () => { + const reversedFiles = [...files].reverse() + const reversedInterpreters = [...interpreters].reverse() + const before = JSON.stringify([reversedFiles, reversedInterpreters]) + assert.equal(combinedInputDigest(reversedFiles, reversedInterpreters), combinedInputDigest(files, interpreters)) + assert.equal(JSON.stringify([reversedFiles, reversedInterpreters]), before) + assert.notEqual(combinedInputDigest([...files, files[0]], interpreters), combinedInputDigest(files, interpreters)) +}) + +test('combined input serialization separates domains and preserves tab/newline record boundaries', () => { + assert.notEqual(combinedInputDigest([{ path: 'node', sha256: a }], []), + combinedInputDigest([], [{ basename: 'node', sha256: a }])) + const split = [{ path: 'first', sha256: a }, { path: 'second', sha256: b }] + const joined = [{ path: `first\t${a}\nsecond`, sha256: b }] + const legacy = (entries) => entries.map((entry) => `${entry.path}\t${entry.sha256}\n`).join('') + assert.equal(legacy(split), legacy(joined)) + assert.notEqual(combinedInputDigest(split, []), combinedInputDigest(joined, [])) +}) + +test('combined input digest refuses unreadable/error markers and malformed records without requiring an optional interpreter', () => { + for (const sha256 of [undefined, null, '', 'unreadable:ENOENT', 'error:EACCES', 'g'.repeat(64), 'A'.repeat(64), 123]) { + assert.throws(() => combinedInputDigest([{ path: 'run.mjs', sha256 }], interpreters), /input_identity_record_invalid:path/) + assert.throws(() => combinedInputDigest(files, [{ basename: 'node', sha256 }]), /input_identity_record_invalid:basename/) + } + for (const invalid of [null, [], {}, { path: '', sha256: a }, { path: 123, sha256: a }]) { + assert.throws(() => combinedInputDigest([invalid], interpreters), /input_identity_record_invalid:path/) + } + assert.throws(() => combinedInputDigest(null, interpreters), /input_identity_records_invalid:path/) + assert.match(combinedInputDigest(files, [{ basename: 'node', sha256: a }]), /^[a-f0-9]{64}$/) + assert.match(combinedInputDigest(files, []), /^[a-f0-9]{64}$/) +}) + +test('G1 records the digest schema, fingerprints its helper and retains separate interpreter drift checking', () => { + assert.equal(INPUT_IDENTITY_SCHEMA, 'codex-unlock-g1-input-identity/2') + const source = readFileSync(new URL('./run.mjs', import.meta.url), 'utf8') + assert.ok(source.includes("'input-identity.mjs'")) + assert.ok(source.includes('schema: INPUT_IDENTITY_SCHEMA')) + assert.ok(source.includes('combinedSha256: combinedInputDigest(files, interpreters)')) + assert.ok(source.includes('inputsSchema: identityAtStart.schema')) + assert.ok(source.includes('JSON.stringify(identityAtEnd.interpreters) !== JSON.stringify(identityAtStart.interpreters)')) +}) diff --git a/experimental/126-linux-gates/g1-identity-visibility/protocol.mjs b/experimental/126-linux-gates/g1-identity-visibility/protocol.mjs index a77898e..6f6202d 100644 --- a/experimental/126-linux-gates/g1-identity-visibility/protocol.mjs +++ b/experimental/126-linux-gates/g1-identity-visibility/protocol.mjs @@ -11,6 +11,8 @@ export const G1_STAGES = ['dist_identity', 'module_load', 'lsof_control', 'opene const ERROR_ALLOWED = new Set(['prepare']) const PROBE_STATUS = new Set(['held', 'free', 'unknown']) const GUARD_STATUS = new Set(['acquired', 'busy', 'absent', 'unsafe', 'changed']) +const CLASSIFICATIONS = new Set(['absent', 'stale_residue', 'live_owner', 'unknown']) +const TRANSCRIPT_STATUS = new Set(['found', 'missing', 'ambiguous', 'unreadable']) const isObject = (v) => v !== null && typeof v === 'object' && !Array.isArray(v) export const isTicks = (v) => typeof v === 'string' && /^\d{1,20}$/.test(v) @@ -36,6 +38,27 @@ export function isProbe(p) { return true } +// Pinned #137 exposes only this subset of InspectionResult. Validate its +// types for every outcome, and reject a claimed eligibility that contradicts +// those same fields. This does not recreate unexported policy inputs or prove +// native Codex safety. Refusal outcomes may carry incomplete owner evidence. +function isInspection(r) { + if (!isObject(r) || typeof r.safeToUnlock !== 'boolean' || !isStrings(r.blockers) || + !isStrings(r.warnings) || !isProbe(r.probe) || !CLASSIFICATIONS.has(r.classification) || + !Number.isSafeInteger(r.openers) || r.openers < 0 || !TRANSCRIPT_STATUS.has(r.transcript) || + !(r.ownerLockFiles === null || isStrings(r.ownerLockFiles))) return false + if (r.owner !== null && !(isObject(r.owner) && isPid(r.owner.pid) && isStrings(r.owner.errors) && + typeof r.owner.isCodex === 'boolean' && typeof r.owner.identityComplete === 'boolean' && + (r.owner.startTicks === null || isTicks(r.owner.startTicks)))) return false + if (!r.safeToUnlock) return true + return r.blockers.length === 0 && r.classification === 'live_owner' && r.openers === 1 && + r.owner?.isCodex === true && r.owner.identityComplete === true && + Array.isArray(r.ownerLockFiles) && r.ownerLockFiles.length === 1 && r.ownerLockFiles[0].length > 0 && + r.transcript === 'found' && r.probe.status === 'held' && + r.probe.participation?.pid === r.owner.pid && + r.probe.participation.startTicks === r.owner.startTicks +} + const RESULT_SHAPES = { dist_identity: (r) => isObject(r) && r.ok === true && isHash(r.manifestSha256) && r.manifestMatchesExpected === true && r.expectedManifestSha256 === r.manifestSha256 && Array.isArray(r.faults) && r.faults.length === 0, @@ -46,11 +69,7 @@ const RESULT_SHAPES = { prepare: (r) => isObject(r) && isPid(r.pid) && isTicks(r.startTicks) && isFdNames(r.candidates) && isFdNames(r.descriptors), guarded_verify: (r) => isObject(r) && (r.skipped === 'prepare_failed' || isProbe(r)), held_probe: (r) => isProbe(r) && r.participation === undefined, - inspect_thread: (r) => isObject(r) && typeof r.safeToUnlock === 'boolean' && isStrings(r.blockers) && - (r.ownerLockFiles === null || isStrings(r.ownerLockFiles)) && - isStrings(r.warnings) && isProbe(r.probe) && typeof r.classification === 'string' && Number.isSafeInteger(r.openers) && - (r.owner === null || (isObject(r.owner) && isPid(r.owner.pid) && isStrings(r.owner.errors) && - (r.owner.startTicks === null || isTicks(r.owner.startTicks)))), + inspect_thread: isInspection, } const ERROR_SHAPE = (e) => isObject(e) && (e.code === null || typeof e.code === 'string') && typeof e.message === 'string' diff --git a/experimental/126-linux-gates/g1-identity-visibility/run.mjs b/experimental/126-linux-gates/g1-identity-visibility/run.mjs index 320cf3d..d2d92da 100644 --- a/experimental/126-linux-gates/g1-identity-visibility/run.mjs +++ b/experimental/126-linux-gates/g1-identity-visibility/run.mjs @@ -49,6 +49,7 @@ import { createSupervisor, stopFaults, superviseOnce } from './lifecycle.mjs' import { parseChildFault, validateG1 } from './protocol.mjs' import { assertPinnedUnpatchedBuild, expectedArgv, parseVariantSelection, VARIANT_ORDER, VARIANTS } from './variants.mjs' import { createPinnedBuildSnapshot } from './build-input.mjs' +import { combinedInputDigest, INPUT_IDENTITY_SCHEMA } from './input-identity.mjs' if (process.platform !== 'linux') throw new Error('G1 runs on Linux only') if (process.getuid() === 0 || process.getuid() !== process.geteuid()) throw new Error('G1 requires an unprivileged same-UID runner') @@ -86,7 +87,7 @@ const bounded = (command, args, timeout) => { } // Research inputs, relative to the experimental/ directory, plus interpreters. const INPUTS = [ - ...['run.mjs', 'inspect-child.mjs', 'protocol.mjs', 'expectations.mjs', 'variants.mjs', 'lifecycle.mjs', 'build-input.mjs'] + ...['run.mjs', 'inspect-child.mjs', 'protocol.mjs', 'expectations.mjs', 'variants.mjs', 'lifecycle.mjs', 'build-input.mjs', 'input-identity.mjs'] .map((f) => path.join(DIR, f)), NODE_FIXTURE, C_SOURCE, SCRIPT_FIXTURE, path.join(SHARED, 'lib', 'common.mjs'), path.join(SHARED, 'lib', 'isolated-observer.mjs'), @@ -100,7 +101,7 @@ function inputIdentity() { const files = INPUTS.map((f) => ({ path: path.relative(base, f), sha256: fileSha(f) })).sort((a, b) => (a.path < b.path ? -1 : 1)) const interpreters = [process.execPath, ...(fs.existsSync(PYTHON) ? [fs.realpathSync(PYTHON)] : [])] .map((f) => ({ basename: path.basename(f), sha256: fileSha(f) })) - return { files, interpreters, combinedSha256: sha256(files.map((f) => `${f.path}\t${f.sha256}\n`).join('')), + return { schema: INPUT_IDENTITY_SCHEMA, files, interpreters, combinedSha256: combinedInputDigest(files, interpreters), limits: 'covers these research files and interpreter binaries only; not shared libraries, Python stdlib or dependency JavaScript' } } @@ -302,7 +303,8 @@ writeNew(resultPath, body) // Sanitized summary: classes, statuses and fault codes; no raw errors, paths or PIDs. const publicCauses = new Set(['argv_identity', 'instance_unverified', 'owner_hidden', 'lsof_mount_warning', 'lsof_infrastructure_error', 'held_observation_missing', 'owner_lockset_unavailable']) -log(JSON.stringify({ resultDirectory: path.basename(out), resultSha256: sha256(body), inputsSha256: identityAtStart.combinedSha256, manifestSha256: EXPECTED_MANIFEST, +log(JSON.stringify({ resultDirectory: path.basename(out), resultSha256: sha256(body), inputsSchema: identityAtStart.schema, + inputsSha256: identityAtStart.combinedSha256, manifestSha256: EXPECTED_MANIFEST, exitCode, collected, failureCount: failures.length, incompleteCount: incomplete.length, hostClass: report.expectations.hostClass, coverageValid: report.expectations.coverageValid, predictionsMatched: report.expectations.predictionsMatched, complete: report.expectations.complete, notExecuted,