Files
Skald-Circle/docs/approval/index.md
T
2026-07-10 15:02:09 +01:00

36 KiB

Approval Gate (Human-in-the-Loop)

Overview

ApprovalManager is a top-level service (in Skald) that intercepts every tool call before execution and decides whether to:

  • Allow — execute freely (no matching rule, or an explicit allow rule)
  • Deny — block immediately (deny rule)
  • Require — suspend and ask the user for confirmation

It is designed to be extensible: multiple notification channels (web, Telegram), granular policies per agent/source/tool, and future support for resuming interrupted sessions.


Architecture

llm_loop.rs
  └─► ApprovalManager.check(session_id, category, agent_id, source, tool_name, args)
        │
        ├─ GateResult::Allow  → execute immediately
        ├─ GateResult::Deny   → fail tool call (not bypassable)
        └─ GateResult::Require
              ├─ (session bypass active?) → GateResult::Allow → execute immediately
              └─► ApprovalManager.register(...)  → (request_id, rx)
                    │  emits ServerEvent::PendingWrite or ApprovalRequired
                    └─► await rx  ← resolved by WS/Telegram via resolve(request_id, decision)

ApprovalManager lives in src/core/approval/mod.rs and is independent of ChatSessionManager.


Permission Groups and RunContext

Rules are scoped to permission groups (tool_permission_groups table). A session's active RunContext references a group via its security_group field; rules in that group take precedence over rules in the "default" group.

For full RunContext documentation (fields, resolution, API, project integration) — see ../session/run-context.md (source of truth).

The "default" group is seeded automatically at startup and cannot be deleted. Its rules can be freely edited.


Rules

Rules are stored in SQLite in the approval_rules table and evaluated in priority ASC order (lower number = evaluated first). The first matching rule determines the action. If no rule matches, the fallback is Require (default-closed policy).

The Default group has a seeded final catch-all require * priority=999999, so it is require-by-default: any tool not matched by a more-specific allow/deny/require rule falls through to it and prompts for human approval. Whitelist (allow) and blacklist (deny) rules layer above the catch-all at lower priority numbers. A handful of benign built-in plumbing/UI tools (write_todos, notify, activate_tools, show_file_to_user, image_generate) are seeded as allow so they don't prompt.

The catch-all is seeded only when absent (at DB init, or if the group has no * rule), so it is never re-created or overwritten on restart — you can change the general rule from the frontend (edit the * row → allow/deny/require) and the choice persists. To prevent the shadowing bug where two * catch-alls with different actions coexist (the lower priority number silently wins), add_rule/update_rule reject adding a second * catch-all with a different action to a group; edit the existing row instead.

Historical note: earlier builds seeded an allow * priority=9999 catch-all (permissive-by-default) via seed_allow_all_default. That could shadow a user-added require * at a higher priority number and let unmatched tools run without approval. It was replaced by seed_default_catch_all (require, only-when-absent).

Table Schema

Column Type Description
id INTEGER PK
agent_id TEXT (nullable) Filter on a specific agent. NULL = all
source TEXT (nullable) Filter on source: web, telegram, cron. NULL = all
tool_pattern TEXT Exact name, glob with * suffix (e.g. mcp__gmail__*), or a @fs_* filesystem class token (see below)
path_pattern TEXT (nullable) Glob on the normalised file path. <dir>/* matches the dir subtree; other patterns use trailing-*/exact. NULL = no path filter
action TEXT require | allow | deny
note TEXT (nullable) Descriptive note
priority INTEGER Evaluation order (default 100; system defaults use 10)
group_id TEXT Permission group this rule belongs to (default: "default")

Pattern Matching

Pattern Matches
execute_cmd only execute_cmd
mcp__gmail__* all tools from the gmail server
mcp__* all MCP tools
* any tool
@fs_read any file-read tool (read_file, grep_files, list_files, search_file, get_ast_outline)
@fs_write any file-write tool (write_file, edit_file, insert_at_line, replace_lines)
@fs_any any filesystem tool (read or write)

