use tracing::{Level, info, instrument};
use crate::hooks::ToolUseHookInput;
use crate::policy::sandbox_types::{NetworkPolicy, ViolationAction};
use crate::settings::ClashSettings;
const NETWORK_ERROR_PATTERNS: &[&str] = &[
"could not resolve host",
"name or service not known",
"temporary failure in name resolution",
"nodename nor servname provided",
"failed to lookup address",
"getaddrinfo",
"network is unreachable",
"network unreachable",
"curl: (6)", "curl: (7)", "curl: (56)", "unable to resolve host address",
"failed to resolve address",
"error trying to connect",
"getaddrinfo enotfound",
"err_socket_not_connected",
"could not find a version that satisfies",
"max retries exceeded with url",
"dial tcp: lookup",
"enetunreach",
"socket: operation not permitted",
"network access denied",
];
#[instrument(level = Level::TRACE, skip(input, settings))]
pub fn check_for_sandbox_network_hint(
input: &ToolUseHookInput,
settings: &ClashSettings,
) -> Option<String> {
if input.tool_name != "Bash" {
return None;
}
let response_text = extract_response_text(input.tool_response.as_ref()?)?;
if !contains_network_error(&response_text) {
return None;
}
let tree = settings.policy_tree()?;
let decision = tree.evaluate(&input.tool_name, &input.tool_input);
let network_policy = decision.sandbox.as_ref().map(|s| &s.network);
let network_denied = matches!(network_policy, Some(NetworkPolicy::Deny));
let network_domain_filtered = matches!(network_policy, Some(NetworkPolicy::AllowDomains(_)));
if !network_denied && !network_domain_filtered {
return None;
}
info!(
tool = "Bash",
domain_filtered = network_domain_filtered,
"Detected network error in sandboxed command output"
);
let sandbox_name = decision
.sandbox_name
.map(|r| r.0)
.unwrap_or_else(|| "unnamed".to_string());
let action = tree.on_sandbox_violation;
Some(build_network_hint(&sandbox_name, action))
}
pub(crate) fn extract_response_text(response: &serde_json::Value) -> Option<String> {
match response {
serde_json::Value::String(s) => Some(s.clone()),
serde_json::Value::Object(obj) => {
let mut parts = Vec::new();
for key in ["content", "stdout", "stderr", "output", "error", "result"] {
if let Some(serde_json::Value::String(s)) = obj.get(key) {
parts.push(s.as_str());
}
}
if parts.is_empty() {
Some(serde_json::to_string(response).ok()?)
} else {
Some(parts.join("\n"))
}
}
serde_json::Value::Array(arr) => {
let texts: Vec<String> = arr.iter().filter_map(extract_response_text).collect();
if texts.is_empty() {
None
} else {
Some(texts.join("\n"))
}
}
_ => None,
}
}
fn contains_network_error(text: &str) -> bool {
let lower = text.to_lowercase();
NETWORK_ERROR_PATTERNS
.iter()
.any(|pattern| lower.contains(pattern))
}
fn build_network_hint(sandbox_name: &str, action: ViolationAction) -> String {
let directive = crate::sandbox_hints::formatter::directive_text(action);
[
&format!(
"SANDBOX VIOLATION: sandbox \"{sandbox_name}\" blocked network access (policy: deny)."
),
"",
"To fix:",
&format!(" clash sandbox add-rule --name {sandbox_name} --net allow"),
"",
directive,
]
.join("\n")
}
#[cfg(test)]
mod tests {
use super::*;
use crate::policy::sandbox_types::ViolationAction;
use serde_json::json;
#[test]
fn test_contains_network_error_dns() {
assert!(contains_network_error(
"curl: (6) Could not resolve host: example.com"
));
}
#[test]
fn test_contains_network_error_unreachable() {
assert!(contains_network_error("Network is unreachable"));
}
#[test]
fn test_contains_network_error_case_insensitive() {
assert!(contains_network_error("COULD NOT RESOLVE HOST: foo.com"));
}
#[test]
fn test_contains_network_error_no_match() {
assert!(!contains_network_error("file not found: /tmp/test.txt"));
}
#[test]
fn test_contains_network_error_cargo() {
assert!(contains_network_error(
"error: failed to resolve address for github.com: Name or service not known"
));
}
#[test]
fn test_contains_network_error_npm() {
assert!(contains_network_error(
"npm ERR! getaddrinfo ENOTFOUND registry.npmjs.org"
));
}
#[test]
fn test_extract_response_text_string() {
let val = json!("some output text");
assert_eq!(extract_response_text(&val), Some("some output text".into()));
}
#[test]
fn test_extract_response_text_object_with_content() {
let val = json!({"content": "error: network unreachable"});
let text = extract_response_text(&val).unwrap();
assert!(text.contains("error: network unreachable"));
}
#[test]
fn test_extract_response_text_object_with_stderr() {
let val = json!({"stdout": "", "stderr": "curl: (6) Could not resolve host"});
let text = extract_response_text(&val).unwrap();
assert!(text.contains("Could not resolve host"));
}
#[test]
fn test_extract_response_text_null() {
assert_eq!(extract_response_text(&json!(null)), None);
}
#[test]
fn test_extract_response_text_array() {
let val = json!(["line 1", "Could not resolve host"]);
let text = extract_response_text(&val).unwrap();
assert!(text.contains("Could not resolve host"));
}
#[test]
fn test_build_network_hint_contains_key_info() {
let hint = build_network_hint("restricted", ViolationAction::Stop);
assert!(hint.contains("SANDBOX VIOLATION"));
assert!(hint.contains("\"restricted\""));
assert!(hint.contains("net allow"));
assert!(hint.contains("Do NOT retry"));
}
#[test]
fn test_build_network_hint_workaround() {
let hint = build_network_hint("mybox", ViolationAction::Workaround);
assert!(hint.contains("\"mybox\""));
assert!(hint.contains("Try an alternative approach"));
}
#[test]
fn test_build_network_hint_smart() {
let hint = build_network_hint("mybox", ViolationAction::Smart);
assert!(hint.contains("Assess"));
}
#[test]
fn test_check_returns_none_for_non_bash() {
let input = ToolUseHookInput {
tool_name: "Read".into(),
tool_response: Some(json!("Could not resolve host")),
..Default::default()
};
let settings = ClashSettings::default();
assert!(check_for_sandbox_network_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_network_hint(&input, &settings).is_none());
}
#[test]
fn test_check_returns_none_for_non_network_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_network_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": "curl example.com"}),
tool_response: Some(json!("Could not resolve host")),
..Default::default()
};
assert!(check_for_sandbox_network_hint(&input, &settings).is_none());
}
#[test]
fn test_check_returns_hint_with_implicit_sandbox() {
let mut settings = ClashSettings::default();
settings.set_policy_source(
r#"{"schema_version":5,"default_effect":"deny",
"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": "curl example.com"}),
tool_response: Some(json!("curl: (6) Could not resolve host: example.com")),
cwd: "/tmp".into(),
..Default::default()
};
let result = check_for_sandbox_network_hint(&input, &settings);
assert!(
result.is_some(),
"should return hint for sandboxed network error"
);
let hint = result.unwrap();
assert!(hint.contains("SANDBOX VIOLATION"));
}
#[test]
fn test_check_returns_hint_with_explicit_sandbox_network_deny() {
let mut settings = ClashSettings::default();
settings.set_policy_source(
r#"{"schema_version":5,"default_effect":"deny",
"sandboxes":{"restricted":{"default":["read","execute"],"rules":[],"network":"deny"}},
"tree":[
{"condition":{"observe":"tool_name","pattern":{"literal":{"literal":"Bash"}},"children":[
{"condition":{"observe":{"positional_arg":0},"pattern":{"literal":{"literal":"curl"}},"children":[
{"decision":{"allow":"restricted"}}
]}}
]}}
]}"#,
);
let input = ToolUseHookInput {
tool_name: "Bash".into(),
tool_input: json!({"command": "curl example.com"}),
tool_response: Some(json!("curl: (6) Could not resolve host: example.com")),
cwd: "/tmp".into(),
..Default::default()
};
let result = check_for_sandbox_network_hint(&input, &settings);
assert!(
result.is_some(),
"should return hint for sandboxed network error"
);
let hint = result.unwrap();
assert!(hint.contains("SANDBOX VIOLATION"));
}
#[test]
fn test_check_returns_none_with_sandbox_network_allow() {
let mut settings = ClashSettings::default();
settings.set_policy_source(
r#"{"schema_version":5,"default_effect":"deny",
"sandboxes":{"with-net":{"default":["read","execute"],"rules":[],"network":"allow"}},
"tree":[
{"condition":{"observe":"tool_name","pattern":{"literal":{"literal":"Bash"}},"children":[
{"condition":{"observe":{"positional_arg":0},"pattern":{"literal":{"literal":"curl"}},"children":[
{"decision":{"allow":"with-net"}}
]}}
]}}
]}"#,
);
let input = ToolUseHookInput {
tool_name: "Bash".into(),
tool_input: json!({"command": "curl example.com"}),
tool_response: Some(json!("Could not resolve host")),
cwd: "/tmp".into(),
..Default::default()
};
assert!(check_for_sandbox_network_hint(&input, &settings).is_none());
}
#[test]
fn test_check_returns_hint_with_domain_specific_net_rule() {
let mut settings = ClashSettings::default();
settings.set_policy_source(
r#"{"schema_version":5,"default_effect":"deny",
"sandboxes":{"with-net":{"default":["read","execute"],"rules":[],"network":{"allow_domains":["example.com"]}}},
"tree":[
{"condition":{"observe":"tool_name","pattern":{"literal":{"literal":"Bash"}},"children":[
{"condition":{"observe":{"positional_arg":0},"pattern":{"literal":{"literal":"curl"}},"children":[
{"decision":{"allow":"with-net"}}
]}}
]}}
]}"#,
);
let input = ToolUseHookInput {
tool_name: "Bash".into(),
tool_input: json!({"command": "curl example.com"}),
tool_response: Some(json!("Could not resolve host")),
cwd: "/tmp".into(),
..Default::default()
};
assert!(check_for_sandbox_network_hint(&input, &settings).is_some());
}
}