[PR #6264] [MERGED] fix(process): keep capture evidence when an admitted root's ancestor changes #7238

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

📋 Pull Request Information

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

Base: main ← Head: fix/6213-capture-obstructed-root


📝 Commits (1)

  • 33828b4 fix(process): keep capture evidence when an admitted root's ancestor changes

📊 Changes

4 files changed (+285 additions, -31 deletions)

View changed files

📝 docs/process-capture.md (+8 -1)
📝 src/process/capture/FilesystemCoverage.test.ts (+113 -0)
📝 src/process/capture/FilesystemSnapshot.ts (+63 -29)
📝 tests/boundary/process/processFilesystemCoverageAdapters.test.ts (+101 -1)

📄 Description

What broke: A selected root that was absent before launch made capture fail with process_capture_failed / ENOTDIR after the child exited, if the child had turned one of the root's ancestors into a regular file.
Fix: One tagged lookup classifies each path. ENOENT/ENOTDIR count as observed absence once the roots are admitted, and denied or unavailable lookups are recorded as entry_unavailable omissions instead of aborting. Roots that are obstructed or denied before launch still fail.
Risk: Low. The change stays inside snapshotRoots. Snapshot outcomes change only in cases that used to throw, and those now report absence or an explicit omission that leaves the root incomplete.

Summary

Fixes #6213.

capture_process_scenario (MCP) and rea capture-process (CLI) both use snapshotRoots. When a selected root such as state/reports was missing at the initial lookup, and the child renamed state and wrote a regular file in its place, the final observation threw ENOTDIR. Capture Evidence was lost and only partial observations remained. These captures now finish with exit 0, no filesystem effect for the root, and complete before/after absence, as docs/process-capture.md already promised ("A path missing at its initial lookup remains an observed absence").

Problem and expected behavior

  • Trigger: the initial snapshot finds the root absent and puts it in completeRoots without a file entry. The final snapshot rebuilt "same-run admission" only from previous.files root entries (previousPaths.has("root_N:.")). An absent root therefore did not get the ENOTDIR-as-absence exception that #3238 added for initially present roots.
  • Violated invariant: a root admitted by the initial snapshot must produce complete or honestly partial final Evidence. It must not cause a capture failure after process settlement.
  • Related brittleness at the same lookup: after admission, a denied (EACCES) or looping (ELOOP) ancestor still threw and aborted capture. The same happened when a child lookup was denied inside a directory that was readable but not searchable (r--).

Change and scope

  • lstatIfPresent(path, previouslyObservedRoot) is replaced by lookupPath, which returns present | absent | unavailable together with the system code and cause. ENOENT and ENOTDIR both prove that no entry exists at the path. Other unavailable codes leave the path unobserved, never absent.
  • Root admission is now owned by the run: a returned initial snapshot admits every selected root, because snapshotRoots returns only after each root lookup has passed admission. The parameter is renamed previous → admission. The per-path previousPaths reconstruction is removed.
  • Root lookup before admission: ENOENT means absent. Any other failure (ENOTDIR obstruction, EACCES, ...) rethrows the original error, so an initial obstruction still fails before launch.
  • Root lookup after admission, and descendant lookups: absent → observed absence; unavailable → entry_unavailable omission with its system_code, and the root leaves completeRoots. Effects under that root then stay unknown with the existing "did not exhaust path enumeration" reason.
  • Not included: ancestor symlink identity for present roots. Lookups still resolve ancestors lexically, which is unchanged behavior.
  • Related: #3263 (another contributor's PR, test-only) covers cancellation during the obstructed lookup of an initially existing file. It does not overlap with this change. Cancellation still wins over a failed lookup, because lookupPath checks the signal before classifying.

Contract and boundary impact

  • Semantic owner and earliest changed stage: src/process/capture/FilesystemSnapshot.ts (snapshotRoots path lookup), at the final filesystem observation.
  • Contribution scope fit and linked maintainer decision: bug fix within the existing process-capture boundary; no maintainer scope decision needed.
  • CLI and MCP/tool-catalog contract: no schema or catalog change. The existing entry_unavailable omission reason and system_code field are used for root paths.
  • Provider, bridge, target-format, or platform compatibility: none. Windows usually reports ENOENT for these lookups, which was already treated as absence.
  • Evidence, artifact, provenance, or reconstruction contract: absence and "could not observe" stay separate. Denied lookups never become absence or deletion.
  • Target selection, authority, upstream behavior, and cleanup details: not applicable. Snapshot handle ownership and cleanup are unchanged.
  • Generated metadata, package, or installation impact: none.

Expected effects:

  • Read existing files or captured data.
  • Launch or attach to a process.
  • Inject instrumentation or run scripts/code.
  • Change process memory, registers, files, or configuration.
  • Contact network services.
  • Recover protected code or work around protections.
  • Documentation or usability only; no target interaction.
  • Not sure yet; investigation or maintainer guidance needed.

Evidence and regression coverage

  • Tests added or updated:
    • tests/boundary/process/processFilesystemCoverageAdapters.test.ts (real capture through CLI and MCP):
      • An absent root state/reports, nested with the root state. The ancestor is replaced (a) by a regular file and (b) by a symlink to a regular file. Expected: exit 0, only root_1:. modified, enumeration_truncated: false, no omissions.
      • A root whose ancestor becomes unsearchable (chmod 0), in three variants: a present file, an absent file, and a present file reached through a symlinked ancestor. Expected: exit 0, the present roots' effects are unknown (not deleted), and each root has an entry_unavailable/EACCES omission. Skipped on Windows and when running as root.
    • src/process/capture/FilesystemCoverage.test.ts:
      • Initial ENOTDIR and EACCES selections are still rejected, and the same lookups after admission give absence or an omission.
      • A child lookup in an r-- directory gives an entry_unavailable omission instead of failing.
      • An admitted root behind a symlink loop gives an ELOOP omission.
      • With FilesystemSnapshot.ts reverted to main, all 3 of these snapshot tests fail.
  • Base reproduction: I ran the issue's MCP probe (production createApplicationMcpHarness registration, real filesystem) on main c537c52c9. Both absent-obstructed requests returned process_capture_failed: Process filesystem snapshot failed: ENOTDIR: not a directory, lstat '<fixture>/state/reports'.
  • User-visible MCP output after the fix (same probe):
    absent-obstructed   {"exit":0,"effects":[],"enumeration_truncated":false,"omissions":[]}   (x2)
    absent-renamed      {"exit":0,"effects":[],"enumeration_truncated":false,"omissions":[]}
    existing-obstructed {"exit":0,"effects":[["root_0:.","deleted"]],...}
    parent-selected     {"exit":0,"effects":[["root_0:.","modified"],["root_0:saved-state","created"],["root_0:state","modified"]],...}
    initial-obstructed  ERROR process_capture_failed Process filesystem snapshot failed: ENOTDIR: not a directory, lstat <fixture>
    
  • Remaining proof gaps: macOS arm64 only. Linux and Windows were not run locally and are left to CI. I did not rebuild main to confirm that the new CLI/MCP adapter tests fail there; the failing base behavior is shown by the MCP probe above.

For evidence-bearing changes:

  • 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

All on macOS arm64, Node 26.5.0:

  • Issue MCP probe on main c537c52c9: reproduced the ENOTDIR failure 2/2.
  • Issue MCP probe on this branch: 5 captures completed as shown above, and the initial obstruction still fails.
  • npm run test:focused -- tests/boundary/process/processFilesystemCoverageAdapters.test.ts tests/boundary/process/processCaptureLifecycle.test.ts tests/boundary/process/processSnapshotHandleOwnership.test.ts tests/boundary/process/processPreparation.test.ts src/process/capture/FilesystemCoverage.test.ts src/process/capture/FilesystemSnapshot.test.ts src/process/capture/ProcessCaptureObservation.test.ts src/domain/process/processCapture.validation.test.ts: 8 files, 111 passed, 4 skipped.
  • npm run check:fast: passed.
  • npm run format:check: passed.
  • npm run docs:check: passed (6 tasks; readme consistency 9/9).

Compatibility, safety, and release

  • Breaking changes or migration steps: none. Captures that used to fail now return Evidence. The initial-obstruction failure is unchanged.
  • Real Hopper/Ghidra, browser, or OS coverage: not applicable; only real local process capture on macOS was run.
  • Package or release metadata impact: none.
  • Security, privacy, process, or containment review: no new reads. Lookups use lstat without following the final component, exactly as before.

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.

Before merge

  • This PR addresses a concrete problem or an agreed enhancement.
  • Material scope and authority questions are resolved, and any required maintainer scope decision covers the final diff.
  • The PR has one focused outcome and the title follows type(scope): outcome.
  • Related issue is linked, or the reason for not linking one is stated above.
  • Tests cover changed observable behavior and meaningful failure paths.
  • Owning docs, contracts, and generated metadata are updated where needed.
  • User-visible CLI/MCP changes include representative output.
  • I checked the final diff for secrets, unrelated cleanup, and unsupported claims.

🤖 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/6264 **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/6213-capture-obstructed-root` --- ### 📝 Commits (1) - [`33828b4`](https://github.com/morluto/rea/commit/33828b429c5972a08aff4b723461da02ff37004a) fix(process): keep capture evidence when an admitted root's ancestor changes ### 📊 Changes **4 files changed** (+285 additions, -31 deletions) <details> <summary>View changed files</summary> 📝 `docs/process-capture.md` (+8 -1) 📝 `src/process/capture/FilesystemCoverage.test.ts` (+113 -0) 📝 `src/process/capture/FilesystemSnapshot.ts` (+63 -29) 📝 `tests/boundary/process/processFilesystemCoverageAdapters.test.ts` (+101 -1) </details> ### 📄 Description **What broke:** A selected root that was absent before launch made capture fail with `process_capture_failed` / ENOTDIR after the child exited, if the child had turned one of the root's ancestors into a regular file. **Fix:** One tagged lookup classifies each path. ENOENT/ENOTDIR count as observed absence once the roots are admitted, and denied or unavailable lookups are recorded as `entry_unavailable` omissions instead of aborting. Roots that are obstructed or denied before launch still fail. **Risk:** Low. The change stays inside `snapshotRoots`. Snapshot outcomes change only in cases that used to throw, and those now report absence or an explicit omission that leaves the root incomplete. ## Summary Fixes #6213. `capture_process_scenario` (MCP) and `rea capture-process` (CLI) both use `snapshotRoots`. When a selected root such as `state/reports` was missing at the initial lookup, and the child renamed `state` and wrote a regular file in its place, the final observation threw `ENOTDIR`. Capture Evidence was lost and only partial observations remained. These captures now finish with exit 0, no filesystem effect for the root, and complete before/after absence, as `docs/process-capture.md` already promised ("A path missing at its initial lookup remains an observed absence"). ## Problem and expected behavior - Trigger: the initial snapshot finds the root absent and puts it in `completeRoots` without a file entry. The final snapshot rebuilt "same-run admission" only from `previous.files` root entries (`previousPaths.has("root_N:.")`). An absent root therefore did not get the ENOTDIR-as-absence exception that #3238 added for initially present roots. - Violated invariant: a root admitted by the initial snapshot must produce complete or honestly partial final Evidence. It must not cause a capture failure after process settlement. - Related brittleness at the same lookup: after admission, a denied (EACCES) or looping (ELOOP) ancestor still threw and aborted capture. The same happened when a child lookup was denied inside a directory that was readable but not searchable (`r--`). ## Change and scope - `lstatIfPresent(path, previouslyObservedRoot)` is replaced by `lookupPath`, which returns `present | absent | unavailable` together with the system code and cause. ENOENT and ENOTDIR both prove that no entry exists at the path. Other unavailable codes leave the path unobserved, never absent. - Root admission is now owned by the run: a returned initial snapshot admits every selected root, because `snapshotRoots` returns only after each root lookup has passed admission. The parameter is renamed `previous` → `admission`. The per-path `previousPaths` reconstruction is removed. - Root lookup before admission: ENOENT means absent. Any other failure (ENOTDIR obstruction, EACCES, ...) rethrows the original error, so an initial obstruction still fails before launch. - Root lookup after admission, and descendant lookups: absent → observed absence; unavailable → `entry_unavailable` omission with its `system_code`, and the root leaves `completeRoots`. Effects under that root then stay `unknown` with the existing "did not exhaust path enumeration" reason. - Not included: ancestor symlink identity for present roots. Lookups still resolve ancestors lexically, which is unchanged behavior. - Related: #3263 (another contributor's PR, test-only) covers cancellation during the obstructed lookup of an initially existing file. It does not overlap with this change. Cancellation still wins over a failed lookup, because `lookupPath` checks the signal before classifying. ## Contract and boundary impact - Semantic owner and earliest changed stage: `src/process/capture/FilesystemSnapshot.ts` (`snapshotRoots` path lookup), at the final filesystem observation. - Contribution scope fit and linked maintainer decision: bug fix within the existing process-capture boundary; no maintainer scope decision needed. - CLI and MCP/tool-catalog contract: no schema or catalog change. The existing `entry_unavailable` omission reason and `system_code` field are used for root paths. - Provider, bridge, target-format, or platform compatibility: none. Windows usually reports ENOENT for these lookups, which was already treated as absence. - Evidence, artifact, provenance, or reconstruction contract: absence and "could not observe" stay separate. Denied lookups never become absence or deletion. - Target selection, authority, upstream behavior, and cleanup details: not applicable. Snapshot handle ownership and cleanup are unchanged. - Generated metadata, package, or installation impact: none. Expected effects: - [x] Read existing files or captured data. - [x] Launch or attach to a process. - [ ] Inject instrumentation or run scripts/code. - [ ] Change process memory, registers, files, or configuration. - [ ] Contact network services. - [ ] Recover protected code or work around protections. - [ ] Documentation or usability only; no target interaction. - [ ] Not sure yet; investigation or maintainer guidance needed. ## Evidence and regression coverage - Tests added or updated: - `tests/boundary/process/processFilesystemCoverageAdapters.test.ts` (real capture through CLI and MCP): - An absent root `state/reports`, nested with the root `state`. The ancestor is replaced (a) by a regular file and (b) by a symlink to a regular file. Expected: exit 0, only `root_1:.` modified, `enumeration_truncated: false`, no omissions. - A root whose ancestor becomes unsearchable (`chmod 0`), in three variants: a present file, an absent file, and a present file reached through a symlinked ancestor. Expected: exit 0, the present roots' effects are `unknown` (not deleted), and each root has an `entry_unavailable`/`EACCES` omission. Skipped on Windows and when running as root. - `src/process/capture/FilesystemCoverage.test.ts`: - Initial ENOTDIR and EACCES selections are still rejected, and the same lookups after admission give absence or an omission. - A child lookup in an `r--` directory gives an `entry_unavailable` omission instead of failing. - An admitted root behind a symlink loop gives an `ELOOP` omission. - With `FilesystemSnapshot.ts` reverted to main, all 3 of these snapshot tests fail. - Base reproduction: I ran the issue's MCP probe (production `createApplicationMcpHarness` registration, real filesystem) on main `c537c52c9`. Both `absent-obstructed` requests returned `process_capture_failed`: `Process filesystem snapshot failed: ENOTDIR: not a directory, lstat '<fixture>/state/reports'`. - User-visible MCP output after the fix (same probe): ``` absent-obstructed {"exit":0,"effects":[],"enumeration_truncated":false,"omissions":[]} (x2) absent-renamed {"exit":0,"effects":[],"enumeration_truncated":false,"omissions":[]} existing-obstructed {"exit":0,"effects":[["root_0:.","deleted"]],...} parent-selected {"exit":0,"effects":[["root_0:.","modified"],["root_0:saved-state","created"],["root_0:state","modified"]],...} initial-obstructed ERROR process_capture_failed Process filesystem snapshot failed: ENOTDIR: not a directory, lstat <fixture> ``` - Remaining proof gaps: macOS arm64 only. Linux and Windows were not run locally and are left to CI. I did not rebuild main to confirm that the new CLI/MCP adapter tests fail there; the failing base behavior is shown by the MCP probe above. For evidence-bearing changes: - [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 All on macOS arm64, Node 26.5.0: - Issue MCP probe on main `c537c52c9`: reproduced the ENOTDIR failure 2/2. - Issue MCP probe on this branch: 5 captures completed as shown above, and the initial obstruction still fails. - `npm run test:focused -- tests/boundary/process/processFilesystemCoverageAdapters.test.ts tests/boundary/process/processCaptureLifecycle.test.ts tests/boundary/process/processSnapshotHandleOwnership.test.ts tests/boundary/process/processPreparation.test.ts src/process/capture/FilesystemCoverage.test.ts src/process/capture/FilesystemSnapshot.test.ts src/process/capture/ProcessCaptureObservation.test.ts src/domain/process/processCapture.validation.test.ts`: 8 files, 111 passed, 4 skipped. - `npm run check:fast`: passed. - `npm run format:check`: passed. - `npm run docs:check`: passed (6 tasks; readme consistency 9/9). ## Compatibility, safety, and release - Breaking changes or migration steps: none. Captures that used to fail now return Evidence. The initial-obstruction failure is unchanged. - Real Hopper/Ghidra, browser, or OS coverage: not applicable; only real local process capture on macOS was run. - Package or release metadata impact: none. - Security, privacy, process, or containment review: no new reads. Lookups use `lstat` without following the final component, exactly as before. ## 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. ## Before merge - [x] This PR addresses a concrete problem or an agreed enhancement. - [x] Material scope and authority questions are resolved, and any required maintainer scope decision covers the final diff. - [x] The PR has one focused outcome and the title follows `type(scope): outcome`. - [x] Related issue is linked, or the reason for not linking one is stated above. - [x] Tests cover changed observable behavior and meaningful failure paths. - [x] Owning docs, contracts, and generated metadata are updated where needed. - [x] User-visible CLI/MCP changes include representative output. - [x] I checked the final diff for secrets, unrelated cleanup, and unsupported claims. 🤖 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:24 +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#7238
No description provided.