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 match action.to_ascii_lowercase().as_str() {
41 "wait" => return Ok(WriteStdinDispatch::Wait),
42 "inspect" => return Ok(WriteStdinDispatch::Inspect),
43 "terminate" => return Ok(WriteStdinDispatch::Terminate),
44 "close" => return Ok(WriteStdinDispatch::Close),
45 "poll" => {
46 if payload.get("chars").is_some_and(|chars| chars.as_str() != Some("")) {
47 return Err("write_stdin poll cannot send chars");
48 }
49 return Ok(WriteStdinDispatch::Poll);
50 }
51 "write" => {}
52 _ => return Err("write_stdin action must be write, poll, wait, inspect, terminate, or close"),
53 }
54 }
55 let chars = payload
56 .get("chars")
57 .and_then(Value::as_str)
58 .ok_or("write_stdin requires string chars")?;
59
60 if chars.is_empty() {
61 Ok(WriteStdinDispatch::Poll)
62 } else {
63 Ok(WriteStdinDispatch::Write)
64 }
65}
66
67fn collect_indexed_command_parts(
68 payload: &serde_json::Map<String, Value>,
69 start_index: usize,
70) -> Result<Vec<String>, &'static str> {
71 let mut parts = Vec::new();
72 let mut index = start_index;
73 while let Some(value) = payload.get(&format!("command.{index}")) {
74 let Some(part) = value.as_str() else {
75 return Err(INDEXED_COMMAND_TYPE_ERROR);
76 };
77 parts.push(part.to_string());
78 index += 1;
79 }
80 Ok(parts)
81}
82
83pub fn has_indexed_command_parts(args: &Value) -> bool {
84 let Some(payload) = args.as_object() else {
85 return false;
86 };
87
88 payload.contains_key("command.0") || payload.contains_key("command.1")
89}
90
91pub fn parse_indexed_command_parts(
92 payload: &serde_json::Map<String, Value>,
93) -> Result<Option<Vec<String>>, &'static str> {
94 let zero_based = collect_indexed_command_parts(payload, 0)?;
95 if !zero_based.is_empty() {
96 return Ok(Some(zero_based));
97 }
98
99 let one_based = collect_indexed_command_parts(payload, 1)?;
100 if one_based.is_empty() {
101 Ok(None)
102 } else {
103 Ok(Some(one_based))
104 }
105}
106
107pub fn normalize_indexed_command_args(args: &Value) -> Result<Option<Value>, &'static str> {
108 let Some(payload) = args.as_object() else {
109 return Ok(None);
110 };
111 if payload.get("command").is_some() {
112 return Ok(None);
113 }
114
115 let Some(parts) = parse_indexed_command_parts(payload)? else {
116 return Ok(None);
117 };
118
119 let mut normalized = payload.clone();
120 normalized.insert("command".to_string(), Value::String(shell_words::join(parts.iter().map(String::as_str))));
121 Ok(Some(Value::Object(normalized)))
122}
123
124pub fn normalized_command_value(args: &Value) -> Result<Option<Value>, &'static str> {
125 if let Some(command) = args
126 .get("command")
127 .or_else(|| args.get("cmd"))
128 .or_else(|| args.get("raw_command"))
129 {
130 return Ok(Some(command.clone()));
131 }
132
133 Ok(normalize_indexed_command_args(args)?.and_then(|normalized| normalized.get("command").cloned()))
134}
135
136pub fn command_words(args: &Value) -> Result<Option<Vec<String>>, &'static str> {
137 let Some(command) = normalized_command_value(args)? else {
138 return Ok(None);
139 };
140
141 let mut parts = match command {
142 Value::String(command) => shell_words::split(&command).map_err(|_e| COMMAND_VALUE_TYPE_ERROR)?,
143 Value::Array(values) => values
144 .iter()
145 .map(|value| value.as_str().map(ToOwned::to_owned).ok_or(COMMAND_VALUE_TYPE_ERROR))
146 .collect::<Result<Vec<_>, _>>()?,
147 _ => return Err(COMMAND_VALUE_TYPE_ERROR),
148 };
149
150 if let Some(extra_args) = args.get("args").and_then(Value::as_array) {
151 for value in extra_args {
152 let Some(part) = value.as_str() else {
153 return Err(COMMAND_VALUE_TYPE_ERROR);
154 };
155 parts.push(part.to_string());
156 }
157 }
158
159 if parts.is_empty() { Ok(None) } else { Ok(Some(parts)) }
160}
161
162pub fn command_text(args: &Value) -> Result<Option<String>, &'static str> {
163 let Some(parts) = command_words(args)? else {
164 return Ok(None);
165 };
166 Ok(Some(shell_words::join(parts.iter().map(String::as_str))))
167}
168
169pub const DISPLAY_COMMAND_KEYS: &[&str] = &["command", "raw_command", "bash_command", "cmd"];
177
178pub fn extract_command_text_with_key(args: &Value) -> Option<(String, &'static str)> {
189 if let Some(array) = args.get("command").and_then(Value::as_array) {
190 let joined: String = array
191 .iter()
192 .filter_map(|value| value.as_str())
193 .filter(|segment| !segment.is_empty())
194 .collect::<Vec<_>>()
195 .join(" ");
196 if !joined.is_empty() {
197 return Some((joined, "command"));
198 }
199 }
200 for &key in DISPLAY_COMMAND_KEYS {
201 let Some(value) = args.get(key).and_then(Value::as_str) else {
202 continue;
203 };
204 let (text, ok) = if key == "command" {
207 let trimmed = value.trim();
208 (trimmed.to_string(), !trimmed.is_empty())
209 } else {
210 (value.to_string(), !value.is_empty())
211 };
212 if ok {
213 return Some((text, key));
214 }
215 }
216 None
217}
218
219fn has_nonempty_string_field(args: &Value, key: &str) -> bool {
220 args.get(key)
221 .and_then(Value::as_str)
222 .map(str::trim)
223 .is_some_and(|value| !value.is_empty())
224}
225
226pub fn interactive_input_text(args: &Value) -> Option<&str> {
227 args.get("input")
228 .and_then(Value::as_str)
229 .or_else(|| args.get("chars").and_then(Value::as_str))
230 .or_else(|| args.get("text").and_then(Value::as_str))
231 .filter(|value| !value.is_empty())
232}
233
234pub fn session_id_text_from_payload(payload: &serde_json::Map<String, Value>) -> Option<&str> {
235 payload
236 .get("session_id")
237 .or_else(|| payload.get("s"))
238 .and_then(Value::as_str)
239 .map(str::trim)
240 .filter(|value| !value.is_empty())
241}
242
243pub fn session_id_text(args: &Value) -> Option<&str> {
244 args.as_object().and_then(session_id_text_from_payload)
245}
246
247pub fn command_session_missing_required_args(args: &Value) -> Vec<&'static str> {
248 if command_session_action(args).is_none() {
249 return Vec::new();
250 }
251
252 let mut missing = Vec::new();
253 if command_session_action_is(args, "run") {
254 if command_text(args).ok().flatten().is_none() {
255 missing.push("command");
256 }
257 } else if command_session_action_is(args, "write") {
258 if session_id_text(args).is_none() {
259 missing.push("session_id");
260 }
261 if interactive_input_text(args).is_none() {
262 missing.push("input or chars or text");
263 }
264 } else if command_session_action_in(args, &["poll", "wait", "continue", "close"]) {
265 if session_id_text(args).is_none() {
266 missing.push("session_id");
267 }
268 } else if command_session_action_is(args, "inspect") {
269 let has_session_id = session_id_text(args).is_some();
270 let has_spool_path = has_nonempty_string_field(args, "spool_path");
271 if !has_session_id && !has_spool_path {
272 missing.push("session_id or spool_path");
273 }
274 } else if command_session_action_is(args, "code") {
275 let has_code = has_nonempty_string_field(args, "code") || has_nonempty_string_field(args, "command");
276 if !has_code {
277 missing.push("code or command");
278 }
279 }
280
281 missing
282}
283
284pub fn command_session_requires_command_safety(args: &Value) -> bool {
285 command_session_action_is(args, "run")
286}
287
288pub fn working_dir_text_from_payload(payload: &serde_json::Map<String, Value>) -> Option<&str> {
289 payload
290 .get("working_dir")
291 .or_else(|| payload.get("cwd"))
292 .or_else(|| payload.get("workdir"))
293 .and_then(Value::as_str)
294 .map(str::trim)
295 .filter(|value| !value.is_empty())
296}
297
298pub fn working_dir_text(args: &Value) -> Option<&str> {
299 args.as_object().and_then(working_dir_text_from_payload)
300}
301
302pub fn raw_command_text(args: &Value) -> Option<String> {
306 let payload = args.as_object()?;
307
308 if let Some(command) = payload
309 .get("command")
310 .or_else(|| payload.get("cmd"))
311 .or_else(|| payload.get("raw_command"))
312 {
313 match command {
314 Value::String(text) => return Some(text.clone()),
315 Value::Array(values) => {
316 let parts: Option<Vec<&str>> = values.iter().map(|v| v.as_str()).collect();
317 return parts.map(shell_words::join);
318 }
319 _ => return None,
320 }
321 }
322
323 let indexed = parse_indexed_command_parts(payload).ok().flatten()?;
324 Some(shell_words::join(indexed.iter().map(String::as_str)))
325}
326
327pub(crate) fn shell_command_text_with_args(args: &Value) -> Option<String> {
332 let mut command = raw_command_text(args)?;
333 if args
334 .get("raw_command")
335 .and_then(Value::as_str)
336 .is_some_and(|raw| raw != command)
337 {
338 return None;
339 }
340 if let Some(arguments) = args.get("args") {
341 let arguments = arguments.as_array()?;
342 let suffix_words = arguments.iter().map(Value::as_str).collect::<Option<Vec<_>>>()?;
343 if !suffix_words.is_empty() {
344 command.push(' ');
345 command.push_str(&shell_words::join(suffix_words));
346 }
347 }
348 Some(command)
349}
350
351pub fn contains_dynamic_shell_syntax(command: &str) -> bool {
359 crate::command_safety::shell_parser::contains_dynamic_shell_syntax(command)
360}
361
362pub fn command_words_after_environment_prefix(words: &[String]) -> &[String] {
368 let mut start = 0;
369 loop {
370 if words.get(start).is_some_and(|word| word == "env") {
371 start += 1;
372 while let Some(word) = words.get(start) {
373 if word == "--" {
374 start += 1;
375 break;
376 }
377 let is_split_string = word == "-S"
378 || word == "--split-string"
379 || word.starts_with("-S")
380 || word.starts_with("--split-string=");
381 let consumes_next = matches!(word.as_str(), "-u" | "--unset" | "-C" | "--chdir");
382 start += 1;
383 if is_split_string {
384 return &words[words.len()..];
385 }
386 if consumes_next {
387 if words.get(start).is_some() {
388 start += 1;
389 }
390 continue;
391 }
392 if word.starts_with('-') || word.contains('=') {
393 continue;
394 }
395 start -= 1;
396 break;
397 }
398 continue;
399 }
400 if words
401 .get(start)
402 .is_some_and(|word| !word.starts_with('-') && word.contains('='))
403 {
404 start += 1;
405 continue;
406 }
407 break;
408 }
409 &words[start..]
410}
411
412const ENV_INJECTION_KEYS: &[&str] = &[
415 "GIT_CONFIG_COUNT",
416 "GIT_CONFIG_GLOBAL",
417 "GIT_CONFIG_SYSTEM",
418 "GIT_CONFIG_NOSYSTEM",
419 "GIT_EXTERNAL_DIFF",
420 "GIT_TEXTCONV",
421 "GIT_DIFF_OPTS",
422 "GIT_EDITOR",
423 "GIT_SEQUENCE_EDITOR",
424 "GIT_SSH",
425 "GIT_SSH_COMMAND",
426 "GIT_EXEC_PATH",
427 "LD_PRELOAD",
428 "LD_AUDIT",
429 "LD_LIBRARY_PATH",
430 "DYLD_INSERT_LIBRARIES",
431 "DYLD_LIBRARY_PATH",
432 "DYLD_FRAMEWORK_PATH",
433 "DYLD_FALLBACK_LIBRARY_PATH",
434 "BASH_ENV",
435 "ENV",
436 "SHELLOPTS",
437 "PERL5OPT",
438 "PYTHONSTARTUP",
439 "NODE_OPTIONS",
440 "RUBYOPT",
441 "EDITOR",
442 "VISUAL",
443 "PAGER",
444];
445
446const ENV_INJECTION_KEY_PREFIXES: &[&str] = &["GIT_CONFIG_KEY_", "GIT_CONFIG_VALUE_"];
449
450fn env_assignment_key(word: &str) -> Option<&str> {
451 if word.starts_with('-') {
452 return None;
453 }
454 let (key, _) = word.split_once('=')?;
455 if key.is_empty() {
456 return None;
457 }
458 Some(key)
459}
460
461fn is_env_injection_key(key: &str) -> bool {
462 ENV_INJECTION_KEYS.contains(&key) || ENV_INJECTION_KEY_PREFIXES.iter().any(|prefix| key.starts_with(prefix))
463}
464
465pub fn environment_prefix_has_injection_keys(words: &[String]) -> bool {
473 let mut start = 0;
474 loop {
475 if words.get(start).is_some_and(|word| word == "env") {
476 start += 1;
477 while let Some(word) = words.get(start) {
478 if word == "--" {
479 start += 1;
480 break;
481 }
482 let is_split_string = word == "-S"
483 || word == "--split-string"
484 || word.starts_with("-S")
485 || word.starts_with("--split-string=");
486 let consumes_next = matches!(word.as_str(), "-u" | "--unset" | "-C" | "--chdir");
487 start += 1;
488 if is_split_string {
489 return true;
492 }
493 if consumes_next {
494 if words.get(start).is_some() {
496 start += 1;
497 }
498 continue;
499 }
500 if let Some(key) = env_assignment_key(word) {
501 if is_env_injection_key(key) {
502 return true;
503 }
504 continue;
505 }
506 if word.starts_with('-') {
507 continue;
508 }
509 start -= 1;
510 break;
511 }
512 continue;
513 }
514 if let Some(key) = words.get(start).and_then(|word| env_assignment_key(word)) {
515 if is_env_injection_key(key) {
516 return true;
517 }
518 start += 1;
519 continue;
520 }
521 break;
522 }
523 false
524}
525
526pub(crate) fn has_unsafe_readonly_options(words: &[String]) -> bool {
535 if environment_prefix_has_injection_keys(words) {
536 return true;
537 }
538 let command_words = command_words_after_environment_prefix(words);
539 let Some(program) = command_words
540 .first()
541 .and_then(|word| Path::new(word).file_name())
542 .and_then(|name| name.to_str())
543 else {
544 return false;
545 };
546 let program = program.to_ascii_lowercase();
547
548 match program.as_str() {
549 "git" => command_words.iter().skip(1).any(|word| {
550 let is_dir_redirect = word == "-C" || (word.starts_with("-C") && word.len() > 2);
556 (!is_dir_redirect && crate::command_safety::git_global_option_requires_prompt(word))
557 || word == "--ext-diff"
558 || word == "--textconv"
559 || word == "-o"
560 || word.starts_with("-o")
561 || word == "--output"
562 || word.starts_with("--output=")
563 }),
564 "sed" => has_unsafe_sed_options(&command_words[1..]),
565 "find" => command_words.iter().skip(1).any(|word| {
566 matches!(
567 word.as_str(),
568 "-delete" | "-exec" | "-execdir" | "-ok" | "-okdir" | "-fls" | "-fprint" | "-fprint0" | "-fprintf"
569 )
570 }),
571 "sort" => command_words.iter().skip(1).any(|word| {
572 word == "-o"
573 || word.starts_with("-o")
574 || word == "--output"
575 || word.starts_with("--output=")
576 || word == "--compress-program"
577 || word.starts_with("--compress-program=")
578 }),
579 "date" => command_words
580 .iter()
581 .skip(1)
582 .any(|word| word == "-s" || word.starts_with("-s") || word == "--set" || word.starts_with("--set=")),
583 "rg" => command_words.iter().skip(1).any(|word| {
584 matches!(word.as_str(), "--pre" | "--hostname-bin" | "--search-zip" | "-z")
585 || word.starts_with("--pre=")
586 || word.starts_with("--hostname-bin=")
587 }),
588 "ast-grep" | "sg" => command_words
589 .iter()
590 .skip(1)
591 .any(|word| word == "-r" || word == "--rewrite" || word.starts_with("--rewrite=")),
592 "fd" => command_words
593 .iter()
594 .skip(1)
595 .any(|word| word == "-x" || word == "--exec" || word == "--exec-batch" || word.starts_with("--exec=")),
596 "tree" => command_words
597 .iter()
598 .skip(1)
599 .any(|word| word == "-o" || word.starts_with("-o") || word == "--output" || word.starts_with("--output=")),
600 "awk" => has_unsafe_awk_options(&command_words[1..]),
608 _ => false,
609 }
610}
611
612fn has_unsafe_sed_options(arguments: &[String]) -> bool {
613 let mut expects_expression = false;
614 let mut expression_seen = false;
615
616 for word in arguments {
617 if expects_expression {
618 if sed_script_may_write(word) {
619 return true;
620 }
621 expects_expression = false;
622 expression_seen = true;
623 continue;
624 }
625
626 if word == "-e" || word == "--expression" {
627 expects_expression = true;
628 continue;
629 }
630 if let Some(expression) = word.strip_prefix("--expression=") {
631 if sed_script_may_write(expression) {
632 return true;
633 }
634 expression_seen = true;
635 continue;
636 }
637 if let Some(expression) = word.strip_prefix("-e")
638 && !expression.is_empty()
639 {
640 if sed_script_may_write(expression) {
641 return true;
642 }
643 expression_seen = true;
644 continue;
645 }
646
647 if word == "-f"
648 || word.starts_with("-f")
649 || word.starts_with("--file")
650 || word == "-i"
651 || word.starts_with("-i")
652 || word == "-I"
653 || word.starts_with("-I")
654 || word.starts_with("--in-place")
655 {
656 return true;
657 }
658
659 if word == "--" {
660 expects_expression = true;
661 continue;
662 }
663 if !word.starts_with('-') && !expression_seen {
664 if sed_script_may_write(word) {
665 return true;
666 }
667 expression_seen = true;
668 }
669 }
670
671 expects_expression || !expression_seen
674}
675
676fn sed_script_may_write(script: &str) -> bool {
677 let chars = script.to_ascii_lowercase().chars().collect::<Vec<_>>();
678 let mut index = 0;
679
680 while index < chars.len() {
681 while chars
682 .get(index)
683 .is_some_and(|character| character.is_whitespace() || *character == ';')
684 {
685 index += 1;
686 }
687
688 if let Some(after_address) = consume_sed_address(&chars, index) {
689 index = after_address;
690 if chars.get(index) == Some(&',') {
691 index += 1;
692 let Some(after_second_address) = consume_sed_address(&chars, index) else {
693 return true;
694 };
695 index = after_second_address;
696 }
697 if chars.get(index) == Some(&'!') {
698 index += 1;
699 }
700 while chars.get(index).is_some_and(|character| character.is_whitespace()) {
701 index += 1;
702 }
703 }
704
705 let Some(&command) = chars.get(index) else {
706 break;
707 };
708 match command {
709 'e' | 'r' | 'w' => return true,
710 's' => {
711 let Some(after_replacement) = consume_sed_substitution(&chars, index) else {
712 return true;
713 };
714 if sed_substitution_may_write(&chars, after_replacement) {
715 return true;
716 }
717 index = after_replacement;
718 while chars.get(index).is_some_and(|character| !matches!(*character, ';' | '\n')) {
719 index += 1;
720 }
721 }
722 _ => {
723 while chars.get(index).is_some_and(|character| !matches!(*character, ';' | '\n')) {
724 index += 1;
725 }
726 }
727 }
728 }
729
730 false
731}
732
733fn consume_sed_address(chars: &[char], start: usize) -> Option<usize> {
734 match chars.get(start)? {
735 character if character.is_ascii_digit() => {
736 let mut index = start + 1;
737 while chars.get(index).is_some_and(|character| character.is_ascii_digit()) {
738 index += 1;
739 }
740 Some(index)
741 }
742 '$' => Some(start + 1),
743 '/' => find_unescaped_sed_delimiter(chars, start + 1, '/').map(|index| index + 1),
744 _ => None,
745 }
746}
747
748fn find_unescaped_sed_delimiter(chars: &[char], start: usize, delimiter: char) -> Option<usize> {
749 let mut index = start;
750 while index < chars.len() {
751 if chars[index] == '\\' {
752 index = index.saturating_add(2);
753 continue;
754 }
755 if chars[index] == delimiter {
756 return Some(index);
757 }
758 index += 1;
759 }
760 None
761}
762
763fn consume_sed_substitution(chars: &[char], start: usize) -> Option<usize> {
764 let delimiter = *chars.get(start + 1)?;
765 if delimiter.is_whitespace() {
766 return None;
767 }
768 let pattern_end = find_unescaped_sed_delimiter(chars, start + 2, delimiter)?;
769 let replacement_end = find_unescaped_sed_delimiter(chars, pattern_end + 1, delimiter)?;
770 Some(replacement_end + 1)
771}
772
773fn sed_substitution_may_write(chars: &[char], start: usize) -> bool {
774 let mut index = start;
775 while chars.get(index).is_some_and(|character| character.is_whitespace()) {
776 index += 1;
777 }
778 matches!(chars.get(index), Some('e' | 'w'))
779 && chars
780 .get(index + 1)
781 .is_none_or(|character| character.is_whitespace() || *character == ';')
782}
783
784fn has_unsafe_awk_options(arguments: &[String]) -> bool {
798 let mut program_seen = false;
799 let mut options_ended = false;
800 let mut index = 0;
801
802 while index < arguments.len() {
803 let word = arguments[index].as_str();
804 if !options_ended && word == "--" {
805 options_ended = true;
806 index += 1;
807 continue;
808 }
809 if !options_ended && word.starts_with('-') && word.len() > 1 {
810 if word == "-v" || word == "--assign" || word == "-F" || word == "--field-separator" {
811 index += 2;
813 continue;
814 }
815 if word.starts_with("-v")
816 || word.starts_with("--assign=")
817 || word.starts_with("-F")
818 || word.starts_with("--field-separator=")
819 {
820 index += 1;
822 continue;
823 }
824 return true;
825 }
826 if !program_seen {
827 program_seen = true;
828 if awk_program_may_write(word) {
829 return true;
830 }
831 }
832 index += 1;
833 }
834
835 !program_seen
836}
837
838fn awk_program_may_write(program: &str) -> bool {
861 let chars = program.chars().collect::<Vec<_>>();
862 let mut index = 0;
863 let mut prev_operand = false;
866 let mut seen_print_in_statement = false;
871 let mut paren_depth: usize = 0;
872 let mut bracket_depth: usize = 0;
873 while index < chars.len() {
874 let character = chars[index];
875 if character.is_whitespace() {
876 index += 1;
877 continue;
878 }
879 if character == '#' {
880 while index < chars.len() && chars[index] != '\n' {
883 index += 1;
884 }
885 continue;
886 }
887 if character == '"' {
888 index += 1;
889 let mut terminated = false;
890 while index < chars.len() {
891 let inner = chars[index];
892 if inner == '\\' {
893 index += 2;
894 continue;
895 }
896 if inner == '"' {
897 index += 1;
898 terminated = true;
899 break;
900 }
901 if inner == '@' {
904 return true;
905 }
906 if (inner == 's' || inner == 'S') && awk_calls_system(&chars, index) {
907 return true;
908 }
909 index += 1;
910 }
911 if !terminated {
912 return true;
913 }
914 prev_operand = true;
915 continue;
916 }
917 if character == '/' && !prev_operand {
918 index += 1;
922 let mut terminated = false;
923 while index < chars.len() {
924 let inner = chars[index];
925 if inner == '\\' {
926 index += 2;
927 continue;
928 }
929 if inner == '/' {
930 index += 1;
931 terminated = true;
932 break;
933 }
934 if inner == '@' {
935 return true;
936 }
937 if inner == '>' {
938 if chars.get(index + 1) == Some(&'=') {
939 index += 2;
940 continue;
941 }
942 return true;
943 }
944 if inner == '|' {
945 if chars.get(index + 1) == Some(&'|') {
946 index += 2;
947 continue;
948 }
949 return true;
950 }
951 index += 1;
952 }
953 if !terminated {
954 return true;
955 }
956 prev_operand = true;
957 continue;
958 }
959 if character == '@' {
960 return true;
961 }
962 if (character == 's' || character == 'S') && awk_calls_system(&chars, index) {
963 return true;
964 }
965 if character == '>' {
966 if chars.get(index + 1) == Some(&'=') {
967 index += 2;
968 prev_operand = false;
969 continue;
970 }
971 if chars.get(index + 1) == Some(&'>') {
973 return true;
974 }
975 if paren_depth == 0 && bracket_depth == 0 && seen_print_in_statement {
979 return true;
980 }
981 index += 1;
982 prev_operand = false;
983 continue;
984 }
985 if character == '|' {
986 if chars.get(index + 1) == Some(&'|') {
987 index += 2;
988 prev_operand = false;
989 continue;
990 }
991 return true;
992 }
993 if character == '/' {
994 prev_operand = false;
996 index += 1;
997 continue;
998 }
999 if character.is_ascii_alphanumeric() || character == '_' {
1000 let start = index;
1001 while index < chars.len() && (chars[index].is_ascii_alphanumeric() || chars[index] == '_') {
1002 index += 1;
1003 }
1004 let word: String = chars[start..index].iter().collect();
1005 if word == "print" || word == "printf" {
1006 seen_print_in_statement = true;
1007 }
1008 prev_operand = !is_awk_keyword(&word);
1009 continue;
1010 }
1011 if character == '(' {
1012 paren_depth = paren_depth.saturating_add(1);
1013 prev_operand = false;
1014 index += 1;
1015 continue;
1016 }
1017 if character == '[' {
1018 bracket_depth = bracket_depth.saturating_add(1);
1019 prev_operand = false;
1020 index += 1;
1021 continue;
1022 }
1023 if character == ')' {
1024 paren_depth = paren_depth.saturating_sub(1);
1025 prev_operand = true;
1026 index += 1;
1027 continue;
1028 }
1029 if character == ']' {
1030 bracket_depth = bracket_depth.saturating_sub(1);
1031 prev_operand = true;
1032 index += 1;
1033 continue;
1034 }
1035 if character == '$' {
1036 prev_operand = true;
1037 index += 1;
1038 continue;
1039 }
1040 if character == ';' || character == '{' || character == '}' {
1041 seen_print_in_statement = false;
1043 prev_operand = false;
1044 index += 1;
1045 continue;
1046 }
1047 prev_operand = false;
1048 index += 1;
1049 }
1050 false
1051}
1052
1053fn is_awk_keyword(word: &str) -> bool {
1059 matches!(
1060 word,
1061 "if" | "else"
1062 | "while"
1063 | "for"
1064 | "do"
1065 | "break"
1066 | "continue"
1067 | "next"
1068 | "nextfile"
1069 | "exit"
1070 | "return"
1071 | "delete"
1072 | "in"
1073 | "getline"
1074 | "print"
1075 | "printf"
1076 | "function"
1077 )
1078}
1079
1080fn awk_calls_system(chars: &[char], start: usize) -> bool {
1084 const NAME: &[char] = &['s', 'y', 's', 't', 'e', 'm'];
1085 if start > 0 && is_awk_ident_char(chars[start - 1]) {
1086 return false;
1087 }
1088 let candidate = chars.get(start..start + NAME.len());
1089 if candidate.is_none_or(|slice| {
1090 slice
1091 .iter()
1092 .zip(NAME.iter())
1093 .any(|(actual, expected)| !actual.eq_ignore_ascii_case(expected))
1094 }) {
1095 return false;
1096 }
1097 let mut index = start + NAME.len();
1098 while chars.get(index).is_some_and(|character| character.is_whitespace()) {
1099 index += 1;
1100 }
1101 chars.get(index) == Some(&'(')
1102}
1103
1104fn is_awk_ident_char(character: char) -> bool {
1105 character.is_ascii_alphanumeric() || character == '_'
1106}
1107
1108pub fn normalize_shell_args(args: &Value) -> Result<Value, &'static str> {
1109 let mut normalized = match normalize_indexed_command_args(args)? {
1110 Some(value) => value,
1111 None => args.clone(),
1112 };
1113
1114 let Some(payload) = normalized.as_object_mut() else {
1115 return Ok(normalized);
1116 };
1117
1118 if payload.get("command").is_none() {
1119 if let Some(command) = payload.get("cmd").cloned() {
1120 payload.insert("command".to_string(), command);
1121 } else if let Some(command) = payload.get("raw_command").cloned() {
1122 payload.insert("command".to_string(), command);
1123 }
1124 }
1125
1126 if payload.get("input").is_none() {
1127 if let Some(input) = payload.get("chars").cloned() {
1128 payload.insert("input".to_string(), input);
1129 } else if let Some(input) = payload.get("text").cloned() {
1130 payload.insert("input".to_string(), input);
1131 }
1132 }
1133
1134 if payload.get("session_id").is_none()
1135 && let Some(session_id) = payload.get("s").cloned()
1136 {
1137 payload.insert("session_id".to_string(), session_id);
1138 }
1139
1140 if payload.get("max_tokens").is_none()
1141 && let Some(max_output_tokens) = payload.get("max_output_tokens").cloned()
1142 {
1143 payload.insert("max_tokens".to_string(), max_output_tokens);
1144 }
1145
1146 if payload.get("max_output_tokens").is_none()
1147 && let Some(max_tokens) = payload.get("max_tokens").cloned()
1148 {
1149 payload.insert("max_output_tokens".to_string(), max_tokens);
1150 }
1151
1152 Ok(normalized)
1153}
1154
1155#[cfg(test)]
1156mod tests {
1157 use super::{
1158 WriteStdinDispatch, command_session_missing_required_args, command_session_requires_command_safety,
1159 command_text, command_words, contains_dynamic_shell_syntax, environment_prefix_has_injection_keys,
1160 extract_command_text_with_key, has_indexed_command_parts, interactive_input_text,
1161 normalize_indexed_command_args, normalize_shell_args, normalized_command_value, parse_indexed_command_parts,
1162 raw_command_text, session_id_text, session_id_text_from_payload, working_dir_text,
1163 working_dir_text_from_payload, write_stdin_dispatch,
1164 };
1165 use serde_json::{Value, json};
1166
1167 #[test]
1168 fn detects_indexed_command_keys() {
1169 assert!(has_indexed_command_parts(&json!({"command.0": "ls"})));
1170 assert!(has_indexed_command_parts(&json!({"command.1": "ls"})));
1171 assert!(!has_indexed_command_parts(&json!({"command.2": "ls"})));
1172 }
1173
1174 #[test]
1175 fn parses_zero_based_indexed_command_parts() {
1176 let parts = parse_indexed_command_parts(
1177 json!({
1178 "command.0": "ls",
1179 "command.1": "-a"
1180 })
1181 .as_object()
1182 .expect("object"),
1183 )
1184 .expect("valid indexed args");
1185
1186 assert_eq!(parts, Some(vec!["ls".to_string(), "-a".to_string()]));
1187 }
1188
1189 #[test]
1190 fn parses_one_based_indexed_command_parts() {
1191 let parts = parse_indexed_command_parts(
1192 json!({
1193 "command.1": "ls",
1194 "command.2": "-a"
1195 })
1196 .as_object()
1197 .expect("object"),
1198 )
1199 .expect("valid indexed args");
1200
1201 assert_eq!(parts, Some(vec!["ls".to_string(), "-a".to_string()]));
1202 }
1203
1204 #[test]
1205 fn rejects_non_string_indexed_command_parts() {
1206 let error = parse_indexed_command_parts(
1207 json!({
1208 "command.0": 42
1209 })
1210 .as_object()
1211 .expect("object"),
1212 )
1213 .expect_err("non-string segment should fail");
1214
1215 assert_eq!(error, "command array must contain only strings");
1216 }
1217
1218 #[test]
1219 fn normalizes_indexed_command_args_into_command_string() {
1220 let normalized = normalize_indexed_command_args(&json!({
1221 "command.1": "ls",
1222 "command.2": "-a",
1223 "working_dir": "."
1224 }))
1225 .expect("valid indexed args")
1226 .expect("normalized payload");
1227
1228 assert_eq!(normalized.get("command").and_then(Value::as_str), Some("ls -a"));
1229 assert_eq!(normalized.get("working_dir").and_then(Value::as_str), Some("."));
1230 }
1231
1232 #[test]
1233 fn normalized_command_value_prefers_cmd_aliases() {
1234 let normalized = normalized_command_value(&json!({"cmd": "ls -a"}))
1235 .expect("valid command alias")
1236 .expect("command value");
1237
1238 assert_eq!(normalized.as_str(), Some("ls -a"));
1239 }
1240
1241 #[test]
1242 fn command_text_joins_command_arrays() {
1243 let command = command_text(&json!({"command": ["git", "status", "--short"]}))
1244 .expect("valid command")
1245 .expect("command text");
1246
1247 assert_eq!(command, "git status --short");
1248 }
1249
1250 #[test]
1251 fn command_words_append_extra_args() {
1252 let words = command_words(&json!({
1253 "command": "cargo test",
1254 "args": ["-p", "vtcode-core"]
1255 }))
1256 .expect("valid command")
1257 .expect("command words");
1258
1259 assert_eq!(words, vec!["cargo", "test", "-p", "vtcode-core"]);
1260 }
1261
1262 #[test]
1263 fn interactive_input_text_preserves_whitespace() {
1264 assert_eq!(interactive_input_text(&json!({"chars": " echo hi\n"})), Some(" echo hi\n"));
1265 }
1266
1267 #[test]
1268 fn write_stdin_dispatch_distinguishes_write_from_poll() {
1269 assert_eq!(write_stdin_dispatch(&json!({"chars": ""})), Ok(WriteStdinDispatch::Poll));
1270 assert_eq!(write_stdin_dispatch(&json!({"chars": " status\n"})), Ok(WriteStdinDispatch::Write));
1271 }
1272
1273 #[test]
1274 fn write_stdin_dispatch_accepts_explicit_wait_without_chars() {
1275 assert_eq!(
1276 write_stdin_dispatch(&json!({"action": "wait", "session_id": "run-1"})),
1277 Ok(WriteStdinDispatch::Wait)
1278 );
1279 }
1280
1281 #[test]
1282 fn write_stdin_dispatch_requires_public_chars() {
1283 assert_eq!(write_stdin_dispatch(&json!({"input": "status\n"})), Err("write_stdin requires string chars"));
1284 assert_eq!(write_stdin_dispatch(&json!({"chars": 1})), Err("write_stdin requires string chars"));
1285 }
1286
1287 #[test]
1288 fn write_stdin_dispatch_validates_control_actions() {
1289 for (action, expected) in [
1290 ("inspect", WriteStdinDispatch::Inspect),
1291 ("terminate", WriteStdinDispatch::Terminate),
1292 ("close", WriteStdinDispatch::Close),
1293 ("poll", WriteStdinDispatch::Poll),
1294 ] {
1295 assert_eq!(write_stdin_dispatch(&json!({"action": action})), Ok(expected));
1296 }
1297 assert!(write_stdin_dispatch(&json!({"action": "typo", "chars": "echo unsafe\n"})).is_err());
1298 assert!(write_stdin_dispatch(&json!({"action": "poll", "chars": "echo unsafe\n"})).is_err());
1299 assert!(write_stdin_dispatch(&json!({"action": 1, "chars": ""})).is_err());
1300 }
1301
1302 #[test]
1303 fn session_id_text_trims_whitespace() {
1304 assert_eq!(session_id_text(&json!({"session_id": " run-1 "})), Some("run-1"));
1305 }
1306
1307 #[test]
1308 fn session_id_text_accepts_compact_alias() {
1309 assert_eq!(session_id_text(&json!({"s": " run-1 "})), Some("run-1"));
1310 }
1311
1312 #[test]
1313 fn session_id_text_from_payload_accepts_aliases() {
1314 let value = json!({"s": " run-1 "});
1315 let payload = value.as_object().expect("object");
1316 assert_eq!(session_id_text_from_payload(payload), Some("run-1"));
1317 }
1318
1319 #[test]
1320 fn working_dir_text_accepts_aliases() {
1321 assert_eq!(working_dir_text(&json!({"workdir": " src "})), Some("src"));
1322 assert_eq!(working_dir_text(&json!({"cwd": "."})), Some("."));
1323 }
1324
1325 #[test]
1326 fn working_dir_text_from_payload_accepts_aliases() {
1327 let value = json!({"workdir": " src "});
1328 let payload = value.as_object().expect("object");
1329 assert_eq!(working_dir_text_from_payload(payload), Some("src"));
1330 }
1331
1332 #[test]
1333 fn normalize_shell_args_maps_codex_fields() {
1334 let normalized = normalize_shell_args(&json!({
1335 "cmd": "echo hi",
1336 "chars": "status\n"
1337 }))
1338 .expect("valid shell args");
1339
1340 assert_eq!(normalized.get("command").and_then(Value::as_str), Some("echo hi"));
1341 assert_eq!(normalized.get("input").and_then(Value::as_str), Some("status\n"));
1342 }
1343
1344 #[test]
1345 fn normalize_shell_args_maps_compact_session_id() {
1346 let normalized = normalize_shell_args(&json!({
1347 "s": "run-1"
1348 }))
1349 .expect("valid shell args");
1350
1351 assert_eq!(normalized.get("session_id").and_then(Value::as_str), Some("run-1"));
1352 }
1353
1354 #[test]
1355 fn normalize_shell_args_copies_max_output_tokens_to_max_tokens() {
1356 let normalized = normalize_shell_args(&json!({
1357 "command": "echo hi",
1358 "max_output_tokens": 42
1359 }))
1360 .expect("valid shell args");
1361
1362 assert_eq!(normalized.get("max_output_tokens").and_then(Value::as_u64), Some(42));
1363 assert_eq!(normalized.get("max_tokens").and_then(Value::as_u64), Some(42));
1364 }
1365
1366 #[test]
1367 fn normalize_shell_args_copies_max_tokens_to_max_output_tokens() {
1368 let normalized = normalize_shell_args(&json!({
1369 "command": "echo hi",
1370 "max_tokens": 42
1371 }))
1372 .expect("valid shell args");
1373
1374 assert_eq!(normalized.get("max_tokens").and_then(Value::as_u64), Some(42));
1375 assert_eq!(normalized.get("max_output_tokens").and_then(Value::as_u64), Some(42));
1376 }
1377
1378 #[test]
1379 fn command_session_missing_required_args_is_action_aware() {
1380 assert_eq!(command_session_missing_required_args(&json!({"action": "run"})), vec!["command"]);
1381 assert_eq!(
1382 command_session_missing_required_args(&json!({"action": "write", "session_id": "run-1"})),
1383 vec!["input or chars or text"]
1384 );
1385 assert_eq!(
1386 command_session_missing_required_args(&json!({"action": "inspect"})),
1387 vec!["session_id or spool_path"]
1388 );
1389 assert!(command_session_missing_required_args(&json!({"action": "list"})).is_empty());
1390 }
1391
1392 #[test]
1393 fn command_session_requires_command_safety_only_for_run() {
1394 assert!(command_session_requires_command_safety(&json!({
1395 "action": "run",
1396 "command": "cargo check"
1397 })));
1398 assert!(!command_session_requires_command_safety(&json!({
1399 "action": "poll",
1400 "session_id": "run-1"
1401 })));
1402 }
1403
1404 #[test]
1405 fn raw_command_text_extracts_command_string() {
1406 assert_eq!(raw_command_text(&json!({"command": "rg foo"})), Some("rg foo".to_string()));
1407 assert_eq!(raw_command_text(&json!({"cmd": "ls -la"})), Some("ls -la".to_string()));
1408 assert_eq!(
1409 raw_command_text(&json!({"command.0": "cat", "command.1": "file.txt"})),
1410 Some("cat file.txt".to_string())
1411 );
1412 assert_eq!(raw_command_text(&json!({"command": ["wc", "-l"]})), Some("wc -l".to_string()));
1413 assert!(raw_command_text(&json!({})).is_none());
1414 }
1415
1416 #[test]
1417 fn display_extraction_reports_key_and_preserves_precedence() {
1418 assert_eq!(
1419 extract_command_text_with_key(&json!({"command": ["bash", "-lc", "ls -R"]})),
1420 Some(("bash -lc ls -R".to_string(), "command"))
1421 );
1422 assert_eq!(
1423 extract_command_text_with_key(&json!({"bash_command": "pwd"})),
1424 Some(("pwd".to_string(), "bash_command"))
1425 );
1426 assert_eq!(
1427 extract_command_text_with_key(&json!({"raw_command": "cargo test"})),
1428 Some(("cargo test".to_string(), "raw_command"))
1429 );
1430 assert_eq!(extract_command_text_with_key(&json!({"cmd": "ls -la"})), Some(("ls -la".to_string(), "cmd")));
1433 assert_eq!(extract_command_text_with_key(&json!({"command": " "})), None);
1435 assert_eq!(
1437 extract_command_text_with_key(
1438 &json!({"raw_command": "cargo check -p vtcode", "bash_command": "cargo test"})
1439 ),
1440 Some(("cargo check -p vtcode".to_string(), "raw_command"))
1441 );
1442 assert!(extract_command_text_with_key(&json!({})).is_none());
1443 }
1444
1445 #[test]
1446 fn env_prefix_injection_keys_are_detected() {
1447 let words = |command: &str| -> Vec<String> { shell_words::split(command).expect("split command") };
1448
1449 assert!(environment_prefix_has_injection_keys(&words(
1452 "env GIT_CONFIG_COUNT=1 GIT_CONFIG_KEY_0=core.fsmonitor GIT_CONFIG_VALUE_0=touch git status"
1453 )));
1454 assert!(environment_prefix_has_injection_keys(&words(
1455 "GIT_CONFIG_COUNT=1 GIT_CONFIG_KEY_0=core.fsmonitor GIT_CONFIG_VALUE_0=touch git status"
1456 )));
1457 assert!(environment_prefix_has_injection_keys(&words("env GIT_EXTERNAL_DIFF=evil git diff")));
1458 assert!(environment_prefix_has_injection_keys(&words("env GIT_TEXTCONV=evil git log")));
1459 assert!(environment_prefix_has_injection_keys(&words("env LD_PRELOAD=./evil.so ls")));
1460 assert!(environment_prefix_has_injection_keys(&words("env BASH_ENV=./evil.sh bash -lc 'echo hi'")));
1461 assert!(environment_prefix_has_injection_keys(&words("env NODE_OPTIONS=--require ./evil.js node --version")));
1462 assert!(environment_prefix_has_injection_keys(&words("env -S 'GIT_CONFIG_COUNT=1 git status'")));
1464
1465 assert!(!environment_prefix_has_injection_keys(&words("env LANG=C.UTF-8 rg foo")));
1467 assert!(!environment_prefix_has_injection_keys(&words("FOO=bar git status")));
1468 assert!(!environment_prefix_has_injection_keys(&words("env FOO=bar git status")));
1469 assert!(!environment_prefix_has_injection_keys(&words("env -u GIT_CONFIG_COUNT git status")));
1471 assert!(!environment_prefix_has_injection_keys(&words("git status")));
1472 }
1473
1474 #[test]
1475 fn dynamic_shell_syntax_allows_quoted_globs_only() {
1476 assert!(!contains_dynamic_shell_syntax("find src -name '*.rs'"));
1477 assert!(!contains_dynamic_shell_syntax("find src -name \"*.rs\""));
1478 assert!(contains_dynamic_shell_syntax("find src -exe$''c touch {} +"));
1479 assert!(contains_dynamic_shell_syntax("find src -ex{e,}c touch {} +"));
1480 }
1481}