main
1package main
2
3import (
4 "encoding/json"
5 "fmt"
6 "io"
7 "os"
8 "path/filepath"
9 "regexp"
10 "strings"
11 "unicode"
12
13 "github.com/gobwas/glob"
14)
15
16type Level string
17
18const (
19 Allow Level = "allow"
20 Ask Level = "ask"
21 Halt Level = "halt"
22)
23
24type Policy struct {
25 ALLOW []string
26 ASK []string
27 HALT []string
28}
29
30type Context struct {
31 Cwd string
32 ProjectDir string
33 Home string
34 RealPath func(path string) (string, bool)
35}
36
37type Match struct {
38 Level Level
39 Path string
40 Pattern string
41}
42
43type Decision struct {
44 Level Level
45 Matches []Match
46 Unresolved []string
47}
48
49type Candidate struct {
50 Raw string
51 Abs string
52 Resolved bool
53}
54
55var defaultPolicy = Policy{
56 ALLOW: []string{
57 "/nix/store",
58 "/tmp/crush",
59 },
60 ASK: []string{
61 "/**/*.env.example",
62 },
63 HALT: []string{
64 "/**/*.env",
65 },
66}
67
68var (
69 pathFields = []string{"file_path", "path"}
70 pathDefaultTools = map[string]bool{"grep": true, "lsp_rename": true}
71 shellModifiers = map[string]bool{
72 "export": true,
73 "declare": true,
74 "local": true,
75 "readonly": true,
76 "typeset": true,
77 }
78 assignmentRE = regexp.MustCompile(`^[A-Za-z_][A-Za-z0-9_]*=`)
79 variableRE = regexp.MustCompile(`\$(\w+)|\$\{(\w+)\}`)
80 uriRE = regexp.MustCompile(`^[A-Za-z][A-Za-z0-9+.-]*://`)
81 globMetaRE = regexp.MustCompile(`[*?[{]`)
82)
83
84type hookOutput struct {
85 Decision string `json:"decision,omitempty"`
86 Halt bool `json:"halt,omitempty"`
87 Reason string `json:"reason,omitempty"`
88}
89
90func decide(toolName string, toolInput any, context Context, policy Policy) Decision {
91 input, _ := asRecord(toolInput)
92 var candidates []Candidate
93 if toolName == "bash" {
94 candidates = extractBashCandidates(input, context)
95 } else {
96 candidates = extractFieldCandidates(toolName, input, context)
97 }
98
99 var matches []Match
100 var unresolved []string
101 for _, candidate := range candidates {
102 if !candidate.Resolved {
103 unresolved = append(unresolved, candidate.Raw)
104 continue
105 }
106 hit, ok := classify(candidate.Abs, policy, context)
107 if !ok {
108 continue
109 }
110 for _, pattern := range hit.patterns {
111 matches = append(matches, Match{Level: hit.level, Path: candidate.Raw, Pattern: pattern})
112 }
113 }
114
115 for _, level := range []Level{Halt, Ask, Allow} {
116 var hits []Match
117 for _, match := range matches {
118 if match.Level == level {
119 hits = append(hits, match)
120 }
121 }
122 if len(hits) == 0 {
123 continue
124 }
125 // An unresolvable path must never be auto-approved.
126 if level == Allow && len(unresolved) > 0 {
127 break
128 }
129 return Decision{Level: level, Matches: hits, Unresolved: unresolved}
130 }
131
132 return Decision{Level: Ask, Unresolved: unresolved}
133}
134
135func render(decision Decision) (string, bool) {
136 if decision.Level == Allow {
137 return marshal(hookOutput{Decision: "allow"}), true
138 }
139 if len(decision.Matches) == 0 && len(decision.Unresolved) == 0 {
140 return "", false
141 }
142 reason := formatReason(decision)
143 if decision.Level == Halt {
144 return marshal(hookOutput{Halt: true, Reason: reason}), true
145 }
146 return marshal(hookOutput{Reason: reason}), true
147}
148
149func formatReason(decision Decision) string {
150 entries := make([]string, 0, len(decision.Matches)+len(decision.Unresolved))
151 for _, match := range decision.Matches {
152 entries = append(entries, fmt.Sprintf(
153 "%s matched %s pattern %s",
154 match.Path, strings.ToUpper(string(match.Level)), match.Pattern,
155 ))
156 }
157 for _, path := range decision.Unresolved {
158 entries = append(entries, fmt.Sprintf("%s could not be resolved", path))
159 }
160 return fmt.Sprintf("%s: %s", decision.Level, strings.Join(entries, "; "))
161}
162
163func extractFieldCandidates(toolName string, input map[string]any, context Context) []Candidate {
164 var candidates []Candidate
165 for _, field := range pathFields {
166 value, ok := input[field].(string)
167 if !ok || value == "" {
168 continue
169 }
170 abs, resolved := resolvePath(value, context.Cwd, context)
171 candidates = append(candidates, Candidate{Raw: value, Abs: abs, Resolved: resolved})
172 }
173 if len(candidates) == 0 && pathDefaultTools[toolName] {
174 abs, resolved := resolvePath(context.Cwd, context.Cwd, context)
175 candidates = append(candidates, Candidate{Raw: context.Cwd, Abs: abs, Resolved: resolved})
176 }
177 return candidates
178}
179
180func extractBashCandidates(input map[string]any, context Context) []Candidate {
181 command, _ := input["command"].(string)
182 workingDir := context.Cwd
183 if dir, ok := input["working_dir"].(string); ok && dir != "" {
184 workingDir = dir
185 }
186
187 var candidates []Candidate
188 variables := map[string]string{}
189 baseDir, _ := resolvePath(workingDir, context.Cwd, context)
190
191 for _, tokens := range tokenize(command) {
192 index := 0
193 for index < len(tokens) && shellModifiers[tokens[index]] {
194 index++
195 }
196
197 if index < len(tokens) && tokens[index] == "cd" {
198 for index+1 < len(tokens) && strings.HasPrefix(tokens[index+1], "-") && tokens[index+1] != "-" {
199 index++
200 }
201 target := ""
202 hasTarget := index+1 < len(tokens)
203 if hasTarget {
204 target = tokens[index+1]
205 }
206 switch {
207 case !hasTarget:
208 baseDir = context.Home
209 case target == "-":
210 baseDir = ""
211 default:
212 value, unresolved := expandVariables(target, variables, context, map[string]bool{})
213 if unresolved {
214 candidates = append(candidates, Candidate{Raw: target})
215 baseDir = ""
216 } else {
217 baseDir, _ = resolvePath(value, baseDir, context)
218 }
219 }
220 continue
221 }
222
223 for index < len(tokens) && assignmentRE.MatchString(tokens[index]) {
224 separator := strings.Index(tokens[index], "=")
225 variables[tokens[index][:separator]] = tokens[index][separator+1:]
226 index++
227 }
228
229 for ; index < len(tokens); index++ {
230 raw := tokens[index]
231 value, unresolved := expandVariables(raw, variables, context, map[string]bool{})
232 if unresolved {
233 candidates = append(candidates, Candidate{Raw: raw})
234 continue
235 }
236 value = stripAssignmentPrefix(value)
237 if !isCommandPath(value) {
238 continue
239 }
240 abs, resolved := resolvePath(value, baseDir, context)
241 candidates = append(candidates, Candidate{Raw: raw, Abs: abs, Resolved: resolved})
242 }
243 }
244
245 return candidates
246}
247
248func tokenize(command string) [][]string {
249 var segments [][]string
250 var tokens []string
251 var token strings.Builder
252 var quote rune
253 inQuote := false
254
255 flushToken := func() {
256 if token.Len() > 0 {
257 tokens = append(tokens, token.String())
258 token.Reset()
259 }
260 }
261 flushSegment := func() {
262 flushToken()
263 if len(tokens) > 0 {
264 segments = append(segments, tokens)
265 }
266 tokens = nil
267 }
268
269 runes := []rune(command)
270 for i := 0; i < len(runes); i++ {
271 char := runes[i]
272 if inQuote {
273 if char == quote {
274 inQuote = false
275 } else {
276 token.WriteRune(char)
277 }
278 continue
279 }
280 if char == '"' || char == '\'' {
281 quote = char
282 inQuote = true
283 continue
284 }
285 if char == '\\' {
286 i++
287 if i < len(runes) {
288 token.WriteRune(runes[i])
289 }
290 continue
291 }
292 if strings.ContainsRune(";|&><()", char) || char == '\n' {
293 flushSegment()
294 continue
295 }
296 if unicode.IsSpace(char) {
297 flushToken()
298 continue
299 }
300 token.WriteRune(char)
301 }
302 flushSegment()
303
304 return segments
305}
306
307func expandVariables(token string, variables map[string]string, context Context, expanding map[string]bool) (string, bool) {
308 unresolved := strings.Contains(token, "`") || strings.Contains(token, "$(")
309 value := variableRE.ReplaceAllStringFunc(token, func(reference string) string {
310 name := referenceName(reference)
311 if name == "HOME" {
312 return context.Home
313 }
314 definition, ok := variables[name]
315 if !ok || expanding[name] {
316 unresolved = true
317 return reference
318 }
319 expanding[name] = true
320 inner, innerUnresolved := expandVariables(expandHome(definition, context.Home), variables, context, expanding)
321 delete(expanding, name)
322 if innerUnresolved {
323 unresolved = true
324 }
325 return inner
326 })
327 return value, unresolved
328}
329
330func referenceName(reference string) string {
331 if strings.HasPrefix(reference, "${") {
332 return reference[2 : len(reference)-1]
333 }
334 return reference[1:]
335}
336
337func stripAssignmentPrefix(value string) string {
338 _, after, ok := strings.Cut(value, "=")
339 if !ok {
340 return value
341 }
342 rhs := after
343 if looksLikePath(rhs) {
344 return rhs
345 }
346 return value
347}
348
349func looksLikePath(value string) bool {
350 if value == "" || value == "." || value == ".." {
351 return false
352 }
353 if uriRE.MatchString(value) {
354 return false
355 }
356 return strings.HasPrefix(value, "/") || strings.HasPrefix(value, "./") ||
357 strings.HasPrefix(value, "../") || strings.HasPrefix(value, "~/") ||
358 strings.Contains(value, "/")
359}
360
361// A bare word can still name a file (`cat .env`); leading dashes mark options.
362func isCommandPath(value string) bool {
363 if value == "" || strings.HasPrefix(value, "-") {
364 return false
365 }
366 return !uriRE.MatchString(value)
367}
368
369func resolvePath(value, base string, context Context) (string, bool) {
370 expanded := expandHome(value, context.Home)
371 var lexical string
372 switch {
373 case strings.HasPrefix(expanded, "/"):
374 lexical = normalizeAbsolute(expanded)
375 case base == "":
376 return "", false
377 default:
378 lexical = normalizeAbsolute(base + "/" + expanded)
379 }
380 // Write targets may not exist yet, so fall back to the lexical form.
381 if context.RealPath != nil {
382 if resolved, ok := context.RealPath(lexical); ok {
383 return normalizeAbsolute(resolved), true
384 }
385 }
386 return lexical, true
387}
388
389type classification struct {
390 level Level
391 patterns []string
392}
393
394func classify(abs string, policy Policy, context Context) (classification, bool) {
395 for _, entry := range []struct {
396 level Level
397 patterns []string
398 }{{Ask, policy.ASK}, {Halt, policy.HALT}} {
399 var hits []string
400 for _, pattern := range entry.patterns {
401 if matchesPattern(pattern, abs, context) {
402 hits = append(hits, pattern)
403 }
404 }
405 if len(hits) > 0 {
406 return classification{level: entry.level, patterns: hits}, true
407 }
408 }
409
410 var hits []string
411 for _, pattern := range policy.ALLOW {
412 if matchesPattern(pattern, abs, context) {
413 hits = append(hits, pattern)
414 }
415 }
416 // Routine work inside the project should not prompt.
417 projectDir := normalizeAbsolute(context.ProjectDir)
418 if projectDir != "/" && prefixMatch(abs, projectDir) {
419 hits = append(hits, context.ProjectDir)
420 }
421 if len(hits) > 0 {
422 return classification{level: Allow, patterns: hits}, true
423 }
424 return classification{}, false
425}
426
427func matchesPattern(pattern, abs string, context Context) bool {
428 anchored := anchorPattern(pattern, context)
429 if isGlob(anchored) {
430 compiled, err := glob.Compile(anchored, '/')
431 if err != nil {
432 return false
433 }
434 return compiled.Match(abs)
435 }
436 return prefixMatch(abs, anchored)
437}
438
439func anchorPattern(pattern string, context Context) string {
440 expanded := expandHome(pattern, context.Home)
441 absolute := expanded
442 if !strings.HasPrefix(expanded, "/") {
443 absolute = context.ProjectDir + "/" + expanded
444 }
445 return normalizeAbsolute(absolute)
446}
447
448func prefixMatch(abs, prefix string) bool {
449 if abs == prefix {
450 return true
451 }
452 if prefix == "/" {
453 return strings.HasPrefix(abs, "/")
454 }
455 return strings.HasPrefix(abs, prefix+"/")
456}
457
458func isGlob(pattern string) bool {
459 return globMetaRE.MatchString(pattern)
460}
461
462func normalizeAbsolute(path string) string {
463 var parts []string
464 for part := range strings.SplitSeq(path, "/") {
465 switch part {
466 case "", ".":
467 continue
468 case "..":
469 if len(parts) > 0 {
470 parts = parts[:len(parts)-1]
471 }
472 default:
473 parts = append(parts, part)
474 }
475 }
476 return "/" + strings.Join(parts, "/")
477}
478
479func expandHome(value, home string) string {
480 if value == "~" {
481 return home
482 }
483 if strings.HasPrefix(value, "~/") {
484 return home + "/" + value[2:]
485 }
486 return value
487}
488
489func asRecord(value any) (map[string]any, bool) {
490 record, ok := value.(map[string]any)
491 return record, ok
492}
493
494func marshal(output hookOutput) string {
495 encoded, err := json.Marshal(output)
496 if err != nil {
497 return ""
498 }
499 return string(encoded)
500}
501
502func main() {
503 raw, err := io.ReadAll(os.Stdin)
504 if err != nil {
505 return
506 }
507 var payload map[string]any
508 if err := json.Unmarshal(raw, &payload); err != nil {
509 payload = nil
510 }
511
512 cwd, _ := payload["cwd"].(string)
513 if cwd == "" {
514 cwd = os.Getenv("CRUSH_CWD")
515 }
516 if cwd == "" {
517 if dir, err := os.Getwd(); err == nil {
518 cwd = dir
519 }
520 }
521
522 projectDir := os.Getenv("CRUSH_PROJECT_DIR")
523 if projectDir == "" {
524 projectDir = cwd
525 }
526
527 context := Context{
528 Cwd: cwd,
529 ProjectDir: projectDir,
530 Home: os.Getenv("HOME"),
531 RealPath: func(path string) (string, bool) {
532 resolved, err := filepath.EvalSymlinks(path)
533 if err != nil {
534 return "", false
535 }
536 return resolved, true
537 },
538 }
539
540 toolName, _ := payload["tool_name"].(string)
541 if output, ok := render(decide(toolName, payload["tool_input"], context, defaultPolicy)); ok {
542 fmt.Println(output)
543 }
544}