> ## Documentation Index
> Fetch the complete documentation index at: https://nono.sh/docs/llms.txt
> Use this file to discover all available pages before exploring further.

# Sandboxed Tool Execution

> Run selected commands in zero-trust command sandboxes with brokered chaining

Sandboxed Tool Execution lets a profile turn specific commands into fine-grained brokered tool executions. The supervisor acts as the Capability Broker and launches each tool into a fresh command sandbox built from the command sandbox policy selected for that invocation.

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 command sandbox 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](/docs/cli/features/dangerous-command-blocking). That page covers deny-only command-policy entries and inherited `dangerous_commands` behavior.

## Mental Model

A command policy answers three questions:

| Question | Field |
| - | - |
| What may the session sandbox run directly? | `command_policies.commands.<name>.sandbox` or `command_policies.commands.<name>.from.session` |
| What may one mediated command run next? | `command_policies.commands.<caller>.can_use` plus `command_policies.commands.<callee>.from.<caller>` for other commands; self-invocation is implicit unless a self-invocation entry is configured |
| What does the selected command sandbox receive? | `sandbox` or caller-specific `from.<caller>` grants |

`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 command sandbox starts from a minimal runtime baseline. It does not inherit the session sandbox's `--allow` grants, CWD access, broad profile groups, raw credential paths, or network policy.

When command mediation is active, the session sandbox'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 command-mediation 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.

The command-mediation security boundary is at policy-controlled *commands*, not at execute-vs-no-execute for the session sandbox: once a command becomes a command-policy entry, everything it invokes is governed by `can_use`/`from` edges regardless of `PATH` membership.

## Available Fields

Command-policy configuration lives under `command_policies`. A minimal shape looks like this:

```json theme={null}
{
  "command_policies": {
    "commands": {
      "tool": {
        "sandbox": {}
      }
    }
  }
}
```

The top-level `command_policies` object accepts these fields:

| Field | Value | Use |
| - | - | - |
| `commands` | object | Policy-controlled commands. A non-empty object activates command mediation. |
| `credentials` | object | Named credentials that selected command sandbox policies may request. |
| `executable_dirs` | string array | Extra directories used while resolving command names before shim `PATH` is prepended. |
| `allow_writable_executables` | boolean | Profile-wide trust downgrade for command targets writable by the session sandbox capability set. |
| `deny_direct_exec_bypass` | absolute path array | Canonical executable paths that must not bypass shim control. |
| `approval_backends` | object | Named backends for `approve` decisions. |
| `approval_defaults` | object | Default approval backend and timeout. |
| `entrypoint` | command name | Legacy session hint. Prefer `commands.<name>.sandbox` or `commands.<name>.from.session`. |

Each entry under `approval_backends` is one of:

| `type` | Fields | Use |
| - | - | - |
| `terminal` | `timeout_secs` | Prompt the operator in the terminal. |
| `webhook` | `url`, `auth`, `timeout_secs` | Ask an HTTP endpoint. Omit `auth` for a plain endpoint that answers the `POST` with the decision. Set `auth: "platform"` to sign every request with this client's enrollment key and follow the submit-and-poll exchange; `url` may then be omitted and defaults to the enrolled platform's approval route. Both variants are specified in `docs/protocols/approval-webhook-v1.md`. |
| `chain` | `mode` (`all` or `any`), `backends` | Combine other backends. |

Each entry under `commands` uses the command name as the key:

| Field | Value | Use |
| - | - | - |
| `sandbox` | object | Direct-session command sandbox policy shorthand. This lets the session sandbox run the command. |
| `from` | object | Caller-specific policies. Use `session` for direct session invocation or another command name for chaining. |
| `can_use` | command-name array | Other commands this command may invoke through command mediation. If no self-invocation entry is configured, self-invocation keeps the current effective command sandbox. |
| `executable` | absolute path | Pin the command name to one executable file instead of the first `PATH` match. |
| `allow_writable_executable` | boolean | Per-command trust downgrade for a pinned executable writable by the session sandbox capability set. Requires absolute `executable`. |
| `intercept` | array | Ordered argument-sequence mediation rules evaluated after invocation policy. |
| `allow_direct_exec_bypass` | absolute path array | Compatibility escape hatch for direct canonical-path invocation. |
| `allow_direct_exec_bypass_with_credentials` | boolean | Required when a credential-using command also allows direct exec bypass. |
| `daemon_pid_source` | `{ argv: string[], env?: string[] }` | macOS only. Helper nono runs to learn the pid of a daemon this command spawns that double-forks and reparents to pid 1 (e.g. a tmux server), so a request from that daemon can still be attributed to this command. `argv` is the helper program (expanded, then must be absolute); `env` allowlists daemon-env keys forwarded to it. See [Daemon Attribution](#daemon-attribution). |

`from` entries accept three value shapes:

| Value | Meaning |
| - | - |
| `"deny"` | Explicitly deny this caller. |
| `{ "fs_read": ["."], ... }` | Bare sandbox shorthand for this caller. |
| `{ "sandbox": { ... }, "invocation_policy": { ... } }` | Full edge policy with optional argv/env mediation before launch. |

The selected `sandbox` object is the command sandbox policy and describes what the command receives:

| Field | Value | Use |
| - | - | - |
| `fs_read` / `fs_write` | directory path arrays | Directory grants. |
| `fs_read_file` / `fs_write_file` | file path arrays | Exact file grants. |
| `exec_paths` | path arrays | Linux-only. Extra paths the child may execute, for multi-call tools (e.g. `git`) that re-exec their own helpers by absolute path from a compiled-in exec-path. A directory entry covers everything beneath it; a matching `fs_read` grant is still required, and missing paths are skipped so profiles can list per-distro candidates. Ignored on macOS. |
| `network` | object | Command sandbox network policy. |
| `environment` | object | Environment allow-list and injected static variables. |
| `argv_prepend` | string array | Mandatory arguments inserted before caller-provided arguments. |
| `use_credentials` | credential-name array | Named credentials injected into the selected command sandbox. |
| `credentials` | array | Credential grants with optional endpoint policy constraints. |
| `stdio` | object | Brokered stdout/stderr byte limits. |
| `resources` | object | Optional command sandbox resource limits. |
| `open_urls` | object | Runtime-delegated URL opening for this command. |
| `allow_launch_services` | boolean | macOS-only direct LaunchServices URL opening. |
| `allow_raw_file_credentials_in_chained_policy` | boolean | Required before non-session callers can receive raw-file credentials. |
| `unsafe_macos_seatbelt_rules` | string array | macOS-only escape hatch: raw Seatbelt rules appended to this command sandbox's Seatbelt profile after the deny gate (last-match-wins). Mirrors the top-level `unsafe_macos_seatbelt_rules` but scoped to one command/intercept. Use sparingly — e.g. to let a command exec + read a binary it forks by absolute path. Honoured only for **user-authored profiles** (same trust boundary as `binary` — see below); pack and built-in profiles have these rules stripped with a warning. |
| `keychain_access` | `"read"` or `"readwrite"` | macOS-only. Grants this command's child the user's login keychain (`login.keychain-db` and `metadata.keychain-db` under `~/Library/Keychains`) in the given mode, plus the keychain Mach services, through the agent's keychain deny. The agent's own sandbox keeps the deny. No separate filesystem grant is needed. See [Keychain access for a mediated command](#keychain-access-for-a-mediated-command). Ignored on Linux. |
| `unix_socket_bind` | path array | Grant `connect(2)` and `bind(2)` on the named pathname AF\_UNIX socket files (supports the dynamic tokens below). Implied filesystem grant is ReadWrite on the file if it already exists, otherwise ReadWrite on its parent directory (so `bind(2)` can create it) — mirrors the agent-level `unix_socket_bind` field, see [AF\_UNIX Socket Grants](/docs/cli/features/profiles-groups#af-unix-socket-grants). A path that resolves to an existing dangling symlink is rejected. |

### Keychain access for a mediated command

Command sandboxes don't inherit the agent's deny groups, so a filesystem grant normally works as written. The exception is the macOS keychain: a command sandbox keeps the agent's keychain deny, so a keychain file grant alone is ineffective. To let one command reach the keychain without exposing it to the agent, set `keychain_access`:

```json theme={null}
{
  "command_policies": {
    "commands": {
      "docker-credential-osxkeychain": {
        "sandbox": {
          "keychain_access": "read"
        }
      }
    }
  }
}
```

This grants the command's child `login.keychain-db` and `metadata.keychain-db` under `~/Library/Keychains` and the keychain Mach services. The agent stays denied all of them. Use `"readwrite"` if the command must store credentials. If no user keychain exists, the command is refused rather than run without it.

`"read"` blocks writes to the keychain files, which stops saving items through the file-based keychain API. The keychain Mach services can't be split by access mode, though, so don't rely on `"read"` alone to stop every keychain write path.

`"readwrite"` lets the command save items: macOS writes a temporary `login.keychain-db.sb-…` file next to the DB and renames it into place. nono allows that temporary file only as a regular file and denies creating hard links or symlinks anywhere in `~/Library/Keychains`, so the command can't swap the keychain for a link to a file the agent can read. It can still overwrite or delete the keychain, so grant `"readwrite"` only to a command that needs it. For a general-purpose tool such as `security`, restrict its arguments with an `invocation_policy` as well.

### 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 selected command 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 selected command 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.

```jsonc theme={null}
{
  "args": ["auth", "switch"],
  "action": { "type": "passthrough" },
  "sandbox": {
    "fs_read": ["~/.config/gh"],
    "fs_write": ["~/.config/gh"],
    "network": { "allow_domain": ["github.com"] }
  }
}
```

### 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 command-sandbox environment filtering, so profiles can mediate out-of-band behavior such as `GIT_SSH_COMMAND` without matching raw host environment state.

```jsonc theme={null}
{
  "match": {
    "argv": { "contains": ["push", "--force"] }
  },
  "action": { "type": "approve" }
}
```

```jsonc theme={null}
{
  "match": {
    "env": {
      "GIT_SSH_COMMAND": { "equals": "ssh -i /tmp/fake_key" }
    }
  },
  "action": { "type": "approve" }
}
```

`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:

