use std::path::{Path, PathBuf};
use anyhow::{Context, Result};
use tracing::{Level, info, instrument};
use crate::cli::PolicyCmd;
use crate::policy::match_tree::PolicyManifest;
use crate::settings::{ClashSettings, PolicyLevel};
use crate::style;
#[instrument(level = Level::TRACE)]
pub fn run(cmd: PolicyCmd) -> Result<()> {
match cmd {
PolicyCmd::Schema { json } => super::schema::run(json),
PolicyCmd::Explain {
json,
trace,
tool,
args,
} => super::explain::run(json, trace, tool.unwrap_or_default(), args.join(" ")),
PolicyCmd::Check { json } => handle_check_portable(json),
PolicyCmd::Convert { file, replace } => handle_convert(file, replace),
PolicyCmd::List { json } => handle_list(json),
PolicyCmd::Validate { file, json } => handle_validate(file, json),
PolicyCmd::Show { json } => handle_show(json),
PolicyCmd::Edit { scope } => handle_edit(scope),
PolicyCmd::Allow {
command,
tool,
bin,
sandbox,
scope,
broad,
yes,
} => handle_allow(command, tool, bin, sandbox, scope, broad, yes),
PolicyCmd::Deny {
command,
tool,
bin,
scope,
broad,
yes,
} => handle_deny(command, tool, bin, scope, broad, yes),
PolicyCmd::Remove {
command,
tool,
bin,
scope,
} => handle_remove(command, tool, bin, scope),
PolicyCmd::Migrate { scope, yes } => handle_migrate(scope, yes),
}
}
fn handle_check_portable(json: bool) -> Result<()> {
use crate::policy::match_tree::{Node, Observable, Pattern, Value};
use crate::style;
use crate::ui;
let settings = ClashSettings::load_or_create()?;
let policy = match settings.policy_tree() {
Some(t) => t,
None => {
if let Some(err) = settings.policy_error() {
anyhow::bail!("{}", err);
}
anyhow::bail!("no policy configured — run `clash init`");
}
};
struct Warning {
tool_name: String,
canonical: Option<&'static str>,
source: Option<String>,
}
fn walk_tree(nodes: &[Node], warnings: &mut Vec<Warning>) {
for node in nodes {
match node {
Node::Condition {
observe: Observable::ToolName,
pattern,
children,
source,
..
} => {
check_pattern(pattern, source, warnings);
walk_tree(children, warnings);
}
Node::Condition { children, .. } => walk_tree(children, warnings),
Node::Decision(_) => {}
}
}
}
fn check_pattern(pattern: &Pattern, source: &Option<String>, warnings: &mut Vec<Warning>) {
match pattern {
Pattern::Literal(Value::Literal(name)) => {
if let Some(canonical) = crate::agents::internal_to_canonical(name) {
warnings.push(Warning {
tool_name: name.clone(),
canonical: Some(canonical),
source: source.clone(),
});
}
else if crate::agents::resolve_any_to_internal(name).is_some()
&& crate::agents::canonical_to_internal(name).is_none()
&& crate::agents::internal_to_canonical(name).is_none()
{
let internal = crate::agents::resolve_any_to_internal(name).unwrap();
let canonical = crate::agents::internal_to_canonical(internal);
warnings.push(Warning {
tool_name: name.clone(),
canonical,
source: source.clone(),
});
}
}
Pattern::AnyOf(pats) => {
for p in pats {
check_pattern(p, source, warnings);
}
}
_ => {}
}
}
let mut warnings = Vec::new();
walk_tree(&policy.tree, &mut warnings);
if json {
let entries: Vec<serde_json::Value> = warnings
.iter()
.map(|w| {
serde_json::json!({
"tool_name": w.tool_name,
"suggestion": w.canonical,
"source": w.source,
})
})
.collect();
println!("{}", serde_json::to_string_pretty(&entries)?);
return Ok(());
}
if warnings.is_empty() {
ui::success("Policy is portable — no agent-specific tool names found.");
println!(
" All tool name rules use canonical names or capabilities that work across agents."
);
} else {
println!(
" {} portability warning(s) found:\n",
style::yellow_bold(&warnings.len().to_string())
);
for w in &warnings {
let location = w.source.as_deref().unwrap_or("unknown");
print!(
" {} tool(\"{}\") is agent-specific",
style::yellow_bold("!"),
w.tool_name
);
if let Some(canonical) = w.canonical {
print!(
" — use tool(\"{}\") for portability",
style::green_bold(canonical)
);
}
println!();
println!(" {}", style::dim(location));
}
println!();
println!(
" {} Canonical names (shell, read, write, edit, glob, grep, web_fetch, web_search)",
style::dim("Tip:")
);
println!(
" {} match across all supported agents automatically.",
style::dim(" ")
);
}
Ok(())
}
fn handle_list(json: bool) -> Result<()> {
let settings = ClashSettings::load_or_create()?;
let policy = match settings.policy_tree() {
Some(t) => t,
None => {
if let Some(err) = settings.policy_error() {
anyhow::bail!("{}", err);
}
anyhow::bail!("no policy configured — run `clash init`");
}
};
if json {
let rules = policy.format_rules();
let entries: Vec<serde_json::Value> = rules
.iter()
.enumerate()
.map(|(i, r)| {
serde_json::json!({
"index": i,
"rule": r,
})
})
.collect();
println!("{}", serde_json::to_string_pretty(&entries)?);
} else {
let lines = policy.format_tree();
if lines.is_empty() {
println!(
"No rules in policy. {}",
style::dim(&format!("(default: {})", policy.default_effect))
);
return Ok(());
}
println!(
"Policy {}\n",
style::dim(&format!(
"(default: {})",
style::effect(&policy.default_effect.to_string()),
))
);
for line in &lines {
println!(" {}", line);
}
}
Ok(())
}
fn handle_show(json: bool) -> Result<()> {
let settings = ClashSettings::load_or_create()?;
let policy = match settings.policy_tree() {
Some(t) => t,
None => {
if let Some(err) = settings.policy_error() {
anyhow::bail!("{}", err);
}
anyhow::bail!("no policy configured — run `clash init`");
}
};
let loaded = settings.loaded_policies();
if json {
let output = serde_json::json!({
"default": format!("{}", policy.default_effect),
"rule_count": policy.rule_count(),
"levels": loaded
.iter()
.map(|lp| {
serde_json::json!({
"level": lp.level.to_string(),
"path": lp.path.display().to_string(),
"source": &lp.source,
})
})
.collect::<Vec<serde_json::Value>>(),
});
println!("{}", serde_json::to_string_pretty(&output)?);
} else {
for lp in loaded {
println!(
"{} {}",
style::cyan(&format!("[{}]", lp.level)),
lp.path.display()
);
println!("{}", style::dim(&"─".repeat(40)));
print!("{}", lp.source);
if !lp.source.ends_with('\n') {
println!();
}
println!();
}
if loaded.is_empty() {
for rule in policy.format_rules() {
println!(" {}", rule);
}
}
}
Ok(())
}
fn handle_validate(file: Option<std::path::PathBuf>, json: bool) -> Result<()> {
if let Some(path) = file {
return validate_single_file(&path, json);
}
let levels = ClashSettings::available_policy_levels();
if levels.is_empty() {
let diag = ClashSettings::diagnose_missing_policies();
if json {
let details: Vec<serde_json::Value> = diag
.iter()
.map(|(level, path, reason)| {
serde_json::json!({"level": level, "path": path, "reason": reason})
})
.collect();
println!(
"{}",
serde_json::json!({"valid": false, "error": "no policy files found", "hint": "run `clash init` to create a policy", "checked": details})
);
} else {
eprintln!("{}: no policy files found", style::err_red_bold("error"));
eprintln!();
eprintln!(" Checked the following locations:");
for (level, path, reason) in &diag {
eprintln!(
" {} ({}): {} — {}",
level,
path,
style::err_red_bold("✗"),
reason
);
}
eprintln!();
eprintln!(
" {}: run {} to create a policy",
style::err_cyan_bold("hint"),
style::bold("clash init")
);
}
std::process::exit(1);
}
let mut all_valid = true;
let mut results: Vec<serde_json::Value> = Vec::new();
for (level, path) in &levels {
let source = match crate::settings::evaluate_policy_file(path) {
Ok(s) => s,
Err(e) => {
all_valid = false;
if json {
results.push(serde_json::json!({
"level": level.to_string(),
"path": path.display().to_string(),
"valid": false,
"error": format!("{}", e),
}));
} else {
eprintln!(
"{} {} {}",
style::err_red_bold("✗"),
style::cyan(&format!("[{}]", level)),
path.display()
);
eprintln!(" {}", style::dim(&format!("{}", e)));
}
continue;
}
};
match crate::policy::compile::compile_to_tree(&source) {
Ok(policy) => {
let warnings = policy.platform_warnings();
if json {
let mut entry = serde_json::json!({
"level": level.to_string(),
"path": path.display().to_string(),
"valid": true,
"default": format!("{}", policy.default_effect),
"rule_count": policy.rule_count(),
});
if !warnings.is_empty() {
entry["warnings"] = serde_json::json!(warnings);
}
results.push(entry);
} else {
println!(
"{} {} {}",
style::green_bold("✓"),
style::cyan(&format!("[{}]", level)),
path.display()
);
println!(
" default {}, {} rules",
style::effect(&policy.default_effect.to_string()),
policy.rule_count()
);
for w in &warnings {
eprintln!(" {} {}", style::err_yellow("warning:"), w,);
}
}
}
Err(e) => {
all_valid = false;
let hint = extract_policy_hint(&e);
if json {
let mut entry = serde_json::json!({
"level": level.to_string(),
"path": path.display().to_string(),
"valid": false,
"error": format!("{}", e),
});
if let Some(h) = &hint {
entry["hint"] = serde_json::json!(h);
}
results.push(entry);
} else {
eprintln!(
"{} {} {}",
style::err_red_bold("✗"),
style::cyan(&format!("[{}]", level)),
path.display()
);
eprintln!(" {}", e);
if let Some(h) = hint {
eprintln!(" {}: {}", style::err_cyan_bold("hint"), h);
}
}
}
}
}
if json {
println!(
"{}",
serde_json::to_string_pretty(&serde_json::json!({
"valid": all_valid,
"levels": results,
}))?
);
} else if all_valid {
println!("\n{}", style::green_bold("All policy files are valid."));
} else {
eprintln!("\n{}", style::err_red_bold("Policy validation failed."));
std::process::exit(1);
}
Ok(())
}
fn validate_single_file(path: &std::path::Path, json: bool) -> Result<()> {
let source = crate::settings::evaluate_policy_file(path)
.with_context(|| format!("failed to evaluate: {}", path.display()))?;
match crate::policy::compile::compile_to_tree(&source) {
Ok(policy) => {
let warnings = policy.platform_warnings();
if json {
let mut output = serde_json::json!({
"valid": true,
"path": path.display().to_string(),
"default": format!("{}", policy.default_effect),
"rule_count": policy.rule_count(),
});
if !warnings.is_empty() {
output["warnings"] = serde_json::json!(warnings);
}
println!("{}", serde_json::to_string_pretty(&output)?);
} else {
println!("{} {}", style::green_bold("✓"), path.display());
println!(
" default {}, {} rules",
style::effect(&policy.default_effect.to_string()),
policy.rule_count()
);
for w in &warnings {
eprintln!(" {} {}", style::err_yellow("warning:"), w,);
}
}
Ok(())
}
Err(e) => {
let hint = extract_policy_hint(&e);
if json {
let mut entry = serde_json::json!({
"valid": false,
"path": path.display().to_string(),
"error": format!("{}", e),
});
if let Some(h) = &hint {
entry["hint"] = serde_json::json!(h);
}
println!("{}", serde_json::to_string_pretty(&entry)?);
} else {
eprintln!("{} {}", style::err_red_bold("✗"), path.display());
eprintln!(" {}", e);
if let Some(h) = hint {
eprintln!(" {}: {}", style::err_cyan_bold("hint"), h);
}
}
std::process::exit(1);
}
}
}
pub fn open_in_editor(path: &Path) -> Result<()> {
let editor = std::env::var("EDITOR").unwrap_or_else(|_| "vi".into());
let status = std::process::Command::new(&editor)
.arg(path)
.status()
.with_context(|| format!("failed to launch editor: {editor}"))?;
if !status.success() {
anyhow::bail!("editor exited with {status}");
}
Ok(())
}
fn validate_policy_source(path: &Path) -> Result<crate::policy::match_tree::CompiledPolicy> {
let source = crate::settings::evaluate_policy_file(path)
.with_context(|| format!("failed to evaluate: {}", path.display()))?;
crate::policy::compile::compile_to_tree(&source).with_context(|| "policy validation failed")
}
fn edit_with_validation(path: &Path) -> Result<()> {
let original = std::fs::read_to_string(path)
.with_context(|| format!("failed to read {}", path.display()))?;
let original_policy = validate_policy_source(path).ok();
let extension = path.extension().and_then(|e| e.to_str()).unwrap_or("star");
let tmp_dir = tempfile::tempdir().context("failed to create temp directory")?;
let tmp_path = tmp_dir.path().join(format!("policy.{extension}"));
std::fs::write(&tmp_path, &original)
.with_context(|| format!("failed to write temp file {}", tmp_path.display()))?;
loop {
open_in_editor(&tmp_path)?;
let edited = std::fs::read_to_string(&tmp_path)
.with_context(|| format!("failed to read temp file {}", tmp_path.display()))?;
if edited == original {
eprintln!(" No changes made.");
return Ok(());
}
match validate_policy_source(&tmp_path) {
Ok(new_policy) => {
if let Some(ref old_policy) = original_policy {
if let Some(diff) = crate::policy::diff::tree_diff(old_policy, &new_policy) {
eprintln!();
eprintln!(" {}:", style::bold("Policy changes"));
for line in diff.lines() {
eprintln!(" {line}");
}
eprintln!();
}
}
eprintln!(
" {} Valid policy: default {}, {} rules",
style::green_bold("✓"),
style::effect(&new_policy.default_effect.to_string()),
new_policy.rule_count()
);
eprint!(" Apply changes? [y/n/e] (yes/no/re-edit) ");
let _ = std::io::Write::flush(&mut std::io::stderr());
let mut answer = String::new();
std::io::stdin().read_line(&mut answer)?;
match answer.trim().to_lowercase().as_str() {
"y" | "yes" => {
std::fs::write(path, &edited)
.with_context(|| format!("failed to write {}", path.display()))?;
println!(
"{} Policy updated: {}",
style::green_bold("✓"),
style::dim(&path.display().to_string())
);
return Ok(());
}
"e" | "edit" => {
continue;
}
_ => {
eprintln!(" Cancelled. No changes written.");
return Ok(());
}
}
}
Err(e) => {
eprintln!();
eprintln!(" {} Validation error:", style::err_red_bold("✗"));
eprintln!(" {e:#}");
if let Some(hint) = extract_policy_hint(&e) {
eprintln!(" {}: {hint}", style::err_cyan_bold("hint"));
}
eprintln!();
eprint!(" Re-edit? [y/n] ");
let _ = std::io::Write::flush(&mut std::io::stderr());
let mut answer = String::new();
std::io::stdin().read_line(&mut answer)?;
if !answer.trim().eq_ignore_ascii_case("y") {
eprintln!(" Cancelled. No changes written.");
return Ok(());
}
}
}
}
}
fn handle_edit(scope: Option<String>) -> Result<()> {
let level = match scope.as_deref() {
Some("user") => PolicyLevel::User,
Some("project") => PolicyLevel::Project,
Some(other) => {
anyhow::bail!("unknown scope: \"{other}\" (expected \"user\" or \"project\")")
}
None => ClashSettings::default_scope(),
};
let path = ClashSettings::policy_file_for_level(level)?;
if !path.exists() {
anyhow::bail!(
"no policy file at {} — run `clash init {}` first",
path.display(),
level,
);
}
edit_with_validation(&path)
}
enum PolicyMutation {
Allow { sandbox: Option<String> },
Deny,
Remove,
}
fn apply_mutation(
command: Vec<String>,
tool: Option<String>,
bin: Option<String>,
scope: Option<String>,
mutation: PolicyMutation,
) -> Result<()> {
let path = resolve_manifest_path(scope)?;
if !path.extension().is_some_and(|ext| ext == "star") {
return Err(crate::policy_loader::legacy_json_error(&path));
}
apply_mutation_star(&path, &command, tool.as_deref(), bin.as_deref(), mutation)
}
fn apply_mutation_star(
path: &Path,
command: &[String],
tool: Option<&str>,
bin: Option<&str>,
mutation: PolicyMutation,
) -> Result<()> {
use clash_starlark::codegen::document::StarDocument;
use clash_starlark::codegen::managed::{self, ManagedUpsertResult};
use clash_starlark::codegen::mutate::Effect as StarEffect;
let mut doc =
StarDocument::open(path).with_context(|| format!("failed to open {}", path.display()))?;
let star_effect = match &mutation {
PolicyMutation::Allow { .. } => StarEffect::Allow,
PolicyMutation::Deny => StarEffect::Deny,
PolicyMutation::Remove => StarEffect::Deny, };
let sandbox_name = match &mutation {
PolicyMutation::Allow { sandbox } => sandbox.as_deref(),
_ => None,
};
let (rule_kind, result_str) = if let Some((bin_name, args)) = parse_command(command) {
let arg_refs: Vec<&str> = args.iter().map(|s| s.as_str()).collect();
match mutation {
PolicyMutation::Remove => {
if managed::remove_exec_rule(&mut doc.stmts, &bin_name, &arg_refs) {
doc.save()
.with_context(|| format!("failed to write {}", path.display()))?;
println!("{} Rule removed", style::green_bold("✓"));
println!(" {}", style::dim(&path.display().to_string()));
} else {
println!("No matching managed rule found");
}
return Ok(());
}
_ => {
let result = managed::upsert_exec_rule(
&mut doc.stmts,
&bin_name,
&arg_refs,
star_effect,
sandbox_name,
)
.map_err(|e| anyhow::anyhow!("{e}"))?;
(
"exec",
match result {
ManagedUpsertResult::Inserted => "Rule added",
ManagedUpsertResult::Replaced => "Rule updated (replaced existing)",
},
)
}
}
} else if let Some(bin_name) = bin {
match mutation {
PolicyMutation::Remove => {
if managed::remove_exec_rule(&mut doc.stmts, bin_name, &[]) {
doc.save()
.with_context(|| format!("failed to write {}", path.display()))?;
println!("{} Rule removed", style::green_bold("✓"));
println!(" {}", style::dim(&path.display().to_string()));
} else {
println!("No matching managed rule found");
}
return Ok(());
}
_ => {
let result = managed::upsert_exec_rule(
&mut doc.stmts,
bin_name,
&[],
star_effect,
sandbox_name,
)
.map_err(|e| anyhow::anyhow!("{e}"))?;
(
"exec",
match result {
ManagedUpsertResult::Inserted => "Rule added",
ManagedUpsertResult::Replaced => "Rule updated (replaced existing)",
},
)
}
}
} else if let Some(tool_name) = tool {
let resolved = crate::agents::resolve_any_to_internal(tool_name).unwrap_or(tool_name);
match mutation {
PolicyMutation::Remove => {
if managed::remove_tool_rule(&mut doc.stmts, resolved) {
doc.save()
.with_context(|| format!("failed to write {}", path.display()))?;
println!("{} Rule removed", style::green_bold("✓"));
println!(" {}", style::dim(&path.display().to_string()));
} else {
println!("No matching managed rule found");
}
return Ok(());
}
_ => {
let result =
managed::upsert_tool_rule(&mut doc.stmts, resolved, star_effect, sandbox_name)
.map_err(|e| anyhow::anyhow!("{e}"))?;
(
"tool",
match result {
ManagedUpsertResult::Inserted => "Rule added",
ManagedUpsertResult::Replaced => "Rule updated (replaced existing)",
},
)
}
}
} else {
anyhow::bail!("provide a command, --tool, or --bin");
};
doc.save()
.with_context(|| format!("failed to write {}", path.display()))?;
let _ = rule_kind; println!("{} {}", style::green_bold("✓"), result_str);
println!(" {}", style::dim(&path.display().to_string()));
Ok(())
}
pub(crate) fn resolve_manifest_path(scope: Option<String>) -> Result<PathBuf> {
let level = match scope.as_deref() {
Some("user") => PolicyLevel::User,
Some("project") => PolicyLevel::Project,
Some(other) => {
anyhow::bail!("unknown scope: \"{other}\" (expected \"user\" or \"project\")")
}
None => ClashSettings::default_scope(),
};
let dir = match level {
PolicyLevel::User => ClashSettings::settings_dir()?,
PolicyLevel::Project => ClashSettings::project_root()?.join(".clash"),
PolicyLevel::Session => anyhow::bail!("session scope not supported for policy mutation"),
};
let star_path = dir.join("policy.star");
if star_path.exists() {
return Ok(star_path);
}
let json_path = dir.join("policy.json");
if json_path.exists() {
return Err(crate::policy_loader::legacy_json_error(&json_path));
}
std::fs::create_dir_all(&dir).with_context(|| format!("failed to create {}", dir.display()))?;
let source = include_str!("../default_policy.star");
std::fs::write(&star_path, source)
.with_context(|| format!("failed to write {}", star_path.display()))?;
info!(path = %star_path.display(), "Created policy.star");
Ok(star_path)
}
fn parse_command(command: &[String]) -> Option<(String, Vec<String>)> {
let joined = command.join(" ");
let parts: Vec<&str> = joined.split_whitespace().collect();
if parts.is_empty() {
return None;
}
let bin = parts[0].to_string();
let args: Vec<String> = parts[1..].iter().map(|s| s.to_string()).collect();
Some((bin, args))
}
fn looks_like_hash(s: &str) -> bool {
(3..=7).contains(&s.len()) && s.chars().all(|c| c.is_ascii_hexdigit())
}
fn extract_command_from_entry(
entry: &crate::debug::AuditLogEntry,
) -> Result<(String, Vec<String>)> {
let summary = &entry.tool_input_summary;
let clean = summary.trim_end_matches("...");
if let Ok(val) = serde_json::from_str::<serde_json::Value>(clean) {
if let Some(cmd) = val.get("command").and_then(|v| v.as_str()) {
let parts: Vec<&str> = cmd.split_whitespace().collect();
if parts.is_empty() {
anyhow::bail!("empty command in audit entry");
}
let bin = parts[0].to_string();
let args: Vec<String> = parts[1..].iter().map(|s| s.to_string()).collect();
return Ok((bin, args));
}
}
anyhow::bail!(
"cannot extract command from audit entry (tool: {}, summary: {})",
entry.tool_name,
summary
)
}
fn handle_allow(
command: Vec<String>,
tool: Option<String>,
bin: Option<String>,
sandbox: Option<String>,
scope: Option<String>,
broad: bool,
yes: bool,
) -> Result<()> {
if command.len() == 1 && tool.is_none() && bin.is_none() && looks_like_hash(&command[0]) {
return handle_allow_by_hash(&command[0], sandbox, scope, broad, yes);
}
apply_mutation(command, tool, bin, scope, PolicyMutation::Allow { sandbox })
}
fn handle_deny(
command: Vec<String>,
tool: Option<String>,
bin: Option<String>,
scope: Option<String>,
broad: bool,
yes: bool,
) -> Result<()> {
if command.len() == 1 && tool.is_none() && bin.is_none() && looks_like_hash(&command[0]) {
return handle_deny_by_hash(&command[0], scope, broad, yes);
}
apply_mutation(command, tool, bin, scope, PolicyMutation::Deny)
}
fn handle_allow_by_hash(
hash: &str,
sandbox: Option<String>,
scope: Option<String>,
broad: bool,
yes: bool,
) -> Result<()> {
let entry = crate::debug::log::find_by_hash(hash).context("failed to look up audit entry")?;
let (bin_name, args) = extract_command_from_entry(&entry)?;
let (display_args, rule_args) = if broad && !args.is_empty() {
let display = format!("{} {} *", bin_name, args[0]);
let rule = vec![args[0].clone(), "*".to_string()];
(display, rule)
} else {
let display = if args.is_empty() {
bin_name.clone()
} else {
format!("{} {}", bin_name, args.join(" "))
};
(display, args.clone())
};
let scope = scope.or_else(|| Some("user".to_string()));
let path = resolve_manifest_path(scope)?;
let scope_label = path
.to_string_lossy()
.contains(".clash/policy")
.then_some("project")
.unwrap_or("user");
eprintln!();
eprintln!(" Add rule to {} policy:", scope_label);
eprintln!(" allow exec {}", display_args);
eprintln!();
if !yes {
eprint!(" Proceed? [y/N] ");
let _ = std::io::Write::flush(&mut std::io::stderr());
let mut answer = String::new();
std::io::stdin().read_line(&mut answer)?;
if !answer.trim().eq_ignore_ascii_case("y") {
eprintln!(" Cancelled.");
return Ok(());
}
}
apply_mutation_by_path(
&path,
&bin_name,
&rule_args,
PolicyMutation::Allow { sandbox },
)
}
fn apply_mutation_by_path(
path: &Path,
bin_name: &str,
rule_args: &[String],
mutation: PolicyMutation,
) -> Result<()> {
if !path.extension().is_some_and(|ext| ext == "star") {
return Err(crate::policy_loader::legacy_json_error(path));
}
{
let arg_refs: Vec<&str> = rule_args.iter().map(|s| s.as_str()).collect();
let star_effect = match &mutation {
PolicyMutation::Allow { .. } => clash_starlark::codegen::mutate::Effect::Allow,
PolicyMutation::Deny => clash_starlark::codegen::mutate::Effect::Deny,
PolicyMutation::Remove => clash_starlark::codegen::mutate::Effect::Deny,
};
let sandbox_name = match &mutation {
PolicyMutation::Allow { sandbox } => sandbox.as_deref(),
_ => None,
};
let mut doc = clash_starlark::codegen::document::StarDocument::open(path)
.with_context(|| format!("failed to open {}", path.display()))?;
match mutation {
PolicyMutation::Remove => {
if clash_starlark::codegen::managed::remove_exec_rule(
&mut doc.stmts,
bin_name,
&arg_refs,
) {
doc.save()
.with_context(|| format!("failed to write {}", path.display()))?;
println!("{} Rule removed", style::green_bold("✓"));
} else {
println!("No matching managed rule found");
}
}
_ => {
let result = clash_starlark::codegen::managed::upsert_exec_rule(
&mut doc.stmts,
bin_name,
&arg_refs,
star_effect,
sandbox_name,
)
.map_err(|e| anyhow::anyhow!("{e}"))?;
doc.save()
.with_context(|| format!("failed to write {}", path.display()))?;
let result_str = match result {
clash_starlark::codegen::managed::ManagedUpsertResult::Inserted => "Rule added",
clash_starlark::codegen::managed::ManagedUpsertResult::Replaced => {
"Rule updated (replaced existing)"
}
};
println!("{} {}", style::green_bold("✓"), result_str);
}
}
println!(" {}", style::dim(&path.display().to_string()));
Ok(())
}
}
fn handle_deny_by_hash(hash: &str, scope: Option<String>, broad: bool, yes: bool) -> Result<()> {
let entry = crate::debug::log::find_by_hash(hash).context("failed to look up audit entry")?;
let (bin_name, args) = extract_command_from_entry(&entry)?;
let (display_args, rule_args) = if broad && !args.is_empty() {
let display = format!("{} {} *", bin_name, args[0]);
let rule = vec![args[0].clone(), "*".to_string()];
(display, rule)
} else {
let display = if args.is_empty() {
bin_name.clone()
} else {
format!("{} {}", bin_name, args.join(" "))
};
(display, args.clone())
};
let scope = scope.or_else(|| Some("user".to_string()));
let path = resolve_manifest_path(scope)?;
let scope_label = path
.to_string_lossy()
.contains(".clash/policy")
.then_some("project")
.unwrap_or("user");
eprintln!();
eprintln!(" Add rule to {} policy:", scope_label);
eprintln!(" deny exec {}", display_args);
eprintln!();
if !yes {
eprint!(" Proceed? [y/N] ");
let _ = std::io::Write::flush(&mut std::io::stderr());
let mut answer = String::new();
std::io::stdin().read_line(&mut answer)?;
if !answer.trim().eq_ignore_ascii_case("y") {
eprintln!(" Cancelled.");
return Ok(());
}
}
apply_mutation_by_path(&path, &bin_name, &rule_args, PolicyMutation::Deny)?;
Ok(())
}
fn handle_remove(
command: Vec<String>,
tool: Option<String>,
bin: Option<String>,
scope: Option<String>,
) -> Result<()> {
apply_mutation(command, tool, bin, scope, PolicyMutation::Remove)
}
fn handle_convert(file: Option<PathBuf>, replace: bool) -> Result<()> {
let json_path = match file {
Some(p) => p,
None => {
let level = ClashSettings::default_scope();
let dir = match level {
PolicyLevel::User => ClashSettings::settings_dir()?,
PolicyLevel::Project => ClashSettings::project_root()?.join(".clash"),
PolicyLevel::Session => {
anyhow::bail!("session scope not supported for policy convert")
}
};
dir.join("policy.json")
}
};
if !json_path.exists() {
anyhow::bail!("policy file not found: {}", json_path.display());
}
if json_path.extension().is_some_and(|ext| ext == "star") {
anyhow::bail!("file is already a .star file: {}", json_path.display());
}
let raw = std::fs::read_to_string(&json_path)
.with_context(|| format!("failed to read {}", json_path.display()))?;
let manifest: PolicyManifest = serde_json::from_str(&raw)
.with_context(|| format!("failed to parse {}", json_path.display()))?;
let manifest_json =
serde_json::to_value(&manifest.policy).context("failed to serialize manifest")?;
let tree = manifest_json
.get("tree")
.and_then(|v| v.as_array())
.cloned()
.unwrap_or_default();
let sandboxes = manifest_json
.get("sandboxes")
.and_then(|v| v.as_object())
.cloned()
.unwrap_or_default();
let default_effect = manifest_json
.get("default_effect")
.and_then(|v| v.as_str())
.unwrap_or("ask");
let default_sandbox = manifest_json
.get("default_sandbox")
.and_then(|v| v.as_str());
use clash_starlark::codegen::ast::{Expr, Stmt};
use clash_starlark::codegen::builder;
let effect_expr = match default_effect {
"allow" => builder::allow(),
"deny" => builder::deny(),
_ => builder::ask(),
};
let mut stmts = vec![
Stmt::load(
"@clash//std.star",
&["policy", "settings", "allow", "deny", "ask"],
),
Stmt::Blank,
];
for (name, sb_value) in &sandboxes {
let expr = clash_starlark::codegen::from_manifest::sandbox_json_to_expr(name, sb_value);
stmts.push(Stmt::Expr(expr));
stmts.push(Stmt::Blank);
clash_starlark::codegen::mutate::ensure_loaded(&mut stmts, "sandbox");
}
let settings = if let Some(sb) = default_sandbox {
builder::settings(effect_expr.clone(), Some(Expr::string(sb)))
} else {
builder::settings(effect_expr.clone(), None)
};
stmts.push(Stmt::Expr(settings));
stmts.push(Stmt::Blank);
let rule_exprs: Vec<Expr> = tree
.iter()
.map(clash_starlark::codegen::from_manifest::node_json_to_expr)
.collect();
let policy_expr = builder::policy("default", effect_expr, rule_exprs, None);
stmts.push(Stmt::Expr(policy_expr));
let source = clash_starlark::codegen::serialize(&stmts);
for name in ["tool", "when", "sandbox"] {
if source.contains(&format!("{name}(")) {
clash_starlark::codegen::mutate::ensure_loaded(&mut stmts, name);
}
}
let source = clash_starlark::codegen::serialize(&stmts);
let star_path = json_path.with_extension("star");
let base_dir = json_path.parent().unwrap_or(Path::new("."));
clash_starlark::evaluate(&source, &star_path.display().to_string(), base_dir)
.context("generated .star file failed validation")?;
std::fs::write(&star_path, &source)
.with_context(|| format!("failed to write {}", star_path.display()))?;
info!(path = %star_path.display(), "wrote .star policy");
eprintln!(
"{} Converted {} → {}",
style::green_bold("✓"),
json_path.display(),
star_path.display()
);
if replace {
std::fs::remove_file(&json_path)
.with_context(|| format!("failed to remove {}", json_path.display()))?;
eprintln!("{} Removed {}", style::green_bold("✓"), json_path.display());
}
Ok(())
}
fn handle_migrate(scope: Option<String>, yes: bool) -> Result<()> {
let path = resolve_manifest_path(scope)?;
if !path.extension().is_some_and(|ext| ext == "star") {
anyhow::bail!(
"policy file is not .star format: {}\nRun `clash policy convert` first to migrate from JSON.",
path.display()
);
}
let source = std::fs::read_to_string(&path)
.with_context(|| format!("failed to read {}", path.display()))?;
let needs_when = source.contains("when(");
let needs_rules = source.contains("rules=") || source.contains("rules =");
let needs_claude_settings = !source.contains("from_claude_settings");
if !needs_when && !needs_rules && !needs_claude_settings {
eprintln!(
"{} Policy is already up to date — no migration needed.",
style::green_bold("✓"),
);
return Ok(());
}
eprintln!("{}", style::bold("Migration report:"));
if needs_when {
eprintln!(
" {} Found deprecated when() syntax — please convert to dict syntax manually",
style::yellow_bold("!"),
);
}
if needs_rules {
eprintln!(
" {} Found deprecated rules= syntax — please convert to dict syntax manually",
style::yellow_bold("!"),
);
}
if !needs_claude_settings {
if needs_when || needs_rules {
eprintln!(
"\n{} from_claude_settings() is already present. Manual migration of when()/rules= is required.",
style::green_bold("✓"),
);
}
return Ok(());
}
eprintln!(
" {} Will add from_claude_settings() to import your Claude settings",
style::green_bold("+"),
);
let mut doc = clash_starlark::codegen::document::StarDocument::open(&path)?;
add_claude_compat_load(&mut doc.stmts);
wrap_policy_with_merge(&mut doc.stmts);
clash_starlark::codegen::mutate::ensure_loaded(&mut doc.stmts, "merge");
let new_source = doc.to_source();
let base_dir = path.parent().unwrap_or(Path::new("."));
clash_starlark::evaluate(&new_source, &path.display().to_string(), base_dir)
.context("migrated policy failed validation — aborting")?;
eprintln!("\n{}", style::bold("Changes:"));
for line in diff_lines(&source, &new_source) {
eprintln!("{line}");
}
let confirmed = crate::dialog::confirm("Apply migration?", yes)?;
if !confirmed {
eprintln!("Migration cancelled.");
return Ok(());
}
std::fs::write(&path, &new_source)
.with_context(|| format!("failed to write {}", path.display()))?;
eprintln!("{} Migrated {}", style::green_bold("✓"), path.display());
Ok(())
}
fn add_claude_compat_load(stmts: &mut Vec<clash_starlark::codegen::ast::Stmt>) {
use clash_starlark::codegen::ast::Stmt;
let compat_module = "@clash//claude_compat.star";
for stmt in stmts.iter() {
if let Stmt::Load { module, names } = stmt {
if module == compat_module && names.iter().any(|n| n == "from_claude_settings") {
return;
}
}
}
let insert_pos = stmts
.iter()
.rposition(|s| matches!(s, Stmt::Load { .. }))
.map(|i| i + 1)
.unwrap_or(0);
stmts.insert(
insert_pos,
Stmt::load(compat_module, &["from_claude_settings"]),
);
}
fn wrap_policy_with_merge(stmts: &mut Vec<clash_starlark::codegen::ast::Stmt>) {
use clash_starlark::codegen::ast::{Expr, Stmt};
for stmt in stmts.iter_mut() {
let is_policy_call = matches!(
stmt,
Stmt::Expr(Expr::Call { func, args, .. })
if matches!(func.as_ref(), Expr::Ident(n) if n == "policy")
&& args.len() >= 2
);
if !is_policy_call {
continue;
}
if let Stmt::Expr(Expr::Call { args, .. }) = stmt {
let already_merged = matches!(
&args[1],
Expr::Call { func, .. } if matches!(func.as_ref(), Expr::Ident(n) if n == "merge")
);
if already_merged {
return;
}
let existing = args[1].clone();
let fcs = Expr::Call {
func: Box::new(Expr::Ident("from_claude_settings".to_string())),
args: vec![],
kwargs: vec![],
};
let merged = Expr::Call {
func: Box::new(Expr::Ident("merge".to_string())),
args: vec![fcs, existing],
kwargs: vec![],
};
args[1] = merged;
return;
}
}
}
fn diff_lines(old: &str, new: &str) -> Vec<String> {
let mut result = Vec::new();
let old_lines: Vec<&str> = old.lines().collect();
let new_lines: Vec<&str> = new.lines().collect();
for line in &old_lines {
if !new_lines.contains(line) {
result.push(format!(" {} {line}", style::red("-")));
}
}
for line in &new_lines {
if !old_lines.contains(line) {
result.push(format!(" {} {line}", style::green_bold("+")));
}
}
result
}
fn extract_policy_hint(err: &anyhow::Error) -> Option<String> {
err.chain().find_map(|cause| {
if let Some(e) = cause.downcast_ref::<crate::policy::error::PolicyParseError>() {
return e.help();
}
if let Some(e) = cause.downcast_ref::<crate::policy::error::CompileError>() {
return e.help();
}
None
})
}