shepherd-cli 6.7.2

The canonical shepherd command-line interface over the per-project registry, run artifacts, and sprint pipeline.
//! Copy the operator's existing Shepherd installation and hook decisions.
//! This does not grant hook trust or import unrelated plugins or host settings.

use std::{fs, path::Path};

use super::{AuthSnapshot, CliError, publish_private_file, read_nofollow, set_private_directory};

const MAX_FILE_BYTES: usize = 4 * 1024 * 1024;
const MAX_TREE_BYTES: usize = 32 * 1024 * 1024;
const MAX_TREE_FILES: usize = 512;

pub(super) fn snapshot_runtime(
    auth: &AuthSnapshot,
    home: &Path,
    project_root: &Path,
) -> Result<(), CliError> {
    let auth_path = Path::new(&auth.source);
    if auth_path.file_name().and_then(|name| name.to_str()) != Some("auth.json") {
        return Err(CliError::message(
            "Codex runtime snapshot requires its existing auth.json",
        ));
    }
    let source = auth_path
        .parent()
        .ok_or_else(|| CliError::message("Codex auth has no parent"))?;
    reject_symlink_components(source)?;
    let bytes = read_nofollow(&source.join("config.toml"), MAX_FILE_BYTES)?;
    let config = selected_config(&bytes, project_root)?;
    // Native created this private home. Resolve platform temp aliases such as
    // macOS /var before checking the snapshot's own directory components.
    let home = fs::canonicalize(home).map_err(|error| {
        CliError::message(format!("cannot resolve isolated Codex home: {error}"))
    })?;
    let destination = home.join(".config/codex");
    private_directory(&destination)?;
    let plugin = Path::new("plugins/cache/shepherd/shepherd");
    let mut count = 0;
    let mut total = 0;
    copy_tree(
        &source.join(plugin),
        &destination.join(plugin),
        &mut count,
        &mut total,
        0,
    )?;
    publish_identical(&destination.join("config.toml"), config.as_bytes())
}

fn selected_config(bytes: &[u8], project_root: &Path) -> Result<String, CliError> {
    let source: toml::Table = std::str::from_utf8(bytes)
        .map_err(|_| CliError::message("Codex configuration is not UTF-8"))?
        .parse()
        .map_err(|_| CliError::message("Codex configuration is invalid TOML"))?;
    let mut selected = toml::Table::new();
    let plugin = source
        .get("plugins")
        .and_then(toml::Value::as_table)
        .and_then(|plugins| plugins.get("shepherd@shepherd"))
        .filter(|plugin| plugin.get("enabled").and_then(toml::Value::as_bool) == Some(true))
        .ok_or_else(|| {
            CliError::message("Codex Shepherd plugin is not enabled in the authenticated runtime")
        })?;
    let mut plugins = toml::Table::new();
    plugins.insert("shepherd@shepherd".into(), plugin.clone());
    selected.insert("plugins".into(), plugins.into());
    if let Some(marketplace) = source
        .get("marketplaces")
        .and_then(|table| table.get("shepherd"))
    {
        let mut marketplaces = toml::Table::new();
        marketplaces.insert("shepherd".into(), marketplace.clone());
        selected.insert("marketplaces".into(), marketplaces.into());
    }
    if let Some(project) = project_root.to_str().and_then(|path| {
        source
            .get("projects")
            .and_then(|projects| projects.get(path))
            .map(|value| (path, value))
    }) {
        let mut projects = toml::Table::new();
        projects.insert(project.0.into(), project.1.clone());
        selected.insert("projects".into(), projects.into());
    }
    if let Some(states) = source
        .get("hooks")
        .and_then(|hooks| hooks.get("state"))
        .and_then(toml::Value::as_table)
    {
        let states: toml::Table = states
            .iter()
            .filter(|(key, _)| key.starts_with("shepherd@shepherd:"))
            .map(|(key, value)| (key.clone(), value.clone()))
            .collect();
        let mut hooks = toml::Table::new();
        hooks.insert("state".into(), states.into());
        selected.insert("hooks".into(), hooks.into());
    }
    toml::to_string(&selected)
        .map_err(|_| CliError::message("cannot serialize Codex runtime snapshot"))
}

fn reject_symlink_components(path: &Path) -> Result<(), CliError> {
    for ancestor in path.ancestors() {
        let metadata = fs::symlink_metadata(ancestor).map_err(|error| {
            CliError::message(format!("cannot inspect Codex runtime path: {error}"))
        })?;
        if metadata.file_type().is_symlink() {
            return Err(CliError::message(
                "Codex runtime snapshot refuses symbolic links",
            ));
        }
    }
    Ok(())
}

fn private_directory(path: &Path) -> Result<(), CliError> {
    fs::create_dir_all(path).map_err(|error| {
        CliError::message(format!("cannot create Codex runtime directory: {error}"))
    })?;
    reject_symlink_components(path)?;
    set_private_directory(path)
}

