use std::collections::BTreeSet;
use std::path::Path;
use regex::Regex;
use tracing::{Level, info, instrument, warn};
use crate::audit;
use crate::hooks::ToolUseHookInput;
use crate::network_hints::extract_response_text;
use crate::policy::sandbox_types::{Cap, RuleEffect, SandboxPolicy};
use crate::settings::ClashSettings;
const FS_ERROR_PATTERNS: &[&str] = &[
"operation not permitted",
"permission denied",
];
const MAX_REPORTED_PATHS: usize = 5;
const NOISE_PATH_PREFIXES: &[&str] = &["/dev/dtrace", "/dev/dtracehelper", "/dev/oslog"];
#[instrument(level = Level::TRACE, skip(input, settings))]
pub fn check_for_sandbox_fs_hint(
input: &ToolUseHookInput,
settings: &ClashSettings,
) -> Option<String> {
if input.tool_name != "Bash" {
return None;
}
let sandbox = match resolve_sandbox_policy(input, settings) {
Some(s) => s,
None => {
info!("check_for_sandbox_fs_hint: no sandbox policy resolved, skipping");
return None;
}
};
let audit_violations = read_audit_violations(input);
let has_audit = !audit_violations.is_empty();
let response_text = input.tool_response.as_ref().and_then(extract_response_text);
let stderr_has_errors = response_text.as_ref().is_some_and(|t| contains_fs_error(t));
info!(
has_audit = has_audit,
audit_count = audit_violations.len(),
has_response = response_text.is_some(),
stderr_has_errors = stderr_has_errors,
"check_for_sandbox_fs_hint: source analysis"
);
if !has_audit && !stderr_has_errors {
return None;
}
let mut blocked_paths = Vec::new();
if has_audit {
blocked_paths.extend(paths_from_audit(&audit_violations, &sandbox, &input.cwd));
}
if let Some(ref text) = response_text {
let extracted_paths = extract_paths_from_errors(text);
info!(
extracted_count = extracted_paths.len(),
paths = ?extracted_paths,
"check_for_sandbox_fs_hint: paths extracted from stderr"
);
let stderr_blocked = extract_blocked_paths(text, &sandbox, &input.cwd);
info!(
stderr_blocked_count = stderr_blocked.len(),
"check_for_sandbox_fs_hint: stderr paths confirmed as sandbox violations"
);
let existing_dirs: BTreeSet<String> = blocked_paths
.iter()
.map(|bp| bp.suggested_dir.clone())
.collect();
for bp in stderr_blocked {
if !existing_dirs.contains(&bp.suggested_dir) {
blocked_paths.push(bp);
}
}
}
if blocked_paths.is_empty() {
info!("check_for_sandbox_fs_hint: no blocked paths after filtering, returning None");
return None;
}
info!(
tool = "Bash",
blocked_count = blocked_paths.len(),
from_audit = has_audit,
"Detected filesystem sandbox violations in command output"
);
Some(build_fs_hint(&blocked_paths))
}
fn resolve_sandbox_policy(
input: &ToolUseHookInput,
settings: &ClashSettings,
) -> Option<SandboxPolicy> {
if let Some(tree) = settings.policy_tree() {
let decision = tree.evaluate(&input.tool_name, &input.tool_input);
if let Some(sandbox) = decision.sandbox {
info!("resolve_sandbox_policy: found sandbox via decision tree re-evaluation");
return Some(sandbox);
}
info!("resolve_sandbox_policy: decision tree returned no sandbox");
} else {
info!("resolve_sandbox_policy: no decision tree available");
}
let command = input.tool_input.get("command")?.as_str()?;
if !command.contains("sandbox exec") || !command.contains("--sandbox") {
info!(
command_prefix = &command[..command.len().min(80)],
"resolve_sandbox_policy: command does not contain sandbox exec + --sandbox"
);
return None;
}
let result = extract_policy_json(command);
info!(
found = result.is_some(),
"resolve_sandbox_policy: extracted policy JSON from rewritten command"
);
result
}
fn extract_policy_json(command: &str) -> Option<SandboxPolicy> {
let policy_idx = command.find("--sandbox ")?;
let after_flag = &command[policy_idx + "--sandbox ".len()..];
let json_start = after_flag.find('{')?;
let json_str = &after_flag[json_start..];
let mut depth = 0;
let mut end = 0;
for (i, ch) in json_str.char_indices() {
match ch {
'{' => depth += 1,
'}' => {
depth -= 1;
if depth == 0 {
end = i + 1;
break;
}
}
_ => {}
}
}
if end == 0 {
return None;
}
serde_json::from_str(&json_str[..end]).ok()
}
fn contains_fs_error(text: &str) -> bool {
let lower = text.to_lowercase();
FS_ERROR_PATTERNS
.iter()
.any(|pattern| lower.contains(pattern))
}
fn is_noise_path(path: &str) -> bool {
NOISE_PATH_PREFIXES
.iter()
.any(|prefix| path.starts_with(prefix))
}
fn operation_to_required_caps(operation: &str) -> Option<Cap> {
match operation {
op if op.starts_with("file-read-") => Some(Cap::READ),
"file-write-create" => Some(Cap::WRITE | Cap::CREATE),
"file-write-unlink" => Some(Cap::WRITE | Cap::DELETE),
op if op.starts_with("file-write-") => Some(Cap::WRITE),
_ => None,
}
}
fn read_audit_violations(input: &ToolUseHookInput) -> Vec<audit::SandboxViolation> {
if input.session_id.is_empty() {
return Vec::new();
}
let tool_use_id = match input.tool_use_id.as_deref() {
Some(id) => id,
None => return Vec::new(),
};
audit::read_sandbox_violations(&input.session_id, tool_use_id)
}
fn paths_from_audit(
violations: &[audit::SandboxViolation],
sandbox: &SandboxPolicy,
cwd: &str,
) -> Vec<BlockedPath> {
let mut blocked = Vec::new();
let mut seen_dirs = BTreeSet::new();
for v in violations {
if is_noise_path(&v.path) {
continue;
}
if let Some(required) = operation_to_required_caps(&v.operation) {
let granted = sandbox.effective_caps(&v.path, cwd);
if granted.contains(required) {
continue;
}
}
let dir = suggest_parent_directory(&v.path);
if seen_dirs.insert(dir.clone()) {
blocked.push(BlockedPath {
current_caps: sandbox.effective_caps(&v.path, cwd),
path: v.path.clone(),
suggested_dir: dir,
});
}
if blocked.len() >= MAX_REPORTED_PATHS {
break;
}
}
blocked
}
#[derive(Debug)]
#[allow(dead_code)]
struct BlockedPath {
path: String,
suggested_dir: String,
current_caps: Cap,
}
fn extract_blocked_paths(text: &str, sandbox: &SandboxPolicy, cwd: &str) -> Vec<BlockedPath> {
let paths = extract_paths_from_errors(text);
let mut blocked = Vec::new();
let mut seen_dirs = BTreeSet::new();
for path in paths {
if is_noise_path(&path) || !is_likely_sandbox_violation(&path, sandbox, cwd) {
continue;
}
let dir = suggest_parent_directory(&path);
if seen_dirs.insert(dir.clone()) {
blocked.push(BlockedPath {
current_caps: sandbox.effective_caps(&path, cwd),
path,
suggested_dir: dir,
});
}
if blocked.len() >= MAX_REPORTED_PATHS {
break;
}
}
blocked
}
fn is_likely_sandbox_violation(path: &str, sandbox: &SandboxPolicy, cwd: &str) -> bool {
let caps = sandbox.effective_caps(path, cwd);
let missing_write_or_create = !caps.contains(Cap::WRITE) || !caps.contains(Cap::CREATE);
let under_explicit_allow = sandbox.rules.iter().any(|rule| {
if rule.effect != RuleEffect::Allow {
return false;
}
let resolved = SandboxPolicy::resolve_path(&rule.path, cwd);
path.starts_with(&resolved)
});
missing_write_or_create && !under_explicit_allow
}
fn extract_paths_from_errors(text: &str) -> Vec<String> {
let patterns = [
r"(?:open|stat|read|write|mkdir|access|unlink|rename|chmod|chown|lstat|readlink|creat|opendir)\s+(/[^\s:]+):\s*(?i:operation not permitted|permission denied)",
r"(?i:operation not permitted|permission denied):\s*'(/[^']+)'",
r"'(/[^']+)':\s*(?i:operation not permitted|permission denied)",
r"(?i:EACCES|EPERM):\s*(?:permission denied|operation not permitted),?\s*\w+\s*'([^']+)'",
r"(/(?:[^\s:])+):\s*(?:Permission denied|Operation not permitted)",
];
let mut paths = Vec::new();
let mut seen = BTreeSet::new();
for pattern in &patterns {
let re = match Regex::new(pattern) {
Ok(re) => re,
Err(e) => {
warn!(pattern = pattern, error = %e, "Failed to compile path extraction regex");
continue;
}
};
for cap in re.captures_iter(text) {
if let Some(m) = cap.get(1) {
let path = m.as_str().to_string();
if path.starts_with('/') && seen.insert(path.clone()) {
paths.push(path);
}
}
}
}
paths
}
fn suggest_parent_directory(path: &str) -> String {
let p = Path::new(path);
if let Some(home) = dirs::home_dir()
&& let Ok(rel) = p.strip_prefix(&home)
{
if let Some(first) = rel.components().next() {
let first_str = first.as_os_str().to_string_lossy();
if first_str.starts_with('.') {
return home.join(first_str.as_ref()).to_string_lossy().into_owned();
}
}
}
p.parent()
.map(|parent| parent.to_string_lossy().into_owned())
.unwrap_or_else(|| path.to_string())
}
fn build_fs_hint(blocked: &[BlockedPath]) -> String {
let mut lines =
vec!["SANDBOX_FS_HINT: Command failed — sandbox is blocking filesystem access.".into()];
for bp in blocked {
lines.push(format!(
"To allow: clash sandbox add-rule --name <SANDBOX> --path \"{}\" --allow \"read + write + create\"",
bp.suggested_dir
));
}
lines.push("Do NOT retry — it will fail again until the policy is updated.".into());
lines.join("\n")
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
use crate::policy::sandbox_types::{NetworkPolicy, PathMatch, SandboxRule};
#[test]
fn test_contains_fs_error_operation_not_permitted() {
assert!(contains_fs_error(
"open /Users/user/.fly/perms.123: operation not permitted"
));
}
#[test]
fn test_contains_fs_error_permission_denied() {
assert!(contains_fs_error("Permission denied: '/tmp/secret'"));
}
#[test]
fn test_contains_fs_error_case_insensitive() {
assert!(contains_fs_error("OPERATION NOT PERMITTED"));
}
#[test]
fn test_contains_fs_error_no_match() {
assert!(!contains_fs_error("file not found: /tmp/test.txt"));
}
#[test]
fn test_extract_path_go_style() {
let text = "open /Users/user/.fly/perms.123: operation not permitted";
let paths = extract_paths_from_errors(text);
assert_eq!(paths, vec!["/Users/user/.fly/perms.123"]);
}
#[test]
fn test_extract_path_python_style() {
let text = "PermissionError: [Errno 1] Operation not permitted: '/home/user/.cache/thing'";
let paths = extract_paths_from_errors(text);
assert!(paths.contains(&"/home/user/.cache/thing".to_string()));
}
#[test]
fn test_extract_path_node_style() {
let text = "Error: EACCES: permission denied, open '/tmp/app/config.json'";
let paths = extract_paths_from_errors(text);
assert!(paths.contains(&"/tmp/app/config.json".to_string()));
}
#[test]
fn test_extract_path_shell_style() {
let text = "/etc/shadow: Permission denied";
let paths = extract_paths_from_errors(text);
assert!(paths.contains(&"/etc/shadow".to_string()));
}
#[test]
fn test_extract_path_issue_example() {
let text = "Error: failed ensuring config directory perms: open /Users/emschwartz/.fly/perms.3199984107: operation not permitted";
let paths = extract_paths_from_errors(text);
assert!(paths.contains(&"/Users/emschwartz/.fly/perms.3199984107".to_string()));
}
#[test]
fn test_extract_path_multiple_errors() {
let text = "open /Users/user/.fly/config: operation not permitted\nstat /Users/user/.cache/sccache/db: operation not permitted";
let paths = extract_paths_from_errors(text);
assert!(paths.len() >= 2);
assert!(paths.contains(&"/Users/user/.fly/config".to_string()));
assert!(paths.contains(&"/Users/user/.cache/sccache/db".to_string()));
}
#[test]
fn test_extract_path_no_match() {
let text = "curl: (6) Could not resolve host: example.com";
let paths = extract_paths_from_errors(text);
assert!(paths.is_empty());
}
#[test]
fn test_extract_path_deduplicates() {
let text = "open /tmp/foo: operation not permitted\nopen /tmp/foo: operation not permitted";
let paths = extract_paths_from_errors(text);
assert_eq!(paths.len(), 1);
}
#[test]
fn test_suggest_parent_home_dotdir() {
let home = dirs::home_dir().unwrap();
let path = format!("{}/.fly/perms.123", home.display());
let suggested = suggest_parent_directory(&path);
assert_eq!(suggested, format!("{}/.fly", home.display()));
}
#[test]
fn test_suggest_parent_home_deep_dotdir() {
let home = dirs::home_dir().unwrap();
let path = format!("{}/.cache/sccache/some/deep/file.db", home.display());
let suggested = suggest_parent_directory(&path);
assert_eq!(suggested, format!("{}/.cache", home.display()));
}
#[test]
fn test_suggest_parent_non_home_path() {
let suggested = suggest_parent_directory("/opt/homebrew/lib/libfoo.so");
assert_eq!(suggested, "/opt/homebrew/lib");
}
#[test]
fn test_violation_outside_allowed_paths() {
let sandbox = SandboxPolicy {
default: Cap::READ | Cap::EXECUTE,
rules: vec![SandboxRule {
effect: RuleEffect::Allow,
caps: Cap::all(),
path: "/project".into(),
path_match: PathMatch::Subpath,
follow_worktrees: false,
doc: None,
}],
network: NetworkPolicy::Deny,
doc: None,
};
assert!(is_likely_sandbox_violation(
"/Users/user/.fly/config",
&sandbox,
"/project"
));
}
#[test]
fn test_no_violation_inside_allowed_paths() {
let sandbox = SandboxPolicy {
default: Cap::READ | Cap::EXECUTE,
rules: vec![SandboxRule {
effect: RuleEffect::Allow,
caps: Cap::all(),
path: "/project".into(),
path_match: PathMatch::Subpath,
follow_worktrees: false,
doc: None,
}],
network: NetworkPolicy::Deny,
doc: None,
};
assert!(!is_likely_sandbox_violation(
"/project/src/main.rs",
&sandbox,
"/project"
));
}
#[test]
fn test_no_violation_when_write_granted() {
let sandbox = SandboxPolicy {
default: Cap::READ | Cap::WRITE | Cap::CREATE | Cap::EXECUTE,
rules: vec![],
network: NetworkPolicy::Deny,
doc: None,
};
assert!(!is_likely_sandbox_violation(
"/Users/user/.fly/config",
&sandbox,
"/project"
));
}
#[test]
fn test_build_hint_contains_key_info() {
let blocked = vec![BlockedPath {
path: "/Users/user/.fly/perms.123".into(),
suggested_dir: "/Users/user/.fly".into(),
current_caps: Cap::READ | Cap::EXECUTE,
}];
let hint = build_fs_hint(&blocked);
assert!(hint.contains("SANDBOX_FS_HINT"));
assert!(hint.contains("/Users/user/.fly"));
assert!(hint.contains("clash sandbox add-rule"));
assert!(hint.contains("Do NOT retry"));
}
#[test]
fn test_build_hint_empty_caps() {
let blocked = vec![BlockedPath {
path: "/secret/file".into(),
suggested_dir: "/secret".into(),
current_caps: Cap::empty(),
}];
let hint = build_fs_hint(&blocked);
assert!(hint.contains("SANDBOX_FS_HINT"));
assert!(hint.contains("/secret"));
assert!(hint.contains("clash sandbox add-rule"));
}
#[test]
fn test_is_noise_path() {
assert!(is_noise_path("/dev/dtracehelper"));
assert!(is_noise_path("/dev/dtrace"));
assert!(is_noise_path("/dev/oslog/foo"));
assert!(!is_noise_path("/Users/user/.fly/config"));
assert!(!is_noise_path("/var/tmp/test"));
assert!(!is_noise_path("/dev/null")); }
#[test]
fn test_paths_from_audit_filters_noise() {
let sandbox = SandboxPolicy {
default: Cap::READ | Cap::EXECUTE,
rules: vec![],
network: NetworkPolicy::Deny,
doc: None,
};
let violations = vec![
audit::SandboxViolation {
operation: "file-read-data".into(),
path: "/dev/dtracehelper".into(),
},
audit::SandboxViolation {
operation: "file-write-create".into(),
path: "/Users/user/.fly/config".into(),
},
];
let blocked = paths_from_audit(&violations, &sandbox, "/project");
assert_eq!(blocked.len(), 1);
assert!(blocked[0].path.contains(".fly"));
}
#[test]
fn test_paths_from_audit_deduplicates_by_dir() {
let sandbox = SandboxPolicy {
default: Cap::READ | Cap::EXECUTE,
rules: vec![],
network: NetworkPolicy::Deny,
doc: None,
};
let violations = vec![
audit::SandboxViolation {
operation: "file-write-create".into(),
path: "/Users/user/.fly/perms.123".into(),
},
audit::SandboxViolation {
operation: "file-write-data".into(),
path: "/Users/user/.fly/config.json".into(),
},
];
let blocked = paths_from_audit(&violations, &sandbox, "/project");
assert_eq!(blocked.len(), 1);
assert!(blocked[0].suggested_dir.ends_with(".fly"));
}
#[test]
fn test_operation_to_required_caps() {
assert_eq!(
operation_to_required_caps("file-read-data"),
Some(Cap::READ)
);
assert_eq!(
operation_to_required_caps("file-read-metadata"),
Some(Cap::READ)
);
assert_eq!(
operation_to_required_caps("file-write-create"),
Some(Cap::WRITE | Cap::CREATE)
);
assert_eq!(
operation_to_required_caps("file-write-unlink"),
Some(Cap::WRITE | Cap::DELETE)
);
assert_eq!(
operation_to_required_caps("file-write-data"),
Some(Cap::WRITE)
);
assert_eq!(
operation_to_required_caps("file-write-flags"),
Some(Cap::WRITE)
);
assert_eq!(operation_to_required_caps("network-outbound"), None);
assert_eq!(operation_to_required_caps("process-exec"), None);
}
#[test]
fn test_paths_from_audit_filters_granted_read() {
let sandbox = SandboxPolicy {
default: Cap::READ | Cap::EXECUTE,
rules: vec![],
network: NetworkPolicy::Deny,
doc: None,
};
let violations = vec![audit::SandboxViolation {
operation: "file-read-data".into(),
path: "/Applications/LM Studio.app/Contents/Info.plist".into(),
}];
let blocked = paths_from_audit(&violations, &sandbox, "/project");
assert!(
blocked.is_empty(),
"read violation on path where sandbox grants READ should be filtered"
);
}
#[test]
fn test_paths_from_audit_keeps_denied_write() {
let sandbox = SandboxPolicy {
default: Cap::READ | Cap::EXECUTE,
rules: vec![],
network: NetworkPolicy::Deny,
doc: None,
};
let violations = vec![audit::SandboxViolation {
operation: "file-write-create".into(),
path: "/Users/user/Desktop/testfile".into(),
}];
let blocked = paths_from_audit(&violations, &sandbox, "/project");
assert_eq!(
blocked.len(),
1,
"write violation where sandbox denies WRITE should be kept"
);
assert!(blocked[0].path.contains("Desktop"));
}
#[test]
fn test_check_returns_none_for_non_bash() {
let input = ToolUseHookInput {
tool_name: "Read".into(),
tool_response: Some(json!("operation not permitted")),
..Default::default()
};
let settings = ClashSettings::default();
assert!(check_for_sandbox_fs_hint(&input, &settings).is_none());
}
#[test]
fn test_check_returns_none_without_response() {
let input = ToolUseHookInput {
tool_name: "Bash".into(),
tool_response: None,
..Default::default()
};
let settings = ClashSettings::default();
assert!(check_for_sandbox_fs_hint(&input, &settings).is_none());
}
#[test]
fn test_check_returns_none_for_non_fs_error() {
let input = ToolUseHookInput {
tool_name: "Bash".into(),
tool_response: Some(json!("file not found")),
..Default::default()
};
let settings = ClashSettings::default();
assert!(check_for_sandbox_fs_hint(&input, &settings).is_none());
}
#[test]
fn test_check_returns_none_without_policy() {
let settings = ClashSettings::default();
assert!(settings.decision_tree().is_none());
let input = ToolUseHookInput {
tool_name: "Bash".into(),
tool_input: json!({"command": "fly logs"}),
tool_response: Some(json!(
"open /Users/user/.fly/perms.123: operation not permitted"
)),
..Default::default()
};
assert!(check_for_sandbox_fs_hint(&input, &settings).is_none());
}
#[test]
fn test_check_returns_hint_with_sandbox() {
let mut settings = ClashSettings::default();
settings.set_policy_source(
r#"{"schema_version":5,"default_effect":"deny",
"sandboxes":{"restricted":{"default":"read + execute","rules":[{"effect":"allow","caps":"read","path":"/tmp"}],"network":"deny"}},
"tree":[
{"condition":{"observe":"tool_name","pattern":{"literal":{"literal":"Bash"}},"children":[
{"decision":{"allow":"restricted"}}
]}}
]}"#,
);
let input = ToolUseHookInput {
tool_name: "Bash".into(),
tool_input: json!({"command": "fly logs --app scour-rs"}),
tool_response: Some(json!(
"Error: failed ensuring config directory perms: open /Users/emschwartz/.fly/perms.3199984107: operation not permitted"
)),
cwd: "/tmp".into(),
..Default::default()
};
let result = check_for_sandbox_fs_hint(&input, &settings);
assert!(
result.is_some(),
"should return hint for sandboxed filesystem error"
);
let hint = result.unwrap();
assert!(hint.contains("SANDBOX_FS_HINT"));
assert!(hint.contains(".fly"));
}
#[test]
fn test_check_returns_none_when_path_is_allowed() {
let mut settings = ClashSettings::default();
settings.set_policy_source(
r#"{"schema_version":5,"default_effect":"deny",
"sandboxes":{"permissive":{"default":"read + execute","rules":[{"effect":"allow","caps":"read + write + create","path":"/Users/emschwartz/.fly"}],"network":"deny"}},
"tree":[
{"condition":{"observe":"tool_name","pattern":{"literal":{"literal":"Bash"}},"children":[
{"decision":{"allow":"permissive"}}
]}}
]}"#,
);
let input = ToolUseHookInput {
tool_name: "Bash".into(),
tool_input: json!({"command": "fly logs"}),
tool_response: Some(json!(
"open /Users/emschwartz/.fly/perms.123: operation not permitted"
)),
cwd: "/tmp".into(),
..Default::default()
};
let result = check_for_sandbox_fs_hint(&input, &settings);
assert!(
result.is_none(),
"should not hint when the path is explicitly allowed"
);
}
#[test]
fn test_check_returns_hint_with_explicit_sandbox() {
let mut settings = ClashSettings::default();
settings.set_policy_source(
r#"{"schema_version":5,"default_effect":"deny",
"sandboxes":{"restricted":{"default":"read + execute","rules":[{"effect":"allow","caps":"read","path":"/project"}],"network":"deny"}},
"tree":[
{"condition":{"observe":"tool_name","pattern":{"literal":{"literal":"Bash"}},"children":[
{"condition":{"observe":{"positional_arg":0},"pattern":{"literal":{"literal":"fly"}},"children":[
{"decision":{"allow":"restricted"}}
]}}
]}}
]}"#,
);
let input = ToolUseHookInput {
tool_name: "Bash".into(),
tool_input: json!({"command": "fly logs --app scour-rs"}),
tool_response: Some(json!(
"open /Users/user/.fly/perms.123: operation not permitted"
)),
cwd: "/project".into(),
..Default::default()
};
let result = check_for_sandbox_fs_hint(&input, &settings);
assert!(
result.is_some(),
"should return hint for explicit sandbox violation"
);
}
}