crewhaus 0.1.3

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.
@@ -0,0 +1,292 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { mkdtempSync, rmSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import type { ProviderAdapter } from "@crewhaus/adapter-anthropic";
6
+ import { openAuditLog, verify } from "@crewhaus/audit-log";
7
+ import type { JustificationJudge } from "@crewhaus/permission-engine";
8
+ import { runChatLoop } from "@crewhaus/runtime-core";
9
+ import { buildTool } from "@crewhaus/tool-builder";
10
+ import { z } from "zod";
11
+ import {
12
+ InvalidJudgeChoiceError,
13
+ createJustificationJudge,
14
+ justificationAuditDir,
15
+ openJustificationAuditSink,
16
+ resolveJudgeChoice,
17
+ } from "./justification-gate";
18
+
19
+ // ---------------------------------------------------------------------------
20
+ // Pure judge-choice resolution (flag > spec > rule-based) + invalid handling.
21
+ // ---------------------------------------------------------------------------
22
+
23
+ describe("resolveJudgeChoice", () => {
24
+ test("defaults to rule-based when neither flag nor spec supply a judge", () => {
25
+ expect(resolveJudgeChoice(undefined, undefined)).toBe("rule-based");
26
+ expect(resolveJudgeChoice(undefined, {})).toBe("rule-based");
27
+ });
28
+
29
+ test("the spec's security.justification.judge is used when the flag is absent", () => {
30
+ expect(resolveJudgeChoice(undefined, { judge: "claude" })).toBe("claude");
31
+ });
32
+
33
+ test("the --justification-judge flag overrides the spec", () => {
34
+ // spec says claude, flag says rule-based → flag wins.
35
+ expect(resolveJudgeChoice("rule-based", { judge: "claude" })).toBe("rule-based");
36
+ expect(resolveJudgeChoice("claude", { judge: "rule-based" })).toBe("claude");
37
+ });
38
+
39
+ test("an unrecognised value throws InvalidJudgeChoiceError with the allowed list", () => {
40
+ expect(() => resolveJudgeChoice("gpt-omniscient", undefined)).toThrow(InvalidJudgeChoiceError);
41
+ try {
42
+ resolveJudgeChoice("gpt-omniscient", undefined);
43
+ } catch (err) {
44
+ expect(err).toBeInstanceOf(InvalidJudgeChoiceError);
45
+ expect((err as InvalidJudgeChoiceError).choice).toBe("gpt-omniscient");
46
+ expect((err as InvalidJudgeChoiceError).message).toContain("rule-based, claude");
47
+ }
48
+ });
49
+ });
50
+
51
+ describe("createJustificationJudge", () => {
52
+ test("returns undefined for rule-based (caller falls back to ruleBasedJustificationJudge)", async () => {
53
+ expect(await createJustificationJudge("rule-based", undefined)).toBeUndefined();
54
+ });
55
+
56
+ test("returns a callable JustificationJudge for claude (lazy import resolves)", async () => {
57
+ // Proves the CLI's lazy import of @crewhaus/justification-judge-claude +
58
+ // the anthropic adapter resolves and yields a function. The judge's
59
+ // verdict behaviour (incl. fail-closed) is exhaustively covered in the
60
+ // judge package's own stubbed tests; here we only assert the handoff
61
+ // produces the functional interface runChatLoop consumes. A dummy key
62
+ // satisfies the adapter constructor — it is never called (no turn runs).
63
+ const prev = process.env["ANTHROPIC_API_KEY"];
64
+ process.env["ANTHROPIC_API_KEY"] = "test-no-call";
65
+ try {
66
+ const judge = await createJustificationJudge("claude", "claude-haiku-4-5");
67
+ expect(typeof judge).toBe("function");
68
+ } finally {
69
+ // Assignment (not `delete`) to restore — matches the env-restore pattern
70
+ // used elsewhere (runtime-core/src/index.test.ts) and avoids noDelete.
71
+ process.env["ANTHROPIC_API_KEY"] = prev;
72
+ }
73
+ });
74
+
75
+ test("a non-Anthropic judge model resolves via the model-router and carries the STRIPPED wire model", async () => {
76
+ // `local/<m>@<url>` resolves the real OpenAI-compatible adapter with NO
77
+ // API key required, so this exercises the router path end-to-end. The
78
+ // endpoint is unreachable (port 1) — the judge fails CLOSED, and its
79
+ // verdict's judgeModel must carry the stripped wire id `<model> (error)`,
80
+ // NOT the full prefixed router string the old hardcoded
81
+ // createAnthropicAdapter() path passed verbatim.
82
+ const judge = await createJustificationJudge(
83
+ "claude",
84
+ "local/justification-judge-model@http://127.0.0.1:1/v1",
85
+ );
86
+ expect(typeof judge).toBe("function");
87
+ if (judge === undefined) throw new Error("unreachable");
88
+ const verdict = await judge({
89
+ toolName: "SendMessage",
90
+ justification: "post the summary",
91
+ sessionGoal: "summarize the channel",
92
+ input: { text: "hi" },
93
+ });
94
+ expect(verdict.allow).toBe(false); // fail-closed on transport error
95
+ expect(verdict.judgeModel).toBe("justification-judge-model (error)");
96
+ });
97
+ });
98
+
99
+ describe("openJustificationAuditSink", () => {
100
+ test("returns undefined when disabled", async () => {
101
+ expect(await openJustificationAuditSink({ cwd: "/tmp", enabled: false })).toBeUndefined();
102
+ });
103
+
104
+ test("roots the audit dir at <cwd>/.crewhaus/audit", () => {
105
+ expect(justificationAuditDir("/work")).toBe(join("/work", ".crewhaus", "audit"));
106
+ });
107
+ });
108
+
109
+ // ---------------------------------------------------------------------------
110
+ // End-to-end: the CLI-resolved judge + CLI-opened durable sink actually
111
+ // govern a real gate decision, and the durable, hash-chained
112
+ // `permission_justification_evaluated` record records the judge identity.
113
+ // This closes the CLI->runtime handoff gap (the prior CLI test only proved
114
+ // the lazy import resolved; the judge was never called because stdin closed
115
+ // before any turn).
116
+ // ---------------------------------------------------------------------------
117
+
118
+ /** One-turn stub adapter: emits a `tool_use` for the justification-gated
119
+ * `sendmessage` tool (carrying a justification), then plain text so the
120
+ * single-turn loop terminates. No network — deterministic. */
121
+ function makeGatedToolUseAdapter(justification: string): ProviderAdapter {
122
+ let i = 0;
123
+ return {
124
+ providerId: "anthropic",
125
+ features: {
126
+ caching: "explicit",
127
+ tool_use: true,
128
+ vision: true,
129
+ thinking: true,
130
+ web_search: true,
131
+ },
132
+ estimateTokens: () => 0,
133
+ stream: () => {
134
+ const isFirst = i === 0;
135
+ i += 1;
136
+ return (async function* () {
137
+ yield { kind: "message_start", usage: { input: 10, output: 0 } } as const;
138
+ if (isFirst) {
139
+ yield {
140
+ kind: "content_block_start",
141
+ index: 0,
142
+ block: { type: "tool_use", id: "toolu_cli", name: "sendmessage", input: {} },
143
+ } as const;
144
+ yield {
145
+ kind: "content_block_delta",
146
+ index: 0,
147
+ delta: {
148
+ type: "input_json_delta",
149
+ partial_json: JSON.stringify({ body: "ack", justification }),
150
+ },
151
+ } as const;
152
+ yield { kind: "content_block_stop", index: 0 } as const;
153
+ yield {
154
+ kind: "message_delta",
155
+ stopReason: "tool_use",
156
+ usage: { input: 10, output: 5 },
157
+ } as const;
158
+ } else {
159
+ yield {
160
+ kind: "content_block_start",
161
+ index: 0,
162
+ block: { type: "text", text: "" },
163
+ } as const;
164
+ yield {
165
+ kind: "content_block_delta",
166
+ index: 0,
167
+ delta: { type: "text_delta", text: "done" },
168
+ } as const;
169
+ yield { kind: "content_block_stop", index: 0 } as const;
170
+ yield {
171
+ kind: "message_delta",
172
+ stopReason: "end_turn",
173
+ usage: { input: 10, output: 5 },
174
+ } as const;
175
+ }
176
+ yield { kind: "message_stop" } as const;
177
+ })();
178
+ },
179
+ };
180
+ }
181
+
182
+ describe("justification gate end-to-end (CLI sink + judge -> durable audit)", () => {
183
+ test("a CLI-opened audit sink records a verify()-clean permission_justification_evaluated record carrying the judge identity", async () => {
184
+ const cwd = mkdtempSync(join(tmpdir(), "crewhaus-just-gate-"));
185
+ try {
186
+ // The judge the CLI would resolve — here a stub with a distinctive model
187
+ // id so the assertion on the DURABLE record's judgeModel is exact and
188
+ // deterministic (no live model). The CLI's real claude-judge factory is
189
+ // covered above; the judge package's verdict logic is covered in its own
190
+ // suite. This test owns the handoff: CLI sink + a judge -> a real gate
191
+ // decision -> a durable, hash-chained record.
192
+ const judge: JustificationJudge = async () => ({
193
+ allow: true,
194
+ reason: "justification is consistent with the acknowledgement goal",
195
+ confidence: 0.83,
196
+ judgeModel: "claude-haiku-4-5",
197
+ });
198
+
199
+ // Open the sink exactly the way `crewhaus run` does (rooted at
200
+ // <cwd>/.crewhaus/audit), via the CLI helper under test.
201
+ const sink = await openJustificationAuditSink({ cwd, enabled: true });
202
+ expect(sink).toBeDefined();
203
+
204
+ const sendMessage = buildTool({
205
+ name: "sendmessage",
206
+ description: "send a message (justification-gated)",
207
+ inputSchema: z.object({ body: z.string(), justification: z.string().optional() }),
208
+ requireJustification: true,
209
+ execute: async () => "sent",
210
+ });
211
+
212
+ const JUSTIFICATION = "acknowledge the user's support ticket per the session goal";
213
+ await runChatLoop({
214
+ model: "test-model",
215
+ instructions: "Acknowledge support tickets the user points you at.",
216
+ _adapter: makeGatedToolUseAdapter(JUSTIFICATION),
217
+ singleTurn: true,
218
+ seedMessages: [{ role: "user", content: "ack the ticket" }],
219
+ tools: [sendMessage],
220
+ permissionMode: "bypass",
221
+ justificationJudge: judge,
222
+ // biome-ignore lint/style/noNonNullAssertion: asserted defined above.
223
+ justificationAuditSink: sink!,
224
+ });
225
+
226
+ // The durable, hash-chained chain must be intact.
227
+ const auditDir = justificationAuditDir(cwd);
228
+ const v = await verify(auditDir);
229
+ expect(v.ok).toBe(true);
230
+ expect(v.recordsChecked).toBe(1);
231
+
232
+ // Read the record back and assert the kind, verbatim justification, and
233
+ // judge identity — proving the CLI-resolved judge governed a real gate
234
+ // decision that reached the durable audit log.
235
+ const log = await openAuditLog({ rootDir: auditDir });
236
+ const records = [];
237
+ for await (const r of log.read()) records.push(r);
238
+ expect(records).toHaveLength(1);
239
+ const rec = records[0];
240
+ expect(rec?.kind).toBe("permission_justification_evaluated");
241
+ const payload = rec?.payload as {
242
+ toolName: string;
243
+ justification: string;
244
+ verdict: string;
245
+ judgeModel: string;
246
+ confidence?: number;
247
+ };
248
+ expect(payload.toolName).toBe("sendmessage");
249
+ expect(payload.verdict).toBe("allow");
250
+ expect(payload.judgeModel).toBe("claude-haiku-4-5");
251
+ // Stored VERBATIM (the audit kind's contract), not a `reason` substring.
252
+ expect(payload.justification).toBe(JUSTIFICATION);
253
+ expect(payload.confidence).toBe(0.83);
254
+ } finally {
255
+ rmSync(cwd, { recursive: true, force: true });
256
+ }
257
+ });
258
+
259
+ test("--no-justification-audit (enabled:false) writes no durable record", async () => {
260
+ const cwd = mkdtempSync(join(tmpdir(), "crewhaus-just-gate-off-"));
261
+ try {
262
+ const sink = await openJustificationAuditSink({ cwd, enabled: false });
263
+ expect(sink).toBeUndefined();
264
+
265
+ const sendMessage = buildTool({
266
+ name: "sendmessage",
267
+ description: "send a message (justification-gated)",
268
+ inputSchema: z.object({ body: z.string(), justification: z.string().optional() }),
269
+ requireJustification: true,
270
+ execute: async () => "sent",
271
+ });
272
+
273
+ await runChatLoop({
274
+ model: "test-model",
275
+ instructions: "Acknowledge support tickets the user points you at.",
276
+ _adapter: makeGatedToolUseAdapter("ack the ticket per the goal"),
277
+ singleTurn: true,
278
+ seedMessages: [{ role: "user", content: "ack the ticket" }],
279
+ tools: [sendMessage],
280
+ permissionMode: "bypass",
281
+ // no judge => rule-based default; no sink => no durable record.
282
+ });
283
+
284
+ // Nothing was written under the audit dir (verify on a missing dir is ok/0).
285
+ const v = await verify(justificationAuditDir(cwd));
286
+ expect(v.ok).toBe(true);
287
+ expect(v.recordsChecked).toBe(0);
288
+ } finally {
289
+ rmSync(cwd, { recursive: true, force: true });
290
+ }
291
+ });
292
+ });
@@ -0,0 +1,124 @@
1
+ import { join } from "node:path";
2
+ import type { JustificationJudge } from "@crewhaus/permission-engine";
3
+ import type { JustificationAuditSink } from "@crewhaus/runtime-core";
4
+
5
+ /**
6
+ * FR-004 — Pillar 3 intent-gate wiring for `crewhaus run`, factored out of
7
+ * the entry file `index.ts` (which runs a top-level argv switch and so cannot
8
+ * be imported by a test without executing the CLI). Side-effect-free and
9
+ * directly unit-testable, mirroring `scope-audit.ts`.
10
+ *
11
+ * Two halves of the gate the CLI threads into `runChatLoop`:
12
+ *
13
+ * 1. The JUDGE that scores a justification against the compile-time session
14
+ * goal — `"rule-based"` (the deterministic default; the CLI leaves
15
+ * `justificationJudge` unset so runtime-core falls back to
16
+ * `ruleBasedJustificationJudge`) or `"claude"` (the model-backed
17
+ * `@crewhaus/justification-judge-claude`).
18
+ *
19
+ * 2. The durable AUDIT SINK the gate appends a
20
+ * `permission_justification_evaluated` record to — a real
21
+ * `@crewhaus/audit-log` rooted at `<cwd>/.crewhaus/audit`. This is the
22
+ * hash-chained, tamper-evident artifact the AuditKind was created for;
23
+ * the trace-bus `permission_decision` event is ephemeral.
24
+ *
25
+ * Resolution precedence for the judge:
26
+ * `--justification-judge` flag > spec `security.justification.judge` >
27
+ * `"rule-based"`.
28
+ */
29
+
30
+ export type JudgeChoice = "rule-based" | "claude";
31
+
32
+ const VALID_JUDGE_CHOICES: ReadonlyArray<JudgeChoice> = ["rule-based", "claude"];
33
+
34
+ /** Thrown by `resolveJudgeChoice` on an unrecognised choice. The CLI entry
35
+ * file catches it and routes the message through `die()`; tests assert on
36
+ * `.choice` / `.message` without the process exiting. */
37
+ export class InvalidJudgeChoiceError extends Error {
38
+ override readonly name = "InvalidJudgeChoiceError";
39
+ constructor(readonly choice: string) {
40
+ super(`invalid --justification-judge "${choice}" — allowed: ${VALID_JUDGE_CHOICES.join(", ")}`);
41
+ }
42
+ }
43
+
44
+ function isJudgeChoice(s: string): s is JudgeChoice {
45
+ return (VALID_JUDGE_CHOICES as ReadonlyArray<string>).includes(s);
46
+ }
47
+
48
+ /**
49
+ * Pure resolution of the judge choice from the flag value + spec block.
50
+ * `flagValue` is the raw `--justification-judge` value (or undefined when the
51
+ * flag is absent). Throws `InvalidJudgeChoiceError` for any value outside the
52
+ * allowed set so the caller can `die()` with a friendly message.
53
+ */
54
+ export function resolveJudgeChoice(
55
+ flagValue: string | undefined,
56
+ securityJustification: { judge?: JudgeChoice; model?: string } | undefined,
57
+ ): JudgeChoice {
58
+ const raw = flagValue ?? securityJustification?.judge ?? "rule-based";
59
+ if (!isJudgeChoice(raw)) throw new InvalidJudgeChoiceError(raw);
60
+ return raw;
61
+ }
62
+
63
+ /**
64
+ * Build the `JustificationJudge` for a resolved choice. Returns `undefined`
65
+ * for `"rule-based"` so the caller omits `justificationJudge` from
66
+ * `runChatLoop` (runtime-core then uses `ruleBasedJustificationJudge`, the
67
+ * documented default for tests/offline runs). For `"claude"` it lazily
68
+ * imports the model-router + `@crewhaus/justification-judge-claude` so
69
+ * that model-backed code only loads when actually selected.
70
+ *
71
+ * The judge model is resolved through the model-router, so the spec's
72
+ * `security.justification.model` accepts the full router grammar
73
+ * (claude-*, openai/*, gemini/*, bedrock/*, local/<m>@<url>) — the judge
74
+ * package only needs a `ProviderAdapter`. The wire model is the
75
+ * resolution's *stripped* modelId; the previous hardcoded
76
+ * `createAnthropicAdapter()` + verbatim model string broke every
77
+ * non-Anthropic judge with model-not-found.
78
+ */
79
+ export async function createJustificationJudge(
80
+ choice: JudgeChoice,
81
+ model: string | undefined,
82
+ ): Promise<JustificationJudge | undefined> {
83
+ if (choice === "rule-based") return undefined;
84
+ const { resolveModel } = await import("@crewhaus/model-router");
85
+ const { createClaudeJustificationJudge } = await import("@crewhaus/justification-judge-claude");
86
+ const resolution = await resolveModel(model ?? "claude-haiku-4-5");
87
+ return createClaudeJustificationJudge({
88
+ adapter: resolution.adapter,
89
+ model: resolution.modelId,
90
+ });
91
+ }
92
+
93
+ /** Directory the run path roots the durable justification audit log at. */
94
+ export function justificationAuditDir(cwd: string): string {
95
+ return join(cwd, ".crewhaus", "audit");
96
+ }
97
+
98
+ export type OpenJustificationAuditSinkOptions = {
99
+ /** Working directory; the sink is rooted at `<cwd>/.crewhaus/audit`. */
100
+ readonly cwd: string;
101
+ /**
102
+ * When false, no durable sink is opened (the gate keeps only its ephemeral
103
+ * trace-bus event). Defaults to true: `crewhaus run` writes the durable,
104
+ * hash-chained `permission_justification_evaluated` record by default so
105
+ * the intent gate's decisions are tamper-evident. The `--no-justification-audit`
106
+ * flag flips this off for ephemeral/offline runs.
107
+ */
108
+ readonly enabled?: boolean;
109
+ };
110
+
111
+ /**
112
+ * Open the durable `JustificationAuditSink` the gate appends to. Returns
113
+ * `undefined` when disabled (so the caller spreads nothing into
114
+ * `runChatLoop` and the durable path is a no-op). The returned object is a
115
+ * real `@crewhaus/audit-log` `AuditLog`, which structurally satisfies the
116
+ * `JustificationAuditSink` seam (its `append({ kind, payload })`).
117
+ */
118
+ export async function openJustificationAuditSink(
119
+ opts: OpenJustificationAuditSinkOptions,
120
+ ): Promise<JustificationAuditSink | undefined> {
121
+ if (opts.enabled === false) return undefined;
122
+ const { openAuditLog } = await import("@crewhaus/audit-log");
123
+ return await openAuditLog({ rootDir: justificationAuditDir(opts.cwd) });
124
+ }
@@ -0,0 +1,207 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { OUTWARD_TOOL_NAMES } from "@crewhaus/tool-builder";
3
+ import type { RegisteredTool, ToolIoCapability } from "@crewhaus/tool-catalog";
4
+ import { fetch as fetchTool } from "@crewhaus/tool-fetch";
5
+ import { imageGenerate } from "@crewhaus/tool-image-generation";
6
+ import { webFetch, webSearch } from "@crewhaus/tool-web";
7
+ import { auditSpecToolNames, auditToolScopes, collectToolNames } from "./scope-audit";
8
+
9
+ // auditToolScopes reads `.name`, `.scope`, and `.ioCapability`; construct
10
+ // minimal doubles so the test can express the exact triples under audit —
11
+ // including the dangerous "outward name forced internal" case that no
12
+ // built-in fixture can produce (every built-in outward tool is external).
13
+ function mkTool(
14
+ name: string,
15
+ scope: "internal" | "external",
16
+ ioCapability?: ToolIoCapability,
17
+ ): RegisteredTool {
18
+ return { name, scope, ...(ioCapability ? { ioCapability } : {}) } as unknown as RegisteredTool;
19
+ }
20
+
21
+ describe("auditToolScopes — FR-002 strict scope gate", () => {
22
+ test("flags an outward-named tool left at scope:'internal' (the red path)", () => {
23
+ const findings = auditToolScopes([mkTool("Fetch", "internal")]);
24
+ expect(findings).toHaveLength(1);
25
+ expect(findings[0]?.toolName).toBe("Fetch");
26
+ expect(findings[0]?.reason).toContain('scope is "internal"');
27
+ expect(findings[0]?.reason).toContain('expected "external"');
28
+ });
29
+
30
+ test("flags a namespaced MCP tool forced internal (prefix rule)", () => {
31
+ const findings = auditToolScopes([mkTool("mcp__slack__send", "internal")]);
32
+ expect(findings).toHaveLength(1);
33
+ expect(findings[0]?.toolName).toBe("mcp__slack__send");
34
+ });
35
+
36
+ test("does NOT flag an outward tool that is correctly external", () => {
37
+ expect(auditToolScopes([mkTool("Fetch", "external")])).toHaveLength(0);
38
+ });
39
+
40
+ test("does NOT flag a pure-compute internal tool", () => {
41
+ expect(auditToolScopes([mkTool("read", "internal")])).toHaveLength(0);
42
+ });
43
+
44
+ test("reports every mis-scoped tool, leaving correct ones unflagged", () => {
45
+ const findings = auditToolScopes([
46
+ mkTool("Fetch", "internal"), // flagged
47
+ mkTool("WebSearch", "external"), // ok
48
+ mkTool("SendMessage", "internal"), // flagged
49
+ mkTool("read", "internal"), // ok (internal compute)
50
+ ]);
51
+ expect(findings.map((f) => f.toolName).sort()).toEqual(["Fetch", "SendMessage"]);
52
+ });
53
+
54
+ test("an empty tool set produces no findings", () => {
55
+ expect(auditToolScopes([])).toHaveLength(0);
56
+ });
57
+
58
+ // FR-002 mechanism-2 residual: the capability-driven path. These are the
59
+ // cases the prior name-only audit could NOT reach — a custom buildTool tool
60
+ // with a NOVEL name that opens a socket / spawns a process. Now that the
61
+ // tool declares `ioCapability`, the audit binds scope:"external" to it.
62
+ test("flags a NOVEL-named custom tool that declares ioCapability:'network' but is internal", () => {
63
+ const findings = auditToolScopes([mkTool("SomeCustomSocketTool", "internal", "network")]);
64
+ expect(findings).toHaveLength(1);
65
+ expect(findings[0]?.toolName).toBe("SomeCustomSocketTool");
66
+ expect(findings[0]?.reason).toContain('ioCapability "network"');
67
+ expect(findings[0]?.reason).toContain('scope is "internal"');
68
+ });
69
+
70
+ test("flags a NOVEL-named custom tool that declares ioCapability:'process' but is internal", () => {
71
+ const findings = auditToolScopes([mkTool("RunDaemon", "internal", "process")]);
72
+ expect(findings).toHaveLength(1);
73
+ expect(findings[0]?.reason).toContain('ioCapability "process"');
74
+ });
75
+
76
+ test("does NOT flag an io-capable custom tool that correctly set scope:'external'", () => {
77
+ expect(auditToolScopes([mkTool("SomeCustomSocketTool", "external", "network")])).toHaveLength(
78
+ 0,
79
+ );
80
+ });
81
+
82
+ test("the capability path fires even for a name the outward-name heuristic would miss", () => {
83
+ // Proves the gate is no longer a no-op for user-authored tools: the name
84
+ // is not in OUTWARD_TOOL_NAMES and is not mcp__-prefixed, yet it is flagged.
85
+ const findings = auditToolScopes([mkTool("totally_internal_looking", "internal", "network")]);
86
+ expect(findings).toHaveLength(1);
87
+ });
88
+
89
+ test("a tool with NEITHER capability nor outward name is not flagged (documented residual)", () => {
90
+ // The irreducible limit of a static annotation check: a tool that touches
91
+ // the network but declares neither its capability nor an outward name is
92
+ // invisible to the audit. Pinned so the boundary is explicit, not implied.
93
+ expect(auditToolScopes([mkTool("opaque", "internal")])).toHaveLength(0);
94
+ });
95
+ });
96
+
97
+ describe("auditSpecToolNames — FR-002 compile --strict spec-level gate", () => {
98
+ // Resolver doubles standing in for the offline built-in tool map.
99
+ const resolveNone = (_name: string): RegisteredTool | undefined => undefined;
100
+
101
+ test("flags an unresolved mcp__ sink referenced by a spec (the live red path)", () => {
102
+ // This is the exact case the adversarial review proved exited 0: a spec
103
+ // referencing an MCP tool the offline map cannot resolve. --strict now
104
+ // refuses it because the sink's external scope is unverifiable offline.
105
+ const findings = auditSpecToolNames(["mcp__evil__exfiltrate"], resolveNone);
106
+ expect(findings).toHaveLength(1);
107
+ expect(findings[0]?.toolName).toBe("mcp__evil__exfiltrate");
108
+ expect(findings[0]?.reason).toContain("unverifiable offline");
109
+ });
110
+
111
+ test("flags an unresolved outward-by-name built-in (e.g. SendMessage) referenced in a spec", () => {
112
+ const findings = auditSpecToolNames(["SendMessage"], resolveNone);
113
+ expect(findings).toHaveLength(1);
114
+ expect(findings[0]?.toolName).toBe("SendMessage");
115
+ });
116
+
117
+ test("does NOT flag an unknown NON-outward custom name (offline gate has nothing to assert)", () => {
118
+ // Documented boundary: a name the offline map doesn't know and whose name
119
+ // carries no outward signal is left to the live doctor audit / runtime.
120
+ expect(auditSpecToolNames(["SomeCustomSocketTool"], resolveNone)).toHaveLength(0);
121
+ expect(auditSpecToolNames(["read", "bash"], resolveNone)).toHaveLength(0);
122
+ });
123
+
124
+ test("defers to the per-tool audit for RESOLVED names (external built-in passes)", () => {
125
+ const resolve = (name: string) =>
126
+ name === "fetch" ? mkTool("Fetch", "external", "network") : undefined;
127
+ expect(auditSpecToolNames(["fetch"], resolve)).toHaveLength(0);
128
+ });
129
+
130
+ test("flags a resolved built-in that an override forced back to internal", () => {
131
+ const resolve = (name: string) =>
132
+ name === "fetch" ? mkTool("Fetch", "internal", "network") : undefined;
133
+ const findings = auditSpecToolNames(["fetch"], resolve);
134
+ expect(findings).toHaveLength(1);
135
+ expect(findings[0]?.toolName).toBe("Fetch");
136
+ });
137
+
138
+ test("mixes resolved-clean, resolved-dirty, and unresolved-outward names correctly", () => {
139
+ const resolve = (name: string): RegisteredTool | undefined => {
140
+ if (name === "fetch") return mkTool("Fetch", "external", "network"); // clean
141
+ if (name === "webFetch") return mkTool("WebFetch", "internal", "network"); // dirty
142
+ if (name === "read") return mkTool("read", "internal"); // clean internal
143
+ return undefined; // mcp__x__y → unresolved outward
144
+ };
145
+ const findings = auditSpecToolNames(["fetch", "webFetch", "read", "mcp__x__y"], resolve);
146
+ expect(findings.map((f) => f.toolName).sort()).toEqual(["WebFetch", "mcp__x__y"]);
147
+ });
148
+ });
149
+
150
+ describe("collectToolNames — variant-agnostic IR tool extraction", () => {
151
+ test("collects top-level ir.tools", () => {
152
+ const ir = { target: "cli", tools: ["fetch", "read"] };
153
+ expect(collectToolNames(ir).sort()).toEqual(["fetch", "read"]);
154
+ });
155
+
156
+ test("collects nested tools (steps / sub-agents) and dedups", () => {
157
+ const ir = {
158
+ target: "workflow",
159
+ tools: ["fetch"],
160
+ steps: [{ tools: ["read", "fetch"] }, { tools: ["bash"] }],
161
+ subAgents: [{ tools: ["webSearch"] }],
162
+ };
163
+ expect(collectToolNames(ir).sort()).toEqual(["bash", "fetch", "read", "webSearch"]);
164
+ });
165
+
166
+ test("ignores non-string entries and non-tools keys", () => {
167
+ const ir = { tools: ["fetch", 42, null], notTools: ["ignored"] };
168
+ expect(collectToolNames(ir)).toEqual(["fetch"]);
169
+ });
170
+
171
+ test("returns empty for a toolless IR", () => {
172
+ expect(collectToolNames({ target: "cli", agent: {} })).toEqual([]);
173
+ });
174
+ });
175
+
176
+ // FR-002 minor gap #3 — pin the loadToolMap key ↔ RegisteredTool.name ↔
177
+ // scope ↔ ioCapability alignment for the offline-resolvable outward built-ins.
178
+ // collectToolNames yields camelCase KEYS (fetch/webFetch/webSearch/
179
+ // imageGenerate); the audit keys on the PascalCase .name PROPERTY. The strict
180
+ // resolve path only lines up because loadToolMap resolves key→RegisteredTool
181
+ // first. This test asserts the source-of-truth: each outward built-in's .name
182
+ // is in OUTWARD_TOOL_NAMES AND it ships scope:"external" AND it declares
183
+ // ioCapability — so a future refactor that breaks any leg fails here rather
184
+ // than silently making the strict audit match nothing.
185
+ describe("FR-002 invariant — outward built-ins are correctly self-describing", () => {
186
+ // Imported from the real packages (the same modules loadToolMap() imports),
187
+ // keyed by the camelCase loadToolMap key they are registered under.
188
+ const outwardBuiltins: Record<string, RegisteredTool> = {
189
+ fetch: fetchTool,
190
+ webFetch,
191
+ webSearch,
192
+ imageGenerate,
193
+ };
194
+
195
+ test.each(Object.entries(outwardBuiltins))(
196
+ "loadToolMap key %s resolves to an external, io-capable, outward-named tool",
197
+ (_key, tool) => {
198
+ expect(OUTWARD_TOOL_NAMES.has(tool.name)).toBe(true);
199
+ expect(tool.scope).toBe("external");
200
+ expect(tool.ioCapability).toBe("network");
201
+ },
202
+ );
203
+
204
+ test("each resolved outward built-in passes auditToolScopes (no finding)", () => {
205
+ expect(auditToolScopes(Object.values(outwardBuiltins))).toHaveLength(0);
206
+ });
207
+ });