1use std::path::Path;
4
5use serde_json::Value;
6
7use crate::tools::tool_intent::{command_session_action, command_session_action_in, command_session_action_is};
8
9const INDEXED_COMMAND_TYPE_ERROR: &str = "command array must contain only strings";
10const COMMAND_VALUE_TYPE_ERROR: &str = "command must be a string or array of strings";
11
12#[derive(Debug, Clone, Copy, PartialEq, Eq)]
13pub(crate) enum WriteStdinDispatch {
14 Write,
15 Poll,
16 Wait,
17 Inspect,
18 Terminate,
19 Close,
20}
21
22impl WriteStdinDispatch {
23 #[must_use]
24 pub(crate) const fn command_session_action(self) -> &'static str {
25 match self {
26 Self::Write => "write",
27 Self::Poll => "poll",
28 Self::Wait => "wait",
29 Self::Inspect => "inspect",
30 Self::Terminate => "terminate",
31 Self::Close => "close",
32 }
33 }
34}
35
36pub(crate) fn write_stdin_dispatch(args: &Value) -> Result<WriteStdinDispatch, &'static str> {
37 let payload = args.as_object().ok_or("write_stdin requires a JSON object")?;
38 if let Some(action) = payload.get("action") {
39 let action = action.as_str().ok_or("write_stdin action must be a string")?;
40 if action.eq_ignore_ascii_case("wait") {
44 return Ok(WriteStdinDispatch::Wait);
45 }
46 if action.eq_ignore_ascii_case("inspect") {
47 return Ok(WriteStdinDispatch::Inspect);
48 }
49 if action.eq_ignore_ascii_case("terminate") {
50 return Ok(WriteStdinDispatch::Terminate);
51 }
52 if action.eq_ignore_ascii_case("close") {
53 return Ok(WriteStdinDispatch::Close);
54 }
55 if action.eq_ignore_ascii_case("poll") {
56 if payload.get("chars").is_some_and(|chars| chars.as_str() != Some("")) {
57 return Err("write_stdin poll cannot send chars");
58 }
59 return Ok(WriteStdinDispatch::Poll);
60 }
61 if !action.eq_ignore_ascii_case("write") {
62 return Err("write_stdin action must be write, poll, wait, inspect, terminate, or close");
63 }
64 }
65 let chars = payload
66 .get("chars")
67 .and_then(Value::as_str)
68 .ok_or("write_stdin requires string chars")?;
69
70 if chars.is_empty() {
71 Ok(WriteStdinDispatch::Poll)
72 } else {
73 Ok(WriteStdinDispatch::Write)
74 }
75}
76
77fn collect_indexed_command_parts(
78 payload: &serde_json::Map<String, Value>,
79 start_index: usize,
80) -> Result<Vec<String>, &'static str> {
81 let mut parts = Vec::new();
82 let mut index = start_index;
83 while let Some(value) = payload.get(&format!("command.{index}")) {
84 let Some(part) = value.as_str() else {
85 return Err(INDEXED_COMMAND_TYPE_ERROR);
86 };
87 parts.push(part.to_string());
88 index += 1;
89 }
90 Ok(parts)
91}
92
93pub fn has_indexed_command_parts(args: &Value) -> bool {
94 let Some(payload) = args.as_object() else {
95 return false;
96 };
97
98 payload.contains_key("command.0") || payload.contains_key("command.1")
99}
100
101pub fn parse_indexed_command_parts(
102 payload: &serde_json::Map<String, Value>,
103) -> Result<Option<Vec<String>>, &'static str> {
104 let zero_based = collect_indexed_command_parts(payload, 0)?;
105 if !zero_based.is_empty() {
106 return Ok(Some(zero_based));
107 }
108
109 let one_based = collect_indexed_command_parts(payload, 1)?;
110 if one_based.is_empty() {
111 Ok(None)
112 } else {
113 Ok(Some(one_based))
114 }
115}
116
117pub fn normalize_indexed_command_args(args: &Value) -> Result<Option<Value>, &'static str> {
118 let Some(payload) = args.as_object() else {
119 return Ok(None);
120 };
121 if payload.get("command").is_some() {
122 return Ok(None);
123 }
124
125 let Some(parts) = parse_indexed_command_parts(payload)? else {
126 return Ok(None);
127 };
128
129 let mut normalized = payload.clone();
130 normalized.insert("command".to_string(), Value::String(shell_words::join(parts.iter().map(String::as_str))));
131 Ok(Some(Value::Object(normalized)))
132}
133
134pub fn normalized_command_value(args: &Value) -> Result<Option<Value>, &'static str> {
135 if let Some(command) = args
136 .get("command")
137 .or_else(|| args.get("cmd"))
138 .or_else(|| args.get("raw_command"))
139 {
140 return Ok(Some(command.clone()));
141 }
142
143 Ok(normalize_indexed_command_args(args)?.and_then(|normalized| normalized.get("command").cloned()))
144}
145
146pub fn command_words(args: &Value) -> Result<Option<Vec<String>>, &'static str> {
147 let Some(command) = normalized_command_value(args)? else {
148 return Ok(None);
149 };
150
151 let mut parts = match command {
152 Value::String(command) => shell_words::split(&command).map_err(|_e| COMMAND_VALUE_TYPE_ERROR)?,
153 Value::Array(values) => values
154 .iter()
155 .map(|value| value.as_str().map(ToOwned::to_owned).ok_or(COMMAND_VALUE_TYPE_ERROR))
156 .collect::<Result<Vec<_>, _>>()?,
157 _ => return Err(COMMAND_VALUE_TYPE_ERROR),
158 };
159
160 if let Some(extra_args) = args.get("args").and_then(Value::as_array) {
161 for value in extra_args {
162 let Some(part) = value.as_str() else {
163 return Err(COMMAND_VALUE_TYPE_ERROR);
164 };
165 parts.push(part.to_string());
166 }
167 }
168
169 if parts.is_empty() { Ok(None) } else { Ok(Some(parts)) }
170}
171
172pub fn command_text(args: &Value) -> Result<Option<String>, &'static str> {
173 let Some(parts) = command_words(args)? else {
174 return Ok(None);
175 };
176 Ok(Some(shell_words::join(parts.iter().map(String::as_str))))
177}
178
179pub const DISPLAY_COMMAND_KEYS: &[&str] = &["command", "raw_command", "bash_command", "cmd"];
187
188pub fn extract_command_text_with_key(args: &Value) -> Option<(String, &'static str)> {
199 if let Some(array) = args.get("command").and_then(Value::as_array) {
200 let joined: String = array
201 .iter()
202 .filter_map(|value| value.as_str())
203 .filter(|segment| !segment.is_empty())
204 .collect::<Vec<_>>()
205 .join(" ");
206 if !joined.is_empty() {
207 return Some((joined, "command"));
208 }
209 }
210 for &key in DISPLAY_COMMAND_KEYS {
211 let Some(value) = args.get(key).and_then(Value::as_str) else {
212 continue;
213 };
214 let (text, ok) = if key == "command" {
217 let trimmed = value.trim();
218 (trimmed.to_string(), !trimmed.is_empty())
219 } else {
220 (value.to_string(), !value.is_empty())
221 };
222 if ok {
223 return Some((text, key));
224 }
225 }
226 None
227}
228
229fn has_nonempty_string_field(args: &Value, key: &str) -> bool {
230 args.get(key)
231 .and_then(Value::as_str)
232 .map(str::trim)
233 .is_some_and(|value| !value.is_empty())
234}
235
236pub fn interactive_input_text(args: &Value) -> Option<&str> {
237 args.get("input")
238 .and_then(Value::as_str)
239 .or_else(|| args.get("chars").and_then(Value::as_str))
240 .or_else(|| args.get("text").and_then(Value::as_str))
241 .filter(|value| !value.is_empty())
242}
243
244pub fn session_id_text_from_payload(payload: &serde_json::Map<String, Value>) -> Option<&str> {
245 payload
246 .get("session_id")
247 .or_else(|| payload.get("s"))
248 .and_then(Value::as_str)
249 .map(str::trim)
250 .filter(|value| !value.is_empty())
251}
252
253pub fn session_id_text(args: &Value) -> Option<&str> {
254 args.as_object().and_then(session_id_text_from_payload)
255}
256
257pub fn command_session_missing_required_args(args: &Value) -> Vec<&'static str> {
258 if command_session_action(args).is_none() {
259 return Vec::new();
260 }
261
262 let mut missing = Vec::new();
263 if command_session_action_is(args, "run") {
264 if command_text(args).ok().flatten().is_none() {
265 missing.push("command");
266 }
267 } else if command_session_action_is(args, "write") {
268 if session_id_text(args).is_none() {
269 missing.push("session_id");
270 }
271 if interactive_input_text(args).is_none() {
272 missing.push("input or chars or text");
273 }
274 } else if command_session_action_in(args, &["poll", "wait", "continue", "close"]) {
275 if session_id_text(args).is_none() {
276 missing.push("session_id");
277 }
278 } else if command_session_action_is(args, "inspect") {
279 let has_session_id = session_id_text(args).is_some();
280 let has_spool_path = has_nonempty_string_field(args, "spool_path");
281 if !has_session_id && !has_spool_path {
282 missing.push("session_id or spool_path");
283 }
284 } else if command_session_action_is(args, "code") {
285 let has_code = has_nonempty_string_field(args, "code") || has_nonempty_string_field(args, "command");
286 if !has_code {
287 missing.push("code or command");
288 }
289 }
290
291 missing
292}
293
294pub fn command_session_requires_command_safety(args: &Value) -> bool {
295 command_session_action_is(args, "run")
296}
297
298pub fn working_dir_text_from_payload(payload: &serde_json::Map<String, Value>) -> Option<&str> {
299 payload
300 .get("working_dir")
301 .or_else(|| payload.get("cwd"))
302 .or_else(|| payload.get("workdir"))
303 .and_then(Value::as_str)
304 .map(str::trim)
305 .filter(|value| !value.is_empty())
306}
307
308pub fn working_dir_text(args: &Value) -> Option<&str> {
309 args.as_object().and_then(working_dir_text_from_payload)
310}
311
312pub fn raw_command_text(args: &Value) -> Option<String> {
316 let payload = args.as_object()?;
317
318 if let Some(command) = payload
319 .get("command")
320 .or_else(|| payload.get("cmd"))
321 .or_else(|| payload.get("raw_command"))
322 {
323 match command {
324 Value::String(text) => return Some(text.clone()),
325 Value::Array(values) => {
326 let parts: Option<Vec<&str>> = values.iter().map(|v| v.as_str()).collect();
327 return parts.map(shell_words::join);
328 }
329 _ => return None,
330 }
331 }
332
333 let indexed = parse_indexed_command_parts(payload).ok().flatten()?;
334 Some(shell_words::join(indexed.iter().map(String::as_str)))
335}
336
337pub(crate) fn shell_command_text_with_args(args: &Value) -> Option<String> {
342 let mut command = raw_command_text(args)?;
343 if args
344 .get("raw_command")
345 .and_then(Value::as_str)
346 .is_some_and(|raw| raw != command)
347 {
348 return None;
349 }
350 if let Some(arguments) = args.get("args") {
351 let arguments = arguments.as_array()?;
352 let suffix_words = arguments.iter().map(Value::as_str).collect::<Option<Vec<_>>>()?;
353 if !suffix_words.is_empty() {
354 command.push(' ');
355 command.push_str(&shell_words::join(suffix_words));
356 }
357 }
358 Some(command)
359}
360
361pub fn contains_dynamic_shell_syntax(command: &str) -> bool {
369 crate::command_safety::shell_parser::contains_dynamic_shell_syntax(command)
370}
371
372#[derive(Debug, Clone, Copy, PartialEq, Eq)]
381struct EnvPrefixScan {
382 stripped_start: usize,
383 has_injection: bool,
384}
385
386fn is_env_split_string_flag(word: &str) -> bool {
387 word == "-S" || word == "--split-string" || word.starts_with("-S") || word.starts_with("--split-string=")
388}
389
390fn env_flag_consumes_next(word: &str) -> bool {
391 matches!(word, "-u" | "--unset" | "-C" | "--chdir")
392}
393
394fn scan_environment_prefix(words: &[String]) -> EnvPrefixScan {
407 let mut start = 0;
408 let mut has_injection = false;
409 loop {
410 if words.get(start).is_some_and(|word| word == "env") {
411 start += 1;
412 while let Some(word) = words.get(start) {
413 if word == "--" {
414 start += 1;
415 break;
416 }
417 if is_env_split_string_flag(word) {
418 return EnvPrefixScan { stripped_start: words.len(), has_injection: true };
421 }
422 let consumes_next = env_flag_consumes_next(word);
423 start += 1;
424 if consumes_next {
425 if words.get(start).is_some() {
427 start += 1;
428 }
429 continue;
430 }
431 if let Some(key) = env_assignment_key(word) {
432 if is_env_injection_key(key) {
433 has_injection = true;
434 }
435 continue;
436 }
437 if word.starts_with('-') {
438 continue;
439 }
440 start -= 1;
441 break;
442 }
443 continue;
444 }
445 if let Some(key) = words.get(start).and_then(|word| env_assignment_key(word)) {
446 if is_env_injection_key(key) {
447 has_injection = true;
448 }
449 start += 1;
450 continue;
451 }
452 break;
453 }
454 EnvPrefixScan { stripped_start: start, has_injection }
455}
456
457pub fn command_words_after_environment_prefix(words: &[String]) -> &[String] {
463 let scan = scan_environment_prefix(words);
464 &words[scan.stripped_start..]
465}
466
467const ENV_INJECTION_KEYS: &[&str] = &[
470 "GIT_CONFIG_COUNT",
471 "GIT_CONFIG_GLOBAL",
472 "GIT_CONFIG_SYSTEM",
473 "GIT_CONFIG_NOSYSTEM",
474 "GIT_EXTERNAL_DIFF",
475 "GIT_TEXTCONV",
476 "GIT_DIFF_OPTS",
477 "GIT_EDITOR",
478 "GIT_SEQUENCE_EDITOR",
479 "GIT_SSH",
480 "GIT_SSH_COMMAND",
481 "GIT_EXEC_PATH",
482 "LD_PRELOAD",
483 "LD_AUDIT",
484 "LD_LIBRARY_PATH",
485 "DYLD_INSERT_LIBRARIES",
486 "DYLD_LIBRARY_PATH",
487 "DYLD_FRAMEWORK_PATH",
488 "DYLD_FALLBACK_LIBRARY_PATH",
489 "BASH_ENV",
490 "ENV",
491 "SHELLOPTS",
492 "PERL5OPT",
493 "PYTHONSTARTUP",
494 "NODE_OPTIONS",
495 "RUBYOPT",
496 "EDITOR",
497 "VISUAL",
498 "PAGER",
499];
500
501const ENV_INJECTION_KEY_PREFIXES: &[&str] = &["GIT_CONFIG_KEY_", "GIT_CONFIG_VALUE_"];
504
505fn env_assignment_key(word: &str) -> Option<&str> {
506 if word.starts_with('-') {
507 return None;
508 }
509 let (key, _) = word.split_once('=')?;
510 if key.is_empty() {
511 return None;
512 }
513 Some(key)
514}
515
516fn is_env_injection_key(key: &str) -> bool {
517 ENV_INJECTION_KEYS.contains(&key) || ENV_INJECTION_KEY_PREFIXES.iter().any(|prefix| key.starts_with(prefix))
518}
519
520pub fn environment_prefix_has_injection_keys(words: &[String]) -> bool {
528 scan_environment_prefix(words).has_injection
529}
530
531pub(crate) fn uv_run_command_words(words: &[String]) -> Option<&[String]> {
534 if words.get(1).map(String::as_str) != Some("run") {
535 return None;
536 }
537 let index = words
538 .iter()
539 .enumerate()
540 .skip(2)
541 .find(|(_, word)| {
542 !matches!(
543 word.as_str(),
544 "--frozen" | "--locked" | "--no-sync" | "--offline" | "--no-project" | "--quiet" | "-q"
545 )
546 })?
547 .0;
548 (!words.get(index)?.starts_with('-')).then(|| &words[index..])
549}
550
551pub(crate) fn command_uses_source_repair(words: &[String]) -> bool {
554 let words = command_words_after_environment_prefix(words);
555 let Some(program) = words
556 .first()
557 .and_then(|word| Path::new(word).file_name())
558 .and_then(|name| name.to_str())
559 else {
560 return false;
561 };
562 let program = program.to_ascii_lowercase();
563 match program.as_str() {
564 "cargo" => {
565 let mut arguments = words.iter().skip(1).take_while(|word| word.as_str() != "--");
566 arguments.clone().any(|word| word.split('=').next() == Some("--fix"))
567 || arguments
568 .find(|word| !word.starts_with(['-', '+']))
569 .is_some_and(|word| word == "fix")
570 }
571 "ruff" => {
572 words.iter().skip(1).take_while(|word| word.as_str() != "--").any(|word| {
573 matches!(word.split('=').next(), Some("--fix" | "--fix-only" | "--add-noqa" | "--add-ignore"))
574 })
575 }
576 "uv" => uv_run_command_words(words).is_some_and(command_uses_source_repair),
577 _ => false,
578 }
579}
580
581pub(crate) fn has_unsafe_readonly_options(words: &[String]) -> bool {
590 if command_uses_source_repair(words) {
591 return true;
592 }
593 let scan = scan_environment_prefix(words);
598 if scan.has_injection {
599 return true;
600 }
601 let command_words = &words[scan.stripped_start..];
602 let Some(program) = command_words
603 .first()
604 .and_then(|word| Path::new(word).file_name())
605 .and_then(|name| name.to_str())
606 else {
607 return false;
608 };
609 let program = program.to_ascii_lowercase();
610
611 match program.as_str() {
612 "rustc" => command_words.iter().enumerate().skip(1).any(|(index, word)| {
613 word.starts_with("-o")
614 || word.split('=').next() == Some("--out-dir")
615 || ["--print", "--emit"].iter().any(|option| {
616 let value = if word == option {
617 command_words.get(index + 1).map(String::as_str)
618 } else {
619 word.strip_prefix(option).and_then(|suffix| suffix.strip_prefix('='))
620 };
621 value.is_some_and(|value| value.contains('='))
622 })
623 }),
624 "pytest" | "python" | "python3" | "uv" => command_words.iter().skip(1).any(|word| {
625 matches!(
626 word.split('=').next(),
627 Some("--junitxml" | "--junit-xml" | "--log-file" | "--basetemp" | "--output-file")
628 )
629 }),
630 "ruff" => command_words
631 .iter()
632 .skip(1)
633 .any(|word| word.split('=').next() == Some("--output-file")),
634 "git" => command_words.iter().skip(1).any(|word| {
635 let is_dir_redirect = word == "-C" || (word.starts_with("-C") && word.len() > 2);
641 (!is_dir_redirect && crate::command_safety::git_global_option_requires_prompt(word))
642 || word == "--ext-diff"
643 || word == "--textconv"
644 || word == "-o"
645 || word.starts_with("-o")
646 || word == "--output"
647 || word.starts_with("--output=")
648 }),
649 "sed" => has_unsafe_sed_options(&command_words[1..]),
650 "find" => command_words.iter().skip(1).any(|word| {
651 matches!(
652 word.as_str(),
653 "-delete" | "-exec" | "-execdir" | "-ok" | "-okdir" | "-fls" | "-fprint" | "-fprint0" | "-fprintf"
654 )
655 }),
656 "sort" => command_words.iter().skip(1).any(|word| {
657 word == "-o"
658 || word.starts_with("-o")
659 || word == "--output"
660 || word.starts_with("--output=")
661 || word == "--compress-program"
662 || word.starts_with("--compress-program=")
663 }),
664 "date" => command_words
665 .iter()
666 .skip(1)
667 .any(|word| word == "-s" || word.starts_with("-s") || word == "--set" || word.starts_with("--set=")),
668 "rg" => command_words.iter().skip(1).any(|word| {
669 matches!(word.as_str(), "--pre" | "--hostname-bin" | "--search-zip" | "-z")
670 || word.starts_with("--pre=")
671 || word.starts_with("--hostname-bin=")
672 }),
673 "ast-grep" | "sg" => command_words
674 .iter()
675 .skip(1)
676 .any(|word| word == "-r" || word == "--rewrite" || word.starts_with("--rewrite=")),
677 "fd" => command_words
678 .iter()
679 .skip(1)
680 .any(|word| word == "-x" || word == "--exec" || word == "--exec-batch" || word.starts_with("--exec=")),
681 "tree" => command_words
682 .iter()
683 .skip(1)
684 .any(|word| word == "-o" || word.starts_with("-o") || word == "--output" || word.starts_with("--output=")),
685 "awk" => has_unsafe_awk_options(&command_words[1..]),
693 _ => false,
694 }
695}
696
697fn has_unsafe_sed_options(arguments: &[String]) -> bool {
698 let mut expects_expression = false;
699 let mut expression_seen = false;
700
701 for word in arguments {
702 if expects_expression {
703 if sed_script_may_write(word) {
704 return true;
705 }
706 expects_expression = false;
707 expression_seen = true;
708 continue;
709 }
710
711 if word == "-e" || word == "--expression" {
712 expects_expression = true;
713 continue;
714 }
715 if let Some(expression) = word.strip_prefix("--expression=") {
716 if sed_script_may_write(expression) {
717 return true;
718 }
719 expression_seen = true;
720 continue;
721 }
722 if let Some(expression) = word.strip_prefix("-e")
723 && !expression.is_empty()
724 {
725 if sed_script_may_write(expression) {
726 return true;
727 }
728 expression_seen = true;
729 continue;
730 }
731
732 if word == "-f"
733 || word.starts_with("-f")
734 || word.starts_with("--file")
735 || word == "-i"
736 || word.starts_with("-i")
737 || word == "-I"
738 || word.starts_with("-I")
739 || word.starts_with("--in-place")
740 {
741 return true;
742 }
743
744 if word == "--" {
745 expects_expression = true;
746 continue;
747 }
748 if !word.starts_with('-') && !expression_seen {
749 if sed_script_may_write(word) {
750 return true;
751 }
752 expression_seen = true;
753 }
754 }
755
756 expects_expression || !expression_seen
759}
760
761fn sed_script_may_write(script: &str) -> bool {
762 let chars = script.to_ascii_lowercase().chars().collect::<Vec<_>>();
763 let mut index = 0;
764
765 while index < chars.len() {
766 while chars
767 .get(index)
768 .is_some_and(|character| character.is_whitespace() || *character == ';')
769 {
770 index += 1;
771 }
772
773 if let Some(after_address) = consume_sed_address(&chars, index) {
774 index = after_address;
775 if chars.get(index) == Some(&',') {
776 index += 1;
777 let Some(after_second_address) = consume_sed_address(&chars, index) else {
778 return true;
779 };
780 index = after_second_address;
781 }
782 if chars.get(index) == Some(&'!') {
783 index += 1;
784 }
785 while chars.get(index).is_some_and(|character| character.is_whitespace()) {
786 index += 1;
787 }
788 }
789
790 let Some(&command) = chars.get(index) else {
791 break;
792 };
793 match command {
794 'e' | 'r' | 'w' => return true,
795 's' => {
796 let Some(after_replacement) = consume_sed_substitution(&chars, index) else {
797 return true;
798 };
799 if sed_substitution_may_write(&chars, after_replacement) {
800 return true;
801 }
802 index = after_replacement;
803 while chars.get(index).is_some_and(|character| !matches!(*character, ';' | '\n')) {
804 index += 1;
805 }
806 }
807 _ => {
808 while chars.get(index).is_some_and(|character| !matches!(*character, ';' | '\n')) {
809 index += 1;
810 }
811 }
812 }
813 }
814
815 false
816}
817
818fn consume_sed_address(chars: &[char], start: usize) -> Option<usize> {
819 match chars.get(start)? {
820 character if character.is_ascii_digit() => {
821 let mut index = start + 1;
822 while chars.get(index).is_some_and(|character| character.is_ascii_digit()) {
823 index += 1;
824 }
825 Some(index)
826 }
827 '$' => Some(start + 1),
828 '/' => find_unescaped_sed_delimiter(chars, start + 1, '/').map(|index| index + 1),
829 _ => None,
830 }
831}
832
833fn find_unescaped_sed_delimiter(chars: &[char], start: usize, delimiter: char) -> Option<usize> {
834 let mut index = start;
835 while index < chars.len() {
836 if chars[index] == '\\' {
837 index = index.saturating_add(2);
838 continue;
839 }
840 if chars[index] == delimiter {
841 return Some(index);
842 }
843 index += 1;
844 }
845 None
846}
847
848fn consume_sed_substitution(chars: &[char], start: usize) -> Option<usize> {
849 let delimiter = *chars.get(start + 1)?;
850 if delimiter.is_whitespace() {
851 return None;
852 }
853 let pattern_end = find_unescaped_sed_delimiter(chars, start + 2, delimiter)?;
854 let replacement_end = find_unescaped_sed_delimiter(chars, pattern_end + 1, delimiter)?;
855 Some(replacement_end + 1)
856}
857
858fn sed_substitution_may_write(chars: &[char], start: usize) -> bool {
859 let mut index = start;
860 while chars.get(index).is_some_and(|character| character.is_whitespace()) {
861 index += 1;
862 }
863 matches!(chars.get(index), Some('e' | 'w'))
864 && chars
865 .get(index + 1)
866 .is_none_or(|character| character.is_whitespace() || *character == ';')
867}
868
869fn has_unsafe_awk_options(arguments: &[String]) -> bool {
883 let mut program_seen = false;
884 let mut options_ended = false;
885 let mut index = 0;
886
887 while index < arguments.len() {
888 let word = arguments[index].as_str();
889 if !options_ended && word == "--" {
890 options_ended = true;
891 index += 1;
892 continue;
893 }
894 if !options_ended && word.starts_with('-') && word.len() > 1 {
895 if word == "-v" || word == "--assign" || word == "-F" || word == "--field-separator" {
896 index += 2;
898 continue;
899 }
900 if word.starts_with("-v")
901 || word.starts_with("--assign=")
902 || word.starts_with("-F")
903 || word.starts_with("--field-separator=")
904 {
905 index += 1;
907 continue;
908 }
909 return true;
910 }
911 if !program_seen {
912 program_seen = true;
913 if awk_program_may_write(word) {
914 return true;
915 }
916 }
917 index += 1;
918 }
919
920 !program_seen
921}
922
923fn awk_program_may_write(program: &str) -> bool {
946 let chars = program.chars().collect::<Vec<_>>();
947 let mut index = 0;
948 let mut prev_operand = false;
951 let mut seen_print_in_statement = false;
956 let mut paren_depth: usize = 0;
957 let mut bracket_depth: usize = 0;
958 while index < chars.len() {
959 let character = chars[index];
960 if character.is_whitespace() {
961 index += 1;
962 continue;
963 }
964 if character == '#' {
965 while index < chars.len() && chars[index] != '\n' {
968 index += 1;
969 }
970 continue;
971 }
972 if character == '"' {
973 index += 1;
974 let mut terminated = false;
975 while index < chars.len() {
976 let inner = chars[index];
977 if inner == '\\' {
978 index += 2;
979 continue;
980 }
981 if inner == '"' {
982 index += 1;
983 terminated = true;
984 break;
985 }
986 if inner == '@' {
989 return true;
990 }
991 if (inner == 's' || inner == 'S') && awk_calls_system(&chars, index) {
992 return true;
993 }
994 index += 1;
995 }
996 if !terminated {
997 return true;
998 }
999 prev_operand = true;
1000 continue;
1001 }
1002 if character == '/' && !prev_operand {
1003 index += 1;
1007 let mut terminated = false;
1008 while index < chars.len() {
1009 let inner = chars[index];
1010 if inner == '\\' {
1011 index += 2;
1012 continue;
1013 }
1014 if inner == '/' {
1015 index += 1;
1016 terminated = true;
1017 break;
1018 }
1019 if inner == '@' {
1020 return true;
1021 }
1022 if inner == '>' {
1023 if chars.get(index + 1) == Some(&'=') {
1024 index += 2;
1025 continue;
1026 }
1027 return true;
1028 }
1029 if inner == '|' {
1030 if chars.get(index + 1) == Some(&'|') {
1031 index += 2;
1032 continue;
1033 }
1034 return true;
1035 }
1036 index += 1;
1037 }
1038 if !terminated {
1039 return true;
1040 }
1041 prev_operand = true;
1042 continue;
1043 }
1044 if character == '@' {
1045 return true;
1046 }
1047 if (character == 's' || character == 'S') && awk_calls_system(&chars, index) {
1048 return true;
1049 }
1050 if character == '>' {
1051 if chars.get(index + 1) == Some(&'=') {
1052 index += 2;
1053 prev_operand = false;
1054 continue;
1055 }
1056 if chars.get(index + 1) == Some(&'>') {
1058 return true;
1059 }
1060 if paren_depth == 0 && bracket_depth == 0 && seen_print_in_statement {
1064 return true;
1065 }
1066 index += 1;
1067 prev_operand = false;
1068 continue;
1069 }
1070 if character == '|' {
1071 if chars.get(index + 1) == Some(&'|') {
1072 index += 2;
1073 prev_operand = false;
1074 continue;
1075 }
1076 return true;
1077 }
1078 if character == '/' {
1079 prev_operand = false;
1081 index += 1;
1082 continue;
1083 }
1084 if character.is_ascii_alphanumeric() || character == '_' {
1085 let start = index;
1086 while index < chars.len() && (chars[index].is_ascii_alphanumeric() || chars[index] == '_') {
1087 index += 1;
1088 }
1089 let word: String = chars[start..index].iter().collect();
1090 if word == "print" || word == "printf" {
1091 seen_print_in_statement = true;
1092 }
1093 prev_operand = !is_awk_keyword(&word);
1094 continue;
1095 }
1096 if character == '(' {
1097 paren_depth = paren_depth.saturating_add(1);
1098 prev_operand = false;
1099 index += 1;
1100 continue;
1101 }
1102 if character == '[' {
1103 bracket_depth = bracket_depth.saturating_add(1);
1104 prev_operand = false;
1105 index += 1;
1106 continue;
1107 }
1108 if character == ')' {
1109 paren_depth = paren_depth.saturating_sub(1);
1110 prev_operand = true;
1111 index += 1;
1112 continue;
1113 }
1114 if character == ']' {
1115 bracket_depth = bracket_depth.saturating_sub(1);
1116 prev_operand = true;
1117 index += 1;
1118 continue;
1119 }
1120 if character == '$' {
1121 prev_operand = true;
1122 index += 1;
1123 continue;
1124 }
1125 if character == ';' || character == '{' || character == '}' {
1126 seen_print_in_statement = false;
1128 prev_operand = false;
1129 index += 1;
1130 continue;
1131 }
1132 prev_operand = false;
1133 index += 1;
1134 }
1135 false
1136}
1137
1138fn is_awk_keyword(word: &str) -> bool {
1144 matches!(
1145 word,
1146 "if" | "else"
1147 | "while"
1148 | "for"
1149 | "do"
1150 | "break"
1151 | "continue"
1152 | "next"
1153 | "nextfile"
1154 | "exit"
1155 | "return"
1156 | "delete"
1157 | "in"
1158 | "getline"
1159 | "print"
1160 | "printf"
1161 | "function"
1162 )
1163}
1164
1165fn awk_calls_system(chars: &[char], start: usize) -> bool {
1169 const NAME: &[char] = &['s', 'y', 's', 't', 'e', 'm'];
1170 if start > 0 && is_awk_ident_char(chars[start - 1]) {
1171 return false;
1172 }
1173 let candidate = chars.get(start..start + NAME.len());
1174 if candidate.is_none_or(|slice| {
1175 slice
1176 .iter()
1177 .zip(NAME.iter())
1178 .any(|(actual, expected)| !actual.eq_ignore_ascii_case(expected))
1179 }) {
1180 return false;
1181 }
1182 let mut index = start + NAME.len();
1183 while chars.get(index).is_some_and(|character| character.is_whitespace()) {
1184 index += 1;
1185 }
1186 chars.get(index) == Some(&'(')
1187}
1188
1189fn is_awk_ident_char(character: char) -> bool {
1190 character.is_ascii_alphanumeric() || character == '_'
1191}
1192
1193pub fn normalize_shell_args(args: &Value) -> Result<Value, &'static str> {
1194 let mut normalized = match normalize_indexed_command_args(args)? {
1195 Some(value) => value,
1196 None => args.clone(),
1197 };
1198
1199 let Some(payload) = normalized.as_object_mut() else {
1200 return Ok(normalized);
1201 };
1202
1203 if payload.get("command").is_none() {
1204 if let Some(command) = payload.get("cmd").cloned() {
1205 payload.insert("command".to_string(), command);
1206 } else if let Some(command) = payload.get("raw_command").cloned() {
1207 payload.insert("command".to_string(), command);
1208 }
1209 }
1210
1211 if payload.get("input").is_none() {
1212 if let Some(input) = payload.get("chars").cloned() {
1213 payload.insert("input".to_string(), input);
1214 } else if let Some(input) = payload.get("text").cloned() {
1215 payload.insert("input".to_string(), input);
1216 }
1217 }
1218
1219 if payload.get("session_id").is_none()
1220 && let Some(session_id) = payload.get("s").cloned()
1221 {
1222 payload.insert("session_id".to_string(), session_id);
1223 }
1224
1225 if payload.get("max_tokens").is_none()
1226 && let Some(max_output_tokens) = payload.get("max_output_tokens").cloned()
1227 {
1228 payload.insert("max_tokens".to_string(), max_output_tokens);
1229 }
1230
1231 if payload.get("max_output_tokens").is_none()
1232 && let Some(max_tokens) = payload.get("max_tokens").cloned()
1233 {
1234 payload.insert("max_output_tokens".to_string(), max_tokens);
1235 }
1236
1237 Ok(normalized)
1238}
1239
1240#[cfg(test)]
1241mod tests {
1242 use super::{
1243 WriteStdinDispatch, command_session_missing_required_args, command_session_requires_command_safety,
1244 command_text, command_words, command_words_after_environment_prefix, contains_dynamic_shell_syntax,
1245 environment_prefix_has_injection_keys, extract_command_text_with_key, has_indexed_command_parts,
1246 has_unsafe_readonly_options, interactive_input_text, normalize_indexed_command_args, normalize_shell_args,
1247 normalized_command_value, parse_indexed_command_parts, raw_command_text, scan_environment_prefix,
1248 session_id_text, session_id_text_from_payload, working_dir_text, working_dir_text_from_payload,
1249 write_stdin_dispatch,
1250 };
1251 use serde_json::{Value, json};
1252
1253 #[test]
1254 fn detects_indexed_command_keys() {
1255 assert!(has_indexed_command_parts(&json!({"command.0": "ls"})));
1256 assert!(has_indexed_command_parts(&json!({"command.1": "ls"})));
1257 assert!(!has_indexed_command_parts(&json!({"command.2": "ls"})));
1258 }
1259
1260 #[test]
1261 fn parses_zero_based_indexed_command_parts() {
1262 let parts = parse_indexed_command_parts(
1263 json!({
1264 "command.0": "ls",
1265 "command.1": "-a"
1266 })
1267 .as_object()
1268 .expect("object"),
1269 )
1270 .expect("valid indexed args");
1271
1272 assert_eq!(parts, Some(vec!["ls".to_string(), "-a".to_string()]));
1273 }
1274
1275 #[test]
1276 fn parses_one_based_indexed_command_parts() {
1277 let parts = parse_indexed_command_parts(
1278 json!({
1279 "command.1": "ls",
1280 "command.2": "-a"
1281 })
1282 .as_object()
1283 .expect("object"),
1284 )
1285 .expect("valid indexed args");
1286
1287 assert_eq!(parts, Some(vec!["ls".to_string(), "-a".to_string()]));
1288 }
1289
1290 #[test]
1291 fn rejects_non_string_indexed_command_parts() {
1292 let error = parse_indexed_command_parts(
1293 json!({
1294 "command.0": 42
1295 })
1296 .as_object()
1297 .expect("object"),
1298 )
1299 .expect_err("non-string segment should fail");
1300
1301 assert_eq!(error, "command array must contain only strings");
1302 }
1303
1304 #[test]
1305 fn normalizes_indexed_command_args_into_command_string() {
1306 let normalized = normalize_indexed_command_args(&json!({
1307 "command.1": "ls",
1308 "command.2": "-a",
1309 "working_dir": "."
1310 }))
1311 .expect("valid indexed args")
1312 .expect("normalized payload");
1313
1314 assert_eq!(normalized.get("command").and_then(Value::as_str), Some("ls -a"));
1315 assert_eq!(normalized.get("working_dir").and_then(Value::as_str), Some("."));
1316 }
1317
1318 #[test]
1319 fn normalized_command_value_prefers_cmd_aliases() {
1320 let normalized = normalized_command_value(&json!({"cmd": "ls -a"}))
1321 .expect("valid command alias")
1322 .expect("command value");
1323
1324 assert_eq!(normalized.as_str(), Some("ls -a"));
1325 }
1326
1327 #[test]
1328 fn command_text_joins_command_arrays() {
1329 let command = command_text(&json!({"command": ["git", "status", "--short"]}))
1330 .expect("valid command")
1331 .expect("command text");
1332
1333 assert_eq!(command, "git status --short");
1334 }
1335
1336 #[test]
1337 fn command_words_append_extra_args() {
1338 let words = command_words(&json!({
1339 "command": "cargo test",
1340 "args": ["-p", "vtcode-core"]
1341 }))
1342 .expect("valid command")
1343 .expect("command words");
1344
1345 assert_eq!(words, vec!["cargo", "test", "-p", "vtcode-core"]);
1346 }
1347
1348 #[test]
1349 fn interactive_input_text_preserves_whitespace() {
1350 assert_eq!(interactive_input_text(&json!({"chars": " echo hi\n"})), Some(" echo hi\n"));
1351 }
1352
1353 #[test]
1354 fn write_stdin_dispatch_distinguishes_write_from_poll() {
1355 assert_eq!(write_stdin_dispatch(&json!({"chars": ""})), Ok(WriteStdinDispatch::Poll));
1356 assert_eq!(write_stdin_dispatch(&json!({"chars": " status\n"})), Ok(WriteStdinDispatch::Write));
1357 }
1358
1359 #[test]
1360 fn write_stdin_dispatch_accepts_explicit_wait_without_chars() {
1361 assert_eq!(
1362 write_stdin_dispatch(&json!({"action": "wait", "session_id": "run-1"})),
1363 Ok(WriteStdinDispatch::Wait)
1364 );
1365 }
1366
1367 #[test]
1368 fn write_stdin_dispatch_requires_public_chars() {
1369 assert_eq!(write_stdin_dispatch(&json!({"input": "status\n"})), Err("write_stdin requires string chars"));
1370 assert_eq!(write_stdin_dispatch(&json!({"chars": 1})), Err("write_stdin requires string chars"));
1371 }
1372
1373 #[test]
1374 fn write_stdin_dispatch_validates_control_actions() {
1375 for (action, expected) in [
1376 ("inspect", WriteStdinDispatch::Inspect),
1377 ("terminate", WriteStdinDispatch::Terminate),
1378 ("close", WriteStdinDispatch::Close),
1379 ("poll", WriteStdinDispatch::Poll),
1380 ] {
1381 assert_eq!(write_stdin_dispatch(&json!({"action": action})), Ok(expected));
1382 }
1383 assert!(write_stdin_dispatch(&json!({"action": "typo", "chars": "echo unsafe\n"})).is_err());
1384 assert!(write_stdin_dispatch(&json!({"action": "poll", "chars": "echo unsafe\n"})).is_err());
1385 assert!(write_stdin_dispatch(&json!({"action": 1, "chars": ""})).is_err());
1386 }
1387
1388 #[test]
1389 fn write_stdin_dispatch_matches_actions_case_insensitively() {
1390 for (action, expected) in [
1393 ("WAIT", WriteStdinDispatch::Wait),
1394 ("WaIt", WriteStdinDispatch::Wait),
1395 ("INSPECT", WriteStdinDispatch::Inspect),
1396 ("Terminate", WriteStdinDispatch::Terminate),
1397 ("CLOSE", WriteStdinDispatch::Close),
1398 ("Poll", WriteStdinDispatch::Poll),
1399 ] {
1400 assert_eq!(
1401 write_stdin_dispatch(&json!({"action": action})),
1402 Ok(expected),
1403 "{action:?} must resolve like its lowercase form"
1404 );
1405 }
1406 assert_eq!(
1407 write_stdin_dispatch(&json!({"action": "WRITE", "chars": "status\n"})),
1408 Ok(WriteStdinDispatch::Write)
1409 );
1410 assert!(write_stdin_dispatch(&json!({"action": "wäit"})).is_err());
1411 assert!(write_stdin_dispatch(&json!({"action": "WAIT "})).is_err());
1412 }
1413
1414 #[test]
1415 fn session_id_text_trims_whitespace() {
1416 assert_eq!(session_id_text(&json!({"session_id": " run-1 "})), Some("run-1"));
1417 }
1418
1419 #[test]
1420 fn session_id_text_accepts_compact_alias() {
1421 assert_eq!(session_id_text(&json!({"s": " run-1 "})), Some("run-1"));
1422 }
1423
1424 #[test]
1425 fn session_id_text_from_payload_accepts_aliases() {
1426 let value = json!({"s": " run-1 "});
1427 let payload = value.as_object().expect("object");
1428 assert_eq!(session_id_text_from_payload(payload), Some("run-1"));
1429 }
1430
1431 #[test]
1432 fn working_dir_text_accepts_aliases() {
1433 assert_eq!(working_dir_text(&json!({"workdir": " src "})), Some("src"));
1434 assert_eq!(working_dir_text(&json!({"cwd": "."})), Some("."));
1435 }
1436
1437 #[test]
1438 fn working_dir_text_from_payload_accepts_aliases() {
1439 let value = json!({"workdir": " src "});
1440 let payload = value.as_object().expect("object");
1441 assert_eq!(working_dir_text_from_payload(payload), Some("src"));
1442 }
1443
1444 #[test]
1445 fn normalize_shell_args_maps_codex_fields() {
1446 let normalized = normalize_shell_args(&json!({
1447 "cmd": "echo hi",
1448 "chars": "status\n"
1449 }))
1450 .expect("valid shell args");
1451
1452 assert_eq!(normalized.get("command").and_then(Value::as_str), Some("echo hi"));
1453 assert_eq!(normalized.get("input").and_then(Value::as_str), Some("status\n"));
1454 }
1455
1456 #[test]
1457 fn normalize_shell_args_maps_compact_session_id() {
1458 let normalized = normalize_shell_args(&json!({
1459 "s": "run-1"
1460 }))
1461 .expect("valid shell args");
1462
1463 assert_eq!(normalized.get("session_id").and_then(Value::as_str), Some("run-1"));
1464 }
1465
1466 #[test]
1467 fn normalize_shell_args_copies_max_output_tokens_to_max_tokens() {
1468 let normalized = normalize_shell_args(&json!({
1469 "command": "echo hi",
1470 "max_output_tokens": 42
1471 }))
1472 .expect("valid shell args");
1473
1474 assert_eq!(normalized.get("max_output_tokens").and_then(Value::as_u64), Some(42));
1475 assert_eq!(normalized.get("max_tokens").and_then(Value::as_u64), Some(42));
1476 }
1477
1478 #[test]
1479 fn normalize_shell_args_copies_max_tokens_to_max_output_tokens() {
1480 let normalized = normalize_shell_args(&json!({
1481 "command": "echo hi",
1482 "max_tokens": 42
1483 }))
1484 .expect("valid shell args");
1485
1486 assert_eq!(normalized.get("max_tokens").and_then(Value::as_u64), Some(42));
1487 assert_eq!(normalized.get("max_output_tokens").and_then(Value::as_u64), Some(42));
1488 }
1489
1490 #[test]
1491 fn command_session_missing_required_args_is_action_aware() {
1492 assert_eq!(command_session_missing_required_args(&json!({"action": "run"})), vec!["command"]);
1493 assert_eq!(
1494 command_session_missing_required_args(&json!({"action": "write", "session_id": "run-1"})),
1495 vec!["input or chars or text"]
1496 );
1497 assert_eq!(
1498 command_session_missing_required_args(&json!({"action": "inspect"})),
1499 vec!["session_id or spool_path"]
1500 );
1501 assert!(command_session_missing_required_args(&json!({"action": "list"})).is_empty());
1502 }
1503
1504 #[test]
1505 fn command_session_requires_command_safety_only_for_run() {
1506 assert!(command_session_requires_command_safety(&json!({
1507 "action": "run",
1508 "command": "cargo check"
1509 })));
1510 assert!(!command_session_requires_command_safety(&json!({
1511 "action": "poll",
1512 "session_id": "run-1"
1513 })));
1514 }
1515
1516 #[test]
1517 fn raw_command_text_extracts_command_string() {
1518 assert_eq!(raw_command_text(&json!({"command": "rg foo"})), Some("rg foo".to_string()));
1519 assert_eq!(raw_command_text(&json!({"cmd": "ls -la"})), Some("ls -la".to_string()));
1520 assert_eq!(
1521 raw_command_text(&json!({"command.0": "cat", "command.1": "file.txt"})),
1522 Some("cat file.txt".to_string())
1523 );
1524 assert_eq!(raw_command_text(&json!({"command": ["wc", "-l"]})), Some("wc -l".to_string()));
1525 assert!(raw_command_text(&json!({})).is_none());
1526 }
1527
1528 #[test]
1529 fn display_extraction_reports_key_and_preserves_precedence() {
1530 assert_eq!(
1531 extract_command_text_with_key(&json!({"command": ["bash", "-lc", "ls -R"]})),
1532 Some(("bash -lc ls -R".to_string(), "command"))
1533 );
1534 assert_eq!(
1535 extract_command_text_with_key(&json!({"bash_command": "pwd"})),
1536 Some(("pwd".to_string(), "bash_command"))
1537 );
1538 assert_eq!(
1539 extract_command_text_with_key(&json!({"raw_command": "cargo test"})),
1540 Some(("cargo test".to_string(), "raw_command"))
1541 );
1542 assert_eq!(extract_command_text_with_key(&json!({"cmd": "ls -la"})), Some(("ls -la".to_string(), "cmd")));
1545 assert_eq!(extract_command_text_with_key(&json!({"command": " "})), None);
1547 assert_eq!(
1549 extract_command_text_with_key(
1550 &json!({"raw_command": "cargo check -p vtcode", "bash_command": "cargo test"})
1551 ),
1552 Some(("cargo check -p vtcode".to_string(), "raw_command"))
1553 );
1554 assert!(extract_command_text_with_key(&json!({})).is_none());
1555 }
1556
1557 #[test]
1558 fn env_prefix_injection_keys_are_detected() {
1559 let words = |command: &str| -> Vec<String> { shell_words::split(command).expect("split command") };
1560
1561 assert!(environment_prefix_has_injection_keys(&words(
1564 "env GIT_CONFIG_COUNT=1 GIT_CONFIG_KEY_0=core.fsmonitor GIT_CONFIG_VALUE_0=touch git status"
1565 )));
1566 assert!(environment_prefix_has_injection_keys(&words(
1567 "GIT_CONFIG_COUNT=1 GIT_CONFIG_KEY_0=core.fsmonitor GIT_CONFIG_VALUE_0=touch git status"
1568 )));
1569 assert!(environment_prefix_has_injection_keys(&words("env GIT_EXTERNAL_DIFF=evil git diff")));
1570 assert!(environment_prefix_has_injection_keys(&words("env GIT_TEXTCONV=evil git log")));
1571 assert!(environment_prefix_has_injection_keys(&words("env LD_PRELOAD=./evil.so ls")));
1572 assert!(environment_prefix_has_injection_keys(&words("env BASH_ENV=./evil.sh bash -lc 'echo hi'")));
1573 assert!(environment_prefix_has_injection_keys(&words("env NODE_OPTIONS=--require ./evil.js node --version")));
1574 assert!(environment_prefix_has_injection_keys(&words("env -S 'GIT_CONFIG_COUNT=1 git status'")));
1576
1577 assert!(!environment_prefix_has_injection_keys(&words("env LANG=C.UTF-8 rg foo")));
1579 assert!(!environment_prefix_has_injection_keys(&words("FOO=bar git status")));
1580 assert!(!environment_prefix_has_injection_keys(&words("env FOO=bar git status")));
1581 assert!(!environment_prefix_has_injection_keys(&words("env -u GIT_CONFIG_COUNT git status")));
1583 assert!(!environment_prefix_has_injection_keys(&words("git status")));
1584 }
1585
1586 #[test]
1587 fn env_prefix_single_scan_agrees_with_both_public_helpers() {
1588 let words = |command: &str| -> Vec<String> { shell_words::split(command).expect("split command") };
1589 for command in [
1590 "git status",
1591 "env LANG=C.UTF-8 rg foo",
1592 "FOO=bar LANG=C git status",
1593 "env FOO=bar git status",
1594 "env -- FOO=bar git status",
1595 "env -u GIT_CONFIG_COUNT git status",
1596 "env -C /tmp git status",
1597 "env GIT_EXTERNAL_DIFF=evil git diff",
1598 "GIT_CONFIG_COUNT=1 GIT_CONFIG_KEY_0=k GIT_CONFIG_VALUE_0=v git status",
1599 "env -S 'GIT_CONFIG_COUNT=1 git status'",
1600 "env --split-string='x=1 ls'",
1601 ] {
1602 let parsed = words(command);
1603 let scan = scan_environment_prefix(&parsed);
1604 assert_eq!(command_words_after_environment_prefix(&parsed), &parsed[scan.stripped_start..], "{command}");
1605 assert_eq!(environment_prefix_has_injection_keys(&parsed), scan.has_injection, "{command}");
1606 }
1607 }
1608
1609 #[test]
1610 fn env_prefix_empty_key_stays_fail_closed() {
1611 let words = |command: &str| -> Vec<String> { shell_words::split(command).expect("split command") };
1612 for command in ["=foo rg foo", "env =foo rg foo"] {
1615 let parsed = words(command);
1616 let stripped = command_words_after_environment_prefix(&parsed);
1617 assert_eq!(stripped.first().map(String::as_str), Some("=foo"), "{command}");
1618 assert!(!environment_prefix_has_injection_keys(&parsed), "{command}");
1619 }
1620 }
1621
1622 #[test]
1623 fn env_prefix_split_string_reports_empty_remainder_and_unsafe() {
1624 let words = |command: &str| -> Vec<String> { shell_words::split(command).expect("split command") };
1625 let parsed = words("env -S 'GIT_CONFIG_COUNT=1 git status'");
1626 assert!(command_words_after_environment_prefix(&parsed).is_empty());
1627 assert!(environment_prefix_has_injection_keys(&parsed));
1628 assert!(has_unsafe_readonly_options(&parsed));
1629 }
1630
1631 #[test]
1632 fn has_unsafe_readonly_options_flags_injection_without_second_walk() {
1633 let words = |command: &str| -> Vec<String> { shell_words::split(command).expect("split command") };
1634 assert!(has_unsafe_readonly_options(&words("env LD_PRELOAD=./evil.so ls")));
1635 assert!(has_unsafe_readonly_options(&words("LD_PRELOAD=./evil.so ls")));
1636 assert!(!has_unsafe_readonly_options(&words("env LANG=C.UTF-8 rg foo")));
1637 assert!(!has_unsafe_readonly_options(&words("git status")));
1638 }
1639
1640 #[test]
1641 fn dynamic_shell_syntax_allows_quoted_globs_only() {
1642 assert!(!contains_dynamic_shell_syntax("find src -name '*.rs'"));
1643 assert!(!contains_dynamic_shell_syntax("find src -name \"*.rs\""));
1644 assert!(contains_dynamic_shell_syntax("find src -exe$''c touch {} +"));
1645 assert!(contains_dynamic_shell_syntax("find src -ex{e,}c touch {} +"));
1646 }
1647}