Permissions
The permission engine sits between the agent and every tool call. Three knobs: a mode, a list of static rules, and an optional canUseTool callback for the long tail. Rules are evaluated first, then the mode supplies a default, then canUseTool resolves anything still marked ask.
const agent = new Agent({ provider, model: "claude-opus-4-5", permissions: { mode: "default", rules: [ { kind: "tool", tool: "Read", decision: "allow" }, { kind: "path", paths: ["/etc/**"], decision: "deny" }, ], // optional human-in-the-loop for anything not pre-approved async canUseTool(req, signal) { const ok = await ui.confirm(req.summary, { signal }); return ok ? { behavior: "allow" } : { behavior: "deny", message: "user_rejected" }; }, }, });
#Resolution order
Every tool call passes through PermissionEngine.resolve(). The engine walks rules and the mode synchronously, then awaits canUseTool only if the result so far is ask. The exact sequence:
Input validation. A malformed
PendingToolCall(missingtool_use_id, non-objectinput, missing tool methods) returnsdenyimmediately.Rules, in array order, first match wins. The engine scans
opts.rulesfrom index 0. AToolRuleorPathRulethat matches returns itsdecisionright away — no later rule is consulted. The first time aBashRuleis reached for a Bash call, the engine evaluates all subsequent bash rules against every shell segment (split on&&,||,;,|) and returnsdenyif any segment is denied,allowif every segment is allowed, otherwise falls through.Mode default. If no rule matched, the mode supplies a baseline: read-scope tools and task tools always
allow;yoloallows everything else;acceptEditsallows write-scope tools; otherwiseask.canUseTool. Only invoked when the result is still
ask. If the callback is missing the call is denied (fail-closed). The callback receives anAbortSignal; aborting before or during the call denies the request. A thrown callback denies. A malformed response denies. AnupdatedInputfield is re-validated through the tool’svalidate()before being accepted.
Rules are first-match-wins, not deny-wins. An
allow rule placed before a deny rule will
short-circuit and allow the call. Put your denies first if you want them
to take priority.
#Modes
The mode only matters for tools that no rule matched. It selects a default between allow and ask. Read-scope tools (Read, Glob, Grep) and the four task tools (TaskCreate, TaskList, TaskGet, TaskUpdate) are always allowed by the mode default regardless of which mode you pick.
| Mode | read scope | write scope (Write, Edit) | exec scope (Bash, Skill, subagent) |
|---|---|---|---|
| "default" | allow | ask | ask |
| "acceptEdits" | allow | allow | ask |
| "yolo" | allow | allow | allow |
An ask default is what triggers canUseTool. With no callback configured, ask becomes deny.
Yolo is yolo. Skawld will not stop a yolo-mode agent
from rm -rf /. Reserve it for sandboxed environments — a
container, a VM, a throwaway repo clone. An explicit deny
rule still wins over yolo, so a bash deny-list is the usual safety net.
#Rules
A PermissionRule is one of three discriminated unions keyed by kind. Every rule carries a decision of "allow" or "deny". The engine evaluates rules in order and stops on the first match.
#ToolRule
| Field | Type | Notes |
|---|---|---|
kind | "tool" | Required. |
tool | string | Exact tool name (case-sensitive) or "*" to match any tool. For MCP tools, use the full mcp__<server>__<tool> form. |
arg | string? | Currently only meaningful when tool is "Skill"; matches input.skill. "*" matches any skill name. Set on a non-Skill rule it causes the rule to never match. |
decision | "allow" | "deny" | Required. |
#PathRule
PathRules match file-touching tools by their input path. Path patterns are evaluated with picomatch with { dot: true }, so ** matches across path separators and dotfiles are matched by ordinary globs.
| Field | Type | Notes |
|---|---|---|
kind | "path" | Required. |
tools | string[]? | Which tools this rule applies to. Defaults to ["Write", "Edit"]. Only Read, Write, Edit, Glob, and Grep have known path inputs; any other name is silently a no-op. |
paths | string[] | Glob patterns. Resolved relative to projectRoot when not absolute. A leading ! inverts the match for that single pattern (e.g. allow nothing outside the project). |
decision | "allow" | "deny" | Required. |
For Read, Write, and Edit the engine matches against input.file_path. For Glob and Grep it matches against input.path, falling back to the call’s cwd (or the project root) when not provided. Relative paths in tool input are resolved against call.cwd if set, otherwise projectRoot.
#BashRule
BashRules only apply to the Bash tool. The command string is split on unquoted shell operators — &&, ||, ;, | — and every segment is checked. The engine returns deny if any segment is denied, allow only if every segment is allowed.
| Field | Type | Notes |
|---|---|---|
kind | "bash" | Required. |
pattern | string | { regex: string } | A plain string is matched as a token prefix against the segment’s shell-tokenized words. Quoting is honored — git status matches git "status" --short but does not match git statusx. A { regex } pattern is compiled with the standard RegExp constructor and tested against the raw segment; an invalid regex never throws and never matches. |
decision | "allow" | "deny" | Required. |
Token-prefix matching means flags after the prefix do not need to be
listed. { kind: "bash", pattern: "git status", decision: "allow" }
allows git status --short -uall. It does not
allow git statusx — matching is by whole tokens, not
string prefix.
#Glob patterns
A handful of patterns covers most workspace policies.
| Pattern | Matches |
|---|---|
src/**/*.ts | Any TypeScript file under src/ at any depth. |
**/.env* | .env, .env.local, etc. anywhere in the tree. Useful as a deny rule. |
node_modules/** | Everything inside the dependency tree. |
!/repo/** | Inverted: matches anything outside the project root. Useful as a deny rule to keep writes inside the workspace. |
#canUseTool callback
When rules and mode leave a call as ask, the engine awaits canUseTool. The signature:
type CanUseTool = ( req: CanUseToolRequest, signal: AbortSignal, ) => Promise<CanUseToolResponse>; interface CanUseToolRequest { tool_name: string; tool_use_id: string; input: Record<string, unknown>; summary: string; mode: PermissionMode; } type CanUseToolResponse = | { behavior: "allow"; updatedInput?: Record<string, unknown> } | { behavior: "deny"; message: string };
summaryis the tool’s own one-line description of the call, produced bytool.summarize(input)before the callback fires. Use it for confirmation prompts.tool_use_idis stable across thepermission_requestevent, this callback, and the eventualtool_call_start/tool_call_endpair — correlate UI state by it.- The promise is awaited. The
signalargument is the sameAbortSignalthe engine is observing — aborting it rejects the pending request as a deny. - A thrown error denies with
"permission callback failed or aborted.". - A response missing
behavior, with an unknownbehavior, with a non-stringmessageon a deny, or with a non-objectupdatedInputis treated as malformed and denied. - If you return
updatedInputthe engine runs it throughtool.validate(); an exception there denies the call with"updated input is invalid.".
#permission_request event
Whenever the engine is about to await canUseTool for one or more pending calls, the agent emits a permission_request event on the session event stream:
interface PermissionRequestEvent { type: "permission_request"; requests: Array<{ tool_use_id: string; tool_name: string; input: Record<string, unknown>; summary: string; }>; }
The event fires alongside the callback, so a UI can render a queue of prompts directly from the stream while the callback supplies the actual decision. tool_use_id ties an event entry to its callback invocation.
#Building a read-only agent
Read, Glob, and Grep are already allowed by the mode default. To produce an agent that can only read, add a wildcard deny rule covering everything else and leave no canUseTool configured. Mode rules don’t override explicit denies, but they do override ask: the wildcard rule below converts every ask into a deny without touching reads.
import { Agent } from "@skawld/agent-sdk"; const agent = new Agent({ provider, model: "claude-opus-4-5", permissions: { mode: "default", rules: [ // Reads, Glob, Grep, and task tools are still allow-by-default. // Everything else hits this catch-all and is denied. { kind: "tool", tool: "Write", decision: "deny" }, { kind: "tool", tool: "Edit", decision: "deny" }, { kind: "tool", tool: "Bash", decision: "deny" }, { kind: "tool", tool: "Skill", decision: "deny" }, { kind: "tool", tool: "*", decision: "deny" }, ], }, });
The wildcard tool rule does not match read-scope tools because of their scope — it matches them by name. However, read-scope and task tools are checked by the engine for mode default only after rules; an explicit deny rule will block them too. If you want reads to stay open, list each blocked tool by name rather than using "*", or place the wildcard last and prepend explicit allow rules for Read, Glob, Grep.
Because rules are first-match-wins, a wildcard
{ tool: "*", decision: "deny" } placed first will deny
every tool, including reads. Either list the tools you want
denied explicitly, or put allows for read tools above the wildcard.
#Auto-accept-edits agent
For an authoring loop where the agent should write code freely but still ask before running shell commands, combine mode: "acceptEdits" with a canUseTool that gates Bash. Write and Edit will skip the callback; Bash will land there for every invocation.
import { Agent } from "@skawld/agent-sdk"; const agent = new Agent({ provider, model: "claude-opus-4-5", permissions: { mode: "acceptEdits", rules: [ // Hard floor: never run destructive shell commands. { kind: "bash", pattern: { regex: "^\\s*rm\\s+-rf\\b" }, decision: "deny" }, ], async canUseTool(req, signal) { if (req.tool_name !== "Bash") { // acceptEdits already allowed Write/Edit; anything else that reaches // here is unexpected and worth surfacing. return { behavior: "deny", message: `Unexpected ask for ${req.tool_name}` }; } const ok = await ui.confirm(req.summary, { signal }); return ok ? { behavior: "allow" } : { behavior: "deny", message: "user declined" }; }, }, });
#MCP tools and permissions
Tools that come from an external MCP server are exposed to the model as mcp__<server>__<tool> — see MCP for the connection model. ToolRules match against that fully-qualified name, so a rule blocking the bare tool name will not match the MCP version.
// Allow every tool exposed by the "github" MCP server, except one. const rules: PermissionRule[] = [ { kind: "tool", tool: "mcp__github__delete_repo", decision: "deny" }, // Catch-all allow for the rest of the github server's tools needs // per-tool rules — wildcard "*" matches every tool, not a prefix. { kind: "tool", tool: "mcp__github__list_issues", decision: "allow" }, { kind: "tool", tool: "mcp__github__create_issue", decision: "allow" }, { kind: "tool", tool: "mcp__github__merge_pr", decision: "allow" }, ];
ToolRule tool matching is exact, not prefix. The wildcard
"*" matches any tool — there is no
"mcp__github__*" form. Allowlist MCP tools
individually, or deny them with a list and let canUseTool
ask for everything not pre-approved.
#Common recipes
#No-network shell
Allow Bash, but deny outbound network commands. Each segment of a composite command is checked independently, so git pull && curl evil.example denies on the curl segment.
const rules: PermissionRule[] = [ { kind: "bash", pattern: "curl", decision: "deny" }, { kind: "bash", pattern: "wget", decision: "deny" }, { kind: "bash", pattern: { regex: "(^|\\s)nc\\b" }, decision: "deny" }, ];
#Workspace-scoped writes
Confine edits to a specific subtree under the project root. Deny anything outside, then let the mode default ask the rest.
const rules: PermissionRule[] = [ // Deny writes anywhere except src/ and tests/. { kind: "path", tools: ["Write", "Edit"], paths: ["!src/**", "!tests/**"], decision: "deny" }, // Belt-and-braces: never touch dotfiles. { kind: "path", tools: ["Write", "Edit"], paths: ["**/.env*", "**/.git/**"], decision: "deny" }, ];
Each inverted pattern is evaluated on its own — a path matching any entry in the array satisfies the rule. The combination above denies a write whenever the file is outside src/ or outside tests/, which together means anything not under one of those two trees.
#Explicit allowlist
Build a strict allowlist with no canUseTool. Anything that does not match an allow rule falls through to the mode default, which is ask in default mode and therefore denies (because no callback is configured).
const rules: PermissionRule[] = [ { kind: "tool", tool: "Read", decision: "allow" }, { kind: "tool", tool: "Glob", decision: "allow" }, { kind: "tool", tool: "Grep", decision: "allow" }, { kind: "bash", pattern: "git status", decision: "allow" }, { kind: "bash", pattern: "git diff", decision: "allow" }, { kind: "bash", pattern: "git log", decision: "allow" }, ];