Skip to content

Commit 7761c70

Browse files
committed
Treat bare "Bash" in allow/deny as a blanket rule over all bash commands
1 parent 2b220de commit 7761c70

3 files changed

Lines changed: 225 additions & 8 deletions

File tree

CHANGELOG.md

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

55
## [Unreleased]
66

7+
### Added
8+
9+
- **Bare `"Bash"` entry in allow / deny acts as a blanket rule.** Adding `"Bash"` to `permissions.allow` in `~/.sofos/config.toml` or `.sofos/config.local.toml` auto-passes every bash command (no Yes/No/remember prompt) except those in the built-in forbidden set (`rm`, `chmod`, `sudo`, …) — useful when you've decided to trust sofos with shell access in a project. Symmetrically, `"Bash"` in `permissions.deny` auto-rejects every bash command. The blanket entry beats every more-specific rule (`Bash(cmd:*)` wildcards, exact-match entries, the built-in allow-list); when both lists contain `"Bash"`, deny wins. Structural safety (`>` redirection, `<<`, `git push` and friends, parent traversal, external-path prompts) still applies.
10+
711
### Changed
812

913
- **Permission check walks every sub-command of a compound shell.** A `for f in *.rs; do echo $f; sed -n '1,320p' $f | nl -ba; done` pipeline used to hit `Ask` because `extract_base_command` only saw the structural keyword `for`, even though every step (`echo`, `sed`, `nl`) is on the built-in read-only allow-list. `check_command_permission` now splits on `;`, `\n`, `|`, `||`, `&&` (quote-aware; `2>&1` preserved), strips shell-control prefixes (`do` / `done` / `then` / `else` / `elif` / `fi` / `case` / `esac` / `{` / `}` / `(`, `while` / `until` / `if` heads, `for VAR in WORDS` headers, `# comment` segments), and auto-allows when every resulting base is in `allowed_commands`. Volatile-args detection (`sed -n '1,N'p`, `head -n N`, `grep -A N`, `awk 'NR==N'`) uses the same splitter, so the Yes/No-only prompt fires for volatile sub-commands buried inside a `for` loop or `&&` chain — not just inside a `|` pipeline.

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -310,6 +310,7 @@ headers = { "Authorization" = "Bearer token123" }
310310
- Outside workspace: prompts interactively on first access, or pre-configure in `allow` list
311311
- Three scopes: `Read(path)` for reading, `Write(path)` for writing, `Bash(path)` for bash access — each independent
312312
- `Bash(path)` entries with globs (e.g. `Bash(/tmp/**)`) grant path access; plain entries (e.g. `Bash(npm test)`) grant command access
313+
- Bare `"Bash"` in `allow` auto-allows every command except those in the built-in forbidden set (`rm`, `chmod`, `sudo`, …); bare `"Bash"` in `deny` auto-rejects every bash command. Deny wins when both lists carry the blanket entry. Structural safety (`>` redirection, `<<`, `git push`, parent traversal, external-path prompts) still applies under blanket-allow.
313314
- Glob patterns supported: `*` (single level), `**` (recursive)
314315
- Tilde expansion: `~``$HOME` on Unix, `%USERPROFILE%` on Windows
315316
- `ask` only works for Bash commands

src/tools/permissions.rs

Lines changed: 220 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,13 @@ const COMPOUND_HEADERS_WITH_BODY: &[&str] = &["while", "until", "if"];
2727
/// base command at all and should yield no entry.
2828
const COMPOUND_HEADERS_NO_BODY: &[&str] = &["for"];
2929

30+
/// Bare `"Bash"` in an `allow` or `deny` list acts as a blanket rule
31+
/// over all bash commands. In `allow` it auto-passes everything except
32+
/// the built-in forbidden set (`rm`, `chmod`, `sudo`, …); in `deny` it
33+
/// auto-rejects everything. Deny beats allow when both lists contain
34+
/// the blanket entry.
35+
const BLANKET_BASH: &str = "Bash";
36+
3037
/// Match POSIX-shell `KEY=value` assignment tokens: the key starts with
3138
/// a letter or underscore, is alphanumeric+`_` throughout, and is
3239
/// followed by `=`. Used by `extract_base_command` to skip leading
@@ -743,9 +750,42 @@ impl PermissionManager {
743750
}
744751

