switchroom 0.19.16 → 0.19.18

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (66) hide show
  1. package/bin/run-hook.sh +148 -0
  2. package/bin/workspace-dynamic-hook.sh +147 -38
  3. package/dist/agent-scheduler/index.js +11 -3
  4. package/dist/auth-broker/index.js +29 -4
  5. package/dist/cli/notion-write-pretool.mjs +11 -3
  6. package/dist/cli/switchroom.js +8307 -7620
  7. package/dist/host-control/main.js +626 -36
  8. package/dist/vault/approvals/kernel-server.js +30 -5
  9. package/dist/vault/broker/server.js +71 -18
  10. package/package.json +3 -2
  11. package/profiles/_base/start.sh.hbs +8 -4
  12. package/profiles/coding/CLAUDE.md.hbs +1 -1
  13. package/profiles/default/CLAUDE.md.hbs +3 -3
  14. package/profiles/executive-assistant/CLAUDE.md.hbs +1 -1
  15. package/profiles/health-coach/CLAUDE.md.hbs +1 -1
  16. package/skills/mental-model-curator/SKILL.md +8 -6
  17. package/telegram-plugin/bridge/bridge.ts +11 -19
  18. package/telegram-plugin/bridge/mcp-instructions.ts +87 -0
  19. package/telegram-plugin/dist/bridge/bridge.js +15 -20
  20. package/telegram-plugin/dist/gateway/gateway.js +763 -373
  21. package/telegram-plugin/dist/server.js +19 -20
  22. package/telegram-plugin/gateway/boot-card.ts +5 -1
  23. package/telegram-plugin/gateway/boot-probes.ts +113 -0
  24. package/telegram-plugin/gateway/config-approval-handler.test.ts +54 -0
  25. package/telegram-plugin/gateway/config-approval-handler.ts +16 -1
  26. package/telegram-plugin/gateway/disconnect-flush.ts +17 -0
  27. package/telegram-plugin/gateway/gateway.ts +43 -1
  28. package/telegram-plugin/gateway/handback-preturn-signal.ts +61 -7
  29. package/telegram-plugin/gateway/ipc-protocol.ts +5 -0
  30. package/telegram-plugin/gateway/ipc-server.ts +13 -0
  31. package/telegram-plugin/gateway/liveness-wiring.ts +125 -5
  32. package/telegram-plugin/gateway/obligation-ledger.ts +84 -4
  33. package/telegram-plugin/gateway/resume-inbound-builder.ts +13 -4
  34. package/telegram-plugin/gateway/stream-render.ts +24 -5
  35. package/telegram-plugin/hooks/secret-guard-pretool.mjs +249 -76
  36. package/telegram-plugin/registry/turns-schema.test.ts +8 -3
  37. package/telegram-plugin/registry/turns-schema.ts +40 -12
  38. package/telegram-plugin/runtime-metrics.ts +14 -0
  39. package/telegram-plugin/silence-poke.ts +138 -0
  40. package/telegram-plugin/tests/boot-probe-drift.test.ts +152 -0
  41. package/telegram-plugin/tests/gateway-disconnect-flush.test.ts +32 -0
  42. package/telegram-plugin/tests/handback-preturn-signal.test.ts +62 -0
  43. package/telegram-plugin/tests/helpers/liveness-wiring-fixture.ts +178 -0
  44. package/telegram-plugin/tests/ipc-server-validate-config-approval.test.ts +95 -0
  45. package/telegram-plugin/tests/mcp-instructions-budget.test.ts +184 -0
  46. package/telegram-plugin/tests/multitopic-routing-wiring.test.ts +22 -2
  47. package/telegram-plugin/tests/obligation-determinism.test.ts +114 -3
  48. package/telegram-plugin/tests/obligation-ledger.test.ts +310 -0
  49. package/telegram-plugin/tests/registry-turns.test.ts +13 -0
  50. package/telegram-plugin/tests/resume-inbound-builder.test.ts +15 -0
  51. package/telegram-plugin/tests/secret-guard-pretool.test.ts +347 -16
  52. package/telegram-plugin/tests/silence-poke-orphan-reap.test.ts +392 -0
  53. package/telegram-plugin/tests/silence-poke-teardown-notice.test.ts +301 -0
  54. package/telegram-plugin/tests/stream-render-golden.test.ts +103 -1
  55. package/telegram-plugin/tests/tts-normalize.test.ts +43 -0
  56. package/telegram-plugin/tests/voice-normalize-text.test.ts +212 -3
  57. package/telegram-plugin/tts-normalize.ts +6 -4
  58. package/telegram-plugin/voice-normalize-text.ts +168 -11
  59. package/vendor/hindsight-memory/CHANGELOG.md +73 -0
  60. package/vendor/hindsight-memory/scripts/lib/config.py +8 -3
  61. package/vendor/hindsight-memory/scripts/lib/directives.py +62 -4
  62. package/vendor/hindsight-memory/scripts/recall.py +257 -12
  63. package/vendor/hindsight-memory/scripts/retain.py +12 -6
  64. package/vendor/hindsight-memory/scripts/tests/test_directives.py +80 -9
  65. package/vendor/hindsight-memory/scripts/tests/test_recall_integration.py +362 -18
  66. package/vendor/hindsight-memory/settings.json +1 -1
