moor 0.9.4

Build software with an AI agent inside a locked-down container
use crate::{audit, manifest::Manifest, paths, proc};
use anyhow::Result;

/// True if the invoked command looks like a `git push` — the one channel
/// through which code actually leaves the sandbox for real (as opposed
/// to routine egress like `git fetch`/`clone`/package installs). Tagged
/// distinctly in the audit chain so it stands out from routine execs.
/// Since ADR-0009, the sandbox carries no GitHub credential, so a push
/// run this way (an operator's own `moor run <p> -- git push`, not
/// `moor ship`) will fail to authenticate — still worth flagging, since
/// an operator could point it at a non-GitHub remote that doesn't need
/// one. Best-effort by nature: it only sees commands run via `moor run`,
/// not ones typed inside an interactive `moor shell` session — see
/// docs/THREAT-MODEL.md for why a git hook baked into the image isn't a
/// stronger alternative (it runs inside the untrusted sandbox and the
/// agent can simply reconfigure or bypass it).
fn looks_like_git_push(cmd: &[String]) -> bool {
    git_subcommand(cmd).is_some_and(|i| cmd[i] == "push")
}

/// Where git's subcommand sits in `cmd`: the first argument that isn't one
/// of git's own global options (or the value such an option takes). So
/// `git -c credential.helper=... push` is a push, and `git stash push` is a
/// stash, not a push: only the subcommand says what git does.
fn git_subcommand(cmd: &[String]) -> Option<usize> {
    const TAKES_VALUE: &[&str] = &[
        "-C",
        "-c",
        "--git-dir",
        "--work-tree",
        "--namespace",
        "--config-env",
        "--super-prefix",
    ];
    if cmd.first().map(String::as_str) != Some("git") {
        return None;
    }
    let mut i = 1;
    while i < cmd.len() {
        let a = cmd[i].as_str();
        if TAKES_VALUE.contains(&a) {
            i += 2;
        } else if a.starts_with('-') {
            i += 1;
        } else {
            return Some(i);
        }
    }
    None
}

pub fn run(name: &str, cmd: &[String]) -> Result<()> {
    if cmd.is_empty() {
        anyhow::bail!("usage: moor run <project> -- <command...>");
    }
    let m = Manifest::load(&paths::manifest_path(name)?)?;
    // So audit::redact below can scrub a secret's value even when it was
    // only ever set via Keychain, never exported into this shell.
    crate::secrets::resolve_into_env(name, &m.secrets);
    let container = m.sandbox_container();

    // What a push sends is resolved before it runs: afterwards the ref may
    // have moved, and the question is what left, not what is there now.
    let pushed = looks_like_git_push(cmd).then(|| {
        let (dir, git_ref) = push_target(cmd);
        let commit = resolve_commit(&container, &dir, &git_ref);
        push_data(&git_ref, commit.as_deref())
    });

    let mut args: Vec<&str> = vec!["exec", &container];
    args.extend(cmd.iter().map(|s| s.as_str()));

    let status = proc::run_inherit("docker", &args)?;
    match pushed {
        Some(data) => audit::log_exec_with(name, &m, "push", cmd, status.code(), data)?,
        None => audit::log_exec(name, &m, "exec", cmd, status.code())?,
    }
    proc::require_success(&format!("`{}` in {container}", cmd.join(" ")), status)?;
    Ok(())
}

/// The repository a `git push` runs in (`-C <dir>`, else `/workspace`) and
/// the ref it sends: the source side of an explicit refspec, else `HEAD`.
fn push_target(cmd: &[String]) -> (String, String) {
    let sub = git_subcommand(cmd).unwrap_or(cmd.len());
    let mut dir = "/workspace".to_string();
    let mut globals = cmd[1..sub].iter();
    while let Some(a) = globals.next() {
        if a == "-C" {
            if let Some(d) = globals.next() {
                dir = d.clone();
            }
        }
    }
    // After `push`: options, then [<remote> [<refspec>...]].
    let args = cmd.iter().skip(sub + 1);
    let positional: Vec<&String> = args.filter(|a| !a.starts_with('-')).collect();
    let git_ref = positional
        .get(1)
        .map(|spec| {
            spec.trim_start_matches('+')
                .split(':')
                .next()
                .unwrap_or("")
                .to_string()
        })
        .filter(|r| !r.is_empty())
        .unwrap_or_else(|| "HEAD".to_string());
    (dir, git_ref)
}

