car-server-core 0.25.0

Transport-neutral library for the CAR daemon JSON-RPC dispatcher (used by car-server and tokhn-daemon)
//! The coder's inspector chain — policy hardening for host tool execution.
//!
//! Every tool call the coder makes (model-proposed AND contract checks) passes
//! through this chain before dispatch; first Deny wins. The checks are
//! deliberately conservative token/substring matchers, not a shell parser —
//! they block the unambiguous footguns. This is hardening, not a sandbox; the
//! real gates are contract confirmation and merge approval (see `coder::`
//! module docs).

use std::path::{Component, Path, PathBuf};

use car_policy::{InspectionResult, Inspector, InspectorChain};
use serde_json::Value;

/// Build the standard coder chain for a worktree.
pub fn coder_inspector_chain(worktree: &Path) -> InspectorChain {
    InspectorChain::new()
        .with(Box::new(DenyGitRemoteMutation))
        .with(Box::new(DenyHistoryRewrite))
        .with(Box::new(DenyPrivilegeEscalation))
        .with(Box::new(DenyCredentialAccess))
        .with(Box::new(DenyDestructiveOutsideWorktree {
            worktree: worktree.to_path_buf(),
        }))
        .with(Box::new(DenyPathEscape {
            worktree: worktree.to_path_buf(),
        }))
}

/// Lexically resolve `candidate` against `root` and decide whether it stays
/// under `root`. Purely lexical (`..` popping) — symlinks inside the worktree
/// are out of scope here, consistent with the hardening-not-sandbox stance.
pub(crate) fn stays_under(root: &Path, candidate: &str) -> bool {
    let p = Path::new(candidate);
    let joined = if p.is_absolute() {
        p.to_path_buf()
    } else {
        root.join(p)
    };
    let mut stack: Vec<Component> = Vec::new();
    for c in joined.components() {
        match c {
            Component::CurDir => {}
            Component::ParentDir => {
                if stack.pop().is_none() {
                    return false;
                }
            }
            other => stack.push(other),
        }
    }
    let normalized: PathBuf = stack.iter().collect();
    normalized.starts_with(root)
}

/// Split a shell command into segments at unquoted-ish separators and each
/// segment into whitespace tokens. Naive on purpose (no quote handling): a
/// quoted `";"` may split a segment too eagerly, which only ever makes the
/// chain MORE likely to deny — never less.
fn segments(command: &str) -> Vec<Vec<String>> {
    command
        .replace("&&", "\n")
        .replace("||", "\n")
        .replace(['', ';', '|'], "\n")
        .lines()
        .map(|seg| {
            seg.split_whitespace()
                .map(|t| t.trim_matches(|c| c == '"' || c == '\'').to_string())
                .filter(|t| !t.is_empty())
                .collect::<Vec<_>>()
        })
        .filter(|toks: &Vec<String>| !toks.is_empty())
        .collect()
}

/// First non-env-assignment token of a segment (`FOO=bar cmd …` → `cmd`).
fn verb(tokens: &[String]) -> Option<&str> {
    tokens
        .iter()
        .map(String::as_str)
        .find(|t| !t.contains('='))
}

fn shell_command(tool: &str, params: &Value) -> Option<String> {
    if tool != "shell" {
        return None;
    }
    params
        .get("command")
        .and_then(Value::as_str)
        .map(str::to_string)
}

/// `git push`, `git remote add/set-url`, `git fetch --force` — the coder's
/// output leaves the machine only via the approved merge branch.
struct DenyGitRemoteMutation;

impl Inspector for DenyGitRemoteMutation {
    fn name(&self) -> &'static str {
        "coder.deny_git_remote_mutation"
    }

    fn inspect(&self, tool: &str, params: &Value) -> InspectionResult {
        let Some(cmd) = shell_command(tool, params) else {
            return InspectionResult::Allow;
        };
        for seg in segments(&cmd) {
            let is_git = verb(&seg) == Some("git");
            if !is_git {
                continue;
            }
            if seg.iter().any(|t| t == "push") {
                return InspectionResult::Deny(
                    "git push is not allowed from a coder session — results are delivered \
                     via the approved local branch"
                        .into(),
                );
            }
            if seg.iter().any(|t| t == "remote")
                && seg.iter().any(|t| t == "add" || t == "set-url" || t == "remove")
            {
                return InspectionResult::Deny("mutating git remotes is not allowed".into());
            }
        }
        InspectionResult::Allow
    }
}

/// `git rebase/reset --hard/filter-branch` — the worktree HEAD is detached;
/// history rewrite is never needed and only ever destroys evidence.
struct DenyHistoryRewrite;

