Skip to content

Bind edit_file writes to verified inode across symlink swaps - #501

Merged
sambitcreate merged 11 commits into
mainfrom
feature/edit-file-symlink-race-423
Oct 16, 2026
Merged

sambitcreate merged 11 commits into
mainfrom
feature/edit-file-symlink-race-423

Conversation

@sambitcreate

@sambitcreate sambitcreate commented Oct 15, 2026 •

Copy link
Copy Markdown
Owner

Fixes #423.

Change

  • Record the pinned root and opened file identities, plus the original content hash, before edit_file commits.
  • Add a one-shot operation to the existing macOS/Linux file-mutator helper. It walks from the pinned root with descriptor-relative no-follow opens, verifies the exact regular file and content, then writes through that descriptor.
  • Preserve stable in-root symlinks by resolving them to their in-root target before the native walk. Reject multiply linked files and edits beyond 256 MiB rather than risk writing through an unverified alias.
  • Refresh the packaged helper binaries for macOS universal, Linux x64, and Linux arm64; add the helper to the coding-tools test lane prerequisites.
  • Pass the installation-relative helper path into CLI Bot file tools so edit_file works from external project directories and never executes a project-provided build/native binary.

Security boundary

This addresses #423's symlink/path swap to a different outside inode. Once the helper has opened and verified the original file, a local actor can still rename that same inode or its parent outside the root before the descriptor write. The helper would modify the moved original inode. It therefore does not guarantee that every file modified remains inside the workspace for the entire operation. A broader guarantee needs control over concurrent namespace mutation; another pathname recheck or a held parent descriptor cannot provide it.

Local verification

  • After the Fix draft-chat E2E focus expectation after workspace action reveal #506 latest-main merge, focused edit/literal tests passed 5/5, including deterministic target and ancestor swaps, a stable in-root symlink, a file above the subagent 200 KB cap, and PR Fix literal replacement markers in edit_file #492's JavaScript replacement markers.
  • Native mutator suite: macOS 30 passed, Linux x64 30 passed, Linux arm64 30 passed; three platform/environment skips per run.
  • On the second latest-main merge (dd2c0d0), root type-check, scoped ESLint, and CI policy/registry tests (62/62) passed. Checked-in native helper manifest tests passed earlier; the helper binaries did not change in either merge.
  • On the latest-main merge, CLI build and CLI type-check passed; full CLI suite passed 99/99. Its Bot parity test executes edit_file after changing to an external project directory that contains an executable decoy at the old fallback path, and verifies the decoy was not run. Bot file router suite passed 8/8.

The complete coding-tools.test.ts run after #492 merged passed 61 of 62 tests; the second merge did not touch coding-tools code or tests, and its focused edit/literal tests passed 5/5. The independent foreground glob agrees with native glob on normal patterns, hidden paths and safe links test failed for **/*: the foreground result descended into link/, while Node's native glob did not. An earlier focused rerun after npm ci reproduced the same mismatch. The edit and literal-replacement tests passed on both merged heads. This is a local test result; hosted checks on the exact new PR head are pending.

PR #492 merged to main as c68462b, followed by #491 at dd2c0d0, #497 at 17d51d4, #498 at 6bc994a, and the shared E2E baseline fix #506 at 896f31a. This branch merged each origin/main head without rebasing. The resulting edit_file uses #492's literal indexOf/slice replacement and #423's descriptor-bound native commit; focused tests cover both. PR #504 remains stacked on this branch.

Exact-head E2E result

Electron E2E shard 3, run 38825795960 failed on head b5a6ba37e5937f47c1ad893795cb088700e40612:

The run's uploaded safe receipt reports electron-e2e / test-failed and deliberately omits screenshots and traces. No timeouts, retries, or assertions were weakened. That workflow was still active when main advanced through #497; the failed job was not rerun on the stale head. After merging #497, focused edit/literal tests passed 5/5, CLI Bot parity passed 1/1, and root type-check passed. The separate #506 change now moves focus out of the row before asserting the hidden state. After merging it, the corrected sidebar test and both previously flaky worktree-send specs passed twice each (6/6) locally with --fail-on-flaky-tests. Production build, root and E2E type checks, focused edit tests, and CLI Bot parity also passed. The new head requires its own CI.