| `action.type` | Behavior |
| - | - |
| `passthrough` | Run the real binary in its sandbox. This is the default when no rule matches. |
| `respond` | Return a static `stdout` string to the caller without forking the binary. |
| `capture` | Run the real binary, buffering its stdout for broker inspection before returning it. |
| `capture_credential` | Run the real binary, store its stdout as a named ambient `credential`, and return a broker nonce. An optional `shape` controls the returned nonce's form: `opaque` (default) returns the bare `nono_<hex>`; `jwt` wraps it as a structurally-valid unsigned JWT (`<header>.<payload>.<nonce>`) for callers that validate token structure before use — the embedded nonce still resolves on egress. |
| `approve` | Route the invocation through an approval backend before forking (optional `timeout_secs`). |
| `exec` | Run the helper at `command[0]` — with `command[1..]` then the original arguments — in place of the real binary, inside the matched command's sandbox; stream its stdout/stderr and propagate its exit code. `command[0]` is `$VAR`-expanded, must be absolute, is resolved with identity expectations, and is subject to the same non-writable-executable trust gate as a pinned `executable`. |

`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 command sandbox is built. They are opt-in: no dynamic paths are added unless a selected command sandbox policy includes one of these tokens.

Supported tokens:

| Token | Use in | Expands to |
| - | - | - |
| `@git:config-files` | `fs_read_file` | Trusted global/system Git config files and configured Git file paths (attributes, excludes, commit templates), plus the declared target of every `include.path` and `includeIf.*.path` directive in those scopes — including conditional includes that do not currently fire. |
| `@git:hooks-path` | `fs_read` | Trusted global/system `core.hooksPath` directories. |
| `@git:common-dir` | `fs_write`, `fs_read` | The git common directory: `.git` in a regular repo, or the absolute path to the main repo's `.git` when running inside a git worktree. Useful in `fs_write` when the agent session starts from a worktree and `--workdir` points to the worktree itself — the token ensures the git object store (which lives in the main repo) is covered. |
| `@git:worktree` | `fs_read`, `fs_write` | The main worktree root (parent of `@git:common-dir`). Empty in a regular repo; in a linked worktree it is the absolute path to the main repo root. `git worktree add` changes CWD to the main repo root, so without this `getcwd()` fails with EPERM. |
| `@git:toplevel` | `fs_read`, `fs_write` | Absolute path of the current checkout root (`git rev-parse --show-toplevel`). In a linked worktree this is the worktree's own root, not the main repo. |
| `@git:toplevel-parent` | `fs_write` | Parent directory of `@git:toplevel`. Required for `git worktree add ../sibling`, where the new worktree is created adjacent to the current checkout. |
| `@git:fsmonitor-socket` | `unix_socket_bind` | Path to `fsmonitor--daemon.ipc` under the current worktree's private git-dir (not the shared common dir — each worktree runs its own fsmonitor daemon). Pair with `unix_socket_bind` to let `core.fsmonitor=true` reach the daemon's socket. |

Example:

```json theme={null}
{
  "command_policies": {
    "commands": {
      "git": {
        "sandbox": {
          "fs_read_file": ["@git:config-files"],
          "fs_read": ["@git:hooks-path"],
          "fs_write": ["$WORKDIR", "@git:common-dir"]
        }
      }
    }
  }
}
```

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:

| Field | Value | Notes |
| - | - | - |
| `allow_all` | boolean | Allows unrestricted network access in the command sandbox. |
| `allow_domain` | string array | Hostname allow-list. Valid only with an enforceable nono proxy/helper mode. |
| `tcp_connect_ports` | port array | Raw TCP connect ports on Linux Landlock. Not hostname-filtered. |
| `tcp_bind_ports` | port array | Raw TCP bind ports on Linux Landlock. Cannot be enforced for command sandboxes on macOS. |
| `open_port` | port array | Localhost bind ports (e.g. an OAuth/OIDC loopback callback listener). Mirrors the top-level `network.open_port`; enforceable for children on macOS and Linux. |
| `open_port_range` | `[start, end]` array | Inclusive localhost bind port ranges, e.g. `[[8250, 8255]]`. |

### Command-scoped proxy policy

An effective command sandbox whose network policy includes `network.allow_domain` receives a dedicated loopback proxy and fresh proxy credential. Its proxy policy does not inherit the session sandbox's broader domain allowlist, although the session network policy's domain denials still apply. The command sandbox may connect only to its selected proxy, so changing proxy environment variables, setting `NO_PROXY`, or opening a direct socket does not bypass its network policy unless the command sandbox policy separately grants raw TCP access.

Proxy credentials granted by that effective command sandbox remain available through the dedicated proxy. Credential reverse routes may reach their configured upstreams under `endpoint_policy`, without adding those upstreams to the command's domain allowlist. Direct and ordinary forward-proxy requests still require `network.allow_domain` permission.

A command sandbox policy that grants a proxy credential without `network.allow_domain` also receives a dedicated proxy with an empty domain allowlist. Only its explicitly granted credential routes are available.

Proxy credentials cannot be combined with `network.allow_all`: unrestricted loopback access would let the command reach a broader proxy and defeat route isolation. On Linux, a raw `tcp_connect_ports` grant that collides with any active nono proxy port also fails closed at launch.

A command-scoped proxy policy needs an active nono proxy, such as one activated by a top-level network policy with `network.allow_domain`. A command-scoped proxy credential activates the proxy by itself. If the selected command sandbox requires a scoped proxy but none is available, the command fails closed at launch.

| Invocation | Selected command sandbox |
| - | - |
| Direct command with `from.session` | `commands.<name>.from.session.sandbox` |
| Direct command without `from.session` | `commands.<name>.sandbox` |
| Command launched by another mediated command | `commands.<name>.from.<caller>.sandbox` |
| Matching intercept with its own `sandbox` | The intercept sandbox replaces the selection above |

`sandbox` and `from.session` are alternative direct-session forms and cannot both be present. Chained access requires both the caller's `can_use` entry and the callee's `from.<caller>` policy; a missing caller edge does not fall back to the direct command sandbox. Direct, caller-specific, and intercept proxy policies are independent and are never merged. The caller's own command sandbox controls the caller, not its child.

`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, command mediation 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:

