1use std::fmt;
2
3#[derive(Debug, Clone, Copy, PartialEq, Eq)]
5pub(crate) enum SafetyLevel {
6 Verbatim,
7 Minimal,
8 Standard,
9 Aggressive,
10}
11
12impl fmt::Display for SafetyLevel {
13 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
14 match self {
15 SafetyLevel::Verbatim => write!(f, "verbatim"),
16 SafetyLevel::Minimal => write!(f, "minimal"),
17 SafetyLevel::Standard => write!(f, "standard"),
18 SafetyLevel::Aggressive => write!(f, "aggressive"),
19 }
20 }
21}
22
23pub(crate) struct CommandSafety {
25 pub command: &'static str,
26 pub level: SafetyLevel,
27 pub description: &'static str,
28}
29
30pub(crate) const COMMAND_SAFETY_TABLE: &[CommandSafety] = &[
32 CommandSafety {
34 command: "df",
35 level: SafetyLevel::Verbatim,
36 description: "Disk usage — root filesystem must never be hidden",
37 },
38 CommandSafety {
39 command: "git stash",
40 level: SafetyLevel::Verbatim,
41 description: "Stash save/pop/list output preserved verbatim",
42 },
43 CommandSafety {
44 command: "ls",
45 level: SafetyLevel::Verbatim,
46 description: "All files shown including .env, dotfiles",
47 },
48 CommandSafety {
49 command: "find",
50 level: SafetyLevel::Verbatim,
51 description: "Full absolute paths preserved",
52 },
53 CommandSafety {
54 command: "wc",
55 level: SafetyLevel::Verbatim,
56 description: "Pipe/stdin input handled correctly",
57 },
58 CommandSafety {
59 command: "env/printenv",
60 level: SafetyLevel::Verbatim,
61 description: "Environment variables preserved (values filtered)",
62 },
63 CommandSafety {
65 command: "git status",
66 level: SafetyLevel::Minimal,
67 description: "Branch/ahead count plus every staged, unstaged, unmerged and untracked path; hints dropped",
68 },
69 CommandSafety {
70 command: "git diff",
71 level: SafetyLevel::Minimal,
72 description: "All +/- lines preserved, only index headers and excess context trimmed",
73 },
74 CommandSafety {
75 command: "git log",
76 level: SafetyLevel::Minimal,
77 description: "Up to 50 entries, respects --max-count/-n, shows truncation notice",
78 },
79 CommandSafety {
80 command: "git blame",
81 level: SafetyLevel::Minimal,
82 description: "Verbatim up to 100 lines, then author/line-range summary",
83 },
84 CommandSafety {
85 command: "docker ps",
86 level: SafetyLevel::Minimal,
87 description: "Header-parsed columns; (unhealthy), Exited status always preserved",
88 },
89 CommandSafety {
90 command: "grep/rg",
91 level: SafetyLevel::Minimal,
92 description: "Verbatim up to 100 lines, then grouped by file with line numbers",
93 },
94 CommandSafety {
95 command: "ruff check",
96 level: SafetyLevel::Minimal,
97 description: "Verbatim up to 30 issues (file:line:col preserved), then summary",
98 },
99 CommandSafety {
100 command: "npm audit",
101 level: SafetyLevel::Minimal,
102 description: "CVE IDs, severity, package names, fix recommendations preserved",
103 },
104 CommandSafety {
105 command: "pip list",
106 level: SafetyLevel::Minimal,
107 description: "All packages shown (no truncation)",
108 },
109 CommandSafety {
110 command: "pip uninstall",
111 level: SafetyLevel::Minimal,
112 description: "All removed package names listed",
113 },
114 CommandSafety {
115 command: "pytest",
116 level: SafetyLevel::Minimal,
117 description: "passed/failed/skipped/xfailed/xpassed/warnings all counted",
118 },
119 CommandSafety {
120 command: "docker logs",
121 level: SafetyLevel::Minimal,
122 description: "Dedup + safety-needle scan preserves FATAL/ERROR/CRITICAL lines",
123 },
124 CommandSafety {
125 command: "cat (logs)",
126 level: SafetyLevel::Minimal,
127 description: "Log dedup preserves all severity levels including CRITICAL",
128 },
129 CommandSafety {
131 command: "cargo build/test",
132 level: SafetyLevel::Standard,
133 description: "Errors and warnings preserved, progress lines removed",
134 },
135 CommandSafety {
136 command: "npm install",
137 level: SafetyLevel::Standard,
138 description: "Package count, vulnerability summary and `audit fix` hint preserved",
139 },
140 CommandSafety {
141 command: "docker build",
142 level: SafetyLevel::Standard,
143 description: "Step count, errors preserved, intermediate output removed",
144 },
145 CommandSafety {
146 command: "git commit",
147 level: SafetyLevel::Standard,
148 description: "Branch, hash, change stats preserved; hook output kept",
149 },
150 CommandSafety {
151 command: "git push/pull",
152 level: SafetyLevel::Standard,
153 description: "Remote, branch, conflict info preserved",
154 },
155 CommandSafety {
156 command: "eslint/biome",
157 level: SafetyLevel::Standard,
158 description: "Error/warning counts, file references preserved",
159 },
160 CommandSafety {
161 command: "tsc",
162 level: SafetyLevel::Standard,
163 description: "Type errors with full path, line:col, code and complete message preserved",
164 },
165 CommandSafety {
166 command: "curl (JSON)",
167 level: SafetyLevel::Standard,
168 description: "Schema extraction; sensitive keys (token/password/secret) REDACTED",
169 },
170 CommandSafety {
172 command: "kubectl describe",
173 level: SafetyLevel::Aggressive,
174 description: "Key fields extracted, verbose event history trimmed",
175 },
176 CommandSafety {
177 command: "aws CLI",
178 level: SafetyLevel::Aggressive,
179 description: "JSON schema extraction for large API responses",
180 },
181 CommandSafety {
182 command: "terraform plan",
183 level: SafetyLevel::Aggressive,
184 description: "Resource changes summarized, full plan truncated",
185 },
186 CommandSafety {
187 command: "docker images",
188 level: SafetyLevel::Aggressive,
189 description: "Compressed to name:tag (size) list",
190 },
191];
192
193pub fn format_safety_table() -> String {
195 let mut out = String::new();
196 out.push_str("Command Compression Safety Levels\n");
197 out.push_str(&"=".repeat(72));
198 out.push('\n');
199 out.push('\n');
200
201 for level in &[
202 SafetyLevel::Verbatim,
203 SafetyLevel::Minimal,
204 SafetyLevel::Standard,
205 SafetyLevel::Aggressive,
206 ] {
207 let label = match level {
208 SafetyLevel::Verbatim => "VERBATIM — output passes through unchanged",
209 SafetyLevel::Minimal => {
210 "MINIMAL — light formatting, all safety-critical data preserved"
211 }
212 SafetyLevel::Standard => "STANDARD — structured compression, key info preserved",
213 SafetyLevel::Aggressive => "AGGRESSIVE — heavy compression for verbose output",
214 };
215 out.push_str(&format!("[{label}]\n"));
216
217 for entry in COMMAND_SAFETY_TABLE.iter().filter(|e| e.level == *level) {
218 out.push_str(&format!(" {:<20} {}\n", entry.command, entry.description));
219 }
220 out.push('\n');
221 }
222
223 out.push_str("Safety features active on ALL commands:\n");
224 out.push_str(" • Safety-needle scan: CRITICAL/FATAL/panic/ERROR/CVE lines preserved\n");
225 out.push_str(" • Safeguard ratio: >95% compression on >100 tokens triggers fallback\n");
226 out.push_str(" • Auth flow detection: login/OAuth prompts never compressed\n");
227 out.push_str(" • Minimum token threshold: outputs <50 tokens pass through unchanged\n");
228 out.push('\n');
229 out.push_str(
230 "Use `lean-ctx raw \"command\"` to skip compression for one command — output \
231 passes through unchanged. This affects compression only; the shell allowlist \
232 and path jail still apply.\n",
233 );
234
235 out
236}
237
238#[cfg(test)]
239mod tests {
240 use super::*;
241
242 #[test]
243 fn safety_table_has_entries() {
244 assert!(COMMAND_SAFETY_TABLE.len() > 20);
245 }
246
247 #[test]
248 fn format_table_contains_all_levels() {
249 let table = format_safety_table();
250 assert!(table.contains("VERBATIM"));
251 assert!(table.contains("MINIMAL"));
252 assert!(table.contains("STANDARD"));
253 assert!(table.contains("AGGRESSIVE"));
254 }
255
256 #[test]
257 fn df_is_verbatim() {
258 let df = COMMAND_SAFETY_TABLE
259 .iter()
260 .find(|e| e.command == "df")
261 .unwrap();
262 assert_eq!(df.level, SafetyLevel::Verbatim);
263 }
264
265 #[test]
266 fn git_diff_is_minimal() {
267 let diff = COMMAND_SAFETY_TABLE
268 .iter()
269 .find(|e| e.command == "git diff")
270 .unwrap();
271 assert_eq!(diff.level, SafetyLevel::Minimal);
272 }
273
274 const CONTRACT_FIXTURES: &[(&str, &str, &str, &[&str])] = &[
278 (
279 "npm install",
280 "npm install",
281 "\nadded 847 packages, and audited 848 packages in 12s\n\n142 packages are looking for funding\n run `npm fund` for details\n\n3 vulnerabilities (1 moderate, 2 high)\n\nTo address all issues, run:\n npm audit fix\n\nRun `npm audit` for details.\n",
282 &[
283 "847",
284 "3 vulnerabilities (1 moderate, 2 high)",
285 "npm audit fix",
286 ],
287 ),
288 (
289 "git status",
290 "git status",
291 "On branch main\nYour branch is ahead of 'origin/main' by 1 commit.\n\nUnmerged paths:\n\tboth modified: c.rs\n\nChanges to be committed:\n\tnew file: a.rs\n\nChanges not staged for commit:\n (use \"git add <file>...\" to update what will be committed)\n\tmodified: b.rs\n\tdeleted: gone.rs\n\nUntracked files:\n\tu.rs\n\nno changes added to commit (use \"git add\" and/or \"git commit -a\")\n",
292 &["main", "c.rs", "a.rs", "b.rs", "gone.rs", "u.rs"],
293 ),
294 (
295 "git diff",
296 "git diff",
297 "diff --git a/src/a.rs b/src/a.rs\nindex 1111111..2222222 100644\n--- a/src/a.rs\n+++ b/src/a.rs\n@@ -1,3 +1,3 @@\n fn a() {\n- old();\n+ new();\n }\n",
298 &["src/a.rs", "- old();", "+ new();"],
299 ),
300 (
301 "npm audit",
302 "npm audit",
303 "# npm audit report\n\nlodash <4.17.21\nSeverity: high\nCommand Injection in lodash - https://github.com/advisories/GHSA-35jh-r3h4-6jhm\nfix available via `npm audit fix`\nnode_modules/lodash\n\n1 high severity vulnerability\n\nTo address all issues, run:\n npm audit fix\n",
304 &["lodash", "high", "GHSA-35jh-r3h4-6jhm", "npm audit fix"],
305 ),
306 (
307 "pytest",
308 "pytest",
309 "============================= test session starts ==============================\ncollected 9 items\n\ntests/test_a.py ..F.sxX.. [100%]\n\n=========================== short test summary info ============================\nFAILED tests/test_a.py::test_c - AssertionError\n===== 1 failed, 5 passed, 1 skipped, 1 xfailed, 1 xpassed, 2 warnings in 0.12s =====\n",
310 &[
311 "1 failed",
312 "5 passed",
313 "1 skipped",
314 "1 xfailed",
315 "1 xpassed",
316 "2 warnings",
317 ],
318 ),
319 (
320 "tsc",
321 "tsc",
322 "src/a.ts(3,7): error TS2322: Type 'string' is not assignable to type 'number'.\nsrc/b.ts(10,1): error TS2304: Cannot find name 'foo'.\n\nFound 2 errors in 2 files.\n",
323 &[
324 "src/a.ts",
325 "3:7",
326 "TS2322",
327 "not assignable to type 'number'",
328 "src/b.ts",
329 "10:1",
330 "TS2304",
331 ],
332 ),
333 ];
334
335 fn entry(command: &str) -> &'static CommandSafety {
336 COMMAND_SAFETY_TABLE
337 .iter()
338 .find(|e| e.command == command)
339 .unwrap_or_else(|| panic!("fixture names unknown table entry {command:?}"))
340 }
341
342 #[test]
343 fn table_promises_hold_for_pattern_output() {
344 for (table_cmd, run, raw, needles) in CONTRACT_FIXTURES {
345 let level = entry(table_cmd).level;
346 let compressed = crate::core::patterns::compress_output(run, raw);
347 if level == SafetyLevel::Verbatim {
348 assert!(
349 compressed.as_deref().is_none_or(|c| c.trim() == raw.trim()),
350 "{table_cmd}: table says verbatim, pattern rewrote it:\n{compressed:?}"
351 );
352 continue;
353 }
354 let out = compressed.unwrap_or_else(|| raw.to_string());
355 for needle in *needles {
356 assert!(
357 out.contains(needle),
358 "{table_cmd}: table promise broken, {needle:?} missing from:\n{out}"
359 );
360 }
361 }
362 }
363}