Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Configuration Reference

Canister uses TOML configuration files with strict schema validation. Unknown fields are rejected at parse time.

When no config file is provided (can run -- command), default policy uses proxy-only egress with strict filesystem defaults and the default seccomp baseline.

Table of Contents


Project Manifest (canister.toml)

A project manifest declares named sandboxes for a project. Instead of remembering which -r flags to pass, you define sandboxes once in canister.toml and run them with can up.

Place canister.toml in your project root (next to .git/). Canister discovers it by walking up from the current directory, similar to .gitignore.

Manifest Format

[sandbox.dev]
description = "Neovim + Elixir development"
recipes = ["neovim", "elixir", "nix"]
command = "nvim"

[sandbox.dev.filesystem]
allow_write = ["$HOME/.local/share/nvim"]

[[sandbox.dev.host]]
domain = "api.myproject.dev"

[sandbox.test]
description = "Mix test runner"
recipes = ["elixir", "nix"]
command = "mix test"

[sandbox.ci]
description = "CI — strict, no network"
recipes = ["elixir", "nix", "generic-strict"]
command = "mix test --cover"
strict = true

[sandbox.ci.resources]
memory_mb = 2048
cpu_percent = 100

[sandbox.<name>] fields:

FieldTypeRequiredDescription
descriptionstringNoHuman-readable description
recipesstring[]YesRecipe names to compose (resolved via recipe search path)
commandstringYesCommand to run (may include arguments)
strictboolNoOverride strict mode for this sandbox

Override sections:

Each sandbox can include optional override sections that merge on top of the composed recipes. These use the same schema as recipe files:

  • [sandbox.<name>.filesystem]allow, allow_write, deny
  • [sandbox.<name>.network]egress, allow_ips, ports, contract_mode
  • [[sandbox.<name>.host]] — one or more per-destination contracts (see [[host]] below)
  • [sandbox.<name>.process]max_pids, allow_execve, env_passthrough
  • [sandbox.<name>.resources]memory_mb, cpu_percent
  • [sandbox.<name>.syscalls]allow_extra, deny_extra, seccomp_mode, notifier

Overrides follow the same merge semantics as recipe composition: Vec fields are unioned, scalar fields use last-Some-wins, strict uses OR.

Validation rules:

  • At least one [sandbox.<name>] must be defined.
  • Each sandbox must have recipes = [...] with at least one entry.
  • Each sandbox must have a non-empty command.
  • Unknown fields are rejected (deny_unknown_fields).
  • Mixing absolute (allow/deny) and relative (allow_extra/deny_extra) syscall fields in a sandbox’s [syscalls] section is an error.

can up

Run a named sandbox from the manifest:

# Run the default sandbox (alphabetically first).
can up

# Run a specific sandbox by name.
can up dev
can up test
can up ci

# With CLI overrides.
can up dev --strict
can up dev --monitor
can up test -p 4000:4000

Default sandbox: When no name is given, can up uses the alphabetically first sandbox. Use descriptive names so the default is predictable (e.g., dev sorts before test).

Error handling: If canister.toml is not found, can up prints an error suggesting can run for ad-hoc use. If the named sandbox doesn’t exist, it lists available sandbox names.

Dry-Run Preview

Use --dry-run to see the fully resolved policy without running anything:

can up dev --dry-run
can up ci --dry-run

The output shows the merged result of base.toml + auto-detected recipes + manifest recipes + manifest overrides, including filesystem paths, network domains, syscall overrides, and resource limits.

Composition Order (can up)

base.toml
  → auto-detected recipes (match_prefix against command binary)
  → recipes listed in manifest (left to right)
  → manifest overrides ([sandbox.<name>.filesystem], etc.)
  = final SandboxConfig

This is the same merge chain as can run, except the explicit -r flags are replaced by the manifest’s recipes = [...] list, and manifest overrides act as the final layer.

Design note: Package manager recipes (nix, homebrew, etc.) should be listed explicitly in recipes = [...]. While auto-detection via match_prefix still works for the command binary, explicit declaration preserves the principle of least privilege — auditors can see exactly which recipes are composed by reading canister.toml.


Recipe Composition

Canister supports composing multiple recipes via repeated -r / --recipe flags. Recipes are merged left-to-right into a single resolved config.

Composition order: base.toml → auto-detected recipes → explicit --recipe args.

