openmoat 0.12.0

Policy checks for AI coding agents: decides allow, defer, ask or deny for every tool call from Claude Code, Codex, Cursor, OpenClaw, GitHub Copilot CLI, pi and Gemini CLI, configures Claude Code's, Codex's and Cursor's own OS sandboxes from the same policy, and keeps a local audit log.
//! The launchd user agent plist: a deterministic XML string, so a unit test
//! can lock the exact bytes `moat proxy install` writes.
//!
//! Only the keys the service needs are rendered: `Label`, `ProgramArguments`,
//! `EnvironmentVariables` (just `MOAT_HOME`, a path, never a secret),
//! `RunAtLoad`, `KeepAlive` with `SuccessfulExit=false` so a lock-drift exit
//! does not loop-restart, `ProcessType=Background`, `StandardOutPath` and
//! `StandardErrorPath` so launchd writes the proxy's logs somewhere the user
//! can read.
//!
//! `openmoat-core` must never see this file (`openmoat-core` is pure), so this
//! stays in the CLI next to the other platform code.

use std::fmt::Write as _;
use std::path::Path;

/// The reverse-DNS launchd label the user agent is bootstrapped under.
pub const LABEL: &str = "dev.openmoat.proxy";

/// What goes into the plist. The caller fills everything in: `moat_home` is a
/// path, never a secret, and `binary` is the pinned `moat` the hooks run.
pub struct Spec<'a> {
    pub binary: &'a Path,
    pub moat_home: &'a Path,
    pub stdout_log: &'a Path,
    pub stderr_log: &'a Path,
}

/// Deterministic XML. Keys are in alphabetical order inside each `<dict>`,
/// which launchd accepts; a byte comparison in the unit test is then stable.
///
/// Every path and value is escaped: a `&`, `<` or `>` in `MOAT_HOME` becomes
/// `&amp;`, `&lt;` or `&gt;`.
pub fn render(spec: &Spec<'_>) -> String {
    let mut out = String::new();
    out.push_str("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n");
    out.push_str(
        "<!DOCTYPE plist PUBLIC \"-//Apple//DTD PLIST 1.0//EN\" \
         \"http://www.apple.com/DTDs/PropertyList-1.0.dtd\">\n",
    );
    out.push_str("<plist version=\"1.0\">\n<dict>\n");
    key_string(&mut out, "EnvironmentVariables");
    out.push_str("  <dict>\n");
    out.push_str("    <key>MOAT_HOME</key>\n");
    let _ = writeln!(
        out,
        "    <string>{}</string>",
        xml_escape(&spec.moat_home.to_string_lossy())
    );
    out.push_str("  </dict>\n");
    key_bool(&mut out, "KeepAlive", "dict", "SuccessfulExit", false);
    key_value(&mut out, "Label", "string", LABEL);
    key_array_of_strings(
        &mut out,
        "ProgramArguments",
        &[spec.binary.to_string_lossy().as_ref(), "proxy"],
    );
    key_value(&mut out, "ProcessType", "string", "Background");
    out.push_str("  <key>RunAtLoad</key>\n  <true/>\n");
    key_value(
        &mut out,
        "StandardErrorPath",
        "string",
        &spec.stderr_log.to_string_lossy(),
    );
    key_value(
        &mut out,
        "StandardOutPath",
        "string",
        &spec.stdout_log.to_string_lossy(),
    );
    out.push_str("</dict>\n</plist>\n");
    out
}

fn key_string(out: &mut String, name: &str) {
    let _ = writeln!(out, "  <key>{name}</key>");
}

fn key_value(out: &mut String, name: &str, kind: &str, value: &str) {
    let _ = writeln!(
        out,
        "  <key>{name}</key>\n  <{kind}>{}</{kind}>",
        xml_escape(value)
    );
}

fn key_array_of_strings(out: &mut String, name: &str, values: &[&str]) {
    let _ = writeln!(out, "  <key>{name}</key>\n  <array>");
    for v in values {
        let _ = writeln!(out, "    <string>{}</string>", xml_escape(v));
    }
    out.push_str("  </array>\n");
}

/// One-key dict with a boolean value, as `KeepAlive = {SuccessfulExit=false}`
/// expresses: restart on crash, do not loop-restart a clean exit (the lock
/// drift case).
fn key_bool(out: &mut String, outer: &str, kind: &str, inner: &str, value: bool) {
    let v = if value { "<true/>" } else { "<false/>" };
    let _ = writeln!(
        out,
        "  <key>{outer}</key>\n  <{kind}>\n    <key>{inner}</key>\n    {v}\n  </{kind}>"
    );
}

/// XML character escapes. `MOAT_HOME` is a user path, so this handles the
/// characters a filesystem allows that are also XML special.
fn xml_escape(text: &str) -> String {
    let mut out = String::with_capacity(text.len());
    for ch in text.chars() {
        match ch {
            '&' => out.push_str("&amp;"),
            '<' => out.push_str("&lt;"),
            '>' => out.push_str("&gt;"),
            '"' => out.push_str("&quot;"),
            '\'' => out.push_str("&apos;"),
            _ => out.push(ch),
        }
    }
    out
}

#[cfg(test)]
mod tests {
    use std::path::PathBuf;

    use super::*;

    fn spec() -> (PathBuf, PathBuf, PathBuf, PathBuf) {
        (
            PathBuf::from("/opt/homebrew/bin/moat"),
            PathBuf::from("/Users/u/.moat"),
            PathBuf::from("/Users/u/.moat/logs/proxy.out.log"),
            PathBuf::from("/Users/u/.moat/logs/proxy.err.log"),
        )
    }

    #[test]
    fn plist_is_deterministic_and_names_what_the_proxy_needs() {
        let (bin, home, out_log, err_log) = spec();
        let text = render(&Spec {
            binary: &bin,
            moat_home: &home,
            stdout_log: &out_log,
            stderr_log: &err_log,
        });
        assert!(text.contains("<key>Label</key>"));
        assert!(text.contains("dev.openmoat.proxy"));
        assert!(text.contains("<key>MOAT_HOME</key>"));
        assert!(text.contains("/Users/u/.moat"));
        assert!(text.contains("<key>ProgramArguments</key>"));
        assert!(text.contains("/opt/homebrew/bin/moat"));
        assert!(text.contains("<string>proxy</string>"));
        assert!(text.contains("<key>RunAtLoad</key>"));
        assert!(text.contains("<true/>"));
        assert!(text.contains("<key>SuccessfulExit</key>"));
        assert!(text.contains("<false/>"));
        assert!(text.contains("StandardOutPath"));
        assert!(text.contains("StandardErrorPath"));
        // Byte-for-byte stability: two renders match.
        let again = render(&Spec {
            binary: &bin,
            moat_home: &home,
            stdout_log: &out_log,
            stderr_log: &err_log,
        });
        assert_eq!(text, again);
    }

    #[test]
    fn xml_special_characters_in_paths_are_escaped() {
        let home = PathBuf::from("/Users/a&b/<moat>");
        let text = render(&Spec {
            binary: &PathBuf::from("/bin/moat"),
            moat_home: &home,
            stdout_log: &PathBuf::from("/tmp/out"),
            stderr_log: &PathBuf::from("/tmp/err"),
        });
        assert!(text.contains("/Users/a&amp;b/&lt;moat&gt;"));
        assert!(!text.contains("/Users/a&b/<moat>"));
    }
}