anthropics/sandbox-runtime

▲ 1,218 stars today★ 6,868⑂ 606

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

Repository anthropics/sandbox-runtime · default branch - · size 0 KB · watchers 0 · source: GitHub REST API and repository README

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:

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:

0d1c612947c798aef48e6ab4beb7e8544da9d41a-4096x2305

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:

Network Isolation (allow-only pattern): By default, all network access is denied. You must explicitly allow domains. An empty allowedDomains list means no network access. Network traffic is routed through proxy servers running on the host: 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:

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:

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. started with a credentials block: the signing hook is installed only then, and reads the policies per request rather than capturing them. profile, the bubblewrap argv or the Windows ACEs, the credential masks were built when the command was wrapped, and the running proxy servers captured network.parentProxy, and whether TLS is terminated at all, when they started. together with 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:

are the standard streams. srt exits with an error instead of running the command when it cannot read the descriptor it was given, so a dead channel never passes for a live one. A channel that dies before it has delivered a single update takes the command down with it; one that dies after says so and leaves the command running under the config last applied. previous config stays in force. The line itself goes to the debug log rather than to the terminal, and a blank line is ignored silently. before then waits in the channel rather than being lost — and cannot be overwritten by the config the sandbox starts with. Such a line may be read while the command is still being wrapped, and is then the config it is wrapped with, filesystem rules included. Whether it is read that early depends on how long the wrap takes (on Linux, a deny glob over a large tree is long enough), so a rule the command must start under belongs in the settings file. to close the descriptor. End of input is not an error either: the command keeps running under the config last applied. non-blocking mode, and that flag lives on the open file description, so anything else holding the same description — a shell's `exec 3 { console.log(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.

GitHub Stars & Activity

6,868Stars
606Forks
0Open issues
TypeScriptLanguage

GitHub Popularity

GitHub stars6,868
Forks606
Open issues0
Primary languageTypeScript
License-
Stars gained today1,218
Created-
Last pushed-

Trending History

Weekly boardrank #49 · ▲ 1,218 stars

Related GitHub Projects

1

openclaw / openclaw

TypeScript★ 393,424⑂ 82,694▲ 149 stars
→
2

n8n-io / n8n

TypeScript★ 209,100⑂ 61,315▲ 154 stars
→
3

thedotmack / claude-mem

TypeScript★ 105,265⑂ 9,275▲ 336 stars
→
4

KeygraphHQ / shannon

TypeScript★ 50,497⑂ 5,811▲ 334 stars
→
5

Crosstalk-Solutions / project-nomad

TypeScript★ 41,318⑂ 4,194▲ 112 stars
→
6

lfnovo / open-notebook

TypeScript★ 40,972⑂ 4,706▲ 220 stars
→
7

AdguardTeam / AdGuardHome

TypeScript★ 37,932⑂ 2,578▲ 107 stars
→
8

pingdotgg / t3code

TypeScript★ 32,231⑂ 8,553▲ 920 stars
→

More Trending Repositories