```json theme={null}
{
  "commands": {
    "aws": {
      "executable": "/usr/sbin/aws",
      "sandbox": {
        "fs_read_file": ["$HOME/.aws/credentials"]
      }
    }
  }
}
```

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

Command mediation rejects executables and executable parent directories that are writable through the session sandbox's 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 session 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`:

```json theme={null}
{
  "commands": {
    "demonator": {
      "executable": "/opt/homebrew/bin/demonator",
      "sandbox": {
        "fs_write": ["/opt/homebrew/bin"]
      },
      "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 command-mediation 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 command mediation:

```json theme={null}
{
  "command_policies": {
    "allow_writable_executables": true,
    "commands": {
      "npm": {
        "executable": "/Users/alice/.nvm/versions/node/v24.13.0/bin/npm",
        "sandbox": {
          "fs_write": ["/Users/alice/.nvm/versions/node/v24.13.0/bin"]
        }
      }
    }
  }
}
```

This is broader than `commands.<name>.allow_writable_executable`: it also covers deny-only commands and the session executable allow-list used by command mediation.

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. The command 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 inferred by command mediation; grant those explicitly with `fs_read`, `fs_read_file`, `fs_write`, or `fs_write_file`.

## Daemon Attribution

Command mediation 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, command mediation attributes such a caller by a platform lineage marker, or denies it:

* **Linux** places each mediated 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**:

```json theme={null}
{
  "schema_version": 1,
  "command": "<command name>",
  "candidate_pid": 48213,
  "workdir": "/abs/session/workdir",
  "daemon_cwd": "/abs/daemon/cwd",
  "daemon_argv": ["/path/to/daemon", "..."],
  "daemon_env": { "SOME_KEY": "value" }
}
```

`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 command mediation 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.

