mirror of
https://github.com/JackChen-me/open-multi-agent.git
synced 2026-10-19 01:29:57 -05:00
* fix(core): resolve dangling symlinks in the filesystem sandbox check The sandbox resolver treated a dangling symlink as a path that does not exist yet, so the containment check passed on the link's own location and file_write then created the link's target, which could be outside the agent's working directory. The resolver now follows a dangling symlink to the target it names, rejects targets outside the root, keeps in-root targets writable, and bounds resolution at 40 hops so a loop fails the check. Addresses GHSA-h42w-g3ch-rjjm (CVE-2026-108600). * fix(core): fail the sandbox check when realpath reports ELOOP A symlink chain longer than the kernel's link limit made realpath fail with ELOOP, and the resolver then followed the first link by hand and resolved the rest, accepting a path the kernel itself refuses. ELOOP from realpath now fails the check, and the resolver's own hop limit stays as the bound for links it follows by hand. * fix(core): resolve symlink targets one component at a time Following a dangling symlink, the resolver normalized the target with path.resolve, which collapses a/.. lexically. When a is itself a symlink the kernel steps out of the directory a points to, so a target such as pivot/../created.txt resolved to a different file than a write through the link would create. The resolver now walks the target component by component, following symlinks before applying .., and fails when .. would step out of a missing directory, as the kernel does. The previous test for a self-referencing target built the link with path.join, which folded ghost/../self to self, so it only exercised a plain loop. Link targets in these tests are now joined without normalization. * fix(core): keep the directory requirement of a trailing separator in symlink targets A symlink target ending in a separator or "." names a directory, so the kernel refuses to open or create a file through it. The resolver dropped those components, and native realpath does the same on macOS, so a link to victim/ resolved to the file victim and file_write overwrote it. The resolver now requires an existing directory at the end of such a target, and confirms each realpath result with stat, which takes the same lookup as open, before trusting it.
217 lines
10 KiB
Markdown
217 lines
10 KiB
Markdown
# Sandbox and shell execution
|
|
|
|
This page answers two questions that are easy to conflate: **where can a granted
|
|
tool touch the filesystem**, and **where does a granted `bash` command actually
|
|
run**. They have different answers. The built-in filesystem tools are contained
|
|
by a per-agent working directory; `bash` is not contained by anything OMA owns,
|
|
and its execution target is a replaceable seam rather than a boundary.
|
|
|
|
Granting the tools themselves is a separate layer, covered in
|
|
[tool configuration](tool-configuration.md).
|
|
|
|
## What the sandbox covers
|
|
|
|
Built-in filesystem tools (`file_read`, `file_write`, `file_edit`, `grep`,
|
|
`glob`) resolve every path inside the agent's working directory. `bash` does
|
|
not.
|
|
|
|
| Surface | Contained by `cwd` / `defaultCwd`? |
|
|
|---|---|
|
|
| `file_read`, `file_write`, `file_edit`, `grep`, `glob` | Yes. Paths must be absolute and resolve inside the root, symlinks included. |
|
|
| `bash` | No. The tool takes a `cwd` argument straight from the model and runs `bash -c` with the host process's permissions. |
|
|
| Custom tools | No. Tool code is application-owned and runs in-process. |
|
|
| `process` and ACP backends | No. They own their own `cwd` and execution; see [external agents](external-agents.md). |
|
|
|
|
This is the repository-level invariant: **filesystem tools are sandboxed;
|
|
`bash` is not.** Once an agent has a shell, any `cd /etc`, absolute path, or
|
|
subshell trivially escapes a per-tool path check, so the sandbox is best
|
|
understood as **path containment for built-in filesystem tools**, not a
|
|
security boundary against arbitrary command execution.
|
|
|
|
If full path containment matters, drop `bash` via `disallowedTools: ['bash']`
|
|
(or omit it from your `tools` allowlist) and rely on the filesystem tools.
|
|
Process-level isolation (containers, seatbelt, firejail) is the right tool for
|
|
an actually-untrusted shell.
|
|
|
|
## Filesystem working directory
|
|
|
|
Built-in filesystem tools are sandboxed to a per-agent working directory. Paths
|
|
must be absolute and resolve inside that directory; symlinks are resolved
|
|
before the check so they cannot escape the configured root. The resolved,
|
|
symlink-free path is what reaches the `fs` API, which closes the window where a
|
|
symlink inside the candidate path could be swapped after the check. A dangling
|
|
symlink is resolved to the target it names, since writing through it would
|
|
create that target, so a link pointing outside the root is rejected like any
|
|
other outside path.
|
|
|
|
A relative path is rejected outright, and a path that resolves outside the root
|
|
returns an error `ToolResult` naming the root and the configuration fields that
|
|
widen or disable it. Either way the tool implementation is not reached.
|
|
|
|
### Three typical configurations
|
|
|
|
```typescript
|
|
import { OpenMultiAgent } from '@open-multi-agent/core'
|
|
|
|
// 1. Default — sandbox rooted at `<cwd>/.agent-workspace`.
|
|
// The directory is auto-created on first write. Agents cannot read or
|
|
// write outside that subdirectory, which keeps source files, `.env`,
|
|
// `.git/`, and `node_modules` off-limits even when the host launched
|
|
// from the repo root.
|
|
const defaultOrchestrator = new OpenMultiAgent()
|
|
|
|
// 2. Widen the sandbox to the entire current working directory.
|
|
// Useful when the agent is a coding assistant operating on the user's
|
|
// project (the host already established trust by launching there).
|
|
const wideOrchestrator = new OpenMultiAgent({
|
|
defaultCwd: process.cwd(),
|
|
})
|
|
|
|
// 3. Disable the sandbox entirely (relative and absolute paths anywhere).
|
|
const unrestrictedOrchestrator = new OpenMultiAgent({
|
|
defaultCwd: null,
|
|
})
|
|
```
|
|
|
|
### Custom sandbox root
|
|
|
|
```typescript
|
|
const orchestrator = new OpenMultiAgent({
|
|
defaultCwd: '/var/run/my-agent-workspace', // any absolute path
|
|
})
|
|
|
|
const agent: AgentConfig = {
|
|
name: 'editor',
|
|
model: 'claude-sonnet-4-6',
|
|
toolPreset: 'readwrite',
|
|
cwd: '/var/run/my-agent-workspace/packages/app', // optional per-agent override
|
|
}
|
|
```
|
|
|
|
**Resolution order.** `AgentConfig.cwd` (if set) → `OrchestratorConfig.defaultCwd` (if set) → `<process.cwd()>/.agent-workspace`. Pass `null` at either level to disable the sandbox for that scope. An explicit `cwd: null` on an agent wins over a configured `defaultCwd`.
|
|
|
|
**Auto-creation.** The sandbox root is `mkdir -p`'d on first write, so callers do not need to pre-create `.agent-workspace` (or any custom path). Only `file_write` creates it; the other filesystem tools report a missing root as an error instead of creating it silently.
|
|
|
|
The default `LocalShellExecutor` runs `bash` in its own process group on POSIX,
|
|
so timeouts and abort signals kill any backgrounded children rather than
|
|
letting them outlive the parent. Custom executors own equivalent cleanup in
|
|
their execution environment.
|
|
|
|
## Shell executors
|
|
|
|
The granted `bash` built-in delegates command execution through a
|
|
`ShellExecutor`. With no executor configured, OMA uses `LocalShellExecutor`,
|
|
which preserves the existing `bash -c` behavior on the host:
|
|
|
|
```typescript
|
|
import { OpenMultiAgent } from '@open-multi-agent/core'
|
|
import type { AgentConfig } from '@open-multi-agent/core'
|
|
import type { ShellExecutor } from '@open-multi-agent/core/shell'
|
|
|
|
declare const sharedRemoteExecutor: ShellExecutor
|
|
declare const specialistExecutor: ShellExecutor
|
|
|
|
const orchestrator = new OpenMultiAgent({
|
|
// Inherited by agents that do not set shellExecutor.
|
|
defaultShellExecutor: sharedRemoteExecutor,
|
|
})
|
|
|
|
const agent: AgentConfig = {
|
|
name: 'builder',
|
|
model: 'claude-sonnet-4-6',
|
|
tools: ['bash'],
|
|
// Per-agent value wins over the orchestrator default.
|
|
shellExecutor: specialistExecutor,
|
|
}
|
|
```
|
|
|
|
The executor changes **where an already-granted command runs**. It does not
|
|
grant `bash` or bypass `disallowedTools`; command execution still happens only
|
|
after `onToolCall` allows it. The existing default-deny and per-call gate rules
|
|
are unchanged. The tool wrapper also keeps the model-facing behavior uniform
|
|
across executors: input validation, the 30-second default timeout,
|
|
stdout/stderr formatting, output redaction, and
|
|
`isError` on a nonzero exit code.
|
|
|
|
The public type contract is available from `@open-multi-agent/core/shell` and
|
|
has no runtime imports:
|
|
|
|
```typescript
|
|
interface ShellExecutor {
|
|
start?(): Promise<void>
|
|
exec(command: string, options: ShellExecOptions): Promise<ShellExecResult>
|
|
dispose?(): Promise<void>
|
|
}
|
|
```
|
|
|
|
`ShellExecOptions` contains `cwd`, `timeoutMs`, and `abortSignal`.
|
|
`ShellExecResult` contains `stdout`, `stderr`, and `exitCode`. Executors must
|
|
use exit code `124` for timeout, `130` for abort, and `127` when the process or
|
|
remote command could not be started. The tool wrapper adds a short deadline
|
|
backstop so an executor that ignores timeout or abort cannot stall the tool
|
|
loop indefinitely, but implementations still own prompt cancellation and
|
|
termination of the process, job, or remote session they control.
|
|
|
|
### Lifecycle and concurrent use
|
|
|
|
One executor instance represents one reusable session:
|
|
|
|
- OMA calls `start()` lazily, immediately before the first allowed `bash`
|
|
execution in a run. A granted tool that is never called (or is denied by
|
|
`onToolCall`) creates no session. Later shell calls in that run reuse it.
|
|
- OMA always attempts `dispose()` when the run succeeds, fails, is aborted, or
|
|
a streaming consumer stops early. If `start()` partially allocates resources
|
|
and then rejects, OMA attempts `dispose()` too.
|
|
- If overlapping runs share the same executor instance (as agents inheriting
|
|
one `defaultShellExecutor` do), OMA reference-counts them: one `start()`, then
|
|
one `dispose()` after the final run finishes. `exec()` may be called
|
|
concurrently, including when one model turn requests multiple shell calls.
|
|
An executor that cannot run commands concurrently must serialize internally;
|
|
use distinct per-agent instances when each agent needs its own session.
|
|
- A process crash cannot execute JavaScript cleanup. Remote adapters should
|
|
also configure a provider-side TTL, lease expiry, or out-of-band reaper for
|
|
crash recovery.
|
|
|
|
These lifecycle calls belong to Agent/Orchestrator runs. If application code
|
|
invokes the low-level exported `bashTool.execute()` directly, that caller owns
|
|
`start()` / `dispose()` around its tool calls.
|
|
|
|
`LocalShellExecutor` is stateless. It keeps the existing safe environment
|
|
allowlist, captures stdout/stderr, runs the command in a separate process group
|
|
on POSIX, and kills the process tree on timeout or abort. The allowlist admits
|
|
only `HOME`, `LANG`, `LC_ALL`, `LOGNAME`, `PATH`, `SHELL`, `TEMP`, `TERM`,
|
|
`TMP`, `TMPDIR`, and `USER`, and a credential-shaped name is dropped even when
|
|
it is on that list, so a command does not inherit the parent process's provider
|
|
keys by default. `LocalShellExecutor` executes with the
|
|
host Node.js process's permissions: **it is not a sandbox or security
|
|
boundary**. A custom executor is only as isolated as the environment and
|
|
adapter implementation behind it; OMA does not ship Docker, VM, or hosted
|
|
sandbox adapters in core.
|
|
|
|
### Host and remote filesystems diverge
|
|
|
|
> **A remote shell executor does not move the built-in filesystem tools.**
|
|
> `file_read`, `file_write`, `file_edit`, `grep`, and `glob` still operate on
|
|
> the host inside `AgentConfig.cwd` / `defaultCwd`. The `cwd` passed to `bash`
|
|
> is interpreted inside the executor's environment. For example, `file_write`
|
|
> may create `report.md` in the host `.agent-workspace`, while a following
|
|
> remote `bash` call to `wc -l report.md` sees no such file. Unless the
|
|
> application provides its own synchronization layer, do not co-grant the
|
|
> host filesystem tools to a remote-shell agent, or explicitly design around
|
|
> the two separate filesystems.
|
|
|
|
Shell executors apply only inside the normal LLM runner tool loop. Process and
|
|
ACP agent backends replace that loop and continue to manage their own command
|
|
execution and `cwd`; `shellExecutor` does not affect them.
|
|
|
|
## Related pages
|
|
|
|
- [Tool configuration](tool-configuration.md) for grants, presets, and the
|
|
per-call `onToolCall` gate that decides whether a specific command runs.
|
|
- [LLM egress policy](egress-policy.md#enforcement-matrix) for why `bash` and
|
|
custom tools stay outside `egressPolicy`.
|
|
- [Self-hosting and data residency](self-hosting.md) for the runtime footprint
|
|
and where state lands on disk.
|
|
- [External agents](external-agents.md) for process and ACP backends, which
|
|
replace the runner tool loop entirely.
|