1use crate::tools::command_args::{
2 command_words_after_environment_prefix, has_unsafe_readonly_options, shell_command_text_with_args,
3};
4use crate::tools::output_spooler::SpooledOutputReference;
5use serde_json::Value;
6use std::path::Path;
7
8const READONLY_UNIFIED_EXEC_COMMANDS: &[&str] = &[
16 "rg", "ls", "cat", "diff", "find", "wc", "grep", "egrep", "fgrep", "head", "tail", "sort", "uniq", "bat", "sed",
17 "awk", "cut", "tr", "ast-grep", "sg", "echo", "pwd", "printf", "true", "false", "test", "cd", "fd", "tree",
18 "which", "stat", "file", "du", "df", "realpath", "basename", "dirname", "nl", "column", "jq", "date", "whoami",
19 "uname",
20];
21
22pub fn is_readonly_base_command(command: &str) -> bool {
23 READONLY_UNIFIED_EXEC_COMMANDS.contains(&command)
24}
25
26fn is_readonly_subcommand(first: &str, second: Option<&str>, third: Option<&str>) -> bool {
36 match first {
37 "git" => matches!(
38 second,
39 Some(
40 "status"
41 | "log"
42 | "diff"
43 | "show"
44 | "blame"
45 | "ls-files"
46 | "rev-parse"
47 | "describe"
48 | "shortlog"
49 | "grep"
50 | "rev-list"
51 | "ls-tree"
52 | "cat-file"
53 | "diff-tree"
54 | "merge-base"
55 | "range-diff"
56 | "whatchanged"
57 | "count-objects"
58 | "var"
59 | "version"
60 | "help"
61 )
62 ),
63 "cargo" => match second {
64 Some("check" | "test" | "metadata" | "tree" | "clippy") => true,
65 Some("nextest") => matches!(third, Some("run" | "list")),
66 _ => false,
67 },
68 "npm" | "pnpm" | "yarn" => match second {
69 Some("test") => true,
70 Some("run") => matches!(third, Some("test")),
71 _ => false,
72 },
73 _ => false,
74 }
75}
76
77fn is_readonly_flag_shaped_git_subcommand(
95 subcommand: Option<&str>,
96 operand: Option<&str>,
97 command_words: &[String],
98 sub_index: usize,
99) -> bool {
100 if subcommand == Some("branch") {
101 let mutates = command_words.iter().skip(sub_index + 1).any(|word| {
102 matches!(word.as_str(), "--set-upstream" | "--unset-upstream" | "--edit-description")
103 || word.starts_with("--set-upstream-to")
104 || word.starts_with("-u")
105 || word.starts_with("-m")
106 || word.starts_with("-M")
107 || word.starts_with("-c")
108 || word.starts_with("-C")
109 });
110 if mutates {
111 return false;
112 }
113 }
114
115 matches!(
116 (subcommand, operand),
117 (Some("tag") | Some("branch"), None)
118 | (Some("reflog"), None | Some("show"))
119 | (Some("remote"), None | Some("show") | Some("get-url"))
120 | (Some("stash"), Some("list") | Some("show"))
121 | (Some("worktree"), Some("list"))
122 )
123}
124
125fn is_version_or_help_probe(command_words: &[String]) -> bool {
135 fn is_probe_flag(word: &str) -> bool {
136 matches!(word, "-h" | "--help" | "-V" | "--version")
137 }
138
139 let mut flags = command_words.iter().skip(1);
140 flags.next().is_some_and(|word| is_probe_flag(word)) && flags.all(|word| is_probe_flag(word))
141}
142
143pub(crate) fn static_shell_command_words(command: &str) -> Option<Vec<Vec<String>>> {
151 let sanitized = sanitize_static_shell_command(command)?;
152 parse_shell_command_words(&sanitized)
153}
154
155pub(crate) fn static_shell_command_words_with_output_plumbing(command: &str) -> Option<Vec<Vec<String>>> {
161 if !crate::command_safety::shell_parser::has_only_output_redirections(command) {
162 return None;
163 }
164 parse_shell_command_words(command)
165}
166
167pub(super) fn parse_shell_command_words(command: &str) -> Option<Vec<Vec<String>>> {
171 let commands = crate::command_safety::shell_parser::parse_shell_commands_tree_sitter(command).ok()?;
172 if commands.is_empty() {
173 return None;
174 }
175
176 commands
177 .into_iter()
178 .map(|command| {
179 let mut words = Vec::new();
180 for word in command {
181 let tokens = shell_words::split(&word).ok()?;
182 words.extend(tokens);
183 }
184 (!words.is_empty()).then_some(words)
185 })
186 .collect()
187}
188
189fn readonly_command_head(words: &[String]) -> Option<(&[String], String)> {
199 let command_words = command_words_after_environment_prefix(words);
200 let first = command_words
201 .first()
202 .and_then(|word| Path::new(word).file_name())
203 .and_then(|name| name.to_str())
204 .map(str::to_ascii_lowercase)?;
205 if has_unsafe_readonly_options(words) {
206 return None;
207 }
208 Some((command_words, first))
209}
210
211pub(crate) fn command_words_are_readonly(words: &[String]) -> bool {
215 let Some((command_words, first)) = readonly_command_head(words) else {
216 return false;
217 };
218 if first == "command" {
219 return matches!(command_words.get(1).map(String::as_str), Some("-v" | "-V"))
220 && command_words.len() > 2
221 && command_words
222 .iter()
223 .skip(2)
224 .all(|word| !word.starts_with('-') && !word.is_empty());
225 }
226
227 if is_readonly_base_command(&first)
228 || is_version_or_help_probe(command_words)
229 || is_known_readonly_dry_run(command_words, &first)
230 {
231 return true;
232 }
233
234 let lowered = command_words
235 .iter()
236 .filter(|word| !word.starts_with('-') && !word.contains('='))
237 .take(3)
238 .map(|word| word.to_ascii_lowercase())
239 .collect::<Vec<_>>();
240 if first != "git" {
241 let (second, third) = (lowered.get(1).map(String::as_str), lowered.get(2).map(String::as_str));
242 return is_readonly_subcommand(&first, second, third);
243 }
244
245 let Some((sub_index, operand)) = git_subcommand_and_operand(command_words) else {
246 return false;
247 };
248 let subcommand = command_words.get(sub_index).map(String::as_str);
249 is_readonly_subcommand("git", subcommand, None)
250 || is_readonly_flag_shaped_git_subcommand(subcommand, operand, command_words, sub_index)
251}
252
253fn git_subcommand_and_operand(command_words: &[String]) -> Option<(usize, Option<&str>)> {
272 const GLOBAL_OPTIONS_WITH_VALUE: &[&str] = &[
273 "-C",
274 "-c",
275 "--git-dir",
276 "--work-tree",
277 "--namespace",
278 "--super-prefix",
279 "--exec-path",
280 "--config-env",
281 "--shallow-file",
282 "--attr-source",
283 ];
284 const GLOBAL_OPTIONS_FLAG_ONLY: &[&str] = &[
285 "-p",
286 "--paginate",
287 "--no-pager",
288 "--bare",
289 "--literal-pathspecs",
290 "--no-replace-objects",
291 "--no-optional-locks",
292 "--end-of-options",
293 "--version",
294 "--help",
295 "--html-path",
296 ];
297
298 let mut index = 1;
299 while let Some(word) = command_words.get(index) {
300 if !word.starts_with('-') {
301 let operand = command_words
302 .iter()
303 .skip(index + 1)
304 .find(|candidate| !candidate.starts_with('-'))
305 .map(String::as_str);
306 return Some((index, operand));
307 }
308 if GLOBAL_OPTIONS_WITH_VALUE.contains(&word.as_str()) {
309 index += 2; continue;
311 }
312 if GLOBAL_OPTIONS_FLAG_ONLY.contains(&word.as_str()) {
313 index += 1;
314 continue;
315 }
316 return None;
320 }
321 None
322}
323
324fn command_words_are_parallel_safe(words: &[String]) -> bool {
329 let Some((command_words, first)) = readonly_command_head(words) else {
330 return false;
331 };
332
333 if is_readonly_base_command(&first) {
334 return true;
335 }
336 if is_version_or_help_probe(command_words) {
338 return true;
339 }
340 if first != "git" {
341 return false;
342 }
343
344 let subcommand = command_words
345 .iter()
346 .skip(1)
347 .find(|word| !word.starts_with('-') && !word.contains('='))
348 .map(|word| word.to_ascii_lowercase());
349 is_readonly_subcommand("git", subcommand.as_deref(), None)
350}
351
352fn is_known_readonly_dry_run(words: &[String], program: &str) -> bool {
353 if !matches!(program, "npm" | "pnpm" | "yarn") {
354 return false;
355 }
356
357 let Some(subcommand) = words.iter().skip(1).find(|word| !word.starts_with('-') && !word.contains('=')) else {
358 return false;
359 };
360
361 subcommand == "install" && words.iter().any(|word| word == "--dry-run")
362}
363
364fn matches_at(chars: &[char], index: usize, pattern: &[char]) -> bool {
365 chars
366 .get(index..index.saturating_add(pattern.len()))
367 .is_some_and(|candidate| candidate == pattern)
368}
369
370fn sanitize_static_shell_command(command: &str) -> Option<String> {
374 if crate::command_safety::shell_parser::contains_dynamic_shell_syntax(command) {
375 return None;
376 }
377
378 let chars = command.chars().collect::<Vec<_>>();
379 let mut sanitized = String::with_capacity(command.len());
380 let mut index = 0;
381 let mut in_single_quote = false;
382 let mut in_double_quote = false;
383
384 while index < chars.len() {
385 let character = chars[index];
386 if character == '\'' && !in_double_quote {
387 in_single_quote = !in_single_quote;
388 sanitized.push(character);
389 index += 1;
390 continue;
391 }
392 if character == '"' && !in_single_quote {
393 in_double_quote = !in_double_quote;
394 sanitized.push(character);
395 index += 1;
396 continue;
397 }
398 if in_single_quote || in_double_quote {
399 sanitized.push(character);
400 index += 1;
401 continue;
402 }
403
404 let preceded_by_boundary = index == 0
405 || chars
406 .get(index.saturating_sub(1))
407 .is_some_and(|previous| previous.is_whitespace() || matches!(previous, '|' | '&' | ';'));
408 if preceded_by_boundary && matches_at(&chars, index, &['2', '>', '&', '1']) {
409 index += 4;
410 continue;
411 }
412 if cfg!(unix)
413 && preceded_by_boundary
414 && matches_at(&chars, index, &['2', '>', '/', 'd', 'e', 'v', '/', 'n', 'u', 'l', 'l'])
415 {
416 index += 11;
417 continue;
418 }
419
420 match character {
421 '<' | '>' => return None,
422 '&' if matches_at(&chars, index, &['&', '&']) => {
423 sanitized.push('&');
424 sanitized.push('&');
425 index += 2;
426 }
427 '&' => return None,
428 _ => {
429 sanitized.push(character);
430 index += 1;
431 }
432 }
433 }
434
435 (!sanitized.trim().is_empty()).then_some(sanitized)
436}
437
438fn parsed_session_commands(args: &Value) -> Option<Vec<Vec<String>>> {
446 let raw = shell_command_text_with_args(args)?;
447 static_shell_command_words(&raw)
448}
449
450fn all_commands_readonly(commands: &[Vec<String>]) -> bool {
455 commands.iter().all(|words| command_words_are_readonly(words))
456}
457
458pub fn is_readonly_command_session_command(args: &Value) -> bool {
459 let Some(commands) = parsed_session_commands(args) else {
460 return false;
461 };
462
463 all_commands_readonly(&commands)
464}
465
466pub(crate) fn is_parallel_safe_command_session_command(args: &Value) -> bool {
469 if args.get("session_id").is_some()
470 || args.get("chars").is_some()
471 || args.get("input").is_some()
472 || args.get("tty").and_then(Value::as_bool) == Some(true)
473 || args
474 .get("sandbox_permissions")
475 .and_then(Value::as_str)
476 .is_some_and(|value| value != "use_default")
477 {
478 return false;
479 }
480 let Some(commands) = parsed_session_commands(args) else {
481 return false;
482 };
483 commands.len() == 1 && command_words_are_parallel_safe(&commands[0])
484}
485
486pub fn is_spool_file_read_command(tool_name: &str, args: &Value) -> bool {
490 if super::classify::canonical_command_session_tool_name(tool_name).is_none() {
491 return false;
492 }
493
494 let Some(commands) = parsed_session_commands(args) else {
499 return false;
500 };
501 if !all_commands_readonly(&commands) {
502 return false;
503 }
504 commands
505 .iter()
506 .flatten()
507 .any(|word| SpooledOutputReference::recognizes_path(Path::new(word)))
508}
509
510#[cfg(test)]
511mod tests {
512 use super::*;
513 use serde_json::json;
514
515 fn run_cmd(command: &str) -> Value {
516 json!({"action": "run", "command": command})
517 }
518
519 #[test]
520 fn command_availability_probes_do_not_admit_execution() {
521 for command in ["command -v npx", "command -V python3 cargo", "command -v npx || true"] {
522 assert!(is_readonly_command_session_command(&run_cmd(command)), "{command}");
523 }
524 for command in [
525 "command rm file",
526 "command -p rm file",
527 "command -v",
528 "command -v -- rm",
529 "command -v $(touch file)",
530 ] {
531 assert!(!is_readonly_command_session_command(&run_cmd(command)), "{command}");
532 }
533 }
534
535 #[test]
536 fn and_chain_allows_readonly_segments() {
537 assert!(is_readonly_command_session_command(&run_cmd("ls -la && echo '---' && ls -la crates/")));
538 assert!(is_readonly_command_session_command(&run_cmd("pwd && ls src/")));
539 assert!(is_readonly_command_session_command(&run_cmd("cat foo.txt && grep bar")));
540 }
541
542 #[test]
543 fn compound_inspection_commands_share_the_readonly_policy() {
544 for command in [
545 "cat README.md; rg -n '^#' README.md",
546 "git diff --stat; find docs -maxdepth 2 -type f | sort | head -40",
547 "cat README.md\nrg -n '^version' Cargo.toml",
548 ] {
549 assert!(is_readonly_command_session_command(&run_cmd(command)), "expected readonly command: {command}");
550 }
551 }
552
553 #[test]
554 fn checkpoint_style_inspection_with_escaped_regex_is_readonly() {
555 let command = r#"sed -n '180,285p' src/main.rs; sed -n '60,285p' src/startup/mod.rs; sed -n '1,220p' src/main_helpers/bootstrap.rs; rg -n "\[profile|lto|codegen-units|strip" Cargo.toml"#;
556
557 assert!(is_readonly_command_session_command(&run_cmd(command)));
558 }
559
560 #[test]
561 fn and_chain_rejects_destructive_segments() {
562 assert!(!is_readonly_command_session_command(&run_cmd("ls -la && rm foo.txt")));
563 assert!(!is_readonly_command_session_command(&run_cmd("cat x && mv a b")));
564 assert!(!is_readonly_command_session_command(&run_cmd("true && cp a b")));
565 }
566
567 #[test]
568 fn and_chain_rejects_non_allowlisted_segments() {
569 assert!(!is_readonly_command_session_command(&run_cmd("ls -la && python script.py")));
570 assert!(!is_readonly_command_session_command(&run_cmd("ls -la && cargo build")));
571 assert!(!is_readonly_command_session_command(&run_cmd("python3 mutate.py --dry-run")));
572 assert!(!is_readonly_command_session_command(&run_cmd("npm install --dry-run && rm output")));
573 }
574
575 #[test]
576 fn and_chain_allows_pipeline_within_segment() {
577 assert!(is_readonly_command_session_command(&run_cmd("ls -la | head -5 && echo done")));
578 }
579
580 #[test]
581 fn and_chain_single_command_passes() {
582 assert!(is_readonly_command_session_command(&run_cmd("ls -la")));
583 assert!(is_readonly_command_session_command(&run_cmd("echo hi")));
584 }
585
586 #[test]
587 fn readonly_command_session_allows_and_chain() {
588 assert!(is_readonly_command_session_command(&run_cmd("ls -la /path/ && echo '---' && ls -la /path/crates/")));
590 }
591
592 #[test]
593 fn readonly_command_session_rejects_destructive_and_chain() {
594 assert!(!is_readonly_command_session_command(&run_cmd("ls -la && rm foo.txt")));
595 }
596
597 #[test]
598 fn awk_range_print_is_readonly() {
599 for command in [
602 "awk 'NR>=40 && NR<=140' README.md",
603 "awk 'NR>=297 && NR<=312' README.md",
604 "awk 'NR>=291 && NR<=296' README.md | cut -c1-150",
605 "awk -F: '{print $1}' README.md",
606 "awk -F, '{print $2}' data.csv",
607 "awk -v limit=10 'NR<=limit' README.md",
608 "awk -- '{print $1}' README.md",
609 "awk -- '{print $1}' -- -weird",
610 "awk 'BEGIN{print systime()}' README.md",
613 "/usr/bin/awk 'NR>=1 && NR<=5' README.md",
614 "awk 'NR>=1 && NR<=5' README.md | sort",
615 "awk '$3>100' README.md",
618 "awk '{if(p2==0 && i>1) print}' README.md",
619 "awk '{if(NR>1 && $2>10) print $1}' README.md",
620 "awk 'NR>=65 && NR<=71 {n=length($0); print n}' README.md",
621 "awk '{print (a>b)}' README.md",
622 ] {
623 assert!(is_readonly_command_session_command(&run_cmd(command)), "expected readonly command: {command}");
624 }
625 }
626
627 #[test]
628 fn awk_write_primitives_stay_mutating() {
629 for command in [
630 "awk '{print > \"out.txt\"}' README.md",
631 "awk '{print >> \"out.txt\"}' README.md",
632 "awk '{print x > \"out.txt\"}' README.md",
633 "awk 'BEGIN { # (\n print \"written\" > \"out.txt\" }'",
634 "awk 'BEGIN { # [\n printf \"written\" > \"out.txt\" }'",
635 "awk 'BEGIN { # ( [ {\n print \"written\" > \"out.txt\" }'",
636 "awk '{print | \"sort\"}' README.md",
637 "awk '\"sort\" | getline line' README.md",
638 "awk 'BEGIN{system(\"touch out\")}' README.md",
639 "awk 'BEGIN{SYSTEM (\"id\")}' README.md",
640 "awk 'BEGIN{System(\"id\")}' README.md",
641 "awk 'BEGIN{system\t(\"id\")}' README.md",
642 "awk -v f=system 'BEGIN{@f(\"id\")}' README.md",
645 "awk 'BEGIN{@s(\"id\")}' README.md",
646 "awk '@include \"x.awk\"' README.md",
647 "awk '@load \"ext\"' README.md",
648 "awk '{print \"a@b\"}' README.md",
649 "awk '/error|warning/' README.md",
652 "awk -i inplace '{print}' README.md",
653 "awk -f program.awk README.md",
654 "awk --source '{print}' README.md",
655 "awk -l injail '{print}' README.md",
656 "awk --profile '{print}' README.md",
657 "awk -W dump-variables '{print}' README.md",
658 "awk --posix '{print}' README.md",
659 "awk -F:",
660 "awk",
661 "awk -v",
662 "awk 'NR>=1' README.md > out.txt",
664 ] {
665 assert!(!is_readonly_command_session_command(&run_cmd(command)), "expected mutating command: {command}");
666 }
667 }
668
669 #[test]
670 fn awk_string_literal_operators_are_readonly() {
671 for command in [
674 r#"awk '{n=index(rest,"|"); print n}' README.md"#,
675 r#"awk 'index($0,">")' README.md"#,
676 r#"awk '$3>=100 && $4||$5 {print $1}' README.md"#,
679 r#"awk '{print $1 / $2}' README.md"#,
681 "awk '{ # ( [ > system(\n if (NR>1) print $1 }' README.md",
682 r#"awk 'NR>=208 && NR<=212 {line=$0; body=substr(line,1,length(line)-1); n=0; while (body ~ / \$/) { body=substr(body,1,length(body)-1); n++ }} # find guide start after label cell
685rest=substr(line,3); g=index(rest,"|")+1; guide=substr(rest,g+2); gp=0; gg=guide; while (gg ~ / \$/) { gg=substr(gg,1,length(gg)-1); gp++ } print "%d: linelen=%d labelcell=%s pad_before_final_pipe=%d guide_pad=%d\n", NR, length(line), substr(line,3,20), n, gp }' README.md"#,
686 ] {
687 assert!(is_readonly_command_session_command(&run_cmd(command)), "expected readonly command: {command}");
688 }
689 }
690
691 #[test]
692 fn awk_regex_with_quote_cannot_hide_pipe() {
693 for command in [
699 r#"awk '/a"b/ {print | "sort"} /c"d/' README.md"#,
700 r#"awk '/a|b/ {print}' README.md"#,
702 ] {
703 assert!(!is_readonly_command_session_command(&run_cmd(command)), "expected mutating command: {command}");
704 }
705 }
706
707 #[test]
708 fn cd_prefixed_exploration_is_readonly() {
709 assert!(is_readonly_command_session_command(&run_cmd("cd /repo && sed -n '440,520p' Cargo.toml")));
712 assert!(is_readonly_command_session_command(&run_cmd(
713 "cd /repo && ls -la && echo '---' && rg --files -g 'Cargo.toml' | sed -n '1,120p'"
714 )));
715 assert!(!is_readonly_command_session_command(&run_cmd("cd /repo && cargo build")));
717 assert!(!is_readonly_command_session_command(&run_cmd("cd /repo && rm -rf target")));
718 }
719
720 #[test]
721 fn git_readonly_subcommands_allowed_in_chains_and_pipelines() {
722 assert!(is_readonly_command_session_command(&run_cmd("git log --oneline | head -20")));
723 assert!(is_readonly_command_session_command(&run_cmd("cd /repo && git diff")));
724 assert!(is_readonly_command_session_command(&run_cmd("git show HEAD")));
725 assert!(is_readonly_command_session_command(&run_cmd("git blame src/main.rs | head")));
726 assert!(!is_readonly_command_session_command(&run_cmd("git checkout main")));
728 assert!(!is_readonly_command_session_command(&run_cmd("cd /repo && git push")));
729 assert!(!is_readonly_command_session_command(&run_cmd("git commit -m 'x'")));
730 }
731
732 #[test]
733 fn env_value_injection_is_not_readonly() {
734 for command in [
738 "env GIT_CONFIG_COUNT=1 GIT_CONFIG_KEY_0=core.fsmonitor GIT_CONFIG_VALUE_0='touch /tmp/pwned' git status",
739 "GIT_CONFIG_COUNT=1 GIT_CONFIG_KEY_0=core.fsmonitor GIT_CONFIG_VALUE_0='touch /tmp/pwned' git status",
740 "env GIT_CONFIG_GLOBAL=/tmp/evil.gitconfig git status",
741 "env GIT_EXTERNAL_DIFF=evil git diff",
742 "env GIT_TEXTCONV=evil git show HEAD",
743 "env LD_PRELOAD=./evil.so ls",
744 "env DYLD_INSERT_LIBRARIES=./evil.dylib ls",
745 "env BASH_ENV=./evil.sh bash -lc 'echo hi'",
746 "env NODE_OPTIONS='--require ./evil.js' node --version",
747 "env EDITOR=evil sed -n '1,10p' README.md",
748 ] {
749 assert!(
750 !is_readonly_command_session_command(&run_cmd(command)),
751 "expected mutating env-injection command: {command}"
752 );
753 }
754
755 for command in [
757 "env LANG=C.UTF-8 rg 'TODO' src",
758 "FOO=bar git status",
759 "env FOO=bar git log --oneline",
760 "env -u GIT_CONFIG_COUNT git status",
761 "env -C /repo git status",
762 ] {
763 assert!(
764 is_readonly_command_session_command(&run_cmd(command)),
765 "expected readonly env-prefixed command: {command}"
766 );
767 }
768 }
769
770 #[test]
771 fn git_worktree_and_history_plumbing_subcommands_are_readonly() {
772 for command in [
775 "git rev-list --count HEAD",
776 "git ls-tree HEAD crates/",
777 "git cat-file -p HEAD:Cargo.toml",
778 "git diff-tree -r HEAD",
779 "git merge-base main feat",
780 "git range-diff main~2..main feat~2..feat",
781 "git whatchanged -5",
782 "git count-objects -v",
783 "git reflog | head",
784 "git worktree list",
785 "git stash list",
786 "git stash show",
787 "git branch --show-current",
788 "git --no-pager log --oneline | head -5",
789 "git remote -v",
790 "cd /repo && git -C . branch -a && git tag",
791 ] {
792 assert!(is_readonly_command_session_command(&run_cmd(command)), "expected readonly command: {command}");
793 }
794 }
795
796 #[test]
797 fn version_and_help_probes_are_readonly_for_any_program() {
798 for command in [
799 "cargo --version",
800 "node --version",
801 "python3 -V",
802 "npm --version",
803 "git --version",
804 "bash --help",
805 ] {
806 assert!(is_readonly_command_session_command(&run_cmd(command)), "expected readonly probe: {command}");
807 }
808 assert!(!is_readonly_command_session_command(&run_cmd("python3 script.py --version")));
812 assert!(!is_readonly_command_session_command(&run_cmd("git commit -v")));
813 assert!(!is_readonly_command_session_command(&run_cmd("bash -v")));
814 }
815
816 #[test]
817 fn git_dash_c_repo_redirect_is_readonly_for_allowed_subcommands() {
818 assert!(is_readonly_command_session_command(&run_cmd("git -C .worktrees/feat log --oneline | head -5")));
821 assert!(is_readonly_command_session_command(&run_cmd("git -C ../other rev-parse HEAD")));
822 assert!(!is_readonly_command_session_command(&run_cmd("git -c core.pager=sh log")));
825 assert!(!is_readonly_command_session_command(&run_cmd("git -C .worktrees/feat diff -o out.patch")));
827 }
828
829 #[test]
830 fn readonly_git_and_inspection_options_stay_fail_closed() {
831 for command in [
832 "git diff -o output.txt",
833 "git diff '--output=output.txt'",
834 "git diff -ooutput.txt",
835 "git log --output=output.txt",
836 "git show --textconv",
837 "git -c core.fsmonitor=touch status",
839 "git --exec-path=/tmp/sh status",
840 "git stash",
844 "git stash -u",
845 "git stash -k",
846 "git branch --unset-upstream",
849 "git branch --set-upstream-to=origin/main",
850 "git branch --edit-description",
851 "git branch -mnew-name",
852 "git branch -u=origin/main",
853 "git --shallow-file log commit -m x",
856 "git tag v1.0.0",
858 "git branch feature-x",
859 "git branch -D stale",
860 "git reflog expire --expire=now --all",
861 "git stash pop",
862 "git remote add origin https://example.com/r.git",
863 "git worktree add ../side feat",
864 "find . -fprint output.txt",
865 "find . -fprintf output.txt '%p'",
866 "rg --hostname-bin sh pattern",
867 "rg --search-zip pattern",
868 "rg -z pattern",
869 "sort -o output.txt README.md",
870 "sort --compress-program=sh README.md",
871 "date -s now",
872 "awk -i inplace '{print}' README.md",
873 "sed -n 's/a/b/e' README.md",
874 "fd --exec sh -c 'touch out'",
875 "tree -o out.txt",
876 "ast-grep -r 'README.md'",
877 "sed -n -fmalicious.sed -e '1p' src/main.rs",
878 "sed -I '' 's/a/b/' src/main.rs",
879 "sed -n '1p\nw leaked.txt' src/main.rs",
880 "sed -n 'woutput.txt' src/main.rs",
881 "cargo check & rm output",
882 ] {
883 assert!(!is_readonly_command_session_command(&run_cmd(command)), "unexpected readonly command: {command}");
884 }
885 }
886
887 #[test]
888 fn cargo_readonly_subcommands_allowed() {
889 assert!(is_readonly_command_session_command(&run_cmd("cargo metadata")));
890 assert!(is_readonly_command_session_command(&run_cmd("cargo tree | head -50")));
891 assert!(is_readonly_command_session_command(&run_cmd("cargo nextest run")));
892 assert!(!is_readonly_command_session_command(&run_cmd("cargo build")));
893 assert!(!is_readonly_command_session_command(&run_cmd("cargo publish")));
894 }
895
896 #[test]
897 fn parallel_commands_are_limited_to_side_effect_free_inspection() {
898 for command in ["rg -n TODO src", "cat Cargo.toml", "git status --short"] {
899 assert!(is_parallel_safe_command_session_command(&run_cmd(command)), "expected parallel-safe: {command}");
900 }
901 for command in [
902 "cargo check",
903 "cargo test",
904 "cargo nextest run",
905 "cargo clippy",
906 "npm test",
907 "pnpm run test",
908 "git diff | head -20",
909 ] {
910 assert!(
911 !is_parallel_safe_command_session_command(&run_cmd(command)),
912 "command can touch shared state or crosses a sequential boundary: {command}"
913 );
914 }
915 }
916
917 #[test]
918 fn shared_command_head_fails_closed_on_env_only_words() {
919 let env_only: Vec<String> = vec!["FOO=bar".to_string()];
924 assert!(!command_words_are_readonly(&env_only));
925 assert!(!command_words_are_parallel_safe(&env_only));
926 assert!(readonly_command_head(&env_only).is_none());
927
928 let env_ls: Vec<String> = vec!["FOO=bar".to_string(), "ls".to_string()];
929 assert!(command_words_are_readonly(&env_ls));
930 assert!(command_words_are_parallel_safe(&env_ls));
931 let (stripped, first) = readonly_command_head(&env_ls).expect("env-prefixed ls classifies");
932 assert_eq!(first, "ls");
933 assert_eq!(stripped, ["ls".to_string()].as_slice());
934 }
935
936 #[test]
937 fn parallel_safe_path_rejects_env_injection_like_readonly() {
938 let injected: Vec<String> = vec!["LD_PRELOAD=./evil.so".to_string(), "ls".to_string()];
942 assert!(!command_words_are_parallel_safe(&injected));
943 assert!(!command_words_are_readonly(&injected));
944 assert!(readonly_command_head(&injected).is_none());
945 }
946
947 #[test]
948 fn stderr_merge_is_not_a_write_redirection() {
949 assert!(is_readonly_command_session_command(&run_cmd("cargo check 2>&1 | head -c 4000")));
950 assert!(!is_readonly_command_session_command(&run_cmd("cargo check > out.txt")));
952 }
953
954 #[test]
955 fn extra_inspection_base_commands_are_readonly() {
956 assert!(is_readonly_command_session_command(&run_cmd("tree -L 2 crates/")));
957 assert!(is_readonly_command_session_command(&run_cmd("fd -e rs planner")));
958 assert!(is_readonly_command_session_command(&run_cmd("stat Cargo.toml && file target")));
959 assert!(is_readonly_command_session_command(&run_cmd("which cargo")));
960 }
961
962 #[test]
963 fn spool_file_reads_are_detected_only_for_readonly_commands() {
964 for command in [
965 "cat .vtcode/context/tool_outputs/run-1.txt",
966 "sed -n '1,20p' .vtcode/context/tool_outputs/run-1.txt",
967 "rg error .vtcode/context/tool_outputs",
968 "rg -n error .vtcode/context/tool_outputs | head -20",
969 "tail -n 20 .vtcode/context/tool_outputs/run-1.txt",
970 ] {
971 assert!(is_spool_file_read_command("exec_command", &run_cmd(command)), "expected spool read: {command}");
972 }
973
974 for command in [
975 "cat .vtcode/context/tool_outputs/run-1.txt > copied.txt",
976 "sed -i 's/a/b/' .vtcode/context/tool_outputs/run-1.txt",
977 "rm .vtcode/context/tool_outputs/run-1.txt",
978 "cat \"$VTCODE_SPOOL\"",
979 "cat .vtcode/context/tool_outputs/run-1.txt |",
980 "cat notes-.vtcode/context/tool_outputs/run-1.txt",
981 "cat .vtcode/context/tool_outputs-extra/run-1.txt",
982 ] {
983 assert!(!is_spool_file_read_command("exec_command", &run_cmd(command)), "unexpected spool read: {command}");
984 }
985
986 assert!(!is_spool_file_read_command("mcp_tool", &run_cmd("cat .vtcode/context/tool_outputs/run-1.txt")));
987 assert!(is_spool_file_read_command("exec_command", &run_cmd("cat .vtcode/context/tool_outputs/run-1.txt")));
988 }
989
990 #[test]
991 fn spool_file_read_uses_one_parse_for_gate_and_scan() {
992 assert!(is_spool_file_read_command(
998 "exec_command",
999 &run_cmd("FOO=bar cat .vtcode/context/tool_outputs/run-1.txt")
1000 ));
1001 assert!(is_spool_file_read_command(
1002 "exec_command",
1003 &run_cmd("cat .vtcode/context/tool_outputs/run-1.txt && echo done")
1004 ));
1005 assert!(!is_spool_file_read_command(
1006 "exec_command",
1007 &run_cmd("cat .vtcode/context/tool_outputs/run-1.txt && rm copied.txt")
1008 ));
1009 }
1010
1011 #[test]
1012 fn shared_prologue_keeps_classifier_gates_distinct() {
1013 let spool_read = run_cmd("cat .vtcode/context/tool_outputs/run-1.txt");
1017 assert!(is_readonly_command_session_command(&spool_read));
1018 assert!(is_spool_file_read_command("exec_command", &spool_read));
1019 assert!(is_parallel_safe_command_session_command(&spool_read));
1020
1021 let build = run_cmd("cargo check");
1025 assert!(is_readonly_command_session_command(&build));
1026 assert!(!is_parallel_safe_command_session_command(&build));
1027 assert!(!is_spool_file_read_command("exec_command", &build));
1028
1029 let mutating_spool = run_cmd("cat .vtcode/context/tool_outputs/run-1.txt && rm copied.txt");
1032 assert!(!is_readonly_command_session_command(&mutating_spool));
1033 assert!(!is_spool_file_read_command("exec_command", &mutating_spool));
1034
1035 let conflicted = json!({"action": "run", "command": "ls", "raw_command": "rm -rf /"});
1038 assert!(!is_readonly_command_session_command(&conflicted));
1039 assert!(!is_parallel_safe_command_session_command(&conflicted));
1040 assert!(!is_spool_file_read_command("exec_command", &conflicted));
1041 }
1042}