feat(tools): per-agent filesystem sandbox#264
Merged
Conversation
- New `resolvePathWithinCwd()` in `src/tool/built-in/path-safety.ts`. Realpath-resolves root and candidate so symlinks cannot escape. - `file_read`, `file_write`, `file_edit`, `grep`, and `glob` apply the helper. Paths must be absolute. `grep`/`glob` format relative output against the agent root rather than `process.cwd()`. - `bash` spawns detached on POSIX and kills the whole process group on timeout or abort. Backgrounded children no longer outlive the parent. Aborted runs exit with 130. - MCP `callTool` receives `ToolUseContext.abortSignal`. - New config: `AgentConfig.cwd`, `CoordinatorConfig.cwd`, `OrchestratorConfig.defaultCwd`. On by default (rooted at `process.cwd()`); pass `null` at any level to opt out.
- tests/fs-walk.test.ts (new): unit tests for collectFiles covering normal recursion, symlinked-directory escape, symlinked-file escape, and SKIP_DIRS. - tests/built-in-tools.test.ts: companion file-symlink test for glob, and both symlink shapes for grep. Mutation-verified by temporarily reverting lstat to stat in fs-walk.ts; the two fs-walk symlink cases fail as expected.
Filesystem sandbox errors previously read 'outside allowed root', which gave the LLM no recovery hint and the developer no config pointer. Errors now name the config levers (defaultCwd / cwd) and clarify that the limit is by design, so callers know how to widen or disable the sandbox without grepping the source.
resolvePathWithinCwd now returns realCandidate so callers hand a symlink-free path to fs APIs. Previously the check resolved symlinks but the returned path was the raw candidate, so the kernel re-resolved symlinks on the next syscall, leaving a TOCTOU window if anything could swap the symlink mid-call. realExistingAncestor is replaced with realpathTolerant, which walks up to the longest existing prefix and re-attaches the non-existent suffix. This keeps file_write creating new files at the path the caller intended.
This was referenced May 28, 2026
JackChen-me
added a commit
that referenced
this pull request
Jun 7, 2026
A no-tools agent received every registered built-in, including an unsandboxed bash, on the default path (runAgent, runTeam/runTasks, and the runTeam short-circuit). Tool output flows back to the model provider, so under prompt injection this was a remotely triggerable exec + exfiltration primitive. #83 added the allowlist/preset mechanism but kept default-allow; #264 sandboxed the filesystem tools but left bash unsandboxed; together they opened this. Built-in tools now require a positive grant via tools or toolPreset; with neither, an agent resolves to zero. resolveTools() is the chokepoint and the runner gates execution on the granted set, so a registered-but-ungranted call returns a "not granted" error instead of running. Custom/runtime tools (customTools/addTool) stay available since registration is the grant, and still honor disallowedTools. delegate_to_agent follows the same rule. Same default-deny model #87 enforces for cross-agent context. OrchestratorConfig.defaultToolPreset restores the old allow-all in one line; per-agent tools/toolPreset override it. BREAKING CHANGE: agents relying on implicit built-in tools get none until granted. Restore with defaultToolPreset: 'full', or grant per agent via tools/toolPreset.
JackChen-me
added a commit
that referenced
this pull request
Jun 7, 2026
* fix(tools)!: make built-in tools opt-in (default-deny) A no-tools agent received every registered built-in, including an unsandboxed bash, on the default path (runAgent, runTeam/runTasks, and the runTeam short-circuit). Tool output flows back to the model provider, so under prompt injection this was a remotely triggerable exec + exfiltration primitive. #83 added the allowlist/preset mechanism but kept default-allow; #264 sandboxed the filesystem tools but left bash unsandboxed; together they opened this. Built-in tools now require a positive grant via tools or toolPreset; with neither, an agent resolves to zero. resolveTools() is the chokepoint and the runner gates execution on the granted set, so a registered-but-ungranted call returns a "not granted" error instead of running. Custom/runtime tools (customTools/addTool) stay available since registration is the grant, and still honor disallowedTools. delegate_to_agent follows the same rule. Same default-deny model #87 enforces for cross-agent context. OrchestratorConfig.defaultToolPreset restores the old allow-all in one line; per-agent tools/toolPreset override it. BREAKING CHANGE: agents relying on implicit built-in tools get none until granted. Restore with defaultToolPreset: 'full', or grant per agent via tools/toolPreset. * docs: clarify defaultToolPreset excludes coordinator, synthesis, and consensus agents
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.
Summary
Built-in filesystem tools now operate inside a per-agent working directory. The sandbox is on by default (rooted at
process.cwd()); passnullto opt out.src/tool/built-in/path-safety.tsexportsresolvePathWithinCwd(). It realpath-resolves both the agent root and the candidate so symlinks cannot escape, and falls back to the closest existing ancestor when the candidate does not yet exist.file_read,file_write,file_edit,grep, andglobapply the helper before any I/O. Paths must be absolute.grepandglobalso format relative output against the agent root rather thanprocess.cwd().bashspawns detached on POSIX and kills the whole process group on timeout or abort. Backgrounded children such as(sleep 0.4; echo done) & sleep 5no longer outlive the parent. Aborted runs exit with 130.mcp.tsforwardsToolUseContext.abortSignalintocallToolso MCP tools observe agent cancellations.Configuration
New optional fields:
AgentConfig.cwd,CoordinatorConfig.cwd,OrchestratorConfig.defaultCwd. Resolution order is per-agent override → orchestrator default →process.cwd(). Propagated throughrunAgent,runTeam,runTasks, the scheduler's best-agent rebuild, andbuildTaskAgentTeamInfo.Behavior change
Agents that previously read or wrote files outside
process.cwd(), or used relative paths, now get a sandbox error. Migration: setdefaultCwdper orchestrator for multi-directory deployments, or passdefaultCwd: nullto restore unrestricted access.Docs added in
docs/tool-configuration.mdanddocs/cli.md.Scope: bash is not sandboxed
The sandbox covers built-in filesystem tools only (
file_read,file_write,file_edit,grep,glob). Thebashtool is deliberately left outside. Once a shell is available, anycd /etc, absolute path, or subshell trivially escapes a per-tool path check, so a half-sandboxedbashwould only give a false sense of safety. The only behavior change tobashin this PR is process-group cleanup on timeout/abort.If you need an agent that cannot read or write outside its working directory, drop
bashviadisallowedTools: ['bash']and rely on the filesystem tools, or replace it with a custom tool that whitelists specific commands.Test plan
npm run lintnpm test— 910 tests pass, including new coverage for relative-path rejection, sandbox-escape rejection infile_write/file_edit,cwd: nullopt-out behavior,bashbackground-child cleanup on timeout, and MCPcallToolreceiving the agent'sAbortSignal.npm run buildnpm pack --dry-run