1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
//! Which `gh` invocations a read-only dispatch may run (#8567).
//!
//! Why: the #8439 allowlist refused every `gh` call, so a `research` brief
//! that opened with `gh issue view N --comments` could not run, and a
//! `code-critic` could not read a PR's checks; the PM relayed each GitHub read
//! through a writing agent.
//! What: [`check_gh`] names the reads and refuses everything else:
//! - `gh issue view|list`, `gh pr view|list|diff|checks` and
//! `gh run view|list`, with any option but `--web`/`-w`, which starts a
//! browser program, and `--watch`, which blocks on CI. A short cluster
//! holding `w` is refused, so `-Rowner/web` is over-refused;
//! - `gh api <endpoint>` that sends GET: `-X`/`--method` only `GET`, the
//! options in [`API_FLAGS`] and [`API_VALUED`] only (so `-f`, `-F`,
//! `--field`, `--raw-field` and `--input`, which send a body and switch the
//! default method to POST, are refused), a `-H` header only `Accept:` or
//! `X-GitHub-Api-Version:`, and never the `graphql` endpoint, whose query
//! can hold a mutation. A `for` variable is refused anywhere in `gh api`.
//! The endpoint is a path on the default host: `--hostname`, a `://` URL, a
//! `..` segment, and a `graphql` segment in any case are refused.
//!
//! Every other verb — `pr create|merge|review|comment|edit|checkout`,
//! `issue create|comment|edit|close`, `repo`, `release`, `auth`, and any verb
//! gh adds later — is refused.
//! Out of scope: a program run by configuration that already exists, such as
//! `GH_PAGER` or `PAGER`.
//! Test: `read_only_allow_tests::gh_read_verbs_are_allowed`,
//! `read_only_allow_tests::gh_mutating_and_unknown_verbs_are_refused`,
//! `read_only_allow_tests::gh_api_get_forms_are_allowed`,
//! `read_only_allow_tests::gh_api_writes_are_refused`.
use super::read_only_programs::Arg;
type Verdict = Result<(), String>;
/// Each resource and the verbs on it that only read.
const READ_VERBS: &[(&str, &[&str])] = &[
("issue", &["view", "list"]),
("pr", &["view", "list", "diff", "checks"]),
("run", &["view", "list"]),
];
/// Valueless `gh api` options that change only what is printed.
const API_FLAGS: &[&str] = &[
"--paginate",
"--slurp",
"-i",
"--include",
"--silent",
"--verbose",
];
/// `gh api` options that take one value; `-X`/`-H` values are judged too.
const API_VALUED: &[&str] = &[
"-X",
"--method",
"-H",
"--header",
"-q",
"--jq",
"-t",
"--template",
"-p",
"--preview",
];
/// Judge a `gh` argv (`rest` excludes `gh` itself).
///
/// Why: see the module doc.
/// What: `Ok` for an allowlisted read; `Err` naming the refusal otherwise.
/// Test: as the module doc.
pub(super) fn check_gh(rest: &[Arg]) -> Verdict {
let resource = rest
.first()
.and_then(Arg::text)
.ok_or("`gh` without a literal command")?;
if resource == "api" {
return api(&rest[1..]);
}
let verb = rest.get(1).and_then(Arg::text).unwrap_or_default();
let reads = READ_VERBS
.iter()
.any(|(r, verbs)| *r == resource && verbs.contains(&verb));
if !reads {
return Err(format!(
"`gh {resource} {verb}`, which is not a read verb (a read-only agent runs gh issue \
view/list, pr view/list/diff/checks, run view/list and api GET only)"
));
}
for t in rest[2..].iter().filter_map(Arg::text) {
let name = t.split('=').next().unwrap_or(t);
let short_web = t
.strip_prefix('-')
.is_some_and(|c| !c.starts_with('-') && c.contains('w'));
if matches!(name, "--web" | "--watch") || short_web {
return Err(format!(
"`gh {resource} {verb} {t}`, which opens a browser or blocks on CI"
));
}
}
Ok(())
}
/// `gh api` that sends a GET and no request body.
fn api(rest: &[Arg]) -> Verdict {
let mut endpoint = None;
let mut i = 0;
while let Some(arg) = rest.get(i) {
let t = arg
.text()
.ok_or("a `for` variable in `gh api`, whose endpoint or method it could be")?;
i += 1;
if !t.starts_with('-') {
if endpoint.replace(t).is_some() {
return Err("`gh api` with more than one endpoint".into());
}
continue;
}
if API_FLAGS.contains(&t) {
continue;
}
// A joined value: `--name=value`, or `-Xvalue` for a short option.
let (name, joined) = match t.split_once('=') {
Some((n, v)) if t.starts_with("--") => (n, Some(v)),
_ if !t.starts_with("--") && t.len() > 2 => (&t[..2], Some(&t[2..])),
_ => (t, None),
};
if !API_VALUED.contains(&name) {
return Err(format!(
"`gh api {name}`, which is not a GET-only option (a field or `--input` sends a \
request body)"
));
}
let value = match joined {
Some(v) => v,
None => {
i += 1;
rest.get(i - 1)
.and_then(Arg::text)
.ok_or_else(|| format!("`gh api {name}` without a literal value"))?
}
};
api_value(name, value)?;
}
let endpoint = endpoint.ok_or("`gh api` without an endpoint")?;
// #8567 critic: a full URL can name another host.
if endpoint.contains("://") {
return Err("a `gh api` endpoint that is a full URL".into());
}
// #8567 critic: judge each path segment, so `graphql/`, `./graphql` and
// `GraphQL` are refused; `..` could climb back to one.
let path = endpoint.split(['?', '#']).next().unwrap_or(endpoint);
let mut segments = path.split('/').filter(|s| !s.is_empty() && *s != ".");
if segments.any(|s| s == ".." || s.eq_ignore_ascii_case("graphql")) {
return Err("a `gh api` graphql endpoint or `..` segment".into());
}
Ok(())
}
/// Judge the value of a valued `gh api` option.
fn api_value(name: &str, value: &str) -> Verdict {
match name {
"-X" | "--method" if !value.eq_ignore_ascii_case("GET") => {
Err(format!("`gh api {name} {value}`, a method other than GET"))
}
"-H" | "--header" => {
let header = value.to_ascii_lowercase();
if header.starts_with("accept:") || header.starts_with("x-github-api-version:") {
return Ok(());
}
Err("a `gh api` header other than `Accept:` or `X-GitHub-Api-Version:`".into())
}
_ => Ok(()),
}
}