fn resolve_commit(container: &str, dir: &str, git_ref: &str) -> Option<String> {
    let rev = format!("{git_ref}^{{commit}}");
    let (status, out) = proc::run_capture(
        "docker",
        &[
            "exec",
            container,
            "git",
            "-C",
            dir,
            "rev-parse",
            "--verify",
            "--quiet",
            &rev,
        ],
    )
    .ok()?;
    let sha = out.trim();
    (status.success() && sha.len() == 40 && sha.bytes().all(|b| b.is_ascii_hexdigit()))
        .then(|| sha.to_string())
}

/// The fields a `push` entry adds to an exec entry. `commit` is null when
/// the ref did not resolve — recorded as unknown rather than omitted.
/// Shared with `ship_cmd`, whose host-side push (ADR-0009) records with
/// the same shape.
pub(crate) fn push_data(git_ref: &str, commit: Option<&str>) -> serde_json::Value {
    serde_json::json!({ "ref": git_ref, "commit": commit })
}

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

    #[test]
    fn detects_plain_git_push() {
        assert!(looks_like_git_push(&["git".into(), "push".into()]));
        assert!(looks_like_git_push(&[
            "git".into(),
            "push".into(),
            "origin".into(),
            "main".into()
        ]));
    }

    #[test]
    fn does_not_flag_other_git_commands() {
        assert!(!looks_like_git_push(&[
            "git".into(),
            "clone".into(),
            "x".into()
        ]));
        assert!(!looks_like_git_push(&["git".into(), "fetch".into()]));
        assert!(!looks_like_git_push(&["git".into(), "status".into()]));
    }

    #[test]
    fn a_push_after_global_options_is_a_push() {
        let argv = |s: &str| s.split_whitespace().map(String::from).collect::<Vec<_>>();
        assert!(looks_like_git_push(&argv(
            "git -C /workspace push origin main"
        )));
        assert!(looks_like_git_push(&argv(
            "git -c credential.helper=!f push -u origin feature"
        )));
        assert_eq!(
            push_target(&argv("git -c x=y -C /repo push origin feature")),
            ("/repo".to_string(), "feature".to_string())
        );
    }

    #[test]
    fn a_push_word_after_another_subcommand_is_not_a_push() {
        let argv = |s: &str| s.split_whitespace().map(String::from).collect::<Vec<_>>();
        for cmd in [
            "git stash push -m wip -- src/a.rs",
            "git commit -m push",
            "git log --grep push",
            "git -C push status",
        ] {
            assert!(!looks_like_git_push(&argv(cmd)), "{cmd}");
        }
    }

    #[test]
    fn does_not_flag_non_git_commands() {
        assert!(!looks_like_git_push(&["keel".into(), "run".into()]));
        assert!(!looks_like_git_push(&[
            "npm".into(),
            "run".into(),
            "push".into()
        ]));
    }

    #[test]
    fn empty_command_is_not_a_push() {
        assert!(!looks_like_git_push(&[]));
    }

    fn argv(s: &str) -> Vec<String> {
        s.split_whitespace().map(String::from).collect()
    }

    #[test]
    fn push_entry_names_ref_and_commit() {
        assert_eq!(
            push_target(&argv("git push")),
            ("/workspace".into(), "HEAD".into())
        );
        assert_eq!(
            push_target(&argv("git push origin")),
            ("/workspace".into(), "HEAD".into())
        );
        assert_eq!(
            push_target(&argv("git push -u origin feature")),
            ("/workspace".into(), "feature".into())
        );
        assert_eq!(
            push_target(&argv(
                "git -C /workspace/app push --force origin +main:release"
            )),
            ("/workspace/app".into(), "main".into())
        );

        let sha = "0123456789abcdef0123456789abcdef01234567";
        let data = push_data("main", Some(sha));
        assert_eq!(data["ref"], "main");
        assert_eq!(data["commit"], sha);
        assert!(push_data("main", None)["commit"].is_null());
    }
}