agent-fleet 0.1.0

Autonomous OSS-repo health for solo maintainers (Rust port of @p-vbordei/agent-fleet)
Documentation
//! gh-only sandbox (SPEC ยง6, S3+S4, C4).

use once_cell_compat::Lazy;
use regex::Regex;

// Lightweight Lazy without pulling once_cell.
mod once_cell_compat {
    use std::cell::UnsafeCell;
    use std::sync::Once;

    pub struct Lazy<T, F = fn() -> T> {
        once: Once,
        cell: UnsafeCell<Option<T>>,
        init: UnsafeCell<Option<F>>,
    }

    unsafe impl<T: Send + Sync, F: Send> Sync for Lazy<T, F> {}

    impl<T, F: FnOnce() -> T> Lazy<T, F> {
        pub const fn new(init: F) -> Self {
            Self {
                once: Once::new(),
                cell: UnsafeCell::new(None),
                init: UnsafeCell::new(Some(init)),
            }
        }

        pub fn get(&self) -> &T {
            self.once.call_once(|| {
                // Safety: only ever called once.
                let init = unsafe { (*self.init.get()).take().expect("init present") };
                unsafe {
                    *self.cell.get() = Some(init());
                }
            });
            unsafe { (*self.cell.get()).as_ref().expect("initialised") }
        }
    }
}

/// gh subcommand prefixes forbidden because they mutate code/PRs/existing issues.
pub const FORBIDDEN_GH_PREFIXES: &[&str] = &[
    "gh pr create",
    "gh pr close",
    "gh pr merge",
    "gh pr review",
    "gh pr edit",
    "gh issue close",
    "gh issue comment",
    "gh issue edit",
    "gh issue reopen",
    "gh issue delete",
    "gh release create",
    "gh release edit",
    "gh release delete",
    "gh release upload",
    "gh repo edit",
    "gh repo delete",
    "gh repo archive",
    "gh repo clone",
    "gh repo create",
    "gh repo fork",
    "gh repo rename",
    "gh repo sync",
    "gh workflow run",
    "gh workflow disable",
    "gh workflow enable",
    "gh secret set",
    "gh secret delete",
    "gh variable set",
    "gh variable delete",
    "gh label create",
    "gh label edit",
    "gh label delete",
];

static MUTATING_API_FLAGS: Lazy<Regex> =
    Lazy::new(|| Regex::new(r"-X\s+(POST|PUT|PATCH|DELETE)\b").unwrap());
static SHELL_METACHARS: Lazy<Regex> = Lazy::new(|| Regex::new(r"[|&;`$<>(){}\\]").unwrap());
static API_PATH_RE: Lazy<Regex> =
    Lazy::new(|| Regex::new(r"gh api(?:\s+-X\s+\w+)?\s+(\S+)").unwrap());
static ALLOWED_API_POST_PREFIXES: Lazy<Vec<Regex>> = Lazy::new(|| {
    vec![Regex::new(r"^/?repos/[^/]+/[^/]+/issues(\?|$)").unwrap()]
});

#[derive(Debug, Clone, PartialEq, Eq)]
pub enum AllowResult {
    Allowed,
    Denied(String),
}

impl AllowResult {
    pub fn allowed(&self) -> bool {
        matches!(self, AllowResult::Allowed)
    }
    pub fn reason(&self) -> Option<&str> {
        match self {
            AllowResult::Allowed => None,
            AllowResult::Denied(s) => Some(s.as_str()),
        }
    }
}

pub fn is_allowed_command(cmd: &str) -> AllowResult {
    let trimmed = cmd.trim();
    if trimmed.is_empty() {
        return AllowResult::Denied("empty command".into());
    }
    if SHELL_METACHARS.get().is_match(trimmed) {
        return AllowResult::Denied("shell metacharacters not permitted".into());
    }
    if !(trimmed.starts_with("gh ") || trimmed == "gh") {
        return AllowResult::Denied("non-gh command rejected".into());
    }
    for prefix in FORBIDDEN_GH_PREFIXES {
        if trimmed == *prefix || trimmed.starts_with(&format!("{} ", prefix)) {
            return AllowResult::Denied(format!("forbidden gh subcommand: {}", prefix));
        }
    }
    if trimmed.starts_with("gh api") && MUTATING_API_FLAGS.get().is_match(trimmed) {
        let path = API_PATH_RE
            .get()
            .captures(trimmed)
            .and_then(|c| c.get(1).map(|m| m.as_str().to_string()))
            .unwrap_or_default();
        let ok = ALLOWED_API_POST_PREFIXES
            .get()
            .iter()
            .any(|re| re.is_match(&path));
        if !ok {
            return AllowResult::Denied(format!("forbidden mutating gh api path: {}", path));
        }
    }
    AllowResult::Allowed
}