#![allow(dead_code)]
use std::collections::HashMap;
use std::time::Instant;
use crate::command_safety::classify_command;
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct ApprovalKey(pub String);
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ApprovalCacheStatus {
Approved,
Denied,
Unknown,
}
#[derive(Debug, Clone)]
struct ApprovalCacheEntry {
created: Instant,
approved_for_session: bool,
}
#[derive(Debug, Default)]
pub struct ApprovalCache {
entries: HashMap<ApprovalKey, ApprovalCacheEntry>,
}
impl ApprovalCache {
#[must_use]
pub fn new() -> Self {
Self {
entries: HashMap::new(),
}
}
pub fn check(&self, key: &ApprovalKey) -> ApprovalCacheStatus {
let Some(entry) = self.entries.get(key) else {
return ApprovalCacheStatus::Unknown;
};
if entry.approved_for_session {
ApprovalCacheStatus::Approved
} else {
ApprovalCacheStatus::Denied
}
}
pub fn insert(&mut self, key: ApprovalKey, approved_for_session: bool) {
self.entries.insert(
key,
ApprovalCacheEntry {
created: Instant::now(),
approved_for_session,
},
);
}
pub fn clear(&mut self) {
self.entries.clear();
}
#[allow(dead_code)]
pub fn len(&self) -> usize {
self.entries.len()
}
#[allow(dead_code)]
pub fn is_empty(&self) -> bool {
self.entries.is_empty()
}
}
#[must_use]
pub fn build_approval_key(tool_name: &str, input: &serde_json::Value) -> ApprovalKey {
let fingerprint = match tool_name {
"apply_patch" => {
let paths_hash = hash_patch_paths(input);
format!("patch:{paths_hash}")
}
"exec_shell"
| "exec_shell_wait"
| "exec_shell_interact"
| "exec_wait"
| "exec_interact" => {
let prefix = command_prefix(input);
format!("shell:{prefix}")
}
"fetch_url" | "web.fetch" | "web_fetch" => {
let host = parse_host(input);
format!("net:{host}")
}
_ => format!("tool:{tool_name}"),
};
ApprovalKey(fingerprint)
}
fn command_prefix(input: &serde_json::Value) -> String {
let cmd = input.get("command").and_then(|v| v.as_str()).unwrap_or("");
let tokens: Vec<&str> = cmd.split_whitespace().collect();
if tokens.is_empty() {
return "<empty>".to_string();
}
classify_command(&tokens)
}
fn hash_patch_paths(input: &serde_json::Value) -> String {
use std::collections::hash_map::DefaultHasher;
use std::hash::{Hash, Hasher};
let mut paths: Vec<&str> = Vec::new();
if let Some(changes) = input.get("changes").and_then(|v| v.as_array()) {
for change in changes {
if let Some(path) = change.get("path").and_then(|v| v.as_str()) {
paths.push(path);
}
}
} else if let Some(patch_text) = input.get("patch").and_then(|v| v.as_str()) {
for line in patch_text.lines() {
if let Some(rest) = line.strip_prefix("+++ b/") {
paths.push(rest.trim());
}
}
}
paths.sort();
paths.dedup();
if paths.is_empty() {
return "no_files".to_string();
}
let mut hasher = DefaultHasher::new();
for path in &paths {
path.hash(&mut hasher);
}
format!("{:x}", hasher.finish())
}
fn parse_host(input: &serde_json::Value) -> String {
let url = input.get("url").and_then(|v| v.as_str()).unwrap_or("");
if let Ok(parsed) = reqwest::Url::parse(url) {
parsed.host_str().unwrap_or(url).to_string()
} else {
url.to_string()
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn cache_hit_returns_approved_for_session() {
let mut cache = ApprovalCache::new();
let key = build_approval_key("exec_shell", &json!({"command": "ls -la"}));
cache.insert(key.clone(), true);
assert_eq!(cache.check(&key), ApprovalCacheStatus::Approved);
}
#[test]
fn cache_one_shot_is_not_reused() {
let mut cache = ApprovalCache::new();
let key = build_approval_key("exec_shell", &json!({"command": "cargo build"}));
cache.insert(key.clone(), false);
assert_eq!(cache.check(&key), ApprovalCacheStatus::Denied);
}
#[test]
fn cache_miss_is_unknown() {
let cache = ApprovalCache::new();
let key = build_approval_key("exec_shell", &json!({"command": "ls"}));
assert_eq!(cache.check(&key), ApprovalCacheStatus::Unknown);
}
#[test]
fn different_commands_different_keys() {
let key_a = build_approval_key("exec_shell", &json!({"command": "ls"}));
let key_b = build_approval_key("exec_shell", &json!({"command": "rm -rf /tmp"}));
assert_ne!(key_a, key_b);
}
#[test]
fn same_command_same_key() {
let key_a = build_approval_key("exec_shell", &json!({"command": "cargo build --release"}));
let key_b = build_approval_key("exec_shell", &json!({"command": "cargo build --release"}));
assert_eq!(key_a, key_b);
}
#[test]
fn command_prefix_drops_flags() {
let key_a = build_approval_key("exec_shell", &json!({"command": "cargo build"}));
let key_b = build_approval_key("exec_shell", &json!({"command": "cargo build --release"}));
assert_eq!(key_a, key_b);
}
#[test]
fn patch_keys_differ_by_path() {
let key_a = build_approval_key(
"apply_patch",
&json!({"changes": [{"path": "a.rs", "content": "x"}]}),
);
let key_b = build_approval_key(
"apply_patch",
&json!({"changes": [{"path": "b.rs", "content": "x"}]}),
);
assert_ne!(key_a, key_b);
}
#[test]
fn net_keys_differ_by_host() {
let key_a = build_approval_key("fetch_url", &json!({"url": "https://example.com"}));
let key_b = build_approval_key("fetch_url", &json!({"url": "https://other.org"}));
assert_ne!(key_a, key_b);
}
#[test]
fn generic_tool_uses_tool_name() {
let key_a = build_approval_key("read_file", &json!({"path": "a.txt"}));
let key_b = build_approval_key("read_file", &json!({"path": "b.txt"}));
assert_eq!(key_a, key_b);
assert_eq!(key_a.0, "tool:read_file");
}
}