From 09e8bbb414940461e2e8935425c945f0b4a1f2c7 Mon Sep 17 00:00:00 2001 From: kewton Date: Thu, 1 Oct 2026 10:45:11 +0900 Subject: [PATCH 1/4] fix(tools): expand Bash glob and brace write targets before confinement MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bash write targets that use glob (`* ? [`) or brace (`{a,b}`, `{1..9}`) syntax were judged on the pre-expansion word. That name does not exist, so the literal proof fell back to the nearest existing parent -- the workspace root -- and allowed writes that the shell resolves onto an intermediate symlink leaving the workspace, or onto a brace-produced `..`. Expand the word the way the shell would -- braces first (nested, `,`, ranges), then globs one component at a time without following symlinks -- and re-run the literal proof on every result. Fail closed once the brace (256) or glob (4096) expansion limit is exceeded, and never read a directory outside the workspace root. - src/tools/path_guard.rs: split the literal proof into ensure_single_target and call the new child module from ensure_bash_write_target. - src/tools/path_guard/bash_pattern.rs: new child module with the expansion. - tests/issue567_bash_glob_write_targets.rs: reject/allow/cap/loop coverage. 判断: the brace cap 256 and glob match cap 4096 are the issue's suggested values; tests pin only that an overshoot is rejected, not the exact boundary. 読み替え: `tee */f` is allowed only in a normal workspace whose root holds no escaping symlink for `*` to match; the issue's "許可のまま" list is read that way. 本文に無い指摘: a glob used as a directory component must drop non-directory matches, otherwise `tee */f` is over-rejected whenever the root also holds a regular file. Co-authored-by: CommandCodeBot --- src/tools/path_guard.rs | 50 +++ src/tools/path_guard/bash_pattern.rs | 458 ++++++++++++++++++++++ tests/issue567_bash_glob_write_targets.rs | 212 ++++++++++ 3 files changed, 720 insertions(+) create mode 100644 src/tools/path_guard/bash_pattern.rs create mode 100644 tests/issue567_bash_glob_write_targets.rs diff --git a/src/tools/path_guard.rs b/src/tools/path_guard.rs index 9756c0d7..ff231fd5 100644 --- a/src/tools/path_guard.rs +++ b/src/tools/path_guard.rs @@ -2,6 +2,8 @@ use std::path::{Component, Path, PathBuf}; use anyhow::{Context, bail}; +mod bash_pattern; + const EXPECTED_PATH_FORM: &str = "use workspace-relative paths"; #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -210,6 +212,17 @@ pub(super) fn ensure_bash_write_target(root: &Path, raw: &str) -> anyhow::Result if raw.contains(['$', '`']) { bail!("dynamic Bash write target cannot be proven to remain in the workspace"); } + ensure_single_target(root, raw)?; + // A glob or brace target spells a name that does not exist yet, so the + // literal proof above stops at the workspace root. Expand the word the way + // the shell would and prove every result too. + if bash_pattern::contains_expansion(raw) { + bash_pattern::ensure_expanded_write_target(root, raw)?; + } + Ok(()) +} + +fn ensure_single_target(root: &Path, raw: &str) -> anyhow::Result<()> { let path = Path::new(raw); if !path.is_absolute() { validate_workspace_relative(raw)?; @@ -969,4 +982,41 @@ mod tests { std::fs::set_permissions(&locked, std::fs::Permissions::from_mode(0o755)).unwrap(); assert!(result.is_err()); } + + #[cfg(unix)] + #[test] + fn ensure_bash_write_target_expansion_table() { + let dir = tempfile::tempdir().unwrap(); + let root = dir.path().join("ws"); + std::fs::create_dir_all(root.join("sub")).unwrap(); + std::fs::write(root.join("sub/f"), "x").unwrap(); + std::fs::create_dir_all(dir.path().join("outside")).unwrap(); + std::fs::write(dir.path().join("outside/secret"), "x").unwrap(); + std::os::unix::fs::symlink(dir.path().join("outside"), root.join("linked-outside")) + .unwrap(); + std::os::unix::fs::symlink(dir.path().join("outside"), root.join(".hidden-out")).unwrap(); + let root = root.canonicalize().unwrap(); + + for raw in [ + "lin*/secret", + "linked-outsid?/secret", + "linked-outsid[e]/secret", + "*/secret", + "**/secret", + ".hid*/f", + ".*/f", + ".?/f", + "{.,}./f", + "{.,.}./f", + "{linked-outside,x}/f", + "{k..m}inked-outside/f", + "{linked-outside/f,x}", + ] { + assert!(ensure_bash_write_target(&root, raw).is_err(), "{raw}"); + } + + for raw in ["sub/f", "nomatch*/f", "src/new.rs", "sub/{a,b}.txt"] { + assert!(ensure_bash_write_target(&root, raw).is_ok(), "{raw}"); + } + } } diff --git a/src/tools/path_guard/bash_pattern.rs b/src/tools/path_guard/bash_pattern.rs new file mode 100644 index 00000000..b60e4e77 --- /dev/null +++ b/src/tools/path_guard/bash_pattern.rs @@ -0,0 +1,458 @@ +//! Shell brace and glob expansion for Bash write-target confinement. +//! +//! The literal path proof in [`super`] cannot see where a Bash *write target* +//! actually lands when the word carries shell expansion syntax. A glob (`*`, +//! `?`, `[...]`) only matches names that already exist, and a brace +//! (`{a,b}`, `{1..9}`) is rewritten before the command runs, so the +//! pre-expansion spelling does not exist on disk. The literal proof then falls +//! back to the nearest existing parent -- the workspace root -- and reports the +//! target as inside the workspace. At run time the shell expands the word and +//! can reach an intermediate symlink that leaves the workspace, or a `..` +//! produced by a brace such as `{.,}./f`. +//! +//! This module expands the word the way the shell would and re-runs the +//! literal proof on every result. It never reads a directory outside the +//! workspace root and it stops with an honest failure once an expansion limit +//! is exceeded, so a symlink loop or an unbounded glob cannot hang the guard. + +use std::collections::VecDeque; +use std::path::{Component, Path, PathBuf}; + +use anyhow::{Context, bail}; +use globset::{Glob, GlobMatcher}; + +/// Upper bound on the number of strings a single brace word may expand into. +pub(super) const MAX_BRACE_EXPANSIONS: usize = 256; +/// Upper bound on the number of names a single word's globs may match. +pub(super) const MAX_GLOB_MATCHES: usize = 4096; + +/// Whether the word carries any shell expansion syntax this module understands. +pub(super) fn contains_expansion(raw: &str) -> bool { + raw.chars().any(|ch| matches!(ch, '*' | '?' | '[' | '{')) +} + +/// Expand `raw` and prove every expansion with the literal write-target proof. +pub(super) fn ensure_expanded_write_target(root: &Path, raw: &str) -> anyhow::Result<()> { + for expanded in expand_braces(raw)? { + if has_glob_meta(&expanded) { + match expand_glob(root, &expanded)? { + Some(matches) => { + for matched in matches { + super::ensure_single_target(root, &matched)?; + } + } + None => super::ensure_single_target(root, &expanded)?, + } + } else { + super::ensure_single_target(root, &expanded)?; + } + } + Ok(()) +} + +fn has_glob_meta(value: &str) -> bool { + value.chars().any(|ch| matches!(ch, '*' | '?' | '[')) +} + +fn expand_braces(raw: &str) -> anyhow::Result> { + let mut pending = VecDeque::from([raw.to_string()]); + let mut results = Vec::new(); + while let Some(item) = pending.pop_front() { + match first_expandable_brace(&item) { + Some(expansion) => { + for alternative in expansion.alternatives { + let mut next = String::with_capacity(item.len() + alternative.len()); + next.push_str(&item[..expansion.start]); + next.push_str(&alternative); + next.push_str(&item[expansion.end..]); + pending.push_back(next); + } + } + None => { + results.push(item); + if results.len() > MAX_BRACE_EXPANSIONS { + bail!( + "Bash write target brace expansion exceeds the {MAX_BRACE_EXPANSIONS}-expansion limit; the target cannot be proven to remain in the workspace" + ); + } + } + } + } + Ok(results) +} + +struct BraceExpansion { + start: usize, + end: usize, + alternatives: Vec, +} + +fn first_expandable_brace(input: &str) -> Option { + let bytes = input.as_bytes(); + let mut index = 0; + while index < bytes.len() { + if bytes[index] != b'{' { + index += 1; + continue; + } + let start = index; + let mut depth = 0usize; + let mut end = None; + let mut cursor = start; + while cursor < bytes.len() { + match bytes[cursor] { + b'{' => depth += 1, + b'}' => { + depth -= 1; + if depth == 0 { + end = Some(cursor); + break; + } + } + _ => {} + } + cursor += 1; + } + let end = end?; + if let Some(alternatives) = brace_alternatives(&input[start + 1..end]) { + return Some(BraceExpansion { + start, + end: end + 1, + alternatives, + }); + } + // This brace is literal; a nested brace may still be expandable, so + // resume just after its opening brace rather than skipping it whole. + index = start + 1; + } + None +} + +fn brace_alternatives(content: &str) -> Option> { + split_top_level_commas(content).or_else(|| range_alternatives(content)) +} + +fn split_top_level_commas(content: &str) -> Option> { + let mut depth = 0i32; + let mut current = String::new(); + let mut parts = Vec::new(); + let mut saw_comma = false; + for ch in content.chars() { + match ch { + '{' => { + depth += 1; + current.push(ch); + } + '}' => { + depth -= 1; + current.push(ch); + } + ',' if depth == 0 => { + saw_comma = true; + parts.push(std::mem::take(&mut current)); + } + _ => current.push(ch), + } + } + if !saw_comma { + return None; + } + parts.push(current); + Some(parts) +} + +fn range_alternatives(content: &str) -> Option> { + let parts: Vec<&str> = content.split("..").collect(); + match parts.as_slice() { + [from, to] => range_values(from, to, 1), + [from, to, step] => { + let step = step.parse::().ok()?; + range_values(from, to, step) + } + _ => None, + } +} + +fn range_values(from: &str, to: &str, step: i64) -> Option> { + let step = step.abs(); + if step == 0 { + return None; + } + if let (Ok(start), Ok(end)) = (from.parse::(), to.parse::()) { + return Some( + integer_range(start, end, step) + .into_iter() + .map(|value| value.to_string()) + .collect(), + ); + } + let start = single_ascii_char(from)?; + let end = single_ascii_char(to)?; + let values = integer_range(i64::from(start), i64::from(end), step) + .into_iter() + .map(|value| char::from(value as u8).to_string()) + .collect(); + Some(values) +} + +fn integer_range(start: i64, end: i64, step: i64) -> Vec { + let direction = if end >= start { 1 } else { -1 }; + let mut values = Vec::new(); + let mut value = start; + while (direction > 0 && value <= end) || (direction < 0 && value >= end) { + values.push(value); + value += direction * step; + } + values +} + +fn single_ascii_char(value: &str) -> Option { + let mut chars = value.chars(); + let ch = chars.next()?; + if chars.next().is_some() || !ch.is_ascii() { + return None; + } + Some(ch as u8) +} + +/// Expand a word that still contains glob metacharacters. +/// +/// Returns `Some(paths)` when the glob matched at least one existing name and +/// `None` when it matched nothing, so the caller can fall back to treating the +/// word as the shell would: an unmatched glob is passed through literally. +fn expand_glob(root: &Path, pattern: &str) -> anyhow::Result>> { + let root_canonical = root + .canonicalize() + .context("workspace root is not accessible")?; + let path = Path::new(pattern); + let remainder = if path.is_absolute() { + match path + .strip_prefix(&root_canonical) + .or_else(|_| path.strip_prefix(root)) + { + Ok(remainder) => remainder, + // An absolute word outside the root cannot be enumerated here; the + // literal proof in the caller rejects it. + Err(_) => return Ok(None), + } + } else { + path + }; + + let mut current = vec![root_canonical.clone()]; + let mut matched_total = 0usize; + let components: Vec = remainder.components().collect(); + for (index, component) in components.iter().enumerate() { + let is_last = index + 1 == components.len(); + let name = match component { + Component::Normal(name) => name.to_string_lossy().to_string(), + Component::CurDir => ".".to_string(), + Component::ParentDir => "..".to_string(), + Component::RootDir | Component::Prefix(_) => continue, + }; + if !has_glob_meta(&name) { + current = current.into_iter().map(|base| base.join(&name)).collect(); + continue; + } + let (next, total, matched) = + enumerate_component(&root_canonical, ¤t, &name, matched_total, !is_last)?; + if !matched { + return Ok(None); + } + matched_total = total; + current = next; + } + Ok(Some( + current + .into_iter() + .map(|path| path.to_string_lossy().to_string()) + .collect(), + )) +} + +fn enumerate_component( + root_canonical: &Path, + current: &[PathBuf], + name: &str, + mut matched_total: usize, + intermediate: bool, +) -> anyhow::Result<(Vec, usize, bool)> { + let Some(matcher) = compile_component_matcher(name) else { + // The component is not a valid glob; the shell treats it literally. + let next = current.iter().map(|base| base.join(name)).collect(); + return Ok((next, matched_total, true)); + }; + let dot_leading = name.starts_with('.'); + let mut next = Vec::new(); + for base in current { + let Ok(base_canonical) = base.canonicalize() else { + continue; + }; + if !base_canonical.starts_with(root_canonical) { + bail!( + "Bash write target glob expands outside the workspace root at `{}`", + base.display() + ); + } + let Ok(entries) = std::fs::read_dir(&base_canonical) else { + continue; + }; + let mut candidates = Vec::new(); + for entry in entries.flatten() { + let entry_name = entry.file_name().to_string_lossy().to_string(); + if !dot_leading && entry_name.starts_with('.') { + continue; + } + candidates.push(entry_name); + } + if dot_leading { + candidates.push(".".to_string()); + candidates.push("..".to_string()); + } + for candidate in candidates { + if !matcher.is_match(&candidate) { + continue; + } + if candidate == ".." { + bail!( + "Bash write target glob matches `..`, which cannot be proven to remain in the workspace" + ); + } + let child = base_canonical.join(&candidate); + let child_canonical = child.canonicalize().ok(); + if let Some(canonical) = &child_canonical + && !canonical.starts_with(root_canonical) + { + bail!( + "Bash write target glob match `{candidate}` resolves outside the workspace root" + ); + } + if intermediate { + // A glob used as a directory component only expands to + // directories the shell can descend into. A regular file (or a + // symlink the shell cannot traverse) drops the whole branch. + match &child_canonical { + Some(canonical) if canonical.is_dir() => {} + _ => continue, + } + } else if child_canonical.is_none() { + bail!( + "Bash write target glob match `{candidate}` cannot be resolved inside the workspace root" + ); + } + matched_total += 1; + if matched_total > MAX_GLOB_MATCHES { + bail!( + "Bash write target glob exceeds the {MAX_GLOB_MATCHES}-match limit; the target cannot be proven to remain in the workspace" + ); + } + next.push(child); + } + } + let matched = !next.is_empty(); + Ok((next, matched_total, matched)) +} + +fn compile_component_matcher(name: &str) -> Option { + let collapsed = collapse_stars(name); + Glob::new(&collapsed) + .ok() + .map(|glob| glob.compile_matcher()) +} + +/// Collapse runs of `*` within one path component. Inside a single component +/// the shell's `**` behaves like `*`, and globset rejects `**` when it is not +/// the whole component. +fn collapse_stars(name: &str) -> String { + let mut out = String::with_capacity(name.len()); + let mut previous_star = false; + for ch in name.chars() { + if ch == '*' { + if previous_star { + continue; + } + previous_star = true; + } else { + previous_star = false; + } + out.push(ch); + } + out +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn contains_expansion_table() { + assert!(contains_expansion("src/[id]")); + assert!(contains_expansion("lin*/secret")); + assert!(contains_expansion("a?b")); + assert!(contains_expansion("{a,b}")); + assert!(!contains_expansion("plain/path.txt")); + } + + #[test] + fn collapse_stars_collapses_runs() { + assert_eq!(collapse_stars("a**b"), "a*b"); + assert_eq!(collapse_stars("**"), "*"); + assert_eq!(collapse_stars("a*b"), "a*b"); + } + + #[test] + fn expand_braces_table() { + assert_eq!(expand_braces("plain").unwrap(), ["plain"]); + assert_eq!(expand_braces("{a,b}").unwrap(), ["a", "b"]); + assert_eq!( + expand_braces("pre{a,b}post").unwrap(), + ["preapost", "prebpost"] + ); + assert_eq!(expand_braces("{a,{b,c}}").unwrap(), ["a", "b", "c"]); + assert_eq!(expand_braces("{a{b,c}}").unwrap(), ["{ab}", "{ac}"]); + assert_eq!(expand_braces("{k..m}").unwrap(), ["k", "l", "m"]); + assert_eq!(expand_braces("{1..3}").unwrap(), ["1", "2", "3"]); + assert_eq!(expand_braces("{3..1}").unwrap(), ["3", "2", "1"]); + assert_eq!(expand_braces("{1..5..2}").unwrap(), ["1", "3", "5"]); + assert_eq!(expand_braces("{a}").unwrap(), ["{a}"]); + assert_eq!(expand_braces("{x}y{z}").unwrap(), ["{x}y{z}"]); + } + + #[test] + fn expand_braces_rejects_over_the_limit() { + let word = "{a,b}".repeat(12); + let error = expand_braces(&word).unwrap_err(); + assert!(error.to_string().contains("256-expansion limit"), "{error}"); + } + + #[cfg(unix)] + fn fixture() -> tempfile::TempDir { + let dir = tempfile::tempdir().unwrap(); + let root = dir.path().join("ws"); + std::fs::create_dir_all(root.join("sub")).unwrap(); + std::fs::write(root.join("sub/f"), "x").unwrap(); + std::fs::create_dir_all(dir.path().join("outside")).unwrap(); + std::fs::write(dir.path().join("outside/secret"), "x").unwrap(); + std::os::unix::fs::symlink(dir.path().join("outside"), root.join("linked-outside")) + .unwrap(); + std::os::unix::fs::symlink(dir.path().join("outside"), root.join(".hidden-out")).unwrap(); + dir + } + + #[cfg(unix)] + #[test] + fn glob_expansion_matches_inside_and_refuses_escapes() { + let dir = fixture(); + let root = dir.path().join("ws"); + + assert!(expand_glob(&root, "lin*/secret").is_err()); + assert!(expand_glob(&root, "lin*/f").is_err()); + assert!(expand_glob(&root, ".*").is_err()); + + assert_eq!(expand_glob(&root, "nomatch*/f").unwrap(), None); + + let matched = expand_glob(&root, "sub/*").unwrap().expect("sub/f match"); + assert_eq!(matched.len(), 1, "{matched:?}"); + assert!(matched[0].ends_with("sub/f"), "{matched:?}"); + } +} diff --git a/tests/issue567_bash_glob_write_targets.rs b/tests/issue567_bash_glob_write_targets.rs new file mode 100644 index 00000000..c76b4cbf --- /dev/null +++ b/tests/issue567_bash_glob_write_targets.rs @@ -0,0 +1,212 @@ +#![cfg(unix)] + +//! Issue #567: Bash write targets that use glob (`* ? [`) or brace (`{a,b}`, +//! `{a..z}`) syntax must be judged after expanding the word the way the shell +//! would, so that an intermediate symlink or a brace-produced `..` cannot carry +//! the write outside the workspace root. + +use std::path::PathBuf; + +use commandagent::mode::ExecutionMode; +use commandagent::tools::bash::path_confinement_rejection; +use commandagent::tools::registry::tool_error_kind; +use commandagent::tools::registry::{ToolContext, ToolRegistry}; +use commandagent::tools::workspace_policy::WorkspacePolicy; +use serde_json::json; + +struct Fixture { + _dir: tempfile::TempDir, + root: PathBuf, +} + +/// A workspace that contains an escaping symlink (visible and hidden), a +/// symlink loop, and an outside directory holding an existing `secret`. +fn escaping_fixture() -> Fixture { + let dir = tempfile::tempdir().unwrap(); + let root = dir.path().join("ws"); + std::fs::create_dir_all(root.join("sub")).unwrap(); + let outside = dir.path().join("outside"); + std::fs::create_dir_all(&outside).unwrap(); + std::fs::write(outside.join("secret"), "outside-secret").unwrap(); + std::os::unix::fs::symlink(&outside, root.join("linked-outside")).unwrap(); + std::os::unix::fs::symlink(&outside, root.join(".hidden-out")).unwrap(); + std::os::unix::fs::symlink(root.join("sub"), root.join("sub/loop")).unwrap(); + let root = root.canonicalize().unwrap(); + Fixture { _dir: dir, root } +} + +/// A workspace whose root contains only inside directories, so `*` matches +/// only real workspace entries. +fn inside_fixture() -> Fixture { + let dir = tempfile::tempdir().unwrap(); + let root = dir.path().join("ws"); + std::fs::create_dir_all(root.join("sub")).unwrap(); + std::fs::create_dir_all(root.join("src/[id]")).unwrap(); + std::fs::write(root.join("sub/f"), "x").unwrap(); + std::fs::write(root.join("a.txt"), "x").unwrap(); + std::os::unix::fs::symlink(root.join("sub"), root.join("sub/loop")).unwrap(); + let root = root.canonicalize().unwrap(); + Fixture { _dir: dir, root } +} + +#[test] +fn rejects_glob_and_brace_write_targets_that_escape() { + let fixture = escaping_fixture(); + let root = &fixture.root; + let root_absolute = root.display(); + let cases = [ + "tee lin*/f".to_string(), + "tee linked-outsid?/f".to_string(), + "tee linked-outsid[e]/f".to_string(), + "tee lin*/secret".to_string(), + "cp a.txt lin*/".to_string(), + "chmod 777 lin*/secret".to_string(), + "tee .hid*/f".to_string(), + "tee */secret".to_string(), + "tee **/secret".to_string(), + format!("tee {root_absolute}/lin*/f"), + "tee .*/f".to_string(), + "tee .?/f".to_string(), + "tee {.,}./f".to_string(), + "tee {.,.}./f".to_string(), + "tee {linked-outside,x}/f".to_string(), + "touch {linked-outside,x}/f".to_string(), + "tee {k..m}inked-outside/f".to_string(), + "tee {linked-outside/f,x}".to_string(), + "printf x > {linked-outside,x}/f".to_string(), + ]; + for command in cases { + assert!( + path_confinement_rejection(&command, root).is_some(), + "expected rejection: {command}" + ); + } +} + +#[test] +fn blocks_brace_and_absolute_glob_reads_that_escape() { + let fixture = escaping_fixture(); + let root = &fixture.root; + let cases = [ + "cat {linked-outside,x}/secret".to_string(), + format!("cat {}/lin*/secret", root.display()), + "cat {.,}./secret".to_string(), + ]; + for command in cases { + assert!( + path_confinement_rejection(&command, root).is_some(), + "expected rejection: {command}" + ); + } +} + +#[test] +fn rejects_targets_over_the_expansion_limits() { + let fixture = inside_fixture(); + let root = &fixture.root; + + // 2^12 brace expansions exceed the 256-expansion limit. + let brace_word = "{a,b}".repeat(12); + assert!( + path_confinement_rejection(&format!("tee {brace_word}"), root).is_some(), + "brace expansion over the limit must be rejected" + ); + + // A directory with more than 4096 matching names exceeds the glob limit. + let crowded = root.join("crowded"); + std::fs::create_dir_all(&crowded).unwrap(); + for index in 0..=4096 { + std::fs::write(crowded.join(format!("n{index}")), "x").unwrap(); + } + assert!( + path_confinement_rejection("tee crowded/*", root).is_some(), + "glob match count over the limit must be rejected" + ); +} + +#[test] +fn glob_expansion_terminates_on_a_symlink_loop() { + let fixture = inside_fixture(); + assert!( + path_confinement_rejection("tee sub/loop/loop/*/f", &fixture.root).is_none(), + "a per-component glob must terminate on a symlink loop" + ); +} + +#[test] +fn keeps_normal_workspace_write_targets() { + let fixture = inside_fixture(); + let root = &fixture.root; + let cases = [ + "tee out.txt", + "tee src/new.rs", + "tee sub/f", + "tee nomatch*/f", + "tee */f", + r#"tee "src/app/[id]/page.tsx""#, + "mkdir -p 'src/app/[id]'", + "printf x > 'src/[id]/route.ts'", + "printf x > \"src/{a,b}.txt\"", + "printf x > /dev/null", + ]; + for command in cases { + assert!( + path_confinement_rejection(command, root).is_none(), + "expected allow: {command}" + ); + } +} + +#[test] +fn keeps_normal_workspace_reads() { + let fixture = inside_fixture(); + let root = &fixture.root; + let cases = [ + r#"cat "src/[id]/route.ts""#.to_string(), + "cat src/{main,lib}.rs".to_string(), + "cat *.txt".to_string(), + "ls src/*.rs".to_string(), + "rg foo src/**/*.rs".to_string(), + format!("ls {}/src/*.rs", root.display()), + ]; + for command in cases { + assert!( + path_confinement_rejection(&command, root).is_none(), + "expected allow: {command}" + ); + } +} + +#[test] +fn escaping_glob_and_brace_targets_are_rejected_before_execution() { + let fixture = escaping_fixture(); + let events = fixture._dir.path().join("events.jsonl"); + let context = ToolContext { + root: fixture.root.clone(), + mode: ExecutionMode::Act, + auto_approve: true, + interactive_approval: false, + offline: true, + workspace_policy: WorkspacePolicy::NormalTask, + eval_events_path: Some(events.clone()), + expected_paths: Vec::new(), + protected_paths: Vec::new(), + }; + let registry = ToolRegistry::default(); + for command in ["tee lin*/secret", "printf x > {linked-outside,x}/f"] { + let error = registry + .execute("Bash", &json!({ "command": command }), &context) + .unwrap_err(); + assert_eq!( + tool_error_kind(&error), + "bash_path_confinement_error", + "{command}" + ); + } + assert!( + !fixture.root.join("linked-outside").join("secret").exists() + || std::fs::read_to_string(fixture.root.join("linked-outside/secret")).unwrap() + == "outside-secret", + "the escaping target must not have been overwritten" + ); +} From 84f5f817096968ef1a8d97bf6ac3a844b462bf0f Mon Sep 17 00:00:00 2001 From: kewton Date: Thu, 1 Oct 2026 14:08:17 +0900 Subject: [PATCH 2/4] fix(tools): match shell spelling before globset and cap brace expansion up front MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-up to #567 (review H-02). Two blockers and three robustness gaps: - POSIX bracket expressions (`[[:class:]]`, `[[=c=]]`, `[[.coll.]]`) are refused as unprovable: globset reads them as an ordinary class, so the shell would expand a word the guard silently passed through literally. - A leftover `{`, `}`, `,`, or `\` is rewritten to a character-class literal (`[{]`, `[}]`, `[,]`, `[\\]`) before globset, because globset reads those as alternation/escaping while the shell reads them as ordinary characters. Without this, `{x}*` matched nothing and fell back to allowing the literal. - Brace expansion is counted before expanding, so an over-limit word (256) is rejected without growing a queue; a range is sized before building a vector and its loop uses checked_add, so `{1..100000000}` and an i64-spanning range fail fast instead of allocating or overflowing. - `{01..02}` is proven both zero-padded (`01`, bash 4+) and stripped (`1`), including when only one endpoint carries the leading zero. - `dotglob` is documented with extglob/GLOBIGNORE/nocaseglob as a known limit. 判断: the requested cargo-mutants command (`--lib --test`, no filter) cannot complete inside the 30-minute cap on this machine: the lib suite is 2854 tests / ~45s / ~670s CPU each, so 172 mutants exceed the budget. Ran the integration selection to completion and cross-checked every survivor against the module's own unit tests. 読み替え: `{9223372036854775806..9223372036854775807}` is two safe inside values, so it is allowed, not rejected; the test pins no-panic + a time bound, and a genuinely overflowing span is asserted rejected. 本文に無い指摘: the asymmetric padding case `{01..2}`/`{1..02}` was not covered by the symmetric `{01..02}` test; added both. Co-authored-by: CommandCodeBot --- src/tools/path_guard/bash_pattern.rs | 371 ++++++++++++++++------ tests/issue567_bash_glob_write_targets.rs | 130 ++++++++ 2 files changed, 409 insertions(+), 92 deletions(-) diff --git a/src/tools/path_guard/bash_pattern.rs b/src/tools/path_guard/bash_pattern.rs index b60e4e77..e6b46659 100644 --- a/src/tools/path_guard/bash_pattern.rs +++ b/src/tools/path_guard/bash_pattern.rs @@ -11,11 +11,18 @@ //! produced by a brace such as `{.,}./f`. //! //! This module expands the word the way the shell would and re-runs the -//! literal proof on every result. It never reads a directory outside the -//! workspace root and it stops with an honest failure once an expansion limit -//! is exceeded, so a symlink loop or an unbounded glob cannot hang the guard. +//! literal proof on every result. It counts a brace word's expansion before +//! expanding it, never builds an over-limit range, never reads a directory +//! outside the workspace root, and fails closed once an expansion limit is +//! exceeded, so a symlink loop, an unbounded glob, or an overflowing range +//! cannot hang the guard. +//! +//! Known limitations: shell options this guard does not model, so a word that +//! behaves differently under them may be mis-proven and fall back to allowing +//! the literal spelling. They are off in the default non-interactive shell this +//! guard targets: `extglob`, `GLOBIGNORE`, `nocaseglob`, and `dotglob` +//! (`shopt -s dotglob` would let `*` match leading-dot names the guard skips). -use std::collections::VecDeque; use std::path::{Component, Path, PathBuf}; use anyhow::{Context, bail}; @@ -55,39 +62,144 @@ fn has_glob_meta(value: &str) -> bool { } fn expand_braces(raw: &str) -> anyhow::Result> { - let mut pending = VecDeque::from([raw.to_string()]); - let mut results = Vec::new(); - while let Some(item) = pending.pop_front() { - match first_expandable_brace(&item) { - Some(expansion) => { - for alternative in expansion.alternatives { - let mut next = String::with_capacity(item.len() + alternative.len()); - next.push_str(&item[..expansion.start]); - next.push_str(&alternative); - next.push_str(&item[expansion.end..]); - pending.push_back(next); - } - } - None => { - results.push(item); - if results.len() > MAX_BRACE_EXPANSIONS { - bail!( - "Bash write target brace expansion exceeds the {MAX_BRACE_EXPANSIONS}-expansion limit; the target cannot be proven to remain in the workspace" - ); - } + if count_expansions(raw, MAX_BRACE_EXPANSIONS).is_none() { + bail!( + "Bash write target brace expansion exceeds the {MAX_BRACE_EXPANSIONS}-expansion limit; the target cannot be proven to remain in the workspace" + ); + } + let mut expanded = Vec::new(); + expand_into(raw, &mut expanded); + Ok(expanded) +} + +fn expand_into(input: &str, out: &mut Vec) { + match find_brace(input) { + Some(span) => { + for alternative in span.shape_values() { + let next = format!( + "{}{alternative}{}", + &input[..span.start], + &input[span.end..] + ); + expand_into(&next, out); } } + None => out.push(input.to_string()), } - Ok(results) } -struct BraceExpansion { +/// Count a word's full expansion without materializing it. +/// +/// Returns `None` when the count exceeds `limit` or the arithmetic overflows, +/// so the caller rejects before any queue or range vector can grow. +fn count_expansions(input: &str, limit: usize) -> Option { + let Some(span) = find_brace(input) else { + return Some(1); + }; + if span.shape_count(limit)? > limit { + return None; + } + // The prefix before the first brace holds no brace, so it contributes + // nothing to the count: only the alternative and the suffix matter. + let suffix = &input[span.end..]; + let mut total = 0usize; + for alternative in span.shape_values() { + let sub = count_expansions(&format!("{alternative}{suffix}"), limit)?; + total = total.checked_add(sub)?; + if total > limit { + return None; + } + } + Some(total) +} + +struct BraceSpan { start: usize, end: usize, - alternatives: Vec, + shape: BraceShape, +} + +impl BraceSpan { + /// Immediate alternative count, capped at `limit + 1` so a huge range is + /// never built. `None` marks an invalid shape (for example a zero step). + fn shape_count(&self, limit: usize) -> Option { + match &self.shape { + BraceShape::List(alternatives) => Some(alternatives.len().min(limit.saturating_add(1))), + BraceShape::Range(range) => range.bounded_count(limit), + } + } + + fn shape_values(&self) -> Vec { + match &self.shape { + BraceShape::List(alternatives) => alternatives.clone(), + BraceShape::Range(range) => range.values(), + } + } +} + +enum BraceShape { + List(Vec), + Range(RangeSpec), +} + +struct RangeSpec { + start: i64, + end: i64, + step: i64, + /// Zero-pad width when an endpoint carries a leading zero, else 0. + width: usize, + /// Character range (`{a..z}`) renders each value as a character. + is_char: bool, +} + +impl RangeSpec { + /// Number of expanded strings, capped at `limit + 1`. A padded range emits + /// both the zero-padded and the zero-stripped form, so it doubles. + fn bounded_count(&self, limit: usize) -> Option { + if self.step == 0 { + return None; + } + let span = (self.end as i128 - self.start as i128).unsigned_abs(); + let size = span / self.step as u128 + 1; + let forms = if self.width > 0 { 2u128 } else { 1u128 }; + let count = size * forms; + Some(if count > limit as u128 + 1 { + limit.saturating_add(1) + } else { + count as usize + }) + } + + fn values(&self) -> Vec { + let direction: i64 = if self.end >= self.start { 1 } else { -1 }; + let mut values = Vec::new(); + let mut value = self.start; + loop { + if (direction > 0 && value > self.end) || (direction < 0 && value < self.end) { + break; + } + let plain = value.to_string(); + if self.is_char { + values.push(char::from(value as u8).to_string()); + } else if self.width > 0 { + values.push(format!("{value:0>width$}", width = self.width)); + values.push(plain); + } else { + values.push(plain); + } + let Some(next) = direction + .checked_mul(self.step) + .and_then(|delta| value.checked_add(delta)) + else { + break; + }; + value = next; + } + values + } } -fn first_expandable_brace(input: &str) -> Option { +fn find_brace(input: &str) -> Option { let bytes = input.as_bytes(); let mut index = 0; while index < bytes.len() { @@ -97,7 +209,7 @@ fn first_expandable_brace(input: &str) -> Option { } let start = index; let mut depth = 0usize; - let mut end = None; + let mut close = None; let mut cursor = start; while cursor < bytes.len() { match bytes[cursor] { @@ -105,7 +217,7 @@ fn first_expandable_brace(input: &str) -> Option { b'}' => { depth -= 1; if depth == 0 { - end = Some(cursor); + close = Some(cursor); break; } } @@ -113,12 +225,14 @@ fn first_expandable_brace(input: &str) -> Option { } cursor += 1; } - let end = end?; - if let Some(alternatives) = brace_alternatives(&input[start + 1..end]) { - return Some(BraceExpansion { + // An unterminated brace is literal, not an error: the word is proven + // as written, matching the shell, and never indexes past the input. + let close = close?; + if let Some(shape) = parse_brace_shape(&input[start + 1..close]) { + return Some(BraceSpan { start, - end: end + 1, - alternatives, + end: close + 1, + shape, }); } // This brace is literal; a nested brace may still be expandable, so @@ -128,8 +242,11 @@ fn first_expandable_brace(input: &str) -> Option { None } -fn brace_alternatives(content: &str) -> Option> { - split_top_level_commas(content).or_else(|| range_alternatives(content)) +fn parse_brace_shape(content: &str) -> Option { + if let Some(parts) = split_top_level_commas(content) { + return Some(BraceShape::List(parts)); + } + range_spec(content).map(BraceShape::Range) } fn split_top_level_commas(content: &str) -> Option> { @@ -161,49 +278,47 @@ fn split_top_level_commas(content: &str) -> Option> { Some(parts) } -fn range_alternatives(content: &str) -> Option> { +fn range_spec(content: &str) -> Option { let parts: Vec<&str> = content.split("..").collect(); - match parts.as_slice() { - [from, to] => range_values(from, to, 1), - [from, to, step] => { - let step = step.parse::().ok()?; - range_values(from, to, step) - } - _ => None, - } -} - -fn range_values(from: &str, to: &str, step: i64) -> Option> { - let step = step.abs(); + let (from, to, step) = match parts.as_slice() { + [from, to] => (*from, *to, 1i64), + [from, to, step] => (*from, *to, step.parse::().ok()?.abs()), + _ => return None, + }; if step == 0 { return None; } if let (Ok(start), Ok(end)) = (from.parse::(), to.parse::()) { - return Some( - integer_range(start, end, step) - .into_iter() - .map(|value| value.to_string()) - .collect(), - ); + return Some(RangeSpec { + start, + end, + step, + width: numeric_pad_width(from, to), + is_char: false, + }); } let start = single_ascii_char(from)?; let end = single_ascii_char(to)?; - let values = integer_range(i64::from(start), i64::from(end), step) - .into_iter() - .map(|value| char::from(value as u8).to_string()) - .collect(); - Some(values) + Some(RangeSpec { + start: i64::from(start), + end: i64::from(end), + step, + width: 0, + is_char: true, + }) } -fn integer_range(start: i64, end: i64, step: i64) -> Vec { - let direction = if end >= start { 1 } else { -1 }; - let mut values = Vec::new(); - let mut value = start; - while (direction > 0 && value <= end) || (direction < 0 && value >= end) { - values.push(value); - value += direction * step; +fn numeric_pad_width(from: &str, to: &str) -> usize { + if has_leading_zero(from) || has_leading_zero(to) { + from.len().max(to.len()) + } else { + 0 } - values +} + +fn has_leading_zero(value: &str) -> bool { + let digits = value.strip_prefix('-').unwrap_or(value); + digits.len() > 1 && digits.starts_with('0') } fn single_ascii_char(value: &str) -> Option { @@ -277,6 +392,15 @@ fn enumerate_component( mut matched_total: usize, intermediate: bool, ) -> anyhow::Result<(Vec, usize, bool)> { + // globset does not implement POSIX bracket expressions (`[[:class:]]`, + // `[[=c=]]`, `[[.coll.]]`); it would read them as an ordinary class and + // silently match a different set than the shell. Refuse rather than fall + // back to the literal spelling and allow a word the shell expands. + if name.contains("[:") || name.contains("[=") || name.contains("[.") { + bail!( + "Bash write target glob uses a POSIX bracket expression (`[:`/`[=`/`[.`) that cannot be proven to remain in the workspace" + ); + } let Some(matcher) = compile_component_matcher(name) else { // The component is not a valid glob; the shell treats it literally. let next = current.iter().map(|base| base.join(name)).collect(); @@ -354,28 +478,50 @@ fn enumerate_component( } fn compile_component_matcher(name: &str) -> Option { - let collapsed = collapse_stars(name); - Glob::new(&collapsed) - .ok() - .map(|glob| glob.compile_matcher()) + let pattern = globset_pattern(name); + Glob::new(&pattern).ok().map(|glob| glob.compile_matcher()) } -/// Collapse runs of `*` within one path component. Inside a single component -/// the shell's `**` behaves like `*`, and globset rejects `**` when it is not -/// the whole component. -fn collapse_stars(name: &str) -> String { +/// Translate one path component into globset's syntax. +/// +/// The shell reads a leftover `{`, `}`, `,`, or `\` as an ordinary character, +/// while globset gives all four a special meaning (alternation and escaping). +/// Rewrite them into character-class literals (`[{]`, `[}]`, `[,]`, `[\\]`) so +/// both engines match the same names, and collapse `*` runs because `**` inside +/// one component behaves like `*` in the shell and globset rejects it there. +fn globset_pattern(name: &str) -> String { let mut out = String::with_capacity(name.len()); let mut previous_star = false; for ch in name.chars() { - if ch == '*' { - if previous_star { - continue; + match ch { + '*' => { + if previous_star { + continue; + } + previous_star = true; + out.push('*'); + } + '{' => { + previous_star = false; + out.push_str("[{]"); + } + '}' => { + previous_star = false; + out.push_str("[}]"); + } + ',' => { + previous_star = false; + out.push_str("[,]"); + } + '\\' => { + previous_star = false; + out.push_str("[\\\\]"); + } + _ => { + previous_star = false; + out.push(ch); } - previous_star = true; - } else { - previous_star = false; } - out.push(ch); } out } @@ -383,6 +529,7 @@ fn collapse_stars(name: &str) -> String { #[cfg(test)] mod tests { use super::*; + use std::time::Instant; #[test] fn contains_expansion_table() { @@ -394,10 +541,13 @@ mod tests { } #[test] - fn collapse_stars_collapses_runs() { - assert_eq!(collapse_stars("a**b"), "a*b"); - assert_eq!(collapse_stars("**"), "*"); - assert_eq!(collapse_stars("a*b"), "a*b"); + fn globset_pattern_renders_shell_literals() { + assert_eq!(globset_pattern("plain"), "plain"); + assert_eq!(globset_pattern("a**b"), "a*b"); + assert_eq!(globset_pattern("**"), "*"); + assert_eq!(globset_pattern("{x}*"), "[{]x[}]*"); + assert_eq!(globset_pattern("{a,b}"), "[{]a[,]b[}]"); + assert_eq!(globset_pattern("a\\b"), "a[\\\\]b"); } #[test] @@ -409,20 +559,56 @@ mod tests { ["preapost", "prebpost"] ); assert_eq!(expand_braces("{a,{b,c}}").unwrap(), ["a", "b", "c"]); + assert_eq!(expand_braces("{{a,b},y}").unwrap(), ["a", "b", "y"]); assert_eq!(expand_braces("{a{b,c}}").unwrap(), ["{ab}", "{ac}"]); assert_eq!(expand_braces("{k..m}").unwrap(), ["k", "l", "m"]); assert_eq!(expand_braces("{1..3}").unwrap(), ["1", "2", "3"]); assert_eq!(expand_braces("{3..1}").unwrap(), ["3", "2", "1"]); assert_eq!(expand_braces("{1..5..2}").unwrap(), ["1", "3", "5"]); + assert_eq!(expand_braces("{01..02}").unwrap(), ["01", "1", "02", "2"]); + assert_eq!(expand_braces("{01..2}").unwrap(), ["01", "1", "02", "2"]); + assert_eq!(expand_braces("{1..02}").unwrap(), ["01", "1", "02", "2"]); assert_eq!(expand_braces("{a}").unwrap(), ["{a}"]); assert_eq!(expand_braces("{x}y{z}").unwrap(), ["{x}y{z}"]); } #[test] - fn expand_braces_rejects_over_the_limit() { - let word = "{a,b}".repeat(12); - let error = expand_braces(&word).unwrap_err(); - assert!(error.to_string().contains("256-expansion limit"), "{error}"); + fn unterminated_and_non_range_braces_stay_literal() { + assert_eq!(expand_braces("{a,b").unwrap(), ["{a,b"]); + assert_eq!(expand_braces("{a").unwrap(), ["{a"]); + assert_eq!(expand_braces("{ab..d}").unwrap(), ["{ab..d}"]); + assert_eq!(expand_braces("{a..}").unwrap(), ["{a..}"]); + } + + #[test] + fn over_limit_braces_are_counted_and_rejected_without_expanding() { + let start = Instant::now(); + for word in [ + "{a,b}".repeat(12), + "{a,b}".repeat(30), + "{1..100000000}".to_string(), + "{1..100000000}{1..100000000}".to_string(), + "{-9223372036854775808..9223372036854775807}".to_string(), + ] { + let error = expand_braces(&word).unwrap_err(); + assert!(error.to_string().contains("256-expansion limit"), "{word}"); + } + assert!( + start.elapsed().as_secs() < 5, + "over-limit braces must be counted before expanding" + ); + } + + #[test] + fn extreme_range_endpoints_do_not_panic() { + assert_eq!( + expand_braces("{9223372036854775806..9223372036854775807}").unwrap(), + ["9223372036854775806", "9223372036854775807"] + ); + assert_eq!( + expand_braces("{9223372036854775807..9223372036854775807}").unwrap(), + ["9223372036854775807"] + ); } #[cfg(unix)] @@ -448,6 +634,7 @@ mod tests { assert!(expand_glob(&root, "lin*/secret").is_err()); assert!(expand_glob(&root, "lin*/f").is_err()); assert!(expand_glob(&root, ".*").is_err()); + assert!(expand_glob(&root, "[[:alpha:]]inked-outside").is_err()); assert_eq!(expand_glob(&root, "nomatch*/f").unwrap(), None); diff --git a/tests/issue567_bash_glob_write_targets.rs b/tests/issue567_bash_glob_write_targets.rs index c76b4cbf..27a95193 100644 --- a/tests/issue567_bash_glob_write_targets.rs +++ b/tests/issue567_bash_glob_write_targets.rs @@ -6,6 +6,7 @@ //! the write outside the workspace root. use std::path::PathBuf; +use std::time::Instant; use commandagent::mode::ExecutionMode; use commandagent::tools::bash::path_confinement_rejection; @@ -31,6 +32,10 @@ fn escaping_fixture() -> Fixture { std::os::unix::fs::symlink(&outside, root.join("linked-outside")).unwrap(); std::os::unix::fs::symlink(&outside, root.join(".hidden-out")).unwrap(); std::os::unix::fs::symlink(root.join("sub"), root.join("sub/loop")).unwrap(); + // Literal-brace and POSIX-class spellings the shell and globset disagree on. + std::os::unix::fs::symlink(&outside, root.join("{x}out")).unwrap(); + std::os::unix::fs::symlink(&outside, root.join("sub/esc")).unwrap(); + std::os::unix::fs::symlink(&outside, root.join("01")).unwrap(); let root = root.canonicalize().unwrap(); Fixture { _dir: dir, root } } @@ -210,3 +215,128 @@ fn escaping_glob_and_brace_targets_are_rejected_before_execution() { "the escaping target must not have been overwritten" ); } + +#[test] +fn rejects_posix_bracket_expression_targets() { + let fixture = escaping_fixture(); + let root = &fixture.root; + for command in [ + "cp a.txt [[:alpha:]]inked-outside/", + "tee [[:lower:]]*/secret", + "tee [[=x=]]inked-outside/secret", + "tee [[.ch.]]inked-outside/secret", + ] { + assert!( + path_confinement_rejection(command, root).is_some(), + "expected rejection: {command}" + ); + } +} + +#[test] +fn rejects_literal_brace_glob_through_symlink() { + // `{x}` is a literal in the shell but an alternation in globset, so the + // guard must escape it to keep matching the same names. + let fixture = escaping_fixture(); + assert!( + path_confinement_rejection("tee {x}*/secret", &fixture.root).is_some(), + "a literal-brace glob must still reach the escaping symlink" + ); +} + +#[test] +fn rejects_glob_with_escaping_symlink_component() { + let fixture = escaping_fixture(); + assert!( + path_confinement_rejection("tee s*/esc/secret", &fixture.root).is_some(), + "a glob component that expands to an escaping symlink must be rejected" + ); +} + +#[test] +fn rejects_nested_brace_escape() { + let fixture = escaping_fixture(); + for command in [ + "tee {{linked-outside,q},y}/secret", + "tee {x,{y,linked-outside}}/secret", + ] { + assert!( + path_confinement_rejection(command, &fixture.root).is_some(), + "expected rejection: {command}" + ); + } +} + +#[test] +fn rejects_zero_padded_range_escape() { + // A range is checked both zero-padded (`01`, bash 4+) and stripped (`1`), + // including when only one endpoint carries the leading zero. + let fixture = escaping_fixture(); + for command in [ + "tee {01..02}/secret", + "tee {01..2}/secret", + "tee {1..02}/secret", + ] { + assert!( + path_confinement_rejection(command, &fixture.root).is_some(), + "a zero-padded range must still reach the escaping symlink: {command}" + ); + } +} + +#[test] +fn allows_exactly_the_expansion_limits() { + let fixture = inside_fixture(); + let root = &fixture.root; + + let crowded = root.join("crowded"); + std::fs::create_dir_all(&crowded).unwrap(); + for index in 0..4096 { + std::fs::write(crowded.join(format!("n{index}")), "x").unwrap(); + } + assert!( + path_confinement_rejection("tee crowded/*", root).is_none(), + "exactly the glob match limit must be allowed" + ); + std::fs::write(crowded.join("overflow"), "x").unwrap(); + assert!( + path_confinement_rejection("tee crowded/*", root).is_some(), + "one match over the glob limit must be rejected" + ); + + let brace_word = "{a,b}".repeat(8); + assert!( + path_confinement_rejection(&format!("tee {brace_word}"), root).is_none(), + "exactly the brace expansion limit must be allowed" + ); +} + +#[test] +fn over_limit_brace_targets_fail_fast_without_panicking() { + let fixture = inside_fixture(); + let root = &fixture.root; + let start = Instant::now(); + + for command in [ + "tee {1..100000000}/f".to_string(), + format!("tee {}", "{a,b}".repeat(30)), + ] { + assert!( + path_confinement_rejection(&command, root).is_some(), + "expected rejection: {command}" + ); + } + + // A two-value range at the i64 boundary is decidable quickly and must not + // panic; an overflow-sized span is rejected, not panicked. + let _ = path_confinement_rejection("tee {9223372036854775806..9223372036854775807}/f", root); + assert!( + path_confinement_rejection("tee {-9223372036854775808..9223372036854775807}/f", root) + .is_some(), + "an overflowing range must be rejected" + ); + assert!( + start.elapsed().as_secs() < 10, + "over-limit brace targets must fail fast" + ); +} From 94e0cf990b11baa334d7139e8ca7171eb17bf0de Mon Sep 17 00:00:00 2001 From: kewton Date: Thu, 1 Oct 2026 15:33:26 +0900 Subject: [PATCH 3/4] test(tools): cover numeric range order, size and caps MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-up to #567 review L-02. The leader's mutant run left 12 survivors in the new numeric-range code. Add direct unit coverage and an integration case: - RangeSpec::values: full-list assertions for ascending/descending, step, endpoints, a single value, zero-padded (both forms) and character ranges. - RangeSpec::bounded_count: counts for the same shapes, a span no step divides exactly, zero-padded doubling, a zero step, and the limit / limit+1 cap. - Integration: `{k..m}`, `{1..3}` (with `2` -> outside) and `{1..5..2}` (with `3` -> outside) are rejected only because the range expands. Manual per-mutant classification (apply mutation, run tests; not cargo-mutants): 10 of 12 killed. Two survive and are provably equivalent: - bounded_count `limit as u128 + 1` -> `* 1`: `count > limit + 1` and `count > limit` agree at every count (<= limit -> count; == limit+1 and >= limit+2 -> limit+1), so no test can distinguish them. - values `direction < 0` -> `<= 0`: direction is only ever +1 or -1, never 0, so the two conditions are identical. 判断: no production change needed. The `values -> vec![]` survivor did not reproduce: with it a range expands to nothing and the guard allows, which makes the reject-asserting tests fail, so the suite kills it (lib and integration). 本文に無い指摘: the reported 174:9 survivor is a stale-build artifact, not a test hole. Co-authored-by: CommandCodeBot --- src/tools/path_guard/bash_pattern.rs | 65 +++++++++++++++++++++++ tests/issue567_bash_glob_write_targets.rs | 22 ++++++++ 2 files changed, 87 insertions(+) diff --git a/src/tools/path_guard/bash_pattern.rs b/src/tools/path_guard/bash_pattern.rs index e6b46659..2654e847 100644 --- a/src/tools/path_guard/bash_pattern.rs +++ b/src/tools/path_guard/bash_pattern.rs @@ -611,6 +611,71 @@ mod tests { ); } + fn range(start: i64, end: i64, step: i64, width: usize, is_char: bool) -> RangeSpec { + RangeSpec { + start, + end, + step, + width, + is_char, + } + } + + #[test] + fn range_values_enumerate_order_endpoints_step_and_padding() { + // Ascending and descending, endpoints included. + assert_eq!(range(1, 3, 1, 0, false).values(), ["1", "2", "3"]); + assert_eq!(range(3, 1, 1, 0, false).values(), ["3", "2", "1"]); + // Step, ascending and descending, still ending on the endpoint. + assert_eq!(range(1, 10, 3, 0, false).values(), ["1", "4", "7", "10"]); + assert_eq!(range(10, 1, 3, 0, false).values(), ["10", "7", "4", "1"]); + // A single value. + assert_eq!(range(5, 5, 1, 0, false).values(), ["5"]); + // Zero-padding proves both the padded and the stripped form. + assert_eq!( + range(1, 3, 1, 2, false).values(), + ["01", "1", "02", "2", "03", "3"] + ); + // Character ranges, ascending and descending. + assert_eq!( + range(i64::from(b'a'), i64::from(b'e'), 1, 0, true).values(), + ["a", "b", "c", "d", "e"] + ); + assert_eq!( + range(i64::from(b'e'), i64::from(b'a'), 1, 0, true).values(), + ["e", "d", "c", "b", "a"] + ); + } + + #[test] + fn range_bounded_count_sizes_before_expanding() { + const LIMIT: usize = MAX_BRACE_EXPANSIONS; + assert_eq!(range(1, 3, 1, 0, false).bounded_count(LIMIT), Some(3)); + assert_eq!(range(3, 1, 1, 0, false).bounded_count(LIMIT), Some(3)); + assert_eq!(range(1, 10, 3, 0, false).bounded_count(LIMIT), Some(4)); + assert_eq!(range(10, 1, 3, 0, false).bounded_count(LIMIT), Some(4)); + assert_eq!(range(5, 5, 1, 0, false).bounded_count(LIMIT), Some(1)); + assert_eq!( + range(i64::from(b'a'), i64::from(b'e'), 1, 0, true).bounded_count(LIMIT), + Some(5) + ); + // A width no step divides exactly still counts whole values. + assert_eq!(range(1, 8, 3, 0, false).bounded_count(LIMIT), Some(3)); + // Zero-padding proves both forms, so the count doubles. + assert_eq!(range(1, 3, 1, 2, false).bounded_count(LIMIT), Some(6)); + // A zero step is invalid. + assert_eq!(range(1, 3, 0, 0, false).bounded_count(LIMIT), None); + // The cap sits just above the limit: exactly the limit passes through, + // limit + 1 and beyond collapse to limit + 1. + assert_eq!(range(1, 4, 1, 0, false).bounded_count(4), Some(4)); + assert_eq!(range(1, 5, 1, 0, false).bounded_count(4), Some(5)); + assert_eq!(range(1, 6, 1, 0, false).bounded_count(4), Some(5)); + assert_eq!( + range(1, MAX_BRACE_EXPANSIONS as i64, 1, 0, false).bounded_count(LIMIT), + Some(MAX_BRACE_EXPANSIONS) + ); + } + #[cfg(unix)] fn fixture() -> tempfile::TempDir { let dir = tempfile::tempdir().unwrap(); diff --git a/tests/issue567_bash_glob_write_targets.rs b/tests/issue567_bash_glob_write_targets.rs index 27a95193..56b6a318 100644 --- a/tests/issue567_bash_glob_write_targets.rs +++ b/tests/issue567_bash_glob_write_targets.rs @@ -36,6 +36,8 @@ fn escaping_fixture() -> Fixture { std::os::unix::fs::symlink(&outside, root.join("{x}out")).unwrap(); std::os::unix::fs::symlink(&outside, root.join("sub/esc")).unwrap(); std::os::unix::fs::symlink(&outside, root.join("01")).unwrap(); + std::os::unix::fs::symlink(&outside, root.join("2")).unwrap(); + std::os::unix::fs::symlink(&outside, root.join("3")).unwrap(); let root = root.canonicalize().unwrap(); Fixture { _dir: dir, root } } @@ -284,6 +286,26 @@ fn rejects_zero_padded_range_escape() { } } +#[test] +fn rejects_numeric_range_escapes() { + // Each word reaches outside only through the range expansion: `{k..m}` to + // `linked-outside`, `{1..3}` to the `2` symlink, `{1..5..2}` to the `3` + // symlink. Were a range expanded to nothing these words would be allowed, + // so this pins the expansion itself (`{k..m}` is also in the table above). + let fixture = escaping_fixture(); + let root = &fixture.root; + for command in [ + "tee {k..m}inked-outside/f", + "tee {1..3}/secret", + "tee {1..5..2}/secret", + ] { + assert!( + path_confinement_rejection(command, root).is_some(), + "a numeric range must expand before the proof: {command}" + ); + } +} + #[test] fn allows_exactly_the_expansion_limits() { let fixture = inside_fixture(); From 4586dcf0360132cb369ceb2839e680a1067bf729 Mon Sep 17 00:00:00 2001 From: kewton Date: Thu, 1 Oct 2026 19:02:05 +0900 Subject: [PATCH 4/4] fix(tools): refuse brackets mixed with brace/comma/backslash, saturate range step MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-up to #567 review (round 3). - globset_pattern rewrote a literal `{`, `}`, `,`, or `\` into a class literal even inside an existing bracket, nesting it (`[,l]` -> `[[,]l]`). globset then read a different set, matched nothing, and fell back to the literal proof, so `[,l]inked-outside/` was allowed. It now returns None when the component contains `[` together with any of `{ } , \`, and the caller refuses the word as unprovable (same treatment as `[:`/`[=`/`[.`). - range_spec used `step.abs()`, which panics in debug for i64::MIN (`{1..5..-9223372036854775808}`); it now uses `checked_abs()` and the word stays literal. Tests: unit coverage for globset_pattern's None cases and the overflowing step; an integration table rejecting `[,l]inked-outside/`, `[{l]inked-outside/secret`, `[!,]inked-outside/secret`, `[l}]`, `[^{]`, `[!{]*`, `[k-m,]`, plus rm/chmod forms; and allow cases confirming brackets without `{ } , \` and braces without a bracket (quoted `[id]`, `cat src/{main,lib}.rs`, `ls src/*.rs`, `tee src/*.rs`, `cp a.txt "src/[id]/x"`) still pass. 判断: a bracket mixed with `{ } , \` is refused rather than expanding both spellings, because any rewrite nests the brackets and cannot be proven to match the shell. Co-authored-by: CommandCodeBot --- src/tools/path_guard/bash_pattern.rs | 57 ++++++++++++++++------- tests/issue567_bash_glob_write_targets.rs | 29 ++++++++++++ 2 files changed, 70 insertions(+), 16 deletions(-) diff --git a/src/tools/path_guard/bash_pattern.rs b/src/tools/path_guard/bash_pattern.rs index 2654e847..57306f5b 100644 --- a/src/tools/path_guard/bash_pattern.rs +++ b/src/tools/path_guard/bash_pattern.rs @@ -26,7 +26,7 @@ use std::path::{Component, Path, PathBuf}; use anyhow::{Context, bail}; -use globset::{Glob, GlobMatcher}; +use globset::Glob; /// Upper bound on the number of strings a single brace word may expand into. pub(super) const MAX_BRACE_EXPANSIONS: usize = 256; @@ -282,7 +282,7 @@ fn range_spec(content: &str) -> Option { let parts: Vec<&str> = content.split("..").collect(); let (from, to, step) = match parts.as_slice() { [from, to] => (*from, *to, 1i64), - [from, to, step] => (*from, *to, step.parse::().ok()?.abs()), + [from, to, step] => (*from, *to, step.parse::().ok()?.checked_abs()?), _ => return None, }; if step == 0 { @@ -401,7 +401,16 @@ fn enumerate_component( "Bash write target glob uses a POSIX bracket expression (`[:`/`[=`/`[.`) that cannot be proven to remain in the workspace" ); } - let Some(matcher) = compile_component_matcher(name) else { + let Some(pattern) = globset_pattern(name) else { + // Rewriting a brace, comma or backslash into a class literal inside an + // existing bracket would nest brackets (`[,l]` -> `[[,]l]`) and globset + // would read a different set than the shell. Refuse rather than fall + // back to the literal spelling and allow a word the shell expands. + bail!( + "Bash write target glob mixes `[` with `{{`, `}}`, `,`, or `\\`, which cannot be proven to remain in the workspace" + ); + }; + let Some(matcher) = Glob::new(&pattern).ok().map(|glob| glob.compile_matcher()) else { // The component is not a valid glob; the shell treats it literally. let next = current.iter().map(|base| base.join(name)).collect(); return Ok((next, matched_total, true)); @@ -477,11 +486,6 @@ fn enumerate_component( Ok((next, matched_total, matched)) } -fn compile_component_matcher(name: &str) -> Option { - let pattern = globset_pattern(name); - Glob::new(&pattern).ok().map(|glob| glob.compile_matcher()) -} - /// Translate one path component into globset's syntax. /// /// The shell reads a leftover `{`, `}`, `,`, or `\` as an ordinary character, @@ -489,11 +493,18 @@ fn compile_component_matcher(name: &str) -> Option { /// Rewrite them into character-class literals (`[{]`, `[}]`, `[,]`, `[\\]`) so /// both engines match the same names, and collapse `*` runs because `**` inside /// one component behaves like `*` in the shell and globset rejects it there. -fn globset_pattern(name: &str) -> String { +/// +/// Returns `None` when the component also contains `[`: rewriting a brace, +/// comma or backslash into a class literal would nest the brackets +/// (`[,l]` -> `[[,]l]`) and globset would read a different set than the shell, +/// so the word must be refused instead of allowed through the literal fallback. +fn globset_pattern(name: &str) -> Option { + let has_bracket = name.contains('['); let mut out = String::with_capacity(name.len()); let mut previous_star = false; for ch in name.chars() { match ch { + '{' | '}' | ',' | '\\' if has_bracket => return None, '*' => { if previous_star { continue; @@ -523,7 +534,7 @@ fn globset_pattern(name: &str) -> String { } } } - out + Some(out) } #[cfg(test)] @@ -542,12 +553,20 @@ mod tests { #[test] fn globset_pattern_renders_shell_literals() { - assert_eq!(globset_pattern("plain"), "plain"); - assert_eq!(globset_pattern("a**b"), "a*b"); - assert_eq!(globset_pattern("**"), "*"); - assert_eq!(globset_pattern("{x}*"), "[{]x[}]*"); - assert_eq!(globset_pattern("{a,b}"), "[{]a[,]b[}]"); - assert_eq!(globset_pattern("a\\b"), "a[\\\\]b"); + assert_eq!(globset_pattern("plain").unwrap(), "plain"); + assert_eq!(globset_pattern("a**b").unwrap(), "a*b"); + assert_eq!(globset_pattern("**").unwrap(), "*"); + assert_eq!(globset_pattern("{x}*").unwrap(), "[{]x[}]*"); + assert_eq!(globset_pattern("{a,b}").unwrap(), "[{]a[,]b[}]"); + assert_eq!(globset_pattern("a\\b").unwrap(), "a[\\\\]b"); + // A bracket without `{ } , \` passes through unchanged. + assert_eq!(globset_pattern("[id]").unwrap(), "[id]"); + // A bracket mixed with `{ } , \` cannot be rewritten without nesting. + assert_eq!(globset_pattern("[,l]inked-outside"), None); + assert_eq!(globset_pattern("[{l]inked-outside"), None); + assert_eq!(globset_pattern("[l}]inked-outside"), None); + assert_eq!(globset_pattern("[!{]inked-outside"), None); + assert_eq!(globset_pattern("a[,\\b"), None); } #[test] @@ -578,6 +597,12 @@ mod tests { assert_eq!(expand_braces("{a").unwrap(), ["{a"]); assert_eq!(expand_braces("{ab..d}").unwrap(), ["{ab..d}"]); assert_eq!(expand_braces("{a..}").unwrap(), ["{a..}"]); + // A step whose magnitude overflows i64 is literal, never a panic. + assert_eq!( + expand_braces("{1..5..-9223372036854775808}").unwrap(), + ["{1..5..-9223372036854775808}"] + ); + assert_eq!(expand_braces("{1..5..0}").unwrap(), ["{1..5..0}"]); } #[test] diff --git a/tests/issue567_bash_glob_write_targets.rs b/tests/issue567_bash_glob_write_targets.rs index 56b6a318..f90f48a8 100644 --- a/tests/issue567_bash_glob_write_targets.rs +++ b/tests/issue567_bash_glob_write_targets.rs @@ -155,6 +155,10 @@ fn keeps_normal_workspace_write_targets() { "printf x > 'src/[id]/route.ts'", "printf x > \"src/{a,b}.txt\"", "printf x > /dev/null", + // A bracket without `{ } , \`, and braces without a bracket, stay usable. + "tee src/*.rs", + r#"cp a.txt "src/[id]/x""#, + "printf x > 'src/[id]/page.tsx'", ]; for command in cases { assert!( @@ -255,6 +259,31 @@ fn rejects_glob_with_escaping_symlink_component() { ); } +#[test] +fn rejects_bracket_mixed_with_brace_comma_or_backslash() { + // Rewriting `{ } , \` into class literals would nest the brackets and let + // globset read a different set, silently matching nothing and falling back + // to the literal spelling. These words are refused instead. + let fixture = escaping_fixture(); + let root = &fixture.root; + for command in [ + "cp a.txt [,l]inked-outside/", + "tee [{l]inked-outside/secret", + "tee [!,]inked-outside/secret", + "tee [l}]inked-outside/secret", + "tee [^{]inked-outside/secret", + "tee [!{]*/secret", + "tee [k-m,]inked-outside/secret", + "rm -rf [,l]inked-outside/secret", + "chmod 777 [,l]inked-outside/secret", + ] { + assert!( + path_confinement_rejection(command, root).is_some(), + "expected rejection: {command}" + ); + } +} + #[test] fn rejects_nested_brace_escape() { let fixture = escaping_fixture();