1use crate::core::config::Config;
13use serde_json::{Map, Value, json};
14
15const HEAD_LINES: usize = 20;
16const TAIL_LINES: usize = 8;
17const LONG_LINE_HEAD_CHARS: usize = 800;
18const LONG_LINE_TAIL_CHARS: usize = 300;
19const JSON_PREVIEW_KEYS: usize = 32;
20
21pub fn is_firewallable_tool(name: &str) -> bool {
24 matches!(
25 name,
26 "ctx_shell" | "ctx_execute" | "ctx_search" | "ctx_tree"
27 )
28}
29
30pub fn is_protected_read(name: &str) -> bool {
37 matches!(name, "ctx_read" | "ctx_multi_read" | "ctx_smart_read")
38}
39
40pub fn min_tokens(config: &Config) -> usize {
42 config.archive.ephemeral_min_tokens_effective()
43}
44
45pub fn should_firewall(tool: &str, output_tokens: usize, config: &Config) -> bool {
47 config.archive.ephemeral_effective()
48 && is_firewallable_tool(tool)
49 && output_tokens >= min_tokens(config)
50}
51
52pub const DEFAULT_RAW_COMMANDS: &[&str] = &["sqlite3", "psql", "duckdb", "jq"];
58
59pub fn is_raw_command(command: &str, config: &Config) -> bool {
63 command
64 .split(['|', ';', '&', '\n'])
65 .any(|seg| match seg.split_whitespace().next() {
66 Some(word) => {
69 let prog = word.rsplit('/').next().unwrap_or(word);
70 config.archive.raw_commands.iter().any(|r| r == prog)
71 || (prog == "gh" && (seg.contains("--json") || seg.contains("--jq")))
72 }
73 None => false,
74 })
75}
76
77pub fn should_inline_shell(inline_requested: bool, output_bytes: usize, config: &Config) -> bool {
80 inline_requested && output_bytes <= config.archive.inline_max_bytes_effective()
81}
82
83pub fn summarize(full: &str, archive_id: &str, tool: &str, output_tokens: usize) -> String {
88 let chars = full.len();
89 let lines: Vec<&str> = full.lines().collect();
90 let line_count = lines.len();
91
92 let mut out = String::new();
93 out.push_str(&format!(
94 "[Firewalled {tool} output — {chars} chars, {output_tokens} tok, {line_count} lines stored out-of-band]\n"
95 ));
96
97 if let Some(preview) = json_structure_preview(full) {
98 out.push_str("--- JSON structural preview (complete summary; original archived) ---\n");
99 out.push_str(&preview);
100 out.push('\n');
101 } else if line_count > HEAD_LINES + TAIL_LINES + 1 {
102 out.push_str("--- head ---\n");
103 out.push_str(&lines[..HEAD_LINES].join("\n"));
104 out.push_str(&format!(
105 "\n--- … {} lines omitted … ---\n",
106 line_count - HEAD_LINES - TAIL_LINES
107 ));
108 out.push_str("--- tail ---\n");
109 out.push_str(&lines[line_count - TAIL_LINES..].join("\n"));
110 out.push('\n');
111 } else {
112 let head_end = full.floor_char_boundary(LONG_LINE_HEAD_CHARS.min(chars));
114 out.push_str(&full[..head_end]);
115 if chars > LONG_LINE_HEAD_CHARS + LONG_LINE_TAIL_CHARS {
116 out.push_str("\n… (truncated) …\n");
117 let tail_start = full.floor_char_boundary(chars - LONG_LINE_TAIL_CHARS);
118 out.push_str(&full[tail_start..]);
119 out.push('\n');
120 }
121 }
122
123 out.push_str("--- retrieve full output ---\n");
124 out.push_str(&format!(
127 "Direct: read {} directly (no MCP)\n",
128 crate::core::archive::content_path_str(archive_id)
129 ));
130 out.push_str(&format!("Full: ctx_expand(id=\"{archive_id}\")\n"));
131 out.push_str(&format!(
132 "Range: ctx_expand(id=\"{archive_id}\", start_line=1, end_line=80)\n"
133 ));
134 out.push_str(&format!(
135 "Head: ctx_expand(id=\"{archive_id}\", head=120)\n"
136 ));
137 out.push_str(&format!(
138 "Search: ctx_expand(id=\"{archive_id}\", search=\"ERROR\")\n"
139 ));
140 out.push_str(&format!(
141 "JSON: ctx_expand(id=\"{archive_id}\", json_keys=true)"
142 ));
143 out
144}
145
146fn json_structure_preview(full: &str) -> Option<String> {
147 let value: Value = serde_json::from_str(full).ok()?;
148 let root = match value {
149 Value::Object(object) => {
150 let total = object.len();
151 let fields = object
152 .into_iter()
153 .take(JSON_PREVIEW_KEYS)
154 .map(|(key, value)| (key, json_value_shape(&value)))
155 .collect::<Map<_, _>>();
156 json!({
157 "type": "object",
158 "keys": total,
159 "fields": fields,
160 "omitted_keys": total.saturating_sub(JSON_PREVIEW_KEYS),
161 })
162 }
163 other => json_value_shape(&other),
164 };
165 serde_json::to_string(&json!({
166 "preview": "structural",
167 "root": root,
168 }))
169 .ok()
170}
171
172fn json_value_shape(value: &Value) -> Value {
173 match value {
174 Value::Null => json!({ "type": "null" }),
175 Value::Bool(_) => json!({ "type": "boolean" }),
176 Value::Number(_) => json!({ "type": "number" }),
177 Value::String(text) => json!({
178 "type": "string",
179 "chars": text.chars().count(),
180 }),
181 Value::Array(items) => json!({
182 "type": "array",
183 "items": items.len(),
184 }),
185 Value::Object(fields) => json!({
186 "type": "object",
187 "keys": fields.len(),
188 }),
189 }
190}
191
192#[cfg(test)]
193mod tests {
194 use super::*;
195
196 #[test]
197 fn firewallable_tools_are_outputs_not_reads() {
198 assert!(is_firewallable_tool("ctx_shell"));
199 assert!(is_firewallable_tool("ctx_search"));
200 assert!(is_firewallable_tool("ctx_tree"));
201 assert!(is_firewallable_tool("ctx_execute"));
202 assert!(!is_firewallable_tool("ctx_read"));
203 assert!(!is_firewallable_tool("ctx_multi_read"));
204 assert!(!is_firewallable_tool("ctx_knowledge"));
205 }
206
207 #[test]
208 fn protected_reads_are_file_readers_and_never_firewallable() {
209 for read in ["ctx_read", "ctx_multi_read", "ctx_smart_read"] {
212 assert!(is_protected_read(read), "{read} must be a protected read");
213 assert!(
214 !is_firewallable_tool(read),
215 "{read} must never be firewallable"
216 );
217 }
218 assert!(!is_protected_read("ctx_shell"));
219 assert!(!is_protected_read("ctx_search"));
220 }
221
222 #[test]
223 fn should_firewall_respects_tool_and_threshold() {
224 let _env_lock = crate::core::data_dir::test_env_lock();
225 let mut cfg = Config::default();
226 cfg.archive.enabled = true;
227 cfg.archive.ephemeral = true;
228 cfg.archive.ephemeral_min_tokens = 2000;
229 crate::test_env::remove_var("LEAN_CTX_EPHEMERAL");
231 crate::test_env::remove_var("LEAN_CTX_EPHEMERAL_MIN_TOKENS");
232
233 assert!(should_firewall("ctx_shell", 5000, &cfg));
234 assert!(!should_firewall("ctx_shell", 1000, &cfg)); assert!(!should_firewall("ctx_read", 5000, &cfg)); }
237
238 #[test]
239 fn dataset_commands_bypass_the_firewall_but_prose_does_not() {
240 let cfg = Config::default();
241 assert!(is_raw_command(
242 "sqlite3 -header backup.db \"select 1\"",
243 &cfg
244 ));
245 assert!(is_raw_command("/usr/bin/psql -c 'select 1'", &cfg));
246 assert!(is_raw_command("cat x.json | jq '.[]'", &cfg));
247 assert!(is_raw_command("gh issue list --json number,title", &cfg));
248 assert!(!is_raw_command("gh issue view 1260", &cfg));
250 assert!(!is_raw_command("grep -rn sqlite3 src/", &cfg));
251 assert!(!is_raw_command("cargo test", &cfg));
252
253 let mut off = Config::default();
255 off.archive.raw_commands.clear();
256 assert!(!is_raw_command("sqlite3 backup.db 'select 1'", &off));
257 }
258
259 #[test]
260 fn inline_shell_stays_inline_under_byte_cap() {
261 let _env_lock = crate::core::data_dir::test_env_lock();
262 let mut cfg = Config::default();
263 cfg.archive.inline_max_bytes = 1024;
264 crate::test_env::remove_var("LEAN_CTX_INLINE_MAX_BYTES");
265
266 assert!(should_inline_shell(true, 1024, &cfg));
267 assert!(should_inline_shell(true, 0, &cfg));
268 }
269
270 #[test]
271 fn inline_shell_over_byte_cap_uses_archive_path() {
272 let _env_lock = crate::core::data_dir::test_env_lock();
274 let mut cfg = Config::default();
275 cfg.archive.inline_max_bytes = 1024;
276 crate::test_env::remove_var("LEAN_CTX_INLINE_MAX_BYTES");
277
278 assert!(!should_inline_shell(true, 1025, &cfg));
279 }
280
281 #[test]
282 fn inline_shell_requires_explicit_request_and_honors_env_cap() {
283 let _env_lock = crate::core::data_dir::test_env_lock();
286 let mut cfg = Config::default();
287 cfg.archive.inline_max_bytes = 1024;
288 crate::test_env::set_var("LEAN_CTX_INLINE_MAX_BYTES", "2048");
289
290 assert!(!should_inline_shell(false, 1, &cfg));
291 assert!(should_inline_shell(true, 2048, &cfg));
292 assert!(!should_inline_shell(true, 2049, &cfg));
293
294 crate::test_env::remove_var("LEAN_CTX_INLINE_MAX_BYTES");
295 }
296
297 #[test]
298 fn summarize_includes_excerpt_stats_and_ref() {
299 let full = (1..=200)
300 .map(|i| format!("line {i}"))
301 .collect::<Vec<_>>()
302 .join("\n");
303 let digest = summarize(&full, "abc123", "ctx_shell", 1234);
304 assert!(digest.contains("Firewalled ctx_shell output"));
305 assert!(digest.contains("1234 tok"));
306 assert!(digest.contains("line 1")); assert!(digest.contains("line 200")); assert!(digest.contains("lines omitted"));
309 assert!(digest.contains("ctx_expand(id=\"abc123\")"));
310 assert!(digest.contains("json_keys=true"));
311 assert!(digest.len() < full.len());
313 }
314
315 #[test]
316 fn summarize_handles_single_giant_line() {
317 let full = "x".repeat(5000);
318 let digest = summarize(&full, "id9", "ctx_search", 1300);
319 assert!(digest.contains("Firewalled ctx_search output"));
320 assert!(digest.contains("truncated"));
321 assert!(digest.len() < full.len());
322 }
323
324 #[test]
325 fn summarize_json_uses_complete_structural_document() {
326 let full = serde_json::to_string(&json!({
327 "body": "x".repeat(5000),
328 "files": [{"path": "src/a.rs"}, {"path": "src/b.rs"}],
329 "state": "MERGED",
330 }))
331 .unwrap();
332
333 let digest = summarize(&full, "json1", "ctx_shell", 2000);
334 assert!(!digest.contains("… (truncated) …"));
335 let preview = digest
336 .lines()
337 .find(|line| line.starts_with("{\"preview\":"))
338 .expect("structural preview JSON");
339 let parsed: Value = serde_json::from_str(preview).expect("preview remains valid JSON");
340 assert_eq!(parsed["root"]["fields"]["body"]["chars"], 5000);
341 assert_eq!(parsed["root"]["fields"]["files"]["items"], 2);
342 assert_eq!(parsed["root"]["keys"], 3);
343 assert!(digest.contains("original archived"));
344 assert!(digest.contains("ctx_expand(id=\"json1\", json_keys=true)"));
345 }
346
347 #[test]
348 fn json_structure_preview_caps_fields_at_valid_boundary() {
349 let object = (0..40)
350 .map(|index| (format!("key_{index:02}"), json!(index)))
351 .collect::<Map<_, _>>();
352 let full = serde_json::to_string(&object).unwrap();
353 let preview = json_structure_preview(&full).unwrap();
354 let parsed: Value = serde_json::from_str(&preview).unwrap();
355
356 assert_eq!(parsed["root"]["fields"].as_object().unwrap().len(), 32);
357 assert_eq!(parsed["root"]["omitted_keys"], 8);
358 }
359}