@sleep2agi/commhub-server 0.9.0-preview.0 → 0.9.0-preview.12
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/package.json +3 -2
- package/src/ack-create-request.test.ts +331 -0
- package/src/api-nodes-shape.test.ts +172 -0
- package/src/auth-kdf-migration.test.ts +378 -0
- package/src/auth-tokens.test.ts +18 -9
- package/src/auth.ts +65 -5
- package/src/config-apply-sec1.test.ts +723 -0
- package/src/config-apply-validate.test.ts +260 -0
- package/src/config-apply-validate.ts +171 -0
- package/src/create-node-validate.test.ts +187 -0
- package/src/create-node-validate.ts +206 -0
- package/src/create-node.test.ts +354 -0
- package/src/create-node.ts +346 -0
- package/src/cross-tenant-injection.test.ts +110 -0
- package/src/db.ts +499 -8
- package/src/index.ts +247 -19
- package/src/lifecycle-guard.ts +50 -0
- package/src/list-host-supervisors.test.ts +314 -0
- package/src/probe-validate.test.ts +221 -0
- package/src/probe-validate.ts +104 -0
- package/src/probe.test.ts +80 -0
- package/src/probe.ts +200 -0
- package/src/retention.test.ts +450 -0
- package/src/retention.ts +269 -0
- package/src/shared/probe-host-allowlist-drift.test.ts +54 -0
- package/src/shared/probe-host-allowlist.ts +128 -0
- package/src/shared/reserved-env-drift.test.ts +43 -0
- package/src/shared/reserved-env.test.ts +35 -0
- package/src/shared/reserved-env.ts +31 -0
- package/src/stale-sweeper.test.ts +181 -0
- package/src/stale-sweeper.ts +114 -0
- package/src/stop-delete-node.test.ts +720 -0
- package/src/tools.ts +2285 -64
- package/src/update-provider.test.ts +234 -0
- package/src/vault.test.ts +200 -0
- package/src/vault.ts +156 -0
|
@@ -0,0 +1,314 @@
|
|
|
1
|
+
import { afterAll, beforeEach, describe, expect, test } from "bun:test";
|
|
2
|
+
import { db, hashToken, generateId } from "./db.js";
|
|
3
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
4
|
+
import { registerTools } from "./tools.js";
|
|
5
|
+
|
|
6
|
+
// RFC-026 §9.2 / #338 PR2 v2 — list_host_supervisors HANDLER tests.
|
|
7
|
+
//
|
|
8
|
+
// PR2 v1 used a mirror-SQL pattern (re-implemented the query in the test
|
|
9
|
+
// itself) → trivially passed but didn't exercise the handler. 通信龙 audit
|
|
10
|
+
// caught: that style misses the SEC-1 scope-derivation bug
|
|
11
|
+
// (`getNetworkId(clientNetId)` returns the unchecked client-supplied
|
|
12
|
+
// network_id for utok_ callers when enforceNetworkId is null, leaking
|
|
13
|
+
// cross-tenant daemons). This rewrite drives the registered MCP tool
|
|
14
|
+
// through registerTools() so the auth context is real.
|
|
15
|
+
//
|
|
16
|
+
// Covers:
|
|
17
|
+
// SEC-1.1 — cross-tenant utok_ caller with foreign network_id: DENIED
|
|
18
|
+
// SEC-1.2 — admin caller with own network_id: returns own daemons
|
|
19
|
+
// SEC-1.3 — caller with no network_id: returns daemons in all member networks
|
|
20
|
+
// masking — non-admin viewer: host_telemetry IP/cpu/mem stripped, alert_level still surfaces
|
|
21
|
+
// revoked — DELETEd token row (revokeToken path): daemon excluded
|
|
22
|
+
// revoked — revoked_at SET token: daemon excluded
|
|
23
|
+
// self-declare — runtimes_supported/allowed_secret_keys surface from columns
|
|
24
|
+
// pre-PR2 daemon — empty arrays on NULL columns (back-compat)
|
|
25
|
+
|
|
26
|
+
const NET_ALPHA = "net_lhs_alpha";
|
|
27
|
+
const NET_BETA = "net_lhs_beta";
|
|
28
|
+
|
|
29
|
+
interface ToolHandler { (args: any, extra?: any): Promise<{ content: Array<{ type: "text"; text: string }> }>; }
|
|
30
|
+
interface Reply { ok?: boolean; error?: string; daemons?: any[]; count?: number; }
|
|
31
|
+
|
|
32
|
+
function cleanup() {
|
|
33
|
+
for (const n of [NET_ALPHA, NET_BETA]) {
|
|
34
|
+
try { db.run("DELETE FROM nodes WHERE network_id = ?1", [n]); } catch {}
|
|
35
|
+
try { db.run("DELETE FROM sessions WHERE network_id = ?1", [n]); } catch {}
|
|
36
|
+
try { db.run("DELETE FROM api_tokens WHERE network_id = ?1", [n]); } catch {}
|
|
37
|
+
try { db.run("DELETE FROM network_members WHERE network_id = ?1", [n]); } catch {}
|
|
38
|
+
try { db.run("DELETE FROM networks WHERE network_id = ?1", [n]); } catch {}
|
|
39
|
+
}
|
|
40
|
+
try { db.run("DELETE FROM users WHERE user_id LIKE 'u_lhs_%'"); } catch {}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
beforeEach(cleanup);
|
|
44
|
+
afterAll(cleanup);
|
|
45
|
+
|
|
46
|
+
function seedUser(user_id: string) {
|
|
47
|
+
db.run(
|
|
48
|
+
`INSERT INTO users (user_id, username, password_hash, role, created_at)
|
|
49
|
+
VALUES (?1, ?2, 'x', 'user', datetime('now'))`,
|
|
50
|
+
[user_id, user_id],
|
|
51
|
+
);
|
|
52
|
+
}
|
|
53
|
+
function seedNetwork(net_id: string, owner_id = "u_lhs_owner") {
|
|
54
|
+
db.run(
|
|
55
|
+
`INSERT OR REPLACE INTO networks (network_id, network_name, owner_id, created_at)
|
|
56
|
+
VALUES (?1, ?2, ?3, datetime('now'))`,
|
|
57
|
+
[net_id, net_id, owner_id],
|
|
58
|
+
);
|
|
59
|
+
}
|
|
60
|
+
function seedMembership(user_id: string, net_id: string, role: "owner" | "admin" | "member" | "viewer" = "member") {
|
|
61
|
+
db.run(
|
|
62
|
+
`INSERT INTO network_members (user_id, network_id, role, joined_at)
|
|
63
|
+
VALUES (?1, ?2, ?3, datetime('now'))`,
|
|
64
|
+
[user_id, net_id, role],
|
|
65
|
+
);
|
|
66
|
+
}
|
|
67
|
+
function seedDaemon(opts: {
|
|
68
|
+
network_id: string, alias: string, node_id?: string,
|
|
69
|
+
role?: string,
|
|
70
|
+
runtimes?: string[],
|
|
71
|
+
secrets?: string[],
|
|
72
|
+
sessionAgeMs?: number,
|
|
73
|
+
mintToken?: boolean,
|
|
74
|
+
tokenRevokedAt?: boolean,
|
|
75
|
+
tokenDeleted?: boolean,
|
|
76
|
+
}) {
|
|
77
|
+
const node_id = opts.node_id || `node_${opts.network_id}_${opts.alias}`;
|
|
78
|
+
const snapshot = JSON.stringify({
|
|
79
|
+
model: "claude-opus", flags: {}, role: opts.role ?? "host_supervisor",
|
|
80
|
+
runtimes_supported: opts.runtimes ?? ["claude-agent-sdk"],
|
|
81
|
+
allowed_secret_keys: opts.secrets ?? [],
|
|
82
|
+
});
|
|
83
|
+
db.run(
|
|
84
|
+
`INSERT OR REPLACE INTO nodes (
|
|
85
|
+
node_id, node_name, alias, network_id,
|
|
86
|
+
runtimes_supported, allowed_secret_keys,
|
|
87
|
+
config_snapshot, hostname,
|
|
88
|
+
created_at, updated_at
|
|
89
|
+
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, datetime('now'), datetime('now'))`,
|
|
90
|
+
[node_id, opts.alias, opts.alias, opts.network_id,
|
|
91
|
+
JSON.stringify(opts.runtimes ?? ["claude-agent-sdk"]),
|
|
92
|
+
JSON.stringify(opts.secrets ?? []),
|
|
93
|
+
snapshot, `host-${opts.alias}`],
|
|
94
|
+
);
|
|
95
|
+
if (opts.sessionAgeMs !== undefined) {
|
|
96
|
+
const ts = new Date(Date.now() - opts.sessionAgeMs).toISOString().replace("T", " ").replace(/\..+/, "");
|
|
97
|
+
db.run(
|
|
98
|
+
`INSERT OR REPLACE INTO sessions (resume_id, alias, network_id, last_seen_at, status, cpu_cores, mem_total_gb, ip)
|
|
99
|
+
VALUES (?1, ?2, ?3, ?4, 'idle', 8, 16, '10.0.0.5')`,
|
|
100
|
+
[`s_${node_id}`, opts.alias, opts.network_id, ts],
|
|
101
|
+
);
|
|
102
|
+
}
|
|
103
|
+
if (opts.mintToken !== false && !opts.tokenDeleted) {
|
|
104
|
+
db.run(
|
|
105
|
+
`INSERT OR REPLACE INTO api_tokens (token_id, user_id, network_id, scope, name, token_hash, expires_at, revoked_at)
|
|
106
|
+
VALUES (?1, ?2, ?3, 'network', ?4, ?5, NULL, ?6)`,
|
|
107
|
+
[`tok_${node_id}`, `u_seed_${node_id}`, opts.network_id, `node:${opts.alias}`,
|
|
108
|
+
`hash_${node_id}`,
|
|
109
|
+
opts.tokenRevokedAt ? new Date().toISOString().replace("T", " ").replace(/\..+/, "") : null],
|
|
110
|
+
);
|
|
111
|
+
}
|
|
112
|
+
return node_id;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** Build a server with registerTools wired to a specific enforceUserId. */
|
|
116
|
+
function buildToolHandler(enforceUserId: string | null): ToolHandler {
|
|
117
|
+
const server = new McpServer({ name: "test", version: "0" }) as any;
|
|
118
|
+
const tools: Record<string, ToolHandler> = {};
|
|
119
|
+
// Capture the registered tool function. The MCP SDK's server.tool stores
|
|
120
|
+
// the handler internally; we intercept by overriding before registerTools.
|
|
121
|
+
const origTool = server.tool.bind(server);
|
|
122
|
+
server.tool = (name: string, _desc: string, _schema: any, handler: ToolHandler) => {
|
|
123
|
+
tools[name] = handler;
|
|
124
|
+
return origTool(name, _desc, _schema, handler);
|
|
125
|
+
};
|
|
126
|
+
// Some tools use registerTool; intercept that too
|
|
127
|
+
const origRegisterTool = server.registerTool?.bind(server);
|
|
128
|
+
if (origRegisterTool) {
|
|
129
|
+
server.registerTool = (name: string, _cfg: any, handler: ToolHandler) => {
|
|
130
|
+
tools[name] = handler;
|
|
131
|
+
return origRegisterTool(name, _cfg, handler);
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
registerTools(
|
|
135
|
+
server,
|
|
136
|
+
/* clientIP */ undefined,
|
|
137
|
+
/* enforceNetworkId */ null,
|
|
138
|
+
/* enforceUserId */ enforceUserId,
|
|
139
|
+
/* callerAlias */ null,
|
|
140
|
+
/* callerTokenIsNetwork */ false,
|
|
141
|
+
/* callerTokenId */ null,
|
|
142
|
+
);
|
|
143
|
+
const h = tools["list_host_supervisors"];
|
|
144
|
+
if (!h) throw new Error("list_host_supervisors tool not registered");
|
|
145
|
+
return h;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
async function callList(handler: ToolHandler, args: any = {}): Promise<Reply> {
|
|
149
|
+
const r = await handler(args);
|
|
150
|
+
return JSON.parse(r.content[0].text) as Reply;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
describe("list_host_supervisors HANDLER tests (RFC-026 §9.2, PR2 v2)", () => {
|
|
154
|
+
test("SEC-1.1: cross-tenant utok_ caller with foreign network_id → DENIED", async () => {
|
|
155
|
+
const userA = "u_lhs_alice"; // belongs to NET_ALPHA only
|
|
156
|
+
seedUser(userA);
|
|
157
|
+
seedNetwork(NET_ALPHA);
|
|
158
|
+
seedNetwork(NET_BETA);
|
|
159
|
+
seedMembership(userA, NET_ALPHA, "admin");
|
|
160
|
+
seedDaemon({ network_id: NET_BETA, alias: "secret-beta-daemon" });
|
|
161
|
+
|
|
162
|
+
const handler = buildToolHandler(userA);
|
|
163
|
+
const reply = await callList(handler, { network_id: NET_BETA });
|
|
164
|
+
// PR2 v1 BUG: this returned ok:true with secret-beta-daemon in the list.
|
|
165
|
+
expect(reply.ok).toBe(false);
|
|
166
|
+
expect(reply.error).toMatch(/access denied|not a member/i);
|
|
167
|
+
// Belt: hostname not in error payload
|
|
168
|
+
expect(JSON.stringify(reply)).not.toContain("secret-beta-daemon");
|
|
169
|
+
expect(JSON.stringify(reply)).not.toContain("host-secret-beta-daemon");
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
test("SEC-1.2: admin caller with own network_id returns own daemons", async () => {
|
|
173
|
+
const userA = "u_lhs_alice";
|
|
174
|
+
seedUser(userA);
|
|
175
|
+
seedNetwork(NET_ALPHA);
|
|
176
|
+
seedMembership(userA, NET_ALPHA, "admin");
|
|
177
|
+
seedDaemon({ network_id: NET_ALPHA, alias: "alpha-daemon-1" });
|
|
178
|
+
seedDaemon({ network_id: NET_ALPHA, alias: "alpha-daemon-2" });
|
|
179
|
+
|
|
180
|
+
const handler = buildToolHandler(userA);
|
|
181
|
+
const reply = await callList(handler, { network_id: NET_ALPHA });
|
|
182
|
+
expect(reply.ok).toBe(true);
|
|
183
|
+
const aliases = (reply.daemons ?? []).map(d => d.alias).sort();
|
|
184
|
+
expect(aliases).toEqual(["alpha-daemon-1", "alpha-daemon-2"]);
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
test("SEC-1.3: caller without network_id arg → returns daemons across member networks only", async () => {
|
|
188
|
+
const userA = "u_lhs_alice"; // member of NET_ALPHA only
|
|
189
|
+
seedUser(userA);
|
|
190
|
+
seedNetwork(NET_ALPHA);
|
|
191
|
+
seedNetwork(NET_BETA);
|
|
192
|
+
seedMembership(userA, NET_ALPHA, "member");
|
|
193
|
+
seedDaemon({ network_id: NET_ALPHA, alias: "ok-daemon" });
|
|
194
|
+
seedDaemon({ network_id: NET_BETA, alias: "forbidden-daemon" });
|
|
195
|
+
|
|
196
|
+
const handler = buildToolHandler(userA);
|
|
197
|
+
const reply = await callList(handler);
|
|
198
|
+
expect(reply.ok).toBe(true);
|
|
199
|
+
const aliases = (reply.daemons ?? []).map(d => d.alias);
|
|
200
|
+
expect(aliases).toContain("ok-daemon");
|
|
201
|
+
expect(aliases).not.toContain("forbidden-daemon");
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
test("masking: member caller gets host_telemetry without IP/cpu/mem; admin gets full", async () => {
|
|
205
|
+
const userMember = "u_lhs_bob_member";
|
|
206
|
+
const userAdmin = "u_lhs_carol_admin";
|
|
207
|
+
seedUser(userMember);
|
|
208
|
+
seedUser(userAdmin);
|
|
209
|
+
seedNetwork(NET_ALPHA);
|
|
210
|
+
seedMembership(userMember, NET_ALPHA, "member");
|
|
211
|
+
seedMembership(userAdmin, NET_ALPHA, "admin");
|
|
212
|
+
seedDaemon({ network_id: NET_ALPHA, alias: "telemetry-daemon", sessionAgeMs: 5_000 });
|
|
213
|
+
|
|
214
|
+
const memberHandler = buildToolHandler(userMember);
|
|
215
|
+
const memberReply = await callList(memberHandler, { network_id: NET_ALPHA });
|
|
216
|
+
expect(memberReply.daemons?.[0].host_telemetry.alert_level).toBeDefined();
|
|
217
|
+
expect(memberReply.daemons?.[0].host_telemetry).not.toHaveProperty("cpu_cores");
|
|
218
|
+
expect(memberReply.daemons?.[0].host_telemetry).not.toHaveProperty("mem_gb");
|
|
219
|
+
expect(memberReply.daemons?.[0].host_telemetry).not.toHaveProperty("ip_internal");
|
|
220
|
+
|
|
221
|
+
const adminHandler = buildToolHandler(userAdmin);
|
|
222
|
+
const adminReply = await callList(adminHandler, { network_id: NET_ALPHA });
|
|
223
|
+
expect(adminReply.daemons?.[0].host_telemetry.cpu_cores).toBe(8);
|
|
224
|
+
expect(adminReply.daemons?.[0].host_telemetry.mem_gb).toBe(16);
|
|
225
|
+
expect(adminReply.daemons?.[0].host_telemetry.ip_internal).toBe("10.0.0.5");
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
test("revoked: DELETEd token row (revokeToken path) excludes daemon", async () => {
|
|
229
|
+
const userA = "u_lhs_alice";
|
|
230
|
+
seedUser(userA);
|
|
231
|
+
seedNetwork(NET_ALPHA);
|
|
232
|
+
seedMembership(userA, NET_ALPHA, "admin");
|
|
233
|
+
seedDaemon({ network_id: NET_ALPHA, alias: "alive-daemon" });
|
|
234
|
+
seedDaemon({ network_id: NET_ALPHA, alias: "deleted-token-daemon", tokenDeleted: true });
|
|
235
|
+
|
|
236
|
+
const handler = buildToolHandler(userA);
|
|
237
|
+
const reply = await callList(handler, { network_id: NET_ALPHA });
|
|
238
|
+
const aliases = (reply.daemons ?? []).map(d => d.alias).sort();
|
|
239
|
+
expect(aliases).toContain("alive-daemon");
|
|
240
|
+
expect(aliases).not.toContain("deleted-token-daemon");
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
test("revoked: token with revoked_at SET also excludes daemon", async () => {
|
|
244
|
+
const userA = "u_lhs_alice";
|
|
245
|
+
seedUser(userA);
|
|
246
|
+
seedNetwork(NET_ALPHA);
|
|
247
|
+
seedMembership(userA, NET_ALPHA, "admin");
|
|
248
|
+
seedDaemon({ network_id: NET_ALPHA, alias: "alive-daemon" });
|
|
249
|
+
seedDaemon({ network_id: NET_ALPHA, alias: "revoked-at-daemon", tokenRevokedAt: true });
|
|
250
|
+
|
|
251
|
+
const handler = buildToolHandler(userA);
|
|
252
|
+
const reply = await callList(handler, { network_id: NET_ALPHA });
|
|
253
|
+
const aliases = (reply.daemons ?? []).map(d => d.alias).sort();
|
|
254
|
+
expect(aliases).toContain("alive-daemon");
|
|
255
|
+
expect(aliases).not.toContain("revoked-at-daemon");
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
test("role filter: regular member nodes (role=member) excluded after extraction", async () => {
|
|
259
|
+
const userA = "u_lhs_alice";
|
|
260
|
+
seedUser(userA);
|
|
261
|
+
seedNetwork(NET_ALPHA);
|
|
262
|
+
seedMembership(userA, NET_ALPHA, "admin");
|
|
263
|
+
seedDaemon({ network_id: NET_ALPHA, alias: "real-daemon", role: "host_supervisor" });
|
|
264
|
+
seedDaemon({ network_id: NET_ALPHA, alias: "regular-node", role: "member" });
|
|
265
|
+
|
|
266
|
+
const handler = buildToolHandler(userA);
|
|
267
|
+
const reply = await callList(handler, { network_id: NET_ALPHA });
|
|
268
|
+
const aliases = (reply.daemons ?? []).map(d => d.alias).sort();
|
|
269
|
+
expect(aliases).toEqual(["real-daemon"]);
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
test("self-declare: runtimes_supported + allowed_secret_keys surface from columns", async () => {
|
|
273
|
+
const userA = "u_lhs_alice";
|
|
274
|
+
seedUser(userA);
|
|
275
|
+
seedNetwork(NET_ALPHA);
|
|
276
|
+
seedMembership(userA, NET_ALPHA, "admin");
|
|
277
|
+
seedDaemon({
|
|
278
|
+
network_id: NET_ALPHA, alias: "rich-daemon",
|
|
279
|
+
runtimes: ["claude-agent-sdk", "codex-sdk", "grok-build-acp"],
|
|
280
|
+
secrets: ["ANTHROPIC_API_KEY", "OPENAI_API_KEY"],
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
const handler = buildToolHandler(userA);
|
|
284
|
+
const reply = await callList(handler, { network_id: NET_ALPHA });
|
|
285
|
+
const d = reply.daemons?.[0];
|
|
286
|
+
expect(d.runtimes_supported).toEqual(["claude-agent-sdk", "codex-sdk", "grok-build-acp"]);
|
|
287
|
+
expect(d.allowed_secret_keys).toEqual(["ANTHROPIC_API_KEY", "OPENAI_API_KEY"]);
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
test("pre-PR2 daemon (NULL columns) extracts to empty arrays back-compat", async () => {
|
|
291
|
+
const userA = "u_lhs_alice";
|
|
292
|
+
seedUser(userA);
|
|
293
|
+
seedNetwork(NET_ALPHA);
|
|
294
|
+
seedMembership(userA, NET_ALPHA, "admin");
|
|
295
|
+
db.run(
|
|
296
|
+
`INSERT INTO nodes (node_id, node_name, alias, network_id, config_snapshot, runtimes_supported, allowed_secret_keys, created_at, updated_at)
|
|
297
|
+
VALUES (?1, 'legacy', 'legacy', ?2, ?3, NULL, NULL, datetime('now'), datetime('now'))`,
|
|
298
|
+
["node_legacy_alpha", NET_ALPHA, JSON.stringify({ role: "host_supervisor" })],
|
|
299
|
+
);
|
|
300
|
+
// Seed active token so the EXISTS guard lets it through
|
|
301
|
+
db.run(
|
|
302
|
+
`INSERT INTO api_tokens (token_id, user_id, network_id, scope, name, token_hash)
|
|
303
|
+
VALUES ('tok_legacy', 'u_seed_legacy', ?1, 'network', 'node:legacy', 'hash_legacy')`,
|
|
304
|
+
[NET_ALPHA],
|
|
305
|
+
);
|
|
306
|
+
|
|
307
|
+
const handler = buildToolHandler(userA);
|
|
308
|
+
const reply = await callList(handler, { network_id: NET_ALPHA });
|
|
309
|
+
const d = reply.daemons?.find(d => d.alias === "legacy");
|
|
310
|
+
expect(d).toBeTruthy();
|
|
311
|
+
expect(d.runtimes_supported).toEqual([]);
|
|
312
|
+
expect(d.allowed_secret_keys).toEqual([]);
|
|
313
|
+
});
|
|
314
|
+
});
|
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
// RFC-028 P1 §4.4 probe-validate tests — pure unit, no I/O.
|
|
2
|
+
|
|
3
|
+
import { describe, expect, test } from "bun:test";
|
|
4
|
+
import {
|
|
5
|
+
validateBaseUrl, isForbiddenIp, isLoopbackHost, assertSecureTlsEnv,
|
|
6
|
+
ProbeAckPayloadSchema, deriveErrorLabel, rejectIfSecretLeaked,
|
|
7
|
+
VENDOR_HOST_ALLOWLIST, SUPPORTED_VENDORS, ProbeValidationError,
|
|
8
|
+
} from "./probe-validate.js";
|
|
9
|
+
|
|
10
|
+
describe("validateBaseUrl (§4.4.1 vendor host allowlist)", () => {
|
|
11
|
+
test("anthropic + api.anthropic.com → ok", () => {
|
|
12
|
+
expect(() => validateBaseUrl("anthropic", "https://api.anthropic.com/v1")).not.toThrow();
|
|
13
|
+
});
|
|
14
|
+
test("anthropic + api.openai.com → probe_target_forbidden", () => {
|
|
15
|
+
expect(() => validateBaseUrl("anthropic", "https://api.openai.com/v1")).toThrow(/probe_target_forbidden/);
|
|
16
|
+
});
|
|
17
|
+
test("anthropic + 169.254.169.254 (metadata) → probe_target_forbidden (host不在 allowlist)", () => {
|
|
18
|
+
expect(() => validateBaseUrl("anthropic", "https://169.254.169.254/v1")).toThrow(/probe_target_forbidden/);
|
|
19
|
+
});
|
|
20
|
+
test("unsupported vendor → vendor_not_supported", () => {
|
|
21
|
+
expect(() => validateBaseUrl("openai", "https://api.openai.com/v1")).toThrow(/vendor_not_supported/);
|
|
22
|
+
});
|
|
23
|
+
test("non-https → probe_base_url_invalid", () => {
|
|
24
|
+
expect(() => validateBaseUrl("anthropic", "http://api.anthropic.com/v1")).toThrow(/probe_base_url_invalid/);
|
|
25
|
+
});
|
|
26
|
+
test("http+loopback OK with allowLoopback opt-in", () => {
|
|
27
|
+
expect(() => validateBaseUrl("anthropic", "http://localhost:8080/v1", { allowLoopback: true }))
|
|
28
|
+
.toThrow(/probe_target_forbidden/); // 但 host 还是不在 allowlist, allowLoopback 只放过 protocol
|
|
29
|
+
});
|
|
30
|
+
test("malformed URL → probe_base_url_invalid", () => {
|
|
31
|
+
expect(() => validateBaseUrl("anthropic", "not a url")).toThrow(/probe_base_url_invalid/);
|
|
32
|
+
});
|
|
33
|
+
test("P1 SUPPORTED_VENDORS only contains anthropic", () => {
|
|
34
|
+
expect(SUPPORTED_VENDORS).toEqual(["anthropic"]);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
// ── RFC-028 P1.5+ — Anthropic-compatible 3rd-party hosts on `anthropic` vendor.
|
|
38
|
+
// Whitelist semantics preserved; new entries require PR + security review.
|
|
39
|
+
// Host allowlist passes here; IP-level guard still fires at fetch time
|
|
40
|
+
// (covered by safelyFetchProbe + docker scenarios qa-rfc028 F + M.ssrf).
|
|
41
|
+
test("anthropic + api.deepseek.com → ok (Anthropic-compatible /v1/messages)", () => {
|
|
42
|
+
expect(() => validateBaseUrl("anthropic", "https://api.deepseek.com/v1")).not.toThrow();
|
|
43
|
+
});
|
|
44
|
+
test("anthropic + api.minimax.chat → ok (MiniMax legacy domain)", () => {
|
|
45
|
+
expect(() => validateBaseUrl("anthropic", "https://api.minimax.chat/v1")).not.toThrow();
|
|
46
|
+
});
|
|
47
|
+
test("anthropic + api.minimax.io → ok (MiniMax new domain, Vincent live)", () => {
|
|
48
|
+
expect(() => validateBaseUrl("anthropic", "https://api.minimax.io/v1")).not.toThrow();
|
|
49
|
+
});
|
|
50
|
+
test("subdomain of allowed host NOT accepted (regex anchored)", () => {
|
|
51
|
+
expect(() => validateBaseUrl("anthropic", "https://attacker.api.deepseek.com/v1")).toThrow(/probe_target_forbidden/);
|
|
52
|
+
expect(() => validateBaseUrl("anthropic", "https://api.minimax.io.attacker.example/v1")).toThrow(/probe_target_forbidden/);
|
|
53
|
+
});
|
|
54
|
+
test("homograph / look-alike host rejected (regex anchored)", () => {
|
|
55
|
+
expect(() => validateBaseUrl("anthropic", "https://api.deepseek.com.attacker/v1")).toThrow(/probe_target_forbidden/);
|
|
56
|
+
expect(() => validateBaseUrl("anthropic", "https://api-deepseek.com/v1")).toThrow(/probe_target_forbidden/);
|
|
57
|
+
});
|
|
58
|
+
test("VENDOR_HOST_ALLOWLIST.anthropic curated list = exactly 4 entries", () => {
|
|
59
|
+
// Lock the curated set — adding a 5th host MUST go through a PR
|
|
60
|
+
// bump this assertion (forces conscious security review).
|
|
61
|
+
expect(VENDOR_HOST_ALLOWLIST.anthropic.length).toBe(4);
|
|
62
|
+
const sources = VENDOR_HOST_ALLOWLIST.anthropic.map(r => r.source).sort();
|
|
63
|
+
expect(sources).toEqual([
|
|
64
|
+
"^api\\.anthropic\\.com$",
|
|
65
|
+
"^api\\.deepseek\\.com$",
|
|
66
|
+
"^api\\.minimax\\.chat$",
|
|
67
|
+
"^api\\.minimax\\.io$",
|
|
68
|
+
]);
|
|
69
|
+
});
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
describe("isForbiddenIp (§4.4.2 private/reserved IP block — anti SSRF)", () => {
|
|
73
|
+
test("blocks IPv4 private + metadata + CGNAT", () => {
|
|
74
|
+
for (const ip of [
|
|
75
|
+
"10.0.0.1", "10.255.255.255",
|
|
76
|
+
"172.16.0.1", "172.31.255.255",
|
|
77
|
+
"192.168.1.1",
|
|
78
|
+
"127.0.0.1", "127.255.255.255",
|
|
79
|
+
"169.254.169.254", // cloud metadata
|
|
80
|
+
"169.254.0.0",
|
|
81
|
+
"100.64.0.1", "100.127.255.254", // CGNAT
|
|
82
|
+
"0.0.0.0",
|
|
83
|
+
"224.0.0.1", // multicast
|
|
84
|
+
"240.0.0.1", "255.255.255.255", // experimental/broadcast
|
|
85
|
+
]) {
|
|
86
|
+
expect(isForbiddenIp(ip)).toBe(true);
|
|
87
|
+
}
|
|
88
|
+
});
|
|
89
|
+
test("allows public IPv4", () => {
|
|
90
|
+
for (const ip of ["1.1.1.1", "8.8.8.8", "104.16.0.1", "172.32.0.1", "172.15.255.254"]) {
|
|
91
|
+
expect(isForbiddenIp(ip)).toBe(false);
|
|
92
|
+
}
|
|
93
|
+
});
|
|
94
|
+
test("blocks IPv6 loopback + link-local + ULA", () => {
|
|
95
|
+
for (const ip of ["::1", "::", "fe80::1", "fc00::1", "fd00::1"]) {
|
|
96
|
+
expect(isForbiddenIp(ip)).toBe(true);
|
|
97
|
+
}
|
|
98
|
+
});
|
|
99
|
+
test("blocks IPv4-mapped IPv6 (anti绕)", () => {
|
|
100
|
+
expect(isForbiddenIp("::ffff:10.0.0.1")).toBe(true);
|
|
101
|
+
expect(isForbiddenIp("::ffff:169.254.169.254")).toBe(true);
|
|
102
|
+
expect(isForbiddenIp("::FFFF:127.0.0.1")).toBe(true);
|
|
103
|
+
});
|
|
104
|
+
test("allows public IPv6", () => {
|
|
105
|
+
expect(isForbiddenIp("2606:4700:4700::1111")).toBe(false);
|
|
106
|
+
expect(isForbiddenIp("2001:4860:4860::8888")).toBe(false);
|
|
107
|
+
});
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
describe("isLoopbackHost", () => {
|
|
111
|
+
test("recognizes localhost/127.0.0.1/::1", () => {
|
|
112
|
+
expect(isLoopbackHost("localhost")).toBe(true);
|
|
113
|
+
expect(isLoopbackHost("127.0.0.1")).toBe(true);
|
|
114
|
+
expect(isLoopbackHost("::1")).toBe(true);
|
|
115
|
+
expect(isLoopbackHost("api.example.com")).toBe(false);
|
|
116
|
+
});
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
describe("assertSecureTlsEnv (§4.4 boot guard)", () => {
|
|
120
|
+
test("clean env passes", () => {
|
|
121
|
+
expect(() => assertSecureTlsEnv({})).not.toThrow();
|
|
122
|
+
});
|
|
123
|
+
test("NODE_TLS_REJECT_UNAUTHORIZED=0 throws probe_tls_insecure_disabled", () => {
|
|
124
|
+
expect(() => assertSecureTlsEnv({ NODE_TLS_REJECT_UNAUTHORIZED: "0" }))
|
|
125
|
+
.toThrow(/probe_tls_insecure_disabled/);
|
|
126
|
+
});
|
|
127
|
+
test("NODE_TLS_REJECT_UNAUTHORIZED=1 passes (default safe)", () => {
|
|
128
|
+
expect(() => assertSecureTlsEnv({ NODE_TLS_REJECT_UNAUTHORIZED: "1" })).not.toThrow();
|
|
129
|
+
});
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
describe("ProbeAckPayloadSchema (§4.4.4 v3 R3 strict whitelist)", () => {
|
|
133
|
+
test("minimal valid payload parses", () => {
|
|
134
|
+
const ok = { probe_id: "p_1", status: "ok", latency_ms: 180 };
|
|
135
|
+
expect(ProbeAckPayloadSchema.parse(ok)).toEqual(ok);
|
|
136
|
+
});
|
|
137
|
+
test("with raw_status_code parses", () => {
|
|
138
|
+
const r = ProbeAckPayloadSchema.parse({ probe_id: "p_2", status: "auth_fail", raw_status_code: 401, latency_ms: 50 });
|
|
139
|
+
expect(r.raw_status_code).toBe(401);
|
|
140
|
+
});
|
|
141
|
+
test("REJECTS extra error_message field (zod .strict, v3 R3 attacker daemon catch)", () => {
|
|
142
|
+
expect(() => ProbeAckPayloadSchema.parse({
|
|
143
|
+
probe_id: "p_3", status: "auth_fail", latency_ms: 50,
|
|
144
|
+
error_message: "Invalid API key: sk-ant-abc", // ← attacker smuggling
|
|
145
|
+
})).toThrow();
|
|
146
|
+
});
|
|
147
|
+
test("REJECTS extra response_body / response_headers / url / vendor_text", () => {
|
|
148
|
+
for (const extra of [
|
|
149
|
+
{ response_body: "..." },
|
|
150
|
+
{ response_headers: { foo: "bar" } },
|
|
151
|
+
{ url: "https://leak" },
|
|
152
|
+
{ vendor_text: "leak" },
|
|
153
|
+
{ error: "leak" },
|
|
154
|
+
{ detail: "leak" },
|
|
155
|
+
]) {
|
|
156
|
+
expect(() => ProbeAckPayloadSchema.parse({
|
|
157
|
+
probe_id: "p", status: "ok", latency_ms: 1,
|
|
158
|
+
...extra,
|
|
159
|
+
})).toThrow();
|
|
160
|
+
}
|
|
161
|
+
});
|
|
162
|
+
test("REJECTS bad enum status", () => {
|
|
163
|
+
expect(() => ProbeAckPayloadSchema.parse({ probe_id: "p", status: "bogus", latency_ms: 1 })).toThrow();
|
|
164
|
+
});
|
|
165
|
+
test("REJECTS latency_ms > 60_000 or negative", () => {
|
|
166
|
+
expect(() => ProbeAckPayloadSchema.parse({ probe_id: "p", status: "ok", latency_ms: 60_001 })).toThrow();
|
|
167
|
+
expect(() => ProbeAckPayloadSchema.parse({ probe_id: "p", status: "ok", latency_ms: -1 })).toThrow();
|
|
168
|
+
});
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
describe("deriveErrorLabel (hub-only, daemon CANNOT submit)", () => {
|
|
172
|
+
test("ok → null", () => {
|
|
173
|
+
expect(deriveErrorLabel({ probe_id: "p", status: "ok", latency_ms: 10 })).toBeNull();
|
|
174
|
+
});
|
|
175
|
+
test("auth_fail with code → label includes HTTP code", () => {
|
|
176
|
+
expect(deriveErrorLabel({ probe_id: "p", status: "auth_fail", raw_status_code: 401, latency_ms: 10 }))
|
|
177
|
+
.toContain("401");
|
|
178
|
+
});
|
|
179
|
+
test("every enum has a label or null", () => {
|
|
180
|
+
for (const status of ["ok", "auth_fail", "quota", "rate_limit", "network_error", "timeout", "redirect_forbidden", "vendor_5xx", "other_4xx", "tls_error"] as const) {
|
|
181
|
+
const r = deriveErrorLabel({ probe_id: "p", status, latency_ms: 1 });
|
|
182
|
+
expect(typeof r === "string" || r === null).toBe(true);
|
|
183
|
+
}
|
|
184
|
+
});
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
describe("rejectIfSecretLeaked (§4.4.4 belt-and-suspenders)", () => {
|
|
188
|
+
test("clean ack passes", () => {
|
|
189
|
+
expect(() => rejectIfSecretLeaked(
|
|
190
|
+
JSON.stringify({ probe_id: "p", status: "ok" }),
|
|
191
|
+
["sk-very-long-secret-1234567890"],
|
|
192
|
+
)).not.toThrow();
|
|
193
|
+
});
|
|
194
|
+
test("ack containing raw secret → ack_secret_leak (plain)", () => {
|
|
195
|
+
expect(() => rejectIfSecretLeaked(
|
|
196
|
+
JSON.stringify({ probe_id: "p", status: "fail", x: "sk-very-long-secret-1234567890" }),
|
|
197
|
+
["sk-very-long-secret-1234567890"],
|
|
198
|
+
)).toThrow(/ack_secret_leak/);
|
|
199
|
+
});
|
|
200
|
+
test("ack containing URL-encoded secret → ack_secret_leak (url_encoded)", () => {
|
|
201
|
+
const secret = "sk/with+special=chars/here-long-enough";
|
|
202
|
+
const enc = encodeURIComponent(secret);
|
|
203
|
+
expect(() => rejectIfSecretLeaked(
|
|
204
|
+
JSON.stringify({ probe_id: "p", url: `https://x/?key=${enc}` }),
|
|
205
|
+
[secret],
|
|
206
|
+
)).toThrow(/ack_secret_leak/);
|
|
207
|
+
});
|
|
208
|
+
test("ack containing 12-char substring of long secret → ack_secret_leak (substring_12)", () => {
|
|
209
|
+
const secret = "sk-ant-api03-very-long-key-1234567890-abcdef";
|
|
210
|
+
expect(() => rejectIfSecretLeaked(
|
|
211
|
+
JSON.stringify({ probe_id: "p", x: "leak prefix sk-ant-api03 here" }),
|
|
212
|
+
[secret],
|
|
213
|
+
)).toThrow(/ack_secret_leak/);
|
|
214
|
+
});
|
|
215
|
+
test("short secret (<8) not checked (avoid false positive on tiny tokens)", () => {
|
|
216
|
+
expect(() => rejectIfSecretLeaked(
|
|
217
|
+
JSON.stringify({ probe_id: "p", x: "test" }),
|
|
218
|
+
["test"],
|
|
219
|
+
)).not.toThrow();
|
|
220
|
+
});
|
|
221
|
+
});
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
// RFC-028 P1 §4.4 — probe path validators (pure, no I/O).
|
|
2
|
+
// Host allowlist + IP block + validateBaseUrl extracted into
|
|
3
|
+
// shared/probe-host-allowlist.ts (mirror of agent-node/src/shared/, G9
|
|
4
|
+
// drift guard enforces byte-identical). This module re-exports them so
|
|
5
|
+
// existing hub call sites keep working, and adds hub-only bits:
|
|
6
|
+
// PROBE_STATUS_ENUM + ProbeAckPayloadSchema + deriveErrorLabel +
|
|
7
|
+
// rejectIfSecretLeaked + assertSecureTlsEnv.
|
|
8
|
+
|
|
9
|
+
import { z } from "zod/v4";
|
|
10
|
+
export {
|
|
11
|
+
VENDOR_HOST_ALLOWLIST,
|
|
12
|
+
SUPPORTED_VENDORS,
|
|
13
|
+
ProbeValidationError,
|
|
14
|
+
validateBaseUrl,
|
|
15
|
+
isLoopbackHost,
|
|
16
|
+
isForbiddenIp,
|
|
17
|
+
} from "./shared/probe-host-allowlist.js";
|
|
18
|
+
import { ProbeValidationError } from "./shared/probe-host-allowlist.js";
|
|
19
|
+
|
|
20
|
+
// ── §4.4.5 boot-time TLS env guard (assert before any probe runs) ──
|
|
21
|
+
|
|
22
|
+
export function assertSecureTlsEnv(env: NodeJS.ProcessEnv = process.env): void {
|
|
23
|
+
if (env.NODE_TLS_REJECT_UNAUTHORIZED === "0") {
|
|
24
|
+
throw new ProbeValidationError("probe_tls_insecure_disabled", {
|
|
25
|
+
reason: "NODE_TLS_REJECT_UNAUTHORIZED=0 forbidden — TLS cert validation must be ON for SSRF defense",
|
|
26
|
+
});
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// ── §4.4.4 ProbeAckPayload — STRICT whitelist (v3 R3) ─────────────
|
|
31
|
+
// Daemon ack returns ONLY these fields. Any extra string is dropped
|
|
32
|
+
// at the zod parse boundary. Hub additionally runs rejectIfSecretLeaked
|
|
33
|
+
// (defense in depth).
|
|
34
|
+
|
|
35
|
+
export const PROBE_STATUS_ENUM = [
|
|
36
|
+
"ok",
|
|
37
|
+
"auth_fail",
|
|
38
|
+
"quota",
|
|
39
|
+
"rate_limit",
|
|
40
|
+
"network_error",
|
|
41
|
+
"timeout",
|
|
42
|
+
"redirect_forbidden",
|
|
43
|
+
"vendor_5xx",
|
|
44
|
+
"other_4xx",
|
|
45
|
+
"tls_error",
|
|
46
|
+
// SSRF guards surface their own statuses so dashboard can show the
|
|
47
|
+
// exact reason; deriveErrorLabel maps to UI text.
|
|
48
|
+
"probe_resolve_unsafe_ip",
|
|
49
|
+
"probe_target_forbidden",
|
|
50
|
+
] as const;
|
|
51
|
+
export type ProbeStatus = typeof PROBE_STATUS_ENUM[number];
|
|
52
|
+
|
|
53
|
+
export const ProbeAckPayloadSchema = z.object({
|
|
54
|
+
probe_id: z.string().min(1).max(200),
|
|
55
|
+
status: z.enum(PROBE_STATUS_ENUM),
|
|
56
|
+
raw_status_code: z.number().int().min(100).max(599).optional(),
|
|
57
|
+
latency_ms: z.number().int().min(0).max(60_000),
|
|
58
|
+
}).strict(); // .strict() rejects ANY extra field — error_message / response_body / etc smuggled by attacker daemon
|
|
59
|
+
export type ProbeAckPayload = z.infer<typeof ProbeAckPayloadSchema>;
|
|
60
|
+
|
|
61
|
+
/** Hub-side: map (status, raw_status_code) → human label.
|
|
62
|
+
* daemon NEVER submits this; hub computes from enum + numeric. */
|
|
63
|
+
export function deriveErrorLabel(ack: ProbeAckPayload): string | null {
|
|
64
|
+
switch (ack.status) {
|
|
65
|
+
case "ok": return null;
|
|
66
|
+
case "auth_fail": return `API key 校验失败 (HTTP ${ack.raw_status_code ?? "?"})`;
|
|
67
|
+
case "quota": return "API 额度用尽 (429)";
|
|
68
|
+
case "rate_limit": return "我方 rate limit (60req/min/provider) 触发";
|
|
69
|
+
case "network_error": return "网络不可达 (connect/DNS fail)";
|
|
70
|
+
case "timeout": return "连通性测试超时 (>30s)";
|
|
71
|
+
case "redirect_forbidden": return "vendor 返回 30x redirect, P1 一律拒";
|
|
72
|
+
case "vendor_5xx": return `vendor 服务端错 (HTTP ${ack.raw_status_code ?? "5xx"})`;
|
|
73
|
+
case "other_4xx": return `vendor 客户端错 (HTTP ${ack.raw_status_code ?? "4xx"})`;
|
|
74
|
+
case "tls_error": return "TLS 证书校验失败";
|
|
75
|
+
case "probe_resolve_unsafe_ip": return "SSRF 拒: base_url 解析到禁用 IP 段 (私网/metadata)";
|
|
76
|
+
case "probe_target_forbidden": return "SSRF 拒: base_url host 不在 vendor allowlist";
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// ── §4.4.4 hub-side belt: reject if daemon ack contains a secret ──
|
|
81
|
+
// Even though daemon ack schema (zod .strict()) blocks string fields,
|
|
82
|
+
// this guard catches a daemon impl bug that smuggles secrets via
|
|
83
|
+
// some other means (e.g. probe_id stuffed with key value).
|
|
84
|
+
// Checks plaintext + URL-encoded + 12-char sliding window substring.
|
|
85
|
+
|
|
86
|
+
export function rejectIfSecretLeaked(ackJson: string, knownSecrets: ReadonlyArray<string>): void {
|
|
87
|
+
for (const s of knownSecrets) {
|
|
88
|
+
if (!s || s.length < 8) continue; // avoid false positive on tiny tokens
|
|
89
|
+
if (ackJson.includes(s)) {
|
|
90
|
+
throw new ProbeValidationError("ack_secret_leak", { reason: "plain", len: s.length });
|
|
91
|
+
}
|
|
92
|
+
const enc = encodeURIComponent(s);
|
|
93
|
+
if (enc !== s && ackJson.includes(enc)) {
|
|
94
|
+
throw new ProbeValidationError("ack_secret_leak", { reason: "url_encoded", len: s.length });
|
|
95
|
+
}
|
|
96
|
+
if (s.length >= 16) {
|
|
97
|
+
for (let i = 0; i <= s.length - 12; i++) {
|
|
98
|
+
if (ackJson.includes(s.slice(i, i + 12))) {
|
|
99
|
+
throw new ProbeValidationError("ack_secret_leak", { reason: "substring_12", offset: i });
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
}
|