]> git.ekhem.eu.org Git - guix.git/commitdiff
[infra] Harden worktree isolation; fix merge script git path
authorJakub Czajka <jakub@ekhem.eu.org>
Tue, 14 Jul 2026 20:03:00 +0000 (20:03 +0000)
committerJakub Czajka <jakub@ekhem.eu.org>
Wed, 15 Jul 2026 10:45:16 +0000 (10:45 +0000)
create-worktree: Block Write/Edit file_path outside the
worktree, Bash cd to main or other worktrees, git -C on main
(except merge-worktree), and shell redirects to main.  Ensure
.claude/skills stays a symlink to main on entry.

merge-worktree, branch-summary: Use plain git from PATH
instead of hardcoded $HOME/.guix-home/profile/bin/git.

Co-Authored-By: Claude <noreply@anthropic.com>
.claude/hooks/create-worktree
.claude/scripts/branch-summary
.claude/scripts/merge-worktree
.claude/skills [new symlink]
.claude/skills/merge-worktree.md [deleted file]

index cf996ff706f830a84a7555c6f8c1fcf36de42beb..10c120ce64e7a9c6debfc150b71f95a18fd5fdf0 100755 (executable)
 # Copyright (c) 2026 Jakub Czajka <jakub@ekhem.eu.org>
 # License: GPL-3.0 or later.
 #
-# create-worktree - Enforce worktree entry on main.
+# create-worktree - Enforce worktree isolation.
 #
-# Matches all tool types (*). Denies every tool call (except EnterWorktree)
-# unless already in this session's worktree.
+# Matches all tool types (*).  Denies every tool call (except
+# EnterWorktree) unless already in this session's worktree.  Once inside
+# the worktree, blocks Write/Edit/Bash from reaching outside it — the
+# only path out is /merge-worktree.
+
+# Capture stdin before any command consumes it.
+stdin=$($GUIX_BIN/cat)
 
 # Let EnterWorktree pass through.
-if $GUIX_BIN/grep -q '"tool_name"[[:space:]]*:[[:space:]]*"EnterWorktree"'
+if $GUIX_BIN/echo "$stdin" \
+    | $GUIX_BIN/grep -q '"tool_name"[[:space:]]*:[[:space:]]*"EnterWorktree"'
 then
     exit 0
 fi
 
-# Allow if already is this session's worktree.
-if $GUIX_BIN/echo "$PWD" | $GUIX_BIN/grep --quiet \
+# Deny if NOT in this session's worktree.
+if $GUIX_BIN/echo "$PWD" | $GUIX_BIN/grep --quiet \
     "/worktrees/${CLAUDE_CODE_SESSION_ID}"
 then
+    ${GUIX_BIN}/echo \
+        '{"hookSpecificOutput": {' \
+        '"hookEventName": "PreToolUse",' \
+        '"permissionDecision": "deny",' \
+        '"permissionDecisionReason": "Not in the right worktree. Call' \
+          'EnterWorktree with name='"$CLAUDE_CODE_SESSION_ID"' to create and' \
+          'switch into a worktree, then retry. You MUST use this session' \
+          'name.",' \
+        '"systemMessage": "🚧 Not in the right worktree. Call EnterWorktree' \
+          'with name='"$CLAUDE_CODE_SESSION_ID"'."' \
+        '}}'
     exit 0
 fi
 
