From ba4ab1a397e138a65404ef5f27e653cfd5a06199 Mon Sep 17 00:00:00 2001 From: Jakub Czajka Date: Mon, 22 Jun 2026 00:05:33 +0200 Subject: [PATCH] [ai] Add guix-build stop hook, guard-commands hook, and Makefile build targets guard-commands guards against dangerous or branch-inappropriate commands: guix reconfigure (denied on branches), guix build (ask everywhere), make reconfigure targets (denied on branches), git checkout/switch (denied everywhere). Makefile gains build-home-nonfree, build-home-libre, build-system-nonfree, and build-system-libre targets for the build queue. --- .claude/hooks/guard-commands | 89 +++++++++++++++++++++++++++++++++ .claude/hooks/guix-build | 97 ++++++++++++++++++++++++++++++++++++ .claude/settings.json | 22 ++++++++ Makefile | 23 ++++++++- 4 files changed, 230 insertions(+), 1 deletion(-) create mode 100755 .claude/hooks/guard-commands create mode 100755 .claude/hooks/guix-build diff --git a/.claude/hooks/guard-commands b/.claude/hooks/guard-commands new file mode 100755 index 0000000..3962a77 --- /dev/null +++ b/.claude/hooks/guard-commands @@ -0,0 +1,89 @@ +#!/bin/sh +# Copyright (c) 2026 Jakub Czajka +# License: GPL-3.0 or later. +# +# guard-commands — PreToolUse hook that guards against dangerous or +# branch-inappropriate commands. + +cmd=$($GUIX_BIN/cat | $GUIX_BIN/sed --regexp-extended --quiet \ + 's/.*"command"\s*:\s*"([^"]+)".*/\1/p') +[ -z "$cmd" ] && exit 0 + +branch=$($HOME/.guix-home/profile/bin/git -C "$CLAUDE_PROJECT_DIR" \ + branch --show-current 2>/dev/null) + +# --- git checkout / git switch: deny everywhere --- +if $GUIX_BIN/echo "$cmd" | $GUIX_BIN/grep --extended-regexp --quiet \ + '\bgit (checkout|switch)\b'; then + $GUIX_BIN/echo \ + '{"hookSpecificOutput": {' \ + ' "hookEventName": "PreToolUse",' \ + ' "permissionDecision": "deny",' \ + ' "systemMessage": "Branch switching disabled. Use EnterWorktree."' \ + '}}' + exit 0 +fi + +# --- make build-*: ask everywhere --- +if $GUIX_BIN/echo "$cmd" | $GUIX_BIN/grep --extended-regexp --quiet \ + '\bmake build-'; then + $GUIX_BIN/echo \ + '{"hookSpecificOutput": {' \ + ' "hookEventName": "PreToolUse",' \ + ' "permissionDecision": "ask"' \ + '}}' + exit 0 +fi + +# --- guix build: ask everywhere --- +if $GUIX_BIN/echo "$cmd" | $GUIX_BIN/grep --extended-regexp --quiet \ + '\bguix (home|system) build\b'; then + $GUIX_BIN/echo \ + '{"hookSpecificOutput": {' \ + ' "hookEventName": "PreToolUse",' \ + ' "permissionDecision": "ask"' \ + '}}' + exit 0 +fi + +# --- guix reconfigure: ask on main, deny on branches --- +if $GUIX_BIN/echo "$cmd" | $GUIX_BIN/grep --extended-regexp --quiet \ + '\bguix (home|system) reconfigure\b'; then + if [ "$branch" != "main" ]; then + $GUIX_BIN/echo \ + '{"hookSpecificOutput": {' \ + ' "hookEventName": "PreToolUse",' \ + ' "permissionDecision": "deny",' \ + ' "systemMessage": "guix reconfigure blocked. Merge into main."' \ + '}}' + exit 0 + fi + $GUIX_BIN/echo \ + '{"hookSpecificOutput": {' \ + ' "hookEventName": "PreToolUse",' \ + ' "permissionDecision": "ask"' \ + '}}' + exit 0 +fi + +# --- make home-* / make system-*: ask on main, deny on branches --- +if $GUIX_BIN/echo "$cmd" | $GUIX_BIN/grep --extended-regexp --quiet \ + '\bmake (home|system)-'; then + if [ "$branch" != "main" ]; then + $GUIX_BIN/echo \ + '{"hookSpecificOutput": {' \ + ' "hookEventName": "PreToolUse",' \ + ' "permissionDecision": "deny",' \ + ' "systemMessage": "Reconfigure blocked. Merge into main."' \ + '}}' + exit 0 + fi + $GUIX_BIN/echo \ + '{"hookSpecificOutput": {' \ + ' "hookEventName": "PreToolUse",' \ + ' "permissionDecision": "ask"' \ + '}}' + exit 0 +fi + +exit 0 diff --git a/.claude/hooks/guix-build b/.claude/hooks/guix-build new file mode 100755 index 0000000..72f1ba1 --- /dev/null +++ b/.claude/hooks/guix-build @@ -0,0 +1,97 @@ +#!/bin/sh +# Copyright (c) 2026 Jakub Czajka +# License: GPL-3.0 or later. +# +# guix-build — Stop hook that drives a build-queue state machine. +# +# State machine (queue = .claude/.build-queue.): +# +# │ +# ▼ +# clean tree ────────────────────---─► exit 0 +# │ +# ▼ +# no queue file ─────────────────---─► exit 2 +# │ ask user for targets +# │ write queue file or touch empty file +# ▼ +# queue non-empty? +# │ +# ├─ no ─────────────────────────---─► exit 0 +# │ rm queue file +# ▼ +# read first target +# │ +# ▼ +# target stale? ─────────────────---─► exit 2 +# │ drop it, check next on re-stop +# ▼ +# build target +# │ +# ├─ failure ───────────────────---──► exit 2 +# │ +# └─ success ──► remove from queue ──► exit 2 +# build next on re-stop + +# Print space-separated Makefile targets with guix reconfigure. +MAKEFILE="$CLAUDE_PROJECT_DIR/Makefile" +build_targets() { + $GUIX_BIN/sed --regexp-extended --quiet \ + '/^([a-zA-Z_-][a-zA-Z0-9_-]*):/h + /guix.*reconfigure/{g;s/^([a-zA-Z_-][a-zA-Z0-9_-]*):.*/\1/p}' \ + "$1" | $GUIX_BIN/tr '\n' ' ' +} + +if [ -z "$($HOME/.guix-home/profile/bin/git -C "$CLAUDE_PROJECT_DIR" \ + status --porcelain --untracked-files=no)" ]; then + $GUIX_BIN/echo '{"systemMessage":"🟢 Working tree clean."}' + exit 0 +fi + +$GUIX_BIN/mkdir --parents "$CLAUDE_PROJECT_DIR/.claude" +QUEUE_FILE="$CLAUDE_PROJECT_DIR/.claude/.build-queue.$CLAUDE_CODE_SESSION_ID" + +# No queue file yet — ask the user which targets to build. +if [ ! -f "$QUEUE_FILE" ]; then + exec 1>&2 + $GUIX_BIN/echo "First, use AskUserQuestion (single-select) to ask whether" + $GUIX_BIN/echo "to build at all. Options: [Build targets, Skip build]." + $GUIX_BIN/echo "" + $GUIX_BIN/echo "If Build, use AskUserQuestion (multiSelect, max 4) with" + $GUIX_BIN/echo "these targets: $(build_targets "$MAKEFILE")." + $GUIX_BIN/echo "" + $GUIX_BIN/echo "Write selected targets to ${QUEUE_FILE} (one per line)," + $GUIX_BIN/echo "then stop so the build begins." + $GUIX_BIN/echo "" + $GUIX_BIN/echo "If Skip: write an empty file (touch ${QUEUE_FILE})." + exit 2 +fi + +# Build the first target if the queue is non-empty. +if [ -s "$QUEUE_FILE" ]; then + target=$($GUIX_BIN/head --lines=1 "$QUEUE_FILE") + if ! $GUIX_BIN/grep --quiet "^${target}:" "$MAKEFILE"; then + $GUIX_BIN/sed --in-place '1d' "$QUEUE_FILE" + + exec 1>&2 + $GUIX_BIN/echo "Target $target no longer exists, skipped." + exit 2 + fi + + $GUIX_BIN/make -C "$CLAUDE_PROJECT_DIR" "$target" 1>&2 || { + exec 1>&2 + + $GUIX_BIN/echo "❌ Build failed (exit $?) — fix and retry." + exit 2 + } + $GUIX_BIN/sed --in-place '1d' "$QUEUE_FILE" + + exec 1>&2 + $GUIX_BIN/echo "Stop to continue." + exit 2 +fi + +# Queue is empty — we are finished. +$GUIX_BIN/echo '{"systemMessage":"✅ Queue finished."}' +$GUIX_BIN/rm --force "$QUEUE_FILE" +exit 0 diff --git a/.claude/settings.json b/.claude/settings.json index d15a659..dc5acb1 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -4,6 +4,17 @@ "PROFILE": "nonfree" }, "hooks": { + "PreToolUse": [ + { + "matcher": "Bash", + "hooks": [ + { + "type": "command", + "command": "${CLAUDE_PROJECT_DIR}/.claude/hooks/guard-commands" + } + ] + } + ], "PostToolUse": [ { "matcher": "Edit|Write", @@ -23,6 +34,17 @@ ] } ], + "Stop": [ + { + "hooks": [ + { + "type": "command", + "command": "${CLAUDE_PROJECT_DIR}/.claude/hooks/guix-build", + "statusMessage": "Build and check Guix." + } + ] + } + ], "Notification": [ { "matcher": "permission_prompt|idle_prompt", diff --git a/Makefile b/Makefile index 6a0ee24..425cb4a 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -# Copyright (c) 2022-2024 Jakub Czajka +# Copyright (c) 2022-2026 Jakub Czajka # License: GPL-3.0 or later. guix-profile := ~/.guix-home/profile @@ -19,6 +19,27 @@ system-nonfree: system-libre: sudo GUIX_PACKAGE_PATH=`pwd` PROFILE=libre guix system reconfigure system.scm +# Build-only targets — validate configuration without activating. +# These build derivations and cache them in the store so a +# subsequent reconfigure is near-instant. The build queue runs +# these on non-main branches; on main it runs the reconfigure +# targets directly. +build-home-nonfree: + GUIX_PACKAGE_PATH=`pwd` PROFILE=nonfree \ + guix home build home.scm + +build-home-libre: + GUIX_PACKAGE_PATH=`pwd` PROFILE=libre \ + guix home build home.scm + +build-system-nonfree: + GUIX_PACKAGE_PATH=`pwd` PROFILE=nonfree \ + guix system build system.scm + +build-system-libre: + GUIX_PACKAGE_PATH=`pwd` PROFILE=libre \ + guix system build system.scm + tags: find $(guix-store) -wholename "*guix-$(guix-version)*.scm" -or \ -wholename "*guix-module-union*.scm" -or \ -- 2.47.3