← Back to document history
Document version

Hurricane Sentinel — For the Bots v6

This is the stored snapshot for the approved document version. The diff below shows what changed from the previous version.

Preview
Source path
sentinel/forthebots.md
Source commit
No commit recorded
Created at
Sep 1, 2026, 2:53 AM UTC
Source digest
737920fb16a5da4387941024242a2f7fad515fec274906ebfe4c9a863ab2a804

Document snapshot

Hurricane Sentinel — For the Bots

An operator manual written for AI coding agents (Claude Code, Codex, and similar). Dense and action-oriented: how to install, configure, edit, and drive Sentinel from a shell or its HTTP API. Every path, command, and field here is real. If you are a human, the README / HOWITWORKS / SECURITY docs are gentler.

Your mental model

Sentinel is a harness that runs AI agents as a dedicated unprivileged Linux user ("the leashed user") under OS-enforced sandboxing. You (the agent) mostly interact with it in one of two ways:

  • The sentinel CLI — for setup and scripting.
  • The HTTP API (sentinel web on :8700, sentinel api on :8701) — token-authed JSON; this is what the web console uses and the most convenient programmatic surface.

Everything an agent does is a run: a worker subprocess spawned as the leashed user, in a per-run cgroup, under Leash (network egress) and — for normal agents — Landlock+seccomp (filesystem/syscalls), with model traffic routed through Muzzle. A run pauses for approval on effectful tools, checkpoints to disk, and resumes.

Two agent flavors:

  1. Normal agents — an LLM (Ollama or an external provider via Muzzle) driving Sentinel's tool loop with per-tool approval gating.
  2. CLI-runtime agents — model: codex or model: claude-code; driven by that CLI's own agentic loop with its native tools, on your Codex/Claude subscription. See the dedicated section below.

Prerequisites & install

  • Linux only (Leash is Linux-only). Needs Muzzle and Leash installed on the same host, plus a dedicated Linux user for agents to run as.
  • On macOS the same install runs inside a managed Lima VM shipped by the desktop app — same artifact, same installer, same production profile, so it IS a Linux install. The host sentinel has no config there; every command goes through sentinel-runtime cli <command>, which forwards into the guest. Anything in this document works on a Mac by adding that prefix.
  • Python managed with uv; the package installs into a venv (commonly /opt/sentinel/v1/.venv).
# Interactive: prompts for ONE name that becomes both the head agent and its Linux user.
# Local model path (Ollama). --model defaults to qwen2.5:7b (a small, reliable tool-caller).
sentinel setup
sentinel setup --leash naomi --model qwen2.5:7b --yes      # non-interactive local

# Bring-your-own-key (no GPU): wire a hosted model end-to-end in one step. Provider-agnostic
# (openai | anthropic | gemini). Pass the key via env (preferred; --api-key is ps-visible) or
# a TTY prompt. Creates the Muzzle upstream + loopback listener + tool_calls override, adds a
# model alias, and binds the head agent to it. An explicit --model (the provider's id) is required.
SENTINEL_MODEL_API_KEY=sk-... sentinel setup --provider openai --model gpt-4o-mini --yes

# Register <user> with Leash + ensure Leash running (no model backend).
sentinel init --leash <user> --muzzle

install.sh verifies (and can install) Muzzle + Leash, creates the leashed user, writes /etc/sentinel/sentinel.yaml, and installs the systemd units. Headless installs default the head agent/user to naomi. Setup ends by printing the console URL + login token and then running sentinel doctor (below) to confirm the box is healthy.

sentinel doctor — run this first when anything's off

sentinel doctor            # full check incl. a real end-to-end model ping
sentinel doctor --no-probe # skip the (slower) model call

