@hasna/hooks 0.3.10 → 0.4.0

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.
@@ -3,27 +3,54 @@
3
3
  /**
4
4
  * Claude Code Hook: fleet-blockers-gate
5
5
  *
6
- * PreToolUse hook — every-turn insurance against working through a fleet
7
- * freeze. While an unread [FREEZE] blocking message is active, mutating
8
- * tools are denied with a reason; read-only tools stay allowed so the agent
9
- * can read the freeze and react.
6
+ * PreToolUse hook — every-turn insurance against working through a real fleet
7
+ * stop. The brake has ONE tamper-resistant, correctly-retrieved signal:
8
+ *
9
+ * A code-flagged blocker (blocking=1) returned by `conversations blockers`
10
+ * denies mutating tools. That CLI runs `getUnreadBlockers`, which selects
11
+ * `WHERE blocking = 1 AND read_at IS NULL AND (to_agent = me OR channel in my
12
+ * channels)` with NO limit window — so every unread, in-scope blocker is
13
+ * returned and evaluated (no oldest-first truncation to hide behind).
14
+ *
15
+ * To halt the fleet, the owner creates a blocking=1 blocker (tagged [FREEZE] by
16
+ * convention). The stop lifts when that blocker leaves the UNREAD set — i.e. it
17
+ * is MARKED READ or REMOVED (`getUnreadBlockers` filters `read_at IS NULL`).
18
+ * Because reading messages can mark them read, `conversations` read_* tools are
19
+ * gated during a freeze (see isReadOnlyTool) so an agent cannot SELF-LIFT the
20
+ * stop just by browsing its inbox/channel. Freeze TEXT posted to channels is
21
+ * informational and NEVER stops work — that kills the phantom-freeze bug where
22
+ * any "[FREEZE]" string from anyone wedged the fleet.
23
+ *
24
+ * IMPORTANT (why this is not author-gated): conversations does not authenticate
25
+ * `from_agent`; any agent can post as any name. Gating the brake on the author
26
+ * field would be false assurance (a spoofed [UNFREEZE]/owner post could lift or
27
+ * forge a stop). So the trigger is the blocking=1 flag alone, author-agnostic.
28
+ * The blocker's author is shown in the deny reason as ADVISORY context only.
29
+ *
30
+ * When frozen, mutating tools are denied with a reason; read-only tools stay
31
+ * allowed so the agent can read the blocker and react.
10
32
  *
11
33
  * Design constraints (fleet comms strategy §3):
12
- * - deterministic local CLI call (`conversations blockers -j`)
13
- * - hard 500ms fail-open timeout on the check
14
- * - TTL cache so the common path never spawns a process per tool call
34
+ * - deterministic local CLI call (`conversations blockers -j`), single spawn
35
+ * - hard fail-open timeout (default 1500ms; the `conversations` CLI has a ~0.5s
36
+ * cold start, so a tighter budget flakes and the brake silently fails open)
37
+ * - fail-open on error: if the comms layer is unreachable, allow (never wedge)
38
+ * - TTL cache so the common path never spawns a process per tool call;
39
+ * asymmetric TTL means a freeze ENGAGES fast and DISENGAGES slowly (safe)
15
40
  *
16
41
  * Environment:
17
- * - HOOKS_FLEET_GATE_DISABLE=1 → allow everything (kill switch)
18
- * - HOOKS_FLEET_GATE_TTL_MS=<n> → cache TTL (default 60000)
19
- * - HOOKS_FLEET_TIMEOUT_MS=<n> → blockers check exec timeout (default 500)
20
- * - HOOKS_FLEET_AGENT=<name> → identity passed as --from
42
+ * - HOOKS_FLEET_GATE_DISABLE=1 → allow everything (kill switch)
43
+ * - HOOKS_FLEET_GATE_TTL_MS=<n> → frozen-state cache TTL (default 60000)
44
+ * - HOOKS_FLEET_GATE_CLEAR_TTL_MS=<n> → clear-state cache TTL (default 5000)
45
+ * - HOOKS_FLEET_TIMEOUT_MS=<n> → CLI exec timeout (default 1500)
46
+ * - HOOKS_FLEET_AGENT=<name> → identity passed as --from to scope
47
+ * the blockers query to this agent
21
48
  */
22
49
 
23
50
  import { readFileSync, existsSync, mkdirSync, writeFileSync } from "fs";
24
51
  import { join } from "path";
25
52
  import { homedir } from "os";
26
- import { execSync } from "child_process";
53
+ import { execFileSync } from "child_process";
27
54
 
