use once_cell_compat::Lazy;
use regex::Regex;
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(|| {
let init = unsafe { (*self.init.get()).take().expect("init present") };
unsafe {
*self.cell.get() = Some(init());
}
});
unsafe { (*self.cell.get()).as_ref().expect("initialised") }
}
}
}
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
}