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}