"Do I trust this sandbox?" is a faith-based question — and faith is a rotten foundation for a threat model. Every AI coding agent ships with a story about what its sandbox can and cannot do: container policies, Landlock rules, seccomp filters, "we only allow reads from the workspace". That story is the vendor's map. You are defending the territory: a developer's laptop, with dotfiles, an SSH agent, cloud credentials, and an agent that any sufficiently clever prompt injection might persuade to go for a wander.
sandbox-probe is a single static Go binary you drop inside the sandbox — Claude Code, Gemini CLI, nono, a container, whatever — and let it look around. It records what the kernel let it do and writes one JSON report. If that report shows it could read ~/.aws/credentials, resolve arbitrary DNS, or reach 169.254.169.254, tighten the policy before you ship another line of code through it.
This repository is the probe and nothing else. The comparison harness built on top of it — the scan matrix, the seeder, the agent stubs, the baseline-normalised methodology and the published results — lives in sandbox-probe-reports, and its site publishes at https://controlplaneio.github.io/sandbox-probe-reports/. (It was previously published from this repository; that is the new address.)
- Who this is for
- How it works
- What it detects
- Reading a report
- Quick start
- CLI reference
- Custom path expectations
- Report format
- Installation
- Development
Four concrete scenarios sandbox-probe is built to answer:
- Assessing an AI coding agent's blast radius. You let developers use Claude Code, Gemini CLI, or similar; you want a concrete inventory of what a compromised agent could read, write, or reach from inside its sandbox.
- Tuning a sandbox policy. You're writing Landlock rules (via
nono), a seccomp profile, or a container policy and need before-and-after evidence that the rule you added actually closed the door you intended. - Detecting sandbox regressions over time. Run the probe in your agent's sandbox on every release of the agent (or your wrapper) and alert when the boundary widens — for example, a new agent version starts seeing
~/.aws/credentials. - Comparing sandboxes apples-to-apples. The same probe, run under different sandboxes, produces directly comparable reports. Turning many such reports into a matrix is what
sandbox-probe-reportsdoes.
A scan runs a registry of tasks — each task tries one class of action (read a sensitive path, scan ports, fingerprint the sandbox runtime, …) and records whatever the kernel let it do. Tasks are grouped into tasksets (baseline, ps, all). The output is a JSON report of findings.
flowchart TB
CLI[sandbox-probe scan<br/>--tasksets baseline] --> Reg[Task registry<br/>pkg/tasks/tasks.go]
Reg --> Tasks[Run each task<br/>sequentially]
Tasks --> F[Findings<br/>finding_type + value]
F --> Report[report.json<br/>+ logs/sandbox-probe-*.log]
The 11 tasks in the baseline taskset:
baseline_path_taskbaseline_network_taskbaseline_proxy_taskbaseline_socket_taskbaseline_process_taskbaseline_user_context_taskbaseline_hostname_taskbaseline_environment_taskbaseline_env_secret_taskbaseline_sandbox_taskbaseline_mount_task
The ps taskset adds ps_all_task, ps_parent_task, and ps_single_task. Tasks run sequentially and deterministically. Each returns zero or more Finding objects with a stable finding_type string — stability is what makes two reports comparable at all.
A finding means the probe could do something. Its absence means it could not. Fewer findings is a tighter sandbox. That inversion is worth holding on to while reading everything below.
Each row below is a finding_type string you will see in report.json, what it captures, and the security question it answers. The full list lives in pkg/tasks/tasks.go.
finding_type in JSON |
What it captures | The question it answers |
|---|---|---|
sensitive_readable_paths |
readable but security-sensitive files (SSH keys, cloud credentials, browser cookies, etc.) | "Could an attacker exfiltrate my AWS keys, SSH keys, browser cookies?" |
writeable_paths |
writable system and home paths | "Could an attacker tamper with shell rc files, cron, systemd units?" |
external_host_dns_resolution |
hostnames the probe could resolve | "Can the agent see public DNS at all?" |
external_host_connectivity |
hosts the probe could reach over the network | "Could an attacker exfiltrate data to an external server?" |
tcp_ports_open / udp_ports_open |
locally reachable ports | "What local services is the agent exposed to?" |
proxy_detection |
proxy configuration in environment variables | "Is traffic forced through a proxy the agent could subvert?" |
unix_socket_detection |
Unix domain sockets visible from inside | "Can the agent talk to the Docker daemon, SSH agent, dbus, …?" |
named_pipe_detection |
Windows named pipes visible from inside (Windows scans only) | "Can the agent see the host's IPC endpoints — the SSH agent, Docker's docker_engine pipe, …?" |
named_pipe_reachable |
the named pipes this process could actually open, proven by a token round trip — the probe's own seeded decoy only, never a real service's pipe | "Does the sandbox stop the agent reaching an IPC endpoint, as opposed to merely seeing its name?" |
named_pipe_creation |
a pipe the probe created and immediately destroyed | "Can the agent squat a name a privileged service will later use?" |
named_pipe_probe_status |
how that measurement went: the control against a name nothing serves, whether the namespace was readable, whether the decoy was enumerated, and the per-pipe outcome | "Was this measured at all, or merely not observed?" |
process_detection / parent_process_detection |
visible processes and the launching parent | "What else is running in the same context, and who launched the agent?" |
mounted_volumes_detections |
mounted filesystems visible from inside | "What of the host filesystem is exposed?" |
user_context_detection |
UID, GID, EUID, EGID | "Is the agent running as a privileged user?" |
hostname_detection |
system hostname | "Does the sandbox leak the host identity?" |
env_secret_detection |
environment variables whose value is secret-shaped, by name and why they matched (never the value) | "Which credentials were handed to the agent's process in the first place?" |
environment_detection |
host kernel release/version + OS release | "Which kernel/OS produced this result?" (so reports stay comparable across upgrades) |
sandbox_detection |
one wrapper name — an inferred best guess at the tool (Docker, Podman, LXC, Firejail, Bubblewrap, gVisor, systemd-nspawn, WSL, OpenVZ, Seatbelt, Landlock, AppArmor, chroot) — plus zero or more kernel-attested mechanisms (seccomp-filter, seccomp-notify, seccomp-strict, no-new-privs, landlock, user-namespace, restricted-token, app-container) |
"Is there any enforcement at all, and what kind?" |
The wrapper name is a hypothesis; a mechanism is read straight off a kernel interface and is a fact. See CONTEXT.md for the distinction and why it matters when adding a detector.
A finding looks like this:
{
"findingType": "sensitive_readable_paths",
"task": "baseline_filesystem_enumerator",
"description": "Readable sensitive paths",
"value": [
"/home/alice/.aws/credentials",
"/home/alice/.ssh/id_ed25519"
]
}Read it as a three-step chain: this finding means X → which tells you Y → which suggests action Z.
sensitive_readable_pathsincludes~/.aws/credentials→ the sandbox does not block reads of cloud credentials → tighten the policy (or accept the residual risk explicitly, in writing).external_host_connectivityincludes169.254.169.254→ the probe can reach the cloud instance metadata service → if you're running on EC2/GCE, that's an IAM credential-theft path; block egress to link-local.sandbox_detectionis empty → no enforcement was detected → either the runtime is one the probe doesn't fingerprint yet (file an issue) or there really is no sandbox.
One caveat that decides how much a report is worth: a finding's absence only proves the sandbox blocked something if the thing was there to find. No ~/.aws/credentials on the host means no finding either way, and reading that as "blocked" is wrong. list-targets and seed exist to close that gap — see CLI reference.
# Build (Go 1.25+)
make build
# Run it and look around
./bin/sandbox-probe scan --output_path report.json
# Inspect findings
jq '.findings | map({findingType, task})' report.jsonThen run the same binary inside a sandbox and diff the two reports. Every line of difference is one capability that sandbox denies:
diff <(jq -S . unconfined.json) <(jq -S . sandboxed.json)Run the configured tasks and write a JSON report.
./bin/sandbox-probe scan [flags]| Flag | Description | Default |
|---|---|---|
--tasksets |
Comma-separated tasksets to run: baseline, ps, all |
baseline |
--tasks |
Additional individual tasks to run (comma-separated) | none |
--output_path |
Path to write the JSON report | report.json |
--tags |
Metadata tags to append to the report (comma-separated) | none |
--fast |
Skip "likely safe" paths for quicker iteration | false |
--config |
YAML file declaring custom filesystem boundary expectations | none |
Examples:
# Default: baseline taskset to ./report.json
./bin/sandbox-probe scan
# Multiple tasksets, with tags written into the report metadata
./bin/sandbox-probe scan --tasksets baseline,ps --tags test,docker
# Single named task only
./bin/sandbox-probe scan --tasks baseline_network_task
# Custom output path
./bin/sandbox-probe scan --output_path results.json
# Check environment-specific filesystem boundaries
./bin/sandbox-probe scan \
--config tests/example/alice-sandbox.yaml \
--output_path results.json--config is a global flag, so it may appear before or after scan.
List every registered task with its description.
./bin/sandbox-probe tasks listThe canonical task list (currently 14 tasks across two tasksets) is the output of this command.
Emit the probe's target registry as JSON, scoped to the running OS.
./bin/sandbox-probe list-targetsEach entry says what the probe checks and how it could be seeded: kind (file, dir, socket, pipe, process), seedable (true only for home-scoped regular files), and — for IPC entries — category and evidence. This is the probe's public registry interface: anything that plants decoys reads it, so seeding cannot drift from what is actually probed.
Plant, then remove, the decoys a shell script cannot create — a bound-and-closed Unix socket at a socket target, a live process under a distinctive command name at a process target.
./bin/sandbox-probe seed
./bin/sandbox-probe scan --output_path report.json
./bin/sandbox-probe cleanupSeeding is soft: a target something already owns is left untouched and counted as skipped, so a real docker.sock is never shadowed and no running process is ever adopted. What was planted is recorded, and cleanup removes exactly that and nothing else — it is idempotent, safe after a crashed run, and never signals a pid that no longer holds the command name it was seeded under. A decoy process also exits on its own after a fixed timeout, so a cleanup that never runs is not a permanent leak. See ADR 0002.
Print version, git commit, and build date.
./bin/sandbox-probe versionExample output:
version dev
git commit 44f7a7bcd2d3ae4215de43dd4d893c3b24587f40
build date 2026-05-16T10:39:11Z
A custom-path policy turns environment-specific filesystem boundaries into executable expectations. This complements the built-in baseline: use it to assert that an agent cannot reach a host user's credentials while retaining access to its toolchain and workspace.
Run a policy with --config:
./bin/sandbox-probe scan \
--config tests/example/alice-sandbox.yaml \
--output_path report.jsonThe example at
tests/example/alice-sandbox.yaml models
an agent user (alice) separated from a human operator (bob). The identity
block is informational only; paths are always taken from the explicit
custom_paths entries.
| Category | Meaning | Default checks |
|---|---|---|
must_block |
The path must be inaccessible. | readdir, open, and write must be denied. stat visibility is permitted and does not violate the expectation. |
must_read |
The path must be accessible for directory reading. | readdir must succeed. |
must_readwrite |
The path must be usable as a read-write location. | readdir and write must succeed. |
audit |
Record observed access without asserting a policy. | stat, readdir, open, and write are recorded. |
check_ops replaces the defaults for an entry rather than adding to them.
Supported operations are category-specific:
must_block:readdir,open,writemust_read:stat,readdir,openmust_readwriteandaudit:stat,readdir,open,write
For must_block, check_files adds open checks for named files beneath the
directory. Values must be simple file names: absolute paths and traversal such
as ../secret are rejected. stat_may_fail: true permits a missing
must_read or must_readwrite path without producing a violation; other
access failures are still violations.
Expectation entries require a severity of critical, error, or warn.
Both critical and error violations make the command exit non-zero. The
probe still completes its selected tasks and writes the JSON report before
returning that failure, so CI retains the evidence. A warn violation is
included as a finding without failing the command. audit entries have no
pass/fail verdict and do not require severity.
The parser rejects unknown fields, multiple YAML documents, empty policies, unsupported operations, and entries without an absolute path. A path may use:
- POSIX absolute form:
/home/alice/workspace - Windows drive form:
C:\Users\Alice\workspaceorD:/Users/Alice/workspace - Windows UNC form:
\\server\share\path
This allows a policy targeting one platform to be validated in CI on another.
Drive-relative paths such as C:temp and ordinary relative paths are rejected.
Minimal example:
custom_paths:
must_block:
- path: /home/bob/.ssh
label: host_ssh_keys
severity: critical
reason: Host credentials must not be visible to the agent
check_files: [id_rsa, id_ed25519]
must_readwrite:
- path: /home/alice/workspace
label: agent_workspace
severity: error
reason: The agent needs a usable workspace
audit:
- path: /proc/self
label: own_process
check_ops: [stat, open]
note: Record process information exposure without failingA report is a JSON object with these top-level fields:
version— report schema version (currently1.0.0)timestamp— when the scan ranprobeBinary— Go version, OS, arch, static flag, binary version, commit, build datemetadata— user-provided tags from--tagsfindings— array ofFindingobjects (see below)
Each Finding has four fields, defined in api/proto/report/v1/report.proto:
findingType— a stable string key (see What it detects); the field you diff ontask— which task produced the finding (e.g.baseline_filesystem_enumerator)description— human-readable labelvalue— the actual data; shape depends onfindingType(string, list of strings, list of ints, or a structured object for processes / user identity / proxy config)
Example report fragment:
{
"version": "1.0.0",
"timestamp": "2026-05-16T15:30:45Z",
"probeBinary": {
"goVersion": "go1.25.0",
"os": "linux",
"arch": "amd64",
"static": false
},
"metadata": {
"tags": ["claude-code", "sandbox-run"]
},
"findings": [
{
"findingType": "sandbox_detection",
"task": "baseline_sandbox_detector",
"description": "Container/wrapper runtime",
"value": "landlock"
},
{
"findingType": "sensitive_readable_paths",
"task": "baseline_filesystem_enumerator",
"description": "Readable sensitive paths",
"value": ["/home/alice/.ssh/id_ed25519"]
}
]
}The console output during a scan is structured logs; the same data is also written to a timestamped log file under logs/ (e.g. logs/sandbox-probe-2026-05-16-15-30-45.log).
For building:
- Go 1.25 or later
- Protocol Buffer compiler (
buf) — install viamake install-buf(only required if you change the protobuf definitions)
For the fingerprint end-to-end checks:
jq— JSON processor for parsing reportsdocker,podmanand/orbubblewrap— whichever runtimes you want to exercise
GitHub releases provide archives for:
- macOS:
amd64,arm64 - Linux:
amd64,arm64,armv6,armv7 - Windows:
amd64,arm64
Download the archive for your platform from the
sandbox-probe releases page.
For example, on an Apple Silicon Mac:
ARCHIVE="sandbox-probe_darwin_arm64.tar.gz"
curl -LO "https://github.com/controlplaneio/sandbox-probe/releases/latest/download/${ARCHIVE}"
curl -LO "https://github.com/controlplaneio/sandbox-probe/releases/latest/download/sandbox-probe_checksums.txt"
grep " ${ARCHIVE}$" sandbox-probe_checksums.txt | shasum -a 256 -c -
tar -xzf "${ARCHIVE}"
mkdir -p ~/.local/bin
install -m 0755 sandbox-probe ~/.local/bin/sandbox-probe
export PATH="$HOME/.local/bin:$PATH"
sandbox-probe versionAdd ~/.local/bin to your shell profile if it is not already on PATH.
Windows releases use .zip archives. Verify one from PowerShell before
extracting it and placing sandbox-probe.exe on PATH:
$Archive = "sandbox-probe_windows_amd64.zip"
$Line = Get-Content .\sandbox-probe_checksums.txt |
Where-Object { $_.EndsWith(" $Archive") }
if (-not $Line) { throw "No checksum found for $Archive" }
$Expected = ($Line -split '\s+')[0].ToLower()
$Actual = (Get-FileHash ".\$Archive" -Algorithm SHA256).Hash.ToLower()
if ($Actual -ne $Expected) { throw "Checksum mismatch for $Archive" }
Expand-Archive ".\$Archive" -DestinationPath .\sandbox-probegit clone https://github.com/controlplaneio/sandbox-probe.git
cd sandbox-probe
make buildReleased binaries are published on the releases page; the module is also go build-able directly:
go build -o bin/sandbox-probe github.com/controlplaneio/sandbox-probe/v6If you intend to run sandbox-probe inside a container, make sure it is built statically with standard library paths, or arrange for the relevant paths to be mounted in. This isn't usually an issue but can bite on non-glibc or non-FHS systems like Alpine, NixOS, or anything via Nix.
The full task list (also obtainable from ./bin/sandbox-probe tasks list):
| Task | Description |
|---|---|
baseline_path_task |
Scans filesystem for writable and sensitive readable paths |
baseline_network_task |
Scans network for DNS resolution, connectivity, and open TCP/UDP ports |
baseline_proxy_task |
Detects proxy configuration from environment variables |
baseline_socket_task |
Scans filesystem for Unix domain sockets |
baseline_process_task |
Detects running processes and parent process information |
baseline_user_context_task |
Detects user and group context information (UID, GID, EUID, EGID) |
baseline_hostname_task |
Detects the system hostname |
baseline_environment_task |
Records the host kernel release/version and OS release |
baseline_env_secret_task |
Detects environment variables whose value is secret-shaped (names only, never values) |
baseline_sandbox_task |
Detects container runtime and sandbox environments (Docker, Podman, LXC, etc.) |
baseline_mount_task |
Detects host-mounted volumes and filesystem mounts |
ps_all_task |
Lists all running processes using ps |
ps_parent_task |
Gets parent process information using ps |
ps_single_task |
Gets information about the running process using ps |
Adding a new task is a matter of implementing the Task interface (pkg/tasks/tasks.go) and registering it in taskRegistry (and, if appropriate, in a taskSetRegistry entry). Findings the new task returns must have a finding_type registered in expectedTypes for runtime validation to pass.
make tests # Go unit tests
make e2etests # the probe's own sandbox-fingerprint checks
make fmt # format Go codetests/fingerprint/ holds the probe's own end-to-end checks. Each builds the binary, runs it inside one minimal sandbox runtime via that runtime's launcher under scripts/, and asserts sandbox_detection names the runtime:
tests/fingerprint/
├── detect_docker.sh # probe runs inside Docker
├── detect_podman.sh # probe runs inside Podman
└── detect_bwrap.sh # probe runs inside Bubblewrap
No agent CLI, model access or API key is involved — a Go toolchain plus the runtimes you want to test is the whole requirement.
Checks that drive a real agent CLI, and everything that compares one sandbox against another, live in sandbox-probe-reports.
See docs/CONTRIBUTING.md for the full contributor guide, and CONTEXT.md for the vocabulary.