//! 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 { 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, /// `None` matches any source (`web`, `telegram`, `cron`, …). pub source: Option, /// 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, pub action: RuleAction, pub note: Option, /// Lower priority number = evaluated first. pub priority: i64, /// Permission group this rule belongs to. `None` is treated as `"default"`. pub group_id: Option, } /// Input for creating a new rule. #[derive(Debug, Clone, Deserialize)] pub struct NewApprovalRule { pub agent_id: Option, pub source: Option, pub tool_pattern: String, /// Optional glob on the normalised file path (e.g. `data/*`). pub path_pattern: Option, pub action: RuleAction, pub note: Option, pub priority: Option, /// Permission group for this rule. Defaults to `"default"` when omitted. pub group_id: Option, } /// 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, /// ISO-8601 timestamp string (UTC). pub created_at: String, /// Registered tool category (None for MCP and unknown tools). pub tool_category: Option, /// MCP server name extracted from the tool name (e.g. "gmail" from "mcp__gmail__search"). /// None for non-MCP tools. pub mcp_server: Option, } /// `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____` 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, } // ── ApprovalManager ─────────────────────────────────────────────────────────── pub struct ApprovalManager { db: Arc, /// 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, session_bypasses: Mutex>>, event_tx: broadcast::Sender, } impl ApprovalManager { pub fn new(db: Arc, event_tx: broadcast::Sender) -> 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, 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, ) -> (i64, oneshot::Receiver) { // 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 { 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 { 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 { 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 { // 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 { 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, ) { 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, ) { 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 { 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 { let rows = sqlx::query_as::<_, (i64, String, Option, 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 { 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> { crate::core::db::approval_rules::list(&self.db).await } pub async fn add_rule(&self, 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, 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, ) -> 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) { 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) { 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 `"/*"` pattern is treated as a **directory subtree**: it matches the directory /// node itself (`path == ""`) *and* everything under it (`path` starts with /// `"/"`). 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, 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____` pattern. fn mcp_server_from_tool_name(name: &str) -> Option { 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() { // `/*` 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}")); } } }