@hasna/hooks 0.3.9 → 0.3.11
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.
- package/bin/index.js +1 -1
- package/hooks/codewith-native-common.ts +30 -29
- package/hooks/hook-fleet-blockers-gate/README.md +23 -11
- package/hooks/hook-fleet-blockers-gate/package.json +2 -2
- package/hooks/hook-fleet-blockers-gate/src/hook.test.ts +211 -15
- package/hooks/hook-fleet-blockers-gate/src/hook.ts +210 -49
- package/package.json +1 -1
package/bin/index.js
CHANGED
|
@@ -367,6 +367,12 @@ interface ProtectedPathRule {
|
|
|
367
367
|
mode: "tree" | "root";
|
|
368
368
|
}
|
|
369
369
|
|
|
370
|
+
interface ProtectedPathContext {
|
|
371
|
+
rules: ProtectedPathRule[];
|
|
372
|
+
workspaceRoots: string[];
|
|
373
|
+
currentManagedRepoRoot: string | null;
|
|
374
|
+
}
|
|
375
|
+
|
|
370
376
|
function splitPathList(value: unknown): string[] {
|
|
371
377
|
if (typeof value === "string") return value.split(":").map((v) => v.trim()).filter(Boolean);
|
|
372
378
|
if (!Array.isArray(value)) return [];
|
|
@@ -418,13 +424,14 @@ function hasnaDivisionRuleFor(target: string, workspaceRoot: string): ProtectedP
|
|
|
418
424
|
return null;
|
|
419
425
|
}
|
|
420
426
|
|
|
421
|
-
async function
|
|
427
|
+
async function protectedPathContextFor(input: CodewithHookInput, cwd: string): Promise<ProtectedPathContext> {
|
|
422
428
|
const home = process.env.HOME || homedir();
|
|
423
429
|
const rules: ProtectedPathRule[] = [
|
|
424
430
|
{ root: join(home, ".hasna"), label: "Hasna state root ~/.hasna", mode: "tree" },
|
|
425
431
|
];
|
|
432
|
+
const workspaceRoots = workspaceRootsFor(input, cwd);
|
|
426
433
|
|
|
427
|
-
for (const root of
|
|
434
|
+
for (const root of workspaceRoots) {
|
|
428
435
|
rules.push({ root, label: "workspace root", mode: "root" });
|
|
429
436
|
}
|
|
430
437
|
|
|
@@ -435,7 +442,17 @@ async function protectedRulesFor(input: CodewithHookInput, cwd: string): Promise
|
|
|
435
442
|
rules.push({ root, label: "active repository or worktree root", mode: "root" });
|
|
436
443
|
}
|
|
437
444
|
|
|
438
|
-
|
|
445
|
+
const worktreesRoot = resolve(defaultWorktreesRoot());
|
|
446
|
+
const isCurrentManagedRepo = repoRoot !== null
|
|
447
|
+
&& isInsidePath(cwd, worktreesRoot)
|
|
448
|
+
&& isInsidePath(repoRoot, worktreesRoot);
|
|
449
|
+
const currentManagedRepoRoot = isCurrentManagedRepo ? resolve(repoRoot) : null;
|
|
450
|
+
|
|
451
|
+
return {
|
|
452
|
+
rules: [...new Map(rules.map((rule) => [resolve(rule.root), { ...rule, root: resolve(rule.root) }])).values()],
|
|
453
|
+
workspaceRoots,
|
|
454
|
+
currentManagedRepoRoot,
|
|
455
|
+
};
|
|
439
456
|
}
|
|
440
457
|
|
|
441
458
|
function threatensProtectedPath(targetPath: string, rule: ProtectedPathRule): boolean {
|
|
@@ -461,37 +478,22 @@ function broadContentWipeBase(targetPath: string): string | null {
|
|
|
461
478
|
return dirname(target);
|
|
462
479
|
}
|
|
463
480
|
|
|
464
|
-
function
|
|
465
|
-
const root = defaultWorktreesRoot();
|
|
466
|
-
if (!isInsidePath(path, root)) return null;
|
|
467
|
-
const rel = relative(resolve(root), resolve(path));
|
|
468
|
-
const parts = rel.split(sep).filter(Boolean);
|
|
469
|
-
if (parts.length < 3) return null;
|
|
470
|
-
const [machine, repoSlugHash, lease] = parts;
|
|
471
|
-
if (!machine || !repoSlugHash || !lease) return null;
|
|
472
|
-
if (!/^[a-zA-Z0-9][a-zA-Z0-9_.-]{1,80}$/.test(machine)) return null;
|
|
473
|
-
if (!/^[a-zA-Z0-9][a-zA-Z0-9_.-]*-[0-9a-fA-F]{7,16}$/.test(repoSlugHash)) return null;
|
|
474
|
-
if (!/^wt_[0-9a-fA-F]{16,64}$/.test(lease)) return null;
|
|
475
|
-
return resolve(root, machine, repoSlugHash, lease);
|
|
476
|
-
}
|
|
477
|
-
|
|
478
|
-
function shouldSkipHasnaTreeRule(targetPath: string, rule: ProtectedPathRule): boolean {
|
|
481
|
+
function shouldSkipHasnaTreeRule(targetPath: string, rule: ProtectedPathRule, currentManagedRepoRoot: string | null): boolean {
|
|
479
482
|
if (rule.label !== "Hasna state root ~/.hasna") return false;
|
|
480
|
-
|
|
481
|
-
if (!leaseRoot) return false;
|
|
483
|
+
if (!currentManagedRepoRoot) return false;
|
|
482
484
|
const target = resolve(targetPath);
|
|
483
|
-
return
|
|
485
|
+
return isInsidePath(target, currentManagedRepoRoot);
|
|
484
486
|
}
|
|
485
487
|
|
|
486
|
-
function threatensRule(targetPath: string, rule: ProtectedPathRule): boolean {
|
|
487
|
-
if (shouldSkipHasnaTreeRule(targetPath, rule)) return false;
|
|
488
|
+
function threatensRule(targetPath: string, rule: ProtectedPathRule, currentManagedRepoRoot: string | null): boolean {
|
|
489
|
+
if (shouldSkipHasnaTreeRule(targetPath, rule, currentManagedRepoRoot)) return false;
|
|
488
490
|
const contentBase = broadContentWipeBase(targetPath);
|
|
489
491
|
if (contentBase && mutatesProtectedPath(contentBase, rule)) return true;
|
|
490
492
|
return threatensProtectedPath(targetPath, rule);
|
|
491
493
|
}
|
|
492
494
|
|
|
493
|
-
function mutatesRule(targetPath: string, rule: ProtectedPathRule): boolean {
|
|
494
|
-
if (shouldSkipHasnaTreeRule(targetPath, rule)) return false;
|
|
495
|
+
function mutatesRule(targetPath: string, rule: ProtectedPathRule, currentManagedRepoRoot: string | null): boolean {
|
|
496
|
+
if (shouldSkipHasnaTreeRule(targetPath, rule, currentManagedRepoRoot)) return false;
|
|
495
497
|
return mutatesProtectedPath(targetPath, rule);
|
|
496
498
|
}
|
|
497
499
|
|
|
@@ -825,8 +827,7 @@ function scopedBlockReason(operation: string, targetPath: string, rule: Protecte
|
|
|
825
827
|
export async function classifyDangerousOperation(input: CodewithHookInput): Promise<DangerousOperationMatch> {
|
|
826
828
|
if (input.hook_event_name !== "PreToolUse") return { block: false };
|
|
827
829
|
const cwd = input.cwd || process.cwd();
|
|
828
|
-
const rules = await
|
|
829
|
-
const workspaceRoots = workspaceRootsFor(input, cwd);
|
|
830
|
+
const { rules, workspaceRoots, currentManagedRepoRoot } = await protectedPathContextFor(input, cwd);
|
|
830
831
|
|
|
831
832
|
if (input.tool_name === "Bash") {
|
|
832
833
|
for (const target of destructiveShellTargets(getCommand(input), cwd)) {
|
|
@@ -834,7 +835,7 @@ export async function classifyDangerousOperation(input: CodewithHookInput): Prom
|
|
|
834
835
|
const extraRule = workspaceRoots.map((root) => hasnaDivisionRuleFor(targetPath, root)).find((rule): rule is ProtectedPathRule => Boolean(rule));
|
|
835
836
|
const allRules = extraRule ? [...rules, extraRule] : rules;
|
|
836
837
|
for (const rule of allRules) {
|
|
837
|
-
if (threatensRule(targetPath, rule)) {
|
|
838
|
+
if (threatensRule(targetPath, rule, currentManagedRepoRoot)) {
|
|
838
839
|
return {
|
|
839
840
|
block: true,
|
|
840
841
|
targetPath,
|
|
@@ -853,7 +854,7 @@ export async function classifyDangerousOperation(input: CodewithHookInput): Prom
|
|
|
853
854
|
const extraRule = workspaceRoots.map((root) => hasnaDivisionRuleFor(targetPath, root)).find((rule): rule is ProtectedPathRule => Boolean(rule));
|
|
854
855
|
const allRules = extraRule ? [...rules, extraRule] : rules;
|
|
855
856
|
for (const rule of allRules) {
|
|
856
|
-
if (mutatesRule(targetPath, rule)) {
|
|
857
|
+
if (mutatesRule(targetPath, rule, currentManagedRepoRoot)) {
|
|
857
858
|
return {
|
|
858
859
|
block: true,
|
|
859
860
|
targetPath,
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
# hook-fleet-blockers-gate
|
|
2
2
|
|
|
3
|
-
A PreToolUse hook that stops an agent from working through a fleet
|
|
3
|
+
A PreToolUse hook that stops an agent from working through a real fleet stop. While an unread **`blocking=1` blocker** is active, mutating tools are denied with a clear reason; read-only tools stay allowed so the agent can read the blocker and react. Part of the Hasna fleet comms workflow (every-turn insurance).
|
|
4
|
+
|
|
5
|
+
To halt the fleet, the owner creates a `blocking=1` blocker (tag it `[FREEZE]` by convention); to lift it, the owner resolves/removes that blocker. Freeze **text** posted to channels is informational and never stops work.
|
|
4
6
|
|
|
5
7
|
## Installation
|
|
6
8
|
|
|
@@ -12,22 +14,32 @@ hooks install fleet-blockers-gate
|
|
|
12
14
|
|
|
13
15
|
On each `PreToolUse` event:
|
|
14
16
|
|
|
15
|
-
1. **Read-only tools pass immediately** — `Read`, `Glob`, `Grep`, `WebFetch`, MCP `get_*`/`list_*`/`search_*`-style operations, etc. A frozen agent must stay able to orient itself.
|
|
16
|
-
2. **Freeze state is TTL-cached** (`~/.hasna/hooks/state/fleet-blockers-gate.json
|
|
17
|
-
3. On cache
|
|
18
|
-
4. If frozen, mutating tools are denied via `permissionDecision: "deny"` with the
|
|
17
|
+
1. **Read-only tools pass immediately** — `Read`, `Glob`, `Grep`, `WebFetch`, MCP `get_*`/`list_*`/`search_*`-style operations, etc. A frozen agent must stay able to orient itself. **Exception:** `conversations` `read_*` tools (`read_messages`, `read_channel`, `read_digest`, `read_thread`, …) are **gated** during a freeze, because they mark messages read by default and would clear the blocker's unread state — self-lifting the stop just by browsing. A frozen agent inspects the blocker with the read-only `mcp__conversations__get_blockers` (and `get_message`/`search_messages`/`list_*`/`get_thread_replies`), none of which mark messages read.
|
|
18
|
+
2. **Freeze state is TTL-cached** (`~/.hasna/hooks/state/fleet-blockers-gate.json`) so the common path never spawns a process per tool call. The TTL is asymmetric — a freeze is held for the full TTL (default 60s) but a "clear" is re-checked quickly (default 5s), so a freshly-issued freeze engages fast while a lift disengages slowly (the safe direction).
|
|
19
|
+
3. On cache miss it runs `conversations blockers -j` once (default 1500ms timeout) and denies if the result contains **any** `blocking=1` blocker. `conversations blockers` (`getUnreadBlockers`) returns every unread, in-scope blocker with no limit window, so the check is order-independent and cannot be truncated.
|
|
20
|
+
4. If frozen, mutating tools are denied via `permissionDecision: "deny"` with the blocker's author and text as **advisory** context; the agent is told the blocker must be resolved/removed to lift the stop.
|
|
21
|
+
|
|
22
|
+
**Freeze text is never scanned.** Denial is driven solely by the `blocking=1` flag — this kills the phantom-freeze bug where any `[FREEZE]` string from any author wedged the fleet, and it stops ignoring real blockers that lacked the `[FREEZE]` text.
|
|
23
|
+
|
|
24
|
+
**Author is not a security gate.** `conversations` does not authenticate `from_agent`, so the author is shown only as advisory context and is never used to allow or deny (gating on a spoofable field would be false assurance).
|
|
25
|
+
|
|
26
|
+
**Fail-open by design:** if the `conversations` CLI is missing, the service is down, or the check times out, tools are allowed — the gate must never wedge an agent. An unverified (comms-failure) "clear" is not cached, so the check retries on the next mutating tool.
|
|
19
27
|
|
|
20
|
-
|
|
28
|
+
## Operating notes & limitations
|
|
21
29
|
|
|
22
|
-
The freeze
|
|
30
|
+
- **Identity and membership are effectively required.** The blockers query is scoped to the agent (`getUnreadBlockers`: `to_agent = me OR channel in my channels`). Set `HOOKS_FLEET_AGENT` to the agent's **real registered identity**, and ensure that identity is a **member of a broadly-subscribed freeze channel**, with the freeze posted using `--blocking`. If the identity is wrong or not subscribed, the blocker is never returned and the brake silently no-ops (fail-open). Wiring this correctly is a deployment responsibility.
|
|
31
|
+
- **Author-agnostic = fail-safe, not tamper-proof.** `conversations` does not authenticate `from_agent`, so the gate does not (and cannot meaningfully) trust the author. Any agent that can set `blocking=1` can therefore cause a stop; recovery is to resolve/remove the blocker or use the kill switch. This is a deliberate fail-safe tradeoff, not an authenticated control.
|
|
32
|
+
- **The deny reason echoes untrusted content.** Up to ~240 characters of the blocker's body are included in the deny message as advisory context. Treat that text as untrusted input (it originates from whoever posted the blocker).
|
|
33
|
+
- **The MCP read-only classification is a prefix heuristic.** Operations whose last `__` segment starts with `get`/`list`/`read`/`search`/… are treated as read-only. A destructively-named op (e.g. a hypothetical `get_and_reset`) could be misclassified as read-only; the `conversations` `read_*` family is explicitly gated as an exception. For high-assurance servers, prefer an explicit per-server allowlist.
|
|
23
34
|
|
|
24
35
|
## Configuration
|
|
25
36
|
|
|
26
37
|
```bash
|
|
27
|
-
export HOOKS_FLEET_GATE_DISABLE=1
|
|
28
|
-
export HOOKS_FLEET_GATE_TTL_MS=60000
|
|
29
|
-
export
|
|
30
|
-
export
|
|
38
|
+
export HOOKS_FLEET_GATE_DISABLE=1 # Kill switch — allow everything
|
|
39
|
+
export HOOKS_FLEET_GATE_TTL_MS=60000 # Frozen-state cache TTL (default 60000)
|
|
40
|
+
export HOOKS_FLEET_GATE_CLEAR_TTL_MS=5000 # Clear-state cache TTL (default 5000)
|
|
41
|
+
export HOOKS_FLEET_TIMEOUT_MS=1500 # Blockers check timeout (default 1500)
|
|
42
|
+
export HOOKS_FLEET_AGENT="chief" # Identity passed as --from to scope the query
|
|
31
43
|
```
|
|
32
44
|
|
|
33
45
|
## Requirements
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hasna/hook-fleet-blockers-gate",
|
|
3
|
-
"version": "0.1.
|
|
4
|
-
"description": "Claude Code PreToolUse hook that blocks mutating tools while an unread
|
|
3
|
+
"version": "0.1.1",
|
|
4
|
+
"description": "Claude Code PreToolUse hook that blocks mutating tools while an unread blocking=1 blocker is active (TTL-cached, fail-open)",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/hook.js",
|
|
7
7
|
"exports": {
|
|
@@ -1,5 +1,16 @@
|
|
|
1
1
|
import { describe, test, expect } from "bun:test";
|
|
2
|
-
import {
|
|
2
|
+
import {
|
|
3
|
+
isReadOnlyTool,
|
|
4
|
+
isSafeArg,
|
|
5
|
+
marksReadState,
|
|
6
|
+
buildDenyReason,
|
|
7
|
+
detectFreeze,
|
|
8
|
+
parseBlockersJson,
|
|
9
|
+
computeFreezeState,
|
|
10
|
+
decide,
|
|
11
|
+
} from "./hook";
|
|
12
|
+
|
|
13
|
+
const NOW = new Date("2026-07-20T00:00:00.000Z");
|
|
3
14
|
|
|
4
15
|
describe("hook-fleet-blockers-gate", () => {
|
|
5
16
|
describe("isReadOnlyTool", () => {
|
|
@@ -43,40 +54,156 @@ describe("hook-fleet-blockers-gate", () => {
|
|
|
43
54
|
expect(isReadOnlyTool("mcp__x__getaway_driver")).toBe(false);
|
|
44
55
|
expect(isReadOnlyTool("mcp__x__listen_events")).toBe(false);
|
|
45
56
|
});
|
|
57
|
+
|
|
58
|
+
// self-lift fix: conversations read_* tools mark messages read, which would
|
|
59
|
+
// clear the blocker's read_at and lift the freeze — so they must be GATED.
|
|
60
|
+
test("conversations read_* tools are GATED (they mark messages read → self-lift)", () => {
|
|
61
|
+
expect(isReadOnlyTool("mcp__conversations__read_messages")).toBe(false);
|
|
62
|
+
expect(isReadOnlyTool("mcp__conversations__read_channel")).toBe(false);
|
|
63
|
+
expect(isReadOnlyTool("mcp__conversations__read_digest")).toBe(false);
|
|
64
|
+
expect(isReadOnlyTool("mcp__conversations__read_thread")).toBe(false);
|
|
65
|
+
expect(isReadOnlyTool("mcp__conversations__read_channel_notifications")).toBe(false);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
test("safe conversations orientation tools stay ALLOWED during a freeze", () => {
|
|
69
|
+
expect(isReadOnlyTool("mcp__conversations__get_blockers")).toBe(true);
|
|
70
|
+
expect(isReadOnlyTool("mcp__conversations__get_message")).toBe(true);
|
|
71
|
+
expect(isReadOnlyTool("mcp__conversations__search_messages")).toBe(true);
|
|
72
|
+
expect(isReadOnlyTool("mcp__conversations__list_channels")).toBe(true);
|
|
73
|
+
expect(isReadOnlyTool("mcp__conversations__get_thread_replies")).toBe(true);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
test("read_* gating is scoped to conversations (other servers' read_ ops unaffected)", () => {
|
|
77
|
+
expect(marksReadState("mcp__conversations__read_messages")).toBe(true);
|
|
78
|
+
expect(marksReadState("mcp__conversations__get_blockers")).toBe(false);
|
|
79
|
+
expect(marksReadState("mcp__files__read_file")).toBe(false);
|
|
80
|
+
// a non-conversations read_ op still passes the generic prefix rule
|
|
81
|
+
expect(isReadOnlyTool("mcp__files__read_file")).toBe(true);
|
|
82
|
+
});
|
|
46
83
|
});
|
|
47
84
|
|
|
48
|
-
describe("
|
|
49
|
-
|
|
85
|
+
describe("buildDenyReason", () => {
|
|
86
|
+
const msg = buildDenyReason("Active blocking=1 blocker from bot: [FREEZE] cutover");
|
|
87
|
+
|
|
88
|
+
test("points at the read-only get_blockers tool", () => {
|
|
89
|
+
expect(msg).toContain("mcp__conversations__get_blockers");
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
test("warns against the self-lifting read_* tools", () => {
|
|
93
|
+
expect(msg).toContain("read_messages");
|
|
94
|
+
expect(msg).toContain("read_channel");
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
test("does not tell the agent to run the gated Bash `conversations blockers` command", () => {
|
|
98
|
+
// The phrase must not appear as a bare command instruction (it is the MCP tool that is referenced).
|
|
99
|
+
expect(msg).not.toContain("conversations blockers");
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
test("carries the underlying blocker reason", () => {
|
|
103
|
+
expect(msg).toContain("Active blocking=1 blocker from bot");
|
|
104
|
+
});
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
describe("isSafeArg (argument-injection guard)", () => {
|
|
108
|
+
test("accepts fleet agent names and email-style ids", () => {
|
|
109
|
+
expect(isSafeArg("lycurgus")).toBe(true);
|
|
110
|
+
expect(isSafeArg("andrei@hasna.com")).toBe(true);
|
|
111
|
+
expect(isSafeArg("agent_07.beta-1")).toBe(true);
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
test("rejects leading dash (flag injection), spaces, shell metachars, empty", () => {
|
|
115
|
+
expect(isSafeArg("--from")).toBe(false);
|
|
116
|
+
expect(isSafeArg("-j")).toBe(false);
|
|
117
|
+
expect(isSafeArg("a b")).toBe(false);
|
|
118
|
+
expect(isSafeArg("a;rm -rf /")).toBe(false);
|
|
119
|
+
expect(isSafeArg("a`whoami`")).toBe(false);
|
|
120
|
+
expect(isSafeArg("$(id)")).toBe(false);
|
|
121
|
+
expect(isSafeArg("")).toBe(false);
|
|
122
|
+
expect(isSafeArg(undefined)).toBe(false);
|
|
123
|
+
});
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
describe("detectFreeze — blocking=1 is the only trigger", () => {
|
|
127
|
+
// blocking=1 -> DENY
|
|
128
|
+
test("a blocking=1 blocker freezes (author- and text-agnostic)", () => {
|
|
50
129
|
const result = detectFreeze([
|
|
51
|
-
{
|
|
130
|
+
{ from_agent: "andrei@hasna.com", content: "[FREEZE] fleet cutover", blocking: true },
|
|
52
131
|
]);
|
|
53
132
|
expect(result.frozen).toBe(true);
|
|
54
|
-
expect(result.reason).toContain("
|
|
55
|
-
expect(result.reason).toContain("
|
|
133
|
+
expect(result.reason).toContain("blocking=1");
|
|
134
|
+
expect(result.reason).toContain("andrei@hasna.com");
|
|
56
135
|
});
|
|
57
136
|
|
|
58
|
-
test("
|
|
59
|
-
const result = detectFreeze([
|
|
137
|
+
test("a blocking=1 blocker with NO freeze text still freezes", () => {
|
|
138
|
+
const result = detectFreeze([
|
|
139
|
+
{ from_agent: "release-bot", content: "migration running, do not deploy", blocking: true },
|
|
140
|
+
]);
|
|
60
141
|
expect(result.frozen).toBe(true);
|
|
142
|
+
expect(result.reason).toContain("release-bot");
|
|
61
143
|
});
|
|
62
144
|
|
|
63
|
-
test("
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
145
|
+
test("a blocking=1 blocker from any author freezes (author is not a security gate)", () => {
|
|
146
|
+
expect(detectFreeze([{ from_agent: "some-agent", content: "hold", blocking: true }]).frozen).toBe(true);
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
// freeze TEXT with no blocking=1 -> ALLOW (phantom-freeze bug stays fixed)
|
|
150
|
+
test("[FREEZE] text without a blocking flag does NOT freeze (phantom-freeze fix)", () => {
|
|
151
|
+
expect(
|
|
152
|
+
detectFreeze([{ from_agent: "andrei@hasna.com", content: "[FREEZE] heads up", blocking: false }]).frozen
|
|
153
|
+
).toBe(false);
|
|
154
|
+
expect(
|
|
155
|
+
detectFreeze([{ from_agent: "some-agent", content: "[FREEZE] cutover", blocking: false }]).frozen
|
|
156
|
+
).toBe(false);
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
test("[UNFREEZE]/severity text is ignored entirely (no text scanning)", () => {
|
|
160
|
+
expect(
|
|
161
|
+
detectFreeze([{ from_agent: "andrei@hasna.com", content: "[UNFREEZE] all clear", blocking: false }]).frozen
|
|
162
|
+
).toBe(false);
|
|
68
163
|
});
|
|
69
164
|
|
|
165
|
+
// no blockers -> ALLOW
|
|
70
166
|
test("empty list does not freeze", () => {
|
|
71
167
|
expect(detectFreeze([]).frozen).toBe(false);
|
|
72
168
|
});
|
|
73
169
|
|
|
170
|
+
test("non-blocking messages do not freeze", () => {
|
|
171
|
+
expect(detectFreeze([{ from_agent: "worker", content: "please review PR #42", blocking: false }]).frozen).toBe(false);
|
|
172
|
+
});
|
|
173
|
+
|
|
74
174
|
test("garbage entries are ignored", () => {
|
|
75
175
|
expect(detectFreeze([null, 42, "string", {}]).frozen).toBe(false);
|
|
76
176
|
});
|
|
77
177
|
|
|
78
|
-
test("
|
|
79
|
-
expect(detectFreeze([{
|
|
178
|
+
test("blocking flag tolerates numeric/string encodings (1, '1', 'true')", () => {
|
|
179
|
+
expect(detectFreeze([{ from_agent: "x", content: "y", blocking: 1 }]).frozen).toBe(true);
|
|
180
|
+
expect(detectFreeze([{ from_agent: "x", content: "y", blocking: "1" }]).frozen).toBe(true);
|
|
181
|
+
expect(detectFreeze([{ from_agent: "x", content: "y", blocking: "true" }]).frozen).toBe(true);
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
test("false-y blocking encodings do not freeze", () => {
|
|
185
|
+
expect(detectFreeze([{ from_agent: "x", content: "y", blocking: 0 }]).frozen).toBe(false);
|
|
186
|
+
expect(detectFreeze([{ from_agent: "x", content: "y", blocking: "false" }]).frozen).toBe(false);
|
|
187
|
+
expect(detectFreeze([{ from_agent: "x", content: "y" }]).frozen).toBe(false);
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
// order-independence: a green suite must not be able to hide a "first item only" bug
|
|
191
|
+
test("a blocking=1 blocker anywhere in the list freezes (order-independent)", () => {
|
|
192
|
+
const result = detectFreeze([
|
|
193
|
+
{ from_agent: "a", content: "note 1", blocking: false },
|
|
194
|
+
{ from_agent: "b", content: "note 2", blocking: false },
|
|
195
|
+
{ from_agent: "c", content: "note 3", blocking: false },
|
|
196
|
+
{ from_agent: "d", content: "the real stop", blocking: true },
|
|
197
|
+
]);
|
|
198
|
+
expect(result.frozen).toBe(true);
|
|
199
|
+
expect(result.reason).toContain("the real stop");
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
test("reads the real from_agent schema field for the advisory reason", () => {
|
|
203
|
+
const result = detectFreeze([
|
|
204
|
+
{ from_agent: "coordinator", to_agent: "worker", channel: "announcements", content: "stop", blocking: true },
|
|
205
|
+
]);
|
|
206
|
+
expect(result.reason).toContain("coordinator");
|
|
80
207
|
});
|
|
81
208
|
});
|
|
82
209
|
|
|
@@ -103,4 +230,73 @@ describe("hook-fleet-blockers-gate", () => {
|
|
|
103
230
|
});
|
|
104
231
|
});
|
|
105
232
|
|
|
233
|
+
describe("computeFreezeState — fail-open + verified", () => {
|
|
234
|
+
// comms error -> ALLOW (fail-open, unverified)
|
|
235
|
+
test("runner error → fail-open (not frozen, unverified)", () => {
|
|
236
|
+
const e = computeFreezeState(NOW, () => {
|
|
237
|
+
throw new Error("conversations CLI missing / timeout");
|
|
238
|
+
});
|
|
239
|
+
expect(e.state.frozen).toBe(false);
|
|
240
|
+
expect(e.verified).toBe(false);
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
// no blockers -> ALLOW (verified)
|
|
244
|
+
test("empty blockers → not frozen, verified", () => {
|
|
245
|
+
const e = computeFreezeState(NOW, () => "[]");
|
|
246
|
+
expect(e.state.frozen).toBe(false);
|
|
247
|
+
expect(e.verified).toBe(true);
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
// blocking=1 -> DENY
|
|
251
|
+
test("blocking=1 in CLI output → frozen, verified", () => {
|
|
252
|
+
const raw = JSON.stringify([{ from_agent: "bot", content: "deploy freeze", blocking: true }]);
|
|
253
|
+
const e = computeFreezeState(NOW, () => raw);
|
|
254
|
+
expect(e.state.frozen).toBe(true);
|
|
255
|
+
expect(e.verified).toBe(true);
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
test("parses the real wrapped shape and stays order-independent", () => {
|
|
259
|
+
const raw = JSON.stringify({
|
|
260
|
+
blockers: [
|
|
261
|
+
{ from_agent: "a", content: "x", blocking: false },
|
|
262
|
+
{ from_agent: "b", content: "stop now", blocking: true },
|
|
263
|
+
],
|
|
264
|
+
});
|
|
265
|
+
const e = computeFreezeState(NOW, () => raw);
|
|
266
|
+
expect(e.state.frozen).toBe(true);
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
test("malformed CLI output → fail-open (not frozen) but verified", () => {
|
|
270
|
+
const e = computeFreezeState(NOW, () => "not-json-at-all");
|
|
271
|
+
expect(e.state.frozen).toBe(false);
|
|
272
|
+
expect(e.verified).toBe(true);
|
|
273
|
+
});
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
describe("decide — permission gate", () => {
|
|
277
|
+
const frozen = { frozen: true, reason: "Active blocking=1 blocker from bot: x" };
|
|
278
|
+
const clear = { frozen: false, reason: "" };
|
|
279
|
+
|
|
280
|
+
// HOOKS_FLEET_GATE_DISABLE=1 -> ALLOW
|
|
281
|
+
test("kill switch (disabled) → allow even under a freeze", () => {
|
|
282
|
+
expect(decide({ disabled: true, toolName: "Bash", freeze: frozen }).allow).toBe(true);
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
// read-only tool under a blocker -> ALLOW
|
|
286
|
+
test("read-only tool under an active freeze → allow", () => {
|
|
287
|
+
expect(decide({ disabled: false, toolName: "Read", freeze: frozen }).allow).toBe(true);
|
|
288
|
+
expect(decide({ disabled: false, toolName: "mcp__conversations__get_blockers", freeze: frozen }).allow).toBe(true);
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
// blocking=1 + mutating -> DENY
|
|
292
|
+
test("mutating tool under an active freeze → deny (with reason)", () => {
|
|
293
|
+
const d = decide({ disabled: false, toolName: "Bash", freeze: frozen });
|
|
294
|
+
expect(d.allow).toBe(false);
|
|
295
|
+
expect(d.reason).toContain("blocking=1");
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
test("mutating tool with no freeze → allow", () => {
|
|
299
|
+
expect(decide({ disabled: false, toolName: "Bash", freeze: clear }).allow).toBe(true);
|
|
300
|
+
});
|
|
301
|
+
});
|
|
106
302
|
});
|
|
@@ -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
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
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
|
|
14
|
-
*
|
|
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
|
|
18
|
-
* - HOOKS_FLEET_GATE_TTL_MS=<n>
|
|
19
|
-
* -
|
|
20
|
-
* -
|
|
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 {
|
|
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
|
|
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
|
-
|
|
101
|
-
|
|
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
|
-
|
|
106
|
-
|
|
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
|
-
/**
|
|
125
|
-
|
|
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
|
-
|
|
130
|
-
|
|
131
|
-
|
|
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: `
|
|
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
|
-
|
|
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
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
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
|
-
|
|
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 —
|
|
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 (
|
|
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