Commit d79fd73
Changed files (4)
modules
dev
agents
crush
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