1use std::path::Path;
8
9use serde_json::Value;
10use vtcode_commons::validation::NonEmptySlice;
11
12use crate::tools::command_args::shell_command_text_with_args;
13
14use super::readonly::{
15 command_words_are_readonly, static_shell_command_words, static_shell_command_words_with_output_plumbing,
16};
17
18#[derive(Clone, Copy, Debug, Eq, PartialEq)]
20pub enum ShellActivity {
21 Inspection,
23 Verification,
25 Mutation,
27}
28
29fn make_like_verification_targets(words: &[String]) -> bool {
30 let mut saw_target = false;
31 for word in words.iter().skip(1) {
32 let lower = word.to_ascii_lowercase();
33 if lower.starts_with('-') || lower.contains('=') {
34 continue;
35 }
36 if !matches!(lower.as_str(), "test" | "tests" | "check" | "checks" | "lint" | "verify" | "validate") {
37 return false;
38 }
39 saw_target = true;
40 }
41 saw_target
42}
43
44fn python_verification_arguments(arguments: &[String]) -> bool {
45 let mut arguments = arguments.iter();
47 let target = arguments.find(|word| {
48 !matches!(word.as_str(), "-I" | "-E" | "-s" | "-S" | "-B" | "-O" | "-OO" | "-q" | "-u" | "-b" | "-bb" | "-v")
49 });
50 match target.map(String::as_str) {
51 Some("-m") => arguments.next().is_some_and(|module| module == "pytest"),
52 Some(script) => {
53 ["scripts/check_markdown.py", "scripts/check_docs_links.py"]
54 .iter()
55 .any(|known| Path::new(script).ends_with(known))
56 && arguments.next().is_none()
57 }
58 None => false,
59 }
60}
61
62fn is_verification_invocation(words: &[String]) -> bool {
63 if crate::tools::command_args::command_uses_source_repair(words)
66 || crate::tools::command_args::environment_prefix_has_injection_keys(words)
67 {
68 return false;
69 }
70 let command_words = crate::tools::command_args::command_words_after_environment_prefix(words);
71 let first = command_words.first().map(String::as_str).unwrap_or_default();
72 let second = command_words.get(1).map(|word| word.to_ascii_lowercase());
73 let third = command_words.get(2).map(|word| word.to_ascii_lowercase());
74 let program = Path::new(first)
75 .file_name()
76 .and_then(|name| name.to_str())
77 .unwrap_or(first)
78 .to_ascii_lowercase();
79
80 match program.as_str() {
81 "markdownlint-cli2" => command_words.get(1..).is_some_and(markdownlint_arguments_verify),
82 "npx" => {
83 let package_index = command_words
84 .iter()
85 .enumerate()
86 .skip(1)
87 .find(|(_, word)| {
88 !matches!(word.as_str(), "--yes" | "-y" | "--no-install")
89 && !word.strip_prefix("--loglevel=").is_some_and(|level| {
90 matches!(
91 level,
92 "silent" | "error" | "warn" | "notice" | "http" | "info" | "verbose" | "silly"
93 )
94 })
95 })
96 .map(|(index, _)| index);
97 package_index.is_some_and(|index| {
98 let Some(package) = command_words.get(index) else {
99 return false;
100 };
101 let is_markdownlint = package == "markdownlint-cli2"
102 || package.strip_prefix("markdownlint-cli2@").is_some_and(|version| {
103 version.split('.').count() == 3
104 && version
105 .split('.')
106 .all(|part| !part.is_empty() && part.bytes().all(|byte| byte.is_ascii_digit()))
107 });
108 is_markdownlint && command_words.get(index + 1..).is_some_and(markdownlint_arguments_verify)
109 })
110 }
111 "cargo" => {
112 if second.as_deref() == Some("fmt") {
113 return words.iter().any(|word| word == "--check");
116 }
117 matches!(second.as_deref(), Some("check" | "build" | "clippy" | "test"))
118 || (second.as_deref() == Some("nextest") && third.as_deref() == Some("run"))
119 }
120 "go" => matches!(second.as_deref(), Some("test" | "build")),
121 "npm" | "pnpm" | "yarn" => {
122 matches!(second.as_deref(), Some("test" | "build" | "lint" | "check"))
123 || (second.as_deref() == Some("run")
124 && matches!(third.as_deref(), Some("test" | "build" | "lint" | "check")))
125 }
126 "bun" | "bunx" => {
127 matches!(second.as_deref(), Some("test"))
128 || (second.as_deref() == Some("run")
129 && matches!(third.as_deref(), Some("test" | "build" | "lint" | "check")))
130 }
131 "deno" => matches!(second.as_deref(), Some("test" | "lint" | "check")),
132 "make" | "gmake" | "just" => make_like_verification_targets(command_words),
133 "uv" => {
134 let Some(arguments) = crate::tools::command_args::uv_run_command_words(command_words) else {
135 return false;
136 };
137 let mut arguments = arguments.iter();
138 let target = arguments.next();
139 match target.map(String::as_str) {
140 Some("pytest") => true,
141 Some("ruff") => arguments.next().is_some_and(|argument| argument == "check"),
142 Some("python" | "python3") => python_verification_arguments(arguments.as_slice()),
143 _ => false,
144 }
145 }
146 "ruff" => {
147 if second.as_deref() == Some("format") {
148 return words.iter().any(|word| word == "--check");
149 }
150 matches!(second.as_deref(), Some("check" | "lint"))
151 }
152 "tsc" => {
153 words.iter().any(|word| word == "--noEmit")
155 }
156 "eslint" => {
157 !words.iter().any(|word| word == "--fix" || word == "--fix-dry-run")
159 }
160 "rustc" | "pytest" | "xcodebuild" | "gradle" | "gradlew" => true,
161 "python" | "python3" => python_verification_arguments(&command_words[1..]),
162 _ if first.ends_with("/scripts/check.sh") || first.ends_with("/scripts/check-dev.sh") => true,
163 _ => false,
164 }
165}
166
167fn markdownlint_arguments_verify(arguments: &[String]) -> bool {
168 !arguments.is_empty()
169 && !arguments.iter().any(|argument| {
170 matches!(argument.as_str(), "--fix" | "-f" | "--help" | "-h" | "--version" | "-v")
171 || argument.starts_with("--fix=")
172 })
173}
174
175fn contains_verification_invocation(command: &str) -> bool {
176 static_shell_command_words(command)
177 .is_some_and(|commands| commands.iter().any(|words| is_verification_invocation(words)))
178}
179
180pub fn shell_command_contains_verifier(args: &Value) -> bool {
184 let Some(command) = shell_command_text_with_args(args) else {
185 return false;
186 };
187 super::readonly::parse_shell_command_words(&command)
188 .is_some_and(|commands| commands.iter().any(|words| is_verification_invocation(words)))
189}
190
191pub fn shell_command_is_output_search(args: &Value) -> bool {
198 let Some(command) = shell_command_text_with_args(args) else {
199 return false;
200 };
201 let Some(segments) = static_shell_command_words(&command) else {
202 return false;
203 };
204 let mut saw_search = false;
205 for words in &segments {
206 if !command_words_are_readonly(words) || is_verification_invocation(words) {
207 return false;
208 }
209 let words = crate::tools::command_args::command_words_after_environment_prefix(words);
210 if command_words_are_grep_search(words) {
211 if grep_words_request_quiet_output(words) {
212 return false;
213 }
214 saw_search = true;
215 }
216 }
217 saw_search
218}
219
220fn grep_words_request_quiet_output(words: &[String]) -> bool {
221 let is_rg = words
222 .first()
223 .and_then(|word| Path::new(word).file_name())
224 .is_some_and(|name| name == "rg");
225 let mut arguments = words.iter().skip(1);
226 let mut quiet = false;
227 while let Some(argument) = arguments.next() {
228 match argument.as_str() {
229 "--" => break,
230 "--quiet" | "--silent" => quiet = true,
231 "--no-quiet" if is_rg => quiet = false,
232 _ if argument.starts_with("--") => {
233 let takes_value = matches!(
235 argument.as_str(),
236 "--regexp"
237 | "--file"
238 | "--after-context"
239 | "--before-context"
240 | "--context"
241 | "--max-count"
242 | "--include"
243 | "--exclude"
244 | "--exclude-from"
245 | "--exclude-dir"
246 | "--label"
247 | "--directories"
248 | "--devices"
249 ) || (is_rg
250 && matches!(
251 argument.as_str(),
252 "--glob"
253 | "--iglob"
254 | "--ignore-file"
255 | "--pre-glob"
256 | "--type"
257 | "--type-not"
258 | "--type-add"
259 | "--type-clear"
260 | "--replace"
261 | "--encoding"
262 | "--engine"
263 | "--color"
264 | "--colors"
265 | "--threads"
266 | "--max-depth"
267 | "--max-columns"
268 | "--max-filesize"
269 | "--dfa-size-limit"
270 | "--regex-size-limit"
271 | "--context-separator"
272 | "--field-context-separator"
273 | "--field-match-separator"
274 | "--hyperlink-format"
275 | "--path-separator"
276 | "--sort"
277 | "--sortr"
278 | "--generate"
279 ));
280 if takes_value {
281 arguments.next();
282 }
283 }
284 _ if argument.starts_with('-') => {
285 let mut flags = argument.chars().skip(1).peekable();
286 while let Some(flag) = flags.next() {
287 if flag == 'q' {
288 quiet = true;
289 }
290 let takes_value = matches!(flag, 'e' | 'f' | 'A' | 'B' | 'C' | 'm' | 'd')
291 || (is_rg && matches!(flag, 'g' | 't' | 'T' | 'r' | 'E' | 'j' | 'M'))
292 || (!is_rg && flag == 'D');
293 if takes_value {
294 if flags.peek().is_none() {
295 arguments.next();
296 }
297 break;
298 }
299 }
300 }
301 _ => {}
302 }
303 }
304 quiet
305}
306
307pub fn shell_command_is_standalone_grep_search(args: &Value) -> bool {
311 let Some(command) = shell_command_text_with_args(args) else {
312 return false;
313 };
314 let Some(segments) = static_shell_command_words(&command) else {
315 return false;
316 };
317 let [words] = segments.as_slice() else {
318 return false;
319 };
320 shell_words::split(&command).is_ok_and(|raw_words| raw_words == *words)
321 && command_words_are_readonly(words)
322 && command_words_are_grep_search(words)
323}
324
325fn command_words_are_grep_search(words: &[String]) -> bool {
326 let words = crate::tools::command_args::command_words_after_environment_prefix(words);
327 let program = words
328 .first()
329 .and_then(|word| Path::new(word).file_name())
330 .and_then(|name| name.to_str());
331 matches!(program, Some("rg" | "grep" | "egrep" | "fgrep"))
332}
333
334const PRE_NODE_VERIFIER_MARKERS: &[(&[&str], &str)] = &[
338 (&["Cargo.toml"], "cargo check --locked"),
339 (&["go.mod"], "go test ./..."),
340 (&["deno.json", "deno.jsonc"], "deno test"),
341 (&["bun.lockb", "bun.lock"], "bun test"),
342];
343
344const POST_NODE_VERIFIER_MARKERS: &[(&[&str], &str)] = &[
347 (&["pytest.ini", "pyproject.toml", "setup.cfg", "tox.ini"], "pytest -q"),
348 (&["Makefile", "makefile"], "make test"),
349 (&["justfile", "Justfile"], "just test"),
350];
351
352fn verifier_for_markers(workspace_root: &Path, markers: &[(&[&str], &str)]) -> Option<String> {
354 markers.iter().find_map(|(files, verifier)| {
355 files
356 .iter()
357 .any(|file| workspace_root.join(file).is_file())
358 .then(|| (*verifier).to_string())
359 })
360}
361
362fn default_verifier_for_node_workspace(workspace_root: &Path) -> Option<String> {
367 let package_json = workspace_root.join("package.json");
368 if !package_json.is_file() {
369 return None;
370 }
371 if let Ok(content) = std::fs::read_to_string(&package_json)
372 && let Ok(parsed) = serde_json::from_str::<Value>(&content)
373 && let Some(scripts) = parsed.get("scripts").and_then(Value::as_object)
374 {
375 for (script, command) in [
376 ("test", "npm test"),
377 ("check", "npm run check"),
378 ("lint", "npm run lint"),
379 ("build", "npm run build"),
380 ] {
381 if scripts.contains_key(script) {
382 return Some(command.to_string());
383 }
384 }
385 }
386 Some("npm test".to_string())
387}
388
389pub fn default_verifier_for_workspace(workspace_root: &Path) -> Option<String> {
405 if let Some(verifier) = verifier_for_markers(workspace_root, PRE_NODE_VERIFIER_MARKERS) {
406 return Some(verifier);
407 }
408 if let Some(verifier) = default_verifier_for_node_workspace(workspace_root) {
409 return Some(verifier);
410 }
411 verifier_for_markers(workspace_root, POST_NODE_VERIFIER_MARKERS)
413}
414
415pub const VERIFIER_SHELL_FORM_NOTE: &str = "Cap output with `max_output_tokens`. Pure `head`/`tail` verifier tails run standalone; static read-only filtering pipelines run with fail-closed `pipefail`. Only terminal exit 0 clears the gate; `;`, `||`, dynamic syntax, and mutating tails do not qualify.";
419
420pub const GENERIC_VERIFIER_DESCRIPTION: &str =
426 "your project's build/test/lint command (e.g. `cargo check --locked`, `go test ./...`, `npm test`, or `pytest -q`)";
427
428pub fn verifier_reference(default_verifier: Option<&str>) -> String {
433 match default_verifier.map(str::trim).filter(|command| !command.is_empty()) {
434 Some(command) => format!("`{command}`"),
435 None => GENERIC_VERIFIER_DESCRIPTION.to_string(),
436 }
437}
438
439pub fn verification_recovery_directive(default_verifier: Option<&str>, attempt: u8, max_attempts: u8) -> String {
444 let verifier = verifier_reference(default_verifier);
445 format!(
446 "Verification recovery ({attempt}/{max_attempts}): pending edits have not been verified, so further mutations are blocked \
447 and the turn ends blocked unless a verifier exits 0. Run {verifier} with `exec_command`, standalone or as a pure `&&` chain of verifiers. \
448 {VERIFIER_SHELL_FORM_NOTE} A failed verifier grants a bounded number of fix-up edits before the next verification is required."
449 )
450}
451
452pub fn shell_command_is_admitted_verification_attempt(args: &Value) -> bool {
467 let Some(command) = shell_command_text_with_args(args) else {
468 return false;
469 };
470 if crate::tools::command_args::contains_dynamic_shell_syntax(&command) {
471 return false;
472 }
473 if command.starts_with(VERIFIER_PIPEFAIL_PREFIX) {
474 return pipefail_verifier_pipeline(&command).is_some();
475 }
476 let segments =
477 static_shell_command_words(&command).or_else(|| static_shell_command_words_with_output_plumbing(&command));
478 let Some(segments) = segments else {
479 return false;
480 };
481 if segments.is_empty() {
482 return false;
483 }
484 let mut saw_verification = false;
485 for words in &segments {
486 if is_verification_invocation(words) {
487 saw_verification = true;
488 } else if !command_words_are_readonly(words) {
489 return false;
490 }
491 }
492 saw_verification
493}
494
495fn has_logical_sequencing(words: &[String]) -> bool {
496 words.iter().any(|word| matches!(word.as_str(), "&&" | "||" | ";"))
497}
498
499fn split_top_level_pipes(command: &str) -> Option<Vec<&str>> {
506 if !command.contains('|') {
507 return None;
508 }
509 let mut stages = Vec::new();
510 let mut start = 0;
511 let mut saw_pipe = false;
512 let mut in_single_quote = false;
513 let mut in_double_quote = false;
514 let mut chars = command.char_indices().peekable();
515 while let Some((index, character)) = chars.next() {
516 if character == '\\' && !in_single_quote {
520 chars.next();
521 continue;
522 }
523 if character == '\'' && !in_double_quote {
524 in_single_quote = !in_single_quote;
525 continue;
526 }
527 if character == '"' && !in_single_quote {
528 in_double_quote = !in_double_quote;
529 continue;
530 }
531 if in_single_quote || in_double_quote {
532 continue;
533 }
534 if character == '|' {
535 saw_pipe = true;
536 stages.push(&command[start..index]);
537 start = index + character.len_utf8();
538 }
539 }
540 if !saw_pipe {
541 return None;
542 }
543 stages.push(&command[start..]);
544 Some(stages)
545}
546
547fn is_pure_truncation_stage(stage: &str) -> bool {
553 let trimmed = stage.trim();
554 if trimmed.is_empty() {
555 return false;
556 }
557 let mut in_single_quote = false;
562 let mut in_double_quote = false;
563 let mut chars = trimmed.chars().peekable();
564 while let Some(character) = chars.next() {
565 if character == '\\' && !in_single_quote {
566 chars.next();
567 continue;
568 }
569 if character == '\'' && !in_double_quote {
570 in_single_quote = !in_single_quote;
571 continue;
572 }
573 if character == '"' && !in_single_quote {
574 in_double_quote = !in_double_quote;
575 continue;
576 }
577 if in_single_quote || in_double_quote {
578 continue;
579 }
580 if matches!(character, ';' | '&' | '<' | '>' | '\n' | '|') {
581 return false;
582 }
583 }
584 let words = match shell_words::split(trimmed) {
585 Ok(words) => words,
586 Err(_) => return false,
587 };
588 let Some(parsed) = NonEmptySlice::from_slice(&words) else {
589 return false;
590 };
591 let program = Path::new(parsed.first())
592 .file_name()
593 .and_then(|name| name.to_str())
594 .unwrap_or(parsed.first().as_str())
595 .to_ascii_lowercase();
596 matches!(program.as_str(), "head" | "tail")
597}
598
599pub fn rewrite_truncation_only_verifier(args: &Value) -> Option<String> {
629 use crate::config::constants::tools as tool_names;
630
631 let command = shell_command_text_with_args(args)?;
632 if !shell_command_is_admitted_verification_attempt(args) {
633 return None;
634 }
635 let stages = split_top_level_pipes(&command)?;
636 let (head, tails) = stages.split_first()?;
637 let head = head.trim();
638 if head.is_empty() || tails.iter().any(|stage| !is_pure_truncation_stage(stage)) {
639 return None;
640 }
641 let head_args = serde_json::json!({"cmd": head});
642 if !matches!(classify_shell_activity(tool_names::EXEC_COMMAND, &head_args), ShellActivity::Verification) {
643 return None;
644 }
645 Some(head.to_string())
646}
647
648const VERIFIER_PIPEFAIL_PREFIX: &str = "set -o pipefail && ";
650
651pub(crate) fn pipefail_verifier_pipeline(command: &str) -> Option<&str> {
654 let pipeline = command.strip_prefix(VERIFIER_PIPEFAIL_PREFIX)?;
655 if crate::tools::command_args::contains_dynamic_shell_syntax(pipeline) {
656 return None;
657 }
658 verifier_pipeline_head(pipeline).map(|_| pipeline)
659}
660
661fn verifier_pipeline_head(command: &str) -> Option<&str> {
663 let stages = split_top_level_pipes(command)?;
664 let (head, tails) = stages.split_first()?;
665 let head = head.trim();
666 let head_args = serde_json::json!({"cmd": head});
667 if classify_shell_activity(crate::config::constants::tools::EXEC_COMMAND, &head_args) != ShellActivity::Verification
668 {
669 return None;
670 }
671 for stage in tails {
674 let segments = static_shell_command_words(stage)?;
675 if segments.len() != 1 || !segments.first().is_some_and(|words| command_words_are_readonly(words)) {
676 return None;
677 }
678 }
679 Some(head)
680}
681
682fn rewrite_verifier_pipeline(args: &Value) -> Option<String> {
687 if let Some(standalone) = rewrite_truncation_only_verifier(args) {
688 return Some(standalone);
689 }
690 let command = shell_command_text_with_args(args)?;
691 if command.starts_with(VERIFIER_PIPEFAIL_PREFIX)
692 || !shell_command_is_admitted_verification_attempt(args)
693 || verifier_pipeline_head(&command).is_none()
694 {
695 return None;
696 }
697 Some(format!("{VERIFIER_PIPEFAIL_PREFIX}{command}"))
698}
699
700pub fn shell_args_as_executed<'a>(tool_name: &str, args: &'a Value) -> std::borrow::Cow<'a, Value> {
712 if !super::is_command_run_tool_call(tool_name, args) {
713 return std::borrow::Cow::Borrowed(args);
714 }
715 let Some(rewritten) = rewrite_verifier_pipeline(args) else {
716 return std::borrow::Cow::Borrowed(args);
717 };
718 let mut executed = args.clone();
719 match executed.as_object_mut() {
720 Some(payload) => {
721 if payload.get("raw_command").is_some_and(Value::is_string) {
722 payload.insert("raw_command".to_string(), Value::String(rewritten.clone()));
723 }
724 payload.remove("args");
727 payload.insert("command".to_string(), Value::String(rewritten));
728 std::borrow::Cow::Owned(executed)
729 }
730 None => std::borrow::Cow::Borrowed(args),
731 }
732}
733
734fn is_known_inspection(words: &[String]) -> bool {
735 if words
736 .iter()
737 .any(|word| matches!(word.as_str(), ">" | ">>" | "|" | "&&" | ";" | "||"))
738 {
739 return false;
740 }
741 command_words_are_readonly(words)
742}
743
744fn classify_provable_shell_sequence(command: &str) -> Option<ShellActivity> {
745 let (segments, has_output_plumbing) = if let Some(segments) = static_shell_command_words(command) {
746 (segments, false)
747 } else {
748 (static_shell_command_words_with_output_plumbing(command)?, true)
749 };
750 if has_output_plumbing && segments.len() != 1 {
751 return None;
752 }
753 let has_multiple_segments = segments.len() > 1;
754 let mut saw_verification = false;
755
756 for words in segments {
757 if is_verification_invocation(&words) {
758 saw_verification = true;
759 } else if !command_words_are_readonly(&words) {
760 return None;
761 }
762 }
763
764 if has_output_plumbing && !saw_verification {
765 return None;
766 }
767
768 if saw_verification && has_multiple_segments {
779 if shell_uses_only_and_chaining(command) {
780 return Some(ShellActivity::Verification);
781 }
782 return Some(ShellActivity::Mutation);
783 }
784
785 Some(if saw_verification {
786 ShellActivity::Verification
787 } else {
788 ShellActivity::Inspection
789 })
790}
791
792fn has_shell_sequence(command: &str) -> bool {
793 static_shell_command_words(command).is_none_or(|segments| segments.len() > 1)
794}
795
796fn shell_uses_only_and_chaining(command: &str) -> bool {
806 let chars: Vec<char> = command.chars().collect();
807 let mut index = 0;
808 let mut in_single_quote = false;
809 let mut in_double_quote = false;
810
811 while index < chars.len() {
812 let character = chars[index];
813 if character == '\\' && !in_single_quote && index + 1 < chars.len() {
818 index += 2;
819 continue;
820 }
821 if character == '\'' && !in_double_quote {
822 in_single_quote = !in_single_quote;
823 index += 1;
824 continue;
825 }
826 if character == '"' && !in_single_quote {
827 in_double_quote = !in_double_quote;
828 index += 1;
829 continue;
830 }
831 if in_single_quote || in_double_quote {
832 index += 1;
833 continue;
834 }
835 match character {
836 ';' | '\n' | '|' | '!' | '(' | ')' => return false,
839 '&' => {
840 let next_is_and = chars.get(index + 1) == Some(&'&');
841 if !next_is_and {
842 return false;
843 }
844 let third = chars.get(index + 2);
848 if third == Some(&'&') || third == Some(&'|') {
849 return false;
850 }
851 index += 2;
852 continue;
853 }
854 _ => {}
855 }
856 index += 1;
857 }
858
859 true
860}
861
862pub(crate) fn shell_command_covers_required_check(command: &str, required: &str) -> bool {
865 if !shell_uses_only_and_chaining(command) || !shell_uses_only_and_chaining(required) {
866 return false;
867 }
868 let Some(segments) = static_shell_command_words(command) else {
869 return false;
870 };
871 let Some(required_segments) = static_shell_command_words(required) else {
872 return false;
873 };
874 if segments.iter().any(|words| {
875 words.first().is_some_and(|word| word == "cd")
876 || (!is_verification_invocation(words) && !command_words_are_readonly(words))
877 }) {
878 return false;
879 }
880 segments
881 .windows(required_segments.len())
882 .any(|window| window == required_segments)
883}
884
885#[must_use]
894pub fn classify_shell_activity(tool_name: &str, args: &Value) -> ShellActivity {
895 let command = shell_command_text_with_args(args);
896 if command.is_none() && crate::tools::command_args::raw_command_text(args).is_some() {
897 return ShellActivity::Mutation;
898 }
899 if command.as_deref().and_then(pipefail_verifier_pipeline).is_some() {
900 return ShellActivity::Verification;
901 }
902 let words = crate::tools::command_args::command_words(args).ok().flatten();
903 let has_unclassified_shell_sequence = command.as_deref().is_some_and(has_shell_sequence);
904
905 if let Some(activity) = command.as_deref().and_then(classify_provable_shell_sequence) {
906 return activity;
907 }
908 if has_unclassified_shell_sequence {
911 return ShellActivity::Mutation;
912 }
913
914 let intent = super::classify_tool_intent(tool_name, args);
915
916 if !has_unclassified_shell_sequence && words.as_deref().is_some_and(is_known_inspection) {
917 return ShellActivity::Inspection;
918 }
919
920 let starts_with_verification = words.as_deref().is_some_and(is_verification_invocation);
921 let contains_verification =
922 starts_with_verification || command.as_deref().is_some_and(contains_verification_invocation);
923 if !intent.mutating {
924 return if contains_verification {
925 ShellActivity::Verification
926 } else {
927 ShellActivity::Inspection
928 };
929 }
930
931 if starts_with_verification
932 && !has_unclassified_shell_sequence
933 && !words.as_deref().is_some_and(has_logical_sequencing)
934 {
935 ShellActivity::Verification
936 } else {
937 ShellActivity::Mutation
938 }
939}
940
941#[must_use]
945pub fn exec_mutation_should_incur_debt(command: &str, output: &Value) -> bool {
946 if exec_output_has_changes(output) {
947 return true;
948 }
949 let mut parser = tree_sitter::Parser::new();
950 if parser.set_language(&tree_sitter_bash::LANGUAGE.into()).is_err() {
951 return true;
952 }
953 let Some(tree) = parser.parse(command, None) else {
954 return true;
955 };
956 let root = tree.root_node();
957 root.has_error() || root.named_child_count() == 0 || !nonmutating_shell_node(root, command)
958}
959
960pub(crate) fn exec_output_has_changes(output: &Value) -> bool {
961 output
962 .get("modified_files")
963 .and_then(Value::as_array)
964 .is_some_and(|files| !files.is_empty())
965 || crate::tools::file_ops::diff_output_has_effective_change(output) == Some(true)
966}
967
968fn is_literal_path_glob(word: &str) -> bool {
972 let Some((prefix, _)) = word.split_once('/') else {
973 return false;
974 };
975 !prefix.starts_with('-')
976 && !prefix.contains(['*', '?', '[', ']'])
977 && word.contains(['*', '?'])
978 && !word.contains(['[', ']'])
979 && !word
980 .chars()
981 .any(|character| character.is_whitespace() || "\"'$`\\{}()<>&;|~!".contains(character))
982}
983
984fn nonmutating_shell_node(node: tree_sitter::Node<'_>, source: &str) -> bool {
987 use crate::command_safety::shell_parser::contains_dynamic_shell_syntax;
988 let Ok(text) = node.utf8_text(source.as_bytes()) else {
989 return false;
990 };
991 match node.kind() {
992 "command" => {
993 let Some(name) = node.child_by_field_name("name") else {
994 return false;
995 };
996 let Ok(name_text) = name.utf8_text(source.as_bytes()) else {
997 return false;
998 };
999 if contains_dynamic_shell_syntax(name_text) {
1000 return false;
1001 }
1002 let mut words = Vec::new();
1003 let mut cursor = node.walk();
1004 for child in node.named_children(&mut cursor) {
1005 let Ok(word) = child.utf8_text(source.as_bytes()) else {
1006 return false;
1007 };
1008 if child.kind() == "variable_assignment" {
1009 return false;
1010 }
1011 if child != name
1012 && child.kind() == "word"
1013 && is_literal_path_glob(word)
1014 && matches!(name_text, "rg" | "grep" | "egrep" | "fgrep" | "ls" | "cat" | "head" | "tail" | "wc")
1015 {
1016 words.push(word.to_owned());
1019 } else if contains_dynamic_shell_syntax(word) {
1020 if !matches!(name_text, "echo" | "test") || !word.starts_with('"') {
1024 return false;
1025 }
1026 words.push("dynamic-argument".to_owned());
1027 } else {
1028 let Ok(parts) = shell_words::split(word) else {
1029 return false;
1030 };
1031 words.extend(parts);
1032 }
1033 }
1034 if crate::tools::command_args::has_unsafe_readonly_options(&words)
1035 || (!is_verification_invocation(&words) && !command_words_are_readonly(&words))
1036 {
1037 return false;
1038 }
1039 }
1040 "file_redirect" => {
1041 return matches!(text, "2>&1" | "1>&2") || (cfg!(unix) && text == "2>/dev/null");
1044 }
1045 "word" | "regex" => return !contains_dynamic_shell_syntax(text) || is_literal_path_glob(text),
1046 "raw_string" | "string_content" | "comment" | "number" | "variable_name" | "special_variable_name" => {
1047 return true;
1048 }
1049 "simple_expansion" => return true,
1050 "expansion" => {
1051 if text.contains([':', '=', '+', '?', '!']) {
1054 return false;
1055 }
1056 }
1057 "program"
1058 | "list"
1059 | "pipeline"
1060 | "for_statement"
1061 | "do_group"
1062 | "if_statement"
1063 | "elif_clause"
1064 | "else_clause"
1065 | "while_statement"
1066 | "command_name"
1067 | "string"
1068 | "concatenation"
1069 | "command_substitution"
1070 | "redirected_statement" => {}
1071 _ if !node.is_named() => {
1072 return matches!(
1073 text,
1074 "&&" | "||"
1075 | ";"
1076 | "|"
1077 | "for"
1078 | "in"
1079 | "do"
1080 | "done"
1081 | "if"
1082 | "then"
1083 | "elif"
1084 | "else"
1085 | "fi"
1086 | "while"
1087 | "until"
1088 | "\""
1089 | "'"
1090 | "$("
1091 | ")"
1092 | "`"
1093 | "$"
1094 | "${"
1095 | "}"
1096 | "#"
1097 | "##"
1098 | "%"
1099 | "%%"
1100 );
1101 }
1102 _ => return false,
1103 }
1104 let mut cursor = node.walk();
1105 node.children(&mut cursor).all(|child| nonmutating_shell_node(child, source))
1106}
1107
1108#[cfg(test)]
1109mod tests {
1110 use serde_json::json;
1111
1112 use super::*;
1113 use crate::config::constants::tools;
1114 use crate::tools::tool_intent::is_readonly_command_session_command;
1115
1116 fn exec_command(command: &str) -> Value {
1117 json!({"cmd": command})
1118 }
1119
1120 #[test]
1121 fn source_repair_commands_create_debt_instead_of_verifying() {
1122 for command in [
1123 "cargo clippy --fix --allow-dirty",
1124 "env LD_PRELOAD=./rewrite.so cargo clippy",
1125 "cargo +nightly clippy --fix",
1126 "cargo fix --allow-dirty",
1127 "ruff check --fix .",
1128 "ruff check --fix-only .",
1129 "ruff check --add-noqa .",
1130 "ruff check --add-ignore=reason .",
1131 "uv run --frozen --offline ruff check --fix .",
1132 "cargo clippy --fix && cargo check",
1133 "ruff check --fix . | head -20",
1134 "ruff check --fix --exclude --diff .",
1136 ] {
1137 let args = exec_command(command);
1138 assert_eq!(classify_shell_activity(tools::EXEC_COMMAND, &args), ShellActivity::Mutation, "{command}");
1139 assert!(!shell_command_is_admitted_verification_attempt(&args), "{command}");
1140 assert!(exec_mutation_should_incur_debt(command, &json!({"exit_code": 0})), "{command}");
1141 }
1142 for command in [
1143 "cargo clippy --locked",
1144 "cargo nextest run fix",
1145 "ruff check --no-fix .",
1146 "ruff check --diff .",
1147 "uv run --frozen --offline ruff check --no-fix .",
1148 ] {
1149 let args = exec_command(command);
1150 assert_eq!(classify_shell_activity(tools::EXEC_COMMAND, &args), ShellActivity::Verification, "{command}");
1151 assert!(shell_command_is_admitted_verification_attempt(&args), "{command}");
1152 assert!(!exec_mutation_should_incur_debt(command, &json!({"exit_code": 0})), "{command}");
1153 }
1154 }
1155
1156 #[test]
1157 fn compiler_artifact_writes_remain_eligible_verification() {
1158 let command = "rustc --crate-type lib sample.rs -o checked.rlib";
1159 let args = exec_command(command);
1160 assert_eq!(classify_shell_activity(tools::EXEC_COMMAND, &args), ShellActivity::Verification);
1161 assert!(shell_command_is_admitted_verification_attempt(&args));
1162 assert!(exec_mutation_should_incur_debt(command, &json!({"exit_code":0})));
1165 }
1166
1167 #[test]
1168 fn verifier_diagnostics_recognize_masked_status_without_granting_admission() {
1169 for command in [
1170 "npx markdownlint-cli2 README.md; echo \"lint exit: $?\"",
1171 "cargo check; echo $?",
1172 "python3 scripts/check_markdown.py; echo \"lint exit: $?\"",
1173 "cargo check --locked || true",
1174 "cargo check; rm marker",
1175 ] {
1176 let args = exec_command(command);
1177 assert!(shell_command_contains_verifier(&args), "{command}");
1178 assert_eq!(classify_shell_activity(tools::EXEC_COMMAND, &args), ShellActivity::Mutation);
1179 }
1180 let masked = exec_command("npx markdownlint-cli2 README.md; echo \"lint exit: $?\"");
1181 assert!(!shell_command_is_admitted_verification_attempt(&masked));
1182 assert_eq!(shell_args_as_executed(tools::EXEC_COMMAND, &masked).as_ref(), &masked);
1183 for args in [
1184 exec_command("echo 'cargo check; echo $?'"),
1185 exec_command("grep 'cargo check' README.md"),
1186 exec_command("npx markdownlint-cli2 --fix README.md"),
1187 json!({"cmd":"cargo check; true", "raw_command":"echo hello"}),
1188 json!({"cmd":"npx markdownlint-cli2", "args":["--fix", "README.md"]}),
1189 ] {
1190 assert!(!shell_command_contains_verifier(&args), "{args}");
1191 }
1192 }
1193
1194 #[test]
1195 fn markdownlint_verification_preserves_command_boundaries() {
1196 for command in [
1197 "markdownlint-cli2 README.md",
1198 "npx --yes markdownlint-cli2 README.md",
1199 "npx --no-install markdownlint-cli2@0.23.3 README.md",
1200 "npx --yes --loglevel=error markdownlint-cli2@0.23.3 --config .markdownlint-cli2.jsonc ':README.md'",
1201 "npx -y markdownlint-cli2 README.md && cargo check --locked",
1202 "python3 scripts/check_markdown.py",
1203 "python ./scripts/check_markdown.py",
1204 "python3 scripts/check_docs_links.py",
1205 "python ./scripts/check_docs_links.py",
1206 ] {
1207 assert!(shell_command_is_admitted_verification_attempt(&exec_command(command)), "{command}");
1208 assert_eq!(
1209 classify_shell_activity(tools::EXEC_COMMAND, &exec_command(command)),
1210 ShellActivity::Verification,
1211 "{command}"
1212 );
1213 }
1214 for command in [
1215 "markdownlint-cli2 --fix README.md",
1216 "npx --yes markdownlint-cli2 -f README.md",
1217 "npx --yes markdownlint-cli2 --fix=true README.md",
1218 "markdownlint-cli2 --version",
1219 "npx --package evil markdownlint-cli2 README.md",
1220 "npx --loglevel=error --package evil markdownlint-cli2 README.md",
1221 "npx --loglevel=error markdownlint-cli2 --fix README.md",
1222 "npx --loglevel=unknown markdownlint-cli2 README.md",
1223 "npx markdownlint-cli2@evil README.md",
1224 "npx markdownlint-cli2 README.md && rm README.md",
1225 "python3 scripts/check_markdown.py --fix",
1226 "python3 scripts/check_markdown.py --list",
1227 "python3 scripts/check_markdown.py --help",
1228 "python3 other/check_markdown.py",
1229 "python3 scripts/check_docs_links.py --help",
1230 "python3 other/check_docs_links.py",
1231 ] {
1232 assert!(!shell_command_is_admitted_verification_attempt(&exec_command(command)), "{command}");
1233 }
1234 assert_ne!(
1235 classify_shell_activity(tools::EXEC_COMMAND, &exec_command("npx markdownlint-cli2 README.md | grep error")),
1236 ShellActivity::Verification
1237 );
1238 }
1239
1240 #[test]
1241 fn output_search_recognizes_static_search_pipelines() {
1242 for command in [
1243 "rg --files --hidden -g '.markdownlint*'",
1244 "rg --files --hidden | rg -i 'markdownlint' | sed -n '1,15p'",
1245 "git ls-files | grep -i markdownlint | head -10",
1246 "LANG=C /usr/bin/grep -n missing README.md",
1247 "rg -- '-q' README.md",
1248 "rg -e -query src | head -10",
1249 "grep -ne-query README.md",
1250 "rg --regexp --quiet src",
1251 "rg -g '-q*' missing src",
1252 "rg '-g-q*' missing src",
1253 "rg -q --no-quiet missing src",
1254 ] {
1255 assert!(shell_command_is_output_search(&exec_command(command)), "{command}");
1256 }
1257 assert!(shell_command_is_output_search(&json!({"command": ["rg", "missing", "src"]})));
1258 }
1259
1260 #[test]
1261 fn output_search_excludes_quiet_probes_and_unproven_commands() {
1262 for command in [
1263 "rg -q pattern src",
1264 "grep -nq pattern README.md",
1265 "rg --quiet pattern src | head -10",
1266 "grep --silent pattern README.md",
1267 "rg -e -query -q src",
1268 "grep -nqe-query README.md",
1269 "rg --regexp --quiet --quiet src",
1270 "rg -q --no-quiet -q missing src",
1271 "echo 'rg missing'",
1272 "cat empty.txt",
1273 "cargo check | rg error",
1274 "rg missing src; touch result.txt",
1275 "rg missing $(pwd)",
1276 "rg missing src > result.txt",
1277 "rg --pre 'touch result.txt' missing src",
1278 ] {
1279 assert!(!shell_command_is_output_search(&exec_command(command)), "{command}");
1280 }
1281 assert!(!shell_command_is_output_search(&json!({})));
1282 }
1283
1284 #[test]
1285 fn output_search_distinguishes_separator_values_from_quiet_flags() {
1286 for option in [
1287 "--context-separator",
1288 "--field-context-separator",
1289 "--field-match-separator",
1290 ] {
1291 let command = format!("rg {option} -q missing src | head -10");
1292 assert!(shell_command_is_output_search(&exec_command(&command)), "{command}");
1293 let quiet_command = format!("rg {option} -q --quiet missing src | head -10");
1294 assert!(!shell_command_is_output_search(&exec_command(&quiet_command)), "{quiet_command}");
1295 }
1296 }
1297
1298 #[test]
1299 fn standalone_grep_search_preserves_exit_status_ownership() {
1300 for command in [
1301 "rg missing src",
1302 "grep -q missing README.md",
1303 "LANG=C /usr/bin/grep -n missing README.md",
1304 "rg 'a|b' src",
1305 "fgrep missing README.md",
1306 ] {
1307 assert!(shell_command_is_standalone_grep_search(&exec_command(command)), "{command}");
1308 }
1309 assert!(shell_command_is_standalone_grep_search(&json!({"command": ["rg", "missing", "src"]})));
1310 for command in [
1311 "rg missing src; false",
1312 "rg missing src && false",
1313 "rg missing src | grep other",
1314 "! rg missing src",
1315 "(rg missing src)",
1316 "echo '/rg missing'; false",
1317 "rg missing src 2>/dev/null",
1318 "rg missing src 2>&1",
1319 "rg missing $(pwd)",
1320 "rg --pre 'false' missing src",
1321 ] {
1322 assert!(!shell_command_is_standalone_grep_search(&exec_command(command)), "{command}");
1323 }
1324 }
1325
1326 #[test]
1327 fn admitted_verification_attempt_allows_truncation_but_blocks_smuggled_mutations() {
1328 for command in [
1329 "cargo check --locked 2>&1 | head -c 4000",
1330 "cargo check --locked",
1331 "cargo nextest run 2>&1 | head -c 4000",
1332 ] {
1333 assert!(
1334 shell_command_is_admitted_verification_attempt(&exec_command(command)),
1335 "expected admission: {command}"
1336 );
1337 }
1338 for command in [
1339 "cargo check && rm -rf target",
1340 "cargo check; rm foo.txt",
1341 "cargo check || rm foo.txt",
1342 "cargo check && cargo test && rm foo.txt",
1343 "sed -i '' 's/old/new/' README.md",
1344 "echo $(date)",
1345 "cargo check > build.log && rm foo.txt",
1346 ] {
1347 assert!(
1348 !shell_command_is_admitted_verification_attempt(&exec_command(command)),
1349 "expected block: {command}"
1350 );
1351 }
1352 assert!(!shell_command_is_admitted_verification_attempt(&json!({})));
1353 }
1354
1355 #[test]
1356 fn logged_compound_inspection_commands_are_not_mutations() {
1357 for command in [
1358 "cat README.md && printf '\\n--- git status ---\\n' && git status --short",
1359 "wc -l README.md; rg -n '^#' README.md",
1360 "git diff --stat; find docs -maxdepth 2 -type f | sort | head -40",
1361 ] {
1362 assert_eq!(
1363 classify_shell_activity(tools::EXEC_COMMAND, &exec_command(command)),
1364 ShellActivity::Inspection,
1365 "{command}"
1366 );
1367 }
1368 }
1369
1370 #[cfg(unix)]
1371 #[test]
1372 fn captured_read_commands_with_output_suppression_are_inspection() {
1373 for command in [
1374 r#"sed -n '1,180p' README.md; sed -n '280,350p' README.md; sed -n '389,411p' README.md; printf '\n--- repo metadata ---\n'; git log -1 --format='%h %s'; sed -n '1,100p' Cargo.toml; rg -n '^version\s*=|rust-version|workspace\.package' Cargo.toml crates -g Cargo.toml | head -40"#,
1375 r#"sed -n '1,120p' crates/codegen/vtcode-core/src/tools/tool_intent/activity.rs; printf '\n--- readonly policy ---\n'; rg -n 'READONLY_UNIFIED_EXEC_COMMANDS|command_words_are_readonly' crates/codegen/vtcode-core/src/tools/tool_intent/readonly.rs; printf '\n--- recent commits ---\n'; git log -5 --oneline; printf '\n--- command arguments ---\n'; sed -n '1,180p' crates/codegen/vtcode-core/src/tools/command_args.rs"#,
1376 r###"git diff --stat; find docs -maxdepth 2 -type f | sort | head -40; rg -n "vtcode init|vtcode models|full-auto|run-debug|cargo install" docs/user-guide docs/installation docs/development 2>/dev/null | head -50"###,
1377 ] {
1378 assert_eq!(
1379 classify_shell_activity(tools::EXEC_COMMAND, &exec_command(command)),
1380 ShellActivity::Inspection,
1381 "{command}"
1382 );
1383 }
1384 }
1385
1386 #[cfg(unix)]
1387 #[test]
1388 fn printf_output_safety_guards_remain_mutations() {
1389 for command in [
1390 "printf 'captured output\\n' > output.txt",
1391 "printf '%s\\n' \"$(git status --short)\"",
1392 "printf '%s\\n' `git status --short`",
1393 "printf '\\n--- inspection ---\\n' && rm output.txt",
1394 ] {
1395 let args = exec_command(command);
1396 assert_eq!(classify_shell_activity(tools::EXEC_COMMAND, &args), ShellActivity::Mutation, "{command}");
1397 assert!(!is_readonly_command_session_command(&args), "unexpected readonly command: {command}");
1398 }
1399 }
1400
1401 #[test]
1402 fn git_diff_check_chain_remains_inspection() {
1403 assert_eq!(
1404 classify_shell_activity(
1405 tools::EXEC_COMMAND,
1406 &exec_command("git diff --check && git status --short && git diff --stat"),
1407 ),
1408 ShellActivity::Inspection
1409 );
1410 }
1411
1412 #[test]
1413 fn verification_detection_skips_environment_prefixes() {
1414 for command in [
1415 "env RUSTFLAGS=-Dwarnings cargo check",
1416 "RUSTFLAGS=-Dwarnings env cargo check",
1417 "env -u PATH cargo check",
1418 "env -C /tmp cargo check",
1419 ] {
1420 assert_eq!(
1421 classify_shell_activity(tools::EXEC_COMMAND, &exec_command(command)),
1422 ShellActivity::Verification,
1423 "{command}"
1424 );
1425 }
1426 }
1427
1428 #[test]
1429 fn ambiguous_or_mutating_compounds_remain_mutations() {
1430 for command in [
1431 "git diff --stat; python3 -c 'open(\"out\", \"w\").write(\"x\")'",
1432 "cat README.md; sed -i '' 's/a/b/' README.md",
1433 "sed --in-place= README.md",
1434 "git diff --output=out",
1435 "git diff '--output=out'",
1436 "git diff -o out",
1437 "git diff -oout",
1438 "git log --output=out",
1439 "git show --textconv",
1440 "git -c core.fsmonitor=touch status",
1444 "find . -fprint output.txt",
1445 "find . -fprintf output.txt '%p'",
1446 "rg --hostname-bin sh pattern",
1447 "rg --search-zip pattern",
1448 "rg -z pattern",
1449 "sort -o generated.txt README.md",
1450 "sort --compress-program=sh README.md",
1451 "date -s now",
1452 "awk -i inplace '{print}' README.md",
1453 "sed -n 's/a/b/e' README.md",
1454 "fd --exec sh -c 'touch output'",
1455 "tree -o output.txt",
1456 "ast-grep -r 'README.md'",
1457 "sed -n -fmalicious.sed -e '1p' src/main.rs",
1458 "sed -I '' 's/a/b/' src/main.rs",
1459 "sed -n '1p\nw leaked.txt' src/main.rs",
1460 "cargo check & rm output",
1461 "cargo check > build.log | rm output",
1462 "cargo check | head -40 > build.log",
1463 "cargo check | echo x > output.log",
1464 "cargo check | head -40",
1465 "cargo check > build.log &",
1466 "env -S 'cargo check'",
1467 "echo x > output.log && cargo check",
1468 "cargo check < build-input.log",
1469 "cat README.md > copied.txt",
1470 "git diff --check; rm output",
1471 "cat README.md\nrm output",
1472 ] {
1473 assert_eq!(
1474 classify_shell_activity(tools::EXEC_COMMAND, &exec_command(command)),
1475 ShellActivity::Mutation,
1476 "{command}"
1477 );
1478 }
1479 }
1480
1481 #[test]
1482 fn quoted_output_text_does_not_change_inspection_classification() {
1483 for command in ["echo 'git diff --output=out'", "printf 'sort -o out input'"] {
1484 assert_eq!(
1485 classify_shell_activity(tools::EXEC_COMMAND, &exec_command(command)),
1486 ShellActivity::Inspection,
1487 "{command}"
1488 );
1489 }
1490 }
1491
1492 #[test]
1493 fn cargo_fmt_check_is_verification_but_plain_fmt_is_not() {
1494 for command in [
1495 "cargo fmt --check",
1496 "cargo fmt --all -- --check",
1497 "cargo fmt -- --check",
1498 ] {
1499 assert_eq!(
1500 classify_shell_activity(tools::EXEC_COMMAND, &exec_command(command)),
1501 ShellActivity::Verification,
1502 "{command}"
1503 );
1504 assert!(
1505 shell_command_is_admitted_verification_attempt(&exec_command(command)),
1506 "expected admission: {command}"
1507 );
1508 }
1509 assert_eq!(classify_shell_activity(tools::EXEC_COMMAND, &exec_command("cargo fmt")), ShellActivity::Mutation);
1512 assert!(!shell_command_is_admitted_verification_attempt(&exec_command("cargo fmt")));
1513 }
1514
1515 #[test]
1516 fn pure_and_chained_verifiers_are_verification() {
1517 for command in [
1518 "cargo fmt --all -- --check && cargo check --locked",
1519 "cargo check --locked && cargo nextest run --locked -p vtcode-ui",
1520 "cargo check --locked && cargo clippy --locked -p vtcode-ui -- -D warnings",
1521 "git diff --check && cargo check --locked",
1522 ] {
1523 assert_eq!(
1524 classify_shell_activity(tools::EXEC_COMMAND, &exec_command(command)),
1525 ShellActivity::Verification,
1526 "{command}"
1527 );
1528 assert!(
1529 shell_command_is_admitted_verification_attempt(&exec_command(command)),
1530 "expected admission: {command}"
1531 );
1532 }
1533 }
1534
1535 #[test]
1536 fn non_and_chained_verifiers_remain_mutations() {
1537 for command in [
1538 "cargo check --locked; cargo nextest run --locked -p vtcode-ui",
1539 "cargo check --locked || cargo nextest run --locked -p vtcode-ui",
1540 "cargo check --locked | head -40",
1541 "cargo check --locked && cargo nextest run --locked -p vtcode-ui | head -40",
1542 "cargo check --locked &",
1543 ] {
1544 assert_eq!(
1545 classify_shell_activity(tools::EXEC_COMMAND, &exec_command(command)),
1546 ShellActivity::Mutation,
1547 "{command}"
1548 );
1549 }
1550 }
1551
1552 #[test]
1553 fn expanded_verifiers_classify_as_verification() {
1554 for command in [
1555 "bun test",
1556 "bun run test",
1557 "deno lint",
1558 "deno check mod.ts",
1559 "make test",
1560 "make lint",
1561 "just verify",
1562 "ruff check src/",
1563 "ruff format --check",
1564 "tsc --noEmit",
1565 "eslint src/",
1566 "python3 -m pytest",
1567 "uv run pytest",
1568 "npm run lint",
1569 ] {
1570 assert_eq!(
1571 classify_shell_activity(tools::EXEC_COMMAND, &exec_command(command)),
1572 ShellActivity::Verification,
1573 "{command}"
1574 );
1575 }
1576 for command in [
1577 "make clean",
1578 "make test clean",
1579 "just fmt",
1580 "tsc",
1581 "eslint --fix src/",
1582 "ruff format src/",
1583 "bun install",
1584 "uv sync",
1585 ] {
1586 assert_eq!(
1587 classify_shell_activity(tools::EXEC_COMMAND, &exec_command(command)),
1588 ShellActivity::Mutation,
1589 "{command}"
1590 );
1591 }
1592 }
1593
1594 #[test]
1595 fn escaped_quotes_fail_closed_instead_of_hiding_operators() {
1596 assert!(!shell_uses_only_and_chaining("cargo check --locked \\\"; echo ok"));
1600 assert!(!shell_uses_only_and_chaining("echo \\\" ; cargo check --locked && echo done"));
1601 assert!(shell_uses_only_and_chaining("echo \"a\\\"b\" && cargo check --locked"));
1603 assert!(shell_uses_only_and_chaining("echo \"path\" && cargo fmt --check"));
1604 assert!(shell_uses_only_and_chaining("echo 'a\\b' && cargo check --locked"));
1606 }
1607
1608 #[test]
1609 fn default_verifier_prefers_manifest_priority_order() {
1610 let dir = tempfile::tempdir().expect("tempdir");
1611 assert_eq!(default_verifier_for_workspace(dir.path()), None);
1612
1613 std::fs::write(dir.path().join("justfile"), "test:\n\techo ok\n").expect("justfile");
1614 assert_eq!(default_verifier_for_workspace(dir.path()).as_deref(), Some("just test"));
1615 std::fs::write(dir.path().join("pyproject.toml"), "[tool.pytest]\n").expect("pyproject");
1616 assert_eq!(default_verifier_for_workspace(dir.path()).as_deref(), Some("pytest -q"));
1617 std::fs::write(dir.path().join("package.json"), r#"{"scripts":{"test":"vitest"}}"#).expect("package.json");
1618 assert_eq!(default_verifier_for_workspace(dir.path()).as_deref(), Some("npm test"));
1619 std::fs::write(dir.path().join("go.mod"), "module example\n").expect("go.mod");
1620 assert_eq!(default_verifier_for_workspace(dir.path()).as_deref(), Some("go test ./..."));
1621 std::fs::write(dir.path().join("Cargo.toml"), "[package]\nname=\"x\"\n").expect("Cargo.toml");
1622 assert_eq!(default_verifier_for_workspace(dir.path()).as_deref(), Some("cargo check --locked"));
1623 }
1624
1625 #[test]
1626 fn default_verifier_marker_aliases_and_tier_priority() {
1627 for (marker, expected) in [
1631 ("Justfile", "just test"),
1632 ("makefile", "make test"),
1633 ("bun.lock", "bun test"),
1634 ("deno.jsonc", "deno test"),
1635 ] {
1636 let dir = tempfile::tempdir().expect("tempdir");
1637 std::fs::write(dir.path().join(marker), "x\n").expect(marker);
1638 assert_eq!(default_verifier_for_workspace(dir.path()).as_deref(), Some(expected), "{marker}");
1639 }
1640 let dir = tempfile::tempdir().expect("tempdir");
1642 std::fs::write(dir.path().join("bun.lockb"), "x\n").expect("bun.lockb");
1643 std::fs::write(dir.path().join("deno.json"), "{}\n").expect("deno.json");
1644 assert_eq!(default_verifier_for_workspace(dir.path()).as_deref(), Some("deno test"));
1645 std::fs::write(dir.path().join("go.mod"), "module example\n").expect("go.mod");
1646 assert_eq!(default_verifier_for_workspace(dir.path()).as_deref(), Some("go test ./..."));
1647 let dir = tempfile::tempdir().expect("tempdir");
1649 std::fs::write(dir.path().join("pyproject.toml"), "[tool.pytest]\n").expect("pyproject");
1650 std::fs::write(dir.path().join("package.json"), r#"{"scripts":{"test":"vitest"}}"#).expect("package.json");
1651 assert_eq!(default_verifier_for_workspace(dir.path()).as_deref(), Some("npm test"));
1652 let dir = tempfile::tempdir().expect("tempdir");
1654 std::fs::write(dir.path().join("package.json"), r#"{"scripts":{"start":"serve"}}"#).expect("package.json");
1655 assert_eq!(default_verifier_for_workspace(dir.path()).as_deref(), Some("npm test"));
1656 }
1657
1658 #[test]
1659 fn default_verifier_reads_npm_script_fallbacks() {
1660 let dir = tempfile::tempdir().expect("tempdir");
1661 std::fs::write(dir.path().join("package.json"), r#"{"scripts":{"lint":"eslint ."}}"#).expect("package.json");
1662 assert_eq!(default_verifier_for_workspace(dir.path()).as_deref(), Some("npm run lint"));
1663 }
1664
1665 #[test]
1666 fn verification_recovery_directive_names_concrete_command() {
1667 let directive = verification_recovery_directive(Some("go test ./..."), 1, 2);
1668 assert!(directive.contains("go test ./..."));
1669 assert!(directive.contains("1/2"));
1670 assert!(directive.contains("max_output_tokens"));
1671 assert!(directive.contains(VERIFIER_SHELL_FORM_NOTE));
1672 assert!(directive.contains("tails run standalone"));
1673 assert!(directive.contains("fail-closed `pipefail`"));
1674 let fallback = verification_recovery_directive(None, 2, 2);
1675 assert!(fallback.contains(GENERIC_VERIFIER_DESCRIPTION));
1676 assert!(fallback.contains("2/2"));
1677 }
1678
1679 #[test]
1680 fn verifier_reference_names_resolved_command_or_generic_description() {
1681 assert_eq!(verifier_reference(Some("go test ./...")), "`go test ./...`");
1682 assert_eq!(verifier_reference(Some(" npm test ")), "`npm test`");
1683 for missing in [None, Some(""), Some(" ")] {
1684 let reference = verifier_reference(missing);
1685 assert_eq!(reference, GENERIC_VERIFIER_DESCRIPTION);
1686 assert!(!reference.starts_with('`'), "no single command is named: {reference}");
1687 for ecosystem in ["cargo check --locked", "go test ./...", "npm test", "pytest -q"] {
1688 assert!(reference.contains(ecosystem), "missing {ecosystem}: {reference}");
1689 }
1690 }
1691 }
1692
1693 #[test]
1694 fn truncation_only_verifier_rewrites_to_standalone_prefix() {
1695 for (command, expected) in [
1696 ("cargo check --locked 2>&1 | head -c 4000", "cargo check --locked 2>&1"),
1697 ("cargo check --locked -p vtcode 2>&1 | tail -20", "cargo check --locked -p vtcode 2>&1"),
1698 ("cargo nextest run 2>&1 | tail -15", "cargo nextest run 2>&1"),
1699 (
1700 "cargo check --locked && cargo nextest run --locked -p vtcode | tail -5",
1701 "cargo check --locked && cargo nextest run --locked -p vtcode",
1702 ),
1703 ("cargo check | tail -5 | head -20", "cargo check"),
1704 ("RUSTFLAGS=-Dwarnings cargo check | head -20", "RUSTFLAGS=-Dwarnings cargo check"),
1705 ("cargo check > build.log | tail -5", "cargo check > build.log"),
1709 ] {
1710 assert_eq!(
1711 rewrite_truncation_only_verifier(&exec_command(command)).as_deref(),
1712 Some(expected),
1713 "expected rewrite: {command}"
1714 );
1715 }
1716 }
1717
1718 #[test]
1719 fn non_truncation_pipelines_and_mutations_are_never_rewritten() {
1720 for command in [
1724 "cargo check | grep error",
1725 "cargo check | wc -l",
1726 "cargo check | sort | uniq",
1727 "cargo check && rm -rf target | tail -5",
1728 "cargo check; git status | tail -5",
1729 "cargo check || cargo test | tail -5",
1730 "cargo check | tail -5; rm foo.txt",
1731 "cargo check | tail &",
1732 "cargo check",
1733 "echo done | tail -5",
1734 "rg -n 'pattern' src | head -20",
1735 "echo $(date) | tail -5",
1736 ] {
1737 assert_eq!(rewrite_truncation_only_verifier(&exec_command(command)), None, "must not rewrite: {command}");
1738 }
1739 assert_eq!(
1740 rewrite_truncation_only_verifier(&serde_json::json!({"command": ["cargo", "check", "|", "tail"]})),
1741 None,
1742 "array-form commands keep today's behavior"
1743 );
1744 assert_eq!(rewrite_truncation_only_verifier(&serde_json::json!({})), None);
1745 }
1746
1747 #[test]
1748 fn args_as_executed_classify_elided_truncation_verifiers_as_verification() {
1749 for command in [
1750 "cargo check --locked 2>&1 | tail -5",
1751 "cargo nextest run 2>&1 | head -c 4000",
1752 ] {
1753 let typed = exec_command(command);
1754 let executed = shell_args_as_executed(tools::EXEC_COMMAND, &typed);
1755 assert!(matches!(executed, std::borrow::Cow::Owned(_)), "expected rewrite: {command}");
1756 assert_eq!(
1757 classify_shell_activity(tools::EXEC_COMMAND, &executed),
1758 ShellActivity::Verification,
1759 "{command}"
1760 );
1761 assert!(matches!(shell_args_as_executed(tools::EXEC_COMMAND, &executed), std::borrow::Cow::Borrowed(_)));
1763 }
1764
1765 let unified = json!({"action": "run", "command": "cargo check 2>&1 | tail -5"});
1766 assert_eq!(shell_args_as_executed(tools::UNIFIED_EXEC, &unified)["command"], "cargo check 2>&1");
1767 }
1768
1769 #[test]
1770 fn verifier_rewrite_keeps_raw_alias_and_appended_arguments_in_sync() {
1771 let command = "cargo check | grep";
1772 let typed = json!({"cmd":command, "raw_command":command, "args":["warning"]});
1773 let executed = shell_args_as_executed(tools::EXEC_COMMAND, &typed);
1774 let expected = "set -o pipefail && cargo check | grep warning";
1775 assert_eq!(executed["command"], expected);
1776 assert_eq!(executed["raw_command"], expected);
1777 assert!(executed.get("args").is_none(), "suffix must execute exactly once");
1778 assert_eq!(classify_shell_activity(tools::EXEC_COMMAND, &executed), ShellActivity::Verification);
1779 assert!(matches!(shell_args_as_executed(tools::EXEC_COMMAND, &executed), std::borrow::Cow::Borrowed(_)));
1780
1781 let truncator = json!({"cmd":"cargo check | head", "args":["-20"]});
1782 assert_eq!(shell_args_as_executed(tools::EXEC_COMMAND, &truncator)["command"], "cargo check");
1783 }
1784
1785 #[test]
1786 fn verifier_pipeline_rejects_conflicting_raw_alias_and_mutating_suffix() {
1787 for typed in [
1788 json!({"cmd":"cargo check | grep marker", "raw_command":"printf marker"}),
1789 json!({"cmd":"cargo check | sort", "args":["-o", "changed.txt"]}),
1790 json!({"cmd":"set -o pipefail && cargo check | sort", "args":["-o", "changed.txt"]}),
1791 json!({"cmd":"cargo check | grep", "args":"invalid"}),
1792 ] {
1793 assert_eq!(rewrite_verifier_pipeline(&typed), None, "{typed}");
1794 assert!(!shell_command_is_admitted_verification_attempt(&typed), "{typed}");
1795 assert_eq!(classify_shell_activity(tools::EXEC_COMMAND, &typed), ShellActivity::Mutation, "{typed}");
1796 assert!(super::super::classify_tool_intent(tools::EXEC_COMMAND, &typed).mutating, "{typed}");
1797 }
1798 }
1799
1800 #[test]
1801 fn filtered_verifier_pipeline_preserves_filters_and_truthful_status() {
1802 for command in [
1803 "python3 scripts/check_markdown.py 2>&1 | grep -E '^(README|docs/SECURITY)\\.md' | head -20",
1804 "cargo check --locked | grep warning",
1805 "cargo fmt --check && cargo check --locked | wc -l",
1806 ] {
1807 let typed = exec_command(command);
1808 let executed = shell_args_as_executed(tools::EXEC_COMMAND, &typed);
1809 assert_eq!(executed["command"], format!("set -o pipefail && {command}"));
1810 assert_eq!(classify_shell_activity(tools::EXEC_COMMAND, &executed), ShellActivity::Verification);
1811 assert!(shell_command_is_admitted_verification_attempt(&executed));
1812 assert!(matches!(shell_args_as_executed(tools::EXEC_COMMAND, &executed), std::borrow::Cow::Borrowed(_)));
1813 assert_eq!(classify_shell_activity(tools::EXEC_COMMAND, &typed), ShellActivity::Mutation);
1814 }
1815 for command in [
1816 "cargo check | grep error; true",
1817 "cargo check | grep error || true",
1818 "cargo check | tee source.rs",
1819 "cargo check | sed -i 's/old/new/' source.rs",
1820 "cargo check | grep $(touch marker)",
1821 "cargo check | grep error > hidden.log",
1822 "cargo check | grep error &",
1823 "cargo check && rm source.rs | grep error",
1824 ] {
1825 assert_eq!(rewrite_verifier_pipeline(&exec_command(command)), None, "{command}");
1826 let prefixed = exec_command(&format!("set -o pipefail && {command}"));
1827 assert_ne!(
1828 classify_shell_activity(tools::EXEC_COMMAND, &prefixed),
1829 ShellActivity::Verification,
1830 "{command}"
1831 );
1832 }
1833 }
1834
1835 #[test]
1836 fn args_as_executed_keep_unsafe_joins_and_non_run_calls_as_typed() {
1837 for command in [
1838 "cargo check 2>&1 | grep error; true",
1839 "cargo check; git status",
1840 "cargo check || true",
1841 ] {
1842 let typed = exec_command(command);
1843 let executed = shell_args_as_executed(tools::EXEC_COMMAND, &typed);
1844 assert!(matches!(executed, std::borrow::Cow::Borrowed(_)), "must run as typed: {command}");
1845 assert_ne!(
1846 classify_shell_activity(tools::EXEC_COMMAND, &executed),
1847 ShellActivity::Verification,
1848 "{command}"
1849 );
1850 }
1851 let poll = json!({"action": "poll", "session_id": "s1", "command": "cargo check | tail -5"});
1852 assert!(matches!(shell_args_as_executed(tools::UNIFIED_EXEC, &poll), std::borrow::Cow::Borrowed(_)));
1853 let read = json!({"path": "src/lib.rs"});
1854 assert!(matches!(shell_args_as_executed(tools::READ_FILE, &read), std::borrow::Cow::Borrowed(_)));
1855 }
1856
1857 #[test]
1858 fn path_glob_inspections_preserve_debt_without_weakening_admission() {
1859 let clean = json!({"exit_code":0});
1860 for command in [
1861 "rg -n \"MD013|line_length\" docs/development/*.md | head",
1862 "rg -n x ./docs/file?.md | head -20",
1863 "grep -n x docs/*.md",
1864 "cat ./docs/*.md",
1865 "ls docs/*.md",
1866 ] {
1867 assert!(!exec_mutation_should_incur_debt(command, &clean), "{command}");
1868 assert_eq!(classify_shell_activity(tools::EXEC_COMMAND, &exec_command(command)), ShellActivity::Mutation);
1869 assert!(exec_mutation_should_incur_debt(command, &json!({"exit_code":0,"modified_files":["src/lib.rs"]})));
1870 }
1871 for command in [
1872 "rg -n x *",
1873 "rg -n x ./docs/[a-z]*.md",
1874 "rg -n x -docs/*.md",
1875 "rg -n x $dir/*.md",
1876 "rg --pre touch docs/*.md",
1877 "rg --hostname-bin=touch docs/*.md",
1878 "sed docs/*.md",
1879 "sort -o marker docs/*.md",
1880 "./tools/* --version",
1881 "rg x docs/$(touch marker)/*.md",
1882 "rg x docs/*.md > marker",
1883 "rg x docs/*.md; touch marker",
1884 "rg x docs/*.md; if",
1885 "rg x docs*/*.md",
1886 ] {
1887 assert!(exec_mutation_should_incur_debt(command, &clean), "{command}");
1888 }
1889 }
1890
1891 #[test]
1892 fn mutation_accounting_rejects_unproven_shell_forms() {
1893 let output = json!({"exit_code": 0});
1894 for command in [
1895 "echo \"$(touch marker)\"",
1896 "echo \"`touch marker`\"",
1897 "for p in $(echo $(touch marker)); do test -f \"$p\"; done",
1898 "for p in a; do sed -i '' 's/a/b/' README.md; done",
1899 "for p in a; do sort -o README.md README.md; done",
1900 "echo x 2>/dev/null.backup",
1901 "echo x 2>&1>marker",
1902 "cat <(touch marker)",
1903 "$program --version",
1904 "\"$(echo rm)\" marker",
1905 "grep $options README.md",
1906 "if true; then touch marker; fi",
1907 "for p in a; do echo x",
1908 "echo $(",
1909 "echo hi &",
1910 "sed -n '1p' README.md > marker",
1911 "echo ${x:=value}",
1912 ] {
1913 assert!(exec_mutation_should_incur_debt(command, &output), "{command}");
1914 }
1915 for command in [
1916 "echo '>'",
1917 "echo '2>/dev/null.backup'",
1918 "echo \"$(grep -n x README.md)\"",
1919 ] {
1920 assert!(!exec_mutation_should_incur_debt(command, &output), "{command}");
1921 }
1922 }
1923
1924 #[test]
1925 fn dynamic_readonly_inspections_do_not_incur_mutation_debt() {
1926 let clean_output = json!({"success": true, "exit_code": 0});
1927 for command in [
1934 "for p in $(grep -o '](\\./[^)#]*' README.md | sed 's/](//' | sort -u); do test -f \"$p\" || echo \"missing: $p\"; done; echo \"check done\"",
1935 "for p in $(grep -o '](\\./[^)#]*' README.md | sed 's/](\\.\\///' | sort -u); do test -f \"docs/${p#docs/}\" 2>/dev/null || echo \"missing: $p\"; done",
1936 "rg -n 'vinhnx/(VTCode|vtcode)' README.md",
1937 "grep -n '^## ' README.md",
1938 "ls docs/guides/zed-acp.md",
1939 "git diff --stat -- README.md",
1940 "git status --short",
1941 "npx markdownlint-cli2 README.md",
1942 "cargo check --locked",
1943 ] {
1944 let expected = if command.starts_with("npx markdownlint") || command.starts_with("cargo check") {
1945 ShellActivity::Verification
1946 } else if command.starts_with("for") {
1947 ShellActivity::Mutation
1948 } else {
1949 ShellActivity::Inspection
1950 };
1951 assert_eq!(classify_shell_activity(tools::EXEC_COMMAND, &exec_command(command)), expected, "{command}");
1952 assert!(!exec_mutation_should_incur_debt(command, &clean_output), "{command}");
1953 }
1954 assert!(!exec_mutation_should_incur_debt(
1956 "for p in $(grep -o 'x' README.md); do test -f \"$p\" || echo \"missing: $p\"; done",
1957 &clean_output,
1958 ));
1959 assert!(!exec_mutation_should_incur_debt("rg -n 'x' README.md", &clean_output));
1960 assert!(!exec_mutation_should_incur_debt("grep -n 'x' README.md", &clean_output));
1961 assert!(!exec_mutation_should_incur_debt("git status --short", &clean_output));
1962 assert!(exec_mutation_should_incur_debt(
1964 "for p in $(grep -o 'x' README.md); do test -f \"$p\" || echo \"missing: $p\"; done",
1965 &json!({"success": true, "exit_code": 0, "modified_files": ["README.md"]}),
1966 ));
1967 assert!(exec_mutation_should_incur_debt("echo hi > README.md", &clean_output));
1969 assert!(exec_mutation_should_incur_debt("for f in a b; do echo x > \"$f\"; done", &clean_output));
1970 assert!(exec_mutation_should_incur_debt("rm -rf target", &clean_output));
1972 assert!(exec_mutation_should_incur_debt("rm $f", &clean_output));
1974 assert!(!exec_mutation_should_incur_debt("rg -n 'x' README.md 2>&1", &clean_output));
1976 assert!(exec_mutation_should_incur_debt("npx markdownlint-cli2 --fix README.md", &clean_output));
1979 assert!(!exec_mutation_should_incur_debt("npx markdownlint-cli2 README.md", &clean_output));
1980 assert!(exec_mutation_should_incur_debt("cargo fmt", &clean_output));
1981 assert!(!exec_mutation_should_incur_debt("cargo fmt --check", &clean_output));
1982 assert!(exec_mutation_should_incur_debt("tsc", &clean_output));
1983 assert!(!exec_mutation_should_incur_debt("tsc --noEmit", &clean_output));
1984 assert!(exec_mutation_should_incur_debt("git diff $x", &clean_output));
1986 assert!(exec_mutation_should_incur_debt("git checkout $x", &clean_output));
1987 assert!(!exec_mutation_should_incur_debt("grep -n 'x' README.md; echo \"exit:$?\"", &clean_output,));
1989 }
1990}