Skip to main content
Sandboxed Tool Execution lets a profile turn specific commands into fine-grained brokered tool executions. The supervisor acts as the Capability Broker and launches the tool into a fresh child sandbox built only from the selected command policy and its lifecycle. This means a single tool, such as git, kubectl, or curl, can be scoped with specific filesystem, network, and credential grants. The tool can be chained to a second selected tool with a separate policy. The second tool can be chained to a third, and so on. For commands that should not run at all, see Dangerous Command Blocking. That page covers deny-only Tool Sandbox command-control entries and inherited dangerous_commands behavior.

Mental Model

A Tool Sandbox profile answers three questions: command_policies.entrypoint is still parsed for older profiles, but current profiles should express direct session access with commands.<name>.sandbox or commands.<name>.from.session. Each child command starts from a minimal runtime baseline. It does not inherit outer --allow, CWD access, broad profile groups, raw credential paths, or outer network access. When Tool Sandbox is active, the outer session’s execute grant is built from every trusted directory on PATH: nono scans each non-writable PATH directory and allow-lists every executable file found there, plus its shared-library dependency closure — not just the initial program and the Tool Sandbox shims. A binary that lives outside PATH (for example a JVM’s jspawnhelper under /usr/lib/jvm/.../lib/) is not covered by this scan and is denied unless it’s reachable through PATH, added to command_policies.executable_dirs, or modeled as a command-policy hop with its own sandbox.exec_paths entry. Tool Sandbox’s real security boundary is at policy-controlled commands, not at execute-vs-no-execute for the outer session: once a command becomes a Tool Sandbox entry, everything it invokes is governed by can_use/from edges regardless of PATH membership.

Available Fields

Tool Sandbox configuration lives under command_policies. A minimal shape looks like this:
The top-level command_policies object accepts these fields: Each entry under approval_backends is one of: Each entry under commands uses the command name as the key: from entries accept three value shapes: The selected sandbox object describes what the child receives:

Per-Intercept Sandbox Override

An intercept rule may carry an optional sandbox (the same object described above). When the rule matches, the process the rule launches runs under the rule’s sandbox instead of the command’s selected sandbox — for that matched invocation only. This applies to every action that launches a process (passthrough, capture, capture_credential, and an approved approve); it does not apply to respond, which returns static output and launches nothing (a sandbox there is rejected at validation). Credentials resolve lazily, so an override that omits credentials/use_credentials injects no credential env vars for that invocation. The override completely replaces the command’s selected sandbox for that invocation — it is validated for well-formedness (known credentials, valid paths, etc.) but is not checked against the command’s base sandbox, so it is not restricted to narrowing. Author it as carefully as the command’s base sandbox; it can grant capabilities the base sandbox does not.

Intercept Actions

Each intercept entry selects an invocation with either legacy args or an explicit match predicate, then applies an action. Rules are evaluated in order; the first match wins. Legacy args is a contiguous argument sequence matched within the invocation’s arguments (an empty args is a catch-all). This lets subcommand rules match after command-global options, such as git -c foo=bar push --force. Broad single-argument rules like ["--force"] match anywhere in the invocation, not only at the subcommand position. Use match when a rule needs an explicit predicate instead of a literal args window. match.argv accepts the same matcher shapes as invocation_policy: exact, prefix, or contains. match.env checks environment variables after Tool Sandbox environment filtering, so profiles can mediate out-of-band behavior such as GIT_SSH_COMMAND without matching raw host environment state.
match is still a predicate over the argv and env that nono observes. It does not perform command-specific semantic normalization such as --flag=value versus --flag value, short versus long aliases (-f versus --force), repeated or duplicate flag folding, flag-order canonicalization, or git-specific parsing. It also does not expand environment references embedded inside argv tokens. If a shell expands $FOO before exec, nono sees the expanded argv; if a caller passes the literal token $FOO, nono matches that literal token. The action.type selects how the matched subcommand is handled: exec is for handling one subcommand with a custom helper while the command’s other subcommands run normally — for example, intercepting an auth switch subcommand with a helper that rewrites a config file, while api calls pass through with their brokered credentials. The helper runs with the matched command’s real credentials, capabilities, and sandbox — it is not itself sandboxed further. Do not point exec at a general-purpose shell (/bin/sh, /bin/bash, etc.): it would let the caller run arbitrary commands with those credentials, defeating the sandbox. Use a purpose-specific helper that only does what the intercepted subcommand needs.

Dynamic Filesystem Grants

