Skip to content

feat(tools): per-agent filesystem sandbox#264

Merged
JackChen-me merged 7 commits into
mainfrom
fix/tool-sandbox
May 28, 2026
Merged

feat(tools): per-agent filesystem sandbox#264
JackChen-me merged 7 commits into
mainfrom
fix/tool-sandbox

Conversation

@JackChen-me

@JackChen-me JackChen-me commented May 27, 2026

Copy link
Copy Markdown
Member

Summary

Built-in filesystem tools now operate inside a per-agent working directory. The sandbox is on by default (rooted at process.cwd()); pass null to opt out.

  • src/tool/built-in/path-safety.ts exports resolvePathWithinCwd(). 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, and glob apply the helper before any I/O. Paths must be absolute. grep and glob also 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 such as (sleep 0.4; echo done) & sleep 5 no longer outlive the parent. Aborted runs exit with 130.
  • mcp.ts forwards ToolUseContext.abortSignal into callTool so 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 through runAgent, runTeam, runTasks, the scheduler's best-agent rebuild, and buildTaskAgentTeamInfo.

Behavior change

Agents that previously read or wrote files outside process.cwd(), or used relative paths, now get a sandbox error. Migration: set defaultCwd per orchestrator for multi-directory deployments, or pass defaultCwd: null to restore unrestricted access.

Docs added in docs/tool-configuration.md and docs/cli.md.

Scope: bash is not sandboxed

The sandbox covers built-in filesystem tools only (file_read, file_write, file_edit, grep, glob). The bash tool is deliberately left outside. Once a shell is available, any cd /etc, absolute path, or subshell trivially escapes a per-tool path check, so a half-sandboxed bash would only give a false sense of safety. The only behavior change to bash in this PR is process-group cleanup on timeout/abort.

If you need an agent that cannot read or write outside its working directory, drop bash via disallowedTools: ['bash'] and rely on the filesystem tools, or replace it with a custom tool that whitelists specific commands.

Test plan

  • npm run lint
  • npm test — 910 tests pass, including new coverage for relative-path rejection, sandbox-escape rejection in file_write / file_edit, cwd: null opt-out behavior, bash background-child cleanup on timeout, and MCP callTool receiving the agent's AbortSignal.
  • npm run build
  • npm pack --dry-run

JackChen-me and others added 7 commits May 28, 2026 02:50
- 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.
@JackChen-me
JackChen-me merged commit 794da6f into main May 28, 2026
4 checks passed
@JackChen-me
JackChen-me deleted the fix/tool-sandbox branch June 4, 2026 16:15
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
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