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
71 changes: 71 additions & 0 deletions src/tools/bash_write_guard.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
use std::path::Path;

mod ansi_c_quoting;
mod command_prefix;

#[derive(Debug, Clone, PartialEq, Eq)]
Expand Down Expand Up @@ -29,6 +30,16 @@ pub(super) fn confinement_rejection(
root: &Path,
) -> Option<BashWriteConfinementRejection> {
for target in write_targets(command) {
if target.operation == ansi_c_quoting::OPERATION {
return Some(BashWriteConfinementRejection {
reason: format!(
"Bash command uses ANSI-C or locale quoting `{}`, whose expansion cannot be verified to remain in the Gate 1 workspace boundary; rewrite it with a literal escape (for example `printf 'a\\tb\\n'` or `grep -P '\\t'`)",
target.path
),
path: target.path,
operation: target.operation,
});
}
if target.operation == command_prefix::UNVERIFIABLE_SPLIT_STRING_OPERATION {
return Some(BashWriteConfinementRejection {
reason: format!(
Expand Down Expand Up @@ -116,6 +127,12 @@ fn path_matches(candidate: &Path, protected: &Path) -> bool {
}

fn write_targets(command: &str) -> Vec<WriteTarget> {
if let Some(kind) = ansi_c_quoting::outside_quotes(command) {
return vec![WriteTarget {
path: kind.introducer().to_string(),
operation: ansi_c_quoting::OPERATION.to_string(),
}];
}
let Some(tokens) = shell_tokens(command) else {
return Vec::new();
};
Expand Down Expand Up @@ -984,4 +1001,58 @@ mod tests {
);
assert!(protected_path_mutation("env FOO=1 cargo test", &root, &protected).is_none());
}

#[test]
fn ansi_c_quoting_is_a_recognized_mutation() {
assert!(has_recognized_mutation("$'tee' a.txt"));
assert!(has_recognized_mutation("$\"tee\" a.txt"));
assert!(has_recognized_mutation("X=$'tee'"));
assert!(!has_recognized_mutation("echo \"$'x'\""));
assert!(!has_recognized_mutation("echo '$'"));
assert!(!has_recognized_mutation("echo \\$'x'"));
}

#[test]
fn ansi_c_quoting_write_targets_record_the_operation() {
assert_eq!(
write_target_pairs("$'tee' a.txt"),
expected_targets(&[("$'", ansi_c_quoting::OPERATION)])
);
assert_eq!(
write_target_pairs("$\"tee\" a.txt"),
expected_targets(&[("$\"", ansi_c_quoting::OPERATION)])
);
}

#[test]
fn ansi_c_quoting_confinement_rejection_table() {
let fixture = tempfile::tempdir().unwrap();
let root = fixture.path().join("workspace");
std::fs::create_dir_all(&root).unwrap();

for command in [
"$'tee' /tmp/f",
"env $'tee' /tmp/f",
"env $'-S' 'tee /tmp/f'",
"$\"tee\" /tmp/f",
"X=$'tee'",
] {
assert!(
confinement_rejection(command, &root).is_some(),
"command must be rejected: {command}"
);
}

for command in [
"echo \"$'x'\"",
"echo '$'",
"echo \\$'x'",
"printf 'a\\tb\\n' > out.txt",
] {
assert!(
confinement_rejection(command, &root).is_none(),
"command must stay allowed: {command}"
);
}
}
}
130 changes: 130 additions & 0 deletions src/tools/bash_write_guard/ansi_c_quoting.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
//! ANSI-C (`$'...'`) and locale (`$"..."`) quoting outside shell quotes
//! (Issue #575).
//!
//! The write-target inspector's lexical analysis in the parent module does not
//! know `$'...'` and `$"..."`: it keeps the `$` as an ordinary character and
//! strips the following quotes as ordinary quotes, so `$'tee'` becomes the word
//! `$tee` and the program, its prefixes, and its options are read wrong. The
//! shell expands those forms before the program runs, and the expansion cannot
//! be verified portably: the escape table differs between shell versions and
//! `$"..."` depends on the locale. Every `$'` or `$"` that sits outside a shell
//! quote is therefore refused as an unverifiable form.
//!
//! Quote handling mirrors the parent module's lexer (outside, `'...'`, `"..."`,
//! and `\` escaping), so a `$'` or `$"` inside a quote, after a backslash, or a
//! lone `$` stays allowed. The scan runs over the whole command before the
//! lexer so a mis-read `$'\''` cannot hide the commands that follow it.

/// Operation recorded for a command that spells `$'` or `$"` outside a shell
/// quote, whose expansion cannot be verified.
pub(super) const OPERATION: &str = "ANSI-C / locale quoting";

/// The quoting introducer found outside a shell quote.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(super) enum Kind {
/// ANSI-C quoting, `$'...'`.
AnsiC,
/// Locale translation quoting, `$"..."`.
Locale,
}

impl Kind {
/// The two-character introducer as it appears in the command.
pub(super) fn introducer(self) -> &'static str {
match self {
Kind::AnsiC => "$'",
Kind::Locale => "$\"",
}
}
}

/// Returns the first `$'` / `$"` introducer that sits outside a shell quote.
pub(super) fn outside_quotes(command: &str) -> Option<Kind> {
let mut chars = command.chars().peekable();
let mut single_quoted = false;
let mut double_quoted = false;
while let Some(ch) = chars.next() {
if single_quoted {
if ch == '\'' {
single_quoted = false;
}
continue;
}
if double_quoted {
match ch {
'"' => double_quoted = false,
'\\' => {
chars.next();
}
_ => {}
}
continue;
}
match ch {
'\'' => single_quoted = true,
'"' => double_quoted = true,
'\\' => {
chars.next();
}
'$' => match chars.peek() {
Some('\'') => return Some(Kind::AnsiC),
Some('"') => return Some(Kind::Locale),
_ => {}
},
_ => {}
}
}
None
}

#[cfg(test)]
mod tests {
use super::*;

#[test]
fn ansi_c_quoting_outside_quotes_detection_table() {
let cases: &[(&str, Option<Kind>)] = &[
("$'tee' /tmp/f", Some(Kind::AnsiC)),
("env $'tee' /tmp/f", Some(Kind::AnsiC)),
("env $'-S' 'tee /tmp/f'", Some(Kind::AnsiC)),
("$'\\x74ee' /tmp/f", Some(Kind::AnsiC)),
("$'t\\145e' /tmp/f", Some(Kind::AnsiC)),
("x$'tee' /tmp/f", Some(Kind::AnsiC)),
("echo $'\\'' ; tee /tmp/f", Some(Kind::AnsiC)),
("echo $'\\''\ntee sub/link/f", Some(Kind::AnsiC)),
("$'\\cX' /tmp/f", Some(Kind::AnsiC)),
("$''tee /tmp/f", Some(Kind::AnsiC)),
("printf $'a\\'b' > sub/link/f", Some(Kind::AnsiC)),
("X=$'tee'", Some(Kind::AnsiC)),
("cat $'.env'", Some(Kind::AnsiC)),
("$\"tee\" /tmp/f", Some(Kind::Locale)),
("$\"\"tee /tmp/f", Some(Kind::Locale)),
// A closed quote must be seen as closed, so the `$'` after it is
// outside; an escaped `"` inside a double quote must not close it.
("echo 'a' $'tee'", Some(Kind::AnsiC)),
("echo \"x\" $'tee'", Some(Kind::AnsiC)),
(r#"echo "a\"" $'tee'"#, Some(Kind::AnsiC)),
("echo \"$'x'\"", None),
("echo \"$\\'x'\"", None),
("echo '$'", None),
("echo 'a$'", None),
("echo 'a' 'b'", None),
("echo \\$'x'", None),
("echo \"$\"", None),
("printf '%s\\n' x", None),
("printf 'a\\tb\\n'", None),
("cargo test", None),
("$", None),
("", None),
];
for (command, expected) in cases {
assert_eq!(outside_quotes(command), *expected, "command: {command}");
}
}

#[test]
fn ansi_c_quoting_kind_reports_its_introducer() {
assert_eq!(Kind::AnsiC.introducer(), "$'");
assert_eq!(Kind::Locale.introducer(), "$\"");
}
}
115 changes: 115 additions & 0 deletions tests/issue575_bash_ansi_c_quoting.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
#![cfg(unix)]

//! Issue #575: the Bash write-target guard's lexical analysis does not know
//! ANSI-C quoting (`$'...'`) or locale quoting (`$"..."`). It leaves the `$` as
//! an ordinary character and strips the following quotes as ordinary quotes, so
//! `$'tee'` becomes the word `$tee` and the program, its prefixes, and its
//! options are read wrong. The shell expands those forms before running, so
//! every command that spells `$'` or `$"` outside a shell quote is refused as an
//! unverifiable form. Every test name contains `ansi_c_quoting` so the
//! mutation-test filter selects these tests together with the unit tests in
//! `bash_write_guard/ansi_c_quoting.rs`.

use std::path::PathBuf;

use commandagent::tools::bash::path_confinement_rejection;

struct Fixture {
_dir: tempfile::TempDir,
root: PathBuf,
}

/// A workspace with an escaping `sub/link` symlink, an inside `a.txt`, and an
/// inside secret file.
fn fixture() -> Fixture {
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("a.txt"), "x").unwrap();
std::fs::write(root.join(".env"), "workspace-secret").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("sub/link")).unwrap();
let root = root.canonicalize().unwrap();
Fixture { _dir: dir, root }
}

#[test]
fn ansi_c_quoting_rejects_program_and_option_spellings() {
let fixture = fixture();
let root = &fixture.root;
let cases = [
// The problem table from the issue body: the program, a prefix, and an
// option are spelled with ANSI-C / locale quoting.
"$'tee' /tmp/f",
"env $'tee' /tmp/f",
"env $'-S' 'tee /tmp/f'",
"$'\\x74ee' /tmp/f",
"$'t\\145e' /tmp/f",
"$\"tee\" /tmp/f",
"x$'tee' /tmp/f",
"cat $'.env'",
"cat $'sub/link/secret'",
// The same spellings through an escaping symlink.
"$'tee' sub/link/f",
"env $'-C' sub tee link/f",
"cp $'-t' sub/link a.txt",
// A mis-read quote that would hide everything after it.
"echo $'\\'' ; tee /tmp/f",
// More program, option, and assignment spellings.
"$'\\cX' /tmp/f",
"$''tee /tmp/f",
"$\"\"tee /tmp/f",
"$'sudo' tee /tmp/f",
"$'rm' -rf sub/link/x",
"echo $'\\''\ntee sub/link/f",
"printf $'a\\'b' > sub/link/f",
"X=$'tee'",
];
for command in cases {
assert!(
path_confinement_rejection(command, root).is_some(),
"expected rejection: {command}"
);
}
}

#[test]
fn ansi_c_quoting_keeps_quoted_escaped_and_plain_forms_allowed() {
let fixture = fixture();
let root = &fixture.root;
let cases = [
// The `$'` / `$"` introducer is not expanded inside a quote, after a
// backslash, or as a lone `$`.
"echo \"$'x'\"",
"echo '$'",
"echo \\$'x'",
"echo \"$\"",
// Ordinary single-quoted escapes stay usable.
"printf '%s\\n' x > out.txt",
"printf 'a\\tb\\n' > out.txt",
// Everyday verification commands.
"cargo test",
"cargo fmt --all -- --check",
"cargo clippy --all-targets -- -D warnings",
"npm test",
"npm run build",
"python3 -m pytest",
"timeout 600 cargo test",
];
for command in cases {
assert!(
path_confinement_rejection(command, root).is_none(),
"expected allow: {command}"
);
}
}

#[test]
fn ansi_c_quoting_rejection_names_the_ansi_c_operation() {
let fixture = fixture();
let rejection =
path_confinement_rejection("$'tee' /tmp/f", &fixture.root).expect("expected rejection");
assert_eq!(rejection.operation, "ANSI-C / locale quoting");
}
Loading