assay-cli 6.5.0

Policy-as-code gate for MCP agent tool calls, with verifiable evidence and Linux kernel enforcement.
use super::suggest::PolicySuggestion;
use assay_common::atomic_write::write_new;
use std::path::{Path, PathBuf};

/// Write policy suggestion to YAML string (deterministic).
pub fn write_yaml(s: &PolicySuggestion) -> anyhow::Result<String> {
    require_empty_extends(s)?;

    let mut out = String::new();
    out.push_str("# Generated by `assay sandbox --profile`\n");
    out.push_str(&format!("api_version: {}\n", s.api_version));

    out.push_str("extends: []\n");

    out.push_str("fs:\n");
    out.push_str("  allow:\n");
    for p in &s.fs.allow {
        out.push_str(&format!("    - \"{}\"\n", escape(p)));
    }
    if s.fs.deny.is_empty() {
        out.push_str("  deny: []\n");
    } else {
        out.push_str("  deny:\n");
        for p in &s.fs.deny {
            out.push_str(&format!("    - \"{}\"\n", escape(p)));
        }
    }

    out.push_str("net:\n");
    if s.net.allow.is_empty() {
        out.push_str("  allow: []\n");
    } else {
        out.push_str("  allow:\n");
        for p in &s.net.allow {
            out.push_str(&format!("    - \"{}\"\n", escape(p)));
        }
    }
    out.push_str("  deny:\n");
    for p in &s.net.deny {
        out.push_str(&format!("    - \"{}\"\n", escape(p)));
    }

    out.push_str("env:\n");
    out.push_str("  allow:\n");
    for k in &s.env.allow {
        out.push_str(&format!("    - \"{}\"\n", escape(k)));
    }

    out.push_str("processes:\n");
    out.push_str("  allow:\n");
    for x in &s.processes.allow {
        out.push_str(&format!("    - \"{}\"\n", escape(x)));
    }

    out.push_str("meta:\n");
    out.push_str("  counters:\n");
    for (k, v) in &s.meta.counters {
        out.push_str(&format!("    \"{}\": {}\n", escape(k), v));
    }

    if !s.meta.notes.is_empty() {
        out.push_str("  notes:\n");
        for n in &s.meta.notes {
            out.push_str(&format!("    - \"{}\"\n", escape(n)));
        }
    }

    Ok(out)
}

fn escape(s: &str) -> String {
    s.replace('\\', "\\\\").replace('"', "\\\"")
}

/// Write policy suggestion to JSON string (deterministic).
pub fn write_json(s: &PolicySuggestion) -> anyhow::Result<String> {
    require_empty_extends(s)?;

    // We use serde_json for SOTA reliability
    Ok(serde_json::to_string_pretty(s)?)
}

fn require_empty_extends(s: &PolicySuggestion) -> anyhow::Result<()> {
    if !s.extends.is_empty() {
        anyhow::bail!("generated sandbox policies do not support non-empty extends");
    }
    Ok(())
}

/// Save content to disk atomically and safely.
pub fn save_atomic(path: &Path, content: &str) -> anyhow::Result<()> {
    if path.exists() {
        let meta = std::fs::symlink_metadata(path)?;
        if meta.file_type().is_symlink() {
            anyhow::bail!("Refusing to write to symlink: {}", path.display());
        }
        if meta.is_file() {
            // #3027: callers write at process exit and readers come later, so delete-then-create is acceptable here.
            std::fs::remove_file(path)?;
        }
    }

    let parent = usable_parent(path);
    let file_name = path
        .file_name()
        .and_then(|name| name.to_str())
        .ok_or_else(|| anyhow::anyhow!("{} must end with a UTF-8 file name", path.display()))?;
    write_new(&parent, file_name, content.as_bytes())
        .map(|_| ())
        .map_err(|error| {
            anyhow::anyhow!("failed to atomically write {}: {error}", path.display())
        })?;

    Ok(())
}

fn usable_parent(path: &Path) -> PathBuf {
    match path.parent() {
        Some(parent) if !parent.as_os_str().is_empty() => parent.to_path_buf(),
        _ => PathBuf::from("."),
    }
}

#[cfg(test)]
mod tests {
    use super::{save_atomic, write_json, write_yaml};
    use crate::profile::suggest::PolicySuggestion;
    use std::fs;

    #[cfg(unix)]
    use std::os::unix::fs::symlink;

    #[test]
    fn generated_formats_refuse_nonempty_extends() {
        let mut suggestion = PolicySuggestion::default();
        suggestion.extends.push("builtin-default".to_string());

        let yaml_error = write_yaml(&suggestion).expect_err("YAML must reject composition");
        let json_error = write_json(&suggestion).expect_err("JSON must reject composition");

        assert_eq!(
            yaml_error.to_string(),
            "generated sandbox policies do not support non-empty extends"
        );
        assert_eq!(
            json_error.to_string(),
            "generated sandbox policies do not support non-empty extends"
        );
    }

    #[test]
    fn save_atomic_overwrites_existing_regular_file() {
        let dir = tempfile::tempdir().expect("tempdir");
        let output = dir.path().join("profile.yaml");
        fs::write(&output, "old").expect("seed old");

        save_atomic(&output, "new content\n").expect("overwrite succeeds");

        assert_eq!(
            fs::read_to_string(&output).expect("read output"),
            "new content\n"
        );
    }

    #[cfg(unix)]
    #[test]
    fn save_atomic_refuses_symlink_targets() {
        let dir = tempfile::tempdir().expect("tempdir");
        let outside = tempfile::tempdir().expect("outside");
        let outside_file = outside.path().join("outside.txt");
        fs::write(&outside_file, "outside").expect("seed outside");

        let output = dir.path().join("profile.yaml");
        symlink(&outside_file, &output).expect("create symlink");

        let error = save_atomic(&output, "new content\n").expect_err("symlink must be rejected");
        assert!(
            error.to_string().contains("Refusing to write to symlink"),
            "unexpected error: {error:#}"
        );
        assert_eq!(
            fs::read_to_string(&outside_file).expect("read outside"),
            "outside"
        );
    }
}