Commit c5ba7f6
Changed files (11)
modules
dev
agents
crush
editor
helix
lang
flake-parts
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;
};
};