Commit c5ba7f6

HPCesia <me@hpcesia.com>
2026-10-10 02:06:15
drop deno for everything
Sadly, Deno will sunset after 1 year, since the team joined Cloudflare. See <https://deno.com/blog/cloudflare>. Assisted-by: crush:deepseek-v4.1-flash
1 parent e34c53e
Changed files (11)
modules/dev/agents/crush/hooks/check_path/_package.nix
@@ -0,0 +1,21 @@
+{
+  lib,
+  buildGoModule,
+}:
+buildGoModule {
+  pname = "check-path";
+  version = "0.1.0";
+
+  src = ./.;
+
+  vendorHash = "sha256-nJ7cOXPON61pq6JZwHg5Aw5u4MCy7r5Xnt5a/jSJz8c=";
+
+  doCheck = true;
+
+  meta = {
+    description = "Crush PreToolUse hook that classifies tool paths by policy";
+    homepage = "https://git.trin.one/HPCesia/ix";
+    license = lib.licenses.mit;
+    mainProgram = "check-path";
+  };
+}
modules/dev/agents/crush/hooks/check_path/go.mod
@@ -0,0 +1,5 @@
+module check-path
+
+go 1.26
+
+require github.com/gobwas/glob v1.0.0
modules/dev/agents/crush/hooks/check_path/go.sum
@@ -0,0 +1,2 @@
+github.com/gobwas/glob v1.0.0 h1:p+FKbLEIsK1yZ39/OINwFvqNb5oyPY4H8xcy6uYu8dg=
+github.com/gobwas/glob v1.0.0/go.mod h1:oWCdo522i2P1n/hMXGNWs7yoV4wy/ciZuUIbvKj5rkc=
modules/dev/agents/crush/hooks/check_path/main.go
@@ -0,0 +1,544 @@
+package main
+
+import (
+	"encoding/json"
+	"fmt"
+	"io"
+	"os"
+	"path/filepath"
+	"regexp"
+	"strings"
+	"unicode"
+
+	"github.com/gobwas/glob"
+)
+
+type Level string
+
+const (
+	Allow Level = "allow"
+	Ask   Level = "ask"
+	Halt  Level = "halt"
+)
+
+type Policy struct {
+	ALLOW []string
+	ASK   []string
+	HALT  []string
+}
+
+type Context struct {
+	Cwd        string
+	ProjectDir string
+	Home       string
+	RealPath   func(path string) (string, bool)
+}
+
+type Match struct {
+	Level   Level
+	Path    string
+	Pattern string
+}
+
+type Decision struct {
+	Level      Level
+	Matches    []Match
+	Unresolved []string
+}
+
+type Candidate struct {
+	Raw      string
+	Abs      string
+	Resolved bool
+}
+
+var defaultPolicy = Policy{
+	ALLOW: []string{
+		"/nix/store",
+		"/tmp/crush",
+	},
+	ASK: []string{
+		"/**/*.env.example",
+	},
+	HALT: []string{
+		"/**/*.env",
+	},
+}
+
+var (
+	pathFields       = []string{"file_path", "path"}
+	pathDefaultTools = map[string]bool{"grep": true, "lsp_rename": true}
+	shellModifiers   = map[string]bool{
+		"export":   true,
+		"declare":  true,
+		"local":    true,
+		"readonly": true,
+		"typeset":  true,
+	}
+	assignmentRE = regexp.MustCompile(`^[A-Za-z_][A-Za-z0-9_]*=`)
+	variableRE   = regexp.MustCompile(`\$(\w+)|\$\{(\w+)\}`)
+	uriRE        = regexp.MustCompile(`^[A-Za-z][A-Za-z0-9+.-]*://`)
+	globMetaRE   = regexp.MustCompile(`[*?[{]`)
+)
+
+type hookOutput struct {
+	Decision string `json:"decision,omitempty"`
+	Halt     bool   `json:"halt,omitempty"`
+	Reason   string `json:"reason,omitempty"`
+}
+
+func decide(toolName string, toolInput any, context Context, policy Policy) Decision {
+	input, _ := asRecord(toolInput)
+	var candidates []Candidate
+	if toolName == "bash" {
+		candidates = extractBashCandidates(input, context)
+	} else {
+		candidates = extractFieldCandidates(toolName, input, context)
+	}
+
+	var matches []Match
+	var unresolved []string
+	for _, candidate := range candidates {
+		if !candidate.Resolved {
+			unresolved = append(unresolved, candidate.Raw)
+			continue
+		}
+		hit, ok := classify(candidate.Abs, policy, context)
+		if !ok {
+			continue
+		}
+		for _, pattern := range hit.patterns {
+			matches = append(matches, Match{Level: hit.level, Path: candidate.Raw, Pattern: pattern})
+		}
+	}
+
+	for _, level := range []Level{Halt, Ask, Allow} {
+		var hits []Match
+		for _, match := range matches {
+			if match.Level == level {
+				hits = append(hits, match)
+			}
+		}
+		if len(hits) == 0 {
+			continue
+		}
+		// An unresolvable path must never be auto-approved.
+		if level == Allow && len(unresolved) > 0 {
+			break
+		}
+		return Decision{Level: level, Matches: hits, Unresolved: unresolved}
+	}
+
+	return Decision{Level: Ask, Unresolved: unresolved}
+}
+
+func render(decision Decision) (string, bool) {
+	if decision.Level == Allow {
+		return marshal(hookOutput{Decision: "allow"}), true
+	}
+	if len(decision.Matches) == 0 && len(decision.Unresolved) == 0 {
+		return "", false
+	}
+	reason := formatReason(decision)
+	if decision.Level == Halt {
+		return marshal(hookOutput{Halt: true, Reason: reason}), true
+	}
+	return marshal(hookOutput{Reason: reason}), true
+}
+
+func formatReason(decision Decision) string {
+	entries := make([]string, 0, len(decision.Matches)+len(decision.Unresolved))
+	for _, match := range decision.Matches {
+		entries = append(entries, fmt.Sprintf(
+			"%s matched %s pattern %s",
+			match.Path, strings.ToUpper(string(match.Level)), match.Pattern,
+		))
+	}
+	for _, path := range decision.Unresolved {
+		entries = append(entries, fmt.Sprintf("%s could not be resolved", path))
+	}
+	return fmt.Sprintf("%s: %s", decision.Level, strings.Join(entries, "; "))
+}
+
+func extractFieldCandidates(toolName string, input map[string]any, context Context) []Candidate {
+	var candidates []Candidate
+	for _, field := range pathFields {
+		value, ok := input[field].(string)
+		if !ok || value == "" {
+			continue
+		}
+		abs, resolved := resolvePath(value, context.Cwd, context)
+		candidates = append(candidates, Candidate{Raw: value, Abs: abs, Resolved: resolved})
+	}
+	if len(candidates) == 0 && pathDefaultTools[toolName] {
+		abs, resolved := resolvePath(context.Cwd, context.Cwd, context)
+		candidates = append(candidates, Candidate{Raw: context.Cwd, Abs: abs, Resolved: resolved})
+	}
+	return candidates
+}
+
+func extractBashCandidates(input map[string]any, context Context) []Candidate {
+	command, _ := input["command"].(string)
+	workingDir := context.Cwd
+	if dir, ok := input["working_dir"].(string); ok && dir != "" {
+		workingDir = dir
+	}
+
+	var candidates []Candidate
+	variables := map[string]string{}
+	baseDir, _ := resolvePath(workingDir, context.Cwd, context)
+
+	for _, tokens := range tokenize(command) {
+		index := 0
+		for index < len(tokens) && shellModifiers[tokens[index]] {
+			index++
+		}
+
+		if index < len(tokens) && tokens[index] == "cd" {
+			for index+1 < len(tokens) && strings.HasPrefix(tokens[index+1], "-") && tokens[index+1] != "-" {
+				index++
+			}
+			target := ""
+			hasTarget := index+1 < len(tokens)
+			if hasTarget {
+				target = tokens[index+1]
+			}
+			switch {
+			case !hasTarget:
+				baseDir = context.Home
+			case target == "-":
+				baseDir = ""
+			default:
+				value, unresolved := expandVariables(target, variables, context, map[string]bool{})
+				if unresolved {
+					candidates = append(candidates, Candidate{Raw: target})
+					baseDir = ""
+				} else {
+					baseDir, _ = resolvePath(value, baseDir, context)
+				}
+			}
+			continue
+		}
+
+		for index < len(tokens) && assignmentRE.MatchString(tokens[index]) {
+			separator := strings.Index(tokens[index], "=")
+			variables[tokens[index][:separator]] = tokens[index][separator+1:]
+			index++
+		}
+
+		for ; index < len(tokens); index++ {
+			raw := tokens[index]
+			value, unresolved := expandVariables(raw, variables, context, map[string]bool{})
+			if unresolved {
+				candidates = append(candidates, Candidate{Raw: raw})
+				continue
+			}
+			value = stripAssignmentPrefix(value)
+			if !isCommandPath(value) {
+				continue
+			}
+			abs, resolved := resolvePath(value, baseDir, context)
+			candidates = append(candidates, Candidate{Raw: raw, Abs: abs, Resolved: resolved})
+		}
+	}
+
+	return candidates
+}
+
+func tokenize(command string) [][]string {
+	var segments [][]string
+	var tokens []string
+	var token strings.Builder
+	var quote rune
+	inQuote := false
+
+	flushToken := func() {
+		if token.Len() > 0 {
+			tokens = append(tokens, token.String())
+			token.Reset()
+		}
+	}
+	flushSegment := func() {
+		flushToken()
+		if len(tokens) > 0 {
+			segments = append(segments, tokens)
+		}
+		tokens = nil
+	}
+
+	runes := []rune(command)
+	for i := 0; i < len(runes); i++ {
+		char := runes[i]
+		if inQuote {
+			if char == quote {
+				inQuote = false
+			} else {
+				token.WriteRune(char)
+			}
+			continue
+		}
+		if char == '"' || char == '\'' {
+			quote = char
+			inQuote = true
+			continue
+		}
+		if char == '\\' {
+			i++
+			if i < len(runes) {
+				token.WriteRune(runes[i])
+			}
+			continue
+		}
+		if strings.ContainsRune(";|&><()", char) || char == '\n' {
+			flushSegment()
+			continue
+		}
+		if unicode.IsSpace(char) {
+			flushToken()
+			continue
+		}
+		token.WriteRune(char)
+	}
+	flushSegment()
+
+	return segments
+}
+
+func expandVariables(token string, variables map[string]string, context Context, expanding map[string]bool) (string, bool) {
+	unresolved := strings.Contains(token, "`") || strings.Contains(token, "$(")
+	value := variableRE.ReplaceAllStringFunc(token, func(reference string) string {
+		name := referenceName(reference)
+		if name == "HOME" {
+			return context.Home
+		}
+		definition, ok := variables[name]
+		if !ok || expanding[name] {
+			unresolved = true
+			return reference
+		}
+		expanding[name] = true
+		inner, innerUnresolved := expandVariables(expandHome(definition, context.Home), variables, context, expanding)
+		delete(expanding, name)
+		if innerUnresolved {
+			unresolved = true
+		}
+		return inner
+	})
+	return value, unresolved
+}
+
+func referenceName(reference string) string {
+	if strings.HasPrefix(reference, "${") {
+		return reference[2 : len(reference)-1]
+	}
+	return reference[1:]
+}
+
+func stripAssignmentPrefix(value string) string {
+	_, after, ok := strings.Cut(value, "=")
+	if !ok {
+		return value
+	}
+	rhs := after
+	if looksLikePath(rhs) {
+		return rhs
+	}
+	return value
+}
+
+func looksLikePath(value string) bool {
+	if value == "" || value == "." || value == ".." {
+		return false
+	}
+	if uriRE.MatchString(value) {
+		return false
+	}
+	return strings.HasPrefix(value, "/") || strings.HasPrefix(value, "./") ||
+		strings.HasPrefix(value, "../") || strings.HasPrefix(value, "~/") ||
+		strings.Contains(value, "/")
+}
+
+// A bare word can still name a file (`cat .env`); leading dashes mark options.
+func isCommandPath(value string) bool {
+	if value == "" || strings.HasPrefix(value, "-") {
+		return false
+	}
+	return !uriRE.MatchString(value)
+}
+
+func resolvePath(value, base string, context Context) (string, bool) {
+	expanded := expandHome(value, context.Home)
+	var lexical string
+	switch {
+	case strings.HasPrefix(expanded, "/"):
+		lexical = normalizeAbsolute(expanded)
+	case base == "":
+		return "", false
+	default:
+		lexical = normalizeAbsolute(base + "/" + expanded)
+	}
+	// Write targets may not exist yet, so fall back to the lexical form.
+	if context.RealPath != nil {
+		if resolved, ok := context.RealPath(lexical); ok {
+			return normalizeAbsolute(resolved), true
+		}
+	}
+	return lexical, true
+}
+
+type classification struct {
+	level    Level
+	patterns []string
+}
+
+func classify(abs string, policy Policy, context Context) (classification, bool) {
+	for _, entry := range []struct {
+		level    Level
+		patterns []string
+	}{{Ask, policy.ASK}, {Halt, policy.HALT}} {
+		var hits []string
+		for _, pattern := range entry.patterns {
+			if matchesPattern(pattern, abs, context) {
+				hits = append(hits, pattern)
+			}
+		}
+		if len(hits) > 0 {
+			return classification{level: entry.level, patterns: hits}, true
+		}
+	}
+
+	var hits []string
+	for _, pattern := range policy.ALLOW {
+		if matchesPattern(pattern, abs, context) {
+			hits = append(hits, pattern)
+		}
+	}
+	// Routine work inside the project should not prompt.
+	projectDir := normalizeAbsolute(context.ProjectDir)
+	if projectDir != "/" && prefixMatch(abs, projectDir) {
+		hits = append(hits, context.ProjectDir)
+	}
+	if len(hits) > 0 {
+		return classification{level: Allow, patterns: hits}, true
+	}
+	return classification{}, false
+}
+
+func matchesPattern(pattern, abs string, context Context) bool {
+	anchored := anchorPattern(pattern, context)
+	if isGlob(anchored) {
+		compiled, err := glob.Compile(anchored, '/')
+		if err != nil {
+			return false
+		}
+		return compiled.Match(abs)
+	}
+	return prefixMatch(abs, anchored)
+}
+
+func anchorPattern(pattern string, context Context) string {
+	expanded := expandHome(pattern, context.Home)
+	absolute := expanded
+	if !strings.HasPrefix(expanded, "/") {
+		absolute = context.ProjectDir + "/" + expanded
+	}
+	return normalizeAbsolute(absolute)
+}
+
+func prefixMatch(abs, prefix string) bool {
+	if abs == prefix {
+		return true
+	}
+	if prefix == "/" {
+		return strings.HasPrefix(abs, "/")
+	}
+	return strings.HasPrefix(abs, prefix+"/")
+}
+
+func isGlob(pattern string) bool {
+	return globMetaRE.MatchString(pattern)
+}
+
+func normalizeAbsolute(path string) string {
+	var parts []string
+	for part := range strings.SplitSeq(path, "/") {
+		switch part {
+		case "", ".":
+			continue
+		case "..":
+			if len(parts) > 0 {
+				parts = parts[:len(parts)-1]
+			}
+		default:
+			parts = append(parts, part)
+		}
+	}
+	return "/" + strings.Join(parts, "/")
+}
+
+func expandHome(value, home string) string {
+	if value == "~" {
+		return home
+	}
+	if strings.HasPrefix(value, "~/") {
+		return home + "/" + value[2:]
+	}
+	return value
+}
+
+func asRecord(value any) (map[string]any, bool) {
+	record, ok := value.(map[string]any)
+	return record, ok
+}
+
+func marshal(output hookOutput) string {
+	encoded, err := json.Marshal(output)
+	if err != nil {
+		return ""
+	}
+	return string(encoded)
+}
+
+func main() {
+	raw, err := io.ReadAll(os.Stdin)
+	if err != nil {
+		return
+	}
+	var payload map[string]any
+	if err := json.Unmarshal(raw, &payload); err != nil {
+		payload = nil
+	}
+
+	cwd, _ := payload["cwd"].(string)
+	if cwd == "" {
+		cwd = os.Getenv("CRUSH_CWD")
+	}
+	if cwd == "" {
+		if dir, err := os.Getwd(); err == nil {
+			cwd = dir
+		}
+	}
+
+	projectDir := os.Getenv("CRUSH_PROJECT_DIR")
+	if projectDir == "" {
+		projectDir = cwd
+	}
+
+	context := Context{
+		Cwd:        cwd,
+		ProjectDir: projectDir,
+		Home:       os.Getenv("HOME"),
+		RealPath: func(path string) (string, bool) {
+			resolved, err := filepath.EvalSymlinks(path)
+			if err != nil {
+				return "", false
+			}
+			return resolved, true
+		},
+	}
+
+	toolName, _ := payload["tool_name"].(string)
+	if output, ok := render(decide(toolName, payload["tool_input"], context, defaultPolicy)); ok {
+		fmt.Println(output)
+	}
+}
modules/dev/agents/crush/hooks/check_path/main_test.go
@@ -0,0 +1,186 @@
+package main
+
+import (
+	"reflect"
+	"testing"
+)
+
+var (
+	testContext = Context{Cwd: "/project", ProjectDir: "/project", Home: "/home/user"}
+	testPolicy  = Policy{
+		ALLOW: []string{"/nix/store", "/tmp/crush"},
+		ASK:   []string{"/**/*.env.example"},
+		HALT:  []string{"/**/*.env"},
+	}
+)
+
+func viewInput(path string) map[string]any {
+	return map[string]any{"file_path": path}
+}
+
+func bashInput(command string) map[string]any {
+	return map[string]any{"command": command}
+}
+
+func assertLevel(t *testing.T, decision Decision, want Level) {
+	t.Helper()
+	if decision.Level != want {
+		t.Fatalf("level = %q, want %q", decision.Level, want)
+	}
+}
+
+func assertRender(t *testing.T, decision Decision, want string) {
+	t.Helper()
+	got, ok := render(decision)
+	if !ok {
+		t.Fatalf("render returned no output, want %q", want)
+	}
+	if got != want {
+		t.Fatalf("render = %q, want %q", got, want)
+	}
+}
+
+func assertNoRender(t *testing.T, decision Decision) {
+	t.Helper()
+	if got, ok := render(decision); ok {
+		t.Fatalf("render = %q, want no output", got)
+	}
+}
+
+func TestHaltsForbiddenFilePath(t *testing.T) {
+	decision := decide("view", viewInput("/home/user/.env"), testContext, testPolicy)
+	assertLevel(t, decision, Halt)
+	assertRender(t, decision, `{"halt":true,"reason":"halt: /home/user/.env matched HALT pattern /**/*.env"}`)
+}
+
+func TestAsksForTemplateSecretPath(t *testing.T) {
+	decision := decide("view", viewInput("/home/user/.env.example"), testContext, testPolicy)
+	assertLevel(t, decision, Ask)
+	assertRender(t, decision, `{"reason":"ask: /home/user/.env.example matched ASK pattern /**/*.env.example"}`)
+}
+
+func TestAllowsNixStorePathsOnComponentBoundaries(t *testing.T) {
+	assertLevel(t, decide("view", viewInput("/nix/store/abc"), testContext, testPolicy), Allow)
+	assertRender(t, decide("view", viewInput("/nix/store"), testContext, testPolicy), `{"decision":"allow"}`)
+	assertLevel(t, decide("view", viewInput("/nix/store-other/abc"), testContext, testPolicy), Ask)
+	assertNoRender(t, decide("view", viewInput("/nix/store-other/abc"), testContext, testPolicy))
+}
+
+func TestPrefersAskOverHaltForSinglePath(t *testing.T) {
+	policy := Policy{ALLOW: []string{}, ASK: []string{"/secret/**"}, HALT: []string{"/secret/**"}}
+	decision := decide("view", viewInput("/secret/x"), testContext, policy)
+	assertLevel(t, decision, Ask)
+	want := []Match{{Level: Ask, Path: "/secret/x", Pattern: "/secret/**"}}
+	if !reflect.DeepEqual(decision.Matches, want) {
+		t.Fatalf("matches = %#v, want %#v", decision.Matches, want)
+	}
+}
+
+func TestPrefersHaltOverAskAcrossPaths(t *testing.T) {
+	decision := decide("bash", bashInput("cat /home/user/.env /home/user/other/.env.example"), testContext, testPolicy)
+	assertLevel(t, decision, Halt)
+	assertRender(t, decision, `{"halt":true,"reason":"halt: /home/user/.env matched HALT pattern /**/*.env"}`)
+}
+
+func TestAnchorsRelativePatternsToProjectDirectory(t *testing.T) {
+	nested := Policy{ALLOW: []string{}, ASK: []string{}, HALT: []string{"**/*.env"}}
+	assertLevel(t, decide("view", viewInput("sub/.env"), testContext, nested), Halt)
+	assertLevel(t, decide("view", viewInput("/elsewhere/sub/.env"), testContext, nested), Ask)
+
+	prefix := Policy{ALLOW: []string{"src"}, ASK: []string{}, HALT: []string{}}
+	assertLevel(t, decide("view", viewInput("src/main.ts"), testContext, prefix), Allow)
+	assertLevel(t, decide("view", viewInput("/other/src/main.ts"), testContext, prefix), Ask)
+}
+
+func TestResolvesRelativeToolPathsAgainstCwd(t *testing.T) {
+	context := Context{Cwd: "/work", ProjectDir: "/project", Home: "/home/user"}
+	policy := Policy{ALLOW: []string{"/work"}, ASK: []string{}, HALT: []string{}}
+	assertLevel(t, decide("view", viewInput("a.txt"), context, policy), Allow)
+}
+
+func TestDefaultsSearchPathToCwd(t *testing.T) {
+	context := Context{Cwd: "/nix/store/pkg", ProjectDir: "/project", Home: "/home/user"}
+	assertLevel(t, decide("grep", map[string]any{}, context, testPolicy), Allow)
+	assertLevel(t, decide("lsp_rename", map[string]any{"symbol": "a", "new_name": "b"}, context, testPolicy), Allow)
+}
+
+func TestParsesQuotedAndChainedBashArguments(t *testing.T) {
+	assertLevel(t, decide("bash", bashInput(`cat "/home/user/.env"`), testContext, testPolicy), Halt)
+	assertLevel(t, decide("bash", bashInput("true && cat /home/user/.env | wc -l"), testContext, testPolicy), Halt)
+	assertLevel(t, decide("bash", bashInput("cat '/home/user/.env.example'"), testContext, testPolicy), Ask)
+}
+
+func TestTracksCdWhenResolvingRelativeBashPaths(t *testing.T) {
+	assertLevel(t, decide("bash", bashInput("cat .env"), testContext, testPolicy), Halt)
+	assertLevel(t, decide("bash", bashInput("cd /home/user && cat .env"), testContext, testPolicy), Halt)
+	assertLevel(t, decide("bash", bashInput("cd sub && cat .env"), testContext, testPolicy), Halt)
+	assertLevel(t, decide("bash", bashInput("cd && cat .env"), testContext, testPolicy), Halt)
+}
+
+func TestUsesBashWorkingDirectoryAsRelativeBase(t *testing.T) {
+	input := map[string]any{"command": "cat .env", "working_dir": "sub"}
+	assertLevel(t, decide("bash", input, testContext, testPolicy), Halt)
+}
+
+func TestExpandsShellVariableDefinitions(t *testing.T) {
+	assertLevel(t, decide("bash", bashInput("FOO=/home/user; cat $FOO/.env"), testContext, testPolicy), Halt)
+	assertLevel(t, decide("bash", bashInput("FOO=/home/user cat $FOO/.env"), testContext, testPolicy), Halt)
+	assertLevel(t, decide("bash", bashInput("export FOO=/home/user && cat ${FOO}/.env"), testContext, testPolicy), Halt)
+	assertLevel(t, decide("bash", bashInput("A=/home/user; B=$A/dir; cat $B/.env"), testContext, testPolicy), Halt)
+}
+
+func TestMarksUnresolvableShellPathsAndBlocksAllow(t *testing.T) {
+	decision := decide("bash", bashInput("cat $UNKNOWN /nix/store/x"), testContext, testPolicy)
+	assertLevel(t, decision, Ask)
+	assertRender(t, decision, `{"reason":"ask: $UNKNOWN could not be resolved"}`)
+
+	assertLevel(t, decide("bash", bashInput("cd - && cat .env"), testContext, testPolicy), Ask)
+	assertLevel(t, decide("bash", bashInput("cd $DIR && cat /nix/store/x"), testContext, testPolicy), Ask)
+}
+
+func TestSwitchesToGlobMatchingOnGlobMetacharacters(t *testing.T) {
+	policy := Policy{ALLOW: []string{"/data/*.log"}, ASK: []string{}, HALT: []string{}}
+	assertLevel(t, decide("view", viewInput("/data/a.log"), testContext, policy), Allow)
+	assertLevel(t, decide("view", viewInput("/data/sub/a.log"), testContext, policy), Ask)
+}
+
+func TestAsksSilentlyOnMalformedOrEmptyInput(t *testing.T) {
+	assertNoRender(t, decide("view", map[string]any{}, testContext, testPolicy))
+	assertNoRender(t, decide("view", nil, testContext, testPolicy))
+}
+
+func TestAutoAllowsProjectPathsForEveryTool(t *testing.T) {
+	empty := Policy{ALLOW: []string{}, ASK: []string{}, HALT: []string{}}
+	decision := decide("view", viewInput("src/main.ts"), testContext, empty)
+	assertLevel(t, decision, Allow)
+	want := []Match{{Level: Allow, Path: "src/main.ts", Pattern: "/project"}}
+	if !reflect.DeepEqual(decision.Matches, want) {
+		t.Fatalf("matches = %#v, want %#v", decision.Matches, want)
+	}
+	assertLevel(t, decide("bash", bashInput("rm -rf build"), testContext, empty), Allow)
+	assertLevel(t, decide("view", viewInput(".env"), testContext, testPolicy), Halt)
+}
+
+func TestResolvesSymlinksBeforeMatching(t *testing.T) {
+	context := testContext
+	context.RealPath = func(path string) (string, bool) {
+		if path == "/tmp/crush/link" {
+			return "/home/user/.env", true
+		}
+		return "", false
+	}
+	assertLevel(t, decide("view", viewInput("/tmp/crush/link"), context, testPolicy), Halt)
+
+	outside := testContext
+	outside.RealPath = func(path string) (string, bool) {
+		if path == "/project/link" {
+			return "/nix/store/abc", true
+		}
+		return "", false
+	}
+	decision := decide("view", viewInput("link"), outside, testPolicy)
+	assertLevel(t, decision, Allow)
+	if decision.Matches[0].Pattern != "/nix/store" {
+		t.Fatalf("pattern = %q, want %q", decision.Matches[0].Pattern, "/nix/store")
+	}
+}
modules/dev/agents/crush/hooks/check-path.ts
@@ -1,434 +0,0 @@
-#!/usr/bin/env -S deno run
-import { globToRegExp } from "jsr:@std/path@1/glob-to-regexp";
-
-const ALLOW_PATH_PATTERN = [
-  "/nix/store",
-  "/tmp/crush",
-];
-const ASK_PATH_PATTERN = [
-  "/**/*.env.example",
-];
-const HALT_PATH_PATTERN = [
-  "/**/*.env",
-];
-
-export type Level = "allow" | "ask" | "halt";
-
-export interface Policy {
-  ALLOW: string[];
-  ASK: string[];
-  HALT: string[];
-}
-
-export interface Context {
-  cwd: string;
-  projectDir: string;
-  home: string;
-  realPath?: (path: string) => string | undefined;
-}
-
-export interface Match {
-  level: Level;
-  path: string;
-  pattern: string;
-}
-
-export interface Decision {
-  level: Level;
-  matches: Match[];
-  unresolved: string[];
-}
-
-export interface Candidate {
-  raw: string;
-  abs?: string;
-}
-
-const DEFAULT_POLICY: Policy = {
-  ALLOW: ALLOW_PATH_PATTERN,
-  ASK: ASK_PATH_PATTERN,
-  HALT: HALT_PATH_PATTERN,
-};
-
-const PATH_FIELDS = ["file_path", "path"] as const;
-const PATH_DEFAULT_TOOLS = new Set(["grep", "lsp_rename"]);
-const SHELL_MODIFIERS = new Set([
-  "export",
-  "declare",
-  "local",
-  "readonly",
-  "typeset",
-]);
-const ASSIGNMENT = /^[A-Za-z_][A-Za-z0-9_]*=/;
-
-export function decide(
-  toolName: string,
-  toolInput: unknown,
-  context: Context,
-  policy: Policy = DEFAULT_POLICY,
-): Decision {
-  const input = isRecord(toolInput) ? toolInput : {};
-  const candidates = toolName === "bash"
-    ? extractBashCandidates(input, context)
-    : extractFieldCandidates(toolName, input, context);
-  const matches: Match[] = [];
-  const unresolved: string[] = [];
-
-  for (const candidate of candidates) {
-    if (candidate.abs === undefined) {
-      unresolved.push(candidate.raw);
-      continue;
-    }
-    const hit = classify(candidate.abs, policy, context);
-    if (hit === undefined) continue;
-    for (const pattern of hit.patterns) {
-      matches.push({ level: hit.level, path: candidate.raw, pattern });
-    }
-  }
-
-  for (const level of ["halt", "ask", "allow"] as const) {
-    const hits = matches.filter((match) => match.level === level);
-    if (hits.length === 0) continue;
-    // An unresolvable path must never be auto-approved.
-    if (level === "allow" && unresolved.length > 0) break;
-    return { level, matches: hits, unresolved };
-  }
-
-  return { level: "ask", matches: [], unresolved };
-}
-
-export function render(decision: Decision): string | undefined {
-  if (decision.level === "allow") return JSON.stringify({ decision: "allow" });
-  if (decision.matches.length === 0 && decision.unresolved.length === 0) {
-    return undefined;
-  }
-  const reason = formatReason(decision);
-  if (decision.level === "halt") return JSON.stringify({ halt: true, reason });
-  return JSON.stringify({ reason });
-}
-
-function formatReason(decision: Decision): string {
-  const entries = [
-    ...decision.matches.map((match) =>
-      `${match.path} matched ${match.level.toUpperCase()} pattern ${match.pattern}`
-    ),
-    ...decision.unresolved.map((path) => `${path} could not be resolved`),
-  ];
-  return `${decision.level}: ${entries.join("; ")}`;
-}
-
-function extractFieldCandidates(
-  toolName: string,
-  input: Record<string, unknown>,
-  context: Context,
-): Candidate[] {
-  const candidates: Candidate[] = [];
-  for (const field of PATH_FIELDS) {
-    const value = input[field];
-    if (typeof value !== "string" || value === "") continue;
-    candidates.push({
-      raw: value,
-      abs: resolvePath(value, context.cwd, context),
-    });
-  }
-  if (candidates.length === 0 && PATH_DEFAULT_TOOLS.has(toolName)) {
-    candidates.push({
-      raw: context.cwd,
-      abs: resolvePath(context.cwd, context.cwd, context),
-    });
-  }
-  return candidates;
-}
-
-function extractBashCandidates(
-  input: Record<string, unknown>,
-  context: Context,
-): Candidate[] {
-  const command = typeof input.command === "string" ? input.command : "";
-  const workingDir =
-    typeof input.working_dir === "string" && input.working_dir !== ""
-      ? input.working_dir
-      : context.cwd;
-  const candidates: Candidate[] = [];
-  const variables = new Map<string, string>();
-  let baseDir: string | undefined = resolvePath(
-    workingDir,
-    context.cwd,
-    context,
-  );
-
-  for (const tokens of tokenize(command)) {
-    let index = 0;
-    while (index < tokens.length && SHELL_MODIFIERS.has(tokens[index])) index++;
-
-    if (tokens[index] === "cd") {
-      while (
-        tokens[index + 1] !== undefined && tokens[index + 1].startsWith("-") &&
-        tokens[index + 1] !== "-"
-      ) {
-        index++;
-      }
-      const target = tokens[index + 1];
-      if (target === undefined) {
-        baseDir = context.home;
-      } else if (target === "-") {
-        baseDir = undefined;
-      } else {
-        const expanded = expandVariables(target, variables, context);
-        if (expanded.unresolved) candidates.push({ raw: target });
-        baseDir = expanded.unresolved
-          ? undefined
-          : resolvePath(expanded.value, baseDir, context);
-      }
-      continue;
-    }
-
-    while (index < tokens.length && ASSIGNMENT.test(tokens[index])) {
-      const separator = tokens[index].indexOf("=");
-      variables.set(
-        tokens[index].slice(0, separator),
-        tokens[index].slice(separator + 1),
-      );
-      index++;
-    }
-
-    for (; index < tokens.length; index++) {
-      const raw = tokens[index];
-      const expanded = expandVariables(raw, variables, context);
-      if (expanded.unresolved) {
-        candidates.push({ raw });
-        continue;
-      }
-      const value = stripAssignmentPrefix(expanded.value);
-      if (!isCommandPath(value)) continue;
-      candidates.push({ raw, abs: resolvePath(value, baseDir, context) });
-    }
-  }
-
-  return candidates;
-}
-
-function tokenize(command: string): string[][] {
-  const segments: string[][] = [];
-  let tokens: string[] = [];
-  let token = "";
-  let quote: string | undefined;
-
-  const flushToken = () => {
-    if (token !== "") tokens.push(token);
-    token = "";
-  };
-  const flushSegment = () => {
-    flushToken();
-    if (tokens.length > 0) segments.push(tokens);
-    tokens = [];
-  };
-
-  for (let i = 0; i < command.length; i++) {
-    const char = command[i];
-    if (quote !== undefined) {
-      if (char === quote) quote = undefined;
-      else token += char;
-      continue;
-    }
-    if (char === '"' || char === "'") {
-      quote = char;
-      continue;
-    }
-    if (char === "\\") {
-      i++;
-      if (i < command.length) token += command[i];
-      continue;
-    }
-    if (";|&><()".includes(char) || char === "\n") {
-      flushSegment();
-      continue;
-    }
-    if (/\s/.test(char)) {
-      flushToken();
-      continue;
-    }
-    token += char;
-  }
-  flushSegment();
-  return segments;
-}
-
-function expandVariables(
-  token: string,
-  variables: Map<string, string>,
-  context: Context,
-  expanding = new Set<string>(),
-): { value: string; unresolved: boolean } {
-  let unresolved = token.includes("`") || token.includes("$(");
-  const value = token.replace(
-    /\$(\w+)|\$\{(\w+)\}/g,
-    (reference, plain: string | undefined, braced: string | undefined) => {
-      const name = plain ?? braced ?? "";
-      if (name === "HOME") return context.home;
-      const definition = variables.get(name);
-      if (definition === undefined || expanding.has(name)) {
-        unresolved = true;
-        return reference;
-      }
-      expanding.add(name);
-      const inner = expandVariables(
-        expandHome(definition, context.home),
-        variables,
-        context,
-        expanding,
-      );
-      expanding.delete(name);
-      if (inner.unresolved) unresolved = true;
-      return inner.value;
-    },
-  );
-  return { value, unresolved };
-}
-
-function stripAssignmentPrefix(value: string): string {
-  const separator = value.indexOf("=");
-  if (separator === -1) return value;
-  const rhs = value.slice(separator + 1);
-  return looksLikePath(rhs) ? rhs : value;
-}
-
-function looksLikePath(value: string): boolean {
-  if (value === "" || value === "." || value === "..") return false;
-  if (/^[A-Za-z][A-Za-z0-9+.-]*:\/\//.test(value)) return false;
-  return value.startsWith("/") || value.startsWith("./") ||
-    value.startsWith("../") ||
-    value.startsWith("~/") || value.includes("/");
-}
-
-// Bare words are candidate paths: `cat .env` names no separator but still
-// touches a secrets file, while leading dashes mark options.
-function isCommandPath(value: string): boolean {
-  if (value === "" || value.startsWith("-")) return false;
-  return !/^[A-Za-z][A-Za-z0-9+.-]*:\/\//.test(value);
-}
-
-function resolvePath(
-  value: string,
-  base: string | undefined,
-  context: Context,
-): string | undefined {
-  const expanded = expandHome(value, context.home);
-  const lexical = expanded.startsWith("/")
-    ? normalizeAbsolute(expanded)
-    : base === undefined
-    ? undefined
-    : normalizeAbsolute(`${base}/${expanded}`);
-  if (lexical === undefined) return undefined;
-  // Write targets may not exist yet, so fall back to the lexical form.
-  const resolved = context.realPath?.(lexical);
-  return resolved === undefined ? lexical : normalizeAbsolute(resolved);
-}
-
-function classify(
-  abs: string,
-  policy: Policy,
-  context: Context,
-): { level: Level; patterns: string[] } | undefined {
-  for (
-    const [level, patterns] of [["ask", policy.ASK], [
-      "halt",
-      policy.HALT,
-    ]] as const
-  ) {
-    const hits = patterns.filter((pattern) =>
-      matchesPattern(pattern, abs, context)
-    );
-    if (hits.length > 0) return { level, patterns: hits };
-  }
-
-  const hits = policy.ALLOW.filter((pattern) =>
-    matchesPattern(pattern, abs, context)
-  );
-  // Routine work inside the project should not prompt.
-  const projectDir = normalizeAbsolute(context.projectDir);
-  if (projectDir !== "/" && prefixMatch(abs, projectDir)) {
-    hits.push(context.projectDir);
-  }
-  if (hits.length > 0) return { level: "allow", patterns: hits };
-  return undefined;
-}
-
-function matchesPattern(
-  pattern: string,
-  abs: string,
-  context: Context,
-): boolean {
-  const anchored = anchorPattern(pattern, context);
-  if (isGlob(anchored)) {
-    try {
-      return globToRegExp(anchored).test(abs);
-    } catch {
-      return false;
-    }
-  }
-  return prefixMatch(abs, anchored);
-}
-
-function anchorPattern(pattern: string, context: Context): string {
-  const expanded = expandHome(pattern, context.home);
-  const absolute = expanded.startsWith("/")
-    ? expanded
-    : `${context.projectDir}/${expanded}`;
-  return normalizeAbsolute(absolute);
-}
-
-function prefixMatch(abs: string, prefix: string): boolean {
-  if (abs === prefix) return true;
-  return abs.startsWith(prefix === "/" ? "/" : `${prefix}/`);
-}
-
-function isGlob(pattern: string): boolean {
-  return /[*?[{]/.test(pattern);
-}
-
-function normalizeAbsolute(path: string): string {
-  const parts: string[] = [];
-  for (const part of path.split("/")) {
-    if (part === "" || part === ".") continue;
-    if (part === "..") parts.pop();
-    else parts.push(part);
-  }
-  return `/${parts.join("/")}`;
-}
-
-function expandHome(value: string, home: string): string {
-  if (value === "~") return home;
-  if (value.startsWith("~/")) return `${home}/${value.slice(2)}`;
-  return value;
-}
-
-function isRecord(value: unknown): value is Record<string, unknown> {
-  return typeof value === "object" && value !== null && !Array.isArray(value);
-}
-
-async function main(): Promise<void> {
-  const raw = await new Response(Deno.stdin.readable).text();
-  const payload = JSON.parse(raw) as Record<string, unknown>;
-  const cwd = typeof payload.cwd === "string" && payload.cwd !== ""
-    ? payload.cwd
-    : Deno.env.get("CRUSH_CWD") ?? Deno.cwd();
-  const context: Context = {
-    cwd,
-    projectDir: Deno.env.get("CRUSH_PROJECT_DIR") ?? cwd,
-    home: Deno.env.get("HOME") ?? "",
-    realPath: (path) => {
-      try {
-        return Deno.realPathSync(path);
-      } catch {
-        return undefined;
-      }
-    },
-  };
-  const output = render(
-    decide(String(payload.tool_name ?? ""), payload.tool_input, context),
-  );
-  if (output !== undefined) console.log(output);
-}
-
-if (import.meta.main) await main();
modules/dev/agents/crush/hooks/check-path_test.ts
@@ -1,304 +0,0 @@
-import { assertEquals } from "jsr:@std/assert@1";
-import { type Context, decide, type Policy, render } from "./check-path.ts";
-
-const CONTEXT: Context = {
-  cwd: "/project",
-  projectDir: "/project",
-  home: "/home/user",
-};
-const POLICY: Policy = {
-  ALLOW: ["/nix/store", "/tmp/crush"],
-  ASK: ["/**/*.env.example"],
-  HALT: ["/**/*.env"],
-};
-
-Deno.test("halts a forbidden file path", () => {
-  const decision = decide(
-    "view",
-    { file_path: "/home/user/.env" },
-    CONTEXT,
-    POLICY,
-  );
-  assertEquals(decision.level, "halt");
-  assertEquals(
-    render(decision),
-    `{"halt":true,"reason":"halt: /home/user/.env matched HALT pattern /**/*.env"}`,
-  );
-});
-
-Deno.test("asks for a template secret path", () => {
-  const decision = decide(
-    "view",
-    { file_path: "/home/user/.env.example" },
-    CONTEXT,
-    POLICY,
-  );
-  assertEquals(decision.level, "ask");
-  assertEquals(
-    render(decision),
-    `{"reason":"ask: /home/user/.env.example matched ASK pattern /**/*.env.example"}`,
-  );
-});
-
-Deno.test("allows nix store paths on component boundaries", () => {
-  assertEquals(
-    decide("view", { file_path: "/nix/store/abc" }, CONTEXT, POLICY).level,
-    "allow",
-  );
-  assertEquals(
-    render(decide("view", { file_path: "/nix/store" }, CONTEXT, POLICY)),
-    `{"decision":"allow"}`,
-  );
-  assertEquals(
-    decide("view", { file_path: "/nix/store-other/abc" }, CONTEXT, POLICY)
-      .level,
-    "ask",
-  );
-  assertEquals(
-    render(
-      decide("view", { file_path: "/nix/store-other/abc" }, CONTEXT, POLICY),
-    ),
-    undefined,
-  );
-});
-
-Deno.test("prefers ask over halt for a single path", () => {
-  const policy: Policy = {
-    ALLOW: [],
-    ASK: ["/secret/**"],
-    HALT: ["/secret/**"],
-  };
-  const decision = decide("view", { file_path: "/secret/x" }, CONTEXT, policy);
-  assertEquals(decision.level, "ask");
-  assertEquals(decision.matches, [{
-    level: "ask",
-    path: "/secret/x",
-    pattern: "/secret/**",
-  }]);
-});
-
-Deno.test("prefers halt over ask across paths", () => {
-  const decision = decide(
-    "bash",
-    { command: "cat /home/user/.env /home/user/other/.env.example" },
-    CONTEXT,
-    POLICY,
-  );
-  assertEquals(decision.level, "halt");
-  assertEquals(
-    render(decision),
-    `{"halt":true,"reason":"halt: /home/user/.env matched HALT pattern /**/*.env"}`,
-  );
-});
-
-Deno.test("anchors relative patterns to the project directory", () => {
-  const nested: Policy = { ALLOW: [], ASK: [], HALT: ["**/*.env"] };
-  assertEquals(
-    decide("view", { file_path: "sub/.env" }, CONTEXT, nested).level,
-    "halt",
-  );
-  assertEquals(
-    decide("view", { file_path: "/elsewhere/sub/.env" }, CONTEXT, nested).level,
-    "ask",
-  );
-
-  const prefix: Policy = { ALLOW: ["src"], ASK: [], HALT: [] };
-  assertEquals(
-    decide("view", { file_path: "src/main.ts" }, CONTEXT, prefix).level,
-    "allow",
-  );
-  assertEquals(
-    decide("view", { file_path: "/other/src/main.ts" }, CONTEXT, prefix).level,
-    "ask",
-  );
-});
-
-Deno.test("resolves relative tool paths against the cwd", () => {
-  const context: Context = {
-    cwd: "/work",
-    projectDir: "/project",
-    home: "/home/user",
-  };
-  const policy: Policy = { ALLOW: ["/work"], ASK: [], HALT: [] };
-  assertEquals(
-    decide("view", { file_path: "a.txt" }, context, policy).level,
-    "allow",
-  );
-});
-
-Deno.test("defaults the search path to the cwd", () => {
-  const context: Context = {
-    cwd: "/nix/store/pkg",
-    projectDir: "/project",
-    home: "/home/user",
-  };
-  assertEquals(decide("grep", {}, context, POLICY).level, "allow");
-  assertEquals(
-    decide("lsp_rename", { symbol: "a", new_name: "b" }, context, POLICY).level,
-    "allow",
-  );
-});
-
-Deno.test("parses quoted and chained bash arguments", () => {
-  assertEquals(
-    decide("bash", { command: `cat "/home/user/.env"` }, CONTEXT, POLICY).level,
-    "halt",
-  );
-  assertEquals(
-    decide(
-      "bash",
-      { command: "true && cat /home/user/.env | wc -l" },
-      CONTEXT,
-      POLICY,
-    ).level,
-    "halt",
-  );
-  assertEquals(
-    decide(
-      "bash",
-      { command: "cat '/home/user/.env.example'" },
-      CONTEXT,
-      POLICY,
-    ).level,
-    "ask",
-  );
-});
-
-Deno.test("tracks cd when resolving relative bash paths", () => {
-  assertEquals(
-    decide("bash", { command: "cat .env" }, CONTEXT, POLICY).level,
-    "halt",
-  );
-  assertEquals(
-    decide("bash", { command: "cd /home/user && cat .env" }, CONTEXT, POLICY)
-      .level,
-    "halt",
-  );
-  assertEquals(
-    decide("bash", { command: "cd sub && cat .env" }, CONTEXT, POLICY).level,
-    "halt",
-  );
-  assertEquals(
-    decide("bash", { command: "cd && cat .env" }, CONTEXT, POLICY).level,
-    "halt",
-  );
-});
-
-Deno.test("uses the bash working directory as the relative base", () => {
-  const input = { command: "cat .env", working_dir: "sub" };
-  assertEquals(decide("bash", input, CONTEXT, POLICY).level, "halt");
-});
-
-Deno.test("expands shell variable definitions", () => {
-  assertEquals(
-    decide(
-      "bash",
-      { command: "FOO=/home/user; cat $FOO/.env" },
-      CONTEXT,
-      POLICY,
-    ).level,
-    "halt",
-  );
-  assertEquals(
-    decide("bash", { command: "FOO=/home/user cat $FOO/.env" }, CONTEXT, POLICY)
-      .level,
-    "halt",
-  );
-  assertEquals(
-    decide(
-      "bash",
-      { command: "export FOO=/home/user && cat ${FOO}/.env" },
-      CONTEXT,
-      POLICY,
-    ).level,
-    "halt",
-  );
-  assertEquals(
-    decide(
-      "bash",
-      { command: "A=/home/user; B=$A/dir; cat $B/.env" },
-      CONTEXT,
-      POLICY,
-    ).level,
-    "halt",
-  );
-});
-
-Deno.test("marks unresolvable shell paths and blocks allow", () => {
-  const decision = decide(
-    "bash",
-    { command: "cat $UNKNOWN /nix/store/x" },
-    CONTEXT,
-    POLICY,
-  );
-  assertEquals(decision.level, "ask");
-  assertEquals(
-    render(decision),
-    `{"reason":"ask: $UNKNOWN could not be resolved"}`,
-  );
-  assertEquals(
-    decide("bash", { command: "cd - && cat .env" }, CONTEXT, POLICY).level,
-    "ask",
-  );
-  assertEquals(
-    decide("bash", { command: "cd $DIR && cat /nix/store/x" }, CONTEXT, POLICY)
-      .level,
-    "ask",
-  );
-});
-
-Deno.test("switches to glob matching on glob metacharacters", () => {
-  const policy: Policy = { ALLOW: ["/data/*.log"], ASK: [], HALT: [] };
-  assertEquals(
-    decide("view", { file_path: "/data/a.log" }, CONTEXT, policy).level,
-    "allow",
-  );
-  assertEquals(
-    decide("view", { file_path: "/data/sub/a.log" }, CONTEXT, policy).level,
-    "ask",
-  );
-});
-
-Deno.test("asks silently on malformed or empty input", () => {
-  assertEquals(render(decide("view", {}, CONTEXT, POLICY)), undefined);
-  assertEquals(render(decide("view", null, CONTEXT, POLICY)), undefined);
-});
-
-Deno.test("auto-allows project paths for every tool", () => {
-  const empty: Policy = { ALLOW: [], ASK: [], HALT: [] };
-  const decision = decide("view", { file_path: "src/main.ts" }, CONTEXT, empty);
-  assertEquals(decision.level, "allow");
-  assertEquals(decision.matches, [{
-    level: "allow",
-    path: "src/main.ts",
-    pattern: "/project",
-  }]);
-  assertEquals(
-    decide("bash", { command: "rm -rf build" }, CONTEXT, empty).level,
-    "allow",
-  );
-  assertEquals(
-    decide("view", { file_path: ".env" }, CONTEXT, POLICY).level,
-    "halt",
-  );
-});
-
-Deno.test("resolves symlinks before matching", () => {
-  const context: Context = {
-    ...CONTEXT,
-    realPath: (path) =>
-      path === "/tmp/crush/link" ? "/home/user/.env" : undefined,
-  };
-  assertEquals(
-    decide("view", { file_path: "/tmp/crush/link" }, context, POLICY).level,
-    "halt",
-  );
-
-  const outside: Context = {
-    ...CONTEXT,
-    realPath: (path) => path === "/project/link" ? "/nix/store/abc" : undefined,
-  };
-  const decision = decide("view", { file_path: "link" }, outside, POLICY);
-  assertEquals(decision.level, "allow");
-  assertEquals(decision.matches[0].pattern, "/nix/store");
-});
modules/dev/agents/crush/default.nix
@@ -34,6 +34,8 @@
         meta = (old.meta or {}) // {mainProgram = "crush";};
       });
 
+      checkPath = pkgs.callPackage ./hooks/check_path/_package.nix {};
+
       agent = mkJailedAgent {
         name = "crush";
         pkg = crush;
@@ -42,7 +44,6 @@
           "${config.xdg.data.directory}/crush"
         ];
         extraPkgs = [
-          pkgs.deno
           pkgs.nur.repos.hpcesia.dcg
         ];
       };
