1use once_cell_compat::Lazy;
4use regex::Regex;
5
6mod 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 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
41pub 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}