From db734305677d676ce5e72a2c9c50bed050d0a839 Mon Sep 17 00:00:00 2001 From: Jakub Czajka Date: Tue, 14 Jul 2026 20:03:00 +0000 Subject: [PATCH] [infra] Harden worktree isolation; fix merge script git path 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 --- .claude/hooks/create-worktree | 166 +++++++++++++++++++++++++++---- .claude/scripts/branch-summary | 6 +- .claude/scripts/merge-worktree | 110 +++++++++++++------- .claude/skills | 1 + .claude/skills/merge-worktree.md | 48 --------- 5 files changed, 225 insertions(+), 106 deletions(-) create mode 120000 .claude/skills delete mode 100644 .claude/skills/merge-worktree.md diff --git a/.claude/hooks/create-worktree b/.claude/hooks/create-worktree index cf996ff..10c120c 100755 --- a/.claude/hooks/create-worktree +++ b/.claude/hooks/create-worktree @@ -2,32 +2,164 @@ # Copyright (c) 2026 Jakub Czajka # 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 diff --git a/.claude/scripts/branch-summary b/.claude/scripts/branch-summary index 2da0ddf..132b8ab 100755 --- a/.claude/scripts/branch-summary +++ b/.claude/scripts/branch-summary @@ -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 ] diff --git a/.claude/scripts/merge-worktree b/.claude/scripts/merge-worktree index aff8fba..d83a171 100755 --- a/.claude/scripts/merge-worktree +++ b/.claude/scripts/merge-worktree @@ -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 +# Usage: merge-worktree [--force] set -eu branch="${1:-}" +force="${2:-}" if [ -z "$branch" ] then - echo "Usage: merge-worktree " >&2 + echo "Usage: merge-worktree [--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 index 0000000..b15f79c --- /dev/null +++ b/.claude/skills @@ -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 index 1a1f6af..0000000 --- a/.claude/skills/merge-worktree.md +++ /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 `. - 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. -- 2.47.3