Skip to main content

apm_core/
denial.rs

1/// Transcript denial scanner for APM worker logs.
2///
3/// # Event format (stream-json JSONL from `claude --output-format stream-json`)
4///
5/// Tool use — carried in an assistant message event:
6/// ```json
7/// {
8///   "type": "assistant",
9///   "message": {
10///     "role": "assistant",
11///     "content": [
12///       {
13///         "type": "tool_use",
14///         "id": "toolu_01JZREMrBXn3AkQaBfgyaFvc",
15///         "name": "Bash",
16///         "input": { "command": "apm doesnotexist", "description": "..." }
17///       }
18///     ]
19///   }
20/// }
21/// ```
22///
23/// Tool result — carried in a user message event (includes timestamp):
24/// ```json
25/// {
26///   "type": "user",
27///   "message": {
28///     "role": "user",
29///     "content": [
30///       {
31///         "type": "tool_result",
32///         "tool_use_id": "toolu_01JZREMrBXn3AkQaBfgyaFvc",
33///         "is_error": true,
34///         "content": "cannot be auto-allowed"
35///       }
36///     ]
37///   },
38///   "timestamp": "2026-05-02T03:28:24.500Z"
39/// }
40/// ```
41///
42/// # Discriminating permission denials from regular errors
43///
44/// Both use `is_error: true`.  Regular Bash failures have content starting with
45/// `"Exit code "` followed by a digit.  Permission denials never do.
46///
47/// Confirmed denial substrings (from real `.apm-worker.log` files):
48/// - `"but you haven't granted it yet"` — Write/Edit to an unapproved path
49/// - `"was blocked. For security"` — Bash output redirection blocked
50/// - `"cannot be auto-allowed"` — Bash pattern rule mismatch (e.g. `find -exec`)
51/// - `"Approve only if you trust it"` — compound `cd && git` safety warning
52
53use std::collections::HashMap;
54use std::path::{Path, PathBuf};
55use serde::{Deserialize, Serialize};
56
57/// Classification of a permission denial.
58#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
59#[serde(rename_all = "snake_case")]
60pub enum DenialClass {
61    /// Denied a Bash call whose command starts with `apm `.  APM should never
62    /// deny its own commands; this indicates a default-allowlist gap.
63    ApmCommandDenial,
64    /// Denied an Edit or Write whose path falls outside the ticket worktree.
65    OutsideWorktree,
66    /// Any other denial not matching the two patterns above.
67    UnknownPattern,
68}
69
70/// One denied tool call extracted from the transcript.
71#[derive(Debug, Clone, Serialize, Deserialize)]
72pub struct DenialEntry {
73    /// ISO-8601 timestamp from the tool-result event, or empty string.
74    pub timestamp: String,
75    /// Tool name, e.g. `"Bash"`, `"Edit"`, `"Write"`.
76    pub tool: String,
77    /// Tool input (command string for Bash; serialised JSON for others),
78    /// truncated to ≤200 chars.
79    pub input: String,
80    pub classification: DenialClass,
81}
82
83/// Summary written alongside `.apm-worker.log` on worker exit.
84#[derive(Debug, Clone, Serialize, Deserialize)]
85pub struct DenialSummary {
86    pub ticket_id: String,
87    /// ISO-8601 timestamp of when the scan ran (worker exit time).
88    pub worker_exited_at: String,
89    /// Absolute path to the `.apm-worker.log` file.
90    pub log_path: String,
91    pub denial_count: usize,
92    pub denials: Vec<DenialEntry>,
93}
94
95/// Scan `log_path` for permission-denial events and return a summary.
96///
97/// Returns an empty summary (zero denials) if the file is missing or
98/// unreadable.
99pub fn scan_transcript(log_path: &Path, worktree: &Path, ticket_id: &str) -> DenialSummary {
100    let content = match std::fs::read_to_string(log_path) {
101        Ok(c) => c,
102        Err(_) => {
103            return empty_summary(log_path, ticket_id);
104        }
105    };
106
107    // Pass 1 — build tool_use_id → (tool_name, input_value, timestamp) map.
108    // The timestamp on assistant-message lines is often absent; we capture it
109    // in case it is present, but the denial timestamp comes from the
110    // tool-result line in pass 2.
111    let mut tool_uses: HashMap<String, (String, serde_json::Value, String)> = HashMap::new();
112
113    for line in content.lines() {
114        let v: serde_json::Value = match serde_json::from_str(line) {
115            Ok(v) => v,
116            Err(_) => continue,
117        };
118        if v["type"] != "assistant" {
119            continue;
120        }
121        let ts = v["timestamp"].as_str().unwrap_or("").to_string();
122        if let Some(arr) = v["message"]["content"].as_array() {
123            for item in arr {
124                if item["type"] != "tool_use" {
125                    continue;
126                }
127                let id = item["id"].as_str().unwrap_or("").to_string();
128                if id.is_empty() {
129                    continue;
130                }
131                let name = item["name"].as_str().unwrap_or("").to_string();
132                let input = item["input"].clone();
133                tool_uses.insert(id, (name, input, ts.clone()));
134            }
135        }
136    }
137
138    // Pass 2 — find denied tool_result events.
139    let canon_worktree = std::fs::canonicalize(worktree)
140        .unwrap_or_else(|_| worktree.to_path_buf());
141
142    let mut denials: Vec<DenialEntry> = Vec::new();
143
144    for line in content.lines() {
145        let v: serde_json::Value = match serde_json::from_str(line) {
146            Ok(v) => v,
147            Err(_) => continue,
148        };
149        if v["type"] != "user" {
150            continue;
151        }
152        let result_ts = v["timestamp"].as_str().unwrap_or("").to_string();
153        let Some(arr) = v["message"]["content"].as_array() else { continue };
154
155        for item in arr {
156            if item["type"] != "tool_result" {
157                continue;
158            }
159            if item["is_error"] != true {
160                continue;
161            }
162            // Discriminate denial from regular error: denials never start with "Exit code "
163            let content_str = match item["content"].as_str() {
164                Some(s) => s,
165                None => continue,
166            };
167            if content_str.starts_with("Exit code ") {
168                continue;
169            }
170
171            let tool_use_id = item["tool_use_id"].as_str().unwrap_or("");
172            let Some((tool_name, input_obj, _)) = tool_uses.get(tool_use_id) else { continue };
173
174            let (input_str, classification) =
175                classify_denial(tool_name, input_obj, &canon_worktree, worktree);
176
177            denials.push(DenialEntry {
178                timestamp: result_ts.clone(),
179                tool: tool_name.clone(),
180                input: truncate_str(&input_str, 200),
181                classification,
182            });
183        }
184    }
185
186    DenialSummary {
187        ticket_id: ticket_id.to_string(),
188        worker_exited_at: chrono::Utc::now().to_rfc3339(),
189        log_path: log_path.to_string_lossy().into_owned(),
190        denial_count: denials.len(),
191        denials,
192    }
193}
194
195/// Write `summary` to `summary_path` as pretty-printed JSON.
196/// Errors are logged and swallowed — this must not panic or crash the
197/// wrapper exit path.
198pub fn write_summary(summary_path: &Path, summary: &DenialSummary) {
199    match serde_json::to_string_pretty(summary) {
200        Ok(json) => {
201            if let Err(e) = std::fs::write(summary_path, json) {
202                crate::logger::log("worker-diag", &format!("write_summary failed: {e}"));
203            }
204        }
205        Err(e) => {
206            crate::logger::log("worker-diag", &format!("write_summary serialize failed: {e}"));
207        }
208    }
209}
210
211/// Read a previously written summary from `summary_path`.
212/// Returns `None` if the file is absent or cannot be parsed.
213pub fn read_summary(summary_path: &Path) -> Option<DenialSummary> {
214    let content = std::fs::read_to_string(summary_path).ok()?;
215    serde_json::from_str(&content).ok()
216}
217
218/// Derive the `.apm-worker.summary.json` path from the `.apm-worker.log` path.
219/// If the log path ends in `.log`, replaces that suffix; otherwise appends
220/// `.summary.json`.
221pub fn summary_path_for(log_path: &Path) -> std::path::PathBuf {
222    if log_path.extension().and_then(|e| e.to_str()) == Some("log") {
223        log_path.with_extension("summary.json")
224    } else {
225        let mut p = log_path.to_path_buf();
226        let name = p.file_name()
227            .map(|n| format!("{}.summary.json", n.to_string_lossy()))
228            .unwrap_or_else(|| "summary.json".to_string());
229        p.set_file_name(name);
230        p
231    }
232}
233
234/// Return the unique command strings from all `ApmCommandDenial` entries in
235/// `summary`, preserving first-seen order.
236pub fn collect_unique_apm_commands(summary: &DenialSummary) -> Vec<String> {
237    let mut seen = std::collections::HashSet::new();
238    summary
239        .denials
240        .iter()
241        .filter(|d| d.classification == DenialClass::ApmCommandDenial)
242        .filter(|d| seen.insert(d.input.clone()))
243        .map(|d| d.input.clone())
244        .collect()
245}
246
247// ── internal helpers ──────────────────────────────────────────────────────────
248
249fn empty_summary(log_path: &Path, ticket_id: &str) -> DenialSummary {
250    DenialSummary {
251        ticket_id: ticket_id.to_string(),
252        worker_exited_at: chrono::Utc::now().to_rfc3339(),
253        log_path: log_path.to_string_lossy().into_owned(),
254        denial_count: 0,
255        denials: Vec::new(),
256    }
257}
258
259/// Return (input_string, classification) for a single denial.
260fn classify_denial(
261    tool: &str,
262    input_obj: &serde_json::Value,
263    canon_worktree: &Path,
264    raw_worktree: &Path,
265) -> (String, DenialClass) {
266    match tool {
267        "Bash" => {
268            let command = input_obj["command"].as_str().unwrap_or("").to_string();
269            let class = if command.trim().starts_with("apm ") {
270                DenialClass::ApmCommandDenial
271            } else {
272                DenialClass::UnknownPattern
273            };
274            (command, class)
275        }
276        "Edit" | "Write" => {
277            let file_path_str = input_obj["file_path"].as_str().unwrap_or("");
278            let class = if !file_path_str.is_empty()
279                && is_outside_worktree(file_path_str, canon_worktree, raw_worktree)
280            {
281                DenialClass::OutsideWorktree
282            } else {
283                DenialClass::UnknownPattern
284            };
285            let input_str = serde_json::to_string(input_obj).unwrap_or_default();
286            (input_str, class)
287        }
288        _ => {
289            let input_str = serde_json::to_string(input_obj).unwrap_or_default();
290            (input_str, DenialClass::UnknownPattern)
291        }
292    }
293}
294
295/// Return true if `file_path_str` resolves to a path outside `canon_worktree`.
296///
297/// Path resolution rules:
298/// - `canon_worktree` is the result of `fs::canonicalize(worktree)`, or the raw
299///   worktree path if canonicalization fails (worktree does not exist yet).
300/// - Absolute `file_path_str`: attempt canonicalize; on failure use as-is.
301/// - Relative `file_path_str`: join with `raw_worktree` first, then attempt
302///   canonicalize; on failure use the joined form.
303fn is_outside_worktree(file_path_str: &str, canon_worktree: &Path, raw_worktree: &Path) -> bool {
304    let file_path = Path::new(file_path_str);
305
306    let resolved: PathBuf = if file_path.is_absolute() {
307        std::fs::canonicalize(file_path).unwrap_or_else(|_| file_path.to_path_buf())
308    } else {
309        let joined = raw_worktree.join(file_path);
310        std::fs::canonicalize(&joined).unwrap_or(joined)
311    };
312
313    !resolved.starts_with(canon_worktree)
314}
315
316fn truncate_str(s: &str, max_bytes: usize) -> String {
317    if s.len() <= max_bytes {
318        s.to_string()
319    } else {
320        // Truncate at a char boundary
321        let mut end = max_bytes;
322        while !s.is_char_boundary(end) {
323            end -= 1;
324        }
325        s[..end].to_string()
326    }
327}
328
329#[cfg(test)]
330mod tests {
331    use super::*;
332
333    fn fixture_path(name: &str) -> std::path::PathBuf {
334        std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
335            .join("tests/fixtures")
336            .join(name)
337    }
338
339    #[test]
340    fn test_apm_command_denial() {
341        let log_path = fixture_path("transcript_apm_denial.jsonl");
342        let worktree = Path::new("/fake/worktree");
343        let summary = scan_transcript(&log_path, worktree, "testticket");
344
345        assert_eq!(summary.denial_count, 1, "expected 1 denial");
346        assert_eq!(summary.denials[0].classification, DenialClass::ApmCommandDenial);
347        assert_eq!(summary.denials[0].tool, "Bash");
348        assert!(
349            summary.denials[0].input.starts_with("apm "),
350            "input should start with 'apm ', got: {:?}",
351            summary.denials[0].input
352        );
353    }
354
355    #[test]
356    fn test_no_denials() {
357        let log_path = fixture_path("transcript_no_denials.jsonl");
358        let worktree = Path::new("/fake/worktree");
359        let summary = scan_transcript(&log_path, worktree, "testticket");
360
361        assert_eq!(summary.denial_count, 0, "expected 0 denials");
362        assert!(summary.denials.is_empty());
363    }
364
365    #[test]
366    fn test_outside_worktree() {
367        let log_path = fixture_path("transcript_outside_worktree.jsonl");
368        let worktree = Path::new("/fake/worktree");
369        let summary = scan_transcript(&log_path, worktree, "testticket");
370
371        assert_eq!(summary.denial_count, 1, "expected 1 denial");
372        assert_eq!(summary.denials[0].classification, DenialClass::OutsideWorktree);
373    }
374
375    #[test]
376    fn test_missing_transcript_returns_empty_summary() {
377        let log_path = Path::new("/nonexistent/path/log.jsonl");
378        let summary = scan_transcript(log_path, Path::new("/fake/worktree"), "t1");
379        assert_eq!(summary.denial_count, 0);
380    }
381
382    #[test]
383    fn test_regular_error_not_classified_as_denial() {
384        // A Bash error starting with "Exit code" must not be treated as a denial
385        let content = r#"{"type":"assistant","message":{"role":"assistant","content":[{"type":"tool_use","id":"t1","name":"Bash","input":{"command":"false"}}]}}
386{"type":"user","message":{"role":"user","content":[{"type":"tool_result","tool_use_id":"t1","is_error":true,"content":"Exit code 1"}]},"timestamp":"2026-01-01T00:00:00Z"}
387"#;
388        let dir = tempfile::tempdir().unwrap();
389        let log = dir.path().join("test.jsonl");
390        std::fs::write(&log, content).unwrap();
391        let summary = scan_transcript(&log, dir.path(), "t");
392        assert_eq!(summary.denial_count, 0);
393    }
394
395    #[test]
396    fn test_truncate_str_at_boundary() {
397        let s = "apm state xyz implemented";
398        let truncated = truncate_str(s, 10);
399        assert_eq!(truncated.len(), 10);
400        assert!(s.starts_with(&truncated));
401    }
402
403    #[test]
404    fn test_write_and_read_summary_roundtrip() {
405        let dir = tempfile::tempdir().unwrap();
406        let summary = DenialSummary {
407            ticket_id: "abc123".to_string(),
408            worker_exited_at: "2026-01-01T00:00:00Z".to_string(),
409            log_path: "/fake/log".to_string(),
410            denial_count: 1,
411            denials: vec![DenialEntry {
412                timestamp: "2026-01-01T00:00:00Z".to_string(),
413                tool: "Bash".to_string(),
414                input: "apm state abc implemented".to_string(),
415                classification: DenialClass::ApmCommandDenial,
416            }],
417        };
418        let path = dir.path().join("summary.json");
419        write_summary(&path, &summary);
420        let loaded = read_summary(&path).expect("should be readable");
421        assert_eq!(loaded.ticket_id, "abc123");
422        assert_eq!(loaded.denial_count, 1);
423        assert_eq!(loaded.denials[0].classification, DenialClass::ApmCommandDenial);
424    }
425}