crewhaus 0.1.4 → 0.1.6
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/dist/doctor-checks.d.ts +45 -0
- package/dist/doctor-checks.js +178 -0
- package/{src/egress-matcher.ts → dist/egress-matcher.d.ts} +17 -52
- package/dist/egress-matcher.js +57 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +2362 -0
- package/{src/justification-gate.ts → dist/justification-gate.d.ts} +21 -61
- package/dist/justification-gate.js +72 -0
- package/{src/scope-audit.ts → dist/scope-audit.d.ts} +3 -42
- package/dist/scope-audit.js +85 -0
- package/package.json +71 -68
- package/src/doctor-checks.test.ts +0 -122
- package/src/doctor-checks.ts +0 -220
- package/src/egress-matcher.test.ts +0 -273
- package/src/eval.test.ts +0 -130
- package/src/index.test.ts +0 -819
- package/src/index.ts +0 -2559
- package/src/justification-gate.test.ts +0 -292
- package/src/scope-audit.test.ts +0 -207
|
@@ -1,292 +0,0 @@
|
|
|
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
|
-
});
|
package/src/scope-audit.test.ts
DELETED
|
@@ -1,207 +0,0 @@
|
|
|
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
|
-
});
|