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:
@@ -6,6 +6,7 @@ import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
@@ -20,10 +21,12 @@ func openWait(id string, bound int) Wait {
|
||||
}
|
||||
|
||||
// 1743/AC1: a wait within its bound and unwatched refuses the stop, naming the wait, its read address and
|
||||
// what the session does about it.
|
||||
// what the session does about it (mesh_wait on its family, conditionally — review round 1 item 3 — or a
|
||||
// watcher that rereads the address).
|
||||
func TestStopRefusesAnUnwatchedWaitWithinItsBound(t *testing.T) {
|
||||
w := openWait("w-1", 10)
|
||||
w.Read = WaitRead{Address: "mesh-delivery.recheck"}
|
||||
w.Family = "mesh-delivery.decided"
|
||||
d := DecideStop("sess-1", WaitsFile{Waits: []Wait{w}}, nil, map[string]int{}, stopT0.Add(time.Minute))
|
||||
if !d.Refuse || !strings.Contains(d.Reason, "w-1") || !strings.Contains(d.Reason, "mesh-delivery.recheck") ||
|
||||
!strings.Contains(d.Reason, "mesh_wait") || !strings.Contains(d.Reason, "watcher") {
|
||||
@@ -70,13 +73,17 @@ func TestStopAllowsWhenTheWaitsFileCannotBeRead(t *testing.T) {
|
||||
t.Fatalf("%+v", d)
|
||||
}
|
||||
waitsDir := t.TempDir() // no file at all for sess-4b
|
||||
var stderr bytes.Buffer
|
||||
var stdout, stderr bytes.Buffer
|
||||
stdin := strings.NewReader(`{"session_id":"sess-4b","stop_hook_active":false}`)
|
||||
start := time.Now()
|
||||
code := runStop(waitsDir, stdin, &stderr)
|
||||
code := runStop(waitsDir, stdin, &stdout, &stderr)
|
||||
if code != 0 || !strings.Contains(stderr.String(), "could not be read") {
|
||||
t.Fatalf("code %d, stderr %s", code, stderr.String())
|
||||
}
|
||||
// Item (a): a fail-open notice also reaches the screen, as the one JSON object Claude Code shows.
|
||||
if !strings.Contains(stdout.String(), "systemMessage") || !strings.Contains(stdout.String(), "could not be read") {
|
||||
t.Fatalf("the fail-open notice did not reach stdout: %s", stdout.String())
|
||||
}
|
||||
if took := time.Since(start); took > 5*time.Second {
|
||||
t.Fatalf("took %s; the Stop hook has no network and is bounded under 5s", took)
|
||||
}
|
||||
@@ -113,8 +120,8 @@ func TestStopAllowsTheFourthRefusalInARow(t *testing.T) {
|
||||
// 1743/AC6: a panic answers allow.
|
||||
func TestStopAPanicAllows(t *testing.T) {
|
||||
waitsDir := t.TempDir()
|
||||
var stderr bytes.Buffer
|
||||
if code := runStop(waitsDir, panicReader{}, &stderr); code != 0 {
|
||||
var stdout, stderr bytes.Buffer
|
||||
if code := runStop(waitsDir, panicReader{}, &stdout, &stderr); code != 0 {
|
||||
t.Fatalf("a panic was not recovered: code %d", code)
|
||||
}
|
||||
}
|
||||
@@ -127,15 +134,19 @@ func TestRunStopKeepsItsCounterBesideTheWaitsFile(t *testing.T) {
|
||||
stdin := func() *strings.Reader { return strings.NewReader(`{"session_id":"sess-6","stop_hook_active":false}`) }
|
||||
|
||||
for i := 1; i <= MostStopRefusals; i++ {
|
||||
var stderr bytes.Buffer
|
||||
if code := runStop(waitsDir, stdin(), &stderr); code != 2 {
|
||||
var stdout, stderr bytes.Buffer
|
||||
if code := runStop(waitsDir, stdin(), &stdout, &stderr); code != 2 {
|
||||
t.Fatalf("refusal %d: code %d, stderr %s", i, code, stderr.String())
|
||||
}
|
||||
}
|
||||
var stderr bytes.Buffer
|
||||
if code := runStop(waitsDir, stdin(), &stderr); code != 0 || !strings.Contains(stderr.String(), "unwatched") {
|
||||
var stdout, stderr bytes.Buffer
|
||||
if code := runStop(waitsDir, stdin(), &stdout, &stderr); code != 0 || !strings.Contains(stderr.String(), "unwatched") {
|
||||
t.Fatalf("the fourth call: code %d, stderr %s", code, stderr.String())
|
||||
}
|
||||
// Item (a): the let-through notice reaches the screen too, not only the log.
|
||||
if !strings.Contains(stdout.String(), "systemMessage") || !strings.Contains(stdout.String(), "unwatched") {
|
||||
t.Fatalf("the let-through notice did not reach stdout: %s", stdout.String())
|
||||
}
|
||||
raw, err := os.ReadFile(refusalCountersPath(waitsDir, "sess-6"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -146,6 +157,87 @@ func TestRunStopKeepsItsCounterBesideTheWaitsFile(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// Review round 1, item 1 (1743/1744, ADR 0489 §5): a session id that is not a plain one (path separators,
|
||||
// ".."), is never used to build a file path — the counter file stays inside the waits directory — and the
|
||||
// Stop hook allows, saying why.
|
||||
func TestStopRejectsAnUnsafeSessionID(t *testing.T) {
|
||||
for _, id := range []string{"../../x", "a/b"} {
|
||||
root := t.TempDir()
|
||||
waitsDir := filepath.Join(root, "agent", "waits")
|
||||
if err := os.MkdirAll(waitsDir, 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var stdout, stderr bytes.Buffer
|
||||
stdin := strings.NewReader(fmt.Sprintf(`{"session_id":%q,"stop_hook_active":false}`, id))
|
||||
if code := runStop(waitsDir, stdin, &stdout, &stderr); code != 0 {
|
||||
t.Fatalf("id %q: code %d, stderr %s", id, code, stderr.String())
|
||||
}
|
||||
if !strings.Contains(stderr.String(), "could not be used") {
|
||||
t.Fatalf("id %q: did not say why it allowed: %s", id, stderr.String())
|
||||
}
|
||||
if !strings.Contains(stdout.String(), "systemMessage") {
|
||||
t.Fatalf("id %q: the notice did not reach stdout: %s", id, stdout.String())
|
||||
}
|
||||
escaped := filepath.Join(waitsDir, id+".refusals.json")
|
||||
if _, err := os.Stat(escaped); !os.IsNotExist(err) {
|
||||
t.Fatalf("id %q: a counter file was written outside the waits dir at %s", id, escaped)
|
||||
}
|
||||
}
|
||||
// A blank id still just allows, quietly: it never reaches the point of building a path at all.
|
||||
waitsDir := t.TempDir()
|
||||
var stdout, stderr bytes.Buffer
|
||||
if code := runStop(waitsDir, strings.NewReader(`{"session_id":""}`), &stdout, &stderr); code != 0 {
|
||||
t.Fatalf("blank id: code %d", code)
|
||||
}
|
||||
}
|
||||
|
||||
// Review round 1, item 2 (ADR 0489 §5 "never loops", 1743/AC5): when the decision is to refuse and the
|
||||
// refusal counter cannot be written, the hook allows rather than risk refusing forever with a cap that
|
||||
// never takes effect.
|
||||
func TestStopAllowsWhenTheRefusalCounterCannotBeWritten(t *testing.T) {
|
||||
if os.Geteuid() == 0 {
|
||||
t.Skip("running as root: a directory's write bit cannot be taken away")
|
||||
}
|
||||
waitsDir := t.TempDir()
|
||||
writeWaitsFixture(t, filepath.Join(waitsDir, "sess-7.json"),
|
||||
WaitsFile{Session: "sess-7", At: stopT0, Waits: []Wait{openWait("w-7", 10)}})
|
||||
if err := os.Chmod(waitsDir, 0o500); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = os.Chmod(waitsDir, 0o700) })
|
||||
var stdout, stderr bytes.Buffer
|
||||
stdin := strings.NewReader(`{"session_id":"sess-7","stop_hook_active":false}`)
|
||||
if code := runStop(waitsDir, stdin, &stdout, &stderr); code != 0 {
|
||||
t.Fatalf("code %d, stderr %s", code, stderr.String())
|
||||
}
|
||||
if !strings.Contains(stderr.String(), "counter") {
|
||||
t.Fatalf("did not say the counter could not be written: %s", stderr.String())
|
||||
}
|
||||
if !strings.Contains(stdout.String(), "systemMessage") {
|
||||
t.Fatalf("the notice did not reach stdout: %s", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
// Review round 1, item 3 (1743/AC1, §3 with ADR 0367 §2): a family is offered only conditionally (the tool
|
||||
// runner may not be granted it); without one, the advice is only to start a watcher.
|
||||
func TestWaitBlocksStopAdvisesMeshWaitOnlyConditionallyOtherwiseAWatcher(t *testing.T) {
|
||||
withFamily := openWait("w-f", 10)
|
||||
withFamily.Read = WaitRead{Address: "mesh-delivery.recheck"}
|
||||
withFamily.Family = "mesh-delivery.decided"
|
||||
_, reason := waitBlocksStop("sess-f", withFamily, stopT0.Add(time.Minute))
|
||||
if !strings.Contains(reason, "mesh_wait on mesh-delivery.decided if this session's tool runner is granted it") ||
|
||||
!strings.Contains(reason, "otherwise start a watcher that rereads mesh-delivery.recheck") {
|
||||
t.Fatalf("%s", reason)
|
||||
}
|
||||
|
||||
noFamily := openWait("w-nf", 10)
|
||||
noFamily.Read = WaitRead{Address: "mesh-delivery.recheck"}
|
||||
_, reason2 := waitBlocksStop("sess-nf", noFamily, stopT0.Add(time.Minute))
|
||||
if strings.Contains(reason2, "mesh_wait") || !strings.Contains(reason2, "start a watcher that rereads mesh-delivery.recheck") {
|
||||
t.Fatalf("%s", reason2)
|
||||
}
|
||||
}
|
||||
|
||||
// 1743/AC7's rendering half (hooks.json holds the guard's PreToolUse entry and the Stop entry) is checked
|
||||
// together with 1744/AC4 in terminal_only_test.go's
|
||||
// TestThePluginsOnlyHooksAreTheGuardTheSessionStartHookAndTheStopHook. claude_code_hook_register still
|
||||
|
||||
Reference in New Issue
Block a user