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, lexically_normalize, normalize_command_whitespace};
use std::path::PathBuf;
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 detect_command_substitution(command: &str) -> Option<&'static str> {
let bytes = command.as_bytes();
let mut i = 0;
let mut in_single = false;
let mut in_double = false;
while i < bytes.len() {
let b = bytes[i];
if !in_single && b == b'\\' {
i = i.saturating_add(2);
continue;
}
if !in_double && b == b'\'' {
in_single = !in_single;
i += 1;
continue;
}
if !in_single && b == b'"' {
in_double = !in_double;
i += 1;
continue;
}
if !in_single {
if b == b'`' {
return Some("`");
}
if b == b'$' && i + 1 < bytes.len() && bytes[i + 1] == b'(' {
if i + 2 < bytes.len() && bytes[i + 2] == b'(' {
i += 3;
continue;
}
return Some("$(");
}
if (b == b'<' || b == b'>') && i + 1 < bytes.len() && bytes[i + 1] == b'(' {
return Some(if b == b'<' { "<(" } else { ">(" });
}
}
i += 1;
}
None
}
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}")))
}
pub(super) fn path_token_shell_meta(tok: &str) -> Option<&'static str> {
if tok.contains('$') {
return Some("$ variable expansion");
}
if tok.contains('`') {
return Some("backtick command substitution");
}
if tok.starts_with('~') && tok != "~" && !tok.starts_with("~/") {
return Some("~user home expansion");
}
if tok.contains('?') || tok.contains('*') || tok.contains('[') || tok.contains('{') {
return Some("glob expansion");
}
None
}
fn token_looks_like_path(tok: &str) -> bool {
tok.contains('/') || tok.starts_with('.') || tok.starts_with('~') || is_absolute_path(tok)
}
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 token_looks_like_path(path_candidate) {
if let Some(kind) = path_token_shell_meta(path_candidate) {
return Err(SofosError::ToolExecution(format!(
"Path argument '{}' uses {} which can't be checked against the permission rules before the shell expands it\n\
Hint: pass the resolved literal path instead, or split this into a separate step that doesn't reference the same path.",
path_candidate, kind
)));
}
}
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)?;
} else {
self.check_workspace_relative_escape(path_candidate, permission_manager)?;
}
}
Ok(())
}
fn check_workspace_relative_escape(
&self,
path_candidate: &str,
permission_manager: &mut PermissionManager,
) -> Result<()> {
let joined = self.workspace.join(path_candidate);
let canonical = match std::fs::canonicalize(&joined) {
Ok(path) => path,
Err(_) => return Ok(()),
};
if canonical.starts_with(&self.workspace) {
return Ok(());
}
let canonical_str = canonical.to_string_lossy().to_string();
self.check_bash_external_path(&canonical_str, permission_manager)
}
pub(super) fn check_bash_external_path(
&self,
path: &str,
permission_manager: &mut PermissionManager,
) -> Result<()> {
let canonical = std::fs::canonicalize(path)
.map(|p| p.to_string_lossy().to_string())
.ok();
let normalized = lexically_normalize(&PathBuf::from(path))
.to_string_lossy()
.to_string();
let candidates: Vec<String> = match canonical {
Some(c) if c == normalized || c == path => vec![c],
Some(c) => vec![c, normalized.clone(), path.to_string()],
None if normalized == path => vec![path.to_string()],
None => vec![normalized.clone(), path.to_string()],
};
let check_path = candidates
.first()
.cloned()
.unwrap_or_else(|| path.to_string());
for cand in &candidates {
if permission_manager.is_bash_path_denied(cand) {
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",
cand
)));
}
}
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(check_path.to_string());
}
}
Ok(())
} else {
if !remember {
if let Ok(mut dirs) = self.bash_path_session_denied.lock() {
dirs.insert(check_path.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 path_shaped =
cleaned.contains('/') || cleaned.starts_with('.') || cleaned.starts_with('~');
if path_shaped {
if let Some(kind) = path_token_shell_meta(cleaned) {
return Err(SofosError::ToolExecution(format!(
"Read argument '{}' uses {} which can't be checked against the Read rules before the shell expands it\n\
Hint: pass the resolved literal path instead, or split this into a separate step that doesn't reference the same path.",
cleaned, kind
)));
}
}
let is_path = path_shaped
|| (!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;
}
if detect_command_substitution(command).is_some() {
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;
}
let matcher_input = normalize_command_whitespace(command).to_lowercase();
if !self.is_safe_git_command(&matcher_input) {
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 matcher_input = normalize_command_whitespace(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 let Some(marker) = detect_command_substitution(command) {
return format!(
"Command '{}' uses shell substitution ('{}') which would run a hidden subcommand outside the permission system\n\
Hint: Run each step as its own bash call so the permission system can see it. Use single quotes if you need the literal characters.",
command, marker
);
}
if !self.is_safe_git_command(&matcher_input) {
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 = normalize_command_whitespace(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
)
}
}