base.toml provides essential OS bind mounts and is always loaded first (embedded in the binary, overridable on disk). Auto-detected recipes are matched by match_prefix before explicit recipes are applied. The default.toml seccomp baseline is resolved separately by the seccomp module and is NOT part of this composition chain.

# base.toml (always) → nix.toml (auto-detected) → elixir.toml (explicit)
can run -r elixir -- mix test    # mix resolves to /nix/store/..., nix.toml auto-detected

# Explicit composition
can run -r nix -r elixir -- mix test
can run -r cargo -r generic-strict -- cargo build

Merge Semantics

When multiple recipes are merged, each field type follows a specific strategy:

Field typeStrategyExample
Vec fields (paths, domains, syscalls, env vars)Union — deduplicated, order preservedTwo recipes allowing /a and /b["/a", "/b"]
strict (Option<bool>)OR — any Some(true) wins, can never be loosenedRecipe A: strict = true, Recipe B: omitted → true
egress (Option<EgressMode>)Last-Some-winsNone preserves earlier valueRecipe A: egress = "proxy-only", Recipe B: egress = "direct"direct
seccomp_mode (Option<SeccompMode>)Last-Some-winsSame as egress
Numeric (max_pids, memory_mb, cpu_percent)Last-Some-winsRecipe A: max_pids = 64, Recipe B: max_pids = 128128
RecipeMetaOverlay — later recipe’s metadata wins if present

The “last-Some-wins” strategy means None (field not specified) preserves the value from an earlier recipe, while Some(value) overwrites it.

Name-Based Lookup

The -r argument is resolved as follows:

  1. If the argument contains / or ends with .toml, treat as a file path.
  2. Otherwise, search for <name>.toml in the recipe search path:
    • ./.canister/
    • $XDG_CONFIG_HOME/canister/recipes/
    • /etc/canister/recipes/
  3. First match wins (project-local takes precedence over user-global).
can run -r elixir -- mix test              # name lookup → elixir.toml
can run -r recipes/custom.toml -- mix test # file path
can run -r ./my-policy.toml -- echo hi     # file path (contains /)

Auto-Detection via match_prefix

Recipes can declare match_prefix patterns in their [recipe] metadata. During CLI setup (before forking), the command binary path is resolved and canonicalized. Each discovered recipe’s match_prefix is checked against the resolved path. Matching recipes are automatically merged into the chain between base.toml and explicit -r args.

This replaces the previous hardcoded detect_command_prefix() logic. Adding support for a new package manager is “write a .toml file” rather than “modify Rust code”.

Environment Variable Expansion

Recipe paths support environment variable expansion:

SyntaxExpansion
$HOMEValue of $HOME
$USERValue of $USER
${XDG_CONFIG_HOME}Value of $XDG_CONFIG_HOME
$$Literal $

Expansion applies to [filesystem].allow, [filesystem].deny, [process].allow_execve, and [recipe].match_prefix. It is performed during config resolution (after merge, before the sandbox uses the paths).

[filesystem]
allow = ["$HOME/.cargo", "$HOME/.rustup", "$HOME/project"]

[recipe]
match_prefix = ["$HOME/.cargo"]

[recipe] (metadata)

Optional metadata section for recipe files. Not used for policy enforcement but controls recipe discovery and composition behavior.

FieldTypeDefaultDescription
namestring (optional)Human-readable recipe name
descriptionstring (optional)Short description shown by can recipe list
match_prefixstring[][]Path prefixes for auto-detection (env vars expanded)
[recipe]
name = "nix"
description = "Nix package manager (/nix/store)"
match_prefix = ["/nix/store"]

[filesystem]

Controls what the sandboxed process can see and access on the filesystem.

When filesystem isolation is active (requires a MAC policy on Ubuntu 24.04+ and Fedora 41+), the sandbox starts with an empty tmpfs root. Only explicitly allowed paths and essential system paths are bind-mounted read-only.

FieldTypeDefaultDescription
allowstring[][]Paths to bind-mount read-only into the sandbox
denystring[][]Paths explicitly denied (checked before allow)

