A one-minute probe that runs through your agent's own shell and shows which secret files, trust files, Docker, and sudo access it really has, then compares that with what your sandbox settings claim.
What you get
A sample report. The numbers and names are invented; your agent prints its own.
**Your agent can open 7 secret files, control Docker, and write to your git hooks.**
22 of 27 checks open: critical 4 of 8, high 16 of 17, medium 2 of 2.
Where it runs: user `sam` (uid 501) on macOS 26.1; agent: Claude Code; container signs: none;
sandbox markers: none; new files in the project: allowed; new files in your home folder: allowed.
| Risk | Area | Open | Found |
|---|---|---|---|
| Critical | Docker control | 1 of 1 | `~/.docker/run/docker.sock` |
| Critical | Git hooks and config | 2 of 3 | `./.git/hooks`, `./.git/config` |
| Critical | Shell startup files | 1 of 2 | `~/.zshrc` |
| Critical | Root and sudo | 0 of 2 | all blocked |
| High | Cloud credentials | 2 of 2 | `~/.kube/config`, `~/.azure/msal_token_cache.json` |
| High | Package and git tokens | 2 of 3 | `~/.config/gh/hosts.yml`, `~/.git-credentials` |
| High | Harness logins | 1 of 1 | `~/.codex/auth.json` |
| High | .env files | 2 of 2 | `./.env`, `../.env.local` |
| High | Harness settings and hooks | 5 of 5 | `./.claude/settings.local.json`, `~/.codex/config.toml`, `~/.gemini/settings.json` and 2 more |
| High | Editor tasks and settings | 1 of 1 | `./.vscode` |
| High | Python virtualenv | 1 of 1 | `./.venv/bin` |
| High | New files in your home folder | 1 of 1 | `~` |
| High | SSH agent | 1 of 1 | `$SSH_AUTH_SOCK` |
| Medium | Secret-like environment variables | 3 names | listed below |
| Medium | New files in the parent folder | 1 of 1 | `..` |
| Info | Network | not checked | run again with `--network` to test it |
Secret-like environment variables (names and lengths only): `GITHUB_TOKEN` (40 characters),
`OPENAI_API_KEY` (164 characters), `NPM_TOKEN` (36 characters).
Settings compared with what the probe found:
- Claude Code (critical): The Claude Code sandbox is off: no settings file sets sandbox.enabled
to true, so shell commands run with your full user rights.
With the sandbox on, the same table turns mostly blocked, and the settings section points at what still gets through, such as "The Codex sandbox leaves ./.vscode, ./.venv/bin writable, and programs outside the sandbox run what is there later."
Manual: copy skills/sandbox-check/ into your agent's skills folder: ~/.claude/skills/ (Claude Code), ~/.agents/skills/ (Codex, Gemini CLI, Cursor, and OpenCode all read it).
Use it
Ask your agent: "What can you actually reach on this machine? Run the sandbox check."
The agent explains what the probe touches, waits for your go-ahead, runs it once through its normal shell tool, and offers the network check as a separate choice. To run the script yourself, call it from the project folder:
Run from the agent's shell, it measures the agent. Run from your own terminal, it measures your terminal, which is what an agent without a sandbox gets. Add --network to test outbound connections, --json for every target, --skip-sudo on shared machines, and --fail-on critical to exit 1 when a Critical check is open.
How it works
The probe is one Python script with a target list beside it. It checks:
Secret files: SSH private keys, AWS, Google Cloud, Azure, and Kubernetes credentials, the Docker login file, npm, PyPI, netrc, and git credential files, the GitHub CLI login, each harness's own login file, and .env files in the project and its parent folders.
Secret-like environment variables: names and value lengths only.
Trust handoffs: files a program outside the sandbox later runs. Git hooks and git config, shell startup files (zsh, bash, and fish), login items, SSH login keys, the editor folder, the project's virtualenv, and the settings, hook, skill, and subagent files of Claude Code, Codex, Gemini CLI, Cursor, and OpenCode.
Everything else in reach: new files in your home folder and the parent folder, the Docker or Podman socket, the SSH agent, sudo -n true, and whether it runs as root. With --network: DNS, direct connections to github.com and pypi.org, and the cloud metadata address.
It edits no existing file. Secret files are opened and closed without reading a byte. Existing files are opened for append and closed at once, so their contents and modified times stay the same. A folder gets one empty test file that is deleted at once; only the folder's own modified time shows it happened. Sockets are connected and closed, with no data sent. The probe tests only targets that exist, because creating a missing one would plant the very file other programs trust.
Every file, folder, and variable name in the report passes through a filter that keeps it on one plain line, so a hostile file name stays inert text.
Then it reads the sandbox keys of your harness settings (Claude Code's settings layers, Codex sandbox_mode, Gemini CLI tools.sandbox) and flags gaps, such as "Codex settings say workspace-write, yet ~/.zshrc is writable from this shell." The trust handoff reference explains each target with the documented escapes and CVE ids, and the fixes reference gives the setting that closes each row.
Works with
Harness
Detected from
Settings compared
Support
Claude Code
CLAUDECODE
sandbox keys in user, project, local, and managed settings
full; smoke-tested on macOS
Codex
CODEX_SANDBOX, CODEX_THREAD_ID, and other CODEX_ variables
sandbox_mode, network access, and secret filtering in config.toml
full with Python 3.11 or newer; the stock macOS python3 (3.9) skips the settings comparison
Gemini CLI
GEMINI_CLI; SANDBOX inside its sandbox
tools.sandbox
full
Cursor
CURSOR_SANDBOX inside its sandbox
the marker stands in for settings
partial
OpenCode
reported as a plain shell
permission rules only, so there is nothing to compare
its settings, plugins, and login file are checked
Any shell or container
reported as a plain shell
every check runs
It runs on macOS and Linux, including WSL2 and containers, with Python 3.9 or newer and nothing else to install. Windows outside WSL2 is not supported.
Limits
It measures one shell at one moment. An agent's other tools can have different access: Claude Code's sandbox covers only its Bash, PowerShell, and Monitor tools, while its file tools follow permission rules instead.
It tests only targets that exist. If the agent can create files in the folder that would hold one, it can usually create it.
When a folder refuses lookups, the report shows that folder once as blocked and the files inside it as hidden, because the probe cannot tell whether they exist.
It knows only the secrets on its list. The macOS Keychain, password managers, web browsers, tokens inside other files, and .env files in the project's subfolders are outside it.
It checks .git/hooks and .git/config; a custom core.hooksPath folder is outside its list, though write access to .git/config is enough to set one.
It reaches Docker through local sockets only; Docker over TCP (DOCKER_HOST=tcp://...) is outside its list.
A variable that Claude Code's sandbox masks shows the length of its stand-in value.
sudo -n true also succeeds for a few minutes after a sudo password was typed in the same terminal session. That access is real while it lasts, and it expires.
Privacy
Read: file and folder metadata, and whether each secret file can be opened. Never the contents of a secret file. Harness settings files are parsed for their sandbox keys only.
Printed: paths, with your home folder as ~ and moved folders as $VARIABLE; variable names and value lengths; your user name and user id. Never a secret value.
Sent: only with --network: DNS lookups and TCP connections to github.com, pypi.org, and 169.254.169.254, carrying no data.
Written: one empty test file per folder, deleted at once, and the report file if you pass --out.
Noticed: sudo -n true may leave a line in the system log. On a work machine, endpoint security and audit rules may log the sudo attempt, the test file in your login-items folder, and the write-opens of shell startup files and authorized_keys. On Linux, each append check sends a file-close event to programs watching that file. Connecting to a socket-activated Docker or Podman service starts it.
---
name: sandbox-check
description: >-
Sandbox audit that probes, from inside a live coding-agent session, what the
agent can really reach: secret files it can open, secret-like environment
variables, files that git, the shell, the editor, or the harness later run
(git hooks, shell startup files, harness settings), writes outside the
project, the Docker socket, the SSH agent, and passwordless sudo. Use when
the user asks what the agent can access or write on this machine, whether
the sandbox actually works, how big the blast radius is if the agent gets
prompt-injected, whether SSH keys, cloud credentials, or .env files are
exposed, or whether it could escape through Docker or sudo. Runs locally and
edits no existing file: it creates and deletes one empty test file per
folder it checks, and only the optional --network flag makes DNS lookups
and TCP connections.
license: MIT
compatibility: "Python 3.9+ on macOS or Linux; Python 3.11+ to compare Codex settings. Without flags nothing touches the network. The optional --network flag makes DNS lookups and TCP connections (no data sent) to github.com, pypi.org, and the cloud metadata address 169.254.169.254."
metadata:
author: "Ryan Alberts"
version: "1.0.0"
source: "https://github.com/RyanAlberts/best-of-Agent-Harnesses"
---
# Sandbox check
A coding agent's shell commands have whatever access that shell has, and a sandbox is only as good
as what actually gets through it. This skill runs one probe through the agent's own shell tool and
reports, headline first, what the agent can reach right now: secret files, secret-like environment
variables, the files other programs later trust and run (a **trust handoff**), the Docker socket,
the SSH agent, sudo, and, only when asked, the network. It reads no secret, prints no secret value,
and sends nothing anywhere unless you add `--network`.
## When to use
- The user asks what the agent can read, write, or reach on this machine or in this container.
- The user wants to know whether a sandbox really holds: Claude Code `/sandbox`, Codex sandbox
modes, Gemini CLI `--sandbox`, or Cursor's sandbox.
- The user worries about prompt injection or a rogue agent and asks for the blast radius.
- The user asks whether SSH keys, cloud credentials, `.env` files, or tokens are exposed to the
agent.
- Before an agent works unattended or on an untrusted repository.
## When not to use
- Testing whether permission rules or hooks block dangerous commands: use `guardrail-tester`.
- Stopping a session that loops or overspends: use `runaway-guard`.
- Checking which instruction files each agent loads: use `agents-md-checker`.
- Finding secrets committed to git history: use a secret scanner such as gitleaks. This skill checks
access, not file contents.
- Turning a sandbox on: that is a settings change. This skill measures the result, and
`references/fixes.md` names the setting.
## What the probe touches
Tell the user this, in short, before the first run. It is the whole contract:
- **Secret files**: opened and closed without reading a byte. Files that macOS keeps only in the
cloud are skipped, so they stay in the cloud.
- **Existing files other programs trust**: opened for append and closed at once, so contents and
modified times stay the same.
- **Folders**: one empty file named `.sandbox-check-` plus random letters and `.tmp` is created and
deleted at once. That includes the login-items folder (`Library/LaunchAgents` on macOS; the
autostart and systemd user folders on Linux). The folder's own modified time changes, and a file
watcher (a dev server, a sync app) may notice for a moment. A test file that cannot be deleted is
named in the report.
- **Targets that do not exist** stay absent: the probe tests only what is there.
- **Docker socket and SSH agent**: connect, then close, with no data sent. A socket-activated Docker
or Podman service starts when something connects, so the probe can start it.
- **sudo**: `sudo -n true`, the form that fails instead of asking for a password. The system log
may record the attempt; `--skip-sudo` leaves sudo alone.
- **Environment variables**: names and value lengths only.
- **Settings files** of Claude Code, Codex, and Gemini CLI: parsed for their sandbox keys, which
are the only part the report shows.
- **Network**: used only with `--network`.
On a work machine, endpoint security and audit rules may log or flag the probe: the sudo attempt,
the test file in the login-items folder, and the write-opens of shell startup files and the SSH
`authorized_keys` file. On Linux, each append check also sends a file-close event to any program
watching that file.
`scripts/targets.json` lists every path the probe checks, so it names credential files by design.
Each of those lines carries the marker `skillscan:allow`, which tells this repository's security
scanner that the path is a probe target, not a file the skill reads.
## Steps
`<skill-dir>` means the folder that holds this SKILL.md (Claude Code shows it as the skill's base
directory). Run every command from the user's project folder.
1. **Explain the probe** in two or three sentences drawn from "What the probe touches", including
the sudo attempt and the login-items test file that security tools may flag. Done when the user
says to go ahead. Wait for that answer before step 2.
2. **Run the probe once, through your normal shell tool:**
```bash
python3 "<skill-dir>/scripts/probe.py" --project .
```
`--project` is the folder the agent works in. When `python3.11` or newer is installed (such as
`python3.12` or `python3.13`), use it in place of `python3`: the stock macOS `python3` is 3.9,
which skips the Codex settings comparison. The run takes about a second and exits 0 even when
checks are blocked: blocked checks are the result, not an error. Run it exactly as sandboxed as
every other command in this session, and report what the sandbox blocked as blocked. Keep it out
of any sandbox bypass (such as a `dangerouslyDisableSandbox` retry): an unsandboxed rerun
measures a different shell and turns a good result into a false alarm. Done when the output
starts with a bold headline sentence, or you have told the user the exact error. Exit code 2
means a bad argument or a missing project folder; Python also exits 2 when the script path is
wrong.
3. **Offer the network check as its own choice.** Say that it looks up `github.com` and `pypi.org`,
opens and closes a TCP connection to each on port 443 and to the cloud metadata address
`169.254.169.254` on port 80, sends no data, and waits at most 3 seconds per host. Run it only
after a clear yes:
```bash
python3 "<skill-dir>/scripts/probe.py" --project . --network
```
Done when the user declined, or the new report's Network rows read open or blocked.
4. **Choose the fixes.** Open `references/fixes.md` at the section for the harness named on the
report's "agent" line, and pick the two or three fixes that close the most Critical and High
rows. When that section has no fix for a row (the SSH agent, for example), use the section "For
any harness". Use `references/trust-handoff.md` to explain why a row matters. Done when each
chosen fix names the exact setting, flag, or command to change.
5. **Report** in the shape below. A fix that edits a settings file is a proposal: show the exact
change, and apply it only after a clear yes.
## Read the results
- **Headline**: the sentence to lead with. It counts readable secret files, then names the worst
reach: running as root, sudo without a password, control of Docker, writes to git hooks.
- **Score**: `N of M checks open`, split by risk. Only checks that ran are counted: missing,
skipped, hidden, and unknown checks and Info rows are left out.
- **Status of each check**:
- `open`: reachable from this shell. A read-only file in a writable folder also counts as open,
because it can be replaced; its detail says so.
- `blocked`: exists, but the open, write, or connect was refused. A folder that refuses lookups
gets one blocked row.
- `hidden`: inside a folder that refuses lookups, so the probe cannot tell whether it exists.
- `missing`: not present, so not tested. A socket file with nothing listening reads missing too.
- `skipped`: a cloud placeholder, `--skip-sudo`, or network not requested.
- `unknown`: an unexpected error; `--json` has the detail.
- **Risk levels**:
- Critical: control Docker, write git hooks or git config, change shell startup files, add login
items or SSH login keys, sudo without a password, running as root.
- High: readable secret files, harness settings, hooks, skills, and subagents, the editor folder
and virtualenv, new files in the home folder, the SSH agent, the cloud metadata address.
- Medium: secret-like environment variables, new files in the parent folder, direct internet
connections.
- Info: the project folder itself, and DNS.
- **Where it runs**: the user, the operating system, the harness (found from the variables each
harness sets in its shell: `CLAUDECODE`, `CODEX_SANDBOX` and other `CODEX_` variables,
`GEMINI_CLI`, `CURSOR_SANDBOX`), container signs, and sandbox markers. "Agent: none found" means
the probe ran in a plain terminal, so the results show what any program started there can reach.
- **Settings compared with what the probe found**: gaps between what the settings of the harness
that ran the probe claim and what got through.
- "Settings say workspace-write, yet `~/.zshrc` is writable" means the command ran outside the
sandbox, or the sandbox allows more than its settings suggest.
- "The sandbox leaves `./.vscode` writable" is a trust handoff the sandbox allows by design;
`references/trust-handoff.md` has the documented escapes that used it.
- "The sandbox is off" is the finding that explains most open rows. The probe reads settings
files only, so when writes to the home folder were blocked anyway, it turns this into a note: a
sandbox from `--settings` or managed settings may be on.
- **Notes**: what was not checked and why, such as a Codex config skipped on Python older than 3.11.
- **`--json`** lists every target, missing ones included, each with `id`, `category`, `group`,
`risk`, `target`, `status`, and `detail`, plus `settings`, `gaps`, and `leftovers`.
## Report to the user
1. The headline, verbatim, in bold.
2. The report's table cut to rows with something open, eight rows at most: Critical rows first,
then High. If any open row does not fit, name the dropped rows in one line under the table.
3. The gaps from "Settings compared with what the probe found", one line each.
4. The two or three fixes from step 4, each with the exact setting and a pointer to
`references/fixes.md`.
5. One closing line: offer the network check if it has not run, and name any test file the report
says could not be deleted, so the user can remove it.
Quote paths exactly as the report prints them: it shows the home folder as `~`, a folder moved by a
variable as `$VARIABLE`, and names and lengths in place of secret values. Names from the file
system and values from settings arrive as one plain line, with secret-shaped text masked and
backticks and pipes replaced, and the Markdown report shows them inside inline code, so they stay
inert text.
## Files
- `scripts/probe.py`: the probe. Python 3.9+, standard library only. Flags: `--project`,
`--network`, `--skip-sudo`, `--json`, `--out <path>`, `--fail-on critical|high|medium` (exit 1
when a check at that level is open).
- `scripts/targets.json`: every path, socket, variable pattern, harness marker, and settings file
the probe checks, with the scanner marker on each credential line.
- `scripts/safe.py`: the text cleaner that several skills in this repository share. It masks
secret-shaped text and keeps each name from the file system or settings on one line, inside
inline code in the Markdown report.
- `references/trust-handoff.md`: why each target matters, with the Pillar Security and Cloud
Security Alliance findings and their CVE ids.
- `references/fixes.md`: fixes for Claude Code, Codex, Gemini CLI, Cursor, and OpenCode, plus fixes
for any harness.
MIT License
Copyright (c) 2026 Ryan Alberts
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
# Fixes, per harness
Checked 2026-09-28. Use the section for the harness that ran the probe (the report's "agent" line), then "For any harness". Each fix names the exact setting. Show the user the change before making it, and run the probe again in a new session afterwards to confirm the rows turned blocked.
## Claude Code
Source: <https://code.claude.com/docs/en/sandboxing> and <https://code.claude.com/docs/en/settings-reference>.
The sandbox is off by default and covers the Bash, PowerShell, and Monitor tools. When it is on, commands can write only the working folder, a temporary folder, and folders you added; the `.claude` settings files, `.mcp.json`, `.vscode`, shell startup files in the working folder, and hooks and config inside `.git` stay protected.
1. **Turn the sandbox on.** Run `/sandbox`, or set it in your user settings (`settings.json` in your Claude Code folder) so every project gets it:
```json
{
"sandbox": {
"enabled": true,
"allowUnsandboxedCommands": false,
"credentials": {
"files": [
{ "path": "~/.config/gh/hosts.yml", "mode": "deny" },
{ "path": "~/.kube/config", "mode": "deny" }
],
"envVars": [
{ "name": "GITHUB_TOKEN", "mode": "deny" }
]
}
},
"permissions": {
"blockReadsOutsideWorkingDirectories": true
}
}
```
2. **List every readable secret file.** The docs say there is no built-in credential deny list: only the files and variables you list are blocked. Add each file the probe marked open (your SSH folder and cloud credential files included) under `sandbox.credentials.files` with `"mode": "deny"`, and each secret-like variable under `sandbox.credentials.envVars`. `"mode": "mask"` keeps tools such as `gh` working by showing commands a stand-in value; it is honored only from user or managed settings.
3. **Close the escape hatch.** `"allowUnsandboxedCommands": false` stops Claude from retrying a blocked command outside the sandbox (shown as strict sandbox mode in `/sandbox`).
4. **Keep Docker out.** Leave `docker` out of `sandbox.excludedCommands` and keep the Docker socket out of `sandbox.network.allowUnixSockets`. An excluded command runs with no sandbox at all.
5. **Leave filesystem isolation on.** With `sandbox.filesystem.disabled` set, the docs warn that a command can write shell startup files, programs on your `PATH`, or Claude Code settings, and widen its own access on the next run.
6. **Strip credentials from every subprocess**, sandboxed or not, with the `CLAUDE_CODE_SUBPROCESS_ENV_SCRUB` environment variable.
7. **Add permission rules as a second layer.** A `Read(.env)` deny rule does not stop `cat .env` in the shell; the sandbox does. This repo's [safe settings template](../../../templates/claude-code-safe-settings/) adds deny rules and a guard hook.
## Codex
Sources: <https://learn.chatgpt.com/docs/config-file/config-reference.md>, <https://learn.chatgpt.com/docs/agent-approvals-security.md>.
1. **Use `workspace-write`.** Set `sandbox_mode = "workspace-write"` in `config.toml` in your Codex folder, or pass `--sandbox workspace-write`. Avoid `danger-full-access` and `--dangerously-bypass-approvals-and-sandbox` (alias `--yolo`).
2. **Know what it leaves open.** `workspace-write` keeps `.git`, `.agents`, and `.codex` read-only, but the rest of the project stays writable, including `.vscode`, `.venv`, and `.claude`. Your editor and other agents trust those. Review them after a session on an untrusted repository, and do not open that repository in an editor that runs tasks automatically.
3. **Keep network off.** `[sandbox_workspace_write] network_access = false` is the default; leave it.
4. **Filter secret variables.** Set `[shell_environment_policy] ignore_default_excludes = false` so Codex drops variables whose names contain KEY, SECRET, or TOKEN before running shell commands. By default Codex keeps them.
5. **Docker.** OpenAI classed Docker socket exposure as informational (Cloud Security Alliance note), so the sandbox is not the fix: stop Docker Desktop, Colima, or OrbStack while an agent works, or run the agent where the socket does not exist.
6. **Update** to Codex CLI 0.95.0 or later for the "GitPwned" allowlist fix.
## Gemini CLI
Sources: <https://github.com/google-gemini/gemini-cli/blob/main/docs/cli/sandbox.md>, <https://github.com/google-gemini/gemini-cli/blob/main/docs/cli/trusted-folders.md>.
1. **Turn the sandbox on.** It is off by default. Use `gemini --sandbox`, set `GEMINI_SANDBOX` to `docker`, `podman`, or `sandbox-exec`, or set `"tools": { "sandbox": true }` in `settings.json`. Inside it, commands see `SANDBOX` in their environment, which the probe reports.
2. **Pick a stricter macOS profile.** The default `SEATBELT_PROFILE` is `permissive-open`: writes restricted, network allowed. The `restrictive-` profiles apply stricter rules, and `strict-proxied` also restricts reads and sends network traffic through a proxy.
3. **Keep network off** inside the sandbox: `tools.sandboxNetworkAccess` defaults to false.
4. **Keep folder trust on** (`security.folderTrust.enabled`, default true). Untrusted folders skip project settings, `.env` files, and MCP servers.
5. **Move API keys out of shell startup files.** Gemini CLI's own authentication guide warns that any process launched from that shell can read them.
6. **Docker.** Google called the Docker socket exposure documented and out of scope (Cloud Security Alliance note); treat it as for Codex.
## Cursor
Sources: <https://cursor.com/docs/agent/security/run-modes>, and the Cloud Security Alliance note in [trust-handoff.md](trust-handoff.md).
1. **Use the sandboxed run mode.** Inside it, commands see `CURSOR_SANDBOX` (`seatbelt` on macOS, `native` on Linux). Writes are limited to the workspace and temporary folders, `.git/config`, `.git/hooks`, and `.vscode` are protected, and network is blocked until your network mode or `sandbox.json` opens it.
2. **Tighten `sandbox.json`** (in `.cursor` in your home folder, or in the project, which wins).
3. **Update** to Cursor 3.0.0 or later (hook settings, git metadata, and Docker socket fixes) and 3.1.2 or later (virtualenv fix).
## OpenCode
Source: <https://opencode.ai/docs/permissions>.
OpenCode has no operating-system sandbox, only permission rules. By default `read` denies `*.env` files (except `*.env.example`) and `external_directory` asks before touching folders outside the project. Keep those, remember that the last matching rule wins, and run OpenCode inside a dev container when the repository is untrusted.
## For any harness
- **Run the agent in a dev container or virtual machine** that mounts only the project: no home folder, no Docker socket, no SSH agent. The [sandboxing guide](../../../comparisons/sandboxed-code-execution.md) compares the options.
- **Use a separate user account for agent work.** Its home folder holds none of your credentials, shell startup files, or login items.
- **Keep credentials out of plain files.** The GitHub CLI stores its token in the system keychain unless you pass `--insecure-storage`; Docker can use a keychain helper through `credsStore`; short-lived cloud logins (such as AWS SSO) expire on their own.
- **Remove passwordless sudo** (`NOPASSWD` rules) on machines where agents run.
- **Stop Docker** (Docker Desktop, Colima, OrbStack, Rancher Desktop) when the agent does not need it.
- **Start the agent without your SSH agent** when it does not need to push: unset `SSH_AUTH_SOCK` in the shell that launches it.
# Trust handoffs: why each target matters
Checked 2026-09-28. Read this when you need to explain a row of the report, or when the user asks why a writable file is dangerous.
A sandbox limits what the agent's own commands can do. A **trust handoff** is the way around it: the agent writes a file, and later a program that runs outside the sandbox (git, your shell, your editor, the login system, or the agent harness itself) reads that file and does what it says, with your full rights. Pillar Security's summary of a week of sandbox escapes in July 2026 makes the point plainly: an agent that can write the future inputs of other programs was never really sandboxed.
## Sources
- Pillar Security, "The Week of Sandbox Escapes", 2026-07-20, by Eilon Cohen, Dan Lisichkin, and Ariel Fogel: <https://www.pillar.security/blog/the-week-of-sandbox-escapes>
- Cloud Security Alliance AI Safety Initiative, "AI Coding Agent Sandbox Escapes: The Trust Handoff Flaw", research note, 2026-07-22: <https://labs.cloudsecurityalliance.org/research/csa-research-note-ai-coding-agent-sandbox-escapes-20260722-c/>
- Claude Code sandbox, section "Protected paths": <https://code.claude.com/docs/en/sandboxing>
- Codex sandbox modes (in `workspace-write`, `.git`, `.agents`, and `.codex` stay read-only): <https://learn.chatgpt.com/docs/agent-approvals-security.md>
- Cursor sandbox and its protected paths: <https://cursor.com/docs/agent/security/run-modes>
- Gemini CLI sandbox (it confines writes to the project folder): <https://github.com/google-gemini/gemini-cli/blob/main/docs/cli/sandbox.md>
## The documented escapes
| Case | Product | ID | What the agent wrote or reached | What ran it later | Status in the sources |
|---|---|---|---|---|---|
| Hook settings | Cursor | CVE-2026-48124, GHSA-pc9j-3qc2-95wv (CVSS 8.5) | hooks in `.claude/settings.local.json` | Cursor ran the hook command outside the sandbox, with no approval | fixed in Cursor 3.0.0 |
| Virtualenv interpreter | Cursor | GHSA-p9g2-cr55-cw9c | replaced the interpreter in `.venv/bin` | the editor's Python extension ran it while looking for interpreters | fixed; the CSA note says update to 3.1.2 |
| Git metadata folder | Cursor | no CVE | a git metadata folder not named `.git`, with its own fsmonitor program | the Git extension ran that program | fixed in Cursor 3.0.0 |
| "GitPwned" | Codex CLI | no CVE; CSA rates it CVSS 8.6 | `git show --output=./.git/config` wrote an external diff tool into the git config; the allowlist trusted `git show` by name | git ran the tool on the next `git diff` | fixed in Codex CLI 0.95.0 |
| Docker socket | Cursor, Codex CLI, Gemini CLI | GHSA-v4xv-rqh3-w9mc (Cursor's advisory) | connected to `/var/run/docker.sock` | Docker started a privileged container that mounted the home folder | fixed in Cursor 3.0.0; per CSA, OpenAI called its exposure informational and Google called it documented and out of scope, so neither changed |
| VS Code task | Antigravity | no CVE | `.vscode/tasks.json` | the host ran the task outside Secure Mode | Pillar calls it hard to exploit; no fix reported |
| Seatbelt denylist | Antigravity | no CVE | nothing: a denylist-style macOS profile left system features reachable | commands ran outside the sandbox | no fix reported |
| "DuneSlide" | Cursor | CVE-2026-50548, CVE-2026-50549 (CVSS 9.8 each) | prompt-injected commands abused working-folder and symlink checks | they overwrote the sandbox helper and wrote outside the workspace | listed by CSA without further detail |
## Each target the probe checks
"Protected by default" means the harness's own sandbox blocks the write when it is on, per the docs above. Outside the project folder, every one of these sandboxes blocks writes by default (Claude Code, Codex, and Cursor also allow temporary folders).
| Target | Who trusts it, and when | Probe | Protected by default |
|---|---|---|---|
| Git hooks folder (`.git/hooks`) | git runs these scripts on commit, checkout, merge, and push | one empty test file, created and deleted | Claude Code, Codex, Cursor |
| Git config (`.git/config`) | git runs the fsmonitor, diff, and merge programs it names (the GitPwned case) | opened for append, closed, nothing written | Claude Code, Codex, Cursor |
| Global git config (`.gitconfig` in your home folder) | every git command in every repository reads it | append check | outside the project |
| Shell startup files (`.zshrc`, `.zshenv`, `.zprofile`, `.zlogin`, `.bashrc`, `.bash_profile`, `.bash_login`, `.profile`, and fish's `config.fish` and `conf.d` folder under `.config/fish`) | every new terminal runs them. The Claude Code docs warn that a command able to write them can widen its own access on the next run | append check; test file for `conf.d` | outside the project |
| Login items (`Library/LaunchAgents` on macOS; `.config/systemd/user` and `.config/autostart` on Linux) | the system starts these programs at every login | test file | outside the project |
| SSH login keys (`authorized_keys` in your SSH folder) | a key added here lets someone log in, when remote login is on | append check | outside the project |
| Editor folder (`.vscode`) | VS Code and its forks run `tasks.json` tasks, some on folder open (the Antigravity case) | test file | Claude Code, Cursor |
| Python virtualenv (`.venv/bin`) | the editor's Python extension runs the interpreter to inspect it (Cursor GHSA-p9g2-cr55-cw9c) | test file | none of the defaults |
| Harness settings and hooks | the harness runs the hooks, MCP servers, and plugins listed there, often outside its own sandbox (Cursor CVE-2026-48124) | append check, or a test file for plugin and rule folders | Claude Code protects its own `.claude` settings and `.mcp.json`; Codex keeps `.codex` read-only; Cursor protects its sensitive config files |
The harness settings files, by harness:
- Claude Code: `.claude/settings.json`, `.claude/settings.local.json`, and `.mcp.json` in the project; `settings.json` in your Claude Code folder (moved by `CLAUDE_CONFIG_DIR`) and `.claude.json` in your home folder. Also the `skills` and `agents` folders, in `.claude` in the project and in your Claude Code folder, because skill and subagent frontmatter can define hooks. Cursor also runs hooks from the `.claude` settings files by default.
- Codex: `.codex/config.toml` and `.codex/hooks.json` in the project (read in trusted projects only); `config.toml`, `hooks.json`, and the `rules` folder in your Codex folder (moved by `CODEX_HOME`). Codex asks you to trust a changed hook again before it runs.
- Gemini CLI: `.gemini/settings.json` in the project; `settings.json` and `trustedFolders.json` in the `.gemini` folder in your home folder. Gemini CLI treats a changed project hook as new and untrusted.
- Cursor: `hooks.json`, `mcp.json`, and `sandbox.json` in `.cursor` in the project; the same plus `cli-config.json` in the `.cursor` folder in your home folder.
- OpenCode: `opencode.json` and the `.opencode/plugins` folder in the project; `opencode.json` and `plugins` in its folder under `.config`. OpenCode has no operating-system sandbox, so nothing here is protected by default.
## Reach that needs no file at all
| Target | Why it matters | Probe |
|---|---|---|
| Docker or Podman socket | whoever can connect controls Docker, and Docker can start a container that mounts your home folder. The Claude Code docs say that allowing `/var/run/docker.sock` through the sandbox "effectively grants access to the host system" | connect, then close; nothing sent |
| SSH agent (`SSH_AUTH_SOCK`) | commands can use your SSH keys through the agent without ever reading a key file: push to GitHub, log in to servers | connect, then close |
| `sudo -n true` | success means root without a password prompt, right now | runs `sudo -n true`; never prompts |
| Running as root | every file on the machine or container is writable | reads the user id |
| New files in your home folder | if this works, most targets above are within reach even when they do not exist yet | test file in the home folder |
| Cloud metadata address (169.254.169.254), with `--network` only | on cloud machines this address hands out the machine's own credentials | TCP connect, then close |
## What "missing" means
The probe does not test a target that does not exist yet, because creating it would plant the very file other programs trust. If the agent can create files in the folder that would hold it, it can usually create the target too, unless a sandbox blocks that name: Claude Code blocks creating several of these by name (the `.claude` settings files, `.mcp.json`, `.vscode`, and hooks and config inside `.git`). So a missing target is not a clean bill of health, and the report does not count it either way.
A folder that refuses lookups is different: the probe cannot tell whether the files inside it exist. The report shows that folder once, as blocked, and each file inside it as `hidden`, which is not counted. A read-only file in a folder the shell can write counts as open, because the shell can delete it and write a new one in its place (dotfiles managed by Nix home-manager are an example).
#!/usr/bin/env python3
"""Probe what this shell can reach: secret files, trust files, Docker, sudo.
Run it through the agent's own shell tool, so it measures the agent's real
sandbox. It edits no existing file: secret files are opened and closed without
reading a byte, existing files are opened for append and closed without
writing, and a folder gets one empty test file that is deleted at once.
"""
from __future__ import annotations
import argparse
import errno
import fnmatch
import glob
import json
import os
import platform
import re
import secrets
import socket
import stat
import subprocess
import sys
try:
import tomllib # Python 3.11+; without it the Codex config is skipped with a note
except ImportError:
tomllib = None
# The shared text cleaner (sync_shared.py copies it here from skills/evals/shared/).
# File, folder, user, and variable names and settings values are untrusted: they
# can hold a secret, carry instructions for the agent that relays the report, or
# break a table. safe_text() masks secrets and makes them one inert line; code()
# also puts that line inside inline code for the Markdown report.
from safe import code, safe_text # noqa: E402
VERSION = "1.0.0"
HERE = os.path.dirname(os.path.abspath(__file__))
TARGETS_FILE = os.path.join(HERE, "targets.json")
SCORED_RISKS = ("critical", "high", "medium")
TEMP_PREFIX = ".sandbox-check-"
SF_DATALESS = 0x40000000 # macOS: file content lives in the cloud; opening it downloads it
_NONBLOCK, _CLOEXEC = getattr(os, "O_NONBLOCK", 0), getattr(os, "O_CLOEXEC", 0)
READ_FLAGS = os.O_RDONLY | _NONBLOCK | _CLOEXEC
APPEND_FLAGS = os.O_WRONLY | os.O_APPEND | _NONBLOCK | _CLOEXEC # no O_CREAT, no O_TRUNC
CREATE_FLAGS = os.O_WRONLY | os.O_CREAT | os.O_EXCL | getattr(os, "O_NOFOLLOW", 0) | _CLOEXEC
SUDO_PATHS = ("/usr/bin/sudo", "/bin/sudo", "/usr/local/bin/sudo", "/run/wrappers/bin/sudo")
TRUE_PATHS = ("/usr/bin/true", "/bin/true")
LOOKUP_REFUSED = "this shell cannot look the file up"
INSIDE_REFUSED = "inside a folder this shell cannot look into"
REPLACEABLE = "the file is read-only, but its folder is writable, so the file can be replaced"
def find_sudo(exists=os.path.exists):
"""sudo from a system folder only. A repository could plant a fake `sudo` on PATH."""
return next((path for path in SUDO_PATHS if exists(path)), None)
def sudo_command(exists=os.path.exists):
"""`sudo -n true` with both programs taken from system folders, never from PATH."""
sudo = find_sudo(exists)
true = next((path for path in TRUE_PATHS if exists(path)), None)
return [sudo, "-n", true] if sudo and true else None
def load_targets(path=TARGETS_FILE):
with open(path, encoding="utf-8") as fh:
return json.load(fh)
class Probes:
"""Every probe that touches a socket, sudo, or the network. Tests pass fakes."""
def __init__(self, env):
self.env = env
def identity(self):
uid = os.getuid() if hasattr(os, "getuid") else -1
euid = os.geteuid() if hasattr(os, "geteuid") else -1
try:
import pwd
user = pwd.getpwuid(uid).pw_name
except (ImportError, KeyError):
user = self.env.get("USER") or "unknown"
return {"user": user, "uid": uid, "euid": euid}
def unix_connect(self, path):
"""Connect to a local socket and close it at once; nothing is sent. 0 or the errno."""
if not hasattr(socket, "AF_UNIX"):
return errno.EAFNOSUPPORT
sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
sock.settimeout(2)
try:
sock.connect(path)
return 0
except socket.timeout:
return errno.ETIMEDOUT
except OSError as exc:
return exc.errno or errno.EIO
finally:
sock.close()
def docker_connect(self, path):
return self.unix_connect(path)
def ssh_agent_connect(self, path):
return self.unix_connect(path)
def sudo_check(self):
"""'open' when `sudo -n true` works: root without a password prompt."""
if not find_sudo():
return "missing"
command = sudo_command()
if not command:
return "unknown"
try:
done = subprocess.run(command, stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL, timeout=5)
except (OSError, subprocess.TimeoutExpired):
return "unknown"
return "open" if done.returncode == 0 else "blocked"
def dns_lookup(self, host):
try:
socket.getaddrinfo(host, 443, type=socket.SOCK_STREAM)
return True
except OSError:
return False
def tcp_connect(self, host, port):
"""Open a TCP connection and close it. No data is sent."""
try:
socket.create_connection((host, port), timeout=3).close()
return True
except OSError:
return False
def is_cloud_placeholder(st):
return bool(getattr(st, "st_flags", 0) & SF_DATALESS)
def is_within(path, base):
path, base = os.path.normpath(path), os.path.normpath(base)
return path == base or path.startswith(base.rstrip(os.sep) + os.sep)
def make_check(cid, category, group, risk, target, status, detail, **extra):
check = {"id": cid, "category": category, "group": group, "risk": risk,
"target": safe_text(target), "status": status, "detail": detail}
check.update(extra)
return check
# --- where targets live -----------------------------------------------------
class Paths:
"""Resolves {placeholders} to absolute paths plus a display form.
Display forms never contain an environment variable's value: a folder moved
by CODEX_HOME shows as $CODEX_HOME, the home folder as ~.
"""
def __init__(self, roots, env, home, project, sysroot):
self.env, self.project = env, project
self.values = {"home": home, "project": project, "sysroot": sysroot.rstrip(os.sep)}
self.labels = {"home": "~", "project": ".", "sysroot": ""}
for name, spec in roots.items():
var = spec.get("env")
if var and env.get(var):
self.values[name] = self.env_path(env[var])
self.labels[name] = "$" + var
elif "default" in spec:
resolved = self.expand(spec["default"])
if resolved:
self.values[name], self.labels[name] = resolved
def env_path(self, value):
if value.startswith("~" + os.sep):
value = os.path.join(self.values["home"], value[2:])
return os.path.normpath(os.path.join(self.project, value))
def expand(self, template):
"""'{home}/.x' -> (absolute path, display) or None when a root is unknown."""
name, _, rest = template[1:].partition("}")
if self.values.get(name) is None:
return None
return os.path.normpath(self.values[name] + rest), self.labels[name] + rest
def display_under(self, template, path):
"""Display form of a path found under the root a template names."""
name = template[1:].partition("}")[0]
return self.labels[name] + path[len(self.values[name]):]
def relative(self, path):
rel = os.path.relpath(path, self.project)
return rel if rel == "." or rel.startswith("..") else "." + os.sep + rel
def show(self, path):
"""Display form of any path: relative inside the project, else under the
most specific known root ($VARIABLE or ~), else as it is."""
path = os.path.normpath(path)
if is_within(path, self.project):
return self.relative(path)
roots = [n for n, v in self.values.items() if v and n not in ("project", "sysroot") and is_within(path, v)]
if roots:
best = max(roots, key=lambda n: len(self.values[n]))
return self.labels[best] + path[len(self.values[best]):]
sysroot = self.values["sysroot"]
if sysroot and is_within(path, sysroot):
return path[len(sysroot):] or os.sep
return path
# --- access checks ----------------------------------------------------------
def lookup(path):
"""(status, stat result) where status is None when the path exists."""
try:
return None, os.stat(path)
except (FileNotFoundError, NotADirectoryError):
return "missing", None
except PermissionError:
return "blocked", None
except OSError:
return "unknown", None
def refusal(exc):
"""Status for a failed open: a refusal is 'blocked', anything else 'unknown'."""
if isinstance(exc, PermissionError) or exc.errno == errno.EROFS:
return "blocked", "refused: " + (errno.errorcode.get(exc.errno) or "denied")
return "unknown", errno.errorcode.get(exc.errno) or exc.__class__.__name__
def check_read(path):
"""Open the file and close it at once. Not one byte is read."""
status, st = lookup(path)
if status:
return status, LOOKUP_REFUSED if status == "blocked" else ""
if not stat.S_ISREG(st.st_mode):
return "missing", "not a regular file"
if is_cloud_placeholder(st):
return "skipped", "stored in the cloud; not opened, so it is not downloaded"
try:
fd = os.open(path, READ_FLAGS)
except OSError as exc:
return refusal(exc)
os.close(fd)
return "open", "opened and closed; nothing read"
class Writes:
"""Write checks. They share the list of test files that could not be deleted
and ask each folder once: can this shell create a file here?"""
def __init__(self, paths):
self.paths, self.leftovers, self.folders = paths, [], {}
def create(self, folder, display):
"""Create one empty, uniquely named file in the folder, then delete it."""
key = os.path.realpath(folder)
if key not in self.folders:
self.folders[key] = self._create(folder, display)
return self.folders[key]
def _create(self, folder, display):
status, st = lookup(folder)
if status:
return status, LOOKUP_REFUSED if status == "blocked" else ""
if not stat.S_ISDIR(st.st_mode):
return "unknown", "not a folder"
if is_cloud_placeholder(st):
return "skipped", "stored in the cloud; left alone"
name = TEMP_PREFIX + secrets.token_hex(8) + ".tmp"
path = os.path.join(folder, name)
try:
fd = os.open(path, CREATE_FLAGS, 0o600)
except OSError as exc:
return refusal(exc)
try:
os.close(fd)
finally:
try:
os.unlink(path)
except OSError:
self.leftovers.append(safe_text(display.rstrip("/") + "/" + name))
return "open", "created and deleted one empty test file"
def append(self, path):
"""Open an existing file for append and close it. Nothing is written."""
status, st = lookup(path)
if status:
return status, LOOKUP_REFUSED if status == "blocked" else ""
if not stat.S_ISREG(st.st_mode):
return "unknown", "not a regular file"
if is_cloud_placeholder(st):
return "skipped", "stored in the cloud; not opened, so it is not downloaded"
try:
fd = os.open(path, APPEND_FLAGS)
except OSError as exc:
# EACCES comes from the file's own permissions. A sandbox refuses with
# EPERM or EROFS, and then the folder test would not speak for this name.
if exc.errno == errno.EACCES and self.replaceable(path, st):
return "open", REPLACEABLE
return refusal(exc)
os.close(fd)
return "open", "opened for append and closed; nothing written"
def replaceable(self, path, st):
folder = os.path.dirname(path)
if self.create(folder, self.paths.show(folder))[0] != "open":
return False
folder_st = lookup(folder)[1]
sticky = bool(folder_st and folder_st.st_mode & stat.S_ISVTX)
return not (sticky and hasattr(os, "geteuid") and st.st_uid != os.geteuid())
def refusing_folder(path):
"""The folder that stopped a refused lookup of path, or None when the refusal
is about that one name (a sandbox rule on the file itself).
Walk up to the deepest folder this shell can stat. If that folder will not
list its files, it is the refusing folder. Otherwise the refusal sits on
its child on the way down: that child, unless the child is path itself."""
child, folder = path, os.path.dirname(path)
while lookup(folder)[0] is not None:
parent = os.path.dirname(folder)
if parent == folder:
return None
child, folder = folder, parent
if listable(folder) is not True:
return folder
return None if child == path else child
def glob_base(pattern):
base = []
for part in pattern.split(os.sep):
if any(ch in part for ch in "*?["):
break
base.append(part)
return os.sep.join(base) or os.sep
def listable(folder):
"""True, False when access is refused, None when the folder is missing."""
try:
os.listdir(folder)
return True
except PermissionError:
return False
except OSError:
return None
class Refused:
"""One blocked row per folder that refuses lookups; files inside it are 'hidden'."""
def __init__(self, paths, category):
self.paths, self.category, self.folders = paths, category, set()
def settle(self, entry, risk, path, status, detail, found):
"""Turn a refused lookup of a file inside a refusing folder into 'hidden'."""
if status == "blocked" and detail == LOOKUP_REFUSED:
folder = refusing_folder(path)
if folder:
self.row(entry, risk, folder, found)
return "hidden", INSIDE_REFUSED
return status, detail
def row(self, entry, risk, folder, found):
if folder not in self.folders:
self.folders.add(folder)
found.append(make_check(entry["id"], self.category, entry["group"], risk, self.paths.show(folder),
"blocked", "this shell cannot look inside this folder"))
# --- secret files -----------------------------------------------------------
def secret_candidates(entry, paths, env):
"""Yield (path, display, kind) for one secret_files entry.
kind "fixed" is listed even when missing; "found" (a glob match) only when
present; "refused" is a folder that would not list its files.
"""
for template in entry.get("paths", []):
resolved = paths.expand(template)
if resolved:
yield resolved[0], resolved[1], "fixed"
for var in entry.get("env_paths", []):
parts = [p for p in env.get(var, "").split(os.pathsep) if p]
for i, part in enumerate(parts, 1):
label = "$" + var if len(parts) == 1 else "$%s (%d of %d)" % (var, i, len(parts))
yield paths.env_path(part), label, "fixed"
excluded = entry.get("exclude", [])
for template in entry.get("globs", []):
resolved = paths.expand(template)
if not resolved:
continue
base = glob_base(resolved[0])
ok = listable(base)
if ok is False:
yield base, paths.display_under(template, base), "refused"
elif ok:
matches = [m for m in sorted(glob.glob(resolved[0]))
if not any(fnmatch.fnmatch(os.path.basename(m), x) for x in excluded)]
hidden = any(ch in part for part in resolved[0].split(os.sep)[:-1] for ch in "*?[")
for i, match in enumerate(matches, 1):
# A folder name matched by a wildcard can be an account name: show the pattern instead.
shown = "%s (%d of %d)" % (resolved[1], i, len(matches)) if hidden else paths.display_under(template, match)
yield match, shown, "found"
if entry.get("pub_siblings"):
resolved = paths.expand(entry["pub_siblings"])
if resolved and listable(resolved[0]):
names = set(os.listdir(resolved[0]))
for name in sorted(names):
if name.endswith(".pub") and name[:-4] in names:
path = os.path.join(resolved[0], name[:-4])
yield path, paths.display_under(entry["pub_siblings"], path), "found"
if entry.get("env_walk"):
for folder in walk_up(paths.project, paths.values["home"]):
for name in env_file_names(folder, entry):
path = os.path.join(folder, name)
yield path, paths.relative(path), "found"
def walk_up(project, home):
"""The project and its parents; stops at the home folder when inside it."""
folders, current = [], project
inside_home = is_within(project, home)
while True:
folders.append(current)
parent = os.path.dirname(current)
if parent == current or (inside_home and os.path.normpath(current) == os.path.normpath(home)):
return folders
current = parent
def env_file_names(folder, entry):
try:
names = sorted(os.listdir(folder))
except PermissionError:
names = entry.get("walk_names", [])
except OSError:
return []
return [n for n in names
if any(fnmatch.fnmatch(n, p) for p in entry["env_walk"])
and not any(fnmatch.fnmatch(n, x) for x in entry.get("exclude", []))]
def probe_secret_files(targets, paths, env):
found, refused = [], Refused(paths, "secret-files")
for entry in targets["secret_files"]:
seen = set()
for path, display, kind in secret_candidates(entry, paths, env):
if path in seen:
continue
seen.add(path)
if kind == "refused":
refused.row(entry, "high", refusing_folder(path) or path, found)
continue
status, detail = refused.settle(entry, "high", path, *check_read(path), found=found)
if status == "missing" and kind == "found":
continue
found.append(make_check(entry["id"], "secret-files", entry["group"], "high", display, status, detail))
return found
# --- trust files: things a program outside the sandbox later runs ------------
def find_git_dir(project, home):
"""The folder that holds hooks and config for the repository around project."""
for folder in walk_up(project, home):
dot_git = os.path.join(folder, ".git")
if os.path.isdir(dot_git):
return dot_git
if os.path.isfile(dot_git):
return git_common_dir(dot_git, folder)
return None
def git_common_dir(pointer, folder):
"""Follow a worktree or submodule '.git' file to the folder git really uses."""
try:
with open(pointer, encoding="utf-8") as fh:
first = fh.readline().strip()
except OSError:
return None
if not first.startswith("gitdir:"):
return None
gitdir = os.path.normpath(os.path.join(folder, first[len("gitdir:"):].strip()))
try:
with open(os.path.join(gitdir, "commondir"), encoding="utf-8") as fh:
return os.path.normpath(os.path.join(gitdir, fh.readline().strip()))
except OSError:
return gitdir
def probe_trust_files(targets, paths, platform_name, writes):
found, refused = [], Refused(paths, "trust-files")
for entry in targets["trust_files"]:
if entry.get("os") and entry["os"] != platform_name:
continue
for template in entry["paths"]:
resolved = paths.expand(template)
if not resolved:
continue
path, display = resolved
result = writes.create(path, display) if entry["kind"] == "dir" else writes.append(path)
status, detail = refused.settle(entry, entry["risk"], path, *result, found=found)
found.append(make_check(entry["id"], "trust-files", entry["group"], entry["risk"], display,
status, detail, why=entry["why"]))
return found
def probe_folders(targets, paths, writes):
"""Can the shell create files in the home folder, the parent folder, the project?
Returns the checks and the ids of outside folders that are the project itself."""
found, seen, same_as_project = [], set(), set()
project = os.path.realpath(paths.project)
for entry in targets["folders"]:
path = paths.expand(entry["path"])[0]
real = os.path.realpath(path)
if real in seen and entry["id"] != "project-folder":
continue
seen.add(real)
if entry["category"] == "outside-project" and real == project:
same_as_project.add(entry["id"])
status, detail = writes.create(path, entry["display"])
found.append(make_check(entry["id"], entry["category"], entry["group"], entry["risk"],
entry["display"], status, detail))
return found, same_as_project
# --- environment, sockets, privilege, network -------------------------------
def probe_env(targets, env):
"""Names and value lengths of variables whose names look like secrets."""
include = re.compile(targets["secret_env"]["include"], re.IGNORECASE)
exclude = re.compile(targets["secret_env"]["exclude"], re.IGNORECASE)
found = [{"name": safe_text(name), "length": len(value)} for name, value in env.items()
if value and include.search(name) and not exclude.search(name)]
found.sort(key=lambda item: item["name"])
check = make_check("secret-env", "secret-env", "Secret-like environment variables", "medium",
"%d variables" % len(found), "open" if found else "blocked",
"names and lengths only" if found else "no secret-like names")
return found, [check]
def probe_sockets(targets, paths, env, probes):
"""Docker or Podman sockets and the SSH agent: connect, then close."""
candidates = [paths.expand(t) for t in targets["docker_sockets"]]
docker_host = env.get("DOCKER_HOST", "")
if docker_host.startswith("unix://"):
candidates.append((docker_host[len("unix://"):], "$DOCKER_HOST"))
found, seen = [], set()
for resolved in candidates:
if not resolved or not os.path.exists(resolved[0]):
continue
real = os.path.realpath(resolved[0])
if real in seen:
continue
seen.add(real)
code = probes.docker_connect(resolved[0])
found.append(socket_check("docker", "Docker control", "critical", resolved[1], code))
if not found:
found.append(make_check("docker", "docker", "Docker control", "critical", "Docker socket",
"missing", "no socket found"))
agent = env.get("SSH_AUTH_SOCK", "")
if agent and os.path.exists(agent):
found.append(socket_check("ssh-agent", "SSH agent", "high", "$SSH_AUTH_SOCK",
probes.ssh_agent_connect(agent)))
else:
found.append(make_check("ssh-agent", "ssh-agent", "SSH agent", "high", "$SSH_AUTH_SOCK",
"missing", "no agent socket"))
return found
def socket_check(cid, group, risk, target, code):
"""A socket probe's errno becomes a status: a stale socket file is not a refusal."""
name = errno.errorcode.get(code, str(code))
if code == 0:
status, detail = "open", "connected and closed; nothing sent"
elif code in (errno.ECONNREFUSED, errno.ENOENT):
status, detail = "missing", "socket file exists but nothing is listening now (%s)" % name
elif code in (errno.EACCES, errno.EPERM):
status, detail = "blocked", "refused: " + name
else:
status, detail = "unknown", name
return make_check(cid, cid, group, risk, target, status, detail)
def probe_privilege(identity, probes, skip_sudo):
root = identity["euid"] == 0
sudo = "skipped" if skip_sudo else probes.sudo_check()
return [
make_check("root", "privilege", "Root and sudo", "critical", "user id %s" % identity["euid"],
"open" if root else "blocked", "runs as root" if root else "not root"),
make_check("sudo", "privilege", "Root and sudo", "critical", "sudo -n true", sudo,
{"open": "sudo works without a password", "blocked": "sudo needs a password",
"missing": "sudo is not installed", "skipped": "skipped (--skip-sudo)"}.get(sudo, "")),
]
def probe_network(targets, probes, network):
net = targets["network"]
plan = [("dns", "info", host, (host,)) for host in net["dns"]]
plan += [("outbound", "medium", "%s:%d" % (h, p), (h, p)) for h, p in net["outbound"]]
plan.append(("metadata", "high", "%s:%d" % tuple(net["metadata"]), tuple(net["metadata"])))
found = []
for cid, risk, target, args in plan:
if not network:
status, detail = "skipped", "not checked; run with --network"
else:
ok = probes.dns_lookup(*args) if cid == "dns" else probes.tcp_connect(*args)
status = "open" if ok else "blocked"
detail = ("resolved" if cid == "dns" else "connected and closed; nothing sent") if ok else "failed"
found.append(make_check(cid, "network", "Network", risk, target, status, detail))
return found
def where_it_runs(targets, paths, env, identity, platform_name):
signs = targets["container_signs"]
containers = [shown for path, shown in map(paths.expand, signs["files"]) if os.path.exists(path)]
try:
with open(paths.expand(signs["cgroup_file"])[0], encoding="utf-8", errors="replace") as fh:
cgroup = fh.read(65536)
except OSError:
cgroup = ""
containers += ["cgroup: " + word for word in signs["cgroup_words"] if word in cgroup]
containers += [name for name in signs["env"] if env.get(name)]
sandbox = []
for marker in targets["sandbox_markers"]:
value = env.get(marker["name"])
if value:
shown = "%s=%s" % (marker["name"], value) if value in marker["values"] else marker["name"] + " (set)"
sandbox.append(shown)
if platform_name == "darwin":
system = "macOS " + (platform.mac_ver()[0] or platform.release())
else:
system = "%s %s" % (platform.system(), platform.release())
return {"user": safe_text(identity["user"]), "uid": identity["uid"], "root": identity["euid"] == 0,
"os": safe_text(system), "python": platform.python_version(),
"harnesses": [h for h, names in targets["harness_markers"].items() if any(env.get(n) for n in names)],
"sandbox_signs": sandbox, "container_signs": containers}
# --- what the settings claim ------------------------------------------------
HARNESS_NAMES = {"claude-code": "Claude Code", "codex": "Codex", "gemini-cli": "Gemini CLI",
"cursor": "Cursor", "opencode": "OpenCode"}
RISK_ORDER = {"critical": 0, "high": 1, "medium": 2, "info": 3}
GEMINI_SANDBOX_KINDS = ("docker", "podman", "sandbox-exec", "runsc", "lxc")
def parse_settings(path):
"""(data, problem). Callers keep only the sandbox keys; nothing else is printed."""
try:
if path.endswith(".toml"):
if tomllib is None:
return None, "parsing TOML needs Python 3.11 or newer"
with open(path, "rb") as fh:
return tomllib.load(fh), None
with open(path, encoding="utf-8") as fh:
return json.load(fh), None
except OSError:
return None, "it could not be read"
except ValueError:
return None, "it could not be parsed"
def read_settings(targets, paths, platform_name, notes):
found = []
for harness, layers in targets["settings"].items():
name, loaded = HARNESS_NAMES[harness], []
for layer in layers:
if layer.get("os") and layer["os"] != platform_name:
continue
files = []
if "path" in layer:
files.append(paths.expand(layer["path"]))
else:
folder, shown = paths.expand(layer["dropins"])
ok = listable(folder)
if ok:
files += [(os.path.join(folder, n), shown + "/" + n)
for n in sorted(os.listdir(folder)) if n.endswith(".json")]
elif ok is False:
notes.append("%s settings folder %s could not be listed, so its files were not read." % (
name, code(shown)))
for resolved in files:
if not resolved or not os.path.isfile(resolved[0]):
continue
shown = safe_text(resolved[1])
data, problem = parse_settings(resolved[0])
if problem or not isinstance(data, dict):
notes.append("%s settings file %s was skipped: %s." % (
name, code(shown), problem or "it is not an object"))
continue
loaded.append((layer["layer"], shown, data))
if loaded:
found.append({"harness": harness, "sources": [shown for _, shown, _ in loaded],
"claims": CLAIMS[harness](loaded)})
return found
def listed(value):
"""The strings in a settings list; anything that is not a list counts as empty."""
return [x for x in value if isinstance(x, str)] if isinstance(value, list) else []
def claude_claims(layers):
"""Merge sandbox keys the way Claude Code does: booleans from the highest
layer that sets them, lists merged. Layers arrive lowest first."""
claims = {"sandbox_enabled": None, "allow_unsandboxed": None, "filesystem_disabled": None,
"excluded_commands": [], "docker_socket_allowed": False, "credential_rules": 0}
excluded = set()
for layer, _, data in layers:
box = data.get("sandbox")
if not isinstance(box, dict):
continue
if isinstance(box.get("enabled"), bool):
claims["sandbox_enabled"] = box["enabled"]
if isinstance(box.get("allowUnsandboxedCommands"), bool):
claims["allow_unsandboxed"] = box["allowUnsandboxedCommands"]
files = box.get("filesystem")
if layer in ("user", "managed") and isinstance(files, dict) and isinstance(files.get("disabled"), bool):
claims["filesystem_disabled"] = files["disabled"] # project settings cannot set this key
excluded.update(safe_text(x) for x in listed(box.get("excludedCommands")))
net = box.get("network")
if isinstance(net, dict):
sockets = listed(net.get("allowUnixSockets"))
if net.get("allowAllUnixSockets") is True or any(x.endswith(("docker.sock", "podman.sock")) for x in sockets):
claims["docker_socket_allowed"] = True
creds = box.get("credentials")
if isinstance(creds, dict):
claims["credential_rules"] += sum(len(creds.get(k)) for k in ("files", "envVars")
if isinstance(creds.get(k), list))
claims["excluded_commands"] = sorted(excluded)
return claims
def codex_claims(layers):
claims = {"sandbox_mode": None, "network_access": None, "ignore_default_excludes": None}
for _, _, data in layers:
if isinstance(data.get("sandbox_mode"), str):
claims["sandbox_mode"] = safe_text(data["sandbox_mode"])
write = data.get("sandbox_workspace_write")
if isinstance(write, dict) and isinstance(write.get("network_access"), bool):
claims["network_access"] = write["network_access"]
policy = data.get("shell_environment_policy")
if isinstance(policy, dict) and isinstance(policy.get("ignore_default_excludes"), bool):
claims["ignore_default_excludes"] = policy["ignore_default_excludes"]
return claims
def gemini_claims(layers):
claims = {"sandbox": None}
for _, _, data in layers:
tools = data.get("tools")
value = tools.get("sandbox") if isinstance(tools, dict) else None
if isinstance(value, bool):
claims["sandbox"] = value
elif isinstance(value, str):
claims["sandbox"] = value if value in GEMINI_SANDBOX_KINDS else bool(value)
return claims
CLAIMS = {"claude-code": claude_claims, "codex": codex_claims, "gemini-cli": gemini_claims}
def sandbox_state(harness, claims, signs):
"""(is a sandbox claimed for this shell, the sentence that says why)."""
if harness == "claude-code":
return claims.get("sandbox_enabled") is True, "Claude Code settings turn the sandbox on"
if harness == "codex":
mode = claims.get("sandbox_mode")
if mode in ("read-only", "workspace-write"):
return True, "Codex settings say %s" % code(mode)
return any(s.startswith("CODEX_SANDBOX") for s in signs), "Codex marks this shell as sandboxed"
if harness == "gemini-cli":
if claims.get("sandbox"):
return True, "Gemini CLI settings turn the sandbox on"
return any(s.startswith("SANDBOX") for s in signs), "Gemini CLI marks this shell as sandboxed"
return any(s.startswith("CURSOR_SANDBOX") for s in signs), "Cursor marks this shell as sandboxed"
def sandbox_off(harness, claims):
rights = "so shell commands run with your full user rights"
if harness == "claude-code":
return ("The Claude Code sandbox is off: no settings file the probe read sets sandbox.enabled to "
"true, %s." % rights)
if harness == "codex" and claims.get("sandbox_mode") == "danger-full-access":
return "Codex settings set sandbox_mode to danger-full-access, %s." % rights
if harness == "gemini-cli":
return "The Gemini CLI sandbox is off: no tools.sandbox setting and no SANDBOX marker, %s." % rights
return None
def protects(harness, check, trust, same_as_project):
"""Would this harness's sandbox block the write by default?"""
if check["category"] == "outside-project":
return check["id"] not in same_as_project # the project itself is writable in every sandbox
entry = trust.get(check["id"]) if check["category"] == "trust-files" else None
return bool(entry) and (entry.get("outside", False) or harness in entry.get("protected_by", []))
def quoted(items):
return listing([code(item) for item in items])
def find_gaps(settings, checks, where, targets, env_found, same_as_project, notes):
claims_by = {s["harness"]: s["claims"] for s in settings}
trust = {e["id"]: e for e in targets["trust_files"]}
opened = [c for c in checks if c["status"] == "open"]
home = [c["status"] for c in checks if c["id"] == "home-folder"]
gaps = []
for harness in where["harnesses"]:
claims, name = claims_by.get(harness, {}), HARNESS_NAMES[harness]
def add(risk, message):
gaps.append({"harness": harness, "risk": risk, "message": message})
active, basis = sandbox_state(harness, claims, where["sandbox_signs"])
message = None if active else sandbox_off(harness, claims)
if message and harness == "claude-code" and home == ["blocked"]:
# --settings, MDM, and server-managed settings are out of reach, and the home folder refused a write.
notes.append("Claude Code: no settings file the probe read sets sandbox.enabled to true, yet new files "
"in your home folder were blocked, so a sandbox from --settings or managed settings may be on.")
elif message:
add("critical", message)
protected = sorted((c for c in opened if protects(harness, c, trust, same_as_project)), key=lambda c: (
RISK_ORDER[c["risk"]], not trust.get(c["id"], {}).get("outside", c["category"] == "outside-project")))
if active and protected:
add("critical", "%s, yet %s is writable from this shell (%d places the sandbox should protect). "
"Either this command ran outside the sandbox, or the sandbox allows more than its "
"settings suggest." % (basis, code(protected[0]["target"]), len(protected)))
handoff = [c["target"] for c in opened
if c["category"] == "trust-files" and not protects(harness, c, trust, same_as_project)]
if active and handoff:
add("high", "The %s sandbox leaves %s writable, and programs outside the sandbox run what is "
"there later." % (name, quoted(handoff)))
if active and any(c["id"] == "docker" for c in opened):
add("critical", "The %s sandbox lets this shell connect to the Docker socket, and Docker can start "
"a container that mounts your home folder outside any sandbox." % name)
if harness == "claude-code" and active:
claude_gaps(claims, checks, add)
if harness == "codex" and env_found and claims.get("ignore_default_excludes") is not False:
add("medium", "Codex passes variables named like KEY, SECRET, or TOKEN to shell commands unless "
"shell_environment_policy.ignore_default_excludes is false; %d are visible here." % len(env_found))
return sorted(gaps, key=lambda g: RISK_ORDER[g["risk"]])
def claude_gaps(claims, checks, add):
docker = [c for c in checks if c["id"] == "docker" and c["status"] in ("open", "blocked")]
if docker and any(x.strip().startswith("docker") for x in claims.get("excluded_commands", [])):
add("critical", "sandbox.excludedCommands runs docker outside the sandbox, so the agent can control "
"Docker even when this probe could not connect.")
if claims.get("docker_socket_allowed"):
add("critical", "sandbox.network allows the Docker socket, so sandboxed commands can control Docker.")
if claims.get("filesystem_disabled") is True:
add("critical", "sandbox.filesystem.disabled is true, so sandboxed commands can write anywhere you can.")
readable = [c for c in checks if c["category"] == "secret-files" and c["status"] == "open"]
if readable:
add("high", "%s readable from this shell; the sandbox blocks only files listed in "
"sandbox.credentials.files with mode deny." % (plural(len(readable), "secret file") +
(" is" if len(readable) == 1 else " are")))
if claims.get("allow_unsandboxed") is not False:
add("medium", "sandbox.allowUnsandboxedCommands is not false, so Claude can retry a blocked command "
"outside the sandbox after asking you.")
def listing(items, limit=3):
shown = ", ".join(items[:limit])
return shown + (" and %d more" % (len(items) - limit) if len(items) > limit else "")
# --- score and report ------------------------------------------------------
def score(checks):
by_risk = {risk: {"open": 0, "checked": 0} for risk in SCORED_RISKS}
for c in checks:
if c["risk"] in by_risk and c["status"] in ("open", "blocked"):
by_risk[c["risk"]]["checked"] += 1
by_risk[c["risk"]]["open"] += c["status"] == "open"
return {"open": sum(v["open"] for v in by_risk.values()),
"checked": sum(v["checked"] for v in by_risk.values()),
"by_risk": by_risk}
def plural(n, word):
return "%d %s%s" % (n, word, "" if n == 1 else "s")
def make_headline(checks, env_found, harnesses):
"""One sentence: secret files first, then the worst places the shell reached."""
subject = "Your agent" if harnesses else "This shell"
opened = [c for c in checks if c["status"] == "open"]
ids, groups = {c["id"] for c in opened}, {c["group"] for c in opened}
secret_files = sum(c["category"] == "secret-files" for c in opened)
harness_files = sum(c["group"] == "Harness settings and hooks" for c in opened)
phrases = [
(secret_files, "open " + plural(secret_files, "secret file")),
("root" in ids, "act as root"),
("sudo" in ids and "root" not in ids, "run sudo without a password"),
("docker" in ids, "control Docker"),
("git-hooks" in ids, "write to your git hooks"),
("git-hooks" not in ids and bool({"git-config", "gitconfig"} & ids), "change your git config"),
("Shell startup files" in groups, "change your shell startup files"),
("Login items" in groups, "add programs that start at login"),
("authorized-keys" in ids, "add SSH login keys"),
(harness_files, "change " + plural(harness_files, "harness settings file")),
(bool({"vscode", "venv-bin"} & ids), "change files your editor runs"),
("home-folder" in ids, "write to files in your home folder"),
("parent-folder" in ids, "write to files in the parent folder"),
("ssh-agent" in ids, "use your SSH agent"),
("metadata" in ids, "reach the cloud metadata service"),
("outbound" in ids, "connect to the internet directly"),
(env_found, "see " + plural(len(env_found), "secret-like environment variable")),
]
parts = [text for present, text in phrases if present][:3]
total = score(checks)
if not parts and total["open"]:
return "%s can reach %d of %d checked targets; the table below lists them." % (
subject, total["open"], total["checked"])
if not parts:
return "%s could not reach any secret file, trust file, Docker socket, or sudo: 0 of %d checks open." % (
subject, total["checked"])
if len(parts) == 3:
return "%s can %s, %s, and %s." % (subject, parts[0], parts[1], parts[2])
return "%s can %s." % (subject, " and ".join(parts))
def table_rows(result):
"""(risk, area, open, found) per group, worst risk first."""
groups = {}
for c in result["checks"]:
if c["risk"] in SCORED_RISKS and c["status"] in ("open", "blocked"):
groups.setdefault((RISK_ORDER[c["risk"]], c["risk"], c["group"]), []).append(c)
rows = []
for (_, risk, group), items in sorted(groups.items(), key=lambda kv: kv[0][0]):
opened = [c["target"] for c in items if c["status"] == "open"]
if group == "Secret-like environment variables":
count, found = plural(len(result["env"]), "name"), "listed below" if opened else "none"
else:
count = "%d of %d" % (len(opened), len(items))
found = quoted(opened)
rows.append((risk.capitalize(), group, count, found or "all blocked"))
if not result["network_checked"]:
rows.append(("Info", "Network", "not checked", "run again with `--network` to test it"))
return rows
def where_line(result):
w = result["where"]
agents = ", ".join(HARNESS_NAMES.get(h, h) for h in w["harnesses"])
status = {c["id"]: c["status"] for c in result["checks"] if c["category"] in ("project", "outside-project")}
parts = ["user %s (uid %s%s) on %s" % (code(w["user"]), w["uid"], ", root" if w["root"] else "", code(w["os"])),
"agent: " + (agents or "none found, so this looks like your own terminal"),
"container signs: " + (", ".join(w["container_signs"]) or "none"),
"sandbox markers: " + (", ".join(w["sandbox_signs"]) or "none"),
"new files in the project: " + ("allowed" if status.get("project-folder") == "open" else "blocked"),
"new files in your home folder: " + ("allowed" if status.get("home-folder") == "open" else "blocked")]
return "; ".join(parts)
def render_markdown(result):
s, by = result["score"], result["score"]["by_risk"]
lines = ["**%s**" % result["headline"], "",
"%d of %d checks open: critical %d of %d, high %d of %d, medium %d of %d." % (
s["open"], s["checked"], by["critical"]["open"], by["critical"]["checked"],
by["high"]["open"], by["high"]["checked"], by["medium"]["open"], by["medium"]["checked"]),
"", "Where it runs: %s." % where_line(result), "",
"| Risk | Area | Open | Found |", "|---|---|---|---|"]
lines += ["| %s | %s | %s | %s |" % row for row in table_rows(result)]
if result["env"]:
lines += ["", "Secret-like environment variables (names and lengths only): " + ", ".join(
"%s (%d characters)" % (code(e["name"]), e["length"]) for e in result["env"]) + "."]
if result["gaps"]:
lines += ["", "Settings compared with what the probe found:"]
lines += ["- %s (%s): %s" % (HARNESS_NAMES[g["harness"]], g["risk"], g["message"]) for g in result["gaps"]]
if result["leftovers"]:
lines += ["", "These test files could not be deleted; remove them by hand: " + quoted(result["leftovers"]) + "."]
if result["notes"]:
lines += ["", "Notes:"] + ["- " + note for note in result["notes"]]
lines += ["", "How it checks: secret files are opened and closed without reading a byte, existing files "
"are opened for append and closed without writing, and a folder gets one empty test file that "
"is deleted at once. Targets that do not exist are listed only in `--json` output."]
return "\n".join(lines) + "\n"
def run(project, env=None, probes=None, network=False, sysroot="/", platform_name=None, skip_sudo=False):
env = os.environ if env is None else env
probes = probes or Probes(env)
platform_name = platform_name or sys.platform
home = os.path.abspath(env.get("HOME") or os.path.expanduser("~"))
project = os.path.abspath(project)
targets = load_targets()
paths = Paths(targets["roots"], env, home, project, sysroot)
identity = probes.identity()
notes, writes = [], Writes(paths)
git_dir = find_git_dir(project, home)
if git_dir:
paths.values["git"], paths.labels["git"] = git_dir, paths.relative(git_dir)
else:
notes.append("The project is not inside a git repository, so its .git/hooks and .git/config were not checked.")
proxies = [name for name in targets["network"]["proxy_env"] if env.get(name)]
if proxies:
notes.append("Proxy variables are set (%s): tools that honor them may reach the network "
"through the proxy even when direct connections fail." % ", ".join(proxies))
env_found, checks = probe_env(targets, env)
checks = probe_secret_files(targets, paths, env) + checks
checks += probe_trust_files(targets, paths, platform_name, writes)
folders, same_as_project = probe_folders(targets, paths, writes)
checks += folders
if "home-folder" in same_as_project:
notes.append("The home folder is the project folder here, so a sandbox that allows the project also "
"allows new files in the home folder.")
checks += probe_sockets(targets, paths, env, probes)
checks += probe_privilege(identity, probes, skip_sudo)
checks += probe_network(targets, probes, network)
unknown = sum(c["status"] == "unknown" for c in checks)
if unknown:
notes.append("%s hit an unexpected error and %s not scored; the detail field in --json says why." % (
plural(unknown, "check"), "is" if unknown == 1 else "are"))
where = where_it_runs(targets, paths, env, identity, platform_name)
settings = read_settings(targets, paths, platform_name, notes)
gaps = find_gaps(settings, checks, where, targets, env_found, same_as_project, notes)
return {"tool": "sandbox-check", "version": VERSION,
"headline": make_headline(checks, env_found, where["harnesses"]),
"where": where, "score": score(checks), "checks": checks, "env": env_found,
"settings": settings, "gaps": gaps,
"network_checked": bool(network), "notes": notes, "leftovers": writes.leftovers}
def main(argv=None, env=None, probes=None, sysroot="/", platform_name=None):
parser = argparse.ArgumentParser(
prog="probe.py", description=__doc__.strip().split("\n\n")[0],
epilog="Exit codes: 0 done, 1 a check at or above --fail-on is open, 2 usage error.")
parser.add_argument("--project", default=".", help="the folder the agent works in (default: current folder)")
parser.add_argument("--network", action="store_true",
help="also test DNS and direct TCP connections to github.com:443, pypi.org:443, and "
"the cloud metadata address 169.254.169.254:80 (3-second timeout, no data sent)")
parser.add_argument("--skip-sudo", action="store_true",
help="do not run `sudo -n true`; on shared machines sudo may log or report the attempt")
parser.add_argument("--json", action="store_true", help="print machine-readable JSON")
parser.add_argument("--out", metavar="PATH", help="write the report to this file instead of printing it")
parser.add_argument("--fail-on", choices=SCORED_RISKS,
help="exit 1 when any check at this risk level or worse is open")
args = parser.parse_args(argv)
if not os.path.isdir(args.project):
print("error: project folder not found: %s" % args.project, file=sys.stderr)
return 2
result = run(args.project, env=env, probes=probes, network=args.network, sysroot=sysroot,
platform_name=platform_name, skip_sudo=args.skip_sudo)
text = json.dumps(result, indent=2, ensure_ascii=False) + "\n" if args.json else render_markdown(result)
if args.out:
try:
with open(args.out, "w", encoding="utf-8") as fh:
fh.write(text)
except OSError as exc:
print("error: cannot write the report to %s: %s" % (args.out, exc.strerror or exc), file=sys.stderr)
return 2
print("Report written to %s" % args.out)
else:
sys.stdout.write(text)
if args.fail_on and any(c["status"] == "open" and RISK_ORDER[c["risk"]] <= RISK_ORDER[args.fail_on]
for c in result["checks"]):
return 1
return 0
if __name__ == "__main__":
sys.exit(main())
# Copied from skills/evals/shared/safe.py by skills/evals/tools/sync_shared.py. Edit the source, then run the sync.
"""Make untrusted text safe to show in a Markdown report.
Text from a repository or a session transcript can hold secrets, carry
instructions for the agent that relays a report, or break a table. redact()
masks secrets; safe_text() also makes the text one inert line; code() puts
that line inside inline code.
Python 3.9+, standard library only.
"""
import re
import unicodedata
_MASK = "[REDACTED]"
# Word edges by ASCII rules. Python's \b counts a non-ASCII letter, such as an
# e with an acute accent, as part of the word, so a key right after one would
# stay unmasked.
_B = r"(?<![A-Za-z0-9_])"
_E = r"(?![A-Za-z0-9_])"
# Characters that draw nothing, besides the format characters (Unicode category
# Cf: zero-width spaces and joiners, direction marks, the soft hyphen): the
# combining grapheme joiner, Hangul fillers, Khmer inherent vowels, Mongolian
# and other variation selectors, and tag characters. Inside a key name they
# hide the key from the patterns below while the text still reads as the key.
_INVISIBLE_RE = re.compile(r"[\u034f\u115f\u1160\u17b4\u17b5\u180b-\u180f\u3164\ufe00-\ufe0f\uffa0"
r"\U000e0000-\U000e0fff]")
# Run time: hooks clean every tool call, and a tool input can be tens of
# kilobytes, so each pattern must take time in proportion to the text. A pattern
# that can start at many places inside one long run of characters, scan to the
# end of that run, and then fail takes time that grows with the square of the
# run (or worse). The patterns below limit or stop such scans; each one says how.
_SECRET_RES = [
# PEM private key blocks, including a block cut off before its END line.
re.compile(r"-----BEGIN [A-Z0-9 ]*PRIVATE KEY-----.*?(?:-----END [A-Z0-9 ]*PRIVATE KEY-----|\Z)", re.S),
re.compile(_B + r"sk-ant-[A-Za-z0-9_\-]{16,}"),
re.compile(_B + r"sk-[A-Za-z0-9_\-]{20,}"),
re.compile(_B + r"gh[pousr]_[A-Za-z0-9]{20,}"),
re.compile(_B + r"github_pat_[A-Za-z0-9_]{20,}"),
re.compile(_B + r"glpat-[A-Za-z0-9_\-]{20,}"),
re.compile(_B + r"xox[abposr]-[A-Za-z0-9\-]{10,}"),
re.compile(_B + r"AIza[0-9A-Za-z_\-]{30,}"),
re.compile(_B + r"(?:AKIA|ASIA)[0-9A-Z]{16}" + _E),
re.compile(_B + r"[rsp]k_(?:live|test)_[0-9A-Za-z]{16,}"),
re.compile(_B + r"npm_[A-Za-z0-9]{30,}"),
re.compile(_B + r"hf_[A-Za-z0-9]{30,}"),
# A JSON web token. The first part stops before a later "-eyJ", where the
# next try starts, so no run is scanned again for each "-eyJ" in it.
re.compile(_B + r"eyJ(?:[A-Za-z0-9_]|-(?!eyJ)){8,}\.[A-Za-z0-9_\-]{8,}\.[A-Za-z0-9_\-]{8,}"),
]
# A mysql or mariadb program name, as one word.
_MYSQL = _B + r"(?:mysql|mysqldump|mysqladmin|mysqlsh|mariadb|mariadb-dump)" + _E
def _credential(before, unquoted, after="", flags=0):
"""A credential after a fixed prefix. Group 1, which redact() keeps, is
`before`, an optional quote (group 2), and `after`. The rest of the match
is masked: with a quote, everything up to the matching quote or the end of
the line, spaces included (a backslash escapes the next character, and the
closing quote stays); without one, `unquoted`."""
return re.compile(r"(%s([\"'])?%s)(?(2)(?:(?!\2)[^\\\n]|\\.)+|%s)" % (before, after, unquoted), flags)
# Keep group 1, mask the rest of the match.
_KEEP_PREFIX_RES = [
# Credentials passed as command flags: sshpass -p X, mysql -pX, curl -u user:X, --password X.
_credential(_B + r"sshpass\s+-p\s*", r"[^\s'\"]+"),
# The search for -p stops at the next program name, where the next try starts.
_credential(_MYSQL + r"(?:(?!" + _MYSQL + r")[^\n|;&])*?\s-p(?=[^\s-])", r"[^\s'\"]+", flags=re.I),
# One "=" or spaces after the flag: a user name can start with "=", so "[=\s]+"
# before it could split a run of "=" in every way.
_credential(r"(?:^|\s)(?:-u|--user)(?:=|\s+)", r"[^\s@'\"]+", after=r"[^\s:@'\"]+:"),
_credential(r"--pass(?:word|wd)?\s+", r"[^\s'\"-][^\s'\"]*", flags=re.I),
re.compile(r"(?i)(" + _B + r"(?:bearer|basic)\s+)[A-Za-z0-9._~+/\-]{16,}=*"),
# A URL scheme has at most 32 characters, so each try scans at most that far.
re.compile(r"(?i)(" + _B + r"[a-z][a-z0-9+.\-]{0,31}://[^/\s:@]+:)[^@\s/]+(?=@)"),
# A secret-like key and any non-empty value. The key is one whole name (a run
# of letters, digits, and _ . -) that holds a keyword anywhere. A try starts
# only where a name starts, and the lookahead stays inside that name, so each
# name is scanned once. "tokens" alone names a count (max_tokens: 1000), not a secret.
_credential(r"(?<![A-Za-z0-9_.\-])(?=[A-Za-z0-9_.\-]*?(?:api[_\-]?key|secret|token(?!s(?![A-Za-z0-9]))|"
r"passw(?:or)?d|pwd|credential|private[_\-]?key|access[_\-]?key))"
r"[A-Za-z0-9_.\-]+[\"']?\s*[:=]+\s*", r"[^\s\"',;}]+", flags=re.I),
]
_LONG_RUN_RE = re.compile(r"[A-Za-z0-9+=_\-]{40,}")
def _mask_long_run(m) -> str:
s = m.group(0)
if re.search(r"[0-9]", s) and re.search(r"[A-Za-z]", s):
return _MASK
return s
def _plain(text) -> str:
"""NFKC form (fullwidth and other compatibility letters become plain ones)
without the characters that draw nothing."""
if text.isascii():
return text
text = unicodedata.normalize("NFKC", text)
return _INVISIBLE_RE.sub("", "".join(ch for ch in text if unicodedata.category(ch) != "Cf"))
def redact(text) -> str:
"""Mask API keys, tokens, private keys, and long secret-like strings. The
text comes back in NFKC form without the characters that draw nothing, so a
fullwidth or split key name is masked like the plain one."""
if not text:
return ""
out = _plain(str(text))
for rx in _SECRET_RES:
out = rx.sub(_MASK, out)
for rx in _KEEP_PREFIX_RES:
out = rx.sub(lambda m: m.group(1) + _MASK, out)
return _LONG_RUN_RE.sub(_mask_long_run, out)
def safe_text(text, limit=160) -> str:
"""Make untrusted text (a command, a path, a claim, a tool description) safe
to show in a markdown report: secrets masked, control characters and line
breaks turned into spaces, backticks and table pipes replaced, runs of
spaces collapsed, and the result cut to `limit` characters. Text from a
repository or a transcript can carry instructions or break a table; this
keeps it one inert line."""
s = redact(text if isinstance(text, str) else str(text))
s = "".join(ch if ch.isprintable() else " " for ch in s)
s = " ".join(s.replace("`", "'").replace("|", "/").split())
return s if len(s) <= limit else s[: max(limit - 3, 0)] + "..."
def code(text, limit=160) -> str:
"""Untrusted text for a Markdown report, inside inline code so links, HTML,
and bare URLs stay literal; safe_text already replaces backticks and pipes.
Text that is empty after cleaning shows as `(empty)`, because a bare pair
of backticks renders as two stray backticks."""
return "`%s`" % (safe_text(text, limit) or "(empty)")