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 name == "ctx_read"
38}
39
40pub(crate) 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(crate) const DEFAULT_RAW_COMMANDS: &[&str] = &["sqlite3", "psql", "duckdb", "jq"];
58
59pub fn verbatim_cap_exceeded(tool: &str, output_tokens: usize, config: &Config) -> bool {
67 let cap = config.archive.verbatim_max_tokens_effective();
68 cap > 0 && is_firewallable_tool(tool) && output_tokens >= cap
69}
70
71pub fn is_raw_command(command: &str, config: &Config) -> bool {
75 command
76 .split(['|', ';', '&', '\n'])
77 .any(|seg| match seg.split_whitespace().next() {
78 Some(word) => {
81 let prog = word.rsplit('/').next().unwrap_or(word);
82 config.archive.raw_commands.iter().any(|r| r == prog)
83 || (prog == "gh" && (seg.contains("--json") || seg.contains("--jq")))
84 }
85 None => false,
86 })
87}
88
89pub fn should_inline_shell(inline_requested: bool, output_bytes: usize, config: &Config) -> bool {
92 inline_requested && output_bytes <= config.archive.inline_max_bytes_effective()
93}
94
95pub fn summarize(
100 full: &str,
101 archive_id: &str,
102 tool: &str,
103 output_tokens: usize,
104 command: &str,
105) -> String {
106 let chars = full.len();
107 let lines: Vec<&str> = full.lines().collect();
108 let line_count = lines.len();
109
110 let mut out = String::new();
111 out.push_str(&format!(
112 "[Firewalled {tool} output — {chars} chars, {output_tokens} tok, {line_count} lines stored out-of-band]\n"
113 ));
114
115 if let Some(preview) = json_structure_preview(full) {
116 out.push_str("--- JSON structural preview (complete summary; original archived) ---\n");
117 out.push_str(&preview);
118 out.push('\n');
119 } else if line_count > HEAD_LINES + TAIL_LINES + 1 {
120 out.push_str("--- head ---\n");
121 out.push_str(&lines[..HEAD_LINES].join("\n"));
122 out.push_str(&format!(
123 "\n--- … {} lines omitted … ---\n",
124 line_count - HEAD_LINES - TAIL_LINES
125 ));
126 out.push_str("--- tail ---\n");
127 out.push_str(&lines[line_count - TAIL_LINES..].join("\n"));
128 out.push('\n');
129 } else {
130 let head_end = full.floor_char_boundary(LONG_LINE_HEAD_CHARS.min(chars));
132 out.push_str(&full[..head_end]);
133 if chars > LONG_LINE_HEAD_CHARS + LONG_LINE_TAIL_CHARS {
134 out.push_str("\n… (truncated) …\n");
135 let tail_start = full.floor_char_boundary(chars - LONG_LINE_TAIL_CHARS);
136 out.push_str(&full[tail_start..]);
137 out.push('\n');
138 }
139 }
140
141 let max_line = lines.iter().map(|l| l.len()).max().unwrap_or(0);
143 if max_line > 2000 {
144 out.push_str(&format!(
145 "⚠ longest line: {max_line} chars — line-based offset/limit chunking will not help; use ctx_expand(search=…) or ctx_expand(json_keys=true).\n"
146 ));
147 }
148
149 out.push_str("--- guidance ---\n");
151 if json_structure_preview(full).is_some() {
152 out.push_str("Content is JSON. Use targeted extraction (ctx_expand with json_keys or search) rather than reading sequentially.\n");
153 if command.contains("--json") && !command.contains("--jq") {
154 out.push_str("TIP: re-run the command with a --jq filter to select only the fields you need at the source.\n");
155 }
156 } else {
157 out.push_str("Narrow the command at the source (grep -m N, sed -n 'A,Bp', head/tail), or use ctx_expand(search=\"KEYWORD\") on this archive. Only read the full archive if you genuinely need every line.\n");
161 }
162
163 out.push_str("--- retrieve full output ---\n");
164 let path = crate::core::archive::content_path_str(archive_id);
167 if crate::core::protected_store::enforce_protected_store_path(std::path::Path::new(&path))
168 .is_ok()
169 {
170 out.push_str(&format!("Direct: read {path} directly (no MCP)\n"));
171 }
172 out.push_str(&format!("Full: ctx_expand(id=\"{archive_id}\")\n"));
173 out.push_str(&format!(
174 "Range: ctx_expand(id=\"{archive_id}\", start_line=1, end_line=80)\n"
175 ));
176 out.push_str(&format!(
177 "Head: ctx_expand(id=\"{archive_id}\", head=120)\n"
178 ));
179 out.push_str(&format!(
180 "Search: ctx_expand(id=\"{archive_id}\", search=\"ERROR\")\n"
181 ));
182 out.push_str(&format!(
183 "JSON: ctx_expand(id=\"{archive_id}\", json_keys=true)"
184 ));
185 out
186}
187
188pub fn summarize_unavailable(full: &str, tool: &str, output_tokens: usize) -> String {
191 let head_end = full.floor_char_boundary(LONG_LINE_HEAD_CHARS.min(full.len()));
192 let mut out = format!(
193 "[Firewalled {tool} output — {} chars, {output_tokens} tok; recovery unavailable]\n--- preview ---\n{}",
194 full.chars().count(),
195 &full[..head_end],
196 );
197 if full.len() > head_end {
198 out.push_str("\n… (omitted; not archived) …\n");
199 let tail_start = full.ceil_char_boundary(
200 full.len()
201 .saturating_sub(LONG_LINE_TAIL_CHARS)
202 .max(head_end),
203 );
204 out.push_str(&full[tail_start..]);
205 }
206 out.push_str(
207 "\nFull recovery is unavailable. Re-run with a narrower query or output selection.",
208 );
209 out
210}
211
212fn json_structure_preview(full: &str) -> Option<String> {
213 let value: Value = serde_json::from_str(full).ok()?;
214 let root = match value {
215 Value::Object(object) => {
216 let total = object.len();
217 let fields = object
218 .into_iter()
219 .take(JSON_PREVIEW_KEYS)
220 .map(|(key, value)| (key, json_value_shape(&value)))
221 .collect::<Map<_, _>>();
222 json!({
223 "type": "object",
224 "keys": total,
225 "fields": fields,
226 "omitted_keys": total.saturating_sub(JSON_PREVIEW_KEYS),
227 })
228 }
229 other => json_value_shape(&other),
230 };
231 serde_json::to_string(&json!({
232 "preview": "structural",
233 "root": root,
234 }))
235 .ok()
236}
237
238const JSON_STRING_PREVIEW_CHARS: usize = 120;
240const JSON_ARRAY_SAMPLE: usize = 3;
242const JSON_SHAPE_MAX_DEPTH: usize = 2;
244
245fn json_value_shape(value: &Value) -> Value {
246 json_value_shape_depth(value, 0)
247}
248
249fn json_value_shape_depth(value: &Value, depth: usize) -> Value {
250 match value {
251 Value::Null => json!({ "type": "null" }),
252 Value::Bool(b) => json!({ "type": "boolean", "value": b }),
253 Value::Number(n) => json!({ "type": "number", "value": n }),
254 Value::String(text) => {
255 let char_count = text.chars().count();
256 if char_count <= JSON_STRING_PREVIEW_CHARS {
257 json!({ "type": "string", "value": text })
258 } else {
259 let preview: String = text.chars().take(JSON_STRING_PREVIEW_CHARS).collect();
260 json!({
261 "type": "string",
262 "chars": char_count,
263 "preview": format!("{preview}…"),
264 })
265 }
266 }
267 Value::Array(items) => {
268 if depth >= JSON_SHAPE_MAX_DEPTH {
269 return json!({ "type": "array", "items": items.len() });
270 }
271 let sample: Vec<Value> = items
272 .iter()
273 .take(JSON_ARRAY_SAMPLE)
274 .map(|v| json_value_shape_depth(v, depth + 1))
275 .collect();
276 json!({
277 "type": "array",
278 "items": items.len(),
279 "sample": sample,
280 })
281 }
282 Value::Object(fields) => {
283 if depth >= JSON_SHAPE_MAX_DEPTH {
284 return json!({ "type": "object", "keys": fields.len() });
285 }
286 let preview: Map<String, Value> = fields
287 .iter()
288 .take(8)
289 .map(|(k, v)| (k.clone(), json_value_shape_depth(v, depth + 1)))
290 .collect();
291 json!({
292 "type": "object",
293 "keys": fields.len(),
294 "fields": preview,
295 })
296 }
297 }
298}
299
300#[cfg(test)]
301mod tests {
302 use super::*;
303
304 #[test]
305 fn firewallable_tools_are_outputs_not_reads() {
306 assert!(is_firewallable_tool("ctx_shell"));
307 assert!(is_firewallable_tool("ctx_search"));
308 assert!(is_firewallable_tool("ctx_tree"));
309 assert!(is_firewallable_tool("ctx_execute"));
310 assert!(!is_firewallable_tool("ctx_read"));
311 assert!(!is_firewallable_tool("ctx_knowledge"));
312 }
313
314 #[test]
315 fn protected_reads_are_file_readers_and_never_firewallable() {
316 assert!(is_protected_read("ctx_read"));
319 assert!(!is_firewallable_tool("ctx_read"));
320 assert!(!is_protected_read("ctx_shell"));
321 assert!(!is_protected_read("ctx_search"));
322 }
323
324 #[test]
325 fn should_firewall_respects_tool_and_threshold() {
326 let _env_lock = crate::core::data_dir::test_env_lock();
327 let mut cfg = Config::default();
328 cfg.archive.enabled = true;
329 cfg.archive.ephemeral = true;
330 cfg.archive.ephemeral_min_tokens = 2000;
331 crate::test_env::remove_var("LEAN_CTX_EPHEMERAL");
333 crate::test_env::remove_var("LEAN_CTX_EPHEMERAL_MIN_TOKENS");
334
335 assert!(should_firewall("ctx_shell", 5000, &cfg));
336 assert!(!should_firewall("ctx_shell", 1000, &cfg)); assert!(!should_firewall("ctx_read", 5000, &cfg)); }
339
340 #[test]
341 fn dataset_commands_bypass_the_firewall_but_prose_does_not() {
342 let cfg = Config::default();
343 assert!(is_raw_command(
344 "sqlite3 -header backup.db \"select 1\"",
345 &cfg
346 ));
347 assert!(is_raw_command("/usr/bin/psql -c 'select 1'", &cfg));
348 assert!(is_raw_command("cat x.json | jq '.[]'", &cfg));
349 assert!(is_raw_command("gh issue list --json number,title", &cfg));
350 assert!(!is_raw_command("gh issue view 1260", &cfg));
352 assert!(!is_raw_command("grep -rn sqlite3 src/", &cfg));
353 assert!(!is_raw_command("cargo test", &cfg));
354
355 let mut off = Config::default();
357 off.archive.raw_commands.clear();
358 assert!(!is_raw_command("sqlite3 backup.db 'select 1'", &off));
359 }
360
361 #[test]
362 fn verbatim_cap_fires_only_above_threshold_and_only_for_firewallable_tools() {
363 let _env_lock = crate::core::data_dir::test_env_lock();
364 crate::test_env::remove_var("LEAN_CTX_VERBATIM_MAX_TOKENS");
365 let mut cfg = Config::default();
366 cfg.archive.verbatim_max_tokens = 10_000;
367 assert!(verbatim_cap_exceeded("ctx_shell", 10_000, &cfg));
368 assert!(!verbatim_cap_exceeded("ctx_shell", 9_999, &cfg));
369 assert!(!verbatim_cap_exceeded("ctx_read", 50_000, &cfg));
370
371 cfg.archive.verbatim_max_tokens = 0;
373 assert!(!verbatim_cap_exceeded("ctx_shell", 1_000_000, &cfg));
374 }
375
376 #[test]
377 fn inline_shell_stays_inline_under_byte_cap() {
378 let _env_lock = crate::core::data_dir::test_env_lock();
379 let mut cfg = Config::default();
380 cfg.archive.inline_max_bytes = 1024;
381 crate::test_env::remove_var("LEAN_CTX_INLINE_MAX_BYTES");
382
383 assert!(should_inline_shell(true, 1024, &cfg));
384 assert!(should_inline_shell(true, 0, &cfg));
385 }
386
387 #[test]
388 fn inline_shell_over_byte_cap_uses_archive_path() {
389 let _env_lock = crate::core::data_dir::test_env_lock();
391 let mut cfg = Config::default();
392 cfg.archive.inline_max_bytes = 1024;
393 crate::test_env::remove_var("LEAN_CTX_INLINE_MAX_BYTES");
394
395 assert!(!should_inline_shell(true, 1025, &cfg));
396 }
397
398 #[test]
399 fn inline_shell_requires_explicit_request_and_honors_env_cap() {
400 let _env_lock = crate::core::data_dir::test_env_lock();
403 let mut cfg = Config::default();
404 cfg.archive.inline_max_bytes = 1024;
405 crate::test_env::set_var("LEAN_CTX_INLINE_MAX_BYTES", "2048");
406
407 assert!(!should_inline_shell(false, 1, &cfg));
408 assert!(should_inline_shell(true, 2048, &cfg));
409 assert!(!should_inline_shell(true, 2049, &cfg));
410
411 crate::test_env::remove_var("LEAN_CTX_INLINE_MAX_BYTES");
412 }
413
414 #[test]
415 fn summarize_includes_excerpt_stats_and_ref() {
416 let full = (1..=200)
417 .map(|i| format!("line {i}"))
418 .collect::<Vec<_>>()
419 .join("\n");
420 let digest = summarize(&full, "abc123", "ctx_shell", 1234, "cargo test");
421 assert!(digest.contains("Firewalled ctx_shell output"));
422 assert!(digest.contains("1234 tok"));
423 assert!(digest.contains("line 1")); assert!(digest.contains("line 200")); assert!(digest.contains("lines omitted"));
426 assert!(digest.contains("ctx_expand(id=\"abc123\")"));
427 assert!(digest.contains("json_keys=true"));
428 assert!(digest.len() < full.len());
430 }
431
432 #[test]
433 fn summarize_handles_single_giant_line() {
434 let full = "x".repeat(5000);
435 let digest = summarize(&full, "id9", "ctx_search", 1300, "rg pattern");
436 assert!(digest.contains("Firewalled ctx_search output"));
437 assert!(digest.contains("truncated"));
438 assert!(digest.len() < full.len());
439 }
440
441 #[test]
442 fn summarize_json_uses_complete_structural_document() {
443 let full = serde_json::to_string(&json!({
444 "body": "x".repeat(5000),
445 "files": [{"path": "src/a.rs"}, {"path": "src/b.rs"}],
446 "state": "MERGED",
447 }))
448 .unwrap();
449
450 let digest = summarize(
451 &full,
452 "json1",
453 "ctx_shell",
454 2000,
455 "gh issue view --json comments",
456 );
457 assert!(!digest.contains("… (truncated) …"));
458 let preview = digest
459 .lines()
460 .find(|line| line.starts_with("{\"preview\":"))
461 .expect("structural preview JSON");
462 let parsed: Value = serde_json::from_str(preview).expect("preview remains valid JSON");
463 assert_eq!(parsed["root"]["fields"]["body"]["chars"], 5000);
465 assert!(
466 parsed["root"]["fields"]["body"]["preview"]
467 .as_str()
468 .unwrap()
469 .starts_with("xxx")
470 );
471 assert_eq!(parsed["root"]["fields"]["state"]["value"], "MERGED");
473 assert_eq!(parsed["root"]["fields"]["files"]["items"], 2);
475 assert!(parsed["root"]["fields"]["files"]["sample"].is_array());
476 assert_eq!(parsed["root"]["keys"], 3);
477 assert!(digest.contains("original archived"));
478 assert!(digest.contains("ctx_expand(id=\"json1\", json_keys=true)"));
479 }
480
481 #[test]
482 fn json_structure_preview_caps_fields_at_valid_boundary() {
483 let object = (0..40)
484 .map(|index| (format!("key_{index:02}"), json!(index)))
485 .collect::<Map<_, _>>();
486 let full = serde_json::to_string(&object).unwrap();
487 let preview = json_structure_preview(&full).unwrap();
488 let parsed: Value = serde_json::from_str(&preview).unwrap();
489
490 assert_eq!(parsed["root"]["fields"].as_object().unwrap().len(), 32);
491 assert_eq!(parsed["root"]["omitted_keys"], 8);
492 let first_field = &parsed["root"]["fields"]["key_00"];
494 assert_eq!(first_field["type"], "number");
495 assert_eq!(first_field["value"], 0);
496 }
497
498 #[test]
499 fn json_value_shape_includes_previews_with_depth_limit() {
500 let nested = json!({
501 "name": "hello world",
502 "count": 42,
503 "active": true,
504 "tags": ["rust", "mcp", "context"],
505 "meta": {
506 "deep": {
507 "very_deep": "should not recurse here"
508 }
509 }
510 });
511 let shape = json_value_shape(&nested);
512 assert_eq!(shape["type"], "object");
514 assert!(shape["fields"].is_object());
515 assert_eq!(shape["fields"]["name"]["value"], "hello world");
517 assert_eq!(shape["fields"]["count"]["value"], 42);
519 assert_eq!(shape["fields"]["active"]["value"], true);
521 assert_eq!(shape["fields"]["tags"]["items"], 3);
523 assert_eq!(shape["fields"]["tags"]["sample"][0]["value"], "rust");
524 let deep = &shape["fields"]["meta"]["fields"]["deep"];
526 assert_eq!(deep["type"], "object");
527 assert_eq!(deep["keys"], 1);
528 assert!(deep.get("fields").is_none());
529 }
530
531 #[test]
532 fn json_value_shape_truncates_long_strings() {
533 let long_str = "a".repeat(500);
534 let val = json!(long_str);
535 let shape = json_value_shape(&val);
536 assert_eq!(shape["type"], "string");
537 assert_eq!(shape["chars"], 500);
538 let preview = shape["preview"].as_str().unwrap();
539 assert!(preview.ends_with('…'));
540 assert!(preview.len() <= JSON_STRING_PREVIEW_CHARS + 4); }
542}