mod audit_source;
pub(crate) mod formatter;
mod stderr_source;
use std::collections::BTreeSet;
use std::path::Path;
use tracing::{Level, info, instrument};
use crate::hooks::ToolUseHookInput;
use crate::network_hints::extract_response_text;
use crate::policy::sandbox_types::{Cap, SandboxPolicy};
use crate::settings::ClashSettings;
use audit_source::{paths_from_audit, read_audit_violations};
use formatter::build_fs_hint;
use stderr_source::{contains_fs_error, extract_blocked_paths, extract_paths_from_errors};
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, sandbox_name) = match resolve_sandbox_policy(input, settings) {
Some(s) => s,
None => {
info!("check_for_sandbox_fs_hint: no sandbox policy resolved, skipping");
return None;
}
};
let action = settings
.policy_tree()
.map(|t| t.on_sandbox_violation)
.unwrap_or_default();
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(&sandbox_name, &blocked_paths, action))
}
fn resolve_sandbox_policy(
input: &ToolUseHookInput,
settings: &ClashSettings,
) -> Option<(SandboxPolicy, String)> {
if let Some(tree) = settings.policy_tree() {
let decision = tree.evaluate(&input.tool_name, &input.tool_input);
if let Some(sandbox) = decision.sandbox {
let name = decision
.sandbox_name
.map(|r| r.0)
.unwrap_or_else(|| "unnamed".to_string());
info!("resolve_sandbox_policy: found sandbox via decision tree re-evaluation");
return Some((sandbox, name));
}
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(" shell ") || !command.contains("--sandbox") {
info!(
command_prefix = &command[..command.len().min(80)],
"resolve_sandbox_policy: command does not contain shell + --sandbox"
);
return None;
}
let name = extract_sandbox_name(command)?;
let tree = settings.policy_tree()?;
let sandbox = tree.sandboxes.get(&name)?.clone();
info!(
sandbox_name = %name,
"resolve_sandbox_policy: found sandbox via rewritten command --sandbox flag"
);
Some((sandbox, name))
}
fn extract_sandbox_name(command: &str) -> Option<String> {
let idx = command.find("--sandbox ")?;
let after = &command[idx + "--sandbox ".len()..];
let name = after.split_whitespace().next()?;
Some(name.trim_matches('\'').trim_matches('"').to_string())
}
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 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())
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
use crate::policy::sandbox_types::{
NetworkPolicy, PathMatch, RuleEffect, SandboxRule, ViolationAction,
};
use formatter::BlockedPath;
use stderr_source::{
contains_fs_error, extract_paths_from_errors, is_likely_sandbox_violation,
};
#[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("restricted", &blocked, ViolationAction::Stop);
assert!(hint.contains("SANDBOX VIOLATION"));
assert!(hint.contains("\"restricted\""));
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("mybox", &blocked, ViolationAction::Stop);
assert!(hint.contains("SANDBOX VIOLATION"));
assert!(hint.contains("\"mybox\""));
assert!(hint.contains("/secret"));
assert!(hint.contains("clash sandbox add-rule"));
}
#[test]
fn test_build_hint_workaround_directive() {
let blocked = vec![BlockedPath {
path: "/foo/bar".into(),
suggested_dir: "/foo".into(),
current_caps: Cap::READ,
}];
let hint = build_fs_hint("box", &blocked, ViolationAction::Workaround);
assert!(hint.contains("Try an alternative approach"));
assert!(hint.contains("If no workaround is possible"));
assert!(!hint.contains("Do NOT retry"));
}
#[test]
fn test_build_hint_smart_directive() {
let blocked = vec![BlockedPath {
path: "/foo/bar".into(),
suggested_dir: "/foo".into(),
current_caps: Cap::READ,
}];
let hint = build_fs_hint("box", &blocked, ViolationAction::Smart);
assert!(hint.contains("Assess"));
assert!(!hint.contains("Do NOT retry"));
}
#[test]
fn test_build_hint_shows_current_grants() {
let blocked = vec![BlockedPath {
path: "/foo/bar".into(),
suggested_dir: "/foo".into(),
current_caps: Cap::READ | Cap::EXECUTE,
}];
let hint = build_fs_hint("box", &blocked, ViolationAction::Stop);
assert!(hint.contains("read+execute"));
}
#[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() {
use audit_source::paths_from_audit;
let sandbox = SandboxPolicy {
default: Cap::READ | Cap::EXECUTE,
rules: vec![],
network: NetworkPolicy::Deny,
doc: None,
};
let violations = vec![
crate::audit::SandboxViolation {
operation: "file-read-data".into(),
path: "/dev/dtracehelper".into(),
},
crate::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() {
use audit_source::paths_from_audit;
let sandbox = SandboxPolicy {
default: Cap::READ | Cap::EXECUTE,
rules: vec![],
network: NetworkPolicy::Deny,
doc: None,
};
let violations = vec![
crate::audit::SandboxViolation {
operation: "file-write-create".into(),
path: "/Users/user/.fly/perms.123".into(),
},
crate::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() {
use audit_source::paths_from_audit;
let sandbox = SandboxPolicy {
default: Cap::READ | Cap::EXECUTE,
rules: vec![],
network: NetworkPolicy::Deny,
doc: None,
};
let violations = vec![crate::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() {
use audit_source::paths_from_audit;
let sandbox = SandboxPolicy {
default: Cap::READ | Cap::EXECUTE,
rules: vec![],
network: NetworkPolicy::Deny,
doc: None,
};
let violations = vec![crate::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 VIOLATION"), "hint: {hint}");
assert!(
hint.contains("\"restricted\""),
"should include sandbox name, hint: {hint}"
);
assert!(hint.contains(".fly"), "hint: {hint}");
assert!(
hint.contains("Do NOT retry"),
"default action is stop, hint: {hint}"
);
}
#[test]
fn test_check_returns_hint_with_workaround_action() {
let mut settings = ClashSettings::default();
settings.set_policy_source(
r#"{"schema_version":5,"default_effect":"deny",
"on_sandbox_violation":"workaround",
"sandboxes":{"restricted":{"default":["read","execute"],"rules":[],"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"}),
tool_response: Some(json!(
"open /Users/user/.fly/perms.123: operation not permitted"
)),
cwd: "/tmp".into(),
..Default::default()
};
let result = check_for_sandbox_fs_hint(&input, &settings);
assert!(result.is_some());
let hint = result.unwrap();
assert!(hint.contains("Try an alternative approach"), "hint: {hint}");
assert!(
hint.contains("If no workaround is possible"),
"hint: {hint}"
);
}
#[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"
);
}
}