Skip to main content

agent_fleet/
sandbox.rs

1//! gh-only sandbox (SPEC ยง6, S3+S4, C4).
2
3use once_cell_compat::Lazy;
4use regex::Regex;
5
6// Lightweight Lazy without pulling once_cell.
7mod once_cell_compat {
8    use std::cell::UnsafeCell;
9    use std::sync::Once;
10
11    pub struct Lazy<T, F = fn() -> T> {
12        once: Once,
13        cell: UnsafeCell<Option<T>>,
14        init: UnsafeCell<Option<F>>,
15    }
16
17    unsafe impl<T: Send + Sync, F: Send> Sync for Lazy<T, F> {}
18
19    impl<T, F: FnOnce() -> T> Lazy<T, F> {
20        pub const fn new(init: F) -> Self {
21            Self {
22                once: Once::new(),
23                cell: UnsafeCell::new(None),
24                init: UnsafeCell::new(Some(init)),
25            }
26        }
27
28        pub fn get(&self) -> &T {
29            self.once.call_once(|| {
30                // Safety: only ever called once.
31                let init = unsafe { (*self.init.get()).take().expect("init present") };
32                unsafe {
33                    *self.cell.get() = Some(init());
34                }
35            });
36            unsafe { (*self.cell.get()).as_ref().expect("initialised") }
37        }
38    }
39}
40
41/// gh subcommand prefixes forbidden because they mutate code/PRs/existing issues.
42pub const FORBIDDEN_GH_PREFIXES: &[&str] = &[
43    "gh pr create",
44    "gh pr close",
45    "gh pr merge",
46    "gh pr review",
47    "gh pr edit",
48    "gh issue close",
49    "gh issue comment",
50    "gh issue edit",
51    "gh issue reopen",
52    "gh issue delete",
53    "gh release create",
54    "gh release edit",
55    "gh release delete",
56    "gh release upload",
57    "gh repo edit",
58    "gh repo delete",
59    "gh repo archive",
60    "gh repo clone",
61    "gh repo create",
62    "gh repo fork",
63    "gh repo rename",
64    "gh repo sync",
65    "gh workflow run",
66    "gh workflow disable",
67    "gh workflow enable",
68    "gh secret set",
69    "gh secret delete",
70    "gh variable set",
71    "gh variable delete",
72    "gh label create",
73    "gh label edit",
74    "gh label delete",
75];
76
77static MUTATING_API_FLAGS: Lazy<Regex> =
78    Lazy::new(|| Regex::new(r"-X\s+(POST|PUT|PATCH|DELETE)\b").unwrap());
79static SHELL_METACHARS: Lazy<Regex> = Lazy::new(|| Regex::new(r"[|&;`$<>(){}\\]").unwrap());
80static API_PATH_RE: Lazy<Regex> =
81    Lazy::new(|| Regex::new(r"gh api(?:\s+-X\s+\w+)?\s+(\S+)").unwrap());
82static ALLOWED_API_POST_PREFIXES: Lazy<Vec<Regex>> = Lazy::new(|| {
83    vec![Regex::new(r"^/?repos/[^/]+/[^/]+/issues(\?|$)").unwrap()]
84});
85
86#[derive(Debug, Clone, PartialEq, Eq)]
87pub enum AllowResult {
88    Allowed,
89    Denied(String),
90}
91
92impl AllowResult {
93    pub fn allowed(&self) -> bool {
94        matches!(self, AllowResult::Allowed)
95    }
96    pub fn reason(&self) -> Option<&str> {
97        match self {
98            AllowResult::Allowed => None,
99            AllowResult::Denied(s) => Some(s.as_str()),
100        }
101    }
102}
103
104pub fn is_allowed_command(cmd: &str) -> AllowResult {
105    let trimmed = cmd.trim();
106    if trimmed.is_empty() {
107        return AllowResult::Denied("empty command".into());
108    }
109    if SHELL_METACHARS.get().is_match(trimmed) {
110        return AllowResult::Denied("shell metacharacters not permitted".into());
111    }
112    if !(trimmed.starts_with("gh ") || trimmed == "gh") {
113        return AllowResult::Denied("non-gh command rejected".into());
114    }
115    for prefix in FORBIDDEN_GH_PREFIXES {
116        if trimmed == *prefix || trimmed.starts_with(&format!("{} ", prefix)) {
117            return AllowResult::Denied(format!("forbidden gh subcommand: {}", prefix));
118        }
119    }
120    if trimmed.starts_with("gh api") && MUTATING_API_FLAGS.get().is_match(trimmed) {
121        let path = API_PATH_RE
122            .get()
123            .captures(trimmed)
124            .and_then(|c| c.get(1).map(|m| m.as_str().to_string()))
125            .unwrap_or_default();
126        let ok = ALLOWED_API_POST_PREFIXES
127            .get()
128            .iter()
129            .any(|re| re.is_match(&path));
130        if !ok {
131            return AllowResult::Denied(format!("forbidden mutating gh api path: {}", path));
132        }
133    }
134    AllowResult::Allowed
135}