```json theme={null}
{
  "extends": "default",
  "meta": {
    "name": "command-jq-readonly",
    "description": "Run jq with read-only access to this project"
  },
  "workdir": {
    "access": "none"
  },
  "command_policies": {
    "commands": {
      "jq": {
        "sandbox": {
          "fs_read": ["."],
          "environment": {
            "allow_vars": ["PATH", "LANG", "LC_*"]
          }
        }
      }
    }
  }
}
```

Run:

```bash theme={null}
nono profile validate ~/.config/nono/profiles/command-jq-readonly.json
nono run --profile command-jq-readonly -- jq '.name' package.json
```

### 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 command mediation rejects raw TCP port rules because Seatbelt cannot enforce per-port filters for command sandboxes.

```json theme={null}
{
  "extends": "default",
  "meta": {
    "name": "command-curl-https",
    "description": "Run curl with raw TCP/443 egress"
  },
  "workdir": {
    "access": "none"
  },
  "command_policies": {
    "commands": {
      "curl": {
        "sandbox": {
          "fs_write": ["."],
          "fs_read_file": [
            "/etc/ssl/certs/ca-certificates.crt",
            "/etc/pki/tls/certs/ca-bundle.crt"
          ],
          "network": {
            "tcp_connect_ports": [443]
          },
          "environment": {
            "allow_vars": ["PATH", "HOME", "LANG", "LC_*", "SSL_CERT_FILE", "SSL_CERT_DIR"]
          }
        }
      }
    }
  }
}
```

