Repository navigation
fix(sandbox-core): prevent create_file/edit_file from writing outside the sandbox - #185
Merged
Merged
Conversation
…side the sandbox Previously both tools did `sandbox_dir.join(&args.path)` and wrote there with no validation. `Path::join` replaces the base for absolute paths, `..` components were not checked, and (most importantly) symlinks were followed — so a symlink committed by an earlier `execute_command` (e.g. `ln -s ~/.ssh/authorized_keys evil`) would ride along into every subsequent workspace and let `create_file evil` or `edit_file evil` read/write arbitrary files with the server's permissions. `create_file`'s `exists()` guard also returned `false` for dangling symlinks, so `fs::write` would happily create the link's target. Reads (`read_file`) are intentionally left unrestricted to match `execute_command`, which may read anywhere. Writes outside the root are rejected in **every** mode, including Direct. ## `resolve_write_path(root, path, display) -> ResolvedWritePath` New helper that resolves the path the way the kernel would and verifies the result is under the canonicalized sandbox root: * Walks components left to right. Whenever the prefix built so far exists on disk (via `lstat`, so dangling symlinks count as existing) it is `canonicalize`d — i.e. `realpath`, which follows every symlink in every component recursively, so no hand-rolled symlink loop is needed. A dangling symlink makes `canonicalize` fail and is rejected. * `..` pops the last component. This is safe because an existing prefix is already canonical (symlink-free) and a not-yet-existing tail will be created by us as real directories. Doing it lexically on the *original* path would be wrong (`link/../x` with `link -> /outside/dir` resolves to `/outside/x`), and walking up with `Path::parent` from the "deepest existing ancestor" would be wrong too (`a/../../outside/x` with `a` missing passes that check but `create_dir_all` then mkdirs through `..` for real — a unit test caught this in an earlier iteration). * Absolute paths need no special-casing: the resolved result is simply compared against the root, so an absolute path *into* the sandbox is allowed and one outside it is rejected. * Returns both the canonical `absolute` path (all I/O uses this, so what was checked is what gets written) and the root-`relative` path (for display). ## Direct-mode approval prompt shows the resolved target The approval prompt previously showed the raw `args.path`, so a symlink or `..` could disguise what the user was approving. Resolution now happens *before* the prompt, inside the `with_sandbox` closure, and the prompt shows the resolved repo-relative path (e.g. `real/new.txt` instead of `inner/new.txt` when `inner -> real`). Paths resolving outside the repo are rejected with no prompt at all. The `write-orig` grant shortcut and grant choices are unchanged. ## `create_file` * Calls `resolve_write_path` *before* `create_dir_all`, which would otherwise mkdir through a symlinked or `..` component. * Replaces the `exists()` check + `fs::write` with `OpenOptions::create_new(true)` (`O_CREAT|O_EXCL`). POSIX guarantees `O_EXCL` does not follow a symlink in the final component, even a dangling one, and the check-and-create is atomic. This matters because the local backend caches sandbox directories per group, so a background `execute_command` can race a file tool in the same directory. ## `edit_file` * Resolves via `resolve_write_path` first and reads/writes the resolved path. ## Tests * 17 unit tests in `sandbox-core` covering: nested nonexistent dirs, `..` that stays inside, `..` escapes (including after a missing component and saturating past `/`), absolute paths inside and outside, symlinked dir/file outside, symlink chains (`a -> b -> outside`), relative symlink targets, `..` after a symlink, dangling symlinks (final and intermediate), legit symlinks that stay inside, a symlinked root (macOS `/var -> /private/var`), prefix-sibling false positives (`root-other` vs `root`), and that the `relative` path is normalized / shows the real target behind a symlink. * New e2e test `crates/sandbox-local/tests/write_escape.rs` that commits symlinks into a real jj repo and drives `create_file`/`edit_file` through the server in both jj and Direct modes, asserting rejections, that nothing lands outside, that legitimate writes still work, and — in Direct mode — that the `UserChoice` prompt text contains the resolved target (not the symlink or `..` form), that "No" leaves the file untouched, and that out-of-repo writes produce a `ToolResult` rejection with no prompt. Verified the escape tests fail when the guard is disabled. `./check.bash` passes (fmt, clippy `-D warnings`, all tests, doctests). The optional Playwright step fails only because `~/.cargo/registry` is read-only in the sandbox, unrelated to this change. Co-authored-by: Infinity 🤖 <infinity@hydro.run> PR: #185
Deploying infinity with
|
| Latest commit: |
8e217f7
|
| Status: | ✅ Deploy successful! |
| Preview URL: | https://edef6138.infinity-dc7.pages.dev |
| Branch Preview URL: | https://sandbox-064642ae-c9cf-4503-b.infinity-dc7.pages.dev |
shadaj
marked this pull request as ready for review
November 3, 2026 19:13
shadaj
force-pushed
the
sandbox-064642ae-c9cf-4503-be3c-c246a980e8a2
branch
from
November 3, 2026 19:32
f8a054f to
8e217f7
Compare
akainth015
approved these changes
Nov 3, 2026
akainth015
left a comment
Member
There was a problem hiding this comment.
Change overall LGTM. Hurts a bit that it's big but a lot of it is tests.
Member
|
I assume we'd have a permissions prompt if the tool call wants to write outside the sandbox? |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Previously both tools did
sandbox_dir.join(&args.path)and wrote there with novalidation.
Path::joinreplaces the base for absolute paths,..componentswere not checked, and (most importantly) symlinks were followed — so a symlink
committed by an earlier
execute_command(e.g.ln -s ~/.ssh/authorized_keys evil)would ride along into every subsequent workspace and let
create_file eviloredit_file evilread/write arbitrary files with the server's permissions.create_file'sexists()guard also returnedfalsefor dangling symlinks, sofs::writewould happily create the link's target.Reads (
read_file) are intentionally left unrestricted to matchexecute_command, which may read anywhere. Writes outside the root arerejected in every mode, including Direct.
resolve_write_path(root, path, display) -> ResolvedWritePathNew helper that resolves the path the way the kernel would and verifies the
result is under the canonicalized sandbox root:
disk (via
lstat, so dangling symlinks count as existing) it iscanonicalized — i.e.realpath, which follows every symlink in everycomponent recursively, so no hand-rolled symlink loop is needed. A dangling
symlink makes
canonicalizefail and is rejected...pops the last component. This is safe because an existing prefix isalready canonical (symlink-free) and a not-yet-existing tail will be created
by us as real directories. Doing it lexically on the original path would be
wrong (
link/../xwithlink -> /outside/dirresolves to/outside/x), andwalking up with
Path::parentfrom the "deepest existing ancestor" would bewrong too (
a/../../outside/xwithamissing passes that check butcreate_dir_allthen mkdirs through..for real — a unit test caught thisin an earlier iteration).
compared against the root, so an absolute path into the sandbox is allowed
and one outside it is rejected.
absolutepath (all I/O uses this, so what waschecked is what gets written) and the root-
relativepath (for display).Direct-mode approval prompt shows the resolved target
The approval prompt previously showed the raw
args.path, so a symlink or..could disguise what the user was approving. Resolution now happensbefore the prompt, inside the
with_sandboxclosure, and the prompt showsthe resolved repo-relative path (e.g.
real/new.txtinstead ofinner/new.txtwheninner -> real). Paths resolving outside the repo arerejected with no prompt at all. The
write-origgrant shortcut and grantchoices are unchanged.
create_fileresolve_write_pathbeforecreate_dir_all, which would otherwisemkdir through a symlinked or
..component.exists()check +fs::writewithOpenOptions::create_new(true)(O_CREAT|O_EXCL). POSIX guaranteesO_EXCLdoes not follow a symlink in the final component, even a dangling one, and
the check-and-create is atomic. This matters because the local backend caches
sandbox directories per group, so a background
execute_commandcan race afile tool in the same directory.
edit_fileresolve_write_pathfirst and reads/writes the resolved path.Tests
sandbox-corecovering: nested nonexistent dirs,..thatstays inside,
..escapes (including after a missing component andsaturating past
/), absolute paths inside and outside, symlinked dir/fileoutside, symlink chains (
a -> b -> outside), relative symlink targets,..after a symlink, dangling symlinks (final and intermediate), legitsymlinks that stay inside, a symlinked root (macOS
/var -> /private/var),prefix-sibling false positives (
root-othervsroot), and that therelativepath is normalized / shows the real target behind a symlink.crates/sandbox-local/tests/write_escape.rsthat commitssymlinks into a real jj repo and drives
create_file/edit_filethroughthe server in both jj and Direct modes, asserting rejections, that nothing
lands outside, that legitimate writes still work, and — in Direct mode —
that the
UserChoiceprompt text contains the resolved target (not thesymlink or
..form), that "No" leaves the file untouched, and thatout-of-repo writes produce a
ToolResultrejection with no prompt. Verifiedthe escape tests fail when the guard is disabled.
./check.bashpasses (fmt, clippy-D warnings, all tests, doctests). Theoptional Playwright step fails only because
~/.cargo/registryis read-onlyin the sandbox, unrelated to this change.