745752
pub fn check_command_permission(&mut self, command: &str) -> Result<CommandPermission> {
753+
// Blanket `"Bash"` rules trump every other check. Deny wins over
754+
// allow when both lists contain the blanket entry, matching the
755+
// existing "deny is strictest" pattern used elsewhere.
756+
let blanket_deny = self
757+
.settings
758+
.permissions
759+
.deny
760+
.iter()
761+
.any(|e| e == BLANKET_BASH);
762+
if blanket_deny {
763+
return Ok(CommandPermission::Denied);
764+
}
765+
746766
let normalized = Self::normalize_command(command);
747767
let base_command = Self::extract_base_command(command);
748768

769+
// Blanket `"Bash"` allow short-circuits below the deny check but
770+
// still defers to `forbidden_commands` — the user's "trust me"
771+
// intent stops at things that are dangerous regardless of
772+
// context (`rm`, `chmod`, `sudo`, …). Structural safety checks
773+
// (`>` redirection, `<<`, `git push`, parent traversal, external
774+
// paths) still run later in `bashexec`.
775+
let blanket_allow = self
776+
.settings
777+
.permissions
778+
.allow
779+
.iter()
780+
.any(|e| e == BLANKET_BASH);
781+
if blanket_allow {
782+
let bases = Self::collect_command_bases(base_command, command);
783+
if bases.iter().any(|b| self.forbidden_commands.contains(b)) {
784+
return Ok(CommandPermission::Denied);
785+
}
786+
return Ok(CommandPermission::Allowed);
787+
}
788+
749789
if self.settings.permissions.allow.contains(&normalized) {
750790
return Ok(CommandPermission::Allowed);
751791
}
@@ -779,24 +819,32 @@ impl PermissionManager {
779819
// The splitter does NOT descend into `$(...)` / backticks, so a
780820
// command smuggled there is still seen as part of the parent
781821
// command's args (same blind spot as before this change).
782-
let compound_bases = Self::enumerate_compound_bases(command);
783-
let bases: Vec<&str> = if compound_bases.is_empty() {
784-
vec![base_command]
785-
} else {
786-
compound_bases.iter().map(String::as_str).collect()
787-
};
822+
let bases = Self::collect_command_bases(base_command, command);
788823

789-
if bases.iter().any(|&b| self.forbidden_commands.contains(b)) {
824+
if bases.iter().any(|b| self.forbidden_commands.contains(b)) {
790825
return Ok(CommandPermission::Denied);
791826
}
792827

793-
if bases.iter().all(|&b| self.allowed_commands.contains(b)) {
828+
if bases.iter().all(|b| self.allowed_commands.contains(b)) {
794829
return Ok(CommandPermission::Allowed);
795830
}
796831

797832
Ok(CommandPermission::Ask)
798833
}
799834

835+
/// Build the list of base-command names to evaluate for a command.
836+
/// Falls back to the leading token from `extract_base_command` when
837+
/// the compound splitter finds no separators, so single commands
838+
/// and compound shells share one verdict path.
839+
fn collect_command_bases(base_command: &str, command: &str) -> Vec<String> {
840+
let compound_bases = Self::enumerate_compound_bases(command);
841+
if compound_bases.is_empty() {
842+
vec![base_command.to_string()]
843+
} else {
844+
compound_bases
845+
}
846+
}
847+
800848
pub fn ask_user_permission(&mut self, command: &str) -> Result<(bool, bool)> {
801849
let normalized = Self::normalize_command(command);
802850
let prompt = format!("Allow command `{}`?", command);
@@ -2308,6 +2356,170 @@ ask = []
23082356
);
23092357
}
23102358

2359+
/// Bare `"Bash"` in the allow list auto-passes any command whose
2360+
/// every base is non-forbidden. Specific entries become moot; the
2361+
/// blanket beats every other check.
2362+
#[test]
2363+
fn blanket_bash_allow_auto_allows_non_forbidden() {
2364+
let temp_dir = TempDir::new().unwrap();
2365+
let mut manager = PermissionManager::new(temp_dir.path().to_path_buf()).unwrap();
2366+
manager.settings.permissions.allow.push("Bash".to_string());
2367+
2368+
// Unknown tool: still Allowed under blanket Bash.
2369+
assert_eq!(
2370+
manager
2371+
.check_command_permission("some_custom_tool --flag")
2372+
.unwrap(),
2373+
CommandPermission::Allowed
2374+
);
2375+
// Built-in allowed: still Allowed.
2376+
assert_eq!(
2377+
manager.check_command_permission("ls -la").unwrap(),
2378+
CommandPermission::Allowed
2379+
);
2380+
// Compound with all-unknown bases: still Allowed.
2381+
assert_eq!(
2382+
manager.check_command_permission("foo && bar; baz").unwrap(),
2383+
CommandPermission::Allowed
2384+
);
2385+
}
2386+
2387+
/// Blanket `"Bash"` allow does NOT override built-in
2388+
/// `forbidden_commands` — `rm`, `chmod`, `sudo`, … stay denied.
2389+
#[test]
2390+
fn blanket_bash_allow_still_blocks_forbidden() {
2391+
let temp_dir = TempDir::new().unwrap();
2392+
let mut manager = PermissionManager::new(temp_dir.path().to_path_buf()).unwrap();
2393+
manager.settings.permissions.allow.push("Bash".to_string());
2394+
2395+
assert_eq!(
2396+
manager.check_command_permission("rm -rf /").unwrap(),
2397+
CommandPermission::Denied
2398+
);
2399+
assert_eq!(
2400+
manager.check_command_permission("sudo whoami").unwrap(),
2401+
CommandPermission::Denied
2402+
);
2403+
// Forbidden buried in a compound is also rejected.
2404+
assert_eq!(
2405+
manager
2406+
.check_command_permission("ls && rm tmp.txt")
2407+
.unwrap(),
2408+
CommandPermission::Denied
2409+
);
2410+
}
2411+
2412+
/// Bare `"Bash"` in the deny list auto-rejects every bash command.
2413+
#[test]
2414+
fn blanket_bash_deny_rejects_everything() {
2415+
let temp_dir = TempDir::new().unwrap();
2416+
let mut manager = PermissionManager::new(temp_dir.path().to_path_buf()).unwrap();
2417+
manager.settings.permissions.deny.push("Bash".to_string());
2418+
2419+
assert_eq!(
2420+
manager.check_command_permission("ls -la").unwrap(),
2421+
CommandPermission::Denied
2422+
);
2423+
assert_eq!(
2424+
manager.check_command_permission("cargo build").unwrap(),
2425+
CommandPermission::Denied
2426+
);
2427+
assert_eq!(
2428+
manager
2429+
.check_command_permission("any_tool whatsoever")
2430+
.unwrap(),
2431+
CommandPermission::Denied
2432+
);
2433+
}
2434+
2435+
/// Specific `Bash(curl --version)` entry alongside a blanket
2436+
/// `"Bash"` deny is irrelevant — deny wins. Symmetrically, a
2437+
/// blanket `"Bash"` allow wins over its companion specific entry.
2438+
#[test]
2439+
fn blanket_bash_beats_specific_companions() {
2440+
let temp_dir = TempDir::new().unwrap();
2441+
let mut manager = PermissionManager::new(temp_dir.path().to_path_buf()).unwrap();
2442+
manager
2443+
.settings
2444+
.permissions
2445+
.deny
2446+
.push("Bash(curl --version)".to_string());
2447+
manager.settings.permissions.deny.push("Bash".to_string());
2448+
2449+
assert_eq!(
2450+
manager.check_command_permission("ls").unwrap(),
2451+
CommandPermission::Denied
2452+
);
2453+
assert_eq!(
2454+
manager.check_command_permission("curl --version").unwrap(),
2455+
CommandPermission::Denied
2456+
);
2457+
2458+
let mut manager = PermissionManager::new(temp_dir.path().to_path_buf()).unwrap();
2459+
manager
2460+
.settings
2461+
.permissions
2462+
.allow
2463+
.push("Bash(curl --version)".to_string());
2464+
manager.settings.permissions.allow.push("Bash".to_string());
2465+
2466+
assert_eq!(
2467+
manager
2468+
.check_command_permission("custom_unknown_tool")
2469+
.unwrap(),
2470+
CommandPermission::Allowed
2471+
);
2472+
}
2473+
2474+
/// Cross-list precedence: blanket `"Bash"` allow wins over a
2475+
/// specific `Bash(curl --version)` *deny* entry — per the rule
2476+
/// that the blanket beats every check except built-in forbidden.
2477+
/// Mirror direction: a specific allow entry can't rescue a command
2478+
/// when blanket deny is set.
2479+
#[test]
2480+
fn blanket_bash_allow_beats_specific_deny_across_lists() {
2481+
let temp_dir = TempDir::new().unwrap();
2482+
let mut manager = PermissionManager::new(temp_dir.path().to_path_buf()).unwrap();
2483+
manager.settings.permissions.allow.push("Bash".to_string());
2484+
manager
2485+
.settings
2486+
.permissions
2487+
.deny
2488+
.push("Bash(curl --version)".to_string());
2489+
2490+
assert_eq!(
2491+
manager.check_command_permission("curl --version").unwrap(),
2492+
CommandPermission::Allowed
2493+
);
2494+
2495+
let mut manager = PermissionManager::new(temp_dir.path().to_path_buf()).unwrap();
2496+
manager
2497+
.settings
2498+
.permissions
2499+
.allow
2500+
.push("Bash(curl --version)".to_string());
2501+
manager.settings.permissions.deny.push("Bash".to_string());
2502+
2503+
assert_eq!(
2504+
manager.check_command_permission("curl --version").unwrap(),
2505+
CommandPermission::Denied
2506+
);
2507+
}
2508+
2509+
/// Both lists carrying the blanket entry: deny wins.
2510+
#[test]
2511+
fn blanket_bash_deny_beats_blanket_allow() {
2512+
let temp_dir = TempDir::new().unwrap();
2513+
let mut manager = PermissionManager::new(temp_dir.path().to_path_buf()).unwrap();
2514+
manager.settings.permissions.allow.push("Bash".to_string());
2515+
manager.settings.permissions.deny.push("Bash".to_string());
2516+
2517+
assert_eq!(
2518+
manager.check_command_permission("ls").unwrap(),
2519+
CommandPermission::Denied
2520+
);
2521+
}
2522+
23112523
/// `while CMD; do CMD; done` and `if CMD; then CMD; fi` should
23122524
/// likewise be inspected sub-command by sub-command.
23132525
#[test]

0 commit comments

Comments
 (0)