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
298const PRE_NODE_VERIFIER_MARKERS: &[(&[&str], &str)] = &[
302 (&["Cargo.toml"], "cargo check --locked"),
303 (&["go.mod"], "go test ./..."),
304 (&["deno.json", "deno.jsonc"], "deno test"),
305 (&["bun.lockb", "bun.lock"], "bun test"),
306];
307
308const POST_NODE_VERIFIER_MARKERS: &[(&[&str], &str)] = &[
311 (&["pytest.ini", "pyproject.toml", "setup.cfg", "tox.ini"], "pytest -q"),
312 (&["Makefile", "makefile"], "make test"),
313 (&["justfile", "Justfile"], "just test"),
314];
315
316fn verifier_for_markers(workspace_root: &Path, markers: &[(&[&str], &str)]) -> Option<String> {
318 markers.iter().find_map(|(files, verifier)| {
319 files
320 .iter()
321 .any(|file| workspace_root.join(file).is_file())
322 .then(|| (*verifier).to_string())
323 })
324}
325
326fn default_verifier_for_node_workspace(workspace_root: &Path) -> Option<String> {
331 let package_json = workspace_root.join("package.json");
332 if !package_json.is_file() {
333 return None;
334 }
335 if let Ok(content) = std::fs::read_to_string(&package_json)
336 && let Ok(parsed) = serde_json::from_str::<Value>(&content)
337 && let Some(scripts) = parsed.get("scripts").and_then(Value::as_object)
338 {
339 for (script, command) in [
340 ("test", "npm test"),
341 ("check", "npm run check"),
342 ("lint", "npm run lint"),
343 ("build", "npm run build"),
344 ] {
345 if scripts.contains_key(script) {
346 return Some(command.to_string());
347 }
348 }
349 }
350 Some("npm test".to_string())
351}
352
353pub fn default_verifier_for_workspace(workspace_root: &Path) -> Option<String> {
369 if let Some(verifier) = verifier_for_markers(workspace_root, PRE_NODE_VERIFIER_MARKERS) {
370 return Some(verifier);
371 }
372 if let Some(verifier) = default_verifier_for_node_workspace(workspace_root) {
373 return Some(verifier);
374 }
375 verifier_for_markers(workspace_root, POST_NODE_VERIFIER_MARKERS)
377}
378
379pub 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.";
383
384pub const GENERIC_VERIFIER_DESCRIPTION: &str =
390 "your project's build/test/lint command (e.g. `cargo check --locked`, `go test ./...`, `npm test`, or `pytest -q`)";
391
392pub fn verifier_reference(default_verifier: Option<&str>) -> String {
397 match default_verifier.map(str::trim).filter(|command| !command.is_empty()) {
398 Some(command) => format!("`{command}`"),
399 None => GENERIC_VERIFIER_DESCRIPTION.to_string(),
400 }
401}
402
403pub fn verification_recovery_directive(default_verifier: Option<&str>, attempt: u8, max_attempts: u8) -> String {
408 let verifier = verifier_reference(default_verifier);
409 format!(
410 "Verification recovery ({attempt}/{max_attempts}): pending edits have not been verified, so further mutations are blocked \
411 and the turn ends blocked unless a verifier exits 0. Run {verifier} with `exec_command`, standalone or as a pure `&&` chain of verifiers. \
412 {VERIFIER_SHELL_FORM_NOTE} A failed verifier grants a bounded number of fix-up edits before the next verification is required."
413 )
414}
415
416pub fn shell_command_is_admitted_verification_attempt(args: &Value) -> bool {
431 let Some(command) = shell_command_text_with_args(args) else {
432 return false;
433 };
434 if crate::tools::command_args::contains_dynamic_shell_syntax(&command) {
435 return false;
436 }
437 if command.starts_with(VERIFIER_PIPEFAIL_PREFIX) {
438 return pipefail_verifier_pipeline(&command).is_some();
439 }
440 let segments =
441 static_shell_command_words(&command).or_else(|| static_shell_command_words_with_output_plumbing(&command));
442 let Some(segments) = segments else {
443 return false;
444 };
445 if segments.is_empty() {
446 return false;
447 }
448 let mut saw_verification = false;
449 for words in &segments {
450 if is_verification_invocation(words) {
451 saw_verification = true;
452 } else if !command_words_are_readonly(words) {
453 return false;
454 }
455 }
456 saw_verification
457}
458
459fn has_logical_sequencing(words: &[String]) -> bool {
460 words.iter().any(|word| matches!(word.as_str(), "&&" | "||" | ";"))
461}
462
463fn split_top_level_pipes(command: &str) -> Option<Vec<&str>> {
470 if !command.contains('|') {
471 return None;
472 }
473 let mut stages = Vec::new();
474 let mut start = 0;
475 let mut saw_pipe = false;
476 let mut in_single_quote = false;
477 let mut in_double_quote = false;
478 let mut chars = command.char_indices().peekable();
479 while let Some((index, character)) = chars.next() {
480 if character == '\\' && !in_single_quote {
484 chars.next();
485 continue;
486 }
487 if character == '\'' && !in_double_quote {
488 in_single_quote = !in_single_quote;
489 continue;
490 }
491 if character == '"' && !in_single_quote {
492 in_double_quote = !in_double_quote;
493 continue;
494 }
495 if in_single_quote || in_double_quote {
496 continue;
497 }
498 if character == '|' {
499 saw_pipe = true;
500 stages.push(&command[start..index]);
501 start = index + character.len_utf8();
502 }
503 }
504 if !saw_pipe {
505 return None;
506 }
507 stages.push(&command[start..]);
508 Some(stages)
509}
510
511fn is_pure_truncation_stage(stage: &str) -> bool {
517 let trimmed = stage.trim();
518 if trimmed.is_empty() {
519 return false;
520 }
521 let mut in_single_quote = false;
526 let mut in_double_quote = false;
527 let mut chars = trimmed.chars().peekable();
528 while let Some(character) = chars.next() {
529 if character == '\\' && !in_single_quote {
530 chars.next();
531 continue;
532 }
533 if character == '\'' && !in_double_quote {
534 in_single_quote = !in_single_quote;
535 continue;
536 }
537 if character == '"' && !in_single_quote {
538 in_double_quote = !in_double_quote;
539 continue;
540 }
541 if in_single_quote || in_double_quote {
542 continue;
543 }
544 if matches!(character, ';' | '&' | '<' | '>' | '\n' | '|') {
545 return false;
546 }
547 }
548 let words = match shell_words::split(trimmed) {
549 Ok(words) => words,
550 Err(_) => return false,
551 };
552 let Some(parsed) = NonEmptySlice::from_slice(&words) else {
553 return false;
554 };
555 let program = Path::new(parsed.first())
556 .file_name()
557 .and_then(|name| name.to_str())
558 .unwrap_or(parsed.first().as_str())
559 .to_ascii_lowercase();
560 matches!(program.as_str(), "head" | "tail")
561}
562
563pub fn rewrite_truncation_only_verifier(args: &Value) -> Option<String> {
593 use crate::config::constants::tools as tool_names;
594
595 let command = shell_command_text_with_args(args)?;
596 if !shell_command_is_admitted_verification_attempt(args) {
597 return None;
598 }
599 let stages = split_top_level_pipes(&command)?;
600 let (head, tails) = stages.split_first()?;
601 let head = head.trim();
602 if head.is_empty() || tails.iter().any(|stage| !is_pure_truncation_stage(stage)) {
603 return None;
604 }
605 let head_args = serde_json::json!({"cmd": head});
606 if !matches!(classify_shell_activity(tool_names::EXEC_COMMAND, &head_args), ShellActivity::Verification) {
607 return None;
608 }
609 Some(head.to_string())
610}
611
612const VERIFIER_PIPEFAIL_PREFIX: &str = "set -o pipefail && ";
614
615pub(crate) fn pipefail_verifier_pipeline(command: &str) -> Option<&str> {
618 let pipeline = command.strip_prefix(VERIFIER_PIPEFAIL_PREFIX)?;
619 if crate::tools::command_args::contains_dynamic_shell_syntax(pipeline) {
620 return None;
621 }
622 verifier_pipeline_head(pipeline).map(|_| pipeline)
623}
624
625fn verifier_pipeline_head(command: &str) -> Option<&str> {
627 let stages = split_top_level_pipes(command)?;
628 let (head, tails) = stages.split_first()?;
629 let head = head.trim();
630 let head_args = serde_json::json!({"cmd": head});
631 if classify_shell_activity(crate::config::constants::tools::EXEC_COMMAND, &head_args) != ShellActivity::Verification
632 {
633 return None;
634 }
635 for stage in tails {
638 let segments = static_shell_command_words(stage)?;
639 if segments.len() != 1 || !segments.first().is_some_and(|words| command_words_are_readonly(words)) {
640 return None;
641 }
642 }
643 Some(head)
644}
645
646fn rewrite_verifier_pipeline(args: &Value) -> Option<String> {
651 if let Some(standalone) = rewrite_truncation_only_verifier(args) {
652 return Some(standalone);
653 }
654 let command = shell_command_text_with_args(args)?;
655 if command.starts_with(VERIFIER_PIPEFAIL_PREFIX)
656 || !shell_command_is_admitted_verification_attempt(args)
657 || verifier_pipeline_head(&command).is_none()
658 {
659 return None;
660 }
661 Some(format!("{VERIFIER_PIPEFAIL_PREFIX}{command}"))
662}
663
664pub fn shell_args_as_executed<'a>(tool_name: &str, args: &'a Value) -> std::borrow::Cow<'a, Value> {
676 if !super::is_command_run_tool_call(tool_name, args) {
677 return std::borrow::Cow::Borrowed(args);
678 }
679 let Some(rewritten) = rewrite_verifier_pipeline(args) else {
680 return std::borrow::Cow::Borrowed(args);
681 };
682 let mut executed = args.clone();
683 match executed.as_object_mut() {
684 Some(payload) => {
685 if payload.get("raw_command").is_some_and(Value::is_string) {
686 payload.insert("raw_command".to_string(), Value::String(rewritten.clone()));
687 }
688 payload.remove("args");
691 payload.insert("command".to_string(), Value::String(rewritten));
692 std::borrow::Cow::Owned(executed)
693 }
694 None => std::borrow::Cow::Borrowed(args),
695 }
696}
697
698fn is_known_inspection(words: &[String]) -> bool {
699 if words
700 .iter()
701 .any(|word| matches!(word.as_str(), ">" | ">>" | "|" | "&&" | ";" | "||"))
702 {
703 return false;
704 }
705 command_words_are_readonly(words)
706}
707
708fn classify_provable_shell_sequence(command: &str) -> Option<ShellActivity> {
709 let (segments, has_output_plumbing) = if let Some(segments) = static_shell_command_words(command) {
710 (segments, false)
711 } else {
712 (static_shell_command_words_with_output_plumbing(command)?, true)
713 };
714 if has_output_plumbing && segments.len() != 1 {
715 return None;
716 }
717 let has_multiple_segments = segments.len() > 1;
718 let mut saw_verification = false;
719
720 for words in segments {
721 if is_verification_invocation(&words) {
722 saw_verification = true;
723 } else if !command_words_are_readonly(&words) {
724 return None;
725 }
726 }
727
728 if has_output_plumbing && !saw_verification {
729 return None;
730 }
731
732 if saw_verification && has_multiple_segments {
743 if shell_uses_only_and_chaining(command) {
744 return Some(ShellActivity::Verification);
745 }
746 return Some(ShellActivity::Mutation);
747 }
748
749 Some(if saw_verification {
750 ShellActivity::Verification
751 } else {
752 ShellActivity::Inspection
753 })
754}
755
756fn has_shell_sequence(command: &str) -> bool {
757 static_shell_command_words(command).is_none_or(|segments| segments.len() > 1)
758}
759
760fn shell_uses_only_and_chaining(command: &str) -> bool {
770 let chars: Vec<char> = command.chars().collect();
771 let mut index = 0;
772 let mut in_single_quote = false;
773 let mut in_double_quote = false;
774
775 while index < chars.len() {
776 let character = chars[index];
777 if character == '\\' && !in_single_quote && index + 1 < chars.len() {
782 index += 2;
783 continue;
784 }
785 if character == '\'' && !in_double_quote {
786 in_single_quote = !in_single_quote;
787 index += 1;
788 continue;
789 }
790 if character == '"' && !in_single_quote {
791 in_double_quote = !in_double_quote;
792 index += 1;
793 continue;
794 }
795 if in_single_quote || in_double_quote {
796 index += 1;
797 continue;
798 }
799 match character {
800 ';' | '\n' | '|' => return false,
801 '&' => {
802 let next_is_and = chars.get(index + 1) == Some(&'&');
803 if !next_is_and {
804 return false;
805 }
806 let third = chars.get(index + 2);
810 if third == Some(&'&') || third == Some(&'|') {
811 return false;
812 }
813 index += 2;
814 continue;
815 }
816 _ => {}
817 }
818 index += 1;
819 }
820
821 true
822}
823
824#[must_use]
833pub fn classify_shell_activity(tool_name: &str, args: &Value) -> ShellActivity {
834 let command = shell_command_text_with_args(args);
835 if command.is_none() && crate::tools::command_args::raw_command_text(args).is_some() {
836 return ShellActivity::Mutation;
837 }
838 if command.as_deref().and_then(pipefail_verifier_pipeline).is_some() {
839 return ShellActivity::Verification;
840 }
841 let words = crate::tools::command_args::command_words(args).ok().flatten();
842 let has_unclassified_shell_sequence = command.as_deref().is_some_and(has_shell_sequence);
843
844 if let Some(activity) = command.as_deref().and_then(classify_provable_shell_sequence) {
845 return activity;
846 }
847 if has_unclassified_shell_sequence {
850 return ShellActivity::Mutation;
851 }
852
853 let intent = super::classify_tool_intent(tool_name, args);
854
855 if !has_unclassified_shell_sequence && words.as_deref().is_some_and(is_known_inspection) {
856 return ShellActivity::Inspection;
857 }
858
859 let starts_with_verification = words.as_deref().is_some_and(is_verification_invocation);
860 let contains_verification =
861 starts_with_verification || command.as_deref().is_some_and(contains_verification_invocation);
862 if !intent.mutating {
863 return if contains_verification {
864 ShellActivity::Verification
865 } else {
866 ShellActivity::Inspection
867 };
868 }
869
870 if starts_with_verification
871 && !has_unclassified_shell_sequence
872 && !words.as_deref().is_some_and(has_logical_sequencing)
873 {
874 ShellActivity::Verification
875 } else {
876 ShellActivity::Mutation
877 }
878}
879
880#[cfg(test)]
881mod tests {
882 use serde_json::json;
883
884 use super::*;
885 use crate::config::constants::tools;
886 use crate::tools::tool_intent::is_readonly_command_session_command;
887
888 fn exec_command(command: &str) -> Value {
889 json!({"cmd": command})
890 }
891
892 #[test]
893 fn verifier_diagnostics_recognize_masked_status_without_granting_admission() {
894 for command in [
895 "npx markdownlint-cli2 README.md; echo \"lint exit: $?\"",
896 "cargo check; echo $?",
897 "python3 scripts/check_markdown.py; echo \"lint exit: $?\"",
898 "cargo check --locked || true",
899 "cargo check; rm marker",
900 ] {
901 let args = exec_command(command);
902 assert!(shell_command_contains_verifier(&args), "{command}");
903 assert_eq!(classify_shell_activity(tools::EXEC_COMMAND, &args), ShellActivity::Mutation);
904 }
905 let masked = exec_command("npx markdownlint-cli2 README.md; echo \"lint exit: $?\"");
906 assert!(!shell_command_is_admitted_verification_attempt(&masked));
907 assert_eq!(shell_args_as_executed(tools::EXEC_COMMAND, &masked).as_ref(), &masked);
908 for args in [
909 exec_command("echo 'cargo check; echo $?'"),
910 exec_command("grep 'cargo check' README.md"),
911 exec_command("npx markdownlint-cli2 --fix README.md"),
912 json!({"cmd":"cargo check; true", "raw_command":"echo hello"}),
913 json!({"cmd":"npx markdownlint-cli2", "args":["--fix", "README.md"]}),
914 ] {
915 assert!(!shell_command_contains_verifier(&args), "{args}");
916 }
917 }
918
919 #[test]
920 fn markdownlint_verification_preserves_command_boundaries() {
921 for command in [
922 "markdownlint-cli2 README.md",
923 "npx --yes markdownlint-cli2 README.md",
924 "npx --no-install markdownlint-cli2@0.23.3 README.md",
925 "npx -y markdownlint-cli2 README.md && cargo check --locked",
926 "python3 scripts/check_markdown.py",
927 "python ./scripts/check_markdown.py",
928 ] {
929 assert!(shell_command_is_admitted_verification_attempt(&exec_command(command)), "{command}");
930 assert_eq!(
931 classify_shell_activity(tools::EXEC_COMMAND, &exec_command(command)),
932 ShellActivity::Verification,
933 "{command}"
934 );
935 }
936 for command in [
937 "markdownlint-cli2 --fix README.md",
938 "npx --yes markdownlint-cli2 -f README.md",
939 "npx --yes markdownlint-cli2 --fix=true README.md",
940 "markdownlint-cli2 --version",
941 "npx --package evil markdownlint-cli2 README.md",
942 "npx markdownlint-cli2@evil README.md",
943 "npx markdownlint-cli2 README.md && rm README.md",
944 "python3 scripts/check_markdown.py --fix",
945 "python3 scripts/check_markdown.py --list",
946 "python3 scripts/check_markdown.py --help",
947 "python3 other/check_markdown.py",
948 ] {
949 assert!(!shell_command_is_admitted_verification_attempt(&exec_command(command)), "{command}");
950 }
951 assert_ne!(
952 classify_shell_activity(tools::EXEC_COMMAND, &exec_command("npx markdownlint-cli2 README.md | grep error")),
953 ShellActivity::Verification
954 );
955 }
956
957 #[test]
958 fn output_search_recognizes_static_search_pipelines() {
959 for command in [
960 "rg --files --hidden -g '.markdownlint*'",
961 "rg --files --hidden | rg -i 'markdownlint' | sed -n '1,15p'",
962 "git ls-files | grep -i markdownlint | head -10",
963 "LANG=C /usr/bin/grep -n missing README.md",
964 "rg -- '-q' README.md",
965 "rg -e -query src | head -10",
966 "grep -ne-query README.md",
967 "rg --regexp --quiet src",
968 "rg -g '-q*' missing src",
969 "rg '-g-q*' missing src",
970 "rg -q --no-quiet missing src",
971 ] {
972 assert!(shell_command_is_output_search(&exec_command(command)), "{command}");
973 }
974 assert!(shell_command_is_output_search(&json!({"command": ["rg", "missing", "src"]})));
975 }
976
977 #[test]
978 fn output_search_excludes_quiet_probes_and_unproven_commands() {
979 for command in [
980 "rg -q pattern src",
981 "grep -nq pattern README.md",
982 "rg --quiet pattern src | head -10",
983 "grep --silent pattern README.md",
984 "rg -e -query -q src",
985 "grep -nqe-query README.md",
986 "rg --regexp --quiet --quiet src",
987 "rg -q --no-quiet -q missing src",
988 "echo 'rg missing'",
989 "cat empty.txt",
990 "cargo check | rg error",
991 "rg missing src; touch result.txt",
992 "rg missing $(pwd)",
993 "rg missing src > result.txt",
994 "rg --pre 'touch result.txt' missing src",
995 ] {
996 assert!(!shell_command_is_output_search(&exec_command(command)), "{command}");
997 }
998 assert!(!shell_command_is_output_search(&json!({})));
999 }
1000
1001 #[test]
1002 fn output_search_distinguishes_separator_values_from_quiet_flags() {
1003 for option in [
1004 "--context-separator",
1005 "--field-context-separator",
1006 "--field-match-separator",
1007 ] {
1008 let command = format!("rg {option} -q missing src | head -10");
1009 assert!(shell_command_is_output_search(&exec_command(&command)), "{command}");
1010 let quiet_command = format!("rg {option} -q --quiet missing src | head -10");
1011 assert!(!shell_command_is_output_search(&exec_command(&quiet_command)), "{quiet_command}");
1012 }
1013 }
1014
1015 #[test]
1016 fn standalone_grep_search_preserves_exit_status_ownership() {
1017 for command in [
1018 "rg missing src",
1019 "grep -q missing README.md",
1020 "LANG=C /usr/bin/grep -n missing README.md",
1021 "rg 'a|b' src",
1022 "fgrep missing README.md",
1023 ] {
1024 assert!(shell_command_is_standalone_grep_search(&exec_command(command)), "{command}");
1025 }
1026 assert!(shell_command_is_standalone_grep_search(&json!({"command": ["rg", "missing", "src"]})));
1027 for command in [
1028 "rg missing src; false",
1029 "rg missing src && false",
1030 "rg missing src | grep other",
1031 "! rg missing src",
1032 "(rg missing src)",
1033 "echo '/rg missing'; false",
1034 "rg missing src 2>/dev/null",
1035 "rg missing src 2>&1",
1036 "rg missing $(pwd)",
1037 "rg --pre 'false' missing src",
1038 ] {
1039 assert!(!shell_command_is_standalone_grep_search(&exec_command(command)), "{command}");
1040 }
1041 }
1042
1043 #[test]
1044 fn admitted_verification_attempt_allows_truncation_but_blocks_smuggled_mutations() {
1045 for command in [
1046 "cargo check --locked 2>&1 | head -c 4000",
1047 "cargo check --locked",
1048 "cargo nextest run 2>&1 | head -c 4000",
1049 ] {
1050 assert!(
1051 shell_command_is_admitted_verification_attempt(&exec_command(command)),
1052 "expected admission: {command}"
1053 );
1054 }
1055 for command in [
1056 "cargo check && rm -rf target",
1057 "cargo check; rm foo.txt",
1058 "cargo check || rm foo.txt",
1059 "cargo check && cargo test && rm foo.txt",
1060 "sed -i '' 's/old/new/' README.md",
1061 "echo $(date)",
1062 "cargo check > build.log && rm foo.txt",
1063 ] {
1064 assert!(
1065 !shell_command_is_admitted_verification_attempt(&exec_command(command)),
1066 "expected block: {command}"
1067 );
1068 }
1069 assert!(!shell_command_is_admitted_verification_attempt(&json!({})));
1070 }
1071
1072 #[test]
1073 fn logged_compound_inspection_commands_are_not_mutations() {
1074 for command in [
1075 "cat README.md && printf '\\n--- git status ---\\n' && git status --short",
1076 "wc -l README.md; rg -n '^#' README.md",
1077 "git diff --stat; find docs -maxdepth 2 -type f | sort | head -40",
1078 ] {
1079 assert_eq!(
1080 classify_shell_activity(tools::EXEC_COMMAND, &exec_command(command)),
1081 ShellActivity::Inspection,
1082 "{command}"
1083 );
1084 }
1085 }
1086
1087 #[cfg(unix)]
1088 #[test]
1089 fn captured_read_commands_with_output_suppression_are_inspection() {
1090 for command in [
1091 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"#,
1092 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"#,
1093 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"###,
1094 ] {
1095 assert_eq!(
1096 classify_shell_activity(tools::EXEC_COMMAND, &exec_command(command)),
1097 ShellActivity::Inspection,
1098 "{command}"
1099 );
1100 }
1101 }
1102
1103 #[cfg(unix)]
1104 #[test]
1105 fn printf_output_safety_guards_remain_mutations() {
1106 for command in [
1107 "printf 'captured output\\n' > output.txt",
1108 "printf '%s\\n' \"$(git status --short)\"",
1109 "printf '%s\\n' `git status --short`",
1110 "printf '\\n--- inspection ---\\n' && rm output.txt",
1111 ] {
1112 let args = exec_command(command);
1113 assert_eq!(classify_shell_activity(tools::EXEC_COMMAND, &args), ShellActivity::Mutation, "{command}");
1114 assert!(!is_readonly_command_session_command(&args), "unexpected readonly command: {command}");
1115 }
1116 }
1117
1118 #[test]
1119 fn git_diff_check_chain_remains_inspection() {
1120 assert_eq!(
1121 classify_shell_activity(
1122 tools::EXEC_COMMAND,
1123 &exec_command("git diff --check && git status --short && git diff --stat"),
1124 ),
1125 ShellActivity::Inspection
1126 );
1127 }
1128
1129 #[test]
1130 fn verification_detection_skips_environment_prefixes() {
1131 for command in [
1132 "env RUSTFLAGS=-Dwarnings cargo check",
1133 "RUSTFLAGS=-Dwarnings env cargo check",
1134 "env -u PATH cargo check",
1135 "env -C /tmp cargo check",
1136 ] {
1137 assert_eq!(
1138 classify_shell_activity(tools::EXEC_COMMAND, &exec_command(command)),
1139 ShellActivity::Verification,
1140 "{command}"
1141 );
1142 }
1143 }
1144
1145 #[test]
1146 fn ambiguous_or_mutating_compounds_remain_mutations() {
1147 for command in [
1148 "git diff --stat; python3 -c 'open(\"out\", \"w\").write(\"x\")'",
1149 "cat README.md; sed -i '' 's/a/b/' README.md",
1150 "sed --in-place= README.md",
1151 "git diff --output=out",
1152 "git diff '--output=out'",
1153 "git diff -o out",
1154 "git diff -oout",
1155 "git log --output=out",
1156 "git show --textconv",
1157 "git -c core.fsmonitor=touch status",
1161 "find . -fprint output.txt",
1162 "find . -fprintf output.txt '%p'",
1163 "rg --hostname-bin sh pattern",
1164 "rg --search-zip pattern",
1165 "rg -z pattern",
1166 "sort -o generated.txt README.md",
1167 "sort --compress-program=sh README.md",
1168 "date -s now",
1169 "awk -i inplace '{print}' README.md",
1170 "sed -n 's/a/b/e' README.md",
1171 "fd --exec sh -c 'touch output'",
1172 "tree -o output.txt",
1173 "ast-grep -r 'README.md'",
1174 "sed -n -fmalicious.sed -e '1p' src/main.rs",
1175 "sed -I '' 's/a/b/' src/main.rs",
1176 "sed -n '1p\nw leaked.txt' src/main.rs",
1177 "cargo check & rm output",
1178 "cargo check > build.log | rm output",
1179 "cargo check | head -40 > build.log",
1180 "cargo check | echo x > output.log",
1181 "cargo check | head -40",
1182 "cargo check > build.log &",
1183 "env -S 'cargo check'",
1184 "echo x > output.log && cargo check",
1185 "cargo check < build-input.log",
1186 "cat README.md > copied.txt",
1187 "git diff --check; rm output",
1188 "cat README.md\nrm output",
1189 ] {
1190 assert_eq!(
1191 classify_shell_activity(tools::EXEC_COMMAND, &exec_command(command)),
1192 ShellActivity::Mutation,
1193 "{command}"
1194 );
1195 }
1196 }
1197
1198 #[test]
1199 fn quoted_output_text_does_not_change_inspection_classification() {
1200 for command in ["echo 'git diff --output=out'", "printf 'sort -o out input'"] {
1201 assert_eq!(
1202 classify_shell_activity(tools::EXEC_COMMAND, &exec_command(command)),
1203 ShellActivity::Inspection,
1204 "{command}"
1205 );
1206 }
1207 }
1208
1209 #[test]
1210 fn cargo_fmt_check_is_verification_but_plain_fmt_is_not() {
1211 for command in [
1212 "cargo fmt --check",
1213 "cargo fmt --all -- --check",
1214 "cargo fmt -- --check",
1215 ] {
1216 assert_eq!(
1217 classify_shell_activity(tools::EXEC_COMMAND, &exec_command(command)),
1218 ShellActivity::Verification,
1219 "{command}"
1220 );
1221 assert!(
1222 shell_command_is_admitted_verification_attempt(&exec_command(command)),
1223 "expected admission: {command}"
1224 );
1225 }
1226 assert_eq!(classify_shell_activity(tools::EXEC_COMMAND, &exec_command("cargo fmt")), ShellActivity::Mutation);
1229 assert!(!shell_command_is_admitted_verification_attempt(&exec_command("cargo fmt")));
1230 }
1231
1232 #[test]
1233 fn pure_and_chained_verifiers_are_verification() {
1234 for command in [
1235 "cargo fmt --all -- --check && cargo check --locked",
1236 "cargo check --locked && cargo nextest run --locked -p vtcode-ui",
1237 "cargo check --locked && cargo clippy --locked -p vtcode-ui -- -D warnings",
1238 "git diff --check && cargo check --locked",
1239 ] {
1240 assert_eq!(
1241 classify_shell_activity(tools::EXEC_COMMAND, &exec_command(command)),
1242 ShellActivity::Verification,
1243 "{command}"
1244 );
1245 assert!(
1246 shell_command_is_admitted_verification_attempt(&exec_command(command)),
1247 "expected admission: {command}"
1248 );
1249 }
1250 }
1251
1252 #[test]
1253 fn non_and_chained_verifiers_remain_mutations() {
1254 for command in [
1255 "cargo check --locked; cargo nextest run --locked -p vtcode-ui",
1256 "cargo check --locked || cargo nextest run --locked -p vtcode-ui",
1257 "cargo check --locked | head -40",
1258 "cargo check --locked && cargo nextest run --locked -p vtcode-ui | head -40",
1259 "cargo check --locked &",
1260 ] {
1261 assert_eq!(
1262 classify_shell_activity(tools::EXEC_COMMAND, &exec_command(command)),
1263 ShellActivity::Mutation,
1264 "{command}"
1265 );
1266 }
1267 }
1268
1269 #[test]
1270 fn expanded_verifiers_classify_as_verification() {
1271 for command in [
1272 "bun test",
1273 "bun run test",
1274 "deno lint",
1275 "deno check mod.ts",
1276 "make test",
1277 "make lint",
1278 "just verify",
1279 "ruff check src/",
1280 "ruff format --check",
1281 "tsc --noEmit",
1282 "eslint src/",
1283 "python3 -m pytest",
1284 "uv run pytest",
1285 "npm run lint",
1286 ] {
1287 assert_eq!(
1288 classify_shell_activity(tools::EXEC_COMMAND, &exec_command(command)),
1289 ShellActivity::Verification,
1290 "{command}"
1291 );
1292 }
1293 for command in [
1294 "make clean",
1295 "make test clean",
1296 "just fmt",
1297 "tsc",
1298 "eslint --fix src/",
1299 "ruff format src/",
1300 "bun install",
1301 "uv sync",
1302 ] {
1303 assert_eq!(
1304 classify_shell_activity(tools::EXEC_COMMAND, &exec_command(command)),
1305 ShellActivity::Mutation,
1306 "{command}"
1307 );
1308 }
1309 }
1310
1311 #[test]
1312 fn escaped_quotes_fail_closed_instead_of_hiding_operators() {
1313 assert!(!shell_uses_only_and_chaining("cargo check --locked \\\"; echo ok"));
1317 assert!(!shell_uses_only_and_chaining("echo \\\" ; cargo check --locked && echo done"));
1318 assert!(shell_uses_only_and_chaining("echo \"a\\\"b\" && cargo check --locked"));
1320 assert!(shell_uses_only_and_chaining("echo \"path\" && cargo fmt --check"));
1321 assert!(shell_uses_only_and_chaining("echo 'a\\b' && cargo check --locked"));
1323 }
1324
1325 #[test]
1326 fn default_verifier_prefers_manifest_priority_order() {
1327 let dir = tempfile::tempdir().expect("tempdir");
1328 assert_eq!(default_verifier_for_workspace(dir.path()), None);
1329
1330 std::fs::write(dir.path().join("justfile"), "test:\n\techo ok\n").expect("justfile");
1331 assert_eq!(default_verifier_for_workspace(dir.path()).as_deref(), Some("just test"));
1332 std::fs::write(dir.path().join("pyproject.toml"), "[tool.pytest]\n").expect("pyproject");
1333 assert_eq!(default_verifier_for_workspace(dir.path()).as_deref(), Some("pytest -q"));
1334 std::fs::write(dir.path().join("package.json"), r#"{"scripts":{"test":"vitest"}}"#).expect("package.json");
1335 assert_eq!(default_verifier_for_workspace(dir.path()).as_deref(), Some("npm test"));
1336 std::fs::write(dir.path().join("go.mod"), "module example\n").expect("go.mod");
1337 assert_eq!(default_verifier_for_workspace(dir.path()).as_deref(), Some("go test ./..."));
1338 std::fs::write(dir.path().join("Cargo.toml"), "[package]\nname=\"x\"\n").expect("Cargo.toml");
1339 assert_eq!(default_verifier_for_workspace(dir.path()).as_deref(), Some("cargo check --locked"));
1340 }
1341
1342 #[test]
1343 fn default_verifier_marker_aliases_and_tier_priority() {
1344 for (marker, expected) in [
1348 ("Justfile", "just test"),
1349 ("makefile", "make test"),
1350 ("bun.lock", "bun test"),
1351 ("deno.jsonc", "deno test"),
1352 ] {
1353 let dir = tempfile::tempdir().expect("tempdir");
1354 std::fs::write(dir.path().join(marker), "x\n").expect(marker);
1355 assert_eq!(default_verifier_for_workspace(dir.path()).as_deref(), Some(expected), "{marker}");
1356 }
1357 let dir = tempfile::tempdir().expect("tempdir");
1359 std::fs::write(dir.path().join("bun.lockb"), "x\n").expect("bun.lockb");
1360 std::fs::write(dir.path().join("deno.json"), "{}\n").expect("deno.json");
1361 assert_eq!(default_verifier_for_workspace(dir.path()).as_deref(), Some("deno test"));
1362 std::fs::write(dir.path().join("go.mod"), "module example\n").expect("go.mod");
1363 assert_eq!(default_verifier_for_workspace(dir.path()).as_deref(), Some("go test ./..."));
1364 let dir = tempfile::tempdir().expect("tempdir");
1366 std::fs::write(dir.path().join("pyproject.toml"), "[tool.pytest]\n").expect("pyproject");
1367 std::fs::write(dir.path().join("package.json"), r#"{"scripts":{"test":"vitest"}}"#).expect("package.json");
1368 assert_eq!(default_verifier_for_workspace(dir.path()).as_deref(), Some("npm test"));
1369 let dir = tempfile::tempdir().expect("tempdir");
1371 std::fs::write(dir.path().join("package.json"), r#"{"scripts":{"start":"serve"}}"#).expect("package.json");
1372 assert_eq!(default_verifier_for_workspace(dir.path()).as_deref(), Some("npm test"));
1373 }
1374
1375 #[test]
1376 fn default_verifier_reads_npm_script_fallbacks() {
1377 let dir = tempfile::tempdir().expect("tempdir");
1378 std::fs::write(dir.path().join("package.json"), r#"{"scripts":{"lint":"eslint ."}}"#).expect("package.json");
1379 assert_eq!(default_verifier_for_workspace(dir.path()).as_deref(), Some("npm run lint"));
1380 }
1381
1382 #[test]
1383 fn verification_recovery_directive_names_concrete_command() {
1384 let directive = verification_recovery_directive(Some("go test ./..."), 1, 2);
1385 assert!(directive.contains("go test ./..."));
1386 assert!(directive.contains("1/2"));
1387 assert!(directive.contains("max_output_tokens"));
1388 assert!(directive.contains(VERIFIER_SHELL_FORM_NOTE));
1389 assert!(directive.contains("tails run standalone"));
1390 assert!(directive.contains("fail-closed `pipefail`"));
1391 let fallback = verification_recovery_directive(None, 2, 2);
1392 assert!(fallback.contains(GENERIC_VERIFIER_DESCRIPTION));
1393 assert!(fallback.contains("2/2"));
1394 }
1395
1396 #[test]
1397 fn verifier_reference_names_resolved_command_or_generic_description() {
1398 assert_eq!(verifier_reference(Some("go test ./...")), "`go test ./...`");
1399 assert_eq!(verifier_reference(Some(" npm test ")), "`npm test`");
1400 for missing in [None, Some(""), Some(" ")] {
1401 let reference = verifier_reference(missing);
1402 assert_eq!(reference, GENERIC_VERIFIER_DESCRIPTION);
1403 assert!(!reference.starts_with('`'), "no single command is named: {reference}");
1404 for ecosystem in ["cargo check --locked", "go test ./...", "npm test", "pytest -q"] {
1405 assert!(reference.contains(ecosystem), "missing {ecosystem}: {reference}");
1406 }
1407 }
1408 }
1409
1410 #[test]
1411 fn truncation_only_verifier_rewrites_to_standalone_prefix() {
1412 for (command, expected) in [
1413 ("cargo check --locked 2>&1 | head -c 4000", "cargo check --locked 2>&1"),
1414 ("cargo check --locked -p vtcode 2>&1 | tail -20", "cargo check --locked -p vtcode 2>&1"),
1415 ("cargo nextest run 2>&1 | tail -15", "cargo nextest run 2>&1"),
1416 (
1417 "cargo check --locked && cargo nextest run --locked -p vtcode | tail -5",
1418 "cargo check --locked && cargo nextest run --locked -p vtcode",
1419 ),
1420 ("cargo check | tail -5 | head -20", "cargo check"),
1421 ("RUSTFLAGS=-Dwarnings cargo check | head -20", "RUSTFLAGS=-Dwarnings cargo check"),
1422 ("cargo check > build.log | tail -5", "cargo check > build.log"),
1426 ] {
1427 assert_eq!(
1428 rewrite_truncation_only_verifier(&exec_command(command)).as_deref(),
1429 Some(expected),
1430 "expected rewrite: {command}"
1431 );
1432 }
1433 }
1434
1435 #[test]
1436 fn non_truncation_pipelines_and_mutations_are_never_rewritten() {
1437 for command in [
1441 "cargo check | grep error",
1442 "cargo check | wc -l",
1443 "cargo check | sort | uniq",
1444 "cargo check && rm -rf target | tail -5",
1445 "cargo check; git status | tail -5",
1446 "cargo check || cargo test | tail -5",
1447 "cargo check | tail -5; rm foo.txt",
1448 "cargo check | tail &",
1449 "cargo check",
1450 "echo done | tail -5",
1451 "rg -n 'pattern' src | head -20",
1452 "echo $(date) | tail -5",
1453 ] {
1454 assert_eq!(rewrite_truncation_only_verifier(&exec_command(command)), None, "must not rewrite: {command}");
1455 }
1456 assert_eq!(
1457 rewrite_truncation_only_verifier(&serde_json::json!({"command": ["cargo", "check", "|", "tail"]})),
1458 None,
1459 "array-form commands keep today's behavior"
1460 );
1461 assert_eq!(rewrite_truncation_only_verifier(&serde_json::json!({})), None);
1462 }
1463
1464 #[test]
1465 fn args_as_executed_classify_elided_truncation_verifiers_as_verification() {
1466 for command in [
1467 "cargo check --locked 2>&1 | tail -5",
1468 "cargo nextest run 2>&1 | head -c 4000",
1469 ] {
1470 let typed = exec_command(command);
1471 let executed = shell_args_as_executed(tools::EXEC_COMMAND, &typed);
1472 assert!(matches!(executed, std::borrow::Cow::Owned(_)), "expected rewrite: {command}");
1473 assert_eq!(
1474 classify_shell_activity(tools::EXEC_COMMAND, &executed),
1475 ShellActivity::Verification,
1476 "{command}"
1477 );
1478 assert!(matches!(shell_args_as_executed(tools::EXEC_COMMAND, &executed), std::borrow::Cow::Borrowed(_)));
1480 }
1481
1482 let unified = json!({"action": "run", "command": "cargo check 2>&1 | tail -5"});
1483 assert_eq!(shell_args_as_executed(tools::UNIFIED_EXEC, &unified)["command"], "cargo check 2>&1");
1484 }
1485
1486 #[test]
1487 fn verifier_rewrite_keeps_raw_alias_and_appended_arguments_in_sync() {
1488 let command = "cargo check | grep";
1489 let typed = json!({"cmd":command, "raw_command":command, "args":["warning"]});
1490 let executed = shell_args_as_executed(tools::EXEC_COMMAND, &typed);
1491 let expected = "set -o pipefail && cargo check | grep warning";
1492 assert_eq!(executed["command"], expected);
1493 assert_eq!(executed["raw_command"], expected);
1494 assert!(executed.get("args").is_none(), "suffix must execute exactly once");
1495 assert_eq!(classify_shell_activity(tools::EXEC_COMMAND, &executed), ShellActivity::Verification);
1496 assert!(matches!(shell_args_as_executed(tools::EXEC_COMMAND, &executed), std::borrow::Cow::Borrowed(_)));
1497
1498 let truncator = json!({"cmd":"cargo check | head", "args":["-20"]});
1499 assert_eq!(shell_args_as_executed(tools::EXEC_COMMAND, &truncator)["command"], "cargo check");
1500 }
1501
1502 #[test]
1503 fn verifier_pipeline_rejects_conflicting_raw_alias_and_mutating_suffix() {
1504 for typed in [
1505 json!({"cmd":"cargo check | grep marker", "raw_command":"printf marker"}),
1506 json!({"cmd":"cargo check | sort", "args":["-o", "changed.txt"]}),
1507 json!({"cmd":"set -o pipefail && cargo check | sort", "args":["-o", "changed.txt"]}),
1508 json!({"cmd":"cargo check | grep", "args":"invalid"}),
1509 ] {
1510 assert_eq!(rewrite_verifier_pipeline(&typed), None, "{typed}");
1511 assert!(!shell_command_is_admitted_verification_attempt(&typed), "{typed}");
1512 assert_eq!(classify_shell_activity(tools::EXEC_COMMAND, &typed), ShellActivity::Mutation, "{typed}");
1513 assert!(super::super::classify_tool_intent(tools::EXEC_COMMAND, &typed).mutating, "{typed}");
1514 }
1515 }
1516
1517 #[test]
1518 fn filtered_verifier_pipeline_preserves_filters_and_truthful_status() {
1519 for command in [
1520 "python3 scripts/check_markdown.py 2>&1 | grep -E '^(README|docs/SECURITY)\\.md' | head -20",
1521 "cargo check --locked | grep warning",
1522 "cargo fmt --check && cargo check --locked | wc -l",
1523 ] {
1524 let typed = exec_command(command);
1525 let executed = shell_args_as_executed(tools::EXEC_COMMAND, &typed);
1526 assert_eq!(executed["command"], format!("set -o pipefail && {command}"));
1527 assert_eq!(classify_shell_activity(tools::EXEC_COMMAND, &executed), ShellActivity::Verification);
1528 assert!(shell_command_is_admitted_verification_attempt(&executed));
1529 assert!(matches!(shell_args_as_executed(tools::EXEC_COMMAND, &executed), std::borrow::Cow::Borrowed(_)));
1530 assert_eq!(classify_shell_activity(tools::EXEC_COMMAND, &typed), ShellActivity::Mutation);
1531 }
1532 for command in [
1533 "cargo check | grep error; true",
1534 "cargo check | grep error || true",
1535 "cargo check | tee source.rs",
1536 "cargo check | sed -i 's/old/new/' source.rs",
1537 "cargo check | grep $(touch marker)",
1538 "cargo check | grep error > hidden.log",
1539 "cargo check | grep error &",
1540 "cargo check && rm source.rs | grep error",
1541 ] {
1542 assert_eq!(rewrite_verifier_pipeline(&exec_command(command)), None, "{command}");
1543 let prefixed = exec_command(&format!("set -o pipefail && {command}"));
1544 assert_ne!(
1545 classify_shell_activity(tools::EXEC_COMMAND, &prefixed),
1546 ShellActivity::Verification,
1547 "{command}"
1548 );
1549 }
1550 }
1551
1552 #[test]
1553 fn args_as_executed_keep_unsafe_joins_and_non_run_calls_as_typed() {
1554 for command in [
1555 "cargo check 2>&1 | grep error; true",
1556 "cargo check; git status",
1557 "cargo check || true",
1558 ] {
1559 let typed = exec_command(command);
1560 let executed = shell_args_as_executed(tools::EXEC_COMMAND, &typed);
1561 assert!(matches!(executed, std::borrow::Cow::Borrowed(_)), "must run as typed: {command}");
1562 assert_ne!(
1563 classify_shell_activity(tools::EXEC_COMMAND, &executed),
1564 ShellActivity::Verification,
1565 "{command}"
1566 );
1567 }
1568 let poll = json!({"action": "poll", "session_id": "s1", "command": "cargo check | tail -5"});
1569 assert!(matches!(shell_args_as_executed(tools::UNIFIED_EXEC, &poll), std::borrow::Cow::Borrowed(_)));
1570 let read = json!({"path": "src/lib.rs"});
1571 assert!(matches!(shell_args_as_executed(tools::READ_FILE, &read), std::borrow::Cow::Borrowed(_)));
1572 }
1573}