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 is_verification_invocation(words: &[String]) -> bool {
45 let command_words = crate::tools::command_args::command_words_after_environment_prefix(words);
46 let first = command_words.first().map(String::as_str).unwrap_or_default();
47 let second = command_words.get(1).map(|word| word.to_ascii_lowercase());
48 let third = command_words.get(2).map(|word| word.to_ascii_lowercase());
49 let program = Path::new(first)
50 .file_name()
51 .and_then(|name| name.to_str())
52 .unwrap_or(first)
53 .to_ascii_lowercase();
54
55 match program.as_str() {
56 "markdownlint-cli2" => command_words.get(1..).is_some_and(markdownlint_arguments_verify),
57 "npx" => {
58 let package_index = command_words
59 .iter()
60 .enumerate()
61 .skip(1)
62 .find(|(_, word)| !matches!(word.as_str(), "--yes" | "-y" | "--no-install"))
63 .map(|(index, _)| index);
64 package_index.is_some_and(|index| {
65 let Some(package) = command_words.get(index) else {
66 return false;
67 };
68 let is_markdownlint = package == "markdownlint-cli2"
69 || package.strip_prefix("markdownlint-cli2@").is_some_and(|version| {
70 version.split('.').count() == 3
71 && version
72 .split('.')
73 .all(|part| !part.is_empty() && part.bytes().all(|byte| byte.is_ascii_digit()))
74 });
75 is_markdownlint && command_words.get(index + 1..).is_some_and(markdownlint_arguments_verify)
76 })
77 }
78 "cargo" => {
79 if second.as_deref() == Some("fmt") {
80 return words.iter().any(|word| word == "--check");
83 }
84 matches!(second.as_deref(), Some("check" | "build" | "clippy" | "test"))
85 || (second.as_deref() == Some("nextest") && third.as_deref() == Some("run"))
86 }
87 "go" => matches!(second.as_deref(), Some("test" | "build")),
88 "npm" | "pnpm" | "yarn" => {
89 matches!(second.as_deref(), Some("test" | "build" | "lint" | "check"))
90 || (second.as_deref() == Some("run")
91 && matches!(third.as_deref(), Some("test" | "build" | "lint" | "check")))
92 }
93 "bun" | "bunx" => {
94 matches!(second.as_deref(), Some("test"))
95 || (second.as_deref() == Some("run")
96 && matches!(third.as_deref(), Some("test" | "build" | "lint" | "check")))
97 }
98 "deno" => matches!(second.as_deref(), Some("test" | "lint" | "check")),
99 "make" | "gmake" | "just" => make_like_verification_targets(command_words),
100 "uv" => {
101 command_words.iter().any(|word| word == "pytest")
102 || (command_words.iter().any(|word| word == "ruff") && command_words.iter().any(|word| word == "check"))
103 }
104 "ruff" => {
105 if second.as_deref() == Some("format") {
106 return words.iter().any(|word| word == "--check");
107 }
108 matches!(second.as_deref(), Some("check" | "lint"))
109 }
110 "tsc" => {
111 words.iter().any(|word| word == "--noEmit")
113 }
114 "eslint" => {
115 !words.iter().any(|word| word == "--fix" || word == "--fix-dry-run")
117 }
118 "rustc" | "pytest" | "xcodebuild" | "gradle" | "gradlew" => true,
119 "python" | "python3" => {
120 (command_words.len() == 2
121 && command_words
122 .get(1)
123 .is_some_and(|script| Path::new(script).ends_with("scripts/check_markdown.py")))
124 || (command_words.iter().any(|word| word == "-m") && command_words.iter().any(|word| word == "pytest"))
125 }
126 _ if first.ends_with("/scripts/check.sh") || first.ends_with("/scripts/check-dev.sh") => true,
127 _ => false,
128 }
129}
130
131fn markdownlint_arguments_verify(arguments: &[String]) -> bool {
132 !arguments.is_empty()
133 && !arguments.iter().any(|argument| {
134 matches!(argument.as_str(), "--fix" | "-f" | "--help" | "-h" | "--version" | "-v")
135 || argument.starts_with("--fix=")
136 })
137}
138
139fn contains_verification_invocation(command: &str) -> bool {
140 static_shell_command_words(command)
141 .is_some_and(|commands| commands.iter().any(|words| is_verification_invocation(words)))
142}
143
144pub fn shell_command_contains_verifier(args: &Value) -> bool {
148 let Some(command) = shell_command_text_with_args(args) else {
149 return false;
150 };
151 super::readonly::parse_shell_command_words(&command)
152 .is_some_and(|commands| commands.iter().any(|words| is_verification_invocation(words)))
153}
154
155pub fn shell_command_is_output_search(args: &Value) -> bool {
162 let Some(command) = shell_command_text_with_args(args) else {
163 return false;
164 };
165 let Some(segments) = static_shell_command_words(&command) else {
166 return false;
167 };
168 let mut saw_search = false;
169 for words in &segments {
170 if !command_words_are_readonly(words) || is_verification_invocation(words) {
171 return false;
172 }
173 let words = crate::tools::command_args::command_words_after_environment_prefix(words);
174 if command_words_are_grep_search(words) {
175 if grep_words_request_quiet_output(words) {
176 return false;
177 }
178 saw_search = true;
179 }
180 }
181 saw_search
182}
183
184fn grep_words_request_quiet_output(words: &[String]) -> bool {
185 let is_rg = words
186 .first()
187 .and_then(|word| Path::new(word).file_name())
188 .is_some_and(|name| name == "rg");
189 let mut arguments = words.iter().skip(1);
190 let mut quiet = false;
191 while let Some(argument) = arguments.next() {
192 match argument.as_str() {
193 "--" => break,
194 "--quiet" | "--silent" => quiet = true,
195 "--no-quiet" if is_rg => quiet = false,
196 _ if argument.starts_with("--") => {
197 let takes_value = matches!(
199 argument.as_str(),
200 "--regexp"
201 | "--file"
202 | "--after-context"
203 | "--before-context"
204 | "--context"
205 | "--max-count"
206 | "--include"
207 | "--exclude"
208 | "--exclude-from"
209 | "--exclude-dir"
210 | "--label"
211 | "--directories"
212 | "--devices"
213 ) || (is_rg
214 && matches!(
215 argument.as_str(),
216 "--glob"
217 | "--iglob"
218 | "--ignore-file"
219 | "--pre-glob"
220 | "--type"
221 | "--type-not"
222 | "--type-add"
223 | "--type-clear"
224 | "--replace"
225 | "--encoding"
226 | "--engine"
227 | "--color"
228 | "--colors"
229 | "--threads"
230 | "--max-depth"
231 | "--max-columns"
232 | "--max-filesize"
233 | "--dfa-size-limit"
234 | "--regex-size-limit"
235 | "--context-separator"
236 | "--field-context-separator"
237 | "--field-match-separator"
238 | "--hyperlink-format"
239 | "--path-separator"
240 | "--sort"
241 | "--sortr"
242 | "--generate"
243 ));
244 if takes_value {
245 arguments.next();
246 }
247 }
248 _ if argument.starts_with('-') => {
249 let mut flags = argument.chars().skip(1).peekable();
250 while let Some(flag) = flags.next() {
251 if flag == 'q' {
252 quiet = true;
253 }
254 let takes_value = matches!(flag, 'e' | 'f' | 'A' | 'B' | 'C' | 'm' | 'd')
255 || (is_rg && matches!(flag, 'g' | 't' | 'T' | 'r' | 'E' | 'j' | 'M'))
256 || (!is_rg && flag == 'D');
257 if takes_value {
258 if flags.peek().is_none() {
259 arguments.next();
260 }
261 break;
262 }
263 }
264 }
265 _ => {}
266 }
267 }
268 quiet
269}
270
271pub fn shell_command_is_standalone_grep_search(args: &Value) -> bool {
275 let Some(command) = shell_command_text_with_args(args) else {
276 return false;
277 };
278 let Some(segments) = static_shell_command_words(&command) else {
279 return false;
280 };
281 let [words] = segments.as_slice() else {
282 return false;
283 };
284 shell_words::split(&command).is_ok_and(|raw_words| raw_words == *words)
285 && command_words_are_readonly(words)
286 && command_words_are_grep_search(words)
287}
288
289fn command_words_are_grep_search(words: &[String]) -> bool {
290 let words = crate::tools::command_args::command_words_after_environment_prefix(words);
291 let program = words
292 .first()
293 .and_then(|word| Path::new(word).file_name())
294 .and_then(|name| name.to_str());
295 matches!(program, Some("rg" | "grep" | "egrep" | "fgrep"))
296}
297
298pub fn default_verifier_for_workspace(workspace_root: &Path) -> Option<String> {
314 if workspace_root.join("Cargo.toml").is_file() {
315 return Some("cargo check --locked".to_string());
316 }
317 if workspace_root.join("go.mod").is_file() {
318 return Some("go test ./...".to_string());
319 }
320 if workspace_root.join("deno.json").is_file() || workspace_root.join("deno.jsonc").is_file() {
321 return Some("deno test".to_string());
322 }
323 if workspace_root.join("bun.lockb").is_file() || workspace_root.join("bun.lock").is_file() {
324 return Some("bun test".to_string());
325 }
326 let package_json = workspace_root.join("package.json");
327 if package_json.is_file() {
328 if let Ok(content) = std::fs::read_to_string(&package_json)
329 && let Ok(parsed) = serde_json::from_str::<Value>(&content)
330 && let Some(scripts) = parsed.get("scripts").and_then(Value::as_object)
331 {
332 if scripts.contains_key("test") {
333 return Some("npm test".to_string());
334 }
335 if scripts.contains_key("check") {
336 return Some("npm run check".to_string());
337 }
338 if scripts.contains_key("lint") {
339 return Some("npm run lint".to_string());
340 }
341 if scripts.contains_key("build") {
342 return Some("npm run build".to_string());
343 }
344 }
345 return Some("npm test".to_string());
346 }
347 for marker in ["pytest.ini", "pyproject.toml", "setup.cfg", "tox.ini"] {
348 if workspace_root.join(marker).is_file() {
349 return Some("pytest -q".to_string());
350 }
351 }
352 if workspace_root.join("Makefile").is_file() || workspace_root.join("makefile").is_file() {
353 return Some("make test".to_string());
354 }
355 if workspace_root.join("justfile").is_file() || workspace_root.join("Justfile").is_file() {
356 return Some("just test".to_string());
357 }
358 None
360}
361
362pub 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.";
366
367pub const GENERIC_VERIFIER_DESCRIPTION: &str =
373 "your project's build/test/lint command (e.g. `cargo check --locked`, `go test ./...`, `npm test`, or `pytest -q`)";
374
375pub fn verifier_reference(default_verifier: Option<&str>) -> String {
380 match default_verifier.map(str::trim).filter(|command| !command.is_empty()) {
381 Some(command) => format!("`{command}`"),
382 None => GENERIC_VERIFIER_DESCRIPTION.to_string(),
383 }
384}
385
386pub fn verification_recovery_directive(default_verifier: Option<&str>, attempt: u8, max_attempts: u8) -> String {
391 let verifier = verifier_reference(default_verifier);
392 format!(
393 "Verification recovery ({attempt}/{max_attempts}): pending edits have not been verified, so further mutations are blocked \
394 and the turn ends blocked unless a verifier exits 0. Run {verifier} with `exec_command`, standalone or as a pure `&&` chain of verifiers. \
395 {VERIFIER_SHELL_FORM_NOTE} A failed verifier grants a bounded number of fix-up edits before the next verification is required."
396 )
397}
398
399pub fn shell_command_is_admitted_verification_attempt(args: &Value) -> bool {
414 let Some(command) = shell_command_text_with_args(args) else {
415 return false;
416 };
417 if crate::tools::command_args::contains_dynamic_shell_syntax(&command) {
418 return false;
419 }
420 if command.starts_with(VERIFIER_PIPEFAIL_PREFIX) {
421 return pipefail_verifier_pipeline(&command).is_some();
422 }
423 let segments =
424 static_shell_command_words(&command).or_else(|| static_shell_command_words_with_output_plumbing(&command));
425 let Some(segments) = segments else {
426 return false;
427 };
428 if segments.is_empty() {
429 return false;
430 }
431 let mut saw_verification = false;
432 for words in &segments {
433 if is_verification_invocation(words) {
434 saw_verification = true;
435 } else if !command_words_are_readonly(words) {
436 return false;
437 }
438 }
439 saw_verification
440}
441
442fn has_logical_sequencing(words: &[String]) -> bool {
443 words.iter().any(|word| matches!(word.as_str(), "&&" | "||" | ";"))
444}
445
446fn split_top_level_pipes(command: &str) -> Option<Vec<&str>> {
453 if !command.contains('|') {
454 return None;
455 }
456 let mut stages = Vec::new();
457 let mut start = 0;
458 let mut saw_pipe = false;
459 let mut in_single_quote = false;
460 let mut in_double_quote = false;
461 let mut chars = command.char_indices().peekable();
462 while let Some((index, character)) = chars.next() {
463 if character == '\\' && !in_single_quote {
467 chars.next();
468 continue;
469 }
470 if character == '\'' && !in_double_quote {
471 in_single_quote = !in_single_quote;
472 continue;
473 }
474 if character == '"' && !in_single_quote {
475 in_double_quote = !in_double_quote;
476 continue;
477 }
478 if in_single_quote || in_double_quote {
479 continue;
480 }
481 if character == '|' {
482 saw_pipe = true;
483 stages.push(&command[start..index]);
484 start = index + character.len_utf8();
485 }
486 }
487 if !saw_pipe {
488 return None;
489 }
490 stages.push(&command[start..]);
491 Some(stages)
492}
493
494fn is_pure_truncation_stage(stage: &str) -> bool {
500 let trimmed = stage.trim();
501 if trimmed.is_empty() {
502 return false;
503 }
504 let mut in_single_quote = false;
509 let mut in_double_quote = false;
510 let mut chars = trimmed.chars().peekable();
511 while let Some(character) = chars.next() {
512 if character == '\\' && !in_single_quote {
513 chars.next();
514 continue;
515 }
516 if character == '\'' && !in_double_quote {
517 in_single_quote = !in_single_quote;
518 continue;
519 }
520 if character == '"' && !in_single_quote {
521 in_double_quote = !in_double_quote;
522 continue;
523 }
524 if in_single_quote || in_double_quote {
525 continue;
526 }
527 if matches!(character, ';' | '&' | '<' | '>' | '\n' | '|') {
528 return false;
529 }
530 }
531 let words = match shell_words::split(trimmed) {
532 Ok(words) => words,
533 Err(_) => return false,
534 };
535 let Some(parsed) = NonEmptySlice::from_slice(&words) else {
536 return false;
537 };
538 let program = Path::new(parsed.first())
539 .file_name()
540 .and_then(|name| name.to_str())
541 .unwrap_or(parsed.first().as_str())
542 .to_ascii_lowercase();
543 matches!(program.as_str(), "head" | "tail")
544}
545
546pub fn rewrite_truncation_only_verifier(args: &Value) -> Option<String> {
576 use crate::config::constants::tools as tool_names;
577
578 let command = shell_command_text_with_args(args)?;
579 if !shell_command_is_admitted_verification_attempt(args) {
580 return None;
581 }
582 let stages = split_top_level_pipes(&command)?;
583 let (head, tails) = stages.split_first()?;
584 let head = head.trim();
585 if head.is_empty() || tails.iter().any(|stage| !is_pure_truncation_stage(stage)) {
586 return None;
587 }
588 let head_args = serde_json::json!({"cmd": head});
589 if !matches!(classify_shell_activity(tool_names::EXEC_COMMAND, &head_args), ShellActivity::Verification) {
590 return None;
591 }
592 Some(head.to_string())
593}
594
595const VERIFIER_PIPEFAIL_PREFIX: &str = "set -o pipefail && ";
597
598pub(crate) fn pipefail_verifier_pipeline(command: &str) -> Option<&str> {
601 let pipeline = command.strip_prefix(VERIFIER_PIPEFAIL_PREFIX)?;
602 if crate::tools::command_args::contains_dynamic_shell_syntax(pipeline) {
603 return None;
604 }
605 verifier_pipeline_head(pipeline).map(|_| pipeline)
606}
607
608fn verifier_pipeline_head(command: &str) -> Option<&str> {
610 let stages = split_top_level_pipes(command)?;
611 let (head, tails) = stages.split_first()?;
612 let head = head.trim();
613 let head_args = serde_json::json!({"cmd": head});
614 if classify_shell_activity(crate::config::constants::tools::EXEC_COMMAND, &head_args) != ShellActivity::Verification
615 {
616 return None;
617 }
618 for stage in tails {
621 let segments = static_shell_command_words(stage)?;
622 if segments.len() != 1 || !segments.first().is_some_and(|words| command_words_are_readonly(words)) {
623 return None;
624 }
625 }
626 Some(head)
627}
628
629fn rewrite_verifier_pipeline(args: &Value) -> Option<String> {
634 if let Some(standalone) = rewrite_truncation_only_verifier(args) {
635 return Some(standalone);
636 }
637 let command = shell_command_text_with_args(args)?;
638 if command.starts_with(VERIFIER_PIPEFAIL_PREFIX)
639 || !shell_command_is_admitted_verification_attempt(args)
640 || verifier_pipeline_head(&command).is_none()
641 {
642 return None;
643 }
644 Some(format!("{VERIFIER_PIPEFAIL_PREFIX}{command}"))
645}
646
647pub fn shell_args_as_executed<'a>(tool_name: &str, args: &'a Value) -> std::borrow::Cow<'a, Value> {
659 if !super::is_command_run_tool_call(tool_name, args) {
660 return std::borrow::Cow::Borrowed(args);
661 }
662 let Some(rewritten) = rewrite_verifier_pipeline(args) else {
663 return std::borrow::Cow::Borrowed(args);
664 };
665 let mut executed = args.clone();
666 match executed.as_object_mut() {
667 Some(payload) => {
668 if payload.get("raw_command").is_some_and(Value::is_string) {
669 payload.insert("raw_command".to_string(), Value::String(rewritten.clone()));
670 }
671 payload.remove("args");
674 payload.insert("command".to_string(), Value::String(rewritten));
675 std::borrow::Cow::Owned(executed)
676 }
677 None => std::borrow::Cow::Borrowed(args),
678 }
679}
680
681fn is_known_inspection(words: &[String]) -> bool {
682 if words
683 .iter()
684 .any(|word| matches!(word.as_str(), ">" | ">>" | "|" | "&&" | ";" | "||"))
685 {
686 return false;
687 }
688 command_words_are_readonly(words)
689}
690
691fn classify_provable_shell_sequence(command: &str) -> Option<ShellActivity> {
692 let (segments, has_output_plumbing) = if let Some(segments) = static_shell_command_words(command) {
693 (segments, false)
694 } else {
695 (static_shell_command_words_with_output_plumbing(command)?, true)
696 };
697 if has_output_plumbing && segments.len() != 1 {
698 return None;
699 }
700 let has_multiple_segments = segments.len() > 1;
701 let mut saw_verification = false;
702
703 for words in segments {
704 if is_verification_invocation(&words) {
705 saw_verification = true;
706 } else if !command_words_are_readonly(&words) {
707 return None;
708 }
709 }
710
711 if has_output_plumbing && !saw_verification {
712 return None;
713 }
714
715 if saw_verification && has_multiple_segments {
726 if shell_uses_only_and_chaining(command) {
727 return Some(ShellActivity::Verification);
728 }
729 return Some(ShellActivity::Mutation);
730 }
731
732 Some(if saw_verification {
733 ShellActivity::Verification
734 } else {
735 ShellActivity::Inspection
736 })
737}
738
739fn has_shell_sequence(command: &str) -> bool {
740 static_shell_command_words(command).is_none_or(|segments| segments.len() > 1)
741}
742
743fn shell_uses_only_and_chaining(command: &str) -> bool {
753 let chars: Vec<char> = command.chars().collect();
754 let mut index = 0;
755 let mut in_single_quote = false;
756 let mut in_double_quote = false;
757
758 while index < chars.len() {
759 let character = chars[index];
760 if character == '\\' && !in_single_quote && index + 1 < chars.len() {
765 index += 2;
766 continue;
767 }
768 if character == '\'' && !in_double_quote {
769 in_single_quote = !in_single_quote;
770 index += 1;
771 continue;
772 }
773 if character == '"' && !in_single_quote {
774 in_double_quote = !in_double_quote;
775 index += 1;
776 continue;
777 }
778 if in_single_quote || in_double_quote {
779 index += 1;
780 continue;
781 }
782 match character {
783 ';' | '\n' | '|' => return false,
784 '&' => {
785 let next_is_and = chars.get(index + 1) == Some(&'&');
786 if !next_is_and {
787 return false;
788 }
789 let third = chars.get(index + 2);
793 if third == Some(&'&') || third == Some(&'|') {
794 return false;
795 }
796 index += 2;
797 continue;
798 }
799 _ => {}
800 }
801 index += 1;
802 }
803
804 true
805}
806
807#[must_use]
816pub fn classify_shell_activity(tool_name: &str, args: &Value) -> ShellActivity {
817 let command = shell_command_text_with_args(args);
818 if command.is_none() && crate::tools::command_args::raw_command_text(args).is_some() {
819 return ShellActivity::Mutation;
820 }
821 if command.as_deref().and_then(pipefail_verifier_pipeline).is_some() {
822 return ShellActivity::Verification;
823 }
824 let words = crate::tools::command_args::command_words(args).ok().flatten();
825 let has_unclassified_shell_sequence = command.as_deref().is_some_and(has_shell_sequence);
826
827 if let Some(activity) = command.as_deref().and_then(classify_provable_shell_sequence) {
828 return activity;
829 }
830 if has_unclassified_shell_sequence {
833 return ShellActivity::Mutation;
834 }
835
836 let intent = super::classify_tool_intent(tool_name, args);
837
838 if !has_unclassified_shell_sequence && words.as_deref().is_some_and(is_known_inspection) {
839 return ShellActivity::Inspection;
840 }
841
842 let starts_with_verification = words.as_deref().is_some_and(is_verification_invocation);
843 let contains_verification =
844 starts_with_verification || command.as_deref().is_some_and(contains_verification_invocation);
845 if !intent.mutating {
846 return if contains_verification {
847 ShellActivity::Verification
848 } else {
849 ShellActivity::Inspection
850 };
851 }
852
853 if starts_with_verification
854 && !has_unclassified_shell_sequence
855 && !words.as_deref().is_some_and(has_logical_sequencing)
856 {
857 ShellActivity::Verification
858 } else {
859 ShellActivity::Mutation
860 }
861}
862
863#[cfg(test)]
864mod tests {
865 use serde_json::json;
866
867 use super::*;
868 use crate::config::constants::tools;
869 use crate::tools::tool_intent::is_readonly_command_session_command;
870
871 fn exec_command(command: &str) -> Value {
872 json!({"cmd": command})
873 }
874
875 #[test]
876 fn verifier_diagnostics_recognize_masked_status_without_granting_admission() {
877 for command in [
878 "npx markdownlint-cli2 README.md; echo \"lint exit: $?\"",
879 "cargo check; echo $?",
880 "python3 scripts/check_markdown.py; echo \"lint exit: $?\"",
881 "cargo check --locked || true",
882 "cargo check; rm marker",
883 ] {
884 let args = exec_command(command);
885 assert!(shell_command_contains_verifier(&args), "{command}");
886 assert_eq!(classify_shell_activity(tools::EXEC_COMMAND, &args), ShellActivity::Mutation);
887 }
888 let masked = exec_command("npx markdownlint-cli2 README.md; echo \"lint exit: $?\"");
889 assert!(!shell_command_is_admitted_verification_attempt(&masked));
890 assert_eq!(shell_args_as_executed(tools::EXEC_COMMAND, &masked).as_ref(), &masked);
891 for args in [
892 exec_command("echo 'cargo check; echo $?'"),
893 exec_command("grep 'cargo check' README.md"),
894 exec_command("npx markdownlint-cli2 --fix README.md"),
895 json!({"cmd":"cargo check; true", "raw_command":"echo hello"}),
896 json!({"cmd":"npx markdownlint-cli2", "args":["--fix", "README.md"]}),
897 ] {
898 assert!(!shell_command_contains_verifier(&args), "{args}");
899 }
900 }
901
902 #[test]
903 fn markdownlint_verification_preserves_command_boundaries() {
904 for command in [
905 "markdownlint-cli2 README.md",
906 "npx --yes markdownlint-cli2 README.md",
907 "npx --no-install markdownlint-cli2@0.23.3 README.md",
908 "npx -y markdownlint-cli2 README.md && cargo check --locked",
909 "python3 scripts/check_markdown.py",
910 "python ./scripts/check_markdown.py",
911 ] {
912 assert!(shell_command_is_admitted_verification_attempt(&exec_command(command)), "{command}");
913 assert_eq!(
914 classify_shell_activity(tools::EXEC_COMMAND, &exec_command(command)),
915 ShellActivity::Verification,
916 "{command}"
917 );
918 }
919 for command in [
920 "markdownlint-cli2 --fix README.md",
921 "npx --yes markdownlint-cli2 -f README.md",
922 "npx --yes markdownlint-cli2 --fix=true README.md",
923 "markdownlint-cli2 --version",
924 "npx --package evil markdownlint-cli2 README.md",
925 "npx markdownlint-cli2@evil README.md",
926 "npx markdownlint-cli2 README.md && rm README.md",
927 "python3 scripts/check_markdown.py --fix",
928 "python3 scripts/check_markdown.py --list",
929 "python3 scripts/check_markdown.py --help",
930 "python3 other/check_markdown.py",
931 ] {
932 assert!(!shell_command_is_admitted_verification_attempt(&exec_command(command)), "{command}");
933 }
934 assert_ne!(
935 classify_shell_activity(tools::EXEC_COMMAND, &exec_command("npx markdownlint-cli2 README.md | grep error")),
936 ShellActivity::Verification
937 );
938 }
939
940 #[test]
941 fn output_search_recognizes_static_search_pipelines() {
942 for command in [
943 "rg --files --hidden -g '.markdownlint*'",
944 "rg --files --hidden | rg -i 'markdownlint' | sed -n '1,15p'",
945 "git ls-files | grep -i markdownlint | head -10",
946 "LANG=C /usr/bin/grep -n missing README.md",
947 "rg -- '-q' README.md",
948 "rg -e -query src | head -10",
949 "grep -ne-query README.md",
950 "rg --regexp --quiet src",
951 "rg -g '-q*' missing src",
952 "rg '-g-q*' missing src",
953 "rg -q --no-quiet missing src",
954 ] {
955 assert!(shell_command_is_output_search(&exec_command(command)), "{command}");
956 }
957 assert!(shell_command_is_output_search(&json!({"command": ["rg", "missing", "src"]})));
958 }
959
960 #[test]
961 fn output_search_excludes_quiet_probes_and_unproven_commands() {
962 for command in [
963 "rg -q pattern src",
964 "grep -nq pattern README.md",
965 "rg --quiet pattern src | head -10",
966 "grep --silent pattern README.md",
967 "rg -e -query -q src",
968 "grep -nqe-query README.md",
969 "rg --regexp --quiet --quiet src",
970 "rg -q --no-quiet -q missing src",
971 "echo 'rg missing'",
972 "cat empty.txt",
973 "cargo check | rg error",
974 "rg missing src; touch result.txt",
975 "rg missing $(pwd)",
976 "rg missing src > result.txt",
977 "rg --pre 'touch result.txt' missing src",
978 ] {
979 assert!(!shell_command_is_output_search(&exec_command(command)), "{command}");
980 }
981 assert!(!shell_command_is_output_search(&json!({})));
982 }
983
984 #[test]
985 fn output_search_distinguishes_separator_values_from_quiet_flags() {
986 for option in [
987 "--context-separator",
988 "--field-context-separator",
989 "--field-match-separator",
990 ] {
991 let command = format!("rg {option} -q missing src | head -10");
992 assert!(shell_command_is_output_search(&exec_command(&command)), "{command}");
993 let quiet_command = format!("rg {option} -q --quiet missing src | head -10");
994 assert!(!shell_command_is_output_search(&exec_command(&quiet_command)), "{quiet_command}");
995 }
996 }
997
998 #[test]
999 fn standalone_grep_search_preserves_exit_status_ownership() {
1000 for command in [
1001 "rg missing src",
1002 "grep -q missing README.md",
1003 "LANG=C /usr/bin/grep -n missing README.md",
1004 "rg 'a|b' src",
1005 "fgrep missing README.md",
1006 ] {
1007 assert!(shell_command_is_standalone_grep_search(&exec_command(command)), "{command}");
1008 }
1009 assert!(shell_command_is_standalone_grep_search(&json!({"command": ["rg", "missing", "src"]})));
1010 for command in [
1011 "rg missing src; false",
1012 "rg missing src && false",
1013 "rg missing src | grep other",
1014 "! rg missing src",
1015 "(rg missing src)",
1016 "echo '/rg missing'; false",
1017 "rg missing src 2>/dev/null",
1018 "rg missing src 2>&1",
1019 "rg missing $(pwd)",
1020 "rg --pre 'false' missing src",
1021 ] {
1022 assert!(!shell_command_is_standalone_grep_search(&exec_command(command)), "{command}");
1023 }
1024 }
1025
1026 #[test]
1027 fn admitted_verification_attempt_allows_truncation_but_blocks_smuggled_mutations() {
1028 for command in [
1029 "cargo check --locked 2>&1 | head -c 4000",
1030 "cargo check --locked",
1031 "cargo nextest run 2>&1 | head -c 4000",
1032 ] {
1033 assert!(
1034 shell_command_is_admitted_verification_attempt(&exec_command(command)),
1035 "expected admission: {command}"
1036 );
1037 }
1038 for command in [
1039 "cargo check && rm -rf target",
1040 "cargo check; rm foo.txt",
1041 "cargo check || rm foo.txt",
1042 "cargo check && cargo test && rm foo.txt",
1043 "sed -i '' 's/old/new/' README.md",
1044 "echo $(date)",
1045 "cargo check > build.log && rm foo.txt",
1046 ] {
1047 assert!(
1048 !shell_command_is_admitted_verification_attempt(&exec_command(command)),
1049 "expected block: {command}"
1050 );
1051 }
1052 assert!(!shell_command_is_admitted_verification_attempt(&json!({})));
1053 }
1054
1055 #[test]
1056 fn logged_compound_inspection_commands_are_not_mutations() {
1057 for command in [
1058 "cat README.md && printf '\\n--- git status ---\\n' && git status --short",
1059 "wc -l README.md; rg -n '^#' README.md",
1060 "git diff --stat; find docs -maxdepth 2 -type f | sort | head -40",
1061 ] {
1062 assert_eq!(
1063 classify_shell_activity(tools::EXEC_COMMAND, &exec_command(command)),
1064 ShellActivity::Inspection,
1065 "{command}"
1066 );
1067 }
1068 }
1069
1070 #[cfg(unix)]
1071 #[test]
1072 fn captured_read_commands_with_output_suppression_are_inspection() {
1073 for command in [
1074 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"#,
1075 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"#,
1076 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"###,
1077 ] {
1078 assert_eq!(
1079 classify_shell_activity(tools::EXEC_COMMAND, &exec_command(command)),
1080 ShellActivity::Inspection,
1081 "{command}"
1082 );
1083 }
1084 }
1085
1086 #[cfg(unix)]
1087 #[test]
1088 fn printf_output_safety_guards_remain_mutations() {
1089 for command in [
1090 "printf 'captured output\\n' > output.txt",
1091 "printf '%s\\n' \"$(git status --short)\"",
1092 "printf '%s\\n' `git status --short`",
1093 "printf '\\n--- inspection ---\\n' && rm output.txt",
1094 ] {
1095 let args = exec_command(command);
1096 assert_eq!(classify_shell_activity(tools::EXEC_COMMAND, &args), ShellActivity::Mutation, "{command}");
1097 assert!(!is_readonly_command_session_command(&args), "unexpected readonly command: {command}");
1098 }
1099 }
1100
1101 #[test]
1102 fn git_diff_check_chain_remains_inspection() {
1103 assert_eq!(
1104 classify_shell_activity(
1105 tools::EXEC_COMMAND,
1106 &exec_command("git diff --check && git status --short && git diff --stat"),
1107 ),
1108 ShellActivity::Inspection
1109 );
1110 }
1111
1112 #[test]
1113 fn verification_detection_skips_environment_prefixes() {
1114 for command in [
1115 "env RUSTFLAGS=-Dwarnings cargo check",
1116 "RUSTFLAGS=-Dwarnings env cargo check",
1117 "env -u PATH cargo check",
1118 "env -C /tmp cargo check",
1119 ] {
1120 assert_eq!(
1121 classify_shell_activity(tools::EXEC_COMMAND, &exec_command(command)),
1122 ShellActivity::Verification,
1123 "{command}"
1124 );
1125 }
1126 }
1127
1128 #[test]
1129 fn ambiguous_or_mutating_compounds_remain_mutations() {
1130 for command in [
1131 "git diff --stat; python3 -c 'open(\"out\", \"w\").write(\"x\")'",
1132 "cat README.md; sed -i '' 's/a/b/' README.md",
1133 "sed --in-place= README.md",
1134 "git diff --output=out",
1135 "git diff '--output=out'",
1136 "git diff -o out",
1137 "git diff -oout",
1138 "git log --output=out",
1139 "git show --textconv",
1140 "git -c core.fsmonitor=touch status",
1144 "find . -fprint output.txt",
1145 "find . -fprintf output.txt '%p'",
1146 "rg --hostname-bin sh pattern",
1147 "rg --search-zip pattern",
1148 "rg -z pattern",
1149 "sort -o generated.txt README.md",
1150 "sort --compress-program=sh README.md",
1151 "date -s now",
1152 "awk -i inplace '{print}' README.md",
1153 "sed -n 's/a/b/e' README.md",
1154 "fd --exec sh -c 'touch output'",
1155 "tree -o output.txt",
1156 "ast-grep -r 'README.md'",
1157 "sed -n -fmalicious.sed -e '1p' src/main.rs",
1158 "sed -I '' 's/a/b/' src/main.rs",
1159 "sed -n '1p\nw leaked.txt' src/main.rs",
1160 "cargo check & rm output",
1161 "cargo check > build.log | rm output",
1162 "cargo check | head -40 > build.log",
1163 "cargo check | echo x > output.log",
1164 "cargo check | head -40",
1165 "cargo check > build.log &",
1166 "env -S 'cargo check'",
1167 "echo x > output.log && cargo check",
1168 "cargo check < build-input.log",
1169 "cat README.md > copied.txt",
1170 "git diff --check; rm output",
1171 "cat README.md\nrm output",
1172 ] {
1173 assert_eq!(
1174 classify_shell_activity(tools::EXEC_COMMAND, &exec_command(command)),
1175 ShellActivity::Mutation,
1176 "{command}"
1177 );
1178 }
1179 }
1180
1181 #[test]
1182 fn quoted_output_text_does_not_change_inspection_classification() {
1183 for command in ["echo 'git diff --output=out'", "printf 'sort -o out input'"] {
1184 assert_eq!(
1185 classify_shell_activity(tools::EXEC_COMMAND, &exec_command(command)),
1186 ShellActivity::Inspection,
1187 "{command}"
1188 );
1189 }
1190 }
1191
1192 #[test]
1193 fn cargo_fmt_check_is_verification_but_plain_fmt_is_not() {
1194 for command in [
1195 "cargo fmt --check",
1196 "cargo fmt --all -- --check",
1197 "cargo fmt -- --check",
1198 ] {
1199 assert_eq!(
1200 classify_shell_activity(tools::EXEC_COMMAND, &exec_command(command)),
1201 ShellActivity::Verification,
1202 "{command}"
1203 );
1204 assert!(
1205 shell_command_is_admitted_verification_attempt(&exec_command(command)),
1206 "expected admission: {command}"
1207 );
1208 }
1209 assert_eq!(classify_shell_activity(tools::EXEC_COMMAND, &exec_command("cargo fmt")), ShellActivity::Mutation);
1212 assert!(!shell_command_is_admitted_verification_attempt(&exec_command("cargo fmt")));
1213 }
1214
1215 #[test]
1216 fn pure_and_chained_verifiers_are_verification() {
1217 for command in [
1218 "cargo fmt --all -- --check && cargo check --locked",
1219 "cargo check --locked && cargo nextest run --locked -p vtcode-ui",
1220 "cargo check --locked && cargo clippy --locked -p vtcode-ui -- -D warnings",
1221 "git diff --check && cargo check --locked",
1222 ] {
1223 assert_eq!(
1224 classify_shell_activity(tools::EXEC_COMMAND, &exec_command(command)),
1225 ShellActivity::Verification,
1226 "{command}"
1227 );
1228 assert!(
1229 shell_command_is_admitted_verification_attempt(&exec_command(command)),
1230 "expected admission: {command}"
1231 );
1232 }
1233 }
1234
1235 #[test]
1236 fn non_and_chained_verifiers_remain_mutations() {
1237 for command in [
1238 "cargo check --locked; cargo nextest run --locked -p vtcode-ui",
1239 "cargo check --locked || cargo nextest run --locked -p vtcode-ui",
1240 "cargo check --locked | head -40",
1241 "cargo check --locked && cargo nextest run --locked -p vtcode-ui | head -40",
1242 "cargo check --locked &",
1243 ] {
1244 assert_eq!(
1245 classify_shell_activity(tools::EXEC_COMMAND, &exec_command(command)),
1246 ShellActivity::Mutation,
1247 "{command}"
1248 );
1249 }
1250 }
1251
1252 #[test]
1253 fn expanded_verifiers_classify_as_verification() {
1254 for command in [
1255 "bun test",
1256 "bun run test",
1257 "deno lint",
1258 "deno check mod.ts",
1259 "make test",
1260 "make lint",
1261 "just verify",
1262 "ruff check src/",
1263 "ruff format --check",
1264 "tsc --noEmit",
1265 "eslint src/",
1266 "python3 -m pytest",
1267 "uv run pytest",
1268 "npm run lint",
1269 ] {
1270 assert_eq!(
1271 classify_shell_activity(tools::EXEC_COMMAND, &exec_command(command)),
1272 ShellActivity::Verification,
1273 "{command}"
1274 );
1275 }
1276 for command in [
1277 "make clean",
1278 "make test clean",
1279 "just fmt",
1280 "tsc",
1281 "eslint --fix src/",
1282 "ruff format src/",
1283 "bun install",
1284 "uv sync",
1285 ] {
1286 assert_eq!(
1287 classify_shell_activity(tools::EXEC_COMMAND, &exec_command(command)),
1288 ShellActivity::Mutation,
1289 "{command}"
1290 );
1291 }
1292 }
1293
1294 #[test]
1295 fn escaped_quotes_fail_closed_instead_of_hiding_operators() {
1296 assert!(!shell_uses_only_and_chaining("cargo check --locked \\\"; echo ok"));
1300 assert!(!shell_uses_only_and_chaining("echo \\\" ; cargo check --locked && echo done"));
1301 assert!(shell_uses_only_and_chaining("echo \"a\\\"b\" && cargo check --locked"));
1303 assert!(shell_uses_only_and_chaining("echo \"path\" && cargo fmt --check"));
1304 assert!(shell_uses_only_and_chaining("echo 'a\\b' && cargo check --locked"));
1306 }
1307
1308 #[test]
1309 fn default_verifier_prefers_manifest_priority_order() {
1310 let dir = tempfile::tempdir().expect("tempdir");
1311 assert_eq!(default_verifier_for_workspace(dir.path()), None);
1312
1313 std::fs::write(dir.path().join("justfile"), "test:\n\techo ok\n").expect("justfile");
1314 assert_eq!(default_verifier_for_workspace(dir.path()).as_deref(), Some("just test"));
1315 std::fs::write(dir.path().join("pyproject.toml"), "[tool.pytest]\n").expect("pyproject");
1316 assert_eq!(default_verifier_for_workspace(dir.path()).as_deref(), Some("pytest -q"));
1317 std::fs::write(dir.path().join("package.json"), r#"{"scripts":{"test":"vitest"}}"#).expect("package.json");
1318 assert_eq!(default_verifier_for_workspace(dir.path()).as_deref(), Some("npm test"));
1319 std::fs::write(dir.path().join("go.mod"), "module example\n").expect("go.mod");
1320 assert_eq!(default_verifier_for_workspace(dir.path()).as_deref(), Some("go test ./..."));
1321 std::fs::write(dir.path().join("Cargo.toml"), "[package]\nname=\"x\"\n").expect("Cargo.toml");
1322 assert_eq!(default_verifier_for_workspace(dir.path()).as_deref(), Some("cargo check --locked"));
1323 }
1324
1325 #[test]
1326 fn default_verifier_reads_npm_script_fallbacks() {
1327 let dir = tempfile::tempdir().expect("tempdir");
1328 std::fs::write(dir.path().join("package.json"), r#"{"scripts":{"lint":"eslint ."}}"#).expect("package.json");
1329 assert_eq!(default_verifier_for_workspace(dir.path()).as_deref(), Some("npm run lint"));
1330 }
1331
1332 #[test]
1333 fn verification_recovery_directive_names_concrete_command() {
1334 let directive = verification_recovery_directive(Some("go test ./..."), 1, 2);
1335 assert!(directive.contains("go test ./..."));
1336 assert!(directive.contains("1/2"));
1337 assert!(directive.contains("max_output_tokens"));
1338 assert!(directive.contains(VERIFIER_SHELL_FORM_NOTE));
1339 assert!(directive.contains("tails run standalone"));
1340 assert!(directive.contains("fail-closed `pipefail`"));
1341 let fallback = verification_recovery_directive(None, 2, 2);
1342 assert!(fallback.contains(GENERIC_VERIFIER_DESCRIPTION));
1343 assert!(fallback.contains("2/2"));
1344 }
1345
1346 #[test]
1347 fn verifier_reference_names_resolved_command_or_generic_description() {
1348 assert_eq!(verifier_reference(Some("go test ./...")), "`go test ./...`");
1349 assert_eq!(verifier_reference(Some(" npm test ")), "`npm test`");
1350 for missing in [None, Some(""), Some(" ")] {
1351 let reference = verifier_reference(missing);
1352 assert_eq!(reference, GENERIC_VERIFIER_DESCRIPTION);
1353 assert!(!reference.starts_with('`'), "no single command is named: {reference}");
1354 for ecosystem in ["cargo check --locked", "go test ./...", "npm test", "pytest -q"] {
1355 assert!(reference.contains(ecosystem), "missing {ecosystem}: {reference}");
1356 }
1357 }
1358 }
1359
1360 #[test]
1361 fn truncation_only_verifier_rewrites_to_standalone_prefix() {
1362 for (command, expected) in [
1363 ("cargo check --locked 2>&1 | head -c 4000", "cargo check --locked 2>&1"),
1364 ("cargo check --locked -p vtcode 2>&1 | tail -20", "cargo check --locked -p vtcode 2>&1"),
1365 ("cargo nextest run 2>&1 | tail -15", "cargo nextest run 2>&1"),
1366 (
1367 "cargo check --locked && cargo nextest run --locked -p vtcode | tail -5",
1368 "cargo check --locked && cargo nextest run --locked -p vtcode",
1369 ),
1370 ("cargo check | tail -5 | head -20", "cargo check"),
1371 ("RUSTFLAGS=-Dwarnings cargo check | head -20", "RUSTFLAGS=-Dwarnings cargo check"),
1372 ("cargo check > build.log | tail -5", "cargo check > build.log"),
1376 ] {
1377 assert_eq!(
1378 rewrite_truncation_only_verifier(&exec_command(command)).as_deref(),
1379 Some(expected),
1380 "expected rewrite: {command}"
1381 );
1382 }
1383 }
1384
1385 #[test]
1386 fn non_truncation_pipelines_and_mutations_are_never_rewritten() {
1387 for command in [
1391 "cargo check | grep error",
1392 "cargo check | wc -l",
1393 "cargo check | sort | uniq",
1394 "cargo check && rm -rf target | tail -5",
1395 "cargo check; git status | tail -5",
1396 "cargo check || cargo test | tail -5",
1397 "cargo check | tail -5; rm foo.txt",
1398 "cargo check | tail &",
1399 "cargo check",
1400 "echo done | tail -5",
1401 "rg -n 'pattern' src | head -20",
1402 "echo $(date) | tail -5",
1403 ] {
1404 assert_eq!(rewrite_truncation_only_verifier(&exec_command(command)), None, "must not rewrite: {command}");
1405 }
1406 assert_eq!(
1407 rewrite_truncation_only_verifier(&serde_json::json!({"command": ["cargo", "check", "|", "tail"]})),
1408 None,
1409 "array-form commands keep today's behavior"
1410 );
1411 assert_eq!(rewrite_truncation_only_verifier(&serde_json::json!({})), None);
1412 }
1413
1414 #[test]
1415 fn args_as_executed_classify_elided_truncation_verifiers_as_verification() {
1416 for command in [
1417 "cargo check --locked 2>&1 | tail -5",
1418 "cargo nextest run 2>&1 | head -c 4000",
1419 ] {
1420 let typed = exec_command(command);
1421 let executed = shell_args_as_executed(tools::EXEC_COMMAND, &typed);
1422 assert!(matches!(executed, std::borrow::Cow::Owned(_)), "expected rewrite: {command}");
1423 assert_eq!(
1424 classify_shell_activity(tools::EXEC_COMMAND, &executed),
1425 ShellActivity::Verification,
1426 "{command}"
1427 );
1428 assert!(matches!(shell_args_as_executed(tools::EXEC_COMMAND, &executed), std::borrow::Cow::Borrowed(_)));
1430 }
1431
1432 let unified = json!({"action": "run", "command": "cargo check 2>&1 | tail -5"});
1433 assert_eq!(shell_args_as_executed(tools::UNIFIED_EXEC, &unified)["command"], "cargo check 2>&1");
1434 }
1435
1436 #[test]
1437 fn verifier_rewrite_keeps_raw_alias_and_appended_arguments_in_sync() {
1438 let command = "cargo check | grep";
1439 let typed = json!({"cmd":command, "raw_command":command, "args":["warning"]});
1440 let executed = shell_args_as_executed(tools::EXEC_COMMAND, &typed);
1441 let expected = "set -o pipefail && cargo check | grep warning";
1442 assert_eq!(executed["command"], expected);
1443 assert_eq!(executed["raw_command"], expected);
1444 assert!(executed.get("args").is_none(), "suffix must execute exactly once");
1445 assert_eq!(classify_shell_activity(tools::EXEC_COMMAND, &executed), ShellActivity::Verification);
1446 assert!(matches!(shell_args_as_executed(tools::EXEC_COMMAND, &executed), std::borrow::Cow::Borrowed(_)));
1447
1448 let truncator = json!({"cmd":"cargo check | head", "args":["-20"]});
1449 assert_eq!(shell_args_as_executed(tools::EXEC_COMMAND, &truncator)["command"], "cargo check");
1450 }
1451
1452 #[test]
1453 fn verifier_pipeline_rejects_conflicting_raw_alias_and_mutating_suffix() {
1454 for typed in [
1455 json!({"cmd":"cargo check | grep marker", "raw_command":"printf marker"}),
1456 json!({"cmd":"cargo check | sort", "args":["-o", "changed.txt"]}),
1457 json!({"cmd":"set -o pipefail && cargo check | sort", "args":["-o", "changed.txt"]}),
1458 json!({"cmd":"cargo check | grep", "args":"invalid"}),
1459 ] {
1460 assert_eq!(rewrite_verifier_pipeline(&typed), None, "{typed}");
1461 assert!(!shell_command_is_admitted_verification_attempt(&typed), "{typed}");
1462 assert_eq!(classify_shell_activity(tools::EXEC_COMMAND, &typed), ShellActivity::Mutation, "{typed}");
1463 assert!(super::super::classify_tool_intent(tools::EXEC_COMMAND, &typed).mutating, "{typed}");
1464 }
1465 }
1466
1467 #[test]
1468 fn filtered_verifier_pipeline_preserves_filters_and_truthful_status() {
1469 for command in [
1470 "python3 scripts/check_markdown.py 2>&1 | grep -E '^(README|docs/SECURITY)\\.md' | head -20",
1471 "cargo check --locked | grep warning",
1472 "cargo fmt --check && cargo check --locked | wc -l",
1473 ] {
1474 let typed = exec_command(command);
1475 let executed = shell_args_as_executed(tools::EXEC_COMMAND, &typed);
1476 assert_eq!(executed["command"], format!("set -o pipefail && {command}"));
1477 assert_eq!(classify_shell_activity(tools::EXEC_COMMAND, &executed), ShellActivity::Verification);
1478 assert!(shell_command_is_admitted_verification_attempt(&executed));
1479 assert!(matches!(shell_args_as_executed(tools::EXEC_COMMAND, &executed), std::borrow::Cow::Borrowed(_)));
1480 assert_eq!(classify_shell_activity(tools::EXEC_COMMAND, &typed), ShellActivity::Mutation);
1481 }
1482 for command in [
1483 "cargo check | grep error; true",
1484 "cargo check | grep error || true",
1485 "cargo check | tee source.rs",
1486 "cargo check | sed -i 's/old/new/' source.rs",
1487 "cargo check | grep $(touch marker)",
1488 "cargo check | grep error > hidden.log",
1489 "cargo check | grep error &",
1490 "cargo check && rm source.rs | grep error",
1491 ] {
1492 assert_eq!(rewrite_verifier_pipeline(&exec_command(command)), None, "{command}");
1493 let prefixed = exec_command(&format!("set -o pipefail && {command}"));
1494 assert_ne!(
1495 classify_shell_activity(tools::EXEC_COMMAND, &prefixed),
1496 ShellActivity::Verification,
1497 "{command}"
1498 );
1499 }
1500 }
1501
1502 #[test]
1503 fn args_as_executed_keep_unsafe_joins_and_non_run_calls_as_typed() {
1504 for command in [
1505 "cargo check 2>&1 | grep error; true",
1506 "cargo check; git status",
1507 "cargo check || true",
1508 ] {
1509 let typed = exec_command(command);
1510 let executed = shell_args_as_executed(tools::EXEC_COMMAND, &typed);
1511 assert!(matches!(executed, std::borrow::Cow::Borrowed(_)), "must run as typed: {command}");
1512 assert_ne!(
1513 classify_shell_activity(tools::EXEC_COMMAND, &executed),
1514 ShellActivity::Verification,
1515 "{command}"
1516 );
1517 }
1518 let poll = json!({"action": "poll", "session_id": "s1", "command": "cargo check | tail -5"});
1519 assert!(matches!(shell_args_as_executed(tools::UNIFIED_EXEC, &poll), std::borrow::Cow::Borrowed(_)));
1520 let read = json!({"path": "src/lib.rs"});
1521 assert!(matches!(shell_args_as_executed(tools::READ_FILE, &read), std::borrow::Cow::Borrowed(_)));
1522 }
1523}