use crate::agent::tools::{AskSender, PermCheck, ToolError, enforce_request};
#[cfg(feature = "semantic-bash")]
use crate::semantic::adapters::bash;
#[allow(unused_imports)]
use crate::sync_util::LockExt;
#[cfg(feature = "semantic-bash")]
pub(super) fn bash_mutation_targets(command: &str) -> Vec<String> {
let mut targets = bash::extract_redirect_targets(command);
targets.extend(bash::extract_mutation_paths(command));
targets
}
#[cfg(feature = "semantic-bash")]
pub(super) fn mark_bash_mutations(permission: Option<&PermCheck>, command: &str) {
let base = permission.map(|p| {
let g = p.lock_ignore_poison();
std::path::PathBuf::from(g.working_dir())
});
for target in bash_mutation_targets(command) {
if target.starts_with("/dev/") || target.starts_with("/proc/") {
continue;
}
let p = std::path::Path::new(&target);
let abs = match &base {
Some(b) if p.is_relative() => b.join(p),
_ => p.to_path_buf(),
};
crate::agent::tools::modified::mark_modified(&abs);
}
}
#[cfg(feature = "semantic-bash")]
fn normalize_lexical(p: &std::path::Path) -> std::path::PathBuf {
let mut out = std::path::PathBuf::new();
for comp in p.components() {
use std::path::Component;
match comp {
Component::ParentDir => {
out.pop();
}
Component::CurDir => {}
other => out.push(other.as_os_str()),
}
}
out
}
#[cfg(feature = "semantic-bash")]
fn fold_cd_dirs(base: &str, segments: &[String]) -> String {
let mut dir = std::path::PathBuf::from(base);
for seg in segments {
let mut it = seg.split_whitespace();
let head = it.next().unwrap_or("");
if head == "cd" || head == "pushd" {
if let Some(target) = it.find(|a| !a.starts_with('-')) {
let t = target.trim_matches(['"', '\'']);
if t.is_empty() {
continue;
}
let tp = std::path::Path::new(t);
if tp.is_absolute() {
dir = tp.to_path_buf();
} else {
dir = normalize_lexical(&dir.join(tp));
}
}
}
}
dir.to_string_lossy().into_owned()
}
#[cfg(feature = "semantic-bash")]
fn resolve_target(effective_dir: &str, target: &str) -> String {
let p = std::path::Path::new(target);
if p.is_absolute() {
target.to_string()
} else {
normalize_lexical(&std::path::Path::new(effective_dir).join(p))
.to_string_lossy()
.into_owned()
}
}
pub(super) async fn check_bash_segments(
permission: &Option<PermCheck>,
ask_tx: &Option<AskSender>,
command: &str,
) -> Result<(), ToolError> {
let Some(perm) = permission else {
return Ok(()); };
let mode = {
let g = perm.lock_ignore_poison();
g.mode()
};
use crate::permission::engine::types::{AccessRequest, Claim, Operation, Resource};
let cmd_claim = |seg: &str| {
Claim::new(
Operation::Execute,
Resource::Command {
raw: seg.to_string(),
head: seg.split_whitespace().next().unwrap_or("").to_string(),
},
)
};
let mut claims: Vec<Claim> = Vec::new();
#[cfg(feature = "semantic-bash")]
{
let working_dir = {
let g = perm.lock_ignore_poison();
g.working_dir().to_string()
};
let (segments, complex) = bash::parse_bash_segments_full(command)
.unwrap_or_else(|_| (vec![command.to_string()], false));
let effective_dir = fold_cd_dirs(&working_dir, &segments);
let path_claim = |target: &str| {
let resolved = resolve_target(&effective_dir, target);
Claim::new(
Operation::Edit,
crate::permission::engine::classify_path(&resolved, &working_dir),
)
};
if complex {
claims.push(cmd_claim(command));
} else {
for segment in &segments {
claims.push(cmd_claim(segment));
}
}
for target in bash::extract_redirect_targets(command) {
claims.push(path_claim(&target));
}
for path in bash::extract_mutation_paths(command) {
claims.push(path_claim(&path));
}
}
#[cfg(not(feature = "semantic-bash"))]
{
let has_substitution = command.contains("$(")
|| command.contains('`')
|| command.contains("<(")
|| command.contains(">(")
|| command.contains("$'")
|| command.contains("<<");
if has_substitution {
claims.push(cmd_claim(command));
} else {
for segment in quote_aware_split(command) {
claims.push(cmd_claim(segment));
}
let working_dir = {
let g = perm.lock_ignore_poison();
g.working_dir().to_string()
};
let path_claim = |target: &str| {
Claim::new(
Operation::Edit,
crate::permission::engine::classify_path(target, &working_dir),
)
};
for target in coarse_redirect_targets(command) {
claims.push(path_claim(&target));
}
for path in coarse_mutation_paths(command) {
claims.push(path_claim(&path));
}
}
}
if claims.is_empty() {
claims.push(cmd_claim(command));
}
let req = AccessRequest {
tool: "bash".to_string(),
claims,
mode,
display_input: command.to_string(),
};
enforce_request(permission, ask_tx, req).await
}
#[cfg_attr(feature = "semantic-bash", allow(dead_code))]
pub(super) fn quote_aware_split(command: &str) -> Vec<&str> {
let bytes = command.as_bytes();
let mut segments = Vec::new();
let mut start = 0;
let mut i = 0;
let mut in_single = false;
let mut in_double = false;
let mut prev_backslash = false;
while i < bytes.len() {
let b = bytes[i];
if prev_backslash {
prev_backslash = false;
i += 1;
continue;
}
if b == b'\\' && !in_single {
prev_backslash = true;
i += 1;
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 {
if i + 1 < bytes.len()
&& ((b == b'&' && bytes[i + 1] == b'&') || (b == b'|' && bytes[i + 1] == b'|'))
{
push_segment(command, start, i, &mut segments);
i += 2;
start = i;
continue;
}
if b == b';' {
push_segment(command, start, i, &mut segments);
i += 1;
start = i;
continue;
}
if b == b'|' {
push_segment(command, start, i, &mut segments);
i += 1;
start = i;
continue;
}
if b == b'&' {
push_segment(command, start, i, &mut segments);
i += 1;
start = i;
continue;
}
}
i += 1;
}
push_segment(command, start, bytes.len(), &mut segments);
segments
}
#[cfg_attr(feature = "semantic-bash", allow(dead_code))]
fn push_segment<'a>(command: &'a str, start: usize, end: usize, out: &mut Vec<&'a str>) {
if end <= start {
return;
}
let s = command[start..end].trim();
if !s.is_empty() {
out.push(s);
}
}
#[cfg(not(feature = "semantic-bash"))]
pub(super) fn coarse_redirect_targets(command: &str) -> Vec<String> {
let bytes = command.as_bytes();
let mut targets = Vec::new();
let mut i = 0;
let mut in_single = false;
let mut in_double = false;
while i < bytes.len() {
let c = bytes[i];
if in_single {
if c == b'\'' {
in_single = false;
}
i += 1;
continue;
}
if in_double {
if c == b'"' {
in_double = false;
}
i += 1;
continue;
}
match c {
b'\\' => i += 2, b'\'' => {
in_single = true;
i += 1;
}
b'"' => {
in_double = true;
i += 1;
}
b'>' => {
i += 1;
if i < bytes.len() && bytes[i] == b'>' {
i += 1; }
if i < bytes.len() && bytes[i] == b'|' {
i += 1; }
while i < bytes.len() && (bytes[i] as char).is_whitespace() {
i += 1;
}
let start = i;
while i < bytes.len() {
let t = bytes[i];
if (t as char).is_whitespace()
|| matches!(t, b';' | b'|' | b'&' | b'>' | b'<' | b'(' | b')')
{
break;
}
i += 1;
}
if i > start {
let tok = command[start..i].trim_matches(['"', '\'']);
if !tok.is_empty() {
targets.push(tok.to_string());
}
}
}
_ => i += 1,
}
}
targets
}
#[cfg(not(feature = "semantic-bash"))]
const COARSE_MUTATORS: &[&str] = &[
"rm", "cp", "mv", "mkdir", "rmdir", "touch", "chmod", "chown", "ln", "dd", "truncate", "tee",
"install", "shred",
];
#[cfg(not(feature = "semantic-bash"))]
pub(super) fn coarse_mutation_paths(command: &str) -> Vec<String> {
let mut out = Vec::new();
for segment in quote_aware_split(command) {
let mut toks = segment.split_whitespace();
let Some(head) = toks.next() else { continue };
let base = head.rsplit('/').next().unwrap_or(head);
if !COARSE_MUTATORS.contains(&base) {
continue;
}
for t in toks {
if t.starts_with('-') {
continue; }
if base == "dd" {
if let Some(rest) = t.strip_prefix("of=") {
if !rest.is_empty() {
out.push(rest.to_string());
}
}
continue; }
out.push(t.to_string());
}
}
out
}