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
allowrule) - Deny — block immediately (
denyrule) - 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=9999catch-all (permissive-by-default) viaseed_allow_all_default. That could shadow a user-addedrequire *at a higher priority number and let unmatched tools run without approval. It was replaced byseed_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():
- DB rules for the session's group, then
"default"group as fallback — sorted bypriority ASC, id ASCwithin each tier — first matching rule wins. (memory/is auto-allowed by a seeded@fs_any allow memory/*rule, not a hardcoded exception.) - Session bypass (in-memory): if the result would be
Requireand an active bypass matchessession_id+category, convert toAllow.Denyis never bypassed. - No matching rule →
Require(default-closed)
Fail-closed on error. If loading the rules themselves fails (transient DB error),
check()returnsRequire, neverAllow— a rule-load failure must not silently let an un-vettedexecute_cmd/restart/write through. This matches the default-closed policy; the error is logged aterrorlevel.
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/resolvewithbypass_secs) - Copilot chat (WebSocket
approve_write/approve_toolwithbypass_secsfield) - 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
Denyrules 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
McpServerscope; they are also covered byAllscope.
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_clarificationwhen 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 secondsbypass_scopedefaults to"category"iftool_categoryis set,"mcp_server"ifmcp_serveris 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 waitingoneshotand 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 min → approve_with_bypass(request_id, Some(900)).
Tapping 🔄 Session → approve_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_id → oneshot 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_pendingunions the in-memory approvals withApprovalManager::list_persisted_pending()— DBpendingrows (excludingask_user_clarification, which shares the status). These carryrequest_id = PERSISTED_REQUEST_ID(a falsy sentinel,0) telling the client to resolve them bytool_call_id(there is no live registry entry / oneshot to address). Both inbox frontends branch on it: truthyrequest_id→POST /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_idafter a restart, so it also resolves viaPOST /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 flatexecute_toolpath (they need the recursive dispatcher). The endpoint marks the call pre-approved (ChatSessionHandler::mark_pre_approved) and drivesChatHub::resume_session, which re-dispatches the tool throughexecute_tool_call— the same router as a live turn — so the sub-agent runs instead of failing withUnknown 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 throughexecute_tool_calltoo, andChatHub::resume/resume_sessioninject theexecute_taskinterface tool somode=asynctasks rebuild viabuild_execution. So both sync and asyncexecute_taskrecover.
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
- Parent agent (
src/core/session/handler/config.rs,build_agent_config): rules are loaded once withlist_for_group, thenbase_tool_defs.retain(...)filters the list before buildingAgentRunConfig. - 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:
ToolDiscoveryobserves the tool array atAgentRunConfig::all_tool_defs()(tapped inllm_loop.rseach round) and upserts every offered tool into theknown_toolstable. 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) mergesknown_toolsinto the response, deduping names already present as built-in/MCP tools, and tags the remainder withcategory: "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
PendingApprovalInfoorPendingClarificationInfo - New Agent Inbox APIs