fn publish_identical(path: &Path, bytes: &[u8]) -> Result<(), CliError> {
    match fs::symlink_metadata(path) {
        Ok(_) if read_nofollow(path, MAX_FILE_BYTES)? == bytes => Ok(()),
        Ok(_) => Err(CliError::message(
            "Codex runtime snapshot destination changed",
        )),
        Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
            publish_private_file(path, bytes)
        }
        Err(error) => Err(CliError::message(format!(
            "cannot inspect Codex snapshot destination: {error}"
        ))),
    }
}

fn copy_tree(
    source: &Path,
    destination: &Path,
    count: &mut usize,
    total: &mut usize,
    depth: usize,
) -> Result<(), CliError> {
    if depth > 16 {
        return Err(CliError::message(
            "Codex plugin snapshot exceeds its depth bound",
        ));
    }
    reject_symlink_components(source)?;
    private_directory(destination)?;
    for entry in fs::read_dir(source).map_err(|error| {
        CliError::message(format!(
            "cannot read installed Codex Shepherd plugin: {error}"
        ))
    })? {
        let entry = entry.map_err(|error| {
            CliError::message(format!("cannot read Codex plugin entry: {error}"))
        })?;
        let metadata = entry.file_type().map_err(|error| {
            CliError::message(format!("cannot inspect Codex plugin entry: {error}"))
        })?;
        let target = destination.join(entry.file_name());
        if metadata.is_dir() {
            copy_tree(&entry.path(), &target, count, total, depth + 1)?;
        } else if metadata.is_file() {
            let bytes = read_nofollow(&entry.path(), MAX_FILE_BYTES)?;
            *count += 1;
            *total += bytes.len();
            if *count > MAX_TREE_FILES || *total > MAX_TREE_BYTES {
                return Err(CliError::message(
                    "Codex plugin snapshot exceeds its size bound",
                ));
            }
            publish_identical(&target, &bytes)?;
        } else {
            return Err(CliError::message(
                "Codex plugin snapshot refuses non-regular entries",
            ));
        }
    }
    Ok(())
}

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

    #[test]
    fn snapshot_preserves_only_existing_shepherd_trust() {
        let result = selected_config(
            br#"
[plugins."shepherd@shepherd"]
enabled = true
[plugins."unrelated@other"]
enabled = true
[hooks.state."shepherd@shepherd:hook"]
trusted_hash = "existing-operator-decision"
[hooks.state."unrelated@other:hook"]
trusted_hash = "foreign"
[hooks.PreToolUse]
command = "unrelated command"
[projects."/project"]
trust_level = "trusted"
[projects."/other"]
trust_level = "trusted"
"#,
            Path::new("/project"),
        )
        .unwrap();
        assert!(result.contains("existing-operator-decision"));
        assert!(result.contains("/project"));
        for excluded in ["unrelated", "foreign", "PreToolUse", "/other"] {
            assert!(!result.contains(excluded));
        }
    }

    #[test]
    fn snapshot_does_not_create_hook_trust() {
        let result = selected_config(
            b"[plugins.\"shepherd@shepherd\"]\nenabled = true\n",
            Path::new("/project"),
        )
        .unwrap();
        assert!(!result.contains("trusted_hash"));
        assert!(!result.contains("hooks"));
        assert!(selected_config(b"", Path::new("/project")).is_err());
    }

    #[test]
    fn snapshot_copies_only_shepherd_and_refuses_changed_output() {
        let temp = tempfile::tempdir().unwrap();
        let root = fs::canonicalize(temp.path()).unwrap();
        let source = root.join("source");
        let plugin = source.join("plugins/cache/shepherd/shepherd/6.7.2");
        fs::create_dir_all(&plugin).unwrap();
        fs::write(plugin.join("SKILL.md"), "existing skill").unwrap();
        fs::write(
            source.join("config.toml"),
            "[plugins.\"shepherd@shepherd\"]\nenabled = true\n",
        )
        .unwrap();
        fs::write(source.join("unrelated"), "not copied").unwrap();
        let home = root.join("home");
        fs::create_dir(&home).unwrap();
        let auth = AuthSnapshot {
            source: source.join("auth.json").to_str().unwrap().into(),
            destination: ".config/codex/auth.json".into(),
        };
        snapshot_runtime(&auth, &home, Path::new("/project")).unwrap();
        snapshot_runtime(&auth, &home, Path::new("/project")).unwrap();
        let copied = home.join(".config/codex/plugins/cache/shepherd/shepherd/6.7.2/SKILL.md");
        assert_eq!(fs::read_to_string(&copied).unwrap(), "existing skill");
        assert!(!home.join(".config/codex/unrelated").exists());
        fs::write(copied, "changed").unwrap();
        assert!(snapshot_runtime(&auth, &home, Path::new("/project")).is_err());
    }

    #[cfg(unix)]
    #[test]
    fn snapshot_refuses_plugin_symlinks() {
        let temp = tempfile::tempdir().unwrap();
        let root = fs::canonicalize(temp.path()).unwrap();
        let source = root.join("source");
        fs::create_dir(&source).unwrap();
        fs::write(root.join("outside"), "outside").unwrap();
        std::os::unix::fs::symlink(root.join("outside"), source.join("skill")).unwrap();
        assert!(copy_tree(&source, &root.join("target"), &mut 0, &mut 0, 0).is_err());
    }
}