Checks, each with the exact fix printed on failure (exit non-zero if any FAIL): models configured, Muzzle up, Muzzle allows tool_calls (an unset policy defaults to block and silently drops every agent tool call — the #1 first-run killer), model providers wired as Muzzle upstreams, an end-to-end backend ping (catches a dead upstream / an empty-reply model), Leash egress, the broker socket, and the console token. If backend fails with an "invalid model" error, your models[0] alias points at a model the backend doesn't serve — fix the alias/model id or reorder models so a served one is first.

Where everything lives

PathWhat
/etc/sentinel/sentinel.yamlThe main config (see below).
/etc/sentinel/web.envSENTINEL_WEB_TOKEN=<hex> — the bearer token for the web/API. 0600.
~/.sentinel/ or <data_root>/.sentinel/State dir (see data_root below). Contains:
…/agents/<name>.yamlAgent profiles. <name>.soul.md = self-edited persona.
…/agents/<name>/workspace/An agent's private workspace (its runs' cwd).
…/teams/<name>/{team.yaml,workspace/,guidelines.md}Teams + shared team workspace.
…/memory/<scope>/{index.md,vault/}Per-agent (or shared-scope) memory bank.
…/runs/<id>/Per-run state, events, messages.
…/mcp/<name>/server.yamlMCP server definitions (0600).
…/cron/ …/tools/uploads/ …/datasources/Cron jobs, uploaded tools, datasources.
<data_root>/workspace/The shared leashed workspace (naomi-scope runs write here).

data_root: unset → state is in the leashed user's $HOME (~/.sentinel). Set (e.g. /var/lib/sentinel-home) → relocated there (needed to enable ProtectHome). On a relocated box the state dir is <data_root>/.sentinel.

Configuration — /etc/sentinel/sentinel.yaml

Full schema (defaults shown). Edit as root, then restart the services.

leashed_user: naomi              # REQUIRED: the Linux user agents run as
data_root: /var/lib/sentinel-home   # optional; unset = leashed user's home
profile: development             # development | production (enforcement strictness)
muzzle_enabled: true
muzzle_endpoint: http://127.0.0.1:11434   # the Muzzle Ollama-passthrough listener

# Model aliases. An agent's `model` field references one of these `name`s, OR a raw tag.
models:
  - name: gpt-oss                # alias used by agents
    provider: openai             # ollama | openai | anthropic | gemini
    model: gpt-oss:20b           # the real model id sent upstream
    endpoint: http://127.0.0.1:11437   # for provider!=ollama: the per-provider Muzzle listener
  - name: local
    provider: ollama
    model: llama3.1

default_agent: main              # head agent name (naomi scope). Shown in the console.
default_soul: assistant
default_leader_model: ""         # tiered defaults: team-leader/naomi tier
default_worker_model: ""         # member/agent tier
reasoning_default: true          # send reasoning/thinking to reasoning-capable models

# Tool approval policy: tool_name -> auto | needs_approval | deny
tools:
  write_file: needs_approval
  run_command: needs_approval

subagent_disposition: discard    # discard | save | ask (temp subagents after a run)
tool_routing: off                # off | keyword (trim advertised tools for small models)
max_parallel_members: 4          # team fan-out concurrency
max_delegations_per_round: 16
fs_sandbox: true                 # per-run Landlock (skipped for CLI-runtime agents)
run_memory_max: null             # per-run cgroup memory.max, e.g. "2G" (null = off)
run_tasks_max: null              # per-run pids.max
run_mirror: true                 # mirror runs into <state>/sentinel.db for fast listing
auto_resume: false               # re-drive a crashed run's owner-dead orphan
backup_retention: 7

CLI-runtime binaries (codex/claude-code agents) are located via env, forwarded to the worker: set SENTINEL_CODEX_BIN / SENTINEL_CLAUDE_BIN in the service environment (a systemd drop-in) — the leashed user must be logged into that CLI.

Extra writable paths — SENTINEL_EXTRA_RW (colon-separated absolute paths, same systemd drop-in channel) adds operator-granted read-write paths to every run's Landlock ruleset. This is the escape hatch for shared data a run must WRITE that lives outside the state tree (a bind-mounted Obsidian vault, project checkouts owned by another user). Unset = no change. It is forwarded to the worker only — untrusted tool subprocesses never see it, and a worker cannot use it to widen its own boundary (Landlock rulesets intersect). Entries are normalized before use, so the path granted is the path reported in the run's attestation; relative entries and / itself are dropped rather than guessed at.

Services (systemd)

UnitRole
sentinel-webThe web console + HTTP API on :8700. Drives runs in-process.
sentinel-apiThe programmable per-endpoint HTTP API on :8701. Also drives runs.
sentinel-cronScheduled jobs.
sentinelSupervisor daemon — idles; does NOT drive runs.
sentinel-brokerThe ONLY root service. Mutates Leash egress policy via a narrow socket.
sudo systemctl restart sentinel-web sentinel-api   # ALWAYS restart via systemctl
sudo systemctl status sentinel-web
# Verify the socket owner PID == the unit's MainPID:
sudo ss -ltnHp 'sport = :8700'; systemctl show -p MainPID --value sentinel-web

