Skip to content

Commit 6afb4f0

Browse files
committed
Harden bash boundary against quote, env-var, glob, and whitespace bypasses
1 parent e7d908e commit 6afb4f0

11 files changed

Lines changed: 532 additions & 106 deletions

File tree

AGENTS.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -444,6 +444,17 @@ Gitignored scratchpad for helper files the user asks to be created there — typ
444444
- `cargo fmt --all`
445445
- `cargo clippy --all-targets -- -D warnings`
446446
- `cargo test --all`
447+
- Windows cross-check (only when the change touches a `cfg(windows)` /
448+
`cfg(target_os = "windows")` / `cfg(not(unix))` arm — Git-Bash shell
449+
resolution in `src/tools/bash/executor.rs`, the console / code-page /
450+
`CONOUT$` handling in `src/repl/tui/mod.rs`, Windows clipboard in
451+
`src/clipboard.rs`, `USERPROFILE` lookup in
452+
`src/tools/permissions/manager.rs`, the case-insensitive command
453+
lookup in `src/tools/permissions/command_parse.rs`, and similar).
454+
Requires `rustup target add x86_64-pc-windows-gnu`, which is already
455+
installed on this machine.
456+
- `cargo check --package sofos --target x86_64-pc-windows-gnu`
457+
- `cargo clippy --package sofos --target x86_64-pc-windows-gnu --all-targets`
447458
- Before finishing, review the change for bugs and corner cases.
448459
- Use international English. Avoid regional idioms (whether American or British), clever shorthand, and compressed phrases; prefer wording that a non-native English reader can understand on the first read. This applies to chat replies, commit messages, code comments, documentation, and error messages.
449460
- After you finish cross-checking against the Non-Negotiable Rules and fixing the code, if needed, do another pass for bugs and regressions.

CHANGELOG.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,14 @@ All notable changes to Sofos are documented in this file.
1212

1313
- **Installing on Windows no longer requires CMake or NASM.** The HTTP and syntax-highlighting backends now use pure-Rust crypto and regex, so `cargo install sofos` succeeds on a clean `rustup` install with no extra build tools.
1414

15+
### Security
16+
17+
- **Forbidden bash commands stay forbidden under blanket `Bash` allow even when the model wraps them.** Adding `Bash` to the allow list used to leave `(rm -rf /)`, `'rm' -rf /`, `\rm -rf /`, and `/bin/rm -rf /` running because the lookup compared the literal token; sofos now strips subshell, quote, backslash, and directory wrappers before checking, and lower-cases the name on Windows. The same fix applies to compound shells, so `ls && (rm bar)` is denied just like `ls && rm bar`.
18+
- **A bare `&` is now a statement separator.** A command like `ls foo & rm bar` used to ship as one segment (auto-allowed because the head was `ls`); sofos now splits at the background-control `&` and rejects the command on the `rm` segment. The `2>&1`, `>&2`, and similar redirection operands stay glued to their preceding `>` / `<`.
19+
- **Bash arguments that the shell would expand at run-time are refused upfront.** Path arguments containing `$VAR`, backticks, `~user`, or unescaped glob characters (`?`, `*`, `[`, `{`) used to slip past the Read/Bash deny rules because the literal text never matched the configured globs; sofos now rejects the command with a clear message asking for the resolved literal path. Plain `~/...`, bare `~`, and absolute paths without metacharacters still work.
20+
- **Whitespace tricks no longer hide dangerous `git` operations.** `git\tpush`, `git$IFS\tpush`, `git${IFS}push`, and `git\\\npush` are now treated the same as `git push` by the structural matcher and the askable-command prompt, so each is blocked or confirmed exactly like its plain form.
21+
- **Deny rules survive path-noise variants.** A `Read(./secrets/**)` deny now matches `.//secrets/keys`, `./secrets/./keys`, and `/etc/./passwd` against `Bash(/etc/**)` — the candidate path is lexically normalised before the globset check. When the path contains `..`, only the resolved form is matched so `./secrets/../allowed.txt` is allowed (the shell would touch `./allowed.txt`).
22+
1523
## [0.3.0] - 2026-05-18
1624

1725
### Added