The @fs_* filesystem class tokens let a single rule target a whole operation class by path, regardless of the individual tool name. They are resolved by is_file_read_tool / is_file_write_tool in tool_pattern_matches, and back the File System permission panel (one path row = one rule row). See File System category.

The path_pattern field is matched by path_pattern_matches against the normalised path. Normalisation canonicalizes args["path"] (resolving .. and symlinks via tools::fs::canonicalize_for_policy) and makes it relative to the process cwd, so docs/../secrets/x or a symlink into secrets/ cannot evade a rule. A <dir>/* pattern is a directory subtree: it matches the directory node itself (path == "<dir>") and everything under it (path starts with "<dir>/"), using a /-delimited boundary so memory/* matches memory and memory/x but not the sibling memory-secrets/x. Any other pattern falls back to exact / trailing-*. If path_pattern is set but the tool has no path argument, the rule does not match.

Evaluation Order

ApprovalManager.check() runs first; the RunContext filesystem fast-path runs after and can only relax a Require to Allow (never overrides a Deny). Inside check():

  1. DB rules for the session's group, then "default" group as fallback — sorted by priority ASC, id ASC within each tier — first matching rule wins. (memory/ is auto-allowed by a seeded @fs_any allow memory/* rule, not a hardcoded exception.)
  2. Session bypass (in-memory): if the result would be Require and an active bypass matches session_id + category, convert to Allow. Deny is never bypassed.
  3. No matching rule → Require (default-closed)

Fail-closed on error. If loading the rules themselves fails (transient DB error), check() returns Require, never Allow — a rule-load failure must not silently let an un-vetted execute_cmd/restart/write through. This matches the default-closed policy; the error is logged at error level.

Then, back in llm_loop.rs, if the result is still Require, the RunContext filesystem fast-path applies: a file-read tool whose path is read-allowed (rc.is_read_allowed: working dir / docs/ / skills/ / allow_fs_reads / allow_fs_writes) or a file-write tool whose path is write-allowed (rc.is_write_allowed) is upgraded to Allow. A Deny from step 2 is never reached here, so the secrets/ deny holds even inside the auto-read working dir. Paths are canonicalized (../symlinks resolved) before matching.

Path Whitelist

There are two ways to pre-authorize writes to a directory:

Option A — RunContext allow_fs_writes (session-scoped, no DB rule needed):

Set allow_fs_writes on the session's RunContext. The fast-path fires in llm_loop.rs after ApprovalManager and upgrades a Require to Allow, so no approval event is emitted (a Deny rule still wins).

{
  "security_group": "cron_restrictive",
  "allow_fs_writes": ["data/output", "/abs/path/to/dir"]
}

Matching semantics: exact file OR recursive directory prefix (no wildcards). "data/output" matches data/output/foo.txt, data/output/sub/bar.txt, etc. Entries can be absolute or relative to the session's working_directory.

Option B — approval_rules DB (persistent, applies to all sessions in the group):

Add a single @fs_* allow rule at a low priority (e.g. 5, before the generic catch-all). One row covers every filesystem tool of that class — no need to repeat it per tool:

-- allow read + write anywhere under data/ (the `/*` also matches the `data` dir node)
INSERT INTO approval_rules (tool_pattern, path_pattern, action, note, priority)
VALUES ('@fs_any', 'data/*', 'allow', 'auto-allow data/', 5);

Use @fs_read for read-only access, @fs_write for write-only, @fs_any for both. This is exactly what the File System panel writes; the defaults (memory/*, data/*, secrets/*) are inserted automatically on first startup by seed_fs_path_rules().

Default Rules (seeded automatically on first startup with empty DB)

Tool Action Priority
execute_cmd require 10
restart require 10
mobile_start_pairing require 10

Default rules are inserted only when the approval_rules table is empty. They can be modified or deleted normally. Filesystem tools are no longer seeded as per-tool require rules — filesystem gating is owned by the File System category (@fs_* path rules) backstopped by the * catch-all.

File System path defaults (seeded)

seed_fs_path_rules() seeds the default File System rows (priority 5, default group), each a single @fs_* row:

Path Rule Effect
memory/* @fs_any allow LLM manages its own memory autonomously (replaces the former hardcoded is_memory_path bypass)
data/* @fs_any allow scratch/data workspace, read + write
secrets/* @fs_any deny no read or write access

Each is inserted independently only when its exact (tool_pattern, path_pattern) is absent, so a row the user deleted is only ever re-created individually.

Secrets deny. Reading a secret would leak its value into the LLM context, chat history, the compactor's summaries and the WS stream — worse than a write — hence deny (non-bypassable) rather than require. @fs_any denies writes as well as reads (the old per-read-tool seed denied reads only). Since Deny is evaluated before the RunContext read fast-path, secrets/ stays unreadable even inside the auto-read working dir. The secrets/* pattern also matches the secrets dir node, so a recursive list_files/grep_files rooted at it is covered; the read tools additionally skip any secrets directory during traversal (SKIP_DIRS).

This protects the cwd-relative secrets/ folder. Tokens read by external MCP server processes are unaffected (they read their own files directly, not via these tools).

Legacy migration. migrate_legacy_fs_rules() runs once at startup (before the seeder) and removes the superseded legacy filesystem rows (the per-tool write require defaults, the old per-tool data/* allows, and the old per-read-tool secrets denies), identified by the exact note text the old seeders stamped. User-authored rules carry different notes and are untouched.

File System category (UI)

In the Security → <group> page, filesystem tools are not shown as individual per-tool Allow/Require/Deny chips. Instead the <approval-rules-page> renders a dedicated File System panel: one row per path, each with a single selector whose options map to a @fs_* rule row:

Selector Rule written
Allow read @fs_read allow
Allow write @fs_any allow (write implies read)
Deny @fs_any deny
Require @fs_any require

A path entered as a directory is stored as <dir>/* and given a priority by depth (deeper = evaluated first). The panel also exposes a settable Default row (an @fs_any no-path rule at priority 900); when unset the effective default is the group's * catch-all (Require). No new API is involved — the panel reuses POST/PUT/DELETE /api/approval/rules.


Useful Rule Examples

Require approval for all Gmail tools

INSERT INTO approval_rules (tool_pattern, action, note, priority)
VALUES ('mcp__gmail__*', 'require', 'Gmail requires approval', 5);

Require approval only for cron jobs (not for web)

INSERT INTO approval_rules (source, tool_pattern, action, note, priority)
VALUES ('cron', 'mcp__*', 'require', 'All MCP tools from cron require approval', 20);

Always allow a specific tool for a specific agent

INSERT INTO approval_rules (agent_id, tool_pattern, action, note, priority)
VALUES ('email-assistant', 'mcp__gmail__list_messages', 'allow', 'free read for email-assistant', 1);

Allow free writes to a specific subfolder

-- For the researcher agent only, allow writes to data/research/ without approval
INSERT INTO approval_rules (agent_id, tool_pattern, path_pattern, action, note, priority)
VALUES ('researcher', 'write_file', 'data/research/*', 'allow', 'researcher writes freely to data/research/', 3);

The researcher defaults to data/research/ but accepts a caller-specified output directory (e.g. when orchestrated by a command like /ideagen, which tells it to write into a session-scoped data/ideagen-*/ folder). Add one rule per pattern you want pre-approved:

-- Also let the researcher write into /ideagen session dirs without approval
INSERT INTO approval_rules (agent_id, tool_pattern, path_pattern, action, note, priority)
VALUES ('researcher', 'write_file', 'data/ideagen-*/*', 'allow', 'researcher writes freely to ideagen session dirs', 3);

Session Bypass (Temporary Allow-All)

The human can temporarily suppress approval prompts for a session without modifying DB rules. The bypass is in-memory only — it disappears on app restart or when the session ends.

Activation

The bypass is activated by the human (not the LLM) from any of these surfaces:

  • Agent Inbox page (REST /api/inbox/approvals/:id/resolve with bypass_secs)
  • Copilot chat (WebSocket approve_write/approve_tool with bypass_secs field)
  • Telegram bot inline keyboard (⏱ 15 min / 🔄 Session buttons → ApprovalApi::approve_with_bypass)

The LLM has no tools to activate it — giving the LLM the ability to disable its own oversight would defeat the purpose of the gate.

Scope

Each bypass entry targets a specific BypassScope:

Scope What it covers
All Every tool regardless of category
Category(ToolCategory) Only tools with the given registered category (e.g. Filesystem, Shell)
McpServer(String) Only tools from the named MCP server (matched by the mcp__<server>__ prefix in the tool name)

