[GH-ISSUE #2035] [Bug] Darwin ownership sweep fails captures when unrelated CS_RESTRICT processes start during capture #678

Closed
opened 2026-10-15 18:27:17 +00:00 by bsakaguchi · 3 comments
Owner

Originally created by @Ghost-Kang on GitHub (Oct 12, 2026).
Original GitHub issue: https://github.com/morluto/rea/issues/2035

Before submitting

  • I searched the open and closed issues for a related report. (Closest: #1056, fixed by #1057, which covers platform binaries and foreign-uid processes but not this case.)
  • I removed credentials, tokens, private paths, and sensitive target data.

Area

Process capture or runtime observation

Observed behavior

On a macOS host with ordinary desktop apps running (Chrome, Electron apps, etc.), capture_process_scenario / capture-process intermittently fail with cleanup_incomplete even though the captured child exited normally (exit code 0, settlement quiesced) and no owned process remains:

owned_process_group: process ownership token could not be read for 1 live process(es): environment_unavailable=1; live candidates 26437=environment_unavailable; temporary_root: temporary root retained because owned process cleanup is unverified

In the test suite this shows up as load-sensitive flakes in, e.g., tests/acceptance/tools/localToolContracts.test.ts ("accepts NUL as actual terminal input while preserving capture defaults"), tests/boundary/process/processTruncationAdapters.test.ts, tests/acceptance/process/processFinalization.test.ts and tests/boundary/process/processCaptureProgress.test.ts. Each passes when rerun alone.

Reproduction

  1. In one shell, keep starting an unrelated same-uid CS_RESTRICT process. Any hardened third-party app's bundled helper works; I used an Electron app's bundled node:
    while true; do "<path-to-a-CS_RESTRICT-binary>" -e "setTimeout(()=>{},800)" & sleep 0.3; done
    
    (Check the flag with csops/CS_OPS_STATUS: 0x800 set.)
  2. In another shell, run repeatedly:
    npx vitest run tests/acceptance/tools/localToolContracts.test.ts -t "accepts NUL"
    
  3. On main: 8/8 runs failed with the cleanup_incomplete message above. Without the background loop: 3/3 passed.

Expected behavior

An unrelated process whose environment the kernel withholds by policy should not fail an otherwise clean capture — consistent with how #1057 already treats unrelated platform binaries and foreign-uid processes (recorded as unverified, capture succeeds). At minimum, the diagnostic should name why the token was unreadable instead of the generic environment_unavailable.

Impact and scope

  • Affects any macOS user running captures while apps like Chrome or Electron apps are spawning helpers; not limited to tests. The failure is load-sensitive because slower captures widen the window in which a new helper can appear.
  • Does not affect evidence content; the capture itself is correct. The cost is a spurious failure plus a retained temporary root.
  • Touches process-isolation semantics, so the fix direction is a maintainer decision (see Analysis).

REA version

main ee50ed121

Runtime and platform

Node 24.21.0; macOS 26.6.2 arm64; Xcode 27.0

Target and artifact context

Not applicable (process capture of a trivial node -e scenario)

Interface

MCP

Earliest failure stage

Cleanup, cancellation, or lifecycle

Relevant output or evidence

Measured on one host with the run-token reader + csops over every same-uid process:
  27 processes reported environment_unavailable  -> all 27 have CS_RESTRICT (0x800)
  126 processes with a readable environment     -> none have CS_RESTRICT
Examples of env-unavailable processes: Chrome helpers, several Electron apps, a few App Store apps.

Analysis or suspected cause

Source-derived, with the measurements above. One link is still a hypothesis.

  1. The cleanup sweep (sweepTokenOwnedProcesses: true in src/process/capture/ProcessCaptureLifecycle.ts) treats every live same-uid process absent from the capture baseline as a candidate (scanTokenOwnedProcesses in src/process/ProcessOwnership.ts).
  2. XNU omits the environment of cs_restricted processes from KERN_PROCARGS2 for ordinary callers. bridge/process/ProcessRunTokenReader.swift only recognises CS_PLATFORM_BINARY in the environmentUnavailable path, so restricted processes get the generic environment_unavailable.
  3. environmentWithheldFromInspection only exempts foreign uid and platform_binary_environment_withheld. Everything else, including these unrelated restricted processes, fails closed.
  4. Ruled out: a process that is mid-exit or a zombie yields sysctl_failed_22 (EINVAL), never environment_unavailable (600 exits under CPU load), so an exit race is not this failure.
  5. Hypothesis: the specific PID in the original failure was a Chrome/Electron helper. The mechanism reproduces deterministically, but that PID's identity was not recorded. A competing explanation that I have not excluded: a Node/npm process mutating its process title (as constructed in tests/boundary/process/darwinProcessRunTokenReader.test.ts).

Fix options (maintainer decision):

  • A. Extend the #1057 exemption to unrelated CS_RESTRICT processes. This removes the flake. Trade-off: a descendant that moves to an unsampled process group, gets reparented, and execs a restricted image before cleanup would be recorded as unverified instead of failing. That is the same blind spot #1057 accepted for platform binaries, but wider, and CS_OPS_MARKRESTRICT means the flag does not prove trusted provenance.
  • B. Narrow the sweep candidate set (e.g. ignore processes with no ancestry link to the captured tree). This removes the whole class of noise, but conflicts with the goal of catching detached escapees.

Related gaps found while investigating, also unaddressed:

  • In the reader's runId == nil path (parse succeeded, no token), only platform binaries are treated as withheld. A restricted owned process whose environment is withheld may parse as "readable, no token", which would be treated as not owned. This is unverified at runtime.
  • An unrelated process caught in P_WEXIT returns sysctl_failed_22, and ps still lists it as live. If it stays in that state beyond the retry budget, it fails the same way. This is rarer.

Additional context

I'll open a small diagnostic-only PR that makes the reader report restricted_process_environment_withheld for this case while keeping it fail-closed, so the failure names its cause without changing containment. It does not choose between A and B.

Originally created by @Ghost-Kang on GitHub (Oct 12, 2026). Original GitHub issue: https://github.com/morluto/rea/issues/2035 ### Before submitting - [x] I searched the open and closed issues for a related report. (Closest: #1056, fixed by #1057, which covers platform binaries and foreign-uid processes but not this case.) - [x] I removed credentials, tokens, private paths, and sensitive target data. ### Area Process capture or runtime observation ### Observed behavior On a macOS host with ordinary desktop apps running (Chrome, Electron apps, etc.), `capture_process_scenario` / `capture-process` intermittently fail with `cleanup_incomplete` even though the captured child exited normally (exit code 0, settlement `quiesced`) and no owned process remains: ``` owned_process_group: process ownership token could not be read for 1 live process(es): environment_unavailable=1; live candidates 26437=environment_unavailable; temporary_root: temporary root retained because owned process cleanup is unverified ``` In the test suite this shows up as load-sensitive flakes in, e.g., `tests/acceptance/tools/localToolContracts.test.ts` ("accepts NUL as actual terminal input while preserving capture defaults"), `tests/boundary/process/processTruncationAdapters.test.ts`, `tests/acceptance/process/processFinalization.test.ts` and `tests/boundary/process/processCaptureProgress.test.ts`. Each passes when rerun alone. ### Reproduction 1. In one shell, keep starting an unrelated same-uid `CS_RESTRICT` process. Any hardened third-party app's bundled helper works; I used an Electron app's bundled `node`: ```sh while true; do "<path-to-a-CS_RESTRICT-binary>" -e "setTimeout(()=>{},800)" & sleep 0.3; done ``` (Check the flag with `csops`/`CS_OPS_STATUS`: `0x800` set.) 2. In another shell, run repeatedly: ```sh npx vitest run tests/acceptance/tools/localToolContracts.test.ts -t "accepts NUL" ``` 3. On `main`: 8/8 runs failed with the `cleanup_incomplete` message above. Without the background loop: 3/3 passed. ### Expected behavior An unrelated process whose environment the kernel withholds by policy should not fail an otherwise clean capture — consistent with how #1057 already treats unrelated platform binaries and foreign-uid processes (recorded as `unverified`, capture succeeds). At minimum, the diagnostic should name why the token was unreadable instead of the generic `environment_unavailable`. ### Impact and scope - Affects any macOS user running captures while apps like Chrome or Electron apps are spawning helpers; not limited to tests. The failure is load-sensitive because slower captures widen the window in which a new helper can appear. - Does not affect evidence content; the capture itself is correct. The cost is a spurious failure plus a retained temporary root. - Touches process-isolation semantics, so the fix direction is a maintainer decision (see Analysis). ### REA version main `ee50ed121` ### Runtime and platform Node 24.21.0; macOS 26.6.2 arm64; Xcode 27.0 ### Target and artifact context Not applicable (process capture of a trivial `node -e` scenario) ### Interface MCP ### Earliest failure stage Cleanup, cancellation, or lifecycle ### Relevant output or evidence ```text Measured on one host with the run-token reader + csops over every same-uid process: 27 processes reported environment_unavailable -> all 27 have CS_RESTRICT (0x800) 126 processes with a readable environment -> none have CS_RESTRICT Examples of env-unavailable processes: Chrome helpers, several Electron apps, a few App Store apps. ``` ### Analysis or suspected cause Source-derived, with the measurements above. One link is still a hypothesis. 1. The cleanup sweep (`sweepTokenOwnedProcesses: true` in `src/process/capture/ProcessCaptureLifecycle.ts`) treats every live same-uid process absent from the capture baseline as a candidate (`scanTokenOwnedProcesses` in `src/process/ProcessOwnership.ts`). 2. XNU omits the environment of `cs_restricted` processes from `KERN_PROCARGS2` for ordinary callers. `bridge/process/ProcessRunTokenReader.swift` only recognises `CS_PLATFORM_BINARY` in the `environmentUnavailable` path, so restricted processes get the generic `environment_unavailable`. 3. `environmentWithheldFromInspection` only exempts foreign uid and `platform_binary_environment_withheld`. Everything else, including these unrelated restricted processes, fails closed. 4. Ruled out: a process that is mid-exit or a zombie yields `sysctl_failed_22` (EINVAL), never `environment_unavailable` (600 exits under CPU load), so an exit race is not this failure. 5. **Hypothesis:** the specific PID in the original failure was a Chrome/Electron helper. The mechanism reproduces deterministically, but that PID's identity was not recorded. A competing explanation that I have not excluded: a Node/npm process mutating its process title (as constructed in `tests/boundary/process/darwinProcessRunTokenReader.test.ts`). **Fix options (maintainer decision):** - **A. Extend the #1057 exemption to unrelated `CS_RESTRICT` processes.** This removes the flake. Trade-off: a descendant that moves to an unsampled process group, gets reparented, and execs a restricted image before cleanup would be recorded as `unverified` instead of failing. That is the same blind spot #1057 accepted for platform binaries, but wider, and `CS_OPS_MARKRESTRICT` means the flag does not prove trusted provenance. - **B. Narrow the sweep candidate set** (e.g. ignore processes with no ancestry link to the captured tree). This removes the whole class of noise, but conflicts with the goal of catching detached escapees. Related gaps found while investigating, also unaddressed: - In the reader's `runId == nil` path (parse succeeded, no token), only platform binaries are treated as withheld. A restricted *owned* process whose environment is withheld may parse as "readable, no token", which would be treated as not owned. This is unverified at runtime. - An unrelated process caught in `P_WEXIT` returns `sysctl_failed_22`, and `ps` still lists it as live. If it stays in that state beyond the retry budget, it fails the same way. This is rarer. ### Additional context I'll open a small diagnostic-only PR that makes the reader report `restricted_process_environment_withheld` for this case while **keeping it fail-closed**, so the failure names its cause without changing containment. It does not choose between A and B.
Author
Owner

@Ghost-Kang commented on GitHub (Oct 12, 2026):

Follow-up evidence with the new diagnostic from 4a4d36f88.

On main at 538f8cc72 (macOS 26.6.2 arm64, Node 24.21.0), a full npx vitest run --maxWorkers=4 gave 9046 passed, 2 failed. Both failures were this issue, and the cleanup reason now names the cause:

owned_process_group: process ownership token could not be read for 1 live process(es): restricted_process_environment_withheld=1; live candidates <pid>=restricted_process_environment_withheld
  • tests/acceptance/process/processFinalization.test.ts: "forces SIGKILL after the finalization interval through the compiled MCP workflow"
  • tests/boundary/process/processFilesystemCoverageAdapters.test.ts: "reports unknown content after a same-size rewrite without digests through mcp"

Both pass when rerun in isolation (22/22).

This resolves the open hypothesis in the report. The unreadable candidates in real full-suite runs are CS_RESTRICT processes, not process-title mutation or an exit race. It also confirms that the diagnostic alone does not remove the flake; that still needs the A/B decision above.

<!-- gh-comment-id:6118865408 --> @Ghost-Kang commented on GitHub (Oct 12, 2026): Follow-up evidence with the new diagnostic from 4a4d36f88. On `main` at `538f8cc72` (macOS 26.6.2 arm64, Node 24.21.0), a full `npx vitest run --maxWorkers=4` gave **9046 passed, 2 failed**. Both failures were this issue, and the cleanup reason now names the cause: ```text owned_process_group: process ownership token could not be read for 1 live process(es): restricted_process_environment_withheld=1; live candidates <pid>=restricted_process_environment_withheld ``` - `tests/acceptance/process/processFinalization.test.ts`: "forces SIGKILL after the finalization interval through the compiled MCP workflow" - `tests/boundary/process/processFilesystemCoverageAdapters.test.ts`: "reports unknown content after a same-size rewrite without digests through mcp" Both pass when rerun in isolation (22/22). This resolves the open hypothesis in the report. The unreadable candidates in real full-suite runs are `CS_RESTRICT` processes, not process-title mutation or an exit race. It also confirms that the diagnostic alone does not remove the flake; that still needs the A/B decision above.
Author
Owner

@AniketR10 commented on GitHub (Oct 12, 2026):

team i want to work on this plese assign

<!-- gh-comment-id:6118937321 --> @AniketR10 commented on GitHub (Oct 12, 2026): team i want to work on this plese assign
Author
Owner

@morluto commented on GitHub (Oct 14, 2026):

Addressed on main in 4a4d36f88, d8c76b629.

Preserve explicit Darwin restricted-process-environment withholding and treat only unrelated candidates as unverified; related or generically unreadable candidates still fail closed.

Validation: Process suite 92 passed, 4 skipped; global gates pass.

Verification scope: No live adversarial macOS process scenario rerun.

<!-- gh-comment-id:6158642790 --> @morluto commented on GitHub (Oct 14, 2026): Addressed on main in [4a4d36f88](https://github.com/morluto/rea/commit/4a4d36f88), [d8c76b629](https://github.com/morluto/rea/commit/d8c76b629). Preserve explicit Darwin restricted-process-environment withholding and treat only unrelated candidates as unverified; related or generically unreadable candidates still fail closed. Validation: Process suite 92 passed, 4 skipped; global gates pass. Verification scope: No live adversarial macOS process scenario rerun.
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
morluto/rea#678
No description provided.