use crate::error::{Result, SofosError};
use crate::tools::ToolName;
use crate::tools::bash::BashExecutor;
use crate::tools::permissions::{CommandPermission, PermissionManager};
use crate::tools::utils::is_absolute_path;
pub(super) 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
}
pub(super) 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}")))
}
impl BashExecutor {
pub(super) 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(())
}
pub(super) 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
)))
}
}
pub(super) 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(())
}
pub(super) 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
}
pub(super) 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
}
pub(super) 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(">>")
{
let edit_hint: String = if self.has_morph {
format!(
"{}/{}",
ToolName::EditFile.as_str(),
ToolName::MorphEditFile.as_str()
)
} else {
ToolName::EditFile.as_str().to_string()
};
return format!(
"Command '{}' contains output redirection ('>' or '>>')\n\
Hint: Use write_file tool to create or {} to modify files. Note: '2>&1' is allowed.",
command, edit_hint
);
}
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
)
}
pub(super) 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
)
}
}