A bypass entry also has an optional expiry (expires_at: Option<Instant>). None means indefinite (session-scoped).

How It Works

ApprovalManager holds session_bypasses: Mutex<HashMap<i64, Vec<ApprovalBypass>>>. check() receives session_id, category, and tool_name. After rule evaluation, if the result is Require and a matching active bypass exists, the result is converted to Allow. Expired entries are pruned lazily on each check() call.

Invariants

  • Deny rules are never bypassable.
  • The bypass state is cleared when cancel_for_session() is called (WS disconnect).
  • Multiple bypasses can coexist for the same session (e.g. "all categories: 30 min" + "filesystem: indefinite").
  • MCP tools match McpServer scope; they are also covered by All scope.

Rust API

approval.bypass_session(session_id).await;                                         // indefinite, all
approval.bypass_session_for(session_id, Duration::from_secs(600)).await;           // 10 min, all
approval.bypass_session_for_category(session_id, ToolCategory::Shell, Some(Duration::from_secs(600))).await;
approval.bypass_session_for_mcp(session_id, "gmail".into(), Some(Duration::from_secs(1800))).await;
approval.clear_session_bypass(session_id).await;

Session Sources (source)

Value When
web Chat from the web UI
telegram Chat from the Telegram bot
cron Trigger from scheduled_jobs

Headless sessions (cron) have no active interface: approval requests are registered as pending and the agent suspends until a response arrives (via web or Telegram).


Pending Approvals

All pending requests are accessible via Inbox.list_pending() (which internally calls ApprovalManager.list_pending() and ClarificationManager.list_pending()), exposed by the GET /api/inbox endpoint, and displayed on the Agent Inbox frontend page.

Each entry contains:

Field Type Description
request_id i64 Unique ID for resolution
session_id i64 Session that generated the request
tool_call_id i64 Tool call in the DB
tool_name String Name of the tool to execute
arguments JSON Full arguments
agent_id String Agent that called the tool
source String Session source
context_label Option<String> Human-readable origin label (e.g. "CronJob: Daily Digest")
created_at String ISO-8601 timestamp
tool_category Option<String> Registered tool category (filesystem, shell, …); null for MCP/unknown tools
mcp_server Option<String> MCP server name extracted from the tool name (e.g. "gmail"); null for non-MCP tools

context_label is set by ChatSessionHandler::set_context_label() before the run (e.g. TaskManager sets "CronJob: <title>"). It is read in llm_loop.rs and resume.rs and passed to approval.register().


Inbox bus events (GlobalEvent)

Inbox lifecycle changes are broadcast on the global GlobalEvent bus so any subscriber (Telegram, the mobile-connector plugin) can react without polling. Plugins subscribe via ctx.chat_hub.events(...). Four events cover the full Inbox cycle:

