返回 DeepSeek-TUI-2026
approval_cache.rs
根目录 / crates / tui / src / tools / approval_cache.rs
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
281 lines RUST