@@ -17,7 +17,7 @@
17
17
 
18
18
  import { afterEach, beforeEach, describe, expect, it } from 'bun:test'
19
19
  import { spawn, spawnSync } from 'node:child_process'
20
- import { mkdtempSync, rmSync, unlinkSync, existsSync } from 'node:fs'
20
+ import { mkdtempSync, rmSync, unlinkSync, existsSync, writeFileSync, chmodSync } from 'node:fs'
21
21
  import { tmpdir } from 'node:os'
22
22
  import { join, resolve } from 'node:path'
23
23
  import { createServer, type Server, type Socket } from 'node:net'
@@ -28,20 +28,55 @@ interface FakeBroker {
28
28
  socketPath: string
29
29
  stop: () => Promise<void>
30
30
  connectionCount: number
31
+ /** High-water mark of `get` requests being serviced simultaneously. */
32
+ maxConcurrentGets: number
31
33
  }
32
34
 
33
35
  /**
34
36
  * Stand up a minimal NDJSON broker. Responds to `list` with the supplied
35
37
  * keys, and to `get` requests with the entry shape Telegram-plugin expects.
36
38
  */
37
- function startFakeBroker(values: Record<string, string>): Promise<FakeBroker> {
39
+ function startFakeBroker(
40
+ values: Record<string, string>,
41
+ opts: {
42
+ /** Artificial per-request service delay, ms, implemented with
43
+ * setTimeout. This models NETWORK/IO latency only — it does NOT occupy
44
+ * the broker's event loop, so it makes client-side fan-out look like a
45
+ * pure win. Use `cpuMs` for the honest model of the real broker. */
46
+ delayMs?: number
47
+ /** Artificial per-request SYNCHRONOUS CPU cost, ms. This is what the
48
+ * real broker actually does: every tokened request awaits a
49
+ * `bcryptjs.compare` (src/vault/grants.ts, BCRYPT_COST=10), and
50
+ * bcryptjs is pure JS that blocks the event loop — measured ~57ms per
51
+ * compare, with 8 "concurrent" compares taking 459ms (fully
52
+ * serialized). A busy-wait reproduces that; a setTimeout does not. */
53
+ cpuMs?: number
54
+ /** Keys whose `get` should return DENIED instead of a value. */
55
+ denyKeys?: string[]
56
+ /** Keys whose `get` should never be answered at all (socket left open). */
57
+ hangKeys?: string[]
58
+ } = {},
59
+ ): Promise<FakeBroker> {
38
60
  return new Promise((resolveStart) => {
39
61
  const dir = mkdtempSync(join(tmpdir(), 'fake-broker-'))
40
62
  const socketPath = join(dir, 'broker.sock')
63
+ const delayMs = opts.delayMs ?? 0
64
+ const cpuMs = opts.cpuMs ?? 0
65
+ /** Block the event loop for `cpuMs`, the way bcryptjs.compare does. */
66
+ const burnCpu = () => {
67
+ if (cpuMs <= 0) return
68
+ const until = Date.now() + cpuMs
69
+ while (Date.now() < until) { /* deliberate busy-wait */ }
70
+ }
71
+ const denyKeys = new Set(opts.denyKeys ?? [])
72
+ const hangKeys = new Set(opts.hangKeys ?? [])
41
73
  let connectionCount = 0
74
+ let inFlightGets = 0
75
+ let maxConcurrentGets = 0
42
76
  const server: Server = createServer((sock: Socket) => {
43
77
  connectionCount++
44
78
  let buf = ''
79
+ sock.on('error', () => { /* client destroys sockets after one turn */ })
45
80
  sock.on('data', (chunk) => {
46
81
  buf += chunk.toString('utf8')
47
82
  let idx
@@ -50,14 +85,32 @@ function startFakeBroker(values: Record<string, string>): Promise<FakeBroker> {
50
85
  buf = buf.slice(idx + 1)
51
86
  let req
52
87
  try { req = JSON.parse(line) } catch { continue }
88
+ const isGet = req?.op === 'get'
89
+ if (isGet) {
90
+ inFlightGets++
91
+ if (inFlightGets > maxConcurrentGets) maxConcurrentGets = inFlightGets
92
+ }
93
+ // The real broker awaits validateGrant -> bcryptjs.compare BEFORE
94
+ // it can reply, and that call blocks its single event loop. Burn
95
+ // here, synchronously, for the same reason and at the same point.
96
+ burnCpu()
97
+ const reply = (obj: unknown) => {
98
+ setTimeout(() => {
99
+ if (isGet) inFlightGets--
100
+ try { sock.write(JSON.stringify(obj) + '\n') } catch { /* closed */ }
101
+ }, delayMs)
102
+ }
53
103
  if (req?.op === 'list') {
54
- sock.write(JSON.stringify({ ok: true, keys: Object.keys(values) }) + '\n')
55
- } else if (req?.op === 'get' && typeof req.key === 'string') {
104
+ reply({ ok: true, keys: Object.keys(values) })
105
+ } else if (isGet && typeof req.key === 'string') {
106
+ if (hangKeys.has(req.key)) continue // never reply, never decrement
56
107
  const v = values[req.key]
57
- if (v !== undefined) {
58
- sock.write(JSON.stringify({ ok: true, entry: { kind: 'string', value: v } }) + '\n')
108
+ if (denyKeys.has(req.key)) {
109
+ reply({ ok: false, code: 'DENIED', msg: req.key })
110
+ } else if (v !== undefined) {
111
+ reply({ ok: true, entry: { kind: 'string', value: v } })
59
112
  } else {
60
- sock.write(JSON.stringify({ ok: false, code: 'UNKNOWN_KEY', msg: req.key }) + '\n')
113
+ reply({ ok: false, code: 'UNKNOWN_KEY', msg: req.key })
61
114
  }
62
115
  }
63
116
  }
@@ -67,6 +120,7 @@ function startFakeBroker(values: Record<string, string>): Promise<FakeBroker> {
67
120
  resolveStart({
68
121
  socketPath,
69
122
  get connectionCount() { return connectionCount },
123
+ get maxConcurrentGets() { return maxConcurrentGets },
70
124
  stop: () => new Promise<void>((stopResolve) => {
71
125
  server.close(() => {
72
126
  try { rmSync(dir, { recursive: true, force: true }) } catch { /* best-effort */ }
@@ -87,9 +141,12 @@ function startFakeBroker(values: Record<string, string>): Promise<FakeBroker> {
87
141
  function runHook(opts: {
88
142
  toolInput: unknown
89
143
  brokerSocket?: string | null
90
- }): Promise<{ stdout: string; stderr: string; status: number }> {
144
+ /** Prepended to the child's PATH — used to plant a `switchroom` shim. */
145
+ pathPrefix?: string
146
+ }): Promise<{ stdout: string; stderr: string; status: number; elapsedMs: number }> {
147
+ const basePath = process.env.PATH ?? ''
91
148
  const env: Record<string, string> = {
92
- PATH: process.env.PATH ?? '',
149
+ PATH: opts.pathPrefix ? `${opts.pathPrefix}:${basePath}` : basePath,
93
150
  NODE_PATH: process.env.NODE_PATH ?? '',
94
151
  HOME: process.env.HOME ?? '',
95
152
  }
@@ -102,18 +159,60 @@ function runHook(opts: {
102
159
  tool_input: opts.toolInput,
103
160
  })
104
161
  return new Promise((resolveRun) => {
162
+ const t0 = Date.now()
105
163
  const child = spawn('node', [HOOK_PATH], { env })
106
164
  let stdout = ''
107
165
  let stderr = ''
108
166
  child.stdout.on('data', (d) => { stdout += d.toString() })
109
167
  child.stderr.on('data', (d) => { stderr += d.toString() })
110
168
  child.on('close', (status) => {
111
- resolveRun({ stdout, stderr, status: status ?? 1 })
169
+ resolveRun({ stdout, stderr, status: status ?? 1, elapsedMs: Date.now() - t0 })
112
170
  })
113
171
  child.stdin.end(stdinJson)
114
172
  })
115
173
  }
116
174
 
175
+ /**
176
+ * Plant an executable `switchroom` on a fresh directory that records every
177
+ * invocation by touching a sentinel file, then returns a plausible-looking
178
+ * success so a forking implementation would appear to work (and therefore
179
+ * would NOT be caught by the block/allow assertions alone).
180
+ *
181
+ * Returns the directory to prepend to PATH and the sentinel path.
182
+ */
183
+ function plantSwitchroomShim(): { dir: string; sentinel: string } {
184
+ // NOT under os.tmpdir(): /tmp is mounted `noexec` in the agent containers
185
+ // this suite runs in (verified 2026-07-25 — `findmnt -no OPTIONS /tmp` =>
186
+ // `rw,nosuid,nodev,noexec`). A shim planted there is silently unrunnable,
187
+ // which would make the no-fork assertion below vacuously true — the exact
188
+ // class of bug this test exists to close. Plant it beside the test file,
189
+ // on the repo filesystem, which is executable.
190
+ const dir = mkdtempSync(join(__dirname, '.shim-'))
191
+ const sentinel = join(dir, 'FORKED')
192
+ const shim = join(dir, 'switchroom')
193
+ writeFileSync(shim, `#!/bin/sh\nprintf '%s\\n' "$*" >> "${sentinel}"\nexit 0\n`)
194
+ chmodSync(shim, 0o755)
195
+
196
+ // Self-check: prove the shim is REACHABLE and RECORDS, so "sentinel
197
+ // absent" can only ever mean "the hook did not fork" — never "the shim
198
+ // could not run". Without this the test can go vacuous again the moment
199
+ // it runs on a noexec mount.
200
+ const probe = spawnSync('switchroom', ['self-check'], {
201
+ env: { ...process.env, PATH: `${dir}:${process.env.PATH ?? ''}` },
202
+ })
203
+ if (probe.status !== 0 || !existsSync(sentinel)) {
204
+ rmSync(dir, { recursive: true, force: true })
205
+ throw new Error(
206
+ `no-fork shim is not executable (status=${probe.status}, `
207
+ + `error=${probe.error?.message ?? 'none'}) — the no-fork assertion `
208
+ + `would be vacuous. Fix the shim location, do not skip this test.`,
209
+ )
210
+ }
211
+ unlinkSync(sentinel)
212
+
213
+ return { dir, sentinel }
214
+ }
215
+
117
216
  let broker: FakeBroker | null = null
118
217
 
119
218
  afterEach(async () => {
@@ -160,20 +259,252 @@ describe('secret-guard-pretool.mjs (broker-direct)', () => {
160
259
  expect(r.stdout).toBe('')
161
260
  })
162
261
 
163
- it('opens a single broker connection per invocation (no per-key forks)', async () => {
262
+ it('issues the per-key gets concurrently, not one at a time (#3543)', async () => {
263
+ // The old implementation awaited each `get` before sending the next, so
264
+ // its cost was 1 + N round trips. The fan-out must have several gets in
265
+ // flight at the same time. This asserts the OUTCOME (overlapping
266
+ // in-flight requests observed by the broker), not that a particular
267
+ // function was called.
164
268
  broker = await startFakeBroker({
165
269
  'a': 'aaaaaaaa-secret-value-aaaaaaaa',
166
270
  'b': 'bbbbbbbb-secret-value-bbbbbbbb',
167
271
  'c': 'cccccccc-secret-value-cccccccc',
168
- })
272
+ 'd': 'dddddddd-secret-value-dddddddd',
273
+ }, { delayMs: 40 })
169
274
  await runHook({
170
275
  toolInput: { command: 'echo hi' },
171
276
  brokerSocket: broker.socketPath,
172
277
  })
173
- // The hook must keep one socket connection open and pipeline list +
174
- // N get requests over it. Forking a child per key was the old shape
175
- // and is what this PR fixes.
176
- expect(broker.connectionCount).toBe(1)
278
+ expect(broker.maxConcurrentGets).toBe(4)
279
+ })
280
+
281
+ it('total cost is ~2 round trips, not 1 + N (#3543)', async () => {
282
+ // Deliberately NOT a wall-clock assertion. The earlier version of this
283
+ // test asserted elapsedMs < 250; measured node cold start for this hook
284
+ // is 104-127ms on an idle box and this suite runs under `bun test` on a
285
+ // 2-vCPU CI runner alongside the rest of telegram-plugin/tests, so a
286
+ // 250ms ceiling had ~65ms of headroom and would have flaked (#3574
287
+ // review F3).
288
+ //
289
+ // `maxConcurrentGets` is the timing-INDEPENDENT discriminator for the
290
+ // same property: the sequential 1 + N implementation could never exceed
291
+ // 1 in flight no matter how fast or slow the box is, while the fan-out
292
+ // has all 8 outstanding at once. Same guarantee, zero clock dependence.
293
+ const values: Record<string, string> = {}
294
+ for (let i = 0; i < 8; i++) values['k' + i] = `secret-value-number-${i}-xxxxxxxx`
295
+ broker = await startFakeBroker(values, { delayMs: 40 })
296
+ const r = await runHook({
297
+ toolInput: { command: 'echo hi' },
298
+ brokerSocket: broker.socketPath,
299
+ })
300
+ expect(r.status).toBe(0)
301
+ expect(broker.maxConcurrentGets).toBe(8)
302
+ // 1 list + 8 gets, one request per connection.
303
+ expect(broker.connectionCount).toBe(9)
304
+ })
305
+
306
+ it('client fan-out does NOT reduce wall time against a CPU-bound broker (#3574)', async () => {
307
+ // The honest counterpart to the test above. The `delayMs` fake sleeps,
308
+ // so it credits the fan-out with a speedup that CANNOT exist against
309
+ // the real broker, whose per-request cost is a synchronous, event-loop-
310
+ // blocking `bcryptjs.compare` inside validateGrant. `cpuMs` reproduces
311
+ // that.
312
+ //
313
+ // Asserted property: with a CPU-bound broker the client's concurrency
314
+ // is irrelevant to wall time — total cost is still ~(N+1) × cpuMs,
315
+ // i.e. STRICTLY MORE than the serialized floor. This is a lower bound
316
+ // (>=), so it cannot flake upward on a loaded runner; it fails only if
317
+ // someone re-introduces the false "concurrency makes it O(1)" model.
318
+ // This is a DIFFERENTIAL, not an absolute budget, for the reason F3
319
+ // flagged: an absolute wall-clock ceiling has to leave room for node
320
+ // cold start (measured 104-127ms for this hook on an idle box) and
321
+ // flakes on a loaded CI runner. Running the SAME hook against both
322
+ // broker models subtracts node startup and every other fixed cost
323
+ // automatically — the baseline is measured, not assumed.
324
+ //
325
+ // sleeping broker (delayMs): fan-out wins => ~startup + 2 × COST
326
+ // CPU-bound broker (cpuMs): fan-out cannot => ~startup + (N+1) × COST
327
+ //
328
+ // so the gap should be ~(N-1) × COST = 160ms. We assert only that it
329
+ // exceeds 2 × COST (80ms): if concurrency helped a CPU-bound broker the
330
+ // way it helps a sleeping one, the gap would be ~0. Load inflates both
331
+ // runs, so the LOWER bound on a difference is what stays stable.
332
+ const N = 5
333
+ const COST_MS = 40
334
+ const values: Record<string, string> = {}
335
+ for (let i = 0; i < N; i++) values['c' + i] = `cpu-secret-value-${i}-xxxxxxxx`
336
+
337
+ const sleepy = await startFakeBroker(values, { delayMs: COST_MS })
338
+ let sleepyMs: number
339
+ try {
340
+ const rs = await runHook({ toolInput: { command: 'echo hi' }, brokerSocket: sleepy.socketPath })
341
+ expect(rs.status).toBe(0)
342
+ sleepyMs = rs.elapsedMs
343
+ } finally {
344
+ await sleepy.stop()
345
+ }
346
+
347
+ broker = await startFakeBroker(values, { cpuMs: COST_MS })
348
+ const r = await runHook({
349
+ toolInput: { command: 'echo hi' },
350
+ brokerSocket: broker.socketPath,
351
+ })
352
+ expect(r.status).toBe(0)
353
+ expect(r.elapsedMs - sleepyMs).toBeGreaterThan(2 * COST_MS)
354
+ // And the guard must still be COMPLETE despite that cost: every key
355
+ // resolved within the deadline, including the last. Partial coverage is
356
+ // the security failure mode this whole area is about.
357
+ expect(broker.connectionCount).toBe(N + 1)
358
+ expect(r.stderr).not.toContain('DEGRADED')
359
+ }, 20_000)
360
+
361
+ it('caps the fan-out at MAX_CONCURRENT_GETS and still guards every key past the cap', async () => {
362
+ // Two properties the fan-out owes us on a vault larger than the cap
363
+ // (MAX_CONCURRENT_GETS = 32), both timing-independent:
364
+ // 1. the cap is real — 40 keys must never put more than 32 gets in
365
+ // flight (an unbounded fan-out would show 40);
366
+ // 2. the batch loop drains the remainder — key #40, which can only be
367
+ // fetched in the second batch, is still guarded.
368
+ const values: Record<string, string> = {}
369
+ for (let i = 0; i < 40; i++) values['k' + i] = `secret-value-number-${i}-xxxxxxxxxx`
370
+ broker = await startFakeBroker(values, { delayMs: 40 })
371
+ const r = await runHook({
372
+ // k39 is the 40th key — past the first batch of 32.
373
+ toolInput: { command: 'echo secret-value-number-39-xxxxxxxxxx' },
374
+ brokerSocket: broker.socketPath,
375
+ })
376
+ expect(r.status).toBe(0)
377
+ expect(r.stdout).toContain('"decision":"block"')
378
+ expect(r.stdout).toContain('k39')
379
+ expect(broker.maxConcurrentGets).toBeLessThanOrEqual(32)
380
+ expect(broker.maxConcurrentGets).toBeGreaterThan(1)
381
+ expect(broker.connectionCount).toBe(41)
382
+ }, 10_000)
383
+
384
+ it('does not fork a child process per key', async () => {
385
+ // The generation-1 shape forked `switchroom vault get` per key. Asserting
386
+ // a connection COUNT cannot prove that — a fork-per-key implementation
387
+ // produces the same 1 + N connections, because each forked CLI opens its
388
+ // own (#3574 review F2). So plant an executable `switchroom` on PATH that
389
+ // records every invocation and assert it was never invoked.
390
+ const { dir, sentinel } = plantSwitchroomShim()
391
+ try {
392
+ broker = await startFakeBroker({
393
+ 'a': 'aaaaaaaa-secret-value-aaaaaaaa',
394
+ 'b': 'bbbbbbbb-secret-value-bbbbbbbb',
395
+ 'c': 'cccccccc-secret-value-cccccccc',
396
+ })
397
+ await runHook({
398
+ toolInput: { command: 'echo hi' },
399
+ brokerSocket: broker.socketPath,
400
+ pathPrefix: dir,
401
+ })
402
+ // The shim is genuinely reachable and would have fired — the hook
403
+ // simply never shells out. (It exits 0 with plausible output, so a
404
+ // forking implementation would still allow/block correctly and could
405
+ // only be caught here.)
406
+ expect(existsSync(sentinel)).toBe(false)
407
+ // Secondary: the hook speaks the socket itself, one request per
408
+ // connection turn (the shape protocol.ts:8-11 documents).
409
+ expect(broker.connectionCount).toBe(4)
410
+ } finally {
411
+ rmSync(dir, { recursive: true, force: true })
412
+ }
413
+ })
414
+
415
+ it('logs a DEGRADED line when a `get` gets no broker response (#3574)', async () => {
416
+ // The fan-out silently drops any key whose `get` never returns, and a
417
+ // dropped key is an unguarded secret. That is exactly what a broker
418
+ // connection cap would cause (this hook needs N+1 connections where the
419
+ // sequential version needed 1). Prose in a comment can't catch it, so
420
+ // the hook must leave a deterministic trace on stderr.
421
+ broker = await startFakeBroker({
422
+ 'hung-key': 'hung-secret-value-aaaaaaaa',
423
+ 'live-key': 'live-secret-value-bbbbbbbb',
424
+ }, { hangKeys: ['hung-key'] })
425
+ const r = await runHook({
426
+ toolInput: { command: 'echo hi' },
427
+ brokerSocket: broker.socketPath,
428
+ })
429
+ expect(r.status).toBe(0)
430
+ expect(r.stderr).toContain('DEGRADED')
431
+ expect(r.stderr).toContain('1/2')
432
+ // Decision channel stays clean — the warning must not leak to stdout,
433
+ // where Claude Code parses the block decision.
434
+ expect(r.stdout).toBe('')
435
+ }, 10_000)
436
+
437
+ it('does not log DEGRADED when every key resolves', async () => {
438
+ // Guards the inverse: a DENIED reply is a broker ANSWER, not an
439
+ // unreachable connection, so it must not raise the degradation signal
440
+ // (otherwise the line is noise and gets ignored).
441
+ broker = await startFakeBroker({
442
+ 'denied-key': 'denied-secret-value-aaaaaaaa',
443
+ 'live-key': 'live-secret-value-bbbbbbbb',
444
+ }, { denyKeys: ['denied-key'] })
445
+ const r = await runHook({
446
+ toolInput: { command: 'echo hi' },
447
+ brokerSocket: broker.socketPath,
448
+ })
449
+ expect(r.status).toBe(0)
450
+ expect(r.stderr).not.toContain('DEGRADED')
451
+ })
452
+
453
+ it('still blocks on the other keys when one key is DENIED', async () => {
454
+ // Security posture: a per-key failure must not disable the guard for
455
+ // the keys that DID resolve. This is the case the sequential version
456
+ // handled with `continue`; the fan-out must preserve it.
457
+ broker = await startFakeBroker({
458
+ 'denied-key': 'denied-secret-value-aaaaaaaa',
459
+ 'live-key': 'live-secret-value-bbbbbbbb',
460
+ }, { denyKeys: ['denied-key'] })
461
+ const r = await runHook({
462
+ toolInput: { command: 'echo live-secret-value-bbbbbbbb' },
463
+ brokerSocket: broker.socketPath,
464
+ })
465
+ expect(r.status).toBe(0)
466
+ expect(r.stdout).toContain('"decision":"block"')
467
+ expect(r.stdout).toContain('live-key')
468
+ })
469
+
470
+ it('still blocks on resolved keys when another key never responds', async () => {
471
+ // A hung `get` used to stall the whole sequential loop until the 1500ms
472
+ // deadline and then discard EVERY value (fail open). Now the other keys
473
+ // resolve independently and are still guarded.
474
+ broker = await startFakeBroker({
475
+ 'hung-key': 'hung-secret-value-aaaaaaaa',
476
+ 'live-key': 'live-secret-value-bbbbbbbb',
477
+ }, { hangKeys: ['hung-key'] })
478
+ const r = await runHook({
479
+ toolInput: { command: 'echo live-secret-value-bbbbbbbb' },
480
+ brokerSocket: broker.socketPath,
481
+ })
482
+ expect(r.status).toBe(0)
483
+ expect(r.stdout).toContain('"decision":"block"')
484
+ expect(r.stdout).toContain('live-key')
485
+ }, 10_000)
486
+
487
+ it('fails open when the broker denies `list`', async () => {
488
+ // No key set is knowable => nothing to guard against => allow. Same as
489
+ // the sequential version; asserted so a refactor cannot silently turn
490
+ // this into a hard block that wedges every session.
491
+ const dir = mkdtempSync(join(tmpdir(), 'deny-broker-'))
492
+ const socketPath = join(dir, 'broker.sock')
493
+ const server = createServer((sock: Socket) => {
494
+ sock.on('error', () => {})
495
+ sock.on('data', () => {
496
+ sock.write(JSON.stringify({ ok: false, code: 'LOCKED', msg: 'locked' }) + '\n')
497
+ })
498
+ })
499
+ await new Promise<void>((r) => server.listen(socketPath, () => r()))
500
+ try {
501
+ const res = await runHook({ toolInput: { command: 'echo hi' }, brokerSocket: socketPath })
502
+ expect(res.status).toBe(0)
503
+ expect(res.stdout).toBe('')
504
+ } finally {
505
+ await new Promise<void>((r) => server.close(() => r()))
506
+ rmSync(dir, { recursive: true, force: true })
507
+ }
177
508
  })
178
509
 
179
510
  it('skips values shorter than the minimum guard length', async () => {