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:
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user