Hosted CI note

Model catalog contracts, run 38812053863 failed during npm ci with ECONNRESET; its verification step was skipped. This is an install/network failure, not a model catalog test failure. That run was on an earlier PR head. The latest pushed head is a616fc4bda6ea409ca4c5f991e1bbbe098772d87; its checks are the merge gate.

@sambitcreate sambitcreate changed the title Fix edit_file symlink-swap write outside pinned workspace Bind edit_file writes to verified inode across symlink swaps Oct 15, 2026

@pullfrog pullfrog Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Important

The native write closes the reported symlink-swap path, but its new helper lookup breaks edit_file for CLI Bots launched outside the repository.

Reviewed changes Reviewed the parent edit path, native descriptor-relative commit, helper distribution, tests, and documented inode-confinement boundary through e4655822d.

  • Verified-inode writes: Parent edits pass exact root/file identities and a content hash to a no-follow native walk, preserving in-place writes and stable in-root symlinks while rejecting multiply linked or oversized files.
  • Helper distribution and CI: Refreshed macOS/Linux prebuilts and manifests, plus native-build prerequisites for coding-tool test lanes.
  • Behavioral validation: Coding-tool/router tests passed 69/69, native mutator tests passed 30 with three platform/environment skips, and manifest/CI-registry tests passed 20/20; an external-CWD reproduction confirmed the inline CLI regression.
  • Security boundary: Reviewed the explicit limitation that a concurrently moved original inode may still be modified through its verified descriptor.

Pullfrog  | Fix all ➔ | Fix 👍s ➔ | View workflow run | Using gpt-6.1-sol | 𝕏

Comment thread main/services/coding-tools.ts Resolved

@pullfrog pullfrog Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ No new issues found.

Reviewed changes Reviewed the CLI helper-resolution fix since the prior review at e4655822d.

  • Passed the installed helper through Bot tools: Derived the native mutator path from AIDEN_CLI_ENTRY and threaded it through the router and pinned coding-tool factory, preserving the verified-inode write path independently of the project directory.
  • Added packaged regression coverage: Exercised a successful Bot edit from an external directory containing an executable decoy at the old fallback path, and verified that the decoy was not executed.

Validation passed: fresh CLI build, all 99 CLI tests, all 69 coding-tool/router tests, and root and CLI type-checks. The prior helper-lookup finding is addressed; hosted CI on the exact head remains a separate merge gate.

Pullfrog  | View workflow run | Using gpt-6.1-sol | 𝕏

@pullfrog pullfrog Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ No new issues found.

Reviewed changes Reviewed the merge integration and verification updates since the prior review at a4535dfc.

  • Integrated literal replacement: Combined main's indexOf/slice replacement with the verified-inode native commit, preserving literal JavaScript replacement markers without weakening the symlink-swap protection.
  • Updated verification records: Documented the merged behavior and local results, including the independently observed glob comparison failure.

Validation passed: six focused edit tests, eight Bot router tests, twenty helper-manifest/CI-inventory tests, and root type-check. The prior finding remains resolved. Full CLI and coding-tools suites were not repeated in this incremental review; exact-head hosted CI remains a separate merge gate.

Pullfrog  | View workflow run | Using gpt-6.1-sol | 𝕏

@very-hermes-bot

very-hermes-bot commented Oct 16, 2026 •

Copy link
Copy Markdown
Collaborator

Hermes Review Bot

Confidence: 5

Engine: agy/gemini-3.8-flash-high
Review mode: full
Head: a616fc4bda6ea409ca4c5f991e1bbbe098772d87
Generated: 2026-10-16T03:10:06+00:00
Reviews: 1

Summary

Resolves issue #423 by binding edit_file writes to the verified regular file descriptor rather than relying on an unfenced, path-based fs.writeFile after path canonicalization. The parent tool captures the canonical root and file identities (dev and ino) alongside the initial content's SHA-256 digest, then invokes a new one-shot edit-file operation in the native aiden-subagent-file-mutator helper. The native helper performs descriptor-relative directory opens with O_NOFOLLOW starting from the pinned root descriptor, asserts single-link regular file status and matching SHA-256 / identity invariants, and commits the mutation directly through the held file descriptor via ftruncate and descriptor writes. Maintainers should double-check that downstream distribution packaging and CI runner environments continue running build:subagent-file-mutator before any test suite executing coding tools, as reflected in package.json and scripts/ci-test-registry.json.

