Skip to content

fix(agents-runtime): realpath guard read/write/edit against symlink escape#4360

Open
msfstef wants to merge 1 commit into
mainfrom
security/pr2-realpath-symlink-guard
Open

fix(agents-runtime): realpath guard read/write/edit against symlink escape#4360
msfstef wants to merge 1 commit into
mainfrom
security/pr2-realpath-symlink-guard

Conversation

@msfstef
Copy link
Copy Markdown
Contributor

@msfstef msfstef commented May 19, 2026

Summary

Closes the symlink-bypass class of the read/write/edit path guard (CVE-2025-53109/53110 class). Previously the cwd check was a string prefix on the un-resolved path, so a symlink at <cwd>/link.txt pointing to /etc/hostname was readable, and a symlinked directory could redirect writes outside the workspace. The guard now realpaths the resolved target (or its deepest existing ancestor for write/edit creating new files) and re-checks containment.

The prefix check and realpath check live together in a single resolveInsideWorkdir(filePath, workingDirectory) helper used by all three tools. Returns { ok: true, resolved } or { ok: false, reason }; each tool calls it once.

See plans/sandboxing-investigation.md §1.3 and §3.5.

Known gap

Hardlinks across the cwd boundary share the inode but report a path inside cwd, so the realpath check passes them. A proper fix requires a sandbox (§3.3 of the design doc).

Risk

Low. Could surface in setups that intentionally symlink the workspace out to a parent monorepo. Predictable error message: "Path X resolves outside the working directory via a symlink".

Test plan

  • test/path-guard.test.ts — 10 unit tests covering the helper directly: in-bounds existent/non-existent, .. and absolute-path escape, file/directory symlinks pointing outside, intra-cwd file/directory symlinks, regular-file ancestor (ENOTDIR walk-up), workspace-itself-is-a-symlink
  • test/tool-path-symlink.test.ts — 3 integration tests proving read/write/edit honor the guard at the tool boundary
  • pnpm test, pnpm typecheck, pnpm stylecheck clean in agents-runtime

Addressed from review

  • ENOTDIR is now treated like ENOENT in the ancestor walk-up so a write target like existing-file.txt/sub/new.txt produces the natural domain error rather than re-throwing the kernel code through the outer catch.

🤖 Generated with Claude Code

Base automatically changed from msfstef/desktop-golden-test-agents to main May 19, 2026 13:34
@codecov
Copy link
Copy Markdown

codecov Bot commented May 19, 2026

Codecov Report

❌ Patch coverage is 90.38462% with 5 lines in your changes missing coverage. Please review.
✅ Project coverage is 55.87%. Comparing base (1ab43f5) to head (9cb21c7).
✅ All tests successful. No failed tests found.

Files with missing lines Patch % Lines
packages/agents-runtime/src/tools/path-guard.ts 86.84% 5 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main    #4360      +/-   ##
==========================================
+ Coverage   55.84%   55.87%   +0.02%     
==========================================
  Files         245      246       +1     
  Lines       24847    24878      +31     
  Branches     6878     6887       +9     
==========================================
+ Hits        13876    13900      +24     
- Misses      10957    10964       +7     
  Partials       14       14              
Flag Coverage Δ
packages/agents 70.92% <ø> (ø)
packages/agents-runtime 81.26% <90.38%> (-0.02%) ⬇️
packages/agents-server 73.93% <ø> (ø)
packages/agents-server-ui 6.66% <ø> (ø)
packages/electric-ax 42.61% <ø> (ø)
typescript 55.87% <90.38%> (+0.02%) ⬆️
unit-tests 55.87% <90.38%> (+0.02%) ⬆️

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@msfstef msfstef force-pushed the security/pr2-realpath-symlink-guard branch 2 times, most recently from 383e8ab to 5a2e847 Compare May 19, 2026 14:25
@msfstef msfstef marked this pull request as ready for review May 19, 2026 15:11
@msfstef msfstef requested review from icehaunter and kevin-dp May 19, 2026 15:11
@msfstef msfstef added the claude label May 19, 2026
@claude
Copy link
Copy Markdown