fs_read, fs_write, fs_read_file, and fs_write_file can contain dynamic provider tokens as well as literal paths. Tokens are expanded at launch before the child sandbox is built. They are opt-in: no dynamic paths are added unless a selected command policy includes one of these tokens. Supported tokens: Example:
The Git provider reads git config --list --show-origin --show-scope and keeps only global and system scoped paths. It ignores local and worktree scopes so a repository cannot grant itself additional host filesystem access through .git/config. If git is unavailable or returns no matching paths, the token expands to no paths. The @git:common-dir token runs git rev-parse --git-common-dir from nono’s working directory at sandbox-prepare time. In a regular repo it returns .git (relative, resolved against $WORKDIR). In a worktree it returns the absolute path to the main repo’s .git. If git is unavailable or the process is not inside a repo, the token expands to no paths. The @git:worktree, @git:toplevel, and @git:toplevel-parent tokens are derived from the same git invocations and behave the same way when git is unavailable. @git:fsmonitor-socket is resolved without spawning git: it walks the filesystem from the current working directory to find the checkout (reading .git, then following a gitdir:/commondir pointer file when present) and appends fsmonitor--daemon.ipc to the private per-worktree git-dir. This mirrors the no-process-spawn approach the other path-resolution tokens use internally, so a working directory with an attacker-controlled .git/config cannot influence resolution through core.pager or similar. If the process is not inside a git repo, the token expands to no paths. @git:config-files expands to the config files git reads in the current context, plus the declared target of every include.path and includeIf.*.path directive in trusted global and system scopes — including conditional includes whose condition does not currently fire (e.g. a hasconfig:remote.*.url rule that only matches certain repositories). A conditional include that is false now is still grantable because a later git operation — such as git worktree add for a repo with a matching remote — may make the condition fire. Like the other git tokens it keeps only global and system scopes; per-repo .git/config entries are not trusted. network accepts these values: environment.allow_vars accepts exact names such as PATH, trailing-prefix wildcards such as AWS_*, and the bare wildcard *. environment.set_vars injects static values after filtering; PATH and NONO_* are reserved. invocation_policy and endpoint policies use the same decision values: deny, approve, and allow. A decision may also route approval with an object such as { "decision": "approve", "backend": "terminal", "timeout_secs": 30 }.

Command Resolution

By default, Tool Sandbox resolves a command to the first executable found on the original PATH, before the shim directory is prepended. Pin a command to an exact executable when PATH order should not decide the security boundary:
executable is canonicalized at startup and bound by path, inode, and digest. The command name still uses the shim, so nono run --profile my-profile -- aws ... reaches the pinned executable. Tool Sandbox rejects executables and executable parent directories that are writable through the outer sandbox capability set. This prevents a profile from both trusting a command target and granting the agent write or replace access to that same target. Homebrew-style or otherwise user-writable host toolchains are allowed when the sandbox does not grant write access to the executable or its parent. If a low-assurance profile intentionally grants write access overlapping a command target, pin the command to a full absolute executable path and set allow_writable_executable: true:
This is a per-command trust downgrade. It is rejected unless executable is set to an absolute file path; a relative path or a bare command name fails validation. It does not cover other binaries in that directory and does not change invocation: the agent still calls demonator ... through the Tool Sandbox shim. For local demos or other low-assurance profiles that intentionally grant write access overlapping tool locations, a profile can disable the writable-executable trust check across Tool Sandbox:
This is broader than commands.<name>.allow_writable_executable: it also covers deny-only commands and the outer executable allow-list used by Tool Sandbox. Linux launches the verified executable object by file descriptor. macOS verifies the pinned file before sandboxing but must still call execve() by path, so a sandbox-writable executable or parent directory remains replaceable before launch. Avoid allow_writable_executable for high-assurance macOS policies. Interpreter-packaged tools, such as a Python aws entrypoint, are classified as shebang scripts. Tool Sandbox grants the selected script, its interpreter, and the interpreter’s ELF loader closure. Language package directories, virtualenvs, SDK homes, and tool-specific data are not guessed by Tool Sandbox; grant those explicitly with fs_read, fs_read_file, fs_write, or fs_write_file.

Daemon Attribution

Tool Sandbox authorizes a request by walking the caller’s process ancestry to the session. A daemon that double-forks and reparents to pid 1 (a tmux server, for example) severs that chain, so a request from it can no longer be tied to the command that spawned it. Rather than fall back to something replayable, Tool Sandbox attributes such a caller by a platform lineage marker, or denies it:
  • Linux places each Tool Sandbox command in a per-command cgroup that survives reparenting; a severed caller is attributed by reading its cgroup. No writable cgroup base means severed callers are denied.
  • macOS uses commands.<name>.daemon_pid_source: a helper nono runs to learn the pid of the daemon the command spawns. When a caller reparented to pid 1 is seen, nono runs each declared helper and attributes the caller to a command only if the reported pid matches, pinned by the daemon’s kernel identity. No declared helper means severed callers are denied.
