Skip to content

Sandbox Overview

runok can execute allowed commands inside a sandbox that restricts file system writes and network access. Sandboxing is applied at the OS level using platform-native mechanisms (macOS Seatbelt or Linux Landlock + seccomp), so sandboxed processes cannot bypass the restrictions.

See Security Model for trust boundaries and design rationale.

Sandbox policies are defined as named presets under definitions.sandbox in your runok.yml:

definitions:
sandbox:
restricted:
fs:
read:
deny:
- '~/.ssh'
- '~/.gnupg'
write:
allow:
- '.'
deny:
- '.git'
- '.runok'
network:
allow: false

Each preset contains two sections:

The fs section controls both read and write access through read and write sub-sections:

FieldTypeDescription
allowstring[]Directories the sandboxed process can write to. Paths support ~ expansion.
denystring[]Paths the sandboxed process cannot write to, even within writable directories. Supports glob patterns (*, **, ?, [...], {a,b}) and <path:name> references.

The deny list always takes priority over allow. For example, if allow includes "." (the current directory) and deny includes ".git", the process can write anywhere in the project except the .git directory.

FieldTypeDescription
denystring[]Paths the sandboxed process cannot read. Supports glob patterns and <path:name> references.

fs.read.deny blocks read access to the listed paths. Since write access requires read access, these paths become completely inaccessible to the sandboxed process.

FieldTypeDescription
allowboolWhether the sandboxed process can make network connections. Defaults to true if omitted.

When network.allow is false, TCP/UDP sockets are blocked. Unix domain sockets (used for local IPC) are always permitted regardless of this setting.

You can define reusable path lists under definitions.paths and reference them in sandbox deny lists:

definitions:
paths:
sensitive:
- '.env*'
- 'credentials.*'
secrets:
- '~/.ssh/**'
- '~/.gnupg/**'
sandbox:
restricted:
fs:
read:
deny:
- '<path:secrets>'
write:
deny:
- '<path:sensitive>'

The <path:secrets> reference expands to all paths listed under definitions.paths.secrets. References can be used in both read.deny and write.deny. See Path References and definitions.paths for details.

Attach a sandbox preset to any allow or ask rule with the sandbox field:

rules:
- allow: 'python3 *'
sandbox: restricted
- allow: 'npm test'
sandbox: restricted

Use defaults.sandbox to apply a sandbox preset to all rules that do not specify one explicitly:

defaults:
sandbox: restricted
rules:
- allow: 'git status' # uses "restricted" sandbox
- allow: 'cargo build *'
sandbox: build-env # overrides with "build-env"

The Claude Code hook applies a sandbox to a compound command like cmd1 && cmd2 sub-command by sub-command: each sub-command that matches a sandboxed rule runs under exactly the preset its own rule named — runok replaces that sub-command’s own text (its KEY=VALUE env-assignment prefix and its own redirects included) with runok exec --sandbox <preset> -- '<sub-command>', shell-quoted, and returns the rewritten string via updatedInput. Only the pipe, &&, ||, and ; that join the sub-commands together are left outside every sandbox. A sub-command whose rule names no sandbox runs unsandboxed, even next to one that does.

runok exec and runok check evaluate one command string at a time and have no per-sub-command shell to hand a rewritten string back to, so they always evaluate the whole compound command against a single, merged sandbox policy instead. The Claude Code hook falls back to that same merged policy, wrapping the entire command in one sandbox, when a sub-command that needs a sandbox can’t be wrapped on its own: it has no byte range of its own in the input (the case after a parse failure), or its range is nested inside another sub-command’s range (as with command substitution $(...) or process substitution <(...)), or it’s a builtin that changes the calling shell’s own state (cd, export, source, and others — see Compound Commands — Fallback: merged sandbox policy for the full list), or it’s a call to a shell function defined in the same input, which the child process would not have. A sub-command that doesn’t itself need a sandbox never triggers this fallback, regardless of its byte range — but one nested inside a sandboxed sub-command’s range is still wrapped along with it, and so runs under that sub-command’s preset.

The merged policy is built from every matched preset with the Strictest Wins rule:

FieldMerge strategyEffect
write.allowIntersectionOnly directories allowed by all policies remain writable
write.denyUnionWrite-denied paths from any policy are protected
read.denyUnionRead-denied paths from any policy are protected
network.allowANDNetwork is blocked if any policy denies it

This ensures that, even when the merged policy applies, a less-restricted command in a pipeline cannot weaken the restrictions of a more-restricted command.

If every sub-command that specifies a sandbox names the same preset (after deduplication), that preset is applied directly instead of a merged policy — the same way a non-compound command’s preset is applied, including under defaults.action: pass. Merging across two or more distinct presets is represented as repeated --sandbox flags instead: the hook writes updatedInput as runok exec --sandbox <preset-a> --sandbox <preset-b> -- '<command>', and runok exec resolves and merges the named presets itself. This applies for allow, ask, and pass alike — a pass resolution is no longer escalated to ask just because two or more distinct presets are involved.

If the intersection of writable roots in the merged policy becomes empty (a contradiction), runok escalates the action to ask so the user can decide whether to proceed. runok exec and runok check always evaluate against the merged policy, so this escalation happens whenever the writable intersection is empty, independent of whether the Claude Code hook was also able to wrap every sub-command that needs one on its own.

See Compound Commands — Sandbox policy aggregation for the full mechanics and worked examples.

definitions:
paths:
sensitive:
- '.env*'
- 'credentials.*'
secrets:
- '~/.ssh/**'
- '~/.gnupg/**'
sandbox:
workspace-write:
fs:
read:
deny:
- '<path:secrets>'
write:
allow:
- '.'
deny:
- '.git'
- '.runok'
- '<path:sensitive>'
network:
allow: true
no-network:
fs:
write:
allow:
- '.'
deny:
- '.git'
- '.runok'
network:
allow: false
defaults:
sandbox: workspace-write
rules:
- allow: 'git *'
- allow: 'python3 *'
sandbox: no-network
- allow: 'npm test'
sandbox: no-network
- deny: 'rm -rf /'