Linux sandbox in Node.js: DevOps/Java/Spark/DB toolbox + exec API for the Atlas deep agent
3.0K
The machine the Atlas deep agent works on: a Linux sandbox in a Node.js container. The agent never runs a command itself. Every command and file operation comes here over an authenticated HTTP API, after the agent's approval policy has passed it.
docker run --rm IMAGE readme # this file
docker run --rm IMAGE tools # every tool with its version
docker run --rm -it IMAGE shell # interactive bash
docker run --rm IMAGE run 'java -version && spark-submit --version'
docker run -d -e SANDBOX_TOKEN=… IMAGE # the exec server on :8080 (what the agent uses)
The easiest way to run and test it together with the agent is the test kit shipped in the atlas-ts image: docker run --rm <registry>/atlas-ts:<tag> kit | tar -x && cd kit && make help.
Everything is installed at build time and pinned. Downloads are checked against upstream checksum files, or against SHA-256 values pinned in the Dockerfile where upstream publishes none. Nothing is fetched at run time (air-gap safe). tools prints the exact versions.
| Area | Tools |
|---|---|
| Shell / text | bash, coreutils, grep, sed, gawk, find/xargs, sort/uniq/cut/tr, diff/patch, jq, yq, ripgrep, fd, xmlstarlet, bc, parallel, moreutils, envsubst, vim-tiny, nano, tmux, less, tree, file |
| Processes / system | ps/top/htop, lsof, strace, psmisc, time, util-linux, tini |
| Network | curl, wget, ssh/scp, sshpass, bash /dev/tcp, rsync, dig/nslookup, ping, traceroute, nc, socat, telnet, whois, openssl, ldapsearch |
| Archives | tar, gzip, bzip2, xz, zstd, zip/unzip, cpio |
| Build | gcc/g++, make, pkg-config, binutils, git, git-lfs, gh |
| Node.js 22 | node, npm; DB libraries pg, mysql2, oracledb (Thin mode), mssql, mongodb, redis, ioredis, better-sqlite3, knex. They resolve from any directory through NODE_PATH. |
| Python 3.11 | venv first on PATH (python, python3, pip): pytest, ruff, ansible-core, yamllint, requests, PyYAML, Jinja2, httpie, csvkit, boto3, kubernetes; DB/data psycopg 3, SQLAlchemy 2, oracledb (Thin mode, no Instant Client), PyMySQL, pymssql, pyodbc (+unixODBC), pymongo, redis, pandas, pyarrow, duckdb |
| Machine learning | scikit-learn, SciPy, XGBoost (CPU), LightGBM, joblib, mlflow-skinny (tracking to a local mlruns/ or your MLflow server); PyTorch CPU only with --build-arg WITH_TORCH=true (adds ~1 GB) |
| Java | Temurin JDK 21, Maven, Gradle (JAVA_HOME, MAVEN_HOME, GRADLE_HOME) |
| JDBC | /opt/jdbc (JDBC_DRIVERS): PostgreSQL, Oracle ojdbc11, MariaDB/MySQL, SQL Server, SQLite, H2. Use them with java -cp '/opt/jdbc/*' App.java. They are also in Spark's jars/. |
| Spark | Apache Spark 4 (SPARK_HOME): spark-submit, spark-shell, pyspark, spark-sql. PySpark comes from $SPARK_HOME/python with pandas and pyarrow. The UI is off and logging is at WARN. |
| DB clients | psql, mariadb/mysql, sqlite3, redis-cli, sqlcmd (go-sqlcmd, SQL Server), duckdb, sql (Oracle SQLcl, optional), mongosh (optional) |
| DevOps | kubectl, oc (OpenShift CLI), helm, kustomize, terraform, aws cli v2 (optional), ansible, task, hadolint, shellcheck, go (optional) |
| MCP | mcp CLI (your MCP servers via the hub), MCP SDKs for Python (mcp) and Node (@modelcontextprotocol/client) |
Build switches (--build-arg):
WITH_AWSCLI (~200 MB), WITH_GO (~250 MB), WITH_SPARK (~650 MB), WITH_SQLCL (~140 MB) and WITH_MONGOSH (~100 MB); all default true.JDK_VERSION, MAVEN_VERSION, GRADLE_VERSION and SPARK_VERSION pick the versions.PIP_INDEX_URL and NPM_REGISTRY.--secret id=ca,src=ca.pem. It is used for downloads only and never stored in a layer.Licences: everything is open source except Oracle SQLcl (Oracle Free Use Terms and Conditions) and the Oracle JDBC driver (Oracle Free Use Terms, from Maven Central). Use --build-arg WITH_SQLCL=false to leave SQLcl out.
Maven/Gradle/pip/npm in an air gap: the tools are installed, but dependency downloads need your internal repository. Mount a settings.xml, an init.gradle, pip.conf or .npmrc into /home/sandbox.
The full description is served as OpenAPI 3.1 at GET /openapi.json.
| Endpoint | Body → reply |
|---|---|
POST /exec | {command, timeout_s?, cwd?, env?} → {output, exit_code, truncated, timed_out, duration_ms} |
POST /exec/stream | Same body. Streams NDJSON: {"type":"output","data":…} lines as output arrives, then {"type":"exit",exit_code,…}. |
POST /processes | {command, cwd?, env?, name?} → 201 {id, pid}. Starts a background process: a server, kubectl port-forward, a watcher, a long build. |
GET /processes | Every background process: id, name, command, running, exit_code, timestamps. |
GET /processes/{id}?offset=N | {running, exit_code, output, next_offset}. Pass the last next_offset to get only new output; the last 1 MB is kept. |
POST /processes/{id}/stdin | {data, close?}: writes to its stdin. |
DELETE /processes/{id}?grace_s=5 | Stops the whole process group (SIGTERM, then SIGKILL) and returns the final output. |
POST /files/upload | {files: [{path, content_b64}]} → [{path, error}] |
POST /files/download | {paths: [...]} → [{path, content_b64, error}] |
POST /files/list | {path, recursive?, max?} → {entries: [{path, type, size, mtime, mode}], truncated}. Skips .git and node_modules when recursive. |
POST /files/stat · /files/mkdir · /files/move · /files/delete | {paths} · {path} · {from, to} · {paths, recursive?}. /, /workspace, /tmp and HOME themselves are never deleted. |
GET /files/archive?path= | The directory as tar.gz, e.g. a whole repository out in one request. |
PUT /files/archive?path= | A tar.gz body, extracted into path (no owners or permissions restored). |
GET /health | {ok, version, workspace, busy, processes, tools: {name: version}}. Needs no auth. |
GET /metrics | Prometheus: commands by result, exec seconds, file operations, processes started and running, unauthorized requests. Needs no auth. |
All other requests need Authorization: Bearer $SANDBOX_TOKEN. env adds variables for that one command on top of the scrubbed environment; the server's token can never be set or read.
POST /mcp (streamable HTTP, Bearer SANDBOX_TOKEN) offers the toolbox to any MCP client: Claude Code, Claude Desktop, other agents.
Tools:
exec;read_file, write_file, list_files;start_process, process_output, stop_process, list_processes.{ "mcpServers": { "atlas-sandbox": { "type": "http", "url": "http://sandbox:8080/mcp",
"headers": { "Authorization": "Bearer ${ATLAS_SANDBOX_TOKEN}" } } } }
These clients bypass the Atlas agent, so the sandbox applies the approval policy itself. It uses policy.ts, a synced copy of the agent's policy, and also checks the .sql files and scripts a command runs.
| Command | What happens |
|---|---|
Never-allowed (wipe /, reverse shells, credential dumps, …) | Refused |
Needs approval (git push, terraform apply, kubectl apply, any database write, …) | Refused while SANDBOX_MCP_ASK=refuse (the default); the reply names the command |
| Anything else | Runs |
Set SANDBOX_MCP_ASK=allow only for clients that ask their user before every tool call; Claude Code's permission prompts are one example. The never-allowed list is also enforced on the plain HTTP API for every caller.
The sandbox has no published port. Run the MCP client on the same network, e.g. as another service in the kit's compose file, or publish 127.0.0.1:8080 for a client on the same host.
The sandbox reaches your organisation's MCP servers over HTTP.
Endpoints:
GET /mcp-servers lists the servers (no secrets).GET /mcp-servers/{server}/tools lists a server's tools.POST /mcp-servers/{server}/tools/{tool} with {"arguments":{…}} calls one.POST /mcp-servers/reload re-reads the configuration.Inside the sandbox, mcp servers · mcp tools <server> · mcp call <server> <tool> '{json}' do the same over loopback, with no token needed.
Where the hub runs: docker run IMAGE mcp-hub is the MCP hub. It is the container that holds the MCP configuration and secrets and talks to the MCP servers. The sandbox forwards to it with SANDBOX_MCP_UPSTREAM=http://mcp-hub:8081 (a sidecar uses http://127.0.0.1:8081). Without an upstream, the hub runs inside the sandbox process. That is simpler, but then the agent's commands share a container (and a UID) with the secrets.
Hub configuration (any of these; they merge):
SANDBOX_MCP_CONFIG (a JSON file, default /etc/atlas/mcp.json);SANDBOX_MCP_CONFIG_JSON (the same JSON inline);ATLAS_MCP_<NAME>_<FIELD> variables: URL, AUTH (none|oauth2|bearer|gateway), TOKEN_URL, CLIENT_ID, CLIENT_SECRET, SCOPE, AUDIENCE, AUTH_STYLE, TOKEN, HEADERS, POLICY_DEFAULT, POLICY_ALLOW, POLICY_ASK, POLICY_DENY, TIMEOUT_S, ENABLED.${VAR} placeholders are rendered at start-up. That is the shape a Vault injector produces.
Auth: OAuth2 client-credentials tokens are minted by the hub, renewed before expiry, and re-minted on 401. Streamable HTTP is tried first, with HTTP+SSE as the fallback for older servers.
| Variable | Default | |
|---|---|---|
SANDBOX_MCP_UPSTREAM | empty (in-process hub) | URL of the mcp-hub container |
SANDBOX_MCP_HUB_PORT / SANDBOX_MCP_HUB_HOST | 8081 / 0.0.0.0 | hub mode only (a sidecar uses 127.0.0.1) |
SANDBOX_MCP_LOOPBACK_NOAUTH | 1 | let commands inside the sandbox use /mcp-servers without the token |
SANDBOX_MCP_ASK | refuse | the sandbox's own /mcp endpoint: allow runs "needs approval" commands (only for clients that prompt their user) |
The deep agent's tools. On top of deepagents' execute and file tools, the agent has start_process, process_output, stop_process and list_processes. Starting a process goes through the same approval policy as execute. Every MCP tool the sandbox can reach also becomes mcp__<server>__<tool>, behind the MCP approval policy: read-only tools and allow-listed tools run; writes, SQL that writes, and unannotated tools need a person.
| Variable | Default | |
|---|---|---|
SANDBOX_TOKEN | (required) | the bearer token the agent sends |
SANDBOX_PASS_ENV | empty | comma list of extra variables commands may see (e.g. KUBECONFIG,AWS_PROFILE) |
SANDBOX_DEFAULT_TIMEOUT / SANDBOX_MAX_TIMEOUT | 120 / 1800 s | per command |
SANDBOX_MAX_OUTPUT | 100000 chars | head and tail kept |
SANDBOX_MAX_CONCURRENCY | 4 | parallel /exec commands |
SANDBOX_MAX_PROCESSES / SANDBOX_PROCESS_BUFFER | 8 / 1 MiB | background processes running at once / output kept per process |
SANDBOX_PORT / SANDBOX_WORKSPACE | 8080 / /workspace | |
ATLAS_LOG_FORMAT | pretty | json: one JSON object per line for log platforms |
ATLAS_LOG_COLOR | auto | always / never; colours only on a terminal by default (NO_COLOR turns them off) |
The container is the boundary. Run it with --read-only --tmpfs /tmp:exec --cap-drop ALL --security-opt no-new-privileges, as the kit's compose file does. It then has:
/workspace, /home/sandbox, /tmp and /conversation_history writable;Commands run with limits:
SANDBOX_PASS_ENV. They never see the token.ATLAS_LOG_FORMAT=json.The agent decides what may run, before a command leaves it:
terraform apply, kubectl apply/delete, helm upgrade, AWS mutations, git push, ssh, destructive SQL, …Credentials the sandbox should act with are mounted into the sandbox, never into the agent.
Network: put the agent and the sandbox on an internal network. Give the sandbox egress only to the systems it should manage (firewall / NetworkPolicy).
Content type
Image
Digest
sha256:04b440abb…
Size
2 GB
Last updated
about 20 hours ago
docker pull n8500x/atlas-sandbox-devops