Skip to main content

zeroclaw_log/
tool_io.rs

1//! Tool input/output capture: leak-scan + truncation + denylist.
2//!
3//! The actual `LeakDetector` lives in `zeroclaw-runtime::security` (it
4//! depends on regex tables that themselves depend on other runtime types).
5//! This crate is upstream of runtime, so we can't reach the detector
6//! directly. Instead, callers in runtime invoke
7//! [`capture_tool_input`] / [`capture_tool_output`] with the post-scan
8//! string (the runtime side runs `LeakDetector::scan` first and passes
9//! the redacted output here for truncation + size-flagging).
10
11use crate::config::{ResolvedPolicy, ToolIoPolicy};
12
13/// Result of a tool-io capture pass. The string in `text` is what should
14/// land in the `attributes.tool_input` (or `tool_output`) field. Metadata
15/// goes into `original_bytes` / `truncated` so the dashboard can render
16/// a "truncated" badge.
17#[derive(Debug, Clone)]
18pub struct ToolIoCapture {
19    pub text: String,
20    pub original_bytes: usize,
21    pub truncated: bool,
22}
23
24impl ToolIoCapture {
25    fn empty() -> Self {
26        Self {
27            text: String::new(),
28            original_bytes: 0,
29            truncated: false,
30        }
31    }
32}
33
34/// Capture redacted tool input.
35///
36/// `redacted` is the input string AFTER the runtime has scanned it for
37/// credential leaks (using `zeroclaw_runtime::security::LeakDetector`).
38/// This function only handles truncation + denylist enforcement.
39///
40/// Returns `None` when policy/denylist says to skip capture entirely.
41#[must_use]
42pub fn capture_tool_input(
43    policy: &ResolvedPolicy,
44    tool: &str,
45    redacted: &str,
46) -> Option<ToolIoCapture> {
47    capture_with_policy(policy, tool, redacted)
48}
49
50/// Capture redacted tool output. Same shape as [`capture_tool_input`].
51#[must_use]
52pub fn capture_tool_output(
53    policy: &ResolvedPolicy,
54    tool: &str,
55    redacted: &str,
56) -> Option<ToolIoCapture> {
57    capture_with_policy(policy, tool, redacted)
58}
59
60fn capture_with_policy(
61    policy: &ResolvedPolicy,
62    tool: &str,
63    redacted: &str,
64) -> Option<ToolIoCapture> {
65    if !policy.tool_io.captures_io() {
66        return None;
67    }
68    if policy.is_tool_denylisted(tool) {
69        return None;
70    }
71    let original_bytes = redacted.len();
72    match policy.tool_io {
73        ToolIoPolicy::Off => None,
74        ToolIoPolicy::Full => Some(ToolIoCapture {
75            text: redacted.to_string(),
76            original_bytes,
77            truncated: false,
78        }),
79        ToolIoPolicy::Redacted => {
80            let cap = policy.tool_io_truncate_bytes;
81            if original_bytes <= cap {
82                Some(ToolIoCapture {
83                    text: redacted.to_string(),
84                    original_bytes,
85                    truncated: false,
86                })
87            } else {
88                // Truncate on a char boundary, not a byte. Simpler: take
89                // the first `cap` chars (lossy but safe).
90                let mut acc = String::with_capacity(cap);
91                for ch in redacted.chars() {
92                    if acc.len() + ch.len_utf8() > cap {
93                        break;
94                    }
95                    acc.push(ch);
96                }
97                Some(ToolIoCapture {
98                    text: acc,
99                    original_bytes,
100                    truncated: true,
101                })
102            }
103        }
104    }
105}
106
107#[allow(dead_code)]
108fn empty_unused_marker() {
109    // Suppress unused-import false positives for `ToolIoCapture::empty`
110    // (kept around for future "explicit empty capture" call sites).
111    let _ = ToolIoCapture::empty();
112}
113
114#[cfg(test)]
115mod tests {
116    use super::*;
117    use crate::config::LogConfig;
118
119    fn make_policy(io: &str, cap: usize, denylist: Vec<String>) -> ResolvedPolicy {
120        let cfg = LogConfig {
121            log_tool_io: io.into(),
122            log_tool_io_truncate_bytes: cap,
123            log_tool_io_denylist: denylist,
124            ..LogConfig::default()
125        };
126        ResolvedPolicy::from_config(&cfg, std::path::Path::new("/"))
127    }
128
129    #[test]
130    fn off_policy_returns_none() {
131        let p = make_policy("off", 8192, vec![]);
132        assert!(capture_tool_input(&p, "shell", "hello").is_none());
133    }
134
135    #[test]
136    fn denylist_skips_capture() {
137        let p = make_policy("redacted", 8192, vec!["memory_recall".into()]);
138        assert!(capture_tool_input(&p, "memory_recall", "hello").is_none());
139        assert!(capture_tool_input(&p, "shell", "hello").is_some());
140    }
141
142    #[test]
143    fn redacted_truncates_when_over_cap() {
144        let p = make_policy("redacted", 4, vec![]);
145        let cap = capture_tool_input(&p, "shell", "hello world").unwrap();
146        assert_eq!(cap.text, "hell");
147        assert_eq!(cap.original_bytes, 11);
148        assert!(cap.truncated);
149    }
150
151    #[test]
152    fn full_policy_keeps_everything() {
153        let p = make_policy("full", 4, vec![]);
154        let cap = capture_tool_output(&p, "shell", "hello world").unwrap();
155        assert_eq!(cap.text, "hello world");
156        assert!(!cap.truncated);
157    }
158}