impl Inspector for DenyHistoryRewrite {
    fn name(&self) -> &'static str {
        "coder.deny_history_rewrite"
    }

    fn inspect(&self, tool: &str, params: &Value) -> InspectionResult {
        let Some(cmd) = shell_command(tool, params) else {
            return InspectionResult::Allow;
        };
        for seg in segments(&cmd) {
            if verb(&seg) != Some("git") {
                continue;
            }
            if seg.iter().any(|t| t == "rebase" || t == "filter-branch") {
                return InspectionResult::Deny("git history rewrite is not allowed".into());
            }
            if seg.iter().any(|t| t == "reset") && seg.iter().any(|t| t == "--hard") {
                return InspectionResult::Deny("git reset --hard is not allowed".into());
            }
            if seg.iter().any(|t| t == "worktree") && seg.iter().any(|t| t == "remove") {
                return InspectionResult::Deny(
                    "removing worktrees is the runtime's job, not the agent's".into(),
                );
            }
        }
        InspectionResult::Allow
    }
}

/// `sudo`/`doas`/service managers — the coder runs with user privileges, full
/// stop.
struct DenyPrivilegeEscalation;

const PRIVILEGE_VERBS: [&str; 5] = ["sudo", "doas", "su", "launchctl", "systemctl"];

impl Inspector for DenyPrivilegeEscalation {
    fn name(&self) -> &'static str {
        "coder.deny_privilege_escalation"
    }

    fn inspect(&self, tool: &str, params: &Value) -> InspectionResult {
        let Some(cmd) = shell_command(tool, params) else {
            return InspectionResult::Allow;
        };
        for seg in segments(&cmd) {
            if let Some(v) = verb(&seg) {
                if PRIVILEGE_VERBS.contains(&v) {
                    return InspectionResult::Deny(format!(
                        "'{v}' is not allowed in a coder session"
                    ));
                }
            }
        }
        InspectionResult::Allow
    }
}

/// Reads of key stores and credential directories, via shell or file tools.
struct DenyCredentialAccess;

const CREDENTIAL_PATH_MARKERS: [&str; 6] = [
    "/.ssh", "/.aws", "/.gnupg", "/.kube", "/.car/secrets", "/.netrc",
];

impl Inspector for DenyCredentialAccess {
    fn name(&self) -> &'static str {
        "coder.deny_credential_access"
    }

    fn inspect(&self, tool: &str, params: &Value) -> InspectionResult {
        let haystacks: Vec<String> = if let Some(cmd) = shell_command(tool, params) {
            if cmd.contains("find-generic-password") || cmd.contains("find-internet-password") {
                return InspectionResult::Deny("keychain access is not allowed".into());
            }
            vec![cmd]
        } else if matches!(tool, "read_file" | "write_file" | "edit_file" | "grep_files") {
            params
                .get("path")
                .and_then(Value::as_str)
                .map(|p| vec![p.to_string()])
                .unwrap_or_default()
        } else {
            return InspectionResult::Allow;
        };
        for hay in &haystacks {
            // Normalize "~/.ssh" and "$HOME/.ssh" spellings into the same
            // marker space as absolute paths.
            let hay = hay.replace("~/", "/HOME/.").replace("$HOME/", "/HOME/.");
            let hay = hay.replace("/HOME/..", "/."); // "~/.ssh" → "/.ssh"
            for marker in CREDENTIAL_PATH_MARKERS {
                if hay.contains(marker) {
                    return InspectionResult::Deny(format!(
                        "access to credential path matching '{marker}' is not allowed"
                    ));
                }
            }
        }
        InspectionResult::Allow
    }
}

/// Destructive shell verbs aimed outside the worktree (absolute paths, `..`
/// escapes, `~`).
struct DenyDestructiveOutsideWorktree {
    worktree: PathBuf,
}

const DESTRUCTIVE_VERBS: [&str; 8] = ["rm", "rmdir", "mv", "cp", "chmod", "chown", "truncate", "dd"];

impl Inspector for DenyDestructiveOutsideWorktree {
    fn name(&self) -> &'static str {
        "coder.deny_destructive_outside_worktree"
    }

    fn inspect(&self, tool: &str, params: &Value) -> InspectionResult {
        let Some(cmd) = shell_command(tool, params) else {
            return InspectionResult::Allow;
        };
        for seg in segments(&cmd) {
            let Some(v) = verb(&seg) else { continue };
            if !DESTRUCTIVE_VERBS.contains(&v) {
                continue;
            }
            for arg in seg.iter().skip(1).filter(|a| !a.starts_with('-')) {
                if arg.starts_with('~') {
                    return InspectionResult::Deny(format!(
                        "'{v}' on a home-relative path ('{arg}') is not allowed"
                    ));
                }
                if (arg.starts_with('/') || arg.contains("..")) && !stays_under(&self.worktree, arg)
                {
                    return InspectionResult::Deny(format!(
                        "'{v}' outside the worktree ('{arg}') is not allowed"
                    ));
                }
            }
        }
        InspectionResult::Allow
    }
}

/// File-tool writes whose path resolves outside the worktree. (The executor
/// also clamps; defense in depth so a future executor change can't silently
/// drop the rule.)
struct DenyPathEscape {
    worktree: PathBuf,
}

