use crate::error::{Result, SofosError};
use crate::tools::permissions::{CommandPermission, PermissionManager};
use crate::tools::utils::{
MAX_TOOL_OUTPUT_TOKENS, TruncationKind, is_absolute_path, truncate_for_context,
};
use std::collections::HashSet;
use std::path::PathBuf;
use std::process::Command;
use std::sync::{Arc, Mutex};
const MAX_OUTPUT_SIZE: usize = 10 * 1024 * 1024;
fn has_path_traversal(command: &str) -> bool {
let split = |c: char| c.is_whitespace() || matches!(c, '=' | ':');
for raw in command.split(split).filter(|t| !t.is_empty()) {
let t = raw.trim_matches(|c: char| {
matches!(
c,
'"' | '\'' | '`' | '(' | ')' | '{' | '}' | '[' | ']' | ';' | ','
)
});
if t == ".." || t.starts_with("../") || t.ends_with("/..") || t.contains("/../") {
return true;
}
}
false
}
fn command_contains_op(command: &str, op: &str) -> bool {
const BOUNDARIES: &[&str] = &[" ", ";", "&&", "||", "|", "`", "$(", "(", "{"];
if command.starts_with(op) {
return true;
}
BOUNDARIES
.iter()
.any(|sep| command.contains(&format!("{sep}{op}")))
}
#[cfg(unix)]
fn signal_name(sig: i32) -> &'static str {
match sig {
1 => "SIGHUP",
2 => "SIGINT",
3 => "SIGQUIT",
4 => "SIGILL",
6 => "SIGABRT",
8 => "SIGFPE",
9 => "SIGKILL",
11 => "SIGSEGV",
13 => "SIGPIPE",
14 => "SIGALRM",
15 => "SIGTERM",
_ => "unknown",
}
}
#[derive(Clone)]
pub struct BashExecutor {
workspace: PathBuf,
interactive: bool,
session_allowed: Arc<Mutex<HashSet<String>>>,
session_denied: Arc<Mutex<HashSet<String>>>,
bash_path_session_allowed: Arc<Mutex<HashSet<String>>>,
bash_path_session_denied: Arc<Mutex<HashSet<String>>>,
}
impl BashExecutor {
pub fn new(workspace: PathBuf, interactive: bool) -> Result<Self> {
Ok(Self {
workspace,
interactive,
session_allowed: Arc::new(Mutex::new(HashSet::new())),
session_denied: Arc::new(Mutex::new(HashSet::new())),
bash_path_session_allowed: Arc::new(Mutex::new(HashSet::new())),
bash_path_session_denied: Arc::new(Mutex::new(HashSet::new())),
})
}
pub fn execute(&self, command: &str) -> Result<String> {
let normalized = format!("Bash({})", command.trim());
if let Ok(allowed) = self.session_allowed.lock() {
if allowed.contains(&normalized) {
return self.execute_after_permission_check(command);
}
}
if let Ok(denied) = self.session_denied.lock() {
if denied.contains(&normalized) {
return Err(SofosError::ToolExecution(format!(
"User already declined '{}' earlier this session. \
Propose a different approach or ask the user to clarify \
rather than retrying the same command.",
command
)));
}
}
let mut permission_manager = PermissionManager::new(self.workspace.clone())?;
let permission = permission_manager.check_command_permission(command)?;
match permission {
CommandPermission::Allowed => {
}
CommandPermission::Denied => {
return Err(SofosError::ToolExecution(
self.get_rejection_reason(command),
));
}
CommandPermission::Ask => {
let (allowed, remember) = permission_manager.ask_user_permission(command)?;
if !allowed {
if !remember {
if let Ok(mut denied) = self.session_denied.lock() {
denied.insert(normalized);
}
}
return Err(SofosError::ToolExecution(format!(
"User declined '{}'. Propose a different approach or \
ask the user to clarify rather than retrying the same \
command.",
command
)));
}
if !remember {
if let Ok(mut allowed) = self.session_allowed.lock() {
allowed.insert(normalized);
}
}
}
}
self.execute_after_permission_check(command)
}
fn execute_after_permission_check(&self, command: &str) -> Result<String> {
let mut permission_manager = PermissionManager::new(self.workspace.clone())?;
self.enforce_read_permissions(&permission_manager, command)?;
if !self.is_safe_command_structure(command) {
return Err(SofosError::ToolExecution(
self.get_rejection_reason(command),
));
}
self.confirm_askable_command(command)?;
self.check_bash_external_paths(command, &mut permission_manager)?;
let output = Command::new("sh")
.arg("-c")
.arg(command)
.current_dir(&self.workspace)
.output()
.map_err(|e| SofosError::ToolExecution(format!("Failed to execute command: {}", e)))?;
if output.stdout.len() > MAX_OUTPUT_SIZE {
return Err(SofosError::ToolExecution(format!(
"Command output too large ({} bytes). Maximum size is {} MB",
output.stdout.len(),
MAX_OUTPUT_SIZE / (1024 * 1024)
)));
}
if output.stderr.len() > MAX_OUTPUT_SIZE {
return Err(SofosError::ToolExecution(format!(
"Command error output too large ({} bytes). Maximum size is {} MB",
output.stderr.len(),
MAX_OUTPUT_SIZE / (1024 * 1024)
)));
}
let stdout = String::from_utf8_lossy(&output.stdout);
let stderr = String::from_utf8_lossy(&output.stderr);
if !output.status.success() {
let exit_info = match output.status.code() {
Some(code) => format!("exit code: {}", code),
None => {
#[cfg(unix)]
{
use std::os::unix::process::ExitStatusExt;
match output.status.signal() {
Some(sig) => format!("signal: {} ({})", sig, signal_name(sig)),
None => "unknown termination".to_string(),
}
}
#[cfg(not(unix))]
{
"unknown termination".to_string()
}
}
};
let error_output = format!(
"Command failed with {}\nSTDOUT:\n{}\nSTDERR:\n{}",
exit_info, stdout, stderr
);
return Ok(truncate_for_context(
&error_output,
MAX_TOOL_OUTPUT_TOKENS,
TruncationKind::BashOutput,
));
}
let mut result = String::new();
if !stdout.is_empty() {
result.push_str("STDOUT:\n");
result.push_str(&stdout);
}
if !stderr.is_empty() {
if !result.is_empty() {
result.push('\n');
}
result.push_str("STDERR:\n");
result.push_str(&stderr);
}
if result.is_empty() {
result = "Command executed successfully (no output)".to_string();
}
Ok(truncate_for_context(
&result,
MAX_TOOL_OUTPUT_TOKENS,
TruncationKind::BashOutput,
))
}
fn confirm_askable_command(&self, command: &str) -> Result<()> {
const ASKABLE_PREFIXES: &[&str] = &["git checkout"];
let matches = ASKABLE_PREFIXES
.iter()
.any(|prefix| command_contains_op(command, prefix));
if !matches {
return Ok(());
}
if !self.interactive {
return Err(SofosError::ToolExecution(format!(
"Command '{}' requires interactive confirmation\n\
Hint: `git checkout` prompts before running because it switches branches \
(or overwrites working-tree files). Run sofos interactively to confirm.",
command
)));
}
let prompt = format!("Run bash command: {}", command);
if !crate::tools::utils::confirm_destructive(&prompt)? {
return Err(SofosError::ToolExecution(format!(
"User declined '{}'. Propose a different approach or ask \
the user to clarify rather than retrying the same command.",
command
)));
}
Ok(())
}
fn check_bash_external_paths(
&self,
command: &str,
permission_manager: &mut PermissionManager,
) -> Result<()> {
for token in command.split_whitespace() {
let cleaned = token
.trim_matches('"')
.trim_matches('\'')
.trim_matches(';')
.trim();
if cleaned.is_empty() {
continue;
}
let path_candidate = if cleaned.starts_with('-') {
match cleaned.find('=') {
Some(i) => cleaned[i + 1..].trim_matches(|c: char| matches!(c, '"' | '\'')),
None => continue,
}
} else {
cleaned
};
if path_candidate.starts_with("~/") || path_candidate == "~" {
let expanded = PermissionManager::expand_tilde_pub(path_candidate);
self.check_bash_external_path(&expanded, permission_manager)?;
} else if is_absolute_path(path_candidate) {
self.check_bash_external_path(path_candidate, permission_manager)?;
}
}
Ok(())
}
fn check_bash_external_path(
&self,
path: &str,
permission_manager: &mut PermissionManager,
) -> Result<()> {
let resolved = std::fs::canonicalize(path)
.map(|p| p.to_string_lossy().to_string())
.unwrap_or_else(|_| path.to_string());
let check_path = resolved.as_str();
if permission_manager.is_bash_path_denied(check_path) {
return Err(SofosError::ToolExecution(format!(
"Bash access denied for path '{}'\n\
Hint: Blocked by deny rule in .sofos/config.local.toml or ~/.sofos/config.toml",
check_path
)));
}
if permission_manager.is_bash_path_allowed(check_path) {
return Ok(());
}
let path_obj = std::path::Path::new(check_path);
if let Ok(allowed_dirs) = self.bash_path_session_allowed.lock() {
for dir in allowed_dirs.iter() {
if path_obj.starts_with(std::path::Path::new(dir)) {
return Ok(());
}
}
}
if let Ok(denied_dirs) = self.bash_path_session_denied.lock() {
for dir in denied_dirs.iter() {
if path_obj.starts_with(std::path::Path::new(dir)) {
return Err(SofosError::ToolExecution(format!(
"Bash access denied for path '{}' (denied earlier this session)",
check_path
)));
}
}
}
let parent = std::path::Path::new(check_path)
.parent()
.and_then(|p| p.to_str())
.unwrap_or(check_path);
if !self.interactive {
return Err(SofosError::ToolExecution(format!(
"Command references path '{}' outside workspace\n\
Hint: Add Bash({}/**) to 'allow' list in .sofos/config.local.toml",
check_path, parent
)));
}
let (allowed, remember) = permission_manager.ask_user_path_permission("Bash", parent)?;
if allowed {
if !remember {
if let Ok(mut dirs) = self.bash_path_session_allowed.lock() {
dirs.insert(parent.to_string());
}
}
Ok(())
} else {
if !remember {
if let Ok(mut dirs) = self.bash_path_session_denied.lock() {
dirs.insert(parent.to_string());
}
}
Err(SofosError::ToolExecution(format!(
"Bash access denied by user for path '{}'",
check_path
)))
}
}
fn enforce_read_permissions(
&self,
permission_manager: &PermissionManager,
command: &str,
) -> Result<()> {
for token in command.split_whitespace().skip(1) {
let cleaned = token
.trim_matches('"')
.trim_matches('\'')
.trim_matches(';')
.trim();
if cleaned.is_empty() || cleaned.starts_with('-') {
continue;
}
let is_path = cleaned.contains('/')
|| cleaned.starts_with('.')
|| cleaned.starts_with('~')
|| (!cleaned.contains('$')
&& !cleaned.contains('`')
&& !cleaned.contains('*')
&& !cleaned.contains('?')
&& !cleaned.contains('['));
if is_path {
let (perm, matched_rule) =
permission_manager.check_read_permission_with_source(cleaned);
match perm {
CommandPermission::Allowed => {}
CommandPermission::Denied => {
let config_source = if let Some(ref rule) = matched_rule {
permission_manager.get_rule_source(rule)
} else {
".sofos/config.local.toml or ~/.sofos/config.toml".to_string()
};
return Err(SofosError::ToolExecution(format!(
"Read access denied for path '{}' in command\n\
Hint: Blocked by deny rule in {}",
cleaned, config_source
)));
}
CommandPermission::Ask => {
return Err(SofosError::ToolExecution(format!(
"Path '{}' requires confirmation per config file\n\
Hint: Move it to 'allow' or 'deny' list.",
cleaned
)));
}
}
}
}
Ok(())
}
fn is_safe_command_structure(&self, command: &str) -> bool {
if has_path_traversal(command) {
return false;
}
let command_without_stderr_redirect = command.replace("2>&1", "");
if command_without_stderr_redirect.contains('>')
|| command_without_stderr_redirect.contains(">>")
{
return false;
}
if command.contains("<<") {
return false;
}
if !self.is_safe_git_command(&command.to_lowercase()) {
return false;
}
true
}
fn is_safe_git_command(&self, command: &str) -> bool {
if !command.starts_with("git ")
&& !command.contains(" git ")
&& !command.contains(";git ")
&& !command.contains("&&git ")
&& !command.contains("||git ")
&& !command.contains("|git ")
{
return true;
}
if command.contains("git stash list") || command.contains("git stash show") {
return true;
}
let dangerous_git_ops = [
"git push",
"git pull",
"git fetch",
"git clone",
"git clean",
"git reset --hard",
"git reset --mixed",
"git checkout -f",
"git checkout -b",
"git branch -d",
"git branch -D",
"git branch -m",
"git branch -M",
"git remote add",
"git remote set-url",
"git remote remove",
"git remote rm",
"git submodule",
"git filter-branch",
"git gc",
"git prune",
"git update-ref",
"git send-email",
"git apply",
"git am",
"git cherry-pick",
"git revert",
"git commit",
"git merge",
"git rebase",
"git tag -d",
"git stash",
"git init",
"git add",
"git rm",
"git mv",
"git switch",
];
for dangerous_op in &dangerous_git_ops {
if command_contains_op(command, dangerous_op) {
return false;
}
}
true
}
fn get_rejection_reason(&self, command: &str) -> String {
let command_lower = command.to_lowercase();
if has_path_traversal(command) {
return format!(
"Command '{}' contains '..' as a path component (parent directory traversal)\n\
Hint: Use absolute paths for external directory access instead of '..'. \
Git revision ranges like `HEAD~5..HEAD` are allowed.",
command
);
}
if !self.is_safe_git_command(&command_lower) {
return self.get_git_rejection_reason(command);
}
let command_without_stderr_redirect = command.replace("2>&1", "");
if command_without_stderr_redirect.contains('>')
|| command_without_stderr_redirect.contains(">>")
{
return format!(
"Command '{}' contains output redirection ('>' or '>>')\n\
Hint: Use write_file tool to create or edit_file/morph_edit_file to modify files. Note: '2>&1' is allowed.",
command
);
}
if command.contains("<<") {
return format!(
"Command '{}' contains here-doc ('<<')\n\
Hint: Use write_file tool to create files instead.",
command
);
}
format!(
"Command '{}' is in the forbidden list (destructive or violates sandbox)\n\
Hint: Use appropriate file operation tools instead.",
command
)
}
fn get_git_rejection_reason(&self, command: &str) -> String {
let command_lower = command.to_lowercase();
if command_lower.contains("git push") {
return format!(
"Command '{}' blocked: 'git push' sends data to remote repositories\n\
Hint: Use 'git status', 'git log', 'git diff' to view changes.",
command
);
}
if command_lower.contains("git pull") || command_lower.contains("git fetch") {
let op = if command_lower.contains("git pull") {
"git pull"
} else {
"git fetch"
};
return format!(
"Command '{}' blocked: '{}' fetches data from remote repositories\n\
Hint: Use 'git status', 'git log', 'git diff' to view local changes.",
command, op
);
}
if command_lower.contains("git clone") {
return format!(
"Command '{}' blocked: 'git clone' downloads repositories\n\
Hint: Clone repositories manually outside of Sofos.",
command
);
}
if command_lower.contains("git commit") || command_lower.contains("git add") {
let op = if command_lower.contains("git commit") {
"git commit"
} else {
"git add"
};
return format!(
"Command '{}' blocked: '{}' modifies the git repository\n\
Hint: Use 'git status', 'git diff' to view changes. Create commits manually.",
command, op
);
}
if command_lower.contains("git reset") || command_lower.contains("git clean") {
let op = if command_lower.contains("git reset") {
"git reset"
} else {
"git clean"
};
return format!(
"Command '{}' blocked: '{}' is a destructive operation\n\
Hint: Use 'git status', 'git log', 'git diff' to view repository state.",
command, op
);
}
if command_lower.contains("git checkout") || command_lower.contains("git switch") {
let op = if command_lower.contains("git checkout") {
"git checkout"
} else {
"git switch"
};
return format!(
"Command '{}' blocked: '{}' changes branches or modifies working directory\n\
Hint: Use 'git branch' to list branches, 'git status' to see current branch.",
command, op
);
}
if command_lower.contains("git merge") || command_lower.contains("git rebase") {
let op = if command_lower.contains("git merge") {
"git merge"
} else {
"git rebase"
};
return format!(
"Command '{}' blocked: '{}' modifies git history\n\
Hint: Perform merges/rebases manually outside of Sofos.",
command, op
);
}
if command_lower.contains("git stash")
&& !command_lower.contains("git stash list")
&& !command_lower.contains("git stash show")
{
return format!(
"Command '{}' blocked: 'git stash' modifies repository state\n\
Hint: Use 'git stash list' or 'git stash show' to view stashed changes.",
command
);
}
if command_lower.contains("git remote add") || command_lower.contains("git remote set-url")
{
return format!(
"Command '{}' blocked: Modifying git remotes is not allowed\n\
Hint: Use 'git remote -v' to view configured remotes.",
command
);
}
if command_lower.contains("git submodule") {
return format!(
"Command '{}' blocked: 'git submodule' can fetch from remote repositories\n\
Hint: Manage submodules manually outside of Sofos.",
command
);
}
format!(
"Command '{}' blocked: git operation modifies repository or accesses network\n\
Hint: Allowed git commands: status, log, diff, show, branch, remote -v, grep, blame",
command
)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[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).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).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).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).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() {
use tempfile;
let temp_dir = tempfile::tempdir().unwrap();
let executor = BashExecutor::new(temp_dir.path().to_path_buf(), 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;
use tempfile::tempdir;
let temp_dir = tempdir().unwrap();
let config_dir = temp_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(temp_dir.path().to_path_buf(), 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).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).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).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).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).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_dir = tempfile::tempdir().unwrap();
let executor = BashExecutor::new(temp_dir.path().to_path_buf(), 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_dir = tempfile::tempdir().unwrap();
let executor = BashExecutor::new(temp_dir.path().to_path_buf(), 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_dir = tempfile::tempdir().unwrap();
let executor = BashExecutor::new(temp_dir.path().to_path_buf(), 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).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).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)"));
}
}
}