-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathhooks.py
More file actions
1060 lines (950 loc) · 48.4 KB
/
Copy pathhooks.py
File metadata and controls
1060 lines (950 loc) · 48.4 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
"""PreToolUse and Stop hooks (HANDOFF §9, §12 step 4).
**This is a speed bump, not the security model, and pretending otherwise is how
people get hurt.** Regexing shell commands is defeated by `ssh host "cmd"`,
`bash -c`, `$(...)`, aliases, and environment indirection. The actual control is
architectural: the agent has no general remote-execution capability, because the
HF token and SSH keys live in Windows Credential Manager and are read only by
`gpu.py` and `jobs.py` at the moment of use. A hook can be argued around; a
token that is not in the environment cannot.
What the hook is genuinely good for is catching the *accident* -- the model
reaching for `ssh` out of habit when it should reach for `gpu.py` -- and saying
so with the right next command.
`evaluate_bash()` is deliberately a pure function so the deny probe from §12
step 1 and the test suite can exercise it without an SDK or a live session.
"""
from __future__ import annotations
import re
import shlex
import sys
from dataclasses import dataclass
from typing import Any
@dataclass(frozen=True)
class Denial:
reason: str
suggestion: str
def message(self) -> str:
return f"{self.reason}\n\nUse instead: {self.suggestion}"
# Bare remote-execution verbs. The suggestion matters as much as the denial:
# a refusal with no route forward is what gets argued around.
_DENIED_COMMANDS: dict[str, Denial] = {
"ssh": Denial(
"bare ssh is denied: remote work goes through gpu.py, which carries the host "
"inventory, the spend ceilings, and the preflight and pre-registration gates",
"python -m tools.gpu submit --spec <spec> --expect <expectation_id> --json",
),
"scp": Denial(
"bare scp is denied: gpu.py stages the pipeline and collects artifacts itself",
"python -m tools.gpu collect <run_id> --json",
),
"rsync": Denial(
"bare rsync to a remote is denied for the same reason as scp",
"python -m tools.gpu submit --spec <spec> --expect <expectation_id> --json",
),
"hf": Denial(
"bare hf is denied: HF Jobs go through jobs.py, which enforces the four gates in §6",
"python -m tools.jobs submit --spec <spec> --expect <expectation_id> --json",
),
"huggingface-cli": Denial(
"bare huggingface-cli is denied: use jobs.py",
"python -m tools.jobs submit --spec <spec> --expect <expectation_id> --json",
),
"kaggle": Denial(
"bare kaggle is denied: Kaggle kernels go through kaggle.py, which enforces the four "
"gates in §6 and the weekly accelerator allowance the dollar ceilings cannot see",
"python -m tools.kaggle submit --spec <spec> --expect <expectation_id> --json",
),
# The environment rail, and the only one here that is about *this* machine
# rather than a remote. `agent.interpreter_env` puts Grad's own scripts
# directory first on PATH, so bare `pip` now resolves correctly -- but it
# resolves correctly by *ordering*, and ordering is a property a `cd`, a
# `PATH=...` prefix or a wrapper script can quietly change. `python -m pip`
# cannot: it installs into the interpreter that runs it, which is the same
# interpreter the kernel (`tools/nb.py`) and the dry run
# (`tools/preflight.py`) use, so a package the agent installs is a package
# the notebook can import.
#
# This machine is the argument: `pip` had three entries on PATH ahead of the
# venv's, and `python` was a global install carrying a second, editable Grad.
"pip": Denial(
"bare pip is denied: it installs into whichever environment PATH happens to name, "
"which is not necessarily the interpreter running Grad, the Jupyter kernel and the "
"preflight dry run",
"python -m pip install <package>",
),
"pip3": Denial(
"bare pip3 is denied for the same reason as pip: the environment it installs into is "
"decided by PATH rather than by the interpreter",
"python -m pip install <package>",
),
"conda": Denial(
"conda is denied: it manages a separate environment from the one Grad, the kernel and "
"the preflight dry run all share, so a package installed here is not importable there",
"python -m pip install <package>",
),
}
# Cost-bearing commands, denied while the current project is over budget
# (HANDOFF-2 §15). This is the *second* of the two token mechanisms: the first
# is `agent.py` refusing to issue the next turn. Neither depends on SDK
# behaviour we have not verified, and this one already denies reliably.
#
# Matched on the module path rather than the whole command line, because
# `python -m tools.jobs submit` and `python.exe -m tools.jobs submit --json`
# and a `cd x && python -m tools.jobs submit` are the same intent.
_COST_BEARING = (
("tools.jobs", "submit"),
("tools.gpu", "submit"),
# The backend that bills the most per second (`H100:8` is $31.60/h) was the
# one entry missing here, and its absence was not the harmless inconsistency
# it looks like. `tools/modal.py` does reach a gate -- `submit_lib.check`
# runs before `record_submission`, so an over-*spend* submit is still
# refused with exit 12 -- but `gates.check_project_spend` gates on
# `gpu_usd` alone, and `budget.over_budget` covers all three resources.
# A project out of `quota_tokens` therefore had no enforcement point at all
# on this backend: not here, because the command was not recognised, and not
# in the submitter, because tokens are not a thing a submitter measures.
("tools.modal", "submit"),
# Here despite costing no dollars. A project that is out of budget is out of
# the *attention* its allocation represents, and "it was free" is exactly the
# argument that turns an exhausted allocation into an afternoon of free-tier
# runs nobody planned. The weekly allowance bounds the hours; this bounds
# whether the project should be spending them at all.
("tools.kaggle", "submit"),
("tools.evolve", "run"),
("tools.report", "write"),
)
# Recursive *and* forced, in either order and in any spelling.
#
# Six hand-written alternations stood here, one per order of one pair of
# spellings, and between them they covered the combined flag (`-rf`, `-fr`), the
# separated short form (`-r -f`) and the separated long form
# (`--recursive --force`). What no alternation covered was a *mixed* pair:
# `rm --recursive -f notes` matched none of them, and it is the spelling
# somebody writes when they are being explicit about the dangerous half.
#
# Two lookaheads instead, one per property, which is what the rule actually says
# -- "recursive appears, and force appears, within this command" -- and is
# order-free and spelling-free by construction rather than by enumeration.
# `[^|;&\r\n]*` in each keeps the search inside the one command, so
# `rm x | grep -rf y` is still not a recursive delete.
#
# The short branch has no trailing `\b` on purpose: `-rf` has no boundary
# between its `r` and its `f`, and requiring one is what made the combined form
# need its own alternation in the first place. The long branch keeps it, so
# `--recursive` matches and `--recursive-something` would not.
_RM_RF = re.compile(
r"\brm\b"
r"(?=[^|;&\r\n]*\s(?:-\w*[rR]|--recursive\b))"
r"(?=[^|;&\r\n]*\s(?:-\w*f|--force\b))"
)
_CURL_PIPE_SH = re.compile(r"\b(curl|wget|iwr|Invoke-WebRequest)\b[^|]*\|[^|]*\b(sh|bash|zsh|python|pwsh|powershell)\b")
# **This rail is porous by design, and the design is deliberate.** It catches the
# habitual spellings of a credential read; it does not catch
# `python -c "from core import credentials; ..."`, the same code run through
# `tools/nb.py`, or `cat ~/.modal.toml` and `cat ~/.kaggle/kaggle.json`, both of
# which hold a plaintext pair that works today.
#
# Widening the pattern would not change that. The tokens have to be readable by
# the process that authenticates and the agent is that process, so there is no
# spelling to forbid that removes the capability -- only spellings that make the
# rule look stronger than it is. `core/credentials.py` states the accepted
# residual and, since it cannot be prevented, logs every successful read to
# `ledger/credential_reads.jsonl`. Read this rule as "not by accident", not as
# "not possible".
_CREDENTIAL_READ = re.compile(r"keyring\s+get|get_password\s*\(|\.credentials\.json")
#: Programs whose *argument* is a program. Listed here and nowhere else in this
#: module, because this is not the `bash -c` exemption being walked back: the
#: module docstring still says an interpreter can be argued around, `_segments`
#: still does not parse a payload, and `bash -c "ssh box"` is still allowed. All
#: this does is stop the three region-removing helpers below from deleting text
#: that is a program rather than data.
_INTERPRETERS = frozenset({
"bash", "sh", "zsh", "dash", "ksh", "fish", "pwsh", "powershell",
"python", "python3", "perl", "ruby", "node",
})
#: A trailing version on an interpreter's name. `_head` already normalises the
#: directory and the `.exe`, so this was the last spelling that walked past an
#: exact-match set: `python3` was in it and `python3.12`, `python3.11` and
#: `python2.7` were not, which is the name an explicit venv shebang or a
#: `py -3.12` habit actually produces.
#:
#: The stem has to be in the set for the substitution to count, so `python3.12`
#: resolves and `foo2` does not become an interpreter for having a digit.
_VERSIONED = re.compile(r"^([a-z]+?)[0-9]+(?:\.[0-9]+)*$")
def _is_interpreter(head: str) -> bool:
"""Whether this command head runs a program handed to it."""
if head in _INTERPRETERS:
return True
versioned = _VERSIONED.match(head)
return bool(versioned) and versioned.group(1) in _INTERPRETERS
#: The opener of a heredoc: `<<` or `<<-`, then a delimiter that may be quoted.
#:
#: `<<<` cannot match, and that is deliberate rather than lucky -- a here-string
#: takes a word on the same line and has no body to skip. After `<<` the third
#: `<` is neither a quote nor a word character, so the delimiter group fails.
_HEREDOC = re.compile(r"<<-?\s*(['\"]?)([A-Za-z_][A-Za-z0-9_]*)\1")
def _strip_heredocs(command: str) -> str:
"""Remove heredoc bodies -- but only where the body is data.
Appending a document *about* this file with `cat >> notes.md <<'EOF'` is
denied, because the body quotes the deny list's own vocabulary. It is the
quoting bug one construct over and it lands on the same population: a quoted
delimiter is the strongest "this is data" signal the shell has, since it
suppresses every expansion, and a heredoc is the ordinary way to write a
file from a shell.
Applied before the head rule as well as before the three whole-string rules,
because a body of `cat` is not commands either: `cat <<'EOF' > notes.md`
with `ssh box` in it writes a file and runs no ssh, and splitting on the
newline made a segment of it.
**Except when the carrier is an interpreter, where the body is the program.**
`bash <<EOF` takes its script on stdin, so the body is exactly as executable
as a `-c` argument -- and because this strip runs before `_segments`, the
first version of it deleted `ssh gpu-box nvidia-smi` out of
`bash <<EOF\\nssh gpu-box nvidia-smi\\nEOF` before the head rule ever ran.
That is not a hole in one of the three regexes; it is a bypass of every entry
in `_DENIED_COMMANDS`, which is the deny list's primary mechanism.
**That is the third instance of one pattern, and the pattern is worth naming
here so a fourth region inherits it: every "remove this region before
matching" rule needs an interpreter exception.** Quoted strings needed one
(`_interpreter_payload`, for `bash -c "..."`). Heredoc bodies need one (this).
Comments do not, and that is the test for whether a region needs one -- a
comment is never executed by anybody, whereas a quoted string and a heredoc
body are data only because of *who they are handed to*.
The decision is made per opener and before segmentation, using the head of
the segment the opener sits in. It cannot be made afterwards: by then the
body is gone and `bash <<EOF` carries no evidence of what it was about to
run.
**A heredoc with no terminator changes nothing.** Without that guard this
would be a different fail-open: a *quoted mention* of `<< EOF` would start a
body that never ends and swallow every real command after it. With it, the
worst an unterminated one costs is the false denial that was already there.
"""
if "<<" not in command:
return command
lines = command.split("\n")
kept: list[str] = []
i = 0
while i < len(lines):
line = lines[i]
kept.append(line)
i += 1
# Openers in line order, each tagged with whether the command carrying
# it is an interpreter. `bash <<A` and `cat <<B` on one line get
# different answers, so this cannot be a property of the line.
openers: list[tuple[str, bool]] = []
for segment in _segments(line):
is_code = _is_interpreter(_head(segment))
openers.extend((delimiter, is_code) for _, delimiter in _HEREDOC.findall(segment))
for delimiter, is_code in openers:
end = i
while end < len(lines) and lines[end].strip() != delimiter:
end += 1
if end >= len(lines):
# Unterminated: vouch for nothing, change nothing.
return command
if is_code:
kept.extend(lines[i:end]) # the body is the program: keep it
kept.append(lines[end]) # the terminator, which runs nothing either
i = end + 1
return "\n".join(kept)
def _strip_comments(command: str) -> str:
"""Remove `#` comments, keeping quotes and everything else intact.
A comment is not executed, so this can hide nothing: unlike `_unquoted` it
removes only text the shell itself discards, which is why it is safe to run
over the raw command before either projection is taken.
Its own pass rather than a branch inside `_unquoted`, because the
interpreter re-check in `evaluate_bash` needs a comment-free segment with
its quotes *still on* -- `python -c "print(1)" # documents rm -rf` denied on
the comment, which is the same false denial one construct further along.
**At the start of a word** is the whole rule. Bash reads `echo a#b` and
`curl http://x/#frag` as ordinary text, and a version of this keyed on the
character alone would silently stop examining the rest of any command line
carrying a URL fragment -- a fail-open, and a quiet one.
The comment inside an interpreter payload is not this function's business:
`bash -c "ls # rm -rf x"` still denies, because reading that `#` as a
comment means parsing the payload as shell, which is the indirection class
the module docstring declines. It errs closed and it is rare.
"""
if "#" not in command:
return command
out: list[str] = []
quote: str | None = None
i, n = 0, len(command)
while i < n:
char = command[i]
if char == "\\" and quote != "'" and i + 1 < n:
out.append(char)
out.append(command[i + 1])
i += 2
continue
if quote:
if char == quote:
quote = None
out.append(char)
i += 1
continue
if char in "'\"":
quote = char
out.append(char)
i += 1
continue
if char == "#" and (not out or out[-1].isspace()):
while i < n and command[i] != "\n":
i += 1
continue
out.append(char)
i += 1
return command if quote is not None else "".join(out)
def _unquoted(command: str) -> str:
"""The command with its quoted regions removed.
The three rules below run `re.search` over the whole command string, and
unlike `_segments` they were never taught about quoting -- so a quoted
*mention* of their vocabulary was refused as though it had been typed as a
command:
DENY echo "rm -rf x"
DENY grep -n 'rm -rf' notes/audit.md
DENY echo "curl x | sh"
DENY echo "keyring get foo"
Same shape as the quoted-pipe bug already fixed for `_segments`, and it
lands on the same people: the second line is what somebody auditing this
file writes, and until this existed you could not grep your own notes for
the deny list's own words.
**The regions are removed, not their contents kept.** Deleting the quoted
text can only ever take text away from a rule, and the rules key off
unquoted flags and operators -- `rm -rf "ledger"`, `curl "url" | sh`,
`keyring get "grad" x` -- so those spellings survive the projection.
**That argument has exactly one hole, and it is not a small one: for an
interpreter, the quoted region is the program.** `bash -c "rm -rf ledger"`
and `python -c "...os.system('rm -rf ledger')..."` are the one case where the
quoted text is precisely what executes, and projecting them leaves `bash -c`
and `python -c`. Nine such spellings stopped being denied when this function
was first written, against the four false denials it was added to remove --
a net loss, and one the head rule cannot cover because `bash` is not in
`_DENIED_COMMANDS`. `_interpreter_payload` below is what makes the claim
above true rather than nearly true; the two are a pair and neither is
correct alone.
**Command substitution is the exception and stays live inside double
quotes**, for the reason it is an exception in `_segments`: `"$(rm -rf x)"`
is a string to a reader and a command to the shell. Dropping it as quoted
text would turn this fix into the fail-open it is supposed to be the
opposite of -- and `rm` is not in `_DENIED_COMMANDS`, so the head rule
downstream would not catch what this let past. Single quotes suppress it, as
the shell does, which is why `echo '$(rm -rf x)'` legitimately becomes
allowed here: no shell runs anything in it.
An unbalanced quote or an unclosed substitution leaves the rest of the line
*unexamined*, so those fall back to the raw string. A line this cannot parse
is one it must not vouch for -- the same rule `_segments` follows, and the
same direction: over-denial is the one a deny list may be wrong in.
"""
out: list[str] = []
quote: str | None = None
#: One frame per open substitution: the quote it suspended, its closer, and
#: the grouping parentheses open inside it. Same shape and same reason as
#: `_segments` -- the closer has to be the *matching* one, or
#: `"$( (true) && rm -rf x )"` re-quotes halfway through a body the shell
#: runs and the tail goes unread.
suspended: list[list[Any]] = []
i, n = 0, len(command)
while i < n:
char = command[i]
if char == "\\" and quote != "'" and i + 1 < n:
# Both characters go: an escaped quote is not a delimiter, and
# neither half is anything the three rules match.
i += 2
continue
if quote == "'":
if char == "'":
quote = None
i += 1
continue
if command.startswith("$(", i) or (char == "`" and quote == '"'):
suspended.append([quote, "`" if char == "`" else ")", 0])
quote = None
i += 2 if char == "$" else 1
continue
if quote == '"':
if char == '"':
quote = None
i += 1
continue
if suspended:
frame = suspended[-1]
if frame[1] == ")" and char == "(":
frame[2] += 1
out.append(char)
i += 1
continue
if char == frame[1]:
if frame[1] == ")" and frame[2]:
frame[2] -= 1
else:
quote = suspended.pop()[0]
i += 1
continue
if char in "'\"":
quote = char
i += 1
continue
out.append(char)
i += 1
if quote is not None or suspended:
return command
return "".join(out)
#: The flags that make an argument code rather than a filename. `-lc` and `-ic`
#: are the clustered short forms a login or interactive shell is invoked with,
#: which is why this matches a cluster ending in the letter rather than the
#: letter alone. Over-matching is cheap here and under-matching is not: a flag
#: wrongly matched only means the segment is *also* checked as raw text, which
#: can deny nothing that does not already contain the vocabulary.
_CODE_FLAG = re.compile(r"^(?:--?command|-[a-z]*[ce])$", re.IGNORECASE)
def _interpreter_payload(segment: str) -> bool:
"""Whether this segment hands a program to an interpreter as an argument.
An argument, specifically: the *stdin* spelling of the same thing is a
heredoc, and it is handled in `_strip_heredocs` because the decision has to
be made before the body is removed. `bash <<EOF` carries no code flag, so
this would answer no for it -- correctly, since there is no payload argument
to read.
"""
if not _is_interpreter(_head(segment)):
return False
try:
tokens = shlex.split(segment, posix=True)
except ValueError:
tokens = segment.split()
return any(_CODE_FLAG.match(token) for token in tokens)
def _whole_string_rules(text: str) -> Denial | None:
"""The three rules that read a command as one string rather than as tokens.
Factored out because they run over two different projections of the same
command -- see `evaluate_bash` -- and a copy that drifted would be a rule
enforced in one projection and not the other.
"""
if _RM_RF.search(text):
return Denial(
"recursive force-delete is denied: the ledger, the corpus, and the papers "
"directory are not reproducible",
"delete specific paths explicitly, or move them aside",
)
if _CURL_PIPE_SH.search(text):
return Denial(
"piping a download into a shell is denied",
"download to a file, read it, then run it deliberately",
)
if _CREDENTIAL_READ.search(text):
return Denial(
"reading credentials directly is denied: they are fetched at the moment of use "
"by gpu.py and jobs.py and are never exported into the environment",
"python -m tools.jobs credential status --json",
)
return None
def evaluate_bash(command: str) -> Denial | None:
"""Return a denial for a Bash command, or None to let it through."""
if not command or not command.strip():
return None
# Two regions of a command line are not commands and are removed here,
# before anything below reads the text -- the head rule included, because
# `cat <<'EOF' > notes.md` with `ssh box` in the body writes a file rather
# than running ssh, and splitting on the newline made a segment of it.
#
# **Every region-removal rule needs an interpreter exception, except the one
# that does not.** This has been got wrong twice in the same shape now, so
# the test is written down rather than rediscovered each time: a region is
# data because of *who it is handed to*, and an interpreter is handed
# programs. The three regions this module removes, and their answers:
#
# * quoted strings -- data, except `bash -c "..."`, where the quoted
# region is the program. Exception in `_interpreter_payload`.
# * heredoc bodies -- data, except `bash <<EOF`, where the body is the
# program arriving on stdin. Exception in `_strip_heredocs`.
# * comments -- **no exception, and this is the case to reason from**: a
# comment is discarded by whoever reads it, so there is no recipient that
# could execute one.
#
# A fourth region added here inherits the question, not the answer.
#
# Heredocs before comments: a `#` after a heredoc opener is a comment on that
# line, but the body still follows, and stripping the comment first would
# take the opener with it.
command = _strip_comments(_strip_heredocs(command))
segments = _segments(command)
# The three whole-string rules see the command with its quoted regions
# removed; everything below sees it as typed. Separate variables on purpose:
# quoting is what tells `_segments` an operator is not one, so handing it
# this projection would throw away what the rest of this function runs on.
denial = _whole_string_rules(_unquoted(command))
if denial:
return denial
# ...and then again, raw, for the one construct where the quoted region is
# the program rather than data. Per segment rather than over the whole line,
# so `echo "rm -rf x" && bash -c "ls"` does not lend the echo's argument to
# the interpreter's check.
for segment in segments:
if _interpreter_payload(segment):
denial = _whole_string_rules(segment)
if denial:
return denial
for segment in segments:
head = _head(segment)
if head in _DENIED_COMMANDS:
return _DENIED_COMMANDS[head]
over = _cost_bearing_over_budget(command)
if over:
return over
return None
def cost_bearing_command(command: str) -> tuple[str, str] | None:
"""Which cost-bearing CLI+verb a command line invokes, if any."""
for segment in _segments(command):
try:
tokens = shlex.split(segment, posix=True)
except ValueError:
tokens = segment.split()
for module, verb in _COST_BEARING:
if module in tokens and verb in tokens:
return module, verb
return None
def _cost_bearing_over_budget(command: str) -> Denial | None:
"""Deny a cost-bearing command while its project is out of allocation.
Failure-open on purpose: if the ledger cannot be read, this returns None
rather than blocking research. The submitters hold the real gate (exit 12) --
this hook exists so the *token* loop, which no submitter sees, has an
enforcement point at all.
"""
found = cost_bearing_command(command)
if not found:
return None
module, verb = found
try:
from core import budget # noqa: PLC0415 - keeps import-time cost off every hook call
project_id = budget.current_project()
over = budget.over_budget(project_id)
except Exception as exc: # noqa: BLE001
# Still fails open -- accounting must not strand a session -- but not
# silently. This is one of the two token enforcement points the README
# advertises, and an unreadable ledger turning "enforced" into
# "unbounded" with nothing on screen is how a ceiling stops existing
# without anyone noticing.
print(
f"[grad] budget check failed ({type(exc).__name__}: {exc}); "
"cost-bearing commands are NOT being gated",
file=sys.stderr,
)
return None
if not over:
return None
return Denial(
f"project {project_id!r} is over budget on {', '.join(over)}, and "
f"`{module} {verb}` spends more. A ceiling that only warns is not a ceiling.",
f"python -m tools.budget raise --project {project_id} "
f"--{over[0].replace('_', '-')} <new ceiling> --json # deliberate, logged, never silent",
)
#: Longest first, so `||` is never read as two `|` and `&&` never as two `&`.
#:
#: `(` and `)` are here because grouping starts a command exactly as an operator
#: does. `( ssh box )` and `{ ssh box; }` are two of the shortest bypasses that
#: existed, and they were invisible for the same reason the substitution bugs
#: were: the segment kept its delimiter, so the head of `( conda )` was `(` and
#: the deny list matched nothing. Note `$(` sits ahead of `(`, and the explicit
#: substitution branch in `_segments` runs before this list is scanned at all --
#: a substitution opens a frame, a bare parenthesis only opens a segment.
_OPERATORS = ("||", "&&", "$(", "|", ";", "&", "`", "(", ")", "\n", "\r")
#: `{` is a separator only where the shell reads it as the group reserved word,
#: which is where a blank follows it. That distinction is not pedantry: `find .
#: -exec grep ssh {} \;` and `${HOME}` and `awk '{print $1}'` all contain a brace
#: that is not grouping, and splitting on those would deny commands nobody wrote.
#: `}` needs no rule of its own -- the `;` the shell requires before it has
#: already ended the segment, and a segment that merely *starts* with `}` has a
#: harmless head.
_GROUP_OPEN = re.compile(r"\{\s")
#: Redirection operators, longest first, matched as a single unit.
#:
#: A redirection is neither a separator nor a command -- it is the grammar that
#: sits between the two, which is why it needs naming here at all. Two of them
#: contain a character this module otherwise reads as an operator, and both were
#: splitting commands in half: the `&` in `2>&1` made `2>&1 ssh box` into
#: `['2>', '1 ssh box']`, and neither half had a head the deny list knew, so the
#: `ssh` ran. `python train.py 2>&1 | tee log` mis-split identically and was
#: harmless only by luck -- its head was still `python`, which is why the bug
#: survived every test written against it.
#:
#: `>|` is here for the same reason as `>&`: without it the `|` in `>|out ssh
#: box` splits, the first segment is a bare `>` and the second's head is the
#: redirection *target*.
#:
#: `<<` is deliberately absent. Consuming it would leave a heredoc body as
#: ordinary text and this module has no concept of one; left alone its `<`
#: falls through as a plain character, exactly as it did before this existed.
_REDIRECTION_OP = re.compile(r"&>>|&>|>>|>&|>\||<<<|<&|>|<")
def _blind_segments(command: str) -> list[str]:
"""The split that ignores quoting. Kept as the fallback -- see `_segments`."""
return [s for s in re.split(r"\|\||&&|[|;&()\r\n]|\{\s|\$\(|`", command) if s.strip()]
def _segments(command: str) -> list[str]:
"""Split on shell operators so `foo && ssh bar` is inspected as two commands.
A newline is in the class because a newline *is* a command separator: without
it `"true\\nssh gpu-box nvidia-smi"` was one segment whose head was `true`,
and the cheapest possible bypass of the deny list was pressing Enter.
**Quoting is respected, because an operator inside quotes is not one.**
`grep -n "a\\|b" file` is a single command, and splitting it blindly left a
tail of `b" file`; a pattern that happened to contain a denied word was
denied as though the user had typed it as a command. Read-only greps for the
deny list's own vocabulary are exactly what someone auditing this file
writes, so the false denial landed on the people best placed to hit it.
**Command substitution is the exception, and stays live inside double
quotes**, where `"$(ssh box)"` really does start a new command -- which is
also the one place worth hiding one. Single quotes suppress it, as the shell
does.
**A substitution opens a whole command context, not just a split point.**
Emitting one split at `$(` and carrying on as though the body were quoted
text is a fail-open: `"$(echo x | ssh box)"` keeps its `|` unsplit, every
head is `echo`, and the deny list waves through a pipeline the shell will
run. The suspended quote is stacked and restored at the *matching* closer,
so operators inside the body split and the text after it is quoted again.
Matching is the operative word, and it is why the frame counts grouping
parentheses. Ending at the first `)` instead reopens the quote halfway
through `"$( (true) | ssh box )"`, and the rest of a body the shell really
does execute is read as a string. The mirror case is the reason this cannot
simply deny every `)`: in `"$(cat f) | ssh box"` the parenthesis genuinely
ends the substitution, the tail is literal text, and no `ssh` runs -- so a
denial there would be a false one.
Unbalanced quotes -- and unclosed substitutions -- fall back to the blind
split: a string this cannot parse is one it must not vouch for.
Over-splitting only ever costs a false denial, and that is the direction a
deny list is allowed to be wrong in.
"""
out: list[str] = []
buf: list[str] = []
quote: str | None = None
#: One frame per open substitution, innermost last: the quote state it
#: suspended, which delimiter closes it, and how many grouping parentheses
#: are open inside it. A substitution is a *command* context, so
#: `"$(a | ssh box)"` has to go back to splitting on `|` inside it and back
#: to quoted text after the `)`.
#:
#: The depth is what makes the closer the *matching* one. Popping at the
#: first `)` ends the frame early on `"$( (true) | ssh box )"`, which puts
#: the pipeline back inside quotes and lets an `ssh` the shell really runs
#: go uninspected. It has to stay a count and not a flag because the
#: grouping can nest.
suspended: list[list[Any]] = []
i, n = 0, len(command)
while i < n:
char = command[i]
# A backslash takes the next character with it. Outside quotes that is
# the shell's rule exactly; inside double quotes the shell honours it
# only before `$`, a backtick, `"`, `\` and a newline, and treating the
# rest as escaped too can only ever suppress a split, never invent one.
# The characters it would wrongly escape are not operators in that
# position anyway, so the two agree everywhere it matters here.
if char == "\\" and quote != "'" and i + 1 < n:
buf.append(char)
buf.append(command[i + 1])
i += 2
continue
# Single quotes first, because they suppress substitution outright.
if quote == "'":
if char == "'":
quote = None
buf.append(char)
i += 1
continue
# `$(` opens a command context anywhere it is not single-quoted; a
# backtick does the same, but only inside double quotes -- unquoted it
# is already an operator below.
if command.startswith("$(", i) or (char == "`" and quote == '"'):
suspended.append([quote, "`" if char == "`" else ")", 0])
quote = None
out.append("".join(buf))
buf = []
i += 2 if char == "$" else 1
continue
if quote == '"':
if char == '"':
quote = None
buf.append(char)
i += 1
continue
if suspended:
frame = suspended[-1]
# A `(` here is grouping, not a substitution: the `$(` case above
# consumed both of its characters, so it can never reach this.
#
# It ends the segment as well as deepening the frame. Counting the
# depth without splitting kept the frame honest and left the group
# unread: `"$( (ssh box) )"` stayed one segment whose head was
# `(ssh`, so the fix for the *matching closer* had a bypass sitting
# inside the very string it was written for.
if frame[1] == ")" and char == "(":
frame[2] += 1
out.append("".join(buf))
buf = []
i += 1
continue
if char == frame[1]:
if frame[1] == ")" and frame[2]:
frame[2] -= 1
out.append("".join(buf))
buf = [char]
else:
quote = suspended.pop()[0]
# The closer ends the segment instead of joining it. A body
# that takes no arguments is a single token, and keeping the
# delimiter made that token `ssh)"`, which is not `ssh` and
# was not denied. It opens the *next* buffer rather than
# being dropped, so the quoted tail of `"$(date) ssh box"`
# inherits a harmless head instead of reading as a command
# the shell never runs.
out.append("".join(buf))
buf = [char]
i += 1
continue
if char in "'\"":
quote = char
buf.append(char)
i += 1
continue
# Before the operator scan, because two redirection operators end in a
# character that scan would claim. Taking the whole operator in one bite
# is what makes `2>&1` a redirection rather than a `2>` and a `1`, and
# it is the reason an escaped `>` still splits: the escape branch above
# has already consumed `\>`, so the `&` after it never reaches this.
redirection = _REDIRECTION_OP.match(command, i)
if redirection:
buf.append(redirection.group())
i = redirection.end()
continue
operator = next((op for op in _OPERATORS if command.startswith(op, i)), None)
if operator is None and _GROUP_OPEN.match(command, i):
operator = "{"
if operator is not None:
out.append("".join(buf))
buf = []
i += len(operator)
continue
buf.append(char)
i += 1
# An unclosed substitution is as unparseable as an unbalanced quote.
if quote is not None or suspended:
return _blind_segments(command)
out.append("".join(buf))
return [s for s in out if s.strip()]
#: Reserved words that *introduce* a command rather than being one. Skipping
#: them is finishing the parse, not widening the rule: `if ssh box; then ...`
#: and `for h in a b; do ssh $h; done` both run ssh, and both left a segment
#: whose first token was grammar. `sudo`, `nohup`, `env` and `exec` are
#: deliberately absent -- those are programs that run other programs, which is
#: the indirection class this module's docstring puts out of scope.
#:
#: `for`, `in` and `case` are absent for a different reason: what follows them
#: is a *name*, not a command, so skipping them would make `for ssh in a b` look
#: like a remote execution and deny a loop nobody should be denied.
_INTRODUCERS = frozenset({
"if", "elif", "then", "else", "while", "until", "do", "!", "time", "{", "(", ";",
})
#: `_REDIRECTION_OP` again, anchored to the start of a token and allowing the
#: file descriptor a token can carry: `shlex` hands back `2>/dev/null` whole,
#: and that is not a command.
#:
#: This closes the counterpart of the `2>&1` split, and the more ordinary half
#: of it. `_head` returned the first token that was neither an assignment nor an
#: introducer, and a redirection is neither -- so `2>/dev/null ssh box` had the
#: head `null`, `>out ssh box` had `>out`, `>/tmp/o kaggle datasets list` had
#: `o`, and every one of them was allowed. The `2>/dev/null` spelling is the one
#: that matters: it is not an attack, it is how a person silences stderr, so the
#: rail was one habit away from failing open with nothing on screen.
#:
#: `&>` and `&>>` are in the alternation because `_segments` no longer splits
#: them. That order is load-bearing in both directions: while `&` was still a
#: separator, `&>out ssh box` lost its `&` to the split and arrived here as
#: `>out ssh box`, so a version of this pattern without the `&>` branch would
#: have closed the case and then reopened it the moment the splitter was fixed.
#:
#: When the match consumes the whole token the operator is bare, which makes the
#: *next* token its target rather than the command -- `> out ssh box` has to
#: step over `out` as well to reach `ssh`.
_REDIRECTION = re.compile(r"^(?:\d*(?:>>|>&|>\||>|<<<|<<|<&|<)|&>>|&>)")
def _head(segment: str) -> str:
try:
tokens = shlex.split(segment, posix=True)
except ValueError:
tokens = segment.split()
skip_operand = False
for token in tokens:
if skip_operand:
skip_operand = False
continue # the target of the bare redirection before it
redirection = _REDIRECTION.match(token)
if redirection:
skip_operand = redirection.end() == len(token)
continue
if "=" in token and not token.startswith("-") and not token.startswith("/"):
continue # leading VAR=value assignments
if token in _INTRODUCERS:
continue
return token.rsplit("/", 1)[-1].rsplit("\\", 1)[-1].lower().removesuffix(".exe")
return ""
# ---------------------------------------------------------------------------
# SDK hook adapters
# ---------------------------------------------------------------------------
def _deny(reason: str) -> dict[str, Any]:
return {
"hookSpecificOutput": {
"hookEventName": "PreToolUse",
"permissionDecision": "deny",
"permissionDecisionReason": reason,
}
}
# Deterministic evidence for the §12 deny probe. The probe must not decide its
# verdict by looking for words in a transcript: the deny message itself contains
# "gpu.py", and a model narrating a successful run can use the word "denied".
DENIALS: list[dict[str, Any]] = []
async def pre_tool_use(input_data: dict[str, Any], tool_use_id: Any, context: Any) -> dict[str, Any]:
"""PreToolUse gate. Runs before deny rules, allow rules, and the mode."""
if (input_data or {}).get("tool_name") != "Bash":
return {}
command = ((input_data or {}).get("tool_input") or {}).get("command", "")
denial = evaluate_bash(command)
if not denial:
return {}
DENIALS.append({"command": command, "reason": denial.reason})
return _deny(denial.message())
# Fractions of a project's allocation at which the Stop hook starts saying so.
WARN_AT = (0.75, 0.9, 1.0)
async def stop(input_data: dict[str, Any], tool_use_id: Any, context: Any) -> dict[str, Any]:
"""Stop hook: the §15 threshold warnings, at a turn boundary.
**It no longer records usage, and that is a fix rather than a loss.** It used
to read `input_data["usage"]` -- a field the Stop hook's input does not carry
-- and `from_sdk_usage` only skips on `None`, so every turn appended an
all-zero `main` row: the `calls` counters inflated while the token totals
stayed at zero, which reads exactly like a session that spent nothing. Worse,
the real recorder in `agent.drive_turn` was already writing the same turn, so
an SDK release that started populating this field would have double-counted
every turn and hit the token ceiling at half its nominal value.
One measurement, one writer. `drive_turn` has the `ResultMessage` and its
usage; this has the turn boundary and the thresholds.
It is also deliberately **not** the enforcement point: the Stop hook's
documented `block` semantics force *continuation* rather than halting, which
is the opposite of what a budget needs. Enforcement lives in `agent.py`'s
pre-turn check and in `pre_tool_use` above.
"""
warning = budget_warning()
if warning:
WARNINGS.append(warning)
print(f"[grad] {warning['message']}", file=sys.stderr)
return {}
# Surfaced for the UI and for tests; the hook itself only prints.
WARNINGS: list[dict[str, Any]] = []
def budget_warning() -> dict[str, Any] | None:
"""A threshold crossing on the current project, or None.
Reports the *highest* threshold crossed rather than one line per resource:
a turn boundary is a bad place for a wall of text, and the resource nearest
its ceiling is the one that matters.
"""
try:
from core import budget # noqa: PLC0415
project_id = budget.current_project()
if not project_id or not budget.exists(project_id):
return None
state = budget.status(project_id)
except Exception as exc: # noqa: BLE001
print(
f"[grad] budget warning check failed ({type(exc).__name__}: {exc})",
file=sys.stderr,
)
return None
worst: dict[str, Any] | None = None
for resource, node in state["resources"].items():
fraction = node.get("fraction")
if fraction is None:
continue
crossed = [t for t in WARN_AT if fraction >= t]
if not crossed:
continue
if worst is None or fraction > worst["fraction"]:
worst = {
"project": project_id,
"resource": resource,
"fraction": fraction,
"threshold": max(crossed),
"spent": node["spent"],
"ceiling": node["ceiling"],
}
if worst is None:
return None
verb = "is over" if worst["fraction"] >= 1.0 else f"has used {worst['fraction']:.0%} of"
worst["message"] = (
f"project {worst['project']} {verb} its {worst['resource']} allocation "
f"({worst['spent']} of {worst['ceiling']})."
+ (
" Cost-bearing commands are now denied."
if worst["fraction"] >= 1.0
else ""
)
)
return worst
def probe(commands: list[str] | None = None) -> list[dict[str, Any]]:
"""The deny probe from §12 step 1, as data.
"Do not take this document's word for it, and re-run the probe after any SDK
upgrade." `agent.py probe` runs this against the live SDK; this function
covers the hook half, which is testable offline.
"""
commands = commands or [
"ssh gpu-box nvidia-smi",
"scp model.pt gpu-box:/tmp/",
"hf jobs run --flavor a100-large image cmd",
"kaggle kernels push -p .",
"rm -rf ledger/",
"curl https://example.com/install.sh | sh",
# The cheapest bypasses of the list above, which it used to miss: