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
438pub fn is_readonly_command_session_command(args: &Value) -> bool {
439 let Some(raw) = shell_command_text_with_args(args) else {
440 return false;
441 };
442
443 static_shell_command_words(&raw)
447 .is_some_and(|commands| commands.iter().all(|words| command_words_are_readonly(words)))
448}
449
450pub(crate) fn is_parallel_safe_command_session_command(args: &Value) -> bool {
453 if args.get("session_id").is_some()
454 || args.get("chars").is_some()
455 || args.get("input").is_some()
456 || args.get("tty").and_then(Value::as_bool) == Some(true)
457 || args
458 .get("sandbox_permissions")
459 .and_then(Value::as_str)
460 .is_some_and(|value| value != "use_default")
461 {
462 return false;
463 }
464 let Some(raw) = shell_command_text_with_args(args) else {
465 return false;
466 };
467 static_shell_command_words(&raw)
468 .is_some_and(|commands| commands.len() == 1 && command_words_are_parallel_safe(&commands[0]))
469}
470
471pub fn is_spool_file_read_command(tool_name: &str, args: &Value) -> bool {
475 if super::classify::canonical_command_session_tool_name(tool_name).is_none() {
476 return false;
477 }
478
479 let Some(raw) = shell_command_text_with_args(args) else {
485 return false;
486 };
487 let Some(commands) = static_shell_command_words(&raw) else {
488 return false;
489 };
490 if !commands.iter().all(|words| command_words_are_readonly(words)) {
491 return false;
492 }
493 commands
494 .iter()
495 .flatten()
496 .any(|word| SpooledOutputReference::recognizes_path(Path::new(word)))
497}
498
499#[cfg(test)]
500mod tests {
501 use super::*;
502 use serde_json::json;
503
504 fn run_cmd(command: &str) -> Value {
505 json!({"action": "run", "command": command})
506 }
507
508 #[test]
509 fn command_availability_probes_do_not_admit_execution() {
510 for command in ["command -v npx", "command -V python3 cargo", "command -v npx || true"] {
511 assert!(is_readonly_command_session_command(&run_cmd(command)), "{command}");
512 }
513 for command in [
514 "command rm file",
515 "command -p rm file",
516 "command -v",
517 "command -v -- rm",
518 "command -v $(touch file)",
519 ] {
520 assert!(!is_readonly_command_session_command(&run_cmd(command)), "{command}");
521 }
522 }
523
524 #[test]
525 fn and_chain_allows_readonly_segments() {
526 assert!(is_readonly_command_session_command(&run_cmd("ls -la && echo '---' && ls -la crates/")));
527 assert!(is_readonly_command_session_command(&run_cmd("pwd && ls src/")));
528 assert!(is_readonly_command_session_command(&run_cmd("cat foo.txt && grep bar")));
529 }
530
531 #[test]
532 fn compound_inspection_commands_share_the_readonly_policy() {
533 for command in [
534 "cat README.md; rg -n '^#' README.md",
535 "git diff --stat; find docs -maxdepth 2 -type f | sort | head -40",
536 "cat README.md\nrg -n '^version' Cargo.toml",
537 ] {
538 assert!(is_readonly_command_session_command(&run_cmd(command)), "expected readonly command: {command}");
539 }
540 }
541
542 #[test]
543 fn checkpoint_style_inspection_with_escaped_regex_is_readonly() {
544 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"#;
545
546 assert!(is_readonly_command_session_command(&run_cmd(command)));
547 }
548
549 #[test]
550 fn and_chain_rejects_destructive_segments() {
551 assert!(!is_readonly_command_session_command(&run_cmd("ls -la && rm foo.txt")));
552 assert!(!is_readonly_command_session_command(&run_cmd("cat x && mv a b")));
553 assert!(!is_readonly_command_session_command(&run_cmd("true && cp a b")));
554 }
555
556 #[test]
557 fn and_chain_rejects_non_allowlisted_segments() {
558 assert!(!is_readonly_command_session_command(&run_cmd("ls -la && python script.py")));
559 assert!(!is_readonly_command_session_command(&run_cmd("ls -la && cargo build")));
560 assert!(!is_readonly_command_session_command(&run_cmd("python3 mutate.py --dry-run")));
561 assert!(!is_readonly_command_session_command(&run_cmd("npm install --dry-run && rm output")));
562 }
563
564 #[test]
565 fn and_chain_allows_pipeline_within_segment() {
566 assert!(is_readonly_command_session_command(&run_cmd("ls -la | head -5 && echo done")));
567 }
568
569 #[test]
570 fn and_chain_single_command_passes() {
571 assert!(is_readonly_command_session_command(&run_cmd("ls -la")));
572 assert!(is_readonly_command_session_command(&run_cmd("echo hi")));
573 }
574
575 #[test]
576 fn readonly_command_session_allows_and_chain() {
577 assert!(is_readonly_command_session_command(&run_cmd("ls -la /path/ && echo '---' && ls -la /path/crates/")));
579 }
580
581 #[test]
582 fn readonly_command_session_rejects_destructive_and_chain() {
583 assert!(!is_readonly_command_session_command(&run_cmd("ls -la && rm foo.txt")));
584 }
585
586 #[test]
587 fn awk_range_print_is_readonly() {
588 for command in [
591 "awk 'NR>=40 && NR<=140' README.md",
592 "awk 'NR>=297 && NR<=312' README.md",
593 "awk 'NR>=291 && NR<=296' README.md | cut -c1-150",
594 "awk -F: '{print $1}' README.md",
595 "awk -F, '{print $2}' data.csv",
596 "awk -v limit=10 'NR<=limit' README.md",
597 "awk -- '{print $1}' README.md",
598 "awk -- '{print $1}' -- -weird",
599 "awk 'BEGIN{print systime()}' README.md",
602 "/usr/bin/awk 'NR>=1 && NR<=5' README.md",
603 "awk 'NR>=1 && NR<=5' README.md | sort",
604 "awk '$3>100' README.md",
607 "awk '{if(p2==0 && i>1) print}' README.md",
608 "awk '{if(NR>1 && $2>10) print $1}' README.md",
609 "awk 'NR>=65 && NR<=71 {n=length($0); print n}' README.md",
610 "awk '{print (a>b)}' README.md",
611 ] {
612 assert!(is_readonly_command_session_command(&run_cmd(command)), "expected readonly command: {command}");
613 }
614 }
615
616 #[test]
617 fn awk_write_primitives_stay_mutating() {
618 for command in [
619 "awk '{print > \"out.txt\"}' README.md",
620 "awk '{print >> \"out.txt\"}' README.md",
621 "awk '{print x > \"out.txt\"}' README.md",
622 "awk 'BEGIN { # (\n print \"written\" > \"out.txt\" }'",
623 "awk 'BEGIN { # [\n printf \"written\" > \"out.txt\" }'",
624 "awk 'BEGIN { # ( [ {\n print \"written\" > \"out.txt\" }'",
625 "awk '{print | \"sort\"}' README.md",
626 "awk '\"sort\" | getline line' README.md",
627 "awk 'BEGIN{system(\"touch out\")}' README.md",
628 "awk 'BEGIN{SYSTEM (\"id\")}' README.md",
629 "awk 'BEGIN{System(\"id\")}' README.md",
630 "awk 'BEGIN{system\t(\"id\")}' README.md",
631 "awk -v f=system 'BEGIN{@f(\"id\")}' README.md",
634 "awk 'BEGIN{@s(\"id\")}' README.md",
635 "awk '@include \"x.awk\"' README.md",
636 "awk '@load \"ext\"' README.md",
637 "awk '{print \"a@b\"}' README.md",
638 "awk '/error|warning/' README.md",
641 "awk -i inplace '{print}' README.md",
642 "awk -f program.awk README.md",
643 "awk --source '{print}' README.md",
644 "awk -l injail '{print}' README.md",
645 "awk --profile '{print}' README.md",
646 "awk -W dump-variables '{print}' README.md",
647 "awk --posix '{print}' README.md",
648 "awk -F:",
649 "awk",
650 "awk -v",
651 "awk 'NR>=1' README.md > out.txt",
653 ] {
654 assert!(!is_readonly_command_session_command(&run_cmd(command)), "expected mutating command: {command}");
655 }
656 }
657
658 #[test]
659 fn awk_string_literal_operators_are_readonly() {
660 for command in [
663 r#"awk '{n=index(rest,"|"); print n}' README.md"#,
664 r#"awk 'index($0,">")' README.md"#,
665 r#"awk '$3>=100 && $4||$5 {print $1}' README.md"#,
668 r#"awk '{print $1 / $2}' README.md"#,
670 "awk '{ # ( [ > system(\n if (NR>1) print $1 }' README.md",
671 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
674rest=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"#,
675 ] {
676 assert!(is_readonly_command_session_command(&run_cmd(command)), "expected readonly command: {command}");
677 }
678 }
679
680 #[test]
681 fn awk_regex_with_quote_cannot_hide_pipe() {
682 for command in [
688 r#"awk '/a"b/ {print | "sort"} /c"d/' README.md"#,
689 r#"awk '/a|b/ {print}' README.md"#,
691 ] {
692 assert!(!is_readonly_command_session_command(&run_cmd(command)), "expected mutating command: {command}");
693 }
694 }
695
696 #[test]
697 fn cd_prefixed_exploration_is_readonly() {
698 assert!(is_readonly_command_session_command(&run_cmd("cd /repo && sed -n '440,520p' Cargo.toml")));
701 assert!(is_readonly_command_session_command(&run_cmd(
702 "cd /repo && ls -la && echo '---' && rg --files -g 'Cargo.toml' | sed -n '1,120p'"
703 )));
704 assert!(!is_readonly_command_session_command(&run_cmd("cd /repo && cargo build")));
706 assert!(!is_readonly_command_session_command(&run_cmd("cd /repo && rm -rf target")));
707 }
708
709 #[test]
710 fn git_readonly_subcommands_allowed_in_chains_and_pipelines() {
711 assert!(is_readonly_command_session_command(&run_cmd("git log --oneline | head -20")));
712 assert!(is_readonly_command_session_command(&run_cmd("cd /repo && git diff")));
713 assert!(is_readonly_command_session_command(&run_cmd("git show HEAD")));
714 assert!(is_readonly_command_session_command(&run_cmd("git blame src/main.rs | head")));
715 assert!(!is_readonly_command_session_command(&run_cmd("git checkout main")));
717 assert!(!is_readonly_command_session_command(&run_cmd("cd /repo && git push")));
718 assert!(!is_readonly_command_session_command(&run_cmd("git commit -m 'x'")));
719 }
720
721 #[test]
722 fn env_value_injection_is_not_readonly() {
723 for command in [
727 "env GIT_CONFIG_COUNT=1 GIT_CONFIG_KEY_0=core.fsmonitor GIT_CONFIG_VALUE_0='touch /tmp/pwned' git status",
728 "GIT_CONFIG_COUNT=1 GIT_CONFIG_KEY_0=core.fsmonitor GIT_CONFIG_VALUE_0='touch /tmp/pwned' git status",
729 "env GIT_CONFIG_GLOBAL=/tmp/evil.gitconfig git status",
730 "env GIT_EXTERNAL_DIFF=evil git diff",
731 "env GIT_TEXTCONV=evil git show HEAD",
732 "env LD_PRELOAD=./evil.so ls",
733 "env DYLD_INSERT_LIBRARIES=./evil.dylib ls",
734 "env BASH_ENV=./evil.sh bash -lc 'echo hi'",
735 "env NODE_OPTIONS='--require ./evil.js' node --version",
736 "env EDITOR=evil sed -n '1,10p' README.md",
737 ] {
738 assert!(
739 !is_readonly_command_session_command(&run_cmd(command)),
740 "expected mutating env-injection command: {command}"
741 );
742 }
743
744 for command in [
746 "env LANG=C.UTF-8 rg 'TODO' src",
747 "FOO=bar git status",
748 "env FOO=bar git log --oneline",
749 "env -u GIT_CONFIG_COUNT git status",
750 "env -C /repo git status",
751 ] {
752 assert!(
753 is_readonly_command_session_command(&run_cmd(command)),
754 "expected readonly env-prefixed command: {command}"
755 );
756 }
757 }
758
759 #[test]
760 fn git_worktree_and_history_plumbing_subcommands_are_readonly() {
761 for command in [
764 "git rev-list --count HEAD",
765 "git ls-tree HEAD crates/",
766 "git cat-file -p HEAD:Cargo.toml",
767 "git diff-tree -r HEAD",
768 "git merge-base main feat",
769 "git range-diff main~2..main feat~2..feat",
770 "git whatchanged -5",
771 "git count-objects -v",
772 "git reflog | head",
773 "git worktree list",
774 "git stash list",
775 "git stash show",
776 "git branch --show-current",
777 "git --no-pager log --oneline | head -5",
778 "git remote -v",
779 "cd /repo && git -C . branch -a && git tag",
780 ] {
781 assert!(is_readonly_command_session_command(&run_cmd(command)), "expected readonly command: {command}");
782 }
783 }
784
785 #[test]
786 fn version_and_help_probes_are_readonly_for_any_program() {
787 for command in [
788 "cargo --version",
789 "node --version",
790 "python3 -V",
791 "npm --version",
792 "git --version",
793 "bash --help",
794 ] {
795 assert!(is_readonly_command_session_command(&run_cmd(command)), "expected readonly probe: {command}");
796 }
797 assert!(!is_readonly_command_session_command(&run_cmd("python3 script.py --version")));
801 assert!(!is_readonly_command_session_command(&run_cmd("git commit -v")));
802 assert!(!is_readonly_command_session_command(&run_cmd("bash -v")));
803 }
804
805 #[test]
806 fn git_dash_c_repo_redirect_is_readonly_for_allowed_subcommands() {
807 assert!(is_readonly_command_session_command(&run_cmd("git -C .worktrees/feat log --oneline | head -5")));
810 assert!(is_readonly_command_session_command(&run_cmd("git -C ../other rev-parse HEAD")));
811 assert!(!is_readonly_command_session_command(&run_cmd("git -c core.pager=sh log")));
814 assert!(!is_readonly_command_session_command(&run_cmd("git -C .worktrees/feat diff -o out.patch")));
816 }
817
818 #[test]
819 fn readonly_git_and_inspection_options_stay_fail_closed() {
820 for command in [
821 "git diff -o output.txt",
822 "git diff '--output=output.txt'",
823 "git diff -ooutput.txt",
824 "git log --output=output.txt",
825 "git show --textconv",
826 "git -c core.fsmonitor=touch status",
828 "git --exec-path=/tmp/sh status",
829 "git stash",
833 "git stash -u",
834 "git stash -k",
835 "git branch --unset-upstream",
838 "git branch --set-upstream-to=origin/main",
839 "git branch --edit-description",
840 "git branch -mnew-name",
841 "git branch -u=origin/main",
842 "git --shallow-file log commit -m x",
845 "git tag v1.0.0",
847 "git branch feature-x",
848 "git branch -D stale",
849 "git reflog expire --expire=now --all",
850 "git stash pop",
851 "git remote add origin https://example.com/r.git",
852 "git worktree add ../side feat",
853 "find . -fprint output.txt",
854 "find . -fprintf output.txt '%p'",
855 "rg --hostname-bin sh pattern",
856 "rg --search-zip pattern",
857 "rg -z pattern",
858 "sort -o output.txt README.md",
859 "sort --compress-program=sh README.md",
860 "date -s now",
861 "awk -i inplace '{print}' README.md",
862 "sed -n 's/a/b/e' README.md",
863 "fd --exec sh -c 'touch out'",
864 "tree -o out.txt",
865 "ast-grep -r 'README.md'",
866 "sed -n -fmalicious.sed -e '1p' src/main.rs",
867 "sed -I '' 's/a/b/' src/main.rs",
868 "sed -n '1p\nw leaked.txt' src/main.rs",
869 "sed -n 'woutput.txt' src/main.rs",
870 "cargo check & rm output",
871 ] {
872 assert!(!is_readonly_command_session_command(&run_cmd(command)), "unexpected readonly command: {command}");
873 }
874 }
875
876 #[test]
877 fn cargo_readonly_subcommands_allowed() {
878 assert!(is_readonly_command_session_command(&run_cmd("cargo metadata")));
879 assert!(is_readonly_command_session_command(&run_cmd("cargo tree | head -50")));
880 assert!(is_readonly_command_session_command(&run_cmd("cargo nextest run")));
881 assert!(!is_readonly_command_session_command(&run_cmd("cargo build")));
882 assert!(!is_readonly_command_session_command(&run_cmd("cargo publish")));
883 }
884
885 #[test]
886 fn parallel_commands_are_limited_to_side_effect_free_inspection() {
887 for command in ["rg -n TODO src", "cat Cargo.toml", "git status --short"] {
888 assert!(is_parallel_safe_command_session_command(&run_cmd(command)), "expected parallel-safe: {command}");
889 }
890 for command in [
891 "cargo check",
892 "cargo test",
893 "cargo nextest run",
894 "cargo clippy",
895 "npm test",
896 "pnpm run test",
897 "git diff | head -20",
898 ] {
899 assert!(
900 !is_parallel_safe_command_session_command(&run_cmd(command)),
901 "command can touch shared state or crosses a sequential boundary: {command}"
902 );
903 }
904 }
905
906 #[test]
907 fn shared_command_head_fails_closed_on_env_only_words() {
908 let env_only: Vec<String> = vec!["FOO=bar".to_string()];
913 assert!(!command_words_are_readonly(&env_only));
914 assert!(!command_words_are_parallel_safe(&env_only));
915 assert!(readonly_command_head(&env_only).is_none());
916
917 let env_ls: Vec<String> = vec!["FOO=bar".to_string(), "ls".to_string()];
918 assert!(command_words_are_readonly(&env_ls));
919 assert!(command_words_are_parallel_safe(&env_ls));
920 let (stripped, first) = readonly_command_head(&env_ls).expect("env-prefixed ls classifies");
921 assert_eq!(first, "ls");
922 assert_eq!(stripped, ["ls".to_string()].as_slice());
923 }
924
925 #[test]
926 fn parallel_safe_path_rejects_env_injection_like_readonly() {
927 let injected: Vec<String> = vec!["LD_PRELOAD=./evil.so".to_string(), "ls".to_string()];
931 assert!(!command_words_are_parallel_safe(&injected));
932 assert!(!command_words_are_readonly(&injected));
933 assert!(readonly_command_head(&injected).is_none());
934 }
935
936 #[test]
937 fn stderr_merge_is_not_a_write_redirection() {
938 assert!(is_readonly_command_session_command(&run_cmd("cargo check 2>&1 | head -c 4000")));
939 assert!(!is_readonly_command_session_command(&run_cmd("cargo check > out.txt")));
941 }
942
943 #[test]
944 fn extra_inspection_base_commands_are_readonly() {
945 assert!(is_readonly_command_session_command(&run_cmd("tree -L 2 crates/")));
946 assert!(is_readonly_command_session_command(&run_cmd("fd -e rs planner")));
947 assert!(is_readonly_command_session_command(&run_cmd("stat Cargo.toml && file target")));
948 assert!(is_readonly_command_session_command(&run_cmd("which cargo")));
949 }
950
951 #[test]
952 fn spool_file_reads_are_detected_only_for_readonly_commands() {
953 for command in [
954 "cat .vtcode/context/tool_outputs/run-1.txt",
955 "sed -n '1,20p' .vtcode/context/tool_outputs/run-1.txt",
956 "rg error .vtcode/context/tool_outputs",
957 "rg -n error .vtcode/context/tool_outputs | head -20",
958 "tail -n 20 .vtcode/context/tool_outputs/run-1.txt",
959 ] {
960 assert!(is_spool_file_read_command("exec_command", &run_cmd(command)), "expected spool read: {command}");
961 }
962
963 for command in [
964 "cat .vtcode/context/tool_outputs/run-1.txt > copied.txt",
965 "sed -i 's/a/b/' .vtcode/context/tool_outputs/run-1.txt",
966 "rm .vtcode/context/tool_outputs/run-1.txt",
967 "cat \"$VTCODE_SPOOL\"",
968 "cat .vtcode/context/tool_outputs/run-1.txt |",
969 "cat notes-.vtcode/context/tool_outputs/run-1.txt",
970 "cat .vtcode/context/tool_outputs-extra/run-1.txt",
971 ] {
972 assert!(!is_spool_file_read_command("exec_command", &run_cmd(command)), "unexpected spool read: {command}");
973 }
974
975 assert!(!is_spool_file_read_command("mcp_tool", &run_cmd("cat .vtcode/context/tool_outputs/run-1.txt")));
976 assert!(is_spool_file_read_command("exec_command", &run_cmd("cat .vtcode/context/tool_outputs/run-1.txt")));
977 }
978
979 #[test]
980 fn spool_file_read_uses_one_parse_for_gate_and_scan() {
981 assert!(is_spool_file_read_command(
987 "exec_command",
988 &run_cmd("FOO=bar cat .vtcode/context/tool_outputs/run-1.txt")
989 ));
990 assert!(is_spool_file_read_command(
991 "exec_command",
992 &run_cmd("cat .vtcode/context/tool_outputs/run-1.txt && echo done")
993 ));
994 assert!(!is_spool_file_read_command(
995 "exec_command",
996 &run_cmd("cat .vtcode/context/tool_outputs/run-1.txt && rm copied.txt")
997 ));
998 }
999}