Skip to content

Commit 7a5c110

Browse files
committed
Tighten bash permission model against env-prefix, whitespace, and merge bypasses
1 parent 8b8208f commit 7a5c110

9 files changed

Lines changed: 238 additions & 50 deletions

File tree

CHANGELOG.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,13 @@ All notable changes to Sofos are documented in this file.
44

55
## [Unreleased]
66

7+
### Security
8+
9+
- **A global deny rule survives a local allow with the same name.** Adding `Bash(rm)` to `.sofos/config.local.toml`'s allow list used to silently strip the matching `Bash(rm)` from `~/.sofos/config.toml`'s deny list; both entries now coexist after merge, so the per-command verdict reflects every configured rule instead of dropping the global guarantee.
10+
- **`PATH=`, `LD_PRELOAD=`, `LD_LIBRARY_PATH=`, `DYLD_*=`, `NODE_PATH=`, and `PYTHONPATH=` prefixes now route the bash call to a confirmation prompt.** A command like `PATH=. cargo build` used to auto-allow as `cargo`; sofos now asks the user before running anything that swaps the binary the shell will execute, even when the base command is on the allow list or when blanket `Bash` allow is active. Built-in forbidden bases (`rm`, `chmod`, `sudo`, …) still take precedence and stay denied.
11+
- **A session-scoped Bash path grant now applies only to the file the user named.** Allowing `cat /home/me/.ssh/config` once used to permit every other file under `/home/me/.ssh` for the rest of the session; the grant is now scoped to the specific file, so a follow-up `cat /home/me/.ssh/id_rsa` re-prompts. Grants saved to config (yes-and-remember) still cover the whole `parent/**` directory because the user explicitly opts in to that wider scope.
12+
- **Repeating a denied command with extra whitespace no longer dodges the session deny.** `ls /etc` and `ls /etc` (any internal whitespace) now hash to the same session-scoped key, so a model that retries a refused command cannot trigger a fresh prompt by adding spaces or tabs.
13+
714
## [0.3.1] - 2026-05-19
815

916
### Added

src/tools/bash/executor.rs

Lines changed: 11 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -114,13 +114,14 @@ impl BashExecutor {
114114
}
115115

116116
pub fn execute(&self, command: &str) -> Result<String> {
117-
let normalized = format!("Bash({})", command.trim());
117+
let mut permission_manager = PermissionManager::new(self.workspace.clone())?;
118+
let normalized = PermissionManager::normalize_command(command);
118119

119120
// Check session-scoped decisions first (for "allow once" / "deny once")
120121
if let Ok(allowed) = self.session_allowed.lock() {
121122
if allowed.contains(&normalized) {
122123
// Previously allowed this session, skip permission check
123-
return self.execute_after_permission_check(command);
124+
return self.execute_after_permission_check(command, &mut permission_manager);
124125
}
125126
}
126127
if let Ok(denied) = self.session_denied.lock() {
@@ -134,7 +135,6 @@ impl BashExecutor {
134135
}
135136
}
136137

137-
let mut permission_manager = PermissionManager::new(self.workspace.clone())?;
138138
let permission = permission_manager.check_command_permission(command)?;
139139

140140
match permission {
@@ -171,14 +171,16 @@ impl BashExecutor {
171171
}
172172
}
173173

174-
self.execute_after_permission_check(command)
174+
self.execute_after_permission_check(command, &mut permission_manager)
175175
}
176176