Either way a severed caller resolves only to the command that spawned it, never the session, and is denied on any mismatch. macOS adds one case where the session is the answer: a launcher nono never mediated — a plain fork of the session root, such as an agent’s status-line script that backgrounds a command and returns — leaves no marker naming it. nono then accepts the orphan under the session policy when it is a member of the session nono created for its direct child. Membership is proof of descent: a session can only be created, never joined. That session exists only when nono allocated a pty for the direct child, which it does when stdin, stdout and stderr are all a terminal. With any of them redirected — a piped or CI run — the direct child inherits nono’s own session, shared with every other process in the user’s terminal session, so membership proves nothing and such a caller is denied. The macOS helper is declared as { "argv": [...], "env": [...] }. nono passes the daemon’s context as a single JSON object on stdin:
candidate_pid is the severed daemon nono walked to; daemon_cwd, daemon_argv, and daemon_env describe it, read from the kernel, and are omitted when unreadable. daemon_env carries only the keys named in env that are present in the daemon’s environment; credential-named keys are always dropped. The helper prints the pid it believes is its server on stdout; nono accepts only if that equals candidate_pid. The helper runs unsandboxed in the supervisor. It MUST only read and emit a single pid (for example, the pid file the daemon writes) and MUST NOT execute workspace-controlled code. nono hardens the launch — the argv program is expanded (~, $WORKDIR, $TMPDIR, $UID, XDG) and must then be an absolute path, its environment is cleared to a minimal set, its working directory is neutral, and it is killed if it exceeds a short timeout — but cannot vet what the declared helper itself does.

macOS: mediated commands run in their own session

To make that marker work for an ordinary orphan — a child & whose parent exits immediately, rather than a deliberate daemon — macOS Tool Sandbox puts every mediated launch in its own POSIX session (setsid) before exec. A session id cannot be joined after the fact, only created, which is what makes it unforgeable; a descendant keeps it across reparenting, so nono can still attribute a request whose ancestry walk has run off the top of the process tree. The cost is that a mediated command has no controlling terminal, and cannot reacquire one (the session’s pty already belongs to the direct child’s session, so TIOCSCTTY is refused):
  • open("/dev/tty") fails with ENXIO. A command that reads a secret straight from the terminal — ssh asking for a passphrase, git with a terminal credential helper — fails instead of prompting. Supply those through credentials, an askpass helper, or an agent socket rather than an interactive prompt.
  • The kernel delivers it no terminal job-control signals. nono relays Ctrl-C, Ctrl-\, Ctrl-Z and terminal hangup to mediated commands itself, so interrupting and suspending a session still reaches them, but a mediated command cannot read or set the terminal’s foreground process group.
nono’s own interactive prompts (command approval, profile save) run in the supervisor, which keeps the terminal, and are unaffected.

Examples

Minimal Direct Tool

This profile lets the session invoke jq directly. The jq child can read the project directory and has no network access.
Run:

Direct Network Tool

On Linux, this profile lets the session invoke curl and allows raw TCP connect on port 443. Hostname filtering is not implied by port rules. macOS Tool Sandbox rejects raw TCP port rules because Seatbelt cannot enforce per-port filters for these child sandboxes.
Run:
If you need hostname enforcement, use an enforceable nono proxy/helper policy. network.allow_domain without such a helper is rejected. For tools that must use raw network directly on macOS, make the broader grant explicit with network.allow_all: true. This allows unrestricted child network for that selected command and cannot be combined with narrower host or port rules.

Chained Tools

This profile lets the session run git, and lets the git child invoke ssh. Direct session ssh remains denied. A shell wrapper is not part of the authority chain; sh -c 'git ls-remote ...' still reaches the same git policy through the shim-prefixed PATH.
Run:
Expected:
  • git calling ssh succeeds;
  • direct session ssh is denied.

Build Tool Chains

Build tools often execute helper programs. Model each helper explicitly.
Compilers may invoke additional helpers such as assemblers, linkers, or language-specific drivers. When a build fails with a Tool Sandbox denial for a helper command, add that helper as another policy-controlled command and caller edge.

Environment Controls

By default, Tool Sandbox preserves a small safe environment set. Override it with environment.allow_vars:
Pattern syntax is intentionally small:
  • exact names: PATH;
  • trailing prefix wildcards: AWS_*;
  • bare wildcard: *.
environment.set_vars injects static values after filtering and after PATH has been shim-prefixed:
PATH and NONO_* are reserved and cannot be set with set_vars.

Forwarding Caller Variables

