use crate::policy::Effect;
use tracing::{Level, info, instrument, warn};
use crate::hooks::{HookOutput, ToolInput, ToolUseHookInput};
use crate::settings::ClashSettings;
#[instrument(level = Level::TRACE, ret)]
pub fn check_permission(
input: &ToolUseHookInput,
settings: &ClashSettings,
) -> anyhow::Result<HookOutput> {
let tree = match settings.policy_tree() {
Some(t) => t,
None => {
let (reason, context) = match settings.policy_error() {
Some(err) => {
let reason = format!(
"Policy failed to compile: {}. All actions are blocked until the policy is fixed.",
err
);
let context = "POLICY ERROR: clash cannot enforce permissions because the policy failed to compile.\n\
The user's policy file has a syntax or compilation error.\n\n\
Agent instructions:\n\
- Tell the user their clash policy has an error and all actions are blocked\n\
- Suggest running: clash policy validate\n\
- Do NOT retry the tool call — it will be blocked until the policy is fixed\n\
- Do NOT attempt workarounds".to_string();
(reason, context)
}
None => {
let reason = "No policy configured. All actions are blocked. Run `clash init` to create a policy.".to_string();
let context = "POLICY ERROR: clash has no compiled policy available.\n\
All actions are blocked because there is no valid policy to evaluate.\n\n\
Agent instructions:\n\
- Tell the user clash has no policy configured\n\
- Suggest running: clash init\n\
- Do NOT retry the tool call"
.to_string();
(reason, context)
}
};
eprintln!(
"{} {}",
crate::style::err_red_bold("clash policy error:"),
&reason
);
eprintln!(
" {} {}",
crate::style::err_dim("To diagnose:"),
crate::style::err_yellow("clash policy validate")
);
warn!("{}", reason);
return Ok(HookOutput::deny(reason, Some(context)));
}
};
let decision = tree.evaluate(&input.tool_name, &input.tool_input);
let noun = extract_noun(&input.tool_name, &input.tool_input);
info!(
tool = %input.tool_name,
noun = %noun,
effect = %decision.effect,
reason = ?decision.reason,
trace = ?decision.trace,
"Policy decision"
);
crate::audit::log_decision(
&settings.audit,
&input.session_id,
&input.tool_name,
&input.tool_input,
decision.effect,
decision.reason.as_deref(),
&decision.trace,
);
let explanation = decision.human_explanation();
let additional_context = if explanation.is_empty() {
None
} else {
Some(explanation.join("\n"))
};
if decision.effect == Effect::Deny {
let verb_str = tool_to_verb_str(&input.tool_name);
let noun_summary = truncate_noun(&noun, 60);
eprintln!(
"{} blocked {} on {}",
crate::style::err_red_bold("clash:"),
verb_str,
noun_summary
);
let is_explicit_deny = decision
.reason
.as_deref()
.is_some_and(|r| r.contains("denied") || r.contains("deny"));
if is_explicit_deny {
eprintln!(
" {}",
crate::style::err_dim("This action is explicitly denied by your policy.")
);
} else {
eprintln!(" {}", crate::style::err_dim(denial_explanation(&verb_str)));
}
eprintln!(
" {} {}",
crate::style::err_dim("To allow this:"),
crate::style::err_yellow(&suggest_allow_command(&verb_str))
);
eprintln!(
" {}",
crate::style::err_dim("(run \"clash allow --help\" for more options)")
);
}
Ok(match decision.effect {
Effect::Allow => {
let mut output = HookOutput::allow(
decision.reason.or(Some("policy: allowed".into())),
additional_context,
);
if let Some(ref sandbox_policy) = decision.sandbox
&& let Some(updated) = wrap_bash_with_sandbox(input, sandbox_policy)
{
output.set_updated_input(updated);
info!("Rewrote Bash command to run under sandbox");
}
output
}
Effect::Deny => {
let deny_context = build_deny_context(
&input.tool_name,
decision.reason.as_deref(),
&input.tool_input,
);
HookOutput::deny(
decision.reason.unwrap_or_else(|| "policy: denied".into()),
Some(deny_context),
)
}
Effect::Ask => HookOutput::ask(
decision.reason.or(Some("policy: ask".into())),
additional_context,
),
})
}
fn tool_to_verb_str(tool_name: &str) -> String {
match tool_name {
"Bash" => "bash".into(),
"Read" | "Glob" | "Grep" => "read".into(),
"Write" | "Edit" => "edit".into(),
"WebFetch" | "WebSearch" => "web".into(),
"Skill" | "Task" | "TaskCreate" | "TaskUpdate" | "TaskList" | "TaskGet" | "TaskStop"
| "TaskOutput" | "AskUserQuestion" | "EnterPlanMode" | "ExitPlanMode" | "NotebookEdit" => {
"tool".into()
}
_ => tool_name.to_lowercase(),
}
}
#[instrument(level = Level::TRACE, skip(input, sandbox_policy))]
fn wrap_bash_with_sandbox(
input: &ToolUseHookInput,
sandbox_policy: &crate::policy::sandbox_types::SandboxPolicy,
) -> Option<serde_json::Value> {
let bash_input = match input.typed_tool_input() {
ToolInput::Bash(b) => b,
_ => return None,
};
let clash_bin = std::env::current_exe().ok()?;
let policy_json = serde_json::to_string(sandbox_policy).ok()?;
let mut extra_args = String::new();
if !input.session_id.is_empty() {
extra_args += &format!(" --session-id {}", shell_escape(&input.session_id));
if let Some(ref tuid) = input.tool_use_id {
extra_args += &format!(" --tool-use-id {}", shell_escape(tuid));
}
}
let sandboxed_command = format!(
"{} sandbox exec --sandbox {} --cwd {}{} -- bash -c {}",
shell_escape(&clash_bin.to_string_lossy()),
shell_escape(&policy_json),
shell_escape(&input.cwd),
extra_args,
shell_escape(&bash_input.command),
);
let mut updated = input.tool_input.clone();
if let Some(obj) = updated.as_object_mut() {
obj.insert(
"command".into(),
serde_json::Value::String(sandboxed_command),
);
}
Some(updated)
}
#[instrument(level = Level::TRACE)]
fn shell_escape(s: &str) -> String {
format!("'{}'", s.replace('\'', "'\\''"))
}
fn suggest_allow_command_specific(tool_name: &str, tool_input: &serde_json::Value) -> String {
match tool_name {
"Bash" => {
if let Some(cmd) = tool_input.get("command").and_then(|v| v.as_str()) {
let parts: Vec<&str> = cmd.split_whitespace().collect();
match parts.len() {
0 => "clash policy allow --tool Bash".into(),
1 => format!("clash policy allow \"{}\"", parts[0]),
_ => {
format!("clash policy allow \"{} {}\"", parts[0], parts[1])
}
}
} else {
"clash policy allow --tool Bash".into()
}
}
"Read" | "Glob" | "Grep" => format!("clash policy allow --tool {tool_name}"),
"Write" | "Edit" => format!("clash policy allow --tool {tool_name}"),
"WebFetch" | "WebSearch" => format!("clash policy allow --tool {tool_name}"),
_ => format!("clash policy allow --tool {tool_name}"),
}
}
fn suggest_allow_command(verb_str: &str) -> String {
match verb_str {
"edit" | "bash" | "read" | "web" | "tool" => {
format!("clash policy allow --tool {}", verb_str_to_tool(verb_str))
}
_ => format!("clash policy allow '{verb_str}'"),
}
}
fn verb_str_to_tool(verb_str: &str) -> &'static str {
match verb_str {
"bash" => "Bash",
"edit" => "Edit",
"read" => "Read",
"web" => "WebFetch",
"tool" => "Skill",
_ => "Bash",
}
}
fn denial_explanation(verb_str: &str) -> &'static str {
match verb_str {
"edit" => "File editing is not allowed by your current policy.",
"bash" => "Command execution is not allowed by your current policy.",
"web" => "Web access is not allowed by your current policy.",
"read" => "File reading outside the project is not allowed by your current policy.",
_ => "This action is not allowed by your current policy.",
}
}
fn truncate_noun(noun: &str, max_len: usize) -> String {
if noun.len() <= max_len {
noun.to_string()
} else {
format!("{}...", &noun[..max_len])
}
}
fn build_deny_context(
tool_name: &str,
reason: Option<&str>,
tool_input: &serde_json::Value,
) -> String {
let is_explicit_deny = reason.is_some_and(|r| r.contains("denied") || r.contains("deny"));
let suggested = suggest_allow_command_specific(tool_name, tool_input);
let mut lines = Vec::new();
if is_explicit_deny {
lines.push(format!(
"BLOCKED by explicit deny rule. To allow: {suggested}"
));
} else {
lines.push(format!("BLOCKED by default deny. To allow: {suggested}"));
}
lines.push("Do NOT retry this tool call — it will be blocked again.".into());
lines.join("\n")
}
pub fn extract_noun(tool_name: &str, tool_input: &serde_json::Value) -> String {
let fields = [
"command", "file_path", "pattern", "query", "url", "path", "prompt", "skill", ];
for field in &fields {
if let Some(val) = tool_input.get(*field).and_then(|v| v.as_str()) {
return val.to_string();
}
}
tool_name.to_lowercase()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::assert_decision;
use crate::hooks::ToolUseHookInput;
use crate::test_utils::{TestPolicy, bash_command, get_context, pre_tool_use, read_file};
use anyhow::Result;
use serde_json::json;
fn bash_input(command: &str) -> ToolUseHookInput {
pre_tool_use("Bash", bash_command(command))
}
fn settings_with_policy(source: &str) -> ClashSettings {
let mut settings = ClashSettings::default();
settings.set_policy_source(source);
settings
}
#[test]
fn test_policy_allow_git_status() -> Result<()> {
let settings = TestPolicy::deny_all().allow_exec("git").build();
let input = pre_tool_use("Bash", bash_command("git status"));
assert_decision!(settings, input, Effect::Allow, reason_contains: "allow");
Ok(())
}
#[test]
fn test_policy_deny_git_push() -> Result<()> {
let source = r#"{"schema_version":5,"default_effect":"deny","sandboxes":{},"tree":[
{"condition":{"observe":"tool_name","pattern":{"literal":{"literal":"Bash"}},"children":[
{"condition":{"observe":{"positional_arg":0},"pattern":{"literal":{"literal":"git"}},"children":[
{"condition":{"observe":{"positional_arg":1},"pattern":{"literal":{"literal":"push"}},"children":[
{"decision":"deny"}
]}},
{"decision":{"allow":null}}
]}}
]}}
]}"#;
let settings = settings_with_policy(source);
assert_decision!(settings, bash_input("git push origin main"), Effect::Deny);
Ok(())
}
#[test]
fn test_policy_default_deny() -> Result<()> {
let settings = TestPolicy::deny_all().allow_exec("git").build();
assert_decision!(settings, bash_input("ls"), Effect::Deny);
Ok(())
}
#[test]
fn test_policy_read_under_cwd() -> Result<()> {
let settings = TestPolicy::deny_all()
.allow_read("/home/user/project")
.build();
let input = pre_tool_use("Read", read_file("/home/user/project/src/main.rs"));
assert_decision!(settings, input, Effect::Allow, reason_contains: "allow");
Ok(())
}
#[test]
fn test_policy_read_outside_cwd_denied() -> Result<()> {
let settings = TestPolicy::deny_all()
.allow_read("/home/user/project")
.build();
let input = pre_tool_use("Read", read_file("/etc/passwd"));
assert_decision!(settings, input, Effect::Deny);
Ok(())
}
#[test]
fn test_no_compiled_policy_denies() -> Result<()> {
let settings = ClashSettings::default();
assert_decision!(settings, bash_input("ls"), Effect::Deny);
Ok(())
}
#[test]
fn test_explanation_contains_matched_rule() -> Result<()> {
let settings = TestPolicy::deny_all().allow_exec("git").build();
let input = pre_tool_use("Bash", bash_command("git status"));
let result = check_permission(&input, &settings)?;
let ctx = get_context(&result).expect("should have additional_context");
assert!(
ctx.contains("matched"),
"explanation should contain 'matched' but got: {ctx}"
);
Ok(())
}
#[test]
fn test_explanation_no_rules_matched() -> Result<()> {
let settings = TestPolicy::ask_all().allow_exec("git").build();
let result = check_permission(&bash_input("ls"), &settings)?;
let ctx = get_context(&result).expect("should have additional_context");
assert!(
ctx.contains("No rules matched"),
"explanation should say 'No rules matched' but got: {ctx}"
);
Ok(())
}
#[test]
fn test_ask_user_question_allowed_by_blanket_tool_rule() -> Result<()> {
let settings = TestPolicy::deny_all().allow_all_tools().build();
let input = pre_tool_use(
"AskUserQuestion",
json!({"questions": [{"question": "Which approach?", "options": []}]}),
);
assert_decision!(settings, input, Effect::Allow, reason_contains: "allow");
Ok(())
}
#[test]
fn test_ask_user_question_denied_by_explicit_deny() -> Result<()> {
let source = r#"{"schema_version":5,"default_effect":"deny","sandboxes":{},"tree":[
{"condition":{"observe":"tool_name","pattern":{"literal":{"literal":"AskUserQuestion"}},"children":[
{"decision":"deny"}
]}},
{"condition":{"observe":"tool_name","pattern":"wildcard","children":[
{"decision":{"allow":null}}
]}}
]}"#;
let settings = settings_with_policy(source);
let input = pre_tool_use("AskUserQuestion", json!({"questions": []}));
assert_decision!(settings, input, Effect::Deny);
Ok(())
}
#[test]
fn test_shell_escape_simple_string() {
assert_eq!(shell_escape("hello"), "'hello'");
}
#[test]
fn test_shell_escape_string_with_spaces() {
assert_eq!(shell_escape("hello world"), "'hello world'");
}
#[test]
fn test_shell_escape_empty_string() {
assert_eq!(shell_escape(""), "''");
}
#[test]
fn test_shell_escape_embedded_single_quotes() {
assert_eq!(shell_escape("it's"), "'it'\\''s'");
}
#[test]
fn test_shell_escape_multiple_single_quotes() {
assert_eq!(shell_escape("a'b'c"), "'a'\\''b'\\''c'");
}
#[test]
fn test_shell_escape_special_characters() {
assert_eq!(shell_escape("$HOME"), "'$HOME'");
assert_eq!(shell_escape("`whoami`"), "'`whoami`'");
assert_eq!(shell_escape("a\\b"), "'a\\b'");
}
#[test]
fn test_shell_escape_double_quotes() {
assert_eq!(shell_escape("say \"hi\""), "'say \"hi\"'");
}
fn test_sandbox_policy() -> crate::policy::sandbox_types::SandboxPolicy {
use crate::policy::sandbox_types::{Cap, NetworkPolicy};
crate::policy::sandbox_types::SandboxPolicy {
default: Cap::READ | Cap::EXECUTE,
rules: vec![],
network: NetworkPolicy::Deny,
doc: None,
}
}
fn bash_input_for_sandbox(command: &str, cwd: &str) -> ToolUseHookInput {
ToolUseHookInput {
tool_name: "Bash".into(),
tool_input: json!({"command": command}),
cwd: cwd.into(),
..Default::default()
}
}
fn extract_wrapped_command(result: &serde_json::Value) -> &str {
result
.get("command")
.and_then(|v| v.as_str())
.expect("wrapped result should have a 'command' string field")
}
#[test]
fn test_wrap_bash_basic_command() {
let input = bash_input_for_sandbox("ls -la", "/home/user/project");
let policy = test_sandbox_policy();
let result = wrap_bash_with_sandbox(&input, &policy);
assert!(result.is_some());
let wrapped = result.unwrap();
let cmd = extract_wrapped_command(&wrapped);
assert!(cmd.contains("sandbox exec"));
assert!(cmd.contains("--sandbox"));
assert!(cmd.contains("--cwd"));
assert!(cmd.contains("-- bash -c 'ls -la'"));
}
#[test]
fn test_wrap_bash_returns_none_for_read_tool() {
let input = ToolUseHookInput {
tool_name: "Read".into(),
tool_input: json!({"file_path": "/tmp/test.txt"}),
..Default::default()
};
let policy = test_sandbox_policy();
let result = wrap_bash_with_sandbox(&input, &policy);
assert!(result.is_none());
}
#[test]
fn test_suggest_allow_specific_bash_command() {
let input = json!({"command": "git status"});
assert_eq!(
suggest_allow_command_specific("Bash", &input),
"clash policy allow \"git status\""
);
}
#[test]
fn test_suggest_allow_specific_bash_single_binary() {
let input = json!({"command": "ls"});
assert_eq!(
suggest_allow_command_specific("Bash", &input),
"clash policy allow \"ls\""
);
}
#[test]
fn test_suggest_allow_specific_tool() {
let input = json!({"file_path": "/tmp/test.txt"});
assert_eq!(
suggest_allow_command_specific("Read", &input),
"clash policy allow --tool Read"
);
}
#[test]
fn test_truncate_noun_short() {
assert_eq!(truncate_noun("hello", 60), "hello");
}
#[test]
fn test_truncate_noun_long() {
let s = "a".repeat(100);
let result = truncate_noun(&s, 60);
assert_eq!(result.len(), 63);
assert!(result.ends_with("..."));
}
#[test]
fn test_build_deny_context_contains_allow_command() {
let input = json!({"command": "ls -la"});
let ctx = build_deny_context("Bash", None, &input);
assert!(ctx.contains("BLOCKED"));
assert!(ctx.contains("clash policy allow \"ls -la\""));
assert!(ctx.contains("Do NOT retry"));
}
#[test]
fn test_deny_decision_includes_agent_context() -> Result<()> {
let settings = TestPolicy::deny_all()
.raw_node(r#"{"condition":{"observe":"tool_name","pattern":{"literal":{"literal":"Bash"}},"children":[
{"decision":"deny"}
]}}"#)
.build();
let result = assert_decision!(settings, bash_input("ls -la"), Effect::Deny);
let ctx = get_context(&result).expect("deny should have additional_context");
assert!(ctx.contains("BLOCKED"), "got: {ctx}");
Ok(())
}
}