use crate::config::{LOCAL_CONFIG_FILE, config_files_hint};
use crate::error::{Result, SofosError};
use crate::tools::ToolName;
use crate::tools::bash::BashExecutor;
use crate::tools::permissions::command_parse::{
COMPOUND_HEADERS_NO_BODY, COMPOUND_HEADERS_WITH_BODY, COMPOUND_KEYWORDS, is_env_assignment,
};
use crate::tools::permissions::{CommandPermission, PermissionManager, grant_dir_for_path};
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 = reduce_for_traversal(raw);
if t == ".." || t.starts_with("../") || t.ends_with("/..") || t.contains("/../") {
return true;
}
}
false
}
fn reduce_for_traversal(raw: &str) -> String {
let mut out = String::with_capacity(raw.len());
let mut in_single = false;
let mut in_double = false;
let mut chars = raw.chars();
while let Some(c) = chars.next() {
match c {
'\'' if !in_double => in_single = !in_single,
'"' if !in_single => in_double = !in_double,
'\\' if !in_single && !in_double => {
if let Some(next) = chars.next() {
out.push(next);
}
}
'"' | '\'' | '`' | '(' | ')' | '{' | '}' | '[' | ']' | ';' | ',' => {}
_ => out.push(c),
}
}
out
}
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 detect_ansi_c_quoting(command: &str) -> bool {
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 && !in_double && b == b'$' && bytes.get(i + 1) == Some(&b'\'') {
return true;
}
i += 1;
}
false
}
const GIT_LAUNCHERS: &[&str] = &[
"env", "nice", "time", "timeout", "command", "nohup", "setsid", "stdbuf", "xargs", "ionice",
"taskset", "chrt",
];
const GIT_SHELLS: &[&str] = &["sh", "bash", "dash", "zsh", "ksh"];
const GIT_GLOBAL_VALUE_OPTIONS: &[&str] = &[
"-C",
"-c",
"--git-dir",
"--work-tree",
"--namespace",
"--super-prefix",
"--config-env",
"--attr-source",
"--shallow-file",
];
const GIT_GLOBAL_NOARG_OPTIONS: &[&str] = &[
"-p",
"-P",
"--paginate",
"--no-pager",
"--bare",
"--no-replace-objects",
"--literal-pathspecs",
"--glob-pathspecs",
"--noglob-pathspecs",
"--icase-pathspecs",
"--no-optional-locks",
"--no-advice",
];
const GIT_NESTING_LIMIT: u8 = 8;
fn flush_word(words: &mut Vec<String>, cur: &mut String, in_word: &mut bool, drop_next: &mut bool) {
if !*in_word {
return;
}
if *drop_next {
cur.clear();
*drop_next = false;
} else {
words.push(std::mem::take(cur));
}
*in_word = false;
}
fn shell_words(segment: &str) -> Vec<String> {
let mut words = Vec::new();
let mut cur = String::new();
let mut in_word = false;
let mut drop_next = false;
let mut chars = segment.chars().peekable();
let mut quote: Option<char> = None;
while let Some(c) = chars.next() {
match quote {
Some('\'') => {
if c == '\'' {
quote = None;
} else {
cur.push(c);
}
}
Some(_) => match c {
'"' => quote = None,
'\\' => match chars.peek() {
Some(&n) if matches!(n, '"' | '\\' | '$' | '`') => {
cur.push(n);
chars.next();
}
_ => cur.push('\\'),
},
_ => cur.push(c),
},
None => match c {
'\'' | '"' => {
quote = Some(c);
in_word = true;
}
'\\' => {
if let Some(n) = chars.next() {
cur.push(n);
}
in_word = true;
}
'<' | '>' => {
if in_word && cur.chars().all(|c| c.is_ascii_digit()) {
cur.clear();
in_word = false;
} else {
flush_word(&mut words, &mut cur, &mut in_word, &mut drop_next);
}
while matches!(chars.peek(), Some('<' | '>' | '&' | '|')) {
chars.next();
}
drop_next = true;
}
'(' | ')' => flush_word(&mut words, &mut cur, &mut in_word, &mut drop_next),
_ if c.is_whitespace() => {
flush_word(&mut words, &mut cur, &mut in_word, &mut drop_next)
}
_ => {
cur.push(c);
in_word = true;
}
},
}
}
flush_word(&mut words, &mut cur, &mut in_word, &mut drop_next);
words
}
fn command_base_index(words: &[String]) -> Option<usize> {
let mut i = 0;
while let Some(tok) = words.get(i) {
if COMPOUND_HEADERS_NO_BODY.contains(&tok.as_str()) {
return None;
}
if is_env_assignment(tok)
|| COMPOUND_KEYWORDS.contains(&tok.as_str())
|| COMPOUND_HEADERS_WITH_BODY.contains(&tok.as_str())
{
i += 1;
} else {
break;
}
}
(i < words.len()).then_some(i)
}
fn collect_git_invocations(words: &[String], depth: u8, out: &mut Vec<Vec<String>>) {
if depth == 0 {
return;
}
let Some(base) = command_base_index(words) else {
return;
};
let rest = &words[base + 1..];
if base_is_git(&words[base]) {
out.push(rest.to_vec());
return;
}
let name = program_name(&words[base]);
if GIT_SHELLS.contains(&name.as_str()) {
for payload in shell_c_payloads(rest) {
collect_git_invocations(&shell_words(&payload), depth - 1, out);
}
return;
}
if GIT_LAUNCHERS.contains(&name.as_str()) {
if name == "env" {
for payload in env_split_string_payloads(rest) {
collect_git_invocations(&shell_words(&payload), depth - 1, out);
}
}
for (i, tok) in rest.iter().enumerate() {
if base_is_git(tok) {
out.push(rest[i + 1..].to_vec());
} else if GIT_SHELLS.contains(&program_name(tok).as_str()) {
for payload in shell_c_payloads(&rest[i + 1..]) {
collect_git_invocations(&shell_words(&payload), depth - 1, out);
}
}
}
}
}
fn env_split_string_payloads(args: &[String]) -> Vec<String> {
let mut out = Vec::new();
let mut i = 0;
while let Some(tok) = args.get(i) {
if tok == "-S" || tok == "--split-string" {
if let Some(payload) = args.get(i + 1) {
out.push(payload.clone());
}
i += 2;
} else if let Some(payload) = tok
.strip_prefix("--split-string=")
.or_else(|| tok.strip_prefix("-S").filter(|s| !s.is_empty()))
{
out.push(payload.to_string());
i += 1;
} else {
i += 1;
}
}
out
}
fn program_name(token: &str) -> String {
token
.rsplit(['/', '\\'])
.next()
.unwrap_or(token)
.to_ascii_lowercase()
}
fn shell_c_payloads(args: &[String]) -> Vec<String> {
let mut out = Vec::new();
let mut i = 0;
while i < args.len() {
if args[i] == "-c" {
if let Some(payload) = args.get(i + 1) {
out.push(payload.clone());
}
i += 2;
} else {
i += 1;
}
}
out
}
fn git_subcommand_candidates(args: &[String]) -> Vec<usize> {
let mut seen = vec![false; args.len()];
let mut out = Vec::new();
let mut stack = vec![0usize];
while let Some(i) = stack.pop() {
if i >= args.len() || seen[i] {
continue;
}
seen[i] = true;
let tok = &args[i];
if tok == "--" {
stack.push(i + 1);
} else if !tok.starts_with('-') || tok == "-" {
out.push(i);
} else if tok.contains('=') {
stack.push(i + 1);
} else if GIT_GLOBAL_VALUE_OPTIONS.contains(&tok.as_str()) {
stack.push(i + 2);
} else if GIT_GLOBAL_NOARG_OPTIONS.contains(&tok.as_str()) {
stack.push(i + 1);
} else {
stack.push(i + 1);
stack.push(i + 2);
}
}
out
}
fn git_args_are_dangerous(args: &[String]) -> bool {
if git_args_use_exec_capable_config(args) {
return true;
}
git_subcommand_candidates(args)
.into_iter()
.any(|i| git_subcommand_is_dangerous(&args[i].to_ascii_lowercase(), &args[i + 1..]))
}
fn git_subcommand_is_dangerous(verb: &str, rest: &[String]) -> bool {
let has = |flags: &[&str]| {
rest.iter()
.any(|t| flags.contains(&t.to_ascii_lowercase().as_str()))
};
let first = || rest.first().map(|s| s.to_ascii_lowercase());
match verb {
"push" | "pull" | "fetch" | "clone" | "clean" | "filter-branch" | "gc" | "prune"
| "update-ref" | "send-email" | "apply" | "am" | "cherry-pick" | "revert" | "commit"
| "merge" | "rebase" | "init" | "add" | "rm" | "mv" | "switch" | "submodule" | "daemon"
| "instaweb" => true,
"reset" => has(&["--hard", "--mixed"]),
"checkout" => has(&["-f", "--force", "-b", "-B"]),
"branch" => has(&["-d", "-D", "-m", "-M", "--delete", "--move"]),
"tag" => has(&["-d", "--delete"]),
"remote" => matches!(
first().as_deref(),
Some("add" | "set-url" | "remove" | "rm")
),
"stash" => !matches!(first().as_deref(), Some("list" | "show")),
_ => false,
}
}
fn git_args_use_exec_capable_config(args: &[String]) -> bool {
let mut i = 0;
while let Some(tok) = args.get(i) {
if tok == "--" || !tok.starts_with('-') || tok == "-" {
return false;
}
if tok == "-c" || tok == "--config-env" {
if args
.get(i + 1)
.is_some_and(|v| config_key_is_exec_capable(v))
{
return true;
}
i += 2;
continue;
}
if let Some(key) = tok.strip_prefix("-c").filter(|k| !k.is_empty()) {
if config_key_is_exec_capable(key) {
return true;
}
}
if let Some(key) = tok.strip_prefix("--config-env=") {
if config_key_is_exec_capable(key) {
return true;
}
}
if !tok.contains('=') && GIT_GLOBAL_VALUE_OPTIONS.contains(&tok.as_str()) {
i += 2;
} else {
i += 1;
}
}
false
}
fn config_key_is_exec_capable(setting: &str) -> bool {
let (key, value) = match setting.split_once('=') {
Some((k, v)) => (k.to_ascii_lowercase(), Some(v)),
None => (setting.to_ascii_lowercase(), None),
};
if key == "alias"
|| key.starts_with("alias.")
|| key == "include.path"
|| (key.starts_with("includeif.") && key.ends_with(".path"))
{
return true;
}
if value.is_none_or(is_git_boolean) {
return false;
}
const EXEC_SUFFIXES: &[&str] = &[
".command",
".cmd",
".process",
".clean",
".smudge",
".textconv",
".helper",
".program",
".editor",
".sshcommand",
".fsmonitor",
".hookspath",
".external",
".askpass",
".packobjectshook",
".gitproxy",
];
key == "core.pager"
|| key.starts_with("pager.")
|| EXEC_SUFFIXES.iter().any(|suffix| key.ends_with(suffix))
}
fn is_git_boolean(value: &str) -> bool {
matches!(
value.to_ascii_lowercase().as_str(),
"" | "true" | "false" | "yes" | "no" | "on" | "off" | "1" | "0"
)
}
fn git_invocations(command: &str) -> Vec<Vec<String>> {
let mut out = Vec::new();
for segment in PermissionManager::split_compound_command(command) {
collect_git_invocations(&shell_words(&segment), GIT_NESTING_LIMIT, &mut out);
}
out
}
pub(super) fn command_runs_only_git(command: &str) -> bool {
if PermissionManager::command_has_dangerous_env_prefix(command) {
return false;
}
let mut saw_git = false;
for segment in PermissionManager::split_compound_command(command) {
let words = shell_words(&segment);
let Some(base) = command_base_index(&words) else {
continue;
};
if !base_is_bare_git(&words[base]) {
return false;
}
saw_git = true;
}
saw_git
}
pub(super) fn command_redirects_output(command: &str) -> bool {
command.replace("2>&1", "").contains('>')
}
fn first_dangerous_git_verb(command: &str) -> Option<String> {
for args in git_invocations(command) {
if git_args_use_exec_capable_config(&args) {
return Some("config".to_string());
}
for i in git_subcommand_candidates(&args) {
let verb = args[i].to_ascii_lowercase();
if git_subcommand_is_dangerous(&verb, &args[i + 1..]) {
return Some(verb);
}
}
}
None
}
pub(super) fn command_contains_askable_git_checkout(command: &str) -> bool {
git_invocations(command).iter().any(|args| {
git_subcommand_candidates(args)
.into_iter()
.any(|i| args[i].eq_ignore_ascii_case("checkout"))
})
}
fn base_is_git(token: &str) -> bool {
let bare: String = token
.chars()
.filter(|c| !matches!(c, '\'' | '"' | '\\' | '(' | ')' | '{' | '}'))
.collect();
bare.rsplit('/')
.next()
.unwrap_or(&bare)
.eq_ignore_ascii_case("git")
}
fn base_is_bare_git(token: &str) -> bool {
let bare: String = token
.chars()
.filter(|c| !matches!(c, '\'' | '"' | '\\' | '(' | ')' | '{' | '}'))
.collect();
bare.eq_ignore_ascii_case("git")
}
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");
}
let body = strip_windows_verbatim_prefix(tok);
if body.contains('?') || body.contains('*') || body.contains('[') || body.contains('{') {
return Some("glob expansion");
}
None
}
fn strip_windows_verbatim_prefix(tok: &str) -> &str {
for prefix in [r"\\?\", r"\\.\", "//?/", "//./"] {
if let Some(rest) = tok.strip_prefix(prefix) {
return rest;
}
}
tok
}
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 resolved = crate::tools::bash::sandbox::canonicalize_existing_prefix(&joined);
if resolved.starts_with(&self.workspace) {
return Ok(());
}
let resolved_str = resolved.to_string_lossy().to_string();
self.check_bash_external_path(&resolved_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 {}",
cand,
config_files_hint()
)));
}
}
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 grant_dir = grant_dir_for_path(std::path::Path::new(&check_path));
if !self.interactive {
return Err(SofosError::ToolExecution(format!(
"Command references path '{}' outside workspace\n\
Hint: Add Bash({}/**) to 'allow' list in {}",
check_path, grant_dir, LOCAL_CONFIG_FILE
)));
}
let (allowed, remember) = permission_manager.ask_user_path_permission("Bash", grant_dir)?;
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 (index, token) in command.split_whitespace().enumerate() {
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('~')
|| is_absolute_path(cleaned);
if index == 0 && !path_shaped {
continue;
}
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 {
config_files_hint()
};
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;
}
if detect_ansi_c_quoting(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(&normalize_command_whitespace(command)) {
return false;
}
true
}
pub(super) fn is_safe_git_command(&self, command: &str) -> bool {
!git_invocations(command)
.iter()
.any(|args| git_args_are_dangerous(args))
}
pub(super) fn get_rejection_reason(&self, command: &str) -> String {
let matcher_input = normalize_command_whitespace(command);
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 detect_ansi_c_quoting(command) {
return format!(
"Command '{}' uses the bash $'...' quoting form, which the shell rewrites into other characters and can hide a program name or git subcommand from the safety checks\n\
Hint: Write the characters directly, or pass them inside ordinary single or double quotes.",
command
);
}
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 verb = first_dangerous_git_verb(&normalize_command_whitespace(command));
match verb.as_deref() {
Some("push") => format!(
"Command '{command}' blocked: 'git push' sends data to remote repositories\n\
Hint: Use 'git status', 'git log', 'git diff' to view changes."
),
Some(op @ ("pull" | "fetch")) => format!(
"Command '{command}' blocked: 'git {op}' fetches data from remote repositories\n\
Hint: Use 'git status', 'git log', 'git diff' to view local changes."
),
Some("clone") => format!(
"Command '{command}' blocked: 'git clone' downloads repositories\n\
Hint: Clone repositories manually outside of Sofos."
),
Some(op @ ("commit" | "add")) => format!(
"Command '{command}' blocked: 'git {op}' modifies the git repository\n\
Hint: Use 'git status', 'git diff' to view changes. Create commits manually."
),
Some(op @ ("reset" | "clean")) => format!(
"Command '{command}' blocked: 'git {op}' is a destructive operation\n\
Hint: Use 'git status', 'git log', 'git diff' to view repository state."
),
Some(op @ ("checkout" | "switch")) => format!(
"Command '{command}' blocked: 'git {op}' changes branches or modifies the working directory\n\
Hint: Use 'git branch' to list branches, 'git status' to see the current branch."
),
Some(op @ ("merge" | "rebase")) => format!(
"Command '{command}' blocked: 'git {op}' modifies git history\n\
Hint: Perform merges/rebases manually outside of Sofos."
),
Some("stash") => format!(
"Command '{command}' blocked: 'git stash' modifies repository state\n\
Hint: Use 'git stash list' or 'git stash show' to view stashed changes."
),
Some("remote") => format!(
"Command '{command}' blocked: modifying git remotes is not allowed\n\
Hint: Use 'git remote -v' to view configured remotes."
),
Some("submodule") => format!(
"Command '{command}' blocked: 'git submodule' can fetch from remote repositories\n\
Hint: Manage submodules manually outside of Sofos."
),
Some("config") => format!(
"Command '{command}' blocked: a '-c'/'--config-env' option sets a git config value that runs an external command\n\
Hint: Run the git command without the inline config override."
),
_ => format!(
"Command '{command}' blocked: git operation modifies repository or accesses network\n\
Hint: Allowed git commands: status, log, diff, show, branch, remote -v, grep, blame"
),
}
}
}