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    /// Command requires explicit approval — config-gap signal distinct from an
67    /// outright deny.  Content contains `"requires approval"`.
68    RequiresApproval,
69    /// Any other denial not matching the patterns above.
70    UnknownPattern,
71}
72
73/// One denied tool call extracted from the transcript.
74#[derive(Debug, Clone, Serialize, Deserialize)]
75pub struct DenialEntry {
76    /// ISO-8601 timestamp from the tool-result event, or empty string.
77    pub timestamp: String,
78    /// Tool name, e.g. `"Bash"`, `"Edit"`, `"Write"`.
79    pub tool: String,
80    /// Tool input (command string for Bash; serialised JSON for others),
81    /// truncated to ≤200 chars.
82    pub input: String,
83    pub classification: DenialClass,
84}
85
86/// Summary written alongside `.apm-worker.log` on worker exit.
87#[derive(Debug, Clone, Serialize, Deserialize)]
88pub struct DenialSummary {
89    pub ticket_id: String,
90    /// ISO-8601 timestamp of when the scan ran (worker exit time).
91    pub worker_exited_at: String,
92    /// Absolute path to the `.apm-worker.log` file.
93    pub log_path: String,
94    pub denial_count: usize,
95    pub denials: Vec<DenialEntry>,
96}
97
98/// Scan `log_path` for permission-denial events and return a summary.
99///
100/// Returns an empty summary (zero denials) if the file is missing or
101/// unreadable.
102pub fn scan_transcript(log_path: &Path, worktree: &Path, ticket_id: &str) -> DenialSummary {
103    let content = match std::fs::read_to_string(log_path) {
104        Ok(c) => c,
105        Err(_) => {
106            return empty_summary(log_path, ticket_id);
107        }
108    };
109
110    // Pass 1 — build tool_use_id → (tool_name, input_value, timestamp) map.
111    // The timestamp on assistant-message lines is often absent; we capture it
112    // in case it is present, but the denial timestamp comes from the
113    // tool-result line in pass 2.
114    let mut tool_uses: HashMap<String, (String, serde_json::Value, String)> = HashMap::new();
115
116    for line in content.lines() {
117        let v: serde_json::Value = match serde_json::from_str(line) {
118            Ok(v) => v,
119            Err(_) => continue,
120        };
121        if v["type"] != "assistant" {
122            continue;
123        }
124        let ts = v["timestamp"].as_str().unwrap_or("").to_string();
125        if let Some(arr) = v["message"]["content"].as_array() {
126            for item in arr {
127                if item["type"] != "tool_use" {
128                    continue;
129                }
130                let id = item["id"].as_str().unwrap_or("").to_string();
131                if id.is_empty() {
132                    continue;
133                }
134                let name = item["name"].as_str().unwrap_or("").to_string();
135                let input = item["input"].clone();
136                tool_uses.insert(id, (name, input, ts.clone()));
137            }
138        }
139    }
140
141    // Pass 2 — find denied tool_result events.
142    let canon_worktree = std::fs::canonicalize(worktree)
143        .unwrap_or_else(|_| worktree.to_path_buf());
144
145    let mut denials: Vec<DenialEntry> = Vec::new();
146
147    for line in content.lines() {
148        let v: serde_json::Value = match serde_json::from_str(line) {
149            Ok(v) => v,
150            Err(_) => continue,
151        };
152        if v["type"] != "user" {
153            continue;
154        }
155        let result_ts = v["timestamp"].as_str().unwrap_or("").to_string();
156        let Some(arr) = v["message"]["content"].as_array() else { continue };
157
158        for item in arr {
159            if item["type"] != "tool_result" {
160                continue;
161            }
162            if item["is_error"] != true {
163                continue;
164            }
165            // Discriminate denial from regular error: denials never start with "Exit code "
166            let content_str = match item["content"].as_str() {
167                Some(s) => s,
168                None => continue,
169            };
170            if content_str.starts_with("Exit code ") {
171                continue;
172            }
173            // Collateral cancellation from a failed parallel sibling — not a denial.
174            if content_str.contains("Cancelled: parallel tool call") {
175                continue;
176            }
177            // "This command requires approval" is a config-gap signal, not a deny.
178            if content_str.contains("requires approval") {
179                let tool_use_id = item["tool_use_id"].as_str().unwrap_or("");
180                let Some((tool_name, input_obj, _)) = tool_uses.get(tool_use_id) else { continue };
181                let input_str = if tool_name == "Bash" {
182                    input_obj["command"].as_str().unwrap_or("").to_string()
183                } else {
184                    serde_json::to_string(input_obj).unwrap_or_default()
185                };
186                denials.push(DenialEntry {
187                    timestamp: result_ts.clone(),
188                    tool: tool_name.clone(),
189                    input: truncate_str(&input_str, 200),
190                    classification: DenialClass::RequiresApproval,
191                });
192                continue;
193            }
194
195            let tool_use_id = item["tool_use_id"].as_str().unwrap_or("");
196            let Some((tool_name, input_obj, _)) = tool_uses.get(tool_use_id) else { continue };
197
198            let (input_str, classification) =
199                classify_denial(tool_name, input_obj, &canon_worktree, worktree);
200
201            denials.push(DenialEntry {
202                timestamp: result_ts.clone(),
203                tool: tool_name.clone(),
204                input: truncate_str(&input_str, 200),
205                classification,
206            });
207        }
208    }
209
210    DenialSummary {
211        ticket_id: ticket_id.to_string(),
212        worker_exited_at: chrono::Utc::now().to_rfc3339(),
213        log_path: log_path.to_string_lossy().into_owned(),
214        denial_count: denials.len(),
215        denials,
216    }
217}
218
219/// Write `summary` to `summary_path` as pretty-printed JSON.
220/// Errors are logged and swallowed — this must not panic or crash the
221/// wrapper exit path.
222pub fn write_summary(summary_path: &Path, summary: &DenialSummary) {
223    match serde_json::to_string_pretty(summary) {
224        Ok(json) => {
225            if let Err(e) = std::fs::write(summary_path, json) {
226                crate::logger::log("worker-diag", &format!("write_summary failed: {e}"));
227            }
228        }
229        Err(e) => {
230            crate::logger::log("worker-diag", &format!("write_summary serialize failed: {e}"));
231        }
232    }
233}
234
235/// Read a previously written summary from `summary_path`.
236/// Returns `None` if the file is absent or cannot be parsed.
237pub fn read_summary(summary_path: &Path) -> Option<DenialSummary> {
238    let content = std::fs::read_to_string(summary_path).ok()?;
239    serde_json::from_str(&content).ok()
240}
241
242/// Derive the `.apm-worker.summary.json` path from the `.apm-worker.log` path.
243/// If the log path ends in `.log`, replaces that suffix; otherwise appends
244/// `.summary.json`.
245pub fn summary_path_for(log_path: &Path) -> std::path::PathBuf {
246    if log_path.extension().and_then(|e| e.to_str()) == Some("log") {
247        log_path.with_extension("summary.json")
248    } else {
249        let mut p = log_path.to_path_buf();
250        let name = p.file_name()
251            .map(|n| format!("{}.summary.json", n.to_string_lossy()))
252            .unwrap_or_else(|| "summary.json".to_string());
253        p.set_file_name(name);
254        p
255    }
256}
257
258/// Return the unique command strings from all `ApmCommandDenial` entries in
259/// `summary`, preserving first-seen order.
260pub fn collect_unique_apm_commands(summary: &DenialSummary) -> Vec<String> {
261    let mut seen = std::collections::HashSet::new();
262    summary
263        .denials
264        .iter()
265        .filter(|d| d.classification == DenialClass::ApmCommandDenial)
266        .filter(|d| seen.insert(d.input.clone()))
267        .map(|d| d.input.clone())
268        .collect()
269}
270
271// ── internal helpers ──────────────────────────────────────────────────────────
272
273fn empty_summary(log_path: &Path, ticket_id: &str) -> DenialSummary {
274    DenialSummary {
275        ticket_id: ticket_id.to_string(),
276        worker_exited_at: chrono::Utc::now().to_rfc3339(),
277        log_path: log_path.to_string_lossy().into_owned(),
278        denial_count: 0,
279        denials: Vec::new(),
280    }
281}
282
283/// Return (input_string, classification) for a single denial.
284fn classify_denial(
285    tool: &str,
286    input_obj: &serde_json::Value,
287    canon_worktree: &Path,
288    raw_worktree: &Path,
289) -> (String, DenialClass) {
290    match tool {
291        "Bash" => {
292            let command = input_obj["command"].as_str().unwrap_or("").to_string();
293            let class = if command.trim().starts_with("apm ") {
294                DenialClass::ApmCommandDenial
295            } else {
296                DenialClass::UnknownPattern
297            };
298            (command, class)
299        }
300        "Edit" | "Write" => {
301            let file_path_str = input_obj["file_path"].as_str().unwrap_or("");
302            let class = if !file_path_str.is_empty()
303                && is_outside_worktree(file_path_str, canon_worktree, raw_worktree)
304            {
305                DenialClass::OutsideWorktree
306            } else {
307                DenialClass::UnknownPattern
308            };
309            let input_str = serde_json::to_string(input_obj).unwrap_or_default();
310            (input_str, class)
311        }
312        _ => {
313            let input_str = serde_json::to_string(input_obj).unwrap_or_default();
314            (input_str, DenialClass::UnknownPattern)
315        }
316    }
317}
318
319/// Return true if `file_path_str` resolves to a path outside `canon_worktree`.
320///
321/// Path resolution rules:
322/// - `canon_worktree` is the result of `fs::canonicalize(worktree)`, or the raw
323///   worktree path if canonicalization fails (worktree does not exist yet).
324/// - Absolute `file_path_str`: attempt canonicalize; on failure use as-is.
325/// - Relative `file_path_str`: join with `raw_worktree` first, then attempt
326///   canonicalize; on failure use the joined form.
327fn is_outside_worktree(file_path_str: &str, canon_worktree: &Path, raw_worktree: &Path) -> bool {
328    let file_path = Path::new(file_path_str);
329
330    let resolved: PathBuf = if file_path.is_absolute() {
331        std::fs::canonicalize(file_path).unwrap_or_else(|_| file_path.to_path_buf())
332    } else {
333        let joined = raw_worktree.join(file_path);
334        std::fs::canonicalize(&joined).unwrap_or(joined)
335    };
336
337    !resolved.starts_with(canon_worktree)
338}
339
340fn truncate_str(s: &str, max_bytes: usize) -> String {
341    if s.len() <= max_bytes {
342        s.to_string()
343    } else {
344        // Truncate at a char boundary
345        let mut end = max_bytes;
346        while !s.is_char_boundary(end) {
347            end -= 1;
348        }
349        s[..end].to_string()
350    }
351}
352
353#[cfg(test)]
354mod tests {
355    use super::*;
356
357    fn fixture_path(name: &str) -> std::path::PathBuf {
358        std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
359            .join("tests/fixtures")
360            .join(name)
361    }
362
363    #[test]
364    fn test_apm_command_denial() {
365        let log_path = fixture_path("transcript_apm_denial.jsonl");
366        let worktree = Path::new("/fake/worktree");
367        let summary = scan_transcript(&log_path, worktree, "testticket");
368
369        assert_eq!(summary.denial_count, 1, "expected 1 denial");
370        assert_eq!(summary.denials[0].classification, DenialClass::ApmCommandDenial);
371        assert_eq!(summary.denials[0].tool, "Bash");
372        assert!(
373            summary.denials[0].input.starts_with("apm "),
374            "input should start with 'apm ', got: {:?}",
375            summary.denials[0].input
376        );
377    }
378
379    #[test]
380    fn test_no_denials() {
381        let log_path = fixture_path("transcript_no_denials.jsonl");
382        let worktree = Path::new("/fake/worktree");
383        let summary = scan_transcript(&log_path, worktree, "testticket");
384
385        assert_eq!(summary.denial_count, 0, "expected 0 denials");
386        assert!(summary.denials.is_empty());
387    }
388
389    #[test]
390    fn test_outside_worktree() {
391        let log_path = fixture_path("transcript_outside_worktree.jsonl");
392        let worktree = Path::new("/fake/worktree");
393        let summary = scan_transcript(&log_path, worktree, "testticket");
394
395        assert_eq!(summary.denial_count, 1, "expected 1 denial");
396        assert_eq!(summary.denials[0].classification, DenialClass::OutsideWorktree);
397    }
398
399    #[test]
400    fn test_missing_transcript_returns_empty_summary() {
401        let log_path = Path::new("/nonexistent/path/log.jsonl");
402        let summary = scan_transcript(log_path, Path::new("/fake/worktree"), "t1");
403        assert_eq!(summary.denial_count, 0);
404    }
405
406    #[test]
407    fn test_regular_error_not_classified_as_denial() {
408        // A Bash error starting with "Exit code" must not be treated as a denial
409        let content = r#"{"type":"assistant","message":{"role":"assistant","content":[{"type":"tool_use","id":"t1","name":"Bash","input":{"command":"false"}}]}}
410{"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"}
411"#;
412        let dir = tempfile::tempdir().unwrap();
413        let log = dir.path().join("test.jsonl");
414        std::fs::write(&log, content).unwrap();
415        let summary = scan_transcript(&log, dir.path(), "t");
416        assert_eq!(summary.denial_count, 0);
417    }
418
419    #[test]
420    fn test_cancelled_parallel_not_a_denial() {
421        let content = r#"{"type":"assistant","message":{"role":"assistant","content":[{"type":"tool_use","id":"t1","name":"Bash","input":{"command":"apm instructions"}}]}}
422{"type":"user","message":{"role":"user","content":[{"type":"tool_result","tool_use_id":"t1","is_error":true,"content":"Cancelled: parallel tool call Bash(apm instructions) errored"}]},"timestamp":"2026-01-01T00:00:00Z"}
423"#;
424        let dir = tempfile::tempdir().unwrap();
425        let log = dir.path().join("test.jsonl");
426        std::fs::write(&log, content).unwrap();
427        let summary = scan_transcript(&log, dir.path(), "t");
428        assert_eq!(summary.denial_count, 0);
429        assert!(summary.denials.is_empty());
430    }
431
432    #[test]
433    fn test_requires_approval_classified_as_requires_approval() {
434        let content = r#"{"type":"assistant","message":{"role":"assistant","content":[{"type":"tool_use","id":"t1","name":"Bash","input":{"command":"apm instructions"}}]}}
435{"type":"user","message":{"role":"user","content":[{"type":"tool_result","tool_use_id":"t1","is_error":true,"content":"This command requires approval"}]},"timestamp":"2026-01-01T00:00:00Z"}
436"#;
437        let dir = tempfile::tempdir().unwrap();
438        let log = dir.path().join("test.jsonl");
439        std::fs::write(&log, content).unwrap();
440        let summary = scan_transcript(&log, dir.path(), "t");
441        assert_eq!(summary.denial_count, 1);
442        assert_eq!(summary.denials[0].classification, DenialClass::RequiresApproval);
443    }
444
445    #[test]
446    fn test_truncate_str_at_boundary() {
447        let s = "apm state xyz implemented";
448        let truncated = truncate_str(s, 10);
449        assert_eq!(truncated.len(), 10);
450        assert!(s.starts_with(&truncated));
451    }
452
453    #[test]
454    fn test_write_and_read_summary_roundtrip() {
455        let dir = tempfile::tempdir().unwrap();
456        let summary = DenialSummary {
457            ticket_id: "abc123".to_string(),
458            worker_exited_at: "2026-01-01T00:00:00Z".to_string(),
459            log_path: "/fake/log".to_string(),
460            denial_count: 1,
461            denials: vec![DenialEntry {
462                timestamp: "2026-01-01T00:00:00Z".to_string(),
463                tool: "Bash".to_string(),
464                input: "apm state abc implemented".to_string(),
465                classification: DenialClass::ApmCommandDenial,
466            }],
467        };
468        let path = dir.path().join("summary.json");
469        write_summary(&path, &summary);
470        let loaded = read_summary(&path).expect("should be readable");
471        assert_eq!(loaded.ticket_id, "abc123");
472        assert_eq!(loaded.denial_count, 1);
473        assert_eq!(loaded.denials[0].classification, DenialClass::ApmCommandDenial);
474    }
475}