use crate::errors::{Result, SafetyError, SelfwareError};
use regex::Regex;
use std::path::PathBuf;
use std::sync::LazyLock;
use crate::api::types::ToolCall;
use crate::config::{is_local_endpoint, SafetyConfig};
use crate::safety::scanner::SecuritySeverity;
use super::types::*;
impl SafetyChecker {
pub fn new(config: &SafetyConfig) -> Self {
Self {
config: config.clone(),
working_dir: std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")),
security_scanner: crate::safety::scanner::SecurityScanner::new(),
}
}
#[cfg(test)]
pub fn with_working_dir(config: &SafetyConfig, working_dir: PathBuf) -> Self {
Self {
config: config.clone(),
working_dir,
security_scanner: crate::safety::scanner::SecurityScanner::new(),
}
}
pub fn check_tool_call(&self, call: &ToolCall) -> Result<()> {
let raw_name = &call.function.name;
let tool_name = raw_name.trim();
if raw_name != tool_name {
tracing::debug!(
"Tool name had whitespace: '{}' -> '{}'",
raw_name,
tool_name
);
}
match tool_name {
"file_write" | "file_edit" | "file_read" | "file_delete" | "search"
| "directory_tree" | "file_list" | "analyze" | "tech_debt_report" => {
let args: serde_json::Value = serde_json::from_str(&call.function.arguments)?;
if let Some(path) = args.get("path").and_then(|v| v.as_str()) {
self.check_path(path)?;
}
if tool_name == "file_write" || tool_name == "file_edit" {
let content = args
.get("content")
.or_else(|| args.get("new_str"))
.and_then(|v| v.as_str())
.unwrap_or("");
if !content.is_empty() {
self.check_content_for_secrets(content)?;
}
}
}
"shell_exec" => {
let args: serde_json::Value = serde_json::from_str(&call.function.arguments)?;
let cmd = args.get("command").and_then(|v| v.as_str()).unwrap_or("");
self.check_shell_command(cmd)?;
if let Some(cwd) = args.get("cwd").and_then(|v| v.as_str()) {
self.check_path(cwd)?;
}
}
"git_commit" | "git_checkpoint" => {
}
"git_push" => {
let args: serde_json::Value = serde_json::from_str(&call.function.arguments)?;
let force = args.get("force").and_then(|v| v.as_bool()).unwrap_or(false);
if force {
return Err(SelfwareError::Safety(SafetyError::BlockedForcePush));
}
if let Some(branch) = args.get("branch").and_then(|v| v.as_str()) {
if self
.config
.protected_branches
.iter()
.any(|b| b == branch)
{
return Err(SelfwareError::Safety(
SafetyError::BlockedProtectedBranchPush {
branch: branch.to_string(),
protected: self.config.protected_branches.clone(),
},
));
}
}
}
"container_exec" => {
let args: serde_json::Value = serde_json::from_str(&call.function.arguments)?;
if let Some(cmd) = args.get("command").and_then(|v| v.as_str()) {
self.check_shell_command(cmd)?;
}
}
"container_run" => {
let args: serde_json::Value = serde_json::from_str(&call.function.arguments)?;
if let Some(cmd) = args.get("command").and_then(|v| v.as_str()) {
self.check_shell_command(cmd)?;
}
if let Some(volumes) = args.get("volumes").and_then(|v| v.as_array()) {
for vol in volumes {
if let Some(mount) = vol.as_str() {
self.check_volume_mount(mount)?;
}
}
}
}
"process_start" => {
let args: serde_json::Value = serde_json::from_str(&call.function.arguments)?;
if let Some(cmd) = args.get("command").and_then(|v| v.as_str()) {
self.check_shell_command(cmd)?;
}
if let Some(cwd) = args.get("cwd").and_then(|v| v.as_str()) {
self.check_path(cwd)?;
}
}
"http_request" => {
let args: serde_json::Value = serde_json::from_str(&call.function.arguments)?;
if let Some(url) = args.get("url").and_then(|v| v.as_str()) {
self.check_http_request_url(url)?;
}
}
"browser_fetch" | "browser_links" => {
let args: serde_json::Value = serde_json::from_str(&call.function.arguments)?;
if let Some(url) = args.get("url").and_then(|v| v.as_str()) {
self.check_browser_url(url)?;
}
}
"browser_screenshot" | "browser_pdf" => {
let args: serde_json::Value = serde_json::from_str(&call.function.arguments)?;
if let Some(url) = args.get("url").and_then(|v| v.as_str()) {
self.check_browser_url(url)?;
}
if let Some(output_path) = args.get("output_path").and_then(|v| v.as_str()) {
self.check_path(output_path)?;
}
}
"screen_capture" => {
let args: serde_json::Value = serde_json::from_str(&call.function.arguments)?;
if let Some(output_path) = args.get("output_path").and_then(|v| v.as_str()) {
self.check_path(output_path)?;
}
}
"browser_eval" => {
let args: serde_json::Value = serde_json::from_str(&call.function.arguments)?;
if let Some(url) = args.get("url").and_then(|v| v.as_str()) {
self.check_browser_url(url)?;
}
if let Some(code) = args
.get("code")
.or_else(|| args.get("expression"))
.and_then(|v| v.as_str())
{
self.check_browser_eval(code)?;
}
}
"npm_install" | "pip_install" | "yarn_install" => {
let args: serde_json::Value = serde_json::from_str(&call.function.arguments)?;
if let Some(script) = args.get("script").and_then(|v| v.as_str()) {
self.check_shell_command(script)?;
}
}
"git_status" | "git_diff" | "grep_search" | "glob_find" | "symbol_search"
| "tool_search" | "process_list" | "process_logs" | "port_check" | "pip_list"
| "pip_freeze" | "npm_scripts" | "container_list" | "container_logs"
| "container_images" | "knowledge_query" | "knowledge_stats" | "knowledge_export"
=> {
}
"knowledge_add" | "knowledge_relate" | "knowledge_remove" | "knowledge_clear" => {
}
"cargo_test" | "cargo_check" | "cargo_clippy" | "cargo_fmt" => {
}
"npm_run" => {
let args: serde_json::Value = serde_json::from_str(&call.function.arguments)?;
if let Some(script) = args.get("script").and_then(|v| v.as_str()) {
self.check_shell_command(script)?;
}
}
"process_stop" | "process_restart" => {
}
"container_stop" | "container_remove" | "container_pull" | "container_build"
| "compose_up" | "compose_down" => {
}
"vision_analyze" | "vision_compare" => {
let args: serde_json::Value = serde_json::from_str(&call.function.arguments)?;
if let Some(endpoint) = args.get("endpoint").and_then(|v| v.as_str()) {
self.check_vision_endpoint_url(endpoint)?;
}
for key in &["image_path", "image_a", "image_b"] {
if let Some(p) = args.get(*key).and_then(|v| v.as_str()) {
self.check_path(p)?;
}
}
}
"file_fim_edit" => {
let args: serde_json::Value = serde_json::from_str(&call.function.arguments)?;
if let Some(path) = args.get("path").and_then(|v| v.as_str()) {
self.check_path(path)?;
}
}
"computer_screen" | "computer_window" => {
}
"code_introspect" | "code_query" | "code_plan" => {
let args: serde_json::Value = serde_json::from_str(&call.function.arguments)?;
if let Some(path) = args.get("target").and_then(|v| v.as_str()) {
self.check_path(path)?;
}
if let Some(path) = args.get("path").and_then(|v| v.as_str()) {
self.check_path(path)?;
}
}
"context_status"
| "context_focus"
| "context_evict"
| "context_recommend"
| "context_load_skeleton"
| "context_bulk_read"
| "context_summary" => {
}
"computer_mouse" | "computer_keyboard" => {
}
"page_control" => {
let args: serde_json::Value = serde_json::from_str(&call.function.arguments)?;
if let Some(url) = args.get("url").and_then(|v| v.as_str()) {
self.check_page_control_url(url)?;
}
if let Some(path) = args.get("path").and_then(|v| v.as_str()) {
self.check_path(path)?;
}
if let Some(expr) = args.get("expression").and_then(|v| v.as_str()) {
self.check_browser_eval(expr)?;
}
}
"pty_shell" => {
let args: serde_json::Value = serde_json::from_str(&call.function.arguments)?;
if let Some(cmd) = args.get("command").and_then(|v| v.as_str()) {
self.check_shell_command(cmd)?;
}
}
"file_multi_edit" => {
let args: serde_json::Value = serde_json::from_str(&call.function.arguments)?;
if let Some(edits) = args.get("edits").and_then(|v| v.as_array()) {
for edit in edits {
if let Some(path) = edit.get("path").and_then(|v| v.as_str()) {
self.check_path(path)?;
}
if let Some(new_str) = edit.get("new_str").and_then(|v| v.as_str()) {
if !new_str.is_empty() {
self.check_content_for_secrets(new_str)?;
}
}
}
}
}
"patch_apply" => {
let args: serde_json::Value = serde_json::from_str(&call.function.arguments)?;
if let Some(diff) = args.get("diff").and_then(|v| v.as_str()) {
for line in diff.lines() {
if let Some(rest) = line.strip_prefix("+++ ") {
let p = rest.trim().trim_start_matches("b/");
if !p.is_empty() && p != "/dev/null" {
self.check_path(p)?;
}
}
}
self.check_content_for_secrets(diff)?;
}
}
"enter_worktree" | "exit_worktree" => {
let args: serde_json::Value = serde_json::from_str(&call.function.arguments)?;
if let Some(path) = args.get("path").and_then(|v| v.as_str()) {
self.check_path(path)?;
}
}
"hot_reload" => {
if let Ok(args) =
serde_json::from_str::<serde_json::Value>(&call.function.arguments)
{
if let Some(path) = args.get("path").and_then(|v| v.as_str()) {
self.check_path(path)?;
}
}
}
"list_worktrees"
| "lsp_goto_definition"
| "lsp_goto_implementation"
| "lsp_find_references"
| "lsp_hover"
| "lsp_document_symbols"
| "lsp_workspace_symbols"
| "lsp_diagnostics"
| "code_metrics"
| "code_map"
| "code_diff_plan"
| "context_budget"
| "context_action"
| "localize_issue"
| "ask_user"
| "knowledge_auto_extract" => {
}
unknown => {
if unknown.starts_with("mcp_") {
if let Ok(args) =
serde_json::from_str::<serde_json::Value>(&call.function.arguments)
{
if let Some(path) = args.get("path").and_then(|v| v.as_str()) {
self.check_path(path)?;
}
if let Some(cmd) = args.get("command").and_then(|v| v.as_str()) {
self.check_shell_command(cmd)?;
}
}
return Ok(());
}
tracing::error!(
"Safety checker: unregistered tool '{}' blocked — add to checker.rs dispatch if legitimate.",
unknown
);
return Err(SelfwareError::Safety(SafetyError::UnregisteredTool {
tool: unknown.to_string(),
}));
}
}
Ok(())
}
pub fn check_shell_command(&self, cmd: &str) -> Result<()> {
let normalized = normalize_shell_command(cmd);
let dequoted = dequote_and_lowercase(&normalized);
for (pattern, description) in DANGEROUS_COMMAND_PATTERNS.iter() {
if pattern.is_match(&normalized) || pattern.is_match(&dequoted) {
return Err(SelfwareError::Safety(
SafetyError::DangerousCommandPattern {
description: (*description).to_string(),
},
));
}
}
if BASE64_EXEC_PATTERN.is_match(&normalized) || BASE64_EXEC_PATTERN.is_match(&dequoted) {
return Err(SelfwareError::Safety(SafetyError::BlockedBase64Command));
}
if HEX_EXEC_PATTERN.is_match(&normalized) || HEX_EXEC_PATTERN.is_match(&dequoted) {
return Err(SelfwareError::Safety(SafetyError::BlockedHexCommand));
}
if ENCODED_EXEC_PATTERN.is_match(&normalized) || ENCODED_EXEC_PATTERN.is_match(&dequoted) {
return Err(SelfwareError::Safety(SafetyError::BlockedEncodedCommand));
}
for target in shell_output_redirect_targets(cmd) {
if let Some(pattern) = redirect_target_matches_denied(
&target,
&self.working_dir,
&self.config.denied_paths,
) {
return Err(SelfwareError::Safety(SafetyError::PathDeniedPattern {
pattern,
}));
}
}
for target in shell_tee_write_targets(cmd) {
if let Some(pattern) = redirect_target_matches_denied(
&target,
&self.working_dir,
&self.config.denied_paths,
) {
return Err(SelfwareError::Safety(SafetyError::PathDeniedPattern {
pattern,
}));
}
}
self.check_shell_command_paths(cmd)?;
for part in split_shell_commands(&normalized) {
let part_trimmed = part.trim();
for (pattern, description) in DANGEROUS_COMMAND_PATTERNS.iter() {
if pattern.is_match(part_trimmed) {
return Err(SelfwareError::Safety(
SafetyError::DangerousCommandPattern {
description: format!("{} (in chain)", *description),
},
));
}
}
}
static DANGEROUS_ENV_VARS: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(r"(?i)^\s*(PATH|LD_PRELOAD|LD_LIBRARY_PATH|DYLD_INSERT_LIBRARIES|DYLD_LIBRARY_PATH|PYTHONPATH|NODE_PATH|PERL5LIB|RUBYLIB|CLASSPATH|HOME|SHELL|USER|TERM|IFS)\s*=")
.expect("Invalid regex")
});
for part in split_shell_commands(&normalized) {
let part_trimmed = part.trim();
if DANGEROUS_ENV_VARS.is_match(part_trimmed) {
return Err(SelfwareError::Safety(SafetyError::BlockedEnvInjection));
}
}
for part in split_shell_commands(&normalized) {
if let Some(branch) =
git_push_protected_branch_target(part.trim(), &self.config.protected_branches)
{
return Err(SelfwareError::Safety(
SafetyError::BlockedProtectedBranchPush {
branch,
protected: self.config.protected_branches.clone(),
},
));
}
}
Ok(())
}
fn check_shell_command_paths(&self, cmd: &str) -> Result<()> {
for segment in split_shell_pipeline(cmd) {
let Some(tokens) = shlex::split(segment) else {
continue;
};
let Some(cmd_idx) = command_word_index(&tokens) else {
continue;
};
let verb = command_basename(&tokens[cmd_idx]);
if verb == "tee" || verb == "sponge" {
continue;
}
let is_file_verb = FILE_TARGET_VERBS.contains(&verb);
let mut chmod_mode_seen = false;
let mut flags_done = false;
enum Redirect {
None,
Skip,
Take,
}
let mut pending = Redirect::None;
for (i, tok) in tokens.iter().enumerate() {
if i == cmd_idx {
continue; }
match pending {
Redirect::Skip => {
pending = Redirect::None;
continue;
}
Redirect::Take => {
pending = Redirect::None;
self.check_shell_path_candidate(tok)?;
continue;
}
Redirect::None => {}
}
let stripped = tok.trim_start_matches(|c: char| c.is_ascii_digit());
if let Some(rest) = stripped.strip_prefix("<<") {
if rest.is_empty() {
pending = Redirect::Skip;
}
continue;
}
if let Some(rest) = stripped.strip_prefix('<') {
if rest.is_empty() {
pending = Redirect::Take;
} else {
self.check_shell_path_candidate(rest)?;
}
continue;
}
if matches!(stripped, ">" | ">>" | ">|") {
pending = Redirect::Skip;
continue;
}
if stripped.starts_with('>') {
continue;
}
if tok == "--" {
flags_done = true;
continue;
}
if !flags_done && tok.starts_with('-') && tok.len() > 1 {
continue; }
if is_file_verb {
if verb == "dd" {
if let Some(v) = tok.strip_prefix("if=").or_else(|| tok.strip_prefix("of="))
{
self.check_shell_path_candidate(v)?;
}
continue; }
if verb == "chmod" && !chmod_mode_seen {
chmod_mode_seen = true; continue;
}
self.check_shell_path_candidate(tok)?;
continue;
}
if looks_like_explicit_path(tok) {
self.check_shell_path_candidate(tok)?;
}
}
}
Ok(())
}
fn check_shell_path_candidate(&self, candidate: &str) -> Result<()> {
let expanded = expand_home_token(candidate);
{
let forms: &[&str] = if expanded == candidate {
&[candidate]
} else {
&[candidate, expanded.as_str()]
};
for form in forms {
if let Some(pattern) = redirect_target_matches_denied(
form,
&self.working_dir,
&self.config.denied_paths,
) {
return Err(SelfwareError::Safety(SafetyError::PathDeniedPattern {
pattern,
}));
}
}
}
if self.config.allowed_paths.is_empty() {
return Ok(());
}
let resolved = resolve_redirect_target_lexical(&expanded, &self.working_dir);
use crate::safety::path_validator::PathValidator;
let validator = PathValidator::new(&self.config, self.working_dir.clone());
let allowed = validator
.is_path_in_allowed_list(&resolved, candidate)
.unwrap_or(false);
if !allowed {
return Err(SelfwareError::Safety(SafetyError::PathNotAllowed {
path: resolved,
}));
}
Ok(())
}
fn check_content_for_secrets(&self, content: &str) -> Result<()> {
let result = self.security_scanner.scan_content(content, None, "");
let blocked: Vec<_> = result
.findings
.iter()
.filter(|f| f.severity >= SecuritySeverity::High)
.collect();
if !blocked.is_empty() {
let titles: Vec<_> = blocked.iter().map(|f| f.title.as_str()).collect();
return Err(SelfwareError::Safety(SafetyError::SecretDetected {
finding: titles.join(", "),
}));
}
Ok(())
}
fn check_volume_mount(&self, mount: &str) -> Result<()> {
let host_path = mount.split(':').next().unwrap_or("");
let dangerous_mounts = [
"/", "/etc", "/boot", "/usr", "/var", "/root", "/sys", "/proc", "/lib", "/lib64",
"/opt", "/run",
];
if host_path.contains("/.ssh")
|| host_path == ".ssh"
|| host_path == "~/.ssh"
|| host_path.starts_with("~/.ssh/")
{
return Err(SelfwareError::Safety(SafetyError::ContainerSshMount {
mount: mount.to_string(),
}));
}
for dm in &dangerous_mounts {
if host_path == *dm
|| (host_path.starts_with(dm) && host_path.as_bytes().get(dm.len()) == Some(&b'/'))
{
return Err(SelfwareError::Safety(SafetyError::ContainerSystemMount {
mount: mount.to_string(),
directory: (*dm).to_string(),
}));
}
}
Ok(())
}
fn check_http_request_url(&self, url: &str) -> Result<()> {
self.check_url_ssrf_with_options(
url,
UrlSafetyOptions {
allow_file_scheme: false,
allow_localhost: true,
},
std::env::var("SELFWARE_ALLOW_PRIVATE_NETWORK").unwrap_or_default() == "1",
)
}
fn check_browser_url(&self, url: &str) -> Result<()> {
self.check_url_ssrf_with_options(
url,
UrlSafetyOptions {
allow_file_scheme: false,
allow_localhost: true,
},
std::env::var("SELFWARE_ALLOW_PRIVATE_NETWORK").unwrap_or_default() == "1",
)
}
fn check_page_control_url(&self, url: &str) -> Result<()> {
if url.starts_with("file://") {
let parsed = url::Url::parse(url)?;
let path = parsed
.to_file_path()
.map_err(|_| anyhow::anyhow!("file:// URL must point to a local absolute path"))?;
let path_str = path.to_string_lossy();
return self.check_path(&path_str);
}
self.check_url_ssrf_with_options(
url,
UrlSafetyOptions {
allow_file_scheme: false,
allow_localhost: true,
},
std::env::var("SELFWARE_ALLOW_PRIVATE_NETWORK").unwrap_or_default() == "1",
)
}
fn check_vision_endpoint_url(&self, url: &str) -> Result<()> {
self.check_url_ssrf_with_options(
url,
UrlSafetyOptions {
allow_file_scheme: false,
allow_localhost: true,
},
std::env::var("SELFWARE_ALLOW_PRIVATE_NETWORK").unwrap_or_default() == "1",
)
}
pub(crate) fn check_url_ssrf_with_options(
&self,
url: &str,
options: UrlSafetyOptions,
allow_private: bool,
) -> Result<()> {
let lower = url.to_lowercase();
for scheme in &["file:", "gopher:", "dict:", "ftp:"] {
if *scheme == "file:" && options.allow_file_scheme && lower.starts_with("file:") {
return Ok(());
}
if lower.starts_with(scheme) {
return Err(SelfwareError::Safety(SafetyError::BlockedUrlScheme {
scheme: (*scheme).trim_end_matches(':').to_string(),
}));
}
}
let blocked_hosts = [
"169.254.169.254",
"metadata.google.internal",
"[fd00:ec2::254]",
"100.100.100.200",
];
for host in &blocked_hosts {
if lower.contains(host) {
return Err(SelfwareError::Safety(SafetyError::BlockedCloudMetadata {
host: (*host).to_string(),
}));
}
}
let encoded_bypasses = [
"0xa9fea9fe",
"0xa9.0xfe.0xa9.0xfe",
"2852039166",
"0251.0376.0251.0376",
"0x646464c8",
"0x64.0x64.0x64.0xc8",
"1684300232",
"0144.0144.0144.0310",
];
for encoded in &encoded_bypasses {
if lower.contains(encoded) {
return Err(SelfwareError::Safety(SafetyError::BlockedEncodedMetadata));
}
}
if lower.contains("169.254.") {
return Err(SelfwareError::Safety(SafetyError::BlockedLinkLocal));
}
if let Ok(parsed) = url::Url::parse(url) {
if options.allow_localhost && is_local_endpoint(url) {
return Ok(());
}
if let Some(host) = parsed.host_str() {
if let Ok(ip) = host.parse::<std::net::IpAddr>() {
if is_private_or_internal(ip) && !allow_private {
return Err(SelfwareError::Safety(SafetyError::BlockedPrivateNetwork {
ip: ip.to_string(),
}));
}
}
}
}
Ok(())
}
fn check_browser_eval(&self, code: &str) -> Result<()> {
let lower = code.to_lowercase();
if (lower.contains("fetch(") || lower.contains("xmlhttprequest"))
&& (lower.contains("document.cookie") || lower.contains("localstorage"))
{
return Err(SelfwareError::Safety(SafetyError::BlockedBrowserEval));
}
Ok(())
}
fn check_path(&self, path: &str) -> Result<()> {
use crate::safety::path_validator::PathValidator;
let validator = PathValidator::new(&self.config, self.working_dir.clone());
validator.validate(path).map_err(|e| match e {
crate::errors::SelfwareError::Safety(safety_err) => {
crate::errors::SelfwareError::Safety(safety_err)
}
other => other,
})
}
#[cfg(test)]
#[allow(dead_code)]
fn is_path_in_allowed_list(&self, canonical_str: &str, _original_path: &str) -> Result<bool> {
use crate::safety::path_validator::PathValidator;
let validator = PathValidator::new(&self.config, self.working_dir.clone());
validator
.is_path_in_allowed_list(canonical_str, _original_path)
.map_err(|e| match e {
crate::errors::SelfwareError::Safety(safety_err) => {
crate::errors::SelfwareError::Safety(safety_err)
}
other => other,
})
}
}
pub fn normalize_shell_command(cmd: &str) -> String {
let mut quoted_segments: Vec<String> = Vec::new();
let mut unquoted = String::with_capacity(cmd.len());
let bytes = cmd.as_bytes();
let mut i = 0;
while i < bytes.len() {
let c = bytes[i];
if (c == b'"' || c == b'\'') && (i == 0 || bytes[i - 1] != b'\\') {
let quote = c;
let seg_start = i;
i += 1;
while i < bytes.len() && !(bytes[i] == quote && bytes[i - 1] != b'\\') {
i += 1;
}
if i < bytes.len() {
i += 1;
}
let placeholder = format!("\x00\x01{}\x00", quoted_segments.len());
quoted_segments.push(cmd[seg_start..i].to_string());
unquoted.push_str(&placeholder);
} else {
unquoted.push(c as char);
i += 1;
}
}
let mut result: String = unquoted.split_whitespace().collect::<Vec<_>>().join(" ");
result = result.to_lowercase();
while result.contains("//") {
result = result.replace("//", "/");
}
result = result.replace("\\n", "").replace("\\t", " ");
let mut deslashed = String::with_capacity(result.len());
let result_bytes = result.as_bytes();
let mut j = 0;
while j < result_bytes.len() {
if result_bytes[j] == b'\\' && j + 1 < result_bytes.len() {
let next = result_bytes[j + 1];
if next.is_ascii_alphanumeric() || next == b'_' || next == b'-' || next == b'/' {
j += 1;
continue;
}
}
deslashed.push(result_bytes[j] as char);
j += 1;
}
result = deslashed;
result = result.replace('`', "$(");
result = result.replace("$(", " $( ");
result = result.replace(')', " ) ");
result = result.replace(" | ", "|");
result = result.replace("| ", "|");
result = result.replace(" |", "|");
result = result.replace('|', " | ");
result = result.split_whitespace().collect::<Vec<_>>().join(" ");
for (idx, segment) in quoted_segments.iter().enumerate() {
let placeholder = format!("\x00\x01{}\x00", idx);
result = result.replace(&placeholder, segment);
}
result
}
pub(crate) fn dequote_and_lowercase(cmd: &str) -> String {
let mut out = String::with_capacity(cmd.len());
let bytes = cmd.as_bytes();
let mut i = 0;
while i < bytes.len() {
let c = bytes[i];
if (c == b'\'' || c == b'"') && (i == 0 || bytes[i - 1] != b'\\') {
let quote = c;
i += 1;
while i < bytes.len() && !(bytes[i] == quote && (i == 0 || bytes[i - 1] != b'\\')) {
out.push(bytes[i] as char);
i += 1;
}
if i < bytes.len() {
i += 1;
}
} else {
out.push(c as char);
i += 1;
}
}
out.to_lowercase()
}
pub fn split_shell_commands(cmd: &str) -> Vec<&str> {
let mut parts = Vec::new();
let mut start = 0;
let mut in_quotes = false;
let mut quote_char = b' ';
let bytes = cmd.as_bytes();
let mut i = 0;
while i < bytes.len() {
let c = bytes[i];
if (c == b'"' || c == b'\'') && (i == 0 || bytes[i - 1] != b'\\') {
if !in_quotes {
in_quotes = true;
quote_char = c;
} else if c == quote_char {
in_quotes = false;
}
}
if !in_quotes {
if c == b';' {
if start < i {
parts.push(&cmd[start..i]);
}
start = i + 1;
} else if (c == b'&' || c == b'|') && i + 1 < bytes.len() && bytes[i + 1] == c {
if start < i {
parts.push(&cmd[start..i]);
}
start = i + 2;
i += 1;
}
}
i += 1;
}
if start < cmd.len() {
parts.push(&cmd[start..]);
}
parts
}
fn split_shell_pipeline(cmd: &str) -> Vec<&str> {
let mut parts = Vec::new();
let mut start = 0;
let mut in_quotes = false;
let mut quote_char = b' ';
let bytes = cmd.as_bytes();
let mut i = 0;
while i < bytes.len() {
let c = bytes[i];
if (c == b'"' || c == b'\'') && (i == 0 || bytes[i - 1] != b'\\') {
if !in_quotes {
in_quotes = true;
quote_char = c;
} else if c == quote_char {
in_quotes = false;
}
}
if !in_quotes && matches!(c, b';' | b'|' | b'&') {
let is_double = c != b';' && i + 1 < bytes.len() && bytes[i + 1] == c;
if start < i {
parts.push(&cmd[start..i]);
}
start = i + if is_double { 2 } else { 1 };
if is_double {
i += 1;
}
}
i += 1;
}
if start < cmd.len() {
parts.push(&cmd[start..]);
}
parts
}
fn is_env_assignment(tok: &str) -> bool {
let Some(eq) = tok.find('=') else {
return false;
};
let name = &tok[..eq];
!name.is_empty()
&& name
.chars()
.next()
.is_some_and(|c| c.is_ascii_alphabetic() || c == '_')
&& name.chars().all(|c| c.is_ascii_alphanumeric() || c == '_')
}
fn command_basename(word: &str) -> &str {
word.rsplit(['/', '\\']).next().unwrap_or(word)
}
fn command_word_index(tokens: &[String]) -> Option<usize> {
let mut idx = 0;
while tokens.get(idx).is_some_and(|t| is_env_assignment(t)) {
idx += 1;
}
if tokens
.get(idx)
.is_some_and(|t| matches!(command_basename(t), "sudo" | "doas"))
{
idx += 1;
while let Some(t) = tokens.get(idx) {
if !t.starts_with('-') || t.as_str() == "-" {
break;
}
idx += 1;
if matches!(
t.as_str(),
"-u" | "--user"
| "-g"
| "--group"
| "-h"
| "--host"
| "-p"
| "--prompt"
| "-C"
| "-D"
| "--chdir"
| "-R"
| "-U"
) {
idx += 1;
}
}
}
(idx < tokens.len()).then_some(idx)
}
fn is_shell_metachar_token(tok: &str) -> bool {
let stripped = tok.trim_start_matches(|c: char| c.is_ascii_digit());
stripped.starts_with('>')
|| stripped.starts_with('<')
|| stripped.starts_with('|')
|| stripped.starts_with('&')
|| stripped.starts_with(';')
}
pub fn shell_tee_write_targets(cmd: &str) -> Vec<String> {
let mut targets = Vec::new();
for segment in split_shell_pipeline(cmd) {
let Some(tokens) = shlex::split(segment) else {
continue;
};
let Some(cmd_idx) = command_word_index(&tokens) else {
continue;
};
if !matches!(command_basename(&tokens[cmd_idx]), "tee" | "sponge") {
continue;
}
let mut flags_done = false;
for tok in &tokens[cmd_idx + 1..] {
if is_shell_metachar_token(tok) {
break;
}
if tok == "--" {
flags_done = true;
continue;
}
if !flags_done && tok.starts_with('-') && tok.len() > 1 {
continue; }
targets.push(tok.clone());
}
}
targets
}
const FILE_TARGET_VERBS: &[&str] = &[
"cat", "cp", "mv", "rm", "chmod", "ln", "mkdir", "touch", "head", "tail", "less", "more", "dd",
"install", "rsync", "scp",
];
fn looks_like_explicit_path(tok: &str) -> bool {
if tok.contains(['$', '`', '(', ')']) {
return false;
}
if tok.starts_with('/')
|| tok.starts_with("~/")
|| tok.starts_with("./")
|| tok.starts_with("../")
|| tok.starts_with(".\\")
|| tok.starts_with("..\\")
{
return true;
}
let b = tok.as_bytes();
b.len() >= 3 && b[0].is_ascii_alphabetic() && b[1] == b':' && (b[2] == b'/' || b[2] == b'\\')
}
fn expand_home_token(tok: &str) -> String {
if tok == "~" || tok.starts_with("~/") {
let home =
std::env::var_os("HOME").or_else(|| dirs::home_dir().map(|p| p.into_os_string()));
if let Some(home) = home {
return format!("{}{}", home.to_string_lossy(), &tok[1..]);
}
}
tok.to_string()
}
fn shell_treats_backslash_as_escape() -> bool {
!cfg!(target_os = "windows")
}
pub fn shell_output_redirect_targets(cmd: &str) -> Vec<String> {
let chars: Vec<char> = cmd.chars().collect();
let mut targets = Vec::new();
let mut in_single = false;
let mut in_double = false;
let mut i = 0;
while i < chars.len() {
let c = chars[i];
if c == '\\' && !in_single && shell_treats_backslash_as_escape() {
i += 2;
continue;
}
if c == '\'' && !in_double {
in_single = !in_single;
i += 1;
continue;
}
if c == '"' && !in_single {
in_double = !in_double;
i += 1;
continue;
}
if c != '>' || in_single || in_double {
i += 1;
continue;
}
let mut j = i + 1;
if j < chars.len() && chars[j] == '>' {
j += 1;
}
if j < chars.len() && (chars[j] == '&' || chars[j] == '(') {
i = j + 1;
continue;
}
if j < chars.len() && chars[j] == '|' {
j += 1;
}
while j < chars.len() && chars[j].is_whitespace() {
j += 1;
}
let mut token = String::new();
let mut token_quote: Option<char> = None;
while j < chars.len() {
let t = chars[j];
if let Some(q) = token_quote {
if t == q {
token_quote = None;
} else {
token.push(t);
}
j += 1;
continue;
}
if t.is_whitespace() || matches!(t, ';' | '|' | '&' | '<' | '>' | '(' | ')') {
break;
}
if t == '\'' || t == '"' {
token_quote = Some(t);
j += 1;
continue;
}
if t == '\\' && j + 1 < chars.len() && shell_treats_backslash_as_escape() {
token.push(chars[j + 1]);
j += 2;
continue;
}
token.push(t);
j += 1;
}
if !token.is_empty() && !token.starts_with('&') {
targets.push(token);
}
i = j.max(i + 1);
}
targets
}
pub(super) fn redirect_target_matches_denied(
target: &str,
working_dir: &std::path::Path,
denied_paths: &[String],
) -> Option<String> {
if denied_paths.is_empty() {
return None;
}
let resolved = resolve_redirect_target_lexical(target, working_dir);
let raw_glob = target.trim_start_matches("./").replace('\\', "/");
for pattern in denied_paths {
let compiled = match glob::Pattern::new(&super::to_glob_form(pattern)) {
Ok(p) => p,
Err(_) => continue,
};
if compiled.matches(&resolved) || compiled.matches(&raw_glob) {
return Some(pattern.clone());
}
if !pattern.contains('/') && !pattern.contains('\\') {
let base = resolved.rsplit('/').next().unwrap_or(resolved.as_str());
if !base.is_empty() && compiled.matches(base) {
return Some(pattern.clone());
}
}
}
None
}
fn resolve_redirect_target_lexical(target: &str, working_dir: &std::path::Path) -> String {
let target_fwd = target.replace('\\', "/");
let joined = if redirect_target_is_absolute(&target_fwd) {
target_fwd
} else {
let wd = working_dir.to_string_lossy().replace('\\', "/");
format!("{}/{}", wd.trim_end_matches('/'), target_fwd)
};
let mut segments: Vec<&str> = Vec::new();
for seg in joined.split('/') {
match seg {
"" | "." => {}
".." => {
if matches!(segments.last(), Some(&last) if last != ".." && !is_drive_letter_segment(last))
{
segments.pop();
}
}
s => segments.push(s),
}
}
let prefix = if joined.starts_with('/') { "/" } else { "" };
format!("{}{}", prefix, segments.join("/"))
}
fn redirect_target_is_absolute(target_fwd: &str) -> bool {
if target_fwd.starts_with('/') {
return true;
}
let bytes = target_fwd.as_bytes();
bytes.len() >= 3 && bytes[0].is_ascii_alphabetic() && bytes[1] == b':' && bytes[2] == b'/'
}
fn is_drive_letter_segment(seg: &str) -> bool {
let bytes = seg.as_bytes();
bytes.len() == 2 && bytes[0].is_ascii_alphabetic() && bytes[1] == b':'
}
fn git_push_protected_branch_target(cmd: &str, protected_branches: &[String]) -> Option<String> {
if protected_branches.is_empty() {
return None;
}
let tokens = shlex::split(cmd)?;
let git_pos = tokens.iter().position(|t| t == "git")?;
let rest = &tokens[git_pos + 1..];
let push_pos = rest.iter().position(|t| t == "push")?;
let args = &rest[push_pos + 1..];
let mut delete_next = false;
let mut candidates: Vec<&str> = Vec::new();
for arg in args {
if delete_next {
candidates.push(arg.as_str());
delete_next = false;
continue;
}
if arg == "--delete" || arg == "-d" {
delete_next = true;
continue;
}
if let Some(stripped) = arg.strip_prefix(':') {
candidates.push(stripped);
continue;
}
if arg.starts_with('-') {
continue; }
candidates.push(arg.as_str());
}
for candidate in candidates {
let (local, remote) = match candidate.split_once(':') {
Some((l, r)) => (l, Some(r)),
None => (candidate, None),
};
for name in [Some(local), remote].into_iter().flatten() {
if protected_branches.iter().any(|b| b == name) {
return Some(name.to_string());
}
}
}
None
}
pub fn is_private_or_internal(ip: std::net::IpAddr) -> bool {
match ip {
std::net::IpAddr::V4(v4) => {
v4.is_private()
|| v4.is_loopback()
|| v4.is_link_local()
|| v4.is_broadcast()
|| v4.is_unspecified()
|| (v4.octets()[0] == 169 && v4.octets()[1] == 254)
|| (v4.octets()[0] == 192 && v4.octets()[1] == 0 && v4.octets()[2] == 2)
|| (v4.octets()[0] == 198 && v4.octets()[1] == 51 && v4.octets()[2] == 100)
|| (v4.octets()[0] == 203 && v4.octets()[1] == 0 && v4.octets()[2] == 113)
|| (v4.octets()[0] == 100 && (v4.octets()[1] & 0xc0) == 64)
}
std::net::IpAddr::V6(v6) => {
v6.is_loopback()
|| v6.is_unspecified()
|| (v6.segments()[0] & 0xffc0) == 0xfe80
|| (v6.segments()[0] & 0xfe00) == 0xfc00
|| v6.to_ipv4_mapped().is_some_and(|v4| {
v4.is_private()
|| v4.is_loopback()
|| v4.is_link_local()
|| v4.is_broadcast()
|| v4.is_unspecified()
|| (v4.octets()[0] == 169 && v4.octets()[1] == 254)
|| (v4.octets()[0] == 100 && (v4.octets()[1] & 0xc0) == 64)
})
}
}
}
#[derive(Clone)]
pub struct PinnedDnsResolver {
allow_private: bool,
}
impl PinnedDnsResolver {
pub fn new(allow_private: bool) -> Self {
Self { allow_private }
}
}
impl reqwest::dns::Resolve for PinnedDnsResolver {
fn resolve(&self, name: reqwest::dns::Name) -> reqwest::dns::Resolving {
let allow_private = self.allow_private;
Box::pin(async move {
let addrs: Vec<std::net::SocketAddr> =
tokio::net::lookup_host(format!("{}:0", name.as_str()))
.await
.map_err(|e| Box::new(e) as Box<dyn std::error::Error + Send + Sync>)?
.collect();
if allow_private {
let iter: reqwest::dns::Addrs = Box::new(addrs.into_iter());
return Ok(iter);
}
let safe_addrs: Vec<std::net::SocketAddr> = addrs
.into_iter()
.filter(|addr| !is_private_or_internal(addr.ip()))
.collect();
if safe_addrs.is_empty() {
return Err(Box::new(std::io::Error::new(
std::io::ErrorKind::PermissionDenied,
"DNS resolved to private/internal IP address",
))
as Box<dyn std::error::Error + Send + Sync>);
}
let iter: reqwest::dns::Addrs = Box::new(safe_addrs.into_iter());
Ok(iter)
})
}
}