177-
fn execute_after_permission_check(&self, command: &str) -> Result<String> {
178-
let mut permission_manager = PermissionManager::new(self.workspace.clone())?;
179-
177+
fn execute_after_permission_check(
178+
&self,
179+
command: &str,
180+
permission_manager: &mut PermissionManager,
181+
) -> Result<String> {
180182
// Enforce read permissions on paths referenced in the command
181-
self.enforce_read_permissions(&permission_manager, command)?;
183+
self.enforce_read_permissions(permission_manager, command)?;
182184

183185
// Non-path structural safety checks (parent traversal, redirection, git restrictions)
184186
if !self.is_safe_command_structure(command) {
@@ -197,7 +199,7 @@ impl BashExecutor {
197199
self.confirm_askable_command(command)?;
198200

199201
// Check external paths in command — ask user for paths not covered by Bash path grants
200-
self.check_bash_external_paths(command, &mut permission_manager)?;
202+
self.check_bash_external_paths(command, permission_manager)?;
201203

202204
let outcome = self.spawn_supervised(command)?;
203205

src/tools/bash/validate.rs

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -322,14 +322,19 @@ impl BashExecutor {
322322
if allowed {
323323
if !remember {
324324
if let Ok(mut dirs) = self.bash_path_session_allowed.lock() {
325-
dirs.insert(parent.to_string());
325+
// Session-only grant: store the file path itself, not
326+
// the parent, so a second file under the same parent
327+
// re-prompts. Persistent grants (remember=true) keep
328+
// `Bash(parent/**)` because the user explicitly
329+
// opted in to that scope through `ask_user_path_permission`.
330+
dirs.insert(check_path.to_string());
326331
}
327332
}
328333
Ok(())
329334
} else {
330335
if !remember {
331336
if let Ok(mut dirs) = self.bash_path_session_denied.lock() {
332-
dirs.insert(parent.to_string());
337+
dirs.insert(check_path.to_string());
333338
}
334339
}
335340
Err(SofosError::ToolExecution(format!(

src/tools/permissions/command_parse.rs

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,41 @@ pub(super) const COMPOUND_HEADERS_WITH_BODY: &[&str] = &["while", "until", "if"]
2626
/// base command at all and should yield no entry.
2727
pub(super) const COMPOUND_HEADERS_NO_BODY: &[&str] = &["for"];
2828

29+
/// Env-assignment keys whose value can swap the binary the shell ends
30+
/// up running, regardless of what base command follows. `PATH=. cargo
31+
/// build` reads `cargo` from the current directory; `LD_PRELOAD=evil.so
32+
/// ls` runs `ls` under a hijacked loader. The base-command lookup
33+
/// would otherwise classify these as `cargo` / `ls` and auto-allow,
34+
/// so the permission system has to surface them explicitly.
35+
const DANGEROUS_ENV_KEYS: &[&str] = &[
36+
"PATH",
37+
"LD_PRELOAD",
38+
"LD_LIBRARY_PATH",
39+
"NODE_PATH",
40+
"PYTHONPATH",
41+
];
42+
43+
/// Returns the dangerous env-key (verbatim, uppercased) found among
44+
/// the leading env-assignment tokens of `segment`. Stops at the first
45+
/// token that is not a `KEY=value`, since that's the base command and
46+
/// any later occurrence isn't an env prefix any more.
47+
pub(super) fn leading_dangerous_env_prefix(segment: &str) -> Option<&'static str> {
48+
for tok in segment.split_whitespace() {
49+
if !is_env_assignment(tok) {
50+
return None;
51+
}
52+
let key = tok.split_once('=').map(|(k, _)| k).unwrap_or(tok);
53+
let upper = key.to_ascii_uppercase();
54+
if upper.starts_with("DYLD_") {
55+
return Some("DYLD_*");
56+
}
57+
if let Some(name) = DANGEROUS_ENV_KEYS.iter().find(|d| **d == upper.as_str()) {
58+
return Some(*name);
59+
}
60+
}
61+
None
62+
}
63+
2964
/// Match POSIX-shell `KEY=value` assignment tokens: the key starts with
3065
/// a letter or underscore, is alphanumeric+`_` throughout, and is
3166
/// followed by `=`. Used by `extract_base_command` to skip leading

src/tools/permissions/manager.rs

Lines changed: 27 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
use crate::error::{Result, SofosError};
22
use crate::tools::permissions::CommandPermission;
3-
use crate::tools::permissions::command_parse::command_lookup_key;
3+
use crate::tools::permissions::command_parse::{command_lookup_key, leading_dangerous_env_prefix};
44
use crate::tools::permissions::pattern::BLANKET_BASH;
55
use crate::tools::permissions::settings::PermissionSettings;
66
use crate::tools::utils::{ConfirmationType, confirm_multi_choice};
@@ -406,6 +406,18 @@ impl PermissionManager {
406406
return Ok(CommandPermission::Denied);
407407
}
408408

409+
// Dangerous env prefixes (`PATH=.`, `LD_PRELOAD=...`, ...) can
410+
// swap the binary the shell ends up running, so an Allowed
411+
// verdict gets downgraded to Ask. Denies still fire normally
412+
// — an explicit `Bash(...)` deny or a forbidden base wins
413+
// because the deny is the conservative choice either way.
414+
let dangerous_env = Self::command_has_dangerous_env_prefix(command);
415+
let allow_verdict = if dangerous_env {
416+
CommandPermission::Ask
417+
} else {
418+
CommandPermission::Allowed
419+
};
420+
409421
let normalized = Self::normalize_command(command);
410422
let base_command = Self::extract_base_command(command);
411423

@@ -429,11 +441,11 @@ impl PermissionManager {
429441
{
430442
return Ok(CommandPermission::Denied);
431443
}
432-
return Ok(CommandPermission::Allowed);
444+
return Ok(allow_verdict);
433445
}
434446

435447
if self.settings.permissions.allow.contains(&normalized) {
436-
return Ok(CommandPermission::Allowed);
448+
return Ok(allow_verdict);
437449
}
438450

439451
if self.settings.permissions.deny.contains(&normalized) {
@@ -442,7 +454,7 @@ impl PermissionManager {
442454

443455
let wildcard_pattern = format!("Bash({}:*)", base_command);
444456
if self.settings.permissions.allow.contains(&wildcard_pattern) {
445-
return Ok(CommandPermission::Allowed);
457+
return Ok(allow_verdict);
446458
}
447459

448460
if self.settings.permissions.deny.contains(&wildcard_pattern) {
@@ -478,7 +490,7 @@ impl PermissionManager {
478490
.iter()
479491
.all(|b| self.allowed_commands.contains(&command_lookup_key(b)))
480492
{
481-
return Ok(CommandPermission::Allowed);
493+
return Ok(allow_verdict);
482494
}
483495

484496
Ok(CommandPermission::Ask)
@@ -497,6 +509,16 @@ impl PermissionManager {
497509
}
498510
}
499511

512+
/// True when any sub-command of a compound shell leads with a
513+
/// dangerous env-assignment (`PATH=`, `LD_PRELOAD=`, etc.). Walking
514+
/// every segment means `ls; PATH=. cargo build` is caught even though
515+
/// the first segment is plain.
516+
pub(super) fn command_has_dangerous_env_prefix(command: &str) -> bool {
517+
Self::split_compound_command(command)
518+
.iter()
519+
.any(|seg| leading_dangerous_env_prefix(seg).is_some())
520+
}
521+
500522
pub fn ask_user_permission(&mut self, command: &str) -> Result<(bool, bool)> {
501523
let normalized = Self::normalize_command(command);
502524
let prompt = format!("Allow command `{}`?", command);

src/tools/permissions/mod.rs

Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1609,6 +1609,121 @@ ask = []
16091609
assert_eq!(bases, vec!["echo".to_string(), "sudo".to_string()]);
16101610
}
16111611

1612+
/// `PATH=. cargo build` previously classified as `cargo` (in the
1613+
/// allowed set), letting an attacker-controlled `./cargo` execute
1614+
/// under the auto-allow. The same trick works with `LD_PRELOAD`,
1615+
/// `DYLD_*`, `NODE_PATH`, `PYTHONPATH`. All must now route through
1616+
/// the Ask prompt regardless of the base command.
1617+
#[test]
1618+
fn dangerous_env_prefix_forces_ask() {
1619+
let temp_dir = TempDir::new().unwrap();
1620+
let mut manager = PermissionManager::new(temp_dir.path().to_path_buf()).unwrap();
1621+
1622+
for cmd in [
1623+
"PATH=. cargo build",
1624+
"LD_PRELOAD=./evil.so ls",
1625+
"LD_LIBRARY_PATH=. ./bin",
1626+
"DYLD_INSERT_LIBRARIES=./evil.dylib ls",
1627+
"DYLD_LIBRARY_PATH=. ls",
1628+
"NODE_PATH=. node script.js",
1629+
"PYTHONPATH=. python script.py",
1630+
] {
1631+
assert_eq!(
1632+
manager.check_command_permission(cmd).unwrap(),
1633+
CommandPermission::Ask,
1634+
"dangerous env prefix should force Ask: {cmd}"
1635+
);
1636+
}
1637+
1638+
// Harmless env prefixes (PII-style, `FOO=bar`) still fall through
1639+
// to the base-command check.
1640+
assert_eq!(
1641+
manager
1642+
.check_command_permission("FOO=bar cargo build")
1643+
.unwrap(),
1644+
CommandPermission::Allowed
1645+
);
1646+
}
1647+
1648+
/// Even a blanket `"Bash"` allow does NOT short-circuit the
1649+
/// dangerous-env check — the user opted in to "trust this command
1650+
/// family", not to "swap my PATH".
1651+
#[test]
1652+
fn dangerous_env_prefix_overrides_blanket_allow() {
1653+
let temp_dir = TempDir::new().unwrap();
1654+
let mut manager = PermissionManager::new(temp_dir.path().to_path_buf()).unwrap();
1655+
manager.settings.permissions.allow.push("Bash".to_string());
1656+
1657+
assert_eq!(
1658+
manager
1659+
.check_command_permission("PATH=. cargo build")
1660+
.unwrap(),
1661+
CommandPermission::Ask
1662+
);
1663+
}
1664+
1665+
/// A dangerous env prefix in any segment of a compound shell trips
1666+
/// the gate — `ls; PATH=. cargo` still routes to Ask.
1667+
#[test]
1668+
fn dangerous_env_prefix_in_compound_segment_caught() {
1669+
let temp_dir = TempDir::new().unwrap();
1670+
let mut manager = PermissionManager::new(temp_dir.path().to_path_buf()).unwrap();
1671+
1672+
assert_eq!(
1673+
manager
1674+
.check_command_permission("ls; PATH=. cargo build")
1675+
.unwrap(),
1676+
CommandPermission::Ask
1677+
);
1678+
assert_eq!(
1679+
manager
1680+
.check_command_permission("cat foo && LD_PRELOAD=evil.so ls")
1681+
.unwrap(),
1682+
CommandPermission::Ask
1683+
);
1684+
}
1685+
1686+
/// A `Bash(rm)` rule in local-allow must NOT silently strip the
1687+
/// matching `Bash(rm)` from global-deny. Per-list seen sets keep the
1688+
/// deny entry around (the runtime then arbitrates).
1689+
#[test]
1690+
fn merge_does_not_drop_global_deny_for_local_allow() {
1691+
let mut global = PermissionSettings::default();
1692+
global.permissions.deny.push("Bash(rm)".to_string());
1693+
1694+
let mut local = PermissionSettings::default();
1695+
local.permissions.allow.push("Bash(rm)".to_string());
1696+
1697+
global.merge(local);
1698+
1699+
assert!(
1700+
global.permissions.deny.contains(&"Bash(rm)".to_string()),
1701+
"global deny must survive a local-allow with the same key"
1702+
);
1703+
assert!(
1704+
global.permissions.allow.contains(&"Bash(rm)".to_string()),
1705+
"local allow is still merged in"
1706+
);
1707+
}
1708+
1709+
/// Whitespace in the command must NOT bypass a session-scoped deny.
1710+
/// `Bash(ls /etc)` and `Bash(ls /etc)` must produce the same key.
1711+
#[test]
1712+
fn normalize_command_collapses_internal_whitespace() {
1713+
assert_eq!(
1714+
PermissionManager::normalize_command("ls /etc"),
1715+
PermissionManager::normalize_command("ls /etc"),
1716+
);
1717+
assert_eq!(
1718+
PermissionManager::normalize_command("ls\t/etc"),
1719+
PermissionManager::normalize_command("ls /etc"),
1720+
);
1721+
assert_eq!(
1722+
PermissionManager::normalize_command(" ls /etc "),
1723+
"Bash(ls /etc)".to_string()
1724+
);
1725+
}
1726+
16121727
/// `./secrets/../allowed.txt` lexically resolves to `./allowed.txt`,
16131728
/// so the deny rule `Read(./secrets/**)` must NOT match it. The
16141729
/// glob deny on `./secrets/**` must still catch the un-normalised

src/tools/permissions/pattern.rs

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
//! place to land.
66
77
use crate::tools::permissions::PermissionManager;
8-
use crate::tools::utils::is_absolute_path;
8+
use crate::tools::utils::{is_absolute_path, normalize_command_whitespace};
99

1010
/// Bare `"Bash"` in an `allow` or `deny` list acts as a blanket rule
1111
/// over all bash commands. In `allow` it auto-passes everything except
@@ -37,8 +37,15 @@ impl PermissionManager {
3737
None
3838
}
3939

40-
pub(super) fn normalize_command(command: &str) -> String {
41-
format!("Bash({})", command.trim())
40+
/// Canonical `Bash(...)` shape for a command string. Internal
41+
/// whitespace runs are collapsed so `ls /etc` and `ls /etc` resolve
42+
/// to the same rule key — a session-scoped deny on the first form
43+
/// stays in force when the model retries with extra spacing. Exposed
44+
/// `pub` so the bash executor can key session state through the
45+
/// same normaliser as the rule lookups.
46+
pub fn normalize_command(command: &str) -> String {
47+
let collapsed = normalize_command_whitespace(command);
48+
format!("Bash({})", collapsed.trim())
4249
}
4350

4451
pub(super) fn normalize_read(path: &str) -> String {

src/tools/permissions/scope.rs

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -228,6 +228,14 @@ impl PermissionManager {
228228
}
229229
}
230230

231+
// Default-Allowed when no explicit rule matches. Inside-workspace
232+
// reads are expected to pass through; paths that leave the
233+
// workspace are gated separately by
234+
// `BashExecutor::check_bash_external_paths`, which runs the
235+
// interactive prompt and the deny-glob check on its own. Do NOT
236+
// collapse these two paths without preserving that boundary —
237+
// the bash side enforces explicit confirmation for external
238+
// paths, which this default branch would skip.
231239
CommandPermission::Allowed
232240
}
233241

0 commit comments

Comments
 (0)