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    matches!(name, "ctx_read" | "ctx_multi_read" | "ctx_smart_read")
38}
39
40/// Effective minimum token count before firewalling (config + env override).
41pub 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 const DEFAULT_RAW_COMMANDS: &[&str] = &["sqlite3", "psql", "duckdb", "jq"];
58
59/// Whether `command` runs a dataset program in any of its pipeline segments.
60/// `gh` counts only with `--json`/`--jq` — plain `gh` output is prose and
61/// compresses fine.
62pub fn is_raw_command(command: &str, config: &Config) -> bool {
63    command
64        .split(['|', ';', '&', '\n'])
65        .any(|seg| match seg.split_whitespace().next() {
66            // ponytail: first word per segment, so `FOO=1 sqlite3 …` and
67            // `$(sqlite3 …)` are missed — pass raw=true for those.
68            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
77/// Whether an explicitly requested `ctx_shell(inline=true)` result fits the
78/// configured verbatim-delivery cap.
79pub 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
83/// Build the inline digest that replaces a firewalled output. Deterministic (no LLM):
84/// a head/tail excerpt for multi-line output, or a char-bounded excerpt for output with
85/// few but very long lines (e.g. a single giant JSON line), followed by drilldown
86/// instructions keyed on `archive_id`.
87pub 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        // Few lines but large (e.g. one giant minified JSON line): char-bounded excerpt.
113        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    // Non-MCP route first: the verbatim blob is a real file any tool can read,
125    // for agents/orgs where MCP is unavailable or forbidden.
126    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        // Explicit reads must always return content (no firewall digest, no
210        // reference stub) so the agent can edit against the lines.
211        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        // Env can override ephemeral; clear it for a deterministic test.
230        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)); // below threshold
235        assert!(!should_firewall("ctx_read", 5000, &cfg)); // not firewallable
236    }
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        // Plain gh is prose; a mention of a dataset tool is not an invocation.
249        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        // Opt out.
254        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        // Clearing the env cap only holds if no other test sets it meanwhile.
273        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        // Sets the env cap to 2048. Unlocked, that leaks into the sibling tests
284        // above, which assert the behaviour with *no* cap set.
285        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")); // head
307        assert!(digest.contains("line 200")); // tail
308        assert!(digest.contains("lines omitted"));
309        assert!(digest.contains("ctx_expand(id=\"abc123\")"));
310        assert!(digest.contains("json_keys=true"));
311        // The digest must be far smaller than the original.
312        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}