Commit d79fd73

HPCesia <me@hpcesia.com>
2026-09-08 15:34:22
crush: init
Assisted-by: crush:deepseek-v4-flash
1 parent c07b0a5
Changed files (4)
modules
dev
users
hpcesia
modules/dev/agents/crush/hooks/check-command.sh
@@ -0,0 +1,53 @@
+#!/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
@@ -0,0 +1,151 @@
+#!/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/default.nix
@@ -0,0 +1,71 @@
+{den, ...}: {
+  den.aspects.dev.agents.crush = {
+    includes = [den.aspects.dev.agents];
+
+    persistHome = {config, ...}: {
+      directories = [
+        "${config.xdg.config.directory}/crush"
+      ];
+    };
+
+    cacheHome = {config, ...}: {
+      directories = [
+        "${config.xdg.data.directory}/crush"
+      ];
+    };
+
+    hjem = {
+      pkgs,
+      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";
+
+        "crush/crushrc" = {
+          type = "copy";
+          permissions = "0600";
+          text = ''
+            set -a; source ./.env; set +a
+
+            option global-context-path "${config.xdg.config.directory}/agents/AGENTS.md"
+            option skill-path "${config.xdg.config.directory}/agents/skills"
+
+            option ui transparent true
+
+            mcp add deepwiki --type http \
+              --url "https://mcp.deepwiki.com/mcp"
+
+            permissions allow ls glob
+
+            hook add PreToolUse \
+              --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"
+          '';
+        };
+      };
+    };
+  };
+
+  den.aspects.dev.crush.themes = {
+    catppuccin = {
+      # Unfortunately, crush lack of themes support :(
+      # TODO: Waiting for https://github.com/charmbracelet/crush/pull/2731
+    };
+  };
+}
modules/users/hpcesia/dev.nix
@@ -4,6 +4,7 @@
 in {
   den.aspects.hpcesia = {
     includes = includeWhen ({host, ...}: host.hasAspect den.aspects.dev) (with den.aspects; [
+      dev.agents.crush
       dev.agents.opencode
       dev.agents.pi
       dev.direnv