Event (ServerEvent) Emitted by When
ApprovalRequested { request_id, tool_call_id, tool_name } ApprovalManager::register A tool call is gated and enters the Inbox
ApprovalResolved { request_id, tool_call_id, approved } ApprovalManager::resolve and resolve_for_tool_call An approval is approved/rejected (from any surface: Inbox REST, WS, mobile, or the inline copilot card)
ClarificationRequested { request_id, title } ClarificationManager::register A clarification question enters the Inbox
ClarificationResolved { request_id } ClarificationManager::resolve A clarification is answered

These are distinct from the per-session WS events ApprovalRequired (carries full args for the active client) and AgentQuestion (the interactive clarification prompt). The ClarificationManager now holds a broadcast::Sender<GlobalEvent> injected from Skald::new (same event_tx the ApprovalManager uses), mirroring the approval manager.


Agent Inbox

The Agent Inbox is the unified web page for managing all pending requests from background sessions (cron, etc.):

  • Approval requests — tool calls requiring human confirmation (e.g. execute_cmd, write_file)
  • Clarification requests — questions posed by the agent via ask_user_clarification when it cannot proceed autonomously

REST API

Method Endpoint Description
GET /api/inbox Returns { total, approvals, clarifications }
POST /api/inbox/approvals/:request_id/resolve Resolve an approval by request_id (see body below)
POST /api/inbox/clarifications/:request_id/resolve Body: { answer: string }
POST /api/tools/:tool_call_id/resolve Resolve an inline chat approval by tool_call_id (source-agnostic); body { action, note }. See Resolution.

Resolve approval body:

{
  "action": "approve" | "reject",
  "note": "",
  "bypass_secs": 900,
  "bypass_scope": "category" | "mcp_server" | "all"
}

bypass_secs and bypass_scope are optional. When present (only on approve):

  • bypass_secs = 0 → indefinite bypass (until WS disconnect)
  • bypass_secs = N → bypass expires after N seconds
  • bypass_scope defaults to "category" if tool_category is set, "mcp_server" if mcp_server is set, otherwise "all"

The legacy endpoints /api/approval/pending and /api/approval/resolve/:id remain active for backwards compatibility.

Frontend

The page is implemented in web/components/agent-inbox.js (<agent-inbox-page>). Polls every 8 s when open. The red badge in the sidebar (independent polling every 10 s) shows the total pending count.

See ../frontend.md for component details.


Resolution

From WebSocket (web copilot)

The client sends a JSON message:

{ "type": "approve_tool", "request_id": 42 }
{ "type": "reject_tool",  "request_id": 42, "note": "optional reason" }

Bypass via WebSocket — include bypass_secs on any approve message:

{ "type": "approve_tool", "request_id": 42, "bypass_secs": 900 }   // 15-min bypass
{ "type": "approve_tool", "request_id": 42, "bypass_secs": 0   }   // session bypass (indefinite)

bypass_secs = 0 maps to an indefinite bypass (until session ends); positive values are seconds. The scope (category / MCP server / all) is auto-detected from the pending request, same as the REST endpoint.

The types approve_write/reject_write are aliases for approve_tool/reject_tool and work identically.

From the inline chat card (any source — web / mobile / project)

A pending tool call rendered inline in the chat (copilot or mobile) is resolved by its globally-unique tool_call_id, not by request_id:

POST /api/tools/{tool_call_id}/resolve
{ "action": "approve" | "reject", "note": "optional reason" }

This path is source-agnostic. resolve_tool (src/frontend/api/sessions.rs) looks the tool call up by id alone and derives the owning session from the tool call's own chat_sessions_stack row, so an approval raised in a mobile / telegram / project session resolves correctly regardless of which client posts it — there is no "active session" scoping. (Historically this endpoint hardcoded the web source, which made a mobile-created approval fail with tool_call_id … not found in current web session.)

  • Live (server still up): delegates to ApprovalManager::resolve_for_tool_call, which fires the waiting oneshot and unblocks the turn.
  • Post-restart (no in-memory entry): executes the tool directly on the session that owns it (ChatHub::handler_for_session) and continues the loop.

