Files
codewhale/crates/tui/src/tools/approval_cache.rs
T
Hunter Bown 373fbd95a0 chore(release): prepare v0.8.39
Bump workspace, inter-crate, and npm package versions 0.8.38 -> 0.8.39.

Roll CHANGELOG [Unreleased] into [0.8.39] with all fixes:
- Revert v0.8.38 /model picker rework (back to instant curated picker)
- Restore approval grouping (lossy v0.8.37 logic for approvals, exact key for denials)
- Thinking-only turn surface fix (#1727)
- ACP server JSON-RPC id stringification (#1696)
- Chat client: reasoning_content for generic providers (#1673)
- Compaction: user text query preservation (#1704)
- Engine: system prompt override survival (#1688)
- Pager: G/End overshoot fix (#1706), mouse scroll (#1716)
- Composer: scroll with text (#1677), multiline arrows (#1721)
- macOS system theme detection (#1670)
- rlm_open blank source fields (#1712)
- Terminal resize paging fix (#1724)
- Docker first-run permission (#1684)
- README Rust 1.88+ requirement note (#1718)

Tests: 3149 passed, 0 failed (deepseek-tui crate)
clippy: clean on --all-targets --all-features
2026-05-17 16:36:21 +08:00

477 lines
16 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#![allow(dead_code)]
//! Percall approval cache with fingerprint keys (§5.A).
//!
//! Instead of caching by tool name alone (which would let an approved
//! `exec_shell "cat foo"` silently pass `exec_shell "rm -rf /"`), the
//! cache keys off a **call fingerprint** — a digest of the tool name and
//! the semanticallyrelevant portion of its arguments.
//!
//! ## Two fingerprint shapes
//!
//! There are two key flavours, used for opposite sides of the decision:
//!
//! * [`build_approval_key`] — an **exact** digest of the full arguments.
//! Used to scope *denials* so that denying one call (e.g. `rm -rf /tmp/x`)
//! does not also suppress a later, different call to the same tool (#1617).
//!
//! | Tool | Exact key |
//! |---------------|------------------------------------------|
//! | file writes | `file:<tool_name>:<hash of args>` |
//! | shell tools | `shell:<tool_name>:<hash of args>` |
//! | `fetch_url` | `net:<hostname>` |
//! | everything else| `tool:<tool_name>:<hash of input>` |
//!
//! * [`build_approval_grouping_key`] — a **lossy / arity-aware** digest.
//! Used to scope *approvals* so that approving `cargo build` for the
//! session also covers `cargo build --release` (the v0.8.37 behaviour).
//!
//! | Tool | Grouping key |
//! |---------------|------------------------------------------|
//! | `apply_patch` | `patch:<hash of file paths>` |
//! | shell tools | `shell:<command prefix>` |
//! | `fetch_url` | `net:<hostname>` |
//! | everything else| `tool:<tool_name>:<hash of input>` |
//!
//! The cache is **sessionkeyed**: entries carry an
//! `ApprovedForSession` flag. When true, the approval is reused for the
//! remainder of the session; when false, it is a oneshot grant (future
//! calls with the same fingerprint still prompt).
use std::collections::HashMap;
use std::fmt::Write as _;
use std::time::Instant;
use serde_json::Value;
use sha2::{Digest, Sha256};
use crate::command_safety::classify_command;
/// The fingerprint of a tool call — stable enough to match repeated
/// calls but specific enough to avoid privilege confusion.
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct ApprovalKey(pub String);
/// Status of a previouslyrendered approval decision.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ApprovalCacheStatus {
/// Call fingerprint matched and the sessionlevel flag says reuse.
Approved,
/// Call fingerprint matched but the grant was oneshot (already consumed).
Denied,
/// No match — requires fresh approval.
Unknown,
}
/// A single cache entry.
#[derive(Debug, Clone)]
struct ApprovalCacheEntry {
/// When this entry was created.
created: Instant,
/// Whether the approval should be reused across the session.
approved_for_session: bool,
}
/// An approval cache backed by toolcall fingerprints.
#[derive(Debug, Default)]
pub struct ApprovalCache {
entries: HashMap<ApprovalKey, ApprovalCacheEntry>,
}
impl ApprovalCache {
/// Construct an empty cache.
#[must_use]
pub fn new() -> Self {
Self {
entries: HashMap::new(),
}
}
/// Look up a previouslyrendered approval decision.
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
}
}
/// Record an approval decision under the given fingerprint.
///
/// When `approved_for_session` is true, subsequent calls with the
/// same key will autoapprove for the remainder of the session.
pub fn insert(&mut self, key: ApprovalKey, approved_for_session: bool) {
self.entries.insert(
key,
ApprovalCacheEntry {
created: Instant::now(),
approved_for_session,
},
);
}
/// Clear all entries.
pub fn clear(&mut self) {
self.entries.clear();
}
/// Number of cached entries.
#[allow(dead_code)]
pub fn len(&self) -> usize {
self.entries.len()
}
/// Whether the cache is empty.
#[allow(dead_code)]
pub fn is_empty(&self) -> bool {
self.entries.is_empty()
}
}
// ── Fingerprint helpers ────────────────────────────────────────────
/// Build the approvalcache key for a tool call.
///
/// The key incorporates the tool name and a canonical digest of the
/// arguments so that denying one call suppresses exact retries, not later
/// invocations of the same tool with different parameters.
#[must_use]
pub fn build_approval_key(tool_name: &str, input: &serde_json::Value) -> ApprovalKey {
let fingerprint = match tool_name {
"apply_patch" | "write_file" | "edit_file" | "fim_edit" => {
format!("file:{tool_name}:{}", hash_json_value(input))
}
"exec_shell"
| "task_shell_start"
| "exec_shell_wait"
| "exec_shell_interact"
| "exec_wait"
| "exec_interact" => {
format!("shell:{tool_name}:{}", hash_json_value(input))
}
"fetch_url" | "web.fetch" | "web_fetch" => {
let host = parse_host(input);
format!("net:{host}")
}
_ => format!("tool:{tool_name}:{}", hash_json_value(input)),
};
ApprovalKey(fingerprint)
}
/// Build the **grouping** approval key for a tool call.
///
/// Unlike [`build_approval_key`], this collapses argument variants of the
/// same command family onto one key (the v0.8.37 behaviour) so that an
/// "approve for session" decision covers later invocations that differ only
/// by flags. Denials must keep using the exact [`build_approval_key`].
#[must_use]
pub fn build_approval_grouping_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"
| "task_shell_start"
| "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}:{}", hash_json_value(input)),
};
ApprovalKey(fingerprint)
}
/// Return the canonical command prefix for the shell command in `input`.
///
/// Uses [`classify_command`] from the arity dictionary so that approving
/// `git status` also covers `git status -s` / `git status --porcelain`
/// without also covering `git push`.
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)
}
/// Hash the sorted set of file paths referenced by a patch input.
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())
}
/// Parse the host portion from a URL input.
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()
}
}
fn hash_json_value(value: &Value) -> String {
let mut canonical = String::new();
push_canonical_json(value, &mut canonical);
let digest = Sha256::digest(canonical.as_bytes());
let mut short = String::with_capacity(16);
for byte in &digest[..8] {
write!(&mut short, "{byte:02x}").expect("writing to String cannot fail");
}
short
}
fn push_canonical_json(value: &Value, out: &mut String) {
match value {
Value::Null => out.push_str("null"),
Value::Bool(value) => {
out.push_str("bool:");
out.push_str(if *value { "true" } else { "false" });
}
Value::Number(value) => {
out.push_str("number:");
out.push_str(&value.to_string());
}
Value::String(value) => {
out.push_str("string:");
let encoded = serde_json::to_string(value).expect("serializing a string cannot fail");
out.push_str(&encoded);
}
Value::Array(items) => {
out.push('[');
for (index, item) in items.iter().enumerate() {
if index > 0 {
out.push(',');
}
push_canonical_json(item, out);
}
out.push(']');
}
Value::Object(map) => {
let mut entries = map.iter().collect::<Vec<_>>();
entries.sort_by_key(|(key, _)| *key);
out.push('{');
for (index, (key, value)) in entries.into_iter().enumerate() {
if index > 0 {
out.push(',');
}
let encoded_key =
serde_json::to_string(key).expect("serializing an object key cannot fail");
out.push_str(&encoded_key);
out.push(':');
push_canonical_json(value, out);
}
out.push('}');
}
}
}
#[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 shell_keys_include_full_command_arguments() {
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_ne!(key_a, key_b);
}
#[test]
fn grouping_key_collapses_shell_flag_variants() {
let key_a = build_approval_grouping_key("exec_shell", &json!({"command": "cargo build"}));
let key_b =
build_approval_grouping_key("exec_shell", &json!({"command": "cargo build --release"}));
assert_eq!(
key_a, key_b,
"approving a command family must cover later flag variants"
);
}
#[test]
fn grouping_key_still_separates_distinct_commands() {
let key_a = build_approval_grouping_key("exec_shell", &json!({"command": "git status"}));
let key_b = build_approval_grouping_key("exec_shell", &json!({"command": "git push"}));
assert_ne!(key_a, key_b);
}
#[test]
fn grouping_key_collapses_patch_body_for_same_path() {
let key_a = build_approval_grouping_key(
"apply_patch",
&json!({"changes": [{"path": "a.rs", "content": "x"}]}),
);
let key_b = build_approval_grouping_key(
"apply_patch",
&json!({"changes": [{"path": "a.rs", "content": "y"}]}),
);
assert_eq!(
key_a, key_b,
"approving a patch family must cover later edits to the same path"
);
}
#[test]
fn denial_key_stays_exact_while_grouping_key_collapses() {
let exact_a = build_approval_key("exec_shell", &json!({"command": "cargo build"}));
let exact_b =
build_approval_key("exec_shell", &json!({"command": "cargo build --release"}));
assert_ne!(exact_a, exact_b, "denials must remain exact-call scoped");
let group_a = build_approval_grouping_key("exec_shell", &json!({"command": "cargo build"}));
let group_b =
build_approval_grouping_key("exec_shell", &json!({"command": "cargo build --release"}));
assert_eq!(group_a, group_b, "approvals must group by command family");
}
#[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 patch_keys_differ_by_body_for_same_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": "a.rs", "content": "y"}]}),
);
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_keys_include_arguments() {
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_ne!(key_a, key_b);
assert!(key_a.0.starts_with("tool:read_file:"));
}
#[test]
fn generic_tool_same_arguments_reuse_key() {
let input = json!({"path": "a.txt"});
let key_a = build_approval_key("edit_file", &input);
let key_b = build_approval_key("edit_file", &input);
assert_eq!(key_a, key_b);
}
#[test]
fn input_hash_is_stable_across_object_key_order() {
let key_a = build_approval_key("write_file", &json!({"path": "a.txt", "content": "x"}));
let key_b = build_approval_key("write_file", &json!({"content": "x", "path": "a.txt"}));
assert_eq!(key_a, key_b);
}
#[test]
fn canonical_json_omits_trailing_commas() {
let mut canonical = String::new();
push_canonical_json(&json!({"b": [true, false], "a": {"x": 1}}), &mut canonical);
assert_eq!(
canonical,
r#"{"a":{"x":number:1},"b":[bool:true,bool:false]}"#
);
assert!(!canonical.contains(",]"));
assert!(!canonical.contains(",}"));
}
}