#![allow(dead_code)]
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;
#[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" | "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)
}
#[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)
}
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()
}
}
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(",}"));
}
}