lean_ctx/core/
firewall.rs1use crate::core::config::Config;
13
14const HEAD_LINES: usize = 20;
15const TAIL_LINES: usize = 8;
16const LONG_LINE_HEAD_CHARS: usize = 800;
17const LONG_LINE_TAIL_CHARS: usize = 300;
18
19pub fn is_firewallable_tool(name: &str) -> bool {
22 matches!(
23 name,
24 "ctx_shell" | "ctx_execute" | "ctx_search" | "ctx_tree"
25 )
26}
27
28pub fn is_protected_read(name: &str) -> bool {
35 matches!(name, "ctx_read" | "ctx_multi_read" | "ctx_smart_read")
36}
37
38pub fn min_tokens(config: &Config) -> usize {
40 config.archive.ephemeral_min_tokens_effective()
41}
42
43pub fn should_firewall(tool: &str, output_tokens: usize, config: &Config) -> bool {
45 config.archive.ephemeral_effective()
46 && is_firewallable_tool(tool)
47 && output_tokens >= min_tokens(config)
48}
49
50pub fn summarize(full: &str, archive_id: &str, tool: &str, output_tokens: usize) -> String {
55 let chars = full.len();
56 let lines: Vec<&str> = full.lines().collect();
57 let line_count = lines.len();
58
59 let mut out = String::new();
60 out.push_str(&format!(
61 "[Firewalled {tool} output — {chars} chars, {output_tokens} tok, {line_count} lines stored out-of-band]\n"
62 ));
63
64 if line_count > HEAD_LINES + TAIL_LINES + 1 {
65 out.push_str("--- head ---\n");
66 out.push_str(&lines[..HEAD_LINES].join("\n"));
67 out.push_str(&format!(
68 "\n--- … {} lines omitted … ---\n",
69 line_count - HEAD_LINES - TAIL_LINES
70 ));
71 out.push_str("--- tail ---\n");
72 out.push_str(&lines[line_count - TAIL_LINES..].join("\n"));
73 out.push('\n');
74 } else {
75 let head_end = full.floor_char_boundary(LONG_LINE_HEAD_CHARS.min(chars));
77 out.push_str(&full[..head_end]);
78 if chars > LONG_LINE_HEAD_CHARS + LONG_LINE_TAIL_CHARS {
79 out.push_str("\n… (truncated) …\n");
80 let tail_start = full.floor_char_boundary(chars - LONG_LINE_TAIL_CHARS);
81 out.push_str(&full[tail_start..]);
82 out.push('\n');
83 }
84 }
85
86 out.push_str("--- retrieve full output ---\n");
87 out.push_str(&format!(
90 "Direct: read {} directly (no MCP)\n",
91 crate::core::archive::content_path_str(archive_id)
92 ));
93 out.push_str(&format!("Full: ctx_expand(id=\"{archive_id}\")\n"));
94 out.push_str(&format!(
95 "Range: ctx_expand(id=\"{archive_id}\", start_line=1, end_line=80)\n"
96 ));
97 out.push_str(&format!(
98 "Head: ctx_expand(id=\"{archive_id}\", head=120)\n"
99 ));
100 out.push_str(&format!(
101 "Search: ctx_expand(id=\"{archive_id}\", search=\"ERROR\")\n"
102 ));
103 out.push_str(&format!(
104 "JSON: ctx_expand(id=\"{archive_id}\", json_keys=true)"
105 ));
106 out
107}
108
109#[cfg(test)]
110mod tests {
111 use super::*;
112
113 #[test]
114 fn firewallable_tools_are_outputs_not_reads() {
115 assert!(is_firewallable_tool("ctx_shell"));
116 assert!(is_firewallable_tool("ctx_search"));
117 assert!(is_firewallable_tool("ctx_tree"));
118 assert!(is_firewallable_tool("ctx_execute"));
119 assert!(!is_firewallable_tool("ctx_read"));
120 assert!(!is_firewallable_tool("ctx_multi_read"));
121 assert!(!is_firewallable_tool("ctx_knowledge"));
122 }
123
124 #[test]
125 fn protected_reads_are_file_readers_and_never_firewallable() {
126 for read in ["ctx_read", "ctx_multi_read", "ctx_smart_read"] {
129 assert!(is_protected_read(read), "{read} must be a protected read");
130 assert!(
131 !is_firewallable_tool(read),
132 "{read} must never be firewallable"
133 );
134 }
135 assert!(!is_protected_read("ctx_shell"));
136 assert!(!is_protected_read("ctx_search"));
137 }
138
139 #[test]
140 fn should_firewall_respects_tool_and_threshold() {
141 let mut cfg = Config::default();
142 cfg.archive.enabled = true;
143 cfg.archive.ephemeral = true;
144 cfg.archive.ephemeral_min_tokens = 2000;
145 crate::test_env::remove_var("LEAN_CTX_EPHEMERAL");
147 crate::test_env::remove_var("LEAN_CTX_EPHEMERAL_MIN_TOKENS");
148
149 assert!(should_firewall("ctx_shell", 5000, &cfg));
150 assert!(!should_firewall("ctx_shell", 1000, &cfg)); assert!(!should_firewall("ctx_read", 5000, &cfg)); }
153
154 #[test]
155 fn summarize_includes_excerpt_stats_and_ref() {
156 let full = (1..=200)
157 .map(|i| format!("line {i}"))
158 .collect::<Vec<_>>()
159 .join("\n");
160 let digest = summarize(&full, "abc123", "ctx_shell", 1234);
161 assert!(digest.contains("Firewalled ctx_shell output"));
162 assert!(digest.contains("1234 tok"));
163 assert!(digest.contains("line 1")); assert!(digest.contains("line 200")); assert!(digest.contains("lines omitted"));
166 assert!(digest.contains("ctx_expand(id=\"abc123\")"));
167 assert!(digest.contains("json_keys=true"));
168 assert!(digest.len() < full.len());
170 }
171
172 #[test]
173 fn summarize_handles_single_giant_line() {
174 let full = "x".repeat(5000);
175 let digest = summarize(&full, "id9", "ctx_search", 1300);
176 assert!(digest.contains("Firewalled ctx_search output"));
177 assert!(digest.contains("truncated"));
178 assert!(digest.len() < full.len());
179 }
180}