28
55
  interface HookInput {
29
56
  session_id: string;
@@ -48,8 +75,28 @@ export interface FreezeState {
48
75
  reason: string;
49
76
  }
50
77
 
78
+ export interface FreezeDetection {
79
+ frozen: boolean;
80
+ reason: string;
81
+ }
82
+
83
+ export interface FreezeEvaluation {
84
+ state: FreezeState;
85
+ /** True when the blockers CLI produced a reading (vs. a comms failure). */
86
+ verified: boolean;
87
+ }
88
+
89
+ export interface HookDecision {
90
+ allow: boolean;
91
+ reason: string;
92
+ }
93
+
51
94
  const DEFAULT_TTL_MS = 60_000;
52
- const DEFAULT_TIMEOUT_MS = 500;
95
+ const DEFAULT_CLEAR_TTL_MS = 5_000;
96
+ // The `conversations` CLI has a ~0.5s cold start, so a 500ms budget flakes and
97
+ // the brake fails open in practice. Give headroom; the TTL cache keeps this
98
+ // single spawn off the per-tool hot path.
99
+ const DEFAULT_TIMEOUT_MS = 1_500;
53
100
  const STATE_DIR = join(homedir(), ".hasna", "hooks", "state");
54
101
  const CACHE_FILE = join(STATE_DIR, "fleet-blockers-gate.json");
55
102
 
@@ -96,19 +143,51 @@ function respond(output: HookOutput): void {
96
143
  console.log(JSON.stringify(output));
97
144
  }
98
145
 
146
+ function positiveIntEnv(name: string, fallback: number): number {
147
+ const raw = Number(process.env[name]);
148
+ return Number.isFinite(raw) && raw > 0 ? raw : fallback;
149
+ }
150
+
99
151
  function ttlMs(): number {
100
- const raw = Number(process.env.HOOKS_FLEET_GATE_TTL_MS);
101
- return Number.isFinite(raw) && raw > 0 ? raw : DEFAULT_TTL_MS;
152
+ return positiveIntEnv("HOOKS_FLEET_GATE_TTL_MS", DEFAULT_TTL_MS);
153
+ }
154
+
155
+ function clearTtlMs(): number {
156
+ return positiveIntEnv("HOOKS_FLEET_GATE_CLEAR_TTL_MS", DEFAULT_CLEAR_TTL_MS);
102
157
  }
103
158
 
104
159
  function timeoutMs(): number {
105
- const raw = Number(process.env.HOOKS_FLEET_TIMEOUT_MS);
106
- return Number.isFinite(raw) && raw > 0 ? raw : DEFAULT_TIMEOUT_MS;
160
+ return positiveIntEnv("HOOKS_FLEET_TIMEOUT_MS", DEFAULT_TIMEOUT_MS);
161
+ }
162
+
163
+ /**
164
+ * Reject values that could be argument-injected (leading dash) or contain
165
+ * shell/space/control characters. Even though we use execFileSync (no shell),
166
+ * a leading-dash value would be parsed as a flag by the CLI, so we forbid it.
167
+ */
168
+ export function isSafeArg(value: string | undefined): value is string {
169
+ return typeof value === "string" && /^[A-Za-z0-9][A-Za-z0-9._@+-]{0,127}$/.test(value);
170
+ }
171
+
172
+ /**
173
+ * `conversations` "read_*" ops (read_messages, read_channel, read_digest,
174
+ * read_thread, read_channel_notifications, ...) mark messages read by default or
175
+ * on request. Because the freeze signal is an UNREAD blocking=1 blocker, letting
176
+ * a frozen agent call one of these would clear the blocker's `read_at` and
177
+ * SELF-LIFT the stop just by browsing. They are therefore gated during a freeze
178
+ * despite matching a read-only prefix. A frozen agent still orients via
179
+ * `get_blockers` / `get_message` / `search_messages` / `list_*` /
180
+ * `get_thread_replies` — none of which mark messages read.
181
+ */
182
+ export function marksReadState(toolName: string): boolean {
183
+ return toolName.startsWith("mcp__conversations__read");
107
184
  }
108
185
 
109
186
  /** True when the tool cannot mutate anything and must stay usable during a freeze. */
110
187
  export function isReadOnlyTool(toolName: string | undefined): boolean {
111
188
  if (!toolName) return false;
189
+ // Gate conversations read_* even though it looks read-only — it consumes unread state.
190
+ if (marksReadState(toolName)) return false;
112
191
  if (READ_ONLY_TOOLS.has(toolName)) return true;
113
192
 
114
193
  if (toolName.startsWith("mcp__")) {
@@ -121,19 +200,44 @@ export function isReadOnlyTool(toolName: string | undefined): boolean {
121
200
  return false;
122
201
  }
123
202
 
124
- /** Detect an active [FREEZE] in the unread blocking messages list. */
125
- export function detectFreeze(blockers: unknown[]): { frozen: boolean; reason: string } {
203
+ /** The author of a blocker (schema is `from_agent`; tolerate legacy shapes). Advisory only. */
204
+ function authorOf(m: Record<string, unknown>): string {
205
+ for (const key of ["from_agent", "from", "author", "sender"]) {
206
+ const v = m[key];
207
+ if (typeof v === "string" && v.trim()) return v.trim();
208
+ }
209
+ return "unknown";
210
+ }
211
+
212
+ /** The scannable text body of a blocker (used only to describe the deny reason). */
213
+ function bodyOf(m: Record<string, unknown>): string {
214
+ return [m.content, m.preview, m.message, m.title, m.body]
215
+ .filter((v): v is string => typeof v === "string")
216
+ .join(" ");
217
+ }
218
+
219
+ /** True when the blocker carries the code-flagged blocking bit (boolean, 1, or "1"/"true"). */
220
+ function isBlockingFlagged(m: Record<string, unknown>): boolean {
221
+ const v = m.blocking;
222
+ return v === true || v === 1 || v === "1" || v === "true";
223
+ }
224
+
225
+ /**
226
+ * Decide whether the blockers list constitutes a freeze. The ONLY trigger is a
227
+ * code-flagged blocker (blocking=1); freeze TEXT is ignored entirely. Scans the
228
+ * whole list (order-independent) so a blocker anywhere in the result freezes.
229
+ * The author is included in the reason as advisory context, NOT as a gate.
230
+ */
231
+ export function detectFreeze(blockers: unknown[]): FreezeDetection {
126
232
  for (const item of blockers) {
127
233
  if (!item || typeof item !== "object") continue;
128
234
  const m = item as Record<string, unknown>;
129
- const body = [m.content, m.preview, m.message, m.title]
130
- .filter((v): v is string => typeof v === "string")
131
- .join(" ");
132
- if (/\[FREEZE\]/.test(body)) {
133
- const from = typeof m.from === "string" ? m.from : "unknown";
235
+ if (isBlockingFlagged(m)) {
236
+ const author = authorOf(m);
237
+ const body = bodyOf(m);
134
238
  return {
135
239
  frozen: true,
136
- reason: `Unread [FREEZE] blocking message from ${from}: ${body.slice(0, 240)}`,
240
+ reason: `Active blocking=1 blocker from ${author}: ${body.slice(0, 240)}`,
137
241
  };
138
242
  }
139
243
  }
@@ -161,7 +265,10 @@ function readCache(now: Date): FreezeState | null {
161
265
  const state = JSON.parse(readFileSync(CACHE_FILE, "utf-8")) as FreezeState;
162
266
  const checkedAt = new Date(state.checked_at).getTime();
163
267
  if (Number.isNaN(checkedAt)) return null;
164
- if (now.getTime() - checkedAt > ttlMs()) return null;
268
+ // Asymmetric TTL: hold a freeze for the full TTL, but re-check a "clear"
269
+ // quickly so a freshly-issued freeze engages fast (the safe direction).
270
+ const maxAge = state.frozen ? ttlMs() : clearTtlMs();
271
+ if (now.getTime() - checkedAt > maxAge) return null;
165
272
  return state;
166
273
  } catch {
167
274
  return null;
@@ -177,31 +284,87 @@ function writeCache(state: FreezeState): void {
177
284
  }
178
285
  }
179
286
 
287
+ /** Run `conversations blockers -j` and return raw stdout, or throw on failure. */
288
+ function defaultBlockersRunner(): string {
289
+ const agent = process.env.HOOKS_FLEET_AGENT;
290
+ const args = ["blockers", "-j"];
291
+ if (isSafeArg(agent)) args.push("--from", agent);
292
+ return execFileSync("conversations", args, {
293
+ encoding: "utf-8",
294
+ timeout: timeoutMs(),
295
+ stdio: ["pipe", "pipe", "pipe"],
296
+ });
297
+ }
298
+
299
+ /**
300
+ * Evaluate freeze state from the blockers source. Injectable runner makes this
301
+ * pure and testable. Fail-open: a runner error (CLI missing / timeout / service
302
+ * down) yields not-frozen with `verified=false`, so the caller can avoid caching
303
+ * an unverified "clear" and retry on the next mutating tool.
304
+ */
305
+ export function computeFreezeState(
306
+ now: Date,
307
+ runner: () => string = defaultBlockersRunner
308
+ ): FreezeEvaluation {
309
+ try {
310
+ const raw = runner();
311
+ const r = detectFreeze(parseBlockersJson(raw.trim()));
312
+ return { state: { checked_at: now.toISOString(), frozen: r.frozen, reason: r.reason }, verified: true };
313
+ } catch {
314
+ return { state: { checked_at: now.toISOString(), frozen: false, reason: "" }, verified: false };
315
+ }
316
+ }
317
+
180
318
  function checkFreeze(now: Date): FreezeState {
181
319
  const cached = readCache(now);
182
320
  if (cached) return cached;
183
321
 
184
- try {
185
- const agent = process.env.HOOKS_FLEET_AGENT;
186
- const from = agent && /^[A-Za-z0-9._-]{1,64}$/.test(agent) ? ` --from ${agent}` : "";
187
- const raw = execSync(`conversations blockers -j${from}`, {
188
- encoding: "utf-8",
189
- timeout: timeoutMs(),
190
- stdio: ["pipe", "pipe", "pipe"],
191
- });
192
- const result = detectFreeze(parseBlockersJson(raw.trim()));
193
- const state: FreezeState = { checked_at: now.toISOString(), frozen: result.frozen, reason: result.reason };
194
- writeCache(state);
195
- return state;
196
- } catch {
197
- // CLI missing / timeout / service down → fail open (never wedge the agent)
198
- // Do not cache an unverified "not frozen" state; retry on the next mutating tool.
199
- return { checked_at: now.toISOString(), frozen: false, reason: "" };
322
+ const evaluation = computeFreezeState(now);
323
+
324
+ // Cache a freeze for the full TTL, and a verified clear for the short TTL.
325
+ // Never cache an unverified (comms-failure) clear — retry on the next tool.
326
+ if (evaluation.state.frozen || evaluation.verified) {
327
+ writeCache(evaluation.state);
200
328
  }
329
+ return evaluation.state;
330
+ }
331
+
332
+ /**
333
+ * Build the deny reason. Points the agent at the read-only, non-mark-read
334
+ * `mcp__conversations__get_blockers` tool — NOT the Bash `conversations blockers`
335
+ * command (Bash is gated) and NOT any read_* tool (those mark the blocker read
336
+ * and would self-lift the stop). The echoed blocker text is UNTRUSTED input.
337
+ */
338
+ export function buildDenyReason(reason: string): string {
339
+ return (
340
+ `[hook-fleet-blockers-gate] Mutating tools are blocked — an active blocking=1 blocker is in effect. ` +
341
+ `${reason} ` +
342
+ `Inspect it with the read-only mcp__conversations__get_blockers tool — do NOT use read_messages / read_channel (they mark it read and would clear the stop). ` +
343
+ `The stop lifts when the blocker is resolved/removed by whoever owns the freeze; then retry.`
344
+ );
345
+ }
346
+
347
+ /**
348
+ * Pure permission decision. Single source of truth for allow/deny:
349
+ * - kill switch disabled → allow
350
+ * - read-only tool → allow (agent must be able to read the blocker)
351
+ * - freeze active → deny
352
+ * - otherwise → allow
353
+ */
354
+ export function decide(params: {
355
+ disabled: boolean;
356
+ toolName: string | undefined;
357
+ freeze: FreezeDetection;
358
+ }): HookDecision {
359
+ if (params.disabled) return { allow: true, reason: "" };
360
+ if (isReadOnlyTool(params.toolName)) return { allow: true, reason: "" };
361
+ if (params.freeze.frozen) return { allow: false, reason: params.freeze.reason };
362
+ return { allow: true, reason: "" };
201
363
  }
202
364
 
203
365
  export function run(): void {
204
- if (process.env.HOOKS_FLEET_GATE_DISABLE === "1") {
366
+ const disabled = process.env.HOOKS_FLEET_GATE_DISABLE === "1";
367
+ if (disabled) {
205
368
  respond({ continue: true });
206
369
  return;
207
370
  }
@@ -212,23 +375,21 @@ export function run(): void {
212
375
  return;
213
376
  }
214
377
 
215
- // Read-only tools always pass — the agent must stay able to read the freeze.
378
+ // Read-only tools always pass — and must never trigger the CLI check.
216
379
  if (isReadOnlyTool(input.tool_name)) {
217
380
  respond({ continue: true });
218
381
  return;
219
382
  }
220
383
 
221
384
  const state = checkFreeze(new Date());
385
+ const decision = decide({ disabled, toolName: input.tool_name, freeze: state });
222
386
 
223
- if (state.frozen) {
387
+ if (!decision.allow) {
224
388
  respond({
225
389
  hookSpecificOutput: {
226
390
  hookEventName: "PreToolUse",
227
391
  permissionDecision: "deny",
228
- permissionDecisionReason:
229
- `[hook-fleet-blockers-gate] Fleet freeze active — mutating tools are blocked. ` +
230
- `${state.reason} ` +
231
- `Read the blocking message (conversations blockers), resolve or wait for [UNFREEZE], then retry.`,
392
+ permissionDecisionReason: buildDenyReason(decision.reason),
232
393
  },
233
394
  });
234
395
  return;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hasna/hooks",
3
- "version": "0.3.10",
3
+ "version": "0.4.0",
4
4
  "description": "Open source hooks library for AI coding agents - Install safety, quality, and automation hooks with a single command",
5
5
  "type": "module",
6
6
  "bin": {