The client only falls back to this REST path when the inline card lacks a live request_id — i.e. it was rebuilt from history after a reconnect/reload. While the server is up, build_items re-attaches the live request_id (via ApprovalManager::request_id_for_tool_call) to history-rebuilt pending cards, so they resolve through the WebSocket path above with full bypass support. The old /api/web/tools/{tool_call_id}/resolve route remains as a back-compat alias.

From Telegram

The Telegram plugin uses ApprovalApi::approve_with_bypass (defined in crates/core-api/src/approval.rs, implemented on ApprovalManager). The inline keyboard shows four buttons in two rows:

[✅ Approve]  [❌ Reject]
[⏱ 15 min]   [🔄 Session]

Tapping ⏱ 15 minapprove_with_bypass(request_id, Some(900)). Tapping 🔄 Sessionapprove_with_bypass(request_id, None).

approve_with_bypass calls ApprovalManager::approve() then registers the appropriate session bypass (auto-detected scope).


Behaviour on Restart

The live request_idoneshot registry is in-memory and lost on restart, but the tool-call intent survives in chat_llm_tools.status='pending'. The system reconstructs around that:

  • Inbox survives restart. Inbox::list_pending unions the in-memory approvals with ApprovalManager::list_persisted_pending() — DB pending rows (excluding ask_user_clarification, which shares the status). These carry request_id = PERSISTED_REQUEST_ID (a falsy sentinel, 0) telling the client to resolve them by tool_call_id (there is no live registry entry / oneshot to address). Both inbox frontends branch on it: truthy request_idPOST /api/inbox/approvals/{request_id}/resolve (with bypass); falsy → POST /api/tools/{tool_call_id}/resolve (bypass buttons hidden).
  • The inline chat card likewise has no live request_id after a restart, so it also resolves via POST /api/tools/{tool_call_id}/resolve.
  • resolve_tool's post-restart branch dispatches correctly per tool kind:
    • Simple tools (registry / MCP) execute directly on the owning session and return.
    • Sub-agent tools (execute_task, execute_subtask, run_subtask) cannot run through the flat execute_tool path (they need the recursive dispatcher). The endpoint marks the call pre-approved (ChatSessionHandler::mark_pre_approved) and drives ChatHub::resume_session, which re-dispatches the tool through execute_tool_call — the same router as a live turn — so the sub-agent runs instead of failing with Unknown tool: execute_task. The approval gate consumes the pre-approved flag and skips re-prompting.
  • resume_pending_tools (the recovery path run on a new message or WS reconnect) routes through execute_tool_call too, and ChatHub::resume/resume_session inject the execute_task interface tool so mode=async tasks rebuild via build_execution. So both sync and async execute_task recover.

There is no separate request_id counter: tool_call_id (the durable chat_llm_tools rowid) is the identity throughout. Live approvals carry request_id == tool_call_id; DB-rebuilt ones carry the falsy PERSISTED_REQUEST_ID. So ApprovalManager::resolve (by request_id) and resolve_for_tool_call are the same lookup, and request_id_for_tool_call just returns the id when a live entry exists.


Tool Visibility Filtering

Beyond the execution-time approval gate, tools are filtered at invitation time — before being included in the LLM context. This reduces token usage and prevents the LLM from attempting to call tools it cannot execute.

Semantics

ApprovalManager.is_tool_visible(rules, tool_name) checks the pre-loaded rules synchronously:

  • If the first matching rule has action Deny → tool is hidden from the LLM
  • All other cases (Allow, Require, or no match) → tool is visible

Only tool_pattern is considered (path/agent/source filters are ignored for visibility — those are execution-time concerns).

Where it runs

  1. Parent agent (src/core/session/handler/config.rs, build_agent_config): rules are loaded once with list_for_group, then base_tool_defs.retain(...) filters the list before building AgentRunConfig.
  2. Sub-agents (src/core/session/handler/agent_dispatch.rs, dispatch_sub_agent): same filter applied after sub-agent-only tools are added.

