From 51c3877f4aa3486576a6d17c376b6d6cbdaed864 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 6 Aug 2026 10:47:12 +0000 Subject: [PATCH] =?UTF-8?q?chore(devx):=20=E8=A1=A5=E4=B8=8A=20Bash=20?= =?UTF-8?q?=E4=BE=A7=E7=9A=84=20worktree=20=E5=BC=BA=E5=88=B6,=E5=A0=B5?= =?UTF-8?q?=E4=BD=8F=20sed=20-i=20/=20tee=20/=20=E9=87=8D=E5=AE=9A?= =?UTF-8?q?=E5=90=91=E7=BB=95=E8=BF=87=E4=B8=BB=20checkout=20=E5=AE=88?= =?UTF-8?q?=E5=8D=AB=20(#5790)?= 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 's/foo/bar/' packages/spec/src/x.ts`、`cat > path` 加 heredoc、`tee`、 `printf ... > path` 都能在共享主 checkout 里一声不响地改文件,而 CLAUDE.md 与 AGENTS.md Prime Directive #11 给每个 agent 的心智模型是「PreToolUse 钩子强制此 规则」。声明的强制范围大于实际的强制范围。 移植 objectui 的成品(objectstack-ai/objectui#3452),逻辑逐例保持一致以免两仓 守卫漂移,只本地化 issue 引用、示例路径与自测里的包名。本仓没有 objectui 的 guard-shared-stash.sh,故 Bash matcher 是新建条目而非并入既有数组;既有 `Edit|Write|NotebookEdit` 条目一字未动。 仓库判定沿用 guard-main-checkout.sh 自己的逻辑 —— 两仓该文件字节相同,故判定 零适配即可移植,两个钩子对「共享 checkout」的定义因此不可能分歧:取目标最近的 存在祖先目录 -> `git rev-parse --git-dir` -> 非 git 仓库(/tmp、scratchpad、 $HOME dotfiles)放行 -> git-dir 命中 `*/worktrees/*` 放行 -> 其余(共享主 checkout、任何 sibling 仓库)拦截。逃生舱沿用同一个 `OS_ALLOW_MAIN_EDITS=1`: 同一条规则,同一个开关。 精确优先于召回:在任意 shell 命令里认出写目标本质上是启发式,而一个会拦住自己 看不懂的活的钩子迟早被关掉,关掉之后它什么也守不住。所以只认能高置信度读出目标 的写法(`>` / `>>` 含 fd 前缀、`sed -i`、`perl -i`、`tee`、`cp`、`mv`、`rm`、 `touch`),读操作永不拦,包装调用(`bash -c`、`xargs`、`node -e`)、含展开或 通配的目标、无 cwd 的相对路径一律 fail open —— 脚本注释里逐条写明这些是有意留 的口子而非疏忽。 写「关于这条禁令」的东西不能触发禁令(#4890 的教训):引号感知的分段与词法切分 让 `'…'` / `"…"` 里的 `>` 或 `sed -i` 是字面文本,heredoc 正文在分析前整段剥离 (正文是文档不是命令,而分段是按换行切的)。引入 heredoc 那一行自己的重定向照样 算数,自测里有成对的 allow / block 两例钉住这个区别。 CLAUDE.md 与 AGENTS.md Prime Directive #11 的钩子描述句同步补上 Bash 面,并写明 精确优先与 fail open —— 声明范围与实际范围重新对齐。 自测 `.claude/hooks/guard-main-checkout-bash.selftest.sh`:自建 fixture(临时 git init 的主仓库 + 它的 linked worktree + 一个非仓库目录),不依赖跑在哪个 checkout 上,70 例全绿。 Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01GX3sL71LFq8m2usg6VqTSE --- .../guard-main-checkout-bash.selftest.sh | 209 +++++++++ .claude/hooks/guard-main-checkout-bash.sh | 438 ++++++++++++++++++ .claude/settings.json | 9 + AGENTS.md | 2 +- CLAUDE.md | 13 +- 5 files changed, 665 insertions(+), 6 deletions(-) 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..bbf5a84d77 --- /dev/null +++ b/.claude/hooks/guard-main-checkout-bash.selftest.sh @@ -0,0 +1,209 @@ +#!/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. +# +# Ported case-for-case from objectui's matrix of the same name (objectui#3452); only the +# `pnpm --filter` package name is localised to this repo. + +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 @objectstack/spec 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..6da7791780 --- /dev/null +++ b/.claude/hooks/guard-main-checkout-bash.sh @@ -0,0 +1,438 @@ +#!/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/spec/src/x.ts`, +# `cat > path < path` all edit the shared primary checkout in +# silence, while CLAUDE.md and AGENTS.md Prime Directive #11 tell every agent that "a +# PreToolUse hook enforces this rule". Declared enforcement scope > actual enforcement +# scope (#5790). This hook closes the Bash half. +# +# Ported from objectui's hook of the same name (objectstack-ai/objectui#3452, filed there +# as objectui#3435) — the logic below is deliberately kept case-for-case identical to it so +# the two repos' guards cannot drift; only issue references, example paths and the package +# name in the self-test are localised. objectui additionally runs guard-shared-stash.sh on +# the same matcher; this repo has no such hook, so the Bash matcher is created here rather +# than joined (that gap is tracked separately — see #5790's closing observation). +# +# 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 (the target is one indirection away from any token this hook can see); +# - 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 (#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. + 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 --------------------------- +# 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 ------------------------------------------------- +# The whole argument list matters here, 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/objectstack && 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 8e7ce810b3..e7de8c8e70 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -31,6 +31,15 @@ "command": "\"$CLAUDE_PROJECT_DIR/.claude/hooks/guard-main-checkout.sh\"" } ] + }, + { + "matcher": "Bash", + "hooks": [ + { + "type": "command", + "command": "\"$CLAUDE_PROJECT_DIR/.claude/hooks/guard-main-checkout-bash.sh\"" + } + ] } ] } diff --git a/AGENTS.md b/AGENTS.md index 1c52f10439..07d51be611 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -112,7 +112,7 @@ Other scripts: `objectui:bump` (pull only), `objectui:build`, `objectui:clean`. When renaming a legacy var, use `readEnvWithDeprecation('OS_NEW', 'LEGACY')` from `@objectstack/types` (keeps legacy working one release). Third-party exceptions kept as-is: `NODE_ENV`, `HOME`, `OPENAI_API_KEY`, `TURSO_*`, OAuth `*_CLIENT_ID/SECRET`, `RESEND_API_KEY`, `POSTMARK_TOKEN`, `AI_GATEWAY_*`, `SMTP_*`. See #1382. 10. **File issues for out-of-scope findings — don't silently expand scope or leave them buried.** When you hit a bug, gap, or unenforced capability that's unrelated to the current task, or too large to fix in scope, open a GitHub issue (`gh issue create`) with a clear repro/decision and link it from your PR. Corollary: **never advertise or demo a capability the runtime doesn't actually deliver** (declared ≠ enforced) — fix it, trim it, or file an issue, but don't fake coverage. Example: the spec once declared 9 validation-rule types while the write-path validator enforced only 3 (`state_machine`/`script`/`cross_field`); the gap was filed as #1475 rather than demoed in the showcase, then closed by **trimming** what could never be enforced (`unique`/`async`/`custom`) and **implementing** the rest — the spec now declares 6 and `rule-validator.ts` handles all 6. Note how narrow that claim stayed even so: the evaluator was wired into insert and single-id update only, so a bulk `updateMany` silently skipped every rule — a second `declared ≠ enforced` gap one layer down, at the **call site** rather than the `switch`; filed as #3106 and closed by evaluating the bulk match set per row. A `case` label is not enforcement; check the **call site**. -11. **Worktree-first — never edit on the shared `main` checkout.** This repo is edited by **multiple agents at once**; the shared `main` tree has its HEAD switched and reset *under you*, silently clobbering uncommitted work. Before your **first file edit**, you MUST be in a dedicated worktree on a feature branch: `git worktree add ../objectstack- -b main && cd ../objectstack- && pnpm install`. A PreToolUse hook (`.claude/hooks/guard-main-checkout.sh`) **enforces** this — it blocks `Edit`/`Write`/`NotebookEdit` unless the edited file is in a dedicated **worktree** — a feature branch on the *shared* checkout is **not** enough (it still gets switched under you) — and it checks the **edited file's own repo**, so sibling repos (`objectui`/`cloud`) you touch are covered too (override for a deliberate non-task fix with `OS_ALLOW_MAIN_EDITS=1`). Full playbook below. +11. **Worktree-first — never edit on the shared `main` checkout.** This repo is edited by **multiple agents at once**; the shared `main` tree has its HEAD switched and reset *under you*, silently clobbering uncommitted work. Before your **first file edit**, you MUST be in a dedicated worktree on a feature branch: `git worktree add ../objectstack- -b main && cd ../objectstack- && pnpm install`. Two PreToolUse hooks **enforce** this — `.claude/hooks/guard-main-checkout.sh` blocks `Edit`/`Write`/`NotebookEdit`, and `.claude/hooks/guard-main-checkout-bash.sh` blocks the identical write arriving through **Bash** (`>`/`>>` redirection, `sed -i`, `perl -i`, `tee`, `cp`, `mv`, `rm`, `touch`) — unless the target is in a dedicated **worktree** — a feature branch on the *shared* checkout is **not** enough (it still gets switched under you) — and both check the **target file's own repo**, so sibling repos (`objectui`/`cloud`) you touch are covered too (override for a deliberate non-task fix with `OS_ALLOW_MAIN_EDITS=1`, one switch for both). The Bash guard is precision-first: it never blocks reads, and any shape it cannot resolve with confidence (`bash -c …`, `xargs`, `node -e`, a `$VAR`/glob target) is allowed through — the rule still outranks the hook. Full playbook below. 12. **Contract-first — fix the metadata, not the runtime.** This is a metadata-driven framework: `packages/spec` is the one contract between metadata *producers* and the runtime/renderers that *consume* it. When a piece of metadata "doesn't work," ask **first**: *is it spec-compliant? is this the long-term-correct direction?* If the metadata is wrong, fix it at the **producer** and **reject it at authoring/publish** (validation / lint) so the error surfaces loudly — do **not** add a lenient alias or `??` fallback in the consumer (a node executor, the REST layer, a renderer) to tolerate off-spec input. A tolerant fallback fossilizes the wrong convention into a second de-facto contract, dilutes the spec, and hides the producer's bug — one strict contract beats N dialects. This is an **internal** contract (we own both ends), so "be liberal in what you accept" (Postel) does **not** apply — that's for untrusted boundaries. Change the **spec** only when the spec itself is genuinely wrong, and then deliberately (edit the Zod schema + migrate), never by accreting consumer-side fallbacks. The `cfg.filter ?? cfg.filters` / `cfg.objectName ?? cfg.object` fallbacks the flow executors once carried are **debt to pay down, not a pattern to copy** — and the way they are being paid down is the pattern to copy. `filters` → `filter` has **graduated** into the ADR-0087 D2 conversion layer (`flow-node-crud-filter-alias`): rewritten to the canonical key at load, including the `AutomationEngine.registerFlow` rehydration seam, so the CRUD executors read `cfg.filter` directly and no consumer-side fallback survives. `object` → `objectName` and the six open-coded stragglers #3796 tracked (notify `to`/`subject`/`body`/`url`, script `functionName`/`input`) graduated the same way at protocol 17 (`flow-node-crud-object-alias`, `flow-node-notify-config-aliases`, `flow-node-script-config-aliases`), emptying the `readAliasedConfig` executor shim — deleted with them. When you must tolerate an alias at all, declare it as a conversion-layer entry (never a bare `??`, and no new executor shims) so it is declared, loud, tested, and *removable on a schedule*. Stored `sys_metadata` rows (data at rest) are covered from the other side: every rehydration seam replays the **full** conversion chain — retired entries included — via `applyConversionsToStoredItem` (#3903, ADR-0087 addendum), so a consumer never needs its own accommodation for a legacy stored shape either. *Worked example:* an AI-authored `create_record` used `fieldValues` / `today()` / `{{trigger.record.id}}` while the executor reads `fields` / `{TODAY()}` / `{record.id}` → the fix was correcting the authoring skill + a publish-gate lint that rejects the wrong shape (cloud#688), **not** a `cfg.fields ?? cfg.fieldValues` runtime alias (framework#2419, rejected). Strengthens #5. 13. **An accepted ADR binds until a superseding ADR says otherwise.** Reversing a recorded decision is itself a decision: it needs a **new ADR** (or an amended status line on the old one), not a changeset that quietly does the opposite. Before changing behaviour in `docs/adr/`-governed territory, **grep the ADRs for the surface you are touching** — the decision is often older and broader than the code comment in front of you. *Worked example:* three accepted ADRs said `sys_member.role` must never carry RBAC authority (ADR-0057 D4 "never as the authority for RBAC", ADR-0090 D3's word ban "distribution = `position`", ADR-0095 D3 "no enforcement-time code path may consult the better-auth role"). A patch-level changeset made app-declared names storable there anyway; a follow-up made it automatic in every host; the reversal held for a day and the tracking issue was closed, reopened and rewritten three times while the cause moved (#3723 → ADR-0108). The mechanism was not carelessness — **the file being edited never named the ADRs that governed it**, so the author could not have known. Hence the corollary: when you implement an ADR's decision, **leave its id in the code**, and anchor load-bearing spots in `scripts/adr-anchors.json` (`pnpm check:adr-anchors`) so the next author is told which decision they are standing on. A decision nobody can find is a decision that will be reversed. diff --git a/CLAUDE.md b/CLAUDE.md index a07127445e..92f06075c5 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -34,11 +34,14 @@ git worktree add ../- -b main && cd ../- && pnp ``` Then make all edits there. This applies **per repo**: if a task spans `framework` and -`objectui`, create a worktree in *each*. A PreToolUse hook -(`.claude/hooks/guard-main-checkout.sh`) enforces this — it blocks `Edit`/`Write`/ -`NotebookEdit` unless the edited file is in a linked worktree, and it checks the edited -file's own repo (so sibling repos are covered). Deliberate non-task exception: -`OS_ALLOW_MAIN_EDITS=1`. Follow the rule because it's correct, not because the hook fires. +`objectui`, create a worktree in *each*. Two PreToolUse hooks enforce this, and both check +the target file's **own** repo (so sibling repos are covered): `guard-main-checkout.sh` +blocks `Edit`/`Write`/`NotebookEdit`, and `guard-main-checkout-bash.sh` blocks the same +write expressed as a **Bash** command (`>`/`>>`, `sed -i`, `perl -i`, `tee`, `cp`, `mv`, +`rm`, `touch`) — reads are never blocked, and anything it cannot parse with confidence is +allowed through, so the rule still outranks the hook. Deliberate non-task exception (both +hooks, one switch): `OS_ALLOW_MAIN_EDITS=1`. Follow the rule because it's correct, not +because the hook fires. ## ⛔ Never edit `content/docs/releases/` in a code PR