anthropics/sandbox-runtime
A lightweight sandboxing tool for enforcing filesystem and network restrictions on arbitrary processes at the OS level, without requiring a container.
About anthropics/sandbox-runtime
anthropics/sandbox-runtime is an open-source project on GitHub, mainly written in TypeScript. A lightweight sandboxing tool for enforcing filesystem and network restrictions on arbitrary processes at the OS level, without requiring a container. It currently holds 6,868 stars and 606 forks with 0 open issues, and was last pushed on an unknown date (repository created unknown).
Project Overview
Git Homed tracks it on the Today's Trending board.
GitHub Repository Details
README
Anthropic Sandbox Runtime (srt)
A lightweight sandboxing tool for enforcing filesystem and network restrictions on arbitrary processes at the OS level, without requiring a container.
srt uses native OS sandboxing primitives (sandbox-exec on macOS, bubblewrap on Linux) and proxy-based network filtering. It can be used to sandbox the behaviour of agents, local MCP servers, bash commands and arbitrary processes.
Beta Research Preview
> The Sandbox Runtime is a research preview developed for Claude Code to enable safer AI agents. It's being made available as an early open source preview to help the broader ecosystem build more secure agentic systems. As this is an early research preview, APIs and configuration formats may evolve. We welcome feedback and contributions to make AI agents safer by default!
Installation
npm install -g @anthropic-ai/sandbox-runtime
Basic Usage
# Network restrictions
$ srt "curl anthropic.com"
Running: curl anthropic.com
... # Request succeeds
$ srt "curl example.com"
Running: curl example.com
Connection blocked by network allowlist # Request blocked
Filesystem restrictions
$ srt "cat README.md"
Running: cat README.md
Anthropic Sandb... # Current directory access allowed
$ srt "cat ~/.ssh/id_rsa"
Running: cat ~/.ssh/id_rsa
cat: /Users/ollie/.ssh/id_rsa: Operation not permitted # Specific file blocked
Overview
This package provides a standalone sandbox implementation that can be used as both a CLI tool and a library. It's designed with a secure-by-default philosophy tailored for common developer use cases: processes start with minimal access, and you explicitly poke only the holes you need.
Key capabilities:
- Network restrictions: Control which hosts/domains can be accessed via HTTP/HTTPS and other protocols
- Filesystem restrictions: Control which files/directories can be read/written
- Unix socket restrictions: Control access to local IPC sockets
- Violation monitoring: On macOS, tap into the system's sandbox violation log store for real-time alerts
Example Use Case: Sandboxing MCP Servers
A key use case is sandboxing Model Context Protocol (MCP) servers to restrict their capabilities. For example, to sandbox the filesystem MCP server:
Without sandboxing (.mcp.json):
{
"mcpServers": {
"filesystem": {
"command": "npx",
"args": ["-y", "@modelcontextprotocol/server-filesystem"]
}
}
}
With sandboxing (.mcp.json):
{
"mcpServers": {
"filesystem": {
"command": "srt",
"args": ["npx", "-y", "@modelcontextprotocol/server-filesystem"]
}
}
}
Then configure restrictions in ~/.srt-settings.json:
{
"filesystem": {
"denyRead": [],
"allowWrite": ["."],
"denyWrite": ["~/sensitive-folder"]
},
"network": {
"allowedDomains": [],
"deniedDomains": []
}
}
Now the MCP server will be blocked from writing to the denied path:
> Write a file to ~/sensitive-folder
✗ Error: EPERM: operation not permitted, open '/Users/ollie/sensitive-folder/test.txt'
How It Works
The sandbox uses OS-level primitives to enforce restrictions that apply to the entire process tree:
- macOS: Uses
sandbox-execwith dynamically generated Seatbelt profiles - Linux: Uses bubblewrap for containerization with network namespace isolation
- Windows: Runs the sandboxed process under a dedicated
srt-sandboxlocal user account, with a Windows Filtering Platform egress fence keyed on that account's SID, a write-restricted token, and explicit ACEs on the working tree
Dual Isolation Model
Both filesystem and network isolation are required for effective sandboxing. Without file isolation, a compromised process could exfiltrate SSH keys or other sensitive files. Without network isolation, a process could escape the sandbox and gain unrestricted network access.
Filesystem Isolation enforces read and write restrictions:
- Read (deny-then-allow pattern): By default, read access is allowed everywhere. You can deny broad regions (e.g.,
/Users) and then re-allow specific paths within them (e.g.,.).allowReadtakes precedence overdenyRead— the opposite of write, wheredenyWritetakes precedence overallowWrite. AdenyReadentry that is more specific than theallowReadregion it falls inside (e.g.denyRead: ["/.env"]or["./secrets"]withallowRead: ["."]) still stays denied. - Write (allow-only pattern): By default, write access is denied everywhere. You must explicitly allow paths (e.g.,
.,/tmp). An empty allow list means no write access.
srt-sandbox account except loopback to the proxy port range. The proxies listen inside that range, creating a controlled channel for all network access. The filters do not follow a sandboxed process that puts a different token on a thread before creating a socket, nor traffic that a system service carries on its behalf; see the Windows Known limitations
Both HTTP/HTTPS (via HTTP proxy) and other TCP traffic (via SOCKS5 proxy) are mediated by these proxies, which enforce your domain allowlists and denylists.
For more details on sandboxing in Claude Code, see:
- Claude Code Sandboxing Documentation
- Beyond Permission Prompts: Making Claude Code More Secure and Autonomous
Architecture
src/
├── index.ts # Library exports
├── cli.ts # CLI entrypoint (srt command)
├── utils/ # Shared utilities
│ ├── debug.ts # Debug logging
│ ├── settings.ts # Settings reader (permissions + sandbox config)
│ ├── platform.ts # Platform detection
│ └── exec.ts # Command execution utilities
└── sandbox/ # Sandbox implementation
├── sandbox-manager.ts # Main sandbox manager
├── sandbox-schemas.ts # Zod schemas for validation
├── sandbox-violation-store.ts # Violation tracking
├── sandbox-utils.ts # Shared sandbox utilities
├── http-proxy.ts # HTTP/HTTPS proxy for network filtering
├── socks-proxy.ts # SOCKS5 proxy for network filtering
├── linux-sandbox-utils.ts # Linux bubblewrap sandboxing
├── macos-sandbox-utils.ts # macOS sandbox-exec sandboxing
└── windows-sandbox-utils.ts # Windows srt-win sandboxing
Usage
As a CLI tool
The srt command (Anthropic Sandbox Runtime) wraps any command with security boundaries:
# Run a command in the sandbox
srt echo "hello world"
With debug logging
srt --debug curl https://example.com
Specify custom settings file
srt --settings /path/to/srt-settings.json npm install
The settings file is optional — with no file at ~/.srt-settings.json, srt
runs with built-in defaults: no network access, no writes outside the default
write paths, and unrestricted reads. A settings file that _is_ there but is
empty, cannot be read, or does not validate is an error: srt says so and
exits rather than falling back to those defaults, which are a different
config rather than a weaker one — falling back would drop the file's
denyRead, allowRead and credential rules along with everything else it
said. The same goes for a file named with --settings, which must also
exist.
Updating the config while the command runs: --control-fd
--control-fd reads config updates from a descriptor the caller has
already opened, one JSON object per line in the same shape as the settings
file. Each line replaces the whole config and is validated against the same
schema, so a line is a complete config rather than a patch: network and
filesystem are required, and a key the line leaves out goes back to its
default.
# fd 3 is the read end of a pipe the caller writes lines to
srt --control-fd 3 -- npm test
What an accepted line changes once the command is running:
- The network lists —
network.allowedDomains,deniedDomains(with
deniedDomainReasons) and deniedResolvedAddresses — take effect on the
next connection, on every platform: the proxy consults them per request,
and the resolved-address check is rebuilt from the same line. So do
network.mitmProxy and network.tlsTerminate.excludeDomains, which it
reads per request too.
credentials.sigv4takes effect on the next request, provided srt
credentials block: the signing hook is installed only
then, and reads the policies per request rather than capturing them.
- Nothing else changes. The filesystem rules are already in the seatbelt
network.parentProxy, and whether TLS is terminated at all,
when they started.
- On Windows, srt notices a line whose file-access set (
filesystem.*
credentials.files) differs from the one applied at
startup, and says so only under SRT_DEBUG: the ACL grant is
session-wide, so the set applied at startup stays in force. The rest of
the line is applied as above.
How srt reads the channel, and what it refuses:
- The descriptor must be an integer 3 or above and readable —
0-2
- A line that is not a valid config is reported on stderr and dropped; the
- srt starts reading only once the sandbox is up, so an update written
- srt exits with the wrapped command and does not wait for the writer
- Give srt a dedicated, read-only end. srt puts a pipe or socket into
Command exited with code ${code})
// Cleanup when done (optional, happens automatically on process exit)
await SandboxManager.reset()
})
Violation attribution (commandId / commandText). Violations observed while a wrapped command runs (seatbelt log lines, seccomp events, proxy denies) are stored under an attribution key, and annotateStderrWithSandboxFailures(key, stderr) / getViolationsForCommand(key) look them up by that same key. By default the key is the wrapped string itself. Pass an opaque per-invocation commandId (e.g. a tool-use id) to key by that instead — recommended: keys compare on their first 100 characters, so long commands sharing a prefix would otherwise cross-attribute, and a rerun of the same text would inherit the earlier run's events. If the string you _execute_ is not the command the invocation _represents_ (e.g. you wrap an assembled source && eval ''), also pass commandText: '': it is what ignoreViolations command patterns match against and what each violation reports as its command. A commandId you pass to wrapWithSandbox must be the same non-empty string you then pass to annotateStderrWithSandboxFailures / getViolationsForCommand; an empty one is treated as no commandId at all, so the key is the command.
Only the key is cut to 100 characters. As of v0.0.76 the reported command — and the text ignoreViolations command patterns are matched against — is the whole command for an invocation wrapped without a commandId, not its first 100 characters; a pattern can therefore only suppress more than it did before, never less. An attribution key no invocation of this process registered (the carriers are writable from inside the sandbox) is reported sanitized and cut to that same key length.
typescript
const wrapped = await SandboxManager.wrapWithSandbox(
assembledCommand, // what actually runs
undefined,
undefined,
undefined,
{ commandId: invocationId, commandText: rawCommand },
)
// ... run it ...
const annotated = SandboxManager.annotateStderrWithSandboxFailures(
invocationId,
stderr,
)
Asking about a host no rule decides (the ask callback). The second argument to SandboxManager.initialize is an optional SandboxAskCallback. It is asked about a destination that neither network.deniedDomains, nor network.allowedDomains, nor a per-command allow list (next section) decided, and it is never asked under network.strictAllowlist. Without a callback such a destination is denied. The callback resolves to one of:
true - allow the connection. Only the value true allows.
false - deny it. The violation line reports the generic reason user denied.
{ allow: false, reason: '...' } - deny it, and report reason in the violation line in place of the generic text, so that whoever reads the <sandbox_violations> block (a model included) learns why and what to do instead. The sandboxed client reads it too, as the status phrase of the 403 that answers its CONNECT.
Any other answer denies, whether it is truthy or not, and reports the generic reason. That includes an object that carries a reason without saying allow: false, such as { allow: true, reason: '...' }: its reason is not reported.
The reason is sanitized before it is stored, the way the rest of a violation line is. Each run of control characters (line breaks and tabs included) or of invisible ones (zero-width characters, the joiner among them, and bidi controls) becomes one space; < and > are removed, so re-run with listed is stored as re-run with host listed; the ends are trimmed. The result is cut to 500 characters, counted as UTF-16 code units the way String.prototype.length counts them. A reason with nothing left after that falls back to user denied. Write it as one line of plain text.
Behaviour change in the first release after 0.0.79: the filter used to allow on any truthy answer, so a callback that resolved to a truthy value other than true (1, a string, any object) allowed the connection. It now denies. For the same reason, { allow: false, reason } returned to an older release would be read there as an allow, because an object is truthy. SandboxManager.askCallbackDenyReason is true on a release that understands the object: check it before returning one, and deny with a plain false where it is absent.
typescript
const deny = (reason: string) =>
SandboxManager.askCallbackDenyReason
? { allow: false as const, reason }
: false
await SandboxManager.initialize(config, async ({ host, port }) => {
if (await userApproves(host, port)) return true
return deny(${host} was not approved for this session)
})
Per-command network allow lists (registerCommandNetworkLists). One invocation can carry an allow list of its own, in addition to the configured one, for as long as it runs:
typescript
import { randomBytes } from 'node:crypto'
// 128 bits of randomness, 22 characters. See below for why nothing less will do. const commandId = randomBytes(16).toString('base64url') const wrapped = await SandboxManager.wrapWithSandbox( command, undefined, undefined, undefined, { commandId }, )
SandboxManager.registerCommandNetworkLists(commandId, { allowedDomains: ['registry.example.org', '*.cdn.example.org:443'], }) const child = spawn(wrapped, { shell: true }) const unregister = () => SandboxManager.unregisterCommandNetworkLists(commandId) child.once('exit', unregister) child.once('error', unregister) // the child never started
Entries use the grammar and the matcher of network.allowedDomains and get the same validation. registerCommandNetworkLists throws on an invalid entry, and on a commandId shorter than 22 characters (an unpaired surrogate does not count). Registering an id again replaces its list; unregisterCommandNetworkLists is a no-op for an id that has none; reset() removes every registration.
The order of evaluation for a connection is:
1. a network.deniedDomains match denies;
2. a network.allowedDomains match allows;
3. network.strictAllowlist denies;
4. a match in the list registered for the id the connection presents allows;
5. the ask callback decides if there is one, and otherwise the connection is denied.
So a per-command entry never overrides a configured deniedDomains entry, and is ignored entirely under strictAllowlist. It never enters network.* either: getConfig() and getNetworkRestrictionConfig() do not show it, and the default injectHosts scope of a masked credential, which is network.allowedDomains, does not grow. A host allowed this way still goes through the resolved-address check when it is dialed, and an IP literal in a per-command list adds no exemption there.
What the id has to be, and what this feature is not: the proxy learns which invocation a connection belongs to from the proxy username, and the username is presented by the client inside the sandbox. The id is therefore the only thing binding a connection to an allow list. It must be unguessable: at least 128 bits of randomness, never a counter, a timestamp or anything derived from the command. A sandboxed process that presents another live invocation's id gets that invocation's allows, so this is attribution, not a boundary between concurrent commands of one session. Register just before spawning the wrapped command, and unregister when the child exits or never started: a registration that outlives its command widens the window in which its id is worth presenting. As with every attribution key, only the first 100 characters of an id take part. Keep ids ASCII: a list under a non-ASCII id whose encoded form does not fit in the proxy username (255 bytes) registers and never applies.
No list applies while network.httpProxyPort names an external proxy: the wrap gives that proxy no username, so no connection carries an id. registerCommandNetworkLists still succeeds (and says so in one line under SRT_DEBUG), and every connection is decided as if no list existed.
Available exports
typescript
// Main sandbox manager
export { SandboxManager } from '@anthropic-ai/sandbox-runtime'
// Violation tracking export { SandboxViolationStore } from '@anthropic-ai/sandbox-runtime'
// TypeScript types export type { SandboxRuntimeConfig, NetworkConfig, FilesystemConfig, IgnoreViolationsConfig, SandboxAskCallback, FsReadRestrictionConfig, FsWriteRestrictionConfig, NetworkRestrictionConfig, } from '@anthropic-ai/sandbox-runtime'
Configuration
Settings File Location
By default, the sandbox runtime looks for configuration at ~/.srt-settings.json. You can specify a custom path using the --settings flag:
bash
srt --settings /path/to/srt-settings.json
Complete Configuration Example
json
{
"network": {
"allowedDomains": [
"github.com",
"*.github.com",
"lfs.github.com",
"api.github.com",
"npmjs.org",
"*.npmjs.org"
],
"deniedDomains": ["malicious.com"],
"allowUnixSockets": ["/var/run/docker.sock"],
"allowLocalBinding": false
},
"filesystem": {
"denyRead": ["~/.ssh"],
"allowRead": [],
"allowWrite": [".", "src/", "test/", "/tmp"],
"denyWrite": [".env", "config/production.json"]
},
"ignoreViolations": {
"*": ["/usr/bin", "/System"],
"git push": ["/usr/bin/nc"],
"npm": ["/private/tmp"]
},
"enableWeakerNestedSandbox": false,
"allowNestedUserNamespaces": false,
"enableWeakerNetworkIsolation": false,
"allowAppleEvents": false
}
```
Configuration Options
Network Configuration
Uses an allow-only pattern - all network access is denied by default.
network.allowedDomains- Array of allowed domains (supports wildcards like*.example.com).