Repository navigation
Expand file tree
/
Copy pathgithub-guard
More file actions
executable file
·712 lines (677 loc) · 38 KB
/
Copy pathgithub-guard
File metadata and controls
executable file
·712 lines (677 loc) · 38 KB
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
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
#!/usr/bin/env bash
# github-guard: Claude Code's PreToolUse hook for Bash. It is the one place that decides what an agent may do
# on GitHub. It reads the hook payload on stdin and answers for `gh api`, `gh pr`, `gh issue`, `git push` and
# `wt pr`. It gives no answer at all for every other command, so the permission lists in
# ~/.claude/settings.json decide those.
#
# GH_API_WRITES and the checker form strings define the approved forms and generate help and command matching.
# The hook approves the forms below on every machine. Each form must be one simple command (defined below),
# and it must stay in the repository of the session. That repository is one of the github.com remotes of the
# working directory. It can also be gh's own {owner}/{repo}, as long as GH_REPO, GH_HOST and
# `gh repo set-default` leave gh pointed at one of those remotes:
#
# read gh api GET or HEAD; gh api graphql with a query; gh pr view|diff|checks|list|status;
# gh issue view|list|status
# push git push [-u] <remote> <src>[:<branch>] to any branch but main, master or the default one (GitHub must say
# which branch that is). <src> is HEAD or a local branch. No force, no delete, no explicit
# tags, one branch, a github.com push URL. But push.followTags can still add annotated tags.
# open gh pr create, gh issue create (not from a file outside this repository or a temp dir, not
# into another repository, no --template); wt pr <name> [--draft]; gh api POST .../issues
# comment gh pr comment, gh issue comment [<n>] --body|--body-file …, and --edit-last to edit your
# latest one; gh api POST .../issues/<n>/comments (on an issue or a pull request),
# .../pulls/<n>/comments (an inline review comment), .../pulls/<n>/comments/<id>/replies
# review gh pr review [<n>] --comment|--request-changes --body …; gh api POST .../pulls/<n>/reviews
# (with comments[][…] for inline comments) and .../reviews/<id>/events
# edit gh issue edit <n>; gh api PATCH .../issues/<n>, .../pulls/comments/<id>,
# .../issues/comments/<id>, PUT .../pulls/<n>/reviews/<id>; only when the gh login wrote it.
# The hook asks GitHub about the author first.
# resolve gh api graphql with a mutation whose only fields are resolveReviewThread/unresolveReviewThread
#
# The hook denies everything else that those commands can do. The deny reason names the approved form, and the
# agent reads it and retries. The hook denies a pull request approval on purpose. An approval from your login
# counts toward branch protection, so it stays yours to give.
#
# Why a hook and not permission rules: a hook's "allow" does not override a deny rule. The permissions docs say
# "Hook decisions don't bypass permission rules". So four rules had to leave the deny list before the hook
# could approve any of this. They are Bash(gh api *), Bash(git push *), Bash(gh pr create *) and Bash(wt pr *).
#
# A permission rule also cannot tell `gh api .../comments` from `gh api -X DELETE ...`, or a feature branch
# from the default one. This script can, because it reads the same words that the shell will see. It approves
# a command only when it is ONE simple command that tokenizes cleanly here. Such a command has no pipe,
# redirect, `;`, `&&`, `$`, backtick, glob, brace range, backslash or unquoted newline. So nothing that the
# script did not read can run beside it.
#
# What the hook does not see: one of these commands later in a compound (`npm test && git push`), or behind a
# wrapper (`env git push`, `command gh api`). The hook gives those no answer, so the permission lists decide.
# Nothing in those lists denies them now, so they prompt (or go to auto mode's classifier). The result is a
# prompt, not a silent run. See the README's Security section.
#
# The exit status is always 0: exit 2 would block the tool call. This hook runs before every Bash command, so
# a failure in the hook must never block them all. Any failure here (no jq, no payload) gives no answer.
set -u -f # -f: no word that this script splits can expand as a glob
# The scripts in bin/ need bash 5 or later (README, "Requirements").
# When /bin/bash 3.2 starts this script, use the first Homebrew candidate with version 5 or later.
# WORKGROVE_REEXEC prevents a loop if the candidate changes after the version check.
# This block must use bash 3.2 syntax.
if ((BASH_VERSINFO[0] < 5)) && [[ -z ${WORKGROVE_REEXEC:-} ]]; then
for bash5 in /opt/homebrew/bin/bash /usr/local/bin/bash; do
if [[ -x $bash5 ]] && "$bash5" -c '((BASH_VERSINFO[0] >= 5))' >/dev/null 2>&1; then
export WORKGROVE_REEXEC=1
exec "$bash5" "$0" "$@"
fi
done
fi
unset WORKGROVE_REEXEC
if ((BASH_VERSINFO[0] < 5)); then
cat >/dev/null 2>&1 # read the payload, so that Claude Code's write cannot fail
exit 0 # no answer: the permission lists decide, as for any other failure here
fi
command -v jq >/dev/null 2>&1 || exit 0
payload=$(cat)
# The hook refuses a control character outright and does not tokenize it. Bash drops a NUL from a variable,
# but the shell that runs the command stops at the NUL. So the two would read different commands:
# `-f x=1\0 -X GET` is a GET here and a POST there. Because jq sees the JSON string before bash does, jq flags
# each control character and replaces it with U+FFFD, which no tokenize() segment accepts.
cmd=$(printf '%s' "$payload" | jq -r '.tool_input.command // empty | explode
| map(if (. < 32 and . != 9 and . != 10) or . == 127 then 65533 else . end) | implode' 2>/dev/null) || exit 0
[[ $cmd == *gh* || $cmd == *git* || $cmd == *wt* ]] || exit 0 # the fast path: most commands exit here
ctrl=false
[[ $cmd == *$'\xef\xbf\xbd'* ]] && ctrl=true
cwd=$(printf '%s' "$payload" | jq -r '.cwd // empty' 2>/dev/null) || cwd=''
[[ -n $cwd && -d $cwd ]] || cwd=$PWD
# decide <allow|deny> <reason>: print the hook's answer and end the run.
decide() {
jq -cn --arg d "$1" --arg r "github-guard: $2" \
'{hookSpecificOutput: {hookEventName: "PreToolUse", permissionDecision: $d, permissionDecisionReason: $r}}'
exit 0
}
allow() { decide allow "$1"; }
deny() { decide deny "$1"; }
# lower_ascii <name> <text>: set the variable <name> to <text> with only A-Z changed to a-z.
# In a UTF-8 locale, ${v,,} also changes U+0130 to i and U+212A to k. gh keeps them in a host name.
# So GH_HOST=gİthub.com would equal github.com here, and gh would send the token to another server.
# Keep LC_ALL=C local. It ends when the function returns, so [[:space:]] in the caller still matches U+3000.
lower_ascii() { local LC_ALL=C; printf -v "$1" '%s' "${2,,}"; }
gh_here() { (cd "$cwd" 2>/dev/null && gh "$@") 2>/dev/null; } # where the agent is, so {owner} means the same
# tokenize <command>: split it into WORDS exactly as sh, bash or zsh would, or fail. The function knows only
# three kinds of segment. They are a bare run of [A-Za-z0-9_./:=@%+,{}-], a '…' string, and a "…" string with
# no $, backtick or backslash in it. A word is any run of segments with no blank between them. Anything else
# fails, and that is the point: each character that makes a shell do more than split words is outside those
# three.
#
# The bare set leaves out the glob characters (* ? [ ]) and ~, so no word can glob. {a,b} and {a..b} are
# brace expansions. So the function refuses a word when its unquoted parts hold a brace and a comma, or hold
# a `..` anywhere. It also refuses a leading =, which zsh expands to a path. The remaining braces are there
# for gh's own {owner} and {repo}, and the comma is there for lists such as --json reviews,comments. Neither
# expands alone.
#
# The function leaves a ! inside "…" alone, because history expansion occurs only in an interactive shell,
# and the Bash tool's shell is not one.
tokenize() {
local rest=$1 word seg got brace comma
local re_bare='^[A-Za-z0-9_./:=@%+,{}-]+' re_sq="^'([^']*)'" re_dq='^"([^"$`\\]*)"' re_blank='^[[:blank:]]+'
WORDS=()
while :; do
[[ $rest =~ $re_blank ]] && rest=${rest:${#BASH_REMATCH[0]}}
[[ -z $rest ]] && return 0
word='' got=0 brace=0 comma=0
while :; do
if [[ $rest =~ $re_bare ]]; then
seg=${BASH_REMATCH[0]}
[[ $seg == *..* ]] && return 1
[[ $got -eq 0 && $seg == =* ]] && return 1
[[ $seg == *[{}]* ]] && brace=1
[[ $seg == *,* ]] && comma=1
word+=$seg
elif [[ $rest =~ $re_sq || $rest =~ $re_dq ]]; then
word+=${BASH_REMATCH[1]}
else
break
fi
rest=${rest:${#BASH_REMATCH[0]}}
got=1
done
[[ $got -eq 1 && $brace$comma != 11 ]] || return 1
WORDS+=("$word")
[[ -z $rest || $rest =~ $re_blank ]] || return 1
done
}
# ---------------------------------------------------------------------------------- where things point
github_repo() { # github_repo <url>: print owner/repo when <url> is a github.com remote, otherwise fail
local u=${1%/} re='^(https://([^@/]+@)?github\.com/|ssh://([^@/]+@)?(ssh\.)?github\.com(:[0-9]+)?/|([^@/:]+@)?github\.com:)([A-Za-z0-9_.-]+)/([A-Za-z0-9_.-]+)$'
u=${u%.git}
[[ $u =~ $re ]] || return 1
printf '%s/%s\n' "${BASH_REMATCH[7]}" "${BASH_REMATCH[8]}"
}
this_repos() { # owner/repo, one per line, of every github.com remote of the repository at $cwd
local r u
for r in $(git -C "$cwd" remote 2>/dev/null); do
for u in $(git -C "$cwd" remote get-url --all "$r" 2>/dev/null); do github_repo "$u"; done
done
}
is_this_repo() { # is_this_repo <owner/repo>: one of this_repos, compared case-insensitively, as GitHub does
local want mine
lower_ascii want "$1"
while IFS= read -r mine; do
lower_ascii mine "$mine"
[[ $mine == "$want" ]] && return 0
done < <(this_repos)
return 1
}
same_repo() { # same_repo <owner> <repo>: gh's {owner}/{repo} (gh_aims_here checks it) or one of this_repos
[[ $1 == '{owner}' && $2 == '{repo}' ]] && return 0
is_this_repo "$1/$2"
}
# gh_aims_here: deny unless gh's own choice of repository is one of this_repos. That choice is what gh puts in
# {owner}/{repo}, and what every gh pr and gh issue command acts on. GH_REPO overrides the remotes.
# `gh repo set-default` leaves a remote.<name>.gh-resolved, and gh follows it even to a repository that is not
# a remote at all. GH_HOST sends even an explicit repos/<owner>/<repo> to another server. This function reads
# each of them before the hook approves any write. It can read only the environment that Claude Code runs this
# hook with. It does not see a variable that is set only inside the Bash tool's own shell.
gh_aims_here() {
local v r host
v=${GH_HOST:-github.com}
lower_ascii host "$v"
[[ $host == github.com ]] || deny "GH_HOST=$v points gh at another server; agents write only to github.com"
if [[ -n ${GH_REPO:-} ]]; then
r=$GH_REPO
lower_ascii host "${r%%/*}"
[[ $host == github.com ]] && r=${r#*/} # [HOST/]OWNER/REPO, as gh takes it
is_this_repo "$r" || deny "GH_REPO=$GH_REPO points gh at a repository that is not a github.com remote of \
this one; agents write only to this repository"
fi
while read -r _ v; do
r=$v
lower_ascii host "${r%%/*}"
[[ $host == github.com ]] && r=${r#*/}
[[ $v == base ]] || is_this_repo "$r" \
|| deny "gh repo set-default points gh at $v, which is not a github.com remote of this repository"
done < <(git -C "$cwd" config --get-regexp '^remote\..*\.gh-resolved$' 2>/dev/null)
}
BRANCH_RE='^[A-Za-z0-9_][A-Za-z0-9._/-]*$'
branch_name() { [[ $1 =~ $BRANCH_RE && $1 != *..* ]]; }
# body_file <path>: true for a --body-file that gh may read and post. A file that an agent wrote for this
# purpose is in the repository or a temp dir. The function refuses anything else (~/.ssh/id_rsa, a symlink to
# it, stdin). So a prompt injection that says "post this file" gets a denial instead of a comment. This is a
# check on the ordinary spelling, not a boundary: an agent that can read a file can paste it into --body.
body_file() {
local p=$1 d t root
[[ -n $p && $p != - ]] || return 1
[[ $p == /* ]] || p="$cwd/$p"
[[ -f $p && ! -L $p ]] || return 1
d=$(cd "$(dirname "$p")" 2>/dev/null && pwd -P) || return 1
root=$(git -C "$cwd" rev-parse --show-toplevel 2>/dev/null) || root=$cwd
for t in "$root" /tmp /private/tmp /private/var/folders "${TMPDIR:-/nonexistent}"; do
t=$(cd "$t" 2>/dev/null && pwd -P) || continue
[[ $d == "$t" || $d == "$t"/* ]] && return 0
done
return 1
}
# ------------------------------------------------------------------------------------------------ gh api
# Each row has a method/path regex, fields, required field, ownership check, event check, label, and help text.
# Separate columns with ~. Keep help fragments in their display order to preserve denial reasons.
ISSUE_FIELDS='title body labels[] assignees[] milestone' # not state: closing and reopening stay out
GH_API_WRITES=(
"^POST issues$~$ISSUE_FIELDS~title~~~a new issue~-X POST repos/{owner}/{repo}/issues (-f title= -f body=, -f 'labels[]=…' -f 'assignees[]=…' -F milestone=), "
"^POST issues/[0-9]+/comments$~body~body~~~a comment~.../issues/<n>/comments, "
"^POST pulls/[0-9]+/comments$~body commit_id path line side start_line start_side in_reply_to subject_type position~body~~~an inline review comment~.../pulls/<n>/comments (an inline comment: -f body= -f commit_id= -f path= -F line=), "
"^POST pulls/[0-9]+/comments/[0-9]+/replies$~body~body~~~a comment~.../pulls/<n>/comments/<id>/replies, "
"^POST pulls/[0-9]+/reviews$~body event commit_id comments[][path] comments[][line] comments[][side] comments[][start_line] comments[][start_side] comments[][body] comments[][position]~~~optional~a review~.../pulls/<n>/reviews (-f event=COMMENT|REQUEST_CHANGES, inline comments as -f 'comments[][path]=…' -F 'comments[][line]=…' -f 'comments[][body]=…'), "
"^POST pulls/[0-9]+/reviews/[0-9]+/events$~body event~body~~required~submitting a pending review~.../pulls/<n>/reviews/<id>/events; "
"^PATCH issues/[0-9]+$~$ISSUE_FIELDS~~own~~an edit of its own issue~on your own, -X PATCH .../issues/<n> with the fields an issue is opened with, and "
"^(PUT pulls/[0-9]+/reviews/[0-9]+|PATCH (pulls|issues)/comments/[0-9]+)$~body~body~own~~an edit of its own~-X PATCH .../pulls/comments/<id> or .../issues/comments/<id> and -X PUT .../pulls/<n>/reviews/<id> with -f body=; "
)
API_READ_FORM="gh api <endpoint> (--jq '<expr>' to filter, --paginate for every \
page, -X GET -f key=value for query parameters, gh api graphql -f query='…' for a GraphQL query)"
API_GRAPHQL_FORM='gh api graphql with a resolveReviewThread or unresolveReviewThread mutation'
PR_REVIEW_FORM="approved form: gh pr review [<number>] --comment|--request-changes --body '…' | --body-file <file>"
PR_CREATE_FORM="approved form: gh pr create --title '…' --body '…' | --body-file <file> | --fill \
[--base <branch>] [--head <branch>] [--draft] [--label|--reviewer|--assignee|--milestone|--project …]"
ISSUE_CREATE_FORM="approved form: gh issue create --title '…' --body '…' | --body-file <file> \
[--label|--assignee|--milestone|--project …]"
ISSUE_EDIT_FORM="approved form: gh issue edit <number> [--title '…'] [--body '…' | --body-file \
<file>] [--add-label|--remove-label|--add-assignee|--remove-assignee|--add-project|--remove-project|--milestone \
…] [--remove-milestone], on an issue this login opened"
PUSH_FORM="approved form: git push [-u] <remote> HEAD[:<branch>], to any branch but the default one"
COMMENT_FORM="approved form: gh pr comment [<number>] --body '…' | --body-file <file> [--edit-last]"
WT_PR_FORM='approved form: wt pr <name> [--draft], from inside this repository'
PR_READ_FORM='gh pr view|diff|checks|list|status'
ISSUE_READ_FORM='gh issue view|list|status'
# gql_class <document>: print "query" or "mutation <root field>…", or fail when it cannot read the document.
# The lexer uses GraphQL's own token set. Anything outside that set (a comment, a block string, a stray
# character) is a failure, not a skip. A block string ("""…""") fails because it ends at the next triple
# quote, while this lexer would pair quotes one by one. So the two could disagree about what is a string and
# what is a field. The root fields are the names at the first level of the one operation's selection set,
# except aliases (a name followed by ':'). A fragment spread (...), a directive (@) or a second definition
# could put a field there that this walk would not see as one. So the function refuses each of them. The
# first two fall to the last branch, which fails any token that it does not list.
gql_class() {
jq -rn --arg q "$1" '
if ($q | contains("\"\"\"")) then error("a block string") else . end
| [$q | scan("\"(?:[^\"\\\\\\n]|\\\\.)*\"|[_A-Za-z][_0-9A-Za-z]*|-?[0-9]+(?:\\.[0-9]+)?(?:[eE][+-]?[0-9]+)?|\\.\\.\\.|[!$&():=@\\[\\]{|}]|[^\\s,]")] as $t
| if any($t[]; . == "mutation") | not then "query"
elif $t[0] != "mutation" then error("a mutation beside another definition")
else
(reduce range(1; $t | length) as $i ({d: 0, p: 0, closed: false, roots: [], bad: false};
if .bad then . else
$t[$i] as $x | ($t[$i + 1] // "") as $next
| if .closed then .bad = true
elif $x == "(" then .p += 1
elif $x == ")" then (if .p == 0 then .bad = true else .p -= 1 end)
elif $x == "{" then .d += 1
elif $x == "}" then
(if .d == 0 then .bad = true
else (.d -= 1 | if .d == 0 and .p == 0 then .closed = true else . end) end)
elif ($x | test("^[_A-Za-z]")) then
(if .d == 1 and .p == 0 and $next != ":" then .roots += [$x] else . end)
elif ($x | test("^(\"|-?[0-9])")) or ($x | test("^[!$&:=\\[\\]|]$")) then .
else .bad = true end
end)) as $s
| if $s.bad or ($s.closed | not) or ($s.roots | length) == 0 then error("unreadable")
else "mutation " + ($s.roots | join(" ")) end
end' 2>/dev/null
}
# fields_only <allowed keys, space-separated>: deny unless every -f/-F key in the caller's fields[] is allowed.
fields_only() {
local k allowed=" ${1//[[:space:]]/ } "
for k in "${fields[@]}"; do
[[ $allowed == *" $k "* ]] || deny "gh api field '$k' is not approved for this endpoint; $GH_API_FORMS"
done
}
field() { # field <key>: print the value of the last -f/-F with that key, or fail
local i v='' hit=1
for ((i = 0; i < nfields; i++)); do [[ ${fields[i]} == "$1" ]] && { v=${values[i]}; hit=0; }; done
printf '%s' "$v"
return "$hit"
}
event_ok() { # event_ok <required|optional>: event= is COMMENT or REQUEST_CHANGES
local e
if e=$(field event); then
case $e in COMMENT|REQUEST_CHANGES) return 0 ;;
APPROVE) deny "approving a pull request is not approved: an approval from this login counts toward \
branch protection, so it stays the user's to give; post event=COMMENT or REQUEST_CHANGES" ;;
*) deny "gh api review event '$e' is not COMMENT or REQUEST_CHANGES" ;;
esac
fi
[[ $1 == optional ]] || deny "gh api needs -f event=COMMENT or REQUEST_CHANGES here"
}
# own <path>: deny unless the gh login wrote the comment or review at repos/<path>. The function asks GitHub,
# because only GitHub knows. A maintainer's login may edit anyone's comment, and an agent that edits a
# reviewer's words is not making a fix.
own() {
local me author me_lower author_lower
me=$(gh_here api user --jq .login) && [[ -n $me ]] \
|| deny "could not ask GitHub who this gh login is (is gh logged in?)"
author=$(gh_here api "$1" --jq .user.login) && [[ -n $author ]] \
|| deny "could not ask GitHub who wrote $1"
lower_ascii me_lower "$me"; lower_ascii author_lower "$author"
[[ $author_lower == "$me_lower" ]] \
|| deny "$1 was written by $author, not by this login ($me); agents edit only their own"
}
check_gh_api() {
local a v endpoint='' method='' nfields=0 fields=() values=() path lowered
while [[ $# -gt 0 ]]; do
a=$1; shift
case $a in
-h|--help) allow "gh api help" ;;
--paginate|--slurp|-i|--include|--silent) continue ;;
--input|--input=*) deny "gh api --input sends a file as the request body; pass fields with -f/-F. $GH_API_FORMS" ;;
--*=*) v=${a#*=}; a=${a%%=*} ;;
-X|--method|-f|--raw-field|-F|--field|-H|--header|-q|--jq|-t|--template|--cache|-p|--preview)
[[ $# -gt 0 ]] || deny "gh api $a needs a value"
v=$1; shift ;;
-*) deny "gh api $a is not an approved option; $GH_API_FORMS" ;;
*) [[ -z $endpoint ]] || deny "gh api takes one endpoint, and this has two ('$endpoint', '$a')"
endpoint=$a; continue ;;
esac
case $a in
-X|--method) method=$v ;;
-f|--raw-field|-F|--field)
[[ $a == -F || $a == --field ]] && [[ $v == *=@* ]] \
&& deny "gh api -F key=@<file> sends a local file to GitHub; pass the value inline"
[[ $v == *=* ]] || deny "gh api $a takes key=value"
nfields=$((nfields + 1)); fields+=("${v%%=*}"); values+=("${v#*=}") ;;
-H|--header)
lower_ascii lowered "$v"
case $lowered in accept:*|x-github-api-version:*) ;;
*) deny "gh api may set only the Accept and X-GitHub-Api-Version headers" ;;
esac ;;
-q|--jq|-t|--template|--cache|-p|--preview) ;;
*) deny "gh api $a= is not an approved option; $GH_API_FORMS" ;;
esac
done
[[ -n $endpoint ]] || deny "gh api needs an endpoint; $GH_API_FORMS"
[[ $endpoint == -* ]] && deny "gh api endpoint '$endpoint' looks like an option"
[[ $endpoint == *://* ]] && deny "gh api endpoint must be a path on api.github.com, not a URL"
[[ $endpoint =~ [[:space:]] ]] && deny "gh api endpoint has a blank in it"
path=${endpoint#/}; path=${path%%\?*}
lower_ascii lowered "$path"
if [[ $lowered == graphql ]]; then
[[ -z $method || $method == POST ]] || deny "gh api graphql is always a POST; drop -X $method"
local i nq=0 query='' class
for ((i = 0; i < nfields; i++)); do
[[ ${fields[i]} == query ]] && { nq=$((nq + 1)); query=${values[i]}; }
done
[[ $nq -eq 1 ]] || deny "gh api graphql needs exactly one -f query='…'"
class=$(gql_class "$query") || deny "could not read that GraphQL document (no # comments, \"\"\" strings, \
fragments or directives in a mutation); $GH_API_FORMS"
[[ $class == query ]] && allow "read-only GraphQL query"
gh_aims_here
for a in ${class#mutation }; do
case $a in resolveReviewThread|unresolveReviewThread) ;;
*) deny "GraphQL mutation $a is not approved; the only approved mutations are resolveReviewThread and \
unresolveReviewThread. $GH_API_FORMS" ;;
esac
done
allow "resolving review threads (${class#mutation })"
fi
[[ -n $method ]] || { [[ $nfields -gt 0 ]] && method=POST || method=GET; }
case $method in GET|HEAD) allow "read-only gh api $method" ;; esac
[[ $endpoint == *\?* ]] && deny "a gh api write takes its parameters as -f fields, not a ?query string"
local re_repo='^repos/([^/]+)/([^/]+)/' rest
[[ $path =~ $re_repo ]] || deny "gh api -X $method $endpoint is not an approved write; $GH_API_FORMS"
local owner=${BASH_REMATCH[1]} name=${BASH_REMATCH[2]} # saved: same_repo's own =~ overwrites BASH_REMATCH
rest=${path#"${BASH_REMATCH[0]}"}
local mr="$method $rest" edit='' what='' need row regex allowed ownership event _help
for row in "${GH_API_WRITES[@]}"; do
IFS='~' read -r regex allowed need ownership event what _help <<<"$row"
[[ $mr =~ $regex ]] || { what=''; continue; }
fields_only "$allowed"
[[ -z $event ]] || event_ok "$event"
[[ $ownership == own ]] && edit=$path
break
done
[[ -n $what ]] || deny "gh api -X $method $endpoint is not an approved write; $GH_API_FORMS"
[[ -z $need ]] || field "$need" >/dev/null || deny "gh api needs -f $need='…' here"
gh_aims_here
same_repo "$owner" "$name" \
|| deny "$owner/$name is not this repository; agents write only to its own issues and pull requests"
[[ -n $edit ]] && own "$edit"
allow "$what in this repository"
}
# ----------------------------------------------------------------------------------------------- gh pr
# body_opt <flag> <value>: comment, review, create and edit share it. It sets body=1 for a body option.
body_opt() {
case $1 in
-b|--body) body=1 ;;
-F|--body-file)
body_file "$2" || deny "--body-file '$2' is not a regular file in this repository or a temp dir; write \
the text to a file there, or pass it with --body"
body=1 ;;
*) return 1 ;;
esac
}
# The hook refuses --template and does not check it, because gh pr create would read the file from any path.
# Also, without a terminal, gh refuses it beside --body or ignores it, so it never does what the agent meant.
NO_TEMPLATE="--template is not approved: read the repository's template yourself and pass the filled-in text \
with --body or --body-file"
# check_gh_comment <pr|issue> <args>: gh pr comment and gh issue comment take the same options.
check_gh_comment() {
local kind=$1 a v n='' body=0 edit=0 noun='a pull request' form
shift
[[ $kind == issue ]] && noun='an issue'
form=${COMMENT_FORM/gh pr/gh $kind}
while [[ $# -gt 0 ]]; do
a=$1; shift
case $a in
--edit-last) edit=1; continue ;;
--create-if-none) continue ;;
--*=*) v=${a#*=}; a=${a%%=*} ;;
-b|--body|-F|--body-file) [[ $# -gt 0 ]] || deny "gh $kind comment $a needs a value"; v=$1; shift ;;
-*) deny "gh $kind comment $a is not approved; $form" ;;
*) [[ -z $n && $a =~ ^[0-9]+$ ]] || deny "gh $kind comment takes $noun number, not '$a'; $form"
n=$a; continue ;;
esac
body_opt "$a" "$v" || deny "gh $kind comment $a is not approved; $form"
done
[[ $body -eq 1 ]] || deny "gh $kind comment needs --body or --body-file; $form"
gh_aims_here
[[ $edit -eq 1 ]] && allow "an edit of this login's latest comment on $noun in this repository"
allow "a comment on $noun in this repository"
}
check_gh_pr_review() {
local a v pr='' body=0 kind='' form=$PR_REVIEW_FORM
while [[ $# -gt 0 ]]; do
a=$1; shift
case $a in
-c|--comment|-r|--request-changes) [[ -z $kind || $kind == "$a" ]] || deny "one kind of review at a time; $form"
kind=$a; continue ;;
-a|--approve) deny "approving a pull request is not approved: an approval from this login counts toward \
branch protection, so it stays the user's to give; $form" ;;
--*=*) v=${a#*=}; a=${a%%=*} ;;
-b|--body|-F|--body-file) [[ $# -gt 0 ]] || deny "gh pr review $a needs a value"; v=$1; shift ;;
-*) deny "gh pr review $a is not approved; $form" ;;
*) [[ -z $pr && $a =~ ^[0-9]+$ ]] || deny "gh pr review takes a pull request number, not '$a'; $form"
pr=$a; continue ;;
esac
body_opt "$a" "$v" || deny "gh pr review $a is not approved; $form"
done
[[ -n $kind ]] || deny "gh pr review needs --comment or --request-changes; $form"
[[ $body -eq 1 ]] || deny "gh pr review needs --body or --body-file; $form"
gh_aims_here
allow "a review of a pull request in this repository"
}
check_gh_pr_create() {
local a v body=0 form=$PR_CREATE_FORM
while [[ $# -gt 0 ]]; do
a=$1; shift
case $a in
-d|--draft|-f|--fill|--fill-first|--fill-verbose|--no-maintainer-edit|--dry-run) continue ;;
-R|--repo|--repo=*) deny "gh pr create opens a pull request in this repository only; drop $a" ;;
-T|--template|--template=*) deny "gh pr create $NO_TEMPLATE" ;;
--*=*) v=${a#*=}; a=${a%%=*} ;;
-t|--title|-b|--body|-F|--body-file|-B|--base|-H|--head|-l|--label|-r|--reviewer|-a|--assignee|-m|--milestone|-p|--project)
[[ $# -gt 0 ]] || deny "gh pr create $a needs a value"; v=$1; shift ;;
-*) deny "gh pr create $a is not approved; $form" ;;
*) deny "gh pr create takes no arguments, only options; $form" ;;
esac
case $a in
-B|--base|-H|--head) branch_name "$v" || deny "gh pr create $a takes a branch of this repository, not '$v'" ;;
-t|--title|-l|--label|-r|--reviewer|-a|--assignee|-m|--milestone|-p|--project) ;;
*) body_opt "$a" "$v" || deny "gh pr create $a is not approved; $form" ;;
esac
done
gh_aims_here
allow "opening a pull request in this repository"
}
check_gh_pr() {
local sub=${1:-}
shift || true
case $sub in
view|diff|checks|list|status) allow "read-only gh pr $sub" ;;
comment) check_gh_comment pr "$@" ;;
review) check_gh_pr_review "$@" ;;
create) check_gh_pr_create "$@" ;;
esac
exit 0 # any other gh pr subcommand: no answer, so the permission lists decide
}
# -------------------------------------------------------------------------------------------- gh issue
check_gh_issue_create() {
local a v body=0 form=$ISSUE_CREATE_FORM
while [[ $# -gt 0 ]]; do
a=$1; shift
case $a in
-R|--repo|--repo=*) deny "gh issue create opens an issue in this repository only; drop $a" ;;
-T|--template|--template=*) deny "gh issue create $NO_TEMPLATE" ;;
--*=*) v=${a#*=}; a=${a%%=*} ;;
-t|--title|-b|--body|-F|--body-file|-l|--label|-a|--assignee|-m|--milestone|-p|--project)
[[ $# -gt 0 ]] || deny "gh issue create $a needs a value"; v=$1; shift ;;
-*) deny "gh issue create $a is not approved; $form" ;;
*) deny "gh issue create takes no arguments, only options; $form" ;;
esac
case $a in
-t|--title|-l|--label|-a|--assignee|-m|--milestone|-p|--project) ;;
*) body_opt "$a" "$v" || deny "gh issue create $a is not approved; $form" ;;
esac
done
gh_aims_here
allow "opening an issue in this repository"
}
check_gh_issue_edit() { # gh issue edit <n> …: one issue, which the gh login opened, as GitHub says
local a v n='' body=0 form=$ISSUE_EDIT_FORM
while [[ $# -gt 0 ]]; do
a=$1; shift
case $a in
--remove-milestone) continue ;;
--*=*) v=${a#*=}; a=${a%%=*} ;;
-t|--title|-b|--body|-F|--body-file|-m|--milestone|--add-label|--remove-label|--add-assignee|--remove-assignee|--add-project|--remove-project)
[[ $# -gt 0 ]] || deny "gh issue edit $a needs a value"; v=$1; shift ;;
-*) deny "gh issue edit $a is not approved; $form" ;;
*) [[ -z $n && $a =~ ^[0-9]+$ ]] || deny "gh issue edit takes one issue number, not '$a'; $form"
n=$a; continue ;;
esac
case $a in
-t|--title|-m|--milestone|--add-label|--remove-label|--add-assignee|--remove-assignee|--add-project|--remove-project) ;;
*) body_opt "$a" "$v" || deny "gh issue edit $a is not approved; $form" ;;
esac
done
[[ -n $n ]] || deny "gh issue edit needs the issue number; $form"
gh_aims_here
own "repos/{owner}/{repo}/issues/$n"
allow "an edit of issue #$n, which this login opened"
}
check_gh_issue() {
local sub=${1:-}
shift || true
case $sub in
view|list|status) allow "read-only gh issue $sub" ;;
comment) check_gh_comment issue "$@" ;;
create) check_gh_issue_create "$@" ;;
edit) check_gh_issue_edit "$@" ;;
esac
exit 0 # close, reopen, delete, transfer, lock, pin, develop…: the permission lists decide
}
# ---------------------------------------------------------------------------------------------- git push
check_git_push() {
local a pos=() form=$PUSH_FORM
while [[ $# -gt 0 ]]; do
a=$1; shift
case $a in
-u|--set-upstream|-q|--quiet|-v|--verbose|-n|--dry-run|--porcelain) ;;
-f|--force|--force-with-lease|--force-with-lease=*|--force-if-includes)
deny "force pushes are never approved; $form" ;;
-d|--delete|--prune|--mirror|--all|--branches|--tags|--follow-tags)
deny "git push $a touches more than one branch; $form" ;;
-*) deny "git push $a is not approved; $form" ;;
*) pos+=("$a") ;;
esac
done
[[ ${#pos[@]} -eq 2 ]] || deny "name the remote and the branch; $form"
local remote=${pos[0]} spec=${pos[1]} src dst url repo='' r def
[[ $remote =~ ^[A-Za-z0-9][A-Za-z0-9._-]*$ ]] || deny "push to a named remote, not '$remote'"
git -C "$cwd" remote 2>/dev/null | grep -qxF -- "$remote" || deny "'$remote' is not a remote of this repository"
# Find the real push target: --push applies pushurl and pushInsteadOf, and a plain get-url would not.
for url in $(git -C "$cwd" remote get-url --push --all "$remote" 2>/dev/null); do
r=$(github_repo "$url") || deny "remote '$remote' pushes to $url, which is not a github.com repository"
[[ -z $repo || $repo == "$r" ]] || deny "remote '$remote' pushes to more than one repository"
repo=$r
done
[[ -n $repo ]] || deny "remote '$remote' has no push URL"
[[ $spec == +* ]] && deny "a + refspec is a force push; $form"
[[ $spec == *'*'* || $spec == *:*:* ]] && deny "push one branch; $form"
src=${spec%%:*}
[[ -n $src ]] || deny "an empty source deletes the remote branch; $form"
if [[ $spec == *:* ]]; then
dst=${spec#*:}
else
# With no :<dst>, remote.<name>.push may map the source to a completely different place, so refuse to guess.
[[ -z $(git -C "$cwd" config --get-all "remote.$remote.push" 2>/dev/null) ]] \
|| deny "remote.$remote.push is set, so name the destination: $form"
dst=$src
[[ $src == HEAD ]] && { dst=$(git -C "$cwd" symbolic-ref -q --short HEAD 2>/dev/null) \
|| deny "HEAD is detached; $form"; }
fi
dst=${dst#refs/heads/}
[[ $src == HEAD ]] || branch_name "$src" || deny "push HEAD or a local branch, not '$src'; $form"
[[ $src == refs/* && $src != refs/heads/* ]] && deny "push a branch, not $src"
# A name is not a type: `git push origin v1` pushes the tag v1 when no branch has that name. A hash or a
# remote-tracking name pushes whatever it points at. So the source must be a local branch. When the source is
# a local branch, git makes a new unqualified destination a branch too. In that case, git also does not move
# a tag that is already at the destination unless the push uses force.
[[ $src == HEAD ]] || git -C "$cwd" show-ref --verify --quiet "refs/heads/${src#refs/heads/}" \
|| deny "'$src' is not a local branch (a tag, a commit or another kind of ref); $form"
[[ $dst == refs/* ]] && deny "push to a branch, not $dst"
branch_name "$dst" || deny "'$dst' is not a branch name git push approves"
# A local remote-tracking HEAD can be stale after a default-branch change. Only GitHub's answer is
# authoritative. When GitHub cannot answer, deny the push: do not risk a push to the current default branch.
command -v gh >/dev/null 2>&1 \
|| deny "gh is needed to check the default branch of $repo before pushing $dst; install gh and log in"
def=$(gh_here api "repos/$repo" --hostname github.com --jq '.default_branch // empty') \
|| deny "could not query github.com for the default branch of $repo; check gh auth status and network access, then retry the push"
branch_name "$def" \
|| deny "github.com did not return a valid default branch for $repo, so pushing $dst is denied"
[[ $dst == main || $dst == master || $dst == "$def" ]] \
&& deny "$dst is the default branch of $repo; agents push only to other branches — push to one and open a \
pull request"
allow "a push to $dst in $repo"
}
# ------------------------------------------------------------------------------------------------ wt pr
check_wt_pr() { # wt pr <name> [--draft]: check the push and the gh target in the selected worktree
local a name='' target
for a in "$@"; do
case $a in
--draft) ;;
-*) deny "wt pr $a is not approved; $WT_PR_FORM" ;;
*) [[ -z $name && $a =~ ^[a-z0-9][a-z0-9_-]*$ ]] || deny "wt pr takes one task name, not '$a'"; name=$a ;;
esac
done
[[ -n $name ]] || deny "wt pr needs the task name"
target=$(cd "$cwd" && wt path "$name" 2>/dev/null) \
|| deny "could not find task '$name' in this repository; wt pr needs an existing task worktree"
[[ -d $target ]] || deny "task '$name' has no worktree directory; wt pr cannot push it"
cwd=$target
gh_aims_here # wt pr runs gh pr create from this worktree after its push
check_git_push origin "wt/$name" # wt pr runs git push -u origin wt/<name> without another hook
}
# ------------------------------------------------------------------------------------------------- main
# Generate help and command prefixes from the same forms that the checkers use.
GH_API_FORMS="approved gh api forms: a read, $API_READ_FORM; and, in this repository, "
for row in "${GH_API_WRITES[@]}"; do
GH_API_FORMS+=${row##*~}
done
GH_API_FORMS+="and $API_GRAPHQL_FORM"
# The API prefix covers reads, GraphQL, and every write row. Match it once.
GUARDED='('
separator=''
for form in "$API_READ_FORM" "$PR_READ_FORM" "$ISSUE_READ_FORM" \
"$COMMENT_FORM" "${COMMENT_FORM/gh pr/gh issue}" "$PR_REVIEW_FORM" "$PR_CREATE_FORM" \
"$ISSUE_CREATE_FORM" "$ISSUE_EDIT_FORM" "$PUSH_FORM" "$WT_PR_FORM"; do
form=${form#approved form: }
read -r command subcommand operation _ <<<"$form"
prefix="${command}[[:space:]]+$subcommand"
if [[ $command == gh && $subcommand != api ]]; then
prefix+="[[:space:]]+($operation)"
fi
GUARDED+="$separator$prefix"
separator='|'
done
GUARDED+=')([[:space:]]|$)'
if [[ $ctrl == false ]] && tokenize "$cmd"; then
set -- "${WORDS[@]}"
lead=0
while [[ $# -gt 0 && $1 =~ ^[A-Za-z_][A-Za-z0-9_]*= ]]; do shift; lead=1; done
case "${1:-} ${2:-}" in
'gh api'|'gh pr'|'gh issue'|'git push'|'wt pr')
[[ $lead -eq 0 ]] || deny "run $1 $2 with no VAR=value in front of it" ;;
*) exit 0 ;;
esac
case "$1 $2" in
'gh api') shift 2; check_gh_api "$@" ;;
'gh pr') shift 2; check_gh_pr "$@" ;;
'gh issue') shift 2; check_gh_issue "$@" ;;
'git push') shift 2; check_git_push "$@" ;;
'wt pr') shift 2; check_wt_pr "$@" ;;
esac
exit 0
fi
# The command is not one clean simple command. The hook answers only a command that STARTS with one of the
# commands in GUARDED. The start is the one place where the hook can find a guarded command without a shell
# parser. Later in the command, a `git push` could equally be a line of a commit message in a heredoc.
lead='^[[:space:]]*([A-Za-z_][A-Za-z0-9_]*=[^[:space:]]*[[:space:]]+)*(cd[[:space:]]+[^[:space:]&;|]+[[:space:]]*&&[[:space:]]*)?'
if [[ $cmd =~ $lead$GUARDED ]]; then
deny "${BASH_REMATCH[3]} is approved only as one simple command: no pipe, redirect, && or ;, no \$(...), \
backticks or \$VARIABLES, no globs, and no cd in front (run the cd as its own command first: the directory \
carries over, and this hook reads it). Filter output with gh's own --jq '<expr>' rather than | jq, pass long \
text with --body-file, and quote any argument with ? or & in it."
fi
exit 0