1use crate::config::{ResolvedPolicy, ToolIoPolicy};
12
13#[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#[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#[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 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 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}