Skip to contents

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.

{
  "permissions": {
    "allow": ["Bash(git status)", "Read(*)"],
    "deny":  ["Bash(rm -rf *)"],
    "ask":   ["Write(*)"],
    "defaultMode": "default"
  }
}

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.