Run:

```bash theme={null}
nono run --profile command-curl-https -- curl -I https://example.com
```

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

```json theme={null}
{
  "extends": "default",
  "meta": {
    "name": "command-git-ssh",
    "description": "Allow git to call ssh without direct session ssh"
  },
  "workdir": {
    "access": "none"
  },
  "command_policies": {
    "commands": {
      "git": {
        "can_use": ["ssh"],
        "sandbox": {
          "fs_read": ["."],
          "fs_write": ["."],
          "environment": {
            "allow_vars": ["PATH", "HOME", "USER", "LOGNAME", "LANG", "LC_*"]
          }
        }
      },
      "ssh": {
        "from": {
          "git": {
            "fs_read": ["."],
            "environment": {
              "allow_vars": ["PATH", "HOME", "USER", "LOGNAME", "LANG", "LC_*"]
            }
          },
          "session": "deny"
        }
      }
    }
  }
}
```

Run:

```bash theme={null}
nono run --profile command-git-ssh -- git ls-remote git@github.com:owner/repo.git
nono run --profile command-git-ssh -- ssh -T git@github.com
```

Expected:

* `git` calling `ssh` succeeds;
* direct session `ssh` is denied.

### Build Tool Chains

Build tools often execute helper programs. Model each helper explicitly.

