Skip to main content

lean_ctx/core/
firewall.rs

1//! Context firewall: replace large tool outputs with a compact digest + retrieval ref.
2//!
3//! When ephemeral mode is active (`[archive].ephemeral`, default on), genuinely large
4//! tool results are stored out-of-band via [`crate::core::archive`] and only a
5//! deterministic digest — a head/tail excerpt, size stats, and `ctx_expand` drilldown
6//! instructions — is returned inline. This keeps the agent's context window small while
7//! preserving full, slice-addressable access to the raw output.
8//!
9//! Scope: tool *outputs* (`ctx_shell`, `ctx_execute`, `ctx_search`, `ctx_tree`). Explicit
10//! file reads keep their own read-mode system and are never firewalled.
11
12use 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
21/// Tools whose large outputs are eligible for the firewall. Explicit file reads are
22/// intentionally excluded — they have their own read-mode (`lines:`, `signatures`, …).
23pub fn is_firewallable_tool(name: &str) -> bool {
24    matches!(
25        name,
26        "ctx_shell" | "ctx_execute" | "ctx_search" | "ctx_tree"
27    )
28}
29
30/// Explicit file-read tools whose result *is* the file content the agent reads and
31/// edits against. They must always return that content inline — never a head/tail
32/// digest (firewall) nor a stored-reference stub (`reference_results`) — regardless
33/// of output size or config. This is the single source of truth for "an explicit
34/// read always returns content"; both the firewall and the reference-results path
35/// honour it so a `ctx_read` can never degrade to a preview the agent can't edit.
36pub fn is_protected_read(name: &str) -> bool {
37    name == "ctx_read"
38}
39
40/// Effective minimum token count before firewalling (config + env override).
41pub(crate) fn min_tokens(config: &Config) -> usize {
42    config.archive.ephemeral_min_tokens_effective()
43}
44
45/// Whether a result of `output_tokens` from `tool` should be firewalled.
46pub 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
52/// Programs whose stdout *is* a dataset: rows or JSON the caller already
53/// narrowed with `where` / `limit` / `--json` / a filter expression. Head+tail
54/// elision does not compress those — it deletes the interior rows, which for
55/// sorted output is exactly where the answer lives (#1260). No size threshold
56/// makes that safe, so these bypass the firewall entirely.
57pub(crate) const DEFAULT_RAW_COMMANDS: &[&str] = &["sqlite3", "psql", "duckdb", "jq"];
58
59/// Context-window guard for *implicitly* verbatim deliveries (#1541): dataset
60/// passthrough (#1260) and `inline=true` stay verbatim at any reasonable size,
61/// but above `archive.verbatim_max_tokens` a single delivery would flood the
62/// calling agent's entire context window. The content is archived losslessly
63/// first, so the digest is a compression, never a loss. Explicit `raw`/`bypass`
64/// and the `LEAN_CTX_MINIMAL` escape hatch are never capped — callers gate on
65/// those before asking. `0` disables the cap.
66pub 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
71/// Whether `command` runs a dataset program in any of its pipeline segments.
72/// `gh` counts only with `--json`/`--jq` — plain `gh` output is prose and
73/// compresses fine.
74pub fn is_raw_command(command: &str, config: &Config) -> bool {
75    command
76        .split(['|', ';', '&', '\n'])
77        .any(|seg| match seg.split_whitespace().next() {
78            // ponytail: first word per segment, so `FOO=1 sqlite3 …` and
79            // `$(sqlite3 …)` are missed — pass raw=true for those.
80            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
89/// Whether an explicitly requested `ctx_shell(inline=true)` result fits the
90/// configured verbatim-delivery cap.
91pub 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
95/// Build the inline digest that replaces a firewalled output. Deterministic (no LLM):
96/// a head/tail excerpt for multi-line output, or a char-bounded excerpt for output with
97/// few but very long lines (e.g. a single giant JSON line), followed by drilldown
98/// instructions keyed on `archive_id`.
99pub 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        // Few lines but large (e.g. one giant minified JSON line): char-bounded excerpt.
131        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    // Shape info: helps agents understand why line-based chunking may fail.
142    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    // GH #1432: content-aware guidance instead of a blanket "read 100%" mandate.
150    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        // P10: after a firewalled log grep the agent re-ran everything with
158        // raw=true, which skips this digest and delivers the whole output.
159        // Point at the two routes that return less, not more.
160        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    // Non-MCP route first: the verbatim blob is a real file any tool can read,
165    // for agents/orgs where MCP is unavailable or forbidden.
166    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
188/// Keep the context bound even when output cannot safely be archived. The
189/// preview makes no lossless-recovery promise and contains no fabricated handle.
190pub 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
238/// Max chars for string value previews inside structural JSON summaries.
239const JSON_STRING_PREVIEW_CHARS: usize = 120;
240/// Max array sample elements shown in structural previews.
241const JSON_ARRAY_SAMPLE: usize = 3;
242/// Max recursion depth for nested value previews.
243const 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        // Explicit reads must always return content (no firewall digest, no
317        // reference stub) so the agent can edit against the lines.
318        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        // Env can override ephemeral; clear it for a deterministic test.
332        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)); // below threshold
337        assert!(!should_firewall("ctx_read", 5000, &cfg)); // not firewallable
338    }
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        // Plain gh is prose; a mention of a dataset tool is not an invocation.
351        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        // Opt out.
356        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        // 0 disables the cap entirely — nothing is ever blocked.
372        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        // Clearing the env cap only holds if no other test sets it meanwhile.
390        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        // Sets the env cap to 2048. Unlocked, that leaks into the sibling tests
401        // above, which assert the behaviour with *no* cap set.
402        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")); // head
424        assert!(digest.contains("line 200")); // tail
425        assert!(digest.contains("lines omitted"));
426        assert!(digest.contains("ctx_expand(id=\"abc123\")"));
427        assert!(digest.contains("json_keys=true"));
428        // The digest must be far smaller than the original.
429        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        // #1453: string fields now include a preview/value, not just char count.
464        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        // Short strings get their full value inline.
472        assert_eq!(parsed["root"]["fields"]["state"]["value"], "MERGED");
473        // Arrays include a sample of element shapes.
474        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        // #1453: number values are now included in the preview.
493        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        // Top-level object gets field previews.
513        assert_eq!(shape["type"], "object");
514        assert!(shape["fields"].is_object());
515        // Short string → full value inline.
516        assert_eq!(shape["fields"]["name"]["value"], "hello world");
517        // Number → value inline.
518        assert_eq!(shape["fields"]["count"]["value"], 42);
519        // Boolean → value inline.
520        assert_eq!(shape["fields"]["active"]["value"], true);
521        // Array → sample with items.
522        assert_eq!(shape["fields"]["tags"]["items"], 3);
523        assert_eq!(shape["fields"]["tags"]["sample"][0]["value"], "rust");
524        // Depth 2 object → no further recursion, just keys count.
525        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); // +multibyte …
541    }
542}