Behavior:

  • Deny rules take precedence over allow rules.
  • Paths are matched by prefix: allowing /usr/lib also allows /usr/lib/python3.
  • Essential paths are defined in recipes/base.toml (embedded in the binary, overridable on disk) and always bind-mounted: /bin, /sbin, /usr/bin, /usr/sbin, /lib, /lib64, /usr/lib, /etc.
  • Auto-detection: When the command binary lives outside standard FHS paths (e.g., installed via Nix, Homebrew, Cargo, etc.), Canister auto-detects the appropriate package manager recipe via match_prefix and merges it into the recipe chain, bringing the necessary mount paths automatically. See Auto-Detection via match_prefix.
  • When filesystem isolation is blocked (MAC system blocks mounts), the sandbox aborts. Run sudo can setup to install the security policy (use --force to reinstall if the policy is outdated).
[filesystem]
allow = ["/usr/lib", "/usr/bin", "/tmp/workspace", "/home/user/data"]
deny  = ["/etc/shadow", "/etc/passwd", "/root", "/home/user/.ssh"]

Package Manager Support

When the command binary is installed outside standard system paths, Canister uses recipe-based auto-detection to ensure the binary is visible inside the sandbox. Each package manager has a recipe with match_prefix patterns:

RecipeAuto-detects when binary is underMounts
nix.toml/nix/store/nix/store (read-only)
homebrew.toml/opt/homebrew, /home/linuxbrew/.linuxbrewThe matching prefix
cargo.toml$HOME/.cargo, $HOME/.rustup$HOME/.cargo, $HOME/.rustup
snap.toml/snap/snap
flatpak.toml/var/lib/flatpak, $HOME/.local/share/flatpakThe matching prefix
gnu-store.toml/gnu/store/gnu/store

How it works:

  1. The command path is canonicalized (all symlinks resolved) at startup.
  2. Each discovered recipe’s match_prefix is checked against the resolved path.
  3. Matching recipes are merged into the composition chain, bringing their [filesystem].allow paths, [process].allow_execve entries, and any other policy fields.
  4. For content-addressed stores like /nix/store, the entire tree is mounted. Binaries reference sibling store entries freely, making individual-entry mounting impractical.

