Sign inSign up

n8500x/atlas-sandbox-devops

By n8500x

•Updated about 20 hours ago

Linux sandbox in Node.js: DevOps/Java/Spark/DB toolbox + exec API for the Atlas deep agent

Buildkit cache
Image
0

3.0K

n8500x/atlas-sandbox-devops repository overview

⁠atlas-sandbox-devops

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.

⁠What is inside

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.

AreaTools
Shell / textbash, 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 / systemps/top/htop, lsof, strace, psmisc, time, util-linux, tini
Networkcurl, wget, ssh/scp, sshpass, bash /dev/tcp, rsync, dig/nslookup, ping, traceroute, nc, socat, telnet, whois, openssl, ldapsearch
Archivestar, gzip, bzip2, xz, zstd, zip/unzip, cpio
Buildgcc/g++, make, pkg-config, binutils, git, git-lfs, gh
Node.js 22node, 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.11venv 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 learningscikit-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)
JavaTemurin 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/.
SparkApache 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 clientspsql, mariadb/mysql, sqlite3, redis-cli, sqlcmd (go-sqlcmd, SQL Server), duckdb, sql (Oracle SQLcl, optional), mongosh (optional)
DevOpskubectl, oc (OpenShift CLI), helm, kustomize, terraform, aws cli v2 (optional), ansible, task, hadolint, shellcheck, go (optional)
MCPmcp 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.
  • An internal mirror: PIP_INDEX_URL and NPM_REGISTRY.
  • Behind a TLS-intercepting proxy, pass the CA as a BuildKit secret: --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.

⁠API

The full description is served as OpenAPI 3.1 at GET /openapi.json.

EndpointBody → reply
POST /exec{command, timeout_s?, cwd?, env?} → {output, exit_code, truncated, timed_out, duration_ms}
POST /exec/streamSame 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 /processesEvery 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=5Stops 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 /metricsPrometheus: 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.

⁠The sandbox as an MCP server

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.

CommandWhat 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 elseRuns

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.

⁠MCP servers

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.

VariableDefault
SANDBOX_MCP_UPSTREAMempty (in-process hub)URL of the mcp-hub container
SANDBOX_MCP_HUB_PORT / SANDBOX_MCP_HUB_HOST8081 / 0.0.0.0hub mode only (a sidecar uses 127.0.0.1)
SANDBOX_MCP_LOOPBACK_NOAUTH1let commands inside the sandbox use /mcp-servers without the token
SANDBOX_MCP_ASKrefusethe 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.

VariableDefault
SANDBOX_TOKEN(required)the bearer token the agent sends
SANDBOX_PASS_ENVemptycomma list of extra variables commands may see (e.g. KUBECONFIG,AWS_PROFILE)
SANDBOX_DEFAULT_TIMEOUT / SANDBOX_MAX_TIMEOUT120 / 1800 sper command
SANDBOX_MAX_OUTPUT100000 charshead and tail kept
SANDBOX_MAX_CONCURRENCY4parallel /exec commands
SANDBOX_MAX_PROCESSES / SANDBOX_PROCESS_BUFFER8 / 1 MiBbackground processes running at once / output kept per process
SANDBOX_PORT / SANDBOX_WORKSPACE8080 / /workspace
ATLAS_LOG_FORMATprettyjson: one JSON object per line for log platforms
ATLAS_LOG_COLORautoalways / never; colours only on a terminal by default (NO_COLOR turns them off)

⁠Security model

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:

  • a non-root user (uid 10001);
  • a read-only root filesystem, with only /workspace, /home/sandbox, /tmp and /conversation_history writable;
  • all capabilities dropped and no Docker socket.

Commands run with limits:

  • They get a scrubbed environment: toolchain paths only, plus SANDBOX_PASS_ENV. They never see the token.
  • Each command runs in its own process group and is killed as a group on timeout.
  • Output, concurrency and timeouts are capped.
  • One audit line per command goes to stdout: readable and coloured by default, or JSON with ATLAS_LOG_FORMAT=json.

The agent decides what may run, before a command leaves it:

  • Read-only and local work runs.
  • Changes to shared systems need a person: terraform apply, kubectl apply/delete, helm upgrade, AWS mutations, git push, ssh, destructive SQL, …
  • A few things never run.

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).

Tag summary

Content type

Image

Digest

sha256:04b440abb…

Size

2 GB

Last updated

about 20 hours ago

docker pull n8500x/atlas-sandbox-devops