1154 lines
50 KiB
Rust
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}"));
|
|
}
|
|
}
|
|
}
|