allow_vars filters the environment nono builds. It does not let a calling command pass its own variables through to a command it invokes — that is what export_env is for:
session_export_env does the same for commands whose resolved caller is the session itself:
Both accept the same three pattern forms as allow_vars, and both deliberately bypass allow_vars and the dangerous-variable blocklist. That is the point of the feature — NODE_OPTIONS and PYTHONPATH are on the blocklist precisely because an untrusted process should not be able to set them, but a legitimate build tool often must.
Values forwarded by export_env come from the calling command’s environment, not from the profile. Anything that can set a variable in that process controls what the child receives. Forward the narrowest set that works, and prefer exact names over *.
Three classes of variable are treated differently, regardless of how broad your pattern is: The loader rule exists because LD_PRELOAD and friends load code into any dynamically-linked child, not just an interpreter, and that child may hold an injected credential socket. Preloading is legitimate for sanitizers and similar tools, so it stays possible — but it has to be deliberate:

Mandatory Arguments

Use argv_prepend when a selected child policy needs mandatory mode flags. The arguments are inserted after the synthesized argv[0] and before caller-provided arguments.
This is useful for mode selection. It is not a substitute for sandbox grants; filesystem, network, and credential access are still controlled by the selected policy.

Credentials

Tool Sandbox supports two credential types: A Git profile can model SSH agent as a local socket credential. A raw TCP/22 rule currently requires Linux Landlock; use a proxy-backed policy for hostname/network enforcement on macOS:
path may reference environment variables, optionally followed by a literal suffix, such as "$XDG_RUNTIME_DIR/sccache.sock". Variables are expanded anywhere in the string; if a referenced variable is unset, a command that selects the credential fails before launch with a Tool Sandbox error naming the variable. Prefer the agent socket over raw private-key file grants.

Shaping an ambient credential’s phantom

An ambient credential may declare a format: a literal template with exactly one {} placeholder, such as "sk-ant-oat01-{}". Every phantom issued for that credential renders as the template with a 64-hex body in place of {}, instead of the default bare nono_<hex>. Use it when the consuming client classifies a credential by sniffing a literal token prefix and would otherwise reject the phantom. The template is stripped on egress, so the real credential — never the template — reaches upstream.
format is rejected on non-ambient credentials, when it carries control characters, and when the capture_credential rule that mints the phantom also sets shape: "jwt" (the JWT shape already dictates the phantom’s structure).

Stdio Output Mediation

Tool Sandbox treats stdout and stderr as output capabilities. A tool that can print unbounded data can exfiltrate files or credentials, or destabilize the broker with excessive output. When sandbox.stdio is present, the Capability Broker does not pass the caller’s stdout/stderr file descriptors directly to the tool. It creates broker-owned pipes, reads output in bounded chunks, applies the byte policy, and relays only permitted bytes back to the caller.
truncate forwards up to max_bytes, then drains the child pipe without forwarding more bytes. terminate and deny stop the delegated tool and return a denied command result. Command-policy audit events include structured stdio counts when brokered stdio is active:

Audit Record

Every command-policy decision is recorded as an audit event by the broker, not by the mediated tool. Each event records what the broker decided as a decision string drawn from a closed vocabulary that mixes lifecycle stages with terminal results, such as invocation_allowed for clearing an invocation gate and allowed, denied, or capture for a terminal result. The vocabulary only ever grows, and a decision an older nono does not recognize is summarized as unclassified rather than counted as allowed or denied. The broker also folds these events into a per-command rollup stored in the session’s session.json, so listing sessions never has to parse event logs. The rollup classifies each decision as allowed, denied, or unclassifiable, and counts only terminal ones, so a command that clears an invocation gate and then runs records two events but counts as one allowed invocation. nono audit list names the mediated commands per session, and nono audit show still carries every individual decision:
The rollup is covered by the session digest committed to the ledger, so a denial edited out of session.json fails nono audit verify. See Audit Trail for the rollup’s bounds and how it renders. A session writes session.json only when it finalizes. While it is still running, nono inspect reports the same rollup recomputed from the event log, marked [live] because the session can still mediate more commands. See Session Lifecycle.

Direct Exec Bypass

Policy-controlled commands are meant to be reached through shims. A direct path like /usr/bin/ssh is denied unless explicitly configured:
Direct bypass is a compatibility escape hatch:
  • tool through PATH still uses Tool Sandbox;
  • /usr/bin/tool runs with outer session capabilities, not the child Tool Sandbox sandbox;
  • deny-only blocked commands are never eligible;
  • credential-using commands require allow_direct_exec_bypass_with_credentials: true.

Validation Checklist

Use these commands while developing a profile:
For command-control behavior:

Troubleshooting