openmoat 0.9.0

Policy checks for AI coding agents: decides allow, defer, ask or deny for every tool call from Claude Code, Codex and Cursor, configures Claude Code's and Codex's own OS sandboxes from the same policy, and keeps a local audit log.
//! The processes that started this one, and whether an agent is among them.
//!
//! An agent can hand a command a terminal (a pty wrapper such as `script`),
//! but the command still descends from the agent's process. Walking parent
//! ids finds it. A process that detaches itself (a double fork, `setsid`, a
//! launchd or systemd job) is reparented to the init process and so escapes
//! this check; `docs/THREAT_MODEL.md` lists that limit.

#[cfg(target_os = "linux")]
mod linux;
#[cfg(target_os = "macos")]
mod macos;
#[cfg(not(any(target_os = "macos", target_os = "linux")))]
mod unsupported;

#[cfg(target_os = "linux")]
use linux as platform;
#[cfg(target_os = "macos")]
use macos as platform;
#[cfg(not(any(target_os = "macos", target_os = "linux")))]
use unsupported as platform;

use std::io;

/// One process: its parent's id and its command line.
#[derive(Debug, Clone, PartialEq, Eq)]
pub(super) struct Process {
    pub ppid: u32,
    pub argv: Vec<String>,
}

/// Executable names of the agent hosts, and `openmoat` itself under either
/// name: `openmoat run` is the parent of the agent it starts.
/// `cn` is the Continue CLI.
const AGENTS: &[&str] = &[
    "claude",
    "codex",
    "cursor-agent",
    "cn",
    "copilot",
    "gemini",
    "openclaw",
    "pi",
    "openmoat",
    "moat",
];

/// The macOS helper processes of the Cursor editor, whose agent runs
/// commands in terminals they start: `Cursor Helper`, `Cursor Helper
/// (Plugin)` and the others, each `<name>.app/Contents/MacOS/<name>`. The
/// editor's own process (`Cursor.app/Contents/MacOS/Cursor`) is not matched.
/// A person's terminal inside the editor descends from a helper too, so the
/// human-only commands are refused there: they need a terminal of one's own.
const CURSOR_HELPER: &str = ".app/contents/macos/cursor helper";

/// Directories the agents install their code under, for an agent that runs
/// as `node <dir>/…/cli.js` or from a versioned file (`claude/versions/2.1.0`).
/// OpenClaw's command is `openclaw.mjs` (its package `bin`); `openclaw/dist`
/// covers a Gateway started from the package's built files. pi runs as
/// `node <…>/pi-coding-agent/dist/bundle/cli.js` (its package `bin`).
const AGENT_DIRS: &[&[&str]] = &[
    &["claude", "versions"],
    &["claude-code"],
    &["cursor-agent"],
    &["openclaw", "dist"],
    &["pi-coding-agent"],
    &["@continuedev", "cli"],
    &["@github", "copilot"],
    &["@google", "gemini-cli"],
];

/// More ancestors than any real process tree has; a longer chain is a loop.
const MAX_DEPTH: usize = 256;

/// The agent among this process's ancestors, by the name it was found under.
pub(super) fn agent_ancestor() -> io::Result<Option<String>> {
    platform::agent_ancestor()
}

/// Walk from `pid` up the parent chain through `lookup` and return the first
/// agent found. The init process (pid 1) is checked too, for a container
/// whose entry point is the agent, but a failure to read it ends the walk
/// instead of failing it: some systems hide it from other users.
fn walk(
    mut pid: u32,
    mut lookup: impl FnMut(u32) -> io::Result<Process>,
) -> io::Result<Option<String>> {
    for _ in 0..MAX_DEPTH {
        if pid == 0 {
            return Ok(None);
        }
        let process = match lookup(pid) {
            Ok(process) => process,
            Err(_) if pid == 1 => return Ok(None),
            Err(error) => return Err(error),
        };
        if let Some(name) = agent_name(&process.argv) {
            return Ok(Some(name));
        }
        if pid == 1 || process.ppid == pid {
            return Ok(None);
        }
        pid = process.ppid;
    }
    Err(io::Error::other(
        "the chain of parent processes does not end",
    ))
}

/// The agent a command line runs, judged by its first two words: the program,
/// and the script an interpreter (`node`) was given; or a Cursor helper,
/// whose program path holds spaces (`ps` splits it into several words), so
/// it is read from the words before the first option.
fn agent_name(argv: &[String]) -> Option<String> {
    let program: Vec<&str> = argv
        .iter()
        .map(String::as_str)
        .take_while(|word| !word.starts_with('-'))
        .collect();
    if program
        .join(" ")
        .to_ascii_lowercase()
        .contains(CURSOR_HELPER)
    {
        return Some("Cursor Helper".to_owned());
    }
    argv.iter().take(2).find_map(|word| {
        let lower = word.trim_start_matches('-').to_ascii_lowercase();
        let segments: Vec<&str> = lower.split(['/', '\\']).filter(|s| !s.is_empty()).collect();
        let base = segments.last()?;
        let stem = [".exe", ".js", ".mjs", ".cjs"]
            .iter()
            .find_map(|ext| base.strip_suffix(ext))
            .unwrap_or(base);
        if let Some(name) = AGENTS.iter().find(|a| **a == stem) {
            return Some((*name).to_owned());
        }
        let dirs = &segments[..segments.len() - 1];
        AGENT_DIRS
            .iter()
            .find(|pattern| dirs.windows(pattern.len()).any(|w| w == **pattern))
            .map(|pattern| pattern[0].to_owned())
    })
}

#[cfg(test)]
mod tests;