src/tools/bash/executor.rs

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,9 @@ use crate::tools::bash::output::{
1515
};
1616
use crate::tools::bash::validate::command_contains_op;
1717
use crate::tools::permissions::{CommandPermission, PermissionManager};
18-
use crate::tools::utils::{MAX_TOOL_OUTPUT_TOKENS, TruncationKind, truncate_for_context};
18+
use crate::tools::utils::{
19+
MAX_TOOL_OUTPUT_TOKENS, TruncationKind, normalize_command_whitespace, truncate_for_context,
20+
};
1921
use std::collections::HashSet;
2022
use std::ffi::OsString;
2123
use std::io::Read;
@@ -436,9 +438,12 @@ impl BashExecutor {
436438
fn confirm_askable_command(&self, command: &str) -> Result<()> {
437439
const ASKABLE_PREFIXES: &[&str] = &["git checkout"];
438440

441+
// Match against normalized input so tab / `$IFS` / backslash-newline
442+
// between `git` and the subcommand don't dodge the prompt.
443+
let matcher_input = normalize_command_whitespace(command).to_lowercase();
439444
let matches = ASKABLE_PREFIXES
440445
.iter()
441-
.any(|prefix| command_contains_op(command, prefix));
446+
.any(|prefix| command_contains_op(&matcher_input, prefix));
442447
if !matches {
443448
return Ok(());
444449
}

src/tools/bash/mod.rs

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -614,4 +614,53 @@ ask = []
614614
panic!("Expected ToolExecution error, got: {result:?}");
615615
}
616616
}
617+
618+
/// `git push` smuggled through tab / `$IFS` / backslash-newline must
619+
/// still be caught by the dangerous-git matcher.
620+
#[test]
621+
fn dangerous_git_with_whitespace_obfuscation_is_rejected() {
622+
let executor = BashExecutor::new(PathBuf::from("."), false, false).unwrap();
623+
624+
assert!(!executor.is_safe_command_structure("git\tpush origin main"));
625+
assert!(!executor.is_safe_command_structure("git$IFS\tpush"));
626+
assert!(!executor.is_safe_command_structure("git${IFS}push"));
627+
assert!(!executor.is_safe_command_structure("git\\\npush"));
628+
assert!(!executor.is_safe_command_structure("ls && git\tcommit -m x"));
629+
630+
// The plain form still works.
631+
assert!(executor.is_safe_command_structure("git status"));
632+
assert!(executor.is_safe_command_structure("git log"));
633+
}
634+
635+
/// Path tokens with shell-meta that the shell would expand at
636+
/// run-time must be refused before they reach the deny-glob check.
637+
#[test]
638+
fn path_token_with_shell_meta_is_rejected() {
639+
let (_temp, path) = test_support::workspace();
640+
let executor = BashExecutor::new(path, false, false).unwrap();
641+
642+
for cmd in [
643+
"cat $HOME/.ssh/id_rsa",
644+
"cat /e?c/passwd",
645+
"cat /etc/p[a]sswd",
646+
"ls /{etc,tmp}/x",
647+
"cat ~root/.ssh/id_rsa",
648+
"grep pattern ${HOME}/.bashrc",
649+
] {
650+
let err = executor.execute(cmd);
651+
assert!(
652+
matches!(&err, Err(SofosError::ToolExecution(msg)) if msg.contains("can't be checked")),
653+
"expected shell-meta rejection for `{cmd}`, got {err:?}"
654+
);
655+
}
656+
}
657+
658+
/// `~/foo` and bare `~` are still legitimate path forms and pass
659+
/// the shell-meta check (they're handled by `expand_tilde` below).
660+
#[test]
661+
fn tilde_home_paths_are_not_blocked_by_shell_meta_check() {
662+
let executor = BashExecutor::new(PathBuf::from("."), false, false).unwrap();
663+
assert!(executor.is_safe_command_structure("ls ~/Documents"));
664+
assert!(executor.is_safe_command_structure("ls ~"));
665+
}
617666
}

src/tools/bash/validate.rs

Lines changed: 104 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,8 @@ use crate::error::{Result, SofosError};
1010
use crate::tools::ToolName;
1111
use crate::tools::bash::BashExecutor;
1212
use crate::tools::permissions::{CommandPermission, PermissionManager};
13-
use crate::tools::utils::is_absolute_path;
13+
use crate::tools::utils::{is_absolute_path, lexically_normalize, normalize_command_whitespace};
14+
use std::path::PathBuf;
1415

