Security considerations
AGRO runs coding agents with broad autonomy. This page lists the security boundaries that the repository holds now, and the file that holds each one. An ENFORCED boundary holds whatever the model does. A DOCTRINE boundary is a convention, and no mechanism holds the line.
Threat model. These boundaries stop two threats: accidental credential leaks into transcripts and prompt caches, and agents that act outside their intended surface. The boundaries do not stop a determined adversary who controls the model. The guards match patterns, and the sandbox trades some isolation for capability (section 4). Treat every model output and tool output as untrusted (section 7).
1. Secrets stay out of git — ENFORCED
Real credentials never enter the tracked checkout.
.gitignoreignores**/.env*,**/auth.json,**/.credentials.json, and/.agro/config.json.- The tracked
.example.envholds no secret. Secrets live in the root.env(mode0600), which.gitignoreexcludes.agro secret set <KEY>writes one key to that file. - Non-secret settings live in the tracked
agro.json..agro/cli/src/lib/secrets.tsholds the secret allowlist..agro/cli/src/lib/config-render.tsrefuses to render a secret into the compose environment. See Configuration. - Provider authentication lives in the
/home/sandboxmount, not in the repository.
No mechanism scans commit contents for a pasted secret.
2. Secret-exposure hooks — ENFORCED
The sandbox runs Claude Code with bypassPermissions and Codex with
approval_policy = "never". The permission engine is off, so a deny list alone
does not hold. Deterministic hooks in .agro/hooks/ run
before each tool call. .claude/hooks is a symlink to .agro/hooks.
deny-env-dump.shchecks eachBashcommand. The hook denies environment dumps, shell history dumps, andechoof a secret-named variable. The hook denies token-printing commands such asgh auth tokenandagro secret get, and reads of secret files such as.env*,*.pem, andid_rsa*. The hook also denies any command that names a.config/path. The operator runsagro secret geton the host.deny-secret-paths.shchecks the file tools (Read,Write,Edit,NotebookEdit,Grep,Glob). The hook denies the same secret paths,.config/, andsettings.local.json.warn-devtcp.shprints a warning for/dev/tcpand/dev/udp. The hook blocks nothing.
deny-env-dump.sh ignores HEREDOC bodies. A template file such as
.example.env stays readable.
Wiring:
- Claude Code:
hooks.PreToolUsein.claude/settings.jsonruns all three hooks.permissions.denyrepeats the same patterns. - Codex:
.codex/hooks.jsonrunsdeny-env-dump.shand.codex/hooks/deny-local-settings.sh. Codex cannot prompt, so the Codex wrapper turns each ask into a deny. - Pi:
.pi/extensions/path-guard.tsasks before a write or an edit to a sensitive path. The guard runs only in an interactive Pi session. - Hermes: no hook surface.
When a commit body contains a flagged substring, pass the body as a file
(git commit -F msg.txt, gh pr create --body-file body.md). Do not retry a
variant that bypasses a deny.
3. Destructive-command guard — ENFORCED
cc-safety-net 2.6.8 parses each
Bash command and denies destructive intent: rm -rf, git reset --hard,
git checkout --, git push --force, git clean -f, find -delete, dd,
mkfs, and destructive interpreter one-liners. The guard follows bash -c,
xargs, and command chains. Version 2.6.8 removes the shell-quote dependency
and its bundled code. It adds secret-path, Git-metadata, policy-tamper, and
parser protections. Section 2's independent secret hooks remain in place.
.devcontainer/Dockerfileinstalls the pinned binary. At boot,.agro/scripts/link-providers.shfails when the binary is missing or differs from the pin.CC_SAFETY_NET_STRICT=1denies shell syntax that the guard cannot parse.CC_SAFETY_NET_WORKTREE=1allows a baregit reset --hardinside a linked worktree.- Claude Code and Codex run the guard as a
Bashhook. Pi loadsnpm:cc-safety-net@2.6.8from.pi/settings.json. Hermes has no guard.
The CLI accepts cc-safety-net hook --claude-code. Its supported JSON payload
includes hook_event_name: "PreToolUse", an absolute cwd, tool_name, and
tool_input. Without the event name, version 2.6.8 ignores the payload.
Allow decisions produce no output. Denials return JSON with
hookSpecificOutput.permissionDecision: "deny" and exit code 0.
The new guard denies shell reads of .env, SSH private keys, and .npmrc.
Pi also checks file-tool calls. Claude Code and Codex retain their Bash-only
cc-safety-net matchers; their independent file guards remain unchanged.
Strict parsing can deny harmless interpreter literals that contain secret-path
text. Existing v1 custom rules need migration review before use with v2.
These guards prevent accidents, not deliberate evasion.
To override a false positive, use one of these:
- Set
CC_SAFETY_NET_OFF=1. Only Claude Code and Codex processes that start after the change read the flag. Pi ignores the flag. To turn the guard off in Pi, remove the package from.pi/settings.jsonand restart the Pi session. - Run the operation through
bash .agro/scripts/git-maintenance.sh <subcommand>. The guard does not read script files.
The script-file route also lets an agent bypass the guard. cc-safety-net catches accidents. Docker is the security boundary (section 4).
4. Sandbox isolation — ENFORCED, with opt-in exceptions
Agents run in a container as the non-root sandbox user. Agent work stays off
the host filesystem and out of host user state.
Docker socket (off by default). The compose files mount no Docker socket.
access.dockerSocket: true in agro.json adds
docker-compose.docker-sock.yml,
which mounts /var/run/docker.sock. The agro sandbox install docker wizard
asks, and the default answer is no.
Warning. Socket access is host root. An agent with the socket can start a privileged container that mounts the host filesystem. With the socket mounted, the sandbox limits accidents but is not a security boundary. Leave the socket off unless the agent must drive Docker. If you need Docker and a hard boundary, use a rootless or proxied Docker daemon.
With the socket present, the entrypoint adds sandbox to the group that owns the
socket GID. On a Debian host, that group can be systemd-journal, which also
grants journal read access.
Privilege posture. The compose files use Compose init (init: true). PID 1
is docker-init, which runs sandbox-main.sh. sandbox-main.sh runs
entrypoint.sh once and stays up. The compose files grant no extra privilege:
- no added capability;
- the default AppArmor profile;
- the default cgroup namespace, with no private cgroup;
- no
tmpfsmount.
The sandbox never sets privileged: true, exposes no host device, and never
binds the host cgroup tree.
sandbox-privilege-boundary.test.ts
fails when a compose file contains privileged: true, devices:, a host cgroup
bind, cap_add:, security_opt:, or cgroup:.
VM image mode is different. A VM that boots the image runs the image CMD,
/sbin/init, as PID 1. systemd then runs agro-bootstrap.service.
Permission engine off. CLAUDE_DANGEROUSLY_SKIP_PERMISSIONS=true turns off
interactive permission prompts. The hooks in sections 2 and 3 still run.
5. Network exposure — ENFORCED defaults
The base container publishes no ports.
- sshd (opt-in).
access.ssh: trueaddsdocker-compose.ssh.yml. The default posture is a127.0.0.1bind, public-key auth,PermitRootLogin no, and password auth off. Beforeagro sandbox install dockercreates the container, a port-collision preflight refuses a port in use.agro sandbox install dockergenerates a randomSANDBOX_PASSWORD; runagro secret get SANDBOX_PASSWORDto read the generated value. You own the remaining weaker choice: a0.0.0.0bind. See SSH. - Tailscale (opt-in).
agro tool install tailscaleadds no capability and no compose change.tailscaledruns in userspace-networking mode assandbox. Nothing joins a tailnet at boot. AGRO ships no Funnel command. Never commit or print a reusable auth key. If you automate login, store the key in.env. Treat a T3 pairing URL as a secret. See Connecting → Mobile access over Tailscale.
6. Human merge gate — DOCTRINE
No agent merges its own work. The /git skill
ends the agent path at gh pr ready. A human merges. No scheduled agent opens
or merges a PR.
Configure GitHub branch protection with required reviews on development and
main. Without branch protection, the gate rests on skill text alone.
7. Untrusted model output — DOCTRINE
Treat every model output, tool output, fetched page, and recalled memory as
untrusted context, not as commands. Verify a cited file or flag before you act on
the citation. The hooks in sections 2 and 3 are the only deterministic authority.
The scope rule in AGENTS.md ("Agent work stays inside the sandbox") and the
merge gate in section 6 are doctrine. When a boundary matters, prefer a hook over
a prompt instruction.
8. Egress allowlist — ENFORCED, opt-in
access.egress defaults to open: the sandbox keeps its current, unrestricted
route out. Set access.egress: "allowlist" and agro sandbox install docker
adds docker-compose.egress.yml.
The overlay puts the sandbox on an internal: true network and adds a proxy
service on a second, egress-only network. The sandbox has no default route and
no direct path to the internet. Every outbound request must pass through the
proxy, and the proxy denies every host outside egress-allow.txt.
Default hosts. materialize() builds egress-allow.txt from one source:
the host entries under network_policies in
openshell-policy.yaml, plus
access.egressAllow. The built-in hosts are github.com, api.github.com,
release-assets.githubusercontent.com, registry.npmjs.org,
api.anthropic.com, and platform.claude.com. access.egressAllow is an
array of extra hosts in agro.json. See Configuration → Egress
allowlist.
Fix a blocked host. Run agro egress log [name] and find the denied host
in the TCP_DENIED lines. Add the host with agro config set access.egressAllow "<hosts>" --sandbox <name>. Run agro egress reload [name]. The command rewrites egress-allow.txt and sends the proxy squid -k reconfigure; the sandbox container keeps running.
The boundary. The network topology enforces the allowlist, not a rule
inside the container. The sandbox has no route to the internet other than the
proxy, and the proxy is a separate container that root in the sandbox cannot
reach or reconfigure. The proof of concept on ADR #1378 ran as root inside the
sandbox with SYS_ADMIN and found no bypass: a request without the proxy
variables failed to resolve its host, a request to a direct IP failed to
connect, and an attempt to add a default route failed with Operation not permitted.
Limits. Allowlist mode blocks git over SSH, UDP other than DNS,
host.docker.internal, cloudflared, and tailscale; none of these pass
through an HTTP(S) proxy. access.ssh (SANDBOX_SSH) does not work in
allowlist mode: the sandbox has no published port, because the sandbox sits on
an internal network with no route from the host. Allowlist mode needs Compose
2.23 or later, because the overlay's proxy configuration uses the inline
configs.content field. The overlay sets NODE_USE_ENV_PROXY=1, because a
Node.js fetch call, including the one corepack makes, ignores the proxy
variables without that setting. Allowlist mode defines no rule for each
binary, and it uses no credential injection or TLS interception. See the PRD
§ Out of Scope.
Report a vulnerability
To report a way past an ENFORCED boundary, open a GitHub issue. For a sensitive report, contact the maintainers privately first. Do not post a working exploit in public.
Related: Connecting to the sandbox, Configuration, SSH.