Security note: Auto-detection makes the prefix visible inside the sandbox but does not grant execution permission. The [process] allow_execve list independently controls what binaries can be executed. Package manager recipes include allow_execve prefix rules (e.g., /nix/store/*) to authorize execution within the mounted tree.

Adding a new package manager: Create a new .toml recipe with appropriate match_prefix, [filesystem].allow, and [process].allow_execve entries. No Rust code changes needed.


[network]

Controls network access. Secure by default: all network access is denied unless explicitly allowed.

FieldTypeDefaultDescription
egress"proxy-only" | "none" | "direct""proxy-only"Outbound networking mode
allow_ipsstring[][]Allowed IPs or CIDR ranges (IPv4 and IPv6)
portsstring[][]Port forwarding specs ([ip:]hostPort:containerPort[/protocol])
contract_mode"strict" | "relaxed""strict"Default for hosts without a [[host]] block. strict refuses; relaxed allows + logs.

FQDN egress goes through the top-level [[host]] table, not this section. Each [[host]] block names a domain and the request shapes accepted on it; see that section for the full schema.

Network mode determination:

The effective egress mode determines isolation behavior:

egressModeDescription
noneNoneNo outbound network. Empty network namespace, loopback only.
proxy-onlyFilteredOutbound traffic must go through local proxy (kernel-enforced).
directFull/FilteredDirect outbound. If allowlists/ports are set, filtered mode is used for policy checks; otherwise full host network namespace.

Specifying ports automatically upgrades None mode to Filtered mode (port forwarding requires a functional network namespace with pasta).

Domain matching:

Domains are matched including subdomains. Allowing pypi.org also allows files.pythonhosted.org if listed, but does not automatically allow subdomains of pypi.org. Each domain must be listed explicitly.

IP/CIDR matching:

IPs support both exact match and CIDR notation:

[network]
allow_ips = [
    "93.184.216.34",        # exact IPv4
    "10.0.0.0/8",           # IPv4 CIDR
    "2606:2800:220:1::/64", # IPv6 CIDR
]

Filtered mode requirements:

Filtered mode requires pasta (from the passt project) installed on the host:

sudo apt install passt       # Debian/Ubuntu
sudo dnf install passt       # Fedora

In filtered mode, pasta mirrors the host’s network configuration into the sandbox namespace. pasta copies the host’s real IP addresses and routes into the namespace. DNS is handled via a link-local address:

AddressRole
Host’s default gatewayGateway
169.254.0.1DNS server (link-local, pasta --dns)
Host’s real IPSandbox IP (mirrored from host)
[network]
egress = "proxy-only"
[[host]]
domain = "pypi.org"

[[host]]
domain = "files.pythonhosted.org"

[[host]]
domain = "registry.npmjs.org"

Port forwarding (ports):

Port forwarding uses Docker/Podman-compatible syntax and is specified via the -p / --port CLI flag or the ports config field:

# CLI usage
can run -p 8080:80 -- my-server
can run -p 127.0.0.1:3000:3000 -p 5432:5432/tcp -- my-app
# Config usage
[network]
egress = "proxy-only"
ports = ["8080:80", "127.0.0.1:3000:3000", "5353:53/udp"]

Syntax: [ip:]hostPort:containerPort[/protocol]

ComponentRequiredDefaultDescription
ipNo0.0.0.0Host IP to bind (e.g., 127.0.0.1)
hostPortYesPort on the host
containerPortYesPort inside the sandbox
protocolNotcptcp or udp

[[host]]

Per-destination egress contract. One [[host]] block per FQDN you allow the sandbox to reach. The block answers every question about that upstream in one place: connect permission, the request shapes that are legitimate, and which DLP detectors may carry verdicts on this host as Warn instead of Block.

There is no separate connect-permission list. Having a [[host]] block at all is the permission to dial; the block’s other fields tighten what’s allowed from there. The minimum block is one line (domain = "x") — equivalent to “allow this host, any shape.”

# Minimum-viable allow.
[[host]]
domain = "static.example.com"

# Full picture for a service we care about.
[[host]]
domain             = "api.github.com"
methods            = ["GET", "POST", "PATCH", "PUT", "DELETE"]
content_types      = ["application/json", "application/vnd.github+json"]
paths              = ["/repos/", "/user/", "/orgs/"]
max_request_bytes  = 1_048_576                       # 1 MiB
allow_credentials  = ["github_pat"]                  # downgrade github_pat hits to Warn here

# Per-host escape hatch.
[[host]]
domain        = "weird-tool.corp.internal"
contract_mode = "relaxed"

Fields

FieldTypeDefaultDescription
domainstringrequiredFQDN this block applies to. Wildcards (*.github.com) match one or more subdomain levels; bare domains match exact + any subdomain. Most-specific match wins.
methodsstring[][]Allowed HTTP methods (case-insensitive). Empty = any.
content_typesstring[][]Allowed request Content-Type values (matched on mime/subtype portion; ; charset=... parameters are ignored). Empty = any.
pathsstring[][]Path prefixes the request URI must start with. Empty = any.
max_request_bytesu64unsetPer-host request body cap. Applies after the global max_streamed_body_bytes.
allow_credentialsstring[][]DLP detector ids whose verdicts on this host downgrade from Block to Warn (e.g. ["github_pat"] means the worker may legitimately carry a github PAT in Authorization to this host).
contract_mode"strict" | "relaxed"inherit [network] contract_modePer-host override of the global default. Only affects the unknown-host decision once you’re inside this block; field-level checks still run.

Multiple [[host]] entries with the same domain merge by: union on vec fields, max for max_request_bytes, last-Some-wins for contract_mode. A project recipe can extend (never silently restrict) a canister-shipped contract by writing another [[host]] with the same domain.

Refusal behaviour

If a request reaches the proxy with a destination that has no matching [[host]] block, the gate decides based on [network] contract_mode:

  • strict (default) — refuse with 415 (or 413 for body-size), x-canister-error: contract-refused, and a response body that carries the exact [[host]] patch to paste into canister.toml to allow this exact shape.
  • relaxed — allow the request but emit an unknown_host_contract tracing event. Intended for prototyping where the upstream set isn’t known up front.

See docs/refusals.md for the operator-facing walkthrough (415 vs 451, how to read the patch, escape hatches).

Shipped service contracts

Canister ships contracts for the upstreams workers most commonly hit under recipes/services/: github.toml, openai.toml, anthropic.toml, npm.toml, pypi.toml, huggingface.toml, docker.toml, aws.toml, stripe.toml, slack.toml. Compose them with -r service:github (etc.) or via a project manifest.


[network.dlp]

Data Loss Prevention layer running inside the L7 egress proxy. Scans outbound HTTP traffic for credential patterns (GitHub PATs, npm tokens, AWS keys, Slack tokens, SSH private keys, generic bearer tokens) and enforces per-host credential scoping via the allow_credentials field on each [[host]] block — a GitHub PAT bound for registry.npmjs.org will be blocked even though both hosts are reachable. See DLP for the full threat model and detector list.

DLP only runs when traffic is inspectable, i.e. when network.egress = "proxy-only".

[network.dlp]
enabled = true
canary_tokens = true              # default when DLP is enabled
max_decode_depth = 32             # base64/hex/percent recursion cap
decompress = true                 # gzip/deflate/brotli before scan
dns_entropy_threshold = 4.5       # Shannon entropy per DNS label
session_entropy_budget = 8192     # cumulative high-entropy bytes/session

# Extend credential scope by adding allow_credentials on the host:
[[host]]
domain            = "github.corp.example.com"
methods           = ["GET", "POST", "PATCH", "PUT", "DELETE"]
content_types     = ["application/json"]
allow_credentials = ["github_pat"]

Fields

FieldTypeDefaultDescription
enabledboolfalseEnable DLP scanning. Implicitly true under --strict when egress = "proxy-only".
canary_tokensbooltrue when DLP enabledInject fake credentials into the sandbox env and treat any outbound appearance as exfiltration.
max_decode_depthusize32Encoding chain recursion depth (base64 / hex / percent).
decompressbooltrueInflate gzip / deflate / brotli bodies before scanning.
dns_entropy_thresholdf644.5Shannon entropy per DNS label above which the hostname is blocked.
session_entropy_budgetu648192Cumulative high-entropy bytes allowed across one sandbox session before further requests are blocked.

Credential-flow scope is configured per host via the allow_credentials field on [[host]].

Built-in scopes

Each detector has a baseline list of home domains hardcoded in the detector registry — destinations where it’s universally legitimate for that credential type to flow:

DetectorBuilt-in home domains
github_patgithub.com, *.github.com
npm_tokenregistry.npmjs.org
aws_access_key*.amazonaws.com
slack_token*.slack.com
bearer_token(none — requires explicit allow_credentials = ["bearer_token"] on the host)
ssh_private_key, canary_token(none — always block)
generic_high_entropy(warn only, block in --strict)

Add to this set per-host via allow_credentials on the relevant [[host]] block. Built-in lists are never narrowed.

Merge semantics

FieldMerge rule
enabledOR — any Some(true) wins (security escalation, never reversed)
canary_tokensOR
max_decode_depth, decompress, dns_entropy_threshold, session_entropy_budgetlast-Some-wins

A downstream recipe can never disable DLP that an upstream recipe enabled.

Interaction with --strict / --monitor

  • --strict implicitly enables DLP (when egress = "proxy-only") and promotes generic_high_entropy from warn to block.
  • --monitor logs DLP findings at warn! level but forwards the request, adding an x-canister-dlp-warning header so the sandboxed process can observe what would have been blocked.

On block, the proxy returns 451 Unavailable For Legal Reasons with headers x-canister-error: dlp-blocked and x-canister-dlp-detector: <name>.


[process]

Controls process creation, environment filtering, and executable restrictions.

FieldTypeDefaultDescription
max_pidsint (optional)noneMaximum number of processes (via RLIMIT_NPROC)
allow_execvestring[][]Executables the sandbox may exec (empty = allow all)
env_passthroughstring[][]Environment variables to pass from host (all others stripped)

PID namespace isolation:

Every sandboxed process runs in its own PID namespace. The sandboxed command becomes PID 1 and cannot see or signal any host processes.

Environment filtering:

When env_passthrough is empty, the sandbox starts with a completely clean environment — zero host environment variables are inherited. This is the most secure default.

When env_passthrough contains variable names, only those variables are kept. A minimal PATH=/usr/local/bin:/usr/bin:/bin is injected if PATH is not in the passthrough list.

max_pids enforcement:

Uses RLIMIT_NPROC to cap the number of processes. When exceeded, fork() returns EAGAIN. This is a per-UID limit, which is effective inside the sandbox’s user namespace (where the process runs as UID 0 mapped to the host user).

allow_execve validation:

When non-empty, the resolved command path must match one of the listed paths. If the command is not in the allow list, execution is rejected before forking.

Prefix rules: Entries ending in /* match any binary under that directory tree. For example, /nix/store/* allows any binary whose resolved path starts with /nix/store/. The match requires a / boundary — /nix/store-extra/foo does NOT match /nix/store/*. This is essential for content-addressed stores like Nix where binary paths contain unpredictable hashes.

Ongoing enforcement: When the USER_NOTIF supervisor is active (kernel 5.9+, default), every execve() and execveat() call inside the sandbox is intercepted and validated against allow_execve. This means child processes cannot exec arbitrary binaries. When the notifier is disabled (kernel < 5.9 or notifier = false), only the initial command is validated, and child processes can exec any binary visible in the mount namespace.

[process]
max_pids = 64
allow_execve = ["/usr/bin/python3", "/usr/bin/pip", "/nix/store/*"]
env_passthrough = ["PATH", "HOME", "LANG", "TERM", "VIRTUAL_ENV"]

[resources]

Resource limits enforced via cgroups v2. Requires systemd with per-user cgroup delegation (default on most modern distributions).

Opt-in: Resource limits are not included in any of the shipped base recipes. They are entirely opt-in — add memory_mb and/or cpu_percent to your own recipe when needed.

FieldTypeDefaultDescription
memory_mbint (optional)noneMemory limit in megabytes
cpu_percentint (optional)noneCPU limit as percentage of one core (e.g., 50 = 50%)

How it works:

Canister detects the current cgroup from /proc/self/cgroup, creates a child cgroup (canister-<pid>), writes memory.max and cpu.max, and moves the sandboxed process into it. No root required.

  • memory_mb = 512memory.max = 536870912 (512 MiB). Exceeding the limit triggers the kernel OOM killer.
  • cpu_percent = 50cpu.max = "50000 100000" (50ms quota per 100ms period), capping the process to 50% of one CPU core.

Failure behavior: If cgroup setup fails (e.g., no cgroup v2, no delegation), the sandbox aborts. In strict mode (--strict), seccomp uses KILL_PROCESS for immediate termination on any denied syscall.

[resources]
memory_mb = 512
cpu_percent = 100

[syscalls]

Customizes the seccomp BPF baseline and enforcement mode.

Canister ships a single default seccomp baseline defined in recipes/default.toml (~187 allowed syscalls, ~18 always-denied). The baseline is embedded in the binary at compile time and can be overridden by placing a default.toml in the recipe search path (./.canister/, $XDG_CONFIG_HOME/canister/recipes/, /etc/canister/recipes/).

Regular recipes customize the baseline by adding or removing syscalls with allow_extra / deny_extra. The baseline itself uses allow / deny (absolute lists). These two pairs are mutually exclusive — a recipe either IS the baseline (uses allow/deny) or EXTENDS it (uses allow_extra/deny_extra).

Override fields (for regular recipes)

FieldTypeDefaultDescription
seccomp_modestring"allow-list"Seccomp mode: "allow-list" (default deny) or "deny-list" (default allow)
allow_extrastring[][]Syscalls to add to the baseline allow list
deny_extrastring[][]Syscalls to add to the deny list (also removed from allow list)
notifierbool (optional)auto-detectEnable/disable the USER_NOTIF supervisor for argument-level syscall filtering

Absolute fields (for default.toml only)

FieldTypeDefaultDescription
allowstring[][]Complete allow list (replaces the baseline, not additive)
denystring[][]Complete deny list (replaces the baseline, not additive)

Mutual exclusion: Using allow or deny together with allow_extra or deny_extra in the same [syscalls] section is a validation error.

Seccomp modes:

ModeDefault actionListed syscallsUse case
allow-listDENY allOnly baseline + allow_extra syscalls permittedProduction, CI (recommended)
deny-listALLOW allOnly baseline deny + deny_extra syscalls blockedCompatibility, unknown workloads

Examples:

# Elixir/BEAM: needs ptrace for observer/dbg/recon
[syscalls]
allow_extra = ["ptrace"]

# Strict: also block personality for extra hardening
[syscalls]
deny_extra = ["personality"]

# Full override: add io_uring support
[syscalls]
allow_extra = ["ptrace", "personality", "seccomp", "io_uring_setup", "io_uring_enter", "io_uring_register"]

# Deny-list mode for maximum compatibility
[syscalls]
seccomp_mode = "deny-list"

See SECCOMP.md for details on the baseline syscall set and how the embed+override resolution works.

USER_NOTIF supervisor (notifier)

The notifier field controls the SECCOMP_RET_USER_NOTIF supervisor, which provides argument-level filtering for connect(), clone()/clone3(), socket(), execve(), and execveat().

ValueBehavior
trueForce the notifier on (fails if kernel < 5.9)
falseForce the notifier off
omittedAuto-detect: enabled if kernel >= 5.9 and not in monitor mode

When the notifier is active, connect() calls are filtered against the resolved IPs from each [[host]].domain and allow_ips, clone()/clone3() are blocked from creating new namespaces, socket() is blocked from creating AF_NETLINK or SOCK_RAW sockets, and execve()/execveat() are validated against allow_execve paths for every execution (not just the initial command).

The notifier is merged using the last-Some-wins strategy during recipe composition, consistent with other Option<bool> scalar fields.

# Disable the notifier for compatibility with older kernels
[syscalls]
notifier = false

# Force it on (fail loudly if not supported)
[syscalls]
notifier = true

See SECCOMP.md for the full technical description.


[proxy]

L7 proxy settings used by proxy-only egress mode.

[proxy]
max_buffered_body_bytes = 8388608   # 8 MiB (default)
upstream_request_timeout_ms = 30000  # 30 s (default)

Fields

FieldTypeDefaultDescription
max_buffered_body_bytesusize8388608Max bytes buffered for DLP body scanning
upstream_request_timeout_msu6430000Upstream request timeout in milliseconds

Enforcement semantics

When network.egress = "proxy-only":

  • sandboxed processes may only open outbound INET/INET6 connections to:
    • loopback proxy endpoint (127.0.0.1:<proxy_port> / ::1:<proxy_port>)
    • configured DNS server on port 53
  • direct outbound internet access is denied by seccomp USER_NOTIF policy even if HTTP_PROXY/HTTPS_PROXY env vars are unset.

This makes seccomp-notify the first-line defense and proxy the forwarding path for legitimate traffic.


Strict Mode

Strict mode (--strict or strict = true in config) tightens all enforcement for CI and production use.

Config:

strict = true

CLI:

can run --strict --recipe policy.toml -- python3 script.py

The CLI --strict flag can only tighten — if the config sets strict = true, the CLI cannot override it to false.

Changes in strict mode:

Enforcement pointNormal modeStrict mode
Filesystem isolationAborts on failureAborts on failure
Network setupAborts on failureAborts on failure
Loopback bring-upAborts on failureAborts on failure
Seccomp deny actionEPERM (process survives)KILL_PROCESS (immediate termination)
Cgroup setupAborts on failureAborts on failure

The key difference is the seccomp deny action: normal mode returns EPERM so the process can handle denials gracefully; strict mode kills the process immediately on any denied syscall.

Mutual exclusion: --strict and --monitor cannot be used together. Strict mode ensures full enforcement; monitor mode relaxes it. These are contradictory intents.


Monitor Mode

Monitor mode (--monitor) is a CLI flag, not a config field. It relaxes enforcement across all policy sections so you can observe what would be blocked without actually blocking it.

can run --monitor --recipe my_policy.toml -- python3 script.py

What changes in monitor mode:

SectionNormalMonitor
[process].allow_execveBlocks unlisted commandsLogs warning, allows
[process].env_passthroughStrips unlisted varsLogs stripped count, passes all
[process].max_pidsEnforces RLIMIT_NPROCLogs limit, skips enforcement
[syscalls] seccompReturns EPERM on denied syscallsLogs to kernel audit, allows
[filesystem]Overlay + pivot_rootUnchanged (isolation active)
[network]Namespace + pastaUnchanged (isolation active)

Reading monitor output:

  • Look for MONITOR: prefixed log lines in stderr.
  • Seccomp events appear in kernel logs: journalctl -k | grep seccomp.
  • A pre-run policy preview and post-run summary are printed automatically.

Monitor mode is a development tool. It provides no security guarantees. Cannot be combined with --strict.


Inspecting the Resolved Policy

Use can recipe show to see the fully resolved policy after all recipe merging and environment variable expansion:

# Show the base policy (no recipes)
can recipe show

# Show the resolved policy with a recipe
can recipe show -r elixir

# Show with auto-detection (pass the command to trigger match_prefix)
can recipe show -r elixir -- mix test

# Compose multiple recipes and see the result
can recipe show -r nix -r elixir

# Save to a standalone recipe file
can recipe show -r nix -r elixir > my-custom.toml
can run -r my-custom.toml -- mix test

The output is valid TOML and includes all resolved fields:

strict = false

[filesystem]
allow = ["/bin", "/sbin", "/usr/bin", ...]
deny = ["/etc/shadow", "/etc/gshadow"]

[network]
[[host]]
domain = "hex.pm"

[[host]]
domain = "repo.hex.pm"

[[host]]
domain = "builds.hex.pm"
egress = "proxy-only"

[process]
allow_execve = ["/nix/store/*"]
env_passthrough = ["PATH", "HOME", ...]

[resources]

[syscalls]
seccomp_mode = "allow-list"
allow_extra = ["ptrace"]

This serves two purposes:

  1. Auditing — see exactly what policy will be enforced before running.
  2. Standalone recipes — capture the output and use it as a custom recipe that doesn’t depend on any other recipe files.

Examples

Minimal: deny everything

No config file needed. This is the default.

can run -- echo "hello"

Equivalent to:

[filesystem]
[network]
egress = "none"
[syscalls]

Python data science

Allow pip installs from PyPI and access to a workspace directory.

[filesystem]
allow = [
    "/usr/lib",
    "/usr/bin",
    "/usr/local/lib",
    "/tmp/workspace",
]
deny = ["/etc/shadow", "/root"]

[network]
egress = "proxy-only"
[[host]]
domain = "pypi.org"

[[host]]
domain = "files.pythonhosted.org"
[process]
env_passthrough = ["PATH", "HOME", "LANG", "VIRTUAL_ENV"]

Node.js build

Allow npm registry access and a project directory.

[filesystem]
allow = [
    "/usr/lib",
    "/usr/bin",
    "/usr/local",
    "/home/user/project",
]

[network]
egress = "proxy-only"
[[host]]
domain = "registry.npmjs.org"

[[host]]
domain = "nodejs.org"
[process]
env_passthrough = ["PATH", "HOME", "NODE_ENV"]

Full network trust

For trusted code that needs unrestricted network access but should still be filesystem- and syscall-restricted.

[filesystem]
allow = ["/tmp/workspace"]

[network]
egress = "direct"

Air-gapped

No network, no filesystem beyond essentials, strict seccomp.

[filesystem]
allow = ["/tmp/workspace"]
deny  = ["/etc", "/root", "/home"]

[network]
egress = "none"

Strict CI

All-or-nothing enforcement. If any isolation layer can’t be set up, the sandbox refuses to start. Denied syscalls kill the process immediately.

strict = true

[filesystem]
allow = ["/tmp/workspace"]

[network]
egress = "none"

[process]
max_pids = 64
allow_execve = ["/usr/bin/python3"]

[resources]
memory_mb = 512
cpu_percent = 100

[syscalls]
seccomp_mode = "allow-list"

Elixir/Erlang (mix tasks, iex, Phoenix)

Run mix tasks, iex shells, or Phoenix servers with hex.pm access. Use with -r nix or -r homebrew if Elixir is installed via a package manager.

[recipe]
name = "elixir"
description = "Elixir/Erlang (BEAM VM) — mix, iex, Phoenix"

[filesystem]
allow = [
    "/usr/lib",
    "/usr/bin",
    "/usr/local/lib",
    "/usr/local/bin",
    "/lib",
    "/tmp/workspace",
]
deny = ["/etc/shadow", "/root"]

[network]
[[host]]
domain = "hex.pm"

[[host]]
domain = "repo.hex.pm"

[[host]]
domain = "builds.hex.pm"
egress = "proxy-only"

[process]
max_pids = 256
env_passthrough = [
    "PATH", "HOME", "LANG", "TERM",
    "MIX_ENV", "MIX_HOME", "HEX_HOME",
    "ERL_AFLAGS", "ELIXIR_ERL_OPTIONS",
    "SECRET_KEY_BASE", "DATABASE_URL", "PORT",
]

[syscalls]
allow_extra = ["ptrace"]   # BEAM tracing tools (:observer, :dbg, recon)

Usage with composition:

# Nix-installed Elixir: nix.toml auto-detected, elixir.toml explicit
can run -r elixir -- mix test

# Explicit composition
can run -r nix -r elixir -- mix test

# Strict CI
can run --strict -r elixir -- mix test