Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
106 changes: 106 additions & 0 deletions .github/workflows/macos-recovery-prerequisites.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
name: macOS recovery prerequisites

on:
pull_request:
branches: [main]
paths:
- '.github/workflows/macos-recovery-prerequisites.yml'
- 'experimental/127-macos-contract/**'
- 'experimental/127-macos-prerequisites/**'
push:
branches: [main]
paths:
- '.github/workflows/macos-recovery-prerequisites.yml'
- 'experimental/127-macos-contract/**'
- 'experimental/127-macos-prerequisites/**'
workflow_dispatch:

permissions:
contents: read

jobs:
synthetic-evidence:
name: macOS prerequisite fixtures (${{ matrix.runner }}, Rust ${{ matrix.toolchain }})
timeout-minutes: 15
strategy:
fail-fast: false
matrix:
runner: [macos-15, macos-15-intel]
toolchain: ['1.90.0', '1.95.0']
runs-on: ${{ matrix.runner }}
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: Check malformed-input and expectation regressions
env:
RESEARCH_TMP_ROOT: ${{ runner.temp }}
run: |
python3 -B -m unittest discover -s experimental/127-macos-prerequisites/rust-launcher -p 'test_*.py'
python3 -B -m unittest discover -s experimental/127-macos-prerequisites/exec-identity -p 'test_*.py'
- name: Install pinned research toolchain
env:
RESEARCH_TOOLCHAIN: ${{ matrix.toolchain }}
run: |
rustup toolchain install "$RESEARCH_TOOLCHAIN" --profile minimal --component clippy
rustc "+$RESEARCH_TOOLCHAIN" --version --verbose
- name: Build unpublished Rust launcher fixture
working-directory: experimental/127-macos-prerequisites/rust-launcher
env:
RESEARCH_TOOLCHAIN: ${{ matrix.toolchain }}
run: |
cargo "+$RESEARCH_TOOLCHAIN" clippy --locked --offline --all-targets -- -D warnings
cargo "+$RESEARCH_TOOLCHAIN" build --locked --offline
- name: Measure Rust Command descriptor inheritance
run: |
python3 -B experimental/127-macos-prerequisites/rust-launcher/run.py \
--binary "$GITHUB_WORKSPACE/experimental/127-macos-prerequisites/rust-launcher/target/debug/macos-command-inheritance-lab" \
--scratch-root "$RUNNER_TEMP" --result "$RUNNER_TEMP/rust-inheritance.json"
- name: Measure same-PID exec identity
if: ${{ !cancelled() && matrix.toolchain == '1.95.0' }}
run: |
python3 -B experimental/127-macos-prerequisites/exec-identity/run.py \
--tmp-root "$RUNNER_TEMP" --result "$RUNNER_TEMP/exec-identity.json"
- name: Record bounded evidence summary
if: ${{ !cancelled() }}
env:
EVIDENCE_ROOT: ${{ runner.temp }}
RESEARCH_TOOLCHAIN: ${{ matrix.toolchain }}
run: |
python3 -B - <<'PY'
import hashlib, json, os
from pathlib import Path
for name in ('rust-inheritance.json', 'exec-identity.json'):
if name == 'exec-identity.json' and os.environ['RESEARCH_TOOLCHAIN'] != '1.95.0':
continue
p = Path(os.environ['EVIDENCE_ROOT']) / name
if not p.is_file():
print(json.dumps({'result': name, 'present': False}))
continue
try:
raw = p.read_bytes()
r = json.loads(raw)
except (OSError, ValueError) as error:
print(json.dumps({'result': name, 'parseError': type(error).__name__}))
continue
print(json.dumps({'result': name, 'sha256': hashlib.sha256(raw).hexdigest(),
'ok': r.get('ok'), 'checks': r.get('checks'),
'sourceHashes': {k: r[k] for k in ('script_sha256', 'shared_sha256', 'binary_sha256',
'sourceSha256', 'runnerSha256') if k in r},
'platform': r.get('environment', r.get('platform')),
'exitCode': r.get('exitCode'), 'childExitCodes': r.get('childExitCodes'),
'measurements': [{'mode': m.get('mode'), 'stage': m.get('stage'),
'probe': m.get('probe'), 'roles': m.get('roles'),
'matches': m.get('matches')} for m in r.get('measurements', [])],
'releaseProbes': [c.get('after_exit_probe') for c in r.get('children', [])],
'startup': [{'mode': h.get('mode'), 'event': h.get('record', {}).get('event'),
'has_fd': h.get('record', {}).get('has_fd'),
'initial_fd_flags': h.get('record', {}).get('initial_fd_flags'),
'pre_spawn_fd_flags': h.get('record', {}).get('pre_spawn_fd_flags')}
for h in r.get('startup_events', [])],
'errorCount': len(r.get('errors', [])),
'errorKinds': [(':'.join(str(e).split(':')[:2]) if str(e).split(':')[0]
in ('default', 'inheritable') else str(e).split(':')[0])[:100]
for e in r.get('errors', [])[:10]],
'incompleteCount': len(r.get('incomplete', r.get('pendingPids', [])))}))
PY
6 changes: 3 additions & 3 deletions docs/macos-owner-contract.md
Original file line number Diff line number Diff line change
Expand Up @@ -193,12 +193,12 @@ their own lifetime, zero signals, and asserts zero SIGTERM for refused cases.
| Area | Item | Status | Narrow fixture or test plan | Needed before |
| --- | --- | --- | --- | --- |
| Inherited FD | fork without exec, both alive | Measured (section 5) | — | — |
| Inherited FD | exec of inheritable FD; `O_CLOEXEC` across exec | Measured with Python's launcher (section 5) | Repeat with a Rust `std::process::Command` child to cover the launcher Codex uses | (b) |
| Inherited FD | exec of inheritable FD; `O_CLOEXEC` across exec | Measured with Python's launcher (section 5); [Rust fixture](../experimental/127-macos-prerequisites/rust-launcher/README.md) tracks Rust 1.90/1.95 through a dedicated CI matrix | Check the workflow/PR evidence for the exact revision; fixture execution does not audit all actual Codex launch paths | (b) |
| Inherited FD | `SCM_RIGHTS` passing to a sibling process | Measured (section 5) | — | — |
| Inherited FD | Mach fileport passing | Not covered | C fixture: `fileport_makeport` → `fileport_makefd` in a separately started same-user actor; record bit and probe | (b) |
| Inherited FD | Shared description with a visible Codex-looking sharer and a second sharer hidden | Not covered | Combine `scm_rights` with the M2 modelled omission; product must not report `safeToUnlock: true` with only one sharer killed. Expected today: possible wrong target (document, do not "fix" in the test) | (b) |
| Inherited FD | Real Codex inheritance / fork paths | Not covered (source-only) | Out of scope without a real Codex binary; requires a separately approved, isolated, synthetic-home run | Holder-positive mode |
| Process identity | Holder execs in place (same PID, new image) while keeping the FD | Not covered on macOS | Actor `execve`s itself with an inheritable lock FD between two observations; assert refusal via changed command/arguments | (b) |
| Process identity | Holder execs in place (same PID, new image) while keeping the FD | [Synthetic OS observation](../experimental/127-macos-prerequisites/exec-identity/README.md) measured on macOS 27 arm64: PID/start unchanged, path/argv changed, lock retained | Production revalidation/refusal still unverified; the fixture measures OS fields and does not call the product's signal path | (b) |
| Process identity | PID reuse, start-time resolution of `ps lstart` | Covered by deterministic revalidation tests ([safety-race-matrix.md](safety-race-matrix.md)); real PID reuse not forced | Unit test with an injected process table where PID and second-resolution start time match but PPID/arguments differ | — |
| Process identity | Credential change of a holder after locking (setuid helper) | Not covered. The existing `--privileged-fixtures` CI job (`scripts/research-lock-attribution.py`) measures a hidden holder that runs under a different UID from the start (#100); it does not change a holder's credentials after it locks | A post-lock credential-change fixture needs a privileged helper; it could be added to that existing job | (c) decision input |
| Permission | Same-user process whose libproc FD info is denied (sandboxed or hardened target) | Not covered | Holder launched under `sandbox-exec` with a deny-default profile; record lsof status, stderr, and whether the record is omitted cleanly | (b) |
Expand All @@ -207,7 +207,7 @@ their own lifetime, zero signals, and asserts zero SIGTERM for refused cases.
| Filesystem | APFS | Measured | — | — |
| Filesystem | HFS+, ExFAT, MS-DOS local volumes | Not covered | `hdiutil create -fs <fs>` image in the scratch root, attached with `-nobrowse -mountpoint` under the scratch; needs approval because it changes the mount table | (b) |
| Filesystem | SMB/NFS/AFP and FUSE (macFUSE, rclone) | Not covered | Network/FUSE flock may be local-only or emulated. Plan: report these as unsupported for unlock unless measured; detect by `statfs` type | (c) decision input |
| OS version | macOS 15 CI (kernel 24.x) sticky-bit behavior | Not covered (only foreign-UID visibility measured there) | Run `experimental/127-macos-contract/inherit.py` and #139 M2 in a research-only CI job on `macos-15` and `macos-15-intel` | (b) |
| OS version | macOS 15 CI (kernel 24.x) sticky-bit behavior | Not covered (only foreign-UID visibility measured there); the new launcher/exec workflow does not test sticky former-holder behavior | Run `experimental/127-macos-contract/inherit.py` and #139 M2 in a research-only CI job on `macos-15` and `macos-15-intel` | (b) |

## 5. Synthetic validation (#127 fixtures)

Expand Down
28 changes: 22 additions & 6 deletions docs/windows-rust-evaluation.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ product-support result, and none establishes owner attribution or safe unlock.

| Item | Value |
| --- | --- |
| Release | `rust-v0.163.0` (GitHub "Latest", not a pre-release, published 2026-10-15T21:30:38Z) |
| Release | `rust-v0.163.0` (GitHub "Latest" as checked on 2026-10-16, not a pre-release, published 2026-10-15T21:30:38Z) |
| Annotated tag object | `129b8ec7ee6f4511557a4856279f529025c25af4` |
| Commit | `83086188ae0614bd882818ff1aa23f3194962012` |
| Upstream toolchain | Rust 1.95.0 (`codex-rs/rust-toolchain.toml`) |
Expand Down Expand Up @@ -201,7 +201,7 @@ issue with the gates below.

## 5. Follow-up units

Each unit should become a separate issue. None authorizes Windows unlock. Every
Each unit is tracked in a separate issue. None authorizes Windows unlock. Every
experimental record keeps `safeToUnlock=false` until every applicable W1-W7
gate for the chosen design and supported environment is resolved and a separate
production design is accepted. These are future work units; this document
Expand All @@ -216,6 +216,8 @@ skip W4/W5 or promote it to a recovery tool.

### W1. File identity on NTFS and ReFS

Tracking: [#146](https://github.com/abruption/codex-unlock/issues/146).

- **Scope:** stable identity for the Codex home, `thread-writer-locks`,
`.coordination.lock`, thread lock files and transcripts: volume serial plus
128-bit `FILE_ID_INFO`, link count, and attributes. Covers no-follow opening
Expand All @@ -235,6 +237,8 @@ skip W4/W5 or promote it to a recovery tool.

### W2. SID and owner checks

Tracking: [#147](https://github.com/abruption/codex-unlock/issues/147).

- **Scope:** owner SID and DACL of the Codex home, the lock directory and lock
and transcript files, compared with the current token user SID. Covers
inherited versus explicit ACEs, and the fact that Administrators and
Expand All @@ -253,6 +257,8 @@ skip W4/W5 or promote it to a recovery tool.

### W3. Process identity and start time

Tracking: [#148](https://github.com/abruption/codex-unlock/issues/148).

- **Scope:** one process handle per candidate. Identity is the handle plus
`GetProcessTimes` creation time plus token SID, and liveness is
`WaitForSingleObject(h, 0)`, never `STILL_ACTIVE`. Covers revalidation of the
Expand All @@ -272,6 +278,8 @@ skip W4/W5 or promote it to a recovery tool.

### W4. Holder attribution (Windows counterpart of #100)

Tracking: [#149](https://github.com/abruption/codex-unlock/issues/149).

- **Scope:** decide whether a positive holder for a `LockFileEx` lock can be
established at all. Candidate sources are Restart Manager, handle
enumeration (which may require privileges that are out of scope) and
Expand All @@ -288,6 +296,8 @@ skip W4/W5 or promote it to a recovery tool.

### W5. Cooperative termination and release path

Tracking: [#150](https://github.com/abruption/codex-unlock/issues/150).

- **Scope:** find a Windows route that lets the owner release one thread
without force. Options are an upstream owner-side release (see
[upstream-handoff-proposal.md](upstream-handoff-proposal.md) and section 3.4)
Expand All @@ -312,6 +322,8 @@ skip W4/W5 or promote it to a recovery tool.

### W6. Medium versus High integrity tokens

Tracking: [#151](https://github.com/abruption/codex-unlock/issues/151).

- **Scope:** rerun the E1, E3 and identity cases from a Medium-integrity
interactive desktop session and compare with the High-integrity SSH results.
Covers cross-integrity `OpenProcess` access (`PROCESS_QUERY_LIMITED_INFORMATION`
Expand All @@ -325,6 +337,8 @@ skip W4/W5 or promote it to a recovery tool.

### W7. Real Codex interoperability on Windows (prerequisite for W3 and W5)

Tracking: [#152](https://github.com/abruption/codex-unlock/issues/152).

- **Scope:** the pinned upstream release (section 2) runs in a disposable
`CODEX_HOME`. A read-only observer checks the coordination and thread lock
ranges and share modes against the fixture model: Rust std `LockFileEx(0,
Expand All @@ -340,15 +354,17 @@ skip W4/W5 or promote it to a recovery tool.

The proposed recommendation is **A go, B conditional no-go, C no-go**;
no migration or product-support decision is adopted by this record.
The remaining gates are W1-W7 above. #122 can be closed once these units exist
as separate issues; that is a maintainer action not taken here. No release,
The remaining gates are W1-W7 above, tracked in #146–#152 as of 2026-10-18.
#122 is closed as the initial evaluation; its outstanding safety evidence
continues in those follow-up issues. No release,
publication or support claim follows from this document.

## 7. Not done here

No Windows host was used, and no real Codex binary, Codex home or process was
For this evaluation record, no Windows host was used, and no real Codex binary, Codex home or process was
run, opened or signaled. No Medium-integrity, ReFS or Defender run took place.
No upstream code was executed. No follow-up issues were filed.
No upstream code was executed. The later tracking update created #146–#152;
it did not execute their experiments.

## 8. Reproducing the citations

Expand Down
2 changes: 2 additions & 0 deletions experimental/127-macos-prerequisites/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
**/__pycache__/
rust-launcher/target/
40 changes: 40 additions & 0 deletions experimental/127-macos-prerequisites/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
# macOS recovery prerequisites

Research for [#127](https://github.com/abruption/codex-unlock/issues/127),
following the [owner contract](../../docs/macos-owner-contract.md).
This increment measures two prerequisites for evaluating option B, the
refusal-only `FWASLOCKED` filter. It does not implement that filter or choose
the final recovery policy.

- [Rust launcher](rust-launcher/README.md): compare default close-on-exec
file descriptors with an explicitly inheritable control across
`std::process::Command`. Observe real flock contention and per-description
flags using the existing strict system-lsof observer.
- [Exec identity](exec-identity/README.md): replace a synthetic process image
at the same PID while retaining its lock. Compare kernel start time,
executable path and argv, then observe normal exit and lock release.

All inputs are newly created fixture files and cooperating children. The
programs do not operate on real Codex homes or signal owners. A passing
measurement does not prove production policy refusal, sole holder control,
complete process visibility or atomic sampling. The Rust experiment pins
1.90.0 by default; the Codex source audited in the owner contract uses Rust
1.95.0. The CI matrix explicitly builds with both toolchains. No equivalence
of complete Codex launch paths follows from the fixture.

The dedicated workflow runs on `macos-15` and `macos-15-intel`: Rust inheritance
uses both 1.90.0 and 1.95.0; the toolchain-independent exec case runs once per
OS architecture. Its configured matrix is not execution evidence until the
corresponding run passes. Raw reports contain paths/PIDs; CI prints only a
bounded summary, source/binary hashes, compiler metadata and result hash. The
raw CI report is ephemeral; its hash alone is not a retained measurement artifact.
The scripts send no termination signals. GitHub runner cancellation, job
timeouts and runner cleanup may terminate leftover synthetic fixture processes.

Remaining prerequisites include Mach fileport transfer, hidden shared
descriptions, permission-limited observers and targets, additional lsof
versions and filesystems, and actual Codex behavior. These remain explicit
gaps in the owner contract. The same-PID experiment records observable OS
fields; it does not exercise the production revalidation decision.
Kernel start timestamps are actor-reported; `ps lstart` is independently
observed. The fixture does not detect same-path, same-argv exec.
Loading
Loading