Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -136,7 +136,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
placeholder while waiting for a branch-specific CNB mirror (#5547).
- Fleet roster members in a selected Fleet now expose a visible edit affordance
and a direct `m` model-picker shortcut, while the coordinator row remains
read-only (#5589).
read-only (#5604, covers #5589).
- The goal-continuation quiet period (`[goal] continuation_delay_seconds`,
added in #5508) now applies on every dispatch path. Previously the
within-turn dispatch hook fired the next continuation prompt immediately
Expand Down
2 changes: 1 addition & 1 deletion crates/tui/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -136,7 +136,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
placeholder while waiting for a branch-specific CNB mirror (#5547).
- Fleet roster members in a selected Fleet now expose a visible edit affordance
and a direct `m` model-picker shortcut, while the coordinator row remains
read-only (#5589).
read-only (#5604, covers #5589).
- The goal-continuation quiet period (`[goal] continuation_delay_seconds`,
added in #5508) now applies on every dispatch path. Previously the
within-turn dispatch hook fired the next continuation prompt immediately
Expand Down
80 changes: 78 additions & 2 deletions crates/tui/src/command_safety.rs
Original file line number Diff line number Diff line change
Expand Up @@ -405,10 +405,66 @@ const GITHUB_READONLY_PREFIXES: &[&str] = &[
"gh workflow view",
];

/// Normalize Windows absolute path spellings before any POSIX-style splitter
/// (`shlex` / `shell_words`) or glob-charset gate in this module:
///
/// - `Path::canonicalize` on Windows embeds the verbatim prefix `\\?\C:\...`
/// whose `?` trips the glob-charset gates and whose backslashes the POSIX
/// splitters eat as escapes; strip it so the remaining spelling resolves to
/// the same location (device `\\.\` paths are preserved verbatim);
/// - double the backslashes of Windows-absolute-path-like words so the
/// splitters round-trip the real path instead of `C:\Users\...` collapsing
/// to `C:Users...`.
///
/// Words that do not look like Windows absolute paths are untouched, so POSIX
/// escapes and unix hosts are unaffected.
pub(crate) fn normalize_windows_command_paths(command: &str) -> String {
let stripped = command.replace(r"\\?\", "");
let mut out = String::with_capacity(stripped.len());
let mut word_start = 0;
let bytes = stripped.as_bytes();
let mut i = 0;
while i < bytes.len() {
if bytes[i].is_ascii_whitespace() {
let word = &stripped[word_start..i];
if looks_like_windows_absolute_path(word) {
out.push_str(&word.replace('\\', r"\\"));
} else {
out.push_str(word);
}
out.push(bytes[i] as char);
word_start = i + 1;
}
i += 1;
}
if word_start < bytes.len() {
let word = &stripped[word_start..];
if looks_like_windows_absolute_path(word) {
out.push_str(&word.replace('\\', r"\\"));
} else {
out.push_str(word);
}
}
out
}

/// A whitespace-delimited word is treated as a Windows absolute path when it
/// starts (after optional quotes) with a drive letter plus colon, a verbatim
/// (`\\?\`/`\\.\`) prefix, or a UNC (`\\`) prefix.
fn looks_like_windows_absolute_path(word: &str) -> bool {
let word = word.trim_start_matches(['\'', '"']);
let bytes = word.as_bytes();
(bytes.len() >= 2 && bytes[0].is_ascii_alphabetic() && bytes[1] == b':')
|| word.starts_with(r"\\?\")
|| word.starts_with(r"\\.\")
|| word.starts_with("\\\\")
}

/// Return `true` when a shell command is safe to auto-approve and run in a
/// parallel read-only chunk.
pub fn is_parallel_readonly_command(command: &str) -> bool {
let trimmed = command.trim();
let trimmed = normalize_windows_command_paths(command);
let trimmed = trimmed.trim();
if trimmed.is_empty() {
return false;
}
Expand Down Expand Up @@ -522,7 +578,8 @@ fn readonly_tokens_admitted(trimmed: &str) -> bool {
/// redirects, backgrounding, command/parameter expansion, subshells, or
/// env-assignment prefixes.
pub fn is_agent_readonly_shell_command(command: &str) -> bool {
let trimmed = command.trim();
let trimmed = normalize_windows_command_paths(command);
let trimmed = trimmed.trim();
if trimmed.is_empty() {
return false;
}
Expand Down Expand Up @@ -2042,6 +2099,25 @@ mod tests {
}
}

#[test]
fn agent_readonly_shell_admits_windows_verbatim_paths() {
// `Path::canonicalize` on Windows embeds `\\?\` verbatim prefixes whose
// `?` trips the glob-charset gate and whose backslashes POSIX splitters
// eat as escapes. The normalize step must admit the same commands with
// either spelling (the classifier is pure string logic, so this is
// platform-independent).
for command in [
r"git -C \\?\C:\Users\foo log --oneline -20",
r"git -C C:\Users\foo log --oneline -20",
"git -C crates/tui log --oneline -n 5",
] {
assert!(
is_agent_readonly_shell_command(command),
"{command} should be agent read-only"
);
}
}

#[test]
fn agent_readonly_shell_rejects_mutation_and_injection() {
for command in [
Expand Down
6 changes: 3 additions & 3 deletions crates/tui/src/tools/shell.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3356,7 +3356,7 @@ pub fn new_shared_shell_manager(workspace: PathBuf) -> SharedShellManager {

use crate::command_safety::{
SafetyLevel, analyze_command, extract_primary_command, is_agent_readonly_shell_command,
is_github_readonly_command, is_parallel_readonly_command,
is_github_readonly_command, is_parallel_readonly_command, normalize_windows_command_paths,
};
use crate::execpolicy::{ExecPolicyDecision, load_default_policy};
use crate::features::Feature;
Expand Down Expand Up @@ -3764,7 +3764,7 @@ fn exec_shell_input_is_parallel_readonly_shape(input: &serde_json::Value) -> boo
}

fn hardened_readonly_argv(command: &str) -> Result<(String, Vec<String>)> {
let mut argv = shell_words::split(command)
let mut argv = shell_words::split(&normalize_windows_command_paths(command))
.map_err(|error| anyhow!("could not parse classifier-approved read command: {error}"))?;
if argv.is_empty() {
return Err(anyhow!("classifier-approved read command was empty"));
Expand Down Expand Up @@ -3829,7 +3829,7 @@ fn enforce_readonly_workspace_operands(
workspace: &std::path::Path,
effective_cwd: &std::path::Path,
) -> Result<(), ToolError> {
let argv = shell_words::split(command).map_err(|error| {
let argv = shell_words::split(&normalize_windows_command_paths(command)).map_err(|error| {
ToolError::invalid_input(format!(
"Could not parse read-only command arguments: {error}"
))
Expand Down
42 changes: 42 additions & 0 deletions crates/tui/src/tools/shell/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1020,6 +1020,48 @@ fn readonly_operands_are_workspace_bounded_and_symlink_aware() {
}
}

#[test]
fn windows_verbatim_and_drive_operands_survive_posix_split() {
// `shell_words` splits with POSIX backslash-escaping, which silently eats
// the separators of Windows absolute paths (`C:\Users\...` becomes
// `C:Users...`) and mangles the `\\?\` verbatim prefix before operand
// classification can see it. The protection doubles those backslashes so
// the splitter round-trips the real path (the `\\?\` cases that the
// verbatim strip alone could never reach).
for (raw, expected) in [
(r"\\?\C:\Users\foo\inside.txt", r"C:\Users\foo\inside.txt"),
(r"C:\Users\foo\inside.txt", r"C:\Users\foo\inside.txt"),
(r"\\server\share\secret", r"\\server\share\secret"),
(r"\\.\device\path", r"\\.\device\path"),
] {
let protected = normalize_windows_command_paths(&format!("cat {raw}"));
let argv = shell_words::split(&protected).expect("split must succeed");
assert_eq!(
argv,
vec!["cat".to_string(), expected.to_string()],
"{raw} must survive the POSIX split"
);
}
}

#[test]
fn windows_path_protection_leaves_other_words_untouched() {
// POSIX escapes, drive-relative spellings, and plain relative operands
// are not Windows absolute paths and must round-trip unchanged.
assert_eq!(
normalize_windows_command_paths("echo a\\ b && cat inside.txt"),
"echo a\\ b && cat inside.txt"
);
assert_eq!(
normalize_windows_command_paths("cat C:secret"),
"cat C:secret"
);
assert_eq!(
normalize_windows_command_paths("cat inside.txt"),
"cat inside.txt"
);
}

#[test]
fn readonly_github_shell_calls_obey_the_host_network_policy_before_spawn() {
let tmp = tempdir().expect("tempdir");
Expand Down
Loading