fix(#3493): confine key_links from:/to: to the project directory (#3506)

`cmdVerifyKeyLinks` resolved `from:` and `to:` with `path.join(cwd, <value>)` where the value comes verbatim from plan YAML. `path.join` normalizes `../` rather than rejecting it, so a plan travelling with a repository could name any file the process can read, and the command reports whether the link's `pattern` matched it — an arbitrary-file-read oracle reachable from `verify-phase`.

Both reads now go through `validatePath` in src/security.cts, the existing realpath-based confinement seam already used at 11 call sites. Not a missing capability — a bypassed one.

Two defects in that seam, found by adversarial review and fixed here because they affect all 11 callers:

1. A dangling in-project symlink escaped confinement. A link to an EXISTING outside path was refused (realpath lands outside) while a link to a MISSING outside path took the parent-resolution fallback and was accepted — an existence oracle for arbitrary absolute paths. lstat succeeds on a dangling link and throws ENOENT on a truly absent path; an unresolvable link is now refused. A symlink resolving inside the project is still accepted.

2. A canonicalized base was compared against an uncanonicalized path when a file and its parent were both missing, wrongly refusing legitimate in-project paths on any non-canonical cwd (every macOS temp dir). This was a live regression in this PR: the wave-pending classification (#1202) depends on the not-yet-created case. Resolution now walks up to the nearest existing ancestor.

Two adjacent aborts fixed: the from: read sat outside the per-link try, so a non-ENOENT errno killed the whole command; and an empty from: read the cwd directory, throwing EISDIR. Both now fail per-link.

The issue was filed as a fourth ADR-0174 consolidation loss. It is not one — validatePath/requireSafePath never went away, only the SDK's name for the concept did. This is an instance of epic #3473's F2 family. The ADR-0174 loss count is three.

Closes #3493

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Tom Boucher
2026-08-14 17:29:58 -04:00
committed by GitHub
parent e57e3b5d6e
commit 7a02f98574
7 changed files with 475 additions and 8 deletions

View File

@@ -0,0 +1,5 @@
---
type: Security
pr: 3506
---
**Path validation no longer accepts a symbolic link whose target is missing** — `validatePath` canonicalizes a path with `realpath`, and for a path that does not exist yet it fell back to canonicalizing the parent directory instead. A symlink inside the project pointing at a **non-existent** location outside it took that fallback and was accepted, while a symlink pointing at an **existing** outside location was correctly refused — a difference an attacker could use to test whether arbitrary absolute paths exist. Such a link is now refused outright. The same fallback also compared an uncanonicalized path against a canonicalized base when several leading directories were missing, wrongly refusing legitimate not-yet-created paths on any non-canonical working directory (every macOS temp directory, for one); it now canonicalizes from the nearest existing ancestor. (#3493)

View File

@@ -0,0 +1,5 @@
---
type: Security
pr: 3506
---
**`verify key-links` no longer reads files outside the project** — `from:` and `to:` were taken verbatim from plan frontmatter and resolved with `path.join(cwd, …)`, which normalizes `../` rather than rejecting it, so a plan carried in an untrusted repository could name any file the process could read and learn from the reported result whether a supplied pattern matched its contents. Both paths now resolve through the project's realpath-based confinement seam; a path that escapes is refused without being read, reported as `path_rejected`, and never counts as verified. (#3493)