Release 0.2.0 #4

Merged
dguiducci merged 96 commits from main into release 2026-08-17 18:07:20 +01:00
6 changed files with 156 additions and 48 deletions
Showing only changes of commit 548871fc72 - Show all commits
+114 -14
View File
@@ -172,13 +172,28 @@ pub const PERSISTED_REQUEST_ID: i64 = 0;
// ── Session bypass ──────────────────────────────────────────────────────────── // ── Session bypass ────────────────────────────────────────────────────────────
/// What a session bypass entry applies to. /// What a session bypass entry applies to.
///
/// [`Tool`](Self::Tool) is the **default** scope of the "15 min" / "Session"
/// buttons on an approval card, and the only one narrow enough to be safe to
/// pick on the user's behalf: a human answering a card has read *that* call,
/// and nothing else. The wider scopes stay reachable through the REST
/// `bypass_scope` field, where choosing one is a deliberate act.
pub enum BypassScope { pub enum BypassScope {
/// Covers every tool regardless of category. /// Covers every tool regardless of category.
All, All,
/// Covers exactly one tool, matched on its full name
/// (`mcp__gmail__send_message`, `write_file`, …).
Tool(String),
/// Covers only tools of the given registered category. /// Covers only tools of the given registered category.
Category(ToolCategory), Category(ToolCategory),
/// Covers only tools belonging to the named MCP server /// Covers only tools belonging to the named MCP server
/// (matched by the `mcp__<server>__` prefix in the tool name). /// (matched by the `mcp__<server>__` prefix in the tool name).
///
/// **A connector is not a permission unit**: its read tools and its write
/// tools live under one name, so this scope reads "trust everything Gmail
/// can do" — including sending mail — from a click on a card that asked
/// about labelling a message. Never auto-detect it; require the caller to
/// name it.
McpServer(String), McpServer(String),
} }
@@ -715,6 +730,22 @@ impl ApprovalManager {
info!(session_id, secs = duration.as_secs(), "approval: bypass active (timed)"); info!(session_id, secs = duration.as_secs(), "approval: bypass active (timed)");
} }
/// Bypasses approval prompts for one tool, matched on its full name.
/// `duration` is `None` for an indefinite (session-scoped) bypass.
pub async fn bypass_session_for_tool(
&self,
session_id: i64,
tool: 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::Tool(tool.clone()), expires_at });
info!(session_id, tool, secs = duration.map(|d| d.as_secs()), "approval: bypass active (tool)");
}
/// Bypasses approval prompts for a specific tool `category`. /// Bypasses approval prompts for a specific tool `category`.
/// `duration` is `None` for an indefinite (session-scoped) bypass. /// `duration` is `None` for an indefinite (session-scoped) bypass.
pub async fn bypass_session_for_category( pub async fn bypass_session_for_category(
@@ -892,14 +923,21 @@ impl ApprovalManager {
Ok(()) Ok(())
} }
/// Approve + register a session bypass so future tool calls of the same /// Approve + register a session bypass so future calls of the **same tool**
/// category / MCP server are auto-approved. /// are auto-approved.
/// ///
/// - `bypass_secs = Some(n)`: bypass lasts `n` seconds (0 is treated as indefinite) /// - `bypass_secs = Some(n)`: bypass lasts `n` seconds (0 is treated as indefinite)
/// - `bypass_secs = None`: bypass lasts until the session ends /// - `bypass_secs = None`: bypass lasts until the session ends
/// ///
/// Scope is auto-detected from the pending request's tool metadata, /// The scope is always [`BypassScope::Tool`] and is deliberately **not**
/// mirroring the web-inbox logic in `src/frontend/api/inbox.rs`. /// inferred from the tool's category or MCP server. It used to be: a click
/// on a Gmail card registered a bypass over the whole connector, so
/// approving `mcp__gmail__modify_message` silently un-gated
/// `mcp__gmail__send_message` — an explicit `require` rule on it and all —
/// and the only trace was a log line. A human answering a card has read one
/// call; that call is the widest thing their click may authorise. The
/// broader scopes remain available to a caller that names one (the REST
/// `bypass_scope` field in `src/frontend/api/inbox.rs`).
pub async fn approve_with_bypass(&self, request_id: i64, bypass_secs: Option<u64>) { pub async fn approve_with_bypass(&self, request_id: i64, bypass_secs: Option<u64>) {
let info = self.get_pending(request_id).await; let info = self.get_pending(request_id).await;
self.approve(request_id).await; self.approve(request_id).await;
@@ -907,16 +945,7 @@ impl ApprovalManager {
let duration = bypass_secs let duration = bypass_secs
.filter(|&s| s > 0) .filter(|&s| s > 0)
.map(Duration::from_secs); .map(Duration::from_secs);
if let Some(cat) = info.tool_category { self.bypass_session_for_tool(info.session_id, info.tool_name, duration).await;
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,
}
}
} }
} }
@@ -1022,6 +1051,7 @@ pub(crate) fn pattern_matches(pattern: &str, tool_name: &str) -> bool {
fn bypass_matches(bypass: &ApprovalBypass, category: Option<ToolCategory>, tool_name: &str) -> bool { fn bypass_matches(bypass: &ApprovalBypass, category: Option<ToolCategory>, tool_name: &str) -> bool {
match &bypass.scope { match &bypass.scope {
BypassScope::All => true, BypassScope::All => true,
BypassScope::Tool(name) => name == tool_name,
BypassScope::Category(bc) => category.map_or(false, |tc| tc == *bc), BypassScope::Category(bc) => category.map_or(false, |tc| tc == *bc),
BypassScope::McpServer(server) => { BypassScope::McpServer(server) => {
mcp_server_from_tool_name(tool_name).map_or(false, |s| s == *server) mcp_server_from_tool_name(tool_name).map_or(false, |s| s == *server)
@@ -1112,6 +1142,76 @@ mod tests {
assert!(pattern_matches("data/*", "data/x")); assert!(pattern_matches("data/*", "data/x"));
} }
/// A bypass answered from a card covers **that tool only**.
///
/// The regression: approving `mcp__gmail__modify_message` with "15 min" used to
/// register a bypass over the whole `gmail` connector, so the very next
/// `mcp__gmail__send_message` executed without a prompt — through an explicit
/// `require` rule written for it — and the only evidence was a log line.
#[tokio::test]
async fn a_tool_bypass_does_not_cover_its_connector() {
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_bypass_test_{}.db", std::process::id()));
let path_str = path.to_string_lossy().to_string();
let _ = std::fs::remove_file(&path);
let pool = crate::db::init_system_pool(&path_str).await.expect("init_system_pool");
let db = Arc::new(pool);
sqlx::query("INSERT INTO tool_permission_groups (id, name) VALUES ('default', 'Default')")
.execute(db.as_ref()).await.unwrap();
for (tool, action) in [
("mcp__gmail__modify_message", "require"),
("mcp__gmail__send_message", "require"),
] {
sqlx::query(
"INSERT INTO approval_rules (tool_pattern, action, priority, group_id)
VALUES (?, ?, 0, 'default')",
)
.bind(tool).bind(action).execute(db.as_ref()).await.unwrap();
}
let (tx, _rx) = broadcast::channel(16);
let mgr = ApprovalManager::new(Arc::clone(&db), tx);
mgr.seed_default_catch_all().await.unwrap();
let decide = |tool: &'static str| {
let mgr = &mgr;
async move {
mgr.check(1, None, "assistant", "web", tool, &json!({}), Some("default")).await
}
};
// Both gated to begin with.
assert!(matches!(decide("mcp__gmail__modify_message").await, GateResult::Require));
assert!(matches!(decide("mcp__gmail__send_message").await, GateResult::Require));
// The human approves ONE call with a bypass.
mgr.bypass_session_for_tool(1, "mcp__gmail__modify_message".into(), None).await;
// It covers that tool…
assert!(matches!(decide("mcp__gmail__modify_message").await, GateResult::Allow));
// …and nothing else on the same connector.
assert!(matches!(decide("mcp__gmail__send_message").await, GateResult::Require));
// Nor another session's calls (the map is keyed by conversation).
let other = mgr
.check(2, None, "assistant", "web", "mcp__gmail__modify_message", &json!({}), Some("default"))
.await;
assert!(matches!(other, GateResult::Require));
// The connector-wide scope still exists for a caller that names it.
mgr.bypass_session_for_mcp(1, "gmail".into(), None).await;
assert!(matches!(decide("mcp__gmail__send_message").await, GateResult::Allow));
db.close().await;
for suffix in ["", "-wal", "-shm"] {
let _ = std::fs::remove_file(format!("{path_str}{suffix}"));
}
}
// End-to-end: run the real startup pipeline (migrate → seed) against a temp SQLite // 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()`. // DB pre-loaded with legacy rules, then assert the gate decisions through `check()`.
#[tokio::test] #[tokio::test]
+21 -16
View File
@@ -145,7 +145,12 @@ pub struct ApproveBody {
/// Seconds for the bypass duration. `0` means indefinite (session-scoped). /// Seconds for the bypass duration. `0` means indefinite (session-scoped).
/// Absent means no bypass. /// Absent means no bypass.
pub bypass_secs: Option<u64>, pub bypass_secs: Option<u64>,
/// `"category"` | `"mcp_server"` | `"all"`. Defaults to auto-detect from tool info. /// `"tool"` | `"category"` | `"mcp_server"` | `"all"`. Defaults to `"tool"`.
///
/// Deliberately **not** auto-detected from the tool's metadata any more: a
/// click on an approval card authorises the call the human just read, and
/// widening that to the tool's category or its whole MCP connector is a
/// decision only an explicit value may make (see `approve_with_bypass`).
pub bypass_scope: Option<String>, pub bypass_scope: Option<String>,
} }
@@ -171,28 +176,28 @@ pub async fn resolve_approval(
if let (Some(info), Some(bypass_secs)) = (info, body.bypass_secs) { if let (Some(info), Some(bypass_secs)) = (info, body.bypass_secs) {
let duration = if bypass_secs == 0 { None } else { Some(Duration::from_secs(bypass_secs)) }; let duration = if bypass_secs == 0 { None } else { Some(Duration::from_secs(bypass_secs)) };
let scope = body.bypass_scope.as_deref().unwrap_or_else(|| { let scope = body.bypass_scope.as_deref().unwrap_or("tool");
if info.tool_category.is_some() { "category" }
else if info.mcp_server.is_some() { "mcp_server" }
else { "all" }
});
// Every fallback here narrows, never widens: a scope that cannot be
// honoured (a category-less tool, a non-MCP one) and an unknown
// scope string both degrade to the tool itself. Only a literal
// `"all"` disables the gate session-wide, and only because the
// caller spelled it out.
match scope { match scope {
"category" => { "category" if info.tool_category.is_some() => {
if let Some(cat) = info.tool_category { let cat = info.tool_category.unwrap();
ctx.approval.bypass_session_for_category(info.session_id, cat, duration).await; ctx.approval.bypass_session_for_category(info.session_id, cat, duration).await;
} else {
apply_all_bypass(&ctx, info.session_id, duration).await;
} }
} "mcp_server" if info.mcp_server.is_some() => {
"mcp_server" => { let server = info.mcp_server.clone().unwrap();
if let Some(server) = info.mcp_server {
ctx.approval.bypass_session_for_mcp(info.session_id, server, duration).await; ctx.approval.bypass_session_for_mcp(info.session_id, server, duration).await;
} else {
apply_all_bypass(&ctx, info.session_id, duration).await;
} }
"all" => apply_all_bypass(&ctx, info.session_id, duration).await,
_ => {
ctx.approval
.bypass_session_for_tool(info.session_id, info.tool_name.clone(), duration)
.await;
} }
_ => apply_all_bypass(&ctx, info.session_id, duration).await,
} }
} }
} }
+2 -2
View File
@@ -321,8 +321,8 @@ export default {
'approval.reject': 'Deny', 'approval.reject': 'Deny',
'approval.confirm_reject': 'Confirm deny', 'approval.confirm_reject': 'Confirm deny',
'approval.reject_hint': 'Optional: say why (the assistant will read it)', 'approval.reject_hint': 'Optional: say why (the assistant will read it)',
'approval.bypass_15': 'Allow and skip similar requests for 15 minutes', 'approval.bypass_15': 'Allow, and stop asking for this same tool for 15 minutes',
'approval.bypass_all': 'Allow and skip all requests for this session', 'approval.bypass_all': 'Allow, and stop asking for this same tool for the rest of this conversation',
// ── Login ────────────────────────────────────────────────────────────────── // ── Login ──────────────────────────────────────────────────────────────────
'login.title': 'Welcome back', 'login.title': 'Welcome back',
+2 -2
View File
@@ -321,8 +321,8 @@ export default {
'approval.reject': 'Refuser', 'approval.reject': 'Refuser',
'approval.confirm_reject': 'Confirmer le refus', 'approval.confirm_reject': 'Confirmer le refus',
'approval.reject_hint': 'Facultatif : dites pourquoi (l\'assistant le lira)', 'approval.reject_hint': 'Facultatif : dites pourquoi (l\'assistant le lira)',
'approval.bypass_15': 'Autoriser et ignorer les demandes similaires pendant 15 minutes', 'approval.bypass_15': 'Autoriser et ne plus demander pour ce même outil pendant 15 minutes',
'approval.bypass_all': 'Autoriser et ignorer toutes les demandes pour cette session', 'approval.bypass_all': 'Autoriser et ne plus demander pour ce même outil jusqu\'à la fin de la conversation',
// ── Login ────────────────────────────────────────────────────────────────── // ── Login ──────────────────────────────────────────────────────────────────
'login.title': 'Bon retour', 'login.title': 'Bon retour',
+2 -2
View File
@@ -321,8 +321,8 @@ export default {
'approval.reject': 'Nega', 'approval.reject': 'Nega',
'approval.confirm_reject': 'Conferma il rifiuto', 'approval.confirm_reject': 'Conferma il rifiuto',
'approval.reject_hint': 'Facoltativo: spiega perché (lo leggerà l\'assistente)', 'approval.reject_hint': 'Facoltativo: spiega perché (lo leggerà l\'assistente)',
'approval.bypass_15': 'Consenti e salta richieste simili per 15 minuti', 'approval.bypass_15': 'Consenti e non chiedere più per questo stesso strumento per 15 minuti',
'approval.bypass_all': 'Consenti e salta tutte le richieste di questa sessione', 'approval.bypass_all': 'Consenti e non chiedere più per questo stesso strumento per il resto della conversazione',
// ── Accesso ──────────────────────────────────────────────────────────────── // ── Accesso ────────────────────────────────────────────────────────────────
'login.title': 'Bentornato', 'login.title': 'Bentornato',
+12 -9
View File
@@ -73,19 +73,22 @@ export const InboxCardsMixin = (Base) => class extends Base {
this._resolveApproval(requestId, 'reject', note, null, null, toolCallId); this._resolveApproval(requestId, 'reject', note, null, null, toolCallId);
} }
/** Approve + set a timed or session bypass scoped to the tool's category or MCP server. */ /**
* Approve + skip approval for **this same tool** for a while.
*
* Scoped to the tool and nothing wider: the card the human just read is
* about one call, and the previous category/MCP-server auto-detect meant a
* click on "label this message" also un-gated "send this message" for the
* rest of the session.
*/
_approveWithBypass(item, bypassSecs) { _approveWithBypass(item, bypassSecs) {
const scope = item.tool_category ? 'category' this._resolveApproval(item.request_id, 'approve', '', bypassSecs, 'tool');
: item.mcp_server ? 'mcp_server'
: 'all';
this._resolveApproval(item.request_id, 'approve', '', bypassSecs, scope);
} }
/** Human-readable bypass scope label, e.g. "filesystem" or "Gmail". */ /** Short label for the bypass scope — the tool, `mcp__x__y` shown as `y`. */
_bypassLabel(item) { _bypassLabel(item) {
if (item.tool_category) return item.tool_category; const name = item.tool_name ?? '';
if (item.mcp_server) return item.mcp_server; return name.startsWith('mcp__') ? name.split('__').pop() : name;
return 'session';
} }
async _resolveClarification(requestId, inputEl) { async _resolveClarification(requestId, inputEl) {