Commit a208c51

HPCesia <me@hpcesia.com>
2026-09-14 14:38:57
crush: replace old bash hook to deno script
Assisted-by: opencode:deepseek-v4.1-flash
1 parent 2062553
Changed files (5)
modules/dev/agents/crush/hooks/check-command.sh
@@ -1,53 +0,0 @@
-#!/usr/bin/env bash
-set -euo pipefail
-
-HALT_PATTERNS=(
-  # rm -rf against filesystem root (any flag spelling, any depth)
-  '^[[:space:]]*rm([[:space:]]+-[rf][a-z]*|[[:space:]]+--recursive[[:space:]]+--force|[[:space:]]+-[a-z]*[rf])+[[:space:]]+/'
-  # rm -rf clearing the current directory
-  '^[[:space:]]*rm([[:space:]]+-[rf][a-z]*|[[:space:]]+--recursive[[:space:]]+--force|[[:space:]]+-[a-z]*[rf])+[[:space:]]+\./?[[:space:]]*$'
-  # rm -rf deleting VCS metadata anywhere (path-segment match)
-  '^[[:space:]]*rm\b.*(^|[ /])\.(git|jj)([ /]|$)'
-  # mkfs and friends
-  '^[[:space:]]*mkfs(\.[a-z0-9]+)?[[:space:]]'
-  # writing to block devices with dd
-  '^[[:space:]]*dd\b.*\bof=/dev/'
-  # shell fork bombs
-  ':\(\)[[:space:]]*\{'
-)
-
-ASK_PATTERNS=(
-  '^[[:space:]]*git[[:space:]]+push\b'
-  '^[[:space:]]*git[[:space:]]+reset\b.*--hard'
-  '^[[:space:]]*git[[:space:]]+clean\b'
-  '^[[:space:]]*chmod\b.*-R[[:space:]]+777\b'
-  '^[[:space:]]*kill\b.*-[a-z]*9\b'
-)
-
-main() {
-  local cmd="${CRUSH_TOOL_INPUT_COMMAND:-}"
-  [[ -z "$cmd" ]] && exit 0
-
-  local p
-  for p in "${HALT_PATTERNS[@]}"; do
-    if [[ "$cmd" =~ $p ]]; then
-      echo "denied: destructive command: $cmd" >&2
-      exit 49
-    fi
-  done
-
-  local ask_reason=""
-  for p in "${ASK_PATTERNS[@]}"; do
-    if [[ "$cmd" =~ $p ]]; then
-      ask_reason="Sensitive command, waiting for your approval: $cmd"
-      break
-    fi
-  done
-
-  if [[ -n "$ask_reason" ]]; then
-    printf '{"context":"%s"}' "$ask_reason"
-  fi
-  # no match: no opinion, normal permission flow decides
-}
-
-main "$@"
modules/dev/agents/crush/hooks/check-path.sh
@@ -1,151 +0,0 @@
-#!/usr/bin/env bash
-set -euo pipefail
-
-READ_ONLY_ALLOW=(
-  "/nix/store"
-)
-
-READ_WRITE_ALLOW=(
-  "/tmp/crush"
-)
-
-BLOCK_SEGMENTS=(
-  ".env"
-  ".ssh"
-  ".gnupg"
-)
-
-WRITE_COMMANDS=(
-  "rm" "mv" "cp" "dd" "mkfs" "chmod" "chown" "mkdir" "rmdir" "ln" "touch"
-  "truncate" "tee" "git" "cargo" "npm" "nix"
-)
-
-normalize() {
-  local p="$1"
-  p="${p/#\~/$HOME}"
-  [[ "$p" == /* ]] || p="${CRUSH_CWD:-$PWD}/$p"
-  readlink -f "$p" 2>/dev/null || printf '%s' "$p"
-}
-
-under_any() {
-  local p="$1" prefix
-  shift
-  for prefix in "$@"; do
-    [[ -n "$prefix" ]] || continue
-    prefix="$(normalize "$prefix")"
-    [[ "$p" == "$prefix" || "$p" == "$prefix"/* ]] && return 0
-  done
-  return 1
-}
-
-blocked_segment() {
-  local p="$1" seg
-  for seg in "${BLOCK_SEGMENTS[@]}"; do
-    [[ "$p" == *"/$seg" || "$p" == *"/$seg/"* ]] && return 0
-  done
-  return 1
-}
-
-is_read_tool() {
-  case "$1" in
-  view | grep) return 0 ;;
-  *) return 1 ;;
-  esac
-}
-
-is_write_command() {
-  local base="$1" w
-  for w in "${WRITE_COMMANDS[@]}"; do
-    [[ "$base" == "$w" ]] && return 0
-  done
-  return 1
-}
-
-# Extract a string field from the single-line JSON on stdin
-json_field() {
-  sed -n "s/.*\"$1\"[[:space:]]*:[[:space:]]*\"\([^\"]*\)\".*/\1/p" | head -1
-}
-
-check_target() {
-  local tool="$1" target="$2"
-  [[ -z "$target" ]] && return 0
-  local p
-  p="$(normalize "$target")"
-
-  if blocked_segment "$p"; then
-    echo "denied: sensitive path $target" >&2
-    exit 49
-  fi
-
-  # Session whitelists: project root and current working dir, read+write
-  local auto=("${CRUSH_PROJECT_DIR:-}" "${CRUSH_CWD:-}")
-  if is_read_tool "$tool"; then
-    if under_any "$p" "${READ_ONLY_ALLOW[@]}" "${READ_WRITE_ALLOW[@]}" "${auto[@]}"; then
-      echo '{"decision":"allow"}'
-    fi
-  else
-    if under_any "$p" "${READ_WRITE_ALLOW[@]}" "${auto[@]}"; then
-      echo '{"decision":"allow"}'
-    fi
-  fi
-}
-
-check_bash() {
-  local cmd="${CRUSH_TOOL_INPUT_COMMAND:-}"
-  [[ -z "$cmd" ]] && return 0
-
-  # Strip quotes, tokenize on whitespace; base command is the first token
-  local -a tokens=()
-  read -r -a tokens <<<"$(printf '%s' "$cmd" | sed -e "s/[\"']//g")"
-  [[ ${#tokens[@]} -eq 0 ]] && return 0
-  local base="${tokens[0]}"
-
-  local token p total=0 allowed=0 is_write=0
-  is_write_command "$base" && is_write=1
-  [[ "$cmd" == *">"* ]] && is_write=1
-
-  for token in "${tokens[@]:1}"; do
-    # Only path-like tokens: starts with . / ~ or contains /, not a bare option
-    [[ "$token" == -* ]] && continue
-    [[ "$token" =~ ^[./~] || "$token" == */* ]] || continue
-    total=$((total + 1))
-    p="$(normalize "$token")"
-
-    if blocked_segment "$p"; then
-      echo "denied: sensitive path $token" >&2
-      exit 49
-    fi
-
-    local auto=("${CRUSH_PROJECT_DIR:-}" "${CRUSH_CWD:-}")
-    if [[ $is_write -eq 1 ]]; then
-      under_any "$p" "${READ_WRITE_ALLOW[@]}" "${auto[@]}" && allowed=$((allowed + 1))
-    else
-      under_any "$p" "${READ_ONLY_ALLOW[@]}" "${READ_WRITE_ALLOW[@]}" "${auto[@]}" && allowed=$((allowed + 1))
-    fi
-  done
-
-  if [[ $total -gt 0 && $allowed -eq $total ]]; then
-    echo '{"decision":"allow"}'
-  fi
-}
-
-main() {
-  local tool="${CRUSH_TOOL_NAME:-}"
-  case "$tool" in
-  bash)
-    check_bash
-    ;;
-  grep)
-    local target
-    target="$(json_field path </dev/stdin)"
-    check_target "$tool" "$target"
-    ;;
-  *)
-    local target="${CRUSH_TOOL_INPUT_FILE_PATH:-}"
-    [[ -z "$target" ]] && target="$(json_field file_path </dev/stdin)"
-    check_target "$tool" "$target"
-    ;;
-  esac
-}
-
-main "$@"
modules/dev/agents/crush/hooks/check-path.ts
@@ -0,0 +1,434 @@
+#!/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
@@ -0,0 +1,304 @@
+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
@@ -16,21 +16,13 @@
 
     hjem = {
       pkgs,
+      lib,
       config,
       ...
     }: {
       packages = [pkgs.crush];
 
-      xdg.config.files = let
-        hookScript = name: {
-          type = "copy";
-          permissions = "0700";
-          source = ./hooks/${name};
-        };
-      in {
-        "crush/hooks/check-path.sh" = hookScript "check-path.sh";
-        "crush/hooks/check-command.sh" = hookScript "check-command.sh";
-
+      xdg.config.files = {
         "crush/crushrc" = {
           type = "copy";
           permissions = "0600";
@@ -48,14 +40,10 @@
             permissions allow ls glob
 
             hook add PreToolUse \
-              --name check-path \
+              --name "check-path" \
               --matcher '^(view|grep|edit|write|multiedit|download|lsp_replace_symbol|lsp_rename|bash)$' \
-              --command "${config.xdg.config.directory}/crush/hooks/check-path.sh"
-
-            hook add PreToolUse \
-              --name check-command \
-              --matcher '^bash$' \
-              --command "${config.xdg.config.directory}/crush/hooks/check-command.sh"
+              --command "${lib.getExe pkgs.deno} run --no-config --allow-read \
+                --allow-env=CRUSH_PROJECT_DIR,CRUSH_CWD,HOME ${./hooks/check-path.ts}"
           '';
         };
       };