impl Inspector for DenyPathEscape {
    fn name(&self) -> &'static str {
        "coder.deny_path_escape"
    }

    fn inspect(&self, tool: &str, params: &Value) -> InspectionResult {
        if !matches!(tool, "write_file" | "edit_file") {
            return InspectionResult::Allow;
        }
        let Some(path) = params.get("path").and_then(Value::as_str) else {
            return InspectionResult::Allow; // missing param fails in the tool itself
        };
        if stays_under(&self.worktree, path) {
            InspectionResult::Allow
        } else {
            InspectionResult::Deny(format!(
                "write to '{path}' resolves outside the worktree"
            ))
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use serde_json::json;

    fn chain() -> InspectorChain {
        coder_inspector_chain(Path::new("/wt"))
    }

    fn denied(tool: &str, params: Value) -> bool {
        chain().check(tool, &params).is_some()
    }

    fn sh(cmd: &str) -> Value {
        json!({ "command": cmd })
    }

    #[test]
    fn git_push_and_remote_mutation_denied() {
        assert!(denied("shell", sh("git push origin main")));
        assert!(denied("shell", sh("cargo test && git push --force")));
        assert!(denied("shell", sh("git remote add evil https://x")));
        assert!(denied("shell", sh("git remote set-url origin https://x")));
        // Reading remotes and committing are fine.
        assert!(!denied("shell", sh("git remote -v")));
        assert!(!denied("shell", sh("git commit -m 'x'")));
        assert!(!denied("shell", sh("git status && git diff")));
        // "push" in a non-git segment is fine.
        assert!(!denied("shell", sh("echo push")));
    }

    #[test]
    fn history_rewrite_denied() {
        assert!(denied("shell", sh("git rebase -i HEAD~3")));
        assert!(denied("shell", sh("git reset --hard HEAD~1")));
        assert!(denied("shell", sh("git filter-branch --all")));
        assert!(denied("shell", sh("git worktree remove /wt")));
        assert!(!denied("shell", sh("git reset HEAD file.txt"))); // soft reset ok
    }

    #[test]
    fn privilege_escalation_denied() {
        assert!(denied("shell", sh("sudo rm -rf /tmp/x")));
        assert!(denied("shell", sh("doas pkg_add x")));
        assert!(denied("shell", sh("FOO=1 sudo make install")));
        assert!(denied("shell", sh("launchctl unload foo")));
        assert!(!denied("shell", sh("echo sudo"))); // verb position only
    }

    #[test]
    fn credential_access_denied_for_shell_and_file_tools() {
        assert!(denied("shell", sh("cat ~/.ssh/id_rsa")));
        assert!(denied("shell", sh("cat $HOME/.aws/credentials")));
        assert!(denied("shell", sh("security find-generic-password -s x")));
        assert!(denied("read_file", json!({"path": "/Users/u/.ssh/id_rsa"})));
        assert!(denied("read_file", json!({"path": "~/.netrc"})));
        assert!(!denied("read_file", json!({"path": "src/main.rs"})));
        // ".ssh" as a repo-relative dir name is unfortunate but stays denied —
        // conservative beats clever here.
    }

    #[test]
    fn destructive_ops_scoped_to_worktree() {
        assert!(denied("shell", sh("rm -rf /etc")));
        assert!(denied("shell", sh("rm -rf ../other-checkout")));
        assert!(denied("shell", sh("mv target ~/elsewhere")));
        assert!(denied("shell", sh("chmod 777 /usr/local/bin/x")));
        // Inside the worktree: fine, relative or absolute.
        assert!(!denied("shell", sh("rm -rf target/debug")));
        assert!(!denied("shell", sh("rm /wt/scratch.txt")));
        assert!(!denied("shell", sh("cp a.txt b.txt")));
    }

    #[test]
    fn write_path_escape_denied_but_reads_allowed() {
        assert!(denied("write_file", json!({"path": "/etc/hosts", "content": "x"})));
        assert!(denied("edit_file", json!({"path": "../outside.txt"})));
        assert!(!denied("write_file", json!({"path": "src/new.rs", "content": "x"})));
        assert!(!denied("write_file", json!({"path": "/wt/src/new.rs", "content": "x"})));
        // Reads outside the worktree are allowed (context gathering) unless
        // they hit credential markers.
        assert!(!denied("read_file", json!({"path": "/usr/include/stdio.h"})));
    }

    #[test]
    fn stays_under_is_lexical_and_strict() {
        let root = Path::new("/wt");
        assert!(stays_under(root, "src/x.rs"));
        assert!(stays_under(root, "a/../b.txt"));
        assert!(stays_under(root, "/wt/deep/file"));
        assert!(!stays_under(root, "../escape"));
        assert!(!stays_under(root, "a/../../escape"));
        assert!(!stays_under(root, "/etc/passwd"));
        assert!(!stays_under(root, "/wtevil/file")); // prefix, not component, match
    }
}