clash 0.7.1

Command Line Agent Safety Harness — permission policies for coding agents
//! Policy file discovery and default policy compilation.

use std::path::PathBuf;

use anyhow::{Context, Result};
use dirs::home_dir;

// PolicyLevel lives in clash-policy to break the clash-lsp circular dep.
// Re-export it here so all existing `clash::settings::PolicyLevel` paths continue to work.
pub use clash_policy::PolicyLevel;

/// Default policy source template embedded at compile time.
/// Contains `{preset}` placeholders for the sandbox preset name.
pub const DEFAULT_POLICY_TEMPLATE: &str = include_str!("../default_policy.star");

/// Available sandbox presets for `clash init`.
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",
    },
];

/// A sandbox preset that can be selected during `clash init`.
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
    }
}

/// Compile the default policy with a preset name (legacy — presets are now
/// baked into the template). Ignores the preset argument.
pub fn compile_default_policy_to_json_with_preset(_preset: &str) -> Result<String> {
    compile_default_policy_to_json()
}

/// Compile the embedded default policy template 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")
}

/// Returns the clash settings directory (`~/.clash/`).
///
/// Respects `CLASH_HOME` env var for override, otherwise defaults to `$HOME/.clash`.
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"))
}

/// Returns the user-level policy file path.
///
/// Respects `CLASH_POLICY_FILE` env var for override. Returns the path to
/// `policy.star` (the only supported format). If a legacy `policy.json` is
/// present without a sibling `policy.star`, returns an error directing the
/// user to `clash policy migrate`.
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)
}

/// Returns the project-level policy file path.
///
/// Returns the path to `policy.star`. If only a legacy `policy.json` exists,
/// returns an error directing the user to `clash policy migrate`.
pub fn project_policy_file(project_root: &std::path::Path) -> Result<PathBuf> {
    let dir = project_root.join(".clash");
    discover_star_in(&dir)
}

/// Returns the session-level policy file path for the given session ID.
pub fn session_policy_file(session_id: &str) -> PathBuf {
    crate::session_dir::SessionDir::new(session_id).policy()
}

/// Return `policy.star` in `dir` (whether or not it exists). If `policy.star`
/// is absent but a legacy `policy.json` is present, return an error directing
/// the user to `clash policy migrate`.
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));
    }
    // Neither exists — return the `.star` path so callers can report
    // "not found" against the canonical name.
    Ok(star_path)
}

/// Shorten a path by replacing the home directory prefix with `~`.
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()
}

/// Find the nearest ancestor directory containing the given name.
///
/// If `stop_at` is provided, stops searching before checking that directory.
/// This prevents `~/.clash/` from being mistaken for a project root.
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;
        }
    }
}

/// Evaluate a `.star` policy file and return the compiled JSON source.
///
/// Delegates to [`policy_loader::evaluate_star_policy`]. This wrapper is kept
/// for backward compatibility with callers that import from `settings`.
pub fn evaluate_star_policy(path: &std::path::Path) -> Result<String> {
    crate::policy_loader::evaluate_star_policy(path).map(|o| o.json)
}

/// Evaluate a policy file and return the compiled JSON source.
///
/// Only `.star` files are accepted. Legacy `.json` files are rejected with a
/// friendly error directing the user to `clash policy migrate`.
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)
    }
}

/// Extract the `notifications:` section from a YAML string.
///
/// Returns the parsed config (falling back to defaults on error) and an
/// optional warning message if parsing failed.
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),
            )
        }
    }
}

/// Extract the `audit:` section from a YAML string.
///
/// Returns the parsed config, falling back to defaults on error.
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)?;
        // The "git_full" sandbox should have a $PWD subpath rule with follow_worktrees
        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(())
    }
}