Exposing the console safely (TLS)

The console is auth'd by a single bearer token, and the SSE stream carries that token in a ?token= query param (EventSource can't set headers), so never bind a non-loopback host over plaintext. sentinel web/sentinel api refuse a non-loopback bind unless the traffic is encrypted. Three ways to expose it:

# 1. Loopback + SSH tunnel (default, zero config): bind 127.0.0.1, then from your laptop
ssh -L 8700:127.0.0.1:8700 <box>     # open http://127.0.0.1:8700 locally

# 2. Built-in TLS with your own cert (a real cert, or one from certbot/mkcert):
sentinel web --host 0.0.0.0 --tls-cert /etc/sentinel/tls/console.crt --tls-key /etc/sentinel/tls/console.key

# 3. Built-in TLS, quick self-signed (encrypted; browser warns "untrusted" — fine for a lab):
sentinel web --host 0.0.0.0 --tls-selfsigned      # cert written under <state>/tls/

# 4. A TLS reverse proxy (Caddy/nginx + Let's Encrypt) in front of loopback :8700, then:
sentinel web --host 0.0.0.0 --allow-external       # declares TLS/a trusted proxy is in front

A non-loopback bind with TLS (--tls-*) needs no --allow-external; --allow-external is only the "I have a TLS proxy in front" escape hatch for a plaintext bind. Multi-user auth (RBAC/SSO) is not yet built — treat the token as root-equivalent and keep the host trusted.

A run inherits the web/api unit's systemd confinement (cgroup, ProtectHome, etc.), so resource/isolation directives belong on those units, not on the idle supervisor.

The sentinel CLI

Run agent-affecting commands as the leashed user (sudo -u <user> sentinel …) so tools execute under that account and write to its workspace. Top-level commands:

CommandPurpose
init / setupFirst-time install/config (see above). setup --provider … for a hosted key.
doctor [--no-probe]Actionable health check (models, Muzzle+tool_calls, backend ping, leash, token).
status / preflightConfig+service summary / gate the box against its security profile.
chat [--new] / chats / restore <id>Interactive REPL; durable, auto-resumes latest.
run --agent <name> "<task>"One headless run.
runs / show <id>List / inspect runs.
approvals / approve <id> / deny <id>The async approval queue.
resume <id>Continue a paused run.
agent create/list/show/add-skill/set-modelManage the roster.
team …Manage teams (leader + members).
workflow create/list/show/runNamed multi-step YAML pipelines.
mcp add/list/remove/testMCP servers (tools).
model add/list/removeModel aliases (also editable in the console).
datasource add/list/remove/testExternal datasources (files/sqlite/postgres/mongo).
memory link/unlink/syncObsidian-vault memory linking/sync.
cron add/list/remove/runScheduled runs (supports --timezone <IANA>).
import openclaw <path> / import hermes <path>Migrate an OpenClaw/Hermes assistant.
package export/importExport/import an agent or team as a secret-free .sentinelpkg.
tools install-reqsLeashed pip install of an uploaded tool's requirements.
backup / restore-backupState backup/restore.
status / logsHealth + logs.
web / apiRun the web console / programmable API.