1516
/// Return true when a command argument looks like a parent-directory
1617
/// reference (`..`, `../foo`, `foo/..`, `foo/../bar`). Substring matches
@@ -107,6 +108,13 @@ pub(super) fn detect_command_substitution(command: &str) -> Option<&'static str>
107108
/// `{...; }` group. False positives (e.g. `ls {git,svn}` brace
108109
/// expansion triggering `git` detection) are acceptable — the worst
109110
/// outcome is the user being prompted to confirm a benign command.
111+
///
112+
/// The caller is expected to pass `command` already routed through
113+
/// [`normalize_command_whitespace`] so non-space whitespace (`\t`,
114+
/// `\r`, `\v`, `\f`, backslash-newline) and the explicit `$IFS` /
115+
/// `${IFS}` shell expansion appear as plain single spaces — otherwise
116+
/// `git\tpush`, `git$IFS\tpush`, and `git\\\npush` would evade the
117+
/// boundary check while still running as `git push`.
110118
pub(super) fn command_contains_op(command: &str, op: &str) -> bool {
111119
const BOUNDARIES: &[&str] = &[" ", ";", "&&", "||", "|", "`", "$(", "(", "{"];
112120
if command.starts_with(op) {
@@ -117,6 +125,34 @@ pub(super) fn command_contains_op(command: &str, op: &str) -> bool {
117125
.any(|sep| command.contains(&format!("{sep}{op}")))
118126
}
119127

128+
/// Returns the kind of expansion that would change the path between
129+
/// our deny check and the shell touching it: `$` / `${...}`, backticks,
130+
/// `~user`, or glob metacharacters (`?`, `*`, `[`, `{`). Plain `~/`
131+
/// and bare `~` are allowed because they expand to a known prefix and
132+
/// are handled by the tilde-expansion helper.
133+
pub(super) fn path_token_shell_meta(tok: &str) -> Option<&'static str> {
134+
if tok.contains('$') {
135+
return Some("$ variable expansion");
136+
}
137+
if tok.contains('`') {
138+
return Some("backtick command substitution");
139+
}
140+
if tok.starts_with('~') && tok != "~" && !tok.starts_with("~/") {
141+
return Some("~user home expansion");
142+
}
143+
if tok.contains('?') || tok.contains('*') || tok.contains('[') || tok.contains('{') {
144+
return Some("glob expansion");
145+
}
146+
None
147+
}
148+
149+
/// True for tokens that look like a path the shell will resolve at
150+
/// run-time. Flag tokens (`--name=value`) and regex patterns fall
151+
/// through so the stricter shell-meta check only fires on real paths.
152+
fn token_looks_like_path(tok: &str) -> bool {
153+
tok.contains('/') || tok.starts_with('.') || tok.starts_with('~') || is_absolute_path(tok)
154+
}
155+
120156
impl BashExecutor {
121157
/// Check all external paths (absolute or tilde) in a command against Bash path grants.
122158
/// Asks the user interactively for any paths not yet covered.
@@ -150,6 +186,17 @@ impl BashExecutor {
150186
cleaned
151187
};
152188

189+
// Reject path tokens whose post-expansion shape we cannot check.
190+
if token_looks_like_path(path_candidate) {
191+
if let Some(kind) = path_token_shell_meta(path_candidate) {
192+
return Err(SofosError::ToolExecution(format!(
193+
"Path argument '{}' uses {} which can't be checked against the permission rules before the shell expands it\n\
194+
Hint: pass the resolved literal path instead, or split this into a separate step that doesn't reference the same path.",
195+
path_candidate, kind
196+
)));
197+
}
198+
}
199+
153200
// Check tilde before absolute so `~` / `~/foo` get expanded
154201
// first. `is_absolute_path` catches Unix (`/foo`) and
155202
// Windows (`C:\foo`, `\\server\share`) shapes on every
@@ -197,27 +244,42 @@ impl BashExecutor {
197244
path: &str,
198245
permission_manager: &mut PermissionManager,
199246
) -> Result<()> {
200-
// Canonicalize to resolve symlinks (e.g. /var/folders -> /private/var/folders on macOS)
201-
let resolved = std::fs::canonicalize(path)
247+
// Canonicalize when possible; also keep a lexically normalized form
248+
// and the raw input so deny rules match every shape of the same path.
249+
let canonical = std::fs::canonicalize(path)
202250
.map(|p| p.to_string_lossy().to_string())
203-
.unwrap_or_else(|_| path.to_string());
204-
let check_path = resolved.as_str();
205-
206-
// Enforce deny rules first (takes priority over allow)
207-
if permission_manager.is_bash_path_denied(check_path) {
208-
return Err(SofosError::ToolExecution(format!(
209-
"Bash access denied for path '{}'\n\
210-
Hint: Blocked by deny rule in .sofos/config.local.toml or ~/.sofos/config.toml",
211-
check_path
212-
)));
251+
.ok();
252+
let normalized = lexically_normalize(&PathBuf::from(path))
253+
.to_string_lossy()
254+
.to_string();
255+
let candidates: Vec<String> = match canonical {
256+
Some(c) if c == normalized || c == path => vec![c],
257+
Some(c) => vec![c, normalized.clone(), path.to_string()],
258+
None if normalized == path => vec![path.to_string()],
259+
None => vec![normalized.clone(), path.to_string()],
260+
};
261+
let check_path = candidates
262+
.first()
263+
.cloned()
264+
.unwrap_or_else(|| path.to_string());
265+
266+
// Deny wins over allow; check every shape.
267+
for cand in &candidates {
268+
if permission_manager.is_bash_path_denied(cand) {
269+
return Err(SofosError::ToolExecution(format!(
270+
"Bash access denied for path '{}'\n\
271+
Hint: Blocked by deny rule in .sofos/config.local.toml or ~/.sofos/config.toml",
272+
cand
273+
)));
274+
}
213275
}
214276

215277
// Already allowed by config?
216-
if permission_manager.is_bash_path_allowed(check_path) {
278+
if permission_manager.is_bash_path_allowed(&check_path) {
217279
return Ok(());
218280
}
219281

220-
let path_obj = std::path::Path::new(check_path);
282+
let path_obj = std::path::Path::new(&check_path);
221283

222284
// Session allowed?
223285
if let Ok(allowed_dirs) = self.bash_path_session_allowed.lock() {
@@ -240,10 +302,10 @@ impl BashExecutor {
240302
}
241303
}
242304

243-
let parent = std::path::Path::new(check_path)
305+
let parent = std::path::Path::new(&check_path)
244306
.parent()
245307
.and_then(|p| p.to_str())
246-
.unwrap_or(check_path);
308+
.unwrap_or(&check_path);
247309

248310
// Non-interactive mode (tests, piped input): deny with a config hint
249311
if !self.interactive {
@@ -296,9 +358,22 @@ impl BashExecutor {
296358
continue;
297359
}
298360

299-
let is_path = cleaned.contains('/')
300-
|| cleaned.starts_with('.')
301-
|| cleaned.starts_with('~')
361+
let path_shaped =
362+
cleaned.contains('/') || cleaned.starts_with('.') || cleaned.starts_with('~');
363+
364+
if path_shaped {
365+
if let Some(kind) = path_token_shell_meta(cleaned) {
366+
return Err(SofosError::ToolExecution(format!(
367+
"Read argument '{}' uses {} which can't be checked against the Read rules before the shell expands it\n\
368+
Hint: pass the resolved literal path instead, or split this into a separate step that doesn't reference the same path.",
369+
cleaned, kind
370+
)));
371+
}
372+
}
373+
374+
// Path candidates: looks-like-path, or a bare token with no
375+
// expansion meta (regex / ad-hoc strings fall through).
376+
let is_path = path_shaped
302377
|| (!cleaned.contains('$')
303378
&& !cleaned.contains('`')
304379
&& !cleaned.contains('*')
@@ -366,7 +441,10 @@ impl BashExecutor {
366441
return false;
367442
}
368443

369-
if !self.is_safe_git_command(&command.to_lowercase()) {
444+
// Normalise whitespace before matching so `git\tpush`,
445+
// `git$IFS\tpush`, `git\\\npush` are seen as `git push`.
446+
let matcher_input = normalize_command_whitespace(command).to_lowercase();
447+
if !self.is_safe_git_command(&matcher_input) {
370448
return false;
371449
}
372450

@@ -448,7 +526,7 @@ impl BashExecutor {
448526
}
449527

450528
pub(super) fn get_rejection_reason(&self, command: &str) -> String {
451-
let command_lower = command.to_lowercase();
529+
let matcher_input = normalize_command_whitespace(command).to_lowercase();
452530

453531
if has_path_traversal(command) {
454532
return format!(
@@ -467,7 +545,7 @@ impl BashExecutor {
467545
);
468546
}
469547

470-
if !self.is_safe_git_command(&command_lower) {
548+
if !self.is_safe_git_command(&matcher_input) {
471549
return self.get_git_rejection_reason(command);
472550
}
473551

@@ -507,7 +585,9 @@ impl BashExecutor {
507585
}
508586

509587
pub(super) fn get_git_rejection_reason(&self, command: &str) -> String {
510-
let command_lower = command.to_lowercase();
588+
// Match against the same normalized input as `is_safe_git_command`
589+
// so the reason picked here lines up with the rejection.
590+
let command_lower = normalize_command_whitespace(command).to_lowercase();
511591

512592
if command_lower.contains("git push") {
513593
return format!(

0 commit comments

Comments
 (0)