Commit a208c51
Changed files (5)
modules
dev
agents
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}"
'';
};
};