From 2c9fb4b828b2eec255d2e94fb6c666da58c417db Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 22 May 2026 22:44:16 +0000 Subject: [PATCH 1/3] chore(claude): add worktree-aware hooks for Bash + UserPromptSubmit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - check-bash-worktree.sh: PreToolUse Bash matcher that blocks commands referencing the main clone instead of the active worktree (parses absolute paths from tool_input.command). - worktree-context.sh: UserPromptSubmit hook that injects a one-line reminder of the active worktree on every user prompt, so the rule survives compaction and attention decay. - settings.json: register both hooks. Reminder + deterministic block give belt-and-suspenders coverage — the reminder shapes behavior, the block catches lapses. https://claude.ai/code/session_01Wo4ETjsNM4ggHqFSqRTyPN --- .claude/hooks/check-bash-worktree.sh | 61 ++++++++++++++++++++++++++++ .claude/hooks/worktree-context.sh | 29 +++++++++++++ .claude/settings.json | 22 ++++++++++ 3 files changed, 112 insertions(+) create mode 100755 .claude/hooks/check-bash-worktree.sh create mode 100755 .claude/hooks/worktree-context.sh diff --git a/.claude/hooks/check-bash-worktree.sh b/.claude/hooks/check-bash-worktree.sh new file mode 100755 index 0000000000..5bf6ec8d2e --- /dev/null +++ b/.claude/hooks/check-bash-worktree.sh @@ -0,0 +1,61 @@ +#!/bin/bash +# PreToolUse hook: block Bash commands that escape the current worktree. +# +# Catches absolute paths (and `cd `) that point at the main clone +# instead of the active worktree. Exits 0 in the primary clone or when the +# command stays inside the worktree. +# +# Exit codes: +# 0 = allow +# 2 = block (path targets the main clone) + +set -euo pipefail + +INPUT=$(cat) + +GIT_DIR=$(git rev-parse --git-dir 2>/dev/null) || exit 0 +GIT_COMMON_DIR=$(git rev-parse --git-common-dir 2>/dev/null) || exit 0 +GIT_DIR=$(cd "$GIT_DIR" && pwd) +GIT_COMMON_DIR=$(cd "$GIT_COMMON_DIR" && pwd) + +# Not a worktree — nothing to enforce +if [[ "$GIT_DIR" == "$GIT_COMMON_DIR" ]]; then + exit 0 +fi + +MAIN_REPO=$(cd "$GIT_COMMON_DIR/.." && pwd) +WORKTREE_ROOT=$(git rev-parse --show-toplevel) + +CMD=$(echo "$INPUT" | jq -r '.tool_input.command // empty' 2>/dev/null) +[[ -z "$CMD" ]] && exit 0 + +# Extract every whitespace-separated token that looks like an absolute path. +# We compare each against MAIN_REPO/WORKTREE_ROOT instead of regexing the whole +# command — fewer false positives, easier to report which token tripped. +TOKENS=$(echo "$CMD" | grep -oE '/[^[:space:];|&<>"'\'']+' || true) + +while IFS= read -r TOKEN; do + [[ -z "$TOKEN" ]] && continue + # Strip trailing punctuation that's not part of a path + TOKEN=${TOKEN%[,.:;]} + + # Token must be exactly MAIN_REPO or start with MAIN_REPO/, AND must NOT be + # under WORKTREE_ROOT (which is itself a subpath of MAIN_REPO). + if [[ "$TOKEN" == "$MAIN_REPO" || "$TOKEN" == "$MAIN_REPO"/* ]]; then + if [[ "$TOKEN" != "$WORKTREE_ROOT" && "$TOKEN" != "$WORKTREE_ROOT"/* ]]; then + cat </dev/null) || exit 0 +GIT_COMMON_DIR=$(git rev-parse --git-common-dir 2>/dev/null) || exit 0 +GIT_DIR=$(cd "$GIT_DIR" && pwd) +GIT_COMMON_DIR=$(cd "$GIT_COMMON_DIR" && pwd) + +# Primary clone — no worktree rules apply +if [[ "$GIT_DIR" == "$GIT_COMMON_DIR" ]]; then + exit 0 +fi + +WORKTREE_ROOT=$(git rev-parse --show-toplevel) +MAIN_REPO=$(cd "$GIT_COMMON_DIR/.." && pwd) + +cat < Date: Tue, 26 May 2026 14:27:05 +0000 Subject: [PATCH 2/3] chore(claude): install lint toolchain in SessionStart hook Move the inline yarn-install SessionStart command into a dedicated .claude/hooks/session-start.sh and add ShellCheck installation. The pre-commit `shellcheck` Lefthook step requires shellcheck >= 0.10.0 and otherwise falls back to Docker. The Claude Code on the web container ships neither, so shell-script commits previously failed until shellcheck was installed by hand. The hook now installs the pinned binary (matching .ci/shellcheck/shellcheck.sh) so linting works on a cold start. Remote-only (CLAUDE_CODE_REMOTE), idempotent, and synchronous so deps are guaranteed ready before the session begins. https://claude.ai/code/session_01Wo4ETjsNM4ggHqFSqRTyPN --- .claude/hooks/session-start.sh | 53 ++++++++++++++++++++++++++++++++++ .claude/settings.json | 5 ++-- 2 files changed, 55 insertions(+), 3 deletions(-) create mode 100755 .claude/hooks/session-start.sh diff --git a/.claude/hooks/session-start.sh b/.claude/hooks/session-start.sh new file mode 100755 index 0000000000..fbb29c0cdd --- /dev/null +++ b/.claude/hooks/session-start.sh @@ -0,0 +1,53 @@ +#!/bin/bash +# SessionStart hook for Claude Code on the web. +# +# Installs the toolchain the pre-commit/pre-push hooks expect so that +# linting works without manual intervention in the ephemeral web container: +# +# - Node dependencies (prettier, eslint, cypress lint hooks via Lefthook) +# - ShellCheck >= 0.10.0 (the `shellcheck` Lefthook step; without a local +# binary it falls back to Docker, which the web container doesn't have) +# +# Local clones manage their own toolchain, so this is a no-op outside the +# remote environment. Idempotent and non-interactive — safe to re-run. + +set -euo pipefail + +# Only run in Claude Code on the web (remote) environments. +if [ "${CLAUDE_CODE_REMOTE:-}" != "true" ]; then + exit 0 +fi + +PROJECT_DIR="${CLAUDE_PROJECT_DIR:-$(git rev-parse --show-toplevel 2>/dev/null || pwd)}" + +# --- Node dependencies ------------------------------------------------------- +# Skip browser binaries the lint hooks don't need; --frozen-lockfile keeps the +# install reproducible. Caching means this only does real work on a cold start. +if [ ! -d "$PROJECT_DIR/node_modules" ]; then + (cd "$PROJECT_DIR" && + CYPRESS_INSTALL_BINARY=0 \ + PUPPETEER_SKIP_DOWNLOAD=1 \ + PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD=1 \ + yarn install --frozen-lockfile 2>&1 | tail -3) || true +fi + +# --- ShellCheck -------------------------------------------------------------- +# Pin must match .ci/shellcheck/shellcheck.sh (SHELLCHECK_VERSION). +SHELLCHECK_VERSION="0.10.0" + +current_shellcheck="" +if command -v shellcheck >/dev/null 2>&1; then + current_shellcheck=$(shellcheck --version 2>/dev/null | awk '/^version:/ {print $2}') +fi + +if [ "$current_shellcheck" != "$SHELLCHECK_VERSION" ]; then + tmp=$(mktemp -d) + url="https://github.com/koalaman/shellcheck/releases/download/v${SHELLCHECK_VERSION}/shellcheck-v${SHELLCHECK_VERSION}.linux.x86_64.tar.xz" + if curl -fsSL "$url" | tar -xJ -C "$tmp"; then + install -m 0755 "$tmp/shellcheck-v${SHELLCHECK_VERSION}/shellcheck" /usr/local/bin/shellcheck + echo "Installed shellcheck v${SHELLCHECK_VERSION}" + else + echo "WARNING: failed to download shellcheck v${SHELLCHECK_VERSION}" >&2 + fi + rm -rf "$tmp" +fi diff --git a/.claude/settings.json b/.claude/settings.json index dbcfdac1a1..650fc2f3e3 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -132,10 +132,9 @@ "hooks": [ { "type": "command", - "command": "[ ! -d node_modules ] && CYPRESS_INSTALL_BINARY=0 PUPPETEER_SKIP_DOWNLOAD=1 PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD=1 yarn install --frozen-lockfile 2>&1 | tail -3 || true", + "command": "bash .claude/hooks/session-start.sh", "timeout": 300, - "async": true, - "statusMessage": "Checking dependencies" + "statusMessage": "Installing dependencies and lint toolchain" } ] } From d7196bc543980f2a98c38f02a9555da9b3c088e7 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 26 May 2026 20:58:34 +0000 Subject: [PATCH 3/3] chore(claude): tune permissions for safer low-friction defaults MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - allow: add read-only shellcheck linter, its repo wrapper, and the github pull_request_read MCP tool (observed in recent sessions). - ask: add high-impact destructive ops so they prompt even under permissive modes — git reset --hard, git clean, git rebase, sudo, dd, shred, mkfs. ask takes precedence over the broad allow wildcards. - deny: broaden sensitive-file protection beyond root .env to nested env files, private keys, credential stores, and ~/.ssh|.aws|.gnupg. https://claude.ai/code/session_01Wo4ETjsNM4ggHqFSqRTyPN --- .claude/settings.json | 36 +++++++++++++++++++++++++++++++++--- 1 file changed, 33 insertions(+), 3 deletions(-) diff --git a/.claude/settings.json b/.claude/settings.json index 650fc2f3e3..9397e3fc3d 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -11,6 +11,8 @@ "Bash(trash:*)", "Bash(stat:*)", "Bash(.ci/vale/vale.sh:*)", + "Bash(.ci/shellcheck/shellcheck.sh:*)", + "Bash(shellcheck:*)", "Bash(npm:*)", "Bash(yarn:*)", "Bash(pnpm:*)", @@ -66,19 +68,47 @@ "LS", "Skill(superpowers:brainstorming)", "Skill(superpowers:brainstorming:*)", - "mcp__acp__Bash" + "mcp__acp__Bash", + "mcp__github__pull_request_read" ], "deny": [ "Read(./.env)", "Read(./.env.*)", "Read(./secrets/**)", "Read(./config/credentials.json)", - "Read(./build)" + "Read(./build)", + "Read(**/.env)", + "Read(**/.env.*)", + "Read(**/secrets/**)", + "Read(**/credentials*)", + "Read(**/*.pem)", + "Read(**/*.key)", + "Read(**/*.p12)", + "Read(**/*.pfx)", + "Read(**/id_rsa)", + "Read(**/id_dsa)", + "Read(**/id_ecdsa)", + "Read(**/id_ed25519)", + "Read(**/.netrc)", + "Read(**/.npmrc)", + "Read(**/.pypirc)", + "Read(**/.git-credentials)", + "Read(**/.ssh/**)", + "Read(**/.aws/**)", + "Read(**/.gnupg/**)", + "Read(**/.config/gh/hosts.yml)" ], "ask": [ "Bash(git push:*)", "Bash(rm:*)", - "Read(/tmp)" + "Read(/tmp)", + "Bash(git reset --hard:*)", + "Bash(git clean:*)", + "Bash(git rebase:*)", + "Bash(sudo:*)", + "Bash(dd:*)", + "Bash(shred:*)", + "Bash(mkfs:*)" ] }, "hooks": {