```json theme={null}
{
  "extends": "default",
  "meta": {
    "name": "command-make-cc",
    "description": "Allow make to call cc and pkg-config"
  },
  "workdir": {
    "access": "none"
  },
  "command_policies": {
    "commands": {
      "make": {
        "can_use": ["cc", "pkg-config"],
        "sandbox": {
          "fs_read": ["."],
          "fs_write": ["."],
          "environment": {
            "allow_vars": ["PATH", "HOME", "USER", "LOGNAME", "TERM", "LANG", "LC_*", "MAKEFLAGS", "CC", "CFLAGS", "LDFLAGS", "PKG_CONFIG_*"]
          }
        }
      },
      "cc": {
        "from": {
          "make": {
            "fs_read": ["."],
            "fs_write": ["."],
            "environment": {
              "allow_vars": ["PATH", "HOME", "LANG", "LC_*", "TMPDIR", "CFLAGS", "LDFLAGS"]
            }
          },
          "session": "deny"
        }
      },
      "pkg-config": {
        "from": {
          "make": {
            "fs_read": ["."],
            "environment": {
              "allow_vars": ["PATH", "HOME", "LANG", "LC_*", "PKG_CONFIG_*"]
            }
          },
          "session": "deny"
        }
      }
    }
  }
}
```

Compilers may invoke additional helpers such as assemblers, linkers, or language-specific drivers. When a build fails with a command-policy denial for a helper command, add that helper as another policy-controlled command and caller edge.

## Environment Controls

By default, command mediation preserves a small safe environment set. Override it with `environment.allow_vars`:

```json theme={null}
{
  "environment": {
    "allow_vars": ["PATH", "HOME", "LANG", "LC_*", "AWS_*"]
  }
}
```

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:

```json theme={null}
{
  "environment": {
    "allow_vars": ["PATH", "LANG", "LC_*"],
    "set_vars": {
      "PAGER": "",
      "GIT_PAGER": ""
    }
  }
}
```

