Skip to main content

agent_code_lib/permissions/
tracking.rs

1//! Permission denial tracking.
2//!
3//! Records which tool calls were denied and why, for reporting
4//! to the user and SDK consumers.
5
6use std::collections::VecDeque;
7
8/// A recorded permission denial event.
9#[derive(Debug, Clone)]
10pub struct DenialRecord {
11    /// Tool that was denied.
12    pub tool_name: String,
13    /// The tool_use ID from the model.
14    pub tool_use_id: String,
15    /// Reason for denial.
16    pub reason: String,
17    /// Timestamp of the denial.
18    pub timestamp: String,
19    /// Summary of what the tool was trying to do.
20    pub input_summary: String,
21}
22
23/// Tracks permission denials for the current session.
24pub struct DenialTracker {
25    /// Recent denials (bounded to prevent unbounded growth).
26    records: VecDeque<DenialRecord>,
27    /// Maximum number of denials to retain.
28    max_records: usize,
29    /// Total denials this session (even if records were evicted).
30    total_denials: usize,
31}
32
33impl DenialTracker {
34    pub fn new(max_records: usize) -> Self {
35        Self {
36            records: VecDeque::new(),
37            max_records,
38            total_denials: 0,
39        }
40    }
41
42    /// Record a new denial.
43    pub fn record(
44        &mut self,
45        tool_name: &str,
46        tool_use_id: &str,
47        reason: &str,
48        input: &serde_json::Value,
49    ) {
50        let summary = summarize_input(tool_name, input);
51
52        self.records.push_back(DenialRecord {
53            tool_name: tool_name.to_string(),
54            tool_use_id: tool_use_id.to_string(),
55            reason: reason.to_string(),
56            timestamp: chrono::Utc::now().to_rfc3339(),
57            input_summary: summary,
58        });
59
60        self.total_denials += 1;
61
62        // Evict oldest if over limit.
63        while self.records.len() > self.max_records {
64            self.records.pop_front();
65        }
66    }
67
68    /// Get all recorded denials.
69    pub fn denials(&self) -> &VecDeque<DenialRecord> {
70        &self.records
71    }
72
73    /// Total denials this session.
74    pub fn total(&self) -> usize {
75        self.total_denials
76    }
77
78    /// Clear all records.
79    pub fn clear(&mut self) {
80        self.records.clear();
81        self.total_denials = 0;
82    }
83
84    /// Get denials for a specific tool.
85    pub fn denials_for_tool(&self, tool_name: &str) -> Vec<&DenialRecord> {
86        self.records
87            .iter()
88            .filter(|r| r.tool_name == tool_name)
89            .collect()
90    }
91}
92
93fn summarize_input(tool_name: &str, input: &serde_json::Value) -> String {
94    match tool_name {
95        "Bash" => input
96            .get("command")
97            .and_then(|v| v.as_str())
98            .unwrap_or("")
99            .chars()
100            .take(100)
101            .collect(),
102        "FileWrite" | "FileEdit" | "FileRead" => input
103            .get("file_path")
104            .and_then(|v| v.as_str())
105            .unwrap_or("")
106            .to_string(),
107        _ => serde_json::to_string(input)
108            .unwrap_or_default()
109            .chars()
110            .take(100)
111            .collect(),
112    }
113}
114
115#[cfg(test)]
116mod tests {
117    use super::*;
118
119    #[test]
120    fn test_new_tracker() {
121        let t = DenialTracker::new(50);
122        assert_eq!(t.total(), 0);
123        assert!(t.denials().is_empty());
124    }
125
126    #[test]
127    fn test_record_denial() {
128        let mut t = DenialTracker::new(10);
129        t.record(
130            "Bash",
131            "call_1",
132            "too dangerous",
133            &serde_json::json!({"command": "rm -rf /"}),
134        );
135        assert_eq!(t.total(), 1);
136        assert_eq!(t.denials().len(), 1);
137        assert_eq!(t.denials()[0].tool_name, "Bash");
138    }
139
140    #[test]
141    fn test_denials_for_tool() {
142        let mut t = DenialTracker::new(10);
143        t.record("Bash", "c1", "reason", &serde_json::json!({}));
144        t.record("FileWrite", "c2", "reason", &serde_json::json!({}));
145        t.record("Bash", "c3", "reason", &serde_json::json!({}));
146        assert_eq!(t.denials_for_tool("Bash").len(), 2);
147        assert_eq!(t.denials_for_tool("FileWrite").len(), 1);
148        assert_eq!(t.denials_for_tool("Grep").len(), 0);
149    }
150
151    #[test]
152    fn test_bounded_capacity() {
153        let mut t = DenialTracker::new(3);
154        for i in 0..5 {
155            t.record("Bash", &format!("c{i}"), "r", &serde_json::json!({}));
156        }
157        assert_eq!(t.total(), 5); // Total tracks all.
158        assert_eq!(t.denials().len(), 3); // Deque bounded.
159    }
160
161    #[test]
162    fn test_clear() {
163        let mut t = DenialTracker::new(10);
164        t.record("Bash", "c1", "r", &serde_json::json!({}));
165        t.clear();
166        assert_eq!(t.total(), 0);
167        assert!(t.denials().is_empty());
168    }
169
170    #[test]
171    fn test_input_summary_bash() {
172        let mut t = DenialTracker::new(10);
173        t.record(
174            "Bash",
175            "c1",
176            "r",
177            &serde_json::json!({"command": "rm -rf /"}),
178        );
179        assert!(t.denials()[0].input_summary.contains("rm -rf"));
180    }
181
182    #[test]
183    fn test_input_summary_file() {
184        let mut t = DenialTracker::new(10);
185        t.record(
186            "FileWrite",
187            "c1",
188            "r",
189            &serde_json::json!({"file_path": "/etc/passwd"}),
190        );
191        assert!(t.denials()[0].input_summary.contains("/etc/passwd"));
192    }
193}