Sub-agents share the parent session's permission group. The execution-time ApprovalManager.check() gate remains active as a second enforcement layer.

Tool Visibility API

// Sync: applied to pre-loaded rules slice
approval.is_tool_visible(rules: &[ApprovalRule], tool_name: &str) -> bool

// Async: one DB round-trip, returns the matched RuleAction (or None if no rule matches)
approval.check_tool_visibility(group_id: &str, tool_name: &str) -> Option<RuleAction>

// Via RunContextManager (resolves group_id from run_context_id automatically)
run_context_manager.check_tool_visibility(run_context_id: Option<&str>, tool_name: &str) -> Option<RuleAction>

Group Duplication

POST /api/tool-permission-groups/{id}/duplicate

Body: { "id": "<new_group_id>", "name": "<new display name>" }

Creates a new permission group that is an exact copy of the source group's rules. The operation is atomic: the new group row and all copied rules are inserted in a single SQLite transaction. The new group inherits the source's description.

Implemented in RunContextManager::duplicate_group (src/core/run_context/mod.rs).


AllTools Response (GET /api/approval/tools)

The endpoint returns AllTools:

{
  "built_in": [
    { "name": "read_file", "description": "...", "source": "built-in", "server": null, "category": "filesystem" },
    { "name": "send_voice_message", "description": "...", "source": "built-in", "server": null, "category": "dynamic" }
  ],
  "mcp": [ { "name": "mcp__gmail__list_messages", "description": "...", "source": "mcp", "server": "gmail" } ],
  "mcp_servers": {
    "gmail": { "friendly_name": "Gmail", "description": "Read and send Gmail messages" }
  }
}

mcp_servers is keyed by the MCP server's internal name (matching server fields in mcp entries). The frontend uses it to group MCP tools under their server's friendly_name and display the server description as a section subtitle.

Making dynamically-injected tools gate-able

The permission grid can only assign allow/require/deny to tools the endpoint enumerates. ToolCatalog::list_all() covers registry tools + a small static synthetic_tools() list (core interface tools) + live MCP tools. Everything injected outside the registry — plugin tools (Telegram send_voice_message), provider tools, memory tools — is surfaced by runtime discovery instead of a hand-maintained list:

  • ToolDiscovery observes the tool array at AgentRunConfig::all_tool_defs() (tapped in llm_loop.rs each round) and upserts every offered tool into the known_tools table. An in-memory seen-set keeps this a no-op after each tool's first sighting; the DB write runs in a spawned task off the turn's critical path.
  • list_tools (src/frontend/api/approval.rs) merges known_tools into the response, deduping names already present as built-in/MCP tools, and tags the remainder with category: "dynamic" (rendered as its own "Dynamic" group in the grid).

Consequence: a tool appears in the grid once it has been offered to the LLM at least once (in practice, after first use of the interface/provider that injects it); until then the catch-all * require @999999 gates it safely. This is drift-proof — it can never fall out of sync with what is actually offered — and needs no per-tool or per-plugin registration. See tools docs.


Module Structure

