- 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).
313 lines
10 KiB
Rust
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),
|
|
}
|
|
}
|
|
}
|