`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:

```json theme={null}
{
  "npm": {
    "export_env": ["NODE_OPTIONS", "NPM_CONFIG_*"]
  }
}
```

`session_export_env` does the same for commands whose resolved caller is the session itself:

```json theme={null}
{
  "tool_sandbox": {
    "session_export_env": ["PYTHONPATH"]
  }
}
```

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.

<Warning>
  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 `*`.
</Warning>

Three classes of variable are treated differently, regardless of how broad your pattern is:

| Class | Examples | Behaviour |
| - | - | - |
| Reserved | `PATH`, `NONO_*` | Never forwarded. nono owns these. |
| Credentials | `OP_SERVICE_ACCOUNT_TOKEN`, `OP_CONNECT_TOKEN`, `OP_CONNECT_HOST`, `OP_SESSION_*` | Never forwarded. Use a broker credential instead. |
| Loader injection | `LD_*`, `DYLD_*` | Forwarded only when named exactly. `*` and `LD_*` skip them. |

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:

```json theme={null}
{
  "make": {
    "export_env": ["LD_PRELOAD"]
  }
}
```

## Mandatory Arguments

Use `argv_prepend` when a selected command sandbox policy needs mandatory mode flags. The arguments are inserted after the synthesized `argv[0]` and before caller-provided arguments.

```json theme={null}
{
  "ssh": {
    "from": {
      "git": {
        "argv_prepend": ["-o", "IdentityFile=none"],
        "use_credentials": ["ssh-agent"]
      }
    }
  }
}
```

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 command sandbox policy.

## Credentials

Command sandbox policies support four credential types:

| Type | Typical use |
| - | - |
| `local-socket` | Grant one selected child access to a local IPC socket and optionally map it into an env var |
| `raw-file` | Grant direct-session tools exact read access to a file credential |
| `proxy` | Route one selected child's requests through a command-scoped credential proxy with endpoint enforcement |
| `ambient` | Hold a captured credential in the broker and expose only a resolvable phantom to selected consumers |

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:

```json theme={null}
{
  "credentials": {
    "ssh-agent": {
      "type": "local-socket",
      "path": "$SSH_AUTH_SOCK",
      "mode": "connect",
      "env_var": "SSH_AUTH_SOCK"
    }
  }
}
```

`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 command-sandbox error naming the variable.
Prefer the agent socket over raw private-key file grants.

### Command-scoped AWS authentication

A `proxy` credential may use `aws_auth` instead of `credential_key` or `source`.
The scoped proxy resolves the selected AWS profile on the host and signs permitted
outbound requests with SigV4. The real AWS credentials never enter the command
sandbox, and `env_var` is not required for an `aws_auth` route.

```json theme={null}
{
  "command_policies": {
    "credentials": {
      "bedrock": {
        "type": "proxy",
        "upstream": "https://bedrock-runtime.us-east-1.amazonaws.com",
        "aws_auth": {
          "profile": "production"
        }
      }
    },
    "commands": {
      "aws": {
        "sandbox": {
          "environment": {
            "set_vars": {
              "AWS_ACCESS_KEY_ID": "nono-placeholder",
              "AWS_SECRET_ACCESS_KEY": "nono-placeholder",
              "AWS_CONFIG_FILE": "/dev/null",
              "AWS_SHARED_CREDENTIALS_FILE": "/dev/null"
            }
          },
          "credentials": [
            {
              "name": "bedrock",
              "endpoint_policy": {
                "default": "deny",
                "allow": [
                  { "method": "POST", "path": "/model/**" }
                ]
              }
            }
          ]
        }
      }
    }
  }
}
```

The AWS CLI requires credentials before sending a signed request. Non-secret
`AWS_ACCESS_KEY_ID` and `AWS_SECRET_ACCESS_KEY` placeholders satisfy that client-side
check; the proxy removes their signature and re-signs the request with host-side
credentials. Pointing `AWS_CONFIG_FILE` and `AWS_SHARED_CREDENTIALS_FILE` at
`/dev/null` prevents the sandboxed CLI from probing `~/.aws`. These variables affect
only the command sandbox: `aws_auth.profile` is still resolved by the host supervisor.
For an SSO profile, run `aws sso login --profile <name>` outside the sandbox first.
Do not grant the command sandbox access to `~/.aws`.

