use std::path::PathBuf;
use anyhow::{Context, Result};
use dirs::home_dir;
pub use clash_policy::PolicyLevel;
pub const DEFAULT_POLICY_TEMPLATE: &str = include_str!("../default_policy.star");
pub const SANDBOX_PRESETS: &[SandboxPreset] = &[
SandboxPreset {
name: "dev",
description: "Build tools, git — read+write project, read home, no network",
},
SandboxPreset {
name: "dev_network",
description: "Package managers, gh — read+write project, full network",
},
SandboxPreset {
name: "read_only",
description: "Linters, analyzers — read project + home, no writes outside temp",
},
SandboxPreset {
name: "restricted",
description: "Untrusted scripts — read-only project, no network",
},
SandboxPreset {
name: "unrestricted",
description: "Fully trusted — all filesystem + network access",
},
];
pub struct SandboxPreset {
pub name: &'static str,
pub description: &'static str,
}
impl crate::dialog::SelectItem for SandboxPreset {
fn label(&self) -> &str {
self.name
}
fn description(&self) -> &str {
self.description
}
fn variants() -> &'static [Self] {
SANDBOX_PRESETS
}
}
pub fn compile_default_policy_to_json_with_preset(_preset: &str) -> Result<String> {
compile_default_policy_to_json()
}
pub fn compile_default_policy_to_json() -> Result<String> {
let output = clash_starlark::evaluate(
DEFAULT_POLICY_TEMPLATE,
"<default_policy>",
std::path::Path::new("."),
)
.context("failed to compile default policy")?;
let value: serde_json::Value =
serde_json::from_str(&output.json).context("default policy produced invalid JSON")?;
serde_json::to_string_pretty(&value).context("failed to pretty-print default policy JSON")
}
pub fn settings_dir() -> Result<PathBuf> {
if let Ok(p) = std::env::var("CLASH_HOME") {
return Ok(PathBuf::from(p));
}
home_dir()
.map(|h| h.join(".clash"))
.ok_or_else(|| anyhow::anyhow!("$HOME is not set; cannot determine settings directory"))
}
pub fn policy_file() -> Result<PathBuf> {
if let Ok(p) = std::env::var("CLASH_POLICY_FILE") {
return Ok(PathBuf::from(p));
}
let dir = settings_dir()?;
discover_star_in(&dir)
}
pub fn project_policy_file(project_root: &std::path::Path) -> Result<PathBuf> {
let dir = project_root.join(".clash");
discover_star_in(&dir)
}
pub fn session_policy_file(session_id: &str) -> PathBuf {
crate::session_dir::SessionDir::new(session_id).policy()
}
pub(crate) fn discover_star_in(dir: &std::path::Path) -> Result<PathBuf> {
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));
}
Ok(star_path)
}
pub(crate) fn tilde_path(path: &std::path::Path) -> String {
if let Some(home) = home_dir()
&& let Ok(rest) = path.strip_prefix(&home)
{
return format!("~/{}", rest.display());
}
path.display().to_string()
}
pub(crate) fn find_ancestor_with(
start: &std::path::Path,
name: &str,
stop_at: Option<&std::path::Path>,
) -> Option<PathBuf> {
let mut current = start.to_path_buf();
loop {
if let Some(boundary) = stop_at
&& current == boundary
{
return None;
}
if current.join(name).exists() {
return Some(current);
}
if !current.pop() {
return None;
}
}
}
pub fn evaluate_star_policy(path: &std::path::Path) -> Result<String> {
crate::policy_loader::evaluate_star_policy(path).map(|o| o.json)
}
pub fn evaluate_policy_file(path: &std::path::Path) -> Result<String> {
if path.extension().is_some_and(|ext| ext == "json") {
Err(crate::policy_loader::legacy_json_error(path))
} else {
crate::policy_loader::evaluate_star_policy(path).map(|o| o.json)
}
}
pub fn parse_notification_config(
yaml_str: &str,
) -> (crate::notifications::NotificationConfig, Option<String>) {
use serde::Deserialize;
use tracing::warn;
#[derive(Deserialize)]
struct RawYaml {
#[serde(default)]
notifications: Option<crate::notifications::NotificationConfig>,
}
match serde_yaml::from_str::<RawYaml>(yaml_str) {
Ok(raw) => (raw.notifications.unwrap_or_default(), None),
Err(e) => {
let warning = format!("notifications config parse error: {}", e);
warn!(error = %e, "Failed to parse notifications config");
(
crate::notifications::NotificationConfig::default(),
Some(warning),
)
}
}
}
pub(crate) fn parse_audit_config(yaml_str: &str) -> crate::audit::AuditConfig {
use serde::Deserialize;
#[derive(Deserialize)]
struct RawYaml {
#[serde(default)]
audit: Option<crate::audit::AuditConfig>,
}
match serde_yaml::from_str::<RawYaml>(yaml_str) {
Ok(raw) => raw.audit.unwrap_or_default(),
Err(_) => crate::audit::AuditConfig::default(),
}
}
#[cfg(test)]
mod test {
use super::*;
#[allow(dead_code)]
struct TestEnv;
impl crate::policy::compile::EnvResolver for TestEnv {
fn resolve(&self, name: &str) -> anyhow::Result<String> {
match name {
"PWD" => Ok("/tmp".into()),
"HOME" => Ok("/tmp/home".into()),
"TMPDIR" => Ok("/tmp".into()),
other => anyhow::bail!("unknown env var in test: {other}"),
}
}
}
#[test]
fn default_policy_compiles() -> anyhow::Result<()> {
let source = DEFAULT_POLICY_TEMPLATE.replace("{preset}", "dev");
let output =
clash_starlark::evaluate(&source, "default_policy.star", std::path::Path::new("."))?;
let tree = crate::policy::compile::compile_to_tree(&output.json)?;
let _ = tree;
Ok(())
}
#[test]
fn default_policy_compiles_all_presets() -> anyhow::Result<()> {
for preset in SANDBOX_PRESETS {
compile_default_policy_to_json_with_preset(preset.name)?;
}
Ok(())
}
#[test]
fn discovery_ignores_policy_json() {
let tmp = tempfile::tempdir().unwrap();
std::fs::write(tmp.path().join("policy.json"), "{}").unwrap();
std::fs::write(tmp.path().join("policy.star"), "# star").unwrap();
let path = discover_star_in(tmp.path()).expect("should succeed");
assert_eq!(path, tmp.path().join("policy.star"));
}
#[test]
fn discovery_errors_on_lone_policy_json() {
let tmp = tempfile::tempdir().unwrap();
std::fs::write(tmp.path().join("policy.json"), "{}").unwrap();
let err = discover_star_in(tmp.path()).expect_err("should error");
let msg = format!("{err}");
assert!(
msg.contains("policy migrate"),
"expected error mentioning `policy migrate`, got: {msg}"
);
}
#[test]
fn default_policy_git_sandbox_uses_worktrees() -> anyhow::Result<()> {
let json_str = compile_default_policy_to_json()?;
let policy: serde_json::Value = serde_json::from_str(&json_str)?;
let git_sandbox = &policy["sandboxes"]["git_full"];
let rules = git_sandbox["rules"].as_array().unwrap();
let pwd_rule = rules
.iter()
.find(|r| r["path"].as_str() == Some("$PWD"))
.expect("should have a $PWD rule");
assert_eq!(
pwd_rule["path_match"].as_str(),
Some("subpath"),
"subpath($PWD) should produce subpath match, got: {pwd_rule}"
);
assert_eq!(
pwd_rule["follow_worktrees"].as_bool(),
Some(true),
"git_full sandbox should have follow_worktrees enabled"
);
Ok(())
}
}