Repository navigation
Expand file tree
/
Copy pathforge-readonly-guard.sh
More file actions
executable file
·1763 lines (1654 loc) · 70.2 KB
/
Copy pathforge-readonly-guard.sh
File metadata and controls
executable file
·1763 lines (1654 loc) · 70.2 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
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
#!/usr/bin/env bash
#
# forge-readonly-guard.sh
#
# Decides whether a candidate shell command is provably a read against the
# source forge, so a runtime can enforce the portfolio-surveyor's read-only
# boundary by construction instead of trusting the model to honour it.
#
# The surveyor's definition already states the boundary in prose — "your shell
# access exists solely to run the source-forge CLI's read verbs" — and asks
# deployments to enforce it in the permission/guard layer. This is the decision
# procedure that layer calls. It is tool-neutral: a Claude Code PreToolUse hook,
# a Codex approval guard, and a plain wrapper all ask the same question.
#
# forge-readonly-guard.sh --command '<command>'
# <command> | forge-readonly-guard.sh --stdin
#
# exit 0 allowed — every pipeline segment is a recognised read
# exit 1 denied — prints `deny: <reason>`
# exit 2 usage error
#
# Deny by default, in two layers.
#
# LAYER 1 — resolve one literal argument vector, or refuse. The command is
# walked once to split it on unquoted `|` and refuse every construct that could
# smuggle a second command in, then walked again to produce the argv the shell
# itself would build: quotes removed, escapes applied, words split only where
# the shell would split them. Whatever the guard cannot resolve to a literal it
# REFUSES rather than guesses at — brace expansion, ANSI-C quoting and globbing
# all rewrite a word into something the guard never saw, and the rewritten form
# is precisely what classification depends on.
#
# LAYER 2 — classify that argv against an allowlist. A subcommand, a flag, or a
# filter operand that is not positively recognised as a read is denied, so a new
# or renamed verb fails closed rather than passing unnoticed.
#
# The split matters: a guard that classifies command TEXT is re-implementing the
# shell's parser, and it loses. Every bypass found in review was another
# spelling of a word bash rewrites before the program sees it — an attached
# flag, `{a,b}`, `$'\x6d'`, a delimiter inside a sed flag. Resolving argv first
# deletes that whole class instead of blacklisting its members one at a time.
#
# Three limits are deliberate and stated rather than hidden:
#
# * Parameter expansion is allowed, because the surveyor's own prescriptions
# use it, and it is the one expansion the guard cannot resolve without
# knowing the environment. A deployment that also lets the agent set
# arbitrary environment variables must constrain that separately.
# * The allowlists are the surveyor's measured vocabulary, not every read a
# forge CLI offers. Widening one is a reviewed edit here, which is the
# point — and a false deny reports the flag it did not recognise, so the
# widening is a one-line change rather than an investigation.
# * A consuming deployment's own reviewed classifiers are admitted only when
# that deployment DECLARES them, in the hook environment, as absolute paths
# (SURVEYOR_FORGE_READONLY_CLASSIFIERS). Declaring a path is a trust
# assertion the guard cannot verify — see the variable's own note below —
# so it is deliberately not inferable from the filesystem, from a directory
# convention, or from anything the classified agent can write. Unset admits
# nothing.
# * Git resolves configuration AFTER parsing, so no inspection of argv can show
# what a configuration key names. Where the effect is unconditional the guard
# acts: it excludes the remote-contacting verbs, whose URL comes from
# `remote.<name>.url`, and requires `--no-ext-diff`/`--no-textconv` on a
# patch-producing read. `core.pager` is the residue — it runs only when git's
# output is a terminal, so a request to page (`--paginate`) is refused, but a
# deployment that attaches a TTY should also set `GIT_PAGER=cat`. What argv
# cannot express, a guard over argv cannot certify.
# Environment: GitHub CLI's default telemetry creates gh/device-id on an
# otherwise-allowed read. The guard therefore requires a disabling
# GH_TELEMETRY (0 or false) in the process environment before it classifies
# any gh segment; argv cannot carry that value (an env-prefixed command is
# denied). Deployments must export GH_TELEMETRY=0 in the runtime that
# invokes gh, the same way a TTY-attached host should set GIT_PAGER=cat.
# Local Git reads also require GIT_NO_LAZY_FETCH=1 in the shared process
# environment so partial clones cannot invoke a missing-object transport.
#
# Written for bash 3.2 so it runs on a stock macOS agent host as well as CI.
#
set -euo pipefail
GUARD_DIR=$(CDPATH='' cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd -P)
DEFAULT_BRANCH_CLASSIFIER="${GUARD_DIR}/classify-default-branch-ci-runs.sh"
UNRESOLVED_THREADS_COUNTER="${GUARD_DIR}/count-unresolved-review-threads.sh"
# Consumer-declared, stdin-only classifiers (default: none).
#
# The guard bundles exactly two local helpers, both its own BASH_SOURCE siblings. A
# CONSUMING deployment also ships reviewed classifiers the surveyor is told to
# call — the PR-ownership disclosure classifier is the measured case — and those
# had no allowed shape at all: denied leading (not a forge command) and denied as
# a filter (not on the allowlist). The surveyor's documented fallback is to
# re-derive the match by hand, on a surface its own definition marks hazardous,
# and that substitution has already misread live maintainer PRs.
#
# Colon-separated ABSOLUTE paths, supplied by the same hook configuration that
# supplies SURVEYOR_FORGE_READONLY_SCOPE. Unset — the default — leaves behaviour
# byte-identical to before, so this is strictly additive.
#
# Three properties keep the widening narrow, and all three are enforced below:
#
# * NON-LEADING ONLY. The pipeline must still start at the forge, so a
# declared classifier only ever consumes forge output on stdin. It can never
# be the thing that opens a local file, which is the invariant that keeps
# `cat ~/.config/gh/hosts.yml` out.
# * STDIN-ONLY ARGV. Exactly `--input -` and nothing else. No path operand, no
# repository or PR selector, so argv cannot redirect it at the filesystem or
# at a second network target.
# * EXACT ABSOLUTE MATCH. No PATH lookup and no basename comparison, so a
# same-named script earlier on PATH is not the declared one.
#
# What this CANNOT do is verify that a declared path is in fact a read-only
# filter. Declaring one asserts it, exactly as the allowlists above assert that
# `gh pr list` reads. That is why the list lives in the hook environment — the
# guard's own trust domain, which the classified agent does not write — and why
# an empty value admits nothing rather than everything.
CONSUMER_CLASSIFIERS="${SURVEYOR_FORGE_READONLY_CLASSIFIERS:-}"
deny() {
printf 'deny: %s\n' "$1"
exit 1
}
die() {
printf 'forge-readonly-guard: %s\n' "$1" >&2
exit 2
}
# Filters with no in-language write primitive: without shell redirection, which
# the scanner already refuses, none of these can create or modify a file. awk is
# absent on purpose — its `print > "file"` needs no shell redirection at all.
#
# Not writing is only half of it: a filter that takes a FILE operand reads local
# state, and `cat ~/.config/gh/hosts.yml` is a credential read wearing a filter's
# name. So a filter may never open the pipeline, and each one is held to a
# whitelist of flags plus a cap on positional operands — enough for the pattern
# or program it needs, never enough for a path.
#
# Per filter: flags that stand alone, flags that consume the next word, and how
# many positional operands the filter legitimately takes on stdin.
FILTER_FLAGS_JQ=" -r -c -e -n -s -j -a -R --tab --slurp --raw-output --compact-output --exit-status --raw-input --null-input --args "
FILTER_VALUE_FLAGS_JQ=" --indent "
FILTER_FLAGS_GREP=" -E -F -i -v -c -o -q -n -h -w -x -s --line-buffered --extended-regexp --fixed-strings --ignore-case --invert-match --count --only-matching --quiet "
FILTER_VALUE_FLAGS_GREP=" -e -m -A -B -C --regexp --max-count --after-context --before-context --context "
FILTER_FLAGS_SORT=" -u -r -n -h -b -f -V --unique --reverse --numeric-sort --version-sort "
FILTER_VALUE_FLAGS_SORT=" -k -t --key --field-separator "
# `sort` is the one filter here that can touch the filesystem at all: past its
# in-memory buffer GNU sort spills to a temporary file. It stays allowed, and the
# reason is what the flag lists must keep true — every spelling that would let a
# caller AIM that write is withheld. `-o`/`--output` names an output file, `-T`/
# `--temporary-directory` chooses where the spill lands, `-S`/`--buffer-size`
# lowers the threshold that triggers it, and `--files0-from` reads a file list;
# none is allowlisted, so the residue is an internal temp file sort creates and
# unlinks in `$TMPDIR`, unreachable from argv. That is a different thing from the
# workspace/credential writes this guard exists to stop.
FILTER_FLAGS_HEADTAIL=" -q -v "
FILTER_VALUE_FLAGS_HEADTAIL=" -n -c --lines --bytes "
FILTER_FLAGS_UNIQ=" -c -d -u -i --count --repeated --unique "
FILTER_FLAGS_WC=" -l -w -c -m --lines --words --chars "
FILTER_FLAGS_CUT=" -s --only-delimited "
FILTER_VALUE_FLAGS_CUT=" -d -f -c -b --delimiter --fields --characters --bytes "
FILTER_FLAGS_TR=" -d -s -c -C --delete --squeeze-repeats --complement "
FILTER_FLAGS_CAT=" -n -b -s --number "
# A flag that supplies grep's PATTERN, in any spelling: once one is present the
# remaining positional words are FILES, so the operand cap must drop to zero.
FILTER_PATTERN_FLAGS_GREP=" -e --regexp "
# gh api flags, split by how they are consumed. Anything outside these three
# sets is denied: an unrecognised flag is a flag whose effect on the request
# method — and so on whether this is a read — has not been established.
#
# Two flags a read plainly "needs" are absent on purpose. `--cache <duration>`
# makes gh write a persistent response cache, so the request is a file write in
# everything but name. `--hostname <host>` retargets the request while gh still
# attaches the deployment's credential, which turns any allowed read into a
# token exfiltration to a host the command names. Neither is recoverable by
# validating its value: the effect is the flag.
GH_API_STANDALONE_FLAGS=" --paginate --slurp -i --include --verbose --silent "
GH_API_VALUE_FLAGS=" --jq -q --method -X --header -H --template -t --preview -p "
# Flags for gh's read VERBS (pr list, issue view, …). A read verb is not enough
# on its own: `gh pr view --web` opens a URL through `$BROWSER`, which runs a
# local program the guard never classified. So the verbs are allowlisted too.
GH_VERB_FLAGS=" --draft --no-draft --archived --no-archived --merged --closed --comments --paginate --fork --source --include-prs --exclude-drafts --checks --required "
GH_VERB_VALUE_FLAGS=" -R --repo --state --limit -L --json --jq -q --search --author --owner --assignee --label --milestone --app --branch --workflow --event --user --sort --order --created --updated --language --match --visibility --topic --exclude --head --base --commit --template -t --filter --commenter --merged-at "
# gh switches the request to POST as soon as a field argument is set, UNLESS an
# explicit `--method GET` is also given — then gh serialises it into the query
# string and the request stays a read. So the four FIELD spellings below —
# `-f`, `--raw-field`, `-F`, `--field` — survive on graphql (which has no other way
# to carry a query) and on an explicit GET.
# Two members of this group are NOT covered by that and are denied for every
# method, GET included: `--input` reads a local file as the request body, and a
# field VALUE beginning with `@` makes gh read the file it names. Both are
# independent rules that fire earlier, and neither is widened by an explicit GET.
GH_API_FIELD_FLAGS=" -f --raw-field -F --field --input "
# Value grammars that legitimately begin with a dash. A value-taking flag consumes the
# next word whatever it looks like — gh's flag parser and git's alike — so `--repo --web`
# never reaches gh as a flag. That inertness lives in a parser the guard does not assert,
# and it stops holding the day any value flag grows an optional-value grammar. So the
# guard classifies the consumed word itself (check_consumed_value): a flag-shaped value
# is denied by name unless its flag is listed here, where a leading dash is part of the
# value's own grammar and the value can name no program, host, or file:
# --search a server-side query expression (`-label:foo` negates a qualifier)
# --label, --milestone free-form server-side filter strings — a label or milestone
# title may itself begin with a dash (`--label -bug`)
# --jq / -q a jq program (`-1`, `-(.x)`) run by gh's embedded jq; its reach into
# the process environment is refused separately by check_gh_flag_value
# --template/-t a Go template rendered locally, with no exec or file primitive
# git --grep, -S, -G, --author, --committer a regex or pickaxe string matched
# against history (`--author -bot` matches authors containing `-bot`)
# git --since, --until, --after, --before a date expression, which git's
# approxidate parser accepts in free form (`--since -yesterday`)
# Everything else — a count, a path, a ref, a format name — has a grammar no
# dash-letter word can satisfy, so a flag-shaped value there is denied by name.
GH_DASH_VALUE_FLAGS=" --search --label --milestone --jq -q --template -t "
GIT_DASH_VALUE_FLAGS=" --grep -S -G --author --committer --since --until --after --before "
# git options. Read verbs are not enough on their own: several options make git
# write a file or execute a program without any shell syntax for the scanner to
# catch, so options are allowlisted like everything else.
#
# `--paginate` is deliberately absent: it asks git to run `core.pager`, a program
# named by configuration the guard cannot see.
# `--work-tree` is deliberately absent, so it is refused as unrecognised. It
# repoints git at an arbitrary directory while the surveyed repository still
# supplies the index, so an otherwise-allowed `diff` prints the contents of files
# the guard never scoped — a plain read that exfiltrates whatever the caller aims
# it at. Nothing in the surveyor's vocabulary needs it; `-C` reaches another
# repository without detaching the tree from its own repository.
GIT_VALUE_FLAGS=" -C -c --git-dir --exec-path --namespace "
GIT_OK_FLAGS=" --no-pager --no-optional-locks --no-ext-diff --no-textconv --oneline --no-color --color --graph --decorate --no-decorate --abbrev-commit --all --branches --tags --remotes --heads --refs --stat --numstat --shortstat --name-only --name-status --porcelain --short --long --branch --verbose --count --not --reverse --first-parent --merges --no-merges --quiet --verify --symbolic --symbolic-full-name --abbrev-ref --show-toplevel --is-inside-work-tree --is-bare-repository --cached --staged --patch -p --no-patch --raw --text --exit-code --no-renames --topo-order --date-order --left-right --boundary --parents --children --objects --stdin --binary --full-history --follow --no-prefix --numbered "
# Flags a PATCH-PRODUCING git read must CARRY, not merely be allowed to carry.
# Generating patch text is what reaches the two mechanisms whose program comes
# from configuration rather than argv, unconditionally and regardless of whether
# a terminal is attached: `diff.external` replaces the diff engine outright, and a
# `diff.<driver>.textconv` driver named by a gitattributes entry is run over each
# blob. `--no-ext-diff` and `--no-textconv` switch them off.
#
# `diff` and `show` produce a patch by default. `log` does so only when asked, so
# it pays this cost only when a patch flag is present — which is what keeps the
# surveyor's own `git log --oneline` and `git status` unchanged.
# `-u` is deliberately not listed: it means `--untracked-files` to `status`, so
# treating it as a patch flag would tax an ordinary status read. It is not on
# GIT_OK_FLAGS either, so it is refused as unrecognised rather than misread.
GIT_PATCH_VERBS=" show diff "
GIT_PATCH_FLAGS=" -p --patch "
# The documented built-in `--pretty`/`--format` names. Any other bare name is a
# `pretty.<name>` configuration lookup, whose expansion the guard cannot see.
GIT_PRETTY_BUILTINS="oneline short medium full fuller reference email mboxrd raw"
# `status --verbose` prints a STAGED PATCH, so it reaches `diff.external` and the
# textconv drivers exactly as `diff` does — but `status` is not a patch verb and
# `--verbose` is not a patch flag, so neither rule fired and the suppression was
# never demanded. It is the one verb where a non-patch flag produces a patch.
GIT_STATUS_PATCH_FLAGS=" -v --verbose "
GIT_PATCH_REQUIRED_FLAGS=" --no-ext-diff --no-textconv "
# Refreshing the index is when git consults `core.fsmonitor`, which is a HOOK
# PROGRAM named in repository configuration and run before any output appears —
# the same argv-invisible blind spot as `diff.external`, reached by a plain read.
#
# Measured on git 2.50.1 against a repository configuring it, with a DIRTY
# worktree: `status`, `diff` and `ls-files` each execute the hook, while `log`,
# `show`, `rev-parse`, `rev-list`, `cat-file` and `describe --dirty` do not. A
# clean worktree hides most of it — nothing needs refreshing — so the verb set
# comes from the dirty case, which is the state a survey actually meets.
#
# `-c core.fsmonitor=` switches it off and leaves output byte-identical, so this
# takes the patch precedent rather than excluding the verbs: require the
# suppression exactly where the hook is reachable, and leave every other read
# untaxed. Only the empty value is admitted; `-c core.fsmonitor=/path` SETS the
# hook, so `-c` stays denied for every other assignment.
GIT_FSMONITOR_VERBS=" status diff ls-files "
GIT_FSMONITOR_SUPPRESSION="core.fsmonitor="
GIT_OK_VALUE_FLAGS=" -C --git-dir -n --max-count --max-parents --min-parents --since --until --after --before --author --committer --grep --pretty --format --date --unified -U --diff-filter -L -S -G --abbrev --contains --no-contains --merged --no-merged --sort --points-at --glob --exclude "
SEGMENTS=()
SEGMENT_COUNT=0
WORDS=()
# Walk the command one character at a time, tracking quote state, splitting on
# unquoted `|` and refusing every other unquoted construct that could smuggle a
# second command in — or rewrite a word into one the guard never classified.
# `${VAR}` is a parameter expansion the guard deliberately tolerates; `${VAR:-…}`
# and its relatives are not the same thing. Every one of them carries text INSIDE
# the expansion that becomes argv when the variable is unset or empty — so
# `gh api "${UNSET:---method}" "${UNSET:-POST}" repos/x/y` is a POST that needs no
# control over the environment at all. A plain name expands to whatever the
# environment holds, which is the documented limit; an operator expands to text
# written in the command itself, which is a bypass.
check_expansion() {
local body=$1
if [[ ! "$body" =~ ^[A-Za-z_][A-Za-z0-9_]*$ ]]; then
deny 'a parameter expansion carrying an operator can synthesize arguments — expand it yourself'
fi
}
scan_segments() {
local s=$1
local n=${#s}
local state=none
local cur=''
local i=0 ch nxt prev='' j ec rt d w t
while [ "$i" -lt "$n" ]; do
ch=${s:$i:1}
nxt=${s:$((i + 1)):1}
if [ "$state" = single ]; then
if [ "$ch" = "'" ]; then state=none; fi
cur=$cur$ch
prev=$ch
i=$((i + 1))
continue
fi
if [ "$state" = double ]; then
case "$ch" in
\\)
# Bash DELETES a backslash-newline before the program sees it, so
# `query="muta\<newline>tion{x}"` reaches gh as `mutation{x}` while the
# text the guard reads contains no such keyword.
case "$nxt" in
$'\n' | $'\r') deny 'a line continuation is removed before the command sees it' ;;
esac
cur=$cur$ch$nxt
prev=$nxt
i=$((i + 2))
continue
;;
'`') deny 'backtick command substitution is not a read' ;;
'$')
case "$nxt" in
'(') deny 'dollar-paren command substitution is not a read' ;;
[0-9] | '*' | '@' | '#' | '?' | '-' | '!' | '$')
deny "the parameter expansion \$$nxt is removed or rewritten before the command sees it — expand it yourself"
;;
'{')
j=$((i + 2))
ec=''
while [ "$j" -lt "$n" ] && [ "${s:$j:1}" != '}' ]; do
ec=$ec${s:$j:1}
j=$((j + 1))
done
check_expansion "$ec"
;;
esac
;;
'"') state=none ;;
esac
cur=$cur$ch
prev=$ch
i=$((i + 1))
continue
fi
case "$ch" in
# An unquoted `#` that STARTS a word begins a comment: bash discards it and
# everything after it. The scanner would otherwise keep reading those words
# as real arguments — so `git show HEAD # --no-ext-diff --no-textconv`
# classifies as fully suppressed while the shell executes only
# `git show HEAD`, and the configured diff/textconv program runs after all.
# Every required-flag check in this guard is defeated the same way, which is
# why this is refused at the parser rather than per verb. Mid-word `#` is
# ordinary data (`issues#1`) and is untouched.
'#')
if [ -z "$prev" ] || [ "$prev" = ' ' ] || [ "$prev" = $'\t' ]; then
deny 'an unquoted # starts a shell comment, so the words after it are discarded before the command runs'
fi
cur=$cur$ch
;;
"'")
state=single
cur=$cur$ch
;;
'"')
state=double
cur=$cur$ch
;;
\\)
case "$nxt" in
$'\n' | $'\r') deny 'a line continuation is removed before the command sees it' ;;
esac
cur=$cur$ch$nxt
prev=$nxt
i=$((i + 2))
continue
;;
'`') deny 'backtick command substitution is not a read' ;;
'$')
case "$nxt" in
'(') deny 'dollar-paren command substitution is not a read' ;;
"'") deny "ANSI-C quoting \$'…' rewrites the word before the command sees it" ;;
'"') deny 'locale-translation quoting $"…" rewrites the word before the command sees it' ;;
# A POSITIONAL or SPECIAL parameter, unbraced. `${9}` already fails
# check_expansion, but `$9` never reached it, and the difference is not
# cosmetic: an execution shell normally has no ninth argument, so `$9`
# expands to NOTHING and splices the words either side of it together —
# `-f 'muta'$9'tion{x}'` is classified as the literal `muta$9tion{x}`
# and executed as `mutation{x}`. That needs no control over the
# environment, only the knowledge that the parameter is unset.
[0-9] | '*' | '@' | '#' | '?' | '-' | '!' | '$')
deny "the parameter expansion \$$nxt is removed or rewritten before the command sees it — expand it yourself"
;;
'{')
j=$((i + 2))
ec=''
while [ "$j" -lt "$n" ] && [ "${s:$j:1}" != '}' ]; do
ec=$ec${s:$j:1}
j=$((j + 1))
done
check_expansion "$ec"
;;
esac
cur=$cur$ch
;;
# Brace expansion turns one word into several — a flag, an operand, a path
# the guard never classified. `${…}` is parameter expansion, which is
# allowed by documented exception, so only a brace NOT introduced by `$`
# is refused.
'{')
if [ "$prev" != '$' ]; then
deny 'brace expansion rewrites the word before the command sees it — quote it'
fi
cur=$cur$ch
;;
# Pathname expansion has the same property: a matching filename becomes an
# argument the guard never saw, and a crafted one can be a flag.
'*' | '?' | '[')
deny "pathname expansion '$ch' rewrites the word before the command sees it — quote it"
;;
'<')
if [ "$nxt" = '(' ]; then deny 'process substitution <( ) is not a read'; fi
deny 'input redirection is not a read'
;;
'>')
if [ "$nxt" = '(' ]; then deny 'process substitution >( ) is not a read'; fi
# Two redirection targets provably create NO file: duplicating a file
# descriptor (`2>&1`) and the null device (`2>/dev/null`). Both are
# ordinary read idioms — merging or silencing stderr — and refusing them
# denied 17 of 120 measured surveyor commands that write nothing at all.
# Everything that can name a file still denies, including `>&word`,
# which bash treats as `&>word` and which DOES write one.
j=$((i + 1))
rt=''
if [ "${s:$j:1}" = '&' ]; then
j=$((j + 1))
d=''
while [ "$j" -lt "$n" ]; do
case "${s:$j:1}" in [0-9]) d=$d${s:$j:1}; j=$((j + 1)) ;; *) break ;; esac
done
# A bare `>&` with no digits is `&>`, and `>&-` closes a descriptor;
# only a digit run followed by a word boundary is a duplication.
if [ -n "$d" ]; then
case "${s:$j:1}" in '' | ' ' | $'\t' | $'\n' | $'\r' | '|' | ';' | '&' | '<' | '>') rt=dup ;; esac
fi
else
while [ "$j" -lt "$n" ]; do
case "${s:$j:1}" in ' ' | $'\t') j=$((j + 1)) ;; *) break ;; esac
done
w=''
while [ "$j" -lt "$n" ]; do
case "${s:$j:1}" in ' ' | $'\t' | $'\n' | $'\r' | '|' | ';' | '&' | '<' | '>') break ;; *) w=$w${s:$j:1}; j=$((j + 1)) ;; esac
done
# Exact match only: `/dev/nullx` is a file, and a quoted spelling is
# left to deny rather than unquoted here on a guess.
if [ "$w" = '/dev/null' ]; then rt=null; fi
fi
if [ -n "$rt" ]; then
# The redirection is not part of argv. Drop the target, and drop a
# leading fd digit run only when it is a WORD of its own — in
# `foo2>/dev/null` bash reads `foo2` as an argument, not a descriptor.
case "$cur" in
*[0-9])
t=$cur
while [ -n "$t" ]; do
case "$t" in *[0-9]) t=${t%?} ;; *) break ;; esac
done
case "$t" in '' | *' ' | *$'\t') cur=$t ;; esac
;;
esac
prev='>'
i=$j
continue
fi
deny 'output redirection writes a file'
;;
';') deny 'chaining with ; can carry a write' ;;
'&') deny '& backgrounding or && chaining can carry a write' ;;
'|')
SEGMENTS[${#SEGMENTS[@]}]=$cur
cur=''
;;
$'\n' | $'\r') deny 'a newline can carry a second command' ;;
*) cur=$cur$ch ;;
esac
prev=$ch
i=$((i + 1))
done
if [ "$state" != none ]; then
deny 'unbalanced quoting — the command cannot be classified'
fi
SEGMENTS[${#SEGMENTS[@]}]=$cur
}
# Second pass: build the literal argument vector the shell would pass to the
# program. Quotes are REMOVED rather than left on the word, which is the whole
# difference between classifying argv and classifying text — `grep -E 'a b'` is
# one operand to grep, and a scanner that splits on whitespace reads it as two
# and denies an ordinary read.
#
# Everything this pass cannot resolve was already refused by scan_segments, so
# it only has to apply quote removal and escaping.
tokenize_segment() {
local s=$1
local n=${#s}
local i=0 ch nxt
local state=none
local cur='' started=0
WORDS=()
while [ "$i" -lt "$n" ]; do
ch=${s:$i:1}
if [ "$state" = single ]; then
if [ "$ch" = "'" ]; then
state=none
else
cur=$cur$ch
fi
i=$((i + 1))
continue
fi
if [ "$state" = double ]; then
if [ "$ch" = "\\" ]; then
nxt=${s:$((i + 1)):1}
case "$nxt" in
'"' | "\\" | '$' | '`')
cur=$cur$nxt
i=$((i + 2))
;;
*)
cur=$cur$ch
i=$((i + 1))
;;
esac
continue
fi
if [ "$ch" = '"' ]; then
state=none
i=$((i + 1))
continue
fi
cur=$cur$ch
i=$((i + 1))
continue
fi
case "$ch" in
"'")
state=single
started=1
;;
'"')
state=double
started=1
;;
\\)
nxt=${s:$((i + 1)):1}
cur=$cur$nxt
started=1
i=$((i + 1))
;;
' ' | $'\t')
if [ "$started" -eq 1 ]; then
WORDS[${#WORDS[@]}]=$cur
cur=''
started=0
fi
;;
*)
cur=$cur$ch
started=1
;;
esac
i=$((i + 1))
done
if [ "$started" -eq 1 ]; then WORDS[${#WORDS[@]}]=$cur; fi
}
# Split one argv word into a flag name and, when the word carries its value
# attached, that value. `--method=GET`, `-XGET` and `-fbody=hi` are all forms gh
# and the filters accept, and a check that only understands the separated form
# reads every attached one as "flag absent".
#
# Reports through globals: FLAG_NAME, FLAG_VALUE, FLAG_HAS_VALUE.
FLAG_NAME=''
FLAG_VALUE=''
FLAG_HAS_VALUE=0
split_flag() {
local w=$1
FLAG_NAME=''
FLAG_VALUE=''
FLAG_HAS_VALUE=0
case "$w" in
--*=*)
FLAG_NAME=${w%%=*}
FLAG_VALUE=${w#*=}
FLAG_HAS_VALUE=1
;;
--*) FLAG_NAME=$w ;;
-?) FLAG_NAME=$w ;;
-?*)
FLAG_NAME=${w:0:2}
FLAG_VALUE=${w:2}
FLAG_HAS_VALUE=1
;;
*) FLAG_NAME=$w ;;
esac
}
# Exact-match a resolved argv word. Compares whole words rather than substrings,
# so `--no-pager` is not satisfied by some longer flag that merely contains it.
words_contain() {
local want=$1 w i=1
while [ "$i" -lt "${#WORDS[@]}" ]; do
w=${WORDS[$i]}
[ "$w" != -- ] || break
if [ "$w" = "$want" ]; then return 0; fi
split_flag "$w"
if [ "$FLAG_HAS_VALUE" -eq 0 ]; then
case "$GIT_OK_VALUE_FLAGS -c " in *" $FLAG_NAME "*) i=$((i + 1)) ;; esac
fi
i=$((i + 1))
done
return 1
}
# `-c name=value` is two adjacent words, and only the pair carries the meaning:
# `-c` alone sets nothing and the value alone is a pathspec. Reporting through
# the exit status keeps this callable directly — a helper that returned the
# index through stdout would run in a subshell and lose it.
words_contain_pair() {
local first=$1 second=$2 i=0
local n=${#WORDS[@]}
while [ "$i" -lt "$((n - 1))" ]; do
[ "${WORDS[$i]}" != -- ] || break
if [ "${WORDS[$i]}" = "$first" ] && [ "${WORDS[$((i + 1))]}" = "$second" ]; then
return 0
fi
i=$((i + 1))
done
return 1
}
# The first non-flag word in WORDS at or after $1, skipping flags and the values
# they consume ($2 is a space-delimited set of value-taking flags).
#
# Reports through the globals SUB_WORD and SUB_INDEX rather than stdout, because
# a caller needs both the word and where it sat — and reading the word through
# `$(...)` would run this in a subshell, where the index assignment dies with it.
SUB_WORD=''
SUB_INDEX=-1
find_subcommand() {
local i=$1
local value_flags=$2
local w
SUB_WORD=''
SUB_INDEX=-1
while [ "$i" -lt "${#WORDS[@]}" ]; do
w=${WORDS[$i]}
case "$w" in
--)
i=$((i + 1))
continue
;;
-*)
case "$value_flags" in
*" $w "*) i=$((i + 2)) ;;
*) i=$((i + 1)) ;;
esac
continue
;;
*)
SUB_WORD=$w
SUB_INDEX=$i
return 0
;;
esac
done
return 0
}
# GraphQL is served over POST, so the method cannot separate a read from a write
# here — the operation keyword does. The spec has exactly three operation types,
# and an anonymous `{ … }` document is a query, so refusing the other two is
# exhaustive rather than a blacklist with gaps. The check runs on the RESOLVED
# field value, which is what makes it exhaustive: `$'\x6dutation'` and
# `'mutation'` are the same word by the time it is asked.
check_graphql_document() {
local v=$1
if [[ "$v" =~ (^|[^A-Za-z])[Mm][Uu][Tt][Aa][Tt][Ii][Oo][Nn]([^A-Za-z]|$) ]]; then
deny 'GraphQL mutation is a write'
fi
if [[ "$v" =~ (^|[^A-Za-z])[Ss][Uu][Bb][Ss][Cc][Rr][Ii][Pp][Tt][Ii][Oo][Nn]([^A-Za-z]|$) ]]; then
deny 'GraphQL subscription is not a bounded read'
fi
return 0
}
classify_gh_api() {
local api_at=$1 i=1 w name val
local n=${#WORDS[@]}
local endpoint='' seen_endpoint=0
local field_count=0 method_count=0
local field_values='' m
# An ARRAY, not a string. `set -euo pipefail` does not disable pathname
# expansion, so iterating an unquoted string would expand a method value of `*`
# against the current directory — and a file named `GET` there would then read
# as an allowed method.
local methods=()
# gh takes the LAST method flag it is given, so a first harmless one is not
# evidence of anything: collect every occurrence and hold them all to the
# allowed method, which makes the check independent of that precedence.
while [ "$i" -lt "$n" ]; do
# `gh` accepts attached api flags before the subcommand. Classify the whole
# argv around the located `api` word so those flags cannot escape the
# request-policy checks below.
if [ "$i" -eq "$api_at" ]; then
i=$((i + 1))
continue
fi
w=${WORDS[$i]}
case "$w" in
--)
i=$((i + 1))
continue
;;
-*) ;;
*)
if [ "$seen_endpoint" -eq 0 ]; then
# An absolute URL lets the endpoint choose the outbound host. That is a
# destination decision, and under this deployment's egress rules the
# destination is never taken from text the agent read. A relative path
# can only ever address the configured forge.
case "$w" in
*://*) deny 'gh api endpoint is an absolute URL, which chooses the outbound host' ;;
esac
endpoint=$w
seen_endpoint=1
fi
i=$((i + 1))
continue
;;
esac
split_flag "$w"
name=$FLAG_NAME
val=$FLAG_VALUE
if [ "$FLAG_HAS_VALUE" -eq 0 ]; then
case "$GH_API_STANDALONE_FLAGS" in
*" $name "*)
i=$((i + 1))
continue
;;
esac
fi
case "$GH_API_FIELD_FLAGS$GH_API_VALUE_FLAGS" in
*" $name "*) ;;
*) deny "gh api flag '$w' is not on the read-only allowlist" ;;
esac
if [ "$FLAG_HAS_VALUE" -eq 0 ]; then
if [ $((i + 1)) -ge "$n" ]; then deny "gh api $name needs a value"; fi
val=${WORDS[$((i + 1))]}
check_consumed_value 'gh api' "$GH_DASH_VALUE_FLAGS" "$name" "$val"
i=$((i + 1))
fi
case "$GH_API_FIELD_FLAGS" in
*" $name "*)
field_count=$((field_count + 1))
if [ "$name" = --input ]; then
deny 'gh api --input reads a local file and makes the request a POST'
fi
# `@value` makes gh read that path and send its contents — a credential
# read wearing a field's name.
case "$val" in
*=@*) deny "gh api $name reads the local file named after @" ;;
@*) deny "gh api $name reads the local file named after @" ;;
esac
field_values="$field_values
$val"
i=$((i + 1))
continue
;;
esac
check_gh_flag_value "$name" "$val"
case "$name" in
--method | -X)
methods+=("$(printf '%s' "$val" | tr '[:lower:]' '[:upper:]')")
# Counted separately: `set -u` under bash 3.2 makes "${methods[@]}" an
# error on an empty array, so a plain counter is the portable way to ask
# "was a method given at all?" below.
method_count=$((method_count + 1))
;;
esac
i=$((i + 1))
done
if [ "$endpoint" = graphql ]; then
for m in ${methods+"${methods[@]}"}; do
if [ "$m" != POST ] && [ "$m" != GET ]; then
deny "gh api graphql --method $m is not a read"
fi
done
while IFS= read -r m; do
if [ -n "$m" ]; then check_graphql_document "$m"; fi
done <<EOF
$field_values
EOF
return 0
fi
for m in ${methods+"${methods[@]}"}; do
if [ "$m" != GET ]; then deny "gh api --method $m is not a read"; fi
done
# A field argument switches gh to POST *unless* an explicit GET method is given,
# in which case gh serialises the fields into the query string — still a read.
# The loop above already denied every non-GET method, so reaching here with a
# method means that method is GET. No method at all means gh's POST default.
# This narrows only the METHOD question: the @file and --input denials are
# independent, fire earlier, and are unaffected.
if [ "$field_count" -gt 0 ] && [ "$method_count" -eq 0 ]; then
deny 'gh api field arguments make the request a POST without an explicit --method GET'
fi
if [ "$seen_endpoint" -eq 0 ]; then
deny 'gh api needs an endpoint to be classified'
fi
return 0
}
# Flags on an allowed gh read verb. Deny by default, exactly as for `gh api`:
# the verb says what gh will fetch, the flags say what else it will do.
# An allowlisted gh flag can still carry its effect in its VALUE, and two of them do.
#
# * `--repo` takes `[HOST/]OWNER/REPO`, so a host in that value retargets the request
# while gh still attaches a credential for it — the same outbound-destination and
# token-disclosure effect that keeps `--hostname` off the allowlist entirely. Only
# the bare `OWNER/REPO` form is admitted.
# * `--jq` is evaluated by gh's OWN formatter, which enables `env` exactly as the
# standalone tool does — so `--jq 'env.GH_TOKEN'` prints the credential without a
# `jq` process ever appearing in the pipeline for the filter classifier to inspect.
#
# Both are value-level, so an allowlist keyed on the flag NAME cannot see either.
check_gh_flag_value() {
local name=$1 val=$2
case "$name" in
-R | --repo)
case "$val" in
*://* | */*/*)
deny "gh $name value names a host, which chooses the outbound destination"
;;
esac
;;
--jq | -q)
# shellcheck disable=SC2016 # the literal characters are the pattern
case "$val" in
*'$ENV'*) deny "gh $name exposes the process environment through \$ENV" ;;
esac
if [[ "$val" =~ (^|[^A-Za-z0-9_.])env([^A-Za-z0-9_]|$) ]]; then
deny "gh $name env exposes the process environment"
fi
;;
esac
}
# A word consumed as a flag's value that is itself flag-shaped. Only a flag whose value
# grammar begins with a dash ($2 names that family's set) may take it; for every other
# flag the guard would be handing a flag it never classified to a parser it does not
# assert. Denied by name, so the message points at the word to remove.
#
# Flag-shaped means a dash followed by a letter or a second dash. A dash followed by a
# digit is a NUMBER, never a flag — `--max-count -1` (unlimited), `-n -1`, and the
# relative date `--since -1.day` are all documented, read-only git grammars — so it is
# admitted for every value flag: no flag's name starts with a digit, so nothing the
# guard classifies can hide behind one.
check_consumed_value() {
local prog=$1 dash_flags=$2 name=$3 val=$4
case "$val" in
-[0-9]*) ;;
-?*)
case "$dash_flags" in
*" $name "*) ;;
*) deny "$prog $name consumed '$val' as its value, but that word is flag-shaped and $name takes no dash-leading value; the guard classifies it as a flag rather than trusting the parser to keep it inert" ;;
esac
;;
esac
}
check_gh_verb_flags() {
local i=1 w name val
local n=${#WORDS[@]}
while [ "$i" -lt "$n" ]; do
w=${WORDS[$i]}
case "$w" in
--)
i=$((i + 1))
continue
;;
-*) ;;
*)
# A POSITIONAL can name the host too, so skipping every one of them
# reopens exactly the hole `--hostname` and `--repo`'s value were closed
# for: `gh pr view https://example.com/x/y/pull/1` and
# `gh repo view example.com/x/y` both send an AUTHENTICATED request to
# that host. The credential travels with the destination, so this is
# exfiltration on a read, not merely a wrong lookup. An in-repo endpoint
# (`repos/owner/name/pulls`) carries no dotted host and is unaffected.
case "$w" in
*://*) deny "gh positional '$w' names a host, which retargets the authenticated request" ;;
*.*/*) deny "gh positional '$w' names a host, which retargets the authenticated request" ;;
esac
i=$((i + 1))
continue
;;
esac
split_flag "$w"
name=$FLAG_NAME
case "$GH_VERB_FLAGS$GH_VERB_VALUE_FLAGS" in
*" $name "*) ;;
*) deny "gh flag '$w' is not on the read-only allowlist" ;;
esac
if [ "$FLAG_HAS_VALUE" -eq 1 ]; then
check_gh_flag_value "$name" "$FLAG_VALUE"
else
case "$GH_VERB_VALUE_FLAGS" in
*" $name "*)
# A TRAILING bare `--json` is gh's field-vocabulary diagnostic: gh
# prints the subcommand's field list and exits nonzero WITHOUT
# contacting the forge. The surveyor's definition requires that probe
# before any ad hoc JSON read, so denying it makes the mandated
# discovery step unreachable. Admitted only as the final word, and
# only for `--json` — every other value flag still needs its value,
# and a value that IS present goes through the normal field check.
if [ "$name" = "--json" ] && [ $((i + 1)) -ge "$n" ]; then
i=$((i + 1))
continue
fi
if [ $((i + 1)) -ge "$n" ]; then deny "gh $name needs a value"; fi
val=${WORDS[$((i + 1))]}
check_consumed_value gh "$GH_DASH_VALUE_FLAGS" "$name" "$val"
check_gh_flag_value "$name" "$val"
i=$((i + 1))
;;
esac
fi
i=$((i + 1))
done
}
classify_gh() {
local sub sub2 sub_at
# GH_TELEMETRY is an environment class, not argv: an env-prefixed command is
# already denied, so the disabling value must already be in the process
# environment. 0 and false are the documented GitHub CLI disable values.
case "${GH_TELEMETRY-}" in
0|false) ;;
*)
printf '%s\n' 'deny: export GH_TELEMETRY=0 before any gh read (prevents gh/device-id telemetry writes)'
return 1
;;
esac
find_subcommand 1 ' '
sub=$SUB_WORD
sub_at=$SUB_INDEX
if [ -z "$sub" ]; then deny 'gh needs a subcommand to be classified'; fi
if [ "$sub" = api ]; then
classify_gh_api "$sub_at"
return 0
fi