Files
Skald-Circle/src/core/approval/mod.rs
T
2026-07-10 15:02:09 +01:00

1154 lines
50 KiB
Rust

//! Approval gate — centralised human-in-the-loop for tool execution.
//!
//! ## Architecture
//!
//! `ApprovalManager` is a top-level service (in `AppState`) shared by all
//! sessions. Its job is threefold:
//!
//! 1. **Rules** — decide, given (agent_id, session source, tool_name, args),
//! whether a tool call needs human approval, is always allowed, or always
//! denied. Rules live in `approval_rules` (SQLite) and are evaluated in
//! priority order; the first match wins. If no rule matches the default is
//! `Require` (default-closed). The `default` group additionally ships an
//! explicit final `* require` catch-all (seeded only when absent) so the
//! policy is visible/editable in the UI.
//!
//! 2. **Pending registry** — when a tool is gated, an in-memory entry tracks
//! the waiting session. The web "Pending Approvals" page reads this list
//! to show the human what needs deciding.
//!
//! 3. **Resolution** — `resolve(request_id, decision)` is called by the WS
//! handler (or the Telegram approval bot in the future) to unblock the
//! waiting session.
//!
//! ## Rule evaluation order
//!
//! Rules are sorted by `priority` ASC (lower = evaluated first). Within the
//! same priority, more-specific rules should be given a lower priority number.
//! The first rule whose `agent_id`, `source`, and `tool_pattern` all match
//! determines the action; if none matches the tool requires approval
//! (default-closed).
//!
//! ## Hardcoded exception
//!
//! File-write tools targeting `memory/` paths bypass the rule engine and are
//! always allowed (this mirrors the original behaviour and can be replaced by
//! an explicit `allow` rule later).
use std::collections::HashMap;
use std::sync::Arc;
use std::time::{Duration, Instant};
use anyhow::Result;
use serde::{Deserialize, Serialize};
use serde_json::Value;
use sqlx::SqlitePool;
use tokio::sync::{broadcast, Mutex, oneshot};
use tracing::{debug, error, info, warn};
use crate::core::pending_registry::PendingRegistry;
use crate::core::session::handler::ApprovalDecision;
use crate::core::tools::tool_names as tn;
use crate::core::tools::ToolCategory;
use crate::core::events::{GlobalEvent, ServerEvent};
// ── Public types ──────────────────────────────────────────────────────────────
/// What the guardian concludes for a given tool call.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum GateResult {
/// No rule fired (or an explicit `allow` rule matched). Execute freely.
Allow,
/// An explicit `deny` rule matched. Reject immediately without asking.
Deny,
/// A `require` rule matched. Ask the human before executing.
Require,
}
/// The action stored in an `approval_rules` row.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum RuleAction {
/// Always require human approval.
Require,
/// Always allow (whitelist — skip the gate even if another rule would require it).
Allow,
/// Always deny (blacklist — tool call is rejected immediately).
Deny,
}
impl RuleAction {
pub fn as_str(&self) -> &'static str {
match self {
RuleAction::Require => "require",
RuleAction::Allow => "allow",
RuleAction::Deny => "deny",
}
}
}
impl std::str::FromStr for RuleAction {
type Err = anyhow::Error;
fn from_str(s: &str) -> Result<Self> {
match s {
"require" => Ok(RuleAction::Require),
"allow" => Ok(RuleAction::Allow),
"deny" => Ok(RuleAction::Deny),
other => anyhow::bail!("unknown RuleAction: {other}"),
}
}
}
/// One row from `approval_rules`.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ApprovalRule {
pub id: i64,
/// `None` matches any agent.
pub agent_id: Option<String>,
/// `None` matches any source (`web`, `telegram`, `cron`, …).
pub source: Option<String>,
/// Exact tool name or glob suffix: `mcp__gmail__*` matches all Gmail tools.
pub tool_pattern: String,
/// Optional glob on the normalised file path (e.g. `data/*`).
/// Only meaningful for tools that carry a `path` argument.
/// `None` = no path filter (matches any path, or tools without a path arg).
/// `Some("data/*")` = only fires when `args["path"]` starts with `data/`.
pub path_pattern: Option<String>,
pub action: RuleAction,
pub note: Option<String>,
/// Lower priority number = evaluated first.
pub priority: i64,
/// Permission group this rule belongs to. `None` is treated as `"default"`.
pub group_id: Option<String>,
}
/// Input for creating a new rule.
#[derive(Debug, Clone, Deserialize)]
pub struct NewApprovalRule {
pub agent_id: Option<String>,
pub source: Option<String>,
pub tool_pattern: String,
/// Optional glob on the normalised file path (e.g. `data/*`).
pub path_pattern: Option<String>,
pub action: RuleAction,
pub note: Option<String>,
pub priority: Option<i64>,
/// Permission group for this rule. Defaults to `"default"` when omitted.
pub group_id: Option<String>,
}
/// Public view of a pending approval request (no channel, safe to clone/serialize).
#[derive(Debug, Clone, Serialize)]
pub struct PendingApprovalInfo {
pub request_id: i64,
pub session_id: i64,
pub tool_call_id: i64,
pub tool_name: String,
pub arguments: Value,
pub agent_id: String,
pub source: String,
/// Human-readable label for the origin (e.g. "CronJob: Daily Digest"). Null for web sessions.
pub context_label: Option<String>,
/// ISO-8601 timestamp string (UTC).
pub created_at: String,
/// Registered tool category (None for MCP and unknown tools).
pub tool_category: Option<ToolCategory>,
/// MCP server name extracted from the tool name (e.g. "gmail" from "mcp__gmail__search").
/// None for non-MCP tools.
pub mcp_server: Option<String>,
}
/// `request_id` value stamped on DB-rebuilt pending approvals (post-restart), where
/// there is no live registry entry / oneshot to address. It is falsy on purpose: the
/// frontend routes a falsy `request_id` to the durable `POST /api/tools/{tool_call_id}/resolve`
/// path instead of the in-memory WS/Inbox path (which would no-op with an empty registry).
/// Live approvals instead carry `request_id == tool_call_id`.
///
/// (Fully retiring this sentinel — so persisted items resolve uniformly by tool_call_id
/// everywhere, including mobile/telegram — is part of the deferred Approach B.)
pub const PERSISTED_REQUEST_ID: i64 = 0;
// ── Session bypass ────────────────────────────────────────────────────────────
/// What a session bypass entry applies to.
pub enum BypassScope {
/// Covers every tool regardless of category.
All,
/// Covers only tools of the given registered category.
Category(ToolCategory),
/// Covers only tools belonging to the named MCP server
/// (matched by the `mcp__<server>__` prefix in the tool name).
McpServer(String),
}
/// A single in-memory bypass entry for a session.
///
/// Converts `GateResult::Require` → `Allow` for the matching scope.
/// `Deny` rules are never bypassed.
struct ApprovalBypass {
scope: BypassScope,
/// `None` = no expiry (lasts until session ends / `clear_session_bypass` is called).
expires_at: Option<Instant>,
}
// ── ApprovalManager ───────────────────────────────────────────────────────────
pub struct ApprovalManager {
db: Arc<SqlitePool>,
/// Shared pending-request plumbing (map + oneshot). Keyed by the durable
/// `tool_call_id` (= the `chat_llm_tools` rowid), which is also mirrored into
/// `PendingApprovalInfo.request_id`. There is no separate in-memory id counter:
/// `request_id == tool_call_id` for every live approval, so the "resolve by
/// request_id" and "resolve by tool_call_id" paths are one and the same lookup.
registry: PendingRegistry<PendingApprovalInfo, ApprovalDecision>,
session_bypasses: Mutex<HashMap<i64, Vec<ApprovalBypass>>>,
event_tx: broadcast::Sender<GlobalEvent>,
}
impl ApprovalManager {
pub fn new(db: Arc<SqlitePool>, event_tx: broadcast::Sender<GlobalEvent>) -> Self {
Self {
db,
registry: PendingRegistry::new(),
session_bypasses: Mutex::new(HashMap::new()),
event_tx,
}
}
// ── Seeding ───────────────────────────────────────────────────────────────
/// Inserts the default rules (equivalent to the old hardcoded `needs_approval`)
/// only if the table is currently empty. Safe to call at every startup.
pub async fn seed_defaults(&self) -> Result<()> {
let count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM approval_rules")
.fetch_one(self.db.as_ref())
.await?;
if count > 0 {
return Ok(());
}
let defaults: &[(&str, &str)] = &[
(tn::EXECUTE_CMD, "require"),
(tn::RESTART, "require"),
// Opening a mobile pairing window emits a secret (the QR) into chat:
// it must be a deliberate human action, not LLM-triggerable (plugin.md §11).
("mobile_start_pairing", "require"),
];
// NOTE: file-write tools are NOT seeded here as per-tool `require` rules.
// Filesystem gating is owned by the "File System" category — path-scoped
// `@fs_*` rules seeded in `seed_fs_path_rules`, backstopped by the `*` catch-all.
for (pattern, action) in defaults {
sqlx::query(
"INSERT INTO approval_rules (tool_pattern, action, note, priority, group_id)
VALUES (?, ?, 'default rule', 10, 'default')",
)
.bind(pattern)
.bind(action)
.execute(self.db.as_ref())
.await?;
}
// Whitelist benign built-in plumbing/UI tools so they don't prompt under the
// require-by-default catch-all below. These carry no side effects worth gating.
let benign_allows = &[
tn::WRITE_TODOS,
tn::NOTIFY,
tn::ACTIVATE_TOOLS,
tn::SHOW_FILE_TO_USER,
"image_generate",
];
for pattern in benign_allows {
sqlx::query(
"INSERT INTO approval_rules (tool_pattern, action, note, priority, group_id)
VALUES (?, 'allow', 'benign builtin', 0, 'default')",
)
.bind(pattern)
.execute(self.db.as_ref())
.await?;
}
// Final catch-all at the bottom of the default group: REQUIRE. Any tool not
// matched by a more-specific rule falls here and prompts for approval. This is
// the intended default-closed policy; whitelist/blacklist rules layer above it.
sqlx::query(
"INSERT INTO approval_rules (tool_pattern, action, note, priority, group_id)
VALUES ('*', 'require', 'catch-all: require by default', 999999, 'default')",
)
.execute(self.db.as_ref())
.await?;
info!(
"approval_rules seeded with {} default rules + {} benign allows + catch-all require",
defaults.len(), benign_allows.len(),
);
Ok(())
}
/// Idempotent: ensures the Default group has a final catch-all `*` rule.
///
/// Inserts `* require @999999` **only when no `*` catch-all exists** for the group.
/// This is the default-closed fallback: any tool not matched by a more-specific rule
/// prompts for approval. Because it only fires when the catch-all is *absent*, it
/// never overwrites a catch-all the user changed from the frontend (allow/deny/require)
/// and never re-creates the legacy allow-all. Runs at every startup (no-op when a
/// `*` catch-all is already present).
pub async fn seed_default_catch_all(&self) -> Result<()> {
let count: i64 = sqlx::query_scalar(
"SELECT COUNT(*) FROM approval_rules
WHERE tool_pattern = '*' AND group_id = 'default'",
)
.fetch_one(self.db.as_ref())
.await?;
if count > 0 {
return Ok(());
}
sqlx::query(
"INSERT INTO approval_rules (tool_pattern, action, note, priority, group_id)
VALUES ('*', 'require', 'catch-all: require by default', 999999, 'default')",
)
.execute(self.db.as_ref())
.await?;
info!("approval_rules: seeded default catch-all require (priority 999999)");
Ok(())
}
/// Seeds the default **File System** path rules using the `@fs_*` class tokens, so
/// each default is a single, visible, editable row in the File System permission
/// panel (one row per path, instead of one row per tool). Each entry is inserted
/// independently only when its exact `(tool_pattern, path_pattern)` is absent, so a
/// rule the user deleted is only ever re-created individually (matches the idempotency
/// style of the other seeders). Runs at every startup.
///
/// Priority `5` places these path-scoped rules *before* the `*` catch-all (999999),
/// so an unmatched path still falls through to the default `require`.
///
/// - `memory/*` → **allow** (the LLM manages its own memory; replaces the former
/// hardcoded `is_memory_path` bypass).
/// - `data/*` → **allow** (scratch/data workspace).
/// - `secrets/*` → **deny** (`@fs_any` denies reads *and* writes; a read would leak
/// the secret into the LLM context / history / WS stream, and `Deny` is
/// non-bypassable). The `/*` pattern also matches the `secrets` dir node itself, so
/// recursive `list_files`/`grep_files` rooted at it are covered.
pub async fn seed_fs_path_rules(&self) -> Result<()> {
// (tool_pattern, path_pattern, action, note)
let rules: &[(&str, &str, &str, &str)] = &[
("@fs_any", "memory/*", "allow", "auto-allow memory/"),
("@fs_any", "data/*", "allow", "auto-allow data/"),
("@fs_any", "secrets/*", "deny", "deny secrets/ access"),
];
let mut seeded = 0;
for (tool_pattern, path_pattern, action, note) in rules {
let exists: i64 = sqlx::query_scalar(
"SELECT COUNT(*) FROM approval_rules
WHERE tool_pattern = ? AND path_pattern = ? AND group_id = 'default'",
)
.bind(tool_pattern)
.bind(path_pattern)
.fetch_one(self.db.as_ref())
.await?;
if exists > 0 {
continue;
}
sqlx::query(
"INSERT INTO approval_rules (tool_pattern, path_pattern, action, note, priority, group_id)
VALUES (?, ?, ?, ?, 5, 'default')",
)
.bind(tool_pattern)
.bind(path_pattern)
.bind(action)
.bind(note)
.execute(self.db.as_ref())
.await?;
seeded += 1;
}
if seeded > 0 {
info!("approval_rules: seeded {seeded} File System path rules (@fs_* tokens)");
}
Ok(())
}
/// One-time migration: removes the legacy per-tool filesystem rules that the new
/// `@fs_*` File System panel supersedes, so they don't linger as un-editable advanced
/// cards or shadow the new path rules. Identified by the exact `note` text the old
/// seeders stamped (user-authored rules carry different notes and are untouched).
///
/// Deletes:
/// - the per-tool write `require` defaults (`note = 'default rule'`, no path) — fs
/// gating now lives in the File System panel + the `*` catch-all;
/// - the old `data/*` allow rows (`note = 'auto-allow data/ writes'`);
/// - the old `secrets` deny rows (`note = 'deny reading secrets/'`).
///
/// Idempotent: a no-op once the legacy rows are gone. Run before `seed_fs_path_rules`.
pub async fn migrate_legacy_fs_rules(&self) -> Result<()> {
let n1 = sqlx::query(
"DELETE FROM approval_rules
WHERE note = 'default rule' AND path_pattern IS NULL
AND tool_pattern IN ('write_file', 'edit_file', 'insert_at_line', 'replace_lines')",
)
.execute(self.db.as_ref())
.await?
.rows_affected();
let n2 = sqlx::query("DELETE FROM approval_rules WHERE note = 'auto-allow data/ writes'")
.execute(self.db.as_ref())
.await?
.rows_affected();
let n3 = sqlx::query("DELETE FROM approval_rules WHERE note = 'deny reading secrets/'")
.execute(self.db.as_ref())
.await?
.rows_affected();
let total = n1 + n2 + n3;
if total > 0 {
info!("approval_rules: migrated {total} legacy filesystem rules to @fs_* File System panel");
}
Ok(())
}
// ── Guardian ──────────────────────────────────────────────────────────────
/// Main gate check: returns what the guardian has decided for this tool call.
///
/// Evaluation order:
/// 1. Rules for `group_id` first, then "default" group as fallback, sorted by priority ASC.
/// First match wins. (`memory/` auto-allow is a seeded `@fs_any allow memory/*` rule,
/// not a hardcoded exception — see `seed_fs_path_rules`.)
/// 2. Session bypass: if a matching bypass is active, `Require` → `Allow`.
/// `Deny` is never bypassed.
/// 3. No match → `Require` (default-closed policy).
/// The Default group ships an explicit final `* require @999999` catch-all (seeded
/// only when absent, editable from the frontend) that makes this policy visible;
/// other groups define their own catch-all (e.g. `readonly` uses `* deny`).
pub async fn check(
&self,
session_id: i64,
category: Option<ToolCategory>,
agent_id: &str,
source: &str,
tool_name: &str,
args: &Value,
group_id: Option<&str>,
) -> GateResult {
let rules = match crate::core::db::approval_rules::list_for_group(&self.db, group_id).await {
Ok(r) => r,
Err(e) => {
// Fail closed: this is a default-closed security gate (see module docs).
// A transient DB error must NOT let an un-vetted tool call (execute_cmd,
// restart, writes) through — require human approval instead.
error!("approval: failed to load rules: {e} — defaulting to Require (fail-closed)");
return GateResult::Require;
}
};
let result = 'rules: {
for rule in &rules {
if !rule_matches(rule, agent_id, source, tool_name, args) {
continue;
}
debug!(
tool = tool_name, agent = agent_id, source,
rule_id = rule.id, action = ?rule.action,
rule_group = rule.group_id.as_deref().unwrap_or("default"),
"approval: rule matched"
);
break 'rules match rule.action {
RuleAction::Require => GateResult::Require,
RuleAction::Allow => GateResult::Allow,
RuleAction::Deny => GateResult::Deny,
};
}
debug!(tool = tool_name, "approval: no rule matched → require");
GateResult::Require
};
// Session bypass: converts Require → Allow when an active bypass matches.
// Deny is intentionally not bypassable.
if matches!(result, GateResult::Require) {
let mut bypasses = self.session_bypasses.lock().await;
if let Some(entries) = bypasses.get_mut(&session_id) {
// Prune expired entries lazily.
entries.retain(|b| b.expires_at.map_or(true, |t| Instant::now() < t));
let active = entries.iter().any(|b| bypass_matches(b, category, tool_name));
if active {
debug!(
tool = tool_name, session_id,
"approval: session bypass active → allow"
);
return GateResult::Allow;
}
}
}
result
}
// ── Pending registry ──────────────────────────────────────────────────────
/// Registers a pending approval and returns `(request_id, rx)`.
/// The caller should send `ApprovalRequired` / `PendingWrite` events to
/// the frontend and then `await rx` to block until the human responds.
pub async fn register(
&self,
session_id: i64,
tool_call_id: i64,
tool_name: &str,
arguments: Value,
agent_id: &str,
source: &str,
context_label: Option<&str>,
category: Option<ToolCategory>,
) -> (i64, oneshot::Receiver<ApprovalDecision>) {
// The durable tool_call_id IS the identity: no separate in-memory counter.
// `request_id == tool_call_id` keeps the wire (events / JS / plugins) unchanged
// while making the id stable and DB-derivable.
let request_id = tool_call_id;
let info = PendingApprovalInfo {
request_id,
session_id,
tool_call_id,
tool_name: tool_name.to_string(),
arguments,
agent_id: agent_id.to_string(),
source: source.to_string(),
context_label: context_label.map(str::to_string),
created_at: chrono::Utc::now().to_rfc3339(),
tool_category: category,
mcp_server: mcp_server_from_tool_name(tool_name),
};
let rx = self.registry.insert(request_id, info).await;
info!(
session_id, tool = tool_name, agent = agent_id, source, request_id,
"approval: pending registered"
);
// Broadcast on the global bus so Inbox subscribers (e.g. the
// mobile-connector plugin) can re-snapshot. This is the bus counterpart
// of the per-session `ApprovalRequired` WS event.
let _ = self.event_tx.send(GlobalEvent {
source: Some(source.to_string()),
session_id: Some(session_id),
event: ServerEvent::ApprovalRequested {
request_id,
tool_call_id,
tool_name: tool_name.to_string(),
},
});
(request_id, rx)
}
/// Resolves a pending approval, unblocks the waiting session, and broadcasts
/// `ApprovalResolved` to all WebSocket subscribers.
pub async fn resolve(&self, request_id: i64, decision: ApprovalDecision) -> Option<PendingApprovalInfo> {
let approved = matches!(decision, ApprovalDecision::Approved);
match self.registry.resolve(request_id, decision).await {
Some(info) => {
let verb = if approved { "approved" } else { "rejected" };
info!(
request_id, tool = info.tool_name,
session_id = info.session_id, verb,
"approval: resolved"
);
let _ = self.event_tx.send(GlobalEvent {
source: Some(info.source.clone()),
session_id: Some(info.session_id),
event: ServerEvent::ApprovalResolved {
request_id,
tool_call_id: info.tool_call_id,
approved,
},
});
Some(info)
}
None => {
warn!(request_id, "approval: resolve called for unknown/already-resolved request_id");
None
}
}
}
/// Convenience wrapper: approve a pending request.
pub async fn approve(&self, request_id: i64) -> Option<PendingApprovalInfo> {
self.resolve(request_id, ApprovalDecision::Approved).await
}
/// Convenience wrapper: reject a pending request with an optional note.
pub async fn reject(&self, request_id: i64, note: String) -> Option<PendingApprovalInfo> {
self.resolve(request_id, ApprovalDecision::Rejected { note }).await
}
/// Resolves a pending approval by `tool_call_id` (used by the REST endpoint,
/// which knows the DB tool id but not the in-memory `request_id`).
/// Returns `true` if an active pending entry was found and resolved,
/// `false` if no matching entry exists (e.g. the app was restarted).
/// Resolve a pending approval by `tool_call_id`. Since `request_id == tool_call_id`,
/// this is the same lookup as [`resolve`] — kept as a named entry point for the REST
/// `/api/tools/{tool_call_id}/resolve` endpoint, which knows the DB id but not the
/// (now identical) request_id. Returns `true` if a live entry was found and resolved.
pub async fn resolve_for_tool_call(
&self,
tool_call_id: i64,
decision: ApprovalDecision,
) -> bool {
self.resolve(tool_call_id, decision).await.is_some()
}
/// Returns the in-memory `request_id` for a pending approval identified by its
/// `tool_call_id`, if a live entry exists. Lets a history-rebuilt tool card be
/// resolved through the source-agnostic WS/Inbox path (which keys on
/// `request_id`, with bypass support) while the server is still up; returns
/// `None` after a restart, when the caller falls back to resolving by the
/// durable `tool_call_id`.
pub async fn request_id_for_tool_call(&self, tool_call_id: i64) -> Option<i64> {
// request_id == tool_call_id: return it iff a live entry exists (so a
// history-rebuilt card resolves via the WS/Inbox path while the server is up),
// else None (post-restart, caller falls back to resolving by tool_call_id).
self.registry.get(tool_call_id).await.map(|_| tool_call_id)
}
/// Drops all pending entries for a session (called when WS disconnects).
/// The waiting `await rx` in `llm_loop` will get `Err(RecvError)` and
/// return `TurnOutcome::Cancelled`.
pub async fn cancel_for_session(&self, session_id: i64) {
let dropped = self.registry.remove_where(|i| i.session_id == session_id).await;
if dropped > 0 {
info!(session_id, dropped, "approval: cancelled pending entries (WS disconnected)");
}
self.session_bypasses.lock().await.remove(&session_id);
}
// ── Session bypass ────────────────────────────────────────────────────────
/// Returns a snapshot of a single pending approval by `request_id`, without resolving it.
pub async fn get_pending(&self, request_id: i64) -> Option<PendingApprovalInfo> {
self.registry.get(request_id).await
}
/// Bypasses all approval prompts for `session_id` for the rest of the session.
pub async fn bypass_session(&self, session_id: i64) {
self.session_bypasses.lock().await
.entry(session_id)
.or_default()
.push(ApprovalBypass { scope: BypassScope::All, expires_at: None });
info!(session_id, "approval: bypass active (session)");
}
/// Bypasses all approval prompts for `session_id` for `duration`.
pub async fn bypass_session_for(&self, session_id: i64, duration: Duration) {
self.session_bypasses.lock().await
.entry(session_id)
.or_default()
.push(ApprovalBypass { scope: BypassScope::All, expires_at: Some(Instant::now() + duration) });
info!(session_id, secs = duration.as_secs(), "approval: bypass active (timed)");
}
/// Bypasses approval prompts for a specific tool `category`.
/// `duration` is `None` for an indefinite (session-scoped) bypass.
pub async fn bypass_session_for_category(
&self,
session_id: i64,
category: ToolCategory,
duration: Option<Duration>,
) {
let expires_at = duration.map(|d| Instant::now() + d);
self.session_bypasses.lock().await
.entry(session_id)
.or_default()
.push(ApprovalBypass { scope: BypassScope::Category(category), expires_at });
info!(session_id, ?category, secs = duration.map(|d| d.as_secs()), "approval: bypass active (category)");
}
/// Bypasses approval prompts for all tools belonging to `mcp_server`.
/// `duration` is `None` for an indefinite (session-scoped) bypass.
pub async fn bypass_session_for_mcp(
&self,
session_id: i64,
mcp_server: String,
duration: Option<Duration>,
) {
let expires_at = duration.map(|d| Instant::now() + d);
self.session_bypasses.lock().await
.entry(session_id)
.or_default()
.push(ApprovalBypass { scope: BypassScope::McpServer(mcp_server.clone()), expires_at });
info!(session_id, mcp_server, secs = duration.map(|d| d.as_secs()), "approval: bypass active (mcp_server)");
}
/// Removes all bypass entries for a session.
pub async fn clear_session_bypass(&self, session_id: i64) {
self.session_bypasses.lock().await.remove(&session_id);
info!(session_id, "approval: bypass cleared");
}
/// Returns a snapshot of all currently-pending approvals (for the web page).
pub async fn list_pending(&self) -> Vec<PendingApprovalInfo> {
self.registry.list().await
}
/// Reconstructs pending approvals from the DB (`chat_llm_tools.status='pending'`,
/// excluding `ask_user_clarification`, which shares that status). Used to keep the
/// Inbox populated after a server restart, when the in-memory registry is empty.
/// `request_id` is [`PERSISTED_REQUEST_ID`] — a falsy sentinel telling the client to
/// resolve by `tool_call_id` (there is no live registry entry / oneshot to address).
pub async fn list_persisted_pending(&self) -> Vec<PendingApprovalInfo> {
let rows = sqlx::query_as::<_, (i64, String, Option<String>, String, i64, String, String)>(
"SELECT t.id, t.name, t.arguments, t.created_at, ss.session_id, ss.agent_id, cs.source
FROM chat_llm_tools t
JOIN chat_history h ON h.id = t.message_id
JOIN chat_sessions_stack ss ON ss.id = h.session_stack_id
JOIN chat_sessions cs ON cs.id = ss.session_id
WHERE t.status = 'pending' AND t.name != 'ask_user_clarification'",
)
.fetch_all(&*self.db)
.await
.unwrap_or_default();
rows.into_iter().map(|(id, name, args, created_at, session_id, agent_id, source)| {
let arguments = args.as_deref()
.and_then(|s| serde_json::from_str(s).ok())
.unwrap_or(Value::Null);
PendingApprovalInfo {
request_id: PERSISTED_REQUEST_ID,
session_id,
tool_call_id: id,
tool_name: name.clone(),
arguments,
agent_id,
source,
context_label: None,
created_at,
tool_category: None,
mcp_server: mcp_server_from_tool_name(&name),
}
}).collect()
}
// ── Tool visibility ───────────────────────────────────────────────────────
/// Returns `false` only when the first matching rule (by tool_pattern) is `Deny`.
/// Path/agent/source filters are intentionally ignored — this is a static
/// "is the tool offered to the LLM at all?" check, not an execution-time gate.
/// Rules must already be loaded via `list_for_group`.
pub fn is_tool_visible(&self, rules: &[ApprovalRule], tool_name: &str) -> bool {
for rule in rules {
if pattern_matches(&rule.tool_pattern, tool_name) {
return rule.action != RuleAction::Deny;
}
}
true // no matching rule → visible (backward compatible)
}
/// Resolves the effective `RuleAction` for `tool_name` in `group_id`,
/// evaluating only `tool_pattern` (no path/agent/source).
/// Returns `None` when no rule matches (tool is implicitly visible).
pub async fn check_tool_visibility(
&self,
group_id: &str,
tool_name: &str,
) -> Option<RuleAction> {
let rules = crate::core::db::approval_rules::list_for_group(&self.db, Some(group_id))
.await
.unwrap_or_default();
for rule in &rules {
if pattern_matches(&rule.tool_pattern, tool_name) {
return Some(rule.action.clone());
}
}
None
}
// ── Rule management ───────────────────────────────────────────────────────
pub async fn list_rules(&self) -> Result<Vec<ApprovalRule>> {
crate::core::db::approval_rules::list(&self.db).await
}
pub async fn add_rule(&self, r: NewApprovalRule) -> Result<i64> {
if r.tool_pattern == "*" {
let group = r.group_id.as_deref().unwrap_or("default");
self.ensure_no_conflicting_catch_all(group, &r.action, None).await?;
}
crate::core::db::approval_rules::insert(&self.db, r).await
}
pub async fn delete_rule(&self, id: i64) -> Result<()> {
crate::core::db::approval_rules::delete(&self.db, id).await
}
pub async fn update_rule(&self, id: i64, r: NewApprovalRule) -> Result<()> {
if r.tool_pattern == "*" {
let group = r.group_id.as_deref().unwrap_or("default");
self.ensure_no_conflicting_catch_all(group, &r.action, Some(id)).await?;
}
crate::core::db::approval_rules::update(&self.db, id, r).await
}
/// Rejects creating/updating a `*` catch-all when the target group already has a
/// **different** `*` catch-all. Two conflicting catch-alls shadow each other silently
/// (lower priority number wins regardless of action) — the exact class of bug that let
/// an `allow *` mask a `require *`. Editing the *same* row (matched by `exclude_id`) is
/// always allowed, so the general rule stays changeable from the frontend.
async fn ensure_no_conflicting_catch_all(
&self,
group_id: &str,
action: &RuleAction,
exclude_id: Option<i64>,
) -> Result<()> {
let rows: Vec<(i64, String)> = sqlx::query_as(
"SELECT id, action FROM approval_rules
WHERE tool_pattern = '*' AND group_id = ?",
)
.bind(group_id)
.fetch_all(self.db.as_ref())
.await?;
for (id, existing_action) in rows {
if Some(id) == exclude_id {
continue;
}
let existing: RuleAction = existing_action.parse()?;
if &existing != action {
anyhow::bail!(
"group '{group_id}' already has a '*' catch-all with action '{}' \
(rule id {id}); conflicting catch-alls shadow silently — edit or \
remove it first",
existing.as_str(),
);
}
}
Ok(())
}
/// Approve + register a session bypass so future tool calls of the same
/// category / MCP server are auto-approved.
///
/// - `bypass_secs = Some(n)`: bypass lasts `n` seconds (0 is treated as indefinite)
/// - `bypass_secs = None`: bypass lasts until the session ends
///
/// Scope is auto-detected from the pending request's tool metadata,
/// mirroring the web-inbox logic in `src/frontend/api/inbox.rs`.
pub async fn approve_with_bypass(&self, request_id: i64, bypass_secs: Option<u64>) {
let info = self.get_pending(request_id).await;
self.approve(request_id).await;
let Some(info) = info else { return };
let duration = bypass_secs
.filter(|&s| s > 0)
.map(Duration::from_secs);
if let Some(cat) = info.tool_category {
self.bypass_session_for_category(info.session_id, cat, duration).await;
} else if let Some(srv) = info.mcp_server {
self.bypass_session_for_mcp(info.session_id, srv, duration).await;
} else {
match duration {
Some(d) => self.bypass_session_for(info.session_id, d).await,
None => self.bypass_session(info.session_id).await,
}
}
}
}
// ── ApprovalApi trait impl ────────────────────────────────────────────────────
#[async_trait::async_trait]
impl core_api::approval::ApprovalApi for ApprovalManager {
async fn approve(&self, request_id: i64) {
self.approve(request_id).await;
}
async fn reject(&self, request_id: i64, note: String) {
self.reject(request_id, note).await;
}
async fn approve_with_bypass(&self, request_id: i64, bypass_secs: Option<u64>) {
self.approve_with_bypass(request_id, bypass_secs).await;
}
}
// ── Matching helpers ──────────────────────────────────────────────────────────
/// Returns `true` if the rule applies to the given (agent_id, source, tool_name, args).
fn rule_matches(rule: &ApprovalRule, agent_id: &str, source: &str, tool_name: &str, args: &Value) -> bool {
// agent_id filter
if let Some(ref ra) = rule.agent_id {
if ra != agent_id {
return false;
}
}
// source filter
if let Some(ref rs) = rule.source {
if rs != source {
return false;
}
}
// tool pattern (exact, `prefix*` glob, or `@fs_*` filesystem class token)
if !tool_pattern_matches(&rule.tool_pattern, tool_name) {
return false;
}
// path_pattern filter: if set, args["path"] must match
if let Some(ref pp) = rule.path_pattern {
let path = args["path"].as_str().unwrap_or("");
let norm = normalize_path(path);
if !path_pattern_matches(pp, &norm) {
return false;
}
}
true
}
/// Matches a rule's `tool_pattern` against a concrete tool name.
///
/// In addition to the plain [`pattern_matches`] syntax (`*` / `prefix*` / exact), three
/// synthetic **filesystem class tokens** are recognised so a single rule can target a
/// whole operation class regardless of the individual tool name:
///
/// - `@fs_read` — any file-read tool (`read_file`, `grep_files`, …)
/// - `@fs_write` — any file-write tool (`write_file`, `edit_file`, …)
/// - `@fs_any` — any filesystem tool (read *or* write)
///
/// These back the "File System" permission panel, where each path row is one rule row.
pub(crate) fn tool_pattern_matches(pattern: &str, tool_name: &str) -> bool {
match pattern {
"@fs_read" => crate::core::tools::is_file_read_tool(tool_name),
"@fs_write" => crate::core::tools::is_file_write_tool(tool_name),
"@fs_any" => {
crate::core::tools::is_file_read_tool(tool_name)
|| crate::core::tools::is_file_write_tool(tool_name)
}
_ => pattern_matches(pattern, tool_name),
}
}
/// Matches a rule's `path_pattern` against a normalised (project-relative) path.
///
/// A `"<dir>/*"` pattern is treated as a **directory subtree**: it matches the directory
/// node itself (`path == "<dir>"`) *and* everything under it (`path` starts with
/// `"<dir>/"`). Unlike the raw trailing-`*` string match in [`pattern_matches`], this uses
/// a `/`-delimited boundary, so `memory/*` matches `memory` and `memory/x` but not the
/// sibling `memory-secrets/x`. All other patterns fall back to [`pattern_matches`]
/// (exact / trailing-`*`), preserving legacy `data/*`-style behaviour.
pub(crate) fn path_pattern_matches(pattern: &str, path: &str) -> bool {
if let Some(dir) = pattern.strip_suffix("/*") {
return path == dir || path.starts_with(&format!("{dir}/"));
}
pattern_matches(pattern, path)
}
/// Matches an exact name or a `prefix*` glob.
pub(crate) fn pattern_matches(pattern: &str, tool_name: &str) -> bool {
if pattern == "*" {
return true;
}
if let Some(prefix) = pattern.strip_suffix('*') {
tool_name.starts_with(prefix)
} else {
pattern == tool_name
}
}
/// Returns `true` when an `ApprovalBypass` entry covers the given tool.
fn bypass_matches(bypass: &ApprovalBypass, category: Option<ToolCategory>, tool_name: &str) -> bool {
match &bypass.scope {
BypassScope::All => true,
BypassScope::Category(bc) => category.map_or(false, |tc| tc == *bc),
BypassScope::McpServer(server) => {
mcp_server_from_tool_name(tool_name).map_or(false, |s| s == *server)
}
}
}
/// Extracts the MCP server name from a tool name following the `mcp__<server>__<tool>` pattern.
fn mcp_server_from_tool_name(name: &str) -> Option<String> {
let rest = name.strip_prefix("mcp__")?;
let end = rest.find("__")?;
Some(rest[..end].to_string())
}
/// Normalises a file path to a project-relative form for rule matching.
///
/// The path is canonicalized first (resolving `..` and symlinks via
/// `canonicalize_for_policy`) so that `docs/../secrets/x` or a symlink into `secrets/`
/// cannot evade a path-pattern rule. If the canonical path falls under the process
/// working directory it is made relative to it; otherwise the leading `/` is stripped
/// as a best-effort fallback.
pub(crate) fn normalize_path(path: &str) -> String {
let cwd = std::env::current_dir().unwrap_or_default();
let cwd = std::fs::canonicalize(&cwd).unwrap_or(cwd);
let canon = crate::core::tools::fs::canonicalize_for_policy(path, &cwd);
if let Ok(rel) = canon.strip_prefix(&cwd) {
return rel.to_string_lossy().into_owned();
}
canon.to_string_lossy().trim_start_matches('/').to_string()
}
#[cfg(test)]
mod tests {
use super::{path_pattern_matches, pattern_matches, tool_pattern_matches};
#[test]
fn fs_class_tokens_resolve_by_tool_class() {
// @fs_read — read tools only
assert!(tool_pattern_matches("@fs_read", "read_file"));
assert!(tool_pattern_matches("@fs_read", "grep_files"));
assert!(!tool_pattern_matches("@fs_read", "write_file"));
// @fs_write — write tools only
assert!(tool_pattern_matches("@fs_write", "write_file"));
assert!(tool_pattern_matches("@fs_write", "insert_at_line"));
assert!(!tool_pattern_matches("@fs_write", "read_file"));
// @fs_any — any filesystem tool (read or write)
assert!(tool_pattern_matches("@fs_any", "read_file"));
assert!(tool_pattern_matches("@fs_any", "write_file"));
// Non-filesystem tools never match the fs class tokens.
for token in ["@fs_read", "@fs_write", "@fs_any"] {
assert!(!tool_pattern_matches(token, "execute_cmd"));
assert!(!tool_pattern_matches(token, "notify"));
}
}
#[test]
fn fs_tokens_fall_back_to_plain_pattern_for_non_tokens() {
assert!(tool_pattern_matches("write_file", "write_file"));
assert!(tool_pattern_matches("*", "anything"));
assert!(tool_pattern_matches("mcp__gmail__*", "mcp__gmail__search"));
assert!(!tool_pattern_matches("write_file", "read_file"));
}
#[test]
fn dir_subtree_pattern_matches_dir_node_and_children_only() {
// `<dir>/*` matches the directory node itself and everything under it…
assert!(path_pattern_matches("memory/*", "memory"));
assert!(path_pattern_matches("memory/*", "memory/notes.md"));
assert!(path_pattern_matches("memory/*", "memory/sub/deep.md"));
// …but NOT a sibling that merely shares the string prefix.
assert!(!path_pattern_matches("memory/*", "memory-secrets/leak.md"));
assert!(!path_pattern_matches("memory/*", "memoryx"));
assert!(!path_pattern_matches("memory/*", "other/memory"));
}
#[test]
fn non_subtree_path_patterns_fall_back_to_plain_match() {
// Exact file path
assert!(path_pattern_matches("config.yml", "config.yml"));
assert!(!path_pattern_matches("config.yml", "config.yml.bak"));
// Legacy trailing-`*` prefix semantics preserved
assert!(path_pattern_matches("data*", "data"));
assert!(path_pattern_matches("data*", "database/x"));
// Sanity: the raw primitive still behaves as before
assert!(pattern_matches("data/*", "data/x"));
}
// End-to-end: run the real startup pipeline (migrate → seed) against a temp SQLite
// DB pre-loaded with legacy rules, then assert the gate decisions through `check()`.
#[tokio::test]
async fn fs_seed_migrate_and_gate_end_to_end() {
use super::{ApprovalManager, GateResult};
use serde_json::json;
use std::sync::Arc;
use tokio::sync::broadcast;
let path = std::env::temp_dir().join(format!("skald_fs_test_{}.db", std::process::id()));
let path_str = path.to_string_lossy().to_string();
let _ = std::fs::remove_file(&path);
let pool = crate::core::db::init_pool(&path_str).await.expect("init_pool");
let db = Arc::new(pool);
// The `default` permission group is a FK target for approval_rules.group_id.
sqlx::query("INSERT INTO tool_permission_groups (id, name) VALUES ('default', 'Default')")
.execute(db.as_ref())
.await
.unwrap();
// Pre-load LEGACY rows as an older install would have them.
for t in ["write_file", "edit_file", "insert_at_line", "replace_lines"] {
sqlx::query(
"INSERT INTO approval_rules (tool_pattern, action, note, priority, group_id)
VALUES (?, 'require', 'default rule', 10, 'default')",
)
.bind(t)
.execute(db.as_ref())
.await
.unwrap();
}
for t in ["write_file", "edit_file"] {
sqlx::query(
"INSERT INTO approval_rules (tool_pattern, path_pattern, action, note, priority, group_id)
VALUES (?, 'data/*', 'allow', 'auto-allow data/ writes', 5, 'default')",
)
.bind(t)
.execute(db.as_ref())
.await
.unwrap();
}
for t in ["read_file", "grep_files"] {
sqlx::query(
"INSERT INTO approval_rules (tool_pattern, path_pattern, action, note, priority, group_id)
VALUES (?, 'secrets', 'deny', 'deny reading secrets/', 5, 'default')",
)
.bind(t)
.execute(db.as_ref())
.await
.unwrap();
}
let (tx, _rx) = broadcast::channel(16);
let mgr = ApprovalManager::new(Arc::clone(&db), tx);
// Startup pipeline order (mirrors bundles.rs).
mgr.seed_defaults().await.unwrap(); // no-op: table already non-empty
mgr.migrate_legacy_fs_rules().await.unwrap();
mgr.seed_fs_path_rules().await.unwrap();
mgr.seed_default_catch_all().await.unwrap();
// Legacy per-tool fs rows are migrated away…
let legacy: i64 = sqlx::query_scalar(
"SELECT COUNT(*) FROM approval_rules
WHERE note IN ('default rule', 'auto-allow data/ writes', 'deny reading secrets/')",
)
.fetch_one(db.as_ref())
.await
.unwrap();
assert_eq!(legacy, 0, "legacy fs rules should be removed by migration");
// …and replaced by exactly the three @fs_* token rows.
let fs_rows: i64 = sqlx::query_scalar(
"SELECT COUNT(*) FROM approval_rules WHERE tool_pattern LIKE '@fs%'",
)
.fetch_one(db.as_ref())
.await
.unwrap();
assert_eq!(fs_rows, 3, "memory/data/secrets @fs_* rules should be seeded");
// Gate decisions through the real check() path.
async fn decide(mgr: &ApprovalManager, tool: &str, path: &str) -> GateResult {
mgr.check(1, None, "main", "web", tool, &json!({ "path": path }), Some("default")).await
}
assert!(matches!(decide(&mgr, "write_file", "memory/notes.md").await, GateResult::Allow));
assert!(matches!(decide(&mgr, "read_file", "data/x.txt").await, GateResult::Allow));
// Improvement over legacy: secrets *writes* are now denied too, not just reads.
assert!(matches!(decide(&mgr, "write_file", "secrets/key").await, GateResult::Deny));
assert!(matches!(decide(&mgr, "read_file", "secrets/key").await, GateResult::Deny));
// Unmatched write falls through to the `*` catch-all.
assert!(matches!(decide(&mgr, "write_file", "src/main.rs").await, GateResult::Require));
// Non-filesystem tool: unaffected by @fs_* rules, gated by catch-all.
let cmd = mgr
.check(1, None, "main", "web", "execute_cmd", &json!({ "command": "ls" }), Some("default"))
.await;
assert!(matches!(cmd, GateResult::Require));
db.close().await;
for suffix in ["", "-wal", "-shm"] {
let _ = std::fs::remove_file(format!("{path_str}{suffix}"));
}
}
}