[PR #6220] [MERGED] fix(process): record withheld unrelated Linux processes as unverified #7208

Closed
opened 2026-11-01 21:46:09 +00:00 by bsakaguchi · 0 comments
Owner

📋 Pull Request Information

Original PR: https://github.com/morluto/rea/pull/6220
Author: @kaoru0822-kitauji
Created: 10/22/2026
Status: ✅ Merged
Merged: 10/22/2026
Merged by: @kaoru0822-kitauji

Base: main ← Head: fix/hermes-sticky-profile-flake


📝 Commits (2)

  • 1f02345 fix(process): record withheld unrelated Linux processes as unverified
  • 02b43f1 test(process): define caller credentials on hosts without geteuid

📊 Changes

6 files changed (+381 additions, -47 deletions)

View changed files

📝 src/process/LinuxProcessStatus.test.ts (+60 -9)
📝 src/process/LinuxProcessStatus.ts (+54 -13)
📝 src/process/ProcessOwnership.identity.test.ts (+70 -0)
📝 src/process/ProcessOwnership.procIdentity.test.ts (+128 -4)
📝 src/process/ProcessOwnership.ts (+20 -9)
📝 src/process/ProcessOwnershipObservation.ts (+49 -12)

📄 Description

What broke: On Linux, rea setup and rea doctor could exit 1 with cleanup_incomplete from the Ghidra Java probe. This happened when an unrelated same-user process started during the cleanup sweep and the kernel denied its /proc/<pid>/environ (environment_errno_EACCES). The affected processes are setgid/setuid programs and non-dumpable processes. This intermittently fails hermesClientSetup.test.ts on main.
Fix: The stat-bracketed Linux identity read already reads /proc/<pid>/status. It now also derives why the kernel withholds the environment (mismatched real/effective/saved UID or GID, or root-owned procfs environ for a non-dumpable process). It keeps that fact through exit settlement. The sweep records such a process as unverified only when the process is known unrelated, which matches how it already treats another account's processes and macOS withheld processes.
Risk: Low to medium. This is a fail-closed area. Related candidates (the leader's descendants and owned process groups), unknown relations, and EACCES without a positively derived restriction still fail closed. It adds one stat per token-unreadable candidate.

Summary

Fixes #6204.

Main CI is intermittently red in tests/acceptance/setup/hermesClientSetup.test.ts:

  • run 39702707721, "custom sticky-profile", line 300: the JSON in the log shows ghidra:java cleanup failing with live candidates 3850=environment_errno_EACCES.
  • run 39703491153, "suffix sticky-profile", line 246: exit 1 with no JSON printed.

Hermes profile resolution is deterministic, so neither the test nor the Hermes code is at fault. The failure comes from the process-ownership sweep that runs after the Java probe, which runs during every setup/doctor.

Problem and expected behavior

When sweepTokenOwnedProcesses is set, cleanup reads the run token of every process that started after the baseline. Linux denies /proc/<pid>/environ to a caller without CAP_SYS_PTRACE in two cases:

  • the target's real, effective or saved UID or GID differs from the caller's, for example a setgid program;
  • the target is non-dumpable, for example after prctl(PR_SET_DUMPABLE, 0). Chrome and Electron sandboxes, agents and other security-sensitive processes do this.

environmentWithheldFromInspection only exempted a different effective UID and the two macOS withheld diagnostics. A same-euid setgid or non-dumpable process that was known to be unrelated therefore failed the whole command. Expected: record it as unverified, as is already done for other withheld classes, and keep related candidates fail-closed.

Change and scope

  • src/process/LinuxProcessStatus.ts: parseLinuxProcessIds(status, "Uid" | "Gid") replaces parseLinuxProcessEffectiveUid. linuxEnvironmentRestriction derives "credentials" | "non-dumpable" from the target's IDs, the owner of /proc/<pid>/environ and REA's euid/egid. procfs reports root ownership of environ only while a process is non-dumpable. I checked this on Linux 6.8: a dumpable process showed its own uid; prctl-non-dumpable and setgid processes showed 0.
  • src/process/ProcessOwnershipObservation.ts: the selective credential read happens inside the existing stat/status/stat generation bracket, so a reused PID cannot supply the fact. effectiveUidPids is renamed to credentialPids. settleExitingProcessIdentities now passes those PIDs on refresh, so an exiting candidate keeps its facts instead of losing them.
  • src/process/ProcessOwnership.ts: ProcessIdentityObservation gains an optional environmentRestriction. environmentWithheldFromInspection accepts it, and it applies only on the existing knownUnrelated path.
  • Not changed: Darwin classification, related-candidate handling, the non-evidence fallback scan, and every failure reason other than this one.

Contribution scope: not applicable. This is an ordinary fix within the existing ownership boundary.

Contract and boundary impact

  • Semantic owner and earliest changed stage: the Linux native identity read (createSystemProcessOwnershipHost().processIdentities). The policy stays in scanWithOwnershipEvidence.

  • CLI and MCP/tool-catalog contract: no schema change. A cleanup that used to fail now succeeds with the process listed in the existing unverified field.

  • Provider, bridge, target-format, or platform compatibility: Linux only.

  • Evidence, artifact, provenance, or reconstruction contract: unverified entries keep the raw token diagnostic (EACCES: …).

  • Target selection, authority, upstream behavior, and cleanup details: no new signals. Withheld processes that are related to the run still fail closed.

  • Generated metadata, package, or installation impact: none.

  • Launch or attach to a process.

(This describes what the existing Java probe already does. This PR adds no new effects.)

Evidence and regression coverage

  • Real Linux reproduction, through the compiled CLI: Docker on colima, Debian trixie, kernel 6.8.0 arm64, Node 24.18.0, OpenJDK 21, run as uid 1000. I ran the suffix-test scenario with node scripts/rea.mjs setup --client hermes --dry-run --json while a noise loop started a new same-user process every ~50 ms (each living 0.3 s). Each cell is failures/runs:

    Concurrent noise main c465ff5a8 this PR
    none 0/10 0/20
    python3 + prctl(PR_SET_DUMPABLE, 0) 6/10 1/150, and 0/200, 0/400 and 1/300 in runs with temporary diagnostics
    setgid copy of sleep 9/10 0/150, 0/100, 0/30

    Every failure on main had the CI signature environment_errno_EACCES. The first fix iteration still failed occasionally, because exit settlement re-read identities without credential facts. That case is fixed here and covered by a test.

  • Remaining residual, observed and not fixed: about 2 failures in ~1250 non-dumpable runs under that synthetic churn of ~20 new processes/s. Temporary diagnostics showed the failing fact as same-generation and known-unrelated, with the first-stat identity and no credential facts. The bracketed credential read can return that identity when a later read in the bracket fails. My inference, not observed directly, is that the process exited and was reaped mid-read. That fallback already exists and decides how stale "live" identities are handled; I left it unchanged here to keep this PR focused.

  • Tests added or updated:

    • ProcessOwnership.identity.test.ts: an unrelated same-UID process with a credentials or non-dumpable restriction becomes unverified and cleanup passes. EACCES without a restriction, and a restricted member of the owned process group, both still fail. Both new cases fail when the policy line is reverted.
    • ProcessOwnership.procIdentity.test.ts: the credential bracket reads /proc/<pid>/environ ownership and derives each restriction (non-dumpable, setgid, inspectable). A new case checks that the restriction survives exit settlement; it fails without the settlement change.
    • LinuxProcessStatus.test.ts: Uid/Gid parsing (malformed rows are rejected) and the derivation table.
  • Observed, derived, and inferred claims remain distinguishable.

  • Artifact identity, source provenance, and failed attempts remain preserved.

  • Unsupported, incomplete, unavailable, or uncertain outcomes remain visible.

Validation performed

  • Linux reproduction above, before and after the change.
  • npm run test:local -- src/process/*.test.ts src/ghidra/GhidraJavaProbe*.test.ts (51 files): 625 passed, 5 skipped.
  • npm run test:focused -- tests/acceptance/setup/hermesClientSetup.test.ts (macOS): 15 passed.
  • npm run check:fast: passed.
  • npm run format:check: passed.

Compatibility, safety, and release

  • Breaking changes or migration steps: none.
  • Real OS coverage: Linux 6.8 arm64 container as described above, and macOS unit and acceptance runs. The x86_64 GitHub runner was not reproduced locally, and the CI culprit process (pid 3850) is not known.
  • Performance: one extra stat syscall per token-unreadable candidate, only during cleanup verification. Not measured separately.
  • Security, privacy, process, or containment review: environments are never read. The new facts are IDs and file ownership only.

Author readiness

Human owner: @kaoru0822-kitauji

  • I reviewed the final diff and relevant existing code, understand the change and its edge cases, and take responsibility for the complete PR, including AI-assisted work.
  • I checked the evidence above, confirmed it supports the current change, and recorded meaningful validation gaps.
  • I declare this PR ready for initial maintainer review.

🤖 Generated with Claude Code


🔄 This issue represents a GitHub Pull Request. It cannot be merged through Gitea due to API limitations.

## 📋 Pull Request Information **Original PR:** https://github.com/morluto/rea/pull/6220 **Author:** [@kaoru0822-kitauji](https://github.com/kaoru0822-kitauji) **Created:** 10/22/2026 **Status:** ✅ Merged **Merged:** 10/22/2026 **Merged by:** [@kaoru0822-kitauji](https://github.com/kaoru0822-kitauji) **Base:** `main` ← **Head:** `fix/hermes-sticky-profile-flake` --- ### 📝 Commits (2) - [`1f02345`](https://github.com/morluto/rea/commit/1f023456b9895b1a6554d9c83b74ba7cd2916004) fix(process): record withheld unrelated Linux processes as unverified - [`02b43f1`](https://github.com/morluto/rea/commit/02b43f16606468cc2bd3fa2c1dfa8634e854fc52) test(process): define caller credentials on hosts without geteuid ### 📊 Changes **6 files changed** (+381 additions, -47 deletions) <details> <summary>View changed files</summary> 📝 `src/process/LinuxProcessStatus.test.ts` (+60 -9) 📝 `src/process/LinuxProcessStatus.ts` (+54 -13) 📝 `src/process/ProcessOwnership.identity.test.ts` (+70 -0) 📝 `src/process/ProcessOwnership.procIdentity.test.ts` (+128 -4) 📝 `src/process/ProcessOwnership.ts` (+20 -9) 📝 `src/process/ProcessOwnershipObservation.ts` (+49 -12) </details> ### 📄 Description **What broke:** On Linux, `rea setup` and `rea doctor` could exit 1 with `cleanup_incomplete` from the Ghidra Java probe. This happened when an unrelated same-user process started during the cleanup sweep and the kernel denied its `/proc/<pid>/environ` (`environment_errno_EACCES`). The affected processes are setgid/setuid programs and non-dumpable processes. This intermittently fails `hermesClientSetup.test.ts` on main. **Fix:** The stat-bracketed Linux identity read already reads `/proc/<pid>/status`. It now also derives why the kernel withholds the environment (mismatched real/effective/saved UID or GID, or root-owned procfs `environ` for a non-dumpable process). It keeps that fact through exit settlement. The sweep records such a process as `unverified` only when the process is known unrelated, which matches how it already treats another account's processes and macOS withheld processes. **Risk:** Low to medium. This is a fail-closed area. Related candidates (the leader's descendants and owned process groups), unknown relations, and EACCES without a positively derived restriction still fail closed. It adds one `stat` per token-unreadable candidate. ## Summary Fixes #6204. Main CI is intermittently red in `tests/acceptance/setup/hermesClientSetup.test.ts`: - run 39702707721, "custom sticky-profile", line 300: the JSON in the log shows `ghidra:java` cleanup failing with `live candidates 3850=environment_errno_EACCES`. - run 39703491153, "suffix sticky-profile", line 246: exit 1 with no JSON printed. Hermes profile resolution is deterministic, so neither the test nor the Hermes code is at fault. The failure comes from the process-ownership sweep that runs after the Java probe, which runs during every setup/doctor. ## Problem and expected behavior When `sweepTokenOwnedProcesses` is set, cleanup reads the run token of every process that started after the baseline. Linux denies `/proc/<pid>/environ` to a caller without CAP_SYS_PTRACE in two cases: - the target's real, effective or saved UID or GID differs from the caller's, for example a setgid program; - the target is non-dumpable, for example after `prctl(PR_SET_DUMPABLE, 0)`. Chrome and Electron sandboxes, agents and other security-sensitive processes do this. `environmentWithheldFromInspection` only exempted a different effective UID and the two macOS withheld diagnostics. A same-euid setgid or non-dumpable process that was known to be unrelated therefore failed the whole command. Expected: record it as `unverified`, as is already done for other withheld classes, and keep related candidates fail-closed. ## Change and scope - `src/process/LinuxProcessStatus.ts`: `parseLinuxProcessIds(status, "Uid" | "Gid")` replaces `parseLinuxProcessEffectiveUid`. `linuxEnvironmentRestriction` derives `"credentials" | "non-dumpable"` from the target's IDs, the owner of `/proc/<pid>/environ` and REA's euid/egid. procfs reports root ownership of `environ` only while a process is non-dumpable. I checked this on Linux 6.8: a dumpable process showed its own uid; `prctl`-non-dumpable and setgid processes showed 0. - `src/process/ProcessOwnershipObservation.ts`: the selective credential read happens inside the existing stat/status/stat generation bracket, so a reused PID cannot supply the fact. `effectiveUidPids` is renamed to `credentialPids`. `settleExitingProcessIdentities` now passes those PIDs on refresh, so an exiting candidate keeps its facts instead of losing them. - `src/process/ProcessOwnership.ts`: `ProcessIdentityObservation` gains an optional `environmentRestriction`. `environmentWithheldFromInspection` accepts it, and it applies only on the existing `knownUnrelated` path. - Not changed: Darwin classification, related-candidate handling, the non-evidence fallback scan, and every failure reason other than this one. Contribution scope: not applicable. This is an ordinary fix within the existing ownership boundary. ## Contract and boundary impact - Semantic owner and earliest changed stage: the Linux native identity read (`createSystemProcessOwnershipHost().processIdentities`). The policy stays in `scanWithOwnershipEvidence`. - CLI and MCP/tool-catalog contract: no schema change. A cleanup that used to fail now succeeds with the process listed in the existing `unverified` field. - Provider, bridge, target-format, or platform compatibility: Linux only. - Evidence, artifact, provenance, or reconstruction contract: unverified entries keep the raw token diagnostic (`EACCES: …`). - Target selection, authority, upstream behavior, and cleanup details: no new signals. Withheld processes that are related to the run still fail closed. - Generated metadata, package, or installation impact: none. - [x] Launch or attach to a process. (This describes what the existing Java probe already does. This PR adds no new effects.) ## Evidence and regression coverage - Real Linux reproduction, through the compiled CLI: Docker on colima, Debian trixie, kernel 6.8.0 arm64, Node 24.18.0, OpenJDK 21, run as uid 1000. I ran the suffix-test scenario with `node scripts/rea.mjs setup --client hermes --dry-run --json` while a noise loop started a new same-user process every ~50 ms (each living 0.3 s). Each cell is failures/runs: | Concurrent noise | main `c465ff5a8` | this PR | | --- | --- | --- | | none | 0/10 | 0/20 | | `python3` + `prctl(PR_SET_DUMPABLE, 0)` | 6/10 | 1/150, and 0/200, 0/400 and 1/300 in runs with temporary diagnostics | | setgid copy of `sleep` | 9/10 | 0/150, 0/100, 0/30 | Every failure on main had the CI signature `environment_errno_EACCES`. The first fix iteration still failed occasionally, because exit settlement re-read identities without credential facts. That case is fixed here and covered by a test. - Remaining residual, observed and not fixed: about 2 failures in ~1250 non-dumpable runs under that synthetic churn of ~20 new processes/s. Temporary diagnostics showed the failing fact as same-generation and known-unrelated, with the first-stat identity and no credential facts. The bracketed credential read can return that identity when a later read in the bracket fails. My inference, not observed directly, is that the process exited and was reaped mid-read. That fallback already exists and decides how stale "live" identities are handled; I left it unchanged here to keep this PR focused. - Tests added or updated: - `ProcessOwnership.identity.test.ts`: an unrelated same-UID process with a `credentials` or `non-dumpable` restriction becomes `unverified` and cleanup passes. EACCES without a restriction, and a restricted member of the owned process group, both still fail. Both new cases fail when the policy line is reverted. - `ProcessOwnership.procIdentity.test.ts`: the credential bracket reads `/proc/<pid>/environ` ownership and derives each restriction (non-dumpable, setgid, inspectable). A new case checks that the restriction survives exit settlement; it fails without the settlement change. - `LinuxProcessStatus.test.ts`: Uid/Gid parsing (malformed rows are rejected) and the derivation table. - [x] Observed, derived, and inferred claims remain distinguishable. - [x] Artifact identity, source provenance, and failed attempts remain preserved. - [x] Unsupported, incomplete, unavailable, or uncertain outcomes remain visible. ## Validation performed - Linux reproduction above, before and after the change. - `npm run test:local -- src/process/*.test.ts src/ghidra/GhidraJavaProbe*.test.ts` (51 files): 625 passed, 5 skipped. - `npm run test:focused -- tests/acceptance/setup/hermesClientSetup.test.ts` (macOS): 15 passed. - `npm run check:fast`: passed. - `npm run format:check`: passed. ## Compatibility, safety, and release - Breaking changes or migration steps: none. - Real OS coverage: Linux 6.8 arm64 container as described above, and macOS unit and acceptance runs. The x86_64 GitHub runner was not reproduced locally, and the CI culprit process (pid 3850) is not known. - Performance: one extra `stat` syscall per token-unreadable candidate, only during cleanup verification. Not measured separately. - Security, privacy, process, or containment review: environments are never read. The new facts are IDs and file ownership only. ## Author readiness Human owner: @kaoru0822-kitauji - [x] I reviewed the final diff and relevant existing code, understand the change and its edge cases, and take responsibility for the complete PR, including AI-assisted work. - [x] I checked the evidence above, confirmed it supports the current change, and recorded meaningful validation gaps. - [x] I declare this PR ready for initial maintainer review. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --- <sub>🔄 This issue represents a GitHub Pull Request. It cannot be merged through Gitea due to API limitations.</sub>
bsakaguchi 2026-11-01 21:46:09 +00:00
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#7208
No description provided.