@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.
- package/README.md +22 -0
- package/bin/index.js +628 -151
- package/hooks/codewith-native-common.ts +155 -3
- 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
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "fs";
|
|
2
|
-
import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "path";
|
|
1
|
+
import { existsSync, lstatSync, mkdirSync, readFileSync, realpathSync, writeFileSync } from "fs";
|
|
2
|
+
import { basename, dirname, isAbsolute, join, parse, relative, resolve, sep } from "path";
|
|
3
3
|
import { homedir, tmpdir } from "os";
|
|
4
4
|
|
|
5
5
|
export interface CodewithHookInput {
|
|
@@ -485,6 +485,148 @@ function shouldSkipHasnaTreeRule(targetPath: string, rule: ProtectedPathRule, cu
|
|
|
485
485
|
return isInsidePath(target, currentManagedRepoRoot);
|
|
486
486
|
}
|
|
487
487
|
|
|
488
|
+
function isMissingPathError(error: unknown): boolean {
|
|
489
|
+
return error instanceof Error && "code" in error && (error as NodeJS.ErrnoException).code === "ENOENT";
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
function hasUnsafeTargetComponent(worktreesRoot: string, target: string): boolean {
|
|
493
|
+
const relativeTarget = relative(worktreesRoot, target);
|
|
494
|
+
const parts = relativeTarget.split(sep).filter(Boolean);
|
|
495
|
+
if (parts.some((part) => part.toLowerCase() === ".git")) return true;
|
|
496
|
+
|
|
497
|
+
const filesystemRoot = parse(target).root;
|
|
498
|
+
const absoluteParts = relative(filesystemRoot, target).split(sep).filter(Boolean);
|
|
499
|
+
let probe = filesystemRoot;
|
|
500
|
+
try {
|
|
501
|
+
if (lstatSync(probe).isSymbolicLink()) return true;
|
|
502
|
+
} catch {
|
|
503
|
+
return true;
|
|
504
|
+
}
|
|
505
|
+
for (const part of absoluteParts) {
|
|
506
|
+
probe = join(probe, part);
|
|
507
|
+
try {
|
|
508
|
+
const metadata = lstatSync(probe);
|
|
509
|
+
if (metadata.isSymbolicLink()) return true;
|
|
510
|
+
if (probe === target && metadata.isFile() && metadata.nlink > 1) return true;
|
|
511
|
+
} catch (error) {
|
|
512
|
+
if (isMissingPathError(error)) return false;
|
|
513
|
+
return true;
|
|
514
|
+
}
|
|
515
|
+
}
|
|
516
|
+
return false;
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
function managedLeaseRoot(worktreesRoot: string, target: string): string | null {
|
|
520
|
+
const relativeTarget = relative(worktreesRoot, target);
|
|
521
|
+
const parts = relativeTarget.split(sep).filter(Boolean);
|
|
522
|
+
if (parts.length < 3) return null;
|
|
523
|
+
const leaseRoot = resolve(worktreesRoot, ...parts.slice(0, 3));
|
|
524
|
+
return managedWorktreeInfo(leaseRoot).managed ? leaseRoot : null;
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
async function verifiedLinkedWorktreeRoot(leaseRoot: string): Promise<string | null> {
|
|
528
|
+
const controlFile = join(leaseRoot, ".git");
|
|
529
|
+
try {
|
|
530
|
+
const metadata = lstatSync(controlFile);
|
|
531
|
+
if (!metadata.isFile() || metadata.isSymbolicLink() || metadata.nlink !== 1) return null;
|
|
532
|
+
} catch {
|
|
533
|
+
return null;
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
if (!commandExists("git")) return null;
|
|
537
|
+
const result = await runCommand([
|
|
538
|
+
"git",
|
|
539
|
+
"rev-parse",
|
|
540
|
+
"--show-toplevel",
|
|
541
|
+
"--absolute-git-dir",
|
|
542
|
+
"--git-common-dir",
|
|
543
|
+
], { cwd: leaseRoot, timeoutMs: 2000 });
|
|
544
|
+
if (result.exitCode !== 0) return null;
|
|
545
|
+
const [repoRootRaw, gitDirRaw, commonDirRaw] = result.stdout.trim().split(/\r?\n/);
|
|
546
|
+
if (!repoRootRaw || !gitDirRaw || !commonDirRaw) return null;
|
|
547
|
+
|
|
548
|
+
const repoRoot = resolve(repoRootRaw);
|
|
549
|
+
const gitDir = resolveFrom(leaseRoot, gitDirRaw);
|
|
550
|
+
const commonDir = resolveFrom(leaseRoot, commonDirRaw);
|
|
551
|
+
if (repoRoot !== resolve(leaseRoot)) return null;
|
|
552
|
+
try {
|
|
553
|
+
const physicalGitDir = realpathSync(gitDir);
|
|
554
|
+
const physicalCommonDir = realpathSync(commonDir);
|
|
555
|
+
const physicalWorktreesDir = realpathSync(join(commonDir, "worktrees"));
|
|
556
|
+
if (physicalGitDir === physicalWorktreesDir || !isInsidePath(physicalGitDir, physicalWorktreesDir)) return null;
|
|
557
|
+
if (dirname(physicalWorktreesDir) !== physicalCommonDir) return null;
|
|
558
|
+
|
|
559
|
+
const commondirPointer = readFileSync(join(gitDir, "commondir"), "utf-8").trim();
|
|
560
|
+
const gitdirPointer = readFileSync(join(gitDir, "gitdir"), "utf-8").trim();
|
|
561
|
+
if (!commondirPointer || !gitdirPointer) return null;
|
|
562
|
+
if (realpathSync(resolveFrom(gitDir, commondirPointer)) !== physicalCommonDir) return null;
|
|
563
|
+
const expectedControlFile = resolve(controlFile);
|
|
564
|
+
const backPointer = resolveFrom(gitDir, gitdirPointer);
|
|
565
|
+
if (backPointer !== expectedControlFile) return null;
|
|
566
|
+
if (realpathSync(backPointer) !== realpathSync(expectedControlFile)) return null;
|
|
567
|
+
} catch {
|
|
568
|
+
return null;
|
|
569
|
+
}
|
|
570
|
+
return repoRoot;
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
async function managedRepoRootForAbsoluteTarget(
|
|
574
|
+
targetPath: string,
|
|
575
|
+
repoRootCache: Map<string, Promise<string | null>>,
|
|
576
|
+
): Promise<string | null> {
|
|
577
|
+
if (!isAbsolute(targetPath)) return null;
|
|
578
|
+
const worktreesRoot = resolve(defaultWorktreesRoot());
|
|
579
|
+
const target = resolve(targetPath);
|
|
580
|
+
if (target === worktreesRoot || !isInsidePath(target, worktreesRoot)) return null;
|
|
581
|
+
if (hasUnsafeTargetComponent(worktreesRoot, target)) return null;
|
|
582
|
+
|
|
583
|
+
let physicalWorktreesRoot: string;
|
|
584
|
+
try {
|
|
585
|
+
physicalWorktreesRoot = realpathSync(worktreesRoot);
|
|
586
|
+
} catch {
|
|
587
|
+
return null;
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
const leaseRoot = managedLeaseRoot(worktreesRoot, target);
|
|
591
|
+
if (!leaseRoot) return null;
|
|
592
|
+
let repoRootPromise = repoRootCache.get(leaseRoot);
|
|
593
|
+
if (!repoRootPromise) {
|
|
594
|
+
repoRootPromise = verifiedLinkedWorktreeRoot(leaseRoot);
|
|
595
|
+
repoRootCache.set(leaseRoot, repoRootPromise);
|
|
596
|
+
}
|
|
597
|
+
const repoRoot = await repoRootPromise;
|
|
598
|
+
if (!repoRoot) return null;
|
|
599
|
+
const resolvedRepoRoot = resolve(repoRoot);
|
|
600
|
+
if (resolvedRepoRoot !== resolve(leaseRoot)) return null;
|
|
601
|
+
try {
|
|
602
|
+
const physicalRepoRoot = realpathSync(resolvedRepoRoot);
|
|
603
|
+
if (physicalRepoRoot === physicalWorktreesRoot || !isInsidePath(physicalRepoRoot, physicalWorktreesRoot)) return null;
|
|
604
|
+
const probe = dirname(target);
|
|
605
|
+
let existingProbe = probe;
|
|
606
|
+
while (true) {
|
|
607
|
+
try {
|
|
608
|
+
lstatSync(existingProbe);
|
|
609
|
+
break;
|
|
610
|
+
} catch (error) {
|
|
611
|
+
if (!isMissingPathError(error)) return null;
|
|
612
|
+
}
|
|
613
|
+
const parent = dirname(existingProbe);
|
|
614
|
+
if (parent === existingProbe || !isInsidePath(parent, resolvedRepoRoot)) return null;
|
|
615
|
+
existingProbe = parent;
|
|
616
|
+
}
|
|
617
|
+
const physicalProbe = realpathSync(existingProbe);
|
|
618
|
+
const missingSuffix = relative(existingProbe, target);
|
|
619
|
+
if (!missingSuffix || missingSuffix === ".." || missingSuffix.startsWith(`..${sep}`) || isAbsolute(missingSuffix)) return null;
|
|
620
|
+
const physicalTarget = existsSync(target)
|
|
621
|
+
? realpathSync(target)
|
|
622
|
+
: resolve(physicalProbe, missingSuffix);
|
|
623
|
+
if (physicalTarget === physicalRepoRoot || !isInsidePath(physicalTarget, physicalRepoRoot)) return null;
|
|
624
|
+
} catch {
|
|
625
|
+
return null;
|
|
626
|
+
}
|
|
627
|
+
return resolvedRepoRoot;
|
|
628
|
+
}
|
|
629
|
+
|
|
488
630
|
function threatensRule(targetPath: string, rule: ProtectedPathRule, currentManagedRepoRoot: string | null): boolean {
|
|
489
631
|
if (shouldSkipHasnaTreeRule(targetPath, rule, currentManagedRepoRoot)) return false;
|
|
490
632
|
const contentBase = broadContentWipeBase(targetPath);
|
|
@@ -849,12 +991,22 @@ export async function classifyDangerousOperation(input: CodewithHookInput): Prom
|
|
|
849
991
|
}
|
|
850
992
|
}
|
|
851
993
|
|
|
994
|
+
const managedRepoRootCache = new Map<string, Promise<string | null>>();
|
|
995
|
+
const worktreesRoot = resolve(defaultWorktreesRoot());
|
|
852
996
|
for (const candidate of extractFileToolPaths(input)) {
|
|
853
997
|
const targetPath = resolveFrom(cwd, candidate.path);
|
|
998
|
+
const hasUnsafeManagedComponent = isInsidePath(targetPath, worktreesRoot)
|
|
999
|
+
&& hasUnsafeTargetComponent(worktreesRoot, targetPath);
|
|
1000
|
+
const targetManagedRepoRoot = hasUnsafeManagedComponent
|
|
1001
|
+
? null
|
|
1002
|
+
: await managedRepoRootForAbsoluteTarget(targetPath, managedRepoRootCache);
|
|
1003
|
+
const exemptManagedRepoRoot = hasUnsafeManagedComponent
|
|
1004
|
+
? null
|
|
1005
|
+
: targetManagedRepoRoot;
|
|
854
1006
|
const extraRule = workspaceRoots.map((root) => hasnaDivisionRuleFor(targetPath, root)).find((rule): rule is ProtectedPathRule => Boolean(rule));
|
|
855
1007
|
const allRules = extraRule ? [...rules, extraRule] : rules;
|
|
856
1008
|
for (const rule of allRules) {
|
|
857
|
-
if (mutatesRule(targetPath, rule,
|
|
1009
|
+
if (mutatesRule(targetPath, rule, exemptManagedRepoRoot)) {
|
|
858
1010
|
return {
|
|
859
1011
|
block: true,
|
|
860
1012
|
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
|
});
|