Driving Sentinel over HTTP (the API you'll usually want)

Base: http://127.0.0.1:8700. Auth: Authorization: Bearer $SENTINEL_WEB_TOKEN.

TOK=$(sudo grep -oP 'SENTINEL_WEB_TOKEN=\K.*' /etc/sentinel/web.env)
H=(-H "Authorization: Bearer $TOK" -H 'Content-Type: application/json')

# Who is the head agent
curl -s "${H[@]}" http://127.0.0.1:8700/api/whoami

# Start a run. scope: omit/"naomi" (head), "agent:<name>", or "team:<name>".
rid=$(curl -s "${H[@]}" -d '{"scope":"agent:backend-dev","message":"build X"}' \
  http://127.0.0.1:8700/api/chat | jq -r .run_id)

# Poll it; read messages + events
curl -s "${H[@]}" http://127.0.0.1:8700/api/runs/$rid   # {status, messages, events, scope, ...}

# Approvals
curl -s "${H[@]}" http://127.0.0.1:8700/api/approvals
curl -s "${H[@]}" -X POST http://127.0.0.1:8700/api/runs/$rid/cancel

# Steer a BUSY run: the text is injected as a user message at the worker's next
# step boundary (before its next model/tool call). {queued:false} on a terminal
# run (just send normally); {waiting_input:true} when a question card is pending.
curl -s "${H[@]}" -d '{"message":"also include the error log"}' \
  http://127.0.0.1:8700/api/runs/$rid/steer

# Manage agents / models / files
curl -s "${H[@]}" http://127.0.0.1:8700/api/agents
curl -s "${H[@]}" http://127.0.0.1:8700/api/models
curl -s "${H[@]}" 'http://127.0.0.1:8700/api/workspace/list?root=agent:backend-dev&path='

Agents (the roster)

An agent is …/agents/<name>.yaml:

name: backend-dev
description: Backend Python specialist
soul: "You are a careful backend engineer…"   # persona / system prompt
model: gpt-oss           # a config.models name, a raw tag, or "codex"/"claude-code"
cli_model: ""            # CLI-runtime only: the underlying model (e.g. gpt-5-codex, sonnet)
cli_reasoning: ""        # CLI-runtime only: minimal|low|medium|high
skills: [file-discipline]
tools: ["*"]             # ["*"]=all, []=none, or a list incl. mcp__<server>__<tool>
memory: ""               # bank scope; ""=private (own name), shared name = shared bank
workspace: ""            # ""=<state>/agents/<name>/workspace

Create via API: POST /api/agents {name, model, soul, cli_model?, cli_reasoning?, tools, …}; edit: PUT /api/agents/<name>. Or sentinel agent create <name> --model <m>.

Memory: agents persist via the tool-based bank — remember(title, content) / recall(query) (lexical, no embeddings), plus self-state edit_soul / update_user (head agent). Soul + operator model + latest memory are injected into the system prompt each turn.

CLI-runtime agents (Codex / Claude Code)

Set model: codex or model: claude-code. The agent runs that CLI's own agentic loop with its native tools (shell/edit/web) on your subscription; granted Sentinel tools are injected as MCP. Enforcement is OS-level only (leashed user + Leash + cgroup + workspace cwd) — the per-run Landlock/seccomp is skipped (it conflicts with the CLI's own sandbox). The step trace (reasoning, tool calls, output) is surfaced as run events / an "Agent steps" panel.

Requirements: the CLI must be installed AND logged in as the leashed user, and allowed to exec under Leash; set SENTINEL_CODEX_BIN/SENTINEL_CLAUDE_BIN. Configure the underlying model + reasoning with cli_model + cli_reasoning. A CLI agent cannot be a team leader (it can't call Sentinel's delegate tools) — use it as a team member with a normal-model leader. The leader's model must support Ollama "thinking" if reasoning_default: true.

Teams

…/teams/<name>/team.yaml: {name, leader, members:[…]} (the leader must be in members). The head delegates to a team → the leader fans out to members (each a scoped, sandboxed, approval-gated child run) → synthesizes. Members share …/teams/<name>/workspace/. Trigger: POST /api/chat {scope:"team:<name>", message}.

Common tasks (recipes)

# Add an external (OpenAI-compatible via Muzzle) model alias, then use it
curl -s "${H[@]}" -d '{"name":"smart","provider":"openai","model":"gpt-oss:20b","endpoint":"http://127.0.0.1:11437"}' \
  http://127.0.0.1:8700/api/models

# Make a Codex agent on high reasoning
curl -s "${H[@]}" -d '{"name":"rex","model":"codex","cli_reasoning":"high","soul":"backend engineer"}' \
  http://127.0.0.1:8700/api/agents

# Grant an agent an MCP tool (its tools list names it mcp__<server>__<tool>)
curl -s "${H[@]}" -X PUT -d '{"tools":["mcp__sentinel-uploads__mytool"]}' \
  http://127.0.0.1:8700/api/agents/rex

# Browse/upload files in an agent's workspace (path-jailed)
curl -s "${H[@]}" 'http://127.0.0.1:8700/api/workspace/roots'
curl -s "${H[@]}" -F root=agent:rex -F path= -F file=@./x.py http://127.0.0.1:8700/api/workspace/upload

# Change the tool approval policy (edit config, then restart)
sudoedit /etc/sentinel/sentinel.yaml   # set tools: { run_command: auto }
sudo systemctl restart sentinel-web sentinel-api

Gotchas & failure modes

  • Restart only via systemctl. A manual kill leaves the enabled unit dead (no self-heal). systemd expands --token ${SENTINEL_WEB_TOKEN} into argv, so seeing it in ps does NOT mean a manual launch.
  • Deploying to a non-editable venv install: the running services import the installed copy under .venv/.../site-packages/sentinel/, NOT the source tree — update both (and clear __pycache__) or reinstall.
  • A mid-flight run is auto-failed on the driving service's restart (crash recovery) — intended, not a bug. Parked (approval/input) runs survive.
  • Reasoning vs the model: with reasoning_default: true, a model that doesn't support Ollama "thinking" returns 400 does not support thinking. Pick a thinking-capable model or turn reasoning off for it.
  • CLI-runtime "not installed for the leashed user": the CLI isn't on the worker's PATH or SENTINEL_*_BIN isn't forwarded, or it's logged in as the wrong user.
  • Muzzle policy tool_calls unset defaults to BLOCK — it silently drops tool calls from Ollama-backed agents. Set it to allow (a Muzzle concern; see the Muzzle manual).

Integrations

  • Muzzle — every model call goes through a Muzzle listener (muzzle_endpoint for Ollama; per-provider listeners for external models). Provider keys live only in Muzzle.
  • Leash — network egress is default-deny; Sentinel's root broker calls leash_allow to permit a host (model provider, MCP server, git remote, datasource). See the Leash manual.
  • Learner — produces SKILL.md playbooks importable via POST /api/skills/upload.

Quick reference

Config:    /etc/sentinel/sentinel.yaml        Token:  /etc/sentinel/web.env
State:     ~/.sentinel or <data_root>/.sentinel
Services:  systemctl restart sentinel-web sentinel-api    (:8700 web, :8701 api)
Run as:    sudo -u <leashed_user> sentinel <cmd>
API auth:  Authorization: Bearer $SENTINEL_WEB_TOKEN
Start run: POST /api/chat {scope, message}   scope = naomi | agent:<n> | team:<n>

What confines YOU (read this before reporting "nothing found")

Your run has a scope, and it — not your config — decides your filesystem reach:

ScopeReadWrite
naomi (head)/its workspace, the state dir, /tmp, /dev/null
agent:<name>system dirs, skills, tools, its OWN config filesits workspace, its memory bank, the run dir, /tmp, /dev/null
team:<name>the above + the team dirthe above + the shared team workspace

A confined scope has no state dir and no /. If a scan comes back empty, check the scope before concluding the data is absent — a run scoped agent:<name> once reported "no indexed content" for a 2,180-file vault it simply could not see, and diagnosed it as broken infrastructure. Every run emits fs_sandbox and security_attestation events recording the confinement it actually had; read those rather than guessing.

Shelling out is what breaks. An authored tool inherits the run's Landlock and seccomp, so file I/O inside the scope works normally while subprocess to something outside it fails. It does not raise usefully — it looks like "nothing found". A tool once reported 0 git worktrees while 76 existed, and an agent relayed that all-clear to a team channel. Sentinel now appends a warning to any tool result whose commands failed: treat a count or a "none found" in a flagged result as unverified, and say the check could not complete.

Credentials an authored tool reads must live under tools_dir; anywhere else under the state dir fails [Errno 13] in a confined run.

If you ARE a CLI runtime (codex / claude-code)

You drive your own agentic loop and bring your own sandbox, so Landlock and seccomp are skipped for your run and your model traffic does not pass through Muzzle. Still in force: the leashed user, Leash's egress enforcement, the per-run cgroup, and your workspace as cwd. Your granted Sentinel tools reach you as MCP servers.

Two consequences worth holding: your filesystem reach is the leashed user's, not a per-run Landlock ruleset — so you can see more than a confined non-runtime agent, and should not treat that as licence — and nothing inspects your model I/O, so the content guardrails an operator configured do not apply to you.

A strict profile refuses to run you at all unless the operator set allow_cli_runtime: true, and each run's attestation records cli_runtime and muzzle_inspected: false.

Which lever actually gates a tool

Tool kindWhat decides it
Built-in introspectionalways auto
Authored (sentinel tool)code trust — approve once, then auto
MCP / datasourcethe config.tools per-tool policy map

config.tools is not consulted for authored tools — an entry there is inert, and a run that calls one records a tool_policy_ignored event saying so. interactive_only is a first-class value meaning "prompt a human, never run unattended". MCP tools are skipped in unattended runs unless their server is marked unattended. A tool that is a read of changing state (polling an async run) should declare "repeatable": true, or its second identical call is skipped as a loop.

Diff from previous

--- forthebots@5
+++ forthebots@6
@@ -31,6 +31,11 @@
 
 - **Linux only** (Leash is Linux-only). Needs **Muzzle** and **Leash** installed on the
   same host, plus a dedicated Linux user for agents to run as.
+- On **macOS** the same install runs inside a managed Lima VM shipped by the desktop app —
+  same artifact, same installer, same `production` profile, so it IS a Linux install. The
+  host `sentinel` has no config there; every command goes through
+  `sentinel-runtime cli <command>`, which forwards into the guest. Anything in this
+  document works on a Mac by adding that prefix.
 - Python managed with `uv`; the package installs into a venv (commonly `/opt/sentinel/v1/.venv`).
 
 ```bash
@@ -362,3 +367,59 @@
 API auth:  Authorization: Bearer $SENTINEL_WEB_TOKEN
 Start run: POST /api/chat {scope, message}   scope = naomi | agent:<n> | team:<n>
 ```
+
+## What confines YOU (read this before reporting "nothing found")
+
+Your run has a **scope**, and it — not your config — decides your filesystem reach:
+
+| Scope | Read | Write |
+|---|---|---|
+| `naomi` (head) | `/` | its workspace, the state dir, `/tmp`, `/dev/null` |
+| `agent:<name>` | system dirs, skills, tools, its OWN config files | its workspace, its memory bank, the run dir, `/tmp`, `/dev/null` |
+| `team:<name>` | the above + the team dir | the above + the shared team workspace |
+
+A confined scope has **no state dir and no `/`**. If a scan comes back empty, check the
+scope before concluding the data is absent — a run scoped `agent:<name>` once reported
+"no indexed content" for a 2,180-file vault it simply could not see, and diagnosed it as
+broken infrastructure. Every run emits `fs_sandbox` and `security_attestation` events
+recording the confinement it actually had; read those rather than guessing.
+
+**Shelling out is what breaks.** An authored tool inherits the run's Landlock and seccomp,
+so file I/O inside the scope works normally while `subprocess` to something outside it
+fails. It does not raise usefully — it looks like "nothing found". A tool once reported 0
+git worktrees while 76 existed, and an agent relayed that all-clear to a team channel.
+Sentinel now appends a warning to any tool result whose commands failed: **treat a count or
+a "none found" in a flagged result as unverified, and say the check could not complete.**
+
+Credentials an authored tool reads must live under `tools_dir`; anywhere else under the
+state dir fails `[Errno 13]` in a confined run.
+
+## If you ARE a CLI runtime (`codex` / `claude-code`)
+
+You drive your own agentic loop and bring your own sandbox, so **Landlock and seccomp are
+skipped for your run and your model traffic does not pass through Muzzle**. Still in force:
+the leashed user, Leash's egress enforcement, the per-run cgroup, and your workspace as cwd.
+Your granted Sentinel tools reach you as MCP servers.
+
+Two consequences worth holding: your filesystem reach is the leashed user's, not a
+per-run Landlock ruleset — so you can see more than a confined non-runtime agent, and should
+not treat that as licence — and nothing inspects your model I/O, so the content guardrails
+an operator configured do not apply to you.
+
+A strict profile refuses to run you at all unless the operator set `allow_cli_runtime: true`,
+and each run's attestation records `cli_runtime` and `muzzle_inspected: false`.
+
+## Which lever actually gates a tool
+
+| Tool kind | What decides it |
+|---|---|
+| Built-in introspection | always auto |
+| **Authored** (`sentinel tool`) | **code trust** — approve once, then auto |
+| MCP / datasource | the `config.tools` per-tool policy map |
+
+`config.tools` is **not consulted for authored tools** — an entry there is inert, and a run
+that calls one records a `tool_policy_ignored` event saying so. `interactive_only` is a
+first-class value meaning "prompt a human, never run unattended". MCP tools are skipped in
+unattended runs unless their server is marked `unattended`. A tool that is a *read of
+changing state* (polling an async run) should declare `"repeatable": true`, or its second
+identical call is skipped as a loop.