Skip to content

Commit c7bccd9

Browse files
authored
chore(hooks): gate the worktree-owner sentinel itself, the one file the lock did not protect (#1438)
1 parent 7ab2955 commit c7bccd9

4 files changed

Lines changed: 82 additions & 5 deletions

File tree

.claude/hooks/worktree-owner-gate.sh

Lines changed: 50 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -89,8 +89,36 @@ esac
8989
# Repo opt-in, matching branch-gate.sh: only repos carrying the
9090
# markgate convention participate.
9191
top=$(git -C "$probe_dir" rev-parse --show-toplevel 2>/dev/null || echo "")
92+
93+
# A path INSIDE the git dir has no work tree, so `--show-toplevel` fails
94+
# there and the opt-in check below used to fall through to a pass. The one
95+
# path that matters is the `session-owner` sentinel itself — which is the
96+
# LOCK. So the lock was the single file the lock did not protect, and
97+
# "take another session's worktree" was a one-line Write to it.
98+
#
99+
# That is not hypothetical: on 2026-08-10 a session found a sentinel owned
100+
# by another session, guessed the owner was dead, Wrote its own id over the
101+
# sentinel, and worked a worktree another live agent was driving. The
102+
# blocking branch below did its job for every ordinary file and was simply
103+
# routed around here.
104+
#
105+
# Recover the worktree root from `<git dir>/gitdir` (which points at the
106+
# linked worktree's `.git` file) so the opt-in still consults the WORKTREE's
107+
# own `.markgate.yml`, matching the non-sentinel path.
108+
if [ -z "$top" ] && [ -f "$git_dir/gitdir" ]; then
109+
linked_dotgit=$(cat "$git_dir/gitdir" 2>/dev/null || echo "")
110+
[ -n "$linked_dotgit" ] && top=$(dirname "$linked_dotgit")
111+
fi
92112
[ -n "$top" ] && [ -f "$top/.markgate.yml" ] || exit 0
93113

114+
# Is this write targeting the sentinel itself? Used only to tailor the
115+
# refusal message — the ownership logic below is deliberately identical,
116+
# so claiming an unowned or stale worktree by writing the file still works.
117+
sentinel_write=0
118+
case "$target" in
119+
*/worktrees/*/session-owner) sentinel_write=1 ;;
120+
esac
121+
94122
sentinel="$git_dir/session-owner"
95123
worktree_name=$(basename "$git_dir")
96124

@@ -129,7 +157,11 @@ if [ -n "$claimed_at" ]; then
129157
fi
130158
fi
131159

132-
echo "Blocked by worktree-owner-gate: '$worktree_name' is owned by another session." >&2
160+
if [ "$sentinel_write" = "1" ]; then
161+
echo "Blocked by worktree-owner-gate: refusing to overwrite the ownership sentinel of '$worktree_name'." >&2
162+
else
163+
echo "Blocked by worktree-owner-gate: '$worktree_name' is owned by another session." >&2
164+
fi
133165
echo " worktree: $top" >&2
134166
echo " owner session: $owner (claimed $claimed_at)" >&2
135167
echo " your session: $session" >&2
@@ -139,9 +171,24 @@ echo "Two sessions editing one worktree is how uncommitted work gets destroyed"
139171
echo "(2026-08-09: a session reverted another's finished, tested fix because it" >&2
140172
echo "could not attribute the diff)." >&2
141173
echo "" >&2
174+
175+
if [ "$sentinel_write" = "1" ]; then
176+
echo "This file IS the lock. Writing it is taking the worktree, not editing a file." >&2
177+
echo "" >&2
178+
fi
179+
180+
echo "The claim above is younger than the ${OWNER_TTL_HOURS}h TTL, so the owning session is" >&2
181+
echo "presumed LIVE. You cannot tell a live session from a dead one by looking:" >&2
182+
echo "a recent claim, a stale-looking diff, and a /clear you did not observe all" >&2
183+
echo "produce the same evidence. Do NOT infer that the owner is gone (2026-08-10:" >&2
184+
echo "a session did exactly that, took this sentinel, and worked a lane another" >&2
185+
echo "live agent was driving)." >&2
186+
echo "" >&2
142187
echo "Use your OWN worktree for this lane:" >&2
143188
echo " git worktree add .claude/worktrees/<branch> -b <branch> origin/main" >&2
144189
echo "" >&2
145-
echo "If the other session is genuinely finished, take ownership explicitly:" >&2
146-
echo " rm \"$sentinel\"" >&2
190+
echo "If you believe the owner is finished, ASK THE MAINTAINER FIRST -- especially" >&2
191+
echo "when the worktree has uncommitted changes (check: git -C \"$top\" status --short)." >&2
192+
echo "Only after they confirm, hand off deliberately:" >&2
193+
echo " rm \"$sentinel\" # or re-run with CDKD_SKIP_WORKTREE_OWNER_GATE=1" >&2
147194
exit 2

.claude/hooks/worktree-owner-gate.test.sh

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,34 @@ echo "== fresh owner is NOT stolen =="
7171
printf 'sessA %s\n' "$(date -u +%Y-%m-%dT%H:%M:%SZ)" > "$WGD/session-owner"
7272
pl sessB "$wt/f.txt" | bash "$HOOK" >/dev/null 2>&1; chk $? 2 "recent owner still blocks"
7373

74+
echo "== the sentinel is itself gated (2026-08-10 bypass) =="
75+
# The sentinel lives INSIDE the git dir, which has no work tree, so
76+
# `rev-parse --show-toplevel` fails on it and the repo opt-in check used to
77+
# fall through to a pass. That made the LOCK the one file the lock did not
78+
# protect: a foreign session could take the worktree with a single Write.
79+
printf 'sessA %s\n' "$(date -u +%Y-%m-%dT%H:%M:%SZ)" > "$WGD/session-owner"
80+
pl sessB "$WGD/session-owner" | bash "$HOOK" 2>"$tmp/err"; chk $? 2 "foreign session cannot WRITE the sentinel"
81+
grep -q "This file IS the lock" "$tmp/err" && ok "message says the write IS a takeover" || no "takeover framing missing"
82+
grep -q "presumed LIVE" "$tmp/err" && ok "message refuses the dead-owner inference" || no "no presume-live guidance"
83+
grep -q "ASK THE MAINTAINER FIRST" "$tmp/err" && ok "message routes to the maintainer" || no "no maintainer escalation"
84+
grep -q sessA "$WGD/session-owner" && ok "sentinel still records the original owner" || no "sentinel was overwritten"
85+
86+
# The owner refreshing its own claim, and claiming a stale/absent one, must
87+
# still work — the guard tailors the message, it does not add a new rule.
88+
pl sessA "$WGD/session-owner" | bash "$HOOK" >/dev/null 2>&1; chk $? 0 "owner may write its own sentinel"
89+
printf 'sessOLD 2020-01-01T00:00:00Z\n' > "$WGD/session-owner"
90+
pl sessB "$WGD/session-owner" | bash "$HOOK" >/dev/null 2>&1; chk $? 0 "stale sentinel may be claimed by writing it"
91+
rm -f "$WGD/session-owner"
92+
pl sessB "$WGD/session-owner" | bash "$HOOK" >/dev/null 2>&1; chk $? 0 "absent sentinel may be claimed by writing it"
93+
94+
# Opt-in still applies on the sentinel path: it is resolved from the linked
95+
# worktree's own checkout via `<git dir>/gitdir`, not from the main tree.
96+
rm -f "$wt/.markgate.yml"
97+
printf 'sessA %s\n' "$(date -u +%Y-%m-%dT%H:%M:%SZ)" > "$WGD/session-owner"
98+
pl sessB "$WGD/session-owner" | bash "$HOOK" >/dev/null 2>&1; chk $? 0 "non-opted-in repo => sentinel write passes"
99+
git -C "$wt" checkout -q -- .markgate.yml
100+
printf 'sessA %s\n' "$(date -u +%Y-%m-%dT%H:%M:%SZ)" > "$WGD/session-owner"
101+
74102
echo "== explicit bypass =="
75103
CDKD_SKIP_WORKTREE_OWNER_GATE=1 bash -c "printf '%s' '$(pl sessB "$wt/f.txt")' | bash '$HOOK'" >/dev/null 2>&1
76104
chk $? 0 "CDKD_SKIP_WORKTREE_OWNER_GATE=1 bypasses"

.claude/rules/hooks.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,8 @@ The two hooks address the two INDEPENDENT layers of that failure. Either one alo
8282

8383
- **`.claude/hooks/restore-backup.sh`** — PreToolUse (matcher `Bash`), **non-blocking**. Before `git checkout -- <path>` / `git checkout .`, `git restore`, `git reset --hard`, `git clean -f*`, or `git stash`, snapshots the working tree into `<resolved git dir>/wipe-backups/<UTC ts>-<verb>/` (`tracked.patch` from `git diff HEAD --binary`, `COMMAND`, plus `untracked.tar` for `clean`, whose targets a diff cannot capture). Always exits 0 and never prompts: blocking a legitimate restore would be constant friction, and the operator does not know the changes are precious at the moment they run it. Skips entirely when `git status --porcelain` is empty, so a pristine tree writes nothing. Cwd-aware with the same `git -C <path>` / leading `cd <path> &&` / payload-`cwd` resolution as `branch-gate.sh`; snapshots land in the **per-worktree** git dir (`.git/worktrees/<name>/`), matching how markgate resolves its marker store. Deliberately does NOT match `git checkout <branch>` / `-b` (a branch switch is not a restore, and is governed by `main-tree-branch-gate.sh`). Recovery is `git apply --include=<path> <snap>/tracked.patch` for one file or `git apply --3way <snap>/tracked.patch` for the tree — the plain `git apply` form fails with "patch does not apply" once any other change in the whole-tree patch is still present, so the hook prints the two forms that were verified against a real wipe-and-recover replay rather than the one that reads best. Smoke test at `.claude/hooks/restore-backup.test.sh` (14 cases against a real throwaway repo, including the end-to-end wipe-then-recover proof and the cdkd#563 quoted-body false-positive cases).
8484

85-
- **`.claude/hooks/worktree-owner-gate.sh`** — PreToolUse (matcher `Edit|Write|NotebookEdit`), **blocking**. Gives each LINKED worktree one owning session: the first file write claims it by recording `<session_id> <UTC time>` in `<worktree git dir>/session-owner`, and a write from a different `session_id` exits 2 naming the owner, the worktree, and the release command. Closes the enforcement gap under the existing "one lane, one worktree" convention — nothing checked ownership at the SESSION level, and `git worktree list` shows which worktrees exist, not which another agent is actively driving. Scope decisions: only linked worktrees (the main tree is already covered by `main-tree-edit-gate.sh`); only file-writing tools, because a Bash command's write targets cannot be resolved statically and the read-only commands that dominate Bash usage must never be blocked (`main-tree-dirty-detector.sh` is the reactive analogue for that gap); repo opt-in via `.markgate.yml` at the TARGET's own toplevel, matching `branch-gate.sh` (issue #1259) — note a linked worktree has its own checkout of that file, so the marker must be absent from the WORKTREE, not just the main tree. Fails OPEN on anything unresolvable (no `session_id` in the payload, path outside a repo, unreadable sentinel): this catches an honest mistake and is not a security boundary, so a false block costs more than a rare miss. An owner idle longer than `CDKD_WORKTREE_OWNER_TTL_HOURS` (default 12) is treated as abandoned and taken over silently — sessions end without cleanup routinely, and a stale lock needing manual clearing is a worse failure than the one being prevented. `CDKD_SKIP_WORKTREE_OWNER_GATE=1` is the deliberate hand-off bypass. Smoke test at `.claude/hooks/worktree-owner-gate.test.sh` (15 cases against a real `git worktree add`, including the foreign-session block, TTL takeover, fresh-owner-not-stolen, and every fail-open case).
85+
- **`.claude/hooks/worktree-owner-gate.sh`** — PreToolUse (matcher `Edit|Write|NotebookEdit`), **blocking**. Gives each LINKED worktree one owning session: the first file write claims it by recording `<session_id> <UTC time>` in `<worktree git dir>/session-owner`, and a write from a different `session_id` exits 2 naming the owner, the worktree, and the release command. Closes the enforcement gap under the existing "one lane, one worktree" convention — nothing checked ownership at the SESSION level, and `git worktree list` shows which worktrees exist, not which another agent is actively driving. Scope decisions: only linked worktrees (the main tree is already covered by `main-tree-edit-gate.sh`); only file-writing tools, because a Bash command's write targets cannot be resolved statically and the read-only commands that dominate Bash usage must never be blocked (`main-tree-dirty-detector.sh` is the reactive analogue for that gap); repo opt-in via `.markgate.yml` at the TARGET's own toplevel, matching `branch-gate.sh` (issue #1259) — note a linked worktree has its own checkout of that file, so the marker must be absent from the WORKTREE, not just the main tree. Fails OPEN on anything unresolvable (no `session_id` in the payload, path outside a repo, unreadable sentinel): this catches an honest mistake and is not a security boundary, so a false block costs more than a rare miss. An owner idle longer than `CDKD_WORKTREE_OWNER_TTL_HOURS` (default 12) is treated as abandoned and taken over silently — sessions end without cleanup routinely, and a stale lock needing manual clearing is a worse failure than the one being prevented. `CDKD_SKIP_WORKTREE_OWNER_GATE=1` is the deliberate hand-off bypass. Smoke test at `.claude/hooks/worktree-owner-gate.test.sh` (24 cases against a real `git worktree add`, including the foreign-session block, TTL takeover, fresh-owner-not-stolen, the sentinel-write block below, and every fail-open case).
86+
87+
**The sentinel is itself gated (2026-08-10).** The gate shipped with one hole, and it was the lock itself: `session-owner` lives INSIDE the git dir, which has no work tree, so `git rev-parse --show-toplevel` fails on it, the repo opt-in check `[ -n "$top" ] && [ -f "$top/.markgate.yml" ] || exit 0` fell through, and a Write targeting the sentinel passed unguarded. Taking another session's worktree was therefore a single `Write` — no `rm`, no env var, no prompt. That is exactly how it went wrong: a session found a sentinel owned by another session, judged from a recent claim plus a stale-looking diff that the owner had been `/clear`-ed, wrote its own id over the file, and drove a lane another LIVE agent was working; the collision only surfaced when the real owner's next edit was blocked and it asked the maintainer. (The victim's uncommitted work survived by luck — the trespassing session had committed it rather than reverting it.) The fix recovers the worktree root from `<git dir>/gitdir` (which points at the linked worktree's `.git` file) so the opt-in still consults the WORKTREE's own `.markgate.yml`, and the ordinary ownership branch below then applies to the sentinel like any other file. Claiming an absent or TTL-stale sentinel by writing it still works, and the owner may still refresh its own — the guard adds no new rule, it removes an exemption. The refusal message is tailored for this path: it states that writing the file IS taking the worktree, that a claim younger than the TTL means the owner is **presumed LIVE**, that a live session and a dead one produce identical evidence (a recent claim, an unfamiliar diff, and a `/clear` you did not observe), and that the operator must ASK THE MAINTAINER before handing off — especially when `git -C <worktree> status --short` is non-empty. **Never infer that an owning session is dead.** The judgemental half lives in memory rule `feedback_never_infer_dead_worktree_owner.md`.
8688

8789
**Related correction.** The same incident produced a second, purely informational failure: both sessions believed markgate markers were repo-global and serialized every gate/commit/integ step across four lanes for no reason. They are per-worktree — `git rev-parse --git-dir` resolves to `.git/worktrees/<name>` in a linked worktree — which the "Markgate gate hooks (cwd-aware)" section above already stated. The wrong belief lived in an agent memory rule that contradicted this checked-in documentation and was never re-derived. `ls .git/worktrees/*/markgate` settles it in one command.

0 commit comments

Comments
 (0)