`aws_auth` is valid only on `proxy` credentials and cannot be combined with
`credential_key` or `source`. As with every command-scoped proxy credential, each
grant must include an `endpoint_policy`. See
[AWS SigV4 Signing](/docs/cli/features/credential-injection#aws-sigv4-signing) for the
profile, region, service, and credential-resolution behavior shared with
session-level routes.

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

```json theme={null}
{
  "credentials": {
    "anthropic-oauth": {
      "type": "ambient",
      "format": "sk-ant-oat01-{}"
    }
  }
}
```

`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

Command mediation 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.

```json theme={null}
{
  "sandbox": {
    "stdio": {
      "stdout": {
        "max_bytes": 1048576,
        "on_limit": "truncate"
      },
      "stderr": {
        "max_bytes": 262144,
        "on_limit": "terminate"
      }
    }
  }
}
```

`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:

```json theme={null}
{
  "stdio": {
    "stdout": {
      "total_bytes": 1200000,
      "forwarded_bytes": 1048576,
      "max_bytes": 1048576,
      "limit_exceeded": true,
      "on_limit": "truncate"
    }
  }
}
```

## 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:

```bash theme={null}
# Which tools did recent sessions mediate, and what was denied?
nono audit list --recent 10

# Every mediated decision for one session, with reasons and stdio counts
nono audit show <session-id> --json

# What has a session mediated so far, while it is still running
nono inspect <session-id>
```

```
20260219-092017-8117  just now  completed  claude
  tools: gh (3 allowed, 1 denied), git (12 allowed)
```

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](/docs/cli/features/audit#mediated-tool-summary) 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](/docs/cli/features/session-lifecycle#mediated-tools-while-the-session-runs).

## 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:

```json theme={null}
{
  "commands": {
    "tool": {
      "allow_direct_exec_bypass": ["/usr/bin/tool"]
    }
  }
}
```

Direct bypass is a compatibility escape hatch:

* `tool` through `PATH` still uses command mediation;
* `/usr/bin/tool` runs with session sandbox capabilities, not the command sandbox's capabilities;
* 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:

```bash theme={null}
nono profile validate ~/.config/nono/profiles/my-command-policy-profile.json
nono profile show my-command-policy-profile --json
nono run --profile my-command-policy-profile --dry-run -- my-command --version
```

For command-control behavior:

```bash theme={null}
# Direct command should be allowed
nono run --profile my-command-policy-profile -- allowed-command --version

# A denied direct command should hit the shim and exit 126
nono run --profile my-command-policy-profile -- denied-command --version

# Direct canonical path should be denied unless explicitly bypassed
REAL="$(command -p -v denied-command)"
nono run --profile my-command-policy-profile -- "$REAL" --version
```

## Troubleshooting

| Symptom | Meaning |
| - | - |
| `nono: command policy denied <cmd>: missing session sandbox` | The session sandbox invoked a policy-controlled command without `commands.<name>.sandbox` or `commands.<name>.from.session`. |
| `nono: command policy denied <cmd>: missing from.<caller>` | A caller was allowed to invoke a callee name, but the callee has no `from.<caller>` policy. |
| `command sandbox credential '<name>' is unavailable: SSH_AUTH_SOCK is unset` | The selected command sandbox policy references a local-socket credential backed by `SSH_AUTH_SOCK`, but no agent socket was available at session startup. |
| `Permission denied (publickey)` | SSH reached the remote server; authentication failed outside nono. |
| `Host key verification failed` | SSH could not verify the host from the known-hosts files the policy grants. |
| `command sandbox exec failed for script ... using interpreter ...` | The command resolved to a shebang script. Grant the required interpreter, package, and runtime data paths explicitly. |
| `command policy direct exec bypass denied` | A policy-controlled command was invoked by absolute path instead of through its shim. |
| `Seatbelt cannot enforce raw per-port TCP rules for command sandboxes` | The selected command sandbox policy uses direct TCP port rules on macOS. Use a Linux host or a proxy policy instead. |
| `legacy_blocked_command` | A deprecated blocked-command entry was folded into command policy as a deny-only entry. |


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.