-# Deny -- must EnterWorktree with this session's ID as the name.
-${GUIX_BIN}/echo \
-    '{"hookSpecificOutput": {' \
-    '"hookEventName": "PreToolUse",' \
-    '"permissionDecision": "deny",' \
-    '"permissionDecisionReason": "Not in the right worktree. Call' \
-      'EnterWorktree with name='"$CLAUDE_CODE_SESSION_ID"' to create and' \
-      'switch into a worktree, then retry. You MUST use this session name.",' \
-    '"systemMessage": "🚧 Not in the right worktree. Call EnterWorktree with' \
-      'name='"$CLAUDE_CODE_SESSION_ID"'."' \
-    '}}'
+# ── We are in the worktree ──────────────────────────────────────
+
+# Keep skills fresh — symlink from main so agents always see the
+# latest version.
+if [ -d "$PWD/.claude/skills" ] && [ ! -L "$PWD/.claude/skills" ]
+then
+    if [ -d "$CLAUDE_PROJECT_DIR/.claude/skills" ] && \
+       [ ! -L "$CLAUDE_PROJECT_DIR/.claude/skills" ]
+    then
+        rm -rf "$PWD/.claude/skills"
+        ln -s "$CLAUDE_PROJECT_DIR/.claude/skills" \
+            "$PWD/.claude/skills"
+    fi
+fi
+
+worktree="$CLAUDE_PROJECT_DIR/.claude/worktrees/$CLAUDE_CODE_SESSION_ID"
+tool=$($GUIX_BIN/echo "$stdin" \
+    | $GUIX_BIN/sed --quiet \
+        's/.*"tool_name"[[:space:]]*:[[:space:]]*"\([^"]\+\)".*/\1/p')
+
+case "$tool" in
+Write|Edit)
+    file_path=$($GUIX_BIN/echo "$stdin" \
+        | $GUIX_BIN/sed --quiet \
+            's/.*"file_path"[[:space:]]*:[[:space:]]*"\([^"]\+\)".*/\1/p')
+    # Block writes to the main project (anything under
+    # $CLAUDE_PROJECT_DIR that is not inside this worktree).
+    case "$file_path" in
+        "$CLAUDE_PROJECT_DIR"*)
+            if ! $GUIX_BIN/echo "$file_path" \
+                | $GUIX_BIN/grep --fixed-strings --quiet "$worktree"
+            then
+                $GUIX_BIN/echo \
+                    '{"hookSpecificOutput": {' \
+                    '"hookEventName": "PreToolUse",' \
+                    '"permissionDecision": "deny",' \
+                    '"systemMessage": "🚫 Write blocked: target is outside' \
+                    ' the worktree. Use the worktree path, not the' \
+                    ' project root."' \
+                    '}}'
+                exit 0
+            fi
+            ;;
+    esac
+    ;;
+
+Bash)
+    command=$($GUIX_BIN/echo "$stdin" \
+        | $GUIX_BIN/sed --quiet \
+            's/.*"command"[[:space:]]*:[[:space:]]*"\([^"]\+\)".*/\1/p')
+
+    # Block cd to main or a different worktree.
+    case "$command" in
+        *"cd $CLAUDE_PROJECT_DIR"*|*"cd ${CLAUDE_PROJECT_DIR}"*)
+            if $GUIX_BIN/echo "$command" \
+                | $GUIX_BIN/grep --quiet \
+                    "/\.claude/worktrees/${CLAUDE_CODE_SESSION_ID}"
+            then
+                # cd to own worktree — allowed.
+                :
+            elif $GUIX_BIN/echo "$command" \
+                | $GUIX_BIN/grep --quiet '/\.claude/worktrees/'
+            then
+                $GUIX_BIN/echo \
+                    '{"hookSpecificOutput": {' \
+                    '"hookEventName": "PreToolUse",' \
+                    '"permissionDecision": "deny",' \
+                    '"systemMessage": "🚫 Wrong worktree — stay in' \
+                    ' your own."' \
+                    '}}'
+                exit 0
+            else
+                $GUIX_BIN/echo \
+                    '{"hookSpecificOutput": {' \
+                    '"hookEventName": "PreToolUse",' \
+                    '"permissionDecision": "deny",' \
+                    '"systemMessage": "🚫 In a worktree — use the' \
+                    ' worktree path, not the project root."' \
+                    '}}'
+                exit 0
+            fi
+            ;;
+
+        *"git -C $CLAUDE_PROJECT_DIR"*|\
+        *"git -C ${CLAUDE_PROJECT_DIR}"*)
+            # Only merge-worktree may operate on main.
+            if $GUIX_BIN/echo "$command" \
+                | $GUIX_BIN/grep --quiet 'merge-worktree'
+            then
+                :
+            else
+                $GUIX_BIN/echo \
+                    '{"hookSpecificOutput": {' \
+                    '"hookEventName": "PreToolUse",' \
+                    '"permissionDecision": "deny",' \
+                    '"systemMessage": "🚫 Git on main blocked from' \
+                    ' worktree. Make changes in the worktree, then' \
+                    ' use /merge-worktree."' \
+                    '}}'
+                exit 0
+            fi
+            ;;
+
+        *"> $CLAUDE_PROJECT_DIR"*|*">> $CLAUDE_PROJECT_DIR"*|\
+        *"> ${CLAUDE_PROJECT_DIR}"*|*">> ${CLAUDE_PROJECT_DIR}"*)
+            # Redirect to main project — block unless it targets
+            # this worktree.
+            if ! $GUIX_BIN/echo "$command" \
+                | $GUIX_BIN/grep --fixed-strings --quiet "$worktree"
+            then
+                $GUIX_BIN/echo \
+                    '{"hookSpecificOutput": {' \
+                    '"hookEventName": "PreToolUse",' \
+                    '"permissionDecision": "deny",' \
+                    '"systemMessage": "🚫 Redirect blocked: target is' \
+                    ' outside the worktree."' \
+                    '}}'
+                exit 0
+            fi
+            ;;
+    esac
+    ;;
+esac
+
+# All checks passed.
+exit 0
index 2da0ddfeed1b1163ff6e033139f43d2728628927..132b8abdfa744ada2a2fa06477c548beca3b8cca 100755 (executable)
@@ -5,7 +5,7 @@
 # branch-summary — Summarize non-main branches as a markdown table.
 
 # List all local branches except main.
