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/// Single-walk result for an `env` / leading-assignment prefix.
363///
364/// `stripped_start` is the index of the executable that will actually run;
365/// `has_injection` reports whether the skipped prefix sets a known
366/// config/loader-injection key or uses `env -S` (which re-parses at runtime
367/// and cannot be audited). One walk produces both so safety checks never
368/// re-scan the same prefix twice and the `env`-flag table cannot drift
369/// between the stripping and injection paths.
370#[derive(Debug, Clone, Copy, PartialEq, Eq)]
371struct EnvPrefixScan {
372    stripped_start: usize,
373    has_injection: bool,
374}
375
376fn is_env_split_string_flag(word: &str) -> bool {
377    word == "-S" || word == "--split-string" || word.starts_with("-S") || word.starts_with("--split-string=")
378}
379
380fn env_flag_consumes_next(word: &str) -> bool {
381    matches!(word, "-u" | "--unset" | "-C" | "--chdir")
382}
383
384/// Walk an optional `env` prefix plus leading `KEY=value` assignments once.
385///
386/// Shared by [`command_words_after_environment_prefix`] (stripping) and
387/// [`environment_prefix_has_injection_keys`] (injection audit) so both paths
388/// inspect the same command shape. Fail-closed notes:
389///
390/// - `env -S`/`--split-string` re-parses the rest at runtime: reported as
391///   injection with an empty remainder, matching both historical paths.
392/// - Words with an empty key (`=foo`) are NOT assignments: scanning stops and
393///   they are kept as the program (unknown, therefore mutating). The old
394///   stripping path skipped them via a broad `contains('=')` test; stopping is
395///   strictly fail-closed and the injection path is unchanged.
396fn scan_environment_prefix(words: &[String]) -> EnvPrefixScan {
397    let mut start = 0;
398    let mut has_injection = false;
399    loop {
400        if words.get(start).is_some_and(|word| word == "env") {
401            start += 1;
402            while let Some(word) = words.get(start) {
403                if word == "--" {
404                    start += 1;
405                    break;
406                }
407                if is_env_split_string_flag(word) {
408                    // Split-string re-parses the rest at runtime; cannot audit
409                    // assignments. Treat as unsafe with no auditable remainder.
410                    return EnvPrefixScan { stripped_start: words.len(), has_injection: true };
411                }
412                let consumes_next = env_flag_consumes_next(word);
413                start += 1;
414                if consumes_next {
415                    // `-u/--unset` removes a key; `-C/--chdir` takes a path.
416                    if words.get(start).is_some() {
417                        start += 1;
418                    }
419                    continue;
420                }
421                if let Some(key) = env_assignment_key(word) {
422                    if is_env_injection_key(key) {
423                        has_injection = true;
424                    }
425                    continue;
426                }
427                if word.starts_with('-') {
428                    continue;
429                }
430                start -= 1;
431                break;
432            }
433            continue;
434        }
435        if let Some(key) = words.get(start).and_then(|word| env_assignment_key(word)) {
436            if is_env_injection_key(key) {
437                has_injection = true;
438            }
439            start += 1;
440            continue;
441        }
442        break;
443    }
444    EnvPrefixScan { stripped_start: start, has_injection }
445}
446
447/// Skip an optional `env` prefix and leading shell environment assignments.
448///
449/// The returned slice starts at the executable that will actually run. Keep
450/// this helper shared by intent and activity classification so both paths
451/// inspect the same command shape.
452pub fn command_words_after_environment_prefix(words: &[String]) -> &[String] {
453    let scan = scan_environment_prefix(words);
454    &words[scan.stripped_start..]
455}
456
457/// Exact environment keys that can inject config, load foreign code, or launch
458/// helpers into an otherwise read-only program.
459const ENV_INJECTION_KEYS: &[&str] = &[
460    "GIT_CONFIG_COUNT",
461    "GIT_CONFIG_GLOBAL",
462    "GIT_CONFIG_SYSTEM",
463    "GIT_CONFIG_NOSYSTEM",
464    "GIT_EXTERNAL_DIFF",
465    "GIT_TEXTCONV",
466    "GIT_DIFF_OPTS",
467    "GIT_EDITOR",
468    "GIT_SEQUENCE_EDITOR",
469    "GIT_SSH",
470    "GIT_SSH_COMMAND",
471    "GIT_EXEC_PATH",
472    "LD_PRELOAD",
473    "LD_AUDIT",
474    "LD_LIBRARY_PATH",
475    "DYLD_INSERT_LIBRARIES",
476    "DYLD_LIBRARY_PATH",
477    "DYLD_FRAMEWORK_PATH",
478    "DYLD_FALLBACK_LIBRARY_PATH",
479    "BASH_ENV",
480    "ENV",
481    "SHELLOPTS",
482    "PERL5OPT",
483    "PYTHONSTARTUP",
484    "NODE_OPTIONS",
485    "RUBYOPT",
486    "EDITOR",
487    "VISUAL",
488    "PAGER",
489];
490
491/// Prefix families that always carry injection payload (`GIT_CONFIG_KEY_n` /
492/// `GIT_CONFIG_VALUE_n` pair with `GIT_CONFIG_COUNT`).
493const ENV_INJECTION_KEY_PREFIXES: &[&str] = &["GIT_CONFIG_KEY_", "GIT_CONFIG_VALUE_"];
494
495fn env_assignment_key(word: &str) -> Option<&str> {
496    if word.starts_with('-') {
497        return None;
498    }
499    let (key, _) = word.split_once('=')?;
500    if key.is_empty() {
501        return None;
502    }
503    Some(key)
504}
505
506fn is_env_injection_key(key: &str) -> bool {
507    ENV_INJECTION_KEYS.contains(&key) || ENV_INJECTION_KEY_PREFIXES.iter().any(|prefix| key.starts_with(prefix))
508}
509
510/// Returns whether an `env` / leading-assignment prefix sets a known
511/// config-injection or loader-injection key.
512///
513/// `command_words_after_environment_prefix` strips these assignments so the
514/// executable can be classified; the values themselves must still be checked.
515/// Unset (`env -u KEY`) is not an assignment. Unknown keys stay allowed so
516/// ordinary exploration (`LANG=C rg …`) keeps working.
517pub fn environment_prefix_has_injection_keys(words: &[String]) -> bool {
518    scan_environment_prefix(words).has_injection
519}
520
521/// Return whether a parsed command uses an option or environment assignment
522/// that can turn an otherwise read-only inspection program into a writer or
523/// command launcher.
524///
525/// This is deliberately option/env focused; the read-only executable/subcommand
526/// allow-list remains in `tool_intent::readonly`. Keeping the unsafe option
527/// rules here lets raw argument validation and activity classification share
528/// the same token-aware guard without searching quoted text.
529pub(crate) fn has_unsafe_readonly_options(words: &[String]) -> bool {
530    // Single prefix walk: the scan yields both the injection verdict and the
531    // stripped executable offset, replacing the historical two-walk sequence
532    // (`environment_prefix_has_injection_keys` then
533    // `command_words_after_environment_prefix`).
534    let scan = scan_environment_prefix(words);
535    if scan.has_injection {
536        return true;
537    }
538    let command_words = &words[scan.stripped_start..];
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, command_words_after_environment_prefix, contains_dynamic_shell_syntax,
1160        environment_prefix_has_injection_keys, extract_command_text_with_key, has_indexed_command_parts,
1161        has_unsafe_readonly_options, interactive_input_text, normalize_indexed_command_args, normalize_shell_args,
1162        normalized_command_value, parse_indexed_command_parts, raw_command_text, scan_environment_prefix,
1163        session_id_text, session_id_text_from_payload, working_dir_text, working_dir_text_from_payload,
1164        write_stdin_dispatch,
1165    };
1166    use serde_json::{Value, json};
1167
1168    #[test]
1169    fn detects_indexed_command_keys() {
1170        assert!(has_indexed_command_parts(&json!({"command.0": "ls"})));
1171        assert!(has_indexed_command_parts(&json!({"command.1": "ls"})));
1172        assert!(!has_indexed_command_parts(&json!({"command.2": "ls"})));
1173    }
1174
1175    #[test]
1176    fn parses_zero_based_indexed_command_parts() {
1177        let parts = parse_indexed_command_parts(
1178            json!({
1179                "command.0": "ls",
1180                "command.1": "-a"
1181            })
1182            .as_object()
1183            .expect("object"),
1184        )
1185        .expect("valid indexed args");
1186
1187        assert_eq!(parts, Some(vec!["ls".to_string(), "-a".to_string()]));
1188    }
1189
1190    #[test]
1191    fn parses_one_based_indexed_command_parts() {
1192        let parts = parse_indexed_command_parts(
1193            json!({
1194                "command.1": "ls",
1195                "command.2": "-a"
1196            })
1197            .as_object()
1198            .expect("object"),
1199        )
1200        .expect("valid indexed args");
1201
1202        assert_eq!(parts, Some(vec!["ls".to_string(), "-a".to_string()]));
1203    }
1204
1205    #[test]
1206    fn rejects_non_string_indexed_command_parts() {
1207        let error = parse_indexed_command_parts(
1208            json!({
1209                "command.0": 42
1210            })
1211            .as_object()
1212            .expect("object"),
1213        )
1214        .expect_err("non-string segment should fail");
1215
1216        assert_eq!(error, "command array must contain only strings");
1217    }
1218
1219    #[test]
1220    fn normalizes_indexed_command_args_into_command_string() {
1221        let normalized = normalize_indexed_command_args(&json!({
1222            "command.1": "ls",
1223            "command.2": "-a",
1224            "working_dir": "."
1225        }))
1226        .expect("valid indexed args")
1227        .expect("normalized payload");
1228
1229        assert_eq!(normalized.get("command").and_then(Value::as_str), Some("ls -a"));
1230        assert_eq!(normalized.get("working_dir").and_then(Value::as_str), Some("."));
1231    }
1232
1233    #[test]
1234    fn normalized_command_value_prefers_cmd_aliases() {
1235        let normalized = normalized_command_value(&json!({"cmd": "ls -a"}))
1236            .expect("valid command alias")
1237            .expect("command value");
1238
1239        assert_eq!(normalized.as_str(), Some("ls -a"));
1240    }
1241
1242    #[test]
1243    fn command_text_joins_command_arrays() {
1244        let command = command_text(&json!({"command": ["git", "status", "--short"]}))
1245            .expect("valid command")
1246            .expect("command text");
1247
1248        assert_eq!(command, "git status --short");
1249    }
1250
1251    #[test]
1252    fn command_words_append_extra_args() {
1253        let words = command_words(&json!({
1254            "command": "cargo test",
1255            "args": ["-p", "vtcode-core"]
1256        }))
1257        .expect("valid command")
1258        .expect("command words");
1259
1260        assert_eq!(words, vec!["cargo", "test", "-p", "vtcode-core"]);
1261    }
1262
1263    #[test]
1264    fn interactive_input_text_preserves_whitespace() {
1265        assert_eq!(interactive_input_text(&json!({"chars": "  echo hi\n"})), Some("  echo hi\n"));
1266    }
1267
1268    #[test]
1269    fn write_stdin_dispatch_distinguishes_write_from_poll() {
1270        assert_eq!(write_stdin_dispatch(&json!({"chars": ""})), Ok(WriteStdinDispatch::Poll));
1271        assert_eq!(write_stdin_dispatch(&json!({"chars": "  status\n"})), Ok(WriteStdinDispatch::Write));
1272    }
1273
1274    #[test]
1275    fn write_stdin_dispatch_accepts_explicit_wait_without_chars() {
1276        assert_eq!(
1277            write_stdin_dispatch(&json!({"action": "wait", "session_id": "run-1"})),
1278            Ok(WriteStdinDispatch::Wait)
1279        );
1280    }
1281
1282    #[test]
1283    fn write_stdin_dispatch_requires_public_chars() {
1284        assert_eq!(write_stdin_dispatch(&json!({"input": "status\n"})), Err("write_stdin requires string chars"));
1285        assert_eq!(write_stdin_dispatch(&json!({"chars": 1})), Err("write_stdin requires string chars"));
1286    }
1287
1288    #[test]
1289    fn write_stdin_dispatch_validates_control_actions() {
1290        for (action, expected) in [
1291            ("inspect", WriteStdinDispatch::Inspect),
1292            ("terminate", WriteStdinDispatch::Terminate),
1293            ("close", WriteStdinDispatch::Close),
1294            ("poll", WriteStdinDispatch::Poll),
1295        ] {
1296            assert_eq!(write_stdin_dispatch(&json!({"action": action})), Ok(expected));
1297        }
1298        assert!(write_stdin_dispatch(&json!({"action": "typo", "chars": "echo unsafe\n"})).is_err());
1299        assert!(write_stdin_dispatch(&json!({"action": "poll", "chars": "echo unsafe\n"})).is_err());
1300        assert!(write_stdin_dispatch(&json!({"action": 1, "chars": ""})).is_err());
1301    }
1302
1303    #[test]
1304    fn session_id_text_trims_whitespace() {
1305        assert_eq!(session_id_text(&json!({"session_id": " run-1 "})), Some("run-1"));
1306    }
1307
1308    #[test]
1309    fn session_id_text_accepts_compact_alias() {
1310        assert_eq!(session_id_text(&json!({"s": " run-1 "})), Some("run-1"));
1311    }
1312
1313    #[test]
1314    fn session_id_text_from_payload_accepts_aliases() {
1315        let value = json!({"s": " run-1 "});
1316        let payload = value.as_object().expect("object");
1317        assert_eq!(session_id_text_from_payload(payload), Some("run-1"));
1318    }
1319
1320    #[test]
1321    fn working_dir_text_accepts_aliases() {
1322        assert_eq!(working_dir_text(&json!({"workdir": " src "})), Some("src"));
1323        assert_eq!(working_dir_text(&json!({"cwd": "."})), Some("."));
1324    }
1325
1326    #[test]
1327    fn working_dir_text_from_payload_accepts_aliases() {
1328        let value = json!({"workdir": " src "});
1329        let payload = value.as_object().expect("object");
1330        assert_eq!(working_dir_text_from_payload(payload), Some("src"));
1331    }
1332
1333    #[test]
1334    fn normalize_shell_args_maps_codex_fields() {
1335        let normalized = normalize_shell_args(&json!({
1336            "cmd": "echo hi",
1337            "chars": "status\n"
1338        }))
1339        .expect("valid shell args");
1340
1341        assert_eq!(normalized.get("command").and_then(Value::as_str), Some("echo hi"));
1342        assert_eq!(normalized.get("input").and_then(Value::as_str), Some("status\n"));
1343    }
1344
1345    #[test]
1346    fn normalize_shell_args_maps_compact_session_id() {
1347        let normalized = normalize_shell_args(&json!({
1348            "s": "run-1"
1349        }))
1350        .expect("valid shell args");
1351
1352        assert_eq!(normalized.get("session_id").and_then(Value::as_str), Some("run-1"));
1353    }
1354
1355    #[test]
1356    fn normalize_shell_args_copies_max_output_tokens_to_max_tokens() {
1357        let normalized = normalize_shell_args(&json!({
1358            "command": "echo hi",
1359            "max_output_tokens": 42
1360        }))
1361        .expect("valid shell args");
1362
1363        assert_eq!(normalized.get("max_output_tokens").and_then(Value::as_u64), Some(42));
1364        assert_eq!(normalized.get("max_tokens").and_then(Value::as_u64), Some(42));
1365    }
1366
1367    #[test]
1368    fn normalize_shell_args_copies_max_tokens_to_max_output_tokens() {
1369        let normalized = normalize_shell_args(&json!({
1370            "command": "echo hi",
1371            "max_tokens": 42
1372        }))
1373        .expect("valid shell args");
1374
1375        assert_eq!(normalized.get("max_tokens").and_then(Value::as_u64), Some(42));
1376        assert_eq!(normalized.get("max_output_tokens").and_then(Value::as_u64), Some(42));
1377    }
1378
1379    #[test]
1380    fn command_session_missing_required_args_is_action_aware() {
1381        assert_eq!(command_session_missing_required_args(&json!({"action": "run"})), vec!["command"]);
1382        assert_eq!(
1383            command_session_missing_required_args(&json!({"action": "write", "session_id": "run-1"})),
1384            vec!["input or chars or text"]
1385        );
1386        assert_eq!(
1387            command_session_missing_required_args(&json!({"action": "inspect"})),
1388            vec!["session_id or spool_path"]
1389        );
1390        assert!(command_session_missing_required_args(&json!({"action": "list"})).is_empty());
1391    }
1392
1393    #[test]
1394    fn command_session_requires_command_safety_only_for_run() {
1395        assert!(command_session_requires_command_safety(&json!({
1396            "action": "run",
1397            "command": "cargo check"
1398        })));
1399        assert!(!command_session_requires_command_safety(&json!({
1400            "action": "poll",
1401            "session_id": "run-1"
1402        })));
1403    }
1404
1405    #[test]
1406    fn raw_command_text_extracts_command_string() {
1407        assert_eq!(raw_command_text(&json!({"command": "rg foo"})), Some("rg foo".to_string()));
1408        assert_eq!(raw_command_text(&json!({"cmd": "ls -la"})), Some("ls -la".to_string()));
1409        assert_eq!(
1410            raw_command_text(&json!({"command.0": "cat", "command.1": "file.txt"})),
1411            Some("cat file.txt".to_string())
1412        );
1413        assert_eq!(raw_command_text(&json!({"command": ["wc", "-l"]})), Some("wc -l".to_string()));
1414        assert!(raw_command_text(&json!({})).is_none());
1415    }
1416
1417    #[test]
1418    fn display_extraction_reports_key_and_preserves_precedence() {
1419        assert_eq!(
1420            extract_command_text_with_key(&json!({"command": ["bash", "-lc", "ls -R"]})),
1421            Some(("bash -lc ls -R".to_string(), "command"))
1422        );
1423        assert_eq!(
1424            extract_command_text_with_key(&json!({"bash_command": "pwd"})),
1425            Some(("pwd".to_string(), "bash_command"))
1426        );
1427        assert_eq!(
1428            extract_command_text_with_key(&json!({"raw_command": "cargo test"})),
1429            Some(("cargo test".to_string(), "raw_command"))
1430        );
1431        // Missing `command` must not block later keys (the copilot
1432        // early-return bug this canonical helper fixes).
1433        assert_eq!(extract_command_text_with_key(&json!({"cmd": "ls -la"})), Some(("ls -la".to_string(), "cmd")));
1434        // `command` trims; other keys keep raw emptiness semantics.
1435        assert_eq!(extract_command_text_with_key(&json!({"command": "  "})), None);
1436        // First non-empty key in canonical order wins.
1437        assert_eq!(
1438            extract_command_text_with_key(
1439                &json!({"raw_command": "cargo check -p vtcode", "bash_command": "cargo test"})
1440            ),
1441            Some(("cargo check -p vtcode".to_string(), "raw_command"))
1442        );
1443        assert!(extract_command_text_with_key(&json!({})).is_none());
1444    }
1445
1446    #[test]
1447    fn env_prefix_injection_keys_are_detected() {
1448        let words = |command: &str| -> Vec<String> { shell_words::split(command).expect("split command") };
1449
1450        // The A4 vector: config injection via env values reopens the blocked
1451        // `git -c` path without any `-c` token.
1452        assert!(environment_prefix_has_injection_keys(&words(
1453            "env GIT_CONFIG_COUNT=1 GIT_CONFIG_KEY_0=core.fsmonitor GIT_CONFIG_VALUE_0=touch git status"
1454        )));
1455        assert!(environment_prefix_has_injection_keys(&words(
1456            "GIT_CONFIG_COUNT=1 GIT_CONFIG_KEY_0=core.fsmonitor GIT_CONFIG_VALUE_0=touch git status"
1457        )));
1458        assert!(environment_prefix_has_injection_keys(&words("env GIT_EXTERNAL_DIFF=evil git diff")));
1459        assert!(environment_prefix_has_injection_keys(&words("env GIT_TEXTCONV=evil git log")));
1460        assert!(environment_prefix_has_injection_keys(&words("env LD_PRELOAD=./evil.so ls")));
1461        assert!(environment_prefix_has_injection_keys(&words("env BASH_ENV=./evil.sh bash -lc 'echo hi'")));
1462        assert!(environment_prefix_has_injection_keys(&words("env NODE_OPTIONS=--require ./evil.js node --version")));
1463        // `env -S` re-parses at runtime; cannot audit.
1464        assert!(environment_prefix_has_injection_keys(&words("env -S 'GIT_CONFIG_COUNT=1 git status'")));
1465
1466        // Benign env stays allowed.
1467        assert!(!environment_prefix_has_injection_keys(&words("env LANG=C.UTF-8 rg foo")));
1468        assert!(!environment_prefix_has_injection_keys(&words("FOO=bar git status")));
1469        assert!(!environment_prefix_has_injection_keys(&words("env FOO=bar git status")));
1470        // Unset is not an assignment.
1471        assert!(!environment_prefix_has_injection_keys(&words("env -u GIT_CONFIG_COUNT git status")));
1472        assert!(!environment_prefix_has_injection_keys(&words("git status")));
1473    }
1474
1475    #[test]
1476    fn env_prefix_single_scan_agrees_with_both_public_helpers() {
1477        let words = |command: &str| -> Vec<String> { shell_words::split(command).expect("split command") };
1478        for command in [
1479            "git status",
1480            "env LANG=C.UTF-8 rg foo",
1481            "FOO=bar LANG=C git status",
1482            "env FOO=bar git status",
1483            "env -- FOO=bar git status",
1484            "env -u GIT_CONFIG_COUNT git status",
1485            "env -C /tmp git status",
1486            "env GIT_EXTERNAL_DIFF=evil git diff",
1487            "GIT_CONFIG_COUNT=1 GIT_CONFIG_KEY_0=k GIT_CONFIG_VALUE_0=v git status",
1488            "env -S 'GIT_CONFIG_COUNT=1 git status'",
1489            "env --split-string='x=1 ls'",
1490        ] {
1491            let parsed = words(command);
1492            let scan = scan_environment_prefix(&parsed);
1493            assert_eq!(command_words_after_environment_prefix(&parsed), &parsed[scan.stripped_start..], "{command}");
1494            assert_eq!(environment_prefix_has_injection_keys(&parsed), scan.has_injection, "{command}");
1495        }
1496    }
1497
1498    #[test]
1499    fn env_prefix_empty_key_stays_fail_closed() {
1500        let words = |command: &str| -> Vec<String> { shell_words::split(command).expect("split command") };
1501        // `=foo` is not a valid assignment: the executable stays `=foo`
1502        // (unknown, therefore mutating downstream), never silently stripped.
1503        for command in ["=foo rg foo", "env =foo rg foo"] {
1504            let parsed = words(command);
1505            let stripped = command_words_after_environment_prefix(&parsed);
1506            assert_eq!(stripped.first().map(String::as_str), Some("=foo"), "{command}");
1507            assert!(!environment_prefix_has_injection_keys(&parsed), "{command}");
1508        }
1509    }
1510
1511    #[test]
1512    fn env_prefix_split_string_reports_empty_remainder_and_unsafe() {
1513        let words = |command: &str| -> Vec<String> { shell_words::split(command).expect("split command") };
1514        let parsed = words("env -S 'GIT_CONFIG_COUNT=1 git status'");
1515        assert!(command_words_after_environment_prefix(&parsed).is_empty());
1516        assert!(environment_prefix_has_injection_keys(&parsed));
1517        assert!(has_unsafe_readonly_options(&parsed));
1518    }
1519
1520    #[test]
1521    fn has_unsafe_readonly_options_flags_injection_without_second_walk() {
1522        let words = |command: &str| -> Vec<String> { shell_words::split(command).expect("split command") };
1523        assert!(has_unsafe_readonly_options(&words("env LD_PRELOAD=./evil.so ls")));
1524        assert!(has_unsafe_readonly_options(&words("LD_PRELOAD=./evil.so ls")));
1525        assert!(!has_unsafe_readonly_options(&words("env LANG=C.UTF-8 rg foo")));
1526        assert!(!has_unsafe_readonly_options(&words("git status")));
1527    }
1528
1529    #[test]
1530    fn dynamic_shell_syntax_allows_quoted_globs_only() {
1531        assert!(!contains_dynamic_shell_syntax("find src -name '*.rs'"));
1532        assert!(!contains_dynamic_shell_syntax("find src -name \"*.rs\""));
1533        assert!(contains_dynamic_shell_syntax("find src -exe$''c touch {} +"));
1534        assert!(contains_dynamic_shell_syntax("find src -ex{e,}c touch {} +"));
1535    }
1536}