From eb165005ddb18398f66f08a4ac18bcb464e47045 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 6 Aug 2026 05:27:09 +0000 Subject: [PATCH] =?UTF-8?q?chore:=20=E8=A1=A5=E4=B8=8A=20Bash=20=E4=BE=A7?= =?UTF-8?q?=E7=9A=84=20worktree=20=E5=BC=BA=E5=88=B6,=E5=A0=B5=E4=BD=8F=20?= =?UTF-8?q?sed=20-i=20/=20tee=20/=20=E9=87=8D=E5=AE=9A=E5=90=91=E7=BB=95?= =?UTF-8?q?=E8=BF=87=E4=B8=BB=20checkout=20=E5=AE=88=E5=8D=AB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit guard-main-checkout.sh 只注册在 "Edit|Write|NotebookEdit" matcher 上,同一个写操作 换成 Bash 就完全不经过钩子:sed -i、cat > path < path 都能 静默改共享主 checkout,而 CLAUDE.md / AGENTS.md 宣称「PreToolUse 钩子强制此规则」。 声明的强制范围 > 实际的强制范围(objectui#3435,与 #3430 同族)。 新增 guard-main-checkout-bash.sh,挂到已有的 Bash matcher 上,与 guard-shared-stash.sh 同形状:jq 为主 + sed 兜底解析 payload、引号感知分段、exit 0 放行 / exit 2 拦截并把理由 写到 stderr、解析不了一律 fail open。仓库判定直接沿用 guard-main-checkout.sh 的逻辑 (最近存在祖先目录 -> git rev-parse --git-dir -> */worktrees/* 放行、非仓库放行), 两个钩子对「共享 checkout」的定义因此不可能分歧;逃生舱沿用同一个 OS_ALLOW_MAIN_EDITS=1, 不另起变量。 精确优先于召回:只认 > / >> 重定向、sed -i、perl -i、tee、cp、mv、rm、touch 这些能 高置信度读出目标路径的写法;bash -c、xargs、node -e / python -c、含 $VAR 或通配符的 目标、payload 缺 cwd 时的相对路径,一律放行并在注释里写明是有意留的口子。读操作 (grep / cat FILE / ls / git grep)永远不拦。 除分段外多加一层 heredoc 剥离:cat > /tmp/notes.md < Claude-Session: https://claude.ai/code/session_01GTRjn8xBqp75dk7kFupVRt --- .../guard-main-checkout-bash.selftest.sh | 208 +++++++++ .claude/hooks/guard-main-checkout-bash.sh | 433 ++++++++++++++++++ .claude/settings.json | 4 + 3 files changed, 645 insertions(+) create mode 100755 .claude/hooks/guard-main-checkout-bash.selftest.sh create mode 100755 .claude/hooks/guard-main-checkout-bash.sh diff --git a/.claude/hooks/guard-main-checkout-bash.selftest.sh b/.claude/hooks/guard-main-checkout-bash.selftest.sh new file mode 100755 index 0000000000..105c283bc5 --- /dev/null +++ b/.claude/hooks/guard-main-checkout-bash.selftest.sh @@ -0,0 +1,208 @@ +#!/usr/bin/env bash +# Self-test for guard-main-checkout-bash.sh — run it after touching that hook: +# +# .claude/hooks/guard-main-checkout-bash.selftest.sh +# +# Feeds the hook the same JSON payload shape Claude Code delivers on PreToolUse and asserts +# the block/allow verdict per command. Hermetic: it builds its OWN throwaway git repo, a +# linked worktree of it, and a non-repo directory under $TMPDIR, so the matrix never depends +# on which machine or which checkout it runs from. Needs jq and git and nothing else — no +# install, no build, no network. Exit 0 = all cases hold. +# +# Mirrors .claude/hooks/guard-shared-stash.selftest.sh case-for-case in shape. + +set -uo pipefail + +here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +hook="$here/guard-main-checkout-bash.sh" +pass=0 +fail=0 + +command -v jq >/dev/null 2>&1 || { echo "selftest needs jq to build payloads" >&2; exit 1; } +command -v git >/dev/null 2>&1 || { echo "selftest needs git to build the fixture" >&2; exit 1; } + +# --- fixture: a shared primary checkout, a linked worktree of it, a plain directory ----- +tmp="$(mktemp -d)" +trap 'rm -rf "$tmp"' EXIT +MAIN="$tmp/mainrepo" +WT="$tmp/wt" +PLAIN="$tmp/plain" +mkdir -p "$MAIN/pkg" "$PLAIN" +( + cd "$MAIN" || exit 1 + git init -q . + git config user.email selftest@example.com + git config user.name selftest + : > README.md + : > pkg/x.ts + git add -A + git commit -qm init + git worktree add -q "$WT" -b selftest-wt +) >/dev/null 2>&1 || { echo "could not build the git fixture" >&2; exit 1; } + +CWD="$MAIN" # payload cwd for the cases that follow; reassigned per section + +# verdict [env assignments…] -> prints "block" or "allow" +verdict() { + local cmd="$1"; shift + local payload out rc + payload="$(jq -nc --arg c "$cmd" --arg w "$CWD" \ + '{cwd:$w,tool_name:"Bash",tool_input:{command:$c}}')" + out="$(printf '%s' "$payload" | env "$@" "$hook" 2>/dev/null)" + rc=$? + case "$rc" in + 0) printf 'allow' ;; + 2) printf 'block' ;; + *) printf 'exit%s' "$rc" ;; + esac +} + +expect() { # expect [env…] + local want="$1" cmd="$2"; shift 2 + local got; got="$(verdict "$cmd" "$@")" + local shown="${cmd//$'\n'/ ⏎ }" + shown="${shown//$MAIN/\$MAIN}"; shown="${shown//$WT/\$WT}"; shown="${shown//$PLAIN/\$PLAIN}" + if [ "$got" = "$want" ]; then + pass=$((pass + 1)); printf ' ok %-5s %s\n' "$got" "$shown" + else + fail=$((fail + 1)); printf ' FAIL want=%s got=%s %s\n' "$want" "$got" "$shown" + fi +} + +echo "== writes into the shared PRIMARY checkout are blocked ==" +CWD="$MAIN" +expect block "sed -i s/a/b/ $MAIN/pkg/x.ts" +expect block 'sed -i "s/a/b/g" pkg/x.ts' +expect block 'sed -i -e "s/a/b/" pkg/x.ts' +expect block 'sed --in-place s/a/b/ pkg/x.ts' +expect block 'sed -ri "s/a/b/" pkg/x.ts' +expect block 'perl -pi -e "s/a/b/" pkg/x.ts' +expect block "echo x > $MAIN/README.md" +expect block 'echo x >> pkg/x.ts' +expect block 'printf hi > pkg/new.ts' +expect block 'pnpm build | tee build.log' +expect block 'tee -a notes.txt' +expect block 'cp /tmp/a.txt pkg/a.txt' +expect block 'mv /tmp/a.txt pkg/a.txt' +expect block "cp -t $MAIN/pkg /tmp/a.txt" +expect block 'rm -rf pkg/x.ts' +expect block 'touch pkg/new.ts' + +echo "== reached through separators, env prefixes, absolute argv0 and cd ==" +CWD="$PLAIN" +expect block "cd $MAIN && tee pkg/a.ts" +expect block "cd $MAIN; echo hi > README.md" +expect block "git status && sed -i s/a/b/ $MAIN/pkg/x.ts; pnpm test" +expect block "$(printf 'cd %s\npnpm install\nsed -i "s/a/b/" pkg/x.ts\n' "$MAIN")" +CWD="$MAIN" +expect block 'FOO=1 sed -i s/a/b/ pkg/x.ts' +expect block '/usr/bin/sed -i s/a/b/ pkg/x.ts' + +echo "== the SAME writes into a linked worktree are fine ==" +CWD="$WT" +expect allow "sed -i s/a/b/ $WT/pkg/x.ts" +expect allow 'sed -i "s/a/b/g" pkg/x.ts' +expect allow "echo x > $WT/README.md" +expect allow 'pnpm build | tee build.log' +expect allow 'rm -rf pkg/x.ts' +expect allow 'touch pkg/new.ts' +expect allow 'cp /tmp/a.txt pkg/a.txt' +expect allow "cd $WT && tee pkg/a.ts" + +echo "== writes outside any repo are fine (/tmp, scratchpad, \$HOME dotfiles) ==" +CWD="$MAIN" +expect allow 'echo x > /tmp/os-selftest-out.log' +expect allow "sed -i s/a/b/ $PLAIN/notes.md" +expect allow "tee $PLAIN/x.log" +expect allow 'echo hi > /dev/null' +expect allow "rm -rf $PLAIN/scratch" + +echo "== reading the shared checkout is NEVER blocked ==" +CWD="$MAIN" +expect allow "cat $MAIN/README.md" +expect allow 'cat < README.md' +expect allow "grep -rn worktree $MAIN" +expect allow "ls -la $MAIN/pkg" +expect allow "git -C $MAIN grep -n sed" +expect allow 'sed -n "1,5p" README.md' +expect allow 'sed "s/a/b/" README.md > /tmp/os-selftest-out' +expect allow "cp $MAIN/README.md /tmp/copy.md" +expect allow 'pnpm --filter @object-ui/app-shell test' +expect allow 'git status' + +echo "== writing ABOUT the ban must not trip the ban ==" +CWD="$MAIN" +expect allow 'grep -n "sed -i" .claude/' +expect allow "grep -rn \"echo x > $MAIN/README.md\" .claude/" +expect allow 'echo "never run sed -i inside the shared checkout"' +expect allow 'git grep -n "cd main && tee packages/a.ts"' +# a heredoc BODY is prose: its lines are documentation, not commands (the introducing +# line's own redirect still counts — see the block case below) +expect allow "$(printf "cat > /tmp/notes.md <<'EOF'\nsed -i 's/a/b/' %s/pkg/x.ts\necho x > %s/README.md\nEOF\n" "$MAIN" "$MAIN")" +expect block "$(printf 'cat > %s/notes.md < README.md' OS_ALLOW_MAIN_EDITS=1 + +echo "== unparseable / absent payload fails open ==" +for probe in '{"tool_name":"Bash","tool_input":{}}' 'not json at all' '{}'; do + if printf '%s' "$probe" | "$hook" >/dev/null 2>&1; then + pass=$((pass + 1)); printf ' ok allow (payload: %s)\n' "$probe" + else + fail=$((fail + 1)); printf ' FAIL should fail open (payload: %s)\n' "$probe" + fi +done + +echo "== a relative target with no cwd in the payload fails open; absolute still lands ==" +printf '%s' '{"tool_name":"Bash","tool_input":{"command":"sed -i s/a/b/ pkg/x.ts"}}' \ + | "$hook" >/dev/null 2>&1 +if [ "$?" -eq 0 ]; then + pass=$((pass + 1)); printf ' ok allow (relative target, no cwd)\n' +else + fail=$((fail + 1)); printf ' FAIL relative target with no cwd should fail open\n' +fi +printf '%s' "{\"tool_name\":\"Bash\",\"tool_input\":{\"command\":\"sed -i s/a/b/ $MAIN/pkg/x.ts\"}}" \ + | "$hook" >/dev/null 2>&1 +if [ "$?" -eq 2 ]; then + pass=$((pass + 1)); printf ' ok block (absolute target, no cwd)\n' +else + fail=$((fail + 1)); printf ' FAIL absolute target should still be judged without cwd\n' +fi + +echo "== jq-less fallback still parses command and cwd ==" +nojq="$(mktemp -d)" +for b in bash env cat sed head grep git dirname basename; do + p="$(command -v "$b")" && ln -s "$p" "$nojq/$b" +done +nojq_case() { # nojq_case + local want="$1" w="$2" c="$3" got + printf '{"cwd":"%s","tool_name":"Bash","tool_input":{"command":"%s"}}' "$w" "$c" \ + | PATH="$nojq" "$hook" >/dev/null 2>&1 + case "$?" in 0) got=allow ;; 2) got=block ;; *) got=other ;; esac + if [ "$got" = "$want" ]; then + pass=$((pass + 1)); printf ' ok %-5s (no jq) %s\n' "$got" "$c" + else + fail=$((fail + 1)); printf ' FAIL want=%s got=%s (no jq) %s\n' "$want" "$got" "$c" + fi +} +nojq_case block "$MAIN" 'sed -i s/a/b/ pkg/x.ts' +nojq_case allow "$WT" 'sed -i s/a/b/ pkg/x.ts' +nojq_case block "$MAIN" 'echo x > README.md' +nojq_case block "$MAIN" 'cd /tmp\ntee '"$MAIN"'/pkg/a.ts' +nojq_case allow "$MAIN" 'grep -n \"sed -i\" .claude/' +rm -rf "$nojq" + +printf '\n%s passed, %s failed\n' "$pass" "$fail" +[ "$fail" -eq 0 ] diff --git a/.claude/hooks/guard-main-checkout-bash.sh b/.claude/hooks/guard-main-checkout-bash.sh new file mode 100755 index 0000000000..11aaae4b19 --- /dev/null +++ b/.claude/hooks/guard-main-checkout-bash.sh @@ -0,0 +1,433 @@ +#!/usr/bin/env bash +# guard-main-checkout-bash.sh — PreToolUse guard: the SAME worktree-first rule as +# guard-main-checkout.sh, applied to file writes that arrive through Bash instead of +# through the Edit / Write / NotebookEdit tools. +# +# Why: guard-main-checkout.sh is registered on the matcher "Edit|Write|NotebookEdit", so +# it only ever sees tool-shaped file writes. The identical mutation expressed as a shell +# command never reaches it — `sed -i 's/foo/bar/' packages/fields/src/x.tsx`, +# `cat > path < path` all edit the shared primary checkout in +# silence, while CLAUDE.md and AGENTS.md tell every agent that "a PreToolUse hook enforces +# this rule". Declared enforcement scope > actual enforcement scope (objectui#3435, same +# family as #3430). This hook closes the Bash half. +# +# The rule and the escape hatch are deliberately the SAME as guard-main-checkout.sh's: +# OS_ALLOW_MAIN_EDITS=1. One rule, one hatch — a second variable would just be another +# thing to forget. +# +# Repo predicate — lifted verbatim from guard-main-checkout.sh so the two hooks can never +# disagree about what "shared checkout" means: +# resolve the target's nearest EXISTING ancestor dir -> `git rev-parse --git-dir` +# * not a git repo at all (/tmp, $HOME dotfiles, the scratchpad) -> allow +# * git-dir matches */worktrees/* (a linked worktree) -> allow +# * anything else (the shared PRIMARY checkout, any sibling repo) -> BLOCK +# +# PRECISION OVER RECALL. Recognising a write target inside an arbitrary shell command is +# heuristic in a way that Edit's file_path is not, and a guard that blocks work it does not +# understand gets switched off — after which it guards nothing. So this hook only claims +# shapes it can read with confidence, and everything else fails OPEN: +# > / >> redirection (incl. fd-prefixed 2>), sed -i, perl -i, tee, cp, mv, rm, touch. +# Reads are never touched: grep / cat FILE / ls / git grep produce no write target. +# +# Fails open, deliberately, on: +# - wrapped invocations: bash -c '…', xargs, ssh host '…', make, a shell script that +# writes (same known boundary guard-shared-stash.sh documents for itself); +# - programs whose writes live inside their own source text: python -c "open(p,'w')", +# node -e, awk > (the target is not a shell token, so there is nothing to read); +# - any target containing an expansion or glob ($VAR, `…`, $(…), *, ?, ~) — unresolvable +# without running it; +# - relative targets when the payload carries no cwd. +# Each of those is a hole by choice, not by accident: OS_ALLOW_MAIN_EDITS=1 already exists +# for anyone who means to write there, and the target of this guard is the reflexive +# `sed -i` an agent reaches for mid-task, not a determined evader. +# +# Writing ABOUT the ban must never trip the ban (objectstack#4890's lesson). Two layers: +# 1. quote-aware segmentation + tokenisation — a `>` or a `sed -i` inside '…' or "…" is +# literal text, so `grep -n "sed -i" .claude/` and `echo "never sed -i in main"` pass; +# 2. heredoc bodies are stripped before analysis — the LINES of a `cat > /tmp/notes </dev/null 2>&1; then + cmd="$(printf '%s' "$input" | jq -r '.tool_input.command // empty' 2>/dev/null || true)" + cwd="$(printf '%s' "$input" | jq -r '.cwd // empty' 2>/dev/null || true)" +fi +if [ -z "$cmd" ]; then + # jq-less fallback: lift the JSON string value honouring backslash escapes (so an + # embedded \" does not truncate the command), then unescape what matters for shell text. + # Identical to guard-shared-stash.sh's fallback. + cmd="$(printf '%s' "$input" \ + | sed -n 's/.*"command"[[:space:]]*:[[:space:]]*"\(\(\\.\|[^"\\]\)*\)".*/\1/p' \ + | head -1 \ + | sed 's/\\n/\n/g; s/\\t/ /g; s/\\"/"/g; s/\\\\/\\/g')" +fi +if [ -z "$cwd" ]; then + cwd="$(printf '%s' "$input" \ + | sed -n 's/.*"cwd"[[:space:]]*:[[:space:]]*"\(\(\\.\|[^"\\]\)*\)".*/\1/p' \ + | head -1 \ + | sed 's/\\"/"/g; s/\\\\/\\/g')" +fi + +[ -n "$cmd" ] || exit 0 + +# --- heredoc bodies are text, not commands --------------------------------------------- +# `cat > /tmp/notes.md </dev/null \ + | sed -E "s/^<<-?[[:space:]]*//; s/^[\"']//; s/[\"']$//") + ;; + esac + done <<<"$s" + printf '%s' "$out" +} + +# --- split the command into shell segments, honouring quotes --------------------------- +# Verbatim from guard-shared-stash.sh. A separator inside '…' or "…" does NOT split, so +# writing *about* the ban is never caught by the ban. +segments=() +split_segments() { + local s="$1" seg="" q="" ch i n=${#1} + for ((i = 0; i < n; i++)); do + ch="${s:i:1}" + if [ -n "$q" ]; then + seg+="$ch" + [ "$ch" = "$q" ] && q="" + continue + fi + case "$ch" in + "'" | '"') q="$ch" ; seg+="$ch" ;; + ';' | '|' | '&' | '(' | ')' | '{' | '}' | $'\n') segments+=("$seg") ; seg="" ;; + *) seg+="$ch" ;; + esac + done + segments+=("$seg") +} + +# --- tokenise ONE segment, quote-aware ------------------------------------------------- +# guard-shared-stash.sh only ever reads a segment's first word, so plain word splitting is +# enough there. Here the whole argument list matters, and `read -r -a` would promote a +# QUOTED ">" or "sed -i" to a real operator/command — exactly the false positive that makes +# a guard get disabled. So: quotes are stripped and their contents are inert. +# TOK[] = token values (unquoted); TOP[] = "op" for an unquoted redirection operator. +TOK=(); TOP=() +tokenize() { + TOK=(); TOP=() + local s="$1" n=${#1} i ch q="" tok="" have=0 op + for ((i = 0; i < n; i++)); do + ch="${s:i:1}" + if [ -n "$q" ]; then + if [ "$ch" = "$q" ]; then q=""; else tok+="$ch"; fi + have=1 + continue + fi + case "$ch" in + "'" | '"') q="$ch" ; have=1 ;; + '\') + i=$((i + 1)) + if [ "$i" -lt "$n" ]; then tok+="${s:i:1}" ; have=1 ; fi + ;; + ' ' | $'\t') + if [ "$have" = 1 ]; then TOK+=("$tok") ; TOP+=("") ; tok="" ; have=0 ; fi + ;; + '>' | '<') + # a bare leading fd ("2>") belongs to the operator, not to the previous word + if [ "$have" = 1 ] && [[ ! "$tok" =~ ^[0-9]+$ ]]; then + TOK+=("$tok") ; TOP+=("") + fi + tok="" ; have=0 + op="$ch" + while [ $((i + 1)) -lt "$n" ] && [ "${s:i+1:1}" = "$ch" ]; do op+="$ch" ; i=$((i + 1)) ; done + TOK+=("$op") ; TOP+=("op") + ;; + *) tok+="$ch" ; have=1 ;; + esac + done + if [ "$have" = 1 ]; then TOK+=("$tok") ; TOP+=("") ; fi +} + +# --- the repo predicate, identical to guard-main-checkout.sh's ------------------------ +# 0 = this target lands in a shared primary checkout (block it), 1 = fine / unknowable. +target_is_shared_checkout() { + local p="$1" d gitdir + [ -n "$p" ] || return 1 + [ "$p" = "-" ] && return 1 # stdout, not a file + + # Unresolvable without executing the command: expansions, command substitution, globs, + # tilde. Fail OPEN rather than guess a path. + case "$p" in + *'$'* | *'`'* | *'*'* | *'?'* | '~'* | *'['*) return 1 ;; + esac + + if [ "${p#/}" = "$p" ]; then + # relative: needs a cwd we actually know + [ -n "$cwd" ] || return 1 + p="$cwd/$p" + fi + + d="$(dirname "$p")" + while [ -n "$d" ] && [ "$d" != "/" ] && [ ! -d "$d" ]; do d="$(dirname "$d")"; done + [ -d "$d" ] || return 1 + + gitdir="$(git -C "$d" rev-parse --git-dir 2>/dev/null)" || return 1 + case "$gitdir" in + */worktrees/*) return 1 ;; + esac + return 0 +} + +# --- write-target extraction ------------------------------------------------------------ +# Fills TARGETS[] with the paths a segment would WRITE. Empty = nothing recognised. +TARGETS=() + +is_opt() { case "$1" in -?*) return 0 ;; *) return 1 ;; esac; } + +# sed/perl in-place flag: `-i`, `-i.bak`, `--in-place[=SUF]`, or an all-lowercase short +# cluster containing i (`-ri`, `-pi`). The all-lowercase rule keeps perl's `-Ilib` out. +is_inplace_flag() { + case "$1" in + --in-place | --in-place=*) return 0 ;; + -i*) return 0 ;; + esac + [[ "$1" =~ ^-[a-z]+$ ]] && [[ "$1" == *i* ]] && return 0 + return 1 +} + +collect_targets() { + TARGETS=() + local -a w=() + local i n j + + # drop redirection operators and their targets, so w[] is the plain argv + n=${#TOK[@]} + for ((i = 0; i < n; i++)); do + if [ "${TOP[$i]}" = "op" ]; then + case "${TOK[$i]}" in + '>' | '>>') + # the redirection itself is a write; record its target + j=$((i + 1)) + if [ "$j" -lt "$n" ] && [ "${TOP[$j]}" != "op" ]; then TARGETS+=("${TOK[$j]}"); fi + ;; + esac + # skip the operator and, when present, its filename/delimiter operand + j=$((i + 1)) + if [ "$j" -lt "$n" ] && [ "${TOP[$j]}" != "op" ]; then i=$j; fi + continue + fi + w+=("${TOK[$i]}") + done + + n=${#w[@]} + i=0 + [ "$n" -gt 0 ] || return 0 + + # leading FOO=bar environment assignments + while [ "$i" -lt "$n" ]; do + case "${w[$i]}" in + [A-Za-z_][A-Za-z0-9_]*=*) i=$((i + 1)) ;; + *) break ;; + esac + done + [ "$i" -lt "$n" ] || return 0 + + local cmdname="${w[$i]##*/}" + i=$((i + 1)) + + case "$cmdname" in + sed | perl) + local inplace=0 explicit_script=0 script_taken=0 rest=0 + local -a files=() + for ((j = i; j < n; j++)); do + local a="${w[$j]}" + if [ "$rest" = 0 ]; then + if [ "$a" = "--" ]; then rest=1; continue; fi + if is_opt "$a"; then + is_inplace_flag "$a" && inplace=1 + case "$a" in + -e* | -f* | --expression* | --file*) explicit_script=1 ;; + esac + case "$a" in + -e | -f | -E | -I | -M | -m | -F | --expression | --file) j=$((j + 1)) ;; + esac + continue + fi + fi + if [ "$explicit_script" = 0 ] && [ "$script_taken" = 0 ]; then + script_taken=1 # the s/// script itself, not a file + continue + fi + files+=("$a") + done + # without an in-place flag, sed/perl write to stdout — only a redirection writes, and + # that is already in TARGETS. + if [ "$inplace" = 1 ] && [ "${#files[@]}" -gt 0 ]; then TARGETS+=("${files[@]}"); fi + ;; + + tee) + # every non-option operand is written to (-a only chooses append vs truncate) + for ((j = i; j < n; j++)); do + [ "${w[$j]}" = "--" ] && continue + is_opt "${w[$j]}" && continue + TARGETS+=("${w[$j]}") + done + ;; + + cp | mv) + # destination = -t DIR when given, else the last operand + local -a ops=() + local tdir="" rest2=0 + for ((j = i; j < n; j++)); do + local a="${w[$j]}" + if [ "$rest2" = 0 ]; then + if [ "$a" = "--" ]; then rest2=1; continue; fi + case "$a" in + -t | --target-directory) + j=$((j + 1)); [ "$j" -lt "$n" ] && tdir="${w[$j]}"; continue ;; + --target-directory=*) tdir="${a#*=}" ; continue ;; + -S | --suffix) j=$((j + 1)); continue ;; + esac + is_opt "$a" && continue + fi + ops+=("$a") + done + if [ -n "$tdir" ]; then + TARGETS+=("$tdir") + elif [ "${#ops[@]}" -ge 2 ]; then + TARGETS+=("${ops[$((${#ops[@]} - 1))]}") + fi + ;; + + rm) + local rest3=0 + for ((j = i; j < n; j++)); do + if [ "$rest3" = 0 ]; then + if [ "${w[$j]}" = "--" ]; then rest3=1; continue; fi + is_opt "${w[$j]}" && continue + fi + TARGETS+=("${w[$j]}") + done + ;; + + touch) + local rest4=0 + for ((j = i; j < n; j++)); do + local a="${w[$j]}" + if [ "$rest4" = 0 ]; then + if [ "$a" = "--" ]; then rest4=1; continue; fi + case "$a" in + -r | --reference | -d | --date | -t) j=$((j + 1)); continue ;; + esac + is_opt "$a" && continue + fi + TARGETS+=("$a") + done + ;; + esac +} + +# --- track `cd` so relative targets resolve the way the shell would -------------------- +# `cd /home/user/objectui && tee packages/a.ts` writes to the shared checkout even though +# the token is relative. Known boundary: a `cd` inside a subshell is treated as sticky, +# because segmentation does not report WHICH separator it split on. +maybe_cd() { + local i=0 n=${#TOK[@]} + local -a plain=() + for ((i = 0; i < n; i++)); do + [ "${TOP[$i]}" = "op" ] && continue + plain+=("${TOK[$i]}") + done + n=${#plain[@]} + [ "$n" -ge 2 ] || return 0 + [ "${plain[0]##*/}" = "cd" ] || return 0 + local d="${plain[1]}" + case "$d" in + -* ) return 0 ;; + *'$'* | *'`'* | *'*'* | '~'* ) cwd="" ; return 0 ;; # unknowable from here on + esac + if [ "${d#/}" = "$d" ]; then + [ -n "$cwd" ] || return 0 + d="$cwd/$d" + fi + cwd="$d" +} + +# --- main ------------------------------------------------------------------------------- +split_segments "$(strip_heredocs "$cmd")" + +for seg in "${segments[@]}"; do + case "$seg" in *[![:space:]]*) ;; *) continue ;; esac + tokenize "$seg" + [ "${#TOK[@]}" -gt 0 ] || continue + + collect_targets + [ "${#TARGETS[@]}" -gt 0 ] || { maybe_cd ; continue ; } + for t in "${TARGETS[@]}"; do + if target_is_shared_checkout "$t"; then + offending="${seg#"${seg%%[![:space:]]*}"}" + abs="$t" + [ "${abs#/}" = "$abs" ] && abs="$cwd/$t" + root="$(git -C "$(dirname "$abs")" rev-parse --show-toplevel 2>/dev/null || printf '%s' "$abs")" + name="$(basename "$root")" + cat >&2 < -b main + cd ../${name}- && pnpm install # then re-run the command there + +Always fine, no flag needed: + reads of the shared checkout grep / cat FILE / ls / git -C
grep … + writes into a linked worktree sed -i … ../${name}-/packages/… + writes outside any repo /tmp/…, the scratchpad, \$HOME dotfiles + +Deliberate non-task exception: re-run with OS_ALLOW_MAIN_EDITS=1. +EOF + exit 2 + fi + done + + maybe_cd +done + +exit 0 diff --git a/.claude/settings.json b/.claude/settings.json index aaf9eb1898..b2eab94f31 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -30,6 +30,10 @@ { "type": "command", "command": "\"$CLAUDE_PROJECT_DIR/.claude/hooks/guard-shared-stash.sh\"" + }, + { + "type": "command", + "command": "\"$CLAUDE_PROJECT_DIR/.claude/hooks/guard-main-checkout-bash.sh\"" } ] }