| 1 | #![allow(dead_code)] |
| 2 | //! Per‑call approval cache with fingerprint keys (§5.A). |
| 3 | //! |
| 4 | //! Instead of caching by tool name alone (which would let an approved |
| 5 | //! `exec_shell "cat foo"` silently pass `exec_shell "rm -rf /"`), the |
| 6 | //! cache keys off a **call fingerprint** — a digest of the tool name and |
| 7 | //! the semantically‑relevant portion of its arguments. |
| 8 | //! |
| 9 | //! ## Fingerprint shape |
| 10 | //! |
| 11 | //! | Tool | Key | |
| 12 | //! |---------------|------------------------------------------| |
| 13 | //! | `apply_patch` | `patch:<hash of file paths>` | |
| 14 | //! | `exec_shell` | `shell:<command prefix (first 3 tokens)>` | |
| 15 | //! | `fetch_url` | `net:<hostname>` | |
| 16 | //! | everything else| `tool:<tool_name>` | |
| 17 | //! |
| 18 | //! The cache is **session‑keyed**: entries carry an |
| 19 | //! `ApprovedForSession` flag. When true, the approval is reused for the |
| 20 | //! remainder of the session; when false, it is a one‑shot grant (future |
| 21 | //! calls with the same fingerprint still prompt). |
| 22 | |
| 23 | use std::collections::HashMap; |
| 24 | use std::time::Instant; |
| 25 | |
| 26 | use crate::command_safety::classify_command; |
| 27 | |
| 28 | /// The fingerprint of a tool call — stable enough to match repeated |
| 29 | /// calls but specific enough to avoid privilege confusion. |
| 30 | #[derive(Debug, Clone, PartialEq, Eq, Hash)] |
| 31 | pub struct ApprovalKey(pub String); |
| 32 | |
| 33 | /// Status of a previously‑rendered approval decision. |
| 34 | #[derive(Debug, Clone, Copy, PartialEq, Eq)] |
| 35 | pub enum ApprovalCacheStatus { |
| 36 | /// Call fingerprint matched and the session‑level flag says reuse. |
| 37 | Approved, |
| 38 | /// Call fingerprint matched but the grant was one‑shot (already consumed). |
| 39 | Denied, |
| 40 | /// No match — requires fresh approval. |
| 41 | Unknown, |
| 42 | } |
| 43 | |
| 44 | /// A single cache entry. |
| 45 | #[derive(Debug, Clone)] |
| 46 | struct ApprovalCacheEntry { |
| 47 | /// When this entry was created. |
| 48 | created: Instant, |
| 49 | /// Whether the approval should be reused across the session. |
| 50 | approved_for_session: bool, |
| 51 | } |
| 52 | |
| 53 | /// An approval cache backed by tool‑call fingerprints. |
| 54 | #[derive(Debug, Default)] |
| 55 | pub struct ApprovalCache { |
| 56 | entries: HashMap<ApprovalKey, ApprovalCacheEntry>, |
| 57 | } |
| 58 | |
| 59 | impl ApprovalCache { |
| 60 | /// Construct an empty cache. |
| 61 | #[must_use] |
| 62 | pub fn new() -> Self { |
| 63 | Self { |
| 64 | entries: HashMap::new(), |
| 65 | } |
| 66 | } |
| 67 | |
| 68 | /// Look up a previously‑rendered approval decision. |
| 69 | pub fn check(&self, key: &ApprovalKey) -> ApprovalCacheStatus { |
| 70 | let Some(entry) = self.entries.get(key) else { |
| 71 | return ApprovalCacheStatus::Unknown; |
| 72 | }; |
| 73 | if entry.approved_for_session { |
| 74 | ApprovalCacheStatus::Approved |
| 75 | } else { |
| 76 | ApprovalCacheStatus::Denied |
| 77 | } |
| 78 | } |
| 79 | |
| 80 | /// Record an approval decision under the given fingerprint. |
| 81 | /// |
| 82 | /// When `approved_for_session` is true, subsequent calls with the |
| 83 | /// same key will auto‑approve for the remainder of the session. |
| 84 | pub fn insert(&mut self, key: ApprovalKey, approved_for_session: bool) { |
| 85 | self.entries.insert( |
| 86 | key, |
| 87 | ApprovalCacheEntry { |
| 88 | created: Instant::now(), |
| 89 | approved_for_session, |
| 90 | }, |
| 91 | ); |
| 92 | } |
| 93 | |
| 94 | /// Clear all entries. |
| 95 | pub fn clear(&mut self) { |
| 96 | self.entries.clear(); |
| 97 | } |
| 98 | |
| 99 | /// Number of cached entries. |
| 100 | #[allow(dead_code)] |
| 101 | pub fn len(&self) -> usize { |
| 102 | self.entries.len() |
| 103 | } |
| 104 | |
| 105 | /// Whether the cache is empty. |
| 106 | #[allow(dead_code)] |
| 107 | pub fn is_empty(&self) -> bool { |
| 108 | self.entries.is_empty() |
| 109 | } |
| 110 | } |
| 111 | |
| 112 | // ── Fingerprint helpers ──────────────────────────────────────────── |
| 113 | |
| 114 | /// Build the approval‑cache key for a tool call. |
| 115 | /// |
| 116 | /// The key incorporates the tool name and a lossy digest of the |
| 117 | /// arguments so that the cache can distinguish `exec_shell "ls"` |
| 118 | /// from `exec_shell "rm -rf /"` while still recognising repeated |
| 119 | /// invocations of the same harmless command. |
| 120 | #[must_use] |
| 121 | pub fn build_approval_key(tool_name: &str, input: &serde_json::Value) -> ApprovalKey { |
| 122 | let fingerprint = match tool_name { |
| 123 | "apply_patch" => { |
| 124 | let paths_hash = hash_patch_paths(input); |
| 125 | format!("patch:{paths_hash}") |
| 126 | } |
| 127 | "exec_shell" |
| 128 | | "exec_shell_wait" |
| 129 | | "exec_shell_interact" |
| 130 | | "exec_wait" |
| 131 | | "exec_interact" => { |
| 132 | let prefix = command_prefix(input); |
| 133 | format!("shell:{prefix}") |
| 134 | } |
| 135 | "fetch_url" | "web.fetch" | "web_fetch" => { |
| 136 | let host = parse_host(input); |
| 137 | format!("net:{host}") |
| 138 | } |
| 139 | _ => format!("tool:{tool_name}"), |
| 140 | }; |
| 141 | ApprovalKey(fingerprint) |
| 142 | } |
| 143 | |
| 144 | /// Return the canonical command prefix for the shell command in `input`. |
| 145 | /// |
| 146 | /// Uses [`classify_command`] from the arity dictionary so that |
| 147 | /// `auto_allow = ["git status"]` correctly matches `git status -s` and |
| 148 | /// `git status --porcelain` without also matching `git push`. |
| 149 | fn command_prefix(input: &serde_json::Value) -> String { |
| 150 | let cmd = input.get("command").and_then(|v| v.as_str()).unwrap_or(""); |
| 151 | let tokens: Vec<&str> = cmd.split_whitespace().collect(); |
| 152 | if tokens.is_empty() { |
| 153 | return "<empty>".to_string(); |
| 154 | } |
| 155 | classify_command(&tokens) |
| 156 | } |
| 157 | |
| 158 | /// Hash the sorted set of file paths referenced by a patch input. |
| 159 | fn hash_patch_paths(input: &serde_json::Value) -> String { |
| 160 | use std::collections::hash_map::DefaultHasher; |
| 161 | use std::hash::{Hash, Hasher}; |
| 162 | |
| 163 | let mut paths: Vec<&str> = Vec::new(); |
| 164 | |
| 165 | if let Some(changes) = input.get("changes").and_then(|v| v.as_array()) { |
| 166 | for change in changes { |
| 167 | if let Some(path) = change.get("path").and_then(|v| v.as_str()) { |
| 168 | paths.push(path); |
| 169 | } |
| 170 | } |
| 171 | } else if let Some(patch_text) = input.get("patch").and_then(|v| v.as_str()) { |
| 172 | for line in patch_text.lines() { |
| 173 | if let Some(rest) = line.strip_prefix("+++ b/") { |
| 174 | paths.push(rest.trim()); |
| 175 | } |
| 176 | } |
| 177 | } |
| 178 | |
| 179 | paths.sort(); |
| 180 | paths.dedup(); |
| 181 | |
| 182 | if paths.is_empty() { |
| 183 | return "no_files".to_string(); |
| 184 | } |
| 185 | |
| 186 | let mut hasher = DefaultHasher::new(); |
| 187 | for path in &paths { |
| 188 | path.hash(&mut hasher); |
| 189 | } |
| 190 | format!("{:x}", hasher.finish()) |
| 191 | } |
| 192 | |
| 193 | /// Parse the host portion from a URL input. |
| 194 | fn parse_host(input: &serde_json::Value) -> String { |
| 195 | let url = input.get("url").and_then(|v| v.as_str()).unwrap_or(""); |
| 196 | |
| 197 | if let Ok(parsed) = reqwest::Url::parse(url) { |
| 198 | parsed.host_str().unwrap_or(url).to_string() |
| 199 | } else { |
| 200 | url.to_string() |
| 201 | } |
| 202 | } |
| 203 | |
| 204 | #[cfg(test)] |
| 205 | mod tests { |
| 206 | use super::*; |
| 207 | use serde_json::json; |
| 208 | |
| 209 | #[test] |
| 210 | fn cache_hit_returns_approved_for_session() { |
| 211 | let mut cache = ApprovalCache::new(); |
| 212 | let key = build_approval_key("exec_shell", &json!({"command": "ls -la"})); |
| 213 | cache.insert(key.clone(), true); |
| 214 | assert_eq!(cache.check(&key), ApprovalCacheStatus::Approved); |
| 215 | } |
| 216 | |
| 217 | #[test] |
| 218 | fn cache_one_shot_is_not_reused() { |
| 219 | let mut cache = ApprovalCache::new(); |
| 220 | let key = build_approval_key("exec_shell", &json!({"command": "cargo build"})); |
| 221 | cache.insert(key.clone(), false); |
| 222 | assert_eq!(cache.check(&key), ApprovalCacheStatus::Denied); |
| 223 | } |
| 224 | |
| 225 | #[test] |
| 226 | fn cache_miss_is_unknown() { |
| 227 | let cache = ApprovalCache::new(); |
| 228 | let key = build_approval_key("exec_shell", &json!({"command": "ls"})); |
| 229 | assert_eq!(cache.check(&key), ApprovalCacheStatus::Unknown); |
| 230 | } |
| 231 | |
| 232 | #[test] |
| 233 | fn different_commands_different_keys() { |
| 234 | let key_a = build_approval_key("exec_shell", &json!({"command": "ls"})); |
| 235 | let key_b = build_approval_key("exec_shell", &json!({"command": "rm -rf /tmp"})); |
| 236 | assert_ne!(key_a, key_b); |
| 237 | } |
| 238 | |
| 239 | #[test] |
| 240 | fn same_command_same_key() { |
| 241 | let key_a = build_approval_key("exec_shell", &json!({"command": "cargo build --release"})); |
| 242 | let key_b = build_approval_key("exec_shell", &json!({"command": "cargo build --release"})); |
| 243 | assert_eq!(key_a, key_b); |
| 244 | } |
| 245 | |
| 246 | #[test] |
| 247 | fn command_prefix_drops_flags() { |
| 248 | let key_a = build_approval_key("exec_shell", &json!({"command": "cargo build"})); |
| 249 | let key_b = build_approval_key("exec_shell", &json!({"command": "cargo build --release"})); |
| 250 | assert_eq!(key_a, key_b); |
| 251 | } |
| 252 | |
| 253 | #[test] |
| 254 | fn patch_keys_differ_by_path() { |
| 255 | let key_a = build_approval_key( |
| 256 | "apply_patch", |
| 257 | &json!({"changes": [{"path": "a.rs", "content": "x"}]}), |
| 258 | ); |
| 259 | let key_b = build_approval_key( |
| 260 | "apply_patch", |
| 261 | &json!({"changes": [{"path": "b.rs", "content": "x"}]}), |
| 262 | ); |
| 263 | assert_ne!(key_a, key_b); |
| 264 | } |
| 265 | |
| 266 | #[test] |
| 267 | fn net_keys_differ_by_host() { |
| 268 | let key_a = build_approval_key("fetch_url", &json!({"url": "https://example.com"})); |
| 269 | let key_b = build_approval_key("fetch_url", &json!({"url": "https://other.org"})); |
| 270 | assert_ne!(key_a, key_b); |
| 271 | } |
| 272 | |
| 273 | #[test] |
| 274 | fn generic_tool_uses_tool_name() { |
| 275 | let key_a = build_approval_key("read_file", &json!({"path": "a.txt"})); |
| 276 | let key_b = build_approval_key("read_file", &json!({"path": "b.txt"})); |
| 277 | assert_eq!(key_a, key_b); |
| 278 | assert_eq!(key_a.0, "tool:read_file"); |
| 279 | } |
| 280 | } |
| 281 |