Confidence Score: 5/5

Full end-to-end trace completed across the TypeScript caller in main/services/coding-tools.ts:1131, the native C implementation in native/subagent-file-mutator/main.c:1843, the Bot tool routing in main/services/bot-file-tool-router.ts:151 and packages/cli/src/bots.ts:69, and prebuilt binary manifest validation in scripts/native-helpers.test.mjs.

📁 Important Files Changed
  • native/subagent-file-mutator/main.c:1798: Adds open_edit_parent and parent_edit_file to execute descriptor-relative O_NOFOLLOW path resolution and descriptor-bound mutation (ftruncate + write_all).
  • main/services/coding-tools.ts:1088: Replaces path-based fs.writeFile with initial handle verification (stat.nlink === 1n), content hashing, and commitPinnedEdit child execution.
  • main/services/bot-file-tool-router.ts:151 & packages/cli/src/bots.ts:69: Passes the installation-relative helper path (AIDEN_CLI_ENTRY-derived) into Bot coding tools so CLI tools do not fall back to untrusted project-local binaries.
  • main/services/coding-tools.test.ts:1334: Adds regression tests verifying that target or ancestor symlink swaps between inspection and commit fail closed without overwriting external files.
  • scripts/subagent-file-mutator.test.mjs:722: Adds native test coverage exercising parent_edit_file against symlink replacement attacks.
  • packages/cli/tests/parity.test.mjs:595: Adds parity test asserting that a project-local decoy mutator binary is not executed.
  • package.json & scripts/ci-test-registry.json: Registers build:subagent-file-mutator as a prerequisite for core-git and tool-outputs test lanes.
  • prebuilt/native/*: Refreshes checked-in native mutator binaries and manifests for Darwin universal, Linux x64, and Linux arm64.

Findings

No findings.

Sequence Diagram

sequenceDiagram
  autonumber
  participant Tool as coding-tools (TS)
  participant FS as Node fs (Handle)
  participant Helper as native mutator (edit-file)
  participant VFS as OS VFS / Kernel

  Tool->>FS: fs.open(canonicalPath, O_RDONLY | O_NOFOLLOW | O_NONBLOCK)
  FS->>Tool: FileHandle
  Tool->>FS: handle.stat({ bigint: true }) & handle.readFile()
  Note over Tool: Verify nlink == 1 & calculate SHA-256
  Tool->>FS: handle.close()
  Tool->>Helper: spawn(edit-file, root, dev/ino, relPath, file dev/ino, sha256, len) via stdin
  Helper->>VFS: open(root, O_RDONLY | O_DIRECTORY | O_NOFOLLOW)
  VFS-->>Helper: root_fd (verify root dev/ino)
  loop For each path component
    Helper->>VFS: openat(current_dir_fd, component, O_DIRECTORY | O_NOFOLLOW)
  end
  Helper->>VFS: openat(parent_dir_fd, leaf, O_RDWR | O_NOFOLLOW | O_NONBLOCK)
  VFS-->>Helper: file_fd
  Note over Helper: Verify dev/ino, nlink == 1, pread SHA-256 == expected, and fstatat(leaf) == before
  Helper->>VFS: ftruncate(file_fd, 0)
  Helper->>VFS: write_all(file_fd, stdin_bytes)
  Helper->>Tool: stdout "edited\n" (exit 0)
  Tool-->>Tool: Return fileMutationResult
Loading
[]

Last reviewed commit: a616fc4bda6e
Reviews (1) · Comment /hermes review to trigger a new review · /hermes review full for full re-review

@sambitcreate
sambitcreate merged commit b1e6054 into main Oct 16, 2026
27 checks passed
@sambitcreate
sambitcreate deleted the feature/edit-file-symlink-race-423 branch October 16, 2026 03:47
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

P2: Concurrent target replacement lets edit_file write outside its pinned root

2 participants