Files
owlen/crates/owlen-core/src/consent.rs
vikingowl c3a92a092b feat(mcp): enforce spec-compliant tool registry
- Reject dotted tool identifiers during registration and remove alias-backed lookups.
- Drop web.search compatibility, normalize all code/tests around the canonical web_search name, and update consent/session logic.
- Harden CLI toggles to manage the spec-compliant identifier and ensure MCP configs shed non-compliant entries automatically.

Acceptance Criteria:
- Tool registry denies invalid identifiers by default and no alias codepaths remain.

Test Notes:
- cargo check -p owlen-core (tests unavailable in sandbox).
2025-10-25 04:48:45 +02:00

313 lines
10 KiB
Rust

use std::collections::HashMap;
use std::io::{self, Write};
use std::sync::Arc;
use anyhow::Result;
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use crate::encryption::VaultHandle;
use crate::tools::canonical_tool_name;
#[derive(Clone, Debug)]
pub struct ConsentRequest {
pub tool_name: String,
}
/// Scope of consent grant
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)]
pub enum ConsentScope {
/// Grant only for this single operation
Once,
/// Grant for the duration of the current session
Session,
/// Grant permanently (persisted across sessions)
Permanent,
/// Explicitly denied
Denied,
}
#[derive(Serialize, Deserialize, Clone, Debug)]
pub struct ConsentRecord {
pub tool_name: String,
pub scope: ConsentScope,
pub timestamp: DateTime<Utc>,
pub data_types: Vec<String>,
pub external_endpoints: Vec<String>,
}
#[derive(Serialize, Deserialize, Default)]
pub struct ConsentManager {
/// Permanent consent records (persisted to vault)
permanent_records: HashMap<String, ConsentRecord>,
/// Session-scoped consent (cleared on manager drop or explicit clear)
#[serde(skip)]
session_records: HashMap<String, ConsentRecord>,
/// Once-scoped consent (used once then cleared)
#[serde(skip)]
once_records: HashMap<String, ConsentRecord>,
/// Pending consent requests (to prevent duplicate prompts)
#[serde(skip)]
pending_requests: HashMap<String, ()>,
}
impl ConsentManager {
pub fn new() -> Self {
Self::default()
}
/// Load consent records from vault storage
pub fn from_vault(vault: &Arc<std::sync::Mutex<VaultHandle>>) -> Self {
let guard = vault.lock().expect("Vault mutex poisoned");
if let Some(permanent_records) =
guard
.settings()
.get("consent_records")
.and_then(|consent_data| {
serde_json::from_value::<HashMap<String, ConsentRecord>>(consent_data.clone())
.ok()
})
{
return Self {
permanent_records,
session_records: HashMap::new(),
once_records: HashMap::new(),
pending_requests: HashMap::new(),
};
}
Self::default()
}
/// Persist permanent consent records to vault storage
pub fn persist_to_vault(&self, vault: &Arc<std::sync::Mutex<VaultHandle>>) -> Result<()> {
let mut guard = vault.lock().expect("Vault mutex poisoned");
let consent_json = serde_json::to_value(&self.permanent_records)?;
guard
.settings_mut()
.insert("consent_records".to_string(), consent_json);
guard.persist()?;
Ok(())
}
pub fn request_consent(
&mut self,
tool_name: &str,
data_types: Vec<String>,
endpoints: Vec<String>,
) -> Result<ConsentScope> {
let canonical = canonical_tool_name(tool_name);
// Check if already granted permanently
if self
.permanent_records
.get(canonical)
.is_some_and(|existing| existing.scope == ConsentScope::Permanent)
{
return Ok(ConsentScope::Permanent);
}
// Check if granted for session
if self
.session_records
.get(canonical)
.is_some_and(|existing| existing.scope == ConsentScope::Session)
{
return Ok(ConsentScope::Session);
}
// Check if request is already pending (prevent duplicate prompts)
if self.pending_requests.contains_key(canonical) {
// Wait for the other prompt to complete by returning denied temporarily
// The caller should retry after a short delay
return Ok(ConsentScope::Denied);
}
// Mark as pending
self.pending_requests.insert(canonical.to_string(), ());
// Show consent dialog and get scope
let scope = self.show_consent_dialog(tool_name, &data_types, &endpoints)?;
// Remove from pending
self.pending_requests.remove(canonical);
// Create record based on scope
let record = ConsentRecord {
tool_name: canonical.to_string(),
scope: scope.clone(),
timestamp: Utc::now(),
data_types,
external_endpoints: endpoints,
};
// Store in appropriate location
match scope {
ConsentScope::Permanent => {
self.permanent_records.insert(canonical.to_string(), record);
}
ConsentScope::Session => {
self.session_records.insert(canonical.to_string(), record);
}
ConsentScope::Once | ConsentScope::Denied => {
// Don't store, just return the decision
}
}
Ok(scope)
}
/// Grant consent programmatically (for TUI or automated flows)
pub fn grant_consent(
&mut self,
tool_name: &str,
data_types: Vec<String>,
endpoints: Vec<String>,
) {
self.grant_consent_with_scope(tool_name, data_types, endpoints, ConsentScope::Permanent);
}
/// Grant consent with specific scope
pub fn grant_consent_with_scope(
&mut self,
tool_name: &str,
data_types: Vec<String>,
endpoints: Vec<String>,
scope: ConsentScope,
) {
let canonical = canonical_tool_name(tool_name);
let record = ConsentRecord {
tool_name: canonical.to_string(),
scope: scope.clone(),
timestamp: Utc::now(),
data_types,
external_endpoints: endpoints,
};
match scope {
ConsentScope::Permanent => {
self.permanent_records.insert(canonical.to_string(), record);
}
ConsentScope::Session => {
self.session_records.insert(canonical.to_string(), record);
}
ConsentScope::Once => {
self.once_records.insert(canonical.to_string(), record);
}
ConsentScope::Denied => {} // Denied is not stored
}
}
/// Check if consent is needed (returns None if already granted, Some(info) if needed)
pub fn check_consent_needed(&self, tool_name: &str) -> Option<ConsentRequest> {
let canonical = canonical_tool_name(tool_name);
if self.has_consent(canonical) {
None
} else {
Some(ConsentRequest {
tool_name: canonical.to_string(),
})
}
}
pub fn has_consent(&self, tool_name: &str) -> bool {
let canonical = canonical_tool_name(tool_name);
// Check permanent first, then session, then once
self.permanent_records
.get(canonical)
.map(|r| r.scope == ConsentScope::Permanent)
.or_else(|| {
self.session_records
.get(canonical)
.map(|r| r.scope == ConsentScope::Session)
})
.or_else(|| {
self.once_records
.get(canonical)
.map(|r| r.scope == ConsentScope::Once)
})
.unwrap_or(false)
}
/// Consume "once" consent for a tool (clears it after first use)
pub fn consume_once_consent(&mut self, tool_name: &str) {
let canonical = canonical_tool_name(tool_name);
self.once_records.remove(canonical);
}
pub fn revoke_consent(&mut self, tool_name: &str) {
let canonical = canonical_tool_name(tool_name);
self.permanent_records.remove(canonical);
self.session_records.remove(canonical);
self.once_records.remove(canonical);
}
pub fn clear_all_consent(&mut self) {
self.permanent_records.clear();
self.session_records.clear();
self.once_records.clear();
}
/// Clear only session-scoped consent (useful when starting new session)
pub fn clear_session_consent(&mut self) {
self.session_records.clear();
self.once_records.clear(); // Also clear once consent on session clear
}
/// Check if consent is needed for a tool (non-blocking)
/// Returns Some with consent details if needed, None if already granted
pub fn check_if_consent_needed(
&self,
tool_name: &str,
data_types: Vec<String>,
endpoints: Vec<String>,
) -> Option<(String, Vec<String>, Vec<String>)> {
let canonical = canonical_tool_name(tool_name);
if self.has_consent(canonical) {
return None;
}
Some((canonical.to_string(), data_types, endpoints))
}
fn show_consent_dialog(
&self,
tool_name: &str,
data_types: &[String],
endpoints: &[String],
) -> Result<ConsentScope> {
// TEMPORARY: Auto-grant session consent when not in a proper terminal (TUI mode)
// TODO: Integrate consent UI into the TUI event loop
use std::io::IsTerminal;
if !io::stdin().is_terminal() || std::env::var("OWLEN_AUTO_CONSENT").is_ok() {
eprintln!("Auto-granting session consent for {} (TUI mode)", tool_name);
return Ok(ConsentScope::Session);
}
println!("\n╔══════════════════════════════════════════════════╗");
println!("║ 🔒 PRIVACY CONSENT REQUIRED 🔒 ║");
println!("╚══════════════════════════════════════════════════╝");
println!();
println!("Tool: {}", tool_name);
println!("Data: {}", data_types.join(", "));
println!("Endpoints: {}", endpoints.join(", "));
println!();
println!("Choose consent scope:");
println!(" [1] Allow once - Grant only for this operation");
println!(" [2] Allow session - Grant for current session");
println!(" [3] Allow always - Grant permanently");
println!(" [4] Deny - Reject this operation");
println!();
print!("Enter choice (1-4) [default: 4]: ");
io::stdout().flush()?;
let mut input = String::new();
io::stdin().read_line(&mut input)?;
match input.trim() {
"1" => Ok(ConsentScope::Once),
"2" => Ok(ConsentScope::Session),
"3" => Ok(ConsentScope::Permanent),
_ => Ok(ConsentScope::Denied),
}
}
}