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        // Zero-alloc ASCII case-insensitive match: identical verdicts to the
41        // old `to_ascii_lowercase()` + `match` (both fold ASCII only), but
42        // without heap-allocating the lowered action on this hot path.
43        if action.eq_ignore_ascii_case("wait") {
44            return Ok(WriteStdinDispatch::Wait);
45        }
46        if action.eq_ignore_ascii_case("inspect") {
47            return Ok(WriteStdinDispatch::Inspect);
48        }
49        if action.eq_ignore_ascii_case("terminate") {
50            return Ok(WriteStdinDispatch::Terminate);
51        }
52        if action.eq_ignore_ascii_case("close") {
53            return Ok(WriteStdinDispatch::Close);
54        }
55        if action.eq_ignore_ascii_case("poll") {
56            if payload.get("chars").is_some_and(|chars| chars.as_str() != Some("")) {
57                return Err("write_stdin poll cannot send chars");
58            }
59            return Ok(WriteStdinDispatch::Poll);
60        }
61        if !action.eq_ignore_ascii_case("write") {
62            return Err("write_stdin action must be write, poll, wait, inspect, terminate, or close");
63        }
64    }
65    let chars = payload
66        .get("chars")
67        .and_then(Value::as_str)
68        .ok_or("write_stdin requires string chars")?;
69
70    if chars.is_empty() {
71        Ok(WriteStdinDispatch::Poll)
72    } else {
73        Ok(WriteStdinDispatch::Write)
74    }
75}
76
77fn collect_indexed_command_parts(
78    payload: &serde_json::Map<String, Value>,
79    start_index: usize,
80) -> Result<Vec<String>, &'static str> {
81    let mut parts = Vec::new();
82    let mut index = start_index;
83    while let Some(value) = payload.get(&format!("command.{index}")) {
84        let Some(part) = value.as_str() else {
85            return Err(INDEXED_COMMAND_TYPE_ERROR);
86        };
87        parts.push(part.to_string());
88        index += 1;
89    }
90    Ok(parts)
91}
92
93pub fn has_indexed_command_parts(args: &Value) -> bool {
94    let Some(payload) = args.as_object() else {
95        return false;
96    };
97
98    payload.contains_key("command.0") || payload.contains_key("command.1")
99}
100
101pub fn parse_indexed_command_parts(
102    payload: &serde_json::Map<String, Value>,
103) -> Result<Option<Vec<String>>, &'static str> {
104    let zero_based = collect_indexed_command_parts(payload, 0)?;
105    if !zero_based.is_empty() {
106        return Ok(Some(zero_based));
107    }
108
109    let one_based = collect_indexed_command_parts(payload, 1)?;
110    if one_based.is_empty() {
111        Ok(None)
112    } else {
113        Ok(Some(one_based))
114    }
115}
116
117pub fn normalize_indexed_command_args(args: &Value) -> Result<Option<Value>, &'static str> {
118    let Some(payload) = args.as_object() else {
119        return Ok(None);
120    };
121    if payload.get("command").is_some() {
122        return Ok(None);
123    }
124
125    let Some(parts) = parse_indexed_command_parts(payload)? else {
126        return Ok(None);
127    };
128
129    let mut normalized = payload.clone();
130    normalized.insert("command".to_string(), Value::String(shell_words::join(parts.iter().map(String::as_str))));
131    Ok(Some(Value::Object(normalized)))
132}
133
134pub fn normalized_command_value(args: &Value) -> Result<Option<Value>, &'static str> {
135    if let Some(command) = args
136        .get("command")
137        .or_else(|| args.get("cmd"))
138        .or_else(|| args.get("raw_command"))
139    {
140        return Ok(Some(command.clone()));
141    }
142
143    Ok(normalize_indexed_command_args(args)?.and_then(|normalized| normalized.get("command").cloned()))
144}
145
146pub fn command_words(args: &Value) -> Result<Option<Vec<String>>, &'static str> {
147    let Some(command) = normalized_command_value(args)? else {
148        return Ok(None);
149    };
150
151    let mut parts = match command {
152        Value::String(command) => shell_words::split(&command).map_err(|_e| COMMAND_VALUE_TYPE_ERROR)?,
153        Value::Array(values) => values
154            .iter()
155            .map(|value| value.as_str().map(ToOwned::to_owned).ok_or(COMMAND_VALUE_TYPE_ERROR))
156            .collect::<Result<Vec<_>, _>>()?,
157        _ => return Err(COMMAND_VALUE_TYPE_ERROR),
158    };
159
160    if let Some(extra_args) = args.get("args").and_then(Value::as_array) {
161        for value in extra_args {
162            let Some(part) = value.as_str() else {
163                return Err(COMMAND_VALUE_TYPE_ERROR);
164            };
165            parts.push(part.to_string());
166        }
167    }
168
169    if parts.is_empty() { Ok(None) } else { Ok(Some(parts)) }
170}
171
172pub fn command_text(args: &Value) -> Result<Option<String>, &'static str> {
173    let Some(parts) = command_words(args)? else {
174        return Ok(None);
175    };
176    Ok(Some(shell_words::join(parts.iter().map(String::as_str))))
177}
178
179/// Ordered argument keys that may carry a shell command string for display.
180///
181/// Canonical display precedence shared by the binary's tool summaries so
182/// headline and detail extraction can never drift. Safety classification must
183/// keep using [`raw_command_text`]/[`command_words`], which re-parse via
184/// `shell_words` and intentionally exclude the legacy display-only
185/// `bash_command` key that core handlers never execute.
186pub const DISPLAY_COMMAND_KEYS: &[&str] = &["command", "raw_command", "bash_command", "cmd"];
187
188/// Display-only extraction of a shell command string plus the key it came from.
189///
190/// Unlike [`raw_command_text`], array commands are joined with plain spaces
191/// (no quoting) because the result is rendered, never executed or re-parsed:
192/// `["bash", "-lc", "ls -R"]` displays as `bash -lc ls -R`. The `command` key
193/// trims before the emptiness check while other keys use raw emptiness,
194/// preserving historical per-key behavior. Indexed `command.N` parts are
195/// handled by [`command_words`]'s primary path, not here.
196///
197/// Returns `None` when no key carries a non-empty command.
198pub fn extract_command_text_with_key(args: &Value) -> Option<(String, &'static str)> {
199    if let Some(array) = args.get("command").and_then(Value::as_array) {
200        let joined: String = array
201            .iter()
202            .filter_map(|value| value.as_str())
203            .filter(|segment| !segment.is_empty())
204            .collect::<Vec<_>>()
205            .join(" ");
206        if !joined.is_empty() {
207            return Some((joined, "command"));
208        }
209    }
210    for &key in DISPLAY_COMMAND_KEYS {
211        let Some(value) = args.get(key).and_then(Value::as_str) else {
212            continue;
213        };
214        // The `command` key trims before the emptiness check; the others do not,
215        // matching historical per-key behavior.
216        let (text, ok) = if key == "command" {
217            let trimmed = value.trim();
218            (trimmed.to_string(), !trimmed.is_empty())
219        } else {
220            (value.to_string(), !value.is_empty())
221        };
222        if ok {
223            return Some((text, key));
224        }
225    }
226    None
227}
228
229fn has_nonempty_string_field(args: &Value, key: &str) -> bool {
230    args.get(key)
231        .and_then(Value::as_str)
232        .map(str::trim)
233        .is_some_and(|value| !value.is_empty())
234}
235
236pub fn interactive_input_text(args: &Value) -> Option<&str> {
237    args.get("input")
238        .and_then(Value::as_str)
239        .or_else(|| args.get("chars").and_then(Value::as_str))
240        .or_else(|| args.get("text").and_then(Value::as_str))
241        .filter(|value| !value.is_empty())
242}
243
244pub fn session_id_text_from_payload(payload: &serde_json::Map<String, Value>) -> Option<&str> {
245    payload
246        .get("session_id")
247        .or_else(|| payload.get("s"))
248        .and_then(Value::as_str)
249        .map(str::trim)
250        .filter(|value| !value.is_empty())
251}
252
253pub fn session_id_text(args: &Value) -> Option<&str> {
254    args.as_object().and_then(session_id_text_from_payload)
255}
256
257pub fn command_session_missing_required_args(args: &Value) -> Vec<&'static str> {
258    if command_session_action(args).is_none() {
259        return Vec::new();
260    }
261
262    let mut missing = Vec::new();
263    if command_session_action_is(args, "run") {
264        if command_text(args).ok().flatten().is_none() {
265            missing.push("command");
266        }
267    } else if command_session_action_is(args, "write") {
268        if session_id_text(args).is_none() {
269            missing.push("session_id");
270        }
271        if interactive_input_text(args).is_none() {
272            missing.push("input or chars or text");
273        }
274    } else if command_session_action_in(args, &["poll", "wait", "continue", "close"]) {
275        if session_id_text(args).is_none() {
276            missing.push("session_id");
277        }
278    } else if command_session_action_is(args, "inspect") {
279        let has_session_id = session_id_text(args).is_some();
280        let has_spool_path = has_nonempty_string_field(args, "spool_path");
281        if !has_session_id && !has_spool_path {
282            missing.push("session_id or spool_path");
283        }
284    } else if command_session_action_is(args, "code") {
285        let has_code = has_nonempty_string_field(args, "code") || has_nonempty_string_field(args, "command");
286        if !has_code {
287            missing.push("code or command");
288        }
289    }
290
291    missing
292}
293
294pub fn command_session_requires_command_safety(args: &Value) -> bool {
295    command_session_action_is(args, "run")
296}
297
298pub fn working_dir_text_from_payload(payload: &serde_json::Map<String, Value>) -> Option<&str> {
299    payload
300        .get("working_dir")
301        .or_else(|| payload.get("cwd"))
302        .or_else(|| payload.get("workdir"))
303        .and_then(Value::as_str)
304        .map(str::trim)
305        .filter(|value| !value.is_empty())
306}
307
308pub fn working_dir_text(args: &Value) -> Option<&str> {
309    args.as_object().and_then(working_dir_text_from_payload)
310}
311
312/// Extract the raw command string from command_session-style arguments without
313/// splitting it into words. This is useful for checking shell syntax that
314/// would be lost after `shell_words::split`, such as redirections and pipes.
315pub fn raw_command_text(args: &Value) -> Option<String> {
316    let payload = args.as_object()?;
317
318    if let Some(command) = payload
319        .get("command")
320        .or_else(|| payload.get("cmd"))
321        .or_else(|| payload.get("raw_command"))
322    {
323        match command {
324            Value::String(text) => return Some(text.clone()),
325            Value::Array(values) => {
326                let parts: Option<Vec<&str>> = values.iter().map(|v| v.as_str()).collect();
327                return parts.map(shell_words::join);
328            }
329            _ => return None,
330        }
331    }
332
333    let indexed = parse_indexed_command_parts(payload).ok().flatten()?;
334    Some(shell_words::join(indexed.iter().map(String::as_str)))
335}
336
337/// Complete static-classification candidate, including the quoted argv suffix
338/// that execution appends to shell text. Conflicting raw overrides cannot prove
339/// one invocation, so callers must fail closed instead of treating it as a read
340/// or verifier. Dynamic syntax and command options still need normal validation.
341pub(crate) fn shell_command_text_with_args(args: &Value) -> Option<String> {
342    let mut command = raw_command_text(args)?;
343    if args
344        .get("raw_command")
345        .and_then(Value::as_str)
346        .is_some_and(|raw| raw != command)
347    {
348        return None;
349    }
350    if let Some(arguments) = args.get("args") {
351        let arguments = arguments.as_array()?;
352        let suffix_words = arguments.iter().map(Value::as_str).collect::<Option<Vec<_>>>()?;
353        if !suffix_words.is_empty() {
354            command.push(' ');
355            command.push_str(&shell_words::join(suffix_words));
356        }
357    }
358    Some(command)
359}
360
361/// Returns whether a shell command contains syntax whose meaning depends on
362/// shell expansion rather than the literal argument text.
363///
364/// Read-only classification and approval-family learning must only operate on
365/// static command shapes. Parameter expansion, command substitution, brace
366/// expansion, globbing, and unquoted backslash escapes can otherwise turn a
367/// harmless-looking token into a different executable argument at runtime.
368pub fn contains_dynamic_shell_syntax(command: &str) -> bool {
369    crate::command_safety::shell_parser::contains_dynamic_shell_syntax(command)
370}
371
372/// Single-walk result for an `env` / leading-assignment prefix.
373///
374/// `stripped_start` is the index of the executable that will actually run;
375/// `has_injection` reports whether the skipped prefix sets a known
376/// config/loader-injection key or uses `env -S` (which re-parses at runtime
377/// and cannot be audited). One walk produces both so safety checks never
378/// re-scan the same prefix twice and the `env`-flag table cannot drift
379/// between the stripping and injection paths.
380#[derive(Debug, Clone, Copy, PartialEq, Eq)]
381struct EnvPrefixScan {
382    stripped_start: usize,
383    has_injection: bool,
384}
385
386fn is_env_split_string_flag(word: &str) -> bool {
387    word == "-S" || word == "--split-string" || word.starts_with("-S") || word.starts_with("--split-string=")
388}
389
390fn env_flag_consumes_next(word: &str) -> bool {
391    matches!(word, "-u" | "--unset" | "-C" | "--chdir")
392}
393
394/// Walk an optional `env` prefix plus leading `KEY=value` assignments once.
395///
396/// Shared by [`command_words_after_environment_prefix`] (stripping) and
397/// [`environment_prefix_has_injection_keys`] (injection audit) so both paths
398/// inspect the same command shape. Fail-closed notes:
399///
400/// - `env -S`/`--split-string` re-parses the rest at runtime: reported as
401///   injection with an empty remainder, matching both historical paths.
402/// - Words with an empty key (`=foo`) are NOT assignments: scanning stops and
403///   they are kept as the program (unknown, therefore mutating). The old
404///   stripping path skipped them via a broad `contains('=')` test; stopping is
405///   strictly fail-closed and the injection path is unchanged.
406fn scan_environment_prefix(words: &[String]) -> EnvPrefixScan {
407    let mut start = 0;
408    let mut has_injection = false;
409    loop {
410        if words.get(start).is_some_and(|word| word == "env") {
411            start += 1;
412            while let Some(word) = words.get(start) {
413                if word == "--" {
414                    start += 1;
415                    break;
416                }
417                if is_env_split_string_flag(word) {
418                    // Split-string re-parses the rest at runtime; cannot audit
419                    // assignments. Treat as unsafe with no auditable remainder.
420                    return EnvPrefixScan { stripped_start: words.len(), has_injection: true };
421                }
422                let consumes_next = env_flag_consumes_next(word);
423                start += 1;
424                if consumes_next {
425                    // `-u/--unset` removes a key; `-C/--chdir` takes a path.
426                    if words.get(start).is_some() {
427                        start += 1;
428                    }
429                    continue;
430                }
431                if let Some(key) = env_assignment_key(word) {
432                    if is_env_injection_key(key) {
433                        has_injection = true;
434                    }
435                    continue;
436                }
437                if word.starts_with('-') {
438                    continue;
439                }
440                start -= 1;
441                break;
442            }
443            continue;
444        }
445        if let Some(key) = words.get(start).and_then(|word| env_assignment_key(word)) {
446            if is_env_injection_key(key) {
447                has_injection = true;
448            }
449            start += 1;
450            continue;
451        }
452        break;
453    }
454    EnvPrefixScan { stripped_start: start, has_injection }
455}
456
457/// Skip an optional `env` prefix and leading shell environment assignments.
458///
459/// The returned slice starts at the executable that will actually run. Keep
460/// this helper shared by intent and activity classification so both paths
461/// inspect the same command shape.
462pub fn command_words_after_environment_prefix(words: &[String]) -> &[String] {
463    let scan = scan_environment_prefix(words);
464    &words[scan.stripped_start..]
465}
466
467/// Exact environment keys that can inject config, load foreign code, or launch
468/// helpers into an otherwise read-only program.
469const ENV_INJECTION_KEYS: &[&str] = &[
470    "GIT_CONFIG_COUNT",
471    "GIT_CONFIG_GLOBAL",
472    "GIT_CONFIG_SYSTEM",
473    "GIT_CONFIG_NOSYSTEM",
474    "GIT_EXTERNAL_DIFF",
475    "GIT_TEXTCONV",
476    "GIT_DIFF_OPTS",
477    "GIT_EDITOR",
478    "GIT_SEQUENCE_EDITOR",
479    "GIT_SSH",
480    "GIT_SSH_COMMAND",
481    "GIT_EXEC_PATH",
482    "LD_PRELOAD",
483    "LD_AUDIT",
484    "LD_LIBRARY_PATH",
485    "DYLD_INSERT_LIBRARIES",
486    "DYLD_LIBRARY_PATH",
487    "DYLD_FRAMEWORK_PATH",
488    "DYLD_FALLBACK_LIBRARY_PATH",
489    "BASH_ENV",
490    "ENV",
491    "SHELLOPTS",
492    "PERL5OPT",
493    "PYTHONSTARTUP",
494    "NODE_OPTIONS",
495    "RUBYOPT",
496    "EDITOR",
497    "VISUAL",
498    "PAGER",
499];
500
501/// Prefix families that always carry injection payload (`GIT_CONFIG_KEY_n` /
502/// `GIT_CONFIG_VALUE_n` pair with `GIT_CONFIG_COUNT`).
503const ENV_INJECTION_KEY_PREFIXES: &[&str] = &["GIT_CONFIG_KEY_", "GIT_CONFIG_VALUE_"];
504
505fn env_assignment_key(word: &str) -> Option<&str> {
506    if word.starts_with('-') {
507        return None;
508    }
509    let (key, _) = word.split_once('=')?;
510    if key.is_empty() {
511        return None;
512    }
513    Some(key)
514}
515
516fn is_env_injection_key(key: &str) -> bool {
517    ENV_INJECTION_KEYS.contains(&key) || ENV_INJECTION_KEY_PREFIXES.iter().any(|prefix| key.starts_with(prefix))
518}
519
520/// Returns whether an `env` / leading-assignment prefix sets a known
521/// config-injection or loader-injection key.
522///
523/// `command_words_after_environment_prefix` strips these assignments so the
524/// executable can be classified; the values themselves must still be checked.
525/// Unset (`env -u KEY`) is not an assignment. Unknown keys stay allowed so
526/// ordinary exploration (`LANG=C rg …`) keeps working.
527pub fn environment_prefix_has_injection_keys(words: &[String]) -> bool {
528    scan_environment_prefix(words).has_injection
529}
530
531/// Resolve the executable behind the supported flag-only `uv run` prefix.
532/// Unknown/value-taking wrapper options remain unclassified.
533pub(crate) fn uv_run_command_words(words: &[String]) -> Option<&[String]> {
534    if words.get(1).map(String::as_str) != Some("run") {
535        return None;
536    }
537    let index = words
538        .iter()
539        .enumerate()
540        .skip(2)
541        .find(|(_, word)| {
542            !matches!(
543                word.as_str(),
544                "--frozen" | "--locked" | "--no-sync" | "--offline" | "--no-project" | "--quiet" | "-q"
545            )
546        })?
547        .0;
548    (!words.get(index)?.starts_with('-')).then(|| &words[index..])
549}
550
551/// Explicit source repair modes cannot serve as completion verifiers, even
552/// when declared by a tracker. Command admission remains policy-owned.
553pub(crate) fn command_uses_source_repair(words: &[String]) -> bool {
554    let words = command_words_after_environment_prefix(words);
555    let Some(program) = words
556        .first()
557        .and_then(|word| Path::new(word).file_name())
558        .and_then(|name| name.to_str())
559    else {
560        return false;
561    };
562    let program = program.to_ascii_lowercase();
563    match program.as_str() {
564        "cargo" => {
565            let mut arguments = words.iter().skip(1).take_while(|word| word.as_str() != "--");
566            arguments.clone().any(|word| word.split('=').next() == Some("--fix"))
567                || arguments
568                    .find(|word| !word.starts_with(['-', '+']))
569                    .is_some_and(|word| word == "fix")
570        }
571        "ruff" => {
572            words.iter().skip(1).take_while(|word| word.as_str() != "--").any(|word| {
573                matches!(word.split('=').next(), Some("--fix" | "--fix-only" | "--add-noqa" | "--add-ignore"))
574            })
575        }
576        "uv" => uv_run_command_words(words).is_some_and(command_uses_source_repair),
577        _ => false,
578    }
579}
580
581/// Return whether a parsed command uses an option or environment assignment
582/// that can turn an otherwise read-only inspection program into a writer or
583/// command launcher.
584///
585/// This is deliberately option/env focused; the read-only executable/subcommand
586/// allow-list remains in `tool_intent::readonly`. Keeping the unsafe option
587/// rules here lets raw argument validation and activity classification share
588/// the same token-aware guard without searching quoted text.
589pub(crate) fn has_unsafe_readonly_options(words: &[String]) -> bool {
590    if command_uses_source_repair(words) {
591        return true;
592    }
593    // Single prefix walk: the scan yields both the injection verdict and the
594    // stripped executable offset, replacing the historical two-walk sequence
595    // (`environment_prefix_has_injection_keys` then
596    // `command_words_after_environment_prefix`).
597    let scan = scan_environment_prefix(words);
598    if scan.has_injection {
599        return true;
600    }
601    let command_words = &words[scan.stripped_start..];
602    let Some(program) = command_words
603        .first()
604        .and_then(|word| Path::new(word).file_name())
605        .and_then(|name| name.to_str())
606    else {
607        return false;
608    };
609    let program = program.to_ascii_lowercase();
610
611    match program.as_str() {
612        "rustc" => command_words.iter().enumerate().skip(1).any(|(index, word)| {
613            word.starts_with("-o")
614                || word.split('=').next() == Some("--out-dir")
615                || ["--print", "--emit"].iter().any(|option| {
616                    let value = if word == option {
617                        command_words.get(index + 1).map(String::as_str)
618                    } else {
619                        word.strip_prefix(option).and_then(|suffix| suffix.strip_prefix('='))
620                    };
621                    value.is_some_and(|value| value.contains('='))
622                })
623        }),
624        "pytest" | "python" | "python3" | "uv" => command_words.iter().skip(1).any(|word| {
625            matches!(
626                word.split('=').next(),
627                Some("--junitxml" | "--junit-xml" | "--log-file" | "--basetemp" | "--output-file")
628            )
629        }),
630        "ruff" => command_words
631            .iter()
632            .skip(1)
633            .any(|word| word.split('=').next() == Some("--output-file")),
634        "git" => command_words.iter().skip(1).any(|word| {
635            // `-C <dir>` (bare or inline `-C<dir>`) only redirects which
636            // repository is read; the subcommand allow-list still gates every
637            // git call, so it cannot turn an inspection into a writer. `-c
638            // key=value` stays prompt-requiring: config injection is a
639            // command-execution vector (fsmonitor/pager hooks).
640            let is_dir_redirect = word == "-C" || (word.starts_with("-C") && word.len() > 2);
641            (!is_dir_redirect && crate::command_safety::git_global_option_requires_prompt(word))
642                || word == "--ext-diff"
643                || word == "--textconv"
644                || word == "-o"
645                || word.starts_with("-o")
646                || word == "--output"
647                || word.starts_with("--output=")
648        }),
649        "sed" => has_unsafe_sed_options(&command_words[1..]),
650        "find" => command_words.iter().skip(1).any(|word| {
651            matches!(
652                word.as_str(),
653                "-delete" | "-exec" | "-execdir" | "-ok" | "-okdir" | "-fls" | "-fprint" | "-fprint0" | "-fprintf"
654            )
655        }),
656        "sort" => command_words.iter().skip(1).any(|word| {
657            word == "-o"
658                || word.starts_with("-o")
659                || word == "--output"
660                || word.starts_with("--output=")
661                || word == "--compress-program"
662                || word.starts_with("--compress-program=")
663        }),
664        "date" => command_words
665            .iter()
666            .skip(1)
667            .any(|word| word == "-s" || word.starts_with("-s") || word == "--set" || word.starts_with("--set=")),
668        "rg" => command_words.iter().skip(1).any(|word| {
669            matches!(word.as_str(), "--pre" | "--hostname-bin" | "--search-zip" | "-z")
670                || word.starts_with("--pre=")
671                || word.starts_with("--hostname-bin=")
672        }),
673        "ast-grep" | "sg" => command_words
674            .iter()
675            .skip(1)
676            .any(|word| word == "-r" || word == "--rewrite" || word.starts_with("--rewrite=")),
677        "fd" => command_words
678            .iter()
679            .skip(1)
680            .any(|word| word == "-x" || word == "--exec" || word == "--exec-batch" || word.starts_with("--exec=")),
681        "tree" => command_words
682            .iter()
683            .skip(1)
684            .any(|word| word == "-o" || word.starts_with("-o") || word == "--output" || word.starts_with("--output=")),
685        // `awk` stays on the read-only allow-list, but its program text can
686        // write (`print > file`), pipe into commands (`print | "cmd"`),
687        // execute them (`system()`), call them indirectly (`@func()`), or
688        // load code (`@include`, `@load`), and its options can edit in place
689        // (`-i inplace`), load external code (`-l`), or write profiles
690        // (`-p`). `has_unsafe_awk_options` fails closed on all of those; only
691        // data-only options (`-v`, `-F`) and a write-free program pass.
692        "awk" => has_unsafe_awk_options(&command_words[1..]),
693        _ => false,
694    }
695}
696
697fn has_unsafe_sed_options(arguments: &[String]) -> bool {
698    let mut expects_expression = false;
699    let mut expression_seen = false;
700
701    for word in arguments {
702        if expects_expression {
703            if sed_script_may_write(word) {
704                return true;
705            }
706            expects_expression = false;
707            expression_seen = true;
708            continue;
709        }
710
711        if word == "-e" || word == "--expression" {
712            expects_expression = true;
713            continue;
714        }
715        if let Some(expression) = word.strip_prefix("--expression=") {
716            if sed_script_may_write(expression) {
717                return true;
718            }
719            expression_seen = true;
720            continue;
721        }
722        if let Some(expression) = word.strip_prefix("-e")
723            && !expression.is_empty()
724        {
725            if sed_script_may_write(expression) {
726                return true;
727            }
728            expression_seen = true;
729            continue;
730        }
731
732        if word == "-f"
733            || word.starts_with("-f")
734            || word.starts_with("--file")
735            || word == "-i"
736            || word.starts_with("-i")
737            || word == "-I"
738            || word.starts_with("-I")
739            || word.starts_with("--in-place")
740        {
741            return true;
742        }
743
744        if word == "--" {
745            expects_expression = true;
746            continue;
747        }
748        if !word.starts_with('-') && !expression_seen {
749            if sed_script_may_write(word) {
750                return true;
751            }
752            expression_seen = true;
753        }
754    }
755
756    // A missing argument makes the command invalid. Treat it as unsafe here
757    // so malformed input never receives a read-only classification.
758    expects_expression || !expression_seen
759}
760
761fn sed_script_may_write(script: &str) -> bool {
762    let chars = script.to_ascii_lowercase().chars().collect::<Vec<_>>();
763    let mut index = 0;
764
765    while index < chars.len() {
766        while chars
767            .get(index)
768            .is_some_and(|character| character.is_whitespace() || *character == ';')
769        {
770            index += 1;
771        }
772
773        if let Some(after_address) = consume_sed_address(&chars, index) {
774            index = after_address;
775            if chars.get(index) == Some(&',') {
776                index += 1;
777                let Some(after_second_address) = consume_sed_address(&chars, index) else {
778                    return true;
779                };
780                index = after_second_address;
781            }
782            if chars.get(index) == Some(&'!') {
783                index += 1;
784            }
785            while chars.get(index).is_some_and(|character| character.is_whitespace()) {
786                index += 1;
787            }
788        }
789
790        let Some(&command) = chars.get(index) else {
791            break;
792        };
793        match command {
794            'e' | 'r' | 'w' => return true,
795            's' => {
796                let Some(after_replacement) = consume_sed_substitution(&chars, index) else {
797                    return true;
798                };
799                if sed_substitution_may_write(&chars, after_replacement) {
800                    return true;
801                }
802                index = after_replacement;
803                while chars.get(index).is_some_and(|character| !matches!(*character, ';' | '\n')) {
804                    index += 1;
805                }
806            }
807            _ => {
808                while chars.get(index).is_some_and(|character| !matches!(*character, ';' | '\n')) {
809                    index += 1;
810                }
811            }
812        }
813    }
814
815    false
816}
817
818fn consume_sed_address(chars: &[char], start: usize) -> Option<usize> {
819    match chars.get(start)? {
820        character if character.is_ascii_digit() => {
821            let mut index = start + 1;
822            while chars.get(index).is_some_and(|character| character.is_ascii_digit()) {
823                index += 1;
824            }
825            Some(index)
826        }
827        '$' => Some(start + 1),
828        '/' => find_unescaped_sed_delimiter(chars, start + 1, '/').map(|index| index + 1),
829        _ => None,
830    }
831}
832
833fn find_unescaped_sed_delimiter(chars: &[char], start: usize, delimiter: char) -> Option<usize> {
834    let mut index = start;
835    while index < chars.len() {
836        if chars[index] == '\\' {
837            index = index.saturating_add(2);
838            continue;
839        }
840        if chars[index] == delimiter {
841            return Some(index);
842        }
843        index += 1;
844    }
845    None
846}
847
848fn consume_sed_substitution(chars: &[char], start: usize) -> Option<usize> {
849    let delimiter = *chars.get(start + 1)?;
850    if delimiter.is_whitespace() {
851        return None;
852    }
853    let pattern_end = find_unescaped_sed_delimiter(chars, start + 2, delimiter)?;
854    let replacement_end = find_unescaped_sed_delimiter(chars, pattern_end + 1, delimiter)?;
855    Some(replacement_end + 1)
856}
857
858fn sed_substitution_may_write(chars: &[char], start: usize) -> bool {
859    let mut index = start;
860    while chars.get(index).is_some_and(|character| character.is_whitespace()) {
861        index += 1;
862    }
863    matches!(chars.get(index), Some('e' | 'w'))
864        && chars
865            .get(index + 1)
866            .is_none_or(|character| character.is_whitespace() || *character == ';')
867}
868
869/// Return whether an `awk` invocation uses an option or program shape that can
870/// write workspace state or execute commands.
871///
872/// Only data-only options pass: `-v`/`--assign` (variable binding) and
873/// `-F`/`--field-separator`, each either attached or consuming the next word.
874/// Every other option fails closed — `-i`/`--include` edits in place,
875/// `-f`/`--file`/`--source` load program text this scanner cannot see,
876/// `-l`/`--load` loads native extensions, `-p`/`--profile`,
877/// `-W`/`--dump-variables`, and `--pretty-print` write files, and the
878/// remaining option surface is too wide to allow-list safely. `--` ends option
879/// parsing. The first bare word is the program and must
880/// be write-free per [`awk_program_may_write`]; later words are filenames. A
881/// missing program is malformed, so it fails closed like the `sed` guard.
882fn has_unsafe_awk_options(arguments: &[String]) -> bool {
883    let mut program_seen = false;
884    let mut options_ended = false;
885    let mut index = 0;
886
887    while index < arguments.len() {
888        let word = arguments[index].as_str();
889        if !options_ended && word == "--" {
890            options_ended = true;
891            index += 1;
892            continue;
893        }
894        if !options_ended && word.starts_with('-') && word.len() > 1 {
895            if word == "-v" || word == "--assign" || word == "-F" || word == "--field-separator" {
896                // Value-taking option with a separate argument.
897                index += 2;
898                continue;
899            }
900            if word.starts_with("-v")
901                || word.starts_with("--assign=")
902                || word.starts_with("-F")
903                || word.starts_with("--field-separator=")
904            {
905                // Attached value (`-F:`, `-vlimit=10`).
906                index += 1;
907                continue;
908            }
909            return true;
910        }
911        if !program_seen {
912            program_seen = true;
913            if awk_program_may_write(word) {
914                return true;
915            }
916        }
917        index += 1;
918    }
919
920    !program_seen
921}
922
923/// Return whether an `awk` program can write files, pipe into commands,
924/// execute them, or load external code. Single `>` (output redirection /
925/// append `>>`) and bare `|` (command pipe) are writes; `>=` and `||` are
926/// comparisons/operators. `system()` runs shell commands; `@` invokes gawk
927/// indirect calls (`@func()`) and directives (`@include`, `@load`), which can
928/// execute or load arbitrary code — including a `system` name smuggled via `-v`.
929///
930/// A single `>` is a comparison (not redirection) when it cannot be `print` /
931/// `printf` redirection: inside `(...)` / `[...]` parentheses (e.g.
932/// `if(p2==0 && i>1)`), or in a statement with no prior `print` / `printf`
933/// (e.g. pattern `$3>100`). `print > file`, `print x > file`, and `print >>`
934/// stay mutating. `|` stays conservative: only `||` and quoted `"|"` pass.
935///
936/// Double-quoted string literals are scanned as data: a quoted `"|"` passed to
937/// `index()` is a literal, not a command pipe, so the read-only shape
938/// `awk '... index(rest,"|") ...' file` is not misclassified. To keep this
939/// safe the scanner distinguishes string literals, regex literals (`/.../`),
940/// and division using awk's operand-vs-operator rule, so a quote *inside* a
941/// regex (`/"/`, `/a"b/`) can never desync string tracking and hide a pipe.
942/// `@` and `system()` fail closed everywhere — including inside string and
943/// regex literals — and `>`/`|`/`@` inside regex literals stay mutating, so the
944/// pinned `/a|b/` and `"a@b"` shapes remain conservative.
945fn awk_program_may_write(program: &str) -> bool {
946    let chars = program.chars().collect::<Vec<_>>();
947    let mut index = 0;
948    // Whether the previous significant token can end an operand. This decides
949    // whether `/` opens a regex literal or is a division operator.
950    let mut prev_operand = false;
951    // `print` / `printf` seen in the current statement (reset on `;`/`{`/`}`).
952    // A single `>` can only be output redirection inside such a statement;
953    // without it (pattern `$3>100`) or inside parens (e.g. `if(i>1)`) it is a
954    // numeric comparison. `>>` is always redirection.
955    let mut seen_print_in_statement = false;
956    let mut paren_depth: usize = 0;
957    let mut bracket_depth: usize = 0;
958    while index < chars.len() {
959        let character = chars[index];
960        if character.is_whitespace() {
961            index += 1;
962            continue;
963        }
964        if character == '#' {
965            // Comments are not AWK syntax. In particular, a commented `(`
966            // must not make a later print redirection look parenthesized.
967            while index < chars.len() && chars[index] != '\n' {
968                index += 1;
969            }
970            continue;
971        }
972        if character == '"' {
973            index += 1;
974            let mut terminated = false;
975            while index < chars.len() {
976                let inner = chars[index];
977                if inner == '\\' {
978                    index += 2;
979                    continue;
980                }
981                if inner == '"' {
982                    index += 1;
983                    terminated = true;
984                    break;
985                }
986                // `@`/`system()` fail closed even inside a literal; `>`/`|`
987                // are literal data here and are skipped.
988                if inner == '@' {
989                    return true;
990                }
991                if (inner == 's' || inner == 'S') && awk_calls_system(&chars, index) {
992                    return true;
993                }
994                index += 1;
995            }
996            if !terminated {
997                return true;
998            }
999            prev_operand = true;
1000            continue;
1001        }
1002        if character == '/' && !prev_operand {
1003            // Regex literal. Quotes are regex content, not string delimiters,
1004            // so they cannot desync string parsing; `>`/`|`/`@` inside still
1005            // fail closed, matching the pinned `/a|b/` policy.
1006            index += 1;
1007            let mut terminated = false;
1008            while index < chars.len() {
1009                let inner = chars[index];
1010                if inner == '\\' {
1011                    index += 2;
1012                    continue;
1013                }
1014                if inner == '/' {
1015                    index += 1;
1016                    terminated = true;
1017                    break;
1018                }
1019                if inner == '@' {
1020                    return true;
1021                }
1022                if inner == '>' {
1023                    if chars.get(index + 1) == Some(&'=') {
1024                        index += 2;
1025                        continue;
1026                    }
1027                    return true;
1028                }
1029                if inner == '|' {
1030                    if chars.get(index + 1) == Some(&'|') {
1031                        index += 2;
1032                        continue;
1033                    }
1034                    return true;
1035                }
1036                index += 1;
1037            }
1038            if !terminated {
1039                return true;
1040            }
1041            prev_operand = true;
1042            continue;
1043        }
1044        if character == '@' {
1045            return true;
1046        }
1047        if (character == 's' || character == 'S') && awk_calls_system(&chars, index) {
1048            return true;
1049        }
1050        if character == '>' {
1051            if chars.get(index + 1) == Some(&'=') {
1052                index += 2;
1053                prev_operand = false;
1054                continue;
1055            }
1056            // `>>` is always append-redirection.
1057            if chars.get(index + 1) == Some(&'>') {
1058                return true;
1059            }
1060            // Single `>` is redirection only inside a `print`/`printf`
1061            // statement at paren/bracket depth 0. Otherwise it is a numeric
1062            // comparison (`if(p2==0 && i>1)`, pattern `$3>100`).
1063            if paren_depth == 0 && bracket_depth == 0 && seen_print_in_statement {
1064                return true;
1065            }
1066            index += 1;
1067            prev_operand = false;
1068            continue;
1069        }
1070        if character == '|' {
1071            if chars.get(index + 1) == Some(&'|') {
1072                index += 2;
1073                prev_operand = false;
1074                continue;
1075            }
1076            return true;
1077        }
1078        if character == '/' {
1079            // Division: an operand precedes it.
1080            prev_operand = false;
1081            index += 1;
1082            continue;
1083        }
1084        if character.is_ascii_alphanumeric() || character == '_' {
1085            let start = index;
1086            while index < chars.len() && (chars[index].is_ascii_alphanumeric() || chars[index] == '_') {
1087                index += 1;
1088            }
1089            let word: String = chars[start..index].iter().collect();
1090            if word == "print" || word == "printf" {
1091                seen_print_in_statement = true;
1092            }
1093            prev_operand = !is_awk_keyword(&word);
1094            continue;
1095        }
1096        if character == '(' {
1097            paren_depth = paren_depth.saturating_add(1);
1098            prev_operand = false;
1099            index += 1;
1100            continue;
1101        }
1102        if character == '[' {
1103            bracket_depth = bracket_depth.saturating_add(1);
1104            prev_operand = false;
1105            index += 1;
1106            continue;
1107        }
1108        if character == ')' {
1109            paren_depth = paren_depth.saturating_sub(1);
1110            prev_operand = true;
1111            index += 1;
1112            continue;
1113        }
1114        if character == ']' {
1115            bracket_depth = bracket_depth.saturating_sub(1);
1116            prev_operand = true;
1117            index += 1;
1118            continue;
1119        }
1120        if character == '$' {
1121            prev_operand = true;
1122            index += 1;
1123            continue;
1124        }
1125        if character == ';' || character == '{' || character == '}' {
1126            // New statement: a later `>` needs its own `print`/`printf`.
1127            seen_print_in_statement = false;
1128            prev_operand = false;
1129            index += 1;
1130            continue;
1131        }
1132        prev_operand = false;
1133        index += 1;
1134    }
1135    false
1136}
1137
1138/// Awk keywords that do not end an operand, so a following `/` opens a regex
1139/// literal (`print /re/`) rather than being a division operator. Kept minimal
1140/// and conservative: an unlisted keyword is treated as an operand, which at
1141/// worst misreads a regex as division — still fail-closed, because regex
1142/// contents are then scanned as top-level code and any `>`/`|`/`@` is caught.
1143fn is_awk_keyword(word: &str) -> bool {
1144    matches!(
1145        word,
1146        "if" | "else"
1147            | "while"
1148            | "for"
1149            | "do"
1150            | "break"
1151            | "continue"
1152            | "next"
1153            | "nextfile"
1154            | "exit"
1155            | "return"
1156            | "delete"
1157            | "in"
1158            | "getline"
1159            | "print"
1160            | "printf"
1161            | "function"
1162    )
1163}
1164
1165/// Return whether `chars[start..]` invokes awk's `system()` builtin: the
1166/// identifier `system` (case-insensitive) on an identifier boundary followed
1167/// by optional whitespace and `(`.
1168fn awk_calls_system(chars: &[char], start: usize) -> bool {
1169    const NAME: &[char] = &['s', 'y', 's', 't', 'e', 'm'];
1170    if start > 0 && is_awk_ident_char(chars[start - 1]) {
1171        return false;
1172    }
1173    let candidate = chars.get(start..start + NAME.len());
1174    if candidate.is_none_or(|slice| {
1175        slice
1176            .iter()
1177            .zip(NAME.iter())
1178            .any(|(actual, expected)| !actual.eq_ignore_ascii_case(expected))
1179    }) {
1180        return false;
1181    }
1182    let mut index = start + NAME.len();
1183    while chars.get(index).is_some_and(|character| character.is_whitespace()) {
1184        index += 1;
1185    }
1186    chars.get(index) == Some(&'(')
1187}
1188
1189fn is_awk_ident_char(character: char) -> bool {
1190    character.is_ascii_alphanumeric() || character == '_'
1191}
1192
1193pub fn normalize_shell_args(args: &Value) -> Result<Value, &'static str> {
1194    let mut normalized = match normalize_indexed_command_args(args)? {
1195        Some(value) => value,
1196        None => args.clone(),
1197    };
1198
1199    let Some(payload) = normalized.as_object_mut() else {
1200        return Ok(normalized);
1201    };
1202
1203    if payload.get("command").is_none() {
1204        if let Some(command) = payload.get("cmd").cloned() {
1205            payload.insert("command".to_string(), command);
1206        } else if let Some(command) = payload.get("raw_command").cloned() {
1207            payload.insert("command".to_string(), command);
1208        }
1209    }
1210
1211    if payload.get("input").is_none() {
1212        if let Some(input) = payload.get("chars").cloned() {
1213            payload.insert("input".to_string(), input);
1214        } else if let Some(input) = payload.get("text").cloned() {
1215            payload.insert("input".to_string(), input);
1216        }
1217    }
1218
1219    if payload.get("session_id").is_none()
1220        && let Some(session_id) = payload.get("s").cloned()
1221    {
1222        payload.insert("session_id".to_string(), session_id);
1223    }
1224
1225    if payload.get("max_tokens").is_none()
1226        && let Some(max_output_tokens) = payload.get("max_output_tokens").cloned()
1227    {
1228        payload.insert("max_tokens".to_string(), max_output_tokens);
1229    }
1230
1231    if payload.get("max_output_tokens").is_none()
1232        && let Some(max_tokens) = payload.get("max_tokens").cloned()
1233    {
1234        payload.insert("max_output_tokens".to_string(), max_tokens);
1235    }
1236
1237    Ok(normalized)
1238}
1239
1240#[cfg(test)]
1241mod tests {
1242    use super::{
1243        WriteStdinDispatch, command_session_missing_required_args, command_session_requires_command_safety,
1244        command_text, command_words, command_words_after_environment_prefix, contains_dynamic_shell_syntax,
1245        environment_prefix_has_injection_keys, extract_command_text_with_key, has_indexed_command_parts,
1246        has_unsafe_readonly_options, interactive_input_text, normalize_indexed_command_args, normalize_shell_args,
1247        normalized_command_value, parse_indexed_command_parts, raw_command_text, scan_environment_prefix,
1248        session_id_text, session_id_text_from_payload, working_dir_text, working_dir_text_from_payload,
1249        write_stdin_dispatch,
1250    };
1251    use serde_json::{Value, json};
1252
1253    #[test]
1254    fn detects_indexed_command_keys() {
1255        assert!(has_indexed_command_parts(&json!({"command.0": "ls"})));
1256        assert!(has_indexed_command_parts(&json!({"command.1": "ls"})));
1257        assert!(!has_indexed_command_parts(&json!({"command.2": "ls"})));
1258    }
1259
1260    #[test]
1261    fn parses_zero_based_indexed_command_parts() {
1262        let parts = parse_indexed_command_parts(
1263            json!({
1264                "command.0": "ls",
1265                "command.1": "-a"
1266            })
1267            .as_object()
1268            .expect("object"),
1269        )
1270        .expect("valid indexed args");
1271
1272        assert_eq!(parts, Some(vec!["ls".to_string(), "-a".to_string()]));
1273    }
1274
1275    #[test]
1276    fn parses_one_based_indexed_command_parts() {
1277        let parts = parse_indexed_command_parts(
1278            json!({
1279                "command.1": "ls",
1280                "command.2": "-a"
1281            })
1282            .as_object()
1283            .expect("object"),
1284        )
1285        .expect("valid indexed args");
1286
1287        assert_eq!(parts, Some(vec!["ls".to_string(), "-a".to_string()]));
1288    }
1289
1290    #[test]
1291    fn rejects_non_string_indexed_command_parts() {
1292        let error = parse_indexed_command_parts(
1293            json!({
1294                "command.0": 42
1295            })
1296            .as_object()
1297            .expect("object"),
1298        )
1299        .expect_err("non-string segment should fail");
1300
1301        assert_eq!(error, "command array must contain only strings");
1302    }
1303
1304    #[test]
1305    fn normalizes_indexed_command_args_into_command_string() {
1306        let normalized = normalize_indexed_command_args(&json!({
1307            "command.1": "ls",
1308            "command.2": "-a",
1309            "working_dir": "."
1310        }))
1311        .expect("valid indexed args")
1312        .expect("normalized payload");
1313
1314        assert_eq!(normalized.get("command").and_then(Value::as_str), Some("ls -a"));
1315        assert_eq!(normalized.get("working_dir").and_then(Value::as_str), Some("."));
1316    }
1317
1318    #[test]
1319    fn normalized_command_value_prefers_cmd_aliases() {
1320        let normalized = normalized_command_value(&json!({"cmd": "ls -a"}))
1321            .expect("valid command alias")
1322            .expect("command value");
1323
1324        assert_eq!(normalized.as_str(), Some("ls -a"));
1325    }
1326
1327    #[test]
1328    fn command_text_joins_command_arrays() {
1329        let command = command_text(&json!({"command": ["git", "status", "--short"]}))
1330            .expect("valid command")
1331            .expect("command text");
1332
1333        assert_eq!(command, "git status --short");
1334    }
1335
1336    #[test]
1337    fn command_words_append_extra_args() {
1338        let words = command_words(&json!({
1339            "command": "cargo test",
1340            "args": ["-p", "vtcode-core"]
1341        }))
1342        .expect("valid command")
1343        .expect("command words");
1344
1345        assert_eq!(words, vec!["cargo", "test", "-p", "vtcode-core"]);
1346    }
1347
1348    #[test]
1349    fn interactive_input_text_preserves_whitespace() {
1350        assert_eq!(interactive_input_text(&json!({"chars": "  echo hi\n"})), Some("  echo hi\n"));
1351    }
1352
1353    #[test]
1354    fn write_stdin_dispatch_distinguishes_write_from_poll() {
1355        assert_eq!(write_stdin_dispatch(&json!({"chars": ""})), Ok(WriteStdinDispatch::Poll));
1356        assert_eq!(write_stdin_dispatch(&json!({"chars": "  status\n"})), Ok(WriteStdinDispatch::Write));
1357    }
1358
1359    #[test]
1360    fn write_stdin_dispatch_accepts_explicit_wait_without_chars() {
1361        assert_eq!(
1362            write_stdin_dispatch(&json!({"action": "wait", "session_id": "run-1"})),
1363            Ok(WriteStdinDispatch::Wait)
1364        );
1365    }
1366
1367    #[test]
1368    fn write_stdin_dispatch_requires_public_chars() {
1369        assert_eq!(write_stdin_dispatch(&json!({"input": "status\n"})), Err("write_stdin requires string chars"));
1370        assert_eq!(write_stdin_dispatch(&json!({"chars": 1})), Err("write_stdin requires string chars"));
1371    }
1372
1373    #[test]
1374    fn write_stdin_dispatch_validates_control_actions() {
1375        for (action, expected) in [
1376            ("inspect", WriteStdinDispatch::Inspect),
1377            ("terminate", WriteStdinDispatch::Terminate),
1378            ("close", WriteStdinDispatch::Close),
1379            ("poll", WriteStdinDispatch::Poll),
1380        ] {
1381            assert_eq!(write_stdin_dispatch(&json!({"action": action})), Ok(expected));
1382        }
1383        assert!(write_stdin_dispatch(&json!({"action": "typo", "chars": "echo unsafe\n"})).is_err());
1384        assert!(write_stdin_dispatch(&json!({"action": "poll", "chars": "echo unsafe\n"})).is_err());
1385        assert!(write_stdin_dispatch(&json!({"action": 1, "chars": ""})).is_err());
1386    }
1387
1388    #[test]
1389    fn write_stdin_dispatch_matches_actions_case_insensitively() {
1390        // Asymmetric: mixed-case spellings resolve like lowercase; non-ASCII
1391        // lookalikes stay rejected (ASCII-only folding, as before).
1392        for (action, expected) in [
1393            ("WAIT", WriteStdinDispatch::Wait),
1394            ("WaIt", WriteStdinDispatch::Wait),
1395            ("INSPECT", WriteStdinDispatch::Inspect),
1396            ("Terminate", WriteStdinDispatch::Terminate),
1397            ("CLOSE", WriteStdinDispatch::Close),
1398            ("Poll", WriteStdinDispatch::Poll),
1399        ] {
1400            assert_eq!(
1401                write_stdin_dispatch(&json!({"action": action})),
1402                Ok(expected),
1403                "{action:?} must resolve like its lowercase form"
1404            );
1405        }
1406        assert_eq!(
1407            write_stdin_dispatch(&json!({"action": "WRITE", "chars": "status\n"})),
1408            Ok(WriteStdinDispatch::Write)
1409        );
1410        assert!(write_stdin_dispatch(&json!({"action": "wäit"})).is_err());
1411        assert!(write_stdin_dispatch(&json!({"action": "WAIT "})).is_err());
1412    }
1413
1414    #[test]
1415    fn session_id_text_trims_whitespace() {
1416        assert_eq!(session_id_text(&json!({"session_id": " run-1 "})), Some("run-1"));
1417    }
1418
1419    #[test]
1420    fn session_id_text_accepts_compact_alias() {
1421        assert_eq!(session_id_text(&json!({"s": " run-1 "})), Some("run-1"));
1422    }
1423
1424    #[test]
1425    fn session_id_text_from_payload_accepts_aliases() {
1426        let value = json!({"s": " run-1 "});
1427        let payload = value.as_object().expect("object");
1428        assert_eq!(session_id_text_from_payload(payload), Some("run-1"));
1429    }
1430
1431    #[test]
1432    fn working_dir_text_accepts_aliases() {
1433        assert_eq!(working_dir_text(&json!({"workdir": " src "})), Some("src"));
1434        assert_eq!(working_dir_text(&json!({"cwd": "."})), Some("."));
1435    }
1436
1437    #[test]
1438    fn working_dir_text_from_payload_accepts_aliases() {
1439        let value = json!({"workdir": " src "});
1440        let payload = value.as_object().expect("object");
1441        assert_eq!(working_dir_text_from_payload(payload), Some("src"));
1442    }
1443
1444    #[test]
1445    fn normalize_shell_args_maps_codex_fields() {
1446        let normalized = normalize_shell_args(&json!({
1447            "cmd": "echo hi",
1448            "chars": "status\n"
1449        }))
1450        .expect("valid shell args");
1451
1452        assert_eq!(normalized.get("command").and_then(Value::as_str), Some("echo hi"));
1453        assert_eq!(normalized.get("input").and_then(Value::as_str), Some("status\n"));
1454    }
1455
1456    #[test]
1457    fn normalize_shell_args_maps_compact_session_id() {
1458        let normalized = normalize_shell_args(&json!({
1459            "s": "run-1"
1460        }))
1461        .expect("valid shell args");
1462
1463        assert_eq!(normalized.get("session_id").and_then(Value::as_str), Some("run-1"));
1464    }
1465
1466    #[test]
1467    fn normalize_shell_args_copies_max_output_tokens_to_max_tokens() {
1468        let normalized = normalize_shell_args(&json!({
1469            "command": "echo hi",
1470            "max_output_tokens": 42
1471        }))
1472        .expect("valid shell args");
1473
1474        assert_eq!(normalized.get("max_output_tokens").and_then(Value::as_u64), Some(42));
1475        assert_eq!(normalized.get("max_tokens").and_then(Value::as_u64), Some(42));
1476    }
1477
1478    #[test]
1479    fn normalize_shell_args_copies_max_tokens_to_max_output_tokens() {
1480        let normalized = normalize_shell_args(&json!({
1481            "command": "echo hi",
1482            "max_tokens": 42
1483        }))
1484        .expect("valid shell args");
1485
1486        assert_eq!(normalized.get("max_tokens").and_then(Value::as_u64), Some(42));
1487        assert_eq!(normalized.get("max_output_tokens").and_then(Value::as_u64), Some(42));
1488    }
1489
1490    #[test]
1491    fn command_session_missing_required_args_is_action_aware() {
1492        assert_eq!(command_session_missing_required_args(&json!({"action": "run"})), vec!["command"]);
1493        assert_eq!(
1494            command_session_missing_required_args(&json!({"action": "write", "session_id": "run-1"})),
1495            vec!["input or chars or text"]
1496        );
1497        assert_eq!(
1498            command_session_missing_required_args(&json!({"action": "inspect"})),
1499            vec!["session_id or spool_path"]
1500        );
1501        assert!(command_session_missing_required_args(&json!({"action": "list"})).is_empty());
1502    }
1503
1504    #[test]
1505    fn command_session_requires_command_safety_only_for_run() {
1506        assert!(command_session_requires_command_safety(&json!({
1507            "action": "run",
1508            "command": "cargo check"
1509        })));
1510        assert!(!command_session_requires_command_safety(&json!({
1511            "action": "poll",
1512            "session_id": "run-1"
1513        })));
1514    }
1515
1516    #[test]
1517    fn raw_command_text_extracts_command_string() {
1518        assert_eq!(raw_command_text(&json!({"command": "rg foo"})), Some("rg foo".to_string()));
1519        assert_eq!(raw_command_text(&json!({"cmd": "ls -la"})), Some("ls -la".to_string()));
1520        assert_eq!(
1521            raw_command_text(&json!({"command.0": "cat", "command.1": "file.txt"})),
1522            Some("cat file.txt".to_string())
1523        );
1524        assert_eq!(raw_command_text(&json!({"command": ["wc", "-l"]})), Some("wc -l".to_string()));
1525        assert!(raw_command_text(&json!({})).is_none());
1526    }
1527
1528    #[test]
1529    fn display_extraction_reports_key_and_preserves_precedence() {
1530        assert_eq!(
1531            extract_command_text_with_key(&json!({"command": ["bash", "-lc", "ls -R"]})),
1532            Some(("bash -lc ls -R".to_string(), "command"))
1533        );
1534        assert_eq!(
1535            extract_command_text_with_key(&json!({"bash_command": "pwd"})),
1536            Some(("pwd".to_string(), "bash_command"))
1537        );
1538        assert_eq!(
1539            extract_command_text_with_key(&json!({"raw_command": "cargo test"})),
1540            Some(("cargo test".to_string(), "raw_command"))
1541        );
1542        // Missing `command` must not block later keys (the copilot
1543        // early-return bug this canonical helper fixes).
1544        assert_eq!(extract_command_text_with_key(&json!({"cmd": "ls -la"})), Some(("ls -la".to_string(), "cmd")));
1545        // `command` trims; other keys keep raw emptiness semantics.
1546        assert_eq!(extract_command_text_with_key(&json!({"command": "  "})), None);
1547        // First non-empty key in canonical order wins.
1548        assert_eq!(
1549            extract_command_text_with_key(
1550                &json!({"raw_command": "cargo check -p vtcode", "bash_command": "cargo test"})
1551            ),
1552            Some(("cargo check -p vtcode".to_string(), "raw_command"))
1553        );
1554        assert!(extract_command_text_with_key(&json!({})).is_none());
1555    }
1556
1557    #[test]
1558    fn env_prefix_injection_keys_are_detected() {
1559        let words = |command: &str| -> Vec<String> { shell_words::split(command).expect("split command") };
1560
1561        // The A4 vector: config injection via env values reopens the blocked
1562        // `git -c` path without any `-c` token.
1563        assert!(environment_prefix_has_injection_keys(&words(
1564            "env GIT_CONFIG_COUNT=1 GIT_CONFIG_KEY_0=core.fsmonitor GIT_CONFIG_VALUE_0=touch git status"
1565        )));
1566        assert!(environment_prefix_has_injection_keys(&words(
1567            "GIT_CONFIG_COUNT=1 GIT_CONFIG_KEY_0=core.fsmonitor GIT_CONFIG_VALUE_0=touch git status"
1568        )));
1569        assert!(environment_prefix_has_injection_keys(&words("env GIT_EXTERNAL_DIFF=evil git diff")));
1570        assert!(environment_prefix_has_injection_keys(&words("env GIT_TEXTCONV=evil git log")));
1571        assert!(environment_prefix_has_injection_keys(&words("env LD_PRELOAD=./evil.so ls")));
1572        assert!(environment_prefix_has_injection_keys(&words("env BASH_ENV=./evil.sh bash -lc 'echo hi'")));
1573        assert!(environment_prefix_has_injection_keys(&words("env NODE_OPTIONS=--require ./evil.js node --version")));
1574        // `env -S` re-parses at runtime; cannot audit.
1575        assert!(environment_prefix_has_injection_keys(&words("env -S 'GIT_CONFIG_COUNT=1 git status'")));
1576
1577        // Benign env stays allowed.
1578        assert!(!environment_prefix_has_injection_keys(&words("env LANG=C.UTF-8 rg foo")));
1579        assert!(!environment_prefix_has_injection_keys(&words("FOO=bar git status")));
1580        assert!(!environment_prefix_has_injection_keys(&words("env FOO=bar git status")));
1581        // Unset is not an assignment.
1582        assert!(!environment_prefix_has_injection_keys(&words("env -u GIT_CONFIG_COUNT git status")));
1583        assert!(!environment_prefix_has_injection_keys(&words("git status")));
1584    }
1585
1586    #[test]
1587    fn env_prefix_single_scan_agrees_with_both_public_helpers() {
1588        let words = |command: &str| -> Vec<String> { shell_words::split(command).expect("split command") };
1589        for command in [
1590            "git status",
1591            "env LANG=C.UTF-8 rg foo",
1592            "FOO=bar LANG=C git status",
1593            "env FOO=bar git status",
1594            "env -- FOO=bar git status",
1595            "env -u GIT_CONFIG_COUNT git status",
1596            "env -C /tmp git status",
1597            "env GIT_EXTERNAL_DIFF=evil git diff",
1598            "GIT_CONFIG_COUNT=1 GIT_CONFIG_KEY_0=k GIT_CONFIG_VALUE_0=v git status",
1599            "env -S 'GIT_CONFIG_COUNT=1 git status'",
1600            "env --split-string='x=1 ls'",
1601        ] {
1602            let parsed = words(command);
1603            let scan = scan_environment_prefix(&parsed);
1604            assert_eq!(command_words_after_environment_prefix(&parsed), &parsed[scan.stripped_start..], "{command}");
1605            assert_eq!(environment_prefix_has_injection_keys(&parsed), scan.has_injection, "{command}");
1606        }
1607    }
1608
1609    #[test]
1610    fn env_prefix_empty_key_stays_fail_closed() {
1611        let words = |command: &str| -> Vec<String> { shell_words::split(command).expect("split command") };
1612        // `=foo` is not a valid assignment: the executable stays `=foo`
1613        // (unknown, therefore mutating downstream), never silently stripped.
1614        for command in ["=foo rg foo", "env =foo rg foo"] {
1615            let parsed = words(command);
1616            let stripped = command_words_after_environment_prefix(&parsed);
1617            assert_eq!(stripped.first().map(String::as_str), Some("=foo"), "{command}");
1618            assert!(!environment_prefix_has_injection_keys(&parsed), "{command}");
1619        }
1620    }
1621
1622    #[test]
1623    fn env_prefix_split_string_reports_empty_remainder_and_unsafe() {
1624        let words = |command: &str| -> Vec<String> { shell_words::split(command).expect("split command") };
1625        let parsed = words("env -S 'GIT_CONFIG_COUNT=1 git status'");
1626        assert!(command_words_after_environment_prefix(&parsed).is_empty());
1627        assert!(environment_prefix_has_injection_keys(&parsed));
1628        assert!(has_unsafe_readonly_options(&parsed));
1629    }
1630
1631    #[test]
1632    fn has_unsafe_readonly_options_flags_injection_without_second_walk() {
1633        let words = |command: &str| -> Vec<String> { shell_words::split(command).expect("split command") };
1634        assert!(has_unsafe_readonly_options(&words("env LD_PRELOAD=./evil.so ls")));
1635        assert!(has_unsafe_readonly_options(&words("LD_PRELOAD=./evil.so ls")));
1636        assert!(!has_unsafe_readonly_options(&words("env LANG=C.UTF-8 rg foo")));
1637        assert!(!has_unsafe_readonly_options(&words("git status")));
1638    }
1639
1640    #[test]
1641    fn dynamic_shell_syntax_allows_quoted_globs_only() {
1642        assert!(!contains_dynamic_shell_syntax("find src -name '*.rs'"));
1643        assert!(!contains_dynamic_shell_syntax("find src -name \"*.rs\""));
1644        assert!(contains_dynamic_shell_syntax("find src -exe$''c touch {} +"));
1645        assert!(contains_dynamic_shell_syntax("find src -ex{e,}c touch {} +"));
1646    }
1647}