v0.1.0GitHubnpm
Concepts

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.

permissions.ts
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:

  1. Input validation. A malformed PendingToolCall (missing tool_use_id, non-object input, missing tool methods) returns deny immediately.

  2. Rules, in array order, first match wins. The engine scans opts.rules from index 0. A ToolRule or PathRule that matches returns its decision right away — no later rule is consulted. The first time a BashRule is reached for a Bash call, the engine evaluates all subsequent bash rules against every shell segment (split on &&, ||, ;, |) and returns deny if any segment is denied, allow if every segment is allowed, otherwise falls through.

  3. Mode default. If no rule matched, the mode supplies a baseline: read-scope tools and task tools always allow; yolo allows everything else; acceptEdits allows write-scope tools; otherwise ask.

  4. canUseTool. Only invoked when the result is still ask. If the callback is missing the call is denied (fail-closed). The callback receives an AbortSignal; aborting before or during the call denies the request. A thrown callback denies. A malformed response denies. An updatedInput field is re-validated through the tool’s validate() 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.

Moderead scopewrite scope (Write, Edit)exec scope (Bash, Skill, subagent)
"default"allowaskask
"acceptEdits"allowallowask
"yolo"allowallowallow

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

FieldTypeNotes
kind"tool"Required.
toolstringExact tool name (case-sensitive) or "*" to match any tool. For MCP tools, use the full mcp__<server>__<tool> form.
argstring?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.

FieldTypeNotes
kind"path"Required.
toolsstring[]?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.
pathsstring[]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.

FieldTypeNotes
kind"bash"Required.
patternstring | { 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.

PatternMatches
src/**/*.tsAny 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:

canUseTool.ts
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 };
  • summary is the tool’s own one-line description of the call, produced by tool.summarize(input) before the callback fires. Use it for confirmation prompts.
  • tool_use_id is stable across the permission_request event, this callback, and the eventual tool_call_start / tool_call_end pair — correlate UI state by it.
  • The promise is awaited. The signal argument is the same AbortSignal the 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 unknown behavior, with a non-string message on a deny, or with a non-object updatedInput is treated as malformed and denied.
  • If you return updatedInput the engine runs it through tool.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:

event.ts
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.

read-only-agent.ts
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.

accept-edits-agent.ts
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.

mcp-rules.ts
// 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.

no-network.ts
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.

workspace-scoped.ts
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).

allowlist.ts
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" },
];