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
+32 -9
View File
@@ -52,6 +52,17 @@ const mostWaitAttempts = 20
var waitSessionID = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9_.-]{0,127}$`)
// requireSessionID trims session and refuses it unless it is a plain id matching waitSessionID — every op
// holds to this, not only add (review round 1, item b), so a session id is never used to build a path from
// anything but a safe name (no "/", so it cannot name a file outside the waits directory).
func requireSessionID(session string) (string, error) {
session = strings.TrimSpace(session)
if !waitSessionID.MatchString(session) {
return "", errors.New("session is required: the id the SessionStart hook named")
}
return session, nil
}
// WaitRead is the verb a session reads to see where one wait stands.
type WaitRead struct {
Address string `json:"address"`
@@ -246,9 +257,9 @@ type WaitInput struct {
// AddWait keeps one more wait for a session, refusing outside ADR 0489's bounds (1629/AC5), and rewrites the
// session's mirrored file (1629/AC3).
func AddWait(p Paths, in WaitInput, store WaitStore, now time.Time) (map[string]any, error) {
session := strings.TrimSpace(in.Session)
if !waitSessionID.MatchString(session) {
return nil, errors.New("session is required: the id the SessionStart hook named")
session, err := requireSessionID(in.Session)
if err != nil {
return nil, err
}
by, err := sessionAndNode(p, in.By)
if err != nil {
@@ -298,7 +309,11 @@ func findWait(sw SessionWaits, id string) int {
// WatchedWait marks a wait as followed: by mesh_wait on its family, or a watcher started for it (1629/AC2).
func WatchedWait(p Paths, session, id, watcher string, store WaitStore, now time.Time) (map[string]any, error) {
session, id, watcher = strings.TrimSpace(session), strings.TrimSpace(id), strings.TrimSpace(watcher)
session, err := requireSessionID(session)
if err != nil {
return nil, err
}
id, watcher = strings.TrimSpace(id), strings.TrimSpace(watcher)
if id == "" {
return nil, errors.New("id is required: the one add answered")
}
@@ -325,7 +340,11 @@ func WatchedWait(p Paths, session, id, watcher string, store WaitStore, now time
// DoneWait removes a wait from the open list: it ended, with outcome (1629/AC2).
func DoneWait(p Paths, session, id, outcome string, store WaitStore, now time.Time) (map[string]any, error) {
session, id, outcome = strings.TrimSpace(session), strings.TrimSpace(id), strings.TrimSpace(outcome)
session, err := requireSessionID(session)
if err != nil {
return nil, err
}
id, outcome = strings.TrimSpace(id), strings.TrimSpace(outcome)
if id == "" {
return nil, errors.New("id is required: the one add answered")
}
@@ -351,7 +370,11 @@ func DoneWait(p Paths, session, id, outcome string, store WaitStore, now time.Ti
// SaidWait marks a wait past its bound as told to the operator: refused while it is still within its bound
// (1629/AC2).
func SaidWait(p Paths, session, id string, store WaitStore, now time.Time) (map[string]any, error) {
session, id = strings.TrimSpace(session), strings.TrimSpace(id)
session, err := requireSessionID(session)
if err != nil {
return nil, err
}
id = strings.TrimSpace(id)
if id == "" {
return nil, errors.New("id is required: the one add answered")
}
@@ -378,9 +401,9 @@ func SaidWait(p Paths, session, id string, store WaitStore, now time.Time) (map[
// ListWaits answers a session's open waits, with their age and whether they are past their bound (1629/AC1).
func ListWaits(p Paths, session string, store WaitStore, now time.Time) (map[string]any, error) {
session = strings.TrimSpace(session)
if session == "" {
return nil, errors.New("session is required: the id the SessionStart hook named")
session, err := requireSessionID(session)
if err != nil {
return nil, err
}
sw, _, _, err := readSessionWaits(store, waitsKey(p, session))
if err != nil {