Skip to main content

vtcode_core/tools/
command_args.rs

1//! Shared helpers for command-style tool arguments.
2
3use 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
169/// Ordered argument keys that may carry a shell command string for display.
170///
171/// Canonical display precedence shared by the binary's tool summaries so
172/// headline and detail extraction can never drift. Safety classification must
173/// keep using [`raw_command_text`]/[`command_words`], which re-parse via
174/// `shell_words` and intentionally exclude the legacy display-only
175/// `bash_command` key that core handlers never execute.
176pub const DISPLAY_COMMAND_KEYS: &[&str] = &["command", "raw_command", "bash_command", "cmd"];
177
178/// Display-only extraction of a shell command string plus the key it came from.
179///
180/// Unlike [`raw_command_text`], array commands are joined with plain spaces
181/// (no quoting) because the result is rendered, never executed or re-parsed:
182/// `["bash", "-lc", "ls -R"]` displays as `bash -lc ls -R`. The `command` key
183/// trims before the emptiness check while other keys use raw emptiness,
184/// preserving historical per-key behavior. Indexed `command.N` parts are
185/// handled by [`command_words`]'s primary path, not here.
186///
187/// Returns `None` when no key carries a non-empty command.
188pub 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        // The `command` key trims before the emptiness check; the others do not,
205        // matching historical per-key behavior.
206        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
302/// Extract the raw command string from command_session-style arguments without
303/// splitting it into words. This is useful for checking shell syntax that
304/// would be lost after `shell_words::split`, such as redirections and pipes.
305pub 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
327/// Complete static-classification candidate, including the quoted argv suffix
328/// that execution appends to shell text. Conflicting raw overrides cannot prove
329/// one invocation, so callers must fail closed instead of treating it as a read
330/// or verifier. Dynamic syntax and command options still need normal validation.
331pub(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
351/// Returns whether a shell command contains syntax whose meaning depends on
352/// shell expansion rather than the literal argument text.
353///
354/// Read-only classification and approval-family learning must only operate on
355/// static command shapes. Parameter expansion, command substitution, brace
356/// expansion, globbing, and unquoted backslash escapes can otherwise turn a
357/// harmless-looking token into a different executable argument at runtime.
358pub fn contains_dynamic_shell_syntax(command: &str) -> bool {
359    crate::command_safety::shell_parser::contains_dynamic_shell_syntax(command)
360}
361
362/// Skip an optional `env` prefix and leading shell environment assignments.
363///
364/// The returned slice starts at the executable that will actually run. Keep
365/// this helper shared by intent and activity classification so both paths
366/// inspect the same command shape.
367pub 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
412/// Exact environment keys that can inject config, load foreign code, or launch
413/// helpers into an otherwise read-only program.
414const 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
446/// Prefix families that always carry injection payload (`GIT_CONFIG_KEY_n` /
447/// `GIT_CONFIG_VALUE_n` pair with `GIT_CONFIG_COUNT`).
448const 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
465/// Returns whether an `env` / leading-assignment prefix sets a known
466/// config-injection or loader-injection key.
467///
468/// `command_words_after_environment_prefix` strips these assignments so the
469/// executable can be classified; the values themselves must still be checked.
470/// Unset (`env -u KEY`) is not an assignment. Unknown keys stay allowed so
471/// ordinary exploration (`LANG=C rg …`) keeps working.
472pub 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                    // Split-string re-parses the rest at runtime; cannot audit
490                    // assignments. Treat as unsafe.
491                    return true;
492                }
493                if consumes_next {
494                    // `-u/--unset` removes a key; `-C/--chdir` takes a path.
495                    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
526/// Return whether a parsed command uses an option or environment assignment
527/// that can turn an otherwise read-only inspection program into a writer or
528/// command launcher.
529///
530/// This is deliberately option/env focused; the read-only executable/subcommand
531/// allow-list remains in `tool_intent::readonly`. Keeping the unsafe option
532/// rules here lets raw argument validation and activity classification share
533/// the same token-aware guard without searching quoted text.
534pub(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            // `-C <dir>` (bare or inline `-C<dir>`) only redirects which
551            // repository is read; the subcommand allow-list still gates every
552            // git call, so it cannot turn an inspection into a writer. `-c
553            // key=value` stays prompt-requiring: config injection is a
554            // command-execution vector (fsmonitor/pager hooks).
555            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` stays on the read-only allow-list, but its program text can
601        // write (`print > file`), pipe into commands (`print | "cmd"`),
602        // execute them (`system()`), call them indirectly (`@func()`), or
603        // load code (`@include`, `@load`), and its options can edit in place
604        // (`-i inplace`), load external code (`-l`), or write profiles
605        // (`-p`). `has_unsafe_awk_options` fails closed on all of those; only
606        // data-only options (`-v`, `-F`) and a write-free program pass.
607        "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    // A missing argument makes the command invalid. Treat it as unsafe here
672    // so malformed input never receives a read-only classification.
673    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
784/// Return whether an `awk` invocation uses an option or program shape that can
785/// write workspace state or execute commands.
786///
787/// Only data-only options pass: `-v`/`--assign` (variable binding) and
788/// `-F`/`--field-separator`, each either attached or consuming the next word.
789/// Every other option fails closed — `-i`/`--include` edits in place,
790/// `-f`/`--file`/`--source` load program text this scanner cannot see,
791/// `-l`/`--load` loads native extensions, `-p`/`--profile`,
792/// `-W`/`--dump-variables`, and `--pretty-print` write files, and the
793/// remaining option surface is too wide to allow-list safely. `--` ends option
794/// parsing. The first bare word is the program and must
795/// be write-free per [`awk_program_may_write`]; later words are filenames. A
796/// missing program is malformed, so it fails closed like the `sed` guard.
797fn 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                // Value-taking option with a separate argument.
812                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                // Attached value (`-F:`, `-vlimit=10`).
821                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
838/// Return whether an `awk` program can write files, pipe into commands,
839/// execute them, or load external code. Single `>` (output redirection /
840/// append `>>`) and bare `|` (command pipe) are writes; `>=` and `||` are
841/// comparisons/operators. `system()` runs shell commands; `@` invokes gawk
842/// indirect calls (`@func()`) and directives (`@include`, `@load`), which can
843/// execute or load arbitrary code — including a `system` name smuggled via `-v`.
844///
845/// A single `>` is a comparison (not redirection) when it cannot be `print` /
846/// `printf` redirection: inside `(...)` / `[...]` parentheses (e.g.
847/// `if(p2==0 && i>1)`), or in a statement with no prior `print` / `printf`
848/// (e.g. pattern `$3>100`). `print > file`, `print x > file`, and `print >>`
849/// stay mutating. `|` stays conservative: only `||` and quoted `"|"` pass.
850///
851/// Double-quoted string literals are scanned as data: a quoted `"|"` passed to
852/// `index()` is a literal, not a command pipe, so the read-only shape
853/// `awk '... index(rest,"|") ...' file` is not misclassified. To keep this
854/// safe the scanner distinguishes string literals, regex literals (`/.../`),
855/// and division using awk's operand-vs-operator rule, so a quote *inside* a
856/// regex (`/"/`, `/a"b/`) can never desync string tracking and hide a pipe.
857/// `@` and `system()` fail closed everywhere — including inside string and
858/// regex literals — and `>`/`|`/`@` inside regex literals stay mutating, so the
859/// pinned `/a|b/` and `"a@b"` shapes remain conservative.
860fn awk_program_may_write(program: &str) -> bool {
861    let chars = program.chars().collect::<Vec<_>>();
862    let mut index = 0;
863    // Whether the previous significant token can end an operand. This decides
864    // whether `/` opens a regex literal or is a division operator.
865    let mut prev_operand = false;
866    // `print` / `printf` seen in the current statement (reset on `;`/`{`/`}`).
867    // A single `>` can only be output redirection inside such a statement;
868    // without it (pattern `$3>100`) or inside parens (e.g. `if(i>1)`) it is a
869    // numeric comparison. `>>` is always redirection.
870    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            // Comments are not AWK syntax. In particular, a commented `(`
881            // must not make a later print redirection look parenthesized.
882            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                // `@`/`system()` fail closed even inside a literal; `>`/`|`
902                // are literal data here and are skipped.
903                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            // Regex literal. Quotes are regex content, not string delimiters,
919            // so they cannot desync string parsing; `>`/`|`/`@` inside still
920            // fail closed, matching the pinned `/a|b/` policy.
921            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            // `>>` is always append-redirection.
972            if chars.get(index + 1) == Some(&'>') {
973                return true;
974            }
975            // Single `>` is redirection only inside a `print`/`printf`
976            // statement at paren/bracket depth 0. Otherwise it is a numeric
977            // comparison (`if(p2==0 && i>1)`, pattern `$3>100`).
978            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            // Division: an operand precedes it.
995            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            // New statement: a later `>` needs its own `print`/`printf`.
1042            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
1053/// Awk keywords that do not end an operand, so a following `/` opens a regex
1054/// literal (`print /re/`) rather than being a division operator. Kept minimal
1055/// and conservative: an unlisted keyword is treated as an operand, which at
1056/// worst misreads a regex as division — still fail-closed, because regex
1057/// contents are then scanned as top-level code and any `>`/`|`/`@` is caught.
1058fn 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
1080/// Return whether `chars[start..]` invokes awk's `system()` builtin: the
1081/// identifier `system` (case-insensitive) on an identifier boundary followed
1082/// by optional whitespace and `(`.
1083fn 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        // Missing `command` must not block later keys (the copilot
1431        // early-return bug this canonical helper fixes).
1432        assert_eq!(extract_command_text_with_key(&json!({"cmd": "ls -la"})), Some(("ls -la".to_string(), "cmd")));
1433        // `command` trims; other keys keep raw emptiness semantics.
1434        assert_eq!(extract_command_text_with_key(&json!({"command": "  "})), None);
1435        // First non-empty key in canonical order wins.
1436        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        // The A4 vector: config injection via env values reopens the blocked
1450        // `git -c` path without any `-c` token.
1451        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        // `env -S` re-parses at runtime; cannot audit.
1463        assert!(environment_prefix_has_injection_keys(&words("env -S 'GIT_CONFIG_COUNT=1 git status'")));
1464
1465        // Benign env stays allowed.
1466        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        // Unset is not an assignment.
1470        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}