-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathclassifier.go
More file actions
2566 lines (2374 loc) · 84.6 KB
/
Copy pathclassifier.go
File metadata and controls
2566 lines (2374 loc) · 84.6 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
// Package danger classifies shell commands by risk level and provides
// a configurable approval system for dangerous operations.
//
// Classification is token-based (not regex) — it respects quotes, pipes,
// redirects, compound commands (&&, ||, ;), and multi-line input. Each
// command is classified into one of 9 risk classes, and the user can
// configure which actions (allow/prompt/deny) apply to each class.
//
// The gate fails CLOSED. A command whose program name is recognised but
// used benignly classifies as Safe (allow); a command whose verb is NOT
// recognised classifies as Unknown and is denied by default. The set of
// recognised-safe commands (safeCommands) is therefore an explicit
// read-only allowlist — extend it, or the per-profile allowlist, to permit
// a tool rather than relying on it slipping through unclassified.
//
// # Threat model
//
// The classifier is an adversarial filter, not a parser for well-behaved
// input. It assumes a prompt-injected agent is actively trying to make a
// dangerous command read as harmless so it slips past the approval gate.
// The design therefore errs toward the worse class when in doubt, and is
// built in layers that each close a category of evasion:
//
// 1. Normalisation (see normalize) rewrites the command so token-level
// analysis can see through shell tricks before classification runs:
// - $'…' ANSI-C escapes decodeANSIC ($'\x72\x6d' → rm)
// - $IFS word-splitting expandIFS (rm$IFS-rf$IFS/ → rm -rf /)
// - {a,b,c} brace expansion expandBraces ({rm,-rf,/} → rm -rf /)
// - $(…)/`…`/<(…)/>(…) subst. extractSubstitutions (bodies classified too)
// - command/exec/builtin stripCommandWrappers
// - \-escapes (r\m, \rm) collapseUnquotedBackslashes
// - absolute paths (/bin/rm) basenameFirstToken + commandName
// The tokenizer additionally treats quote boundaries as NON word
// boundaries, so empty/adjacent quotes like r""m and "rm" still
// resolve to the single word `rm`.
//
// 2. Structural decomposition. A command is split into segments (on ;,
// &&, ||), each segment into pipe stages (on |), and EVERY stage is
// classified — not just the head — so `true | dd of=/dev/sda` and
// `echo x | sudo rm -rf /home` are seen for what their later stages do.
// The worst class across all parts wins (see rank).
//
// 3. Wrapper unwrapping (unwrapWrappers). Leading execution wrappers
// (env, xargs, nohup, nice, setsid, timeout, …) are stripped so the
// real command underneath is classified; privileged wrappers (sudo,
// doas, pkexec) additionally impose a system_write floor and then let
// the inner command escalate further (sudo rm -rf /var → destructive).
//
// 4. Verb-independent resource scanning (classifyResourceToken). Some
// resources are dangerous regardless of the command touching them:
// /dev/tcp and /dev/udp pseudo-devices (reverse-shell channels) and
// sensitive credential paths (~/.ssh, /etc/shadow, ~/.aws/credentials,
// /proc/self/environ, …). These are flagged wherever they appear.
//
// 5. Payload re-classification. Shell -c strings (bash -c '…') and the
// bodies of command/process substitutions are themselves classified by
// re-entering Classify, so nested commands cannot hide a level deeper.
//
// # Limitations
//
// This is a heuristic defence-in-depth layer, NOT a sandbox or a complete
// shell interpreter. It does not, and cannot, catch everything:
//
// - Variable indirection: `X=rm; $X -rf /` — the value of $X is not
// tracked. Note the fail-closed default turns this from a silent bypass
// into a denial: the unrecognised `$X` verb classifies as Unknown.
// - Fully dynamic construction from runtime data, command output, or
// environment the classifier cannot evaluate.
// - Arbitrary value transformations beyond the enumerated encodings
// (e.g. a secret piped through gzip/openssl before exfiltration).
// - Interpreter escape hatches we do not special-case. Common ones ARE
// covered: awk/ed/vi/emacs invocations that carry a script or file operand
// classify as code_execution (embeddedShellInterpreters), as do non-shell
// interpreters fed from a pipe (`curl … | python`). Language-specific eval
// paths or editor command-mode shells we have not enumerated may still read
// as a known command used benignly — the known verb is the gap, not an
// unknown one.
//
// Because these gaps exist, the classifier is paired with other controls:
// non-interactive denial, output redaction (internal/redact), and — for
// strong isolation — the container sandbox. When tuning, remember that
// over-classification only costs an extra prompt, while under-classification
// can let a destructive or exfiltrating command through silently; prefer the
// former.
package danger
import (
"fmt"
"net"
"net/url"
"os"
"path/filepath"
"regexp"
"strconv"
"strings"
)
// ── Types ──────────────────────────────────────────────────────────────
// RiskClass represents the risk level of a shell command.
type RiskClass string
const (
Safe RiskClass = "safe"
LocalWrite RiskClass = "local_write"
SystemWrite RiskClass = "system_write"
Destructive RiskClass = "destructive"
NetworkEgress RiskClass = "network_egress"
CodeExecution RiskClass = "code_execution"
Install RiskClass = "install"
Blocked RiskClass = "blocked"
// Unknown is the fall-through class for a command whose program name the
// classifier does not recognise. It defaults to Deny (same as
// Destructive): the gate fails CLOSED rather than open, so a novel or
// obfuscated verb that dodged every known-dangerous check cannot run
// unprompted. Recognised-but-benign usage classifies as Safe instead.
Unknown RiskClass = "unknown"
)
// Action represents what to do when a command of a given risk class is detected.
type Action string
const (
Allow Action = "allow"
Prompt Action = "prompt"
Deny Action = "deny"
)
// ── Tool Operation ─────────────────────────────────────────────────────
// ToolOperation describes a native tool call for approval checking.
type ToolOperation struct {
Name string
Resource string
Risk RiskClass
}
// ── Path-based classification ──────────────────────────────────────────
// ClassifyPath returns a RiskClass for a filesystem path.
//
// Classification rules (highest wins):
// - /boot, /dev, /proc, /sys, /mnt, /media → destructive
// - /tmp, $TMPDIR → local_write
// - /etc, /root, /var, /run, /lib, /usr → system_write
// - $HOME/.ssh, .config, .gnupg, .aws, .kube, .docker, .gitconfig, .env → system_write
// - $HOME/.odek/config.json, secrets.env, IDENTITY.md, skills/, sessions/, audit/,
// plans/, schedules.json, schedule-state.json, mcp_approvals.json,
// mcp_tool_approvals.json, restart.json, telegram.lock, etc. → system_write
// (odek trust anchors; rewriting them can disable the sandbox, persist attacker
// control, or leak secrets)
// - $HOME shell rc/profile files (.bashrc, .zshrc, .profile, .zshenv, etc.) → system_write
// - everything else → local_write
//
// macOS: /private/{etc,var,tmp} are transparently normalised before matching.
func ClassifyPath(path string) RiskClass {
abs, err := filepath.Abs(path)
if err != nil {
return SystemWrite
}
abs = filepath.Clean(abs)
// macOS canonicalizes /etc, /var, and /tmp as symlinks under /private.
// Strip the /private prefix so the sensitivity checks below match
// consistently — e.g. /private/etc/master.passwd must classify the same
// as /etc/master.passwd (system_write), and /private/var/folders/... must
// still resolve to the temp dir (local_write).
if strings.HasPrefix(abs, "/private/") {
abs = strings.TrimPrefix(abs, "/private")
}
for _, prefix := range []string{"/boot", "/dev", "/proc", "/sys", "/mnt", "/media"} {
if strings.HasPrefix(abs, prefix) {
return Destructive
}
}
// Temp directory paths are always local, not system. This handles
// macOS where temp dirs live under /var/folders/, preventing false
// SystemWrite classification (matching Linux /tmp behavior).
// os.TempDir may include a trailing separator on some platforms;
// Clean normalises it before the prefix check.
if tmpDir := filepath.Clean(os.TempDir()); abs == tmpDir || strings.HasPrefix(abs, tmpDir+string(filepath.Separator)) {
return LocalWrite
}
for _, prefix := range []string{"/etc", "/root", "/var", "/run", "/lib", "/usr"} {
if strings.HasPrefix(abs, prefix) {
return SystemWrite
}
}
home, _ := os.UserHomeDir()
if home != "" {
for _, sub := range []string{"/.ssh", "/.config", "/.gnupg", "/.aws", "/.kube",
"/.docker", "/.gitconfig", "/.env"} {
if strings.HasPrefix(abs, home+sub) {
return SystemWrite
}
}
// odek's own trust anchors. Rewriting ~/.odek/config.json can disable
// the sandbox or set "action": "allow" (YOLO) for the next run; a
// SKILL.md dropped under ~/.odek/skills/ is auto-loaded into future
// prompts; secrets.env is injected into the process environment;
// IDENTITY.md becomes the system prompt on the next run, so writing it
// lets a prompt-injected agent rewrite its own trusted instructions.
// sessions/, audit/, plans/, schedules.json, schedule-state.json and
// other state files similarly grant persistence or leak secrets.
// Auto-allowing these as LocalWrite would let a confined agent
// escalate out of its own sandbox, so they classify as SystemWrite
// (prompt/deny). Keep in sync with the carve-out exclusions in
// cmd/odek/file_tool.go (isProtectedOdekPath).
if isOdekTrustAnchor(home, abs) {
return SystemWrite
}
// Shell rc/profile files execute on the user's next shell start —
// writing them is persistence/escalation, not a local file edit.
// Case-folding defends against case-insensitive filesystems (macOS APFS).
if filepath.Dir(abs) == home && shellRCFilesLower[strings.ToLower(filepath.Base(abs))] {
return SystemWrite
}
}
return LocalWrite
}
// shellRCFiles are dotfiles in $HOME that shells execute automatically on
// startup/login. Writing any of them is code execution on the next shell,
// so ClassifyPath escalates them to SystemWrite. Fish/nushell configs live
// under ~/.config, which is already covered by the home-sensitive-dir list.
var shellRCFiles = map[string]bool{
".bashrc": true, ".bash_profile": true, ".bash_login": true,
".bash_logout": true, ".bash_aliases": true, ".profile": true,
".zshrc": true, ".zprofile": true, ".zshenv": true, ".zlogin": true,
".zlogout": true, ".kshrc": true, ".cshrc": true, ".tcshrc": true,
".login": true, ".logout": true,
}
// ClassifyPath uses shellRCFiles with case-folding because macOS APFS is
// case-insensitive by default: ~/.odek/BASHRC and ~/.bashrc refer to the
// same file.
var shellRCFilesLower = func() map[string]bool {
m := make(map[string]bool, len(shellRCFiles))
for k := range shellRCFiles {
m[strings.ToLower(k)] = true
}
return m
}()
// isOdekTrustAnchor reports whether abs is a file or directory under ~/.odek
// that must not be writable through auto-approved local_write tools. It must
// stay in sync with cmd/odek/file_tool.go::isProtectedOdekPath.
func isOdekTrustAnchor(home, abs string) bool {
if home == "" {
return false
}
prefix := home + "/.odek"
if abs != prefix && !strings.HasPrefix(abs, prefix+"/") {
return false
}
// The ~/.odek directory itself is an anchor.
if abs == prefix {
return true
}
rel := strings.ToLower(filepath.Clean(abs[len(prefix+"/"):]))
protectedExact := []string{
"config.json",
"secrets.env",
"identity.md",
"schedules.json",
"schedule-state.json",
"schedules.lock",
"mcp_approvals.json",
"mcp_tool_approvals.json",
"restart.json",
"telegram.lock",
"telegram.pid",
"schedule.pid",
"schedule.log",
}
for _, p := range protectedExact {
if rel == p {
return true
}
}
protectedDirs := []string{
"skills",
"sessions",
"audit",
"plans",
}
for _, d := range protectedDirs {
if rel == d || strings.HasPrefix(rel, d+string(filepath.Separator)) {
return true
}
}
return false
}
// ClassifyURL returns a RiskClass for a browser URL.
// Internal IPs → system_write; external → network_egress.
// Uses proper IP parsing (handles decimal, octal, hex, IPv6 compressed,
// short forms like 127.1, and all other representations that browsers
// accept via inet_aton-style parsing) instead of string prefix matching
// which was trivially bypassable.
func ClassifyURL(rawURL string) RiskClass {
u, err := url.Parse(rawURL)
if err != nil || u.Scheme == "" || u.Host == "" {
return NetworkEgress // can't parse — don't block, but will fail at fetch time
}
host := u.Hostname()
// Try as an IP address — uses browser-compatible parsing that handles
// decimal (127.0.0.1), octal (0177.0.0.1), hex (0x7f000001),
// mixed (127.0x1), short (127.1), single-integer (2130706433),
// IPv6 compressed ([::1]), IPv4-mapped IPv6, etc.
if ip := parseBrowserIP(host); ip != nil {
if IsBlockedIP(ip) {
return SystemWrite
}
return NetworkEgress
}
// Hostname-based: well-known private names and private suffixes.
if hostnameIsInternal(host) {
return SystemWrite
}
return NetworkEgress
}
// IsBlockedIP reports whether ip falls in a range that the agent's web tools
// must never reach: loopback (127/8, ::1), RFC1918 / RFC4193 private (incl.
// IPv6 ULA fc00::/7), link-local (169.254/16 — which covers the
// 169.254.169.254 cloud-metadata endpoint — and fe80::/10), or the unspecified
// address (0.0.0.0, ::). It is the single source of truth shared by both
// ClassifyURL's literal-host gate and the dial-time SSRF guard, so the two
// cannot drift apart. A nil IP is treated as blocked (fail closed).
func IsBlockedIP(ip net.IP) bool {
if ip == nil {
return true
}
return ip.IsLoopback() || ip.IsPrivate() ||
ip.IsLinkLocalUnicast() || ip.IsLinkLocalMulticast() ||
ip.IsUnspecified()
}
// hostnameIsInternal reports whether a non-IP hostname denotes a well-known
// loopback/internal name or a private suffix that must classify as SystemWrite.
// Matching is case-insensitive.
func hostnameIsInternal(host string) bool {
hostLower := strings.ToLower(host)
switch hostLower {
case "localhost", "localhost.localdomain", "localhost6", "localhost6.localdomain6",
"ip6-localhost", "ip6-loopback":
return true
}
// *.local (mDNS) resolves to link-local.
if strings.HasSuffix(hostLower, ".local") {
return true
}
// Common cloud metadata endpoints (SSRF targets) and private TLDs.
if hostLower == "169.254.169.254" || hostLower == "[fd00:ec2::254]" ||
hostLower == "metadata.google.internal" ||
hostLower == "metadata.internal" ||
strings.HasSuffix(hostLower, ".internal") {
return true
}
// Docker internal hostnames.
if strings.HasSuffix(hostLower, ".docker.internal") {
return true
}
return false
}
// HostIsImplicitlyInternal reports whether the literal host string already
// resolves to an internal target by inspection alone — i.e. ClassifyURL returns
// SystemWrite for it with no DNS lookup (a literal internal IP, in any browser
// encoding, or a known-internal hostname). The dial-time SSRF guard uses this
// to tell apart a target that was *already* surfaced to the policy gate as
// internal (and dialed under that decision) from one that presented as external
// and must be re-validated against its resolved IPs.
func HostIsImplicitlyInternal(host string) bool {
if ip := parseBrowserIP(host); ip != nil {
return IsBlockedIP(ip)
}
return hostnameIsInternal(host)
}
// parseBrowserIP parses an IP address using the same rules browsers use
// (inet_aton-style), handling representations that Go's net.ParseIP doesn't:
// - Octal: 0177.0.0.1
// - Hex: 0x7f000001, 0x0.0x0.0x0.0x0
// - Integer: 2130706433
// - Short: 127.1
func parseBrowserIP(host string) net.IP {
// Try standard parse first (handles IPv6, dotted decimal, etc.)
if ip := net.ParseIP(host); ip != nil {
return ip
}
// Try inet_aton-style parsing for IPv4 with non-standard representations
parts := strings.Split(host, ".")
if len(parts) < 1 || len(parts) > 4 {
return nil
}
var nums []uint32
for _, p := range parts {
var val uint64
var err error
switch {
case strings.HasPrefix(p, "0x") || strings.HasPrefix(p, "0X"):
val, err = strconv.ParseUint(p[2:], 16, 32)
case strings.HasPrefix(p, "0") && len(p) > 1:
// Only octal if it starts with 0 and has more digits
// Single "0" is just decimal zero
val, err = strconv.ParseUint(p[1:], 8, 32)
default:
val, err = strconv.ParseUint(p, 10, 32)
}
if err != nil || val > 0xFFFFFFFF {
return nil
}
nums = append(nums, uint32(val))
}
switch len(nums) {
case 1:
// Single number: full 32-bit address
return net.IPv4(byte(nums[0]>>24), byte(nums[0]>>16), byte(nums[0]>>8), byte(nums[0]))
case 2:
// a.b: a = high byte, b = remaining 24 bits
return net.IPv4(byte(nums[0]), byte(nums[1]>>16), byte(nums[1]>>8), byte(nums[1]))
case 3:
// a.b.c: a, b = high bytes, c = remaining 16 bits
return net.IPv4(byte(nums[0]), byte(nums[1]), byte(nums[2]>>8), byte(nums[2]))
case 4:
return net.IPv4(byte(nums[0]), byte(nums[1]), byte(nums[2]), byte(nums[3]))
}
return nil
}
// ── Config ─────────────────────────────────────────────────────────────
// DangerousConfig defines how dangerous operations are handled.
// Configurable via the standard 4-layer odek config chain.
//
// Default behavior per class (no sandbox):
//
// safe → allow, local_write → allow, system_write → prompt,
// destructive → deny, network_egress → prompt,
// code_execution → prompt, install → prompt, blocked → deny,
// unknown → deny
//
// The classifier fails closed: a command whose program name is not
// recognised classifies as Unknown and is denied by default. Set
// "unknown": "prompt" (or add trusted tools to the allowlist) to soften
// this for a given profile.
type DangerousConfig struct {
// Classes maps risk classes to their configured action.
// Only overrides for non-default values need to be set.
Classes map[RiskClass]Action `json:"classes,omitempty"`
// Allowlist is a list of command strings that are always allowed,
// regardless of their risk classification. Exact match only.
// Takes priority over Denylist.
Allowlist []string `json:"allowlist,omitempty"`
// Denylist is a list of command strings that are always denied,
// regardless of their risk classification. Prefix match (after trimming).
Denylist []string `json:"denylist,omitempty"`
// DefaultAction is the global default action applied to ALL risk classes
// when set. Per-class overrides in Classes still win.
// "allow" → YOLO mode (everything runs without prompt)
// "deny" → lockdown (everything denied unless explicitly allowed)
// Not set → uses built-in defaults per class
DefaultAction *string `json:"action,omitempty"`
// NonInteractive specifies what to do when running without a TTY.
// "deny" (default) — block all prompted ops, "allow" — run everything.
// The default is deny so that headless/CI/piped usage cannot be silently
// auto-approved by a prompt-injection payload.
NonInteractive *string `json:"non_interactive,omitempty"`
// Approver handles interactive approval prompts for dangerous operations.
// When set, all Prompt-class operations use this instead of /dev/tty.
// Tools can inject their own approver (e.g., WebSocket-based for odek serve).
// When nil, CheckOperation falls back to /dev/tty (CLI-compatible default).
Approver Approver `json:"-"`
}
// defaultActions defines the base per-class behavior.
var defaultActions = map[RiskClass]Action{
Safe: Allow,
LocalWrite: Allow,
SystemWrite: Prompt,
Destructive: Deny,
NetworkEgress: Prompt,
CodeExecution: Prompt,
Install: Prompt,
Blocked: Deny,
// Unrecognised commands fail closed — denied by default, like
// Destructive. Override per-profile (e.g. "unknown": "prompt") or via
// the allowlist for tools you trust.
Unknown: Deny,
}
// ActionFor returns the configured action for the given risk class.
// Per-class overrides in Classes win first, then the global default
// action (the "action" field), then built-in defaults, then Prompt.
func (c *DangerousConfig) ActionFor(cls RiskClass) Action {
// If the user explicitly configured an action for this class, use it.
if c.Classes != nil {
if a, ok := c.Classes[cls]; ok {
return a
}
}
// Blocked is always denied regardless of global default action.
// This covers commands like "rm -rf /" that are hardcoded as
// unrecoverable even in YOLO mode.
if cls == Blocked {
return Deny
}
// Global default action overrides all built-in defaults.
// Set "action": "allow" for YOLO mode, "action": "deny" for lockdown.
if c.DefaultAction != nil {
return parseAction(*c.DefaultAction)
}
// Fallback to built-in defaults
if a, ok := defaultActions[cls]; ok {
return a
}
return Prompt
}
// ActionForCommand returns the action for a specific command string.
// Allowlist and denylist are checked first (exact match for allowlist,
// prefix match for denylist), then falls back to the risk-class-based action.
func (c *DangerousConfig) ActionForCommand(cmd string) Action {
cmd = strings.TrimSpace(cmd)
if cmd == "" {
return Allow
}
// Allowlist has highest priority — exact match after trimming both sides.
for _, pattern := range c.Allowlist {
if cmd == strings.TrimSpace(pattern) {
return Allow
}
}
// Denylist is checked before classification — prefix match after trimming.
for _, pattern := range c.Denylist {
if strings.HasPrefix(cmd, strings.TrimSpace(pattern)) {
return Deny
}
}
// Classify and use class-based action
cls := Classify(cmd)
return c.ActionFor(cls)
}
// NonInteractiveAction returns the action to use when no TTY is available.
// Defaults to Deny so unattended/headless runs fail closed rather than
// auto-approving dangerous operations.
func (c *DangerousConfig) NonInteractiveAction() Action {
if c.NonInteractive != nil {
return parseAction(*c.NonInteractive)
}
return Deny
}
// CheckOperation checks whether a tool operation is allowed, denied,
// or needs approval. Returns nil on allow, error on deny, and prompts
// the user on prompt. Uses the configured Approver when set; falls back
// to /dev/tty (TTYApprover) when no approver is configured.
func (c *DangerousConfig) CheckOperation(op ToolOperation, trustedClasses map[RiskClass]bool) error {
action := c.ActionFor(op.Risk)
switch action {
case Allow:
return nil
case Deny:
return fmt.Errorf("operation denied by configuration: %s %s (risk: %s)",
op.Name, op.Resource, op.Risk)
case Prompt:
// Use configured approver, or fall back to TTY
approver := c.Approver
if approver == nil {
approver = NewTTYApprover(c)
}
// Build a TTYApprover for trustedClasses tracking if needed
if tty, ok := approver.(*TTYApprover); ok && trustedClasses != nil {
tty.TrustedClasses = trustedClasses
}
return approver.PromptOperation(op)
default:
return nil
}
}
func parseAction(s string) Action {
switch strings.ToLower(strings.TrimSpace(s)) {
case "allow":
return Allow
case "deny":
return Deny
default:
return Prompt
}
}
// ── Tokenizer ──────────────────────────────────────────────────────────
// tokenize splits a shell command into tokens, respecting:
// - Single and double quotes (content preserved as-is)
// - Pipes (|), redirects (>, >>), compound (&&, ||, ;)
// - Newlines mapped to semicolons (command separators)
//
// Output: flattened token slice including operators as tokens.
func tokenize(input string) []string {
input = strings.TrimSpace(input)
if input == "" {
return nil
}
// Normalize newlines to semicolons
input = strings.NewReplacer("\r\n", ";", "\n", ";", "\r", ";").Replace(input)
var tokens []string
var current strings.Builder
inSingle := false
inDouble := false
escapeNext := false
flush := func() {
if current.Len() > 0 {
tokens = append(tokens, current.String())
current.Reset()
}
}
for i := 0; i < len(input); i++ {
ch := input[i]
if escapeNext {
current.WriteByte(ch)
escapeNext = false
continue
}
if ch == '\\' && inDouble {
// In double quotes, \ escapes \, ", $, `, and newline
next := i + 1
if next < len(input) {
switch input[next] {
case '\\', '"', '$', '`':
escapeNext = true
continue
case '\n':
i++ // skip newline
continue
}
}
current.WriteByte(ch)
continue
}
if ch == '\'' && !inDouble {
// Toggle quote state WITHOUT flushing. A quote boundary is not a
// word boundary in a shell: r''m and "rm" both denote the single
// word `rm`. Flushing here split them into r,m — letting an
// attacker hide a command name from prefix matching. Words are
// only broken on unquoted whitespace/operators (handled below).
inSingle = !inSingle
continue
}
if ch == '"' && !inSingle {
inDouble = !inDouble
continue
}
if inSingle || inDouble {
current.WriteByte(ch)
continue
}
// Outside quotes — handle operators and whitespace
if ch == ' ' || ch == '\t' {
flush()
continue
}
// Multi-char operators: &&, ||, >>
// Check for two-character operators
if i+1 < len(input) {
op2 := string(input[i]) + string(input[i+1])
switch op2 {
case "&&", "||", ">>":
flush()
tokens = append(tokens, op2)
i++
continue
}
}
// Single-char operators: |, >, ;
switch ch {
case '|', '>', ';':
flush()
tokens = append(tokens, string(ch))
continue
}
// Regular character
current.WriteByte(ch)
}
flush()
return tokens
}
// ── Safe command prefixes ──────────────────────────────────────────────
// (Unused — classification falls through to Safe by default. Kept as
// documentation of what's considered read-only.)
var writePrefixes = map[string]bool{
"echo": true, "sed": true, "tee": true,
"rm": true, "mv": true, "cp": true, "touch": true,
"mkdir": true, "rmdir": true, "chmod": true, "chown": true,
// shred overwrites/removes files like rm. isDestructive escalates it to
// destructive when aimed at a block device or catastrophic wipe target;
// otherwise a local-file shred is a write (local_write / system_write).
"shred": true,
}
var systemPrefixes = map[string]bool{
"sudo": true, "apt": true, "apt-get": true, "yum": true,
"brew": true, "dpkg": true, "systemctl": true, "service": true,
"useradd": true, "groupadd": true, "passwd": true, "chown": true,
}
var destructivePrefixes = map[string]bool{
"dd": true, "mkfs": true, "mkfs.ext4": true, "mkfs.ext3": true,
"mkfs.ext2": true, "mkfs.xfs": true, "mkfs.btrfs": true,
"mkfs.vfat": true, "mkfs.fat": true, "mkfs.ntfs": true, "mkfs.f2fs": true,
"fdisk": true, "parted": true, "mke2fs": true,
// Partition-table and filesystem-signature destroyers. Each operates on a
// whole disk/partition and is unrecoverable, so any invocation is treated
// as destructive (deny-by-default, overridable in godmode for legitimate
// disk work) — matching the existing mkfs/fdisk handling.
"sgdisk": true, "gdisk": true, "cfdisk": true, "sfdisk": true,
"wipefs": true, "blkdiscard": true, "mkswap": true, "badblocks": true,
"cryptsetup": true, "zerofree": true,
}
var networkPrefixes = map[string]bool{
"curl": true, "wget": true, "scp": true, "rsync": true,
"nc": true, "ncat": true, "ssh": true, "sftp": true,
"ftp": true, "tftp": true, "telnet": true, "git": true,
// reverse-shell / tunnelling relays
"socat": true, "rclone": true,
// DNS lookups double as exfiltration channels
"dig": true, "nslookup": true, "host": true, "drill": true,
// other downloaders
"aria2c": true, "axel": true, "httpie": true,
}
var pipedShells = map[string]bool{
"sh": true, "bash": true, "zsh": true, "fish": true, "dash": true, "ksh": true,
}
// embeddedShellInterpreters are programs whose payload (script, expression,
// or file operand) can invoke arbitrary shell commands. They are treated like
// script interpreters: a bare --version/--help query stays safe, but any other
// invocation that supplies code or a file is code execution.
var embeddedShellInterpreters = map[string]bool{
"awk": true, "gawk": true, "mawk": true, "nawk": true,
"ed": true, "ex": true,
"vi": true, "vim": true, "nvim": true, "view": true,
"emacs": true, "emacsclient": true,
}
var codeEvalPrefixes = map[string]bool{
"eval": true, "node": true, "python": true, "python3": true,
"perl": true, "ruby": true, "php": true,
}
// stdinExecInterpreters read and execute a program from standard input when no
// script file is given (`curl … | python`, `… | perl`, `… | node`). Fed by an
// upstream pipe they are code execution, the non-shell analogue of `… | bash`.
// Kept separate from codeEvalPrefixes because that set includes the `eval`
// builtin, which is not a program a pipe can feed into.
var stdinExecInterpreters = map[string]bool{
"node": true, "python": true, "python3": true,
"perl": true, "ruby": true, "php": true,
}
// remoteRunPrefixes fetch and execute a (possibly remote) package or script
// in one shot — code execution, not a plain install.
var remoteRunPrefixes = map[string]bool{
"npx": true, "bunx": true, "uvx": true, "pipx": true,
}
var installPrefixes = map[string]bool{
"npm": true, "pip": true, "pip3": true, "gem": true,
"cargo": true, "brew": true, "go": true,
"pnpm": true, "yarn": true, "bun": true, "apk": true,
}
// pkgRunSubcommands map package managers to the subcommands that execute
// arbitrary project-defined code: package.json lifecycle/`run` scripts, cargo
// build scripts (build.rs), test harnesses, etc. These are code execution, not
// a plain install — an attacker who can drop a malicious package.json or
// build.rs runs code the moment one of these is invoked. Subcommands that only
// download (e.g. "go mod download") are handled as installs instead, and go's
// run/test/build verbs are intentionally absent here (see isCodeExecution /
// isInstall) so existing go build|test|mod-tidy behaviour is preserved.
var pkgRunSubcommands = map[string]map[string]bool{
"npm": {"start": true, "run": true, "run-script": true, "test": true, "stop": true, "restart": true, "exec": true},
"pnpm": {"start": true, "run": true, "test": true, "exec": true},
"yarn": {"start": true, "run": true, "test": true, "exec": true},
"bun": {"start": true, "run": true, "test": true, "exec": true},
"cargo": {"run": true, "build": true, "test": true, "bench": true},
}
// safeCommands are read-only / no-op programs that inspect state or
// transform stdin→stdout without touching the filesystem, network, or
// privileges. They classify as Safe (allow) so ordinary inspection keeps
// working under the fail-closed default. A command here that is given a
// write redirect or a system/sensitive path is still escalated by the
// LocalWrite / SystemWrite / resource-scan checks before this set is
// consulted — so adding a tool here cannot make `cmd > /etc/x` allowed.
//
// Only genuinely non-mutating tools belong here: anything that writes
// files, mutates system state, opens the network, or executes arbitrary
// code must NOT be added (it would become silently allowed).
var safeCommands = map[string]bool{
// listing / reading files
"ls": true, "ll": true, "dir": true, "vdir": true, "cat": true, "tac": true,
"head": true, "tail": true, "less": true, "more": true, "bat": true,
"nl": true, "wc": true, "file": true, "stat": true, "readlink": true,
"realpath": true, "basename": true, "dirname": true, "tree": true,
"du": true, "df": true, "find": true, "locate": true, "mdfind": true,
// text transforms (stdin→stdout; a > redirect escalates to LocalWrite)
"grep": true, "egrep": true, "fgrep": true, "rg": true, "ag": true, "ack": true,
"sort": true, "uniq": true, "cut": true, "paste": true, "column": true,
"fold": true, "comm": true, "join": true, "look": true, "tr": true,
"expand": true, "unexpand": true, "fmt": true, "pr": true, "rev": true,
"diff": true, "cmp": true, "sdiff": true, "colordiff": true, "diffstat": true,
"jq": true, "yq": true, "xmllint": true, "csvlook": true,
// hashing / encoding (read-only inspection)
"strings": true, "od": true, "hexdump": true, "xxd": true,
"base32": true, "md5sum": true, "sha1sum": true, "sha256sum": true,
"sha512sum": true, "cksum": true, "b2sum": true, "sum": true, "shasum": true,
// system / process inspection
"pwd": true, "printf": true, "date": true, "cal": true, "uptime": true,
"uname": true, "arch": true, "hostname": true, "nproc": true, "free": true,
"vmstat": true, "iostat": true, "mpstat": true, "lscpu": true, "lsblk": true,
"lsmem": true, "lsusb": true, "lspci": true, "lsof": true, "dmesg": true,
"id": true, "whoami": true, "groups": true, "users": true, "who": true,
"w": true, "last": true, "getent": true, "ps": true, "pgrep": true,
"pidof": true, "netstat": true, "ss": true, "locale": true,
"getconf": true, "which": true, "whereis": true, "type": true, "hash": true,
// control / no-op builtins
"true": true, "false": true, ":": true, "test": true, "[": true,
"sleep": true, "seq": true, "yes": true, "expr": true, "echo": true,
"man": true, "info": true, "tldr": true, "help": true, "clear": true,
// benign shell builtins (navigation, var/job control; no FS/net/priv).
// NOTE: eval/source/. are deliberately absent — they execute code and
// are handled as code_execution.
"cd": true, "pushd": true, "popd": true, "dirs": true, "export": true,
"unset": true, "set": true, "read": true, "wait": true, "shift": true,
"return": true, "exit": true, "trap": true, "umask": true, "getopts": true,
"local": true, "declare": true, "typeset": true, "readonly": true,
"alias": true, "unalias": true, "jobs": true, "bg": true, "fg": true,
"disown": true, "let": true, "ulimit": true, "times": true,
// common modern read-only CLIs (ls/find/cat/ps/df/du/diff/hex viewers)
"fd": true, "fdfind": true, "eza": true, "exa": true, "lsd": true,
"htop": true, "btop": true, "glances": true, "pstree": true, "procs": true,
"duf": true, "dust": true, "delta": true, "hexyl": true, "glow": true,
}
// ── Classifier ─────────────────────────────────────────────────────────
// Classify determines the risk class of a shell command using token-level
// heuristics. Returns the highest-severity class detected.
//
// Priority (highest to lowest):
// blocked > destructive > system_write > code_execution > network_egress >
// install > local_write > safe
//
// Pipeline (see the package doc for the full evasion model):
//
// raw cmd ─▶ isRawBlocked ─▶ normalize ─┬─▶ classifyOne(main) ─┐
// └─▶ Classify(sub) ⟳ ───┴─▶ worst wins
//
// normalize neutralises shell evasion tricks (ANSI-C/$IFS/brace expansion,
// $(…)/`…`/<(…) substitutions, command/exec wrappers, backslash escapes,
// absolute-path basenames) and returns the rewritten command plus any
// substitution bodies. classifyOne then splits into segments and pipe stages
// and classifies each (see classifyPipeline/classifyStage). Every extracted
// sub-expression is re-classified through Classify so nested commands cannot
// hide one level deeper; the worst class across the whole tree is returned.
func Classify(cmd string) RiskClass {
cmd = strings.TrimSpace(cmd)
if cmd == "" {
return Safe
}
// Check blocked patterns on raw command (before tokenization mangles them)
if isRawBlocked(cmd) {
return Blocked
}
main, subs := normalize(cmd)
worst := classifyOne(main)
for _, s := range subs {
// Substitutions are themselves commands the shell will run.
// Re-enter Classify (not classifyOne) so nested substitutions
// inside them also normalise.
if r := Classify(s); Rank(r) > Rank(worst) {
worst = r
}
}
return worst
}
// classifyOne runs the existing token-level pipeline against an already-
// normalised command string.
func classifyOne(cmd string) RiskClass {
tokens := tokenize(cmd)
if len(tokens) == 0 {
return Safe
}
segments := splitSegments(tokens)
worst := Safe
for _, seg := range segments {
cls := classifyPipeline(seg)
if Rank(cls) > Rank(worst) {
worst = cls
}
}
return worst
}
// classifyPipeline classifies one command segment that may contain pipes.
// Each pipe stage is classified independently — so `true | dd of=/dev/sda`
// is seen as the dd, not just the harmless `true` at the head — and a stage
// that pipes INTO a shell interpreter is treated as code execution
// (`curl … | bash`). The worst stage wins.
func classifyPipeline(tokens []string) RiskClass {
stages := splitPipes(tokens)
worst := Safe
for idx, stage := range stages {
// idx > 0 means this stage receives piped input from the previous one.
worst = worstOf(worst, classifyStage(stage, idx > 0))
}
return worst
}
// classifyStage classifies a single pipe stage. It first strips leading
// execution wrappers (sudo/env/xargs/nohup/timeout/…) so the real command
// underneath is the one classified, while privileged wrappers still set a
// system_write floor. It then escalates for shell `-c` payloads, `find
// -exec`, and any reverse-shell or sensitive-resource tokens in the stage.
// pipedInto reports whether the stage's stdin comes from an upstream pipe, in
// which case feeding it to a shell interpreter is code execution.
func classifyStage(tokens []string, pipedInto bool) RiskClass {
if len(tokens) == 0 {
return Safe
}
// Bare `env` / `printenv` dumps the full process environment, including
// secrets not covered by redaction patterns. Treat it as system_write so
// it requires approval in interactive modes and is denied by default in
// non-interactive mode.
if isEnvironmentDump(tokens) {
return SystemWrite
}
cmdTokens, floor := unwrapWrappers(tokens)
cls := floor
if len(cmdTokens) > 0 {
cls = worstOf(cls, classifyCommand(cmdTokens))
name := commandName(cmdTokens[0])
// A shell interpreter that executes code: piped-in data (`… | bash`),
// a -c payload, a script file, or a process substitution `<(curl …)`.
if pipedShells[name] {
if pipedInto {
cls = worstOf(cls, CodeExecution)
}
if arg := flagArg(cmdTokens, "-c"); arg != "" {
cls = worstOf(cls, CodeExecution)
cls = worstOf(cls, Classify(arg))
} else if shellHasOperand(cmdTokens) {
cls = worstOf(cls, CodeExecution)
}