Skip to main content

lean_ctx/core/
compression_safety.rs

1use std::fmt;
2
3/// How aggressively a command's output may be compressed.
4#[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
23/// Maps a CLI command to its compression safety level and rationale.
24pub(crate) struct CommandSafety {
25    pub command: &'static str,
26    pub level: SafetyLevel,
27    pub description: &'static str,
28}
29
30/// Static lookup table of per-command compression safety levels.
31pub(crate) const COMMAND_SAFETY_TABLE: &[CommandSafety] = &[
32    // --- Verbatim: output passes through unchanged ---
33    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    // --- Minimal: light formatting, all critical data preserved ---
64    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    // --- Standard: structured compression, key info preserved ---
130    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    // --- Aggressive: heavy compression for verbose output ---
171    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
193/// Renders the full safety table as a human-readable report.
194pub 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    /// One executable fixture per table promise (#1894): `(table entry, command
275    /// run, raw output, strings the compressed output must still contain)`.
276    /// Verbatim entries need no needles — their output must come back unchanged.
277    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}