From c728e82f908e4a1e309d1b91821ff4f7a4e67c8a Mon Sep 17 00:00:00 2001 From: YEONGUK LEE Date: Sun, 18 Oct 2026 15:58:28 +0900 Subject: [PATCH 1/3] test: add fail-closed Windows observer research gates --- .github/workflows/windows-native-observer.yml | 49 + .../windows-native-observer/README.md | 754 +++++++++++ .../windows-native-observer/WinObserver.cs | 722 +++++++++++ .../windows-native-observer/check.ps1 | 518 ++++++++ .../windows-native-observer/evaluate.mjs | 141 ++ .../windows-native-observer/expectations.mjs | 433 +++++++ experimental/windows-native-observer/lib.mjs | 1096 ++++++++++++++++ .../windows-native-observer/observer.ps1 | 1141 +++++++++++++++++ .../test/cli-errors.test.mjs | 50 + .../test/evaluate.test.mjs | 1138 ++++++++++++++++ 10 files changed, 6042 insertions(+) create mode 100644 .github/workflows/windows-native-observer.yml create mode 100644 experimental/windows-native-observer/README.md create mode 100644 experimental/windows-native-observer/WinObserver.cs create mode 100644 experimental/windows-native-observer/check.ps1 create mode 100644 experimental/windows-native-observer/evaluate.mjs create mode 100644 experimental/windows-native-observer/expectations.mjs create mode 100644 experimental/windows-native-observer/lib.mjs create mode 100644 experimental/windows-native-observer/observer.ps1 create mode 100644 experimental/windows-native-observer/test/cli-errors.test.mjs create mode 100644 experimental/windows-native-observer/test/evaluate.test.mjs diff --git a/.github/workflows/windows-native-observer.yml b/.github/workflows/windows-native-observer.yml new file mode 100644 index 0000000..784f2ec --- /dev/null +++ b/.github/workflows/windows-native-observer.yml @@ -0,0 +1,49 @@ +name: Windows native observer static gate + +on: + pull_request: + branches: [main] + paths: + - experimental/windows-native-observer/** + - .github/workflows/windows-native-observer.yml + push: + branches: [main] + paths: + - experimental/windows-native-observer/** + - .github/workflows/windows-native-observer.yml + +permissions: + contents: read + +jobs: + static: + name: Windows PowerShell 5.1 parse, pure helpers and compile (no native calls) + runs-on: windows-2022 + timeout-minutes: 10 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - name: Static gate without running the observer + shell: powershell + run: | + $ErrorActionPreference = 'Stop' + if ($PSVersionTable.PSVersion.Major -ne 5) { throw 'This gate requires Windows PowerShell 5.1' } + $dir = Join-Path $env:GITHUB_WORKSPACE 'experimental/windows-native-observer' + & powershell -NoProfile -NonInteractive -File (Join-Path $dir 'check.ps1') + if ($LASTEXITCODE -ne 0) { throw "check.ps1 exited $LASTEXITCODE" } + Write-Output 'Static gate passed; no observer mode, Codex binary or native observation was run.' + + evaluator: + name: Evaluator unit tests (synthetic records) + 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: 22.13.0 + - name: Run evaluator tests + run: node --test experimental/windows-native-observer/test/*.test.mjs diff --git a/experimental/windows-native-observer/README.md b/experimental/windows-native-observer/README.md new file mode 100644 index 0000000..cf44b31 --- /dev/null +++ b/experimental/windows-native-observer/README.md @@ -0,0 +1,754 @@ +# Windows native observer (research, W6 #151 and W7 #152) + +**Statically validated; native modes remain refused.** This directory +holds a reviewable observer harness and offline evaluator for two evidence +gates from +[docs/windows-rust-evaluation.md](../../docs/windows-rust-evaluation.md): +W6 (Medium versus High integrity) and W7 (real Codex interoperability). The +PowerShell scripts were parsed, their AST gates and pure helpers checked, and +the C# compiled on a designated Windows host. No observer mode, native method +or real Codex binary was run. JavaScript tests use synthetic evidence only. + +`observer.ps1 -Mode preflight` and `-Mode observe` **refuse to run** (exit 3). +The ancestor/junction and creation boundary is not closed: every check the +harness can make compares identities at two points in time instead of pinning +a path, so a same-user actor could still swap an ancestor between the check and +the use. No flag, parameter or environment variable turns that refusal off. +Closing the boundary, or accepting the residual, is a separate decision +(remaining decision 10). The native code stays in the tree to be reviewed and +statically gated, not to be run. + +This is not part of the npm package, the production CLI or JSON v1. Windows +remains unsupported. Every record carries `safeToUnlock: false`, and no +result from this harness authorizes unlocking, signaling, terminating or +deleting anything. + +## Dependency on later gates + +- **W3 (process classification)** depends on W7 evidence. The evaluator + reports `dependents.W3.nativeClassification = "blocked_on_W7"` unless the W7 + gate is `pass` at that integrity level. Even after a pass the state is only + `evidence_available_decision_pending`; classifying a real standalone, app-server, + daemon or Remote Control owner is still a separate W3 decision. +- **W5 (cooperative release)** depends on W7 evidence and on W4. The evaluator + reports the same state for `dependents.W5.releaseDecision` and never + authorizes release. +- **W6** covers only the levels that were actually run. A High result never + stands in for Medium, or the reverse. + +## Files + +| File | Runs on | Role | +| --- | --- | --- | +| `observer.ps1` | Windows PowerShell 5.1 (designated host) | Orchestrator with three modes: `preflight` and `observe` (both hard-refused) and `fixture-meta` | +| `WinObserver.cs` | Compiled by `Add-Type` inside `observer.ps1` | Read-only native calls: token integrity, file identity, no-follow ancestry, share-mode probes, guarded lock-range probes, Restart Manager, process identity | +| `check.ps1` | Windows PowerShell 5.1 | Static gate. Parses both scripts, checks the syntax tree (automatic-variable assignment, forbidden commands, gate and refusal ordering, harness list), runs the pure helper functions taken from the observer's tree, validates its own paths and `%TEMP%`, then compiles the C# without calling it | +| `expectations.mjs` | Node.js 22.13+ or 24 (any OS) | The expected-result table, typed per-step schemas with internal-consistency invariants, format validators and the path-spelling rule (single source of truth) | +| `lib.mjs` | Node.js | Pure evaluation: JSONL parsing, `collected`/`measured` split, manifest reconstruction, cross-stage binding, provenance state, verdicts, W6 pairing | +| `evaluate.mjs` | Node.js | Offline CLI over JSONL files copied off the host | +| `test/*.test.mjs` | Node.js | Host-independent unit tests, CLI failures and counterexamples with synthetic records | +| [Static CI workflow](../../.github/workflows/windows-native-observer.yml) | Windows / Linux CI | Windows static gate and synthetic evaluator tests; no observer mode | + +Run the offline tests from the repository root: + +```sh +node --test experimental/windows-native-observer/test/*.test.mjs +``` + +On macOS, set `RESEARCH_TMP_ROOT` to the approved writable temporary root +first. The CLI failure tests create a fresh private `codex-*` directory under +that root and remove only that directory. Other platforms default to their +system temporary root. + +## Safety boundary + +- **The native modes are refused.** `preflight` and `observe` exit 3 before + anything is compiled, created or observed. `check.ps1` fails if that refusal + is removed, moved after `Add-Type`, or made conditional on a flag. +- **No user Codex home.** The user's `%USERPROFILE%\.codex` and any + pre-set `CODEX_HOME` are compared with every path this harness touches + **lexically only**. They are never opened, listed or statted. The disposable + home is a new `%TEMP%\codex-win-obs-<12 hex>\home`. The parent and the home + are each created with their own protected DACL holding a single full-control + ACE for the current user SID (a directory created without a security + descriptor would only inherit, and an inherited DACL is never marked + protected, so the contract could not hold). The harness reads both ACLs back and records the owner, the + ACE identity, its rights, its inheritance flags and its type as **raw + fields**; the evaluator recomputes the contract and never reads an + `aceIsCurrentUser`-style boolean. +- **Validate every path before touching it.** One gate runs before any + `Test-Path`, read, hash, creation or `Add-Type`: `%TEMP%`, the harness + directory, `-OutFile` and its parent, `-Manifest`, `-CodexBinary` and + `-FixtureDir` must each be an **absolute local drive path with an accepted + spelling** and disjoint from the actual, default and environment Codex homes. + UNC (`\\server\share`), device (`\\?\`, `\\.\`), rooted-without-drive, + drive-relative, `.`/`..`, double-separator, trailing-space and trailing-dot + spellings are **refused, not normalized**. `-OutFile` is additionally refused + inside any `codex-win-obs-<12 hex>` directory on any volume, matched literally + per path component and independently of the current `%TEMP%`. +- **The compiler is a side effect, and it is honest.** `Add-Type` starts the + .NET compiler as a child process and writes compiler temporaries into + `%TEMP%`. Both `observer.ps1` and `check.ps1` run the path and `%TEMP%` gate + **before** `Add-Type`, so the compiler never writes inside a real Codex home. + A static gate means no observation and no native method call, not no compiler + process and no compiler files. The compiler inherits this shell's + environment, which is why no credential may be exported into the shell that + runs these scripts. +- **No-follow ancestry, which is a check and not a pin.** The temp root's + ancestry is walked with `FILE_FLAG_OPEN_REPARSE_POINT`, component by + component, before creation, and the new parent's ancestry is walked again + before the home is created inside it. Each component's attributes and file ID + are recorded, and the evaluator refuses the home when any component is not a + plain directory. The components are opened one after another by path, so an + ancestry that passes can still be replaced afterwards. That residual window + is not asserted away; it is why the native modes are refused. +- **One manifest, reconstructed.** Observe requires exactly one manifest + record in the whole preflight file, as the **last** record, complete, with + every other required preflight step present exactly once and usable. A file + with both a complete and an incomplete manifest record is refused. Each + manifest field is then cross-checked against the raw preflight observation it + claims to describe (home and parent paths and file IDs, the hashed binary's + path, digest and size, the recorded version line, the harness digests, the + measured integrity SID), and the path shape is re-derived: the home must be + exactly the parent's `home` child and the parent must be a + `codex-win-obs-<12 hex>` directory. A file ID alone is never treated as + authorization or provenance. This prevents stale, partial, contradictory or + out-of-scope inputs; it does **not** defend against an adversarial same-user + forged manifest. +- **No credentials.** The harness does not read, copy, create or forward any + credential, token or `auth.json`. The one child process it starts + (`codex --version`) gets a **cleared** environment plus a narrow allowlist + (`SystemRoot`, `SystemDrive`, `windir`, `COMSPEC`, `PATHEXT`, + `NUMBER_OF_PROCESSORS`, `PROCESSOR_ARCHITECTURE`) with `CODEX_HOME`, `TEMP` + and `TMP` pointed at the disposable directory, so no inherited + authentication, proxy or `USERPROFILE` variable reaches it. The allowlist is + recorded in the version record. How Codex is authenticated in the disposable + home is an open decision (see below). +- **Binary before execution.** The binary's SHA-256 must equal + `-ExpectedSha256` before it is executed. It is hashed again immediately + before the only execution the harness performs (`--version`). +- **Read-only file access.** Identity reads use `FILE_READ_ATTRIBUTES` with + `FILE_FLAG_OPEN_REPARSE_POINT`. Probes open existing files only (never + `CREATE_*` or `GENERIC_WRITE`). The harness never deletes, renames, + truncates or writes Codex files, and it never changes an ACL. +- **Gated active operations, per sample.** Before **each** sample's + enumeration, Restart Manager, share and range operations, the declared PID is + re-queried and must still be the same live, same-user instance at the same + creation FILETIME, image path and integrity SID; the queried record is + emitted either way (`process.sample`) with the refusal reason when the gate + stopped that sample. A refused sample runs none of those operations and never + inherits an earlier sample's verdict. Per sample, the lock directory must be a + plain non-reparse directory and the coordination guard a single-link regular + file that is not pending delete **before** any enumeration, and the thread + lock must be a single-link regular file that is not pending delete before the + probes. A failure at the end of the run (`process.identity_after`) is + recorded, but it cannot undo operations that were already performed. +- **Locks, bound to expected identity — and not passive.** The only state the + harness takes is a brief, fail-immediately, **exclusive** `LockFileEx` on the + live `.coordination.lock` (Rust range). Under it, it runs fail-immediately + exclusive probes on the thread lock, which succeed and are released at once + whenever a range is free. Codex acquires with a fail-immediately `try_lock`, + so a Codex acquisition that falls inside one of those windows **fails**: this + is an effect on the observed process, not an observation of it. + The guarded probe is given the already-validated expected file IDs of the + lock directory, the coordination guard and the thread lock; it verifies the + opened guard and thread handles against them **before** taking any lock + (including that each is a regular, non-reparse, single-link, not-delete-pending + file, with the expected link count fixed at one inside the native layer rather + than passed in by the caller), and revalidates the guard on the same handle + and the directory by path **after** the guard is released. A changed, + unreadable or unreleasable identity makes the result `unknown`. There is no + native file creation and no fallback. Each probe that succeeds is released at + once, and the guard is released in `finally`. No await, spawn or signal + happens while the guard is held. The lock order matches the production rule: + native guard, then thread probe. A missing or busy guard is `unknown`. +- **Full-sharing opens only.** Every open of a Codex file uses the full share + mask (read, write, delete). `share.read_rwd` and `share.delete_rwd` (DELETE + access plus the attribute-read right the file-type check needs, shared) are + the only share probes the default observer runs, so it + never withholds a sharing mode the holder relies on. Requesting the DELETE + access right is not a file-removal call; no delete disposition, rename or + removal is ever issued. Restrictive-sharing probes (omitting write or delete + sharing, which could block the holder's writes or its Drop-time delete) are a + separate, **not-executed** proposal below. +- **Processes.** The harness queries the operator-supplied PID through a + `PROCESS_QUERY_LIMITED_INFORMATION | SYNCHRONIZE` handle, opening a **fresh + handle per sample** (it is not a run-long pinned handle), and every native + result echoes the PID that was queried so the evaluator can bind it to the + run record instead of trusting a record's position. Liveness comes only from + `WaitForSingleObject(h, 0)`. The harness never calls `TerminateProcess`, + `Stop-Process` or `taskkill`, and it never sends console control events. The + `--version` run drains stdout and stderr **concurrently**, each with a byte + cap, against one monotonic deadline that also bounds process exit, so a + descendant that inherits the pipes cannot hang it and a full pipe on one + stream cannot deadlock the other. A read is usable only when it reached + end-of-stream inside the deadline without overflowing its cap (the cap is + enforced by reading one byte more than the cap, so exactly-at-the-cap is + end-of-stream and not an overflow); a valid first line beside a failed, + truncated or timed-out read is never success. A timeout is reported incomplete + with the remaining PID and the process is left running (never killed, so + child termination is not guaranteed). +- **No host changes.** Execution policy, Defender state and the OS build are + recorded as found **before** anything is created or executed. Defender is + never enabled, disabled or configured. Execution policy is never changed. The + entry point is `powershell -NoProfile -NonInteractive -File`, with no + `Bypass`, no encoded command and no wrapper. +- **No network.** Neither the observer nor the evaluator opens a network + connection. What Codex itself does in the disposable home is the operator's + launch decision. +- **Outputs.** `-OutFile` must not exist and must pass the path gate. Each + output is created with `FileMode.CreateNew` as UTF-8 without a BOM, one + LF-terminated JSON object per line. Raw records contain local SIDs and paths, + so do not publish them unredacted. + +## Required operator environment + +Recorded for review. With the native modes refused, none of this is a request +to run anything. + +| Item | Requirement | +| --- | --- | +| Host | The designated Windows host. The harness does not connect to it; an operator runs every command there | +| OS / shell | Windows 10 or 11, Windows PowerShell 5.1 (`powershell.exe`), Storage module with `Get-Volume -FilePath`, `Get-MpComputerStatus` | +| Filesystem | `%TEMP%` on NTFS (ReFS is W1 scope; the volume is recorded either way), on a local drive, with no reparse point anywhere in its ancestry | +| Defender | Already enabled with real-time protection on. If it is not, preflight aborts before creating anything. Changing it needs separate authorization | +| Execution policy | Existing policy must allow `-File` on these scripts. If it blocks them, stop and report; do not change it | +| Checkout | This repository at one commit, identical for every run being paired. Record `git rev-parse HEAD` as `-SourceCommit` | +| Codex binary | The native Windows executable of the pinned release `rust-v0.163.0` (see section 2 of the evaluation document), not the npm launcher. Use its absolute path | +| Expected digest | SHA-256 of that executable, derived from the published release asset (below) | +| Sessions | W6 needs both: a **Medium** non-elevated interactive desktop PowerShell window, and a **High** session (elevated window or the SSH session used before). Each run uses one session only | +| Shell hygiene | No credential, token or proxy variable exported in the shell that runs these scripts: the .NET compiler `Add-Type` starts inherits that environment | +| Evaluator | Node.js 22.13+ or 24 on any machine; copy the JSONL files off the host | +| Baseline | A `--baseline` JSON file (source commit, binary SHA-256, version, harness digests for the exact six harness file names), established independently of the run. Without a format-valid baseline the evaluator cannot pass | + +### Obtaining the expected digest (operator, before preflight) + +1. On a machine with network access, list the release assets with their + published digests, for example with + `gh release view rust-v0.163.0 --repo openai/codex --json assets`. + Record the asset name and its `digest`. +2. Download that asset to the designated host. Compare + `(Get-FileHash -Algorithm SHA256 ).Hash.ToLower()` with the published + digest. If they differ, stop. +3. If the asset is an archive, extract it into a new directory. Then record the + SHA-256 of the extracted executable as `-ExpectedSha256`, together with the + archive digest that vouches for it. + +`-ExpectedVersion` is the exact first line that the pinned binary prints for +`--version` (for example `codex-cli 0.163.0`). Confirm it against the release +before the run; the harness compares it exactly. + +## Preconditions (check before step 1) + +- No other Codex process uses the disposable home (it is new, so none should). +- For a same-level run, the Codex window you will open in step 3 runs at the + **same integrity level** as the observer window. The cross-level variant + below is a separate run. +- `%TEMP%` resolves to a local NTFS directory you own, with a plain ancestry. +- You know how Codex will be authenticated in the disposable home without + copying credentials from the real home. If you do not, stop (remaining + decision 1). + +## Run commands (operator, on the designated host) + +> **Steps 1 and 4 currently refuse.** `-Mode preflight` and `-Mode observe` +> exit 3 with the refusal reason while remaining decision 10 is open. The +> commands are documented so the intended procedure can be reviewed, not so it +> can be run. Step 0 (static gate), step 6 (`fixture-meta`) and step 7 +> (evaluator) do run. + +Run from `experimental\windows-native-observer` in the checkout. Choose an +output directory outside `%TEMP%\codex-win-obs-*`, for example +`$out = "$env:USERPROFILE\Documents\codex-w7"`, and create it first. Repeat +steps 1–6 once per integrity level (`medium` in the desktop window, `high` in +the High session), from the same commit. + +0. **Static gate** (no native calls): + + ```powershell + powershell -NoProfile -NonInteractive -File check.ps1 + ``` + + Expected: `parse ok: observer.ps1`, `parse ok: check.ps1`, + `syntax-tree checks ok …`, `ordering ok: …`, `refusal ok: …`, + `harness file-name set ok`, `pure helpers loaded …`, one + `pure helper checks ok: ` line for each of the nine assertion groups, + `path gate ok: …`, `compile ok: WinObserver.cs (no method called)`, exit 0. + +1. **Preflight** (refuses today): + + ```powershell + powershell -NoProfile -NonInteractive -File observer.ps1 -Mode preflight ` + -IntegrityLabel medium -SourceCommit <40-hex commit> ` + -CodexBinary -ExpectedSha256 <64 lowercase hex> ` + -ExpectedVersion '' -OutFile "$out\medium-preflight.jsonl" + ``` + + Today: `refused: native preflight and observe are refused …`, exit 3, + nothing created. If the boundary is ever closed, the expected output is + `results: …`, `disposable CODEX_HOME: C:\…\codex-win-obs-<12 hex>\home`, a + `next:` line, and `verdicts:` with only `match` and `recorded`, exit 0, with + `manifest` (`complete: true`) as the last record. + +2. **Note the home path** that was printed. Do not reuse a home from another run + or another integrity level. + +3. **Launch Codex** in a **new window at the same integrity level**: + + ```powershell + $env:CODEX_HOME = '' + & '' + ``` + + Use the launch mode you decided on (remaining decision 2) and do whatever + that mode needs to create **exactly one** thread (one + `thread-writer-locks\.lock`). Leave Codex idle. Record the mode + and the exact command. Then find the PID from a third window, for example + `Get-Process | Where-Object { $_.Path -eq '' } | Select-Object Id, Path, StartTime`. + If more than one process matches, stop: the observer needs one unambiguous + PID. + +4. **Observe** (refuses today; in the observer window from step 1): + + ```powershell + powershell -NoProfile -NonInteractive -File observer.ps1 -Mode observe ` + -IntegrityLabel medium -CodexIntegrityLabel medium -Manifest "$out\medium-preflight.jsonl" ` + -CodexPid -Samples 3 -OutFile "$out\medium-observe.jsonl" + ``` + + `-CodexIntegrityLabel` is the level of the window that launched Codex. The + observer checks it against the Codex token when that token can be read. + +5. **Stop Codex normally** in its own window, the way the chosen mode exits. + The harness never stops it. If it does not exit, report that; do not force it. + +6. **W6 fixture pairing** (same window, same commit). Running the existing + synthetic fixture and recording this session's integrity is possible, but the + pairing it feeds can never complete, so this step is **deferred**: the + fixture producer is not bound to its own bytes, run or source (remaining + decision 4), and binding it would mean changing + `experimental/windows-fixture/`, which is outside this change. Recorded for + completeness: + + ```powershell + powershell -NoProfile -NonInteractive -File ..\windows-fixture\lab.ps1 -OutFile "$out\medium-lab.jsonl" + powershell -NoProfile -NonInteractive -File observer.ps1 -Mode fixture-meta ` + -IntegrityLabel medium -SourceCommit -OutFile "$out\medium-meta.jsonl" + ``` + +7. **Evaluate** (any machine with Node.js; files copied off the host). Supply a + `--baseline` JSON file holding the independently established evidence, or + provenance rows stay `unknown` and the run can never pass: + + ```json + { "sourceCommit": "<40 hex>", "binarySha256": "<64 hex>", "version": "codex-cli 0.163.0", + "harness": { "observer.ps1": "<64 hex>", "WinObserver.cs": "<64 hex>", "check.ps1": "<64 hex>", + "expectations.mjs": "<64 hex>", "lib.mjs": "<64 hex>", "evaluate.mjs": "<64 hex>" } } + ``` + + ```sh + node experimental/windows-native-observer/evaluate.mjs run --label medium \ + --preflight medium-preflight.jsonl --observe medium-observe.jsonl --baseline baseline.json + node experimental/windows-native-observer/evaluate.mjs pair \ + --medium-preflight medium-preflight.jsonl --medium-observe medium-observe.jsonl \ + --high-preflight high-preflight.jsonl --high-observe high-observe.jsonl \ + --baseline baseline.json [--explanations w6-explanations.json] + node experimental/windows-native-observer/evaluate.mjs pair-fixture \ + --medium-lab medium-lab.jsonl --medium-meta medium-meta.jsonl \ + --high-lab high-lab.jsonl --high-meta high-meta.jsonl [--explanations w6-fixture-explanations.json] + ``` + + The baseline's field formats and its exact harness file-name set are + validated; the harness map is an exact map of this checkout's bytes, line + endings included, so a line-ending change alone makes it differ. The + evaluator cannot prove how the operator established the baseline + (remaining decision 5). It recomputes every provenance verdict from the raw + recorded fields and that baseline; it never trusts a harness-written boolean + such as `matchesManifest`, `imageMatchesBinary`, `isolated` or `sameCreate`. + `w6-explanations.json` maps a row ID to a written explanation of a + difference between levels, for example `{"OB-openers": "…"}`. An explanation + can account for a difference; it can never erase a failed hypothesis (a + mismatch at either level, in a compared row or in the provenance it rests + on). `pair-fixture` is always `unknown`. + +8. **Cleanup** (operator, after Codex has exited). Confirm that no process + still uses the home, keep the JSONL files, then delete the + `codex-win-obs-` directory printed in step 1 by hand. The harness + never deletes it. + +### Cross-level variant (W6 only, optional) + +W6 also covers cross-integrity `OpenProcess` access. To record it, run +preflight and observe from the **Medium** window as above, but launch Codex in +step 3 from a **High** window, and pass `-CodexIntegrityLabel high`. Use +separate file names (for example `medium-observer-high-codex-*.jsonl`) and a +fresh preflight and home. The evaluator reports `variant: "cross_level"`. It +never pairs a cross-level run with a same-level run, and a denied token query +stays `unknown`. + +### Exit codes + +| Program | 0 | 1 | 2 | 3 | 64 | +| --- | --- | --- | --- | --- | --- | +| `observer.ps1` | every hypothesis matched | at least one **measured mismatch** | incomplete, error, unknown, abort, or a setup/orchestrator failure (including an unreadable or unparsable manifest, a failed `Add-Type`, or an exception escaping setup) | refused on an input, path, host precondition or the native-mode gate, before anything was created, compiled or recorded | — | +| `evaluate.mjs` | W7 `pass` / W6 `compared` | at least one mismatch (`fail`) | `unknown` | — | usage error | +| `check.ps1` | parse, syntax-tree, pure-helper, path-gate and compile checks ok | any failure | — | — | — | + +Exit 1 means a measured mismatch and nothing else. Setup problems that cannot +establish anything either way are exit 2 with an `unknown:` line, never an +accidental uncaught exit 1. + +### Expected evaluator outcome + +With the current table and a correct baseline, a perfect run still yields +**`w7: "unknown"` with `blockers: ["OB-coordination-range"]`** and exit 2. Codex +holds the coordination lock only for an instant, so a passive observer cannot +measure its range. That row is intentionally never success (remaining decision +3). Every other row is expected to be `match` or `recorded`. For a matched +observer pair with a validated provenance state on both sides, W6 `pair` is +expected to report `status: "compared"`; `pair-fixture` is always `unknown`. + +## Restrictive-sharing experiment (proposal, NOT executed) + +A restrictive-sharing probe would open the thread lock without granting write +or delete sharing (for example read-only with `FILE_SHARE_READ` only) to learn +the holder's access rights from the resulting `ERROR_SHARING_VIOLATION`. The +default observer does **not** run it: while Codex holds the file, an observer +handle that withholds delete sharing can block Codex's Drop-time delete, and one +that withholds write sharing can block its writes. Running it is a disruptive +active test that must be separately authorized and performed only against a +disposable home where such interference is acceptable; it is not approved here. +The generic `ShareProbe` method can express these masks, but `observer.ps1` +never calls it with a restrictive mask. + +## Limitations to preserve + +- **Ancestry is compared, not pinned.** The no-follow ancestry walk, the + creation gate and every identity comparison happen at discrete points in + time, on handles opened separately by path. A same-user actor can replace a + component between a check and its use. This is the open boundary that keeps + the native modes refused (remaining decision 10). +- **Sampled, not pinned.** `ProcessIdentity` opens a new handle per sample and + again at the end; it is not a single run-long pinned handle. A PID reused + between samples is a distinct risk; the creation FILETIME is the only + cross-sample witness and it too is sampled. The final + `process.identity_after` check can only report a change; it cannot undo an + operation already performed. +- **Preflight hash is not the target's identity.** The preflight SHA-256 is of + the binary preflight hashed and ran for `--version`. It does not establish the + bytes of the separately operator-launched Codex process, nor that that process + uses the disposable home. The observer binds the launched PID's image path to + the manifest binary path, which is a path match, not a byte-identity proof. +- **Rehash race.** Re-hashing immediately before `Process.Start` narrows but + does not close a replace-after-hash window unless the file is otherwise + prevented from changing. This limitation is preserved rather than asserted + away. +- **The baseline is an exact byte map, not external provenance.** The evaluator + checks formats, the exact harness file-name set and equality with the recorded + digests. It cannot show that the operator obtained the baseline + independently, and a line-ending change in the checkout is enough to make it + differ. +- **The compiler's environment is wider than the child's.** The `--version` + child gets a cleared environment plus a narrow allowlist. The .NET compiler + that `Add-Type` starts does not: it inherits this shell's environment. +- **Bounded reads refuse rather than guarantee.** The concurrent drain bounds + both pipes with one deadline and caps each stream, but a producer that keeps a + pipe open is only reported, never terminated, and the abandoned read's buffer + is simply not reused. +- **Same-user forgery is out of scope.** Manifest reconstruction, digest + cross-checks and the DACL contract defend against stale, partial and + contradictory inputs, not against an adversarial same-user actor who can write + the same files. +- **Restart Manager reports openers, never holders** (W4), and a target PID + with a different creation FILETIME is treated as a reused PID, not as the + launched process. +- **The range probe can make Codex fail.** The exclusive guard and thread + probes are brief, but a concurrent Codex `try_lock` inside one of those + windows fails. A failed release is reported as `guard_unlock_failed` (with + its Win32 code) and is never overwritten by a later revalidation reason. +- **`DirFileId` does not say why.** A missing, unreadable, reparse or + non-directory target all return the same `null`; the per-sample identity + records carry the attributes and Win32 codes that tell them apart. +- **The static gate checks names, not intent.** `check.ps1` refuses forbidden + cmdlets and a denylist of .NET member names in the syntax tree. The members + the harness uses on purpose (`Process::Start` for `--version`, + `Directory::CreateDirectory` for the disposable home, `StreamWriter.Write` + for the results file) are allowed by design, and a call through reflection or + a dynamically built name would not be caught. + +## Abort conditions + +Stop and report; do not work around any of these. + +| Condition | Where | Effect | +| --- | --- | --- | +| `-Mode preflight` or `-Mode observe` | any host | exit 3, refused: the ancestry/creation boundary is open and there is no bypass | +| Missing or invalid parameter, existing `-OutFile`, any path that is not an accepted absolute local drive spelling, any path inside a real Codex home, an output inside a `codex-win-obs-<12 hex>` directory, `%TEMP%` or the harness directory inside a real home, `USERPROFILE` unset | any mode | exit 3 before any file is opened or compiled | +| `Get-Volume -FilePath` or `Get-MpComputerStatus` unavailable | preflight | exit 3 before anything is created | +| `-CodexPid` missing, zero, or this observer process | observe | exit 3 | +| Own integrity unreadable, or the measured user SID differs from the current identity | any mode | exit 3 / exit 2, nothing observed | +| Manifest unreadable, unparsable, of another schema or mode, not exactly one manifest record, not last, incomplete, missing a required preflight record, or disagreeing with the raw preflight observations it describes (home, parent, file IDs, binary path, digest, size, version, commit, harness, integrity SID) | observe | exit 3 (or exit 2 when it cannot be read at all); refuse before recording | +| Temp root not an accepted absolute local path, not disjoint from a real Codex home, or with a reparse point in its ancestry | preflight | refused before any directory is created (creation callback never runs) | +| Harness digests incomplete | preflight | abort, no home created | +| Measured integrity differs from `-IntegrityLabel` | preflight, observe, fixture-meta | abort; the result is never relabelled | +| Defender not observed enabled, execution policy unreadable | preflight | abort **before** anything is created or executed; do not change either | +| Binary missing, a reparse point, SHA-256 differs, or changes before execution | preflight | abort before or instead of execution | +| New parent's ancestry not plain after creation | preflight | refused before the home is created inside it | +| Disposable home not isolated (overlapping a real home, not the parent's `home` child, parent not a disposable name, not empty, owner or DACL contract broken, missing file ID, ancestry not plain) | preflight | abort | +| `--version` does not exit within budget, or either stream fails, truncates or times out | preflight | abort; PID reported; process left running, never killed | +| Version line differs | preflight | abort | +| Observer files differ from preflight, integrity differs from the preflight, home re-validation fails (path, parent/home file ID, disjointness, owner/DACL contract or ancestry) | observe | abort before any probe | +| First process observation is not a live, same-user instance of the manifest binary at the declared integrity | observe | active operations refused for the whole run; do not retry with another PID | +| Per sample: the re-queried PID is gone, reused, unreadable, at another user, integrity or image | observe | that sample's enumeration, RM, share and range operations are skipped; the refused process record is still emitted | +| Per sample: lock directory not a plain directory, coordination guard not a single-link regular file, thread lock a reparse point, directory, hardlinked or pending delete, or not exactly one thread lock | observe | that sample's RM/share/range operations are skipped; rows become `unknown` | +| Any agent/process does not exit when the operator expects | operator | report; never force | + +## Result model: `collected` versus `measured` + +The evaluator ignores the harness's own verdict and booleans. It re-checks each +record against `expectations.mjs` and reports two separate facts for every row: + +- **`collected`**: a record exists for the step (`all`, `partial` or `none` + across samples). +- **`measured`**: that record holds a complete, in-schema, internally + consistent native observation. That means `observed` is present, `ok` is + `true`, `complete` is not `false`, the state is not `unknown`, there is no + exception, no Win32 `5` (access denied) anywhere, the typed per-step schema + passes (required fields present and correctly formatted — SHA-256, commit, + file ID, SID, absolute local drive path, integer, decimal FILETIME string), + every schema **invariant** holds, and every hypothesis field is present and + not null. + +Invariants are the record's internal consistency, and a record that +contradicts itself is never evidence. For example: `opened: true` with a +nonzero Win32 code; a range with `win32: 33` and `acquired: true`, or an +acquired range that reports no release; a probed range whose guard was not +taken and released; a Restart Manager record whose `count` differs from its +`processes` length, repeats a PID and creation pair, carries a role outside the +closed set, carries a FILETIME as a JSON number instead of an exact decimal +string, or whose `interpretation` is not exactly `opener_candidates_only`; a +`thread.count` whose count does not match its own entries; a `binary.version` +record whose streams did not both complete inside the deadline without +truncation; a `binary.pe_version` record with `{ok:true}` and nothing declared; +a `run` record whose observer PID equals its target PID. + +Three kinds of row: + +- **Derived** rows (provenance and bindings) recompute the verdict from raw + fields, the cross-stage bindings and the `--baseline`. Booleans the harness + wrote are never read. Without a format-valid baseline these rows are + `unknown`, so the gate cannot pass. +- **Raw-comparison** rows compare named observed fields with expected values. +- **Recorded** rows have no hypothesis but must still pass their typed schema + and invariants; a bare `{ok:true}` fails and is `unknown`, never success. + +| Situation | Row verdict | +| --- | --- | +| Not collected, no observation, partial, native error, exception, `unknown` state | `unknown` | +| A required field is missing or malformed for its type | `unknown` (`schema_*`) | +| A record contradicts itself | `unknown` (`schema_invariant:*`) | +| Access denied anywhere in the observation | `unknown` (`access_denied`), never `mismatch` | +| Sample count not an integer 1..10, or any missing sample | `unknown` | +| The record's `expect` differs from the table | `unknown` (`expectation_drift`) | +| A derivation cannot be established (no baseline, no manifest, no run record, missing sibling record) | `unknown` | +| Recomputed home isolation differs from the recorded flag | `unknown` (`evaluator_disagreement`) | +| Mixed integrity, label/SID disagreement, duplicate step, unexpected step, a sample index on a non-sampled step or missing/out-of-range on a sampled one, samples out of order, control or process records not bracketing the samples, `orchestrator_error`, `safeToUnlock` not `false`, preflight/observe at different levels, observe referencing another preflight, a manifest that is absent, duplicated, not last or unusable, a missing required preflight record, an unusable run record, an invalid baseline | whole file unusable / problem, rows `unknown` | +| Measured and derivation/comparison satisfied | `match` | +| Measured and a derivation or field differs | `mismatch` | +| Measured recorded row with a passing schema | `recorded` (never counted as hypothesis success) | + +A **file-global problem makes the whole result `unknown` before any per-row +mismatch is reported**: when the structure, the labels or the provenance inputs +are not in a usable state, a measured contradiction cannot be attributed to the +hypothesis. Valid, complete evidence with a genuine mismatch still fails. W7 is +therefore `pass` only if there are no problems and every row is `match` or +`recorded`, `fail` on a mismatch with no problems, and `unknown` otherwise. The +unmeasurable `OB-coordination-range` row keeps a perfect run at `unknown`. + +Each run also reports a **provenance state** +(`provenance.state: "validated" | "unvalidated"`). It is `validated` only when +there are no file-global problems, the baseline is format-valid with the exact +harness file-name set, and every provenance row (`PF-harness`, `PF-token`, +`PF-binary-sha256`, `PF-binary-version`, `PF-manifest`, `OB-harness`, +`OB-token`, `OB-home`, `OB-run`) is `match`. + +### W6 pairing rules + +- Each file must hold a single integrity level. The token integrity SID is + measured at start and stamped on every record. `S-1-16-8192` is Medium and + `S-1-16-12288` is High; anything else is unusable. +- Pairing requires a **validated provenance state on both sides**, byte-identical + harness digests over the exact harness file-name set, the same source commit + and, for observer runs, the same binary SHA-256, the same version line, a + format-valid baseline on each side, and same-level runs (observer and Codex + at the same level). Cross-level runs are reported alone. W6 does **not** + require W7's unmeasurable coordination range, but it does require every + provenance prerequisite of it. +- Only rows tagged `W6` are compared. Verdicts and selected values (hypothesis + fields, plus Restart Manager `count` and role list) are shown side by side + per level. They are never merged. +- Any difference needs a written explanation. Any row that is `unknown` at + either level keeps the pair `unknown`; it is never filled in from the other + level. A mismatch in a compared row **or in a provenance row** at either + level is `fail`, and no explanation changes that. +- `lab.ps1` records integrity once per run, in its `environment/host` record. + For fixture pairing, the evaluator requires the `fixture-meta` record from + the same shell to measure the same SID, then stamps that level on every + derived row. But the `fixture-meta` record is not bound to the lab's bytes, + run or source, and the fixture has no closed terminal manifest, so + `fixtureSummary` always carries `fixture_producer_unbound`, its provenance + state is always `unvalidated` and every fixture row is `unknown`. + `pair-fixture` can therefore never be `compared`; it stays `unknown` until a + separately reviewed producer proposal binds the evidence. The evaluator also + recomputes each fixture row from raw fields and never trusts `lab.verdict` (a + `null` observation with `verdict:"match"` is still `unknown`). + +## Expected-result table + +Generated from `expectations.mjs` (`node evaluate.mjs table`). A unit test +keeps this copy identical to the source. + + +| ID | Mode | Step | Per sample | Check | Gates | +| --- | --- | --- | --- | --- | --- | +| PF-harness | preflight | `harness` | no | derived: `harnessBaseline` | W6, W7 | +| PF-token | preflight | `token.self` | no | derived: `tokenIntegrity` | W6, W7 | +| PF-binary-sha256 | preflight | `binary.sha256` | no | derived: `binarySha256Baseline` | W7 | +| PF-binary-signature | preflight | `binary.signature` | no | recorded only (typed schema) | W7 | +| PF-binary-pe-version | preflight | `binary.pe_version` | no | recorded only (typed schema) | W7 | +| PF-home | preflight | `home.isolated` | no | derived: `homeIsolated` | W7 | +| PF-binary-version | preflight | `binary.version` | no | derived: `binaryVersion` | W7 | +| PF-home-after-version | preflight | `home.after_version` | no | recorded only (typed schema) | W7 | +| PF-execution-policy | preflight | `host.execution_policy` | no | recorded only (typed schema) | W6, W7 | +| PF-defender | preflight | `host.defender` | no | `amServiceEnabled=true`, `realTimeProtectionEnabled=true` | W7 | +| PF-volume | preflight | `host.volume` | no | recorded only (typed schema) | W7 | +| PF-os | preflight | `host.os` | no | recorded only (typed schema) | W6, W7 | +| PF-manifest | preflight | `manifest` | no | derived: `manifestBaseline` | W7 | +| OB-harness | observe | `harness` | no | derived: `harnessMatchesManifest` | W6, W7 | +| OB-token | observe | `token.self` | no | derived: `tokenMatchesManifest` | W6, W7 | +| OB-home | observe | `home.identity` | no | derived: `homeMatchesManifest` | W7 | +| OB-run | observe | `run` | no | derived: `runControl` | W6, W7 | +| OB-process | observe | `process.identity` | no | derived: `imageLive` | W3, W6, W7 | +| OB-process-token | observe | `process.identity` | no | derived: `codexToken` | W6 | +| OB-process-sample | observe | `process.sample` | yes | derived: `sampleProcess` | W3, W7 | +| OB-lockdir | observe | `lockdir.identity` | yes | `directory=true`, `reparse=false` | W7 | +| OB-guard | observe | `coordination.identity` | yes | `directory=false`, `reparse=false`, `links=1`, `deletePending=false` | W7 | +| OB-thread-count | observe | `thread.count` | yes | `count=1` | W7 | +| OB-thread-identity | observe | `thread.identity` | yes | `directory=false`, `reparse=false`, `links=1`, `deletePending=false` | W7 | +| OB-share-read-rwd | observe | `share.read_rwd` | yes | `opened=true` | W7 | +| OB-share-delete-rwd | observe | `share.delete_rwd` | yes | `opened=true` | W7 | +| OB-range-rust | observe | `range.probe` | yes | `state="probed"`, `ranges.rust.win32=33` | W7 | +| OB-range-fsext | observe | `range.probe` | yes | `state="probed"`, `ranges.fsext.win32=33` | W7 | +| OB-range-beyond-fsext | observe | `range.probe` | yes | `state="probed"`, `ranges.beyond_fsext.win32=33` | W7 | +| OB-range-tail | observe | `range.probe` | yes | recorded only (typed schema) | W7 | +| OB-range-guard | observe | `range.probe` | yes | derived: `rangeGuardBinding` | W7 | +| OB-thread-stable | observe | `thread.identity_after` | yes | derived: `threadStable` | W7 | +| OB-openers | observe | `openers.rm` | yes | derived: `openersRm` | W4, W6, W7 | +| OB-coordination-range | observe | `coordination.range` | no | not measurable (always unknown) | W7 | +| OB-process-after | observe | `process.identity_after` | no | derived: `processAfterLive` | W3, W7 | + + +The range rows tell the two fixture models apart. Rust std locks offset 0 with +length `u32::MAX:u32::MAX`. The fs-ext emulation locks `0..0xffff0000`. A +one-byte probe at `0xffff0000` (`beyond_fsext`) conflicts only with a holder +whose range extends past the fs-ext range. The `tail` probe at the last 64-bit +offset lies outside the Rust range. It is recorded only, because whether +Windows accepts that offset has not been established. Restart Manager rows are +`opener_candidates_only` and are never holder evidence (W4). + +## Verification scope and remaining unexecuted work + +| Item | Status | Reason | +| --- | --- | --- | +| `check.ps1` | executed on Windows PowerShell 5.1 | Both scripts parsed; AST checks, refusal mutations and nine pure-helper groups passed | +| `observer.ps1` body (all modes) | not executed | Only its parsed AST and selected pure helper functions were used | +| `WinObserver.cs` | compiled on Windows | `Add-Type` and method-name reflection only; no native method was called | +| `observer.ps1 -Mode preflight` / `-Mode observe` | **refused by the harness** | The ancestry/creation boundary is open (remaining decision 10). This is a hard refusal, not just an absence of a host | +| Real Codex binary, version, disposable home, lock observations | not executed | Requires the designated host and an operator, and the native modes refuse; no real Codex was run anywhere | +| Medium-integrity and High-integrity runs | not executed | Same | +| `lab.ps1` reruns and the `fixture-meta` pairing step | deferred | The fixture producer cannot be bound without changing `experimental/windows-fixture/` (remaining decision 4), so the paired result could never complete | +| Static CI workflow | added | Its remote CI results must be checked for the exact PR head; a local host run is not CI | +| `evaluate.mjs`, `lib.mjs`, `expectations.mjs` | executed | Unit tests with synthetic records only, including the creation and per-sample seams, path-spelling and gate rules, schema/invariant failures, manifest reconstruction and placement, PID/run binding, guard binding, baseline and provenance counterexamples, and the fixture repros | + +The PowerShell path gate, creation-gate ordering, per-sample seam, path-spelling +rule, opener-role recomputation and the `check.ps1` syntax-tree checks are +mirrored in JS (`pathGateSafe`, `tempRootSafeForCreation`, `guardedCreate`, +`guardedSample`, `normalizeLocalPath`, `insideDisposableDir`, plus source +assertions over `observer.ps1` and `check.ps1`) and tested on this host. The +PowerShell and C# static checks were then executed on Windows 10 22H2 +(19045), Windows PowerShell 5.1.19041.6456, NTFS, in an elevated SSH session. +The existing `RemoteSigned` policy was unchanged. A new private staging +directory outside every real Codex home held the checked inputs and compiler +temporaries. Source hashes were checked before execution. + +The final static run exited **0**: both parses, automatic-variable and +forbidden-call checks, exact hard-refusal AST contract and seven negative +mutations, nine pure-helper groups, and C# compilation passed. Tested inputs: + +- `check.ps1`: `ddec2d14899ae70ab18fd8f0081c4f03b3e996db0aa9e04d10d066ac68ba01d4` +- `observer.ps1`: `e39fbd2497f69c97b9769698fa525545316ab98d430e57e3a6f3104590a1f40e` +- `WinObserver.cs`: `de0356fedee35ca2cb239b2c0f237021ea60f27b59f61f960c62db52768e3d3e` +- Static output SHA-256: `3ea3e85a2b6f31ba4bb7a41b9273460832bd9d8667ad0ff12e1edf74ff111736` + +Earlier failures were retained rather than counted as passes: the original +checker used an unavailable `VariablePath.UnqualifiedPath` property; after +switching to public `UserPath`, a faulted stream task was mistaken for EOF by +PowerShell 5.1. Explicit task fault/cancellation checks and +`GetAwaiter().GetResult()` fixed that helper. An intermediate AST-mutation +checker also rejected its own string `.Replace()` calls; literal split/join +now preserves the file-replacement denylist. These are static/pure-helper +findings, not real Codex or lock observations. + +The evaluator now requires every ancestry prefix, self-token/run/home identity +binding, exact RM resource paths and share modes, and complete contiguous +sample stages. The dormant producer's late `process.sample` record does not +satisfy that ordering. It has not been loosened or enabled to make a synthetic +comparison pass. Native runtime behavior, Medium/High interoperability, +real lock operations and the creation boundary remain **unverified**. + +## Remaining decisions + +1. **Authentication in the disposable home.** Codex may need a login to create + a thread. Copying credentials from the real home is excluded. A dedicated + test login, or a credential the operator supplies only to that session, + needs separate authorization. It is also unverified whether startup alone + creates a thread writer lock before authentication. +2. **Launch mode.** Choose a standalone TUI, `exec` or app-server, and the + action that creates exactly one thread. Record it. This choice also feeds + W3 classification. +3. **Coordination lock range.** It cannot be measured passively, so it stays + `unknown`. Choose one: accept the inference from the measured thread range + and the shared Rust std implementation (a maintainer decision, recorded as + inference and not as measurement), or authorize an active experiment. Such + an experiment would hold a partial guard range while Codex acquires, which + briefly blocks Codex, so it needs its own approval. +4. **Bound fixture producer for W6.** `pair-fixture` is always `unknown` because + the current `experimental/windows-fixture` producer is not bound to its own + bytes, run or source and has no terminal manifest. A separately reviewed + producer proposal (per-record integrity and a closed, hash-bound manifest, + which would mean changing `experimental/windows-fixture/`, outside this + change) is required before fixture evidence can complete a comparison. +5. **Baseline provenance source.** Decide how the operator independently + establishes the `--baseline`. The evaluator validates its formats, its exact + harness file-name set and its equality with the recorded digests; it cannot + show where it came from. +6. **Restrictive-sharing experiment.** Decide whether to authorize the + proposal-only restrictive-sharing probe, which is disruptive and not run by + the default observer. +7. **Hosts without Defender enabled.** Out of scope; enabling Defender is a + separate authorization. +8. **Publication.** Raw JSONL contains SIDs and local paths. Decide what to + redact before attaching results to #151 or #152. +9. **CI.** Require the checked-in static workflow on the final PR head. + Compilation and pure-helper checks do not establish native-mode safety or + Windows support. +10. **The ancestry and creation boundary.** This is what keeps `preflight` and + `observe` refused. Closing it needs a mechanism that pins the path rather + than comparing identities twice (for example opening every component + relative to a held parent handle, or creating and then verifying by file ID + from a handle that is never released), which Windows PowerShell 5.1 and the + current P/Invoke surface do not give us. The alternatives are: build that + mechanism, accept the residual explicitly as a reviewed risk on a host where + only this harness and Codex run, or leave the native modes refused and keep + the evaluator as the only executable part. No bypass flag will be added + either way. diff --git a/experimental/windows-native-observer/WinObserver.cs b/experimental/windows-native-observer/WinObserver.cs new file mode 100644 index 0000000..b7bc9ed --- /dev/null +++ b/experimental/windows-native-observer/WinObserver.cs @@ -0,0 +1,722 @@ +// Windows observation helpers for codex-unlock research W6/W7 (#151, #152). +// Loaded by observer.ps1 through Add-Type. It never creates, writes, deletes, or +// renames a file, never changes an ACL or privilege, and never terminates or +// signals a process. Paths are restricted lexically to the disposable +// CODEX_HOME passed to SetRoot. Results are evidence only and never authorize +// recovery. +// +// NOT purely passive: GuardedRangeProbe takes a brief EXCLUSIVE, fail- +// immediately LockFileEx on the LIVE .coordination.lock and holds it across the +// thread-lock probes, and ProbeThreadRanges takes an exclusive lock on the +// thread lock file for each range that is currently free. Codex acquires with a +// fail-immediately try_lock, so a Codex acquisition that happens inside one of +// those windows FAILS. That is an effect on the observed process, not an +// observation of it. Every lock is released before the call returns (the guard +// in a finally), and a failed release makes the result `unknown`. +// +// Unverified: this file has never been compiled. The static CI gate +// (ci-proposal.patch) is the first place that would compile it. +using System; +using System.ComponentModel; +using System.IO; +using System.Runtime.InteropServices; +using System.Security.Principal; +using System.Text; +using System.Threading; +using Microsoft.Win32.SafeHandles; + +public static class WinObserver +{ + const uint GENERIC_READ = 0x80000000; + const uint DELETE = 0x00010000; + const uint FILE_READ_ATTRIBUTES = 0x80; + const uint FILE_SHARE_READ = 1, FILE_SHARE_WRITE = 2, FILE_SHARE_DELETE = 4; + const uint OPEN_EXISTING = 3; + const uint FILE_FLAG_BACKUP_SEMANTICS = 0x02000000; + const uint FILE_FLAG_OPEN_REPARSE_POINT = 0x00200000; + const uint FILE_ATTRIBUTE_DIRECTORY = 0x10; + const uint FILE_ATTRIBUTE_REPARSE_POINT = 0x400; + const uint LOCKFILE_FAIL_IMMEDIATELY = 1, LOCKFILE_EXCLUSIVE_LOCK = 2; + const int ERROR_LOCK_VIOLATION = 33, ERROR_MORE_DATA = 234; + const uint PROCESS_QUERY_LIMITED_INFORMATION = 0x1000, SYNCHRONIZE = 0x00100000; + const uint TOKEN_QUERY = 0x0008; + const uint WAIT_OBJECT_0 = 0, WAIT_TIMEOUT = 0x102; + const int TokenUser = 1, TokenSessionId = 12, TokenElevationType = 18, TokenIntegrityLevel = 25; + // A lock file the observer probes must be a single-link regular file. The + // expected link count is fixed here, not taken from the caller. + const uint EXPECTED_LINKS = 1; + + [DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)] + static extern SafeFileHandle CreateFileW(string path, uint access, uint share, IntPtr sa, + uint disposition, uint flags, IntPtr template); + + [DllImport("kernel32.dll", SetLastError = true)] + static extern bool LockFileEx(SafeFileHandle h, uint flags, uint reserved, uint lenLow, + uint lenHigh, ref NativeOverlapped ov); + + [DllImport("kernel32.dll", SetLastError = true)] + static extern bool UnlockFileEx(SafeFileHandle h, uint reserved, uint lenLow, uint lenHigh, + ref NativeOverlapped ov); + + [StructLayout(LayoutKind.Sequential)] + struct FILE_ATTRIBUTE_TAG_INFO { public uint FileAttributes; public uint ReparseTag; } + + [StructLayout(LayoutKind.Sequential)] + struct FILE_ID_INFO { public ulong VolumeSerialNumber; public ulong IdLow; public ulong IdHigh; } + + [StructLayout(LayoutKind.Sequential)] + struct FILE_STANDARD_INFO + { + public long AllocationSize; + public long EndOfFile; + public uint NumberOfLinks; + [MarshalAs(UnmanagedType.U1)] public bool DeletePending; + [MarshalAs(UnmanagedType.U1)] public bool Directory; + } + + [DllImport("kernel32.dll", SetLastError = true, EntryPoint = "GetFileInformationByHandleEx")] + static extern bool GetTagInfo(SafeFileHandle h, int cls, out FILE_ATTRIBUTE_TAG_INFO info, uint size); + + [DllImport("kernel32.dll", SetLastError = true, EntryPoint = "GetFileInformationByHandleEx")] + static extern bool GetFileIdInfo(SafeFileHandle h, int cls, out FILE_ID_INFO info, uint size); + + [DllImport("kernel32.dll", SetLastError = true, EntryPoint = "GetFileInformationByHandleEx")] + static extern bool GetStandardInfo(SafeFileHandle h, int cls, out FILE_STANDARD_INFO info, uint size); + + [DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)] + static extern uint GetFinalPathNameByHandleW(SafeFileHandle h, StringBuilder buf, uint n, uint flags); + + [DllImport("kernel32.dll", SetLastError = true)] + static extern IntPtr OpenProcess(uint access, bool inherit, int pid); + + [DllImport("kernel32.dll", SetLastError = true)] + static extern IntPtr GetCurrentProcess(); + + [DllImport("kernel32.dll", SetLastError = true)] + static extern bool CloseHandle(IntPtr h); + + [DllImport("kernel32.dll", SetLastError = true)] + static extern uint WaitForSingleObject(IntPtr h, uint ms); + + [DllImport("kernel32.dll", SetLastError = true)] + static extern bool GetProcessTimes(IntPtr h, out long create, out long exit, out long kernel, out long user); + + [DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)] + static extern bool QueryFullProcessImageNameW(IntPtr h, uint flags, StringBuilder buf, ref uint size); + + [DllImport("advapi32.dll", SetLastError = true)] + static extern bool OpenProcessToken(IntPtr h, uint access, out IntPtr token); + + [DllImport("advapi32.dll", SetLastError = true)] + static extern bool GetTokenInformation(IntPtr token, int cls, IntPtr buf, int len, out int needed); + + // Restart Manager reports resource users (openers). It is never proof of a + // byte-range lock holder. + [StructLayout(LayoutKind.Sequential)] + struct RM_UNIQUE_PROCESS { public int dwProcessId; public System.Runtime.InteropServices.ComTypes.FILETIME ProcessStartTime; } + + [StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)] + struct RM_PROCESS_INFO + { + public RM_UNIQUE_PROCESS Process; + [MarshalAs(UnmanagedType.ByValTStr, SizeConst = 256)] public string strAppName; + [MarshalAs(UnmanagedType.ByValTStr, SizeConst = 64)] public string strServiceShortName; + public int ApplicationType; + public uint AppStatus; + public uint TSSessionId; + [MarshalAs(UnmanagedType.Bool)] public bool bRestartable; + } + + [DllImport("rstrtmgr.dll", CharSet = CharSet.Unicode)] + static extern int RmStartSession(out uint session, int flags, StringBuilder key); + + [DllImport("rstrtmgr.dll", CharSet = CharSet.Unicode)] + static extern int RmRegisterResources(uint session, uint nFiles, string[] files, uint nApps, + IntPtr apps, uint nServices, string[] services); + + [DllImport("rstrtmgr.dll")] + static extern int RmGetList(uint session, out uint needed, ref uint count, + [In, Out] RM_PROCESS_INFO[] info, out uint rebootReasons); + + [DllImport("rstrtmgr.dll")] + static extern int RmEndSession(uint session); + + // Probe ranges. "rust" is Rust std File::lock/try_lock (offset 0, length + // u32::MAX:u32::MAX). "fsext" is the fs-ext 2.2.14 emulation (offset 0, + // length 0xffff0000). "beyond_fsext" is one byte just past the fs-ext range; + // it conflicts only with a holder whose range extends past 0xffff0000. + // "tail" is the last 64-bit offset, outside the Rust range; it is recorded + // only, because acceptance of that offset is not established. + static void Range(string name, out uint offLow, out uint offHigh, out uint lenLow, out uint lenHigh) + { + switch (name) + { + case "rust": offLow = 0; offHigh = 0; lenLow = uint.MaxValue; lenHigh = uint.MaxValue; return; + case "fsext": offLow = 0; offHigh = 0; lenLow = 0xffff0000; lenHigh = 0; return; + case "beyond_fsext": offLow = 0xffff0000; offHigh = 0; lenLow = 1; lenHigh = 0; return; + case "tail": offLow = uint.MaxValue; offHigh = uint.MaxValue; lenLow = 1; lenHigh = 0; return; + default: throw new ArgumentException("unknown range " + name); + } + } + + static readonly string[] ThreadRanges = { "rust", "fsext", "beyond_fsext", "tail" }; + + static string J(string s) + { + if (s == null) return "null"; + var b = new StringBuilder("\""); + foreach (char c in s) + { + if (c == '"' || c == '\\') b.Append('\\').Append(c); + // ASCII-only output survives any console code page. + else if (c < 0x20 || c > 0x7e) b.AppendFormat("\\u{0:x4}", (int)c); + else b.Append(c); + } + return b.Append('"').ToString(); + } + + static string Err(int code) + { + return "{\"ok\":false,\"win32\":" + code + ",\"message\":" + J(new Win32Exception(code).Message) + "}"; + } + + static string Ok(string extra) + { + return "{\"ok\":true" + (string.IsNullOrEmpty(extra) ? "" : "," + extra) + "}"; + } + + static string Unknown(string reason, int win32) + { + return "{\"ok\":true,\"state\":\"unknown\",\"reason\":" + J(reason) + ",\"win32\":" + win32 + "}"; + } + + static string Bool(bool v) { return v ? "true" : "false"; } + + // ---- Scope ----------------------------------------------------------- + static string Root; + + // Directory identity without following reparse points. It returns null for + // four different causes — the open failed, the attribute query failed, the + // target is a reparse point, or the target is not a directory — and does + // NOT distinguish them. Callers must not report a Win32 code next to a null + // from here: the handle is already closed by then, and two of the four + // causes set no error at all. The per-sample `*.identity` records carry the + // attributes, file ID and Win32 codes that do distinguish them. + public static string DirFileId(string path) + { + using (var d = OpenNoFollow(path, FILE_READ_ATTRIBUTES, "rwd")) + { + if (d.IsInvalid) return null; + uint attrs = Attributes(d); + if (attrs == uint.MaxValue || (attrs & FILE_ATTRIBUTE_REPARSE_POINT) != 0 || (attrs & FILE_ATTRIBUTE_DIRECTORY) == 0) return null; + return FileId(d); + } + } + + public static void SetRoot(string root) + { + string full = Path.GetFullPath(root).TrimEnd('\\'); + if (DirFileId(full) == null) throw new InvalidOperationException("observation root is not a plain directory"); + Root = full; + } + + static string Scoped(string path) + { + if (Root == null) throw new InvalidOperationException("observation root not set"); + string full = Path.GetFullPath(path); + if (!full.StartsWith(Root + "\\", StringComparison.OrdinalIgnoreCase)) + throw new UnauthorizedAccessException("path outside observation root"); + return full; + } + + static string Leaf(string name) + { + if (name.Length == 0 || name.IndexOfAny(new[] { '\\', '/', ':' }) >= 0 || name == "." || name == "..") + throw new ArgumentException("name must be a leaf"); + return name; + } + + // ---- File handles (never create, never write access) ----------------- + static uint Share(string spec) + { + uint s = 0; + foreach (char c in spec) + { + if (c == 'r') s |= FILE_SHARE_READ; + else if (c == 'w') s |= FILE_SHARE_WRITE; + else if (c == 'd') s |= FILE_SHARE_DELETE; + else if (c != '-') throw new ArgumentException("share spec " + spec); + } + return s; + } + + static SafeFileHandle OpenNoFollow(string path, uint access, string share) + { + return CreateFileW(path, access, Share(share), IntPtr.Zero, OPEN_EXISTING, + FILE_FLAG_BACKUP_SEMANTICS | FILE_FLAG_OPEN_REPARSE_POINT, IntPtr.Zero); + } + + static uint Attributes(SafeFileHandle h) + { + FILE_ATTRIBUTE_TAG_INFO tag; + return GetTagInfo(h, 9, out tag, (uint)Marshal.SizeOf(typeof(FILE_ATTRIBUTE_TAG_INFO))) + ? tag.FileAttributes : uint.MaxValue; + } + + static string FileId(SafeFileHandle h) + { + FILE_ID_INFO id; + if (!GetFileIdInfo(h, 18, out id, (uint)Marshal.SizeOf(typeof(FILE_ID_INFO)))) return null; + return id.VolumeSerialNumber.ToString("x16") + ":" + id.IdHigh.ToString("x16") + id.IdLow.ToString("x16"); + } + + static string FinalPath(SafeFileHandle h) + { + var buf = new StringBuilder(1024); + uint n = GetFinalPathNameByHandleW(h, buf, (uint)buf.Capacity, 0); + return n > 0 && n < buf.Capacity ? buf.ToString() : null; + } + + static bool IsRegular(SafeFileHandle h) + { + uint a = Attributes(h); + return a != uint.MaxValue && (a & (FILE_ATTRIBUTE_REPARSE_POINT | FILE_ATTRIBUTE_DIRECTORY)) == 0; + } + + static string IdentityFields(SafeFileHandle h) + { + FILE_ATTRIBUTE_TAG_INFO tag; + bool tagOk = GetTagInfo(h, 9, out tag, (uint)Marshal.SizeOf(typeof(FILE_ATTRIBUTE_TAG_INFO))); + int tagErr = tagOk ? 0 : Marshal.GetLastWin32Error(); + string id = FileId(h); + int idErr = id != null ? 0 : Marshal.GetLastWin32Error(); + FILE_STANDARD_INFO std; + bool stdOk = GetStandardInfo(h, 1, out std, (uint)Marshal.SizeOf(typeof(FILE_STANDARD_INFO))); + int stdErr = stdOk ? 0 : Marshal.GetLastWin32Error(); + string final = FinalPath(h); + bool complete = tagOk && id != null && stdOk && final != null; + return "\"complete\":" + Bool(complete) + + ",\"attributes\":" + (tagOk ? tag.FileAttributes.ToString() : "null") + + ",\"reparse\":" + (tagOk ? Bool((tag.FileAttributes & FILE_ATTRIBUTE_REPARSE_POINT) != 0) : "null") + + ",\"directory\":" + (tagOk ? Bool((tag.FileAttributes & FILE_ATTRIBUTE_DIRECTORY) != 0) : "null") + + ",\"tagWin32\":" + tagErr + + ",\"fileId\":" + J(id) + ",\"idWin32\":" + idErr + + ",\"links\":" + (stdOk ? std.NumberOfLinks.ToString() : "null") + + ",\"size\":" + (stdOk ? std.EndOfFile.ToString() : "null") + + ",\"deletePending\":" + (stdOk ? Bool(std.DeletePending) : "null") + + ",\"stdWin32\":" + stdErr + + ",\"finalPath\":" + J(final); + } + + // Attribute-only open: FILE_READ_ATTRIBUTES is exempt from sharing checks, + // so this observes identity without contending with the holder's share mode. + public static string Identity(string path) + { + string full = Scoped(path); + using (var h = OpenNoFollow(full, FILE_READ_ATTRIBUTES, "rwd")) + { + if (h.IsInvalid) return Err(Marshal.GetLastWin32Error()); + return Ok(IdentityFields(h)); + } + } + + // No-follow ancestry of a directory, root first. Each component is opened + // with FILE_FLAG_OPEN_REPARSE_POINT and read for attributes only, so a + // junction or symlink anywhere above the target is reported as a reparse + // point instead of being silently traversed. The caller refuses the path + // when any component is not a plain directory. + // + // This is NOT scoped (it runs before SetRoot, to validate the candidate + // temp root itself) and it is NOT a pin: components are opened one after + // another, so an ancestry that passes here can still be replaced + // afterwards. That residual window is why the native modes stay refused. + public static string Ancestry(string path) + { + string full = Path.GetFullPath(path); + string root = Path.GetPathRoot(full); + if (string.IsNullOrEmpty(root)) return Unknown("no_path_root", 0); + // Trim the separator only below the root: trimming "C:\" would leave the + // drive-relative spelling "C:", which names a different directory. + if (full.Length > root.Length) full = full.TrimEnd('\\'); + var parts = full.Substring(root.Length).Split(new[] { '\\' }, StringSplitOptions.RemoveEmptyEntries); + var b = new StringBuilder(); + bool complete = true; + string cur = root; + for (int i = 0; i <= parts.Length; i++) + { + if (i > 0) cur = Path.Combine(cur, parts[i - 1]); + string lexical = i == 0 ? root : cur; + using (var h = OpenNoFollow(lexical, FILE_READ_ATTRIBUTES, "rwd")) + { + if (b.Length > 0) b.Append(','); + if (h.IsInvalid) + { + complete = false; + b.Append("{\"path\":").Append(J(lexical)).Append(",\"fileId\":null,\"directory\":null,\"reparse\":null,\"win32\":") + .Append(Marshal.GetLastWin32Error()).Append('}'); + continue; + } + uint attrs = Attributes(h); + string id = FileId(h); + if (attrs == uint.MaxValue || id == null) complete = false; + b.Append("{\"path\":").Append(J(lexical)) + .Append(",\"fileId\":").Append(J(id)) + .Append(",\"directory\":").Append(attrs == uint.MaxValue ? "null" : Bool((attrs & FILE_ATTRIBUTE_DIRECTORY) != 0)) + .Append(",\"reparse\":").Append(attrs == uint.MaxValue ? "null" : Bool((attrs & FILE_ATTRIBUTE_REPARSE_POINT) != 0)) + .Append(",\"finalPath\":").Append(J(FinalPath(h))) + .Append(",\"win32\":0}"); + } + } + return Ok("\"complete\":" + Bool(complete) + ",\"entries\":[" + b + "]"); + } + + // Share-mode probe: open with one access right and one share mask, record + // the Win32 result, close immediately. "read" requests GENERIC_READ. + // "delete" requests the DELETE access right only to test whether the + // holder shares delete; no delete disposition, rename, or file removal is + // ever issued on that handle. + // + // The DEFAULT observer (observer.ps1) calls this only with the full share + // mask "rwd", so it never withholds a sharing mode the Codex holder relies + // on. A RESTRICTIVE share mask (one that omits write or delete sharing) can + // block the holder's own writes or its Drop-time delete, so such masks are + // a separately proposed, NOT-executed experiment (see README). Opening a + // handle at all is not provably side-effect free even with full sharing; + // requesting the DELETE access right is not a file-removal call, but that is + // not proof of no interference. + // FILE_READ_ATTRIBUTES is added to the DELETE probe because the file-type + // classification below (FileAttributeTagInfo) needs that right: a handle + // granted DELETE alone cannot be queried, so the probe would always report + // `not_regular_file` instead of answering whether the holder shares delete. + // The right is attribute-read only and is exempt from sharing checks, so it + // does not change what the probe withholds from the holder. GENERIC_READ + // already includes it. + public static string ShareProbe(string path, string access, string share) + { + string full = Scoped(path); + uint want; + if (access == "read") want = GENERIC_READ; + else if (access == "delete") want = DELETE | FILE_READ_ATTRIBUTES; + else return Err(87); + using (var h = OpenNoFollow(full, want, share)) + { + string tail = ",\"access\":" + J(access) + ",\"share\":" + J(share); + if (h.IsInvalid) return Ok("\"opened\":false,\"win32\":" + Marshal.GetLastWin32Error() + tail); + if (!IsRegular(h)) return Unknown("not_regular_file", 0); + return Ok("\"opened\":true,\"win32\":0" + tail); + } + } + + static int TryLock(SafeFileHandle h, string range) + { + uint ol, oh, ll, lh; + Range(range, out ol, out oh, out ll, out lh); + var ov = new NativeOverlapped(); + ov.OffsetLow = unchecked((int)ol); + ov.OffsetHigh = unchecked((int)oh); + return LockFileEx(h, LOCKFILE_EXCLUSIVE_LOCK | LOCKFILE_FAIL_IMMEDIATELY, 0, ll, lh, ref ov) + ? 0 : Marshal.GetLastWin32Error(); + } + + static int Unlock(SafeFileHandle h, string range) + { + uint ol, oh, ll, lh; + Range(range, out ol, out oh, out ll, out lh); + var ov = new NativeOverlapped(); + ov.OffsetLow = unchecked((int)ol); + ov.OffsetHigh = unchecked((int)oh); + return UnlockFileEx(h, 0, ll, lh, ref ov) ? 0 : Marshal.GetLastWin32Error(); + } + + static bool StandardOf(SafeFileHandle h, out uint links, out bool deletePending, out bool directory) + { + FILE_STANDARD_INFO std; + bool ok = GetStandardInfo(h, 1, out std, (uint)Marshal.SizeOf(typeof(FILE_STANDARD_INFO))); + links = ok ? std.NumberOfLinks : 0; + deletePending = ok && std.DeletePending; + directory = ok && std.Directory; + return ok; + } + + // Synchronous guarded range probe, bound to caller-validated expected + // identities. Lock order: native coordination guard, then thread probes. + // + // The lock directory, the coordination guard and the thread lock are all + // opened no-follow. Each one's identity is verified BEFORE any lock is + // taken: the expected FILE_ID_INFO of the directory, of the guard and of + // the thread lock must match, and the guard and thread lock must each be a + // regular, non-reparse, single-link file that is not pending delete. The + // link count is fixed at one here and is never taken from the caller. The + // guard and the directory are revalidated AFTER the guard is released, and + // any changed, unreadable or unreleasable identity makes the whole result + // `unknown`. No native file is created and there is no fallback. Handles + // use GENERIC_READ only; every successful probe lock is released before the + // next, and the guard is released in finally. No await, spawn, or signal + // happens while the guard is held. + // + // Limitation, not closed here: the directory, guard and thread handles are + // opened separately by path, so this binds identity by comparison at two + // points in time rather than pinning the path. A same-user actor can still + // replace a component between those points. It is documented, not asserted + // away, and it is one reason the native modes stay refused. + public static string GuardedRangeProbe(string lockDir, string threadLeaf, string expectDirId, string expectGuardId, string expectThreadId) + { + string dir = Scoped(lockDir); + string thread = Leaf(threadLeaf); + if (string.IsNullOrEmpty(expectDirId) || string.IsNullOrEmpty(expectGuardId) || string.IsNullOrEmpty(expectThreadId)) + return Unknown("expected_identity_missing", 0); + string dirBefore = DirFileId(dir); + // No Win32 code here: DirFileId cannot attribute its null (see above). + if (dirBefore == null) return Unknown("lock_directory_not_plain", 0); + if (dirBefore != expectDirId) return Unknown("lock_directory_identity_changed", 0); + using (var guard = OpenNoFollow(Path.Combine(dir, ".coordination.lock"), GENERIC_READ, "rwd")) + { + if (guard.IsInvalid) + { + int e = Marshal.GetLastWin32Error(); + return Unknown(e == 2 || e == 3 ? "missing_guard" : "guard_open", e); + } + if (!IsRegular(guard)) return Unknown("guard_not_regular", 0); + string guardId = FileId(guard); + if (guardId == null) return Unknown("guard_file_id", Marshal.GetLastWin32Error()); + if (guardId != expectGuardId) return Unknown("guard_identity_changed", 0); + uint guardLinks; bool guardDelete, guardDir; + if (!StandardOf(guard, out guardLinks, out guardDelete, out guardDir)) + return Unknown("guard_standard_info", Marshal.GetLastWin32Error()); + if (guardLinks != EXPECTED_LINKS || guardDelete || guardDir) return Unknown("guard_not_single_regular", 0); + string guardPath = FinalPath(guard); + if (guardPath == null) return Unknown("guard_final_path", Marshal.GetLastWin32Error()); + int g = TryLock(guard, "rust"); + if (g == ERROR_LOCK_VIOLATION) return Unknown("busy_guard", g); + if (g != 0) return Unknown("guard_lock_error", g); + string body; + bool guardReleased = false; + try + { + body = ProbeThreadRanges(Path.Combine(dir, thread), expectThreadId); + } + catch (Exception ex) + { + body = "\"state\":\"unknown\",\"reason\":\"probe_exception\",\"exception\":" + J(ex.GetType().Name + ": " + ex.Message); + } + finally + { + int gu = Unlock(guard, "rust"); + guardReleased = gu == 0; + if (gu != 0) body = "\"state\":\"unknown\",\"reason\":\"guard_unlock_failed\",\"win32\":" + gu; + } + // Revalidate the guard on the SAME handle and the directory by path + // after the guard was released. A failed RELEASE is the most + // serious state this code can reach (the observer may still hold an + // exclusive range on the live coordination guard), so its reason and + // Win32 code are kept and the revalidation never overwrites them. + string guardAfter = FileId(guard); + uint linksAfter; bool deleteAfter, dirAfter2; + bool stdAfter = StandardOf(guard, out linksAfter, out deleteAfter, out dirAfter2); + string dirAfter = DirFileId(dir); + if (!guardReleased) + { + // keep the guard_unlock_failed body + } + else if (guardAfter == null || guardAfter != expectGuardId || !stdAfter || linksAfter != EXPECTED_LINKS || deleteAfter) + body = "\"state\":\"unknown\",\"reason\":\"guard_identity_changed_after\""; + else if (dirAfter == null || dirAfter != expectDirId) + body = "\"state\":\"unknown\",\"reason\":\"lock_directory_identity_changed_after\""; + return Ok(body + + ",\"dirFileIdBefore\":" + J(dirBefore) + ",\"dirFileIdAfter\":" + J(dirAfter) + + ",\"guardFileId\":" + J(guardId) + ",\"guardFileIdAfter\":" + J(guardAfter) + + ",\"guardLinks\":" + guardLinks + ",\"guardDeletePending\":" + Bool(guardDelete) + + ",\"guardAcquired\":true,\"guardReleased\":" + Bool(guardReleased) + + ",\"guardFinalPath\":" + J(guardPath)); + } + } + + static string ProbeThreadRanges(string path, string expectThreadId) + { + using (var t = OpenNoFollow(path, GENERIC_READ, "rwd")) + { + if (t.IsInvalid) + { + int e = Marshal.GetLastWin32Error(); + return "\"state\":\"" + (e == 2 ? "absent" : "unknown") + "\",\"reason\":\"thread_open\",\"win32\":" + e; + } + if (!IsRegular(t)) return "\"state\":\"unknown\",\"reason\":\"thread_not_regular\",\"win32\":0"; + // Verify identity BEFORE taking any lock. + string beforeId = FileId(t); + uint linksBefore; bool delBefore, dirBefore; + if (!StandardOf(t, out linksBefore, out delBefore, out dirBefore)) + return "\"state\":\"unknown\",\"reason\":\"thread_standard_info\",\"win32\":" + Marshal.GetLastWin32Error(); + if (beforeId == null || beforeId != expectThreadId) + return "\"state\":\"unknown\",\"reason\":\"thread_identity_changed\",\"threadFileIdBefore\":" + J(beforeId); + if (linksBefore != EXPECTED_LINKS || delBefore || dirBefore) + return "\"state\":\"unknown\",\"reason\":\"thread_not_single_regular\",\"threadLinks\":" + linksBefore + + ",\"threadDeletePending\":" + Bool(delBefore); + var b = new StringBuilder(); + foreach (string r in ThreadRanges) + { + int rc = TryLock(t, r); + bool released = false; + if (rc == 0) + { + int un = Unlock(t, r); + released = un == 0; + if (un != 0) + return "\"state\":\"unknown\",\"reason\":\"thread_unlock_failed\",\"range\":" + J(r) + ",\"win32\":" + un; + } + if (b.Length > 0) b.Append(','); + b.Append(J(r)).Append(":{\"win32\":").Append(rc).Append(",\"acquired\":").Append(Bool(rc == 0)) + .Append(",\"released\":").Append(rc == 0 ? Bool(released) : "null").Append('}'); + } + // Revalidate the SAME handle AFTER all locks were released. + string afterId = FileId(t); + uint linksAfter; bool delAfter, dirAfter; + bool afterOk = StandardOf(t, out linksAfter, out delAfter, out dirAfter); + if (afterId == null || afterId != expectThreadId || !afterOk || linksAfter != EXPECTED_LINKS || delAfter || dirAfter) + return "\"state\":\"unknown\",\"reason\":\"thread_identity_changed_after\",\"threadFileIdAfter\":" + J(afterId); + return "\"state\":\"probed\"" + + ",\"threadFileIdBefore\":" + J(beforeId) + ",\"threadFileIdAfter\":" + J(afterId) + + ",\"threadLinks\":" + linksBefore + ",\"threadDeletePending\":" + Bool(delBefore) + + ",\"ranges\":{" + b + "}"; + } + } + + public static string RmList(string[] files) + { + var scoped = new string[files.Length]; + var names = new StringBuilder(); + for (int i = 0; i < files.Length; i++) + { + scoped[i] = Scoped(files[i]); + if (i > 0) names.Append(','); + names.Append(J(scoped[i])); + } + string target = ",\"files\":[" + names + "],\"interpretation\":\"opener_candidates_only\""; + uint session; + var key = new StringBuilder(64); + int rc = RmStartSession(out session, 0, key); + if (rc != 0) return Err(rc); + try + { + rc = RmRegisterResources(session, (uint)scoped.Length, scoped, 0, IntPtr.Zero, 0, null); + if (rc != 0) return Err(rc); + uint needed, count = 0, reasons; + RM_PROCESS_INFO[] info = null; + for (int attempt = 0; attempt < 4; attempt++) + { + rc = RmGetList(session, out needed, ref count, info, out reasons); + if (rc == 0) break; + if (rc != ERROR_MORE_DATA) return Err(rc); + count = needed; + info = new RM_PROCESS_INFO[needed]; + } + if (rc != 0) return Ok("\"complete\":false,\"reason\":\"list_kept_growing\"" + target); + var b = new StringBuilder(); + for (int i = 0; i < count; i++) + { + var p = info[i]; + long start = ((long)(uint)p.Process.ProcessStartTime.dwHighDateTime << 32) | (uint)p.Process.ProcessStartTime.dwLowDateTime; + if (i > 0) b.Append(','); + // The creation FILETIME exceeds 2^53, so it is emitted as an + // exact decimal STRING; a JSON number would lose precision and + // break the equality the evaluator relies on. + b.Append("{\"pid\":").Append(p.Process.dwProcessId) + .Append(",\"startFiletime\":").Append(J(start.ToString())) + .Append(",\"app\":").Append(J(p.strAppName)) + .Append(",\"service\":").Append(J(p.strServiceShortName)) + .Append(",\"type\":").Append(p.ApplicationType) + .Append(",\"session\":").Append(p.TSSessionId).Append('}'); + } + return Ok("\"complete\":true,\"count\":" + count + ",\"processes\":[" + b + "]" + target); + } + finally { RmEndSession(session); } + } + + // ---- Tokens and processes (query only) -------------------------------- + static string TokenFields(IntPtr process) + { + IntPtr token; + if (!OpenProcessToken(process, TOKEN_QUERY, out token)) + return "\"tokenOpened\":false,\"tokenWin32\":" + Marshal.GetLastWin32Error(); + try + { + string user = SidInfo(token, TokenUser); + string integrity = SidInfo(token, TokenIntegrityLevel); + int elevation = IntInfo(token, TokenElevationType); + int session = IntInfo(token, TokenSessionId); + return "\"tokenOpened\":true,\"userSid\":" + J(user) + ",\"integritySid\":" + J(integrity) + + ",\"elevationType\":" + (elevation < 0 ? "null" : elevation.ToString()) + + ",\"sessionId\":" + (session < 0 ? "null" : session.ToString()); + } + finally { CloseHandle(token); } + } + + // TokenUser and TokenIntegrityLevel both start with SID_AND_ATTRIBUTES. + static string SidInfo(IntPtr token, int cls) + { + int needed; + GetTokenInformation(token, cls, IntPtr.Zero, 0, out needed); + if (needed <= 0) return null; + IntPtr buf = Marshal.AllocHGlobal(needed); + try + { + if (!GetTokenInformation(token, cls, buf, needed, out needed)) return null; + return new SecurityIdentifier(Marshal.ReadIntPtr(buf)).Value; + } + finally { Marshal.FreeHGlobal(buf); } + } + + static int IntInfo(IntPtr token, int cls) + { + IntPtr buf = Marshal.AllocHGlobal(4); + try + { + int needed; + if (!GetTokenInformation(token, cls, buf, 4, out needed)) return -1; + return Marshal.ReadInt32(buf); + } + finally { Marshal.FreeHGlobal(buf); } + } + + public static string SelfToken() + { + // GetCurrentProcess returns a pseudo handle; it needs no CloseHandle. + return Ok(TokenFields(GetCurrentProcess()) + ",\"pid\":" + System.Diagnostics.Process.GetCurrentProcess().Id); + } + + // Query-only identity of a process the operator started. This opens a NEW + // handle on every call; it is not a run-long pinned handle, so a PID that is + // reused between calls is a distinct risk the caller must weigh (the + // creation FILETIME is the only cross-call witness, and it too is sampled, + // not pinned). Every result echoes the PID that was queried, so the + // evaluator can bind each record to the run's declared target instead of + // trusting the record's position in the file. Access failures (for example a + // Medium observer and a High target) are reported with their Win32 code; the + // caller maps them to unknown. Liveness comes only from WaitForSingleObject + // on this handle, never from STILL_ACTIVE. + public static string ProcessIdentity(int pid) + { + string queried = "\"pid\":" + pid; + IntPtr h = OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION | SYNCHRONIZE, false, pid); + if (h == IntPtr.Zero) return Ok(queried + ",\"opened\":false,\"state\":\"unknown\",\"reason\":\"open_process\",\"win32\":" + Marshal.GetLastWin32Error()); + try + { + uint w = WaitForSingleObject(h, 0); + if (w == WAIT_OBJECT_0) return Ok(queried + ",\"opened\":true,\"state\":\"exited\""); + if (w != WAIT_TIMEOUT) return Ok(queried + ",\"opened\":true,\"state\":\"unknown\",\"reason\":\"wait_failed\",\"win32\":" + Marshal.GetLastWin32Error()); + long create, exit, k, u; + if (!GetProcessTimes(h, out create, out exit, out k, out u)) + return Ok(queried + ",\"opened\":true,\"state\":\"unknown\",\"reason\":\"process_times\",\"win32\":" + Marshal.GetLastWin32Error()); + var img = new StringBuilder(32768); + uint size = (uint)img.Capacity; + string image = QueryFullProcessImageNameW(h, 0, img, ref size) ? img.ToString() : null; + string token = TokenFields(h); + uint w2 = WaitForSingleObject(h, 0); + string state = w2 == WAIT_OBJECT_0 ? "exited" : w2 == WAIT_TIMEOUT ? "live" : "unknown"; + // FILETIME exceeds 2^53; emit it as a string so JSON consumers keep + // exact equality (JavaScript numbers would round it). + return Ok(queried + ",\"opened\":true,\"state\":\"" + state + "\",\"createFiletime\":" + J(create.ToString()) + + ",\"image\":" + J(image) + "," + token); + } + finally { CloseHandle(h); } + } +} diff --git a/experimental/windows-native-observer/check.ps1 b/experimental/windows-native-observer/check.ps1 new file mode 100644 index 0000000..1c29996 --- /dev/null +++ b/experimental/windows-native-observer/check.ps1 @@ -0,0 +1,518 @@ +<# +Static gate for the W6/W7 observer. It + + 1. parses observer.ps1 and check.ps1, + 2. checks the syntax tree for assignments to automatic/read-only variables, + for forbidden commands, for the exact harness file-name list, and for the + ordering rules (the path gate and the native-mode refusal must both come + before Add-Type), + 3. evaluates the real pure helper functions taken from the observer's syntax + tree, including the bounded stream reader, + 4. validates its own paths and %TEMP%, and only then + 5. compiles WinObserver.cs with Add-Type without calling any native method. + +It creates no file of its own, starts no observer mode, takes no lock and +never runs the observer body. Add-Type does start the .NET compiler as a child +process, which writes compiler temporaries into %TEMP%; that is why step 4 +runs before step 5. A static gate means no observation and no native method +call, not no compiler process. + + powershell -NoProfile -NonInteractive -File check.ps1 + +Exit 0 when every check passed, 1 on any failure. +#> +$ErrorActionPreference = 'Stop' +$failed = $false +$observerAst = $null +$trees = @{} + +function Report([string]$text) { Write-Output $text } +function Fail([string]$text) { $script:failed = $true; Write-Output $text } + +# UserPath is public in Windows PowerShell 5.1. UnqualifiedPath is an +# implementation detail and is not exposed through the normal PS adapter. +function Get-DeclaredVariableName($VariablePath) { + $name = [string]$VariablePath.UserPath + if ([string]::IsNullOrWhiteSpace($name)) { throw 'AST variable has no public UserPath' } + if ($name -match '^(global|local|script|private):(.+)$') { return $Matches[2] } + return $name +} + +foreach ($case in @(@('home', 'home'), @('script:home', 'home'), @('global:PID', 'PID'), + @('local:Host', 'Host'), @('private:args', 'args'), @('env:TEMP', 'env:TEMP'))) { + $vp = New-Object System.Management.Automation.VariablePath($case[0]) + if ((Get-DeclaredVariableName $vp) -cne $case[1]) { Fail 'AST variable scope handling failed' } +} + +# Automatic and read-only PowerShell variables. Repurposing one of these is +# refused: $HOME in particular is read-only and variable names are +# case-insensitive, so `$home = ...` fails at runtime. +$Automatic = @('home', 'host', 'pid', 'error', 'args', 'input', 'matches', 'this', 'psitem', '_', 'profile', + 'pshome', 'psscriptroot', 'pscommandpath', 'psboundparameters', 'myinvocation', 'executioncontext', + 'true', 'false', 'null', 'lastexitcode', 'stacktrace', 'psculture', 'pscmdlet', 'switch', 'foreach') +$Forbidden = @('Stop-Process', 'Remove-Item', 'Set-Acl', 'Set-MpPreference', 'Set-ExecutionPolicy', + 'Invoke-Expression', 'Invoke-WebRequest', 'Start-Process', 'New-Service', 'Set-Content', 'Out-File', + 'Clear-Content', 'Move-Item', 'Rename-Item') +# A cmdlet denylist alone proves nothing: the same effects are reachable through +# .NET members, which are not CommandAst nodes. These member names are refused +# wherever they are invoked. The members this harness DOES use on purpose +# (Process::Start for `--version`, Directory::CreateDirectory for the +# disposable home, StreamWriter.Write for the results file, SetOwner and +# AddAccessRule on a NEW DirectorySecurity) are deliberately absent from the +# list and are described in the README's safety boundary instead. +$ForbiddenMembers = @('Kill', 'Delete', 'SetAccessControl', 'SetAttributes', 'Move', 'MoveTo', 'Replace', + 'Encrypt', 'Decrypt', 'WriteAllText', 'WriteAllBytes', 'WriteAllLines', 'AppendAllText', 'AppendAllLines', + 'SetCreationTime', 'SetLastWriteTime', 'TerminateProcess', 'CloseMainWindow', 'SetEnvironmentVariable') +$ExpectedHarness = @('observer.ps1', 'WinObserver.cs', 'check.ps1', 'expectations.mjs', 'lib.mjs', 'evaluate.mjs') + +foreach ($script in 'observer.ps1', 'check.ps1') { + $tokens = $null + $errors = $null + $parsed = [System.Management.Automation.Language.Parser]::ParseFile((Join-Path $PSScriptRoot $script), [ref]$tokens, [ref]$errors) + if ($errors.Count -gt 0) { + $failed = $true + foreach ($e in $errors) { Report ("parse error {0}:{1}: {2}" -f $script, $e.Extent.StartLineNumber, $e.Message) } + } else { + Report "parse ok: $script" + $trees[$script] = $parsed + if ($script -eq 'observer.ps1') { $observerAst = $parsed } + } +} + +# ---- Syntax-tree checks (no execution) ---------------------------------- +# Only variables that are assignment targets count. Walking every descendant +# would falsely reject `$object[$HOME] = ...` or `$object.Member++`. +function Get-WriteTargetVariables($target) { + if ($target -is [System.Management.Automation.Language.AttributedExpressionAst]) { + Get-WriteTargetVariables $target.Child + } elseif ($target -is [System.Management.Automation.Language.ArrayLiteralAst]) { + foreach ($element in $target.Elements) { Get-WriteTargetVariables $element } + } elseif ($target -is [System.Management.Automation.Language.VariableExpressionAst]) { + $target + } +} + +function Get-AutomaticWriteProblems($tree) { + foreach ($assignment in $tree.FindAll({ param($node) $node -is [System.Management.Automation.Language.AssignmentStatementAst] }, $true)) { + foreach ($variable in @(Get-WriteTargetVariables $assignment.Left)) { + $declaredName = Get-DeclaredVariableName $variable.VariablePath + if ($Automatic -contains $declaredName) { "assignment:$declaredName" } + } + } + foreach ($unary in $tree.FindAll({ param($node) $node -is [System.Management.Automation.Language.UnaryExpressionAst] }, $true)) { + if ($unary.TokenKind.ToString() -notmatch 'PlusPlus|MinusMinus') { continue } + foreach ($variable in @(Get-WriteTargetVariables $unary.Child)) { + $declaredName = Get-DeclaredVariableName $variable.VariablePath + if ($Automatic -contains $declaredName) { "mutation:$declaredName" } + } + } +} + +# This is deliberately an exact contract, not a general control-flow proof. +# The one true assignment must be top-level, and the exact refusal statement +# must immediately precede the sole Add-Type command in the same top-level +# try body. No additional reference (including a parameter or mutation) to the +# switch is accepted. These checks inspect ASTs; they never run the observer. +function Test-NativeRefusalAst($tree) { + $switchUses = @($tree.FindAll({ + param($node) + $node -is [System.Management.Automation.Language.VariableExpressionAst] -and + ($node.VariablePath.UserPath -match '(^|:)NATIVE_MODES_REFUSED$') + }, $true)) + if ($switchUses.Count -ne 2) { return $false } + $assignments = @($tree.FindAll({ + param($node) + $node -is [System.Management.Automation.Language.AssignmentStatementAst] -and + $node.Left.Extent.Text -ieq '$NATIVE_MODES_REFUSED' + }, $true)) + if ($assignments.Count -ne 1) { return $false } + $assignment = $assignments[0] + if ($assignment.Right.Extent.Text.Trim() -ine '$true' -or $assignment.Operator.ToString() -ne 'Equals') { return $false } + if (-not [object]::ReferenceEquals($assignment.Parent, $tree.EndBlock)) { return $false } + $compiles = @($tree.FindAll({ + param($node) + $node -is [System.Management.Automation.Language.CommandAst] -and $node.GetCommandName() -eq 'Add-Type' + }, $true)) + if ($compiles.Count -ne 1) { return $false } + $compilePipeline = $compiles[0].Parent + if ($compilePipeline -isnot [System.Management.Automation.Language.PipelineAst] -or $compilePipeline.PipelineElements.Count -ne 1) { return $false } + $block = $compilePipeline.Parent + if ($block -isnot [System.Management.Automation.Language.StatementBlockAst]) { return $false } + $setupTry = $block.Parent + if ($setupTry -isnot [System.Management.Automation.Language.TryStatementAst] -or + -not [object]::ReferenceEquals($setupTry.Body, $block) -or + -not [object]::ReferenceEquals($setupTry.Parent, $tree.EndBlock)) { return $false } + if ($assignment.Extent.EndOffset -ge $setupTry.Extent.StartOffset) { return $false } + $statements = @($block.Statements) + $index = -1 + for ($i = 0; $i -lt $statements.Count; $i++) { + if ([object]::ReferenceEquals($statements[$i], $compilePipeline)) { $index = $i; break } + } + if ($index -lt 1) { return $false } + $refusal = $statements[$index - 1] + if ($refusal -isnot [System.Management.Automation.Language.IfStatementAst] -or + $refusal.Clauses.Count -ne 1 -or $null -ne $refusal.ElseClause) { return $false } + $condition = $refusal.Clauses[0].Item1.Extent.Text -replace '\s+', '' + if ($condition -cne '$NATIVE_MODES_REFUSED-and($Mode-eq''preflight''-or$Mode-eq''observe'')') { return $false } + $body = $refusal.Clauses[0].Item2.Statements + if ($body.Count -ne 1 -or ($body[0].Extent.Text -replace '\s+', ' ').Trim() -cne 'Refuse $NativeRefusalReason') { return $false } + $true +} + +function Parse-Only([string]$text) { + $parseTokens = $null + $parseErrors = $null + $parsedTree = [System.Management.Automation.Language.Parser]::ParseInput($text, [ref]$parseTokens, [ref]$parseErrors) + if ($parseErrors.Count -ne 0) { throw 'AST mutation fixture did not parse' } + $parsedTree +} + +# Literal source-text substitution for AST-only fixtures. Do not use a member +# named Replace: the same name is forbidden for filesystem replacement APIs. +function Replace-SourceText([string]$text, [string]$needle, [string]$replacement) { + if ([string]::IsNullOrEmpty($needle)) { throw 'empty source-text mutation anchor' } + [string]::Join($replacement, $text.Split([string[]]@($needle), [StringSplitOptions]::None)) +} + +foreach ($name in $trees.Keys) { + $tree = $trees[$name] + foreach ($problem in @(Get-AutomaticWriteProblems $tree)) { + Fail ("automatic variable write in {0}: {1}" -f $name, $problem) + } + $loops = $tree.FindAll({ param($node) $node -is [System.Management.Automation.Language.ForEachStatementAst] }, $true) + foreach ($loop in $loops) { + $varName = Get-DeclaredVariableName $loop.Variable.VariablePath + if ($Automatic -contains $varName.ToLowerInvariant()) { + Fail ("automatic variable used as a loop variable in {0} line {1}: `${2}" -f $name, $loop.Extent.StartLineNumber, $varName) + } + } + $params = $tree.FindAll({ param($node) $node -is [System.Management.Automation.Language.ParameterAst] }, $true) + foreach ($p in $params) { + $varName = Get-DeclaredVariableName $p.Name.VariablePath + if ($Automatic -contains $varName.ToLowerInvariant()) { + Fail ("automatic variable used as a parameter in {0} line {1}: `${2}" -f $name, $p.Extent.StartLineNumber, $varName) + } + } + $commands = $tree.FindAll({ param($node) $node -is [System.Management.Automation.Language.CommandAst] }, $true) + foreach ($c in $commands) { + $called = $c.GetCommandName() + if ($called -and ($Forbidden -contains $called)) { + Fail ("forbidden command in {0} line {1}: {2}" -f $name, $c.Extent.StartLineNumber, $called) + } + } + # The same effects through .NET members, which are not commands. + $members = $tree.FindAll({ param($node) $node -is [System.Management.Automation.Language.InvokeMemberExpressionAst] }, $true) + foreach ($m in $members) { + $called = $m.Member.Extent.Text + if ($called -and ($ForbiddenMembers -contains $called)) { + Fail ("forbidden member call in {0} line {1}: {2}" -f $name, $m.Extent.StartLineNumber, $called) + } + } +} +if (-not $failed) { Report 'syntax-tree checks ok (no automatic-variable assignment, no forbidden cmdlet or .NET member)' } + +if ($null -ne $observerAst) { + $commands = $observerAst.FindAll({ param($node) $node -is [System.Management.Automation.Language.CommandAst] }, $true) + $offsetOf = { + param($commandName) + $found = @($commands | Where-Object { $_.GetCommandName() -eq $commandName } | Sort-Object { $_.Extent.StartOffset }) + if ($found.Count -eq 0) { -1 } else { $found[0].Extent.StartOffset } + } + $addTypeAt = & $offsetOf 'Add-Type' + $gateAt = & $offsetOf 'Assert-PathGate' + if ($gateAt -lt 0) { Fail 'observer.ps1 never calls Assert-PathGate' } + elseif ($addTypeAt -lt 0) { Fail 'observer.ps1 never calls Add-Type' } + elseif ($gateAt -gt $addTypeAt) { Fail 'observer.ps1 compiles before its path and TEMP gate' } + else { Report 'ordering ok: observer.ps1 gates paths and TEMP before Add-Type' } + if (-not (Test-NativeRefusalAst $observerAst)) { Fail 'observer.ps1 lost the exact top-level native refusal contract' } + else { Report 'refusal ok: exact native refusal immediately precedes Add-Type in the same setup try' } + try { + $source = $observerAst.Extent.Text + $line = 'if ($NATIVE_MODES_REFUSED -and ($Mode -eq ''preflight'' -or $Mode -eq ''observe'')) { Refuse $NativeRefusalReason }' + if (-not $source.Contains($line)) { throw 'native refusal mutation anchor missing' } + $compile = 'Add-Type -Path (Join-Path $PSScriptRoot ''WinObserver.cs'')' + if (-not $source.Contains($compile)) { throw 'compile mutation anchor missing' } + $variants = @{ + removed = Replace-SourceText $source $line '' + moved = Replace-SourceText (Replace-SourceText $source $line '') $compile ($compile + "`n" + $line) + conditional_false = Replace-SourceText $source $line ('if ($false) { ' + $line + ' }') + condition_and_false = Replace-SourceText $source '$NATIVE_MODES_REFUSED -and ($Mode' '$NATIVE_MODES_REFUSED -and $false -and ($Mode' + second_assignment = Replace-SourceText $source $line ('$NATIVE_MODES_REFUSED = $false' + "`n" + $line) + parameter_override = ('param([bool]$NATIVE_MODES_REFUSED)' + "`n" + '$NATIVE_MODES_REFUSED = $true' + "`ntry { " + $line + "`n" + $compile + ' } catch {}') + env_override = Replace-SourceText $source '$NATIVE_MODES_REFUSED = $true' '$NATIVE_MODES_REFUSED = $env:NATIVE_MODES_REFUSED' + } + foreach ($variant in $variants.Keys) { + if (Test-NativeRefusalAst (Parse-Only $variants[$variant])) { throw "native refusal mutation accepted: $variant" } + } + foreach ($text in '$HOME = 1', '$script:home = 1', '$local:PID, $ordinary = 1, 2', '++$global:host', '$private:HOME--') { + if (@(Get-AutomaticWriteProblems (Parse-Only $text)).Count -eq 0) { throw "automatic variable mutation missed: $text" } + } + foreach ($text in '$ordinary = 1', '$ordinary++', '$object.home++', '$object[$HOME] = 1', '$env:HOME = ''example''') { + if (@(Get-AutomaticWriteProblems (Parse-Only $text)).Count -ne 0) { throw "ordinary target falsely rejected: $text" } + } + Report 'AST-only mutation checks ok: refusal variants and scoped/array/unary variable writes' + } catch { Fail ('AST-only mutation check error: ' + $_.Exception.Message) } + # The harness digest list must be exactly the evaluator's file-name set. + $harness = $observerAst.Find({ + param($node) + $node -is [System.Management.Automation.Language.AssignmentStatementAst] -and + $node.Left.Extent.Text -eq '$HarnessNames' + }, $true) + if ($null -eq $harness) { Fail 'observer.ps1 has no $HarnessNames list' } + else { + $names = @($harness.Right.Find({ param($node) $node -is [System.Management.Automation.Language.ArrayLiteralAst] }, $true).Elements | + ForEach-Object { $_.Value }) + if (@(Compare-Object ($names | Sort-Object) ($ExpectedHarness | Sort-Object)).Count -ne 0) { + Fail ('observer.ps1 $HarnessNames differs from the expected file-name set: ' + ($names -join ',')) + } else { Report 'harness file-name set ok' } + } +} + +# ---- Pure helper functions, taken from the observer's syntax tree -------- +# Each group of assertions runs in its own try/catch. A single `try` around +# everything would report only the first failure and silently skip the rest, +# which defeats the purpose of a gate that is supposed to say WHAT drifted. +function Invoke-CheckGroup([string]$Label, [scriptblock]$Body) { + try { + & $Body + Report "pure helper checks ok: $Label" + } catch { + Fail ("pure helper check error ($Label): " + $_.Exception.Message) + } +} + +$helpersLoaded = $false +if ($null -ne $observerAst) { + try { + foreach ($name in 'Get-PathRelation', 'Test-LocalPathSpelling', 'Test-InsideDisposableDir', 'Test-PathGateSafe', + 'Test-CandidateTempSafe', 'Invoke-GuardedCreate', 'Invoke-GuardedSample', 'New-StreamChannel', + 'Read-BothStreams', 'Read-BoundedStream', 'Get-OpenerRole', 'Get-Dotted', 'Test-Denied', 'Test-Expect', + 'Test-SameDigests') { + $fn = $observerAst.Find({ + param($node) + $node -is [System.Management.Automation.Language.FunctionDefinitionAst] -and $node.Name -eq $name + }, $true) + if ($null -eq $fn) { throw "missing pure helper $name" } + . ([scriptblock]::Create($fn.Extent.Text)) + } + $helpersLoaded = $true + Report 'pure helpers loaded from the observer syntax tree' + } catch { + Fail ('pure helper load error: ' + $_.Exception.Message) + } +} + +if ($helpersLoaded) { + Invoke-CheckGroup 'path relations' { + $cases = @( + @('C:\Temp\A', 'c:\temp\a\', 'same'), + @('C:\Temp\A\home', 'C:\Temp\A', 'inside'), + @('C:\Users\op', 'C:\Users\op\.codex', 'contains'), + @('C:\Users\op\.codex2', 'C:\Users\op\.codex', 'disjoint') + ) + foreach ($c in $cases) { + if ((Get-PathRelation $c[0] $c[1]) -ne $c[2]) { throw ('path relation regression: ' + ($c -join ' ')) } + } + # A drive root contains everything on its volume. This is why the + # observer never gates a path's PARENT on this relation. + if ((Get-PathRelation 'C:\' 'C:\Users\op\.codex') -ne 'contains') { throw 'a drive root must report contains' } + } + + Invoke-CheckGroup 'path spelling' { + # Only absolute local drive spellings are accepted; an ambiguous + # spelling is refused, never rewritten. + foreach ($good in 'C:\Temp', 'C:\', 'c:\Users\op\.codex', 'D:\a\b\c', 'C:\Temp\') { + if (-not (Test-LocalPathSpelling $good)) { throw "accepted spelling rejected: $good" } + } + foreach ($bad in '\\?\C:\Temp', '\\.\C:\Temp', '\\server\share\x', '\rooted\x', 'C:relative', 'relative\x', + 'C:/Temp/x', 'C:\Temp\\x', 'C:\Temp\..\Users', 'C:\Temp\.', 'C:\Temp\x ', 'C:\Temp\x.', 'C:\Temp\ x', + 'C:\Temp\a:b', 'C:\Temp\a*b', 'C:\Temp\a?b', 'C:\Temp\a|b', '') { + if (Test-LocalPathSpelling $bad) { throw "ambiguous spelling accepted: $bad" } + } + } + + Invoke-CheckGroup 'disposable directories' { + # Matched per component, literally. + if (-not (Test-InsideDisposableDir 'C:\Temp\codex-win-obs-0123456789ab\home')) { throw 'disposable directory not detected' } + if (-not (Test-InsideDisposableDir 'D:\scratch\codex-win-obs-0123456789ab')) { throw 'disposable directory on another volume not detected' } + if (Test-InsideDisposableDir 'C:\Temp\codex-win-obs-0123456789abc') { throw 'a longer suffix must not match' } + if (Test-InsideDisposableDir 'C:\Temp\codex-win-obs-0123456789AB') { throw 'the hex suffix is lowercase only' } + if (Test-InsideDisposableDir 'C:\Temp\codex-win-obs-[0-9a-f]') { throw 'the component match must be literal, not a wildcard' } + } + + Invoke-CheckGroup 'path gate' { + if ($null -ne (Test-PathGateSafe 'C:\Users\op\Documents\out.jsonl' 'C:\Users\op\.codex' $null $false)) { throw 'a safe output path must pass the gate' } + if ((Test-PathGateSafe 'C:\Users\op\.codex\out.jsonl' 'C:\Users\op\.codex' $null $false) -ne 'user_home_overlap') { throw 'an output inside the real home must be refused' } + if ((Test-PathGateSafe 'C:\Users\op\out.jsonl' 'C:\Users\op\.codex' 'C:\Users\op' $false) -ne 'env_home_overlap') { throw 'an output inside CODEX_HOME must be refused' } + if ((Test-PathGateSafe 'C:\Temp\codex-win-obs-0123456789ab\o.jsonl' 'C:\Users\op\.codex' $null $false) -ne 'inside_disposable_dir') { throw 'an output inside a disposable directory must be refused' } + if ($null -ne (Test-PathGateSafe 'C:\Temp\codex-win-obs-0123456789ab\home' 'C:\Users\op\.codex' $null $true)) { throw 'the disposable home itself must be allowed' } + if ((Test-PathGateSafe '\\server\share\o.jsonl' 'C:\Users\op\.codex' $null $false) -ne 'path_spelling') { throw 'a UNC output must be refused' } + } + + Invoke-CheckGroup 'creation seam' { + # TEMP inside a real home must refuse, and the creation callback must + # never run. + if (Test-CandidateTempSafe 'C:\Users\op\.codex\AppData\Temp' 'C:\Users\op\.codex' $null) { throw 'TEMP inside a real home must be refused' } + if (Test-CandidateTempSafe 'C:\Users\op\AppData\Local\Temp' 'C:\Users\op\.codex' 'C:\Users\op\AppData\Local\Temp\x') { throw 'TEMP containing the env home must be refused' } + if (-not (Test-CandidateTempSafe 'C:\Users\op\AppData\Local\Temp' 'C:\Users\op\.codex' $null)) { throw 'disjoint TEMP must be allowed' } + $script:created = $false + $refused = Invoke-GuardedCreate { Test-CandidateTempSafe 'C:\Users\op\.codex\AppData\Temp' 'C:\Users\op\.codex' $null } { $script:created = $true; [pscustomobject]@{ created = $true } } + if ($script:created) { throw 'creation callback ran despite an unsafe TEMP' } + if ($refused.created -ne $false) { throw 'guarded create must report not created on refusal' } + $script:created = $false + $allowed = Invoke-GuardedCreate { Test-CandidateTempSafe 'C:\Users\op\AppData\Local\Temp' 'C:\Users\op\.codex' $null } { $script:created = $true; [pscustomobject]@{ created = $true } } + if (-not $script:created -or $allowed.created -ne $true) { throw 'guarded create must run the callback when the gate passes' } + } + + Invoke-CheckGroup 'per-sample seam' { + # The gate scriptblock is built from a LITERAL so the assertion cannot + # accidentally compare the seam's own variable with itself: a gate that + # dropped or rewrote the reason would otherwise still pass. + foreach ($expected in 'process_exited', 'create_filetime_changed', 'process_query_failed', 'token_unreadable', 'user_sid_differs') { + $script:ran = $false + $gate = [scriptblock]::Create(("'" + $expected + "'")) + $sample = Invoke-GuardedSample $gate { $script:ran = $true; 'probed' } + if ($script:ran) { throw "sample operations ran despite the gate reason $expected" } + if ($sample.ran -ne $false) { throw 'guarded sample must report that it did not run' } + if ($sample.reason -cne $expected) { throw ('guarded sample reported reason ' + $sample.reason + ' instead of ' + $expected) } + } + $script:ran = $false + $sample = Invoke-GuardedSample { $null } { $script:ran = $true; 'probed' } + if (-not $script:ran -or $sample.ran -ne $true -or $sample.result -ne 'probed') { throw 'guarded sample must run when its gate passes' } + } + + Invoke-CheckGroup 'opener roles' { + # Recomputed, never read from a record. + if ((Get-OpenerRole 7 '132' 7 '132' 5) -ne 'launched_codex') { throw 'the launched process must be labelled from PID and creation time' } + if ((Get-OpenerRole 7 '999' 7 '132' 5) -ne 'other') { throw 'a reused PID must not be labelled as the launched process' } + if ((Get-OpenerRole 7 '132' 7 '' 5) -ne 'other') { throw 'without a creation witness nothing is the launched process' } + if ((Get-OpenerRole 5 '500' 7 '132' 5) -ne 'observer') { throw 'the observer must be labelled' } + if ((Get-OpenerRole 9 '500' 7 '132' 5) -ne 'other') { throw 'an unrelated opener must be other' } + } + + Invoke-CheckGroup 'bounded stream reads' { + # End of stream, exactly at the cap, overflow, a failing stream, and a + # stream that never ends. Only `complete` is usable evidence, and the + # deadline is shared. + $clock = [Diagnostics.Stopwatch]::StartNew() + $small = New-Object IO.MemoryStream (, [Text.Encoding]::UTF8.GetBytes('codex-cli 0.163.0')) + $read = Read-BoundedStream $small 65536 $clock 5000 + if ($read.complete -ne $true -or $read.truncated -ne $false -or $read.timedOut -ne $false -or $read.error) { throw 'a short stream must read completely' } + if ($read.text -cne 'codex-cli 0.163.0') { throw 'the decoded text must be exact' } + + $exact = New-Object IO.MemoryStream (, (New-Object byte[] 16)) + $read = Read-BoundedStream $exact 16 $clock 5000 + if ($read.complete -ne $true -or $read.truncated -ne $false) { throw 'exactly the cap is end of stream, not an overflow' } + if ($read.bytes -ne 16) { throw 'exactly the cap must keep every byte' } + + $over = New-Object IO.MemoryStream (, (New-Object byte[] 100)) + $read = Read-BoundedStream $over 16 $clock 5000 + if ($read.truncated -ne $true -or $read.complete -eq $true) { throw 'more than the cap must be an overflow, never complete' } + + $closed = New-Object IO.MemoryStream + $closed.Dispose() + $read = Read-BoundedStream $closed 1024 $clock 5000 + if (-not $read.error -or $read.complete -eq $true) { throw 'a failing stream must record its error and never be complete' } + + # A pipe whose write end is still held (as an inherited descendant would + # hold it) never reaches end of stream, so the deadline must bound it + # without anything being terminated. Each case gets a FRESH pipe: a + # stream serializes its async reads, so reusing one would make the + # second case time out because it was queued, not because it blocked. + # Disposing a pipe with a read still pending faults that orphaned task; + # nothing observes it, which is benign under the default .NET policy. + $blocked = New-Object IO.Pipes.AnonymousPipeServerStream ([IO.Pipes.PipeDirection]::In) + try { + $deadline = [Diagnostics.Stopwatch]::StartNew() + $read = Read-BoundedStream $blocked 1024 $deadline 200 + if ($read.timedOut -ne $true -or $read.complete -eq $true) { throw 'an open pipe must time out, never complete' } + if ($deadline.ElapsedMilliseconds -gt 4000) { throw 'the deadline did not bound the read' } + } finally { $blocked.Dispose() } + + $blocked2 = New-Object IO.Pipes.AnonymousPipeServerStream ([IO.Pipes.PipeDirection]::In) + try { + # Concurrency: a blocked stream must not starve a finished one. + $both = @( + (New-StreamChannel (New-Object IO.MemoryStream (, [Text.Encoding]::UTF8.GetBytes('out'))) 1024), + (New-StreamChannel $blocked2 1024) + ) + $pair = [Diagnostics.Stopwatch]::StartNew() + Read-BothStreams $both $pair 200 | Out-Null + if ($both[0].complete -ne $true -or $both[0].text -cne 'out') { throw 'a finished stream must complete even beside a blocked one' } + if ($both[1].timedOut -ne $true) { throw 'the blocked stream must be reported as timed out' } + } finally { $blocked2.Dispose() } + } + + Invoke-CheckGroup 'expectation and digest helpers' { + $probe = [pscustomobject]@{ ok = $true; state = 'probed'; ranges = [pscustomobject]@{ rust = [pscustomobject]@{ win32 = 33 } } } + if ((Test-Expect $probe @{ state = 'probed'; 'ranges.rust.win32' = 33 }) -ne 'match') { throw 'dotted hypothesis must match' } + if ((Test-Expect $probe @{ 'ranges.fsext.win32' = 33 }) -ne 'mismatch') { throw 'missing dotted field must not match' } + if ((Test-Expect $null @{ ok = $true }) -ne 'incomplete') { throw 'missing observation must be incomplete' } + if ((Test-Expect ([pscustomobject]@{ ok = $false; win32 = 2 }) $null) -ne 'error') { throw 'native error must be error' } + if ((Test-Expect ([pscustomobject]@{ ok = $true; complete = $false }) @{ ok = $true }) -ne 'error') { throw 'partial must be error' } + # A usable observation with no hypothesis is recorded, not a match: the + # observer relies on that for its harness and manifest records. + if ((Test-Expect ([pscustomobject]@{ ok = $true; complete = $true }) $null) -ne 'recorded') { throw 'no hypothesis must be recorded' } + $denied = [pscustomobject]@{ ok = $true; opened = $false; win32 = 5 } + if ((Test-Expect $denied @{ opened = $false; win32 = 32 }) -ne 'unknown') { throw 'access denied must be unknown' } + $deep = [pscustomobject]@{ ok = $true; list = @([pscustomobject]@{ tokenWin32 = 5 }) } + if (-not (Test-Denied $deep)) { throw 'nested access denial must be found' } + if ((Test-Expect ([pscustomobject]@{ ok = $true; state = 'unknown' }) @{ state = 'live' }) -ne 'unknown') { throw 'unknown state must be unknown' } + if ((Test-Expect ([pscustomobject]@{ ok = $true; opened = $false }) @{ opened = $false }) -ne 'match') { throw 'false hypothesis must match false' } + if ((Get-Dotted $probe 'ranges.rust.win32') -ne 33) { throw 'dotted lookup must reach a nested value' } + if ($null -ne (Get-Dotted $probe 'ranges.absent.win32')) { throw 'a missing dotted path must be null' } + $a = [pscustomobject]@{ 'x.ps1' = 'aa'; 'y.cs' = 'bb' } + if (-not (Test-SameDigests $a ([pscustomobject]@{ 'y.cs' = 'bb'; 'x.ps1' = 'aa' }))) { throw 'equal digests must compare equal' } + if (Test-SameDigests $a ([pscustomobject]@{ 'x.ps1' = 'aa'; 'y.cs' = 'BB' })) { throw 'digests compare case-sensitively' } + if (Test-SameDigests $a ([pscustomobject]@{ 'x.ps1' = 'aa' })) { throw 'missing digest must differ' } + } +} + +# ---- This script's own path and TEMP gate, before Add-Type --------------- +# The compiler Add-Type starts writes its temporaries into %TEMP%, so %TEMP% +# and this directory must clear the real-home boundary first. The helpers used +# here are the observer's own, taken from its syntax tree above. +function Assert-PathGate { + if (-not (Get-Command Test-PathGateSafe -ErrorAction SilentlyContinue)) { throw 'the path gate helpers were not loaded' } + $userProfile = $env:USERPROFILE + if (-not $userProfile -or -not (Test-LocalPathSpelling $userProfile)) { throw 'USERPROFILE is unset or not an accepted absolute local path' } + $userCodexHome = [IO.Path]::GetFullPath((Join-Path $userProfile '.codex')) + $envCodexHome = $null + if ($env:CODEX_HOME) { $envCodexHome = [IO.Path]::GetFullPath($env:CODEX_HOME) } + $tempRoot = $null + if ($env:TEMP) { $tempRoot = [IO.Path]::GetFullPath($env:TEMP) } + if (-not $tempRoot) { throw 'TEMP is not set' } + $tempReason = Test-PathGateSafe $tempRoot $userCodexHome $envCodexHome $false + if ($tempReason) { throw "TEMP is not usable as a compiler scratch root ($tempReason)" } + $here = [IO.Path]::GetFullPath($PSScriptRoot) + $hereReason = Test-PathGateSafe $here $userCodexHome $envCodexHome $false + if ($hereReason) { throw "the harness directory is not usable ($hereReason)" } + [pscustomobject]@{ tempRoot = $tempRoot; scriptRoot = $here; userCodexHome = $userCodexHome; envCodexHome = $envCodexHome } +} + +$gateOk = $false +try { + $PathGate = Assert-PathGate + Report ('path gate ok: TEMP ' + $PathGate.tempRoot + ' is outside every real Codex home') + $gateOk = $true +} catch { + Fail ('path gate error: ' + $_.Exception.Message) +} + +if ($gateOk) { + try { + Add-Type -Path (Join-Path $PSScriptRoot 'WinObserver.cs') + $methods = [WinObserver].GetMethods() | Select-Object -ExpandProperty Name + foreach ($m in 'SelfToken', 'ProcessIdentity', 'Identity', 'Ancestry', 'ShareProbe', 'GuardedRangeProbe', 'RmList', 'DirFileId', 'SetRoot') { + if ($methods -notcontains $m) { throw "missing method $m" } + } + Report 'compile ok: WinObserver.cs (no method called)' + } catch { + Fail ('compile error: ' + $_.Exception.Message) + } +} else { + Report 'skipped the compile check because the path gate failed' +} + +if ($failed) { exit 1 } +exit 0 diff --git a/experimental/windows-native-observer/evaluate.mjs b/experimental/windows-native-observer/evaluate.mjs new file mode 100644 index 0000000..4f2dfa3 --- /dev/null +++ b/experimental/windows-native-observer/evaluate.mjs @@ -0,0 +1,141 @@ +// Offline evaluator for W6/W7 observer evidence (#151, #152). Reads only the +// JSONL files named on the command line. It never runs Codex, opens a Codex +// home, takes locks, or signals processes. Not part of npm test or npm files. +// +// node evaluate.mjs table +// node evaluate.mjs run --label medium|high --preflight P --observe O --baseline B.json +// node evaluate.mjs pair --medium-preflight P --medium-observe O --high-preflight P --high-observe O --baseline B.json [--explanations E.json] +// node evaluate.mjs pair-fixture --medium-lab L --medium-meta M --high-lab L --high-meta M [--explanations E.json] +// +// --baseline is the operator-asserted, independently established evidence: +// {"sourceCommit":"<40 hex>","binarySha256":"<64 hex>","version":"","harness":{"":"<64 hex>",...}}, +// where the harness map holds exactly the harness file names (expectations.mjs, +// HARNESS_FILES) and their exact checkout bytes, line endings included. The +// formats and that name set are validated here; where the operator obtained +// the baseline cannot be shown by this tool. Without a format-valid baseline, +// provenance rows stay `unknown` and the run can never pass. +// +// `pair` additionally requires a validated provenance state on both sides. +// `pair-fixture` is always `unknown`: the existing fixture producer is not +// bound to its own bytes, run or source, so that comparison is deferred until +// a separately reviewed producer proposal binds it. +// +// Exit codes: 0 = W7 pass / W6 compared; 1 = at least one mismatch / W6 fail; +// 2 = unknown (missing, partial, denied, or inconsistent evidence); 64 = usage. +import { createHash } from 'node:crypto' +import { readFileSync } from 'node:fs' +import console from 'node:console' +import process from 'node:process' +import { parseArgs } from 'node:util' +import { evaluateRun, fixtureSummary, pairSummaries, parseJsonl, renderExpectationTable } from './lib.mjs' + +class EvidenceInputError extends Error { + constructor(reason) { super(reason); this.reason = reason } +} + +function readInput(path) { + try { return readFileSync(path) } + catch { throw new EvidenceInputError('input_unreadable') } +} + +function load(path) { + const bytes = readInput(path) + const parsed = parseJsonl(bytes.toString('utf8')) + return { ...parsed, sha256: createHash('sha256').update(bytes).digest('hex') } +} + +function jsonObject(path, what) { + if (!path) return null + const bytes = readInput(path) + let value + try { value = JSON.parse(bytes.toString('utf8')) } + catch { throw new EvidenceInputError(`${what}_invalid_json`) } + if (value === null || typeof value !== 'object' || Array.isArray(value)) throw new EvidenceInputError(`${what}_not_object`) + return value +} + +function run(label, preflightPath, observePath, baseline) { + const preflight = load(preflightPath) + const observe = load(observePath) + return evaluateRun({ preflight, observe, preflightSha256: preflight.sha256, expectedLabel: label, baseline }) +} + +function exitFor(status) { + if (status === 'pass' || status === 'compared') return 0 + if (status === 'fail') return 1 + return 2 +} + +function usage(message) { + console.error(message) + console.error('usage: evaluate.mjs table | run | pair | pair-fixture (see file header)') + process.exit(64) +} + +function main() { + const [command, ...rest] = process.argv.slice(2) + let values + try { + ({ values } = parseArgs({ + args: rest, + options: { + label: { type: 'string' }, + preflight: { type: 'string' }, + observe: { type: 'string' }, + baseline: { type: 'string' }, + 'medium-preflight': { type: 'string' }, + 'medium-observe': { type: 'string' }, + 'high-preflight': { type: 'string' }, + 'high-observe': { type: 'string' }, + 'medium-lab': { type: 'string' }, + 'medium-meta': { type: 'string' }, + 'high-lab': { type: 'string' }, + 'high-meta': { type: 'string' }, + explanations: { type: 'string' }, + }, + strict: true, + })) + } catch (error) { + usage(error.message) + } + if (command === 'table') { + process.stdout.write(renderExpectationTable()) + process.exit(0) + } + if (command === 'run') { + if (!['medium', 'high'].includes(values.label) || !values.preflight || !values.observe) usage('run needs --label, --preflight and --observe') + const summary = run(values.label, values.preflight, values.observe, jsonObject(values.baseline, 'baseline')) + console.log(JSON.stringify(summary, null, 2)) + process.exit(exitFor(summary.w7)) + } + if (command === 'pair') { + const need = ['medium-preflight', 'medium-observe', 'high-preflight', 'high-observe'] + if (need.some((k) => !values[k])) usage('pair needs --' + need.join(', --')) + const baseline = jsonObject(values.baseline, 'baseline') + const medium = run('medium', values['medium-preflight'], values['medium-observe'], baseline) + const high = run('high', values['high-preflight'], values['high-observe'], baseline) + const paired = pairSummaries(medium, high, jsonObject(values.explanations, 'explanations') ?? {}) + const side = (s) => ({ w7: s.w7, totals: s.totals, problems: s.problems, provenance: s.provenance }) + console.log(JSON.stringify({ medium: side(medium), high: side(high), w6: paired }, null, 2)) + process.exit(exitFor(paired.status)) + } + if (command === 'pair-fixture') { + const need = ['medium-lab', 'medium-meta', 'high-lab', 'high-meta'] + if (need.some((k) => !values[k])) usage('pair-fixture needs --' + need.join(', --')) + const medium = fixtureSummary({ lab: load(values['medium-lab']), meta: load(values['medium-meta']), expectedLabel: 'medium' }) + const high = fixtureSummary({ lab: load(values['high-lab']), meta: load(values['high-meta']), expectedLabel: 'high' }) + const paired = pairSummaries(medium, high, jsonObject(values.explanations, 'explanations') ?? {}) + console.log(JSON.stringify({ medium: { totals: medium.totals, problems: medium.problems }, high: { totals: high.totals, problems: high.problems }, w6: paired }, null, 2)) + process.exit(exitFor(paired.status)) + } + usage(`unknown command: ${command ?? '(none)'}`) +} + +try { main() } +catch (error) { + if (!(error instanceof EvidenceInputError)) throw error + // Do not expose paths, parser excerpts or host exception messages in a + // shareable diagnostic. A failed input is unknown, not a measured mismatch. + console.log(JSON.stringify({ status: 'unknown', problems: [error.reason], safeToUnlock: false })) + process.exitCode = 2 +} diff --git a/experimental/windows-native-observer/expectations.mjs b/experimental/windows-native-observer/expectations.mjs new file mode 100644 index 0000000..6d8110b --- /dev/null +++ b/experimental/windows-native-observer/expectations.mjs @@ -0,0 +1,433 @@ +// Expected-result table, typed per-step schemas, and format validators for the +// W6/W7 Windows native observer (#151, #152). Pure data plus small pure +// predicates. observer.ps1 emits records whose `step` names match this table; +// lib.mjs re-derives every provenance verdict from the raw recorded fields and +// never trusts a harness-provided boolean or verdict. +// +// Expectation fields: +// id stable row identifier used in summaries and W6 pairing +// mode 'preflight' or 'observe' +// step record step name emitted by observer.ps1 +// perSample true when one record per observation sample is required +// hypothesis dotted observed paths and expected RAW values; null unless a +// row is a plain raw-field comparison. Derived rows use `derive` +// derive name of a lib.mjs derivation that recomputes the verdict from +// raw fields and validated baselines (booleans are never trusted) +// compare extra observed paths compared between integrity levels (W6) +// measurable false when the harness cannot measure it (always unknown) +// gates issues the row contributes evidence to +// basis why the hypothesis is expected + +export const SCHEMA = 'codex-unlock-win-observer/1' + +export const INTEGRITY = Object.freeze({ + medium: 'S-1-16-8192', + high: 'S-1-16-12288', +}) + +// The exact harness file-name set. A manifest, preflight or observe harness +// record whose key set differs is out of schema, and a `--baseline` harness map +// with a different key set is not a usable baseline. The baseline is an exact +// map of this checkout's bytes (line-ending changes included); it is not proof +// of independent external provenance. +export const HARNESS_FILES = Object.freeze([ + 'observer.ps1', 'WinObserver.cs', 'check.ps1', 'expectations.mjs', 'lib.mjs', 'evaluate.mjs', +]) + +// Restart Manager opener roles are a closed set, recomputed by the evaluator +// from the run record's PIDs and the process creation FILETIME. +export const ROLES = Object.freeze(['launched_codex', 'observer', 'other']) + +export const MAX_SAMPLES = 10 + +// The disposable parent directory name shape. Observe re-derives it instead of +// trusting any recorded flag, and -OutFile is refused inside such a directory +// regardless of the current %TEMP%. +export const DISPOSABLE_DIR = /^codex-win-obs-[0-9a-f]{12}$/ + +const FORBIDDEN_PATH_CHARS = /["<>|*?]/ +// eslint-disable-next-line no-control-regex +const CONTROL_CHARS = /[\u0000-\u001f\u007f]/ + +// Strict spelling rules for a recorded Windows path, applied before the path is +// compared, opened, read, hashed or created. Returns the lowercased path with +// no trailing separator, or null when the spelling is not accepted. Only +// absolute local DRIVE paths are accepted: UNC (`\\server\share`), device +// (`\\?\`, `\\.\`), relative, rooted-without-drive (`\x`) and drive-relative +// (`C:x`) spellings are rejected rather than normalized, as are `.`/`..` +// components, empty components, components ending in a space or dot, and +// characters Windows does not allow in a name. An ambiguous spelling is +// refused; it is never rewritten into something that looks safe. +export function normalizeLocalPath(p) { + if (typeof p !== 'string' || p.length === 0 || p.length > 32767) return null + if (CONTROL_CHARS.test(p) || FORBIDDEN_PATH_CHARS.test(p) || p.includes('/')) return null + if (!/^[A-Za-z]:\\/.test(p)) return null + const parts = p.split('\\') + const drive = parts.shift() + if (!/^[A-Za-z]:$/.test(drive)) return null + // A single trailing separator spells the drive root or an existing directory; + // anything else empty is a double separator. + if (parts.length > 1 && parts[parts.length - 1] === '') parts.pop() + if (parts.length === 1 && parts[0] === '') return drive.toLowerCase() + '\\' + for (const part of parts) { + if (part.length === 0 || part === '.' || part === '..') return null + if (part.includes(':')) return null + if (part.endsWith(' ') || part.endsWith('.')) return null + if (part.startsWith(' ')) return null + } + return (drive + '\\' + parts.join('\\')).toLowerCase() +} + +// Field-format validators. A format failure means the field is not usable +// evidence, so the row is `unknown`, never a silent success. +export const FORMATS = Object.freeze({ + sha256: (v) => typeof v === 'string' && /^[0-9a-f]{64}$/.test(v), + commit: (v) => typeof v === 'string' && /^[0-9a-f]{40}$/.test(v), + // VolumeSerial(16 hex):IdHigh(16 hex)IdLow(16 hex), from GetFileIdInfo. + fileId: (v) => typeof v === 'string' && /^[0-9a-f]{16}:[0-9a-f]{32}$/.test(v), + // Mandatory integrity label SID. + sid: (v) => typeof v === 'string' && /^S-1-16-\d+$/.test(v), + // Any SID, used for user and owner SIDs. + anySid: (v) => typeof v === 'string' && /^S-1-\d+(-\d+)+$/.test(v), + // Absolute local drive path with an accepted spelling (see normalizeLocalPath). + localPath: (v) => normalizeLocalPath(v) !== null, + nullableLocalPath: (v) => v === null || normalizeLocalPath(v) !== null, + int: (v) => Number.isInteger(v), + nonNegInt: (v) => Number.isInteger(v) && v >= 0, + posInt: (v) => Number.isSafeInteger(v) && v > 0, + // FILETIME (100ns since 1601) exceeds 2^53, so it is carried as an exact + // decimal string and compared as a string; a JSON number would lose precision. + filetime: (v) => typeof v === 'string' && /^[1-9]\d*$/.test(v), + bool: (v) => typeof v === 'boolean', + string: (v) => typeof v === 'string' && v.length > 0, + // A string that may be absent as an explicit null, never undefined. + nullableString: (v) => v === null || typeof v === 'string', + anyString: (v) => typeof v === 'string', + array: (v) => Array.isArray(v), + label: (v) => v === 'medium' || v === 'high', + relation: (v) => ['same', 'inside', 'contains', 'disjoint'].includes(v), + nullableRelation: (v) => v === null || ['same', 'inside', 'contains', 'disjoint'].includes(v), + // A map of names to SHA-256 digests, at least one entry. + hashMap: (v) => v !== null && typeof v === 'object' && !Array.isArray(v) && + Object.keys(v).length > 0 && Object.values(v).every((h) => typeof h === 'string' && /^[0-9a-f]{64}$/.test(h)), +}) + +function f(format) { + const fn = FORMATS[format] + if (!fn) throw new Error(`unknown format ${format}`) + return fn +} + +export function sameKeySet(value, names) { + if (value === null || typeof value !== 'object' || Array.isArray(value)) return false + const keys = Object.keys(value).sort() + const want = [...names].sort() + return keys.length === want.length && keys.every((k, i) => k === want[i]) +} + +// The ACL contract of the disposable parent and home: owned by the current +// user, protected (no inheritance from above), exactly one inherited-everywhere +// Allow FullControl entry for that same user. Recorded as raw fields; the +// evaluator recomputes the verdict and never reads an `aceIsCurrentUser`-style +// boolean. +export const ACL_RIGHTS = 'FullControl' +export const ACL_INHERITANCE = 'ContainerInherit, ObjectInherit' +export const ACL_TYPE = 'Allow' + +const ACL_FIELDS = { + owner: f('anySid'), daclProtected: f('bool'), aceCount: f('int'), + aceIdentity: f('anySid'), aceRights: f('string'), aceInheritance: f('anyString'), aceType: f('string'), + parentOwner: f('anySid'), parentDaclProtected: f('bool'), parentAceCount: f('int'), + parentAceIdentity: f('anySid'), parentAceRights: f('string'), parentAceInheritance: f('anyString'), + parentAceType: f('string'), +} + +const PATH_FIELDS = { + home: f('localPath'), parent: f('localPath'), + userCodexHome: f('localPath'), envCodexHome: f('nullableLocalPath'), + userRelation: f('relation'), envRelation: f('nullableRelation'), + currentUserSid: f('anySid'), + fileId: f('fileId'), parentFileId: f('fileId'), + // No-follow ancestry of the parent directory, root first. Every component + // must be a plain directory; a reparse point anywhere above is refused. + ancestry: f('array'), +} + +const IDENTITY_FIELDS = { + complete: f('bool'), attributes: f('int'), reparse: f('bool'), directory: f('bool'), + fileId: f('fileId'), links: f('int'), deletePending: f('bool'), finalPath: f('string'), +} + +const RANGE_NAMES = Object.freeze(['rust', 'fsext', 'beyond_fsext', 'tail']) + +// Each probed range must be internally consistent: a Win32 code of 0 is exactly +// an acquired lock, an acquired lock must report its release, and a failed lock +// must not claim a release. +function rangesConsistent(ranges) { + if (ranges === null || typeof ranges !== 'object' || Array.isArray(ranges)) return false + if (!sameKeySet(ranges, RANGE_NAMES)) return false + return RANGE_NAMES.every((name) => { + const r = ranges[name] + if (r === null || typeof r !== 'object') return false + if (!FORMATS.nonNegInt(r.win32) || !FORMATS.bool(r.acquired)) return false + if ((r.win32 === 0) !== (r.acquired === true)) return false + if (r.acquired === true) return r.released === true + return r.released === null + }) +} + +function rmProcessesConsistent(o) { + if (!Array.isArray(o.processes)) return false + if (o.count !== o.processes.length) return false + const seen = new Set() + for (const p of o.processes) { + if (p === null || typeof p !== 'object' || Array.isArray(p)) return false + if (!FORMATS.posInt(p.pid) || !FORMATS.filetime(p.startFiletime)) return false + if (!ROLES.includes(p.role)) return false + if (!FORMATS.anyString(p.app) || !FORMATS.nullableString(p.service)) return false + if (!FORMATS.int(p.type) || !FORMATS.int(p.session)) return false + const key = `${p.pid}/${p.startFiletime}` + if (seen.has(key)) return false + seen.add(key) + } + return true +} + +// Typed per-step schemas, keyed by `${mode}:${step}`. `required` fields must be +// present and pass their format; `nested` uses dotted paths; `invariants` are +// internal-consistency predicates that must hold for the record to be usable. +// A record that fails any of them is unmeasured (`unknown`), including +// recorded-only rows: a bare `{ok:true}` never counts as a recorded success. +export const STEP_SCHEMAS = Object.freeze({ + 'preflight:harness': { + required: { complete: f('bool'), files: f('hashMap'), sourceCommit: f('commit') }, + invariants: { harness_name_set: (o) => sameKeySet(o.files, HARNESS_FILES) }, + }, + 'preflight:token.self': { required: { integritySid: f('sid'), userSid: f('anySid'), tokenOpened: f('bool'), pid: f('posInt') } }, + 'preflight:binary.sha256': { + required: { path: f('localPath'), size: f('posInt'), sha256: f('sha256'), expected: f('sha256') }, + }, + 'preflight:binary.signature': { required: { status: f('string'), statusMessage: f('nullableString'), signer: f('nullableString'), thumbprint: f('nullableString') } }, + 'preflight:binary.pe_version': { + required: { declared: f('bool'), fileVersion: f('nullableString'), productVersion: f('nullableString'), productName: f('nullableString') }, + invariants: { declared_version_present: (o) => o.declared === false || FORMATS.string(o.fileVersion) }, + }, + 'preflight:home.isolated': { + required: { + ...PATH_FIELDS, ...ACL_FIELDS, + empty: f('bool'), entries: f('array'), isolated: f('bool'), + }, + invariants: { empty_matches_entries: (o) => o.empty === (o.entries.length === 0) }, + }, + 'preflight:binary.version': { + required: { + exitCode: f('int'), pid: f('posInt'), stdoutFirstLine: f('string'), expected: f('string'), + stdoutComplete: f('bool'), stderrComplete: f('bool'), stdoutTruncated: f('bool'), stderrTruncated: f('bool'), + stderr: f('anyString'), timedOut: f('bool'), exited: f('bool'), envAllowlist: f('array'), + }, + invariants: { + // Usable version output requires both streams to have been read to the + // end, inside the deadline and without overflowing the byte cap. + streams_complete: (o) => o.stdoutComplete === true && o.stderrComplete === true && + o.stdoutTruncated === false && o.stderrTruncated === false && o.timedOut === false && o.exited === true, + }, + }, + 'preflight:home.after_version': { required: { count: f('nonNegInt'), entries: f('array') }, invariants: { count_matches_entries: (o) => o.count === o.entries.length } }, + 'preflight:host.execution_policy': { required: { effective: f('string'), scopes: f('array') } }, + 'preflight:host.defender': { required: { amServiceEnabled: f('bool'), realTimeProtectionEnabled: f('bool'), antivirusEnabled: f('bool') } }, + 'preflight:host.volume': { required: { fileSystem: f('string'), probePath: f('localPath') } }, + 'preflight:host.os': { required: { os: f('string'), ps: f('string'), elevated: f('bool'), sshSession: f('bool') } }, + 'preflight:manifest': { + required: { + complete: f('bool'), home: f('localPath'), homeFileId: f('fileId'), + parent: f('localPath'), parentFileId: f('fileId'), + binaryPath: f('localPath'), binarySha256: f('sha256'), binarySize: f('posInt'), version: f('string'), + sourceCommit: f('commit'), integritySid: f('sid'), label: f('label'), harness: f('hashMap'), + createdUtc: f('string'), + }, + invariants: { harness_name_set: (o) => sameKeySet(o.harness, HARNESS_FILES) }, + }, + 'observe:harness': { + required: { complete: f('bool'), files: f('hashMap'), manifestSha256: f('sha256') }, + invariants: { harness_name_set: (o) => sameKeySet(o.files, HARNESS_FILES) }, + }, + 'observe:token.self': { required: { integritySid: f('sid'), userSid: f('anySid'), tokenOpened: f('bool'), pid: f('posInt') } }, + 'observe:home.identity': { required: { ...PATH_FIELDS, ...ACL_FIELDS } }, + 'observe:run': { + required: { + samples: f('int'), codexPid: f('posInt'), codexLabel: f('label'), + observerPid: f('posInt'), observerUserSid: f('anySid'), startedUtc: f('string'), + }, + invariants: { + samples_in_range: (o) => o.samples >= 1 && o.samples <= MAX_SAMPLES, + observer_is_not_target: (o) => o.observerPid !== o.codexPid, + }, + }, + 'observe:process.identity': { + required: { + pid: f('posInt'), opened: f('bool'), state: f('string'), createFiletime: f('filetime'), + image: f('localPath'), tokenOpened: f('bool'), integritySid: f('sid'), userSid: f('anySid'), + }, + invariants: { live_requires_handles: (o) => o.state !== 'live' || (o.opened === true && o.tokenOpened === true) }, + }, + // Re-queried before every sample's active operations; same fields as the + // first process record, carrying the PID that was actually queried. + 'observe:process.sample': { + required: { + pid: f('posInt'), opened: f('bool'), state: f('string'), createFiletime: f('filetime'), + image: f('localPath'), tokenOpened: f('bool'), integritySid: f('sid'), userSid: f('anySid'), + }, + invariants: { live_requires_handles: (o) => o.state !== 'live' || (o.opened === true && o.tokenOpened === true) }, + }, + 'observe:lockdir.identity': { required: IDENTITY_FIELDS }, + 'observe:coordination.identity': { required: IDENTITY_FIELDS }, + 'observe:thread.count': { + required: { count: f('nonNegInt'), entries: f('array') }, + invariants: { + count_matches_entries: (o) => o.count === o.entries.filter((e) => typeof e === 'string' && + e !== '.coordination.lock' && e.toLowerCase().endsWith('.lock')).length, + }, + }, + 'observe:thread.identity': { required: IDENTITY_FIELDS }, + 'observe:thread.identity_after': { required: IDENTITY_FIELDS }, + 'observe:share.read_rwd': { + required: { opened: f('bool'), win32: f('nonNegInt'), access: f('string'), share: f('string') }, + invariants: { + opened_matches_win32: (o) => (o.opened === true) === (o.win32 === 0), + requested_mode: (o) => o.access === 'read' && o.share === 'rwd', + }, + }, + 'observe:share.delete_rwd': { + required: { opened: f('bool'), win32: f('nonNegInt'), access: f('string'), share: f('string') }, + invariants: { + opened_matches_win32: (o) => (o.opened === true) === (o.win32 === 0), + requested_mode: (o) => o.access === 'delete' && o.share === 'rwd', + }, + }, + 'observe:range.probe': { + required: { + state: f('string'), + dirFileIdBefore: f('fileId'), dirFileIdAfter: f('fileId'), + guardFileId: f('fileId'), guardFileIdAfter: f('fileId'), guardLinks: f('int'), guardDeletePending: f('bool'), + threadFileIdBefore: f('fileId'), threadFileIdAfter: f('fileId'), threadLinks: f('int'), threadDeletePending: f('bool'), + guardAcquired: f('bool'), guardReleased: f('bool'), + }, + nested: { + 'ranges.rust.win32': f('int'), 'ranges.fsext.win32': f('int'), + 'ranges.beyond_fsext.win32': f('int'), 'ranges.tail.win32': f('int'), + }, + invariants: { + ranges_consistent: (o) => rangesConsistent(o.ranges), + // The guard is taken and released synchronously around the probes; a + // probed state with an unreleased guard is contradictory. + guard_taken_and_released: (o) => o.guardAcquired === true && o.guardReleased === true, + guard_single_regular: (o) => o.guardLinks === 1 && o.guardDeletePending === false, + thread_single_regular: (o) => o.threadLinks === 1 && o.threadDeletePending === false, + }, + }, + 'observe:openers.rm': { + required: { complete: f('bool'), count: f('nonNegInt'), processes: f('array'), interpretation: f('string'), files: f('array') }, + invariants: { + interpretation_exact: (o) => o.interpretation === 'opener_candidates_only', + processes_consistent: rmProcessesConsistent, + }, + }, + 'observe:process.identity_after': { + required: { + pid: f('posInt'), opened: f('bool'), state: f('string'), createFiletime: f('filetime'), + image: f('localPath'), tokenOpened: f('bool'), integritySid: f('sid'), userSid: f('anySid'), + }, + invariants: { live_requires_handles: (o) => o.state !== 'live' || (o.opened === true && o.tokenOpened === true) }, + }, +}) + +export const EXPECTATIONS = Object.freeze([ + // ---- preflight: before the operator launches Codex ------------------- + { id: 'PF-harness', mode: 'preflight', step: 'harness', derive: 'harnessBaseline', gates: ['W6', 'W7'], + basis: 'harness file SHA-256 values recorded for the exact file-name set and equal to the supplied baseline' }, + { id: 'PF-token', mode: 'preflight', step: 'token.self', derive: 'tokenIntegrity', gates: ['W6', 'W7'], + basis: 'measured token integrity SID equals the label SID; never relabelled' }, + { id: 'PF-binary-sha256', mode: 'preflight', step: 'binary.sha256', derive: 'binarySha256Baseline', gates: ['W7'], + basis: 'binary bytes equal the recorded expected digest and the supplied baseline before any execution' }, + { id: 'PF-binary-signature', mode: 'preflight', step: 'binary.signature', hypothesis: null, gates: ['W7'], + basis: 'Authenticode status is recorded, not interpreted' }, + { id: 'PF-binary-pe-version', mode: 'preflight', step: 'binary.pe_version', hypothesis: null, gates: ['W7'], + basis: 'PE version resource is recorded without executing the binary; an absent resource is declared, not implied' }, + { id: 'PF-home', mode: 'preflight', step: 'home.isolated', derive: 'homeIsolated', gates: ['W7'], + basis: 'fresh private CODEX_HOME under a validated disposable parent, disjoint from the user and environment Codex homes, with the owner/DACL contract and a plain no-follow ancestry, all recomputed by the evaluator' }, + { id: 'PF-binary-version', mode: 'preflight', step: 'binary.version', derive: 'binaryVersion', gates: ['W7'], + basis: 'version output of the verified binary equals the baseline version, exits 0, and both streams completed inside the deadline without truncation' }, + { id: 'PF-home-after-version', mode: 'preflight', step: 'home.after_version', hypothesis: null, gates: ['W7'], + basis: 'entries created by the version command are recorded' }, + { id: 'PF-execution-policy', mode: 'preflight', step: 'host.execution_policy', hypothesis: null, gates: ['W6', 'W7'], + basis: 'existing policy is recorded; it is never changed' }, + { id: 'PF-defender', mode: 'preflight', step: 'host.defender', hypothesis: { amServiceEnabled: true, realTimeProtectionEnabled: true }, gates: ['W7'], + basis: 'W7 requires a host where Defender is already enabled; it is never enabled or changed here' }, + { id: 'PF-volume', mode: 'preflight', step: 'host.volume', hypothesis: null, gates: ['W7'], + basis: 'filesystem of the disposable home is recorded (NTFS or ReFS)' }, + { id: 'PF-os', mode: 'preflight', step: 'host.os', hypothesis: null, gates: ['W6', 'W7'], + basis: 'OS build, PowerShell version, Administrators membership and SSH session are recorded' }, + { id: 'PF-manifest', mode: 'preflight', step: 'manifest', derive: 'manifestBaseline', gates: ['W7'], + basis: 'the manifest is complete and reconstructible field by field from the raw preflight observations and the baseline' }, + + // ---- observe: while the operator-launched Codex holds one thread ----- + { id: 'OB-harness', mode: 'observe', step: 'harness', derive: 'harnessMatchesManifest', gates: ['W6', 'W7'], + basis: 'observer files byte-identical to the preflight run, recomputed from the recorded digests' }, + { id: 'OB-token', mode: 'observe', step: 'token.self', derive: 'tokenMatchesManifest', gates: ['W6', 'W7'], + basis: 'observe integrity SID equals the preflight manifest SID and the label SID' }, + { id: 'OB-home', mode: 'observe', step: 'home.identity', derive: 'homeMatchesManifest', gates: ['W7'], + basis: 'home and parent paths, file IDs, structure, disjointness, owner/DACL contract and no-follow ancestry all still hold and match the manifest' }, + { id: 'OB-run', mode: 'observe', step: 'run', derive: 'runControl', gates: ['W6', 'W7'], + basis: 'the control record declares one positive target PID, a different observer PID and a sample count, and every process record binds to it' }, + { id: 'OB-process', mode: 'observe', step: 'process.identity', derive: 'imageLive', gates: ['W3', 'W6', 'W7'], + basis: 'query-only handle to the declared PID is live, same-user, and its image equals the manifest binary path' }, + { id: 'OB-process-token', mode: 'observe', step: 'process.identity', derive: 'codexToken', gates: ['W6'], + basis: 'Codex token integrity SID equals the -CodexIntegrityLabel SID; denial is unknown' }, + { id: 'OB-process-sample', mode: 'observe', step: 'process.sample', perSample: true, derive: 'sampleProcess', gates: ['W3', 'W7'], + basis: 'before each sample\'s active operations the declared PID is re-queried and must still be the same live, same-user instance, at the same creation FILETIME, image and integrity' }, + { id: 'OB-lockdir', mode: 'observe', step: 'lockdir.identity', perSample: true, hypothesis: { directory: true, reparse: false }, gates: ['W7'], + basis: 'thread-writer-locks is a plain directory inside the disposable home' }, + { id: 'OB-guard', mode: 'observe', step: 'coordination.identity', perSample: true, + hypothesis: { directory: false, reparse: false, links: 1, deletePending: false }, gates: ['W7'], + basis: '.coordination.lock exists as a single-link regular file, not pending delete' }, + { id: 'OB-thread-count', mode: 'observe', step: 'thread.count', perSample: true, hypothesis: { count: 1 }, gates: ['W7'], + basis: 'exactly one thread lock; zero or several makes per-thread probes inapplicable' }, + { id: 'OB-thread-identity', mode: 'observe', step: 'thread.identity', perSample: true, + hypothesis: { directory: false, reparse: false, links: 1, deletePending: false }, gates: ['W7'], + basis: 'single-link regular lock file, not pending delete' }, + { id: 'OB-share-read-rwd', mode: 'observe', step: 'share.read_rwd', perSample: true, hypothesis: { opened: true }, gates: ['W7'], + basis: 'full-sharing read open succeeds; it withholds no sharing mode the holder relies on' }, + { id: 'OB-share-delete-rwd', mode: 'observe', step: 'share.delete_rwd', perSample: true, hypothesis: { opened: true }, gates: ['W7'], + basis: 'full-sharing DELETE-access open; the holder shares delete and no delete is issued' }, + { id: 'OB-range-rust', mode: 'observe', step: 'range.probe', perSample: true, hypothesis: { state: 'probed', 'ranges.rust.win32': 33 }, gates: ['W7'], + basis: 'Rust std range probe conflicts with the live holder (ERROR_LOCK_VIOLATION)' }, + { id: 'OB-range-fsext', mode: 'observe', step: 'range.probe', perSample: true, hypothesis: { state: 'probed', 'ranges.fsext.win32': 33 }, gates: ['W7'], + basis: 'fs-ext 0..0xffff0000 emulation conflicts with the holder' }, + { id: 'OB-range-beyond-fsext', mode: 'observe', step: 'range.probe', perSample: true, hypothesis: { state: 'probed', 'ranges.beyond_fsext.win32': 33 }, gates: ['W7'], + basis: 'holder range extends past 0xffff0000, consistent with Rust std and not with fs-ext' }, + { id: 'OB-range-tail', mode: 'observe', step: 'range.probe', perSample: true, hypothesis: null, gates: ['W7'], + basis: 'last-offset probe outside the Rust range; Windows acceptance of that offset is not established' }, + { id: 'OB-range-guard', mode: 'observe', step: 'range.probe', perSample: true, derive: 'rangeGuardBinding', gates: ['W7'], + basis: 'the probe ran under the expected coordination guard and lock directory, and every identity it verified matches that sample\'s identity records before and after' }, + { id: 'OB-thread-stable', mode: 'observe', step: 'thread.identity_after', perSample: true, derive: 'threadStable', gates: ['W7'], + basis: 'thread lock identity is unchanged across the probes of a sample, recomputed from both identity records' }, + { id: 'OB-openers', mode: 'observe', step: 'openers.rm', perSample: true, derive: 'openersRm', compare: ['count', 'roles'], gates: ['W4', 'W6', 'W7'], + basis: 'Restart Manager opener candidates only, with every role recomputed from the control record; never holder evidence' }, + { id: 'OB-coordination-range', mode: 'observe', step: 'coordination.range', measurable: false, hypothesis: { range: 'rust' }, gates: ['W7'], + basis: 'Codex holds the coordination lock only transiently; a passive observer cannot measure its range' }, + { id: 'OB-process-after', mode: 'observe', step: 'process.identity_after', derive: 'processAfterLive', gates: ['W3', 'W7'], + basis: 'the same process instance (equal PID and creation FILETIME) is still live after all samples; a failure here cannot undo operations already performed' }, +]) + +// The closed set of record steps each mode may contain. Any other step, or an +// orchestrator_error record, invalidates the file. +export function knownSteps(mode) { + return new Set(EXPECTATIONS.filter((e) => e.mode === mode && e.measurable !== false).map((e) => e.step)) +} + +// Steps that must carry a sample index (1..samples); every other known step +// must carry no sample at all. A known step with an invalid sample invalidates +// the whole file. +export function perSampleSteps(mode) { + return new Set(EXPECTATIONS.filter((e) => e.mode === mode && e.measurable !== false && e.perSample).map((e) => e.step)) +} diff --git a/experimental/windows-native-observer/lib.mjs b/experimental/windows-native-observer/lib.mjs new file mode 100644 index 0000000..e97785c --- /dev/null +++ b/experimental/windows-native-observer/lib.mjs @@ -0,0 +1,1096 @@ +// Pure evaluation of W6/W7 observer evidence (#151, #152). No filesystem, +// process, or network access: callers pass record arrays and digests. +// Every missing, partial, denied, inconsistent, out-of-schema, or unproven +// observation is `unknown`; a harness-provided boolean or verdict is never +// trusted. Provenance is reconstructed from the raw preflight observations and +// a validated baseline, and every stage is cross-bound to the next. +// Nothing here authorizes recovery: safeToUnlock stays false and W7 stays +// `unknown` while the coordination-range row is unmeasurable. +import { + ACL_INHERITANCE, ACL_RIGHTS, ACL_TYPE, DISPOSABLE_DIR, EXPECTATIONS, FORMATS, HARNESS_FILES, INTEGRITY, + MAX_SAMPLES, ROLES, SCHEMA, STEP_SCHEMAS, knownSteps, normalizeLocalPath, perSampleSteps, sameKeySet, +} from './expectations.mjs' + +const ERROR_ACCESS_DENIED = 5 + +// Rows whose verdicts make up the provenance state. W6 needs all of them to be +// `match`; it does not need W7's unmeasurable coordination range. +export const PROVENANCE_ROWS = Object.freeze([ + 'PF-harness', 'PF-token', 'PF-home', 'PF-binary-sha256', 'PF-binary-version', 'PF-manifest', + 'OB-harness', 'OB-token', 'OB-home', 'OB-run', +]) + +// Complete, contiguous samples: the process re-query precedes all evidence +// collected for that sample. This is an offline contract, not permission to +// run the dormant native producer. +const SAMPLE_STEPS = Object.freeze([ + 'process.sample', 'lockdir.identity', 'coordination.identity', 'thread.count', + 'thread.identity', 'openers.rm', 'share.read_rwd', 'share.delete_rwd', + 'range.probe', 'thread.identity_after', +]) + +// GetFinalPathNameByHandle reports an extended drive path. Only remove that +// exact kernel spelling; UNC/device/relative paths remain unusable. +function identityPath(path) { + if (typeof path !== 'string') return null + return normalizeLocalPath(path.startsWith('\\\\?\\') ? path.slice(4) : path) +} + +function samplePaths(sample, ctx) { + if (!ctx.manifest) return null + const count = ctx.observeByStep.get(`thread.count#${sample}`)?.observed + if (!Array.isArray(count?.entries) || count.count !== 1) return null + const dir = `${ctx.manifest.home}\\thread-writer-locks` + if (count.entries.some((entry) => typeof entry !== 'string' || entry.includes('\\') || + normalizeLocalPath(`${dir}\\${entry}`) === null)) return null + const threads = count.entries.filter((entry) => entry.toLowerCase() !== '.coordination.lock' && entry.toLowerCase().endsWith('.lock')) + if (threads.length !== 1) return null + return { dir, guard: `${dir}\\.coordination.lock`, thread: `${dir}\\${threads[0]}` } +} + +function sampleIdentityProblem(sample, ctx) { + const paths = samplePaths(sample, ctx) + if (!paths) return 'sample_paths_unusable' + for (const [step, path] of [['lockdir.identity', paths.dir], ['coordination.identity', paths.guard], + ['thread.identity', paths.thread], ['thread.identity_after', paths.thread]]) { + const actual = ctx.observeByStep.get(`${step}#${sample}`)?.observed?.finalPath + if (identityPath(actual) !== normalizeLocalPath(path)) return `${step}_path_differs` + } + return null +} + +export function parseJsonl(text) { + const records = [] + const problems = [] + if (typeof text !== 'string' || text.length === 0) return { records, problems: ['empty_file'] } + if (text.charCodeAt(0) === 0xfeff) problems.push('bom') + if (!text.endsWith('\n')) problems.push('missing_final_newline') + const lines = text.split('\n') + if (lines[lines.length - 1] === '') lines.pop() + lines.forEach((line, index) => { + if (line.endsWith('\r')) problems.push(`crlf:${index + 1}`) + try { + const value = JSON.parse(line) + if (value === null || typeof value !== 'object' || Array.isArray(value)) problems.push(`not_object:${index + 1}`) + else records.push(value) + } catch { + problems.push(`invalid_json:${index + 1}`) + } + }) + return { records, problems } +} + +export function integrityFromSid(sid) { + if (sid === INTEGRITY.medium) return 'medium' + if (sid === INTEGRITY.high) return 'high' + if (typeof sid === 'string' && /^S-1-16-\d+$/.test(sid)) return 'other' + return null +} + +// whoami /groups prints localized names but a constant label SID. +export function sidFromWhoamiLine(line) { + if (typeof line !== 'string') return null + const found = line.match(/\bS-1-16-\d+\b/g) + return found && found.length === 1 ? found[0] : null +} + +// Lexical, case-insensitive relation of two absolute local Windows paths: +// 'same', 'inside' (a under b), 'contains' (b under a), 'disjoint', or null +// when either spelling is not accepted (see normalizeLocalPath: UNC, device, +// relative and ambiguous spellings are rejected, never rewritten). Lexical +// only; reparse points are the harness's responsibility and are covered +// separately by the recorded no-follow ancestry. +export function windowsPathRelation(a, b) { + const x = normalizeLocalPath(a) + const y = normalizeLocalPath(b) + if (x === null || y === null) return null + if (x === y) return 'same' + const under = (p, d) => p.startsWith(d.endsWith('\\') ? d : d + '\\') + if (under(x, y)) return 'inside' + if (under(y, x)) return 'contains' + return 'disjoint' +} + +export function pathComponents(p) { + const norm = normalizeLocalPath(p) + if (norm === null) return null + const parts = norm.split('\\').filter((part) => part.length > 0) + parts.shift() + return parts +} + +// True when any component of the path is a disposable observer directory. +// Literal component matching, never a wildcard: a bracket or a `*` in a real +// path name must not change the decision. +export function insideDisposableDir(p) { + const parts = pathComponents(p) + return parts !== null && parts.some((part) => DISPOSABLE_DIR.test(part)) +} + +// Verified mirror of observer.ps1's lexical output/input path gate. Every +// relevant input and output path is validated BEFORE it is tested, read, +// hashed or created: it must be an accepted absolute local drive spelling, +// disjoint from the actual/default and environment Codex homes, and (for an +// output) outside any disposable observer directory on any volume, regardless +// of the current %TEMP%. Real homes are compared textually; nothing is opened. +export function pathGateSafe(path, userCodexHome, envCodexHome, { allowDisposable = false } = {}) { + if (normalizeLocalPath(path) === null) return 'path_spelling' + if (normalizeLocalPath(userCodexHome) === null) return 'user_home_spelling' + if (windowsPathRelation(path, userCodexHome) !== 'disjoint') return 'user_home_overlap' + if (envCodexHome !== null && envCodexHome !== undefined) { + if (normalizeLocalPath(envCodexHome) === null) return 'env_home_spelling' + if (windowsPathRelation(path, envCodexHome) !== 'disjoint') return 'env_home_overlap' + } + if (!allowDisposable && insideDisposableDir(path)) return 'inside_disposable_dir' + return null +} + +// A disposable temp root is safe to create inside only when it passes the same +// lexical gate (its own components may not already be a disposable directory). +// The PowerShell Test-CandidateTempSafe implements the same rule (unverified on +// this host until the static CI gate runs). +export function tempRootSafeForCreation(tempRoot, userCodexHome, envCodexHome) { + return pathGateSafe(tempRoot, userCodexHome, envCodexHome) === null +} + +// Creation seam: the create callback runs only when the gate returns true, so a +// gate failure provably skips creation. Mirrors Invoke-GuardedCreate. +export function guardedCreate(gate, create) { + if (gate()) return create() + return { created: false, reason: 'gate_refused' } +} + +// Per-sample seam: the active operations of a sample run only when that +// sample's own gate passes. Mirrors Invoke-GuardedSample; one failing sample +// never carries a previous sample's verdict. +export function guardedSample(gate, operations) { + const reason = gate() + if (reason !== null && reason !== undefined) return { ran: false, reason } + return { ran: true, result: operations() } +} + +export function getPath(obj, dotted) { + let cur = obj + for (const key of dotted.split('.')) { + if (cur === null || typeof cur !== 'object' || !Object.hasOwn(cur, key)) return undefined + cur = cur[key] + } + return cur +} + +export function hasAccessDenied(value) { + if (value === null || typeof value !== 'object') return false + for (const [key, inner] of Object.entries(value)) { + if ((key === 'win32' || key === 'tokenWin32') && inner === ERROR_ACCESS_DENIED) return true + if (hasAccessDenied(inner)) return true + } + return false +} + +function schemaProblem(mode, step, observed) { + const schema = STEP_SCHEMAS[`${mode}:${step}`] + if (!schema) return 'no_schema' + for (const [key, ok] of Object.entries(schema.required ?? {})) { + if (!Object.hasOwn(observed, key)) return `missing:${key}` + if (!ok(observed[key])) return `format:${key}` + } + for (const [key, ok] of Object.entries(schema.nested ?? {})) { + const v = getPath(observed, key) + if (v === undefined) return `missing:${key}` + if (!ok(v)) return `format:${key}` + } + // Internal consistency: a record that contradicts itself is not evidence. + for (const [name, ok] of Object.entries(schema.invariants ?? {})) { + let held + try { + held = ok(observed) === true + } catch { + // A predicate that throws on a malformed record is a failed invariant. + held = false + } + if (!held) return `invariant:${name}` + } + return null +} + +// Returns null when the record is a usable, in-schema observation; otherwise +// the reason it is not. A record is measured only when ok is true, nothing is +// partial/denied/unknown, and the typed schema and its invariants for the step +// pass. Hypothesis keys are additionally required and format-free beyond the +// schema. +export function unmeasuredReason(record, mode, hypothesis) { + if (!record) return 'not_collected' + const o = record.observed + if (o === null || o === undefined) return 'no_observation' + if (typeof o !== 'object' || Array.isArray(o)) return 'malformed_observation' + if (hasAccessDenied(o)) return 'access_denied' + if (o.ok !== true) return 'native_error' + if (o.complete === false) return 'partial' + if (o.state === 'unknown') return 'unknown_state' + if (Object.hasOwn(o, 'exception')) return 'exception' + const sp = schemaProblem(mode, record.step, o) + if (sp) return `schema_${sp}` + for (const key of Object.keys(hypothesis ?? {})) { + const value = getPath(o, key) + if (value === undefined || value === null) return 'field_missing' + } + return null +} + +export function compareHypothesis(observed, hypothesis) { + for (const [key, want] of Object.entries(hypothesis)) { + if (getPath(observed, key) !== want) return 'mismatch' + } + return 'match' +} + +// Values kept for W6 comparison: the hypothesis paths, plus any `compare` +// paths of the row. 'roles' is the sorted role list of a process array. +function pickValues(observed, hypothesis, compare = []) { + if (!observed || typeof observed !== 'object') return null + const keys = [...Object.keys(hypothesis ?? {}), ...compare] + if (keys.length === 0) return null + const out = {} + for (const key of keys) { + if (key === 'roles') out.roles = Array.isArray(observed.processes) ? observed.processes.map((p) => p?.role ?? 'unlabelled').sort() : null + else out[key] = getPath(observed, key) ?? null + } + return out +} + +// The structural, disjointness, ownership and ancestry contract of the +// disposable parent and home, recomputed from raw recorded fields only. +// Returns a list of reasons; an empty list means the contract holds. +export function homeContractProblems(o) { + const problems = [] + const rel = (a, b) => windowsPathRelation(a, b) + if (rel(o.home, o.parent) !== 'inside') problems.push('home_not_inside_parent') + const homeParts = pathComponents(o.home) + const parentParts = pathComponents(o.parent) + if (homeParts === null || parentParts === null) problems.push('path_spelling') + else { + if (homeParts.length !== parentParts.length + 1 || homeParts[homeParts.length - 1] !== 'home') problems.push('home_not_parent_home_child') + if (!DISPOSABLE_DIR.test(parentParts[parentParts.length - 1] ?? '')) problems.push('parent_not_disposable_name') + } + const user = rel(o.home, o.userCodexHome) + if (user === null) problems.push('user_home_spelling') + else if (user !== 'disjoint') problems.push('home_overlaps_user_home') + if (o.userRelation !== user) problems.push('recorded_user_relation_differs') + if (o.envCodexHome === null) { + if (o.envRelation !== null) problems.push('recorded_env_relation_differs') + } else { + const env = rel(o.home, o.envCodexHome) + if (env === null) problems.push('env_home_spelling') + else if (env !== 'disjoint') problems.push('home_overlaps_env_home') + if (o.envRelation !== env) problems.push('recorded_env_relation_differs') + } + // Ownership and DACL contract of both directories, for the measuring user. + const sid = o.currentUserSid + if (o.owner !== sid || o.parentOwner !== sid) problems.push('owner_not_current_user') + if (o.aceIdentity !== sid || o.parentAceIdentity !== sid) problems.push('ace_not_current_user') + if (o.daclProtected !== true || o.parentDaclProtected !== true) problems.push('dacl_not_protected') + if (o.aceCount !== 1 || o.parentAceCount !== 1) problems.push('ace_count_not_one') + if (o.aceType !== ACL_TYPE || o.parentAceType !== ACL_TYPE) problems.push('ace_not_allow') + if (o.aceRights !== ACL_RIGHTS || o.parentAceRights !== ACL_RIGHTS) problems.push('ace_rights_differ') + if (o.aceInheritance !== ACL_INHERITANCE || o.parentAceInheritance !== ACL_INHERITANCE) problems.push('ace_inheritance_differs') + // No-follow ancestry: every component above the parent must be a plain + // directory. This does not prove the ancestry could not change afterwards. + if (!Array.isArray(o.ancestry) || o.ancestry.length === 0) problems.push('ancestry_missing') + else { + const parent = normalizeLocalPath(o.parent) + const components = pathComponents(o.parent) + const expected = parent === null || components === null ? [] : [parent.slice(0, 3)] + for (const component of components ?? []) expected.push(`${expected[expected.length - 1].replace(/\\$/, '')}\\${component}`) + if (o.ancestry.length !== expected.length) problems.push('ancestry_component_count') + if (o.ancestry.some((entry, index) => normalizeLocalPath(entry?.path) !== expected[index])) problems.push('ancestry_component_order') + for (const entry of o.ancestry) { + if (entry === null || typeof entry !== 'object') { problems.push('ancestry_malformed'); break } + if (!FORMATS.localPath(entry.path) || !FORMATS.fileId(entry.fileId)) { problems.push('ancestry_malformed'); break } + if (entry.directory !== true || entry.reparse !== false) { problems.push('ancestry_not_plain'); break } + } + const last = o.ancestry[o.ancestry.length - 1] + if (last && windowsPathRelation(last.path, o.parent) !== 'same') problems.push('ancestry_does_not_end_at_parent') + if (last && last.fileId !== o.parentFileId) problems.push('ancestry_parent_fileid_differs') + } + if (!FORMATS.fileId(o.fileId) || !FORMATS.fileId(o.parentFileId)) problems.push('file_ids_unusable') + return problems +} + +// Recomputes PF-home isolation from the recorded fields; it never trusts the +// recorded `isolated` flag. Returns null when a field is missing or malformed +// in a way that makes the recomputation impossible. +export function recomputeIsolation(o) { + if (!o || typeof o !== 'object') return null + const required = ['home', 'parent', 'userCodexHome', 'empty', 'entries', 'currentUserSid', 'fileId', 'parentFileId', 'ancestry'] + if (required.some((k) => o[k] === undefined || o[k] === null)) return null + if (!FORMATS.localPath(o.home) || !FORMATS.localPath(o.parent) || !FORMATS.localPath(o.userCodexHome)) return null + if (!FORMATS.fileId(o.fileId) || !FORMATS.fileId(o.parentFileId)) return null + if (o.envCodexHome !== null && o.envCodexHome !== undefined && !FORMATS.localPath(o.envCodexHome)) return null + const problems = homeContractProblems(o) + return problems.length === 0 && o.empty === true +} + +function sameExpectation(tableHypothesis, recordExpect) { + if (tableHypothesis === null) return true + if (!recordExpect || typeof recordExpect !== 'object') return false + return Object.entries(tableHypothesis).every(([k, v]) => Object.hasOwn(recordExpect, k) && recordExpect[k] === v) +} + +export function deepEqualMap(a, b) { + if (!a || !b || typeof a !== 'object' || typeof b !== 'object') return false + const ka = Object.keys(a).sort() + const kb = Object.keys(b).sort() + return ka.length > 0 && JSON.stringify(ka) === JSON.stringify(kb) && ka.every((k) => a[k] === b[k]) +} + +// The baseline is the operator-asserted evidence the evaluator compares +// against. Its field formats and its exact harness file-name set are validated +// here; the evaluator cannot prove how the operator established it (see +// README, remaining decision 5), and the harness map is an exact map of the +// checkout's bytes, line endings included. +export function validateBaseline(baseline) { + if (baseline === null || baseline === undefined) return ['baseline_missing'] + if (typeof baseline !== 'object' || Array.isArray(baseline)) return ['baseline_not_object'] + const problems = [] + if (!FORMATS.commit(baseline.sourceCommit)) problems.push('baseline_source_commit') + if (!FORMATS.sha256(baseline.binarySha256)) problems.push('baseline_binary_sha256') + if (!FORMATS.string(baseline.version)) problems.push('baseline_version') + if (!FORMATS.hashMap(baseline.harness)) problems.push('baseline_harness') + else if (!sameKeySet(baseline.harness, HARNESS_FILES)) problems.push('baseline_harness_name_set') + return problems +} + +// Derivations recompute a verdict from raw fields, cross-stage bindings and a +// validated baseline. Each returns { verdict, reason, value }. `verdict` is +// 'match', 'mismatch' or 'unknown'. They never read a harness-provided +// boolean. The record has already passed its typed schema when a derivation +// runs. +const DERIVATIONS = { + harnessBaseline(rec, _s, ctx) { + const o = rec.observed + if (o.complete !== true) return unk('incomplete') + if (!ctx.baseline) return unk('baseline_missing') + if (ctx.baselineProblems.length > 0) return unk('baseline_invalid') + if (!deepEqualMap(o.files, ctx.baseline.harness)) return mis('harness_differs_from_baseline') + return ok(o.files) + }, + tokenIntegrity(rec, _s, ctx) { + const o = rec.observed + if (!ctx.label) return unk('label_unresolved') + if (o.tokenOpened !== true) return unk('token_not_opened') + if (o.integritySid !== INTEGRITY[ctx.label]) return mis('integrity_sid_differs_from_label') + return ok(o.integritySid) + }, + binarySha256Baseline(rec, _s, ctx) { + const o = rec.observed + if (o.sha256 !== o.expected) return mis('binary_hash_differs_from_expected') + if (!ctx.baseline) return unk('baseline_missing') + if (ctx.baselineProblems.length > 0) return unk('baseline_invalid') + if (o.sha256 !== ctx.baseline.binarySha256) return mis('binary_hash_differs_from_baseline') + return ok(o.sha256) + }, + homeIsolated(rec, _s, ctx) { + const token = ctx.preflightByStep.get('token.self')?.observed + if (!token || !FORMATS.anySid(token.userSid)) return unk('self_token_user_missing') + if (rec.observed.currentUserSid !== token.userSid) return mis('home_user_differs_from_self_token') + const again = recomputeIsolation(rec.observed) + if (again === null) return unk('isolation_fields_unusable') + if (again !== rec.observed.isolated) return unk('evaluator_disagreement') + if (!again) return mis(`home_not_isolated:${homeContractProblems(rec.observed).join(',') || 'not_empty'}`) + return ok(rec.observed.home) + }, + binaryVersion(rec, _s, ctx) { + const o = rec.observed + if (o.exitCode !== 0) return mis('version_exit_nonzero') + if (o.stdoutFirstLine !== o.expected) return mis('version_differs_from_expected') + if (!ctx.baseline) return unk('baseline_missing') + if (ctx.baselineProblems.length > 0) return unk('baseline_invalid') + if (o.stdoutFirstLine !== ctx.baseline.version) return mis('version_differs_from_baseline') + return ok(o.stdoutFirstLine) + }, + // The manifest is not trusted: it is reconstructed field by field from the + // raw preflight observations, then compared with the validated baseline. + manifestBaseline(rec, _s, ctx) { + const o = rec.observed + if (o.complete !== true) return unk('manifest_incomplete') + if (ctx.reconstruction.problems.length > 0) return unk(`preflight_unusable:${ctx.reconstruction.problems.join(',')}`) + const want = ctx.reconstruction.fields + for (const key of ['home', 'parent', 'binaryPath']) { + if (windowsPathRelation(o[key], want[key]) !== 'same') return mis(`manifest_${key}_differs_from_preflight`) + } + for (const key of ['homeFileId', 'parentFileId', 'binarySha256', 'binarySize', 'version', 'sourceCommit', 'integritySid']) { + if (o[key] !== want[key]) return mis(`manifest_${key}_differs_from_preflight`) + } + if (!deepEqualMap(o.harness, want.harness)) return mis('manifest_harness_differs_from_preflight') + if (!ctx.label) return unk('label_unresolved') + if (o.label !== ctx.label || o.integritySid !== INTEGRITY[ctx.label]) return mis('manifest_label_differs_from_measured_integrity') + if (!ctx.baseline) return unk('baseline_missing') + if (ctx.baselineProblems.length > 0) return unk('baseline_invalid') + if (o.sourceCommit !== ctx.baseline.sourceCommit) return mis('manifest_commit_differs_from_baseline') + if (o.binarySha256 !== ctx.baseline.binarySha256) return mis('manifest_binary_differs_from_baseline') + if (o.version !== ctx.baseline.version) return mis('manifest_version_differs_from_baseline') + if (!deepEqualMap(o.harness, ctx.baseline.harness)) return mis('manifest_harness_differs_from_baseline') + return ok(o.sourceCommit) + }, + harnessMatchesManifest(rec, _s, ctx) { + const o = rec.observed + if (o.complete !== true) return unk('incomplete') + if (!ctx.manifest) return unk('no_manifest') + if (o.manifestSha256 !== ctx.preflightSha256) return mis('manifest_reference_differs') + if (!deepEqualMap(o.files, ctx.manifest.harness)) return mis('observer_harness_differs_from_manifest') + return ok(o.files) + }, + tokenMatchesManifest(rec, _s, ctx) { + const o = rec.observed + if (!ctx.label) return unk('label_unresolved') + if (o.tokenOpened !== true) return unk('token_not_opened') + if (o.integritySid !== INTEGRITY[ctx.label]) return mis('integrity_sid_differs_from_label') + if (!ctx.manifest) return unk('no_manifest') + if (o.integritySid !== ctx.manifest.integritySid) return mis('integrity_sid_differs_from_manifest') + return ok(o.integritySid) + }, + // Observe binds the home to the manifest by path AND file ID for both the + // home and its parent, re-checks the structure and disjointness, and + // re-checks the owner/DACL contract and the no-follow ancestry. + homeMatchesManifest(rec, _s, ctx) { + const o = rec.observed + const token = ctx.observeByStep.get('token.self#')?.observed + if (!token || !FORMATS.anySid(token.userSid)) return unk('self_token_user_missing') + if (o.currentUserSid !== token.userSid) return mis('home_user_differs_from_self_token') + if (!ctx.manifest) return unk('no_manifest') + for (const [key, manifestKey] of [['home', 'home'], ['parent', 'parent']]) { + if (windowsPathRelation(o[key], ctx.manifest[manifestKey]) !== 'same') return mis(`${key}_path_differs_from_manifest`) + } + if (o.fileId !== ctx.manifest.homeFileId) return mis('home_fileid_differs_from_manifest') + if (o.parentFileId !== ctx.manifest.parentFileId) return mis('parent_fileid_differs_from_manifest') + const problems = homeContractProblems(o) + if (problems.length > 0) return mis(`home_contract:${problems.join(',')}`) + return ok(o.fileId) + }, + // The control record is mandatory: it declares the target PID, the observer + // PID and the sample count, and every process record must bind to it. + runControl(rec, _s, ctx) { + const o = rec.observed + const token = ctx.observeByStep.get('token.self#')?.observed + if (!token || !FORMATS.posInt(token.pid) || !FORMATS.anySid(token.userSid)) return unk('self_token_identity_missing') + if (o.observerPid !== token.pid) return mis('observer_pid_differs_from_self_token') + if (o.observerUserSid !== token.userSid) return mis('observer_user_differs_from_self_token') + const first = ctx.observeByStep.get('process.identity#')?.observed + const after = ctx.observeByStep.get('process.identity_after#')?.observed + for (const [name, p] of [['process.identity', first], ['process.identity_after', after]]) { + if (!p || typeof p !== 'object') return unk(`${name}_missing`) + if (!FORMATS.posInt(p.pid)) return unk(`${name}_pid_unusable`) + if (p.pid !== o.codexPid) return mis(`${name}_pid_differs_from_run`) + if (p.pid === o.observerPid) return mis(`${name}_is_the_observer`) + } + if (!FORMATS.filetime(first.createFiletime) || !FORMATS.filetime(after.createFiletime)) return unk('create_filetime_unusable') + if (first.createFiletime !== after.createFiletime) return mis('create_filetime_changed') + if (!ctx.label) return unk('label_unresolved') + return ok(o.codexPid) + }, + imageLive(rec, _s, ctx) { + const o = rec.observed + if (o.state !== 'live') return mis(`process_state_${o.state}`) + if (!ctx.run) return unk('no_run_record') + if (o.pid !== ctx.run.codexPid) return mis('queried_pid_differs_from_run') + if (o.userSid !== ctx.run.observerUserSid) return mis('target_user_differs_from_observer') + if (!ctx.manifest) return unk('no_manifest') + if (windowsPathRelation(o.image, ctx.manifest.binaryPath) !== 'same') return mis('image_differs_from_manifest_binary') + return ok(o.image) + }, + // The per-sample re-query: the same live instance, at the same image, + // integrity and user, immediately before that sample's active operations. + // A later failure cannot undo operations already performed, so the producer + // must refuse the sample rather than record and continue. + sampleProcess(rec, _s, ctx) { + const o = rec.observed + if (o.state !== 'live') return mis(`process_state_${o.state}`) + if (!ctx.run) return unk('no_run_record') + if (o.pid !== ctx.run.codexPid) return mis('queried_pid_differs_from_run') + if (o.userSid !== ctx.run.observerUserSid) return mis('target_user_differs_from_observer') + const first = ctx.observeByStep.get('process.identity#')?.observed + if (!first || !FORMATS.filetime(first.createFiletime)) return unk('baseline_create_missing') + if (o.createFiletime !== first.createFiletime) return mis('create_filetime_changed') + if (o.integritySid !== first.integritySid) return mis('integrity_sid_changed') + if (!ctx.manifest) return unk('no_manifest') + if (windowsPathRelation(o.image, ctx.manifest.binaryPath) !== 'same') return mis('image_differs_from_manifest_binary') + return ok(o.createFiletime) + }, + codexToken(rec, _s, ctx) { + const o = rec.observed + if (!ctx.codexLabel) return unk('codex_label_missing') + if (o.tokenOpened !== true) return unk('token_not_opened') + if (o.integritySid !== INTEGRITY[ctx.codexLabel]) return mis('codex_integrity_differs_from_label') + return ok(o.integritySid) + }, + processAfterLive(rec, _s, ctx) { + const o = rec.observed + if (o.state !== 'live') return mis(`process_state_${o.state}`) + if (!ctx.run) return unk('no_run_record') + if (o.pid !== ctx.run.codexPid) return mis('queried_pid_differs_from_run') + const first = ctx.observeByStep.get('process.identity#')?.observed + if (!FORMATS.filetime(first?.createFiletime)) return unk('baseline_create_missing') + if (o.createFiletime !== first.createFiletime) return mis('create_filetime_changed') + if (o.userSid !== first.userSid) return mis('target_user_changed') + return ok(o.createFiletime) + }, + threadStable(rec, sample, ctx) { + const o = rec.observed + const before = ctx.observeByStep.get(`thread.identity#${sample}`)?.observed + if (!before || !FORMATS.fileId(before.fileId)) return unk('baseline_identity_missing') + if (o.fileId !== before.fileId) return mis('thread_fileid_changed') + for (const key of ['links', 'deletePending', 'directory', 'reparse']) { + if (o[key] !== before[key]) return mis(`thread_${key}_changed`) + } + return ok(o.fileId) + }, + // The guarded probe must have run under the expected coordination guard and + // lock directory, and every identity it verified must match this sample's + // own identity records, before and after the locks. + rangeGuardBinding(rec, sample, ctx) { + const o = rec.observed + const lockdir = ctx.observeByStep.get(`lockdir.identity#${sample}`)?.observed + const guard = ctx.observeByStep.get(`coordination.identity#${sample}`)?.observed + const thread = ctx.observeByStep.get(`thread.identity#${sample}`)?.observed + for (const [name, r] of [['lockdir', lockdir], ['coordination', guard], ['thread', thread]]) { + if (!r || !FORMATS.fileId(r.fileId)) return unk(`${name}_identity_missing`) + } + const pathProblem = sampleIdentityProblem(sample, ctx) + if (pathProblem) return unk(pathProblem) + if (o.dirFileIdBefore !== lockdir.fileId || o.dirFileIdAfter !== lockdir.fileId) return mis('lock_directory_identity_differs') + if (o.guardFileId !== guard.fileId || o.guardFileIdAfter !== guard.fileId) return mis('guard_identity_differs') + if (o.threadFileIdBefore !== thread.fileId || o.threadFileIdAfter !== thread.fileId) return mis('thread_identity_differs') + if (o.guardLinks !== guard.links || o.threadLinks !== thread.links) return mis('link_count_differs') + if (o.state !== 'probed') return mis(`range_state_${o.state}`) + return ok(o.guardFileId) + }, + // Restart Manager roles are recomputed from the control record; a recorded + // role is never read as fact, and the target PID with a different creation + // FILETIME is a reused PID, not the launched process. + openersRm(rec, sample, ctx) { + const o = rec.observed + if (o.complete !== true) return unk('rm_incomplete') + if (!ctx.run) return unk('no_run_record') + const paths = samplePaths(sample, ctx) + const pathProblem = sampleIdentityProblem(sample, ctx) + if (!paths || pathProblem) return unk(pathProblem ?? 'sample_paths_unusable') + const expected = [paths.thread, paths.guard].map(normalizeLocalPath).sort() + const actual = o.files.map(normalizeLocalPath).sort() + if (actual.length !== expected.length || actual.some((path, i) => path !== expected[i])) return unk('rm_target_files_differ') + const first = ctx.observeByStep.get('process.identity#')?.observed + if (!FORMATS.filetime(first?.createFiletime)) return unk('baseline_create_missing') + for (const p of o.processes) { + if (p.pid === ctx.run.codexPid && p.startFiletime !== first.createFiletime) return mis(`target_pid_reused:${p.pid}`) + const want = p.pid === ctx.run.codexPid ? 'launched_codex' : p.pid === ctx.run.observerPid ? 'observer' : 'other' + if (p.role !== want) return mis(`role_recomputed_differs:${p.pid}`) + } + return ok(o.count) + }, +} + +function ok(value) { return { verdict: 'match', reason: null, value } } +function mis(reason) { return { verdict: 'mismatch', reason, value: null } } +function unk(reason) { return { verdict: 'unknown', reason, value: null } } + +function validSampleCount(samples) { + return Number.isInteger(samples) && samples >= 1 && samples <= MAX_SAMPLES +} + +// Validates one observer JSONL file of a single mode. Integrity must be +// identical on every record; a mixed file, an unexpected step, an out-of-range +// or misplaced sample index, or an orchestrator_error record makes the file +// unusable. +export function validateObserverFile(records, mode, problems = [], samples = null) { + const out = { mode, label: null, sid: null, problems: [...problems] } + if (records.length === 0) out.problems.push('no_records') + const allowed = knownSteps(mode) + const sampled = perSampleSteps(mode) + const limit = validSampleCount(samples) ? samples : MAX_SAMPLES + const sids = new Set() + const labels = new Set() + const seen = new Set() + const order = new Map() + const sampleSeq = new Map() + records.forEach((r, index) => { + if (r.schema !== SCHEMA) out.problems.push(`schema:${r.step}`) + if (r.mode !== mode) out.problems.push(`mode:${r.step}`) + if (r.safeToUnlock !== false) out.problems.push(`safe_to_unlock:${r.step}`) + const sample = r.sample ?? null + if (r.step === 'orchestrator_error') out.problems.push('orchestrator_error') + else if (!allowed.has(r.step)) out.problems.push(`unexpected_step:${r.step}`) + else if (sampled.has(r.step)) { + if (!Number.isInteger(sample) || sample < 1 || sample > limit) out.problems.push(`sample_invalid:${r.step}#${sample}`) + else { + const seq = sampleSeq.get(r.step) ?? [] + seq.push(sample) + sampleSeq.set(r.step, seq) + } + } else if (sample !== null) out.problems.push(`sample_unexpected:${r.step}#${sample}`) + sids.add(r.integrity?.sid ?? null) + labels.add(r.integrity?.label ?? null) + const key = `${r.step}#${sample ?? ''}` + if (seen.has(key)) out.problems.push(`duplicate:${key}`) + seen.add(key) + if (!order.has(r.step)) order.set(r.step, index) + order.set(`last:${r.step}`, index) + }) + // Per-sample steps must appear once per sample, in increasing sample order. + if (validSampleCount(samples)) { + for (const step of sampled) { + if (!sampleSeq.has(step)) out.problems.push(`missing_sample_step:${step}`) + } + for (const [step, seq] of sampleSeq) { + const wanted = Array.from({ length: samples }, (_, i) => i + 1) + if (seq.length !== samples || seq.some((v, i) => v !== wanted[i])) out.problems.push(`sample_order:${step}:${seq.join('.')}`) + } + const actual = records.filter((r) => sampled.has(r.step)).map((r) => `${r.step}#${r.sample}`) + const expected = Array.from({ length: samples }, (_, i) => SAMPLE_STEPS.map((step) => `${step}#${i + 1}`)).flat() + if (actual.length !== expected.length || actual.some((key, i) => key !== expected[i])) out.problems.push('sample_sequence_invalid') + } + // Control and process records bracket the samples. + if (mode === 'observe' && sampleSeq.size > 0) { + const firstSampled = Math.min(...[...sampleSeq.keys()].map((step) => order.get(step))) + const lastSampled = Math.max(...[...sampleSeq.keys()].map((step) => order.get(`last:${step}`))) + for (const step of ['run', 'process.identity']) { + const at = order.get(step) + if (at === undefined) out.problems.push(`missing_step:${step}`) + else if (at > firstSampled) out.problems.push(`order:${step}_after_samples`) + } + const afterAt = order.get('process.identity_after') + if (afterAt === undefined) out.problems.push('missing_step:process.identity_after') + else if (afterAt < lastSampled) out.problems.push('order:process.identity_after_before_samples') + } + if (sids.size === 1 && labels.size === 1) { + const [sid] = sids + const [label] = labels + if (sid !== null && integrityFromSid(sid) === label && (label === 'medium' || label === 'high')) { + out.sid = sid + out.label = label + } else out.problems.push('integrity_label_sid_mismatch') + } else if (records.length > 0) out.problems.push('mixed_integrity') + return out +} + +// A preflight file must end in exactly one manifest record, and every other +// required preflight step must be present exactly once as a usable +// observation. A file with both a complete and an incomplete manifest record, +// or with a manifest that is not last, is refused. +export function validateManifestPlacement(records) { + const problems = [] + const manifests = records.filter((r) => r.step === 'manifest') + if (manifests.length === 0) problems.push('manifest_absent') + if (manifests.length > 1) problems.push(`manifest_not_unique:${manifests.length}`) + if (manifests.length > 0 && records[records.length - 1] !== manifests[manifests.length - 1]) problems.push('manifest_not_last') + const record = manifests.length === 1 ? manifests[0] : null + if (record && record.observed?.complete !== true) problems.push('manifest_not_complete') + for (const exp of EXPECTATIONS) { + if (exp.mode !== 'preflight' || exp.step === 'manifest' || exp.measurable === false) continue + const found = records.filter((r) => r.step === exp.step) + if (found.length !== 1) problems.push(`manifest_requires_one:${exp.step}:${found.length}`) + } + return { record, problems } +} + +// Rebuilds the manifest's fields from the raw preflight observations. The +// manifest's own values are then compared with this reconstruction, so a +// manifest that disagrees with the run it claims to describe can never pass. +export function reconstructManifest(records, label) { + const problems = [] + const usable = (step) => { + const rec = records.find((r) => r.step === step) + const reason = unmeasuredReason(rec, 'preflight', null) + if (reason) { + problems.push(`${step}:${reason}`) + return null + } + return rec.observed + } + const harness = usable('harness') + const token = usable('token.self') + const binary = usable('binary.sha256') + const home = usable('home.isolated') + const version = usable('binary.version') + if (problems.length > 0) return { fields: null, problems } + return { + problems, + fields: { + home: home.home, homeFileId: home.fileId, parent: home.parent, parentFileId: home.parentFileId, + binaryPath: binary.path, binarySha256: binary.sha256, binarySize: binary.size, + version: version.stdoutFirstLine, sourceCommit: harness.sourceCommit, + integritySid: token.integritySid, label, harness: harness.files, + }, + } +} + +function rowFor(exp, records, samples, fileUsable, ctx) { + const row = { + id: exp.id, mode: exp.mode, step: exp.step, hypothesis: exp.hypothesis ?? null, derive: exp.derive ?? null, + gates: exp.gates, collected: 'none', measured: 'none', verdict: 'unknown', reasons: [], values: [], + } + if (exp.measurable === false) { + row.reasons.push('not_measurable') + return row + } + if (exp.perSample && !validSampleCount(samples)) { + row.reasons.push('sample_count_invalid') + return row + } + const indices = exp.perSample ? Array.from({ length: samples }, (_, i) => i + 1) : [null] + let collected = 0 + let measured = 0 + const verdicts = [] + for (const sample of indices) { + const tag = sample === null ? '' : `#${sample}` + const rec = records.find((r) => r.step === exp.step && (r.sample ?? null) === sample) + if (rec) collected++ + let reason = unmeasuredReason(rec, exp.mode, exp.hypothesis) + // Drift only applies to raw-comparison rows; derived and recorded rows have + // no table hypothesis to drift from. + if (!reason && exp.hypothesis && !sameExpectation(exp.hypothesis, rec.expect)) reason = 'expectation_drift' + if (reason) { + row.reasons.push(`${reason}${tag}`) + verdicts.push('unknown') + row.values.push(null) + continue + } + measured++ + row.values.push(pickValues(rec.observed, exp.hypothesis, exp.compare)) + if (exp.derive) { + const d = DERIVATIONS[exp.derive](rec, sample, ctx) + if (d.verdict !== 'match') row.reasons.push(`${d.reason}${tag}`) + verdicts.push(d.verdict) + continue + } + if (exp.hypothesis === null) { + verdicts.push('recorded') + continue + } + verdicts.push(compareHypothesis(rec.observed, exp.hypothesis)) + } + row.collected = collected === indices.length ? 'all' : collected === 0 ? 'none' : 'partial' + row.measured = measured === indices.length ? 'all' : measured === 0 ? 'none' : 'partial' + if (!fileUsable) { + row.reasons.push('file_unusable') + row.verdict = 'unknown' + } else if (verdicts.includes('mismatch')) row.verdict = 'mismatch' + else if (verdicts.includes('unknown')) row.verdict = 'unknown' + else row.verdict = exp.hypothesis === null && !exp.derive ? 'recorded' : 'match' + return row +} + +// A file-global problem (structure, labels, provenance inputs) makes the whole +// result `unknown` before any per-row mismatch is reported: the evidence is not +// in a state where a measured contradiction can be attributed. Valid, complete +// evidence with a genuine mismatch still fails. +function gateOf(rows, problems) { + if (problems.length > 0) return 'unknown' + if (rows.some((r) => r.verdict === 'mismatch')) return 'fail' + if (rows.some((r) => r.verdict === 'unknown')) return 'unknown' + return 'pass' +} + +function dependents(w7, label, codexLabel) { + const state = w7 === 'pass' ? 'evidence_available_decision_pending' : 'blocked_on_W7' + return { + W3: { nativeClassification: state, integrity: label, codexIntegrity: codexLabel, note: 'real process classification needs W7 measured evidence at this integrity level' }, + W5: { releaseDecision: state, integrity: label, codexIntegrity: codexLabel, note: 'also requires W4; never authorized by observation' }, + } +} + +// The provenance state of one run: every provenance row measured and matching, +// a format-valid baseline, a usable manifest and no file-global problem. W6 +// pairing requires it on both sides; it never requires W7's unmeasurable +// coordination range. +function provenanceOf(rows, problems, baselineProblems) { + const state = { state: 'unvalidated', problems: [], mismatches: [], unknowns: [] } + for (const p of baselineProblems) state.problems.push(`baseline:${p}`) + for (const p of problems) state.problems.push(`file:${p}`) + for (const id of PROVENANCE_ROWS) { + const row = rows.find((r) => r.id === id) + if (!row) { + state.problems.push(`row_absent:${id}`) + continue + } + if (row.verdict === 'mismatch') { + state.mismatches.push(id) + state.problems.push(`row_mismatch:${id}`) + } else if (row.verdict !== 'match') { + state.unknowns.push(id) + state.problems.push(`row_${row.verdict}:${id}`) + } + } + if (state.problems.length === 0) state.state = 'validated' + return state +} + +// Evaluates one integrity level: one preflight file plus one observe file. +// preflightSha256 is the digest of the preflight file bytes as read by the +// caller; observe must reference it. `baseline` is the operator-asserted, +// independently established { sourceCommit, binarySha256, version, harness }. +// Without a format-valid baseline, provenance rows stay `unknown` and the gate +// cannot pass. +export function evaluateRun({ preflight, observe, preflightSha256, expectedLabel, baseline = null, expectations = EXPECTATIONS }) { + const baselineProblems = validateBaseline(baseline) + const runRec = observe.records.find((r) => r.step === 'run' && (r.sample ?? null) === null) + const runReason = unmeasuredReason(runRec, 'observe', null) + const run = runReason ? null : runRec.observed + const samples = run?.samples + const pf = validateObserverFile(preflight.records, 'preflight', preflight.problems) + const ob = validateObserverFile(observe.records, 'observe', observe.problems, samples) + const problems = [...pf.problems.map((p) => `preflight:${p}`), ...ob.problems.map((p) => `observe:${p}`)] + if (pf.label !== null && ob.label !== null && (pf.label !== ob.label || pf.sid !== ob.sid)) problems.push('preflight_observe_integrity_differs') + const label = pf.label !== null && pf.label === ob.label ? pf.label : null + if (expectedLabel && label !== expectedLabel) problems.push('expected_label_mismatch') + if (!FORMATS.sha256(preflightSha256)) problems.push('preflight_sha256_invalid') + for (const p of baselineProblems) problems.push(p) + if (runReason) problems.push(`run_unusable:${runReason}`) + if (!validSampleCount(samples)) problems.push('sample_count_invalid') + const placement = validateManifestPlacement(preflight.records) + for (const p of placement.problems) problems.push(`preflight:${p}`) + const reconstruction = reconstructManifest(preflight.records, label) + const manifestUsable = placement.record !== null && !schemaProblem('preflight', 'manifest', placement.record.observed ?? {}) + const manifest = manifestUsable ? placement.record.observed : null + if (!manifest) problems.push('manifest_unusable') + const obHarness = observe.records.find((r) => r.step === 'harness') + if (!FORMATS.sha256(obHarness?.observed?.manifestSha256) || obHarness?.observed?.manifestSha256 !== preflightSha256) problems.push('manifest_reference_mismatch') + const codexLabel = ['medium', 'high'].includes(run?.codexLabel) ? run.codexLabel : null + if (codexLabel === null) problems.push('codex_label_missing') + const observeByStep = new Map(observe.records.map((r) => [`${r.step}#${r.sample ?? ''}`, r])) + const preflightByStep = new Map(preflight.records.map((r) => [r.step, r])) + const ctx = { label, codexLabel, baseline, baselineProblems, manifest, reconstruction, preflightSha256, preflightByStep, observeByStep, run } + const pfUsable = pf.problems.length === 0 + const obUsable = ob.problems.length === 0 && !problems.includes('preflight_observe_integrity_differs') && !problems.includes('manifest_reference_mismatch') + const rows = expectations.map((exp) => { + const recs = exp.mode === 'preflight' ? preflight.records : observe.records + const row = rowFor(exp, recs, samples, exp.mode === 'preflight' ? pfUsable : obUsable, ctx) + row.integrity = label + return row + }) + const w7 = gateOf(rows, problems) + return { + kind: 'observer', + label, + sid: label ? pf.sid : null, + codexLabel, + variant: label && codexLabel ? (label === codexLabel ? 'same_level' : 'cross_level') : null, + baselineProvided: Boolean(baseline) && baselineProblems.length === 0, + harness: manifest?.harness ?? null, + sourceCommit: manifest?.sourceCommit ?? null, + binarySha256: manifest?.binarySha256 ?? null, + version: manifest?.version ?? null, + homeFileId: manifest?.homeFileId ?? null, + codexPid: run?.codexPid ?? null, + problems, + provenance: provenanceOf(rows, problems, baselineProblems), + rows, + totals: tally(rows), + w7, + blockers: rows.filter((r) => r.verdict !== 'match' && r.verdict !== 'recorded').map((r) => r.id), + dependents: dependents(w7, label, codexLabel), + safeToUnlock: false, + } +} + +function tally(rows) { + const t = {} + for (const r of rows) t[r.verdict] = (t[r.verdict] ?? 0) + 1 + return t +} + +// The existing experimental/windows-fixture producer records integrity once +// per run and has no terminal manifest binding the fixture-meta record to the +// lab bytes, run, or source. Its logs therefore cannot be promoted to a passing +// W6 gate. This summary recomputes every row from raw fields (never trusting +// lab.verdict) for information, and always carries `fixture_producer_unbound` +// so pairing can never report `compared` from fixture evidence. Changing that +// needs a separately reviewed producer proposal. +const FIXTURE_FIELDS = ['ok', 'state', 'reason', 'win32', 'locked', 'complete', 'count', 'exitCode', 'empty', 'exists', 'bytes'] + +function fixtureValues(observed) { + if (!observed || typeof observed !== 'object') return null + const out = {} + for (const k of FIXTURE_FIELDS) if (Object.hasOwn(observed, k)) out[k] = observed[k] + if (Array.isArray(observed.processes)) out.roles = observed.processes.map((p) => p?.role ?? 'unlabelled').sort() + return out +} + +// A fixture row is measured only when its observation is a usable object that +// is not denied/partial/error/unknown. lab.verdict is never trusted. +function fixtureRowReasons(r, ids, usable) { + const reasons = [] + const o = r.observed + if (r.safeToUnlock !== false) reasons.push('safe_to_unlock') + if (ids.get(`${r.scenario}/${r.step}`) > 1) reasons.push('duplicate') + if (o === null || o === undefined) reasons.push('no_observation') + else if (typeof o !== 'object' || Array.isArray(o)) reasons.push('malformed_observation') + else { + if (hasAccessDenied(o)) reasons.push('access_denied') + if (o.ok === false) reasons.push('native_error') + if (o.complete === false) reasons.push('partial') + if (o.state === 'unknown') reasons.push('unknown_state') + if (Object.hasOwn(o, 'exception')) reasons.push('exception') + } + if (!usable) reasons.push('file_unusable') + if (!['match', 'mismatch', 'recorded'].includes(r.verdict)) reasons.push(`harness_${r.verdict ?? 'no_verdict'}`) + return reasons +} + +export function fixtureSummary({ lab, meta, expectedLabel }) { + const problems = [...lab.problems.map((p) => `lab:${p}`), ...meta.problems.map((p) => `meta:${p}`)] + // The fixture producer is not bound to its own bytes/run/source, so its + // evidence can never be a successful gate. This is unconditional. + problems.push('fixture_producer_unbound') + const mv = validateObserverFile(meta.records, 'fixture-meta', []) + // fixture-meta uses a mode not covered by knownSteps, so its own step checks + // are advisory; keep only the integrity and harness facts. + const host = lab.records.filter((r) => r.scenario === 'environment' && r.step === 'host') + let labSid = null + if (host.length !== 1) problems.push('lab:host_record_count') + else labSid = sidFromWhoamiLine(host[0].observed?.integrity) + if (labSid === null) problems.push('lab:integrity_unparsed') + const metaSid = mv.sid ?? (meta.records.find((r) => r.step === 'token.self')?.integrity?.sid ?? null) + if (labSid !== null && metaSid !== null && labSid !== metaSid) problems.push('lab_meta_integrity_differs') + const label = labSid !== null && labSid === metaSid ? integrityFromSid(labSid) : null + if (label !== 'medium' && label !== 'high') problems.push('fixture_integrity_unresolved') + if (expectedLabel && label !== expectedLabel) problems.push('expected_label_mismatch') + const metaHarness = meta.records.find((r) => r.step === 'fixture.harness')?.observed + if (!metaHarness || metaHarness.complete !== true || !FORMATS.hashMap(metaHarness.files)) problems.push('meta:fixture_harness_incomplete') + // A closed expected set: the fixture must at least carry the host record and + // its canonical E1/E3/identity anchors. Anything less is out of scope. + const present = new Set(lab.records.map((r) => `${r.scenario}/${r.step}`)) + const expectedSet = ['environment/host', 'E1/free', 'E3/holder', 'identity/live'] + const missingAnchors = expectedSet.filter((id) => !present.has(id)) + if (missingAnchors.length > 0) problems.push(`lab:missing_anchors:${missingAnchors.join(',')}`) + const usable = false // never usable as a successful gate + const ids = new Map() + for (const r of lab.records) { + const id = `${r.scenario}/${r.step}` + ids.set(id, (ids.get(id) ?? 0) + 1) + } + const rows = lab.records.map((r) => { + const reasons = fixtureRowReasons(r, ids, usable) + return { + id: `${r.scenario}/${r.step}`, gates: ['W6'], integrity: label, + integritySource: 'lab_host_record+fixture_meta(unbound)', + harnessVerdict: r.verdict ?? null, reasons, + verdict: 'unknown', values: [fixtureValues(r.observed)], + } + }) + return { + kind: 'fixture', + label, + sid: label ? metaSid : null, + bound: false, + baselineProvided: false, + harness: metaHarness?.files ?? null, + sourceCommit: metaHarness?.sourceCommit ?? null, + binarySha256: null, + version: null, + problems, + // Fixture evidence is never a validated provenance state. + provenance: { state: 'unvalidated', problems: ['fixture_producer_unbound'], mismatches: [], unknowns: [] }, + rows, + totals: tally(rows), + safeToUnlock: false, + } +} + +// W6 pairing. Each level stays a separate result. Comparison completion is kept +// separate from expectation satisfaction and from CLI success: a mismatch at +// either level (in a compared row or in the provenance it rests on) is a failed +// hypothesis that no explanation can erase, and any unknown row or unvalidated +// provenance keeps the pair unresolved. Nothing is carried across levels. +export function pairSummaries(medium, high, explanations = {}) { + const problems = [] + if (medium.label !== 'medium') problems.push('medium_label') + if (high.label !== 'high') problems.push('high_label') + if (medium.kind !== high.kind) problems.push('kind_differs') + if (medium.problems.length > 0) problems.push('medium_problems') + if (high.problems.length > 0) problems.push('high_problems') + if (!deepEqualMap(medium.harness, high.harness)) problems.push('harness_differs') + if (!sameKeySet(medium.harness, HARNESS_FILES)) problems.push('harness_name_set') + if (!FORMATS.commit(medium.sourceCommit) || medium.sourceCommit !== high.sourceCommit) problems.push('source_commit_differs') + if (medium.kind === 'observer') { + if (!FORMATS.sha256(medium.binarySha256) || medium.binarySha256 !== high.binarySha256) problems.push('binary_differs') + if (!FORMATS.string(medium.version) || medium.version !== high.version) problems.push('version_differs') + if (!medium.baselineProvided || !high.baselineProvided) problems.push('baseline_missing') + // Provenance must be validated on BOTH sides before any comparison is + // reported; W6 does not require W7's unmeasurable coordination range, but + // it does require every provenance prerequisite of it. + if (medium.provenance?.state !== 'validated') problems.push('medium_provenance_unvalidated') + if (high.provenance?.state !== 'validated') problems.push('high_provenance_unvalidated') + // A cross-level run (observer and Codex at different levels) is reported on + // its own and never paired with a same-level run. + if (medium.variant !== 'same_level' || high.variant !== 'same_level') problems.push('cross_level_run_not_pairable') + } + if (medium.kind === 'fixture' || high.kind === 'fixture') problems.push('fixture_evidence_not_pairable') + const provenanceFailures = [ + ...(medium.provenance?.mismatches ?? []).map((id) => `medium:${id}`), + ...(high.provenance?.mismatches ?? []).map((id) => `high:${id}`), + ] + const w6Rows = (s) => new Map(s.rows.filter((r) => r.gates.includes('W6')).map((r) => [r.id, r])) + const m = w6Rows(medium) + const h = w6Rows(high) + const ids = [...new Set([...m.keys(), ...h.keys()])].sort() + const rows = [] + const differences = [] + const failedRows = [] + for (const id of ids) { + const a = m.get(id) + const b = h.get(id) + const entry = { + id, + medium: a ? { verdict: a.verdict, values: a.values, reasons: a.reasons } : { verdict: 'unknown', reasons: ['not_collected'] }, + high: b ? { verdict: b.verdict, values: b.values, reasons: b.reasons } : { verdict: 'unknown', reasons: ['not_collected'] }, + } + if (entry.medium.verdict === 'mismatch' || entry.high.verdict === 'mismatch') failedRows.push(id) + const differs = entry.medium.verdict !== entry.high.verdict || JSON.stringify(entry.medium.values ?? null) !== JSON.stringify(entry.high.values ?? null) + if (differs) { + const text = typeof explanations[id] === 'string' && explanations[id].trim().length > 0 ? explanations[id].trim() : null + entry.explanation = text + differences.push(id) + } + rows.push(entry) + } + // Explanations apply only to differences; they never erase a failed + // hypothesis (a mismatch at either level). + const unexplained = rows.filter((r) => differences.includes(r.id) && !failedRows.includes(r.id) && r.explanation === null).map((r) => r.id) + const unknownRows = rows.filter((r) => r.medium.verdict === 'unknown' || r.high.verdict === 'unknown').map((r) => r.id) + let status = 'compared' + if (failedRows.length > 0 || provenanceFailures.length > 0) status = 'fail' + else if (problems.length > 0 || unexplained.length > 0 || unknownRows.length > 0) status = 'unknown' + return { + kind: medium.kind === high.kind ? medium.kind : null, + status, + problems, + provenanceFailures, + differences, + unexplained, + unknownRows, + failedRows, + rows, + note: 'comparison completion is separate from expectation satisfaction; a mismatch at either level, in a compared row or in its provenance, fails regardless of explanation, and no result is carried across levels', + safeToUnlock: false, + } +} + +function cell(v) { + return String(v).replace(/\|/g, '\\|') +} + +export function renderExpectationTable(expectations = EXPECTATIONS) { + const lines = [ + '| ID | Mode | Step | Per sample | Check | Gates |', + '| --- | --- | --- | --- | --- | --- |', + ] + for (const e of expectations) { + const check = e.measurable === false ? 'not measurable (always unknown)' + : e.derive ? `derived: \`${e.derive}\`` + : e.hypothesis === null ? 'recorded only (typed schema)' + : Object.entries(e.hypothesis).map(([k, v]) => `\`${k}=${JSON.stringify(v)}\``).join(', ') + lines.push(`| ${cell(e.id)} | ${e.mode} | \`${cell(e.step)}\` | ${e.perSample ? 'yes' : 'no'} | ${cell(check)} | ${e.gates.join(', ')} |`) + } + return lines.join('\n') + '\n' +} + +export { ROLES } diff --git a/experimental/windows-native-observer/observer.ps1 b/experimental/windows-native-observer/observer.ps1 new file mode 100644 index 0000000..fdc7da6 --- /dev/null +++ b/experimental/windows-native-observer/observer.ps1 @@ -0,0 +1,1141 @@ +<# +Read-only Windows observer for codex-unlock research W6/W7 (#151, #152). + +Windows PowerShell 5.1. WinObserver.cs is compiled by Add-Type. Three modes: + + preflight verify the Codex binary by SHA-256 before any execution, then + its version; measure token integrity; record host policy and + Defender state; create a fresh private disposable CODEX_HOME. + observe with the operator-launched Codex holding one thread in that + home, observe lock file identity, share modes, lock ranges + (brief probes under the native coordination lock), Restart + Manager openers, and the process identity of the given PID. + fixture-meta record token integrity and fixture file digests next to an + experimental/windows-fixture lab.ps1 run for W6 pairing. + + powershell -NoProfile -NonInteractive -File observer.ps1 -Mode preflight -IntegrityLabel -SourceCommit <40 hex> -CodexBinary -ExpectedSha256 <64 hex> -ExpectedVersion -OutFile + powershell -NoProfile -NonInteractive -File observer.ps1 -Mode observe -IntegrityLabel -CodexIntegrityLabel -Manifest -CodexPid -OutFile + powershell -NoProfile -NonInteractive -File observer.ps1 -Mode fixture-meta -IntegrityLabel -SourceCommit <40 hex> -OutFile + +HARD REFUSAL (incomplete prototype gate): -Mode preflight and -Mode observe +refuse to run. The ancestor/junction and creation boundary is NOT closed: the +no-follow ancestry check, the identity comparisons and the creation gate all +compare identities at separate points in time instead of pinning a path, so a +same-user actor could still swap an ancestor between the check and the use. No +flag, parameter or environment variable turns this off; closing it, or +accepting the residual, is a separate review decision (README, remaining +decision 10). The code below stays in the tree to be reviewed and statically +gated, not to be run. + +It never reads the user's Codex home or a pre-set CODEX_HOME, never changes +an ACL, privilege, execution policy, or Defender setting, never deletes a +file, and never terminates or signals a process. The host's existing +execution policy applies. Every record carries the measured token integrity +and safeToUnlock=false. + +It is not purely passive, though: the range probe takes a brief EXCLUSIVE +byte-range lock on the live .coordination.lock and on a thread lock that is +currently free. Codex acquires with a fail-immediately try_lock, so a Codex +acquisition inside one of those windows fails. That is an effect on the +observed process, and it is one more reason the native modes are refused. + +Add-Type compiles WinObserver.cs with the .NET compiler, which starts csc.exe +as a child process and writes its own temporary files into %TEMP%. That is a +compiler side effect, not an observation: no observer method is called before +the path and TEMP gate below has passed, and the gate runs BEFORE Add-Type so +the compiler never writes into a directory that resolves inside a real Codex +home. + +Exit codes: 0 = every hypothesis matched; 1 = at least one mismatch; +2 = incomplete, error, unknown, or an orchestrator/setup failure; +3 = refused before anything was created or observed. +#> +param( + [ValidateSet('preflight', 'observe', 'fixture-meta')][string]$Mode, + [string]$OutFile, + [ValidateSet('medium', 'high')][string]$IntegrityLabel, + [ValidateSet('medium', 'high')][string]$CodexIntegrityLabel, + [string]$SourceCommit, + [string]$CodexBinary, + [string]$ExpectedSha256, + [string]$ExpectedVersion, + [string]$Manifest, + [ValidateRange(0, 2147483647)][int]$CodexPid = 0, + [ValidateRange(1, 10)][int]$Samples = 3, + [ValidateRange(0, 600000)][int]$WaitBudgetMs = 15000, + [string]$FixtureDir +) +$ErrorActionPreference = 'Stop' +$Schema = 'codex-unlock-win-observer/1' +$IntegritySids = @{ medium = 'S-1-16-8192'; high = 'S-1-16-12288' } +# Exactly the file-name set expectations.mjs (HARNESS_FILES) requires. A unit +# test compares the two lists. +$HarnessNames = @('observer.ps1', 'WinObserver.cs', 'check.ps1', 'expectations.mjs', 'lib.mjs', 'evaluate.mjs') +$FixtureNames = @('lab.ps1', 'WinLockLab.cs', 'check.ps1') +# The DACL contract of the disposable parent and home, recorded as raw fields +# and recomputed by the evaluator. +$AclRights = 'FullControl' +$AclInheritance = 'ContainerInherit, ObjectInherit' +$AclType = 'Allow' +# Narrow environment allowlist for the one child process this harness starts +# (`codex --version`). The child's environment is cleared first, so no +# inherited authentication variable (CODEX_*, OPENAI_*, *TOKEN*, *KEY*, proxy +# or credential variables) and no USERPROFILE reaches it. The .NET compiler +# Add-Type starts is NOT covered by this allowlist: it inherits this process's +# environment, which is why no credential may be exported into this shell. +$ChildEnvAllowlist = @('SystemRoot', 'SystemDrive', 'windir', 'COMSPEC', 'PATHEXT', 'NUMBER_OF_PROCESSORS', 'PROCESSOR_ARCHITECTURE') +# The native modes stay refused while the ancestry/creation boundary is open. +$NATIVE_MODES_REFUSED = $true +$NativeRefusalReason = 'native preflight and observe are refused in this prototype: the ancestor/junction and creation boundary is not closed (identities are compared at separate points in time, not pinned), so no native observation is approved. There is no bypass flag; see README remaining decision 10.' + +# Union of the expectations.mjs hypotheses per step. evaluate.mjs re-checks +# every record against its own table and reports drift as unknown. +$Expect = @{ + # `complete = $false` is already classified as an error by Test-Expect + # before any hypothesis is compared, so `harness` and `manifest` carry no + # hypothesis: declaring `complete = $true` here would advertise a + # comparison that can never produce `mismatch`. + 'preflight' = @{ + 'harness' = $null + 'token.self' = @{ integrityMatchesLabel = $true } + 'binary.sha256' = @{ matches = $true } + 'home.isolated' = @{ isolated = $true } + 'binary.version' = @{ matches = $true; exitCode = 0 } + 'host.defender' = @{ amServiceEnabled = $true; realTimeProtectionEnabled = $true } + 'manifest' = $null + } + 'observe' = @{ + 'harness' = @{ matchesManifest = $true } + 'token.self' = @{ integrityMatchesLabel = $true; matchesManifest = $true } + 'home.identity' = @{ matchesManifest = $true } + 'process.identity' = @{ state = 'live'; imageMatchesBinary = $true; tokenOpened = $true; codexIntegrityMatchesLabel = $true } + 'process.sample' = @{ state = 'live' } + 'lockdir.identity' = @{ directory = $true; reparse = $false } + 'coordination.identity' = @{ directory = $false; reparse = $false; links = 1; deletePending = $false } + 'thread.count' = @{ count = 1 } + 'thread.identity' = @{ directory = $false; reparse = $false; links = 1; deletePending = $false } + 'share.read_rwd' = @{ opened = $true } + 'share.delete_rwd' = @{ opened = $true } + 'range.probe' = @{ state = 'probed'; 'ranges.rust.win32' = 33; 'ranges.fsext.win32' = 33; 'ranges.beyond_fsext.win32' = 33 } + 'thread.identity_after' = @{ sameFileId = $true } + 'process.identity_after' = @{ state = 'live'; sameCreate = $true } + } + 'fixture-meta' = @{ + 'token.self' = @{ integrityMatchesLabel = $true } + 'fixture.harness' = $null + } +} + +# Refused before anything was created or observed. `exit` raises a PowerShell +# flow-control exception that a `catch` block does not intercept, so the catch +# around setup is not expected to see a refusal; $script:SetupExit is recorded +# anyway so that a later refactor which does intercept it cannot silently turn +# a deliberate refusal into a different exit code. +$script:SetupExit = $null +function Refuse([string]$why) { Write-Output "refused: $why"; $script:SetupExit = 3; exit 3 } +# Setup could not be established either way: documented unknown, never the +# mismatch code 1 and never an accidental uncaught exit. +function Stop-Unknown([string]$why) { Write-Output "unknown: $why"; $script:SetupExit = 2; exit 2 } + +# ---- Pure helpers (check.ps1 extracts and exercises these) --------------- + +# Lexical, case-insensitive relation of a to b: same, inside, contains, +# disjoint. A drive root is reported as `contains` of everything on its volume, +# which is correct but is why a path's PARENT is never gated on this relation. +function Get-PathRelation([string]$a, [string]$b) { + $x = $a.TrimEnd('\') + $y = $b.TrimEnd('\') + if ([string]::Equals($x, $y, [StringComparison]::OrdinalIgnoreCase)) { return 'same' } + if ($x.StartsWith($y + '\', [StringComparison]::OrdinalIgnoreCase)) { return 'inside' } + if ($y.StartsWith($x + '\', [StringComparison]::OrdinalIgnoreCase)) { return 'contains' } + 'disjoint' +} + +# Strict spelling rules for every path this harness compares, reads, hashes or +# creates. Only absolute local DRIVE paths are accepted. UNC (\\server\share), +# device (\\?\, \\.\), rooted-without-drive (\x), drive-relative (C:x) and +# relative spellings are REFUSED rather than normalized, as are `.`/`..` +# components, empty components, components ending in a space or a dot, and +# characters Windows does not allow in a name. This mirrors +# normalizeLocalPath in expectations.mjs. An ambiguous spelling is refused; it +# is never rewritten into something that looks safe. +function Test-LocalPathSpelling([string]$p) { + if ([string]::IsNullOrEmpty($p)) { return $false } + if ($p.Length -gt 32767) { return $false } + if ($p -match '[\x00-\x1f\x7f"<>|*?]') { return $false } + if ($p.Contains('/')) { return $false } + if ($p -notmatch '^[A-Za-z]:\\') { return $false } + $parts = $p.Split('\') + if ($parts[0] -notmatch '^[A-Za-z]:$') { return $false } + # @() keeps array semantics for a one-element slice: without it a single + # remaining component would be a bare string and index into characters. + $rest = @($parts | Select-Object -Skip 1) + if ($rest.Count -gt 1 -and $rest[$rest.Count - 1] -eq '') { $rest = @($rest[0..($rest.Count - 2)]) } + if ($rest.Count -eq 1 -and $rest[0] -eq '') { return $true } + foreach ($part in $rest) { + if ($part -eq '' -or $part -eq '.' -or $part -eq '..') { return $false } + if ($part.Contains(':')) { return $false } + if ($part.EndsWith(' ') -or $part.EndsWith('.') -or $part.StartsWith(' ')) { return $false } + } + $true +} + +# True when any COMPONENT of the path is a disposable observer directory. The +# comparison is literal per component, never a wildcard match, so a bracket or +# a `*` in a real directory name cannot change the decision, and the rule holds +# on any volume regardless of the current %TEMP%. +function Test-InsideDisposableDir([string]$p) { + if (-not (Test-LocalPathSpelling $p)) { return $false } + foreach ($part in $p.Split('\')) { + if ($part -cmatch '^codex-win-obs-[0-9a-f]{12}$') { return $true } + } + $false +} + +# The lexical gate every relevant input and output path must pass BEFORE it is +# tested, read, hashed or created: accepted spelling, disjoint from the +# actual/default and environment Codex homes, and outside any disposable +# observer directory unless the caller is the disposable home itself. Real +# homes are compared textually and are never opened, listed or statted. +# Returns $null when the path is acceptable, otherwise the refusal reason. +function Test-PathGateSafe([string]$p, [string]$userCodexHome, [string]$envCodexHome, [bool]$allowDisposable) { + if (-not (Test-LocalPathSpelling $p)) { return 'path_spelling' } + if (-not (Test-LocalPathSpelling $userCodexHome)) { return 'user_home_spelling' } + if ((Get-PathRelation $p $userCodexHome) -ne 'disjoint') { return 'user_home_overlap' } + if ($envCodexHome) { + if (-not (Test-LocalPathSpelling $envCodexHome)) { return 'env_home_spelling' } + if ((Get-PathRelation $p $envCodexHome) -ne 'disjoint') { return 'env_home_overlap' } + } + if (-not $allowDisposable -and (Test-InsideDisposableDir $p)) { return 'inside_disposable_dir' } + $null +} + +# True only when the disposable temp ROOT passes the same gate. This refuses to +# create anything when TEMP resolves inside a real Codex home. +function Test-CandidateTempSafe([string]$tempRoot, [string]$userCodexHome, [string]$envCodexHome) { + $null -eq (Test-PathGateSafe $tempRoot $userCodexHome $envCodexHome $false) +} + +# Creation seam: the creation callback runs only when the gate returns true, so +# a gate failure (for example TEMP inside a real home) provably skips creation. +function Invoke-GuardedCreate([scriptblock]$Gate, [scriptblock]$Create) { + if (& $Gate) { return & $Create } + [pscustomobject]@{ created = $false; reason = 'gate_refused' } +} + +# Per-sample seam: a sample's active operations run only when that sample's own +# gate returns $null. One sample never inherits another sample's verdict, and a +# failed gate provably skips the enumeration, Restart Manager, share and range +# operations instead of recording them and continuing. +function Invoke-GuardedSample([scriptblock]$Gate, [scriptblock]$Operations) { + $reason = & $Gate + if ($reason) { return [pscustomobject]@{ ran = $false; reason = $reason } } + [pscustomobject]@{ ran = $true; result = (& $Operations) } +} + +function New-StreamChannel($stream, [int]$maxBytes) { + @{ stream = $stream; max = $maxBytes; buf = (New-Object byte[] 4096); ms = (New-Object IO.MemoryStream) + task = $null; complete = $false; truncated = $false; timedOut = $false; error = $null + bytes = 0; text = $null } +} + +# Drains every channel CONCURRENTLY against one monotonic deadline, so a child +# that fills one pipe while the reader waits on the other cannot deadlock the +# observer. Each channel is capped: the loop reads one byte MORE than the cap, +# so a short read exactly at the cap is end-of-stream while a full read is a +# real overflow. The channel hashtables are mutated in place: +# complete $true only at end-of-stream, inside the deadline, within the cap +# truncated more bytes than the cap were offered +# timedOut the deadline passed before end-of-stream +# error the read itself failed (the message is kept) +# Only complete=$true is usable evidence; the caller must not treat a valid +# first line beside a failed or truncated read as success. Nothing is ever +# terminated: a producer still holding the pipe is reported and left alone. A +# read still pending at the deadline is abandoned, and its buffer is not +# reused afterwards. +function Read-BothStreams($channels, [Diagnostics.Stopwatch]$clock, [int]$deadlineMs) { + while ($true) { + $pending = @() + foreach ($c in $channels) { + if ($c.complete -or $c.truncated -or $c.timedOut -or $c.error) { continue } + if ($null -eq $c.task) { + $room = ($c.max + 1) - $c.ms.Length + if ($room -le 0) { $c.truncated = $true; continue } + $want = [Math]::Min([int]$room, $c.buf.Length) + try { $c.task = $c.stream.ReadAsync($c.buf, 0, $want) } + catch { $c.error = $_.Exception.Message; continue } + } + $pending += $c + } + if ($pending.Count -eq 0) { break } + $remaining = $deadlineMs - $clock.ElapsedMilliseconds + if ($remaining -le 0) { foreach ($c in $pending) { $c.timedOut = $true }; break } + $tasks = [Threading.Tasks.Task[]]@($pending | ForEach-Object { $_.task }) + $index = -1 + try { $index = [Threading.Tasks.Task]::WaitAny($tasks, [int]$remaining) } + catch { foreach ($c in $pending) { $c.error = $_.Exception.Message }; break } + if ($index -lt 0) { foreach ($c in $pending) { $c.timedOut = $true }; break } + $done = $pending[$index] + $doneTask = $done.task + $done.task = $null + # Windows PowerShell 5.1 can turn a throwing Task.Result property + # getter into $null without a catchable exception. Never interpret + # that as EOF: inspect task state and use the awaiter's method. + if ($doneTask.IsCanceled) { $done.error = 'stream_read_canceled'; continue } + if ($doneTask.IsFaulted) { + $done.error = 'stream_read_faulted' + if ($null -ne $doneTask.Exception) { $done.error = $doneTask.Exception.GetBaseException().Message } + continue + } + try { + $n = $doneTask.GetAwaiter().GetResult() + if ($null -eq $n) { throw 'stream read returned no byte count' } + if ($n -le 0) { $done.complete = $true } else { $done.ms.Write($done.buf, 0, $n) } + } catch { + $done.error = $_.Exception.Message + } + } + foreach ($c in $channels) { + if ($c.ms.Length -gt $c.max) { $c.truncated = $true; $c.complete = $false } + if ($c.error) { $c.complete = $false } + $c.bytes = [int]$c.ms.Length + $c.text = [Text.Encoding]::UTF8.GetString($c.ms.ToArray()) + } + $channels +} + +# Single-stream wrapper over the same drain loop, so the bounded-read contract +# that check.ps1 exercises is the one preflight uses. +function Read-BoundedStream($stream, [int]$maxBytes, [Diagnostics.Stopwatch]$clock, [int]$deadlineMs) { + $c = New-StreamChannel $stream $maxBytes + Read-BothStreams @($c) $clock $deadlineMs | Out-Null + $c +} + +# Restart Manager opener roles, recomputed from the run's own PIDs and the +# target's creation FILETIME. The target PID with a different creation time is +# a reused PID, not the launched process, so it is not labelled as one. +function Get-OpenerRole([int]$ProcessId, [string]$StartFiletime, [int]$TargetPid, [string]$TargetCreate, [int]$ObserverPid) { + if ($ProcessId -eq $TargetPid -and $TargetCreate -and $StartFiletime -ceq $TargetCreate) { return 'launched_codex' } + if ($ProcessId -eq $ObserverPid) { return 'observer' } + 'other' +} + +function Get-Dotted($o, [string]$path) { + $cur = $o + foreach ($k in $path.Split('.')) { + if ($null -eq $cur) { return $null } + $p = $cur.PSObject.Properties[$k] + if ($null -eq $p) { return $null } + $cur = $p.Value + } + $cur +} + +function Test-Denied($o) { + if ($o -is [System.Management.Automation.PSCustomObject]) { + foreach ($p in $o.PSObject.Properties) { + if (($p.Name -eq 'win32' -or $p.Name -eq 'tokenWin32') -and "$($p.Value)" -eq '5') { return $true } + if (Test-Denied $p.Value) { return $true } + } + } elseif ($o -is [Array]) { + foreach ($i in $o) { if (Test-Denied $i) { return $true } } + } + $false +} + +# incomplete (no observation) first; ok=false/complete=false/exception is +# error; an unknown state or access denial is unknown; recorded has no +# hypothesis. Only then is the hypothesis compared. +function Test-Expect($observed, $expect) { + if ($null -eq $observed) { return 'incomplete' } + if ($observed.PSObject.Properties['exception'] -or $observed.ok -ne $true -or $observed.complete -eq $false) { return 'error' } + if ($observed.state -eq 'unknown' -or (Test-Denied $observed)) { return 'unknown' } + if ($null -eq $expect) { return 'recorded' } + foreach ($k in $expect.Keys) { + $v = Get-Dotted $observed $k + if ($null -eq $v -or "$v" -ne "$($expect[$k])") { return 'mismatch' } + } + 'match' +} + +function Test-SameDigests($a, $b) { + if ($null -eq $a -or $null -eq $b) { return $false } + $na = @($a.PSObject.Properties | ForEach-Object { $_.Name } | Sort-Object) + $nb = @($b.PSObject.Properties | ForEach-Object { $_.Name } | Sort-Object) + if (($na -join '|') -ne ($nb -join '|') -or $na.Count -eq 0) { return $false } + foreach ($n in $na) { if ($null -eq $a.$n -or "$($a.$n)" -cne "$($b.$n)") { return $false } } + $true +} + +# ---- Path gate: runs before Add-Type and before any file is opened -------- + +function Resolve-Full([string]$p) { + [IO.Path]::GetFullPath($ExecutionContext.SessionState.Path.GetUnresolvedProviderPathFromPSPath($p)) +} + +# Validates every path this invocation will touch, lexically and in one place, +# before any Test-Path, read, hash, creation or Add-Type. The actual and +# environment Codex homes are derived textually from the environment and are +# never opened. Returns a result object; it refuses (exit 3) on any failure. +function Assert-PathGate { + $userProfile = $env:USERPROFILE + if (-not $userProfile) { Refuse 'USERPROFILE is not set, so the real Codex home cannot be excluded' } + if (-not (Test-LocalPathSpelling $userProfile)) { Refuse 'USERPROFILE is not an accepted absolute local path' } + $userCodexHome = [IO.Path]::GetFullPath((Join-Path $userProfile '.codex')) + $envCodexHome = $null + if ($env:CODEX_HOME) { + $envCodexHome = [IO.Path]::GetFullPath($env:CODEX_HOME) + if (-not (Test-LocalPathSpelling $envCodexHome)) { Refuse 'CODEX_HOME is set to a path spelling this harness refuses' } + } + $tempRoot = $null + if ($env:TEMP) { $tempRoot = Resolve-Full $env:TEMP } + if (-not $tempRoot -or -not (Test-LocalPathSpelling $tempRoot)) { Refuse 'TEMP is unset or not an accepted absolute local path' } + # The compiler Add-Type starts writes into TEMP, so TEMP must clear the + # real-home boundary before anything is compiled. + $tempReason = Test-PathGateSafe $tempRoot $userCodexHome $envCodexHome $false + if ($tempReason) { Refuse "TEMP is not usable as a disposable root ($tempReason); nothing was created or compiled" } + $scriptRoot = Resolve-Full $PSScriptRoot + $scriptReason = Test-PathGateSafe $scriptRoot $userCodexHome $envCodexHome $false + if ($scriptReason) { Refuse "the harness directory is not usable ($scriptReason)" } + [pscustomobject]@{ + userProfile = $userProfile; userCodexHome = $userCodexHome; envCodexHome = $envCodexHome + tempRoot = $tempRoot; scriptRoot = $scriptRoot + } +} + +# ---- Refusals: nothing is created, compiled or observed until these pass -- +try { + if (-not $Mode) { Refuse '-Mode is required (preflight, observe, fixture-meta)' } + if (-not $IntegrityLabel) { Refuse '-IntegrityLabel is required (medium or high, the level this session is expected to have)' } + if (-not $OutFile) { Refuse '-OutFile is required (new file)' } + + $PathGate = Assert-PathGate + $TempRoot = $PathGate.tempRoot + $UserCodexHome = $PathGate.userCodexHome + $EnvCodexHome = $PathGate.envCodexHome + + # Every input and output path is normalized and gated before it is tested, + # read, hashed or created. + $OutFile = Resolve-Full $OutFile + $outReason = Test-PathGateSafe $OutFile $UserCodexHome $EnvCodexHome $false + if ($outReason) { Refuse "-OutFile is not usable ($outReason); it must be an absolute local drive path outside any real Codex home and outside any codex-win-obs-<12 hex> directory" } + # The parent is checked for spelling and for a disposable component only. + # Whether the output overlaps a real home is already decided by the output + # path itself above; a parent that merely CONTAINS a real home (a drive + # root, or the profile directory next to `.codex`) is not a reason to + # refuse an output written beside it. + $outParent = Split-Path $OutFile -Parent + if (-not (Test-LocalPathSpelling $outParent)) { Refuse '-OutFile parent directory is not an accepted absolute local path' } + if (Test-InsideDisposableDir $outParent) { Refuse '-OutFile parent directory is inside a codex-win-obs disposable directory' } + if (Test-Path -LiteralPath $OutFile) { Refuse "$OutFile already exists" } + if (-not (Test-Path -LiteralPath $outParent -PathType Container)) { Refuse '-OutFile parent directory does not exist' } + + if ($Mode -ne 'observe' -and $SourceCommit -cnotmatch '^[0-9a-f]{40}$') { Refuse '-SourceCommit must be the 40-hex commit of this checkout' } + + if ($Mode -eq 'preflight') { + if ($ExpectedSha256 -cnotmatch '^[0-9a-f]{64}$') { Refuse '-ExpectedSha256 must be 64 lowercase hex characters from the pinned release asset' } + if (-not $ExpectedVersion) { Refuse '-ExpectedVersion is required' } + if (-not $CodexBinary) { Refuse '-CodexBinary is required' } + $CodexBinary = Resolve-Full $CodexBinary + $binReason = Test-PathGateSafe $CodexBinary $UserCodexHome $EnvCodexHome $false + if ($binReason) { Refuse "-CodexBinary is not usable ($binReason)" } + } + if ($Mode -eq 'observe') { + if (-not $Manifest) { Refuse '-Manifest must name the preflight JSONL' } + $Manifest = Resolve-Full $Manifest + $manifestReason = Test-PathGateSafe $Manifest $UserCodexHome $EnvCodexHome $false + if ($manifestReason) { Refuse "-Manifest is not usable ($manifestReason)" } + if ($CodexPid -le 0) { Refuse '-CodexPid is required (the PID of the operator-launched codex.exe)' } + if ($CodexPid -eq $PID) { Refuse '-CodexPid is this observer process' } + if (-not $CodexIntegrityLabel) { Refuse '-CodexIntegrityLabel is required (the level of the window that launched Codex)' } + } + if ($Mode -eq 'fixture-meta') { + if (-not $FixtureDir) { $FixtureDir = Join-Path $PSScriptRoot '..\windows-fixture' } + $FixtureDir = Resolve-Full $FixtureDir + $fixtureReason = Test-PathGateSafe $FixtureDir $UserCodexHome $EnvCodexHome $false + if ($fixtureReason) { Refuse "-FixtureDir is not usable ($fixtureReason)" } + } + + # Host preconditions that must hold before anything is created or executed. + if ($Mode -eq 'preflight') { + $volumeCommand = Get-Command Get-Volume -ErrorAction SilentlyContinue + if ($null -eq $volumeCommand -or -not $volumeCommand.Parameters.ContainsKey('FilePath')) { Refuse 'Storage Get-Volume -FilePath is required' } + if ($null -eq (Get-Command Get-MpComputerStatus -ErrorAction SilentlyContinue)) { Refuse 'Get-MpComputerStatus is required to record the existing Defender state; it is never changed' } + } + + # ---- Hard refusal of the native modes (incomplete prototype gate) ---- + # The ancestor/junction and creation boundary is not closed, so no native + # preflight or observe run is approved. There is no bypass. + if ($NATIVE_MODES_REFUSED -and ($Mode -eq 'preflight' -or $Mode -eq 'observe')) { Refuse $NativeRefusalReason } + + # ---- Compilation (after the path and TEMP gate) ---------------------- + # Add-Type starts the .NET compiler as a child process and writes compiler + # temporaries into the gated TEMP. No observer method is called here. + Add-Type -Path (Join-Path $PSScriptRoot 'WinObserver.cs') + $SelfTok = [WinObserver]::SelfToken() | ConvertFrom-Json + if ($SelfTok.ok -ne $true -or $SelfTok.tokenOpened -ne $true -or -not $SelfTok.integritySid -or -not $SelfTok.userSid) { + Refuse 'own token integrity could not be measured' + } + $UserSid = [Security.Principal.WindowsIdentity]::GetCurrent().User + if ("$UserSid" -ne $SelfTok.userSid) { Stop-Unknown 'the measured token user SID differs from the current identity' } +} catch { + # A deliberate refusal keeps its own code; anything else that escapes + # setup (a path API, Add-Type, the compiler, a token read) is reported as + # unknown, never as the mismatch code 1 and never as an uncaught exit. + if ($null -ne $script:SetupExit) { exit $script:SetupExit } + Stop-Unknown ('setup failed: ' + $_.Exception.Message) +} + +# Every record carries the measured integrity with the declared label; a +# mismatch is recorded and stops the run, it is never relabelled. +$Integrity = [pscustomobject]@{ + label = $IntegrityLabel; sid = $SelfTok.integritySid + elevationType = $SelfTok.elevationType; sessionId = $SelfTok.sessionId +} + +$Results = New-Object System.Collections.Generic.List[object] +$Utf8 = New-Object Text.UTF8Encoding($false) +$Abort = $null +$OrchestratorFailed = $false + +function Get-Sha256Hex([string]$path) { + $fs = [IO.File]::Open($path, [IO.FileMode]::Open, [IO.FileAccess]::Read, [IO.FileShare]::Read) + try { + $sha = [Security.Cryptography.SHA256]::Create() + try { -join ($sha.ComputeHash($fs) | ForEach-Object { $_.ToString('x2') }) } finally { $sha.Dispose() } + } finally { $fs.Dispose() } +} + +function Get-FileDigests([string]$dir, [string[]]$names) { + $files = New-Object psobject + $complete = $true + foreach ($n in $names) { + $p = Join-Path $dir $n + $v = $null + if (Test-Path -LiteralPath $p -PathType Leaf) { $v = Get-Sha256Hex $p } else { $complete = $false } + $files | Add-Member -NotePropertyName $n -NotePropertyValue $v + } + [pscustomobject]@{ ok = $true; complete = $complete; files = $files } +} + +function Record([string]$step, $observed, $sample = $null) { + $e = $Expect[$Mode][$step] + $v = Test-Expect $observed $e + $Results.Add([pscustomobject]@{ + schema = $Schema; mode = $Mode; step = $step; sample = $sample + integrity = $Integrity + expect = $e; observed = $observed; verdict = $v + safeToUnlock = $false + }) + $v +} + +function Native([scriptblock]$call) { + try { (& $call) | ConvertFrom-Json } catch { + [pscustomobject]@{ ok = $false; exception = ($_.Exception.GetType().Name + ': ' + $_.Exception.Message) } + } +} + +# Records the ACL contract of a directory pair as RAW fields. No boolean +# summary is trusted downstream: the evaluator recomputes the contract from the +# owner, the single ACE's identity, rights, inheritance and type. +function Get-AclFields([string]$HomeDir, [string]$ParentDir) { + $homeAcl = Get-Acl -LiteralPath $HomeDir + $parentAcl = Get-Acl -LiteralPath $ParentDir + $homeRules = @($homeAcl.GetAccessRules($true, $true, [Security.Principal.SecurityIdentifier])) + $parentRules = @($parentAcl.GetAccessRules($true, $true, [Security.Principal.SecurityIdentifier])) + $homeAce = $(if ($homeRules.Count -eq 1) { $homeRules[0] } else { $null }) + $parentAce = $(if ($parentRules.Count -eq 1) { $parentRules[0] } else { $null }) + [pscustomobject]@{ + currentUserSid = "$UserSid" + owner = "$($homeAcl.GetOwner([Security.Principal.SecurityIdentifier]))" + daclProtected = $homeAcl.AreAccessRulesProtected + aceCount = $homeRules.Count + aceIdentity = $(if ($homeAce) { "$($homeAce.IdentityReference)" } else { $null }) + aceRights = $(if ($homeAce) { "$($homeAce.FileSystemRights)" } else { $null }) + aceInheritance = $(if ($homeAce) { "$($homeAce.InheritanceFlags)" } else { $null }) + aceType = $(if ($homeAce) { "$($homeAce.AccessControlType)" } else { $null }) + parentOwner = "$($parentAcl.GetOwner([Security.Principal.SecurityIdentifier]))" + parentDaclProtected = $parentAcl.AreAccessRulesProtected + parentAceCount = $parentRules.Count + parentAceIdentity = $(if ($parentAce) { "$($parentAce.IdentityReference)" } else { $null }) + parentAceRights = $(if ($parentAce) { "$($parentAce.FileSystemRights)" } else { $null }) + parentAceInheritance = $(if ($parentAce) { "$($parentAce.InheritanceFlags)" } else { $null }) + parentAceType = $(if ($parentAce) { "$($parentAce.AccessControlType)" } else { $null }) + } +} + +# No-follow ancestry of the disposable parent, recorded as raw entries. The +# evaluator refuses the home when any component is not a plain directory. This +# is a check, not a pin: see the header's hard-refusal note. +function Get-AncestryEntries([string]$ParentDir) { + $a = Native { [WinObserver]::Ancestry($ParentDir) } + if ($a.ok -ne $true -or $a.state -eq 'unknown') { return @() } + @($a.entries) +} + +# Parameter names are task-specific on purpose: $home would collide with the +# read-only automatic variable $HOME (PowerShell variable names are +# case-insensitive), and check.ps1 refuses such a name in the syntax tree. +function Add-PathFields($o, [string]$HomeDir, [string]$ParentDir) { + $o | Add-Member -NotePropertyName home -NotePropertyValue $HomeDir + $o | Add-Member -NotePropertyName parent -NotePropertyValue $ParentDir + $o | Add-Member -NotePropertyName userCodexHome -NotePropertyValue $UserCodexHome + $o | Add-Member -NotePropertyName envCodexHome -NotePropertyValue $EnvCodexHome + $o | Add-Member -NotePropertyName userRelation -NotePropertyValue (Get-PathRelation $HomeDir $UserCodexHome) + $o | Add-Member -NotePropertyName envRelation -NotePropertyValue $(if ($EnvCodexHome) { Get-PathRelation $HomeDir $EnvCodexHome } else { $null }) + $o | Add-Member -NotePropertyName fileId -NotePropertyValue ([WinObserver]::DirFileId($HomeDir)) + $o | Add-Member -NotePropertyName parentFileId -NotePropertyValue ([WinObserver]::DirFileId($ParentDir)) + # Capture into an array first: a function that emits an empty array emits + # nothing, which would store $null and make an "@(...).Count -gt 0" guard + # on the stored value vacuously true. + $ancestryEntries = @(Get-AncestryEntries $ParentDir) + $o | Add-Member -NotePropertyName ancestry -NotePropertyValue $ancestryEntries + foreach ($p in (Get-AclFields $HomeDir $ParentDir).PSObject.Properties) { + $o | Add-Member -NotePropertyName $p.Name -NotePropertyValue $p.Value + } + $o +} + +# The per-sample process gate. It re-queries the DECLARED PID and returns $null +# only when that PID is still the same live, same-user instance at the same +# creation FILETIME, image and integrity. The queried record is emitted so the +# evaluator can bind it to the run record. A failure here refuses this sample's +# active operations; it cannot undo a previous sample's operations. +function Get-SampleRefusal([int]$TargetPid, [string]$ExpectCreate, [string]$ExpectImage, [string]$ExpectUserSid, + [string]$ExpectIntegritySid, $Collected) { + $p = Native { [WinObserver]::ProcessIdentity($TargetPid) } + $Collected.record = $p + if ($p.ok -ne $true -or $p.PSObject.Properties['exception']) { return 'process_query_failed' } + if ($p.opened -ne $true) { return 'process_not_opened' } + if ([int]$p.pid -ne $TargetPid) { return 'queried_pid_differs' } + if ($p.state -ne 'live') { return ('process_state_' + $p.state) } + if (-not $p.createFiletime -or "$($p.createFiletime)" -cne "$ExpectCreate") { return 'create_filetime_changed' } + if ($p.tokenOpened -ne $true) { return 'token_unreadable' } + if ("$($p.userSid)" -cne "$ExpectUserSid") { return 'user_sid_differs' } + if ("$($p.integritySid)" -cne "$ExpectIntegritySid") { return 'integrity_sid_changed' } + if (-not $p.image -or -not [string]::Equals($p.image, $ExpectImage, [StringComparison]::OrdinalIgnoreCase)) { return 'image_differs' } + $null +} + +# ---- Manifest validation (observe), before anything is recorded ---------- +# Exactly one manifest record in the whole file, and it must be the LAST +# record: zero means an unfinished preflight, more than one (complete or not) +# means an ambiguous or tampered file, and a manifest that is not last means +# the preflight continued after claiming to be done. Every other required +# preflight step must appear exactly once with a usable verdict. Internal +# consistency and digest provenance are checked here, and the values are +# cross-checked against the raw preflight records they claim to describe. A +# file ID alone is never treated as authorization or provenance. This prevents +# stale, partial, contradictory or out-of-scope inputs. It does NOT claim to +# defend against an adversarial same-user forged manifest. +$M = $null +$ManifestSha = $null +$records = @() +# Set before the read so a failure in the hash or the read itself still +# reports a defined position. +$lineNo = 0 +if ($Mode -eq 'observe') { + try { + $ManifestSha = Get-Sha256Hex $Manifest + foreach ($line in ([IO.File]::ReadAllText($Manifest) -split "`n")) { + $lineNo++ + if (-not $line) { continue } + $records += ($line | ConvertFrom-Json) + } + } catch { + Stop-Unknown ('manifest could not be read or parsed at line ' + $lineNo + ': ' + $_.Exception.Message) + } + if ($records.Count -eq 0) { Refuse 'manifest file has no records' } + foreach ($r in $records) { + if ($r.schema -ne $Schema) { Refuse 'manifest file holds a record of another schema' } + if ($r.mode -ne 'preflight') { Refuse 'manifest file holds a record of another mode' } + if ($r.safeToUnlock -ne $false) { Refuse 'manifest file holds a record without safeToUnlock=false' } + if ($r.step -eq 'orchestrator_error') { Refuse 'manifest file holds an orchestrator_error record' } + } + $manifestRecords = @($records | Where-Object { $_.step -eq 'manifest' }) + if ($manifestRecords.Count -eq 0) { Refuse 'manifest has no manifest record; rerun preflight' } + if ($manifestRecords.Count -gt 1) { Refuse 'manifest file has more than one manifest record; it is ambiguous' } + if ($records[$records.Count - 1].step -ne 'manifest') { Refuse 'the manifest record is not the last record of the preflight file' } + $required = @('harness', 'token.self', 'binary.sha256', 'binary.signature', 'binary.pe_version', 'home.isolated', + 'binary.version', 'home.after_version', 'host.execution_policy', 'host.defender', 'host.volume', 'host.os') + foreach ($step in $required) { + $found = @($records | Where-Object { $_.step -eq $step }) + if ($found.Count -ne 1) { Refuse "preflight file must hold exactly one $step record" } + if ($found[0].verdict -notin @('match', 'recorded')) { Refuse "preflight record $step is not a usable observation" } + } + $M = $manifestRecords[0].observed + if ($M.complete -ne $true) { Refuse 'the manifest record is not complete; rerun preflight' } + foreach ($field in 'home', 'homeFileId', 'parent', 'parentFileId', 'binaryPath', 'binarySha256', 'binarySize', + 'version', 'sourceCommit', 'integritySid', 'label', 'harness') { + if (-not $M.PSObject.Properties[$field] -or $null -eq $M.$field) { Refuse "manifest is missing field $field" } + } + foreach ($field in 'home', 'parent', 'binaryPath') { + if (-not (Test-LocalPathSpelling $M.$field)) { Refuse "manifest field $field is not an accepted absolute local path" } + } + # The manifest must describe a disposable home outside every real home. + if ((Get-PathRelation $M.home $M.parent) -ne 'inside' -or (Split-Path $M.home -Leaf) -cne 'home') { Refuse 'manifest home is not the parent''s home child' } + if ((Split-Path $M.parent -Leaf) -cnotmatch '^codex-win-obs-[0-9a-f]{12}$') { Refuse 'manifest parent is not a codex-win-obs-<12 hex> directory' } + $homeReason = Test-PathGateSafe $M.home $UserCodexHome $EnvCodexHome $true + if ($homeReason) { Refuse "manifest home is not usable ($homeReason)" } + if ($M.integritySid -ne $SelfTok.integritySid) { Refuse 'manifest integrity SID differs from the current token (digest provenance)' } + if ($M.label -ne $IntegrityLabel) { Refuse 'manifest label differs from -IntegrityLabel' } + if ($M.sourceCommit -cnotmatch '^[0-9a-f]{40}$' -or $M.binarySha256 -cnotmatch '^[0-9a-f]{64}$') { Refuse 'manifest commit or binary digest is malformed' } + # Cross-check the manifest against the raw preflight observations it claims + # to describe; a manifest that disagrees with its own run is refused. + $pfHome = @($records | Where-Object { $_.step -eq 'home.isolated' })[0].observed + $pfBinary = @($records | Where-Object { $_.step -eq 'binary.sha256' })[0].observed + $pfVersion = @($records | Where-Object { $_.step -eq 'binary.version' })[0].observed + $pfHarness = @($records | Where-Object { $_.step -eq 'harness' })[0].observed + $pfToken = @($records | Where-Object { $_.step -eq 'token.self' })[0].observed + if ((Get-PathRelation $M.home $pfHome.home) -ne 'same' -or $M.homeFileId -cne $pfHome.fileId) { Refuse 'manifest home does not match the preflight home observation' } + if ((Get-PathRelation $M.parent $pfHome.parent) -ne 'same' -or $M.parentFileId -cne $pfHome.parentFileId) { Refuse 'manifest parent does not match the preflight home observation' } + if ((Get-PathRelation $M.binaryPath $pfBinary.path) -ne 'same' -or $M.binarySha256 -cne $pfBinary.sha256 -or $M.binarySize -ne $pfBinary.size) { Refuse 'manifest binary does not match the hashed binary observation' } + if ($M.version -cne $pfVersion.stdoutFirstLine) { Refuse 'manifest version does not match the recorded version output' } + if ($M.sourceCommit -cne $pfHarness.sourceCommit) { Refuse 'manifest commit does not match the preflight harness record' } + if ($M.integritySid -cne $pfToken.integritySid) { Refuse 'manifest integrity SID does not match the preflight token record' } + if (-not (Test-SameDigests $M.harness $pfHarness.files)) { Refuse 'manifest harness digests do not match the preflight harness record' } +} + +try { + if ($Mode -eq 'fixture-meta') { + $tok = $SelfTok | Select-Object * + $tok | Add-Member -NotePropertyName integrityMatchesLabel -NotePropertyValue ($SelfTok.integritySid -eq $IntegritySids[$IntegrityLabel]) + Record 'token.self' $tok | Out-Null + $fx = Get-FileDigests $FixtureDir $FixtureNames + $fx | Add-Member -NotePropertyName sourceCommit -NotePropertyValue $SourceCommit + $fx | Add-Member -NotePropertyName note -NotePropertyValue 'run lab.ps1 from this same shell session; integrity applies to that run only' + Record 'fixture.harness' $fx | Out-Null + } + + if ($Mode -eq 'preflight') { + $h = Get-FileDigests $PSScriptRoot $HarnessNames + $h | Add-Member -NotePropertyName sourceCommit -NotePropertyValue $SourceCommit + $Harness = $h.files + # The preflight harness record has no hypothesis, so a usable + # observation verdicts `recorded`; an incomplete one verdicts `error`. + if ((Record 'harness' $h) -notin @('match', 'recorded')) { $Abort = 'harness digests incomplete' } + + $tok = $SelfTok | Select-Object * + $tok | Add-Member -NotePropertyName integrityMatchesLabel -NotePropertyValue ($SelfTok.integritySid -eq $IntegritySids[$IntegrityLabel]) + if (-not $Abort -and (Record 'token.self' $tok) -ne 'match') { $Abort = 'measured integrity differs from -IntegrityLabel' } + + # Host state is recorded as found, BEFORE anything is created or + # executed, and is never changed. A host that does not meet the W7 + # precondition stops the run before the disposable home exists. + $pol = $null + try { + $pol = [pscustomobject]@{ ok = $true; effective = (Get-ExecutionPolicy).ToString() + scopes = @(Get-ExecutionPolicy -List | ForEach-Object { [pscustomobject]@{ scope = $_.Scope.ToString(); policy = $_.ExecutionPolicy.ToString() } }) } + } catch { $pol = [pscustomobject]@{ ok = $false; exception = $_.Exception.Message } } + Record 'host.execution_policy' $pol | Out-Null + Record 'host.os' ([pscustomobject]@{ ok = $true; os = [Environment]::OSVersion.VersionString; ps = $PSVersionTable.PSVersion.ToString() + elevated = (New-Object Security.Principal.WindowsPrincipal ([Security.Principal.WindowsIdentity]::GetCurrent())).IsInRole('Administrators') + sshSession = [bool]$env:SSH_CONNECTION }) | Out-Null + $def = $null + try { + $s = Get-MpComputerStatus + $def = [pscustomobject]@{ ok = $true; amServiceEnabled = [bool]$s.AMServiceEnabled; realTimeProtectionEnabled = [bool]$s.RealTimeProtectionEnabled + antivirusEnabled = [bool]$s.AntivirusEnabled; amProductVersion = $s.AMProductVersion } + } catch { $def = [pscustomobject]@{ ok = $false; exception = $_.Exception.Message } } + if ((Record 'host.defender' $def) -ne 'match' -and -not $Abort) { $Abort = 'Defender is not observed enabled; it is not changed here' } + + # Bytes before execution. The binary is never run unless this matches. + if (-not $Abort) { + if (-not (Test-Path -LiteralPath $CodexBinary -PathType Leaf)) { $Abort = '-CodexBinary does not name an existing file' } + elseif ((Get-Item -LiteralPath $CodexBinary -Force).Attributes -band [IO.FileAttributes]::ReparsePoint) { $Abort = '-CodexBinary is a reparse point' } + } + if (-not $Abort) { + $sha = Get-Sha256Hex $CodexBinary + $o = [pscustomobject]@{ ok = $true; path = $CodexBinary; size = (Get-Item -LiteralPath $CodexBinary -Force).Length + sha256 = $sha; expected = $ExpectedSha256; matches = ($sha -ceq $ExpectedSha256) } + if ((Record 'binary.sha256' $o) -ne 'match') { $Abort = 'binary SHA-256 differs from -ExpectedSha256' } + } + if (-not $Abort) { + $sig = $null + try { + $s = Get-AuthenticodeSignature -LiteralPath $CodexBinary + $sig = [pscustomobject]@{ ok = $true; status = $s.Status.ToString(); statusMessage = $s.StatusMessage + signer = $(if ($s.SignerCertificate) { $s.SignerCertificate.Subject } else { $null }) + thumbprint = $(if ($s.SignerCertificate) { $s.SignerCertificate.Thumbprint } else { $null }) } + } catch { $sig = [pscustomobject]@{ ok = $false; exception = $_.Exception.Message } } + Record 'binary.signature' $sig | Out-Null + # An absent version resource is DECLARED, not implied by empty fields. + $vi = (Get-Item -LiteralPath $CodexBinary -Force).VersionInfo + $declared = [bool]$vi.FileVersion + Record 'binary.pe_version' ([pscustomobject]@{ ok = $true; declared = $declared + fileVersion = $(if ($vi.FileVersion) { "$($vi.FileVersion)" } else { $null }) + productVersion = $(if ($vi.ProductVersion) { "$($vi.ProductVersion)" } else { $null }) + productName = $(if ($vi.ProductName) { "$($vi.ProductName)" } else { $null }) }) | Out-Null + } + + # Disposable home: %TEMP%\codex-win-obs-\home. The candidate + # temp root and the real homes were validated LEXICALLY by the path + # gate before anything was compiled or created; the creation callback + # runs only when that gate passes again here, so a TEMP that resolves + # inside a real Codex home never reaches CreateDirectory. The parent and + # the home each get a protected DACL with one full-control ACE for the + # current user SID. Real homes are never opened. + # + # The gate itself is LEXICAL: re-evaluating it proves only that the + # paths still spell something outside every real home. The junction + # check is the no-follow ancestry walk INSIDE the callback, before and + # after the parent is created. + # + # Not closed: the components are opened separately by path, so this + # compares identities at two points in time instead of pinning them. + # That residual window is why the native modes are refused. + $Parent = $null + $CodexHomePath = $null + if (-not $Abort) { + $created = Invoke-GuardedCreate { Test-CandidateTempSafe $TempRoot $UserCodexHome $EnvCodexHome } { + $before = Native { [WinObserver]::Ancestry($TempRoot) } + if ($before.ok -ne $true -or $before.complete -ne $true -or + @($before.entries | Where-Object { $_.directory -ne $true -or $_.reparse -ne $false }).Count -gt 0) { + return [pscustomobject]@{ created = $false; reason = 'temp_root_ancestry_not_plain' } + } + $p = Join-Path $TempRoot ('codex-win-obs-' + [guid]::NewGuid().ToString('N').Substring(0, 12)) + if (-not (Test-LocalPathSpelling $p)) { return [pscustomobject]@{ created = $false; reason = 'parent_path_spelling' } } + if (Test-Path -LiteralPath $p) { return [pscustomobject]@{ created = $false; reason = 'name_collision' } } + $acl = New-Object Security.AccessControl.DirectorySecurity + $acl.SetOwner($UserSid) + $acl.SetAccessRuleProtection($true, $false) + $acl.AddAccessRule((New-Object Security.AccessControl.FileSystemAccessRule($UserSid, $AclRights, + $AclInheritance, 'None', $AclType))) + [IO.Directory]::CreateDirectory($p, $acl) | Out-Null + # Revalidate the ancestry of the new parent before creating the + # home inside it. + $after = Native { [WinObserver]::Ancestry($p) } + if ($after.ok -ne $true -or $after.complete -ne $true -or + @($after.entries | Where-Object { $_.directory -ne $true -or $_.reparse -ne $false }).Count -gt 0) { + return [pscustomobject]@{ created = $false; reason = 'parent_ancestry_not_plain'; parent = $p } + } + # The home gets its OWN protected DACL, not an inherited one: + # a directory created without a security descriptor inherits + # its parent's entries and is marked auto-inherited, never + # protected, so `AreAccessRulesProtected` would be false and + # the isolation contract below could never hold. + $homeDir = Join-Path $p 'home' + [IO.Directory]::CreateDirectory($homeDir, $acl) | Out-Null + [pscustomobject]@{ created = $true; parent = $p; home = $homeDir } + } + if (-not $created.created) { + $Abort = 'disposable CODEX_HOME refused before creation: ' + $created.reason + Record 'home.isolated' ([pscustomobject]@{ ok = $true; state = 'unknown'; reason = $created.reason; isolated = $false }) | Out-Null + } else { + $Parent = $created.parent + $CodexHomePath = $created.home + $entries = @(Get-ChildItem -LiteralPath $CodexHomePath -Force | ForEach-Object { $_.Name }) + $o = [pscustomobject]@{ ok = $true; empty = ($entries.Count -eq 0); entries = $entries } + $o = Add-PathFields $o $CodexHomePath $Parent + $ancestryPlain = $null -ne $o.ancestry -and @($o.ancestry).Count -gt 0 -and + @($o.ancestry | Where-Object { $_.directory -ne $true -or $_.reparse -ne $false }).Count -eq 0 + $isolated = $o.userRelation -eq 'disjoint' -and ($null -eq $EnvCodexHome -or $o.envRelation -eq 'disjoint') -and + $o.empty -and $o.daclProtected -eq $true -and $o.parentDaclProtected -eq $true -and + $o.aceCount -eq 1 -and $o.parentAceCount -eq 1 -and + "$($o.owner)" -ceq "$UserSid" -and "$($o.parentOwner)" -ceq "$UserSid" -and + "$($o.aceIdentity)" -ceq "$UserSid" -and "$($o.parentAceIdentity)" -ceq "$UserSid" -and + $o.aceRights -ceq $AclRights -and $o.parentAceRights -ceq $AclRights -and + $o.aceInheritance -ceq $AclInheritance -and $o.parentAceInheritance -ceq $AclInheritance -and + $o.aceType -ceq $AclType -and $o.parentAceType -ceq $AclType -and + $null -ne $o.fileId -and $null -ne $o.parentFileId -and $ancestryPlain + $o | Add-Member -NotePropertyName isolated -NotePropertyValue $isolated + if ((Record 'home.isolated' $o) -ne 'match') { $Abort = 'disposable CODEX_HOME could not be verified as isolated' } + } + } + + if (-not $Abort) { + # The volume probe lives in the parent, so the home stays empty. + $probe = Join-Path $Parent '.volume-probe' + ([IO.File]::Open($probe, [IO.FileMode]::CreateNew, [IO.FileAccess]::Write, [IO.FileShare]::None)).Dispose() + $vols = @(Get-Volume -FilePath $probe -ErrorAction Stop) + Record 'host.volume' ([pscustomobject]@{ ok = ($vols.Count -eq 1); probePath = $probe + fileSystem = $(if ($vols.Count -eq 1) { "$($vols[0].FileSystemType)" } else { $null }) }) | Out-Null + } + + # Version: re-hash immediately before the only execution, which runs + # with CODEX_HOME set to the disposable home and a cleared environment + # plus a narrow allowlist. A process that does not exit within budget + # is reported and left alone; it is never killed. + if (-not $Abort) { + if ((Get-Sha256Hex $CodexBinary) -cne $ExpectedSha256) { + Record 'binary.version' ([pscustomobject]@{ ok = $false; reason = 'binary changed after hashing' }) | Out-Null + $Abort = 'binary changed between hash and execution' + } else { + $psi = New-Object Diagnostics.ProcessStartInfo + $psi.FileName = $CodexBinary + $psi.Arguments = '--version' + $psi.UseShellExecute = $false + $psi.CreateNoWindow = $true + $psi.RedirectStandardInput = $true + $psi.RedirectStandardOutput = $true + $psi.RedirectStandardError = $true + $psi.WorkingDirectory = $Parent + # Clear the inherited environment first: no authentication, + # proxy or credential variable of this shell reaches the child. + $psi.EnvironmentVariables.Clear() + $passed = @() + foreach ($name in $ChildEnvAllowlist) { + $value = [Environment]::GetEnvironmentVariable($name) + if ($value) { $psi.EnvironmentVariables[$name] = $value; $passed += $name } + } + $psi.EnvironmentVariables['CODEX_HOME'] = $CodexHomePath + $psi.EnvironmentVariables['TEMP'] = $Parent + $psi.EnvironmentVariables['TMP'] = $Parent + $passed += @('CODEX_HOME', 'TEMP', 'TMP') + $p = [Diagnostics.Process]::Start($psi) + $p.StandardInput.Close() + # ONE monotonic deadline bounds both pipe reads and the process + # exit. Both streams are drained concurrently with a byte cap, + # so a descendant that inherits the pipes cannot hang this, and + # a full pipe on one stream cannot deadlock the other. Nothing + # is killed; an incomplete read is reported with the PID. + $clock = [Diagnostics.Stopwatch]::StartNew() + $outC = New-StreamChannel $p.StandardOutput.BaseStream 65536 + $errC = New-StreamChannel $p.StandardError.BaseStream 65536 + Read-BothStreams @($outC, $errC) $clock $WaitBudgetMs | Out-Null + $exitRemaining = [int][Math]::Max(0, $WaitBudgetMs - $clock.ElapsedMilliseconds) + $exited = $p.WaitForExit($exitRemaining) + $streamsOk = $outC.complete -and $errC.complete -and -not $outC.truncated -and -not $errC.truncated -and + -not $outC.timedOut -and -not $errC.timedOut -and -not $outC.error -and -not $errC.error + if (-not $exited -or -not $streamsOk) { + Record 'binary.version' ([pscustomobject]@{ ok = $false; complete = $false + reason = 'process or pipe did not complete inside the deadline; left running, not killed' + pid = $p.Id; exited = $exited + stdoutComplete = $outC.complete; stderrComplete = $errC.complete + stdoutTruncated = $outC.truncated; stderrTruncated = $errC.truncated + timedOut = ($outC.timedOut -or $errC.timedOut) + stdoutError = $outC.error; stderrError = $errC.error }) | Out-Null + $Abort = 'version command did not complete within budget; process ' + $p.Id + ' left running (never killed)' + } else { + $first = (($outC.text -split "`n") | Select-Object -First 1) + if ($null -ne $first) { $first = $first.Trim() } + $o = [pscustomobject]@{ ok = $true; exitCode = $p.ExitCode; pid = $p.Id; stdoutFirstLine = $first + stdout = $outC.text; stderr = $errC.text + stdoutComplete = $outC.complete; stderrComplete = $errC.complete + stdoutTruncated = $outC.truncated; stderrTruncated = $errC.truncated + stdoutBytes = $outC.bytes; stderrBytes = $errC.bytes + timedOut = $false; exited = $true; envAllowlist = $passed + expected = $ExpectedVersion; matches = ($first -ceq $ExpectedVersion) } + if ((Record 'binary.version' $o) -ne 'match') { $Abort = 'version output differs from -ExpectedVersion' } + $created2 = @(Get-ChildItem -LiteralPath $CodexHomePath -Force -Recurse | ForEach-Object { $_.FullName.Substring($CodexHomePath.Length + 1) }) + Record 'home.after_version' ([pscustomobject]@{ ok = $true; count = $created2.Count; entries = $created2 }) | Out-Null + } + } + } + + $bad = @($Results | Where-Object { $_.verdict -notin @('match', 'recorded') }) + if (-not $Abort -and $bad.Count -eq 0) { + $binaryRec = @($Results | Where-Object { $_.step -eq 'binary.sha256' })[0].observed + $versionRec = @($Results | Where-Object { $_.step -eq 'binary.version' })[0].observed + $homeRec = @($Results | Where-Object { $_.step -eq 'home.isolated' })[0].observed + Record 'manifest' ([pscustomobject]@{ ok = $true; complete = $true + home = $homeRec.home; homeFileId = $homeRec.fileId + parent = $homeRec.parent; parentFileId = $homeRec.parentFileId + binaryPath = $binaryRec.path; binarySha256 = $binaryRec.sha256; binarySize = $binaryRec.size + version = $versionRec.stdoutFirstLine + sourceCommit = $SourceCommit; integritySid = $SelfTok.integritySid; label = $IntegrityLabel + harness = $Harness; createdUtc = [DateTime]::UtcNow.ToString('o') }) | Out-Null + Write-Output ('disposable CODEX_HOME: ' + $CodexHomePath) + Write-Output 'next: launch Codex from a new window at the same integrity level with CODEX_HOME set to that path (README, step 3), then run -Mode observe.' + } else { + $why = $Abort + if (-not $why) { $why = 'non-matching steps: ' + (($bad | ForEach-Object { $_.step }) -join ',') } + Record 'manifest' ([pscustomobject]@{ ok = $true; complete = $false; reason = $why }) | Out-Null + } + } + + if ($Mode -eq 'observe') { + $h = Get-FileDigests $PSScriptRoot $HarnessNames + $h | Add-Member -NotePropertyName manifestSha256 -NotePropertyValue $ManifestSha + $h | Add-Member -NotePropertyName matchesManifest -NotePropertyValue (Test-SameDigests $h.files $M.harness) + if ((Record 'harness' $h) -ne 'match') { $Abort = 'observer files differ from the preflight run' } + + $tok = $SelfTok | Select-Object * + $tok | Add-Member -NotePropertyName integrityMatchesLabel -NotePropertyValue ($SelfTok.integritySid -eq $IntegritySids[$IntegrityLabel]) + $tok | Add-Member -NotePropertyName matchesManifest -NotePropertyValue ($SelfTok.integritySid -eq $M.integritySid -and $IntegrityLabel -eq $M.label) + if (-not $Abort -and (Record 'token.self' $tok) -ne 'match') { $Abort = 'integrity differs from -IntegrityLabel or the preflight run' } + + # Re-validate the disposable home against the manifest before use: same + # path, unchanged parent and home file IDs, still disjoint from the + # current real homes, still owned by this user with the protected + # single-ACE DACL, and a still-plain no-follow ancestry. A matching file + # ID is necessary but NOT sufficient. Real homes are compared lexically + # only. The structure and the paths were already validated before any + # of them was opened. + if (-not $Abort) { + $CodexHomePath = $M.home + $o = Add-PathFields ([pscustomobject]@{ ok = $true }) $CodexHomePath $M.parent + $ancestryPlain = $null -ne $o.ancestry -and @($o.ancestry).Count -gt 0 -and + @($o.ancestry | Where-Object { $_.directory -ne $true -or $_.reparse -ne $false }).Count -eq 0 + $homeOk = ($null -ne $o.fileId -and $o.fileId -ceq $M.homeFileId) -and + ($null -ne $o.parentFileId -and $o.parentFileId -ceq $M.parentFileId) -and + ($o.userRelation -eq 'disjoint') -and ($null -eq $EnvCodexHome -or $o.envRelation -eq 'disjoint') -and + $o.daclProtected -eq $true -and $o.parentDaclProtected -eq $true -and + $o.aceCount -eq 1 -and $o.parentAceCount -eq 1 -and + "$($o.owner)" -ceq "$UserSid" -and "$($o.parentOwner)" -ceq "$UserSid" -and + "$($o.aceIdentity)" -ceq "$UserSid" -and "$($o.parentAceIdentity)" -ceq "$UserSid" -and + $o.aceRights -ceq $AclRights -and $o.parentAceRights -ceq $AclRights -and + $o.aceInheritance -ceq $AclInheritance -and $o.parentAceInheritance -ceq $AclInheritance -and + $o.aceType -ceq $AclType -and $o.parentAceType -ceq $AclType -and $ancestryPlain + $o | Add-Member -NotePropertyName matchesManifest -NotePropertyValue $homeOk + if ((Record 'home.identity' $o) -ne 'match') { $Abort = 'disposable home failed re-validation against the manifest' } + } + + # Control record and the first process observation. The per-sample gate + # below re-queries this PID before every sample; the first observation + # is the creation-FILETIME witness every later record binds to. + $create0 = $null + $codexIntegrity0 = $null + if (-not $Abort) { + [WinObserver]::SetRoot($CodexHomePath) + Record 'run' ([pscustomobject]@{ ok = $true; samples = $Samples; codexPid = $CodexPid + codexLabel = $CodexIntegrityLabel; observerPid = $PID; observerUserSid = "$UserSid" + startedUtc = [DateTime]::UtcNow.ToString('o') }) | Out-Null + $proc = Native { [WinObserver]::ProcessIdentity($CodexPid) } + $create0 = $proc.createFiletime + $codexIntegrity0 = $proc.integritySid + # Both booleans are advisory for the operator; they are recorded as + # $null when the evidence is missing and the evaluator recomputes + # the verdict from the raw fields either way. + $imageMatch = $null + if ($proc.image) { $imageMatch = [string]::Equals($proc.image, $M.binaryPath, [StringComparison]::OrdinalIgnoreCase) } + $proc | Add-Member -NotePropertyName imageMatchesBinary -NotePropertyValue $imageMatch -Force + $integrityMatch = $null + if ($proc.integritySid) { $integrityMatch = ($proc.integritySid -ceq $IntegritySids[$CodexIntegrityLabel]) } + $proc | Add-Member -NotePropertyName codexIntegrityMatchesLabel -NotePropertyValue $integrityMatch -Force + Record 'process.identity' $proc | Out-Null + $firstOk = ($proc.ok -eq $true) -and ([int]$proc.pid -eq $CodexPid) -and ($proc.state -eq 'live') -and + ($imageMatch -eq $true) -and ($proc.tokenOpened -eq $true) -and ("$($proc.userSid)" -ceq "$UserSid") -and + ($null -ne $create0) -and ($integrityMatch -eq $true) + if (-not $firstOk) { $Abort = 'launched process is not a live, same-user match for the manifest binary at the declared integrity; active operations refused' } + } + + if (-not $Abort) { + $lockDir = Join-Path $CodexHomePath 'thread-writer-locks' + $guard = Join-Path $lockDir '.coordination.lock' + for ($s = 1; $s -le $Samples; $s++) { + # Per-sample process gate: nothing active runs for this sample + # unless the DECLARED PID is still the same live, same-user + # instance. The queried record is emitted either way so the + # evaluator can bind it; a refusal here cannot undo the + # operations of an earlier sample. + $collected = @{ record = $null } + $sample = Invoke-GuardedSample { + Get-SampleRefusal $CodexPid $create0 $M.binaryPath "$UserSid" $codexIntegrity0 $collected + } { + $ld = Native { [WinObserver]::Identity($lockDir) } + Record 'lockdir.identity' $ld $s | Out-Null + $gd = Native { [WinObserver]::Identity($guard) } + Record 'coordination.identity' $gd $s | Out-Null + # A lexically scoped path does not exclude a junction + # ANCESTOR, so the lock directory must be a plain + # non-reparse directory, and the coordination guard a + # single-link regular file that is not pending delete, + # BEFORE any enumeration or probe. Guard identity failures + # are consumed here, ahead of every active operation. + $lockDirOk = ($ld.ok -eq $true -and $ld.directory -eq $true -and $ld.reparse -eq $false -and $null -ne $ld.fileId) + $guardOk = ($gd.ok -eq $true -and $gd.directory -eq $false -and $gd.reparse -eq $false -and + $gd.links -eq 1 -and $gd.deletePending -eq $false -and $null -ne $gd.fileId) + if (-not $lockDirOk -or -not $guardOk) { + $why = $(if (-not $lockDirOk) { 'lock_directory_not_plain' } else { 'coordination_guard_not_single_regular' }) + Record 'thread.count' ([pscustomobject]@{ ok = $true; state = 'unknown'; reason = $why }) $s | Out-Null + return + } + $threads = @() + try { + $names = @([IO.Directory]::GetFileSystemEntries($lockDir) | ForEach-Object { Split-Path $_ -Leaf }) + $threads = @($names | Where-Object { $_ -ne '.coordination.lock' -and $_ -like '*.lock' }) + Record 'thread.count' ([pscustomobject]@{ ok = $true; count = $threads.Count; entries = $names }) $s | Out-Null + } catch { + Record 'thread.count' ([pscustomobject]@{ ok = $false; exception = $_.Exception.Message }) $s | Out-Null + } + if ($threads.Count -ne 1) { return } + $leaf = $threads[0] + $thread = Join-Path $lockDir $leaf + $before = Native { [WinObserver]::Identity($thread) } + Record 'thread.identity' $before $s | Out-Null + # Thread identity gate: a reparse point, a directory, a + # hardlinked (links != 1) or pending-delete lock file + # refuses the RM/share/range operations for this sample. + $threadOk = ($before.ok -eq $true -and $before.reparse -eq $false -and $before.directory -eq $false -and + $before.links -eq 1 -and $before.deletePending -eq $false -and $null -ne $before.fileId) + if (-not $threadOk) { return } + $rm = Native { [WinObserver]::RmList([string[]]@($thread, $guard)) } + if ($rm.ok -eq $true -and $rm.complete -eq $true) { + foreach ($entry in @($rm.processes)) { + $entry | Add-Member -NotePropertyName role -NotePropertyValue ( + Get-OpenerRole ([int]$entry.pid) "$($entry.startFiletime)" $CodexPid "$create0" $PID) + } + } + Record 'openers.rm' $rm $s | Out-Null + # Only full-sharing opens; restrictive-sharing probes are a + # separately proposed, NOT-executed experiment (see README). + Record 'share.read_rwd' (Native { [WinObserver]::ShareProbe($thread, 'read', 'rwd') }) $s | Out-Null + Record 'share.delete_rwd' (Native { [WinObserver]::ShareProbe($thread, 'delete', 'rwd') }) $s | Out-Null + # Bind the guarded range probe to the validated expected + # identities (lock directory, coordination guard and thread + # lock) so it refuses a changed target before locking. The + # expected link count is fixed at one inside the native + # layer and is not passed from here. + Record 'range.probe' (Native { + [WinObserver]::GuardedRangeProbe($lockDir, $leaf, $ld.fileId, $gd.fileId, $before.fileId) + }) $s | Out-Null + $after = Native { [WinObserver]::Identity($thread) } + if ($after.ok -eq $true -and $before.ok -eq $true) { + $after | Add-Member -NotePropertyName sameFileId -NotePropertyValue ($null -ne $after.fileId -and $after.fileId -ceq $before.fileId) + } + Record 'thread.identity_after' $after $s | Out-Null + } + # The re-queried process record is always emitted, with the + # refusal reason when the gate stopped this sample. + $gateRecord = $collected.record + if ($null -eq $gateRecord) { $gateRecord = [pscustomobject]@{ ok = $false; reason = 'process_not_queried' } } + if (-not $sample.ran) { $gateRecord | Add-Member -NotePropertyName refused -NotePropertyValue $sample.reason -Force } + Record 'process.sample' $gateRecord $s | Out-Null + if ($s -lt $Samples) { Start-Sleep -Milliseconds 500 } + } + # A fresh handle, not the one used above; identity is sampled, never pinned. + $proc2 = Native { [WinObserver]::ProcessIdentity($CodexPid) } + if ($proc2.PSObject.Properties['createFiletime']) { + $proc2 | Add-Member -NotePropertyName sameCreate -NotePropertyValue ($null -ne $create0 -and "$($proc2.createFiletime)" -ceq "$create0") + } + Record 'process.identity_after' $proc2 | Out-Null + } + } +} +catch { + $OrchestratorFailed = $true + Record 'orchestrator_error' ([pscustomobject]@{ ok = $false; exception = $_.Exception.Message }) | Out-Null +} +finally { + # New file only; never overwrite. UTF-8 without BOM, one JSON object per line. + $fs = $null + $sw = $null + $written = $false + try { + $lines = $Results | ForEach-Object { $_ | ConvertTo-Json -Compress -Depth 10 } + $fs = New-Object IO.FileStream($OutFile, [IO.FileMode]::CreateNew, [IO.FileAccess]::Write, [IO.FileShare]::None) + $sw = New-Object IO.StreamWriter($fs, $Utf8) + foreach ($l in $lines) { $sw.Write($l); $sw.Write("`n") } + $sw.Flush() + $written = $true + } catch { + $OrchestratorFailed = $true + Write-Output ('results write error: ' + $_.Exception.Message) + } finally { + foreach ($resource in @($sw, $fs)) { + if ($null -ne $resource) { try { $resource.Dispose() } catch { $OrchestratorFailed = $true; $written = $false } } + } + } + if ($written) { Write-Output ('results: ' + $OutFile) } + if ($Abort) { Write-Output ('aborted: ' + $Abort) } + $counts = @{} + foreach ($r in $Results) { $counts[$r.verdict] = 1 + [int]$counts[$r.verdict] } + Write-Output ('verdicts: ' + (($counts.Keys | Sort-Object | ForEach-Object { "$_=$($counts[$_])" }) -join ' ')) + $code = 0 + if ($counts['mismatch']) { $code = 1 } + if ($counts['incomplete'] -or $counts['error'] -or $counts['unknown'] -or $OrchestratorFailed -or -not $written -or ($Abort -and $code -eq 0)) { $code = 2 } + exit $code +} diff --git a/experimental/windows-native-observer/test/cli-errors.test.mjs b/experimental/windows-native-observer/test/cli-errors.test.mjs new file mode 100644 index 0000000..189d935 --- /dev/null +++ b/experimental/windows-native-observer/test/cli-errors.test.mjs @@ -0,0 +1,50 @@ +import assert from 'node:assert/strict' +import { spawnSync } from 'node:child_process' +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import process from 'node:process' +import { test } from 'node:test' +import { fileURLToPath, URL } from 'node:url' + +const cli = fileURLToPath(new URL('../evaluate.mjs', import.meta.url)) +const secret = 'PRIVATE_INPUT_SENTINEL_NOT_FOR_OUTPUT' +const root = process.env.RESEARCH_TMP_ROOT ?? (process.platform === 'darwin' ? null : tmpdir()) +if (!root) throw new Error('RESEARCH_TMP_ROOT is required on macOS') + +function invoke(args) { + return spawnSync(process.execPath, [cli, ...args], { encoding: 'utf8', timeout: 10000 }) +} + +test('unreadable and invalid configuration inputs are structured unknown without data excerpts', () => { + const dir = mkdtempSync(join(root, 'codex-observer-cli-')) + try { + const preflight = join(dir, 'preflight.jsonl') + const observe = join(dir, 'observe.jsonl') + const baseline = join(dir, 'baseline.json') + writeFileSync(preflight, '{}\n', { mode: 0o600 }) + writeFileSync(observe, '{}\n', { mode: 0o600 }) + const common = ['run', '--label', 'medium', '--preflight', preflight, '--observe', observe] + const cases = [ + { args: ['run', '--label', 'medium', '--preflight', join(dir, secret), '--observe', observe], reason: 'input_unreadable' }, + { baseline: `{"${secret}":`, reason: 'baseline_invalid_json' }, + { baseline: '[]', reason: 'baseline_not_object' }, + ] + for (const entry of cases) { + if (entry.baseline !== undefined) writeFileSync(baseline, entry.baseline, { mode: 0o600 }) + const result = invoke(entry.args ?? [...common, '--baseline', baseline]) + assert.equal(result.status, 2, result.stderr) + assert.equal(result.stderr, '') + assert.deepEqual(JSON.parse(result.stdout), { status: 'unknown', problems: [entry.reason], safeToUnlock: false }) + assert.ok(!result.stdout.includes(secret)) + assert.ok(!result.stdout.includes(dir)) + } + assert.equal(readFileSync(preflight, 'utf8'), '{}\n') + assert.equal(readFileSync(observe, 'utf8'), '{}\n') + } finally { rmSync(dir, { recursive: true }) } +}) + +test('usage and table retain their documented exit codes', () => { + assert.equal(invoke([]).status, 64) + assert.equal(invoke(['table']).status, 0) +}) diff --git a/experimental/windows-native-observer/test/evaluate.test.mjs b/experimental/windows-native-observer/test/evaluate.test.mjs new file mode 100644 index 0000000..1f953a3 --- /dev/null +++ b/experimental/windows-native-observer/test/evaluate.test.mjs @@ -0,0 +1,1138 @@ +// Host-independent tests for the W6/W7 observer evaluator. Synthetic records +// only; nothing here touches Windows, Codex, a Codex home, or a lock. +// node --test experimental/windows-native-observer/test/ +import assert from 'node:assert/strict' +import { readFileSync } from 'node:fs' +import { dirname, join } from 'node:path' +import { test } from 'node:test' +import { fileURLToPath } from 'node:url' +import { EXPECTATIONS, HARNESS_FILES, INTEGRITY, SCHEMA, STEP_SCHEMAS, normalizeLocalPath } from '../expectations.mjs' +import { + deepEqualMap, evaluateRun, fixtureSummary, guardedCreate, guardedSample, hasAccessDenied, insideDisposableDir, + integrityFromSid, pairSummaries, parseJsonl, pathGateSafe, recomputeIsolation, reconstructManifest, + renderExpectationTable, sidFromWhoamiLine, tempRootSafeForCreation, validateBaseline, windowsPathRelation, +} from '../lib.mjs' + +const here = dirname(fileURLToPath(import.meta.url)) +const root = join(here, '..') +const HARNESS = Object.fromEntries(HARNESS_FILES.map((name, i) => [name, String(i + 1).repeat(64)])) +const COMMIT = 'c'.repeat(40) +const BINSHA = 'd'.repeat(64) +const MANIFEST_SHA = 'e'.repeat(64) +const VERSION = 'codex-cli 0.163.0' +const USER_SID = 'S-1-5-21-111-222-333-1001' +const TEMP = 'C:\\Users\\op\\AppData\\Local\\Temp' +const PARENT = `${TEMP}\\codex-win-obs-0123456789ab` +const HOME = `${PARENT}\\home` +const PARENT_B = `${TEMP}\\codex-win-obs-ba9876543210` +const HOME_B = `${PARENT_B}\\home` +const USER_HOME = 'C:\\Users\\op\\.codex' +const id = (n) => `0123456789abcdef:${String(n).padStart(32, '0')}` +const PARENT_ID = id(1) +const HOME_ID = id(2) +const THREAD_ID = id(3) +const LOCKDIR_ID = id(4) +const GUARD_ID = id(5) +const BINARY = 'C:\\Program Files\\codex\\codex.exe' +const CREATE = '132000000000000000' +const CODEX_PID = 7 +const OBSERVER_PID = 5 +const BASELINE = { sourceCommit: COMMIT, binarySha256: BINSHA, version: VERSION, harness: HARNESS } + +// No-follow ancestry of a disposable parent, root first, as the observer +// records it: every component a plain directory, ending at the parent itself. +function ancestry(parent, parentFileId) { + const parts = parent.split('\\') + const drive = parts.shift() + const out = [{ path: `${drive}\\`, fileId: id(90), directory: true, reparse: false }] + let cur = drive + parts.forEach((part, index) => { + cur = `${cur}\\${part}` + out.push({ path: cur, fileId: index === parts.length - 1 ? parentFileId : id(91 + index), directory: true, reparse: false }) + }) + return out +} + +const ACL = { + currentUserSid: USER_SID, + owner: USER_SID, daclProtected: true, aceCount: 1, aceIdentity: USER_SID, + aceRights: 'FullControl', aceInheritance: 'ContainerInherit, ObjectInherit', aceType: 'Allow', + parentOwner: USER_SID, parentDaclProtected: true, parentAceCount: 1, parentAceIdentity: USER_SID, + parentAceRights: 'FullControl', parentAceInheritance: 'ContainerInherit, ObjectInherit', parentAceType: 'Allow', +} + +function homeFields(home = HOME, parent = PARENT, homeId = HOME_ID, parentId = PARENT_ID) { + return { + home, parent, userCodexHome: USER_HOME, envCodexHome: null, userRelation: 'disjoint', envRelation: null, + fileId: homeId, parentFileId: parentId, ancestry: ancestry(parent, parentId), ...ACL, + } +} + +function identityFields(fileId, { directory = false, links = 1 } = {}) { + const leaf = fileId === GUARD_ID ? '\\.coordination.lock' : fileId === THREAD_ID ? '\\t.lock' : '' + return { + ok: true, complete: true, attributes: directory ? 16 : 128, reparse: false, directory, + fileId, links, deletePending: false, finalPath: `\\\\?\\${HOME}\\thread-writer-locks${leaf}`, + } +} + +function ranges(overrides = {}) { + const held = { win32: 33, acquired: false, released: null } + return { rust: { ...held }, fsext: { ...held }, beyond_fsext: { ...held }, tail: { ...held }, ...overrides } +} + +// The union of the raw table hypotheses for a mode+step, mirroring what +// observer.ps1 records as its own advisory expectation (dotted keys literal). +function expectFor(mode, step) { + const e = {} + for (const x of EXPECTATIONS) if (x.mode === mode && x.step === step && x.hypothesis) Object.assign(e, x.hypothesis) + return Object.keys(e).length > 0 ? e : null +} + +function rec(mode, step, label, observed, sample = null) { + return { + schema: SCHEMA, mode, step, sample, + integrity: { label, sid: INTEGRITY[label], elevationType: label === 'high' ? 2 : 3, sessionId: 1 }, + expect: expectFor(mode, step), observed, verdict: 'match', safeToUnlock: false, + } +} + +function idealRun(label, samples = 2, codexLabel = label) { + const pf = [ + rec('preflight', 'harness', label, { ok: true, complete: true, files: { ...HARNESS }, sourceCommit: COMMIT }), + rec('preflight', 'token.self', label, { ok: true, tokenOpened: true, pid: OBSERVER_PID, integritySid: INTEGRITY[label], userSid: USER_SID, integrityMatchesLabel: true }), + rec('preflight', 'binary.sha256', label, { ok: true, path: BINARY, size: 12345, sha256: BINSHA, expected: BINSHA, matches: true }), + rec('preflight', 'binary.signature', label, { ok: true, status: 'Valid', statusMessage: 'Signature verified.', signer: 'CN=OpenAI', thumbprint: 'ABCD' }), + rec('preflight', 'binary.pe_version', label, { ok: true, declared: true, fileVersion: '0.163.0', productVersion: '0.163.0', productName: 'codex' }), + rec('preflight', 'home.isolated', label, { ok: true, ...homeFields(), empty: true, entries: [], isolated: true }), + rec('preflight', 'binary.version', label, { + ok: true, exitCode: 0, pid: 4242, stdoutFirstLine: VERSION, expected: VERSION, matches: true, + stdout: VERSION, stderr: '', stdoutComplete: true, stderrComplete: true, stdoutTruncated: false, + stderrTruncated: false, timedOut: false, exited: true, envAllowlist: ['SystemRoot', 'CODEX_HOME'], + }), + rec('preflight', 'home.after_version', label, { ok: true, count: 0, entries: [] }), + rec('preflight', 'host.execution_policy', label, { ok: true, effective: 'RemoteSigned', scopes: [] }), + rec('preflight', 'host.defender', label, { ok: true, amServiceEnabled: true, realTimeProtectionEnabled: true, antivirusEnabled: true }), + rec('preflight', 'host.volume', label, { ok: true, fileSystem: 'NTFS', probePath: `${PARENT}\\.volume-probe` }), + rec('preflight', 'host.os', label, { ok: true, os: 'Microsoft Windows NT 10.0.19045.0', ps: '5.1.19041.1', elevated: label === 'high', sshSession: false }), + rec('preflight', 'manifest', label, { + ok: true, complete: true, home: HOME, homeFileId: HOME_ID, parent: PARENT, parentFileId: PARENT_ID, + binaryPath: BINARY, binarySha256: BINSHA, binarySize: 12345, version: VERSION, sourceCommit: COMMIT, + integritySid: INTEGRITY[label], label, harness: { ...HARNESS }, createdUtc: '2026-10-18T00:00:00.0000000Z', + }), + ] + const ob = [ + rec('observe', 'harness', label, { ok: true, complete: true, files: { ...HARNESS }, manifestSha256: MANIFEST_SHA }), + rec('observe', 'token.self', label, { ok: true, tokenOpened: true, pid: OBSERVER_PID, integritySid: INTEGRITY[label], userSid: USER_SID }), + rec('observe', 'home.identity', label, { ok: true, ...homeFields() }), + rec('observe', 'run', label, { + ok: true, samples, codexPid: CODEX_PID, codexLabel, observerPid: OBSERVER_PID, + observerUserSid: USER_SID, startedUtc: '2026-10-18T00:10:00.0000000Z', + }), + rec('observe', 'process.identity', label, { + ok: true, pid: CODEX_PID, opened: true, state: 'live', createFiletime: CREATE, image: BINARY, + tokenOpened: true, integritySid: INTEGRITY[codexLabel], userSid: USER_SID, + }), + ] + for (let s = 1; s <= samples; s++) { + ob.push(rec('observe', 'process.sample', label, { + ok: true, pid: CODEX_PID, opened: true, state: 'live', createFiletime: CREATE, image: BINARY, + tokenOpened: true, integritySid: INTEGRITY[codexLabel], userSid: USER_SID, + }, s)) + ob.push(rec('observe', 'lockdir.identity', label, identityFields(LOCKDIR_ID, { directory: true }), s)) + ob.push(rec('observe', 'coordination.identity', label, identityFields(GUARD_ID), s)) + ob.push(rec('observe', 'thread.count', label, { ok: true, count: 1, entries: ['.coordination.lock', 't.lock'] }, s)) + ob.push(rec('observe', 'thread.identity', label, identityFields(THREAD_ID), s)) + ob.push(rec('observe', 'openers.rm', label, { + ok: true, complete: true, count: 1, interpretation: 'opener_candidates_only', + files: [`${HOME}\\thread-writer-locks\\t.lock`, `${HOME}\\thread-writer-locks\\.coordination.lock`], + processes: [{ pid: CODEX_PID, startFiletime: CREATE, role: 'launched_codex', app: 'codex', service: null, type: 1, session: 1 }], + }, s)) + ob.push(rec('observe', 'share.read_rwd', label, { ok: true, opened: true, win32: 0, access: 'read', share: 'rwd' }, s)) + ob.push(rec('observe', 'share.delete_rwd', label, { ok: true, opened: true, win32: 0, access: 'delete', share: 'rwd' }, s)) + ob.push(rec('observe', 'range.probe', label, { + ok: true, state: 'probed', + dirFileIdBefore: LOCKDIR_ID, dirFileIdAfter: LOCKDIR_ID, + guardFileId: GUARD_ID, guardFileIdAfter: GUARD_ID, guardLinks: 1, guardDeletePending: false, + threadFileIdBefore: THREAD_ID, threadFileIdAfter: THREAD_ID, threadLinks: 1, threadDeletePending: false, + guardAcquired: true, guardReleased: true, ranges: ranges(), + }, s)) + ob.push(rec('observe', 'thread.identity_after', label, identityFields(THREAD_ID), s)) + } + ob.push(rec('observe', 'process.identity_after', label, { + ok: true, pid: CODEX_PID, opened: true, state: 'live', createFiletime: CREATE, image: BINARY, + tokenOpened: true, integritySid: INTEGRITY[codexLabel], userSid: USER_SID, + })) + return { preflight: { records: pf, problems: [] }, observe: { records: ob, problems: [] } } +} + +const measurableOnly = EXPECTATIONS.filter((e) => e.measurable !== false) + +function evaluate(run, opts = {}) { + return evaluateRun({ ...run, preflightSha256: MANIFEST_SHA, baseline: BASELINE, ...opts }) +} + +function find(records, step, sample = null) { + return records.find((r) => r.step === step && (r.sample ?? null) === sample) +} + +function row(summary, id2) { + return summary.rows.find((r) => r.id === id2) +} + +test('parseJsonl accepts LF JSONL and reports format problems', () => { + assert.deepEqual(parseJsonl('{"a":1}\n{"b":2}\n'), { records: [{ a: 1 }, { b: 2 }], problems: [] }) + assert.deepEqual(parseJsonl('').problems, ['empty_file']) + assert.ok(parseJsonl('\ufeff{"a":1}\n').problems.includes('bom')) + assert.ok(parseJsonl('{"a":1}\r\n').problems.includes('crlf:1')) + assert.ok(parseJsonl('{"a":1}').problems.includes('missing_final_newline')) + assert.ok(parseJsonl('nope\n').problems.includes('invalid_json:1')) + assert.ok(parseJsonl('[1]\n').problems.includes('not_object:1')) +}) + +test('integrity is derived only from the mandatory label SID', () => { + assert.equal(integrityFromSid('S-1-16-8192'), 'medium') + assert.equal(integrityFromSid('S-1-16-12288'), 'high') + assert.equal(integrityFromSid('S-1-16-8448'), 'other') + assert.equal(integrityFromSid('S-1-5-32-544'), null) + assert.equal(sidFromWhoamiLine('Mandatory Label\\High Mandatory Level Label S-1-16-12288'), 'S-1-16-12288') + assert.equal(sidFromWhoamiLine('Etiqueta obligatoria\\Nivel obligatorio medio Etiqueta S-1-16-8192'), 'S-1-16-8192') + assert.equal(sidFromWhoamiLine('S-1-16-8192 S-1-16-12288'), null) +}) + +test('only absolute local drive spellings are accepted; ambiguous ones are rejected', () => { + assert.equal(normalizeLocalPath('C:\\Temp\\A\\'), 'c:\\temp\\a') + assert.equal(normalizeLocalPath('C:\\'), 'c:\\') + for (const bad of ['\\\\?\\C:\\Temp', '\\\\.\\C:\\Temp', '\\\\server\\share\\x', '\\rooted\\x', 'C:relative', + 'relative\\x', 'C:/Temp/x', 'C:\\Temp\\\\x', 'C:\\Temp\\..\\Users', 'C:\\Temp\\.', 'C:\\Temp\\x ', + 'C:\\Temp\\x.', 'C:\\Temp\\ x', 'C:\\Temp\\a:b', 'C:\\Temp\\a*b', 'C:\\Temp\\a?b', 'C:\\Temp\\a|b', '', null]) { + assert.equal(normalizeLocalPath(bad), null, String(bad)) + } +}) + +test('windowsPathRelation is lexical, case-insensitive and boundary aware', () => { + assert.equal(windowsPathRelation('C:\\Users\\Op\\.codex', 'c:\\users\\op\\.CODEX\\'), 'same') + assert.equal(windowsPathRelation('C:\\Users\\op\\.codex\\x', 'C:\\Users\\op\\.codex'), 'inside') + assert.equal(windowsPathRelation('C:\\Users\\op', 'C:\\Users\\op\\.codex'), 'contains') + assert.equal(windowsPathRelation('C:\\Users\\op\\.codex2', 'C:\\Users\\op\\.codex'), 'disjoint') + // A device-path spelling of the same file is refused, not normalized. + assert.equal(windowsPathRelation('\\\\?\\C:\\Temp\\a', 'C:\\Temp\\a'), null) + assert.equal(windowsPathRelation('C:\\Temp\\..\\Users', 'C:\\Users'), null) +}) + +test('the output/input path gate refuses real homes and disposable directories', () => { + assert.equal(pathGateSafe(`C:\\Users\\op\\Documents\\w7\\out.jsonl`, USER_HOME, null), null) + assert.equal(pathGateSafe(`${USER_HOME}\\out.jsonl`, USER_HOME, null), 'user_home_overlap') + assert.equal(pathGateSafe('C:\\Users\\op\\out.jsonl', USER_HOME, 'C:\\Users\\op'), 'env_home_overlap') + assert.equal(pathGateSafe(`${PARENT}\\out.jsonl`, USER_HOME, null), 'inside_disposable_dir') + // Another volume's disposable directory is still refused, whatever %TEMP% is. + assert.equal(pathGateSafe('D:\\scratch\\codex-win-obs-0123456789ab\\out.jsonl', USER_HOME, null), 'inside_disposable_dir') + assert.equal(pathGateSafe('\\\\server\\share\\out.jsonl', USER_HOME, null), 'path_spelling') + assert.equal(pathGateSafe(`${PARENT}\\home`, USER_HOME, null, { allowDisposable: true }), null) + assert.equal(insideDisposableDir('C:\\Temp\\codex-win-obs-0123456789abc'), false) + assert.equal(insideDisposableDir('C:\\Temp\\codex-win-obs-0123456789ab\\home'), true) +}) + +test('access denial is found at any depth', () => { + assert.equal(hasAccessDenied({ ok: true, ranges: { rust: { win32: 5 } } }), true) + assert.equal(hasAccessDenied({ ok: true, tokenWin32: 5 }), true) + assert.equal(hasAccessDenied({ ok: true, win32: 32, list: [{ win32: 33 }] }), false) +}) + +test('a baseline must be format-valid with the exact harness file-name set', () => { + assert.deepEqual(validateBaseline(BASELINE), []) + assert.deepEqual(validateBaseline(null), ['baseline_missing']) + assert.deepEqual(validateBaseline([]), ['baseline_not_object']) + assert.ok(validateBaseline({ ...BASELINE, sourceCommit: 'short' }).includes('baseline_source_commit')) + assert.ok(validateBaseline({ ...BASELINE, binarySha256: 'x' }).includes('baseline_binary_sha256')) + assert.ok(validateBaseline({ ...BASELINE, version: '' }).includes('baseline_version')) + const partial = { ...HARNESS } + delete partial['lib.mjs'] + assert.ok(validateBaseline({ ...BASELINE, harness: partial }).includes('baseline_harness_name_set')) + assert.ok(validateBaseline({ ...BASELINE, harness: { ...HARNESS, extra: 'f'.repeat(64) } }).includes('baseline_harness_name_set')) +}) + +test('ideal evidence still leaves W7 unknown on the unmeasurable coordination range', () => { + const s = evaluate(idealRun('medium'), { expectedLabel: 'medium' }) + assert.equal(s.label, 'medium') + assert.deepEqual(s.problems, []) + assert.equal(s.w7, 'unknown') + assert.deepEqual(s.blockers, ['OB-coordination-range']) + assert.equal(row(s, 'OB-coordination-range').reasons[0], 'not_measurable') + assert.equal(s.dependents.W3.nativeClassification, 'blocked_on_W7') + assert.equal(s.safeToUnlock, false) + assert.equal(s.provenance.state, 'validated') + for (const r of s.rows) assert.equal(r.integrity, 'medium') +}) + +test('a fully measured table passes with a baseline, and authorizes nothing', () => { + const s = evaluate(idealRun('high'), { expectedLabel: 'high', expectations: measurableOnly }) + assert.equal(s.w7, 'pass', JSON.stringify(s.rows.filter((r) => r.verdict !== 'match' && r.verdict !== 'recorded'))) + assert.equal(s.dependents.W3.nativeClassification, 'evidence_available_decision_pending') + assert.equal(s.safeToUnlock, false) + assert.equal(row(s, 'OB-range-beyond-fsext').measured, 'all') + assert.equal(row(s, 'OB-run').verdict, 'match') + assert.equal(row(s, 'OB-range-guard').verdict, 'match') + assert.equal(row(s, 'OB-openers').verdict, 'match') +}) + +test('without a baseline the provenance rows stay unknown and the gate cannot pass', () => { + const s = evaluateRun({ ...idealRun('high'), preflightSha256: MANIFEST_SHA, baseline: null, expectations: measurableOnly, expectedLabel: 'high' }) + assert.ok(s.problems.includes('baseline_missing')) + assert.equal(s.w7, 'unknown') + assert.equal(s.provenance.state, 'unvalidated') + assert.equal(s.baselineProvided, false) + for (const id2 of ['PF-harness', 'PF-binary-sha256', 'PF-binary-version', 'PF-manifest']) { + assert.equal(row(s, id2).verdict, 'unknown') + } +}) + +test('provenance verdicts are recomputed, not read from harness booleans', () => { + // Flip every self-asserted boolean to a lie; derived rows must still pass + // because they recompute from raw fields and the baseline. + const run = idealRun('high') + find(run.preflight.records, 'token.self').observed.integrityMatchesLabel = false + find(run.preflight.records, 'binary.sha256').observed.matches = false + find(run.preflight.records, 'home.isolated').observed.isolated = false + find(run.preflight.records, 'binary.version').observed.matches = false + find(run.observe.records, 'harness').observed.matchesManifest = false + const s = evaluate(run, { expectations: measurableOnly }) + // home.isolated now disagrees with the recomputation -> unknown, not trusted. + assert.equal(row(s, 'PF-home').verdict, 'unknown') + assert.ok(row(s, 'PF-home').reasons.includes('evaluator_disagreement')) + for (const id2 of ['PF-token', 'PF-binary-sha256', 'PF-binary-version', 'OB-harness']) { + assert.equal(row(s, id2).verdict, 'match') + } +}) + +test('a baseline that disagrees with the manifest is a mismatch, never success', () => { + const s = evaluate(idealRun('high'), { baseline: { ...BASELINE, binarySha256: 'f'.repeat(64) } }) + assert.equal(row(s, 'PF-binary-sha256').verdict, 'mismatch') + assert.equal(row(s, 'PF-manifest').verdict, 'mismatch') + assert.equal(s.w7, 'fail') + assert.equal(s.provenance.state, 'unvalidated') + assert.deepEqual(s.provenance.mismatches, ['PF-binary-sha256', 'PF-manifest']) +}) + +test('counterexample: a paired baseline mismatch can never report compared', () => { + const bad = { ...BASELINE, binarySha256: 'f'.repeat(64) } + const m = evaluate(idealRun('medium'), { expectedLabel: 'medium', baseline: bad }) + const h = evaluate(idealRun('high'), { expectedLabel: 'high', baseline: bad }) + const p = pairSummaries(m, h) + assert.equal(p.status, 'fail') + assert.ok(p.problems.includes('medium_provenance_unvalidated')) + assert.ok(p.problems.includes('high_provenance_unvalidated')) + assert.ok(p.provenanceFailures.includes('medium:PF-binary-sha256')) + assert.ok(p.provenanceFailures.includes('high:PF-manifest')) +}) + +test('counterexample: a W6 pair needs a validated provenance state on both sides', () => { + const m = evaluate(idealRun('medium'), { expectedLabel: 'medium', baseline: null }) + const h = evaluate(idealRun('high'), { expectedLabel: 'high' }) + const p = pairSummaries(m, h) + assert.equal(p.status, 'unknown') + assert.ok(p.problems.includes('medium_provenance_unvalidated')) + assert.ok(p.problems.includes('baseline_missing')) +}) + +test('observe harness that references another preflight is a mismatch', () => { + const run = idealRun('high') + find(run.observe.records, 'harness').observed.manifestSha256 = 'f'.repeat(64) + const s = evaluate(run, { expectations: measurableOnly }) + assert.ok(s.problems.includes('manifest_reference_mismatch')) + assert.equal(s.w7, 'unknown') +}) + +test('a record missing a schema-required field is unknown, never recorded success', () => { + const run = idealRun('high') + delete find(run.observe.records, 'openers.rm', 1).observed.count + const s = evaluate(run, { expectations: measurableOnly }) + assert.equal(row(s, 'OB-openers').verdict, 'unknown') + assert.ok(row(s, 'OB-openers').reasons.some((x) => x.startsWith('schema_missing:count'))) +}) + +test('a recorded row with only {ok:true} fails its typed schema', () => { + for (const [mode, step, id2] of [['observe', 'openers.rm', 'OB-openers'], ['preflight', 'binary.pe_version', 'PF-binary-pe-version'], + ['preflight', 'binary.signature', 'PF-binary-signature'], ['preflight', 'host.volume', 'PF-volume']]) { + const run = idealRun('high') + const records = mode === 'preflight' ? run.preflight.records : run.observe.records + find(records, step, step === 'openers.rm' ? 1 : null).observed = { ok: true } + const s = evaluate(run, { expectations: measurableOnly }) + assert.equal(row(s, id2).verdict, 'unknown', id2) + } +}) + +test('an undeclared PE version resource must be declared, not implied', () => { + const run = idealRun('high') + find(run.preflight.records, 'binary.pe_version').observed = { ok: true, declared: true, fileVersion: null, productVersion: null, productName: null } + assert.equal(row(evaluate(run, { expectations: measurableOnly }), 'PF-binary-pe-version').verdict, 'unknown') + const explicit = idealRun('high') + find(explicit.preflight.records, 'binary.pe_version').observed = { ok: true, declared: false, fileVersion: null, productVersion: null, productName: null } + assert.equal(row(evaluate(explicit, { expectations: measurableOnly }), 'PF-binary-pe-version').verdict, 'recorded') +}) + +test('a malformed field format is unknown', () => { + const run = idealRun('high') + find(run.observe.records, 'home.identity').observed.fileId = 'not-a-file-id' + const s = evaluate(run, { expectations: measurableOnly }) + assert.equal(row(s, 'OB-home').verdict, 'unknown') + assert.ok(row(s, 'OB-home').reasons.some((x) => x.startsWith('schema_format:fileId'))) +}) + +test('a non-absolute or device home path fails the path contract', () => { + for (const bad of ['home', '\\\\?\\C:\\Temp\\codex-win-obs-0123456789ab\\home', '\\\\srv\\share\\home']) { + const run = idealRun('high') + find(run.observe.records, 'home.identity').observed.home = bad + assert.equal(row(evaluate(run, { expectations: measurableOnly }), 'OB-home').verdict, 'unknown', bad) + } +}) + +test('samples must be an integer 1..10, checked before allocation', () => { + for (const bad of [0, 11, 2.5, -1, '2', null, undefined]) { + const run = idealRun('high') + find(run.observe.records, 'run').observed.samples = bad + const s = evaluate(run, { expectations: measurableOnly }) + assert.ok(s.problems.includes('sample_count_invalid'), `samples=${bad}`) + assert.equal(row(s, 'OB-thread-count').reasons[0], 'sample_count_invalid') + assert.equal(s.w7, 'unknown') + } +}) + +test('a missing sample is partial collection and unknown, never success', () => { + const run = idealRun('medium', 3) + run.observe.records = run.observe.records.filter((r) => !(r.step === 'range.probe' && r.sample === 2)) + const s = evaluate(run, { expectations: measurableOnly }) + assert.equal(row(s, 'OB-range-rust').collected, 'partial') + assert.equal(row(s, 'OB-range-rust').verdict, 'unknown') + assert.ok(row(s, 'OB-range-rust').reasons.includes('not_collected#2')) + assert.ok(s.problems.some((p) => p.startsWith('observe:sample_order:range.probe'))) +}) + +test('counterexample: an extra, orphan or misplaced sampled record invalidates the file', () => { + const extra = idealRun('medium', 2) + extra.observe.records.push(rec('observe', 'range.probe', 'medium', { ok: true, state: 'probed' }, 9)) + assert.ok(evaluate(extra).problems.includes('observe:sample_invalid:range.probe#9')) + const orphan = idealRun('medium', 2) + orphan.observe.records.push(rec('observe', 'process.identity', 'medium', { ok: true, state: 'live' }, 99)) + assert.ok(evaluate(orphan).problems.includes('observe:sample_unexpected:process.identity#99')) + const sampledRun = idealRun('medium', 2) + find(sampledRun.observe.records, 'run').sample = 99 + const s = evaluate(sampledRun) + assert.ok(s.problems.includes('observe:sample_unexpected:run#99')) + assert.equal(s.w7, 'unknown') + const unsampled = idealRun('medium', 2) + find(unsampled.observe.records, 'thread.identity', 1).sample = null + assert.ok(evaluate(unsampled).problems.includes('observe:sample_invalid:thread.identity#null')) + const reordered = idealRun('medium', 2) + const runRec = find(reordered.observe.records, 'run') + reordered.observe.records = [...reordered.observe.records.filter((r) => r !== runRec), runRec] + assert.ok(evaluate(reordered).problems.includes('observe:order:run_after_samples')) +}) + +test('counterexample: the run control record is mandatory and binds every process record', () => { + const noPid = idealRun('high') + delete find(noPid.observe.records, 'run').observed.codexPid + const s1 = evaluate(noPid, { expectations: measurableOnly }) + assert.ok(s1.problems.some((p) => p.startsWith('run_unusable:schema_missing:codexPid'))) + assert.ok(s1.problems.includes('sample_count_invalid')) + assert.equal(s1.w7, 'unknown') + assert.notEqual(s1.rows.length, 0) + assert.equal(row(s1, 'OB-run').verdict, 'unknown') + const wrongPid = idealRun('high') + find(wrongPid.observe.records, 'process.identity').observed.pid = CODEX_PID + 1 + const s2 = evaluate(wrongPid, { expectations: measurableOnly }) + assert.equal(row(s2, 'OB-run').verdict, 'mismatch') + assert.ok(row(s2, 'OB-run').reasons.includes('process.identity_pid_differs_from_run')) + assert.equal(row(s2, 'OB-process').verdict, 'mismatch') + assert.equal(s2.w7, 'fail') + const observerIsTarget = idealRun('high') + find(observerIsTarget.observe.records, 'run').observed.observerPid = CODEX_PID + assert.equal(row(evaluate(observerIsTarget, { expectations: measurableOnly }), 'OB-run').verdict, 'unknown') + const otherUser = idealRun('high') + find(otherUser.observe.records, 'process.identity').observed.userSid = 'S-1-5-21-111-222-333-2002' + assert.equal(row(evaluate(otherUser, { expectations: measurableOnly }), 'OB-process').verdict, 'mismatch') +}) + +test('counterexample: a between-sample exit, PID reuse or token failure refuses that sample', () => { + // The producer re-queries the declared PID before each sample's active + // operations. A sample whose gate failed must carry its refused process + // record and no RM/share/range records, and must never borrow the previous + // sample's verdict. + const exited = idealRun('high', 2) + const gate = find(exited.observe.records, 'process.sample', 2) + gate.observed = { ok: true, pid: CODEX_PID, opened: true, state: 'exited' } + exited.observe.records = exited.observe.records.filter((r) => r.sample !== 2 || + !['openers.rm', 'share.read_rwd', 'share.delete_rwd', 'range.probe', 'thread.identity_after'].includes(r.step)) + const s = evaluate(exited, { expectations: measurableOnly }) + assert.equal(row(s, 'OB-process-sample').verdict, 'unknown') + assert.ok(row(s, 'OB-process-sample').reasons.some((x) => x.startsWith('schema_missing:createFiletime#2'))) + for (const id2 of ['OB-openers', 'OB-share-read-rwd', 'OB-range-rust', 'OB-thread-stable']) { + assert.equal(row(s, id2).collected, 'partial', id2) + assert.equal(row(s, id2).verdict, 'unknown', id2) + } + assert.equal(s.w7, 'unknown') + // A PID reused between samples, a changed integrity and a foreign user are + // each a measured mismatch of that sample, not of the run as a whole. + for (const [patch, reason] of [ + [{ createFiletime: '132000000000000999' }, 'create_filetime_changed#1'], + [{ pid: CODEX_PID + 1 }, 'queried_pid_differs_from_run#1'], + [{ userSid: 'S-1-5-21-111-222-333-2002' }, 'target_user_differs_from_observer#1'], + [{ integritySid: INTEGRITY.medium }, 'integrity_sid_changed#1'], + [{ image: 'C:\\Windows\\other.exe' }, 'image_differs_from_manifest_binary#1'], + ]) { + const run = idealRun('high', 2) + Object.assign(find(run.observe.records, 'process.sample', 1).observed, patch) + const r = row(evaluate(run, { expectations: measurableOnly }), 'OB-process-sample') + assert.equal(r.verdict, 'mismatch', JSON.stringify(patch)) + assert.ok(r.reasons.includes(reason), `${JSON.stringify(patch)}: ${r.reasons.join(',')}`) + } +}) + +test('counterexample: the manifest must be reconstructible from the raw preflight records', () => { + // Home and its IDs moved to another disposable directory in the manifest and + // in observe, while preflight still validates the first one. + const moved = idealRun('high') + const manifest = find(moved.preflight.records, 'manifest').observed + Object.assign(manifest, { home: HOME_B, parent: PARENT_B, homeFileId: id(12), parentFileId: id(11) }) + find(moved.observe.records, 'home.identity').observed = { ok: true, ...homeFields(HOME_B, PARENT_B, id(12), id(11)) } + const s = evaluate(moved, { expectations: measurableOnly }) + assert.equal(row(s, 'PF-manifest').verdict, 'mismatch') + assert.ok(row(s, 'PF-manifest').reasons.includes('manifest_home_differs_from_preflight')) + assert.equal(s.w7, 'fail') + // The hashed binary path and the recorded source commit must bind too. + for (const [patch, reason] of [ + [{ binaryPath: 'C:\\other\\codex.exe' }, 'manifest_binaryPath_differs_from_preflight'], + [{ binarySize: 999 }, 'manifest_binarySize_differs_from_preflight'], + [{ version: 'codex-cli 9.9.9' }, 'manifest_version_differs_from_preflight'], + [{ sourceCommit: 'a'.repeat(40) }, 'manifest_sourceCommit_differs_from_preflight'], + [{ parentFileId: id(77) }, 'manifest_parentFileId_differs_from_preflight'], + ]) { + const run = idealRun('high') + Object.assign(find(run.preflight.records, 'manifest').observed, patch) + const r = row(evaluate(run, { expectations: measurableOnly }), 'PF-manifest') + assert.equal(r.verdict, 'mismatch', JSON.stringify(patch)) + assert.ok(r.reasons.includes(reason), `${JSON.stringify(patch)}: ${r.reasons.join(',')}`) + } + // A preflight record the manifest depends on being unusable keeps it unknown. + const broken = idealRun('high') + find(broken.preflight.records, 'binary.sha256').observed = { ok: true, complete: false } + const r2 = row(evaluate(broken, { expectations: measurableOnly }), 'PF-manifest') + assert.equal(r2.verdict, 'unknown') + assert.ok(r2.reasons.some((x) => x.startsWith('preflight_unusable:binary.sha256'))) + assert.deepEqual(reconstructManifest([], 'high').fields, null) +}) + +test('counterexample: a complete plus incomplete manifest pair, or a manifest that is not last, refuses', () => { + const pair = idealRun('high') + pair.preflight.records.push(rec('preflight', 'manifest', 'high', { ok: true, complete: false, reason: 'aborted' })) + const s1 = evaluate(pair, { expectations: measurableOnly }) + assert.ok(s1.problems.includes('preflight:manifest_not_unique:2')) + assert.ok(s1.problems.includes('preflight:duplicate:manifest#')) + assert.equal(s1.w7, 'unknown') + const notLast = idealRun('high') + notLast.preflight.records.push(rec('preflight', 'host.os', 'high', { ok: true, os: 'x', ps: '5.1', elevated: false, sshSession: false })) + const s2 = evaluate(notLast, { expectations: measurableOnly }) + assert.ok(s2.problems.includes('preflight:manifest_not_last')) + const missing = idealRun('high') + missing.preflight.records = missing.preflight.records.filter((r) => r.step !== 'host.volume') + assert.ok(evaluate(missing).problems.includes('preflight:manifest_requires_one:host.volume:0')) + const incompleteOnly = idealRun('high') + find(incompleteOnly.preflight.records, 'manifest').observed = { ok: true, complete: false, reason: 'aborted' } + const s3 = evaluate(incompleteOnly, { expectations: measurableOnly }) + assert.ok(s3.problems.includes('preflight:manifest_not_complete')) + assert.ok(s3.problems.includes('manifest_unusable')) +}) + +test('access denial on the process maps to unknown, not mismatch', () => { + const run = idealRun('medium') + find(run.observe.records, 'process.identity').observed = { ok: true, pid: CODEX_PID, opened: false, state: 'unknown', reason: 'open_process', win32: 5 } + const s = evaluate(run, { expectations: measurableOnly }) + assert.equal(row(s, 'OB-process').verdict, 'unknown') + assert.deepEqual(row(s, 'OB-process').reasons, ['access_denied']) +}) + +test('partial, native error, exception and null observations are unknown', () => { + for (const observed of [{ ok: true, complete: false }, { ok: false, win32: 2 }, { ok: true, exception: 'x' }, null]) { + const run = idealRun('high') + find(run.observe.records, 'openers.rm', 1).observed = observed + const s = evaluate(run, { expectations: measurableOnly }) + assert.equal(row(s, 'OB-openers').verdict, 'unknown') + } +}) + +test('counterexample: contradictory share, range and Restart Manager records are unknown', () => { + const cases = [ + ['share.read_rwd', { opened: true, win32: 32, access: 'read', share: 'rwd' }, 'OB-share-read-rwd'], + ['share.read_rwd', { opened: false, win32: 0, access: 'read', share: 'rwd' }, 'OB-share-read-rwd'], + ['share.delete_rwd', { opened: true, win32: 5, access: 'delete', share: 'rwd' }, 'OB-share-delete-rwd'], + ] + for (const [step, patch, id2] of cases) { + const run = idealRun('high') + find(run.observe.records, step, 1).observed = { ok: true, ...patch } + assert.equal(row(evaluate(run, { expectations: measurableOnly }), id2).verdict, 'unknown', `${step} ${JSON.stringify(patch)}`) + } + // A held range cannot also be acquired, and an acquired range must report a release. + for (const patch of [{ rust: { win32: 33, acquired: true, released: false } }, { rust: { win32: 0, acquired: true, released: false } }, + { fsext: { win32: 0, acquired: false, released: null } }, { tail: { win32: 33, acquired: false, released: true } }]) { + const run = idealRun('high') + for (const r of run.observe.records.filter((x) => x.step === 'range.probe')) r.observed.ranges = ranges(patch) + const s = evaluate(run, { expectations: measurableOnly }) + assert.equal(row(s, 'OB-range-rust').verdict, 'unknown', JSON.stringify(patch)) + assert.ok(row(s, 'OB-range-rust').reasons.some((x) => x.includes('invariant:ranges_consistent'))) + } + // A probed state with an unreleased guard, or a hardlinked target, is contradictory. + for (const patch of [{ guardReleased: false }, { guardAcquired: false }, { guardLinks: 2 }, { threadLinks: 2 }, { threadDeletePending: true }]) { + const run = idealRun('high') + for (const r of run.observe.records.filter((x) => x.step === 'range.probe')) Object.assign(r.observed, patch) + assert.equal(row(evaluate(run, { expectations: measurableOnly }), 'OB-range-guard').verdict, 'unknown', JSON.stringify(patch)) + } + // Restart Manager: count, entry types, roles and interpretation must agree. + const rmCases = [ + [{ count: 2 }, 'unknown'], + [{ interpretation: 'holders' }, 'unknown'], + [{ processes: [{ pid: CODEX_PID, startFiletime: 132, role: 'launched_codex', app: 'c', service: null, type: 1, session: 1 }] }, 'unknown'], + [{ processes: [{ pid: 0, startFiletime: CREATE, role: 'launched_codex', app: 'c', service: null, type: 1, session: 1 }] }, 'unknown'], + [{ processes: [{ pid: CODEX_PID, startFiletime: CREATE, role: 'holder', app: 'c', service: null, type: 1, session: 1 }] }, 'unknown'], + [{ + count: 2, + processes: [ + { pid: CODEX_PID, startFiletime: CREATE, role: 'launched_codex', app: 'c', service: null, type: 1, session: 1 }, + { pid: CODEX_PID, startFiletime: CREATE, role: 'launched_codex', app: 'c', service: null, type: 1, session: 1 }, + ], + }, 'unknown'], + [{ processes: [{ pid: CODEX_PID, startFiletime: CREATE, role: 'other', app: 'c', service: null, type: 1, session: 1 }] }, 'mismatch'], + [{ processes: [{ pid: CODEX_PID, startFiletime: '132000000000000999', role: 'launched_codex', app: 'c', service: null, type: 1, session: 1 }] }, 'mismatch'], + [{ processes: [{ pid: OBSERVER_PID, startFiletime: CREATE, role: 'other', app: 'c', service: null, type: 1, session: 1 }] }, 'mismatch'], + ] + for (const [patch, want] of rmCases) { + const run = idealRun('high') + for (const r of run.observe.records.filter((x) => x.step === 'openers.rm')) Object.assign(r.observed, patch) + assert.equal(row(evaluate(run, { expectations: measurableOnly }), 'OB-openers').verdict, want, JSON.stringify(patch)) + } +}) + +test('counterexample: the guarded probe must bind to that sample\'s guard and directory', () => { + for (const [patch, reason] of [ + [{ guardFileId: id(55) }, 'guard_identity_differs'], + [{ guardFileIdAfter: id(55) }, 'guard_identity_differs'], + [{ dirFileIdAfter: id(55) }, 'lock_directory_identity_differs'], + [{ threadFileIdBefore: id(55) }, 'thread_identity_differs'], + ]) { + const run = idealRun('high') + Object.assign(find(run.observe.records, 'range.probe', 1).observed, patch) + const r = row(evaluate(run, { expectations: measurableOnly }), 'OB-range-guard') + assert.equal(r.verdict, 'mismatch', JSON.stringify(patch)) + assert.ok(r.reasons.includes(`${reason}#1`), r.reasons.join(',')) + } + // Without that sample's own identity records the binding is unknown. + const run = idealRun('high') + find(run.observe.records, 'coordination.identity', 1).observed = { ok: true, state: 'unknown', reason: 'guard_open' } + const r = row(evaluate(run, { expectations: measurableOnly }), 'OB-range-guard') + assert.equal(r.verdict, 'unknown') + assert.ok(r.reasons.includes('coordination_identity_missing#1')) +}) + +test('a held range ending at the fs-ext boundary is a mismatch', () => { + const run = idealRun('high') + for (const r of run.observe.records.filter((x) => x.step === 'range.probe')) { + r.observed.ranges = ranges({ beyond_fsext: { win32: 0, acquired: true, released: true } }) + } + const s = evaluate(run, { expectations: measurableOnly }) + assert.equal(row(s, 'OB-range-beyond-fsext').verdict, 'mismatch') + assert.equal(row(s, 'OB-range-rust').verdict, 'match') + assert.equal(s.w7, 'fail') +}) + +test('a live process whose image differs from the manifest binary is a mismatch', () => { + const run = idealRun('high') + find(run.observe.records, 'process.identity').observed.image = 'C:\\Windows\\System32\\other.exe' + const s = evaluate(run, { expectations: measurableOnly }) + assert.equal(row(s, 'OB-process').verdict, 'mismatch') +}) + +test('a changed creation FILETIME or PID after the samples is a mismatch', () => { + const changed = idealRun('high') + find(changed.observe.records, 'process.identity_after').observed.createFiletime = '132000000000000001' + const s1 = evaluate(changed, { expectations: measurableOnly }) + assert.equal(row(s1, 'OB-process-after').verdict, 'mismatch') + assert.equal(row(s1, 'OB-run').verdict, 'mismatch') + const exited = idealRun('high') + find(exited.observe.records, 'process.identity_after').observed.state = 'exited' + assert.equal(row(evaluate(exited, { expectations: measurableOnly }), 'OB-process-after').verdict, 'mismatch') +}) + +test('a changed thread identity across a sample is a mismatch', () => { + for (const patch of [{ fileId: id(33) }, { links: 2 }, { deletePending: true }]) { + const run = idealRun('high') + Object.assign(find(run.observe.records, 'thread.identity_after', 1).observed, patch) + const s = evaluate(run, { expectations: measurableOnly }) + assert.equal(row(s, 'OB-thread-stable').verdict, 'mismatch', JSON.stringify(patch)) + } +}) + +test('an orchestrator_error record invalidates the whole file', () => { + const run = idealRun('high') + run.observe.records.push(rec('observe', 'orchestrator_error', 'high', { ok: false, exception: 'boom' })) + const s = evaluate(run, { expectations: measurableOnly }) + assert.ok(s.problems.includes('observe:orchestrator_error')) + assert.equal(s.w7, 'unknown') + assert.ok(s.rows.filter((r) => r.mode === 'observe').every((r) => r.verdict === 'unknown')) +}) + +test('an unexpected step invalidates the file', () => { + const run = idealRun('high') + run.observe.records.push(rec('observe', 'surprise.step', 'high', { ok: true })) + const s = evaluate(run, { expectations: measurableOnly }) + assert.ok(s.problems.includes('observe:unexpected_step:surprise.step')) +}) + +test('a harness expectation that drifts from the table is unknown', () => { + const run = idealRun('high') + find(run.observe.records, 'range.probe', 1).expect = { state: 'probed', 'ranges.rust.win32': 0 } + const s = evaluate(run, { expectations: measurableOnly }) + assert.ok(row(s, 'OB-range-rust').reasons.includes('expectation_drift#1')) +}) + +test('mixed integrity in one file makes it unusable', () => { + const run = idealRun('medium') + run.observe.records[3].integrity = { label: 'high', sid: INTEGRITY.high } + const s = evaluate(run, { expectations: measurableOnly }) + assert.ok(s.problems.includes('observe:mixed_integrity')) + assert.ok(s.rows.filter((r) => r.mode === 'observe').every((r) => r.verdict === 'unknown')) +}) + +test('a declared label that disagrees with the measured SID is never relabelled', () => { + const run = idealRun('medium') + for (const r of [...run.preflight.records, ...run.observe.records]) r.integrity.sid = INTEGRITY.high + const s = evaluate(run, { expectedLabel: 'medium', expectations: measurableOnly }) + assert.equal(s.label, null) + assert.ok(s.problems.includes('preflight:integrity_label_sid_mismatch')) + assert.ok(s.problems.includes('expected_label_mismatch')) +}) + +test('home isolation is recomputed from the recorded paths, owner, DACL and ancestry', () => { + const base = { ...homeFields(), empty: true, entries: [] } + assert.equal(recomputeIsolation(base), true) + assert.equal(recomputeIsolation({ ...base, home: `${USER_HOME}\\tmp`, parent: USER_HOME }), false) + assert.equal(recomputeIsolation({ ...base, envCodexHome: TEMP, envRelation: 'inside' }), false) + assert.equal(recomputeIsolation({ ...base, userRelation: 'same' }), false) + assert.equal(recomputeIsolation({ ...base, aceCount: 2 }), false) + assert.equal(recomputeIsolation({ ...base, daclProtected: false }), false) + assert.equal(recomputeIsolation({ ...base, owner: 'S-1-5-18' }), false) + assert.equal(recomputeIsolation({ ...base, aceIdentity: 'S-1-1-0' }), false) + assert.equal(recomputeIsolation({ ...base, aceRights: 'Read' }), false) + assert.equal(recomputeIsolation({ ...base, parentAceInheritance: 'None' }), false) + assert.equal(recomputeIsolation({ ...base, empty: false, entries: ['x'] }), false) + // Structure: the home must be exactly the disposable parent's `home` child. + assert.equal(recomputeIsolation({ ...base, home: `${PARENT}\\other` }), false) + assert.equal(recomputeIsolation({ ...base, home: `${PARENT}\\home\\deeper` }), false) + assert.equal(recomputeIsolation({ ...base, parent: TEMP, home: `${TEMP}\\home`, ancestry: ancestry(TEMP, PARENT_ID) }), false) + // A reparse point anywhere in the recorded ancestry refuses the home. + const junction = ancestry(PARENT, PARENT_ID) + junction[2] = { ...junction[2], reparse: true } + assert.equal(recomputeIsolation({ ...base, ancestry: junction }), false) + assert.equal(recomputeIsolation({ ...base, ancestry: [] }), false) + assert.equal(recomputeIsolation({ ...base, ancestry: [{ path: PARENT, fileId: 'bad', directory: true, reparse: false }] }), false) + // A field that is absent makes the recomputation impossible, not false. + assert.equal(recomputeIsolation({ ...base, ancestry: null }), null) + assert.equal(recomputeIsolation({ ...base, currentUserSid: null }), null) + assert.equal(recomputeIsolation({ ...base, fileId: 'bad' }), null) + assert.equal(recomputeIsolation({ ...base, home: 'relative' }), null) +}) + +test('cross-level runs are reported alone and never paired', () => { + const cross = evaluate(idealRun('medium', 2, 'high'), { expectations: measurableOnly }) + assert.equal(cross.variant, 'cross_level') + assert.equal(cross.codexLabel, 'high') + const p = pairSummaries(cross, evaluate(idealRun('high'))) + assert.ok(p.problems.includes('cross_level_run_not_pairable')) + assert.notEqual(p.status, 'compared') + const missing = idealRun('medium') + delete find(missing.observe.records, 'run').observed.codexLabel + assert.ok(evaluate(missing).problems.includes('codex_label_missing')) +}) + +test('W6 pairing compares levels side by side without carrying results', () => { + const m = evaluate(idealRun('medium'), { expectedLabel: 'medium' }) + const h = evaluate(idealRun('high'), { expectedLabel: 'high' }) + const p = pairSummaries(m, h) + assert.equal(p.status, 'compared') + assert.deepEqual(p.differences, []) + assert.deepEqual(p.provenanceFailures, []) + assert.ok(p.rows.every((r) => r.id !== 'OB-coordination-range')) + assert.equal(p.safeToUnlock, false) +}) + +test('identical mismatches at both levels are a fail, not compared', () => { + const runM = idealRun('medium') + const runH = idealRun('high') + for (const run of [runM, runH]) find(run.observe.records, 'process.identity').observed.image = 'C:\\Windows\\other.exe' + const p = pairSummaries(evaluate(runM), evaluate(runH)) + assert.ok(p.failedRows.includes('OB-process')) + assert.equal(p.status, 'fail') +}) + +test('an explanation cannot erase a failed hypothesis', () => { + const runM = idealRun('medium') + const runH = idealRun('high') + find(runM.observe.records, 'process.identity').observed.image = 'C:\\a.exe' + find(runH.observe.records, 'process.identity').observed.image = 'C:\\b.exe' + const p = pairSummaries(evaluate(runM), evaluate(runH), { 'OB-process': 'different images, explained' }) + assert.ok(p.failedRows.includes('OB-process')) + assert.equal(p.status, 'fail') +}) + +test('a W6 difference needs an explanation and an unknown level stays unknown', () => { + const runM = idealRun('medium') + for (const r of runM.observe.records.filter((x) => x.step === 'openers.rm')) { r.observed.processes = []; r.observed.count = 0 } + const m = evaluate(runM) + const h = evaluate(idealRun('high')) + const p1 = pairSummaries(m, h) + assert.deepEqual(p1.differences, ['OB-openers']) + assert.deepEqual(p1.unexplained, ['OB-openers']) + assert.equal(p1.status, 'unknown') + assert.equal(pairSummaries(m, h, { 'OB-openers': 'RM hides the High process from a Medium caller' }).status, 'compared') +}) + +test('W6 pairing refuses different harness, commit, binary, version, missing baseline or swapped labels', () => { + const m = evaluate(idealRun('medium')) + const h = evaluate(idealRun('high')) + assert.ok(pairSummaries({ ...m, harness: { ...HARNESS, 'observer.ps1': '0'.repeat(64) } }, h).problems.includes('harness_differs')) + assert.ok(pairSummaries({ ...m, harness: { 'observer.ps1': '0'.repeat(64) } }, h).problems.includes('harness_name_set')) + assert.ok(pairSummaries({ ...m, sourceCommit: '1'.repeat(40) }, h).problems.includes('source_commit_differs')) + assert.ok(pairSummaries({ ...m, binarySha256: '2'.repeat(64) }, h).problems.includes('binary_differs')) + assert.ok(pairSummaries({ ...m, version: 'codex-cli 9.9.9' }, h).problems.includes('version_differs')) + assert.ok(pairSummaries({ ...m, baselineProvided: false }, h).problems.includes('baseline_missing')) + const swapped = pairSummaries(h, m) + assert.ok(swapped.problems.includes('medium_label') && swapped.problems.includes('high_label')) + assert.notEqual(swapped.status, 'compared') +}) + +// ---- fixture producer (unbound) -------------------------------------------- +function labRun(label, overrides = {}, extra = []) { + const whoami = label === 'high' ? 'Mandatory Label\\High Mandatory Level Label S-1-16-12288' : 'Mandatory Label\\Medium Mandatory Level Label S-1-16-8192' + const records = [ + { scenario: 'environment', step: 'host', expect: null, observed: { ok: true, integrity: whoami }, verdict: 'recorded', safeToUnlock: false }, + { scenario: 'E1', step: 'free', expect: { state: 'free' }, observed: { ok: true, state: 'free' }, verdict: 'match', safeToUnlock: false }, + { scenario: 'E3', step: 'holder', expect: null, observed: { ok: true, complete: true, processes: [{ pid: 9, role: 'writer' }], count: 1 }, verdict: 'recorded', safeToUnlock: false }, + { scenario: 'identity', step: 'live', expect: { state: 'live_same_user' }, observed: { ok: true, state: 'live_same_user' }, verdict: 'match', safeToUnlock: false }, + ...extra, + ] + for (const [id2, patch] of Object.entries(overrides)) Object.assign(records.find((r) => `${r.scenario}/${r.step}` === id2), patch) + const meta = [ + rec('fixture-meta', 'token.self', label, { ok: true, integritySid: INTEGRITY[label], integrityMatchesLabel: true }), + rec('fixture-meta', 'fixture.harness', label, { ok: true, complete: true, files: { 'lab.ps1': '3'.repeat(64) }, sourceCommit: COMMIT }), + ] + return { lab: { records, problems: [] }, meta: { records: meta, problems: [] } } +} + +test('fixture evidence is always unbound and never a successful gate', () => { + const m = fixtureSummary({ ...labRun('medium'), expectedLabel: 'medium' }) + const h = fixtureSummary({ ...labRun('high'), expectedLabel: 'high' }) + assert.equal(m.bound, false) + assert.equal(m.provenance.state, 'unvalidated') + assert.ok(m.problems.includes('fixture_producer_unbound')) + assert.ok(m.rows.every((r) => r.verdict === 'unknown')) + const p = pairSummaries(m, h) + assert.ok(p.problems.includes('fixture_evidence_not_pairable')) + assert.notEqual(p.status, 'compared') +}) + +test('fixture counterexample 1: host-only logs do not compare', () => { + const onlyHost = (label) => { + const r = labRun(label) + r.lab.records = r.lab.records.filter((x) => `${x.scenario}/${x.step}` === 'environment/host') + return r + } + const m = fixtureSummary(onlyHost('medium')) + const h = fixtureSummary(onlyHost('high')) + assert.ok(m.problems.some((p) => p.startsWith('lab:missing_anchors'))) + assert.notEqual(pairSummaries(m, h).status, 'compared') +}) + +test('fixture counterexample 2: a null observation with verdict match is not success', () => { + const m = fixtureSummary(labRun('medium', {}, [{ scenario: 'E1', step: 'null', expect: { state: 'free' }, observed: null, verdict: 'match', safeToUnlock: false }])) + const nullRow = m.rows.find((r) => r.id === 'E1/null') + assert.equal(nullRow.verdict, 'unknown') + assert.ok(nullRow.reasons.includes('no_observation')) + assert.notEqual(pairSummaries(m, fixtureSummary(labRun('high'))).status, 'compared') +}) + +test('fixture access failures and integrity disagreement are unknown', () => { + const denied = fixtureSummary(labRun('medium', { 'identity/live': { observed: { ok: true, state: 'unknown', reason: 'open_process', win32: 5 }, verdict: 'mismatch' } })) + assert.ok(denied.rows.find((r) => r.id === 'identity/live').reasons.includes('access_denied')) + const run = labRun('medium') + run.lab.records[0].observed.integrity = 'Mandatory Label\\High Mandatory Level Label S-1-16-12288' + const wrong = fixtureSummary({ ...run, expectedLabel: 'medium' }) + assert.ok(wrong.problems.includes('lab_meta_integrity_differs')) + assert.equal(wrong.label, null) +}) + +// ---- documentation and harness consistency --------------------------------- +const PS = readFileSync(join(root, 'observer.ps1'), 'utf8') +const CHECK = readFileSync(join(root, 'check.ps1'), 'utf8') +const CS = readFileSync(join(root, 'WinObserver.cs'), 'utf8') + +// Code only: the prose in these files discusses the rules it must not break, +// so the source greps below run on the script with its comments removed. +// check.ps1 enforces the same rules on the real syntax tree. +function code(text) { + return text.replace(/<#[\s\S]*?#>/g, '').replace(/#.*$/gm, '') +} +const PS_CODE = code(PS) +const CHECK_CODE = code(CHECK) + +test('README embeds the generated expectation table verbatim', () => { + const readme = readFileSync(join(root, 'README.md'), 'utf8') + const start = readme.indexOf('\n') + const end = readme.indexOf('') + assert.ok(start >= 0 && end > start, 'README expectation markers') + assert.equal(readme.slice(start + '\n'.length, end), renderExpectationTable()) +}) + +test('observer.ps1 hypotheses cover every raw-comparison row', () => { + const block = (mode) => { + const start = PS.indexOf(`'${mode}'`, PS.indexOf('$Expect = @{')) + const end = PS.indexOf('\n }', start) + return PS.slice(start, end) + } + const literal = (v) => (v === true ? '$true' : v === false ? '$false' : typeof v === 'string' ? `'${v}'` : String(v)) + for (const e of EXPECTATIONS) { + if (!e.hypothesis || e.derive || e.measurable === false) continue + const line = block(e.mode).split('\n').find((l) => l.trimStart().startsWith(`'${e.step}'`)) + assert.ok(line, `observer.ps1 lacks ${e.mode} ${e.step}`) + for (const [k, v] of Object.entries(e.hypothesis)) { + const key = /^[A-Za-z][A-Za-z0-9]*$/.test(k) ? k : `'${k}'` + assert.ok(line.includes(`${key} = ${literal(v)}`), `${e.id}: ${k}`) + } + } +}) + +test('every expectation step has a typed schema (or is not measurable)', () => { + for (const e of EXPECTATIONS) { + if (e.measurable === false) continue + assert.ok(STEP_SCHEMAS[`${e.mode}:${e.step}`], `${e.id} missing schema`) + } +}) + +test('harness sources contain no forbidden mutation or termination calls', () => { + const forbidden = ['TerminateProcess', 'Stop-Process', 'taskkill', 'DeleteFile', 'Remove-Item', 'Set-ExecutionPolicy', + /\s-ExecutionPolicy\b/, 'EncodedCommand', 'Set-MpPreference', 'Set-Acl', 'SetAccessControl', 'AdjustTokenPrivileges', + 'GenerateConsoleCtrlEvent', 'GENERIC_WRITE', 'CREATE_ALWAYS', 'OPEN_ALWAYS', 'MoveFile', 'SetFileInformationByHandle', + 'Invoke-WebRequest', 'Invoke-Expression', 'Start-Process', 'SetEnvironmentVariable', '.codex\\auth', 'auth.json'] + // check.ps1 names these commands on purpose: it refuses them in the syntax + // tree, so it is checked separately below. + for (const [name, text] of [['observer.ps1', PS], ['WinObserver.cs', CS]]) { + for (const f of forbidden) assert.ok(typeof f === 'string' ? !text.includes(f) : !f.test(text), `${name} contains ${f}`) + } + for (const needle of ['$Forbidden = @(', 'forbidden command in', 'automatic variable write in']) { + assert.ok(CHECK.includes(needle), `check.ps1 lost its syntax-tree gate: ${needle}`) + } +}) + +test('the PowerShell harness name list is exactly the expected file-name set', () => { + const match = PS.match(/\$HarnessNames\s*=\s*@\(([^)]*)\)/) + assert.ok(match, 'observer.ps1 $HarnessNames not found') + const names = match[1].split(',').map((s) => s.trim().replace(/^'|'$/g, '')).filter((s) => s.length > 0) + assert.deepEqual([...names].sort(), [...HARNESS_FILES].sort()) +}) + +test('no script assigns to the automatic $home variable', () => { + // $HOME is a read-only automatic variable and PowerShell variable names are + // case-insensitive, so `$home = ...` would fail at runtime. check.ps1 gates + // this on the syntax tree; this test is the host-independent mirror. + for (const [name, text] of [['observer.ps1', PS_CODE], ['check.ps1', CHECK_CODE]]) { + // Only a line-leading assignment is a text smoke check. Mutation fixtures + // deliberately contain forbidden assignments as string data; the actual + // Windows AST gate checks scoped, array and unary write targets as well. + assert.ok(!/^\s*\$home\s*=[^=]/im.test(text), `${name} assigns to $home`) + assert.ok(!/^\s*\$(host|pid|error|matches|args|input|psitem|profile|pshome|executioncontext|true|false|null)\s*=[^=]/im.test(text), + `${name} assigns to an automatic variable`) + } +}) + +test('Add-Type runs only after the path and TEMP gate in both scripts', () => { + for (const [name, text] of [['observer.ps1', PS_CODE], ['check.ps1', CHECK_CODE]]) { + const gate = text.indexOf('$PathGate = Assert-PathGate') + // The real compile site, not the name as data in check.ps1's own rules. + const addType = text.search(/^\s*Add-Type -Path/m) + assert.ok(gate > 0, `${name} has no Assert-PathGate call`) + assert.ok(addType > gate, `${name} compiles before the path gate`) + } +}) + +test('native preflight and observe stay hard-refused while the ancestry boundary is open', () => { + assert.ok(/NATIVE_MODES_REFUSED\s*=\s*\$true/.test(PS), 'observer.ps1 lost its hard refusal switch') + assert.ok(PS.includes('Refuse $NativeRefusalReason'), 'observer.ps1 does not refuse the native modes') + // No bypass: nothing may turn the refusal off from the command line. + assert.ok(!/param\([^)]*Force/is.test(PS), 'observer.ps1 gained a force-style parameter') + assert.ok(!/\$env:[A-Z_]*(FORCE|BYPASS|ALLOW)/i.test(PS), 'observer.ps1 reads a bypass environment variable') +}) + +test('the creation gate refuses a TEMP inside a real home and skips creation', () => { + assert.equal(tempRootSafeForCreation(TEMP, USER_HOME, null), true) + assert.equal(tempRootSafeForCreation(`${USER_HOME}\\AppData\\Temp`, USER_HOME, null), false) + assert.equal(tempRootSafeForCreation(TEMP, USER_HOME, `${TEMP}\\x`), false) + assert.equal(tempRootSafeForCreation('relative', USER_HOME, null), false) + assert.equal(tempRootSafeForCreation('\\\\?\\C:\\Temp', USER_HOME, null), false) + let created = false + const refused = guardedCreate(() => tempRootSafeForCreation(`${USER_HOME}\\Temp`, USER_HOME, null), () => { created = true; return { created: true } }) + assert.equal(created, false) + assert.equal(refused.created, false) + let created2 = false + const ok = guardedCreate(() => tempRootSafeForCreation(TEMP, USER_HOME, null), () => { created2 = true; return { created: true } }) + assert.equal(created2, true) + assert.equal(ok.created, true) +}) + +test('the per-sample seam does not run the active operations when its gate refuses', () => { + // Mirror of Invoke-GuardedSample: a between-sample exit, PID reuse, failed + // query or unreadable token must stop that sample before any enumeration, + // Restart Manager, share or range operation. + for (const reason of ['process_exited', 'create_filetime_changed', 'process_query_failed', 'token_unreadable', 'user_sid_differs']) { + let ran = false + const result = guardedSample(() => reason, () => { ran = true; return { probed: true } }) + assert.equal(ran, false, reason) + assert.deepEqual(result, { ran: false, reason }) + } + let ran2 = false + const passed = guardedSample(() => null, () => { ran2 = true; return { probed: true } }) + assert.equal(ran2, true) + assert.deepEqual(passed, { ran: true, result: { probed: true } }) +}) + +test('check.ps1 exercises the real PowerShell pure helpers it gates', () => { + for (const name of ['Get-PathRelation', 'Test-LocalPathSpelling', 'Test-InsideDisposableDir', 'Test-CandidateTempSafe', + 'Invoke-GuardedCreate', 'Invoke-GuardedSample', 'Read-BoundedStream', 'Get-OpenerRole', 'Test-Expect']) { + assert.ok(CHECK.includes(`'${name}'`) || CHECK.includes(name), `check.ps1 does not exercise ${name}`) + assert.ok(PS.includes(`function ${name}`), `observer.ps1 does not define ${name}`) + } +}) + +test('the Restart Manager FILETIME stays a decimal string in the native layer', () => { + assert.ok(CS.includes('\\"startFiletime\\":'), 'RmList no longer emits startFiletime') + assert.ok(/startFiletime\\":\\"/.test(CS) || CS.includes('J(start.ToString())'), 'startFiletime must be a quoted decimal string') +}) + +test('source regressions found in review stay fixed', () => { + // The home must get its own protected DACL: an inherited DACL is never + // marked protected, so the isolation contract could never hold. + assert.ok(PS_CODE.includes('[IO.Directory]::CreateDirectory($homeDir, $acl)'), 'home created without its own DACL') + // The DELETE share probe needs the attribute-read right for its file-type check. + assert.ok(CS.includes('want = DELETE | FILE_READ_ATTRIBUTES'), 'DELETE probe cannot classify its handle') + // A failed guard release is never overwritten by a later revalidation reason. + assert.ok(/if \(!guardReleased\)\s*\{/.test(CS), 'guard_unlock_failed can be overwritten') + // The automatic-variable gate must see through scope prefixes. + assert.ok(CHECK_CODE.includes('VariablePath.UserPath') && !CHECK_CODE.includes('VariablePath.UnqualifiedPath'), + 'check.ps1 must use the public Windows PowerShell variable-path property') + assert.ok(CHECK_CODE.includes('Get-DeclaredVariableName') && CHECK_CODE.includes('global|local|script|private'), + 'the public UserPath must be checked through the scope-aware helper') + // Each pure-helper group reports on its own. + assert.ok((CHECK_CODE.match(/Invoke-CheckGroup '/g) ?? []).length >= 9, 'check.ps1 lost its per-group assertions') +}) + +test('deepEqualMap compares digest maps exactly and case-sensitively', () => { + assert.equal(deepEqualMap({ a: 'x', b: 'y' }, { b: 'y', a: 'x' }), true) + assert.equal(deepEqualMap({ a: 'x' }, { a: 'X' }), false) + assert.equal(deepEqualMap({ a: 'x' }, { a: 'x', b: 'y' }), false) + assert.equal(deepEqualMap({}, {}), false) + assert.equal(deepEqualMap(null, { a: 'x' }), false) +}) + +test('ancestry must enumerate every root-to-parent component in order', () => { + const base = homeFields() + const chain = base.ancestry + for (const bad of [chain.slice(-1), chain.slice(1), chain.filter((_, i) => i !== 2), + [...chain].reverse(), [chain[0], chain[0], ...chain.slice(1)], + [{ ...chain[0], path: 'D:\\' }, ...chain.slice(1)]]) { + const run = idealRun('medium') + find(run.preflight.records, 'home.isolated').observed.ancestry = bad + const summary = evaluate(run) + assert.notEqual(row(summary, 'PF-home').verdict, 'match') + assert.notEqual(summary.provenance.state, 'validated') + assert.notEqual(pairSummaries(summary, evaluate(idealRun('high'))).status, 'compared') + } +}) + +test('self-token identity binds the run observer and both per-mode home owners', () => { + const foreign = 'S-1-5-21-111-222-333-2002' + const user = idealRun('medium') + find(user.observe.records, 'run').observed.observerUserSid = foreign + for (const record of user.observe.records.filter((r) => r.step.startsWith('process.'))) record.observed.userSid = foreign + const summary = evaluate(user) + assert.ok(row(summary, 'OB-run').reasons.includes('observer_user_differs_from_self_token')) + assert.notEqual(summary.provenance.state, 'validated') + + const pid = idealRun('medium') + find(pid.observe.records, 'run').observed.observerPid = 987 + assert.ok(row(evaluate(pid), 'OB-run').reasons.includes('observer_pid_differs_from_self_token')) + + for (const [mode, step, id2] of [['preflight', 'home.isolated', 'PF-home'], ['observe', 'home.identity', 'OB-home']]) { + const run = idealRun('medium') + const o = find(run[mode].records, step).observed + for (const key of ['currentUserSid', 'owner', 'parentOwner', 'aceIdentity', 'parentAceIdentity']) o[key] = foreign + const s = evaluate(run) + assert.ok(row(s, id2).reasons.includes('home_user_differs_from_self_token')) + assert.notEqual(s.provenance.state, 'validated') + } +}) + +test('RM evidence must name exactly this sample thread and coordination files', () => { + const files = find(idealRun('medium').observe.records, 'openers.rm', 1).observed.files + for (const bad of [[], [null], [files[0]], [files[0], files[0]], ['C:\\other.lock', files[1]]]) { + const run = idealRun('medium') + find(run.observe.records, 'openers.rm', 1).observed.files = bad + const s = evaluate(run) + assert.notEqual(row(s, 'OB-openers').verdict, 'match') + assert.notEqual(pairSummaries(s, evaluate(idealRun('high'))).status, 'compared') + } + const reordered = idealRun('medium') + find(reordered.observe.records, 'openers.rm', 1).observed.files.reverse() + assert.equal(row(evaluate(reordered), 'OB-openers').verdict, 'match') +}) + +test('sample identity paths must bind to the manifest home and selected thread', () => { + for (const step of ['lockdir.identity', 'coordination.identity', 'thread.identity', 'thread.identity_after']) { + for (const bad of ['C:\\elsewhere\\t.lock', '\\\\?\\UNC\\host\\share\\t.lock']) { + const run = idealRun('medium') + find(run.observe.records, step, 1).observed.finalPath = bad + const s = evaluate(run) + assert.notEqual(row(s, 'OB-openers').verdict, 'match', step) + assert.notEqual(row(s, 'OB-range-guard').verdict, 'match', step) + } + } + const run = idealRun('medium') + find(run.observe.records, 'thread.count', 1).observed.entries = ['.coordination.lock', '..\\t.lock'] + assert.notEqual(row(evaluate(run), 'OB-openers').verdict, 'match') +}) + +test('share probe evidence fixes its exact requested access and sharing mode', () => { + for (const [step, id2, access] of [['share.read_rwd', 'OB-share-read-rwd', 'delete'], ['share.delete_rwd', 'OB-share-delete-rwd', 'read']]) { + for (const patch of [{ access }, { share: 'r' }, { share: 'rw' }]) { + const run = idealRun('medium') + Object.assign(find(run.observe.records, step, 1).observed, patch) + assert.equal(row(evaluate(run), id2).verdict, 'unknown') + } + } +}) + +test('missing whole steps, late process re-query and interleaved samples are unknown', () => { + const missing = idealRun('medium') + missing.observe.records = missing.observe.records.filter((r) => r.step !== 'range.probe') + assert.ok(evaluate(missing).problems.includes('observe:missing_sample_step:range.probe')) + + const late = idealRun('medium') + const gate = find(late.observe.records, 'process.sample', 1) + late.observe.records = late.observe.records.filter((r) => r !== gate) + late.observe.records.splice(late.observe.records.indexOf(find(late.observe.records, 'range.probe', 1)) + 1, 0, gate) + const s = evaluate(late) + assert.ok(s.problems.includes('observe:sample_sequence_invalid')) + assert.equal(s.w7, 'unknown') + assert.notEqual(pairSummaries(s, evaluate(idealRun('high'))).status, 'compared') + + const interleaved = idealRun('medium') + const next = find(interleaved.observe.records, 'process.sample', 2) + interleaved.observe.records = interleaved.observe.records.filter((r) => r !== next) + interleaved.observe.records.splice(interleaved.observe.records.indexOf(find(interleaved.observe.records, 'range.probe', 1)), 0, next) + assert.ok(evaluate(interleaved).problems.includes('observe:sample_sequence_invalid')) +}) From b28328a9f6dfe3ab6fc1a1d1047f18a3ed1bfae3 Mon Sep 17 00:00:00 2001 From: YEONGUK LEE Date: Sun, 18 Oct 2026 16:37:42 +0900 Subject: [PATCH 2/3] fix(experimental): close Windows observer review gaps --- .github/workflows/windows-native-observer.yml | 16 ++++ .../windows-native-observer/README.md | 60 ++++++++++---- .../windows-native-observer/WinObserver.cs | 5 +- .../windows-native-observer/check.ps1 | 82 ++++++++++++++++++- .../windows-native-observer/evaluate.mjs | 7 +- .../windows-native-observer/expectations.mjs | 6 +- experimental/windows-native-observer/lib.mjs | 4 +- .../windows-native-observer/observer.ps1 | 9 +- .../test/cli-errors.test.mjs | 25 ++++++ .../test/evaluate.test.mjs | 38 ++++++++- 10 files changed, 224 insertions(+), 28 deletions(-) diff --git a/.github/workflows/windows-native-observer.yml b/.github/workflows/windows-native-observer.yml index 784f2ec..66ca731 100644 --- a/.github/workflows/windows-native-observer.yml +++ b/.github/workflows/windows-native-observer.yml @@ -29,6 +29,22 @@ jobs: run: | $ErrorActionPreference = 'Stop' if ($PSVersionTable.PSVersion.Major -ne 5) { throw 'This gate requires Windows PowerShell 5.1' } + # Do not inherit a short-name TEMP (e.g. RUNNER~1). The checker now + # refuses tilde spellings instead of guessing their physical target. + if ([string]::IsNullOrWhiteSpace($env:RUNNER_TEMP)) { throw 'RUNNER_TEMP is required' } + $compilerRoot = [IO.Path]::GetFullPath($env:RUNNER_TEMP) + if ($compilerRoot -notmatch '^[A-Za-z]:\\' -or $compilerRoot.Contains('~')) { throw 'RUNNER_TEMP must use a long absolute drive spelling' } + $compilerTemp = Join-Path $compilerRoot ('codex-observer-static-' + [Guid]::NewGuid().ToString('N')) + if (Test-Path -LiteralPath $compilerTemp) { throw 'Compiler scratch already exists' } + $sid = [Security.Principal.WindowsIdentity]::GetCurrent().User + $acl = New-Object Security.AccessControl.DirectorySecurity + $acl.SetOwner($sid) + $acl.SetAccessRuleProtection($true, $false) + $acl.AddAccessRule((New-Object Security.AccessControl.FileSystemAccessRule($sid, 'FullControl', 'ContainerInherit,ObjectInherit', 'None', 'Allow'))) + [IO.Directory]::CreateDirectory($compilerTemp, $acl) | Out-Null + # Process-local compiler environment only; no host policy/settings change. + $env:TEMP = $compilerTemp + $env:TMP = $compilerTemp $dir = Join-Path $env:GITHUB_WORKSPACE 'experimental/windows-native-observer' & powershell -NoProfile -NonInteractive -File (Join-Path $dir 'check.ps1') if ($LASTEXITCODE -ne 0) { throw "check.ps1 exited $LASTEXITCODE" } diff --git a/experimental/windows-native-observer/README.md b/experimental/windows-native-observer/README.md index cf44b31..3e97446 100644 --- a/experimental/windows-native-observer/README.md +++ b/experimental/windows-native-observer/README.md @@ -64,7 +64,10 @@ system temporary root. - **The native modes are refused.** `preflight` and `observe` exit 3 before anything is compiled, created or observed. `check.ps1` fails if that refusal - is removed, moved after `Add-Type`, or made conditional on a flag. + is removed, moved after `Add-Type`, or made conditional on a flag. It also + constrains the actual `Refuse` and `Stop-Unknown` definitions and their + literal exit 3/2 effects, with AST-only negative mutations, never by running + a potentially weakened observer. - **No user Codex home.** The user's `%USERPROFILE%\.codex` and any pre-set `CODEX_HOME` are compared with every path this harness touches **lexically only**. They are never opened, listed or statted. The disposable @@ -83,13 +86,23 @@ system temporary root. spelling** and disjoint from the actual, default and environment Codex homes. UNC (`\\server\share`), device (`\\?\`, `\\.\`), rooted-without-drive, drive-relative, `.`/`..`, double-separator, trailing-space and trailing-dot - spellings are **refused, not normalized**. `-OutFile` is additionally refused + spellings are **refused, not normalized**. Every component containing `~` + is also refused, including ordinary literal tilde names. This deliberately + excludes 8.3 aliases that `GetFullPath` does not expand and that lexical + comparisons cannot equate with a long name. If the host's TEMP uses a short + spelling, use an independently known safe long-path compiler scratch; do not + weaken the gate. CI creates such a private scratch under `RUNNER_TEMP` and + overrides TEMP/TMP for that step only. `-OutFile` is additionally refused inside any `codex-win-obs-<12 hex>` directory on any volume, matched literally - per path component and independently of the current `%TEMP%`. + and case-insensitively per path component, independently of the current + `%TEMP%`. These checks are not physical canonicalization or a proof against + junction/ancestor replacement; native modes remain refused. - **The compiler is a side effect, and it is honest.** `Add-Type` starts the .NET compiler as a child process and writes compiler temporaries into `%TEMP%`. Both `observer.ps1` and `check.ps1` run the path and `%TEMP%` gate - **before** `Add-Type`, so the compiler never writes inside a real Codex home. + **before** `Add-Type`, rejecting the declared real-home spellings. This is + not a guarantee against all physical aliases; use a known safe staging + directory for the static gate and preserve the ancestry limitations below. A static gate means no observation and no native method call, not no compiler process and no compiler files. The compiler inherits this shell's environment, which is why no credential may be exported into the shell that @@ -254,8 +267,8 @@ before the run; the harness compares it exactly. > **Steps 1 and 4 currently refuse.** `-Mode preflight` and `-Mode observe` > exit 3 with the refusal reason while remaining decision 10 is open. The > commands are documented so the intended procedure can be reviewed, not so it -> can be run. Step 0 (static gate), step 6 (`fixture-meta`) and step 7 -> (evaluator) do run. +> can be run. Step 0 (static gate) and step 7 (offline evaluator) can run. +> Step 6 remains deferred even though the `fixture-meta` command exists. Run from `experimental\windows-native-observer` in the checkout. Choose an output directory outside `%TEMP%\codex-win-obs-*`, for example @@ -271,6 +284,7 @@ the High session), from the same commit. Expected: `parse ok: observer.ps1`, `parse ok: check.ps1`, `syntax-tree checks ok …`, `ordering ok: …`, `refusal ok: …`, + `AST-only mutation checks ok: …`, `harness file-name set ok`, `pure helpers loaded …`, one `pure helper checks ok: ` line for each of the nine assertion groups, `path gate ok: …`, `compile ok: WinObserver.cs (no method called)`, exit 0. @@ -398,6 +412,14 @@ Exit 1 means a measured mismatch and nothing else. Setup problems that cannot establish anything either way are exit 2 with an `unknown:` line, never an accidental uncaught exit 1. +The offline evaluator also maps unexpected internal exceptions to structured +`unknown`/2 with a fixed `evaluator_internal_error` reason, without exception +messages or stack traces. In the observer an incomplete/error row takes +priority over mismatch; in a usable offline file a measured mismatch takes +priority over an unknown row, while file-global errors take priority over both. +`pair` exit 0 means W6 comparison completion only, not W7 success: W7-only +failures remain visible in each side's `w7` result and do not become W6 rows. + ### Expected evaluator outcome With the current table and a correct baseline, a perfect run still yields @@ -513,7 +535,8 @@ record against `expectations.mjs` and reports two separate facts for every row: - **`measured`**: that record holds a complete, in-schema, internally consistent native observation. That means `observed` is present, `ok` is `true`, `complete` is not `false`, the state is not `unknown`, there is no - exception, no Win32 `5` (access denied) anywhere, the typed per-step schema + exception, no detected Win32 `5` (access denied) in the recursive `win32` or + `tokenWin32` fields, the typed per-step schema passes (required fields present and correctly formatted — SHA-256, commit, file ID, SID, absolute local drive path, integer, decimal FILETIME string), every schema **invariant** holds, and every hypothesis field is present and @@ -681,14 +704,21 @@ The existing `RemoteSigned` policy was unchanged. A new private staging directory outside every real Codex home held the checked inputs and compiler temporaries. Source hashes were checked before execution. -The final static run exited **0**: both parses, automatic-variable and -forbidden-call checks, exact hard-refusal AST contract and seven negative -mutations, nine pure-helper groups, and C# compilation passed. Tested inputs: - -- `check.ps1`: `ddec2d14899ae70ab18fd8f0081c4f03b3e996db0aa9e04d10d066ac68ba01d4` -- `observer.ps1`: `e39fbd2497f69c97b9769698fa525545316ab98d430e57e3a6f3104590a1f40e` -- `WinObserver.cs`: `de0356fedee35ca2cb239b2c0f237021ea60f27b59f61f960c62db52768e3d3e` -- Static output SHA-256: `3ea3e85a2b6f31ba4bb7a41b9273460832bd9d8667ad0ff12e1edf74ff111736` +The post-review static run exited **0**: both parses, automatic-variable and +forbidden-call checks, the exact hard-refusal AST contract, seven switch/guard +mutations plus twelve definition/termination mutations for each of `Refuse` +and `Stop-Unknown`, nine pure-helper groups (including short-name/tilde +refusal and case-insensitive disposable matching), and C# compilation passed. +Tested inputs: + +- `check.ps1`: `a5e0499990c9700e743429b794e69366469def8f465e40a15b3c4aedbafb17b2` +- `observer.ps1`: `15e5ea87bd4766de1fe3537d49fec201837abbfda1a378f4cced3f3fe99a06de` +- `WinObserver.cs`: `9b44dc7403368857fd7c55cdf4a596310b7b501c1343755f3adc08758f3eb83c` +- Static output SHA-256: `5e8951d7b56c31012aadc8980b8f9694f2cfbe859fd742fc6937ce9619702fc8` + +The earlier static baseline (output digest +`3ea3e85a2b6f31ba4bb7a41b9273460832bd9d8667ad0ff12e1edf74ff111736`) +is retained separately and is not counted as verification of these later edits. Earlier failures were retained rather than counted as passes: the original checker used an unavailable `VariablePath.UnqualifiedPath` property; after diff --git a/experimental/windows-native-observer/WinObserver.cs b/experimental/windows-native-observer/WinObserver.cs index b7bc9ed..f15734f 100644 --- a/experimental/windows-native-observer/WinObserver.cs +++ b/experimental/windows-native-observer/WinObserver.cs @@ -14,8 +14,9 @@ // observation of it. Every lock is released before the call returns (the guard // in a finally), and a failed release makes the result `unknown`. // -// Unverified: this file has never been compiled. The static CI gate -// (ci-proposal.patch) is the first place that would compile it. +// Compiled by the Windows static gate without invoking these native methods. +// The checked-in windows-native-observer workflow checks compilation only; +// native runtime behavior and real Codex interoperability remain unverified. using System; using System.ComponentModel; using System.IO; diff --git a/experimental/windows-native-observer/check.ps1 b/experimental/windows-native-observer/check.ps1 index 1c29996..04f63dc 100644 --- a/experimental/windows-native-observer/check.ps1 +++ b/experimental/windows-native-observer/check.ps1 @@ -108,6 +108,44 @@ function Get-AutomaticWriteProblems($tree) { } } +# The called helper must itself terminate. Its complete AST is constrained to +# the three current statements: emit the diagnostic, record the literal setup +# code, then exit with that same literal code. No conditional, early return, +# additional block or second/scoped definition may hide a nonterminating path. +function Test-TerminationDefinition($tree, [string]$functionName, [int]$exitCode, [string]$prefix, [int]$beforeOffset) { + $definitions = @($tree.FindAll({ + param($node) + $node -is [System.Management.Automation.Language.FunctionDefinitionAst] -and + (($node.Name -replace '^(global|local|script|private):', '') -ieq $functionName) + }, $true)) + if ($definitions.Count -ne 1) { return $false } + $definition = $definitions[0] + if ($definition.Name -cne $functionName -or $definition.IsFilter -or $definition.IsWorkflow) { return $false } + if (-not [object]::ReferenceEquals($definition.Parent, $tree.EndBlock) -or + $definition.Extent.EndOffset -ge $beforeOffset) { return $false } + if ($definition.Parameters.Count -ne 1 -or $definition.Parameters[0].Extent.Text -cne '[string]$why') { return $false } + $scriptBody = $definition.Body + if ($null -ne $scriptBody.ParamBlock -or $null -ne $scriptBody.DynamicParamBlock -or + $null -ne $scriptBody.BeginBlock -or $null -ne $scriptBody.ProcessBlock -or $null -eq $scriptBody.EndBlock) { return $false } + if (@($scriptBody.FindAll({ param($node) $node -is [System.Management.Automation.Language.TrapStatementAst] }, $true)).Count -ne 0) { return $false } + $statements = @($scriptBody.EndBlock.Statements) + if ($statements.Count -ne 3) { return $false } + $diagnostic = $statements[0] + $expectedDiagnostic = 'Write-Output "' + $prefix + ': $why"' + if ($diagnostic -isnot [System.Management.Automation.Language.PipelineAst] -or + $diagnostic.PipelineElements.Count -ne 1 -or + $diagnostic.PipelineElements[0] -isnot [System.Management.Automation.Language.CommandAst] -or + ($diagnostic.Extent.Text -replace '\s+', ' ').Trim() -cne $expectedDiagnostic) { return $false } + $setCode = $statements[1] + if ($setCode -isnot [System.Management.Automation.Language.AssignmentStatementAst] -or + $setCode.Operator.ToString() -ne 'Equals' -or $setCode.Left.Extent.Text -cne '$script:SetupExit' -or + $setCode.Right.Extent.Text.Trim() -cne [string]$exitCode) { return $false } + $exitStatement = $statements[2] + if ($exitStatement -isnot [System.Management.Automation.Language.ExitStatementAst] -or + $null -eq $exitStatement.Pipeline -or $exitStatement.Pipeline.Extent.Text.Trim() -cne [string]$exitCode) { return $false } + $true +} + # This is deliberately an exact contract, not a general control-flow proof. # The one true assignment must be top-level, and the exact refusal statement # must immediately precede the sole Add-Type command in the same top-level @@ -143,6 +181,8 @@ function Test-NativeRefusalAst($tree) { -not [object]::ReferenceEquals($setupTry.Body, $block) -or -not [object]::ReferenceEquals($setupTry.Parent, $tree.EndBlock)) { return $false } if ($assignment.Extent.EndOffset -ge $setupTry.Extent.StartOffset) { return $false } + if (-not (Test-TerminationDefinition $tree 'Refuse' 3 'refused' $setupTry.Extent.StartOffset) -or + -not (Test-TerminationDefinition $tree 'Stop-Unknown' 2 'unknown' $setupTry.Extent.StartOffset)) { return $false } $statements = @($block.Statements) $index = -1 for ($i = 0; $i -lt $statements.Count; $i++) { @@ -244,13 +284,44 @@ if ($null -ne $observerAst) { foreach ($variant in $variants.Keys) { if (Test-NativeRefusalAst (Parse-Only $variants[$variant])) { throw "native refusal mutation accepted: $variant" } } + # Mutate only source text and parse it. None of these altered helper + # definitions, including their exit statements, is ever executed. + foreach ($spec in @(@('Refuse', 'refused', 3), @('Stop-Unknown', 'unknown', 2))) { + $functionName = $spec[0] + $prefix = $spec[1] + $exitCode = [string]$spec[2] + $diagnostic = 'Write-Output "' + $prefix + ': $why"' + $setCode = '$script:SetupExit = ' + $exitCode + $exitText = 'exit ' + $exitCode + $header = 'function ' + $functionName + '([string]$why) { ' + $definitionText = $header + $diagnostic + '; ' + $setCode + '; ' + $exitText + ' }' + if (-not $source.Contains($definitionText)) { throw "termination definition mutation anchor missing: $functionName" } + $definitionVariants = @{ + removed_definition = '' + removed_exit = $header + $diagnostic + '; ' + $setCode + ' }' + changed_exit = $header + $diagnostic + '; ' + $setCode + '; exit 0 }' + changed_setup_code = $header + $diagnostic + '; $script:SetupExit = 0; ' + $exitText + ' }' + conditional_exit = $header + $diagnostic + '; ' + $setCode + '; if ($false) { ' + $exitText + ' } }' + early_return = $header + 'return; ' + $diagnostic + '; ' + $setCode + '; ' + $exitText + ' }' + added_trap = $header + 'trap { continue }; ' + $diagnostic + '; ' + $setCode + '; ' + $exitText + ' }' + return_instead_of_exit = $header + $diagnostic + '; ' + $setCode + '; return ' + $exitCode + ' }' + duplicate_definition = $definitionText + "`n" + $definitionText + conditional_definition = 'if ($false) { ' + $definitionText + ' }' + nested_definition = 'function UncalledFixture { ' + $definitionText + ' }' + scoped_duplicate = $definitionText + "`nfunction script:" + $functionName + '([string]$why) { return }' + } + foreach ($variant in $definitionVariants.Keys) { + $mutated = Replace-SourceText $source $definitionText $definitionVariants[$variant] + if (Test-NativeRefusalAst (Parse-Only $mutated)) { throw "termination mutation accepted: $functionName/$variant" } + } + } foreach ($text in '$HOME = 1', '$script:home = 1', '$local:PID, $ordinary = 1, 2', '++$global:host', '$private:HOME--') { if (@(Get-AutomaticWriteProblems (Parse-Only $text)).Count -eq 0) { throw "automatic variable mutation missed: $text" } } foreach ($text in '$ordinary = 1', '$ordinary++', '$object.home++', '$object[$HOME] = 1', '$env:HOME = ''example''') { if (@(Get-AutomaticWriteProblems (Parse-Only $text)).Count -ne 0) { throw "ordinary target falsely rejected: $text" } } - Report 'AST-only mutation checks ok: refusal variants and scoped/array/unary variable writes' + Report 'AST-only mutation checks ok: refusal variants, terminating helper definitions and scoped/array/unary variable writes' } catch { Fail ('AST-only mutation check error: ' + $_.Exception.Message) } # The harness digest list must be exactly the evaluator's file-name set. $harness = $observerAst.Find({ @@ -326,7 +397,8 @@ if ($helpersLoaded) { } foreach ($bad in '\\?\C:\Temp', '\\.\C:\Temp', '\\server\share\x', '\rooted\x', 'C:relative', 'relative\x', 'C:/Temp/x', 'C:\Temp\\x', 'C:\Temp\..\Users', 'C:\Temp\.', 'C:\Temp\x ', 'C:\Temp\x.', 'C:\Temp\ x', - 'C:\Temp\a:b', 'C:\Temp\a*b', 'C:\Temp\a?b', 'C:\Temp\a|b', '') { + 'C:\Temp\a:b', 'C:\Temp\a*b', 'C:\Temp\a?b', 'C:\Temp\a|b', + 'C:\Users\OP~1\.codex', 'C:\Temp\CODEX-~1\home', 'C:\Temp\ordinary~name', 'C:\Temp\~', '') { if (Test-LocalPathSpelling $bad) { throw "ambiguous spelling accepted: $bad" } } } @@ -336,7 +408,8 @@ if ($helpersLoaded) { if (-not (Test-InsideDisposableDir 'C:\Temp\codex-win-obs-0123456789ab\home')) { throw 'disposable directory not detected' } if (-not (Test-InsideDisposableDir 'D:\scratch\codex-win-obs-0123456789ab')) { throw 'disposable directory on another volume not detected' } if (Test-InsideDisposableDir 'C:\Temp\codex-win-obs-0123456789abc') { throw 'a longer suffix must not match' } - if (Test-InsideDisposableDir 'C:\Temp\codex-win-obs-0123456789AB') { throw 'the hex suffix is lowercase only' } + if (-not (Test-InsideDisposableDir 'C:\Temp\codex-win-obs-0123456789AB')) { throw 'an uppercase hex suffix must be detected' } + if (-not (Test-InsideDisposableDir 'C:\Temp\CODEX-WIN-OBS-0123456789AB\HOME')) { throw 'uppercase disposable components must be detected' } if (Test-InsideDisposableDir 'C:\Temp\codex-win-obs-[0-9a-f]') { throw 'the component match must be literal, not a wildcard' } } @@ -347,6 +420,9 @@ if ($helpersLoaded) { if ((Test-PathGateSafe 'C:\Temp\codex-win-obs-0123456789ab\o.jsonl' 'C:\Users\op\.codex' $null $false) -ne 'inside_disposable_dir') { throw 'an output inside a disposable directory must be refused' } if ($null -ne (Test-PathGateSafe 'C:\Temp\codex-win-obs-0123456789ab\home' 'C:\Users\op\.codex' $null $true)) { throw 'the disposable home itself must be allowed' } if ((Test-PathGateSafe '\\server\share\o.jsonl' 'C:\Users\op\.codex' $null $false) -ne 'path_spelling') { throw 'a UNC output must be refused' } + if ((Test-PathGateSafe 'C:\Users\OP~1\out.jsonl' 'C:\Users\operator\.codex' $null $false) -ne 'path_spelling') { throw 'an 8.3-shaped path must be refused' } + if ((Test-PathGateSafe 'C:\Temp\ordinary~name\out.jsonl' 'C:\Users\op\.codex' $null $false) -ne 'path_spelling') { throw 'a general tilde component must be conservatively refused' } + if ((Test-PathGateSafe 'C:\Temp\CODEX-WIN-OBS-0123456789AB\out.jsonl' 'C:\Users\op\.codex' $null $false) -ne 'inside_disposable_dir') { throw 'an uppercase disposable output must be refused' } } Invoke-CheckGroup 'creation seam' { diff --git a/experimental/windows-native-observer/evaluate.mjs b/experimental/windows-native-observer/evaluate.mjs index 4f2dfa3..59a1b67 100644 --- a/experimental/windows-native-observer/evaluate.mjs +++ b/experimental/windows-native-observer/evaluate.mjs @@ -133,9 +133,10 @@ function main() { try { main() } catch (error) { - if (!(error instanceof EvidenceInputError)) throw error // Do not expose paths, parser excerpts or host exception messages in a - // shareable diagnostic. A failed input is unknown, not a measured mismatch. - console.log(JSON.stringify({ status: 'unknown', problems: [error.reason], safeToUnlock: false })) + // shareable diagnostic. Input and unexpected evaluator failures are unknown, + // not measured mismatches. Never inspect an unexpected exception's message. + const reason = error instanceof EvidenceInputError ? error.reason : 'evaluator_internal_error' + console.log(JSON.stringify({ status: 'unknown', problems: [reason], safeToUnlock: false })) process.exitCode = 2 } diff --git a/experimental/windows-native-observer/expectations.mjs b/experimental/windows-native-observer/expectations.mjs index 6d8110b..7411b58 100644 --- a/experimental/windows-native-observer/expectations.mjs +++ b/experimental/windows-native-observer/expectations.mjs @@ -56,7 +56,10 @@ const CONTROL_CHARS = /[\u0000-\u001f\u007f]/ // (`\\?\`, `\\.\`), relative, rooted-without-drive (`\x`) and drive-relative // (`C:x`) spellings are rejected rather than normalized, as are `.`/`..` // components, empty components, components ending in a space or dot, and -// characters Windows does not allow in a name. An ambiguous spelling is +// characters Windows does not allow in a name. Every tilde component is also +// refused (including literal tilde names), conservatively excluding 8.3 aliases +// that lexical comparisons cannot equate with long paths. This does not prove +// physical disjointness or close ancestor/creation races. An ambiguous spelling is // refused; it is never rewritten into something that looks safe. export function normalizeLocalPath(p) { if (typeof p !== 'string' || p.length === 0 || p.length > 32767) return null @@ -71,6 +74,7 @@ export function normalizeLocalPath(p) { if (parts.length === 1 && parts[0] === '') return drive.toLowerCase() + '\\' for (const part of parts) { if (part.length === 0 || part === '.' || part === '..') return null + if (part.includes('~')) return null if (part.includes(':')) return null if (part.endsWith(' ') || part.endsWith('.')) return null if (part.startsWith(' ')) return null diff --git a/experimental/windows-native-observer/lib.mjs b/experimental/windows-native-observer/lib.mjs index e97785c..352964d 100644 --- a/experimental/windows-native-observer/lib.mjs +++ b/experimental/windows-native-observer/lib.mjs @@ -1075,7 +1075,9 @@ export function pairSummaries(medium, high, explanations = {}) { } function cell(v) { - return String(v).replace(/\|/g, '\\|') + // Escape existing backslashes first; otherwise an input backslash can + // cancel the escape added for a Markdown table delimiter. + return String(v).replaceAll('\\', '\\\\').replaceAll('|', '\\|') } export function renderExpectationTable(expectations = EXPECTATIONS) { diff --git a/experimental/windows-native-observer/observer.ps1 b/experimental/windows-native-observer/observer.ps1 index fdc7da6..18a6e95 100644 --- a/experimental/windows-native-observer/observer.ps1 +++ b/experimental/windows-native-observer/observer.ps1 @@ -156,7 +156,11 @@ function Get-PathRelation([string]$a, [string]$b) { # device (\\?\, \\.\), rooted-without-drive (\x), drive-relative (C:x) and # relative spellings are REFUSED rather than normalized, as are `.`/`..` # components, empty components, components ending in a space or a dot, and -# characters Windows does not allow in a name. This mirrors +# characters Windows does not allow in a name. Tilde components are also +# refused conservatively: GetFullPath does not expand 8.3 aliases, so textual +# disjointness cannot safely compare a short spelling with a long one. This +# rejects literal tilde names too; it is not physical-path canonicalization. +# This mirrors # normalizeLocalPath in expectations.mjs. An ambiguous spelling is refused; it # is never rewritten into something that looks safe. function Test-LocalPathSpelling([string]$p) { @@ -174,6 +178,7 @@ function Test-LocalPathSpelling([string]$p) { if ($rest.Count -eq 1 -and $rest[0] -eq '') { return $true } foreach ($part in $rest) { if ($part -eq '' -or $part -eq '.' -or $part -eq '..') { return $false } + if ($part.Contains('~')) { return $false } if ($part.Contains(':')) { return $false } if ($part.EndsWith(' ') -or $part.EndsWith('.') -or $part.StartsWith(' ')) { return $false } } @@ -187,7 +192,7 @@ function Test-LocalPathSpelling([string]$p) { function Test-InsideDisposableDir([string]$p) { if (-not (Test-LocalPathSpelling $p)) { return $false } foreach ($part in $p.Split('\')) { - if ($part -cmatch '^codex-win-obs-[0-9a-f]{12}$') { return $true } + if ($part -imatch '^codex-win-obs-[0-9a-f]{12}$') { return $true } } $false } diff --git a/experimental/windows-native-observer/test/cli-errors.test.mjs b/experimental/windows-native-observer/test/cli-errors.test.mjs index 189d935..b699036 100644 --- a/experimental/windows-native-observer/test/cli-errors.test.mjs +++ b/experimental/windows-native-observer/test/cli-errors.test.mjs @@ -48,3 +48,28 @@ test('usage and table retain their documented exit codes', () => { assert.equal(invoke([]).status, 64) assert.equal(invoke(['table']).status, 0) }) + +test('unexpected evaluator exceptions are structured unknown without paths or stack traces', () => { + const dir = mkdtempSync(join(root, 'codex-observer-cli-')) + try { + const preflight = join(dir, `${secret}-preflight.jsonl`) + const observe = join(dir, 'observe.jsonl') + // Valid JSON passes parseJsonl. String interpolation of this step in the + // record validator throws TypeError rather than EvidenceInputError. + const bytes = JSON.stringify({ step: { toString: 0 }, observed: { private: secret } }) + '\n' + writeFileSync(preflight, bytes, { mode: 0o600 }) + writeFileSync(observe, '{}\n', { mode: 0o600 }) + const result = invoke(['run', '--label', 'medium', '--preflight', preflight, '--observe', observe]) + assert.equal(result.status, 2, result.stderr) + assert.equal(result.signal, null) + assert.equal(result.stderr, '') + assert.deepEqual(JSON.parse(result.stdout), { + status: 'unknown', problems: ['evaluator_internal_error'], safeToUnlock: false, + }) + for (const value of [secret, dir, cli, 'TypeError', ' at ']) { + assert.ok(!result.stdout.includes(value), `diagnostic exposed ${value}`) + } + assert.equal(readFileSync(preflight, 'utf8'), bytes) + assert.equal(readFileSync(observe, 'utf8'), '{}\n') + } finally { rmSync(dir, { recursive: true }) } +}) diff --git a/experimental/windows-native-observer/test/evaluate.test.mjs b/experimental/windows-native-observer/test/evaluate.test.mjs index 1f953a3..addefeb 100644 --- a/experimental/windows-native-observer/test/evaluate.test.mjs +++ b/experimental/windows-native-observer/test/evaluate.test.mjs @@ -205,7 +205,8 @@ test('only absolute local drive spellings are accepted; ambiguous ones are rejec assert.equal(normalizeLocalPath('C:\\'), 'c:\\') for (const bad of ['\\\\?\\C:\\Temp', '\\\\.\\C:\\Temp', '\\\\server\\share\\x', '\\rooted\\x', 'C:relative', 'relative\\x', 'C:/Temp/x', 'C:\\Temp\\\\x', 'C:\\Temp\\..\\Users', 'C:\\Temp\\.', 'C:\\Temp\\x ', - 'C:\\Temp\\x.', 'C:\\Temp\\ x', 'C:\\Temp\\a:b', 'C:\\Temp\\a*b', 'C:\\Temp\\a?b', 'C:\\Temp\\a|b', '', null]) { + 'C:\\Temp\\x.', 'C:\\Temp\\ x', 'C:\\Temp\\a:b', 'C:\\Temp\\a*b', 'C:\\Temp\\a?b', 'C:\\Temp\\a|b', + 'C:\\Users\\OPERAT~1\\.codex\\out.jsonl', 'C:\\Users\\RUNNER~1\\Temp', 'C:\\Temp\\literal~name', '', null]) { assert.equal(normalizeLocalPath(bad), null, String(bad)) } }) @@ -231,6 +232,11 @@ test('the output/input path gate refuses real homes and disposable directories', assert.equal(pathGateSafe(`${PARENT}\\home`, USER_HOME, null, { allowDisposable: true }), null) assert.equal(insideDisposableDir('C:\\Temp\\codex-win-obs-0123456789abc'), false) assert.equal(insideDisposableDir('C:\\Temp\\codex-win-obs-0123456789ab\\home'), true) + assert.equal(insideDisposableDir('C:\\Temp\\CODEX-WIN-OBS-0123456789AB\\home'), true) + assert.equal(pathGateSafe('C:\\Temp\\CODEX-WIN-OBS-0123456789AB\\out.jsonl', USER_HOME, null), 'inside_disposable_dir') + assert.equal(pathGateSafe('C:\\Users\\OPERAT~1\\.codex\\out.jsonl', USER_HOME, null), 'path_spelling') + assert.equal(pathGateSafe('C:\\Temp\\out.jsonl', 'C:\\Users\\OPERAT~1\\.codex', null), 'user_home_spelling') + assert.equal(tempRootSafeForCreation('C:\\Users\\RUNNER~1\\Temp', USER_HOME, null), false) }) test('access denial is found at any depth', () => { @@ -1116,6 +1122,36 @@ test('share probe evidence fixes its exact requested access and sharing mode', ( } }) +test('Markdown expectation cells escape existing backslashes before pipes', () => { + for (const [value, escaped] of [ + ['a|b', String.raw`a\|b`], + [String.raw`a\b|c`, String.raw`a\\b\|c`], + [String.raw`a\|b|c`, String.raw`a\\\|b\|c`], + ]) { + const table = renderExpectationTable([{ id: value, mode: 'preflight', step: value, perSample: false, hypothesis: null, gates: [] }]) + assert.ok(table.endsWith(`| ${escaped} | preflight | \`${escaped}\` | no | recorded only (typed schema) | |\n`)) + } +}) + +test('W6 comparison completion does not turn W7-only failures into success', () => { + const sides = ['medium', 'high'].map((label) => { + const run = idealRun(label) + for (const r of run.observe.records.filter((r) => r.step === 'range.probe')) { + r.observed.ranges.beyond_fsext = { win32: 0, acquired: true, released: true } + } + const summary = evaluate(run, { expectedLabel: label }) + assert.equal(summary.w7, 'fail') + assert.equal(row(summary, 'OB-range-beyond-fsext').verdict, 'mismatch') + assert.equal(summary.provenance.state, 'validated') + assert.equal(summary.safeToUnlock, false) + return summary + }) + const paired = pairSummaries(...sides) + assert.equal(paired.status, 'compared') + assert.equal(paired.safeToUnlock, false) + assert.ok(!paired.rows.some((r) => r.id === 'OB-range-beyond-fsext')) +}) + test('missing whole steps, late process re-query and interleaved samples are unknown', () => { const missing = idealRun('medium') missing.observe.records = missing.observe.records.filter((r) => r.step !== 'range.probe') From bf1d0016dc3f9c212ab5fd72c55dfc58020f87a7 Mon Sep 17 00:00:00 2001 From: YEONGUK LEE Date: Sun, 18 Oct 2026 17:05:33 +0900 Subject: [PATCH 3/3] fix(experimental): address Windows observer evidence review --- .../windows-native-observer/README.md | 76 +++++++--- .../windows-native-observer/check.ps1 | 50 +++++- .../windows-native-observer/expectations.mjs | 37 +++-- experimental/windows-native-observer/lib.mjs | 64 ++++++-- .../windows-native-observer/observer.ps1 | 53 ++++--- .../test/evaluate.test.mjs | 142 +++++++++++++++++- .../test/schema-review.test.mjs | 84 +++++++++++ 7 files changed, 425 insertions(+), 81 deletions(-) create mode 100644 experimental/windows-native-observer/test/schema-review.test.mjs diff --git a/experimental/windows-native-observer/README.md b/experimental/windows-native-observer/README.md index 3e97446..645e2b0 100644 --- a/experimental/windows-native-observer/README.md +++ b/experimental/windows-native-observer/README.md @@ -9,7 +9,9 @@ PowerShell scripts were parsed, their AST gates and pure helpers checked, and the C# compiled on a designated Windows host. No observer mode, native method or real Codex binary was run. JavaScript tests use synthetic evidence only. -`observer.ps1 -Mode preflight` and `-Mode observe` **refuse to run** (exit 3). +All `observer.ps1` modes — `preflight`, `observe` and `fixture-meta` — +**refuse to run** (exit 3) at the first statement of setup, before path lookups +or compilation. The ancestor/junction and creation boundary is not closed: every check the harness can make compares identities at two points in time instead of pinning a path, so a same-user actor could still swap an ancestor between the check and @@ -40,7 +42,7 @@ deleting anything. | File | Runs on | Role | | --- | --- | --- | -| `observer.ps1` | Windows PowerShell 5.1 (designated host) | Orchestrator with three modes: `preflight` and `observe` (both hard-refused) and `fixture-meta` | +| `observer.ps1` | Windows PowerShell 5.1 (designated host) | Proposed orchestrator; all three modes (`preflight`, `observe`, `fixture-meta`) are hard-refused | | `WinObserver.cs` | Compiled by `Add-Type` inside `observer.ps1` | Read-only native calls: token integrity, file identity, no-follow ancestry, share-mode probes, guarded lock-range probes, Restart Manager, process identity | | `check.ps1` | Windows PowerShell 5.1 | Static gate. Parses both scripts, checks the syntax tree (automatic-variable assignment, forbidden commands, gate and refusal ordering, harness list), runs the pure helper functions taken from the observer's tree, validates its own paths and `%TEMP%`, then compiles the C# without calling it | | `expectations.mjs` | Node.js 22.13+ or 24 (any OS) | The expected-result table, typed per-step schemas with internal-consistency invariants, format validators and the path-spelling rule (single source of truth) | @@ -62,8 +64,9 @@ system temporary root. ## Safety boundary -- **The native modes are refused.** `preflight` and `observe` exit 3 before - anything is compiled, created or observed. `check.ps1` fails if that refusal +- **All observer modes are refused.** `preflight`, `observe` and `fixture-meta` + exit 3 before path lookups or anything is compiled, created or observed. + `check.ps1` fails if that refusal is removed, moved after `Add-Type`, or made conditional on a flag. It also constrains the actual `Refuse` and `Stop-Unknown` definitions and their literal exit 3/2 effects, with AST-only negative mutations, never by running @@ -175,11 +178,14 @@ system temporary root. once, and the guard is released in `finally`. No await, spawn or signal happens while the guard is held. The lock order matches the production rule: native guard, then thread probe. A missing or busy guard is `unknown`. -- **Full-sharing opens only.** Every open of a Codex file uses the full share - mask (read, write, delete). `share.read_rwd` and `share.delete_rwd` (DELETE - access plus the attribute-read right the file-type check needs, shared) are - the only share probes the default observer runs, so it - never withholds a sharing mode the holder relies on. Requesting the DELETE +- **RM/share observations are deferred, even in the dormant producer.** + Path-based RM and share calls have been removed from the producer. It emits + `unknown` with `guarded_handle_binding_not_implemented` instead of reopening + a path outside a common guard. Implementing synchronous shared coordination + and handle/FileId binding is a separate requirement, not satisfied by the + current range-only guard. No new guarded native helper is introduced here. + The reviewable C# share helpers use full sharing (read, write, delete). + Requesting the DELETE access right is not a file-removal call; no delete disposition, rename or removal is ever issued. Restrictive-sharing probes (omitting write or delete sharing, which could block the holder's writes or its Drop-time delete) are a @@ -264,7 +270,8 @@ before the run; the harness compares it exactly. ## Run commands (operator, on the designated host) -> **Steps 1 and 4 currently refuse.** `-Mode preflight` and `-Mode observe` +> **All observer modes currently refuse.** This includes `-Mode fixture-meta` +> as well as `-Mode preflight` and `-Mode observe`. They > exit 3 with the refusal reason while remaining decision 10 is open. The > commands are documented so the intended procedure can be reviewed, not so it > can be run. Step 0 (static gate) and step 7 (offline evaluator) can run. @@ -298,7 +305,7 @@ the High session), from the same commit. -ExpectedVersion '' -OutFile "$out\medium-preflight.jsonl" ``` - Today: `refused: native preflight and observe are refused …`, exit 3, + Today: `refused: all observer modes are refused …`, exit 3, nothing created. If the boundary is ever closed, the expected output is `results: …`, `disposable CODEX_HOME: C:\…\codex-win-obs-<12 hex>\home`, a `next:` line, and `verdicts:` with only `match` and `recorded`, exit 0, with @@ -504,7 +511,7 @@ Stop and report; do not work around any of these. | Condition | Where | Effect | | --- | --- | --- | -| `-Mode preflight` or `-Mode observe` | any host | exit 3, refused: the ancestry/creation boundary is open and there is no bypass | +| Any observer mode, including `fixture-meta` | any host | exit 3 before path lookups; the ancestry/creation boundary is open and there is no bypass | | Missing or invalid parameter, existing `-OutFile`, any path that is not an accepted absolute local drive spelling, any path inside a real Codex home, an output inside a `codex-win-obs-<12 hex>` directory, `%TEMP%` or the harness directory inside a real home, `USERPROFILE` unset | any mode | exit 3 before any file is opened or compiled | | `Get-Volume -FilePath` or `Get-MpComputerStatus` unavailable | preflight | exit 3 before anything is created | | `-CodexPid` missing, zero, or this observer process | observe | exit 3 | @@ -542,6 +549,22 @@ record against `expectations.mjs` and reports two separate facts for every row: every schema **invariant** holds, and every hypothesis field is present and not null. +Only range result codes 0 (acquired and released) or 33 (lock conflict) are +measured lock states. Only share-open codes 0 (opened) or 32 (sharing +violation) measure sharing behavior. Other errors, including invalid parameter, +missing file or unsupported operation, are `unknown`, not measured mismatch. +W7's `host.volume` must report `NTFS`; other filesystems remain `unknown`. +Every self/target token requires measured `elevationType` enum 1, 2 or 3. Missing +queries and null values are unknown; Full/Limited status is never inferred from +a Medium/High label. + +Every record's advisory `expect` must exactly equal the canonical key/value +union for that mode and step, even for derived or recorded rows. This includes +all raw hypotheses of a shared step plus the closed legacy derived expectation +map in `lib.mjs`; extra keys, missing keys, wrong values and wrong shapes are +`expectation_drift`. Matching that metadata never makes a producer-written +boolean or verdict evidence: derivations still recompute from raw fields. + Invariants are the record's internal consistency, and a record that contradicts itself is never evidence. For example: `opened: true` with a nonzero Win32 code; a range with `win32: 33` and `acquired: true`, or an @@ -608,7 +631,7 @@ harness file-name set, and every provenance row (`PF-harness`, `PF-token`, require W7's unmeasurable coordination range, but it does require every provenance prerequisite of it. - Only rows tagged `W6` are compared. Verdicts and selected values (hypothesis - fields, plus Restart Manager `count` and role list) are shown side by side + fields, measured token `elevationType`, and Restart Manager `count` and role list) are shown side by side per level. They are never merged. - Any difference needs a written explanation. Any row that is `unknown` at either level keeps the pair `unknown`; it is never filled in from the other @@ -686,7 +709,7 @@ Windows accepts that offset has not been established. Restart Manager rows are | `check.ps1` | executed on Windows PowerShell 5.1 | Both scripts parsed; AST checks, refusal mutations and nine pure-helper groups passed | | `observer.ps1` body (all modes) | not executed | Only its parsed AST and selected pure helper functions were used | | `WinObserver.cs` | compiled on Windows | `Add-Type` and method-name reflection only; no native method was called | -| `observer.ps1 -Mode preflight` / `-Mode observe` | **refused by the harness** | The ancestry/creation boundary is open (remaining decision 10). This is a hard refusal, not just an absence of a host | +| All `observer.ps1` modes, including `fixture-meta` | **refused by the harness** | The ancestry/creation boundary is open (remaining decision 10). Refusal is before path checks, not just compilation | | Real Codex binary, version, disposable home, lock observations | not executed | Requires the designated host and an operator, and the native modes refuse; no real Codex was run anywhere | | Medium-integrity and High-integrity runs | not executed | Same | | `lab.ps1` reruns and the `fixture-meta` pairing step | deferred | The fixture producer cannot be bound without changing `experimental/windows-fixture/` (remaining decision 4), so the paired result could never complete | @@ -704,21 +727,24 @@ The existing `RemoteSigned` policy was unchanged. A new private staging directory outside every real Codex home held the checked inputs and compiler temporaries. Source hashes were checked before execution. -The post-review static run exited **0**: both parses, automatic-variable and -forbidden-call checks, the exact hard-refusal AST contract, seven switch/guard -mutations plus twelve definition/termination mutations for each of `Refuse` -and `Stop-Unknown`, nine pure-helper groups (including short-name/tilde -refusal and case-insensitive disposable matching), and C# compilation passed. +The latest review-follow-up static run exited **0**: both parses, +automatic-variable and forbidden-call checks, the exact all-mode refusal AST +contract, eleven switch/guard mutations plus twelve definition/termination +mutations for each of `Refuse` and `Stop-Unknown`, two deferred RM/share-call +insertion checks, nine pure-helper groups (including short-name/tilde refusal, +case-insensitive disposable matching, and execution-policy failure handling), +and C# compilation passed. Tested inputs: -- `check.ps1`: `a5e0499990c9700e743429b794e69366469def8f465e40a15b3c4aedbafb17b2` -- `observer.ps1`: `15e5ea87bd4766de1fe3537d49fec201837abbfda1a378f4cced3f3fe99a06de` +- `check.ps1`: `738d4fdd8278602f2e32faa35b6d501264a8c300c27a715b501c87acae4d3360` +- `observer.ps1`: `85a84194bef9b781a8ff3b059a87616bd0e3e46d6ae8e6b3c5eb4e4a3d477c28` - `WinObserver.cs`: `9b44dc7403368857fd7c55cdf4a596310b7b501c1343755f3adc08758f3eb83c` -- Static output SHA-256: `5e8951d7b56c31012aadc8980b8f9694f2cfbe859fd742fc6937ce9619702fc8` +- Static output SHA-256: `3d642797f734d144fdaf559ed4e2806a119b009cae6a26198f531c27c6eebaed` -The earlier static baseline (output digest -`3ea3e85a2b6f31ba4bb7a41b9273460832bd9d8667ad0ff12e1edf74ff111736`) -is retained separately and is not counted as verification of these later edits. +The earlier static baselines (output digests +`3ea3e85a2b6f31ba4bb7a41b9273460832bd9d8667ad0ff12e1edf74ff111736` and +`5e8951d7b56c31012aadc8980b8f9694f2cfbe859fd742fc6937ce9619702fc8`) +are retained separately and are not counted as verification of these later edits. Earlier failures were retained rather than counted as passes: the original checker used an unavailable `VariablePath.UnqualifiedPath` property; after diff --git a/experimental/windows-native-observer/check.ps1 b/experimental/windows-native-observer/check.ps1 index 04f63dc..5c5e08d 100644 --- a/experimental/windows-native-observer/check.ps1 +++ b/experimental/windows-native-observer/check.ps1 @@ -148,8 +148,9 @@ function Test-TerminationDefinition($tree, [string]$functionName, [int]$exitCode # This is deliberately an exact contract, not a general control-flow proof. # The one true assignment must be top-level, and the exact refusal statement -# must immediately precede the sole Add-Type command in the same top-level -# try body. No additional reference (including a parameter or mutation) to the +# must be the FIRST statement of the top-level setup try containing the sole +# Add-Type command. Its condition cannot depend on Mode, so all three modes +# refuse before path lookups or compilation. No additional reference to the # switch is accepted. These checks inspect ASTs; they never run the observer. function Test-NativeRefusalAst($tree) { $switchUses = @($tree.FindAll({ @@ -189,16 +190,27 @@ function Test-NativeRefusalAst($tree) { if ([object]::ReferenceEquals($statements[$i], $compilePipeline)) { $index = $i; break } } if ($index -lt 1) { return $false } - $refusal = $statements[$index - 1] + $refusal = $statements[0] if ($refusal -isnot [System.Management.Automation.Language.IfStatementAst] -or $refusal.Clauses.Count -ne 1 -or $null -ne $refusal.ElseClause) { return $false } $condition = $refusal.Clauses[0].Item1.Extent.Text -replace '\s+', '' - if ($condition -cne '$NATIVE_MODES_REFUSED-and($Mode-eq''preflight''-or$Mode-eq''observe'')') { return $false } + if ($condition -cne '$NATIVE_MODES_REFUSED') { return $false } $body = $refusal.Clauses[0].Item2.Statements if ($body.Count -ne 1 -or ($body[0].Extent.Text -replace '\s+', ' ').Trim() -cne 'Refuse $NativeRefusalReason') { return $false } $true } +# RM/share observations remain deferred in the dormant producer as well. This +# checks literal calls only; it is not a general proof against dynamic code. +function Get-DeferredObservationCalls($tree) { + $tree.FindAll({ + param($node) + $node -is [System.Management.Automation.Language.InvokeMemberExpressionAst] -and + $node.Expression.Extent.Text -ieq '[WinObserver]' -and + $node.Member.Extent.Text -in @('RmList', 'ShareProbe') + }, $true) +} + function Parse-Only([string]$text) { $parseTokens = $null $parseErrors = $null @@ -265,18 +277,23 @@ if ($null -ne $observerAst) { elseif ($gateAt -gt $addTypeAt) { Fail 'observer.ps1 compiles before its path and TEMP gate' } else { Report 'ordering ok: observer.ps1 gates paths and TEMP before Add-Type' } if (-not (Test-NativeRefusalAst $observerAst)) { Fail 'observer.ps1 lost the exact top-level native refusal contract' } - else { Report 'refusal ok: exact native refusal immediately precedes Add-Type in the same setup try' } + else { Report 'refusal ok: all-mode refusal is first in the setup try, before paths and Add-Type' } + if (@(Get-DeferredObservationCalls $observerAst).Count -ne 0) { Fail 'observer.ps1 invokes deferred RM/share observations' } try { $source = $observerAst.Extent.Text - $line = 'if ($NATIVE_MODES_REFUSED -and ($Mode -eq ''preflight'' -or $Mode -eq ''observe'')) { Refuse $NativeRefusalReason }' + $line = 'if ($NATIVE_MODES_REFUSED) { Refuse $NativeRefusalReason }' if (-not $source.Contains($line)) { throw 'native refusal mutation anchor missing' } $compile = 'Add-Type -Path (Join-Path $PSScriptRoot ''WinObserver.cs'')' if (-not $source.Contains($compile)) { throw 'compile mutation anchor missing' } $variants = @{ removed = Replace-SourceText $source $line '' moved = Replace-SourceText (Replace-SourceText $source $line '') $compile ($compile + "`n" + $line) + moved_after_path = Replace-SourceText (Replace-SourceText $source $line '') '$PathGate = Assert-PathGate' ('$PathGate = Assert-PathGate' + "`n" + $line) conditional_false = Replace-SourceText $source $line ('if ($false) { ' + $line + ' }') - condition_and_false = Replace-SourceText $source '$NATIVE_MODES_REFUSED -and ($Mode' '$NATIVE_MODES_REFUSED -and $false -and ($Mode' + condition_and_false = Replace-SourceText $source $line 'if ($NATIVE_MODES_REFUSED -and $false) { Refuse $NativeRefusalReason }' + omit_preflight = Replace-SourceText $source $line 'if ($NATIVE_MODES_REFUSED -and $Mode -ne ''preflight'') { Refuse $NativeRefusalReason }' + omit_observe = Replace-SourceText $source $line 'if ($NATIVE_MODES_REFUSED -and $Mode -ne ''observe'') { Refuse $NativeRefusalReason }' + omit_fixture_meta = Replace-SourceText $source $line 'if ($NATIVE_MODES_REFUSED -and $Mode -ne ''fixture-meta'') { Refuse $NativeRefusalReason }' second_assignment = Replace-SourceText $source $line ('$NATIVE_MODES_REFUSED = $false' + "`n" + $line) parameter_override = ('param([bool]$NATIVE_MODES_REFUSED)' + "`n" + '$NATIVE_MODES_REFUSED = $true' + "`ntry { " + $line + "`n" + $compile + ' } catch {}') env_override = Replace-SourceText $source '$NATIVE_MODES_REFUSED = $true' '$NATIVE_MODES_REFUSED = $env:NATIVE_MODES_REFUSED' @@ -284,6 +301,11 @@ if ($null -ne $observerAst) { foreach ($variant in $variants.Keys) { if (Test-NativeRefusalAst (Parse-Only $variants[$variant])) { throw "native refusal mutation accepted: $variant" } } + foreach ($call in '[WinObserver]::RmList(@())', '[WinObserver]::ShareProbe(''fixture'', ''read'', ''rwd'')') { + if (@(Get-DeferredObservationCalls (Parse-Only ($source + "`n" + $call))).Count -ne 1) { + throw 'deferred native call mutation was not detected' + } + } # Mutate only source text and parse it. None of these altered helper # definitions, including their exit statements, is ever executed. foreach ($spec in @(@('Refuse', 'refused', 3), @('Stop-Unknown', 'unknown', 2))) { @@ -357,7 +379,7 @@ if ($null -ne $observerAst) { try { foreach ($name in 'Get-PathRelation', 'Test-LocalPathSpelling', 'Test-InsideDisposableDir', 'Test-PathGateSafe', 'Test-CandidateTempSafe', 'Invoke-GuardedCreate', 'Invoke-GuardedSample', 'New-StreamChannel', - 'Read-BothStreams', 'Read-BoundedStream', 'Get-OpenerRole', 'Get-Dotted', 'Test-Denied', 'Test-Expect', + 'Read-BothStreams', 'Read-BoundedStream', 'Get-OpenerRole', 'Get-Dotted', 'Test-Denied', 'Test-Expect', 'Get-ExecutionPolicyFailure', 'Test-SameDigests') { $fn = $observerAst.Find({ param($node) @@ -520,6 +542,18 @@ if ($helpersLoaded) { } Invoke-CheckGroup 'expectation and digest helpers' { + $policy = [pscustomobject]@{ ok = $true; effective = 'RemoteSigned'; + scopes = @([pscustomobject]@{ scope = 'LocalMachine'; policy = 'RemoteSigned' }) } + if ($null -ne (Get-ExecutionPolicyFailure $policy)) { throw 'a complete policy observation must be usable' } + foreach ($invalidPolicy in @($null, + [pscustomobject]@{ ok = $false; exception = 'synthetic failure' }, + [pscustomobject]@{ ok = $true; complete = $false; effective = 'RemoteSigned'; scopes = $policy.scopes }, + [pscustomobject]@{ ok = $true; state = 'unknown'; effective = 'RemoteSigned'; scopes = $policy.scopes }, + [pscustomobject]@{ ok = $true; effective = ''; scopes = $policy.scopes }, + [pscustomobject]@{ ok = $true; effective = 'RemoteSigned'; scopes = @() }, + [pscustomobject]@{ ok = $true; effective = 'RemoteSigned'; scopes = @([pscustomobject]@{ scope = 'LocalMachine' }) })) { + if (-not (Get-ExecutionPolicyFailure $invalidPolicy)) { throw 'failed or incomplete policy must stop dormant preflight' } + } $probe = [pscustomobject]@{ ok = $true; state = 'probed'; ranges = [pscustomobject]@{ rust = [pscustomobject]@{ win32 = 33 } } } if ((Test-Expect $probe @{ state = 'probed'; 'ranges.rust.win32' = 33 }) -ne 'match') { throw 'dotted hypothesis must match' } if ((Test-Expect $probe @{ 'ranges.fsext.win32' = 33 }) -ne 'mismatch') { throw 'missing dotted field must not match' } diff --git a/experimental/windows-native-observer/expectations.mjs b/experimental/windows-native-observer/expectations.mjs index 7411b58..f190af2 100644 --- a/experimental/windows-native-observer/expectations.mjs +++ b/experimental/windows-native-observer/expectations.mjs @@ -99,6 +99,9 @@ export const FORMATS = Object.freeze({ int: (v) => Number.isInteger(v), nonNegInt: (v) => Number.isInteger(v) && v >= 0, posInt: (v) => Number.isSafeInteger(v) && v > 0, + // TOKEN_ELEVATION_TYPE: Default, Full or Limited. Missing/failed queries + // are unusable evidence; integrity level cannot substitute for this value. + elevationType: (v) => v === 1 || v === 2 || v === 3, // FILETIME (100ns since 1601) exceeds 2^53, so it is carried as an exact // decimal string and compared as a string; a JSON number would lose precision. filetime: (v) => typeof v === 'string' && /^[1-9]\d*$/.test(v), @@ -166,7 +169,8 @@ const RANGE_NAMES = Object.freeze(['rust', 'fsext', 'beyond_fsext', 'tail']) // Each probed range must be internally consistent: a Win32 code of 0 is exactly // an acquired lock, an acquired lock must report its release, and a failed lock -// must not claim a release. +// must not claim a release. Only success and ERROR_LOCK_VIOLATION establish +// range state; operational/unsupported errors remain unmeasured. function rangesConsistent(ranges) { if (ranges === null || typeof ranges !== 'object' || Array.isArray(ranges)) return false if (!sameKeySet(ranges, RANGE_NAMES)) return false @@ -174,6 +178,7 @@ function rangesConsistent(ranges) { const r = ranges[name] if (r === null || typeof r !== 'object') return false if (!FORMATS.nonNegInt(r.win32) || !FORMATS.bool(r.acquired)) return false + if (r.win32 !== 0 && r.win32 !== 33) return false if ((r.win32 === 0) !== (r.acquired === true)) return false if (r.acquired === true) return r.released === true return r.released === null @@ -207,7 +212,7 @@ export const STEP_SCHEMAS = Object.freeze({ required: { complete: f('bool'), files: f('hashMap'), sourceCommit: f('commit') }, invariants: { harness_name_set: (o) => sameKeySet(o.files, HARNESS_FILES) }, }, - 'preflight:token.self': { required: { integritySid: f('sid'), userSid: f('anySid'), tokenOpened: f('bool'), pid: f('posInt') } }, + 'preflight:token.self': { required: { integritySid: f('sid'), userSid: f('anySid'), tokenOpened: f('bool'), pid: f('posInt'), elevationType: f('elevationType') } }, 'preflight:binary.sha256': { required: { path: f('localPath'), size: f('posInt'), sha256: f('sha256'), expected: f('sha256') }, }, @@ -239,7 +244,12 @@ export const STEP_SCHEMAS = Object.freeze({ 'preflight:home.after_version': { required: { count: f('nonNegInt'), entries: f('array') }, invariants: { count_matches_entries: (o) => o.count === o.entries.length } }, 'preflight:host.execution_policy': { required: { effective: f('string'), scopes: f('array') } }, 'preflight:host.defender': { required: { amServiceEnabled: f('bool'), realTimeProtectionEnabled: f('bool'), antivirusEnabled: f('bool') } }, - 'preflight:host.volume': { required: { fileSystem: f('string'), probePath: f('localPath') } }, + 'preflight:host.volume': { + required: { fileSystem: f('string'), probePath: f('localPath') }, + // Other filesystems require separate evidence; they are outside this run + // contract rather than measured failures of the NTFS hypothesis. + invariants: { supported_filesystem: (o) => o.fileSystem === 'NTFS' }, + }, 'preflight:host.os': { required: { os: f('string'), ps: f('string'), elevated: f('bool'), sshSession: f('bool') } }, 'preflight:manifest': { required: { @@ -255,7 +265,7 @@ export const STEP_SCHEMAS = Object.freeze({ required: { complete: f('bool'), files: f('hashMap'), manifestSha256: f('sha256') }, invariants: { harness_name_set: (o) => sameKeySet(o.files, HARNESS_FILES) }, }, - 'observe:token.self': { required: { integritySid: f('sid'), userSid: f('anySid'), tokenOpened: f('bool'), pid: f('posInt') } }, + 'observe:token.self': { required: { integritySid: f('sid'), userSid: f('anySid'), tokenOpened: f('bool'), pid: f('posInt'), elevationType: f('elevationType') } }, 'observe:home.identity': { required: { ...PATH_FIELDS, ...ACL_FIELDS } }, 'observe:run': { required: { @@ -271,6 +281,7 @@ export const STEP_SCHEMAS = Object.freeze({ required: { pid: f('posInt'), opened: f('bool'), state: f('string'), createFiletime: f('filetime'), image: f('localPath'), tokenOpened: f('bool'), integritySid: f('sid'), userSid: f('anySid'), + elevationType: f('elevationType'), }, invariants: { live_requires_handles: (o) => o.state !== 'live' || (o.opened === true && o.tokenOpened === true) }, }, @@ -280,6 +291,7 @@ export const STEP_SCHEMAS = Object.freeze({ required: { pid: f('posInt'), opened: f('bool'), state: f('string'), createFiletime: f('filetime'), image: f('localPath'), tokenOpened: f('bool'), integritySid: f('sid'), userSid: f('anySid'), + elevationType: f('elevationType'), }, invariants: { live_requires_handles: (o) => o.state !== 'live' || (o.opened === true && o.tokenOpened === true) }, }, @@ -299,6 +311,7 @@ export const STEP_SCHEMAS = Object.freeze({ invariants: { opened_matches_win32: (o) => (o.opened === true) === (o.win32 === 0), requested_mode: (o) => o.access === 'read' && o.share === 'rwd', + interpretable_result: (o) => o.win32 === 0 || o.win32 === 32, }, }, 'observe:share.delete_rwd': { @@ -306,6 +319,7 @@ export const STEP_SCHEMAS = Object.freeze({ invariants: { opened_matches_win32: (o) => (o.opened === true) === (o.win32 === 0), requested_mode: (o) => o.access === 'delete' && o.share === 'rwd', + interpretable_result: (o) => o.win32 === 0 || o.win32 === 32, }, }, 'observe:range.probe': { @@ -340,6 +354,7 @@ export const STEP_SCHEMAS = Object.freeze({ required: { pid: f('posInt'), opened: f('bool'), state: f('string'), createFiletime: f('filetime'), image: f('localPath'), tokenOpened: f('bool'), integritySid: f('sid'), userSid: f('anySid'), + elevationType: f('elevationType'), }, invariants: { live_requires_handles: (o) => o.state !== 'live' || (o.opened === true && o.tokenOpened === true) }, }, @@ -349,8 +364,8 @@ export const EXPECTATIONS = Object.freeze([ // ---- preflight: before the operator launches Codex ------------------- { id: 'PF-harness', mode: 'preflight', step: 'harness', derive: 'harnessBaseline', gates: ['W6', 'W7'], basis: 'harness file SHA-256 values recorded for the exact file-name set and equal to the supplied baseline' }, - { id: 'PF-token', mode: 'preflight', step: 'token.self', derive: 'tokenIntegrity', gates: ['W6', 'W7'], - basis: 'measured token integrity SID equals the label SID; never relabelled' }, + { id: 'PF-token', mode: 'preflight', step: 'token.self', derive: 'tokenIntegrity', compare: ['elevationType'], gates: ['W6', 'W7'], + basis: 'measured integrity SID equals the label SID and a valid elevationType is recorded for W6; never inferred from a label' }, { id: 'PF-binary-sha256', mode: 'preflight', step: 'binary.sha256', derive: 'binarySha256Baseline', gates: ['W7'], basis: 'binary bytes equal the recorded expected digest and the supplied baseline before any execution' }, { id: 'PF-binary-signature', mode: 'preflight', step: 'binary.signature', hypothesis: null, gates: ['W7'], @@ -368,7 +383,7 @@ export const EXPECTATIONS = Object.freeze([ { id: 'PF-defender', mode: 'preflight', step: 'host.defender', hypothesis: { amServiceEnabled: true, realTimeProtectionEnabled: true }, gates: ['W7'], basis: 'W7 requires a host where Defender is already enabled; it is never enabled or changed here' }, { id: 'PF-volume', mode: 'preflight', step: 'host.volume', hypothesis: null, gates: ['W7'], - basis: 'filesystem of the disposable home is recorded (NTFS or ReFS)' }, + basis: 'the W7 disposable-home environment is measured as NTFS; ReFS belongs to separate W1 research' }, { id: 'PF-os', mode: 'preflight', step: 'host.os', hypothesis: null, gates: ['W6', 'W7'], basis: 'OS build, PowerShell version, Administrators membership and SSH session are recorded' }, { id: 'PF-manifest', mode: 'preflight', step: 'manifest', derive: 'manifestBaseline', gates: ['W7'], @@ -377,16 +392,16 @@ export const EXPECTATIONS = Object.freeze([ // ---- observe: while the operator-launched Codex holds one thread ----- { id: 'OB-harness', mode: 'observe', step: 'harness', derive: 'harnessMatchesManifest', gates: ['W6', 'W7'], basis: 'observer files byte-identical to the preflight run, recomputed from the recorded digests' }, - { id: 'OB-token', mode: 'observe', step: 'token.self', derive: 'tokenMatchesManifest', gates: ['W6', 'W7'], - basis: 'observe integrity SID equals the preflight manifest SID and the label SID' }, + { id: 'OB-token', mode: 'observe', step: 'token.self', derive: 'tokenMatchesManifest', compare: ['elevationType'], gates: ['W6', 'W7'], + basis: 'observe integrity SID matches the manifest/label and measured self elevationType remains equal to preflight' }, { id: 'OB-home', mode: 'observe', step: 'home.identity', derive: 'homeMatchesManifest', gates: ['W7'], basis: 'home and parent paths, file IDs, structure, disjointness, owner/DACL contract and no-follow ancestry all still hold and match the manifest' }, { id: 'OB-run', mode: 'observe', step: 'run', derive: 'runControl', gates: ['W6', 'W7'], basis: 'the control record declares one positive target PID, a different observer PID and a sample count, and every process record binds to it' }, { id: 'OB-process', mode: 'observe', step: 'process.identity', derive: 'imageLive', gates: ['W3', 'W6', 'W7'], basis: 'query-only handle to the declared PID is live, same-user, and its image equals the manifest binary path' }, - { id: 'OB-process-token', mode: 'observe', step: 'process.identity', derive: 'codexToken', gates: ['W6'], - basis: 'Codex token integrity SID equals the -CodexIntegrityLabel SID; denial is unknown' }, + { id: 'OB-process-token', mode: 'observe', step: 'process.identity', derive: 'codexToken', compare: ['elevationType'], gates: ['W6'], + basis: 'target integrity SID equals the declared label SID and a valid measured elevationType is retained for W6; denial is unknown' }, { id: 'OB-process-sample', mode: 'observe', step: 'process.sample', perSample: true, derive: 'sampleProcess', gates: ['W3', 'W7'], basis: 'before each sample\'s active operations the declared PID is re-queried and must still be the same live, same-user instance, at the same creation FILETIME, image and integrity' }, { id: 'OB-lockdir', mode: 'observe', step: 'lockdir.identity', perSample: true, hypothesis: { directory: true, reparse: false }, gates: ['W7'], diff --git a/experimental/windows-native-observer/lib.mjs b/experimental/windows-native-observer/lib.mjs index 352964d..3632f67 100644 --- a/experimental/windows-native-observer/lib.mjs +++ b/experimental/windows-native-observer/lib.mjs @@ -330,10 +330,38 @@ export function recomputeIsolation(o) { return problems.length === 0 && o.empty === true } -function sameExpectation(tableHypothesis, recordExpect) { - if (tableHypothesis === null) return true - if (!recordExpect || typeof recordExpect !== 'object') return false - return Object.entries(tableHypothesis).every(([k, v]) => Object.hasOwn(recordExpect, k) && recordExpect[k] === v) +// Derived rows recompute their verdicts instead of comparing producer booleans. +// Their legacy advisory expectations remain a separate, closed contract that +// mirrors observer.ps1's $Expect, alongside the raw table hypotheses. Neither +// these expected booleans nor the producer's verdict establish an observation. +const DERIVED_EXPECTATIONS = Object.freeze({ + 'preflight:token.self': { integrityMatchesLabel: true }, + 'preflight:binary.sha256': { matches: true }, + 'preflight:home.isolated': { isolated: true }, + 'preflight:binary.version': { matches: true, exitCode: 0 }, + 'observe:harness': { matchesManifest: true }, + 'observe:token.self': { integrityMatchesLabel: true, matchesManifest: true }, + 'observe:home.identity': { matchesManifest: true }, + 'observe:process.identity': { state: 'live', imageMatchesBinary: true, tokenOpened: true, codexIntegrityMatchesLabel: true }, + 'observe:process.sample': { state: 'live' }, + 'observe:thread.identity_after': { sameFileId: true }, + 'observe:process.identity_after': { state: 'live', sameCreate: true }, +}) + +// A record carries the union for its mode/step, including steps represented +// by several rows (range.probe). The caller's selected rows cannot weaken it. +export function canonicalExpectation(mode, step) { + const union = { ...DERIVED_EXPECTATIONS[`${mode}:${step}`] } + for (const exp of EXPECTATIONS) { + if (exp.mode === mode && exp.step === step && exp.hypothesis) Object.assign(union, exp.hypothesis) + } + return Object.keys(union).length > 0 ? union : null +} + +function sameExpectation(canonical, recordExpect) { + if (canonical === null) return recordExpect === null + return sameKeySet(recordExpect, Object.keys(canonical)) && + Object.entries(canonical).every(([key, value]) => recordExpect[key] === value) } export function deepEqualMap(a, b) { @@ -379,7 +407,8 @@ const DERIVATIONS = { if (!ctx.label) return unk('label_unresolved') if (o.tokenOpened !== true) return unk('token_not_opened') if (o.integritySid !== INTEGRITY[ctx.label]) return mis('integrity_sid_differs_from_label') - return ok(o.integritySid) + if (o.elevationType !== rec.integrity.elevationType) return mis('elevation_type_differs_from_file') + return ok({ integritySid: o.integritySid, elevationType: o.elevationType }) }, binarySha256Baseline(rec, _s, ctx) { const o = rec.observed @@ -447,7 +476,12 @@ const DERIVATIONS = { if (o.integritySid !== INTEGRITY[ctx.label]) return mis('integrity_sid_differs_from_label') if (!ctx.manifest) return unk('no_manifest') if (o.integritySid !== ctx.manifest.integritySid) return mis('integrity_sid_differs_from_manifest') - return ok(o.integritySid) + if (o.elevationType !== rec.integrity.elevationType) return mis('elevation_type_differs_from_file') + const before = ctx.preflightByStep.get('token.self') + if (unmeasuredReason(before, 'preflight', null)) return unk('preflight_token_unusable') + if (o.elevationType !== before.observed.elevationType) return mis('elevation_type_differs_from_preflight') + if (o.userSid !== before.observed.userSid) return mis('user_sid_differs_from_preflight') + return ok({ integritySid: o.integritySid, elevationType: o.elevationType }) }, // Observe binds the home to the manifest by path AND file ID for both the // home and its parent, re-checks the structure and disjointness, and @@ -512,6 +546,8 @@ const DERIVATIONS = { if (!first || !FORMATS.filetime(first.createFiletime)) return unk('baseline_create_missing') if (o.createFiletime !== first.createFiletime) return mis('create_filetime_changed') if (o.integritySid !== first.integritySid) return mis('integrity_sid_changed') + if (!FORMATS.elevationType(first.elevationType)) return unk('baseline_elevation_type_unusable') + if (o.elevationType !== first.elevationType) return mis('elevation_type_changed') if (!ctx.manifest) return unk('no_manifest') if (windowsPathRelation(o.image, ctx.manifest.binaryPath) !== 'same') return mis('image_differs_from_manifest_binary') return ok(o.createFiletime) @@ -521,7 +557,7 @@ const DERIVATIONS = { if (!ctx.codexLabel) return unk('codex_label_missing') if (o.tokenOpened !== true) return unk('token_not_opened') if (o.integritySid !== INTEGRITY[ctx.codexLabel]) return mis('codex_integrity_differs_from_label') - return ok(o.integritySid) + return ok({ integritySid: o.integritySid, elevationType: o.elevationType }) }, processAfterLive(rec, _s, ctx) { const o = rec.observed @@ -532,6 +568,9 @@ const DERIVATIONS = { if (!FORMATS.filetime(first?.createFiletime)) return unk('baseline_create_missing') if (o.createFiletime !== first.createFiletime) return mis('create_filetime_changed') if (o.userSid !== first.userSid) return mis('target_user_changed') + if (o.integritySid !== first.integritySid) return mis('integrity_sid_changed') + if (!FORMATS.elevationType(first.elevationType)) return unk('baseline_elevation_type_unusable') + if (o.elevationType !== first.elevationType) return mis('elevation_type_changed') return ok(o.createFiletime) }, threadStable(rec, sample, ctx) { @@ -608,6 +647,7 @@ export function validateObserverFile(records, mode, problems = [], samples = nul const limit = validSampleCount(samples) ? samples : MAX_SAMPLES const sids = new Set() const labels = new Set() + const elevations = new Set() const seen = new Set() const order = new Map() const sampleSeq = new Map() @@ -628,12 +668,16 @@ export function validateObserverFile(records, mode, problems = [], samples = nul } else if (sample !== null) out.problems.push(`sample_unexpected:${r.step}#${sample}`) sids.add(r.integrity?.sid ?? null) labels.add(r.integrity?.label ?? null) + const elevation = r.integrity?.elevationType + if (!FORMATS.elevationType(elevation)) out.problems.push(`elevation_type_unusable:${r.step}`) + elevations.add(elevation) const key = `${r.step}#${sample ?? ''}` if (seen.has(key)) out.problems.push(`duplicate:${key}`) seen.add(key) if (!order.has(r.step)) order.set(r.step, index) order.set(`last:${r.step}`, index) }) + if (elevations.size > 1) out.problems.push('mixed_elevation_type') // Per-sample steps must appear once per sample, in increasing sample order. if (validSampleCount(samples)) { for (const step of sampled) { @@ -744,9 +788,9 @@ function rowFor(exp, records, samples, fileUsable, ctx) { const rec = records.find((r) => r.step === exp.step && (r.sample ?? null) === sample) if (rec) collected++ let reason = unmeasuredReason(rec, exp.mode, exp.hypothesis) - // Drift only applies to raw-comparison rows; derived and recorded rows have - // no table hypothesis to drift from. - if (!reason && exp.hypothesis && !sameExpectation(exp.hypothesis, rec.expect)) reason = 'expectation_drift' + // Every row sharing this step checks the entire canonical union, including + // derived/recorded rows. Additional producer keys cannot go unexamined. + if (!reason && !sameExpectation(canonicalExpectation(exp.mode, exp.step), rec.expect)) reason = 'expectation_drift' if (reason) { row.reasons.push(`${reason}${tag}`) verdicts.push('unknown') diff --git a/experimental/windows-native-observer/observer.ps1 b/experimental/windows-native-observer/observer.ps1 index 18a6e95..bfee93d 100644 --- a/experimental/windows-native-observer/observer.ps1 +++ b/experimental/windows-native-observer/observer.ps1 @@ -1,7 +1,7 @@ <# Read-only Windows observer for codex-unlock research W6/W7 (#151, #152). -Windows PowerShell 5.1. WinObserver.cs is compiled by Add-Type. Three modes: +Windows PowerShell 5.1. All three modes below are dormant and hard-refused: preflight verify the Codex binary by SHA-256 before any execution, then its version; measure token integrity; record host policy and @@ -17,8 +17,9 @@ Windows PowerShell 5.1. WinObserver.cs is compiled by Add-Type. Three modes: powershell -NoProfile -NonInteractive -File observer.ps1 -Mode observe -IntegrityLabel -CodexIntegrityLabel -Manifest -CodexPid -OutFile powershell -NoProfile -NonInteractive -File observer.ps1 -Mode fixture-meta -IntegrityLabel -SourceCommit <40 hex> -OutFile -HARD REFUSAL (incomplete prototype gate): -Mode preflight and -Mode observe -refuse to run. The ancestor/junction and creation boundary is NOT closed: the +HARD REFUSAL (incomplete prototype gate): preflight, observe and fixture-meta +all refuse before setup path checks, compilation or observation. The +ancestor/junction and creation boundary is NOT closed: the no-follow ancestry check, the identity comparisons and the creation gate all compare identities at separate points in time instead of pinning a path, so a same-user actor could still swap an ancestor between the check and the use. No @@ -84,9 +85,9 @@ $AclType = 'Allow' # Add-Type starts is NOT covered by this allowlist: it inherits this process's # environment, which is why no credential may be exported into this shell. $ChildEnvAllowlist = @('SystemRoot', 'SystemDrive', 'windir', 'COMSPEC', 'PATHEXT', 'NUMBER_OF_PROCESSORS', 'PROCESSOR_ARCHITECTURE') -# The native modes stay refused while the ancestry/creation boundary is open. +# All modes stay refused while the ancestry/creation boundary is open. $NATIVE_MODES_REFUSED = $true -$NativeRefusalReason = 'native preflight and observe are refused in this prototype: the ancestor/junction and creation boundary is not closed (identities are compared at separate points in time, not pinned), so no native observation is approved. There is no bypass flag; see README remaining decision 10.' +$NativeRefusalReason = 'all modes (preflight, observe and fixture-meta) are refused in this prototype: the ancestor/junction and creation boundary is not closed, so no observation is approved. There is no bypass flag; see README remaining decision 10.' # Union of the expectations.mjs hypotheses per step. evaluate.mjs re-checks # every record against its own table and reports drift as unknown. @@ -364,6 +365,20 @@ function Test-Expect($observed, $expect) { 'match' } +# Pure preflight predicate. A failed/partial policy record cannot permit +# downstream binary hashing, directory creation or execution. This does not +# approve a particular policy or change the host policy. +function Get-ExecutionPolicyFailure($observed) { + if ((Test-Expect $observed $null) -ne 'recorded') { return 'execution_policy_unusable' } + if ($observed.effective -isnot [string] -or [string]::IsNullOrWhiteSpace($observed.effective) -or + $observed.scopes -isnot [Array] -or $observed.scopes.Count -eq 0) { return 'execution_policy_incomplete' } + foreach ($entry in $observed.scopes) { + if ($entry.scope -isnot [string] -or [string]::IsNullOrWhiteSpace($entry.scope) -or + $entry.policy -isnot [string] -or [string]::IsNullOrWhiteSpace($entry.policy)) { return 'execution_policy_incomplete' } + } + $null +} + function Test-SameDigests($a, $b) { if ($null -eq $a -or $null -eq $b) { return $false } $na = @($a.PSObject.Properties | ForEach-Object { $_.Name } | Sort-Object) @@ -411,6 +426,9 @@ function Assert-PathGate { # ---- Refusals: nothing is created, compiled or observed until these pass -- try { + # Deliberately mode-independent and first: even fixture-meta must not + # reach path checks, compiler scratch or token observation in this state. + if ($NATIVE_MODES_REFUSED) { Refuse $NativeRefusalReason } if (-not $Mode) { Refuse '-Mode is required (preflight, observe, fixture-meta)' } if (-not $IntegrityLabel) { Refuse '-IntegrityLabel is required (medium or high, the level this session is expected to have)' } if (-not $OutFile) { Refuse '-OutFile is required (new file)' } @@ -469,11 +487,6 @@ try { if ($null -eq (Get-Command Get-MpComputerStatus -ErrorAction SilentlyContinue)) { Refuse 'Get-MpComputerStatus is required to record the existing Defender state; it is never changed' } } - # ---- Hard refusal of the native modes (incomplete prototype gate) ---- - # The ancestor/junction and creation boundary is not closed, so no native - # preflight or observe run is approved. There is no bypass. - if ($NATIVE_MODES_REFUSED -and ($Mode -eq 'preflight' -or $Mode -eq 'observe')) { Refuse $NativeRefusalReason } - # ---- Compilation (after the path and TEMP gate) ---------------------- # Add-Type starts the .NET compiler as a child process and writes compiler # temporaries into the gated TEMP. No observer method is called here. @@ -735,6 +748,8 @@ try { scopes = @(Get-ExecutionPolicy -List | ForEach-Object { [pscustomobject]@{ scope = $_.Scope.ToString(); policy = $_.ExecutionPolicy.ToString() } }) } } catch { $pol = [pscustomobject]@{ ok = $false; exception = $_.Exception.Message } } Record 'host.execution_policy' $pol | Out-Null + $policyFailure = Get-ExecutionPolicyFailure $pol + if ($policyFailure -and -not $Abort) { $Abort = $policyFailure } Record 'host.os' ([pscustomobject]@{ ok = $true; os = [Environment]::OSVersion.VersionString; ps = $PSVersionTable.PSVersion.ToString() elevated = (New-Object Security.Principal.WindowsPrincipal ([Security.Principal.WindowsIdentity]::GetCurrent())).IsInRole('Administrators') sshSession = [bool]$env:SSH_CONNECTION }) | Out-Null @@ -1067,18 +1082,14 @@ try { $threadOk = ($before.ok -eq $true -and $before.reparse -eq $false -and $before.directory -eq $false -and $before.links -eq 1 -and $before.deletePending -eq $false -and $null -ne $before.fileId) if (-not $threadOk) { return } - $rm = Native { [WinObserver]::RmList([string[]]@($thread, $guard)) } - if ($rm.ok -eq $true -and $rm.complete -eq $true) { - foreach ($entry in @($rm.processes)) { - $entry | Add-Member -NotePropertyName role -NotePropertyValue ( - Get-OpenerRole ([int]$entry.pid) "$($entry.startFiletime)" $CodexPid "$create0" $PID) - } + # Deferred even in this dormant producer: separate path-based + # RM/share calls do not bind the validated handles and are not + # covered by a common guard. Do not issue them until that + # contract has been implemented and separately reviewed. + foreach ($deferredStep in 'openers.rm', 'share.read_rwd', 'share.delete_rwd') { + Record $deferredStep ([pscustomobject]@{ ok = $true; state = 'unknown'; + deferred = $true; reason = 'guarded_handle_binding_not_implemented' }) $s | Out-Null } - Record 'openers.rm' $rm $s | Out-Null - # Only full-sharing opens; restrictive-sharing probes are a - # separately proposed, NOT-executed experiment (see README). - Record 'share.read_rwd' (Native { [WinObserver]::ShareProbe($thread, 'read', 'rwd') }) $s | Out-Null - Record 'share.delete_rwd' (Native { [WinObserver]::ShareProbe($thread, 'delete', 'rwd') }) $s | Out-Null # Bind the guarded range probe to the validated expected # identities (lock directory, coordination guard and thread # lock) so it refuses a changed target before locking. The diff --git a/experimental/windows-native-observer/test/evaluate.test.mjs b/experimental/windows-native-observer/test/evaluate.test.mjs index addefeb..6bfcabd 100644 --- a/experimental/windows-native-observer/test/evaluate.test.mjs +++ b/experimental/windows-native-observer/test/evaluate.test.mjs @@ -8,7 +8,7 @@ import { test } from 'node:test' import { fileURLToPath } from 'node:url' import { EXPECTATIONS, HARNESS_FILES, INTEGRITY, SCHEMA, STEP_SCHEMAS, normalizeLocalPath } from '../expectations.mjs' import { - deepEqualMap, evaluateRun, fixtureSummary, guardedCreate, guardedSample, hasAccessDenied, insideDisposableDir, + canonicalExpectation, deepEqualMap, evaluateRun, fixtureSummary, guardedCreate, guardedSample, hasAccessDenied, insideDisposableDir, integrityFromSid, pairSummaries, parseJsonl, pathGateSafe, recomputeIsolation, reconstructManifest, renderExpectationTable, sidFromWhoamiLine, tempRootSafeForCreation, validateBaseline, windowsPathRelation, } from '../lib.mjs' @@ -84,15 +84,18 @@ function ranges(overrides = {}) { // The union of the raw table hypotheses for a mode+step, mirroring what // observer.ps1 records as its own advisory expectation (dotted keys literal). function expectFor(mode, step) { - const e = {} - for (const x of EXPECTATIONS) if (x.mode === mode && x.step === step && x.hypothesis) Object.assign(e, x.hypothesis) - return Object.keys(e).length > 0 ? e : null + return canonicalExpectation(mode, step) } function rec(mode, step, label, observed, sample = null) { + // Default synthetic type 1 at either integrity level. A label must never + // imply Full/Limited elevation: dedicated tests vary measured enum values. + if (observed && ['token.self', 'process.identity', 'process.sample', 'process.identity_after'].includes(step)) { + observed = { elevationType: 1, ...observed } + } return { schema: SCHEMA, mode, step, sample, - integrity: { label, sid: INTEGRITY[label], elevationType: label === 'high' ? 2 : 3, sessionId: 1 }, + integrity: { label, sid: INTEGRITY[label], elevationType: 1, sessionId: 1 }, expect: expectFor(mode, step), observed, verdict: 'match', safeToUnlock: false, } } @@ -967,14 +970,23 @@ test('Add-Type runs only after the path and TEMP gate in both scripts', () => { } }) -test('native preflight and observe stay hard-refused while the ancestry boundary is open', () => { +test('all observer modes stay hard-refused before path lookups while the boundary is open', () => { assert.ok(/NATIVE_MODES_REFUSED\s*=\s*\$true/.test(PS), 'observer.ps1 lost its hard refusal switch') assert.ok(PS.includes('Refuse $NativeRefusalReason'), 'observer.ps1 does not refuse the native modes') + assert.ok(PS_CODE.includes('if ($NATIVE_MODES_REFUSED) { Refuse $NativeRefusalReason }'), 'mode-dependent refusal could omit fixture-meta') + assert.ok(PS_CODE.indexOf('if ($NATIVE_MODES_REFUSED) { Refuse $NativeRefusalReason }') < PS_CODE.indexOf('$PathGate = Assert-PathGate')) // No bypass: nothing may turn the refusal off from the command line. assert.ok(!/param\([^)]*Force/is.test(PS), 'observer.ps1 gained a force-style parameter') assert.ok(!/\$env:[A-Z_]*(FORCE|BYPASS|ALLOW)/i.test(PS), 'observer.ps1 reads a bypass environment variable') }) +test('dormant RM/share observations are deferred rather than run outside a common guard', () => { + assert.ok(!/\[WinObserver\]::(?:RmList|ShareProbe)\s*\(/i.test(PS_CODE)) + assert.ok(PS_CODE.includes('guarded_handle_binding_not_implemented')) + assert.ok(CHECK_CODE.includes('Get-DeferredObservationCalls')) + assert.ok(CHECK_CODE.includes('Get-ExecutionPolicyFailure')) +}) + test('the creation gate refuses a TEMP inside a real home and skips creation', () => { assert.equal(tempRootSafeForCreation(TEMP, USER_HOME, null), true) assert.equal(tempRootSafeForCreation(`${USER_HOME}\\AppData\\Temp`, USER_HOME, null), false) @@ -1152,6 +1164,124 @@ test('W6 comparison completion does not turn W7-only failures into success', () assert.ok(!paired.rows.some((r) => r.id === 'OB-range-beyond-fsext')) }) +test('missing or unusable measured elevation can never complete W6 comparison', () => { + for (const [mode, step, id2] of [ + ['preflight', 'token.self', 'PF-token'], ['observe', 'token.self', 'OB-token'], + ['observe', 'process.identity', 'OB-process-token'], + ]) { + for (const value of [undefined, null, 0, 4, '2']) { + const run = idealRun('medium') + const o = find(run[mode].records, step).observed + if (value === undefined) delete o.elevationType + else o.elevationType = value + const summary = evaluate(run) + assert.equal(row(summary, id2).verdict, 'unknown', `${mode}:${step}:${value}`) + assert.notEqual(pairSummaries(summary, evaluate(idealRun('high'))).status, 'compared') + } + } +}) + +test('W6 compares measured elevation differences without inferring them from integrity labels', () => { + const summaries = [['medium', 3], ['high', 2]].map(([label, value]) => { + const run = idealRun(label) + for (const file of [run.preflight, run.observe]) { + for (const r of file.records) { + r.integrity.elevationType = value + if (Object.hasOwn(r.observed, 'elevationType')) r.observed.elevationType = value + } + } + return evaluate(run, { expectedLabel: label }) + }) + for (const [s, value] of [[summaries[0], 3], [summaries[1], 2]]) { + for (const id2 of ['PF-token', 'OB-token', 'OB-process-token']) { + assert.equal(row(s, id2).verdict, 'match') + assert.deepEqual(row(s, id2).values, [{ elevationType: value }]) + } + } + const paired = pairSummaries(...summaries) + assert.equal(paired.status, 'unknown') + assert.deepEqual(paired.unexplained.sort(), ['OB-process-token', 'OB-token', 'PF-token']) + const explanations = Object.fromEntries(paired.unexplained.map((id2) => [id2, 'Synthetic measured elevation differs; no value inferred from a label.'])) + assert.equal(pairSummaries(...summaries, explanations).status, 'compared') +}) + +test('elevation metadata binds to the file, preflight token and target instance', () => { + const mixed = idealRun('medium') + mixed.observe.records[0].integrity.elevationType = 2 + assert.ok(evaluate(mixed).problems.includes('observe:mixed_elevation_type')) + const self = idealRun('medium') + find(self.preflight.records, 'token.self').observed.elevationType = 2 + assert.equal(row(evaluate(self), 'PF-token').verdict, 'mismatch') + const changed = idealRun('medium') + for (const r of changed.observe.records) { + r.integrity.elevationType = 2 + if (r.step === 'token.self') r.observed.elevationType = 2 + } + assert.ok(row(evaluate(changed), 'OB-token').reasons.includes('elevation_type_differs_from_preflight')) + for (const [step, id2] of [['process.sample', 'OB-process-sample'], ['process.identity_after', 'OB-process-after']]) { + const run = idealRun('medium') + find(run.observe.records, step, step === 'process.sample' ? 1 : null).observed.elevationType = 2 + assert.equal(row(evaluate(run), id2).verdict, 'mismatch') + } +}) + +test('unexplained range/share errors are unknown while valid contrary measurements remain mismatches', () => { + for (const code of [2, 5, 6, 32, 50, 87]) { + const run = idealRun('medium') + find(run.observe.records, 'range.probe', 1).observed.ranges.rust = { win32: code, acquired: false, released: null } + assert.equal(row(evaluate(run), 'OB-range-rust').verdict, 'unknown', `range ${code}`) + } + const free = idealRun('medium') + find(free.observe.records, 'range.probe', 1).observed.ranges.rust = { win32: 0, acquired: true, released: true } + assert.equal(row(evaluate(free), 'OB-range-rust').verdict, 'mismatch') + for (const [step, id2] of [['share.read_rwd', 'OB-share-read-rwd'], ['share.delete_rwd', 'OB-share-delete-rwd']]) { + for (const code of [2, 5, 6, 33, 50, 87]) { + const run = idealRun('medium') + Object.assign(find(run.observe.records, step, 1).observed, { opened: false, win32: code }) + assert.equal(row(evaluate(run), id2).verdict, 'unknown', `share ${code}`) + } + const denied = idealRun('medium') + Object.assign(find(denied.observe.records, step, 1).observed, { opened: false, win32: 32 }) + assert.equal(row(evaluate(denied), id2).verdict, 'mismatch') + } +}) + +test('non-NTFS filesystem evidence cannot satisfy the W7 environment precondition', () => { + for (const fileSystem of ['ReFS', 'FAT32', 'unknown', null]) { + const run = idealRun('medium') + find(run.preflight.records, 'host.volume').observed.fileSystem = fileSystem + assert.equal(row(evaluate(run), 'PF-volume').verdict, 'unknown') + assert.ok(evaluate(run).blockers.includes('PF-volume')) + } +}) + +test('producer expectations require the exact canonical union on raw, derived and recorded steps', () => { + assert.deepEqual(canonicalExpectation('observe', 'range.probe'), { + state: 'probed', 'ranges.rust.win32': 33, 'ranges.fsext.win32': 33, 'ranges.beyond_fsext.win32': 33, + }) + assert.deepEqual(canonicalExpectation('preflight', 'binary.version'), { matches: true, exitCode: 0 }) + assert.equal(canonicalExpectation('preflight', 'host.os'), null) + for (const [mode, step, id2, sample] of [ + ['observe', 'share.read_rwd', 'OB-share-read-rwd', 1], + ['observe', 'range.probe', 'OB-range-rust', 1], + ['preflight', 'binary.version', 'PF-binary-version', null], + ['preflight', 'host.os', 'PF-os', null], + ]) { + const run = idealRun('medium') + const r = find(run[mode].records, step, sample) + r.expect = { ...r.expect, unexplainedAdditionalHypothesis: false } + r.verdict = 'mismatch' + const result = row(evaluate(run), id2) + assert.equal(result.verdict, 'unknown') + assert.ok(result.reasons.some((r) => r.startsWith('expectation_drift'))) + } + for (const bad of [null, [], {}, { state: 'probed' }]) { + const run = idealRun('medium') + find(run.observe.records, 'range.probe', 1).expect = bad + assert.equal(row(evaluate(run), 'OB-range-rust').verdict, 'unknown') + } +}) + test('missing whole steps, late process re-query and interleaved samples are unknown', () => { const missing = idealRun('medium') missing.observe.records = missing.observe.records.filter((r) => r.step !== 'range.probe') diff --git a/experimental/windows-native-observer/test/schema-review.test.mjs b/experimental/windows-native-observer/test/schema-review.test.mjs new file mode 100644 index 0000000..42b5265 --- /dev/null +++ b/experimental/windows-native-observer/test/schema-review.test.mjs @@ -0,0 +1,84 @@ +// Offline records only: no Windows/native APIs, processes or files. +import assert from 'node:assert/strict' +import { test } from 'node:test' +import { STEP_SCHEMAS } from '../expectations.mjs' +import { unmeasuredReason } from '../lib.mjs' + +const fileId = '0000000000000001:00000000000000000000000000000001' +const token = { + ok: true, pid: 123, integritySid: 'S-1-16-8192', userSid: 'S-1-5-21-123-1001', + tokenOpened: true, elevationType: 3, +} +const processRecord = { + ...token, opened: true, state: 'live', image: 'C:\\tools\\codex.exe', + createFiletime: '132000000000000000', +} + +function reason(mode, step, observed) { + return unmeasuredReason({ step, observed }, mode, null) +} + +test('every self/target token requires actual valid elevation-type evidence', () => { + for (const [mode, step, base] of [ + ['preflight', 'token.self', token], ['observe', 'token.self', token], + ['observe', 'process.identity', processRecord], ['observe', 'process.sample', processRecord], + ['observe', 'process.identity_after', processRecord], + ]) { + for (const elevationType of [1, 2, 3]) { + assert.equal(reason(mode, step, { ...base, elevationType }), null, `${mode}:${step}/${elevationType}`) + } + for (const elevationType of [undefined, null, 0, 4, -1, 1.5, '2', true]) { + const observed = { ...base, elevationType } + if (elevationType === undefined) delete observed.elevationType + assert.match(reason(mode, step, observed), /^schema_(missing|format):elevationType$/) + } + } +}) + +function rangeRecord() { + return { + ok: true, state: 'probed', dirFileIdBefore: fileId, dirFileIdAfter: fileId, + guardFileId: fileId, guardFileIdAfter: fileId, guardLinks: 1, guardDeletePending: false, + threadFileIdBefore: fileId, threadFileIdAfter: fileId, threadLinks: 1, threadDeletePending: false, + guardAcquired: true, guardReleased: true, + ranges: Object.fromEntries(['rust', 'fsext', 'beyond_fsext', 'tail'].map((name) => + [name, { win32: 33, acquired: false, released: null }])), + } +} + +test('only success or lock violation yields measured range state, including the tail', () => { + for (const name of ['rust', 'fsext', 'beyond_fsext', 'tail']) { + for (const win32 of [0, 33]) { + const observed = rangeRecord() + observed.ranges[name] = { win32, acquired: win32 === 0, released: win32 === 0 ? true : null } + assert.equal(reason('observe', 'range.probe', observed), null) + } + for (const win32 of [1, 2, 3, 5, 32, 50, 87, 995]) { + const observed = rangeRecord() + observed.ranges[name] = { win32, acquired: false, released: null } + assert.notEqual(reason('observe', 'range.probe', observed), null, `${name}/${win32}`) + assert.equal(STEP_SCHEMAS['observe:range.probe'].invariants.ranges_consistent(observed), false) + } + } +}) + +test('operational share-open failures are unknown rather than measured sharing mismatches', () => { + for (const [step, access] of [['share.read_rwd', 'read'], ['share.delete_rwd', 'delete']]) { + for (const win32 of [0, 32]) { + assert.equal(reason('observe', step, { ok: true, opened: win32 === 0, win32, access, share: 'rwd' }), null) + } + for (const win32 of [1, 2, 3, 5, 33, 50, 87, 995]) { + const observed = { ok: true, opened: false, win32, access, share: 'rwd' } + assert.notEqual(reason('observe', step, observed), null, `${step}/${win32}`) + assert.equal(STEP_SCHEMAS[`observe:${step}`].invariants.interpretable_result(observed), false) + } + } +}) + +test('W7 filesystem evidence must satisfy the NTFS environment precondition', () => { + const base = { ok: true, probePath: 'C:\\temp\\private\\.volume-probe' } + assert.equal(reason('preflight', 'host.volume', { ...base, fileSystem: 'NTFS' }), null) + for (const fileSystem of ['ReFS', 'FAT32', 'exFAT', 'ntfs', '', null, undefined]) { + assert.notEqual(reason('preflight', 'host.volume', { ...base, fileSystem }), null) + } +})