pub mod executor;
pub mod output;
pub mod validate;
use std::collections::HashSet;
use std::path::PathBuf;
use std::sync::{Arc, Mutex};
#[derive(Clone)]
pub struct BashExecutor {
pub(super) workspace: PathBuf,
pub(super) interactive: bool,
pub(super) has_morph: bool,
pub(super) session_allowed: Arc<Mutex<HashSet<String>>>,
pub(super) session_denied: Arc<Mutex<HashSet<String>>>,
pub(super) bash_path_session_allowed: Arc<Mutex<HashSet<String>>>,
pub(super) bash_path_session_denied: Arc<Mutex<HashSet<String>>>,
}
#[cfg(test)]
mod tests {
use super::*;
use crate::error::SofosError;
use crate::tools::bash::validate::{command_contains_op, has_path_traversal};
use crate::tools::test_support;
#[test]
fn command_contains_op_catches_shell_boundaries() {
assert!(command_contains_op("git push", "git push"));
assert!(command_contains_op("ls; git push", "git push"));
assert!(command_contains_op("ls && git push", "git push"));
assert!(command_contains_op("ls || git push", "git push"));
assert!(command_contains_op("ls | git push", "git push"));
assert!(command_contains_op("echo hi; `git push`", "git push"));
assert!(command_contains_op("echo $(git push)", "git push"));
assert!(command_contains_op("(git push)", "git push"));
assert!(command_contains_op("{ git push; }", "git push"));
assert!(!command_contains_op("rgit push", "git push")); assert!(!command_contains_op("ls", "git push"));
}
#[test]
fn test_safe_commands() {
let executor = BashExecutor::new(PathBuf::from("."), false, false).unwrap();
assert!(executor.is_safe_command_structure("ls -la"));
assert!(executor.is_safe_command_structure("cat file.txt"));
assert!(executor.is_safe_command_structure("grep pattern file.txt"));
assert!(executor.is_safe_command_structure("cargo test"));
assert!(executor.is_safe_command_structure("cargo build"));
assert!(executor.is_safe_command_structure("echo hello"));
assert!(executor.is_safe_command_structure("pwd"));
assert!(executor.is_safe_command_structure("cargo build 2>&1"));
assert!(executor.is_safe_command_structure("npm test 2>&1"));
assert!(executor.is_safe_command_structure("ls 2>&1 | grep error"));
assert!(executor.is_safe_command_structure("cargo test 2>&1"));
}
#[test]
fn test_unsafe_command_structures() {
let executor = BashExecutor::new(PathBuf::from("."), false, false).unwrap();
assert!(!executor.is_safe_command_structure("echo hello > file.txt"));
assert!(!executor.is_safe_command_structure("cat file.txt >> output.txt"));
assert!(!executor.is_safe_command_structure("echo hello > file.txt 2>&1"));
assert!(!executor.is_safe_command_structure("cargo build 2>&1 > output.txt"));
}
#[test]
fn test_path_traversal_blocked() {
let executor = BashExecutor::new(PathBuf::from("."), false, false).unwrap();
assert!(!executor.is_safe_command_structure("cat ../file.txt"));
assert!(!executor.is_safe_command_structure("ls ../../etc"));
assert!(!executor.is_safe_command_structure("cat ../../../etc/passwd"));
assert!(!executor.is_safe_command_structure("cat file.txt && ls .."));
assert!(!executor.is_safe_command_structure("ls | cat ../secret"));
}
#[test]
fn test_absolute_paths_pass_structural_check() {
let executor = BashExecutor::new(PathBuf::from("."), false, false).unwrap();
assert!(executor.is_safe_command_structure("/bin/ls"));
assert!(executor.is_safe_command_structure("cat /etc/passwd"));
assert!(executor.is_safe_command_structure("ls /tmp"));
assert!(executor.is_safe_command_structure("cat /home/user/file"));
}
#[test]
fn test_output_size_limit() {
let (_temp, path) = test_support::workspace();
let executor = BashExecutor::new(path, false, false).unwrap();
let result = executor.execute("seq 1 2000000");
assert!(result.is_err());
if let Err(SofosError::ToolExecution(msg)) = result {
assert!(msg.contains("too large"));
assert!(msg.contains("10 MB"));
} else {
panic!("Expected ToolExecution error");
}
}
#[test]
fn test_read_permission_blocks_cat() {
use std::fs;
let (_temp, path) = test_support::workspace();
let config_dir = path.join(".sofos");
fs::create_dir_all(&config_dir).unwrap();
fs::write(
config_dir.join("config.local.toml"),
r#"[permissions]
allow = []
deny = ["Read(./test/**)"]
ask = []
"#,
)
.unwrap();
let executor = BashExecutor::new(path, false, false).unwrap();
let result = executor.execute("cat ./test/secret.txt");
assert!(result.is_err());
if let Err(SofosError::ToolExecution(msg)) = result {
assert!(msg.contains("Read access denied") || msg.contains("denied"));
} else {
panic!("Expected ToolExecution error");
}
}
#[test]
fn test_safe_git_commands() {
let executor = BashExecutor::new(PathBuf::from("."), false, false).unwrap();
assert!(executor.is_safe_command_structure("git status"));
assert!(executor.is_safe_command_structure("git log"));
assert!(executor.is_safe_command_structure("git log --oneline"));
assert!(executor.is_safe_command_structure("git diff"));
assert!(executor.is_safe_command_structure("git diff HEAD~1"));
assert!(executor.is_safe_command_structure("git show"));
assert!(executor.is_safe_command_structure("git show HEAD"));
assert!(executor.is_safe_command_structure("git branch"));
assert!(executor.is_safe_command_structure("git branch -v"));
assert!(executor.is_safe_command_structure("git branch --list"));
assert!(executor.is_safe_command_structure("git remote -v"));
assert!(executor.is_safe_command_structure("git config --list"));
assert!(executor.is_safe_command_structure("git ls-files"));
assert!(executor.is_safe_command_structure("git ls-tree HEAD"));
assert!(executor.is_safe_command_structure("git blame file.txt"));
assert!(executor.is_safe_command_structure("git grep pattern"));
assert!(executor.is_safe_command_structure("git rev-parse HEAD"));
assert!(executor.is_safe_command_structure("git describe --tags"));
assert!(executor.is_safe_command_structure("git stash list"));
assert!(executor.is_safe_command_structure("git stash show"));
assert!(executor.is_safe_command_structure("git stash show stash@{0}"));
assert!(executor.is_safe_command_structure("git restore file.txt"));
assert!(executor.is_safe_command_structure("git restore src/foo.rs"));
assert!(executor.is_safe_command_structure("git checkout -- file.txt"));
assert!(executor.is_safe_command_structure("git checkout HEAD -- src/foo.rs"));
assert!(executor.is_safe_command_structure("git log HEAD~5..HEAD"));
assert!(executor.is_safe_command_structure("git diff HEAD~1..HEAD"));
assert!(executor.is_safe_command_structure("git log HEAD~5..HEAD -- src/foo.rs"));
}
#[test]
fn test_path_traversal_token_detection() {
assert!(has_path_traversal("cd .."));
assert!(has_path_traversal("cat ../file"));
assert!(has_path_traversal("ls ../../etc"));
assert!(has_path_traversal("cat /foo/..")); assert!(has_path_traversal("cat foo/../bar")); assert!(has_path_traversal("cat \"../secret\""));
assert!(has_path_traversal("cat '../secret'"));
assert!(has_path_traversal("echo $(cat ../secret)"));
assert!(has_path_traversal("ls `../bin/tool`"));
assert!(has_path_traversal("clang --include=../secret.h file.c"));
assert!(has_path_traversal("PATH=/usr/bin:../foo cmd"));
assert!(has_path_traversal("FOO=.. cmd"));
assert!(!has_path_traversal("git log HEAD~5..HEAD"));
assert!(!has_path_traversal("git diff HEAD~1..HEAD -- src/foo.rs"));
assert!(!has_path_traversal("grep '\\.\\.\\.' file.txt"));
assert!(!has_path_traversal("ls foo..bar")); assert!(!has_path_traversal("git show HEAD:src/foo.rs"));
assert!(!has_path_traversal("git show HEAD~5:src/foo.rs"));
}
#[test]
fn test_dangerous_git_commands() {
let executor = BashExecutor::new(PathBuf::from("."), false, false).unwrap();
assert!(!executor.is_safe_command_structure("git push"));
assert!(!executor.is_safe_command_structure("git push origin main"));
assert!(!executor.is_safe_command_structure("git push --force"));
assert!(!executor.is_safe_command_structure("git pull"));
assert!(!executor.is_safe_command_structure("git pull origin main"));
assert!(!executor.is_safe_command_structure("git fetch"));
assert!(!executor.is_safe_command_structure("git fetch origin"));
assert!(!executor.is_safe_command_structure("git clone https://example.com/repo.git"));
assert!(!executor.is_safe_command_structure("git clean -fd"));
assert!(!executor.is_safe_command_structure("git reset --hard"));
assert!(!executor.is_safe_command_structure("git reset --hard HEAD~1"));
assert!(!executor.is_safe_command_structure("git checkout -f"));
assert!(!executor.is_safe_command_structure("git checkout -b newbranch"));
assert!(!executor.is_safe_command_structure("git branch -D branch-name"));
assert!(!executor.is_safe_command_structure("git branch -d branch-name"));
assert!(!executor.is_safe_command_structure("git filter-branch"));
assert!(!executor.is_safe_command_structure("git add ."));
assert!(!executor.is_safe_command_structure("git add file.txt"));
assert!(!executor.is_safe_command_structure("git commit -m 'message'"));
assert!(!executor.is_safe_command_structure("git commit --amend"));
assert!(!executor.is_safe_command_structure("git rm file.txt"));
assert!(!executor.is_safe_command_structure("git mv old.txt new.txt"));
assert!(!executor.is_safe_command_structure("git merge branch"));
assert!(!executor.is_safe_command_structure("git rebase main"));
assert!(!executor.is_safe_command_structure("git cherry-pick abc123"));
assert!(!executor.is_safe_command_structure("git revert abc123"));
assert!(!executor.is_safe_command_structure("git switch main"));
assert!(
!executor.is_safe_command_structure("git remote add origin https://evil.com/repo.git")
);
assert!(
!executor
.is_safe_command_structure("git remote set-url origin https://evil.com/repo.git")
);
assert!(!executor.is_safe_command_structure("git remote remove origin"));
assert!(!executor.is_safe_command_structure("git submodule update"));
assert!(!executor.is_safe_command_structure("git submodule init"));
assert!(!executor.is_safe_command_structure("git stash"));
assert!(!executor.is_safe_command_structure("git stash pop"));
assert!(!executor.is_safe_command_structure("git stash apply"));
assert!(!executor.is_safe_command_structure("git stash drop"));
assert!(!executor.is_safe_command_structure("git stash clear"));
assert!(!executor.is_safe_command_structure("git init"));
assert!(!executor.is_safe_command_structure("git init new-repo"));
}
#[test]
fn test_git_commands_in_chains() {
let executor = BashExecutor::new(PathBuf::from("."), false, false).unwrap();
assert!(executor.is_safe_command_structure("git status && git log"));
assert!(executor.is_safe_command_structure("git diff | grep pattern"));
assert!(executor.is_safe_command_structure("echo test; git status"));
assert!(!executor.is_safe_command_structure("git status && git push"));
assert!(!executor.is_safe_command_structure("git log | git commit -m 'test'"));
assert!(!executor.is_safe_command_structure("echo test; git add ."));
assert!(!executor.is_safe_command_structure("git status || git pull"));
}
#[test]
fn test_error_messages_are_informative() {
let executor = BashExecutor::new(PathBuf::from("."), false, false).unwrap();
let reason = executor.get_git_rejection_reason("git push origin main");
assert!(reason.contains("git push origin main"));
assert!(reason.contains("remote repositories"));
assert!(reason.contains("git status"));
}
#[test]
fn test_tilde_paths_pass_structural_check() {
let executor = BashExecutor::new(PathBuf::from("."), false, false).unwrap();
assert!(executor.is_safe_command_structure("ls ~/tmp"));
assert!(executor.is_safe_command_structure("cat ~/file.txt"));
assert!(executor.is_safe_command_structure("grep pattern ~/docs/file.txt"));
}
#[test]
fn test_git_checkout_requires_confirmation_non_interactive() {
let (_temp, path) = test_support::workspace();
let executor = BashExecutor::new(path, false, false).unwrap();
for cmd in &[
"git checkout main",
"git checkout HEAD~3",
"git checkout -- src/lib.rs",
] {
let result = executor.execute(cmd);
assert!(
result.is_err(),
"expected confirmation gate to deny `{}` in non-interactive mode",
cmd
);
if let Err(SofosError::ToolExecution(msg)) = result {
assert!(
msg.contains("confirmation"),
"expected 'confirmation' hint for `{}`, got: {}",
cmd,
msg
);
} else {
panic!(
"expected ToolExecution error for `{}`, got: {:?}",
cmd, result
);
}
}
}
#[test]
fn test_git_checkout_force_stays_hard_denied() {
let (_temp, path) = test_support::workspace();
let executor = BashExecutor::new(path, false, false).unwrap();
for cmd in &["git checkout -f main", "git checkout -b new-branch"] {
let result = executor.execute(cmd);
assert!(result.is_err(), "`{}` must stay hard-denied", cmd);
if let Err(SofosError::ToolExecution(msg)) = result {
assert!(
!msg.contains("requires interactive confirmation"),
"`{}` should be hard-denied, not askable — got: {}",
cmd,
msg
);
}
}
}
#[test]
fn test_flag_embedded_external_path_is_checked() {
let (_temp, path) = test_support::workspace();
let executor = BashExecutor::new(path, false, false).unwrap();
let result = executor.execute("grep --include=/etc/passwd pattern .");
assert!(result.is_err(), "expected external-path rejection");
if let Err(SofosError::ToolExecution(msg)) = result {
assert!(
msg.contains("outside workspace"),
"expected 'outside workspace' in error, got: {msg}"
);
} else {
panic!("Expected ToolExecution error, got: {result:?}");
}
}
#[test]
fn test_session_scoped_permissions_persist() {
let executor = BashExecutor::new(PathBuf::from("."), false, false).unwrap();
{
let mut allowed = executor.session_allowed.lock().unwrap();
allowed.insert("Bash(my_custom_cmd)".to_string());
}
{
let allowed = executor.session_allowed.lock().unwrap();
assert!(allowed.contains("Bash(my_custom_cmd)"));
}
{
let mut denied = executor.session_denied.lock().unwrap();
denied.insert("Bash(blocked_cmd)".to_string());
}
{
let denied = executor.session_denied.lock().unwrap();
assert!(denied.contains("Bash(blocked_cmd)"));
}
}
#[test]
fn test_session_permissions_shared_across_clones() {
let executor1 = BashExecutor::new(PathBuf::from("."), false, false).unwrap();
let executor2 = executor1.clone();
{
let mut allowed = executor1.session_allowed.lock().unwrap();
allowed.insert("Bash(shared_cmd)".to_string());
}
{
let allowed = executor2.session_allowed.lock().unwrap();
assert!(allowed.contains("Bash(shared_cmd)"));
}
}
}