diff --git a/.github/workflows/windows-native-observer.yml b/.github/workflows/windows-native-observer.yml new file mode 100644 index 0000000..66ca731 --- /dev/null +++ b/.github/workflows/windows-native-observer.yml @@ -0,0 +1,65 @@ +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' } + # 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" } + 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..645e2b0 --- /dev/null +++ b/experimental/windows-native-observer/README.md @@ -0,0 +1,810 @@ +# 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. + +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 +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) | 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) | +| `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 + +- **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 + 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 + 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**. 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 + 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`, 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 + 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`. +- **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 + 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) + +> **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. +> 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 +`$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: …`, + `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. + +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: 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 + `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. + +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 +**`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 | +| --- | --- | --- | +| 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 | +| 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 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 + 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 +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, 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 + 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 | +| 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 | +| 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 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`: `738d4fdd8278602f2e32faa35b6d501264a8c300c27a715b501c87acae4d3360` +- `observer.ps1`: `85a84194bef9b781a8ff3b059a87616bd0e3e46d6ae8e6b3c5eb4e4a3d477c28` +- `WinObserver.cs`: `9b44dc7403368857fd7c55cdf4a596310b7b501c1343755f3adc08758f3eb83c` +- Static output SHA-256: `3d642797f734d144fdaf559ed4e2806a119b009cae6a26198f531c27c6eebaed` + +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 +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..f15734f --- /dev/null +++ b/experimental/windows-native-observer/WinObserver.cs @@ -0,0 +1,723 @@ +// 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`. +// +// 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; +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..5c5e08d --- /dev/null +++ b/experimental/windows-native-observer/check.ps1 @@ -0,0 +1,628 @@ +<# +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" } + } + } +} + +# 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 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({ + 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 } + 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++) { + if ([object]::ReferenceEquals($statements[$i], $compilePipeline)) { $index = $i; break } + } + if ($index -lt 1) { return $false } + $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') { 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 + $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: 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) { 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 $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' + } + 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))) { + $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, 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({ + 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', 'Get-ExecutionPolicyFailure', + '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', + '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" } + } + } + + 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 (-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' } + } + + 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' } + 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' { + # 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' { + $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' } + 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..59a1b67 --- /dev/null +++ b/experimental/windows-native-observer/evaluate.mjs @@ -0,0 +1,142 @@ +// 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) { + // Do not expose paths, parser excerpts or host exception messages in a + // 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 new file mode 100644 index 0000000..f190af2 --- /dev/null +++ b/experimental/windows-native-observer/expectations.mjs @@ -0,0 +1,452 @@ +// 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. 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 + 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.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, + // 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), + 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. 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 + 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.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 + }) +} + +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'), elevationType: f('elevationType') } }, + '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') }, + // 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: { + 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'), elevationType: f('elevationType') } }, + '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'), + elevationType: f('elevationType'), + }, + 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'), + elevationType: f('elevationType'), + }, + 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', + interpretable_result: (o) => o.win32 === 0 || o.win32 === 32, + }, + }, + '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', + interpretable_result: (o) => o.win32 === 0 || o.win32 === 32, + }, + }, + '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'), + elevationType: f('elevationType'), + }, + 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', 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'], + 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: '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'], + 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', 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', 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'], + 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..3632f67 --- /dev/null +++ b/experimental/windows-native-observer/lib.mjs @@ -0,0 +1,1142 @@ +// 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 +} + +// 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) { + 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') + 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 + 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') + 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 + // 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 (!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) + }, + 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({ integritySid: o.integritySid, elevationType: o.elevationType }) + }, + 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') + 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) { + 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 elevations = 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 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) { + 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) + // 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') + 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) { + // 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) { + 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..bfee93d --- /dev/null +++ b/experimental/windows-native-observer/observer.ps1 @@ -0,0 +1,1157 @@ +<# +Read-only Windows observer for codex-unlock research W6/W7 (#151, #152). + +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 + 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): 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 +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') +# All modes stay refused while the ancestry/creation boundary is open. +$NATIVE_MODES_REFUSED = $true +$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. +$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. 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) { + 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.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 -imatch '^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' +} + +# 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) + $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 { + # 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)' } + + $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' } + } + + # ---- 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 + $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 + $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 } + # 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 + } + # 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..b699036 --- /dev/null +++ b/experimental/windows-native-observer/test/cli-errors.test.mjs @@ -0,0 +1,75 @@ +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) +}) + +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 new file mode 100644 index 0000000..6bfcabd --- /dev/null +++ b/experimental/windows-native-observer/test/evaluate.test.mjs @@ -0,0 +1,1304 @@ +// 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 { + canonicalExpectation, 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) { + 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: 1, 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', + 'C:\\Users\\OPERAT~1\\.codex\\out.jsonl', 'C:\\Users\\RUNNER~1\\Temp', 'C:\\Temp\\literal~name', '', 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) + 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', () => { + 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('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) + 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('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 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') + 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')) +}) 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) + } +})