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
189pub(crate) fn command_words_are_readonly(words: &[String]) -> bool {
193 let command_words = command_words_after_environment_prefix(words);
194 let Some(first) = command_words
195 .first()
196 .and_then(|word| Path::new(word).file_name())
197 .and_then(|name| name.to_str())
198 .map(str::to_ascii_lowercase)
199 else {
200 return false;
201 };
202
203 if has_unsafe_readonly_options(words) {
204 return false;
205 }
206
207 if first == "command" {
208 return matches!(command_words.get(1).map(String::as_str), Some("-v" | "-V"))
209 && command_words.len() > 2
210 && command_words
211 .iter()
212 .skip(2)
213 .all(|word| !word.starts_with('-') && !word.is_empty());
214 }
215
216 if is_readonly_base_command(&first)
217 || is_version_or_help_probe(command_words)
218 || is_known_readonly_dry_run(command_words, &first)
219 {
220 return true;
221 }
222
223 let lowered = command_words
224 .iter()
225 .filter(|word| !word.starts_with('-') && !word.contains('='))
226 .take(3)
227 .map(|word| word.to_ascii_lowercase())
228 .collect::<Vec<_>>();
229 if first != "git" {
230 let (second, third) = (lowered.get(1).map(String::as_str), lowered.get(2).map(String::as_str));
231 return is_readonly_subcommand(&first, second, third);
232 }
233
234 let Some((sub_index, operand)) = git_subcommand_and_operand(command_words) else {
235 return false;
236 };
237 let subcommand = command_words.get(sub_index).map(String::as_str);
238 is_readonly_subcommand("git", subcommand, None)
239 || is_readonly_flag_shaped_git_subcommand(subcommand, operand, command_words, sub_index)
240}
241
242fn git_subcommand_and_operand(command_words: &[String]) -> Option<(usize, Option<&str>)> {
261 const GLOBAL_OPTIONS_WITH_VALUE: &[&str] = &[
262 "-C",
263 "-c",
264 "--git-dir",
265 "--work-tree",
266 "--namespace",
267 "--super-prefix",
268 "--exec-path",
269 "--config-env",
270 "--shallow-file",
271 "--attr-source",
272 ];
273 const GLOBAL_OPTIONS_FLAG_ONLY: &[&str] = &[
274 "-p",
275 "--paginate",
276 "--no-pager",
277 "--bare",
278 "--literal-pathspecs",
279 "--no-replace-objects",
280 "--no-optional-locks",
281 "--end-of-options",
282 "--version",
283 "--help",
284 "--html-path",
285 ];
286
287 let mut index = 1;
288 while let Some(word) = command_words.get(index) {
289 if !word.starts_with('-') {
290 let operand = command_words
291 .iter()
292 .skip(index + 1)
293 .find(|candidate| !candidate.starts_with('-'))
294 .map(String::as_str);
295 return Some((index, operand));
296 }
297 if GLOBAL_OPTIONS_WITH_VALUE.contains(&word.as_str()) {
298 index += 2; continue;
300 }
301 if GLOBAL_OPTIONS_FLAG_ONLY.contains(&word.as_str()) {
302 index += 1;
303 continue;
304 }
305 return None;
309 }
310 None
311}
312
313fn command_words_are_parallel_safe(words: &[String]) -> bool {
318 let command_words = command_words_after_environment_prefix(words);
319 let Some(first) = command_words
320 .first()
321 .and_then(|word| Path::new(word).file_name())
322 .and_then(|name| name.to_str())
323 .map(str::to_ascii_lowercase)
324 else {
325 return false;
326 };
327 if has_unsafe_readonly_options(words) {
328 return false;
329 }
330
331 if is_readonly_base_command(&first) {
332 return true;
333 }
334 if is_version_or_help_probe(command_words) {
336 return true;
337 }
338 if first != "git" {
339 return false;
340 }
341
342 let subcommand = command_words
343 .iter()
344 .skip(1)
345 .find(|word| !word.starts_with('-') && !word.contains('='))
346 .map(|word| word.to_ascii_lowercase());
347 is_readonly_subcommand("git", subcommand.as_deref(), None)
348}
349
350fn is_known_readonly_dry_run(words: &[String], program: &str) -> bool {
351 if !matches!(program, "npm" | "pnpm" | "yarn") {
352 return false;
353 }
354
355 let Some(subcommand) = words.iter().skip(1).find(|word| !word.starts_with('-') && !word.contains('=')) else {
356 return false;
357 };
358
359 subcommand == "install" && words.iter().any(|word| word == "--dry-run")
360}
361
362fn matches_at(chars: &[char], index: usize, pattern: &[char]) -> bool {
363 chars
364 .get(index..index.saturating_add(pattern.len()))
365 .is_some_and(|candidate| candidate == pattern)
366}
367
368fn sanitize_static_shell_command(command: &str) -> Option<String> {
372 if crate::command_safety::shell_parser::contains_dynamic_shell_syntax(command) {
373 return None;
374 }
375
376 let chars = command.chars().collect::<Vec<_>>();
377 let mut sanitized = String::with_capacity(command.len());
378 let mut index = 0;
379 let mut in_single_quote = false;
380 let mut in_double_quote = false;
381
382 while index < chars.len() {
383 let character = chars[index];
384 if character == '\'' && !in_double_quote {
385 in_single_quote = !in_single_quote;
386 sanitized.push(character);
387 index += 1;
388 continue;
389 }
390 if character == '"' && !in_single_quote {
391 in_double_quote = !in_double_quote;
392 sanitized.push(character);
393 index += 1;
394 continue;
395 }
396 if in_single_quote || in_double_quote {
397 sanitized.push(character);
398 index += 1;
399 continue;
400 }
401
402 let preceded_by_boundary = index == 0
403 || chars
404 .get(index.saturating_sub(1))
405 .is_some_and(|previous| previous.is_whitespace() || matches!(previous, '|' | '&' | ';'));
406 if preceded_by_boundary && matches_at(&chars, index, &['2', '>', '&', '1']) {
407 index += 4;
408 continue;
409 }
410 if cfg!(unix)
411 && preceded_by_boundary
412 && matches_at(&chars, index, &['2', '>', '/', 'd', 'e', 'v', '/', 'n', 'u', 'l', 'l'])
413 {
414 index += 11;
415 continue;
416 }
417
418 match character {
419 '<' | '>' => return None,
420 '&' if matches_at(&chars, index, &['&', '&']) => {
421 sanitized.push('&');
422 sanitized.push('&');
423 index += 2;
424 }
425 '&' => return None,
426 _ => {
427 sanitized.push(character);
428 index += 1;
429 }
430 }
431 }
432
433 (!sanitized.trim().is_empty()).then_some(sanitized)
434}
435
436pub fn is_readonly_command_session_command(args: &Value) -> bool {
437 let Some(raw) = shell_command_text_with_args(args) else {
438 return false;
439 };
440
441 static_shell_command_words(&raw)
445 .is_some_and(|commands| commands.iter().all(|words| command_words_are_readonly(words)))
446}
447
448pub(crate) fn is_parallel_safe_command_session_command(args: &Value) -> bool {
451 if args.get("session_id").is_some()
452 || args.get("chars").is_some()
453 || args.get("input").is_some()
454 || args.get("tty").and_then(Value::as_bool) == Some(true)
455 || args
456 .get("sandbox_permissions")
457 .and_then(Value::as_str)
458 .is_some_and(|value| value != "use_default")
459 {
460 return false;
461 }
462 let Some(raw) = shell_command_text_with_args(args) else {
463 return false;
464 };
465 static_shell_command_words(&raw)
466 .is_some_and(|commands| commands.len() == 1 && command_words_are_parallel_safe(&commands[0]))
467}
468
469pub fn is_spool_file_read_command(tool_name: &str, args: &Value) -> bool {
473 if super::classify::canonical_command_session_tool_name(tool_name).is_none() {
474 return false;
475 }
476
477 if !is_readonly_command_session_command(args) {
478 return false;
479 }
480
481 shell_command_text_with_args(args)
482 .and_then(|command| static_shell_command_words(&command))
483 .is_some_and(|commands| {
484 commands
485 .iter()
486 .flatten()
487 .any(|word| SpooledOutputReference::recognizes_path(Path::new(word)))
488 })
489}
490
491#[cfg(test)]
492mod tests {
493 use super::*;
494 use serde_json::json;
495
496 fn run_cmd(command: &str) -> Value {
497 json!({"action": "run", "command": command})
498 }
499
500 #[test]
501 fn command_availability_probes_do_not_admit_execution() {
502 for command in ["command -v npx", "command -V python3 cargo", "command -v npx || true"] {
503 assert!(is_readonly_command_session_command(&run_cmd(command)), "{command}");
504 }
505 for command in [
506 "command rm file",
507 "command -p rm file",
508 "command -v",
509 "command -v -- rm",
510 "command -v $(touch file)",
511 ] {
512 assert!(!is_readonly_command_session_command(&run_cmd(command)), "{command}");
513 }
514 }
515
516 #[test]
517 fn and_chain_allows_readonly_segments() {
518 assert!(is_readonly_command_session_command(&run_cmd("ls -la && echo '---' && ls -la crates/")));
519 assert!(is_readonly_command_session_command(&run_cmd("pwd && ls src/")));
520 assert!(is_readonly_command_session_command(&run_cmd("cat foo.txt && grep bar")));
521 }
522
523 #[test]
524 fn compound_inspection_commands_share_the_readonly_policy() {
525 for command in [
526 "cat README.md; rg -n '^#' README.md",
527 "git diff --stat; find docs -maxdepth 2 -type f | sort | head -40",
528 "cat README.md\nrg -n '^version' Cargo.toml",
529 ] {
530 assert!(is_readonly_command_session_command(&run_cmd(command)), "expected readonly command: {command}");
531 }
532 }
533
534 #[test]
535 fn checkpoint_style_inspection_with_escaped_regex_is_readonly() {
536 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"#;
537
538 assert!(is_readonly_command_session_command(&run_cmd(command)));
539 }
540
541 #[test]
542 fn and_chain_rejects_destructive_segments() {
543 assert!(!is_readonly_command_session_command(&run_cmd("ls -la && rm foo.txt")));
544 assert!(!is_readonly_command_session_command(&run_cmd("cat x && mv a b")));
545 assert!(!is_readonly_command_session_command(&run_cmd("true && cp a b")));
546 }
547
548 #[test]
549 fn and_chain_rejects_non_allowlisted_segments() {
550 assert!(!is_readonly_command_session_command(&run_cmd("ls -la && python script.py")));
551 assert!(!is_readonly_command_session_command(&run_cmd("ls -la && cargo build")));
552 assert!(!is_readonly_command_session_command(&run_cmd("python3 mutate.py --dry-run")));
553 assert!(!is_readonly_command_session_command(&run_cmd("npm install --dry-run && rm output")));
554 }
555
556 #[test]
557 fn and_chain_allows_pipeline_within_segment() {
558 assert!(is_readonly_command_session_command(&run_cmd("ls -la | head -5 && echo done")));
559 }
560
561 #[test]
562 fn and_chain_single_command_passes() {
563 assert!(is_readonly_command_session_command(&run_cmd("ls -la")));
564 assert!(is_readonly_command_session_command(&run_cmd("echo hi")));
565 }
566
567 #[test]
568 fn readonly_command_session_allows_and_chain() {
569 assert!(is_readonly_command_session_command(&run_cmd("ls -la /path/ && echo '---' && ls -la /path/crates/")));
571 }
572
573 #[test]
574 fn readonly_command_session_rejects_destructive_and_chain() {
575 assert!(!is_readonly_command_session_command(&run_cmd("ls -la && rm foo.txt")));
576 }
577
578 #[test]
579 fn awk_range_print_is_readonly() {
580 for command in [
583 "awk 'NR>=40 && NR<=140' README.md",
584 "awk 'NR>=297 && NR<=312' README.md",
585 "awk 'NR>=291 && NR<=296' README.md | cut -c1-150",
586 "awk -F: '{print $1}' README.md",
587 "awk -F, '{print $2}' data.csv",
588 "awk -v limit=10 'NR<=limit' README.md",
589 "awk -- '{print $1}' README.md",
590 "awk -- '{print $1}' -- -weird",
591 "awk 'BEGIN{print systime()}' README.md",
594 "/usr/bin/awk 'NR>=1 && NR<=5' README.md",
595 "awk 'NR>=1 && NR<=5' README.md | sort",
596 "awk '$3>100' README.md",
599 "awk '{if(p2==0 && i>1) print}' README.md",
600 "awk '{if(NR>1 && $2>10) print $1}' README.md",
601 "awk 'NR>=65 && NR<=71 {n=length($0); print n}' README.md",
602 "awk '{print (a>b)}' README.md",
603 ] {
604 assert!(is_readonly_command_session_command(&run_cmd(command)), "expected readonly command: {command}");
605 }
606 }
607
608 #[test]
609 fn awk_write_primitives_stay_mutating() {
610 for command in [
611 "awk '{print > \"out.txt\"}' README.md",
612 "awk '{print >> \"out.txt\"}' README.md",
613 "awk '{print x > \"out.txt\"}' README.md",
614 "awk 'BEGIN { # (\n print \"written\" > \"out.txt\" }'",
615 "awk 'BEGIN { # [\n printf \"written\" > \"out.txt\" }'",
616 "awk 'BEGIN { # ( [ {\n print \"written\" > \"out.txt\" }'",
617 "awk '{print | \"sort\"}' README.md",
618 "awk '\"sort\" | getline line' README.md",
619 "awk 'BEGIN{system(\"touch out\")}' README.md",
620 "awk 'BEGIN{SYSTEM (\"id\")}' README.md",
621 "awk 'BEGIN{System(\"id\")}' README.md",
622 "awk 'BEGIN{system\t(\"id\")}' README.md",
623 "awk -v f=system 'BEGIN{@f(\"id\")}' README.md",
626 "awk 'BEGIN{@s(\"id\")}' README.md",
627 "awk '@include \"x.awk\"' README.md",
628 "awk '@load \"ext\"' README.md",
629 "awk '{print \"a@b\"}' README.md",
630 "awk '/error|warning/' README.md",
633 "awk -i inplace '{print}' README.md",
634 "awk -f program.awk README.md",
635 "awk --source '{print}' README.md",
636 "awk -l injail '{print}' README.md",
637 "awk --profile '{print}' README.md",
638 "awk -W dump-variables '{print}' README.md",
639 "awk --posix '{print}' README.md",
640 "awk -F:",
641 "awk",
642 "awk -v",
643 "awk 'NR>=1' README.md > out.txt",
645 ] {
646 assert!(!is_readonly_command_session_command(&run_cmd(command)), "expected mutating command: {command}");
647 }
648 }
649
650 #[test]
651 fn awk_string_literal_operators_are_readonly() {
652 for command in [
655 r#"awk '{n=index(rest,"|"); print n}' README.md"#,
656 r#"awk 'index($0,">")' README.md"#,
657 r#"awk '$3>=100 && $4||$5 {print $1}' README.md"#,
660 r#"awk '{print $1 / $2}' README.md"#,
662 "awk '{ # ( [ > system(\n if (NR>1) print $1 }' README.md",
663 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
666rest=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"#,
667 ] {
668 assert!(is_readonly_command_session_command(&run_cmd(command)), "expected readonly command: {command}");
669 }
670 }
671
672 #[test]
673 fn awk_regex_with_quote_cannot_hide_pipe() {
674 for command in [
680 r#"awk '/a"b/ {print | "sort"} /c"d/' README.md"#,
681 r#"awk '/a|b/ {print}' README.md"#,
683 ] {
684 assert!(!is_readonly_command_session_command(&run_cmd(command)), "expected mutating command: {command}");
685 }
686 }
687
688 #[test]
689 fn cd_prefixed_exploration_is_readonly() {
690 assert!(is_readonly_command_session_command(&run_cmd("cd /repo && sed -n '440,520p' Cargo.toml")));
693 assert!(is_readonly_command_session_command(&run_cmd(
694 "cd /repo && ls -la && echo '---' && rg --files -g 'Cargo.toml' | sed -n '1,120p'"
695 )));
696 assert!(!is_readonly_command_session_command(&run_cmd("cd /repo && cargo build")));
698 assert!(!is_readonly_command_session_command(&run_cmd("cd /repo && rm -rf target")));
699 }
700
701 #[test]
702 fn git_readonly_subcommands_allowed_in_chains_and_pipelines() {
703 assert!(is_readonly_command_session_command(&run_cmd("git log --oneline | head -20")));
704 assert!(is_readonly_command_session_command(&run_cmd("cd /repo && git diff")));
705 assert!(is_readonly_command_session_command(&run_cmd("git show HEAD")));
706 assert!(is_readonly_command_session_command(&run_cmd("git blame src/main.rs | head")));
707 assert!(!is_readonly_command_session_command(&run_cmd("git checkout main")));
709 assert!(!is_readonly_command_session_command(&run_cmd("cd /repo && git push")));
710 assert!(!is_readonly_command_session_command(&run_cmd("git commit -m 'x'")));
711 }
712
713 #[test]
714 fn env_value_injection_is_not_readonly() {
715 for command in [
719 "env GIT_CONFIG_COUNT=1 GIT_CONFIG_KEY_0=core.fsmonitor GIT_CONFIG_VALUE_0='touch /tmp/pwned' git status",
720 "GIT_CONFIG_COUNT=1 GIT_CONFIG_KEY_0=core.fsmonitor GIT_CONFIG_VALUE_0='touch /tmp/pwned' git status",
721 "env GIT_CONFIG_GLOBAL=/tmp/evil.gitconfig git status",
722 "env GIT_EXTERNAL_DIFF=evil git diff",
723 "env GIT_TEXTCONV=evil git show HEAD",
724 "env LD_PRELOAD=./evil.so ls",
725 "env DYLD_INSERT_LIBRARIES=./evil.dylib ls",
726 "env BASH_ENV=./evil.sh bash -lc 'echo hi'",
727 "env NODE_OPTIONS='--require ./evil.js' node --version",
728 "env EDITOR=evil sed -n '1,10p' README.md",
729 ] {
730 assert!(
731 !is_readonly_command_session_command(&run_cmd(command)),
732 "expected mutating env-injection command: {command}"
733 );
734 }
735
736 for command in [
738 "env LANG=C.UTF-8 rg 'TODO' src",
739 "FOO=bar git status",
740 "env FOO=bar git log --oneline",
741 "env -u GIT_CONFIG_COUNT git status",
742 "env -C /repo git status",
743 ] {
744 assert!(
745 is_readonly_command_session_command(&run_cmd(command)),
746 "expected readonly env-prefixed command: {command}"
747 );
748 }
749 }
750
751 #[test]
752 fn git_worktree_and_history_plumbing_subcommands_are_readonly() {
753 for command in [
756 "git rev-list --count HEAD",
757 "git ls-tree HEAD crates/",
758 "git cat-file -p HEAD:Cargo.toml",
759 "git diff-tree -r HEAD",
760 "git merge-base main feat",
761 "git range-diff main~2..main feat~2..feat",
762 "git whatchanged -5",
763 "git count-objects -v",
764 "git reflog | head",
765 "git worktree list",
766 "git stash list",
767 "git stash show",
768 "git branch --show-current",
769 "git --no-pager log --oneline | head -5",
770 "git remote -v",
771 "cd /repo && git -C . branch -a && git tag",
772 ] {
773 assert!(is_readonly_command_session_command(&run_cmd(command)), "expected readonly command: {command}");
774 }
775 }
776
777 #[test]
778 fn version_and_help_probes_are_readonly_for_any_program() {
779 for command in [
780 "cargo --version",
781 "node --version",
782 "python3 -V",
783 "npm --version",
784 "git --version",
785 "bash --help",
786 ] {
787 assert!(is_readonly_command_session_command(&run_cmd(command)), "expected readonly probe: {command}");
788 }
789 assert!(!is_readonly_command_session_command(&run_cmd("python3 script.py --version")));
793 assert!(!is_readonly_command_session_command(&run_cmd("git commit -v")));
794 assert!(!is_readonly_command_session_command(&run_cmd("bash -v")));
795 }
796
797 #[test]
798 fn git_dash_c_repo_redirect_is_readonly_for_allowed_subcommands() {
799 assert!(is_readonly_command_session_command(&run_cmd("git -C .worktrees/feat log --oneline | head -5")));
802 assert!(is_readonly_command_session_command(&run_cmd("git -C ../other rev-parse HEAD")));
803 assert!(!is_readonly_command_session_command(&run_cmd("git -c core.pager=sh log")));
806 assert!(!is_readonly_command_session_command(&run_cmd("git -C .worktrees/feat diff -o out.patch")));
808 }
809
810 #[test]
811 fn readonly_git_and_inspection_options_stay_fail_closed() {
812 for command in [
813 "git diff -o output.txt",
814 "git diff '--output=output.txt'",
815 "git diff -ooutput.txt",
816 "git log --output=output.txt",
817 "git show --textconv",
818 "git -c core.fsmonitor=touch status",
820 "git --exec-path=/tmp/sh status",
821 "git stash",
825 "git stash -u",
826 "git stash -k",
827 "git branch --unset-upstream",
830 "git branch --set-upstream-to=origin/main",
831 "git branch --edit-description",
832 "git branch -mnew-name",
833 "git branch -u=origin/main",
834 "git --shallow-file log commit -m x",
837 "git tag v1.0.0",
839 "git branch feature-x",
840 "git branch -D stale",
841 "git reflog expire --expire=now --all",
842 "git stash pop",
843 "git remote add origin https://example.com/r.git",
844 "git worktree add ../side feat",
845 "find . -fprint output.txt",
846 "find . -fprintf output.txt '%p'",
847 "rg --hostname-bin sh pattern",
848 "rg --search-zip pattern",
849 "rg -z pattern",
850 "sort -o output.txt README.md",
851 "sort --compress-program=sh README.md",
852 "date -s now",
853 "awk -i inplace '{print}' README.md",
854 "sed -n 's/a/b/e' README.md",
855 "fd --exec sh -c 'touch out'",
856 "tree -o out.txt",
857 "ast-grep -r 'README.md'",
858 "sed -n -fmalicious.sed -e '1p' src/main.rs",
859 "sed -I '' 's/a/b/' src/main.rs",
860 "sed -n '1p\nw leaked.txt' src/main.rs",
861 "sed -n 'woutput.txt' src/main.rs",
862 "cargo check & rm output",
863 ] {
864 assert!(!is_readonly_command_session_command(&run_cmd(command)), "unexpected readonly command: {command}");
865 }
866 }
867
868 #[test]
869 fn cargo_readonly_subcommands_allowed() {
870 assert!(is_readonly_command_session_command(&run_cmd("cargo metadata")));
871 assert!(is_readonly_command_session_command(&run_cmd("cargo tree | head -50")));
872 assert!(is_readonly_command_session_command(&run_cmd("cargo nextest run")));
873 assert!(!is_readonly_command_session_command(&run_cmd("cargo build")));
874 assert!(!is_readonly_command_session_command(&run_cmd("cargo publish")));
875 }
876
877 #[test]
878 fn parallel_commands_are_limited_to_side_effect_free_inspection() {
879 for command in ["rg -n TODO src", "cat Cargo.toml", "git status --short"] {
880 assert!(is_parallel_safe_command_session_command(&run_cmd(command)), "expected parallel-safe: {command}");
881 }
882 for command in [
883 "cargo check",
884 "cargo test",
885 "cargo nextest run",
886 "cargo clippy",
887 "npm test",
888 "pnpm run test",
889 "git diff | head -20",
890 ] {
891 assert!(
892 !is_parallel_safe_command_session_command(&run_cmd(command)),
893 "command can touch shared state or crosses a sequential boundary: {command}"
894 );
895 }
896 }
897
898 #[test]
899 fn stderr_merge_is_not_a_write_redirection() {
900 assert!(is_readonly_command_session_command(&run_cmd("cargo check 2>&1 | head -c 4000")));
901 assert!(!is_readonly_command_session_command(&run_cmd("cargo check > out.txt")));
903 }
904
905 #[test]
906 fn extra_inspection_base_commands_are_readonly() {
907 assert!(is_readonly_command_session_command(&run_cmd("tree -L 2 crates/")));
908 assert!(is_readonly_command_session_command(&run_cmd("fd -e rs planner")));
909 assert!(is_readonly_command_session_command(&run_cmd("stat Cargo.toml && file target")));
910 assert!(is_readonly_command_session_command(&run_cmd("which cargo")));
911 }
912
913 #[test]
914 fn spool_file_reads_are_detected_only_for_readonly_commands() {
915 for command in [
916 "cat .vtcode/context/tool_outputs/run-1.txt",
917 "sed -n '1,20p' .vtcode/context/tool_outputs/run-1.txt",
918 "rg error .vtcode/context/tool_outputs",
919 "rg -n error .vtcode/context/tool_outputs | head -20",
920 "tail -n 20 .vtcode/context/tool_outputs/run-1.txt",
921 ] {
922 assert!(is_spool_file_read_command("exec_command", &run_cmd(command)), "expected spool read: {command}");
923 }
924
925 for command in [
926 "cat .vtcode/context/tool_outputs/run-1.txt > copied.txt",
927 "sed -i 's/a/b/' .vtcode/context/tool_outputs/run-1.txt",
928 "rm .vtcode/context/tool_outputs/run-1.txt",
929 "cat \"$VTCODE_SPOOL\"",
930 "cat .vtcode/context/tool_outputs/run-1.txt |",
931 "cat notes-.vtcode/context/tool_outputs/run-1.txt",
932 "cat .vtcode/context/tool_outputs-extra/run-1.txt",
933 ] {
934 assert!(!is_spool_file_read_command("exec_command", &run_cmd(command)), "unexpected spool read: {command}");
935 }
936
937 assert!(!is_spool_file_read_command("mcp_tool", &run_cmd("cat .vtcode/context/tool_outputs/run-1.txt")));
938 assert!(is_spool_file_read_command("exec_command", &run_cmd("cat .vtcode/context/tool_outputs/run-1.txt")));
939 }
940}