use std::path::Path;
use anyhow::{Context, Result};
use tracing::{error, warn};
#[cfg(test)]
use tracing::info;
use crate::policy::compile;
use crate::policy::match_tree::{CompiledPolicy, PolicyManifest};
use crate::settings::{LoadedPolicy, PolicyLevel};
pub const MAX_POLICY_SIZE: u64 = 1024 * 1024;
pub struct ValidatedPolicy {
pub json_source: String,
pub loaded: LoadedPolicy,
}
pub fn evaluate_star_policy(path: &Path) -> Result<String> {
let source = std::fs::read_to_string(path)
.with_context(|| format!("failed to read {}", path.display()))?;
let base_dir = path.parent().unwrap_or(Path::new("."));
let output = clash_starlark::evaluate(&source, &path.display().to_string(), base_dir)?;
Ok(output.json)
}
fn migrate_legacy_caps(path: &Path, raw: String) -> Result<String> {
let mut value: serde_json::Value = serde_json::from_str(&raw)
.with_context(|| format!("failed to parse {}", path.display()))?;
let mut changed = false;
if let Some(sandboxes) = value.get_mut("sandboxes").and_then(|v| v.as_object_mut()) {
for (_name, sandbox) in sandboxes.iter_mut() {
let Some(sandbox_obj) = sandbox.as_object_mut() else {
continue;
};
if let Some(default_val) = sandbox_obj.get("default") {
if let Some(migrated) = migrate_cap_value(default_val) {
sandbox_obj.insert("default".into(), migrated);
changed = true;
}
}
if let Some(rules) = sandbox_obj.get_mut("rules").and_then(|v| v.as_array_mut()) {
for rule in rules.iter_mut() {
if let Some(rule_obj) = rule.as_object_mut() {
if let Some(caps_val) = rule_obj.get("caps") {
if let Some(migrated) = migrate_cap_value(caps_val) {
rule_obj.insert("caps".into(), migrated);
changed = true;
}
}
}
}
}
}
}
if !changed {
return Ok(raw);
}
let fixed =
serde_json::to_string_pretty(&value).context("failed to serialize migrated policy")?;
if let Err(e) = std::fs::write(path, &fixed) {
warn!(path = %path.display(), error = %e, "Failed to write migrated policy back to disk");
} else {
warn!(
path = %path.display(),
"Migrated legacy string-style caps to array format"
);
}
Ok(fixed)
}
fn migrate_cap_value(value: &serde_json::Value) -> Option<serde_json::Value> {
use crate::policy::sandbox_types::Cap;
let s = value.as_str()?;
let cap = Cap::parse(s).ok()?;
let names: Vec<serde_json::Value> = cap
.to_list()
.into_iter()
.map(|n| serde_json::Value::String(n.into()))
.collect();
Some(serde_json::Value::Array(names))
}
pub fn load_json_policy(path: &Path) -> Result<String> {
let raw = std::fs::read_to_string(path)
.with_context(|| format!("failed to read {}", path.display()))?;
let raw = migrate_legacy_caps(path, raw)?;
let manifest: PolicyManifest = serde_json::from_str(&raw)
.with_context(|| format!("failed to parse {}", path.display()))?;
if manifest.includes.is_empty() {
return Ok(raw);
}
let base_dir = path.parent().unwrap_or(Path::new("."));
merge_manifest_with_includes(&manifest, base_dir)
}
fn merge_manifest_with_includes(manifest: &PolicyManifest, base_dir: &Path) -> Result<String> {
let mut merged = manifest.policy.clone();
for include in &manifest.includes {
let json_source = evaluate_include(&include.path, base_dir)?;
let included: CompiledPolicy = serde_json::from_str(&json_source)
.with_context(|| format!("failed to parse included policy {:?}", include.path))?;
merged.tree.extend(included.tree);
for (k, v) in included.sandboxes {
merged.sandboxes.entry(k).or_insert(v);
}
}
serde_json::to_string(&merged).context("failed to serialize merged policy")
}
fn evaluate_include(include_path: &str, base_dir: &Path) -> Result<String> {
if include_path.starts_with("@clash//") {
evaluate_stdlib_include(include_path)
} else {
let resolved = base_dir.join(include_path);
evaluate_star_policy(&resolved)
}
}
fn evaluate_stdlib_include(include_path: &str) -> Result<String> {
let wrapper = format!(
"load(\"{include_path}\", \"builtins\")\n\
load(\"@clash//std.star\", \"deny\", \"policy\")\n\
def main():\n return policy(default=deny(), rules=builtins)\n"
);
let output = clash_starlark::evaluate(&wrapper, "<include>", Path::new("."))
.with_context(|| format!("failed to evaluate stdlib include {include_path}"))?;
Ok(output.json)
}
pub fn read_manifest(path: &Path) -> Result<PolicyManifest> {
let raw = std::fs::read_to_string(path)
.with_context(|| format!("failed to read {}", path.display()))?;
serde_json::from_str(&raw).with_context(|| format!("failed to parse {}", path.display()))
}
pub fn resolve_includes(
manifest: &PolicyManifest,
base_dir: &Path,
) -> Result<(CompiledPolicy, Vec<String>)> {
use std::collections::HashMap;
let mut merged = CompiledPolicy {
sandboxes: HashMap::new(),
tree: vec![],
default_effect: manifest.policy.default_effect,
default_sandbox: None,
};
let mut warnings = Vec::new();
for include in &manifest.includes {
match evaluate_include(&include.path, base_dir) {
Ok(json_source) => match serde_json::from_str::<CompiledPolicy>(&json_source) {
Ok(included) => {
for mut node in included.tree {
node.stamp_source(&include.path);
merged.tree.push(node);
}
for (k, v) in included.sandboxes {
merged.sandboxes.entry(k).or_insert(v);
}
}
Err(e) => {
warnings.push(format!("{}: parse error: {e}", include.path));
}
},
Err(e) => {
warnings.push(format!("{}: {e:#}", include.path));
}
}
}
if !warnings.is_empty() {
tracing::warn!("include resolution warnings: {}", warnings.join("; "));
}
Ok((merged, warnings))
}
pub fn write_manifest(path: &Path, manifest: &PolicyManifest) -> Result<()> {
let json =
serde_json::to_string_pretty(manifest).context("failed to serialize policy manifest")?;
std::fs::write(path, json).with_context(|| format!("failed to write {}", path.display()))
}
fn validate_policy_file(path: &Path, level: PolicyLevel) -> Option<std::fs::Metadata> {
match validate_policy_file_with_diagnostics(path) {
Ok(metadata) => {
#[cfg(unix)]
check_permissions_warning(path, level, &metadata);
Some(metadata)
}
Err(ValidationError::NotFound) => None,
Err(e) => {
warn!(path = %path.display(), level = %level, "Policy file invalid: {e}");
None
}
}
}
fn validate_policy_file_with_diagnostics(
path: &Path,
) -> Result<std::fs::Metadata, ValidationError> {
let metadata = match std::fs::metadata(path) {
Ok(m) => m,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
return Err(ValidationError::NotFound);
}
Err(e) => {
return Err(ValidationError::IoError(format!(
"Cannot read policy file at {}: {}",
path.display(),
e
)));
}
};
if metadata.is_dir() {
return Err(ValidationError::IsDirectory(format!(
"{} is a directory, not a file. Remove it and run `clash init` to create a policy.",
path.display()
)));
}
if metadata.len() > MAX_POLICY_SIZE {
return Err(ValidationError::TooLarge(format!(
"policy file is too large ({} bytes, max {} bytes). Check that {} is the correct file.",
metadata.len(),
MAX_POLICY_SIZE,
path.display()
)));
}
Ok(metadata)
}
#[cfg(unix)]
fn check_permissions_warning(path: &Path, level: PolicyLevel, metadata: &std::fs::Metadata) {
use std::os::unix::fs::PermissionsExt;
let mode = metadata.permissions().mode();
if mode & 0o044 != 0 {
warn!(
path = %path.display(),
level = %level,
mode = format!("{:o}", mode),
"policy file is readable by other users; consider `chmod 600`"
);
}
}
enum ValidationError {
NotFound,
IoError(String),
IsDirectory(String),
TooLarge(String),
}
impl std::fmt::Display for ValidationError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
ValidationError::NotFound => write!(f, "file not found"),
ValidationError::IoError(msg)
| ValidationError::IsDirectory(msg)
| ValidationError::TooLarge(msg) => write!(f, "{msg}"),
}
}
}
pub fn try_load_policy(
level: PolicyLevel,
path: &Path,
policy_error: &mut Option<String>,
) -> Option<ValidatedPolicy> {
let _metadata = validate_policy_file(path, level)?;
let is_json = path.extension().is_some_and(|ext| ext == "json");
let result = if is_json {
load_json_policy(path)
} else {
evaluate_star_policy(path)
};
match result {
Ok(json_source) => {
let loaded = LoadedPolicy {
level,
path: path.to_path_buf(),
source: json_source.clone(),
};
Some(ValidatedPolicy {
json_source,
loaded,
})
}
Err(e) => {
let kind = if is_json { "JSON" } else { "starlark" };
error!(
path = %path.display(),
level = %level,
error = %e,
"Failed to evaluate {kind} policy"
);
*policy_error = Some(format!("Failed to evaluate {}: {}", path.display(), e));
None
}
}
}
pub fn compile_policies(level_sources: &[(PolicyLevel, String, String)]) -> Result<CompiledPolicy> {
let level_refs: Vec<(PolicyLevel, &str, &str)> = level_sources
.iter()
.map(|(l, s, p)| (*l, s.as_str(), p.as_str()))
.collect();
compile::compile_multi_level_to_tree(&level_refs)
}
pub fn compile_source(source: &str) -> Result<CompiledPolicy> {
compile::compile_to_tree(source)
}
#[cfg(test)]
pub fn load_and_compile_single(
path: &Path,
policy_error: &mut Option<String>,
) -> Option<CompiledPolicy> {
let metadata = match validate_policy_file_with_diagnostics(path) {
Ok(m) => m,
Err(ValidationError::NotFound) => return None,
Err(e) => {
warn!(path = %path.display(), "Policy file invalid: {e}");
*policy_error = Some(e.to_string());
return None;
}
};
#[cfg(unix)]
check_permissions_warning(path, PolicyLevel::User, &metadata);
#[cfg(not(unix))]
let _ = metadata;
let is_json = path.extension().is_some_and(|ext| ext == "json");
let eval_result = if is_json {
load_json_policy(path)
} else {
evaluate_star_policy(path)
};
match eval_result {
Ok(json_source) => match compile::compile_to_tree(&json_source) {
Ok(tree) => {
info!(path = %path.display(), "Loaded policy");
Some(tree)
}
Err(e) => {
let msg = format!("Failed to compile policy: {}", e);
warn!(path = %path.display(), error = %e, "Failed to compile policy");
*policy_error = Some(msg);
None
}
},
Err(e) => {
let msg = format!("Failed to evaluate policy: {}", e);
warn!(path = %path.display(), error = %e, "Failed to evaluate policy");
*policy_error = Some(msg);
None
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn load_json_policy_without_includes() {
let dir = tempfile::tempdir().unwrap();
let json_path = dir.path().join("policy.json");
std::fs::write(
&json_path,
r#"{
"default_effect": "deny",
"sandboxes": {},
"tree": [{
"condition": {
"observe": "tool_name",
"pattern": {"literal": {"literal": "Bash"}},
"children": [{"decision": {"allow": null}}]
}
}]
}"#,
)
.unwrap();
let source = load_json_policy(&json_path).unwrap();
let policy: CompiledPolicy = serde_json::from_str(&source).unwrap();
assert_eq!(policy.tree.len(), 1);
}
#[test]
fn load_json_policy_with_star_include() {
let dir = tempfile::tempdir().unwrap();
let star_path = dir.path().join("extra.star");
std::fs::write(
&star_path,
r#"
load("@clash//std.star", "tool", "policy", "deny")
def main():
return policy(default = deny(), rules = [tool("Read").allow()])
"#,
)
.unwrap();
let json_path = dir.path().join("policy.json");
std::fs::write(
&json_path,
r#"{
"default_effect": "deny",
"sandboxes": {},
"includes": [{"path": "extra.star"}],
"tree": [{
"condition": {
"observe": "tool_name",
"pattern": {"literal": {"literal": "Bash"}},
"children": [{"decision": {"allow": null}}]
}
}]
}"#,
)
.unwrap();
let source = load_json_policy(&json_path).unwrap();
let policy: CompiledPolicy = serde_json::from_str(&source).unwrap();
assert!(
policy.tree.len() >= 2,
"expected at least 2 rules, got {}",
policy.tree.len()
);
}
#[test]
fn load_json_policy_with_stdlib_include() {
let dir = tempfile::tempdir().unwrap();
let json_path = dir.path().join("policy.json");
std::fs::write(
&json_path,
r#"{
"default_effect": "deny",
"sandboxes": {},
"includes": [{"path": "@clash//builtin.star"}],
"tree": []
}"#,
)
.unwrap();
let source = load_json_policy(&json_path).unwrap();
let policy: CompiledPolicy = serde_json::from_str(&source).unwrap();
assert!(
!policy.tree.is_empty(),
"builtin.star should contribute rules"
);
}
#[test]
fn manifest_roundtrip() {
let dir = tempfile::tempdir().unwrap();
let json_path = dir.path().join("policy.json");
let manifest = PolicyManifest {
includes: vec![crate::policy::match_tree::IncludeEntry {
path: "@clash//builtin.star".into(),
}],
policy: CompiledPolicy {
sandboxes: std::collections::HashMap::new(),
tree: vec![],
default_effect: crate::policy::Effect::Deny,
default_sandbox: None,
},
};
write_manifest(&json_path, &manifest).unwrap();
let loaded = read_manifest(&json_path).unwrap();
assert_eq!(loaded.includes.len(), 1);
assert_eq!(loaded.includes[0].path, "@clash//builtin.star");
}
#[test]
fn migrate_legacy_string_caps_to_array() {
let dir = tempfile::tempdir().unwrap();
let json_path = dir.path().join("policy.json");
std::fs::write(
&json_path,
r#"{
"default_effect": "deny",
"sandboxes": {
"dev": {
"default": "read + execute",
"rules": [
{
"effect": "allow",
"caps": "all - delete",
"path": "/tmp",
"path_match": "subpath"
}
]
}
},
"tree": []
}"#,
)
.unwrap();
let source = load_json_policy(&json_path).unwrap();
let policy: CompiledPolicy = serde_json::from_str(&source).unwrap();
let dev = policy.sandboxes.get("dev").expect("dev sandbox");
assert_eq!(
dev.default,
crate::policy::sandbox_types::Cap::READ | crate::policy::sandbox_types::Cap::EXECUTE
);
assert_eq!(
dev.rules[0].caps,
crate::policy::sandbox_types::Cap::READ
| crate::policy::sandbox_types::Cap::WRITE
| crate::policy::sandbox_types::Cap::CREATE
| crate::policy::sandbox_types::Cap::EXECUTE
);
let on_disk: serde_json::Value =
serde_json::from_str(&std::fs::read_to_string(&json_path).unwrap()).unwrap();
let dev_default = &on_disk["sandboxes"]["dev"]["default"];
assert!(dev_default.is_array(), "default should be an array on disk");
let rule_caps = &on_disk["sandboxes"]["dev"]["rules"][0]["caps"];
assert!(rule_caps.is_array(), "caps should be an array on disk");
}
#[test]
fn no_migration_when_already_array_format() {
let dir = tempfile::tempdir().unwrap();
let json_path = dir.path().join("policy.json");
let original = r#"{
"default_effect": "deny",
"sandboxes": {
"dev": {
"default": ["read", "execute"],
"rules": []
}
},
"tree": []
}"#;
std::fs::write(&json_path, original).unwrap();
let _source = load_json_policy(&json_path).unwrap();
let on_disk = std::fs::read_to_string(&json_path).unwrap();
assert_eq!(on_disk, original);
}
}