@@ -76,8 +77,7 @@
             hook add PreToolUse \
               --name "check-path" \
               --matcher '^(view|grep|edit|write|multiedit|download|lsp_replace_symbol|lsp_rename|bash)$' \
-              --command "${lib.getExe pkgs.deno} run --no-config --allow-read \
-                --allow-env=CRUSH_PROJECT_DIR,CRUSH_CWD,HOME ${./hooks/check-path.ts}"
+              --command "${lib.getExe checkPath}"
 
             hook add PreToolUse \
               --name "dcg" \
@@ -148,4 +148,13 @@
       };
     };
   };
+
+  perSystem = {pkgs, ...}: {
+    devshells.default = {
+      packages = [
+        pkgs.go
+        pkgs.gopls
+      ];
+    };
+  };
 }
modules/dev/editor/helix/lang/javascript.nix
@@ -10,7 +10,6 @@
             tab-width = 2;
             unit = "  ";
           };
-          language-servers = ["deno"];
         }) ["javascript" "jsx" "typescript" "tsx"];
       };
     };
modules/dev/tools.nix
@@ -33,8 +33,6 @@
           ty # Python LSP
           ruff # Python fomatter
 
-          deno
-
           # Documents
           rumdl # Markdown linter / formatter
 
modules/flake-parts/treefmt.nix
@@ -9,10 +9,7 @@
   perSystem = {
     treefmt = {
       programs.alejandra.enable = true;
-      programs.deno = {
-        enable = true;
-        includes = ["*.js" "*.ts"];
-      };
+      programs.gofmt.enable = true;
       programs.rumdl-format.enable = true;
     };
   };