claude Bot commented May 19, 2026

Claude Code Review

Summary

Incremental review. Iteration 2 addresses the ENOTDIR suggestion from iteration 1; the implementation now walks past regular-file ancestors instead of surfacing an opaque tool error, with a dedicated test. No regressions, no new issues. Recommending merge.

What's Working Well

  • ENOTDIR is now handled symmetrically with ENOENTpackages/agents-runtime/src/tools/path-guard.ts:38 extends the catch to code !== 'ENOENT' && code !== 'ENOTDIR', so a write target like existing.txt/sub/new.txt resolves to a real in-cwd ancestor and returns ok. Downstream mkdir/writeFile then produce the real domain error rather than a leaked ENOTDIR from the guard. Fails-closed behavior preserved for unexpected error codes.
  • Test added for the new branchpackages/agents-runtime/test/path-guard.test.ts:72-79 asserts the regular-file-ancestor case returns ok with the expected resolved path. Good targeted coverage of the change.
  • No collateral changes — the iteration is scoped to the one branch and its test. Everything else (intra-cwd symlinks, escape symlinks, working directory under a symlink, brand-new subdirectory writes) stays as previously reviewed.

Issues Found

Critical (Must Fix)

None.

Important (Should Fix)

None.

Suggestions (Nice to Have)

Carried over from iteration 1, all explicitly low-priority — none block merge. Listing for completeness:

  • `realpath(workingDirectory)` recomputed on every call (`path-guard.ts:24`) — could be hoisted/memoized; negligible overhead in practice.
  • `startsWith('..')` false-positive for filenames literally beginning with `..` (`path-guard.ts:18,29`) — pre-existing pattern, vanishingly rare.
  • `parent === probe` branch is effectively unreachable (`path-guard.ts:40-45`) — a one-line "defensive" comment would help a future reader, but not required.
  • `readSet` keyed by un-realpath'd path (`edit.ts:61`, `read-file.ts:66`) — minor UX wart for intra-cwd symlink users; out of scope for this PR.

Issue Conformance

Still no linked GitHub issue. The PR description references `plans/sandboxing-investigation.md §1.3 and §3.5` — that file is not in the repo at the time of this review (only `plans/DeployStarter.md` exists at the root). Same minor traceability gap noted previously; not a code issue.

Implementation continues to match the PR description and changeset: realpath guard on read/write/edit, intra-workspace symlinks preserved, hardlink gap acknowledged, predictable error string.

Previous Review Status

  • Suggestion 1 (ENOTDIR handling) — fixed in `path-guard.ts:38`, tested in `path-guard.test.ts:72-79`.
  • Suggestions 2–5 — not addressed; all were flagged as low-priority/nice-to-have. Reasonable to leave as-is.

Review iteration: 2 | 2026-05-19

…scape

The pre-existing cwd guard in `read-file.ts`, `write.ts`, and `edit.ts`
was a string prefix check on the resolved-but-not-realpathed path. A
symlink inside the working directory pointing outside was followed
transparently — CVE-2025-53109/53110 class bypass. The characterization
tests added in #4354 documented this behavior; this PR flips them to
asserting the bypass is now blocked, and adds positive tests for
intra-workspace symlinks (pnpm `.pnpm` trees, etc.) which keep working.

For non-existent write/edit targets, `realpath` is called on the deepest
existing ancestor and containment is checked from there — the file's
final name has no bearing on the parent's real path.

Known gap, documented in `path-guard.ts`: hardlinks across the cwd
boundary share the inode but report a path inside cwd, so the realpath
check passes them. A proper fix requires a sandbox; see
plans/sandboxing-investigation.md §3.3.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@msfstef msfstef force-pushed the security/pr2-realpath-symlink-guard branch from 5a2e847 to 9cb21c7 Compare May 19, 2026 15:40
@msfstef msfstef self-assigned this May 20, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants