Skip to content

fix(security): symlinks and Grep bypassed the sensitive-path deny-list - #16

Merged
ForkedInTime merged 1 commit into
mainfrom
fix/phase2-fs-sensitivity
Aug 3, 2026
Merged

fix(security): symlinks and Grep bypassed the sensitive-path deny-list#16
ForkedInTime merged 1 commit into
mainfrom
fix/phase2-fs-sensitivity

Conversation

@ForkedInTime

Copy link
Copy Markdown
Owner

Phase 2 (filesystem tool surface, 6 files, 1,019 LOC). Two live bypasses, both reproduced before fixing and re-verified after.

Symlinks defeated every sensitive-path protection (CRITICAL)

check_sensitive_path matches on the file name of the path it is handed, and nothing resolved symlinks first. Measured against the real tools:

Attempt Result
read .ssh/id_rsa directly ✅ refused — "private-key file"
read notes.md.ssh/id_rsa private key contents returned
write config.json.aws/credentials credentials overwritten

The guard demonstrably worked when the name matched and demonstrably didn't when it didn't — every protection it offers was one innocuously-named link away from nothing.

This needs no unusual privileges and no suspicious command. A repository can simply ship a symlink called README.md; asking the agent to read it exfiltrates the target. The agent can also create one itself with ln -s.

check_sensitive_path_resolved now checks both the supplied path and its canonicalised target — either looking sensitive is a refusal. For paths that don't exist yet (a fresh write) it canonicalises the parent, which also catches a symlinked parent directory. Applied at all four entry points: FileRead, FileWrite, FileEdit, MultiEdit.

Benign symlinks still work — a guard that blocks real work gets turned off, which is worse than no guard.

Grep returned the contents of files FileRead refuses (HIGH)

Grep never consulted the deny-list, so searching for a string inside server.pem returned the key material verbatim — a read primitive that walked past the guard on FileRead.

Grep has two backends and my first fix only covered one. The pure-Rust fallback can check each file as it opens it; rg opens files itself and had to be told up front via --glob exclusions derived from the same deny-list. Patching only the fallback left the leak fully open on any machine with ripgrep installed — which is most of them, and was this one.

Both are now covered, and the tests exercise them independently (the fallback test clears PATH so rg can't be found).

Not changed

Reading credentials.json and .env stays permitted — that's a deliberate deny-list decision (only key material is read-blocked), not an oversight.

QA

593 tests, 0 failures. Clippy clean under the gate. Release 19.06 MB. Zero panics in production code across all six Phase 2 files.

Each fix verified by reverting it: reverting symlink resolution fails 2 tests, reverting the rg globs fails the rg test, reverting the fallback check fails the fallback test.

Phase 2 (filesystem tool surface, 6 files, 1,019 LOC). Two live bypasses, both
reproduced before fixing and re-verified after.

## Symlinks defeated every sensitive-path protection (CRITICAL)

`check_sensitive_path` matches on the *file name* of the path it is handed, and
nothing resolved symlinks first. Measured, on the real tools:

    read  .ssh/id_rsa directly          -> refused ("private-key file")
    read  notes.md -> .ssh/id_rsa       -> PRIVATE KEY CONTENTS RETURNED
    write config.json -> .aws/credentials -> CREDENTIALS OVERWRITTEN

So the guard demonstrably worked when the name matched and demonstrably did not
when it did not — every protection it offers was one innocuously-named link away
from nothing.

This needs no unusual privileges and no suspicious command. A repository can
simply *ship* a symlink called `README.md`; asking the agent to read it
exfiltrates the target. The agent can also create one itself with `ln -s`.

`check_sensitive_path_resolved` now checks both the supplied path and its
canonicalised target — either looking sensitive is a refusal. For paths that do
not exist yet (a fresh write) it canonicalises the parent instead, which also
catches a symlinked parent directory. Applied at all four filesystem entry
points: FileRead, FileWrite, FileEdit, MultiEdit.

Benign symlinks still work; a guard that blocks real work gets turned off, which
is worse than no guard.

## Grep returned the contents of files FileRead refuses (HIGH)

Grep never consulted the deny-list at all, so searching for a string inside
`server.pem` returned the key material verbatim — a read primitive that walked
straight past the guard on FileRead.

Grep has **two** backends and the first fix only covered one of them. The
pure-Rust fallback can check each file as it opens it; `rg` opens files itself
and had to be told up front via `--glob` exclusions derived from the same
deny-list. Patching only the fallback left the leak fully open on any machine
with ripgrep installed — which is most of them, and was this one.

Both backends are now covered, and the tests exercise them independently (the
fallback test clears PATH so `rg` cannot be found).

## Not changed

Reading `credentials.json` and `.env` stays permitted — that is a deliberate
design decision in the deny-list (only key material is read-blocked), not an
oversight, and it is unaffected here.

QA: 593 tests, 0 failures. Clippy clean under the CI gate. Release 19.06 MB.
Zero panics in production code across all six Phase 2 files. Each fix verified
by reverting it: reverting symlink resolution fails 2 tests, reverting the rg
globs fails the rg test, reverting the fallback check fails the fallback test.

Co-Authored-By: Arch Linux <noreply@archlinux.org>
@ForkedInTime
ForkedInTime merged commit d821a4d into main Aug 3, 2026
5 checks passed
@ForkedInTime
ForkedInTime deleted the fix/phase2-fs-sensitivity branch August 3, 2026 06:09
ForkedInTime added a commit that referenced this pull request Aug 3, 2026
#17)

Completes Phase 2 — the data-integrity half that #16 left open.

- MultiEdit documented its edits as atomic but wrote inside the loop, so a batch where
  edit 3 of 5 failed left the other four on disk: a half-finished refactor reported as one
  '✗' among several '✓'. Edits are now staged and committed per file, all-or-nothing.
- No atomic writes anywhere: fs::write truncates first, so a crash mid-write left the file
  empty with the original gone. atomic_write does temp+fsync+rename in the same directory,
  and carries the original mode across — without that, editing a 0600 file would silently
  republish it at 0644.
- glob built an unbounded result Vec on broad patterns. Capped at 1000 after the mtime sort,
  with truncation stated rather than silent.
- Encoding checked and closed with no change needed: CRLF and BOM round-trip byte-identically,
  non-UTF-8 is refused with bytes untouched.

601 tests, 0 failures. Clippy clean. Zero panics in production code across all six Phase 2
files plus tools/mod.rs. #16's security tests re-run green.
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.

1 participant