File Role
crates/core-api/src/approval.rs ApprovalApi trait — approve, reject, approve_with_bypass; exposed to plugins via PluginContext
src/core/pending_registry.rs PendingRegistry<Info, Resolution> — generic in-memory pending-request store (map + oneshot) shared by the three managers below. Id minting and event emission stay in the managers.
src/core/approval/mod.rs ApprovalManager (composes a PendingRegistry keyed by tool_call_id + rules engine + session bypasses), GateResult, ApprovalRule, PendingApprovalInfo, PERSISTED_REQUEST_ID, session bypass methods; is_tool_visible (sync); check_tool_visibility (async); impl ApprovalApi
src/core/clarification/mod.rs ClarificationManager (composes a PendingRegistry + its own request_id counter), PendingClarificationInfo
src/core/elicitation/mod.rs ElicitationManager (composes a PendingRegistry + counter + secret handling + MCP ElicitationBridge), PendingElicitationInfo
src/core/inbox.rs Inbox: unified façade for pending approvals + clarifications + elicitations (wraps ApprovalManager, ClarificationManager, ElicitationManager)
src/core/run_context/mod.rs RunContext domain object: fields security_group, system_prompt, allow_fs_writes, allow_fs_reads, working_directory + applicative methods tool_group_id(), extra_system_prompt(), effective_working_dir(), is_write_allowed(), is_read_allowed(). RunContextManager: CRUD for permission groups; duplicate_group (atomic); check_tool_visibility.
src/core/tools/fs/mod.rs canonicalize_for_policy / path_under — path canonicalization shared by the RunContext fast-paths and approval::normalize_path
src/core/db/approval_rules.rs SQLite queries: list, insert, update, delete
src/core/db/mod.rs approval_rules table creation
src/core/session/handler/config.rs Loads rules once with list_for_group, calls approval.is_tool_visible to filter base_tool_defs for the parent agent
src/core/session/handler/agent_dispatch.rs Same visibility filter applied to sub-agent base_tool_defs after sub-agent-only tools are added
src/core/session/handler/llm_loop.rs Resolves category via ToolRegistry::category_of, calls approval.check(session_id, category, ...) + approval.register()
src/core/session/handler/resume.rs Same check() call as llm_loop.rs for pending tool re-gating
src/core/session/handler/mod.rs ChatSessionHandler holds Arc<ApprovalManager>, Arc<ClarificationManager>, context_label: RwLock<Option<String>>
src/frontend/api/inbox.rs /api/inbox endpoint + resolve for approval and clarification (uses skald.inbox)
src/frontend/api/approval.rs Approval rules CRUD + /api/approval/pending + /api/approval/tools (returns AllTools with mcp_servers metadata map)
src/frontend/api/run_context.rs POST /api/tool-permission-groups/{id}/duplicate handler
web/components/approval-groups.js Groups list page (<approval-groups-page>): create, rename, duplicate, delete groups; fires approval-navigate event
web/components/approval-rules.js Per-group rules view (<approval-rules-page>): rule matrix + override/low-priority panels + default action bar; listens to approval-navigate
src/frontend/api/ws.rs Handles approve_tool/reject_tool/approve_write/reject_write; optional bypass_secs field activates approve_with_bypass
src/core/events.rs ServerEvent::ApprovalRequired (generic tools) and PendingWrite (files with diff)

Frontend — Approval Rules page

The UI is split into two Lit components that communicate via the approval-navigate custom event (see frontend.md for the event protocol).

<approval-groups-page> (web/components/approval-groups.js): lists all tool_permission_groups. Each group card shows its name, description, and rule count. Groups can be added, renamed, duplicated, or deleted; the "default" group cannot be deleted. Clicking a group fires approval-navigate with the group object and hides itself.

<approval-rules-page> (web/components/approval-rules.js): per-group rules view with four panels:

Panel Priority range Purpose
Overrides < 0 Wildcard/path rules evaluated before any per-tool entry
Per-tool matrix = 0 Simple 4-chip toggle (—/Allow/Require/Deny) per tool, grouped by category/MCP server
Low Priority 1…999998 Wildcard/path rules as a safety net, evaluated after the matrix
Default Action 999999 Catch-all * rule with no filters; inline selector; missing = no catch-all

MCP tools are grouped under their server's friendly_name (from mcp_servers in the GET /api/approval/tools response). The server description is shown as a subtitle.

The Agent Profiles page (web/components/agent-profiles.js, <agent-profiles-page>) is a separate sidebar entry that manages run_contexts. Each profile links a session to a permission group via a dropdown. The "default" profile cannot be deleted. See ../session.md for the resolution chain.


When to Update This File

  • New action types in rules
  • New notification channel added (e.g. Telegram)
  • Pending approval persistence added to DB
  • New fields in PendingApprovalInfo or PendingClarificationInfo
  • New Agent Inbox APIs