mesh/merge-gate head 0f159a166200 merged into trunk 7a7a4034852a — pass: builds claude-code → ace, g14, novox, shanks; no bus step; every machine composes …
mesh/repo-check head 0f159a166200 merged into trunk 7a7a4034852a — pass: its merge-check.sh passed, each part in its toolchain: merge-check.sh (go), merge-…
mesh/delivery delivered
The Stop hook treated a missing waits file exactly like one that could not be read: a notice on every stop of every session with no waits — the normal state, not an exceptional one. DecideStop now treats os.ErrNotExist as the ordinary case of no open waits, silently; the notice is still said for a file that exists but cannot be read or does not parse. ADR 0490 §1 keeps waits under a name, but the file a hook reads is kept per session id: writing it from only the name an op just acted on meant a second name bound to the same session id had its waits silently drop out of enforcement the moment the first name was touched, and adopting one name away emptied the id's file outright even when another name was still bound to it. Every write now rewrites a session's file from the union of every name currently bound to it (sessionWaitsUnion), adopt's rewrite of the superseded id included. Also: each atomic write gets its own temp file name, so two writes to the same path can never interleave on one; adopt clears the refusal counter of both the superseded and the adopting session, so a fresh three-strikes count starts under the new binding; and list is read-only again — it no longer claims an unbound name or refuses a session that is not (yet) the one bound to it, and instead says who currently holds the name.
276 lines
12 KiB
Go
276 lines
12 KiB
Go
package main
|
|
|
|
// The plugin's Stop hook (novox/hq ADR 0489 §3-§6, issue 1743).
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io/fs"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
var stopT0 = time.Date(2026, 10, 21, 12, 0, 0, 0, time.UTC)
|
|
|
|
func openWait(id string, bound int) Wait {
|
|
return Wait{ID: id, What: "the operator's Approve", Bound: bound, At: stopT0}
|
|
}
|
|
|
|
// 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 (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") {
|
|
t.Fatalf("%+v", d)
|
|
}
|
|
}
|
|
|
|
// 1743/AC2: a watched wait within its bound, a done wait (absent from the file), or no file entry: allowed.
|
|
func TestStopAllowsAWatchedOrDoneOrAbsentWait(t *testing.T) {
|
|
watched := openWait("w-2", 10)
|
|
watched.Watcher = "mesh_wait on mesh-delivery.decided"
|
|
if d := DecideStop("sess-2", WaitsFile{Waits: []Wait{watched}}, nil, map[string]int{}, stopT0.Add(time.Minute)); d.Refuse {
|
|
t.Fatalf("a watched wait within its bound was refused: %+v", d)
|
|
}
|
|
if d := DecideStop("sess-2", WaitsFile{Waits: []Wait{}}, nil, map[string]int{}, stopT0); d.Refuse {
|
|
t.Fatalf("no open waits was refused: %+v", d)
|
|
}
|
|
}
|
|
|
|
// 1743/AC3: a wait past its bound, watched or not, is refused until said, then allowed.
|
|
func TestStopRefusesPastBoundUntilSaidWatchedOrNot(t *testing.T) {
|
|
unwatched := openWait("w-3", 5)
|
|
if d := DecideStop("sess-3", WaitsFile{Waits: []Wait{unwatched}}, nil, map[string]int{}, stopT0.Add(10*time.Minute)); !d.Refuse ||
|
|
!strings.Contains(d.Reason, "w-3") || !strings.Contains(d.Reason, "said") {
|
|
t.Fatalf("unwatched past bound: %+v", d)
|
|
}
|
|
watched := unwatched
|
|
watched.Watcher = "mesh_wait on mesh-delivery.decided"
|
|
if d := DecideStop("sess-3", WaitsFile{Waits: []Wait{watched}}, nil, map[string]int{}, stopT0.Add(10*time.Minute)); !d.Refuse {
|
|
t.Fatalf("watched past bound was allowed: %+v", d)
|
|
}
|
|
said := watched
|
|
said.Said = true
|
|
if d := DecideStop("sess-3", WaitsFile{Waits: []Wait{said}}, nil, map[string]int{}, stopT0.Add(10*time.Minute)); d.Refuse {
|
|
t.Fatalf("said past bound was still refused: %+v", d)
|
|
}
|
|
}
|
|
|
|
// 1743/AC4: an unreadable or malformed waits file is allowed, saying so on stderr and as a systemMessage on
|
|
// stdout; the decision does not touch the network and answers at once.
|
|
func TestStopAllowsAndSaysSoWhenTheWaitsFileIsUnreadableOrMalformed(t *testing.T) {
|
|
d := DecideStop("sess-4", WaitsFile{}, errors.New("no such file"), map[string]int{}, stopT0)
|
|
if d.Refuse || !strings.Contains(d.Reason, "could not be read") {
|
|
t.Fatalf("%+v", d)
|
|
}
|
|
waitsDir := t.TempDir()
|
|
if err := os.WriteFile(filepath.Join(waitsDir, "sess-4b.json"), []byte("{not json"), 0o600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
var stdout, stderr bytes.Buffer
|
|
stdin := strings.NewReader(`{"session_id":"sess-4b","stop_hook_active":false}`)
|
|
start := time.Now()
|
|
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)
|
|
}
|
|
}
|
|
|
|
// Review round 2, item 1 (1743/AC2, ADR 0490 §2): a missing waits file is the normal state for a session
|
|
// with no waits — every stop of every such session — so it must print nothing, on either stream, and
|
|
// simply allow; a notice there would fire constantly for nothing wrong.
|
|
func TestStopPrintsNothingWhenTheWaitsFileIsSimplyMissing(t *testing.T) {
|
|
d := DecideStop("sess-4c", WaitsFile{}, fs.ErrNotExist, map[string]int{}, stopT0)
|
|
if d.Refuse || d.Reason != "" {
|
|
t.Fatalf("a missing file (fs.ErrNotExist) said something: %+v", d)
|
|
}
|
|
waitsDir := t.TempDir() // no file at all for sess-4d
|
|
var stdout, stderr bytes.Buffer
|
|
stdin := strings.NewReader(`{"session_id":"sess-4d","stop_hook_active":false}`)
|
|
if code := runStop(waitsDir, stdin, &stdout, &stderr); code != 0 {
|
|
t.Fatalf("code %d", code)
|
|
}
|
|
if stdout.Len() != 0 || stderr.Len() != 0 {
|
|
t.Fatalf("a missing waits file printed something: stdout %q, stderr %q", stdout.String(), stderr.String())
|
|
}
|
|
}
|
|
|
|
// 1743/AC5: the same stop refused three times running for the same wait is allowed the fourth time, saying
|
|
// it was left unwatched; the counter resets when the wait changes.
|
|
func TestStopAllowsTheFourthRefusalInARow(t *testing.T) {
|
|
w := openWait("w-5", 10)
|
|
counters := map[string]int{}
|
|
for i := 1; i <= MostStopRefusals; i++ {
|
|
d := DecideStop("sess-5", WaitsFile{Waits: []Wait{w}}, nil, counters, stopT0.Add(time.Minute))
|
|
if !d.Refuse {
|
|
t.Fatalf("refusal %d was allowed: %+v", i, d)
|
|
}
|
|
counters = d.Counters
|
|
}
|
|
d := DecideStop("sess-5", WaitsFile{Waits: []Wait{w}}, nil, counters, stopT0.Add(time.Minute))
|
|
if d.Refuse || !strings.Contains(d.Reason, "unwatched") {
|
|
t.Fatalf("the fourth refusal was not let through: %+v", d)
|
|
}
|
|
if len(d.Counters) != 0 {
|
|
t.Fatalf("the counter was not reset once let through: %+v", d.Counters)
|
|
}
|
|
|
|
// A different wait starts its own count from zero.
|
|
w2 := openWait("w-5b", 10)
|
|
d2 := DecideStop("sess-5", WaitsFile{Waits: []Wait{w2}}, nil, counters, stopT0.Add(time.Minute))
|
|
if !d2.Refuse || d2.Counters["w-5b"] != 1 {
|
|
t.Fatalf("a different wait did not start its own count: %+v", d2)
|
|
}
|
|
}
|
|
|
|
// 1743/AC6: a panic answers allow.
|
|
func TestStopAPanicAllows(t *testing.T) {
|
|
waitsDir := t.TempDir()
|
|
var stdout, stderr bytes.Buffer
|
|
if code := runStop(waitsDir, panicReader{}, &stdout, &stderr); code != 0 {
|
|
t.Fatalf("a panic was not recovered: code %d", code)
|
|
}
|
|
}
|
|
|
|
// runStop through its counter file: three refusals in a row for the one open wait, then let through.
|
|
func TestRunStopKeepsItsCounterBesideTheWaitsFile(t *testing.T) {
|
|
waitsDir := t.TempDir()
|
|
f := WaitsFile{Session: "sess-6", At: stopT0, Waits: []Wait{openWait("w-6", 10)}}
|
|
writeWaitsFixture(t, filepath.Join(waitsDir, "sess-6.json"), f)
|
|
stdin := func() *strings.Reader { return strings.NewReader(`{"session_id":"sess-6","stop_hook_active":false}`) }
|
|
|
|
for i := 1; i <= MostStopRefusals; i++ {
|
|
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 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)
|
|
}
|
|
var counters map[string]int
|
|
if json.Unmarshal(raw, &counters) != nil || len(counters) != 0 {
|
|
t.Fatalf("the counter file was not reset: %s", raw)
|
|
}
|
|
}
|
|
|
|
// 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
|
|
// refuses a Stop hook (or any other) at every scope.
|
|
func TestHookRegisterStillRefusesEveryHook(t *testing.T) {
|
|
p, _ := node(t, "laptop")
|
|
s := serve(t, p, &bus{kept: map[string]map[string]any{}}, memConfig{})
|
|
text, isError := s.call(t, "claude_code_hook_register", map[string]any{"name": "x", "event": "Stop", "command": "true"})
|
|
if !isError || !strings.Contains(text, "issue 340") {
|
|
t.Fatalf("a Stop hook was not refused: %v %s", isError, text)
|
|
}
|
|
}
|