Language: English | 简体中文
Every registered tool call passes through one central authorization
gate before it runs. Native, btw, Format, and MCP tools are built in
bypass mode; the gate installed on chat$on_tool_request is
the sole permission authority. Gate installation fails closed if that
callback cannot be registered.
How the gate decides
model requests a tool
|
v
chat$on_tool_request -> central gate (.tool_gate_fn)
|
+- Data Shield ingress scan (when a shield is active; every tool)
| block/error -> PermissionDenied -> ellmer::tool_reject()
|
+- capability == read AND no per-tool override AND no Shield ask?
| yes -> ALLOW at the gate (fast path)
|
v .gate_decide() (highest precedence first)
1. settings$tools$overrides[tool] -> allow / deny / ask
2. settings$tools$capabilities[class] -> write|exec|net -> allow/deny/ask
3. fallback -> check_permission(mode, rules):
plan -> deny non-read before checking rules
user rules -> first matching glob wins
accept_edits -> edit tools allow
bypass -> allow
bubble -> ask (parent/host resolves it)
dont_ask -> read-only allow, otherwise deny
auto -> fast-model classifier -> allow/deny/ask
default -> read-only and recognized read-only Bash allow;
otherwise ask
|
v
decision --+-- deny -> PermissionDenied -> ellmer::tool_reject()
+-- ask -> ask_fn(): CLI console prompt or async Shiny bar
| reject/error/no ask_fn -> deny
| approve -> continue
+-- allow ---------------------------> continue
|
v
PreToolUse wrapper: deny or rewrite arguments;
rewritten arguments are rechecked by gate/Shield
|
v
tool runs -> on_tool_result -> PostToolUse hook
The read fast path is deliberate: a read-capability tool with no
explicit per-tool override is allowed after the Shield scan, before
capability policy, rules, or mode fallback. Use
settings$tools$overrides when a read tool must be asked
about or denied. Unknown host tools default to the read
capability for compatibility, so hosts should declare them with
register_tool_meta() or tool_meta when
installing the gate.
settings$tools$sets ("A" = codeagent core,
"B" = btw) controls which tool sets are registered. It is
registration policy, not a per-call decision.
Modes
These are fallback behaviors. A per-tool override or non-read capability policy can supersede them.
| Mode | Fallback behavior |
|---|---|
default |
Read-only tools and recognized read-only Bash commands are allowed; other calls ask |
plan |
Non-read calls are denied before user rules; read tools are allowed |
accept_edits |
File edit tools are allowed; other non-read calls still ask |
bypass |
All calls are allowed (use with care) |
dont_ask |
Read-only calls are allowed; non-read calls are denied, which suits unattended runs |
auto |
The configured fast model classifies the call as allow, deny, or ask |
bubble |
Returns ask so a parent agent or host approval callback can decide |
client <- codeagent_client(chat, permission_mode = "default")Fine-grained rules
A PermissionRule first glob-matches the tool name, then
optionally matches the relevant argument: command for Bash,
file_path for Read/Write/Edit/MultiEdit, and
pattern for Glob/Grep. Matching is case-sensitive and the
first match wins. Rules supplied directly to
codeagent_client(rules=) precede rules loaded from
settings.
Settings arrays are converted in allow, then
deny, then ask order. Avoid overlapping
patterns: a deny entry does not automatically outrank an earlier allow
entry. Rules are reached only after per-tool and capability policy, and
not by the read fast path described above.
Public signatures and defaults
PermissionRule(
tool_name,
behavior = c("allow", "deny", "ask"),
source = "session",
rule_content = NULL
)
check_permission(
tool_name,
mode = "default",
rules = list(),
tool_input = NULL
)
install_permission_gate(
chat,
permission_mode = "default",
rules = list(),
tools = list(),
ask_fn = NULL,
tool_meta = list()
)
codeagent_client(
chat = NULL,
permission_mode = "default",
rules = list(),
cwd = getwd(),
max_turns = 100L,
btw_groups = NULL,
worktree_isolation = FALSE,
verify_fn = NULL,
mcp_config = NULL,
register_tools = TRUE,
data_shield = NULL,
max_budget_usd = NULL
)install_permission_gate() is for attaching the central
gate to an existing ellmer::Chat. tools has
the same sets / capabilities /
overrides shape as settings$tools;
tool_meta is a named tool-to-capability list. In normal
codeagent use, codeagent_client() loads settings and
installs the gate.
Interactive approval
In default mode, the Shiny app displays an Allow/Deny
bar above the composer when a call resolves to ask;
execution resumes when the promise resolves. The CLI uses a synchronous
console prompt. Failure, rejection, or a missing ask_fn is
denial. AskUserQuestion uses a separate async question bar
to pause for a clarifying answer.
Hooks and rewritten input
PreToolUse does not make the gate’s initial decision. It
runs once in the tool wrapper after gate authorization (and therefore
after any human approval), but before execution. It may deny the call or
return updatedInput; rewritten arguments are checked again
against the live permission policy and Data Shield, and an
ask result with no second approval path is denied.
PermissionDenied fires on gate denial, and
PostToolUse fires from on_tool_result after
execution.
This ordering means a PreToolUse veto still prevents
execution, but it does not prevent an approval prompt from first being
shown.
Dangerous approvals and defense in depth
If an operator approves Bash: rm -rf ./data, approval
alone does not establish that the operation is safe. Available veto
points have different timing:
| Layer | Timing and limitation |
|---|---|
settings$tools$overrides deny |
Denies before prompting; use this for unconditional per-tool policy, including read tools |
A deny PermissionRule
|
Absolute deny before allow/ask rules, per-tool overrides, capability allows, mode shortcuts, and the read fast path |
| Data Shield ingress/tool policy with a blocking result | Runs before the read fast path and before prompting |
PreToolUse returning deny |
Runs after gate/human authorization but still before tool execution |
These controls are syntactic or policy-based defense in depth. For
example, Bash(rm -rf *) does not catch equivalent behavior
routed through RunR,
unlink(..., recursive = TRUE), another destructive command,
runtime string construction, or a script written in one call and
executed in another.
A semantic reviewer of operation intent could raise the bar but would
remain model-based. A full OS-level sandbox with read-only or
scoped-writable mounts would enforce the boundary independently of
command spelling; the current portable shield_sandbox()
policy backend is not such a kernel-enforced adapter. A curated
cross-language destructive-operation strategy remains an open design
question, not an implemented guarantee.