-branches=$($HOME/.guix-home/profile/bin/git \
+branches=$(git \
     -C "$CLAUDE_PROJECT_DIR" for-each-ref \
     --format='%(refname:short)' refs/heads/ \
     | $GUIX_BIN/grep -v '^main$')
@@ -19,7 +19,7 @@ fi
 # Print up to 3 commit subjects from branch $1 as a comma-separated
 # list, escaped for markdown table use.
 format_commits() {
-    $HOME/.guix-home/profile/bin/git -C "$CLAUDE_PROJECT_DIR" log --oneline \
+    git -C "$CLAUDE_PROJECT_DIR" log --oneline \
         --format='%s' main.."$1" 2>/dev/null \
         | head -3 \
         | $GUIX_BIN/sed 's/|/\\|/g' \
@@ -34,7 +34,7 @@ $GUIX_BIN/echo '|--------|---------|---------|'
 # One row per branch.
 echo "$branches" | while read -r b
 do
-    count=$($HOME/.guix-home/profile/bin/git \
+    count=$(git \
         -C "$CLAUDE_PROJECT_DIR" rev-list --count \
         main.."$b" 2>/dev/null)
     if [ -z "$count" ] || [ "$count" -eq 0 ]
index aff8fba72674273b3381240b09b14e7659ba581e..d83a171e49fb6f34c4b2dbeff47cf05e2953a934 100755 (executable)
@@ -5,24 +5,27 @@
 # merge-worktree — Merge a worktree branch into main.
 #
 # This script operates on the main worktree (CLAUDE_PROJECT_DIR) as a
-# subprocess, independent of the session's current directory. It rebases
-# the branch for linear history then fast-forwards main.
+# subprocess, independent of the session's current directory.  When the
+# branch is based on main it rebases for linear history then
+# fast-forwards.  When the branch has diverged (e.g. after squashing)
+# it can reset main with --force.
 #
-# Usage: merge-worktree <branch-name>
+# Usage: merge-worktree <branch-name> [--force]
 
 set -eu
 
 branch="${1:-}"
+force="${2:-}"
 if [ -z "$branch" ]
 then
-    echo "Usage: merge-worktree <branch-name>" >&2
+    echo "Usage: merge-worktree <branch-name> [--force]" >&2
     exit 1
 fi
 
 PROJECT_DIR="${CLAUDE_PROJECT_DIR:-$(pwd)}"
 
 # Validate branch.
-if ! $HOME/.guix-home/profile/bin/git -C "$PROJECT_DIR" rev-parse \
+if ! git -C "$PROJECT_DIR" rev-parse \
     --verify "$branch" >/dev/null 2>&1
 then
     echo "Error: Branch '$branch' does not exist." >&2
@@ -36,7 +39,7 @@ then
 fi
 
 # Check main worktree is clean.
-if [ -n "$($HOME/.guix-home/profile/bin/git -C "$PROJECT_DIR" \
+if [ -n "$(git -C "$PROJECT_DIR" \
     status --porcelain --untracked-files=no)" ]
 then
     echo "Error: Main worktree has uncommitted changes." >&2
@@ -45,44 +48,75 @@ then
 fi
 
 # Ensure main is restored on any exit (rebase failure, interruption).
-trap '$HOME/.guix-home/profile/bin/git -C "$PROJECT_DIR" checkout main \
+trap 'git -C "$PROJECT_DIR" checkout main \
     2>/dev/null || true' EXIT
 
 # Fetch from origin.
 echo "=== Fetching from origin ==="
-$HOME/.guix-home/profile/bin/git -C "$PROJECT_DIR" fetch origin
+git -C "$PROJECT_DIR" fetch origin
 
-# Rebase branch onto main.
-echo "=== Rebasing $branch onto main ==="
-if ! $HOME/.guix-home/profile/bin/git -C "$PROJECT_DIR" \
-    rebase main "$branch"
+# Determine whether this is a normal fast-forward or a divergent
+# branch that needs a reset.
+if git -C "$PROJECT_DIR" merge-base --is-ancestor \
+    main "$branch" 2>/dev/null
 then
-    rc=$?
-    echo "Error: Rebase failed (conflicts)." >&2
-    echo "  Resolve them in the main worktree:" >&2
-    echo "    cd $PROJECT_DIR" >&2
-    echo "    git status  # see conflicted files" >&2
-    echo "    # fix conflicts, then:" >&2
-    echo "    git rebase --continue" >&2
-    echo "    git checkout main" >&2
-    echo "    git merge --ff-only $branch" >&2
-    exit $rc
-fi
+    # ── Normal case: branch is based on main ──
 
-# Switch back to main.
-echo "=== Switching to main ==="
-$HOME/.guix-home/profile/bin/git -C "$PROJECT_DIR" checkout main
+    # Rebase branch onto main.
+    echo "=== Rebasing $branch onto main ==="
+    if ! git -C "$PROJECT_DIR" \
+        rebase main "$branch"
+    then
+        rc=$?
+        echo "Error: Rebase failed (conflicts)." >&2
+        echo "  Resolve them in the main worktree:" >&2
+        echo "    cd $PROJECT_DIR" >&2
+        echo "    git status  # see conflicted files" >&2
+        echo "    # fix conflicts, then:" >&2
+        echo "    git rebase --continue" >&2
+        echo "    git checkout main" >&2
+        echo "    git merge --ff-only $branch" >&2
+        exit $rc
+    fi
 
-# Fast-forward merge.
-echo "=== Merging $branch into main (fast-forward) ==="
-if ! $HOME/.guix-home/profile/bin/git -C "$PROJECT_DIR" \
-    merge --ff-only "$branch"
-then
-    rc=$?
-    echo "Error: Fast-forward merge failed." >&2
-    echo "  This should not happen after a successful rebase." >&2
-    echo "  Try: git -C $PROJECT_DIR merge $branch" >&2
-    exit $rc
-fi
+    # Switch back to main if needed.
+    current=$(git -C "$PROJECT_DIR" branch --show-current)
+    if [ "$current" != "main" ]
+    then
+        echo "=== Switching to main ==="
+        git -C "$PROJECT_DIR" checkout main
+    fi
 
-echo "=== Branch '$branch' successfully merged into main ==="
+    # Fast-forward merge.
+    echo "=== Merging $branch into main (fast-forward) ==="
+    if ! git -C "$PROJECT_DIR" \
+        merge --ff-only "$branch"
+    then
+        rc=$?
+        echo "Error: Fast-forward merge failed." >&2
+        echo "  This should not happen after a successful rebase." >&2
+        echo "  Try: git -C $PROJECT_DIR merge $branch" >&2
+        exit $rc
+    fi
+
+    echo "=== Branch '$branch' successfully merged into main ==="
+else
+    # ── Divergent case: branch was rewritten (e.g. squashed) ──
+
+    if [ "$force" = "--force" ]
+    then
+        echo "=== Resetting main to $branch ==="
+        current=$(git -C "$PROJECT_DIR" branch --show-current)
+        if [ "$current" != "main" ]
+        then
+            git -C "$PROJECT_DIR" checkout main
+        fi
+        git -C "$PROJECT_DIR" reset --hard "$branch"
+        echo "=== Main reset to '$branch' ==="
+    else
+        echo "Branch '$branch' has diverged from main." >&2
+        echo "Fast-forward merge is not possible." >&2
+        echo "Re-run with --force to reset main to this branch." >&2
+        exit 2
+    fi
+fi
diff --git a/.claude/skills b/.claude/skills
new file mode 120000 (symlink)
index 0000000..b15f79c
--- /dev/null
@@ -0,0 +1 @@
+/home/dak/guix/.claude/skills
\ No newline at end of file
diff --git a/.claude/skills/merge-worktree.md b/.claude/skills/merge-worktree.md
deleted file mode 100644 (file)
index 1a1f6af..0000000
+++ /dev/null
@@ -1,48 +0,0 @@
----
-name: merge-worktree
-description: Merge a workspace branch into main and optionally reconfigure.
----
-
-# merge-worktree
-
-Merge a worktree branch into main, rebasing for linear history, then
-optionally run guix reconfigure.
-
-## Instructions
-
-When the user asks to merge a workspace, integrate branch changes, or
-"commit to main":
-
-1. **Show available workspaces**:
-   Run `.claude/scripts/branch-summary` to display a markdown table of
-   all non-main branches (workspaces) and their commits.
-
-2. **Ask which workspace to merge**:
-   Use AskUserQuestion (single-select). Present the workspace branch
-   names from step 1 as options. If only one non-main branch exists,
-   ask to confirm merging that one.
-
-3. **Execute the merge**:
-   Run `.claude/scripts/merge-worktree <selected-branch>`.
-   If the merge fails (rebase conflict), instruct the user to resolve
-   conflicts in the main worktree at `$CLAUDE_PROJECT_DIR`, then retry.
-
-4. **Ask about guix reconfigure**:
-   Use AskUserQuestion (single-select) with these options:
-   - "guix home reconfigure"
-   - "guix system reconfigure"
-   - "Skip reconfigure"
-
-5. **Run reconfigure if selected**:
-   Run the chosen command using Bash against the main worktree.
-
-6. **Offer branch cleanup**:
-   Use AskUserQuestion (single-select) asking whether to delete the
-   merged branch. Options: "Delete branch and worktree", "Keep branch".
-
-## Notes
-
-- The merge script `cd`s to `$CLAUDE_PROJECT_DIR` (main worktree) as a
-  subprocess, independent of the session's worktree directory.
-- After the merge, `ExitWorktree` becomes available since the branch is
-  now an ancestor of main.