Fix review round 1 of the wait list and its hooks (issues 1629, 1743, 1744)

A session id reaching the Stop and SessionStart hooks was only checked
for blank, so "../../x" or "a/b" could read or write outside the waits
directory; both hooks now hold every session id to waitSessionID before
building any path, and allow (saying why) rather than fail on a bad one.
claude_code_wait's list/watched/done/said held only add to that same
check, letting a malformed id through as an ordinary, always-empty key;
they all refuse it now.

The Stop hook's three-strikes cap could be bypassed forever if its
counter file could not be written on a refusal: it now allows instead,
saying so, so a wait can never hold a turn open indefinitely. Its advice
offered mesh_wait on a wait's event family unconditionally, ignoring
that the session's tool runner may not be granted it (ADR 0367 §2); it
now offers mesh_wait only "if this session's tool runner is granted it,
otherwise start a watcher", and drops the family entirely when a wait
has none.

Both hooks' fail-open and let-through notices were said only on stderr,
which Claude Code does not show the operator on an allowed (exit 0)
outcome; they now also print the same words as a "systemMessage" JSON
object on stdout, which Claude Code does show.
This commit is contained in:
jochen
2026-10-21 18:23:42 +02:00
parent 0fa427dad3
commit 02a8837348
8 changed files with 280 additions and 36 deletions
+48 -15
View File
@@ -49,8 +49,8 @@ func waitBlocksStop(session string, w Wait, now time.Time) (bool, string) {
return false, ""
}
return true, fmt.Sprintf("wait %s (%s) is open and nothing follows it yet. Read where it stands with %s, then "+
"either mesh_wait on %s or start a watcher that rereads it, and mark it watched: claude_code_wait "+
"{\"op\":\"watched\",\"session\":%q,\"id\":%q,\"watcher\":\"...\"}.", w.ID, w.What, w.Read.Address, familyOrAddress(w),
"%s, and mark it watched: claude_code_wait "+
"{\"op\":\"watched\",\"session\":%q,\"id\":%q,\"watcher\":\"...\"}.", w.ID, w.What, w.Read.Address, watcherAdvice(w),
session, w.ID)
}
return true, fmt.Sprintf("wait %s (%s) is past its bound and the operator has not been told. Tell the operator what "+
@@ -58,11 +58,15 @@ func waitBlocksStop(session string, w Wait, now time.Time) (bool, string) {
"{\"op\":\"said\",\"session\":%q,\"id\":%q}.", w.ID, w.What, session, w.ID)
}
func familyOrAddress(w Wait) string {
// watcherAdvice is what a session is told to do to follow a wait (ADR 0489 §3, review round 1 item 3): a
// family is a candidate for mesh_wait only when the tool runner is granted it (ADR 0367 §2), which this hook
// cannot know, so it is offered conditionally; a watcher that rereads the read address always works.
func watcherAdvice(w Wait) string {
if w.Family != "" {
return w.Family
return fmt.Sprintf("mesh_wait on %s if this session's tool runner is granted it, otherwise start a watcher "+
"that rereads %s", w.Family, w.Read.Address)
}
return w.Read.Address
return fmt.Sprintf("start a watcher that rereads %s", w.Read.Address)
}
// DecideStop is the Stop hook's decision, pure of IO: f and readErr are what reading the session's waits
@@ -103,32 +107,61 @@ func readRefusalCounters(waitsDir, session string) map[string]int {
return m
}
// writeRefusalCounters is best-effort: a counter file that cannot be written only means the three-strikes
// cap may be judged again from zero, never that the hook fails.
func writeRefusalCounters(waitsDir, session string, m map[string]int) {
// writeRefusalCounters writes the counter file beside the waits file. Its caller decides what an error here
// means: harmless when the stop is already allowed, but when the stop was about to be refused, a cap that
// cannot be written is a cap that cannot take effect (ADR 0489 §5 "never loops", review round 1 item 2).
func writeRefusalCounters(waitsDir, session string, m map[string]int) error {
raw, err := json.Marshal(m)
if err != nil {
return
return err
}
_ = os.WriteFile(refusalCountersPath(waitsDir, session), raw, 0o600)
return os.WriteFile(refusalCountersPath(waitsDir, session), raw, 0o600)
}
// notice says a fail-open or let-through reason on both the log (stderr) and the screen: Claude Code shows a
// hook's "systemMessage" on stdout, but not stderr, when the hook allows (review round 1, item a).
func notice(stdout, stderr io.Writer, reason string) {
fmt.Fprintln(stderr, reason)
writeSystemMessage(stdout, reason)
}
// runStop is the hook: exit 2 with the reason on stderr refuses the stop, exit 0 allows (1743).
func runStop(waitsDir string, stdin io.Reader, stderr io.Writer) (code int) {
func runStop(waitsDir string, stdin io.Reader, stdout, stderr io.Writer) (code int) {
defer func() {
if recover() != nil {
code = 0 // a panic always allows (1743/AC6)
}
}()
var in StopInput
if err := json.NewDecoder(stdin).Decode(&in); err != nil || strings.TrimSpace(in.SessionID) == "" {
if err := json.NewDecoder(stdin).Decode(&in); err != nil {
return 0
}
sessionID := strings.TrimSpace(in.SessionID)
if sessionID == "" {
return 0
}
// Review round 1, item 1: a session id is never used to build a path until it is checked against
// waitSessionID — no "/" in it reaches the filesystem, so it can never name a file outside waitsDir.
if !waitSessionID.MatchString(sessionID) {
notice(stdout, stderr, fmt.Sprintf("the session id %q could not be used to find its waits (letters, digits, "+
"'.', '_' and '-' only); this stop is allowed", sessionID))
return 0
}
f, err := readWaitsFile(waitsFileIn(waitsDir, sessionID))
counters := readRefusalCounters(waitsDir, sessionID)
d := DecideStop(sessionID, f, err, counters, time.Now())
if werr := writeRefusalCounters(waitsDir, sessionID, d.Counters); werr != nil && d.Refuse {
// Item 2: writing the counter failed on the very call that would have incremented it toward the cap;
// refusing anyway could hold the turn forever, so this call is let through instead.
notice(stdout, stderr, fmt.Sprintf("this wait's refusal counter could not be written (%v); allowed so it "+
"cannot hold the turn forever", werr))
return 0
}
f, err := readWaitsFile(waitsFileIn(waitsDir, in.SessionID))
d := DecideStop(in.SessionID, f, err, readRefusalCounters(waitsDir, in.SessionID), time.Now())
writeRefusalCounters(waitsDir, in.SessionID, d.Counters)
if d.Reason != "" {
fmt.Fprintln(stderr, d.Reason)
if !d.Refuse {
writeSystemMessage(stdout, d.Reason) // item a: a fail-open or let-through notice also reaches the screen
}
}
if d.Refuse {
return 2