@sleep2agi/commhub-server 0.9.0-preview.4 → 0.9.0-preview.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/package.json +1 -1
- package/src/api-nodes-shape.test.ts +89 -11
- package/src/db.ts +70 -0
- package/src/index.ts +42 -2
- 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/shared/probe-host-allowlist-drift.test.ts +54 -0
- package/src/shared/probe-host-allowlist.ts +128 -0
- package/src/tools.ts +483 -0
- package/src/update-provider.test.ts +169 -0
- package/src/vault.test.ts +200 -0
- package/src/vault.ts +156 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sleep2agi/commhub-server",
|
|
3
|
-
"version": "0.9.0-preview.
|
|
3
|
+
"version": "0.9.0-preview.6",
|
|
4
4
|
"description": "CommHub Server \u2014 AI Agent communication hub with MCP protocol, multi-network isolation, user auth, and 17 MCP tools.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "src/index.ts",
|
|
@@ -29,8 +29,23 @@ afterAll(() => {
|
|
|
29
29
|
try { db.run("DELETE FROM nodes WHERE node_id = ?1", [TEST_NODE]); } catch {}
|
|
30
30
|
});
|
|
31
31
|
|
|
32
|
+
// Helper: mirror handler's role-extraction pass to mimic the production
|
|
33
|
+
// response shape exactly. Keeps the test the source of truth for the
|
|
34
|
+
// contract; if the handler changes, the assertions still lock what
|
|
35
|
+
// the dashboard / external clients see.
|
|
36
|
+
function mapRow(r: Record<string, unknown>): Record<string, unknown> {
|
|
37
|
+
let role: string | null = null;
|
|
38
|
+
const snap = r.config_snapshot;
|
|
39
|
+
if (snap) {
|
|
40
|
+
try { role = (typeof snap === "string" ? JSON.parse(snap) : snap)?.role ?? null; }
|
|
41
|
+
catch { /* malformed */ }
|
|
42
|
+
}
|
|
43
|
+
const { config_snapshot, ...rest } = r;
|
|
44
|
+
return { ...rest, role };
|
|
45
|
+
}
|
|
46
|
+
|
|
32
47
|
describe("RFC-024 regression — GET /api/nodes response shape (no SELECT *)", () => {
|
|
33
|
-
test("explicit columns:
|
|
48
|
+
test("explicit columns + role extracted: config_snapshot NOT in response, role IS", () => {
|
|
34
49
|
// Insert a row with the leak-prone columns populated. Use INSERT OR
|
|
35
50
|
// REPLACE so concurrent test runs don't trip on PRIMARY KEY.
|
|
36
51
|
db.run(
|
|
@@ -43,38 +58,101 @@ describe("RFC-024 regression — GET /api/nodes response shape (no SELECT *)", (
|
|
|
43
58
|
)`,
|
|
44
59
|
[TEST_NODE, "test-shape", "test-shape", "claude-agent-sdk", "claude-opus",
|
|
45
60
|
"/tmp/cfg.json", "[]", "localhost", "test-host", TEST_NETWORK,
|
|
46
|
-
7, JSON.stringify({ model: "claude-opus", flags: { x: true } })],
|
|
61
|
+
7, JSON.stringify({ model: "claude-opus", flags: { x: true }, role: "host_supervisor" })],
|
|
47
62
|
);
|
|
48
63
|
|
|
49
|
-
//
|
|
50
|
-
|
|
51
|
-
const rows = db.all<Record<string, unknown>>(
|
|
64
|
+
// Mirror handler SQL + mapping
|
|
65
|
+
const raw = db.all<Record<string, unknown>>(
|
|
52
66
|
`SELECT node_id, node_name, alias, runtime, model,
|
|
53
67
|
config_path, channels, server, hostname,
|
|
54
|
-
network_id, created_at, updated_at
|
|
68
|
+
network_id, created_at, updated_at,
|
|
69
|
+
config_snapshot
|
|
55
70
|
FROM nodes WHERE node_id = ?1`,
|
|
56
71
|
TEST_NODE,
|
|
57
72
|
);
|
|
73
|
+
const rows = raw.map(mapRow);
|
|
58
74
|
|
|
59
75
|
expect(rows).toHaveLength(1);
|
|
60
76
|
const row = rows[0]!;
|
|
61
|
-
// Expected fields present
|
|
77
|
+
// Expected fields present (13 = 12 contract + new role)
|
|
62
78
|
for (const k of ["node_id", "node_name", "alias", "runtime", "model",
|
|
63
79
|
"config_path", "channels", "server", "hostname",
|
|
64
|
-
"network_id", "created_at", "updated_at"]) {
|
|
80
|
+
"network_id", "created_at", "updated_at", "role"]) {
|
|
65
81
|
expect(row).toHaveProperty(k);
|
|
66
82
|
}
|
|
67
|
-
// Internal fields NOT present
|
|
83
|
+
// Internal fields NOT present
|
|
68
84
|
expect(row).not.toHaveProperty("config_revision");
|
|
69
85
|
expect(row).not.toHaveProperty("config_snapshot");
|
|
70
|
-
//
|
|
86
|
+
// role is EXTRACTED from config_snapshot JSON, not the raw blob
|
|
87
|
+
expect(row.role).toBe("host_supervisor");
|
|
88
|
+
// Belt: full key set is exactly the 13 contract fields
|
|
71
89
|
expect(Object.keys(row).sort()).toEqual([
|
|
72
90
|
"alias", "channels", "config_path", "created_at", "hostname",
|
|
73
|
-
"model", "network_id", "node_id", "node_name", "runtime",
|
|
91
|
+
"model", "network_id", "node_id", "node_name", "role", "runtime",
|
|
74
92
|
"server", "updated_at",
|
|
75
93
|
]);
|
|
76
94
|
});
|
|
77
95
|
|
|
96
|
+
test("role is null when config_snapshot is empty / missing / malformed", () => {
|
|
97
|
+
// Empty snapshot → null
|
|
98
|
+
db.run(
|
|
99
|
+
`INSERT OR REPLACE INTO nodes (node_id, node_name, alias, network_id, config_snapshot)
|
|
100
|
+
VALUES (?1, ?2, ?3, ?4, NULL)`,
|
|
101
|
+
[TEST_NODE, "test-shape", "test-shape", TEST_NETWORK],
|
|
102
|
+
);
|
|
103
|
+
let row = mapRow(db.get<Record<string, unknown>>(
|
|
104
|
+
`SELECT node_id, node_name, alias, runtime, model, config_path, channels,
|
|
105
|
+
server, hostname, network_id, created_at, updated_at, config_snapshot
|
|
106
|
+
FROM nodes WHERE node_id = ?1`,
|
|
107
|
+
TEST_NODE,
|
|
108
|
+
)!);
|
|
109
|
+
expect(row.role).toBeNull();
|
|
110
|
+
expect(row).not.toHaveProperty("config_snapshot");
|
|
111
|
+
|
|
112
|
+
// Malformed JSON → null (handler doesn't throw, just leaves role null)
|
|
113
|
+
db.run(`UPDATE nodes SET config_snapshot = ?1 WHERE node_id = ?2`, ["not-json{", TEST_NODE]);
|
|
114
|
+
row = mapRow(db.get<Record<string, unknown>>(
|
|
115
|
+
`SELECT node_id, node_name, alias, runtime, model, config_path, channels,
|
|
116
|
+
server, hostname, network_id, created_at, updated_at, config_snapshot
|
|
117
|
+
FROM nodes WHERE node_id = ?1`,
|
|
118
|
+
TEST_NODE,
|
|
119
|
+
)!);
|
|
120
|
+
expect(row.role).toBeNull();
|
|
121
|
+
|
|
122
|
+
// Snapshot without role key → null
|
|
123
|
+
db.run(`UPDATE nodes SET config_snapshot = ?1 WHERE node_id = ?2`,
|
|
124
|
+
[JSON.stringify({ model: "x", flags: {} }), TEST_NODE]);
|
|
125
|
+
row = mapRow(db.get<Record<string, unknown>>(
|
|
126
|
+
`SELECT node_id, node_name, alias, runtime, model, config_path, channels,
|
|
127
|
+
server, hostname, network_id, created_at, updated_at, config_snapshot
|
|
128
|
+
FROM nodes WHERE node_id = ?1`,
|
|
129
|
+
TEST_NODE,
|
|
130
|
+
)!);
|
|
131
|
+
expect(row.role).toBeNull();
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
test("daemon discovery use-case: dashboard finds role=host_supervisor without prefix heuristic", () => {
|
|
135
|
+
// Daemon registered with arbitrary node_id (NO `node_daemon_` prefix)
|
|
136
|
+
db.run(
|
|
137
|
+
`INSERT OR REPLACE INTO nodes (node_id, node_name, alias, network_id, config_snapshot)
|
|
138
|
+
VALUES (?1, ?2, ?3, ?4, ?5)`,
|
|
139
|
+
[TEST_NODE, "my-daemon", "my-daemon", TEST_NETWORK,
|
|
140
|
+
JSON.stringify({ role: "host_supervisor", runtime: "claude-agent-sdk" })],
|
|
141
|
+
);
|
|
142
|
+
const rows = db.all<Record<string, unknown>>(
|
|
143
|
+
`SELECT node_id, node_name, alias, runtime, model, config_path, channels,
|
|
144
|
+
server, hostname, network_id, created_at, updated_at, config_snapshot
|
|
145
|
+
FROM nodes WHERE network_id = ?1`,
|
|
146
|
+
TEST_NETWORK,
|
|
147
|
+
).map(mapRow);
|
|
148
|
+
// Dashboard's lookup: find first node with role==='host_supervisor'
|
|
149
|
+
const daemon = rows.find(r => r.role === "host_supervisor");
|
|
150
|
+
expect(daemon).toBeTruthy();
|
|
151
|
+
expect(daemon!.node_id).toBe(TEST_NODE);
|
|
152
|
+
// Even though node_id does NOT start with `node_daemon_` (the prior heuristic)
|
|
153
|
+
expect(String(daemon!.node_id).startsWith("node_daemon_")).toBe(false);
|
|
154
|
+
});
|
|
155
|
+
|
|
78
156
|
test("internal columns are still readable via dedicated query (config snapshot endpoint path)", () => {
|
|
79
157
|
db.run(
|
|
80
158
|
`INSERT OR REPLACE INTO nodes (node_id, node_name, alias, network_id,
|
package/src/db.ts
CHANGED
|
@@ -436,6 +436,76 @@ try {
|
|
|
436
436
|
console.warn(`[commhub] partial unique index uniq_ncr_inflight skipped: ${e?.message || e}`);
|
|
437
437
|
}
|
|
438
438
|
|
|
439
|
+
// RFC-028 P1 v4 §2.2 — providers + provider_models + network_secrets +
|
|
440
|
+
// probe_results. All 4 tables idempotent CREATE TABLE IF NOT EXISTS. F2
|
|
441
|
+
// lazy gate: tables created on hub boot regardless; vault master key
|
|
442
|
+
// (ANET_HUB_SECRET_VAULT_KEY env) only required when these tables get
|
|
443
|
+
// their first non-empty row (lazy in vault.ts). Empty tables = hub
|
|
444
|
+
// boots fine without env (critical for existing prod hub升级 不 brick).
|
|
445
|
+
db.exec(`
|
|
446
|
+
CREATE TABLE IF NOT EXISTS providers (
|
|
447
|
+
provider_id TEXT PRIMARY KEY,
|
|
448
|
+
network_id TEXT NOT NULL,
|
|
449
|
+
name TEXT NOT NULL,
|
|
450
|
+
vendor TEXT NOT NULL,
|
|
451
|
+
base_url TEXT NOT NULL,
|
|
452
|
+
secret_key_ref TEXT NOT NULL,
|
|
453
|
+
created_at INTEGER NOT NULL,
|
|
454
|
+
created_by TEXT NOT NULL,
|
|
455
|
+
enabled INTEGER NOT NULL DEFAULT 1,
|
|
456
|
+
UNIQUE(network_id, name)
|
|
457
|
+
);
|
|
458
|
+
CREATE INDEX IF NOT EXISTS idx_providers_network ON providers(network_id);
|
|
459
|
+
`);
|
|
460
|
+
db.exec(`
|
|
461
|
+
CREATE TABLE IF NOT EXISTS provider_models (
|
|
462
|
+
model_id TEXT PRIMARY KEY,
|
|
463
|
+
provider_id TEXT NOT NULL,
|
|
464
|
+
model_name TEXT NOT NULL,
|
|
465
|
+
display_name TEXT,
|
|
466
|
+
context_window INTEGER,
|
|
467
|
+
supports_vision INTEGER NOT NULL DEFAULT 0,
|
|
468
|
+
enabled INTEGER NOT NULL DEFAULT 1,
|
|
469
|
+
created_at INTEGER NOT NULL,
|
|
470
|
+
UNIQUE(provider_id, model_name)
|
|
471
|
+
);
|
|
472
|
+
CREATE INDEX IF NOT EXISTS idx_models_provider ON provider_models(provider_id);
|
|
473
|
+
`);
|
|
474
|
+
// network_secrets — encrypted-at-rest vault. ciphertext + iv + tag are
|
|
475
|
+
// AES-GCM output; plaintext NEVER touches this table. master key from
|
|
476
|
+
// ANET_HUB_SECRET_VAULT_KEY env (lazy gate in vault.ts).
|
|
477
|
+
db.exec(`
|
|
478
|
+
CREATE TABLE IF NOT EXISTS network_secrets (
|
|
479
|
+
network_id TEXT NOT NULL,
|
|
480
|
+
key TEXT NOT NULL,
|
|
481
|
+
ciphertext BLOB NOT NULL,
|
|
482
|
+
iv BLOB NOT NULL,
|
|
483
|
+
tag BLOB NOT NULL,
|
|
484
|
+
created_at INTEGER NOT NULL,
|
|
485
|
+
updated_at INTEGER NOT NULL,
|
|
486
|
+
PRIMARY KEY (network_id, key)
|
|
487
|
+
);
|
|
488
|
+
`);
|
|
489
|
+
// probe_results — connectivity matrix history. v3 R3 lock: error_label
|
|
490
|
+
// is HUB-DERIVED ONLY from (status, raw_status_code) — daemon CANNOT
|
|
491
|
+
// submit arbitrary error string. status enum mirrors ProbeAckPayload.
|
|
492
|
+
db.exec(`
|
|
493
|
+
CREATE TABLE IF NOT EXISTS probe_results (
|
|
494
|
+
probe_id TEXT PRIMARY KEY,
|
|
495
|
+
provider_id TEXT NOT NULL,
|
|
496
|
+
model_name TEXT NOT NULL,
|
|
497
|
+
daemon_node_id TEXT NOT NULL,
|
|
498
|
+
network_id TEXT NOT NULL,
|
|
499
|
+
status TEXT NOT NULL,
|
|
500
|
+
latency_ms INTEGER,
|
|
501
|
+
error_label TEXT,
|
|
502
|
+
probed_at INTEGER NOT NULL,
|
|
503
|
+
probed_by_user TEXT,
|
|
504
|
+
raw_status_code INTEGER
|
|
505
|
+
);
|
|
506
|
+
CREATE INDEX IF NOT EXISTS idx_probe_matrix ON probe_results(network_id, provider_id, model_name, daemon_node_id, probed_at DESC);
|
|
507
|
+
`);
|
|
508
|
+
|
|
439
509
|
// ── V3: audit_log table ──
|
|
440
510
|
db.exec(`
|
|
441
511
|
CREATE TABLE IF NOT EXISTS audit_log (
|
package/src/index.ts
CHANGED
|
@@ -1965,16 +1965,37 @@ Bun.serve({
|
|
|
1965
1965
|
if (url.pathname === "/api/nodes") {
|
|
1966
1966
|
const nodeId = url.searchParams.get("node_id");
|
|
1967
1967
|
const alias = url.searchParams.get("alias");
|
|
1968
|
+
// `config_snapshot` SELECTed ONLY to extract the daemon `role` field
|
|
1969
|
+
// for dashboard discovery — full snapshot is NOT broadcast (would
|
|
1970
|
+
// re-introduce the #312 SELECT * fragility). Mapping step pulls
|
|
1971
|
+
// `role` out then drops `config_snapshot` from the response row.
|
|
1968
1972
|
let sql = `SELECT node_id, node_name, alias, runtime, model,
|
|
1969
1973
|
config_path, channels, server, hostname,
|
|
1970
|
-
network_id, created_at, updated_at
|
|
1974
|
+
network_id, created_at, updated_at,
|
|
1975
|
+
config_snapshot
|
|
1971
1976
|
FROM nodes WHERE 1=1`;
|
|
1972
1977
|
const params: any[] = [];
|
|
1973
1978
|
sql = addNetworkScope(sql, params, restScope);
|
|
1974
1979
|
if (nodeId) { sql += ` AND node_id = ?${params.length + 1}`; params.push(nodeId); }
|
|
1975
1980
|
if (alias) { sql += ` AND alias = ?${params.length + 1}`; params.push(alias); }
|
|
1976
1981
|
sql += " ORDER BY updated_at DESC";
|
|
1977
|
-
const
|
|
1982
|
+
const rawRows = db.all<Record<string, any>>(sql, ...params);
|
|
1983
|
+
// Add a single new field `role: string | null` per row, extracted from
|
|
1984
|
+
// config_snapshot JSON. Daemon nodes that posted role='host_supervisor'
|
|
1985
|
+
// via report_status surface here; dashboard uses this for daemon
|
|
1986
|
+
// discovery (replacing the prior `node_daemon_` prefix heuristic).
|
|
1987
|
+
// RFC-026 P1 P2 §9 will supersede this REST proxy with the
|
|
1988
|
+
// `list_host_supervisors` MCP tool.
|
|
1989
|
+
const rows = rawRows.map(r => {
|
|
1990
|
+
let role: string | null = null;
|
|
1991
|
+
const snap = r.config_snapshot;
|
|
1992
|
+
if (snap) {
|
|
1993
|
+
try { role = (typeof snap === "string" ? JSON.parse(snap) : snap)?.role ?? null; }
|
|
1994
|
+
catch { /* malformed snapshot — leave role null */ }
|
|
1995
|
+
}
|
|
1996
|
+
const { config_snapshot, ...rest } = r;
|
|
1997
|
+
return { ...rest, role };
|
|
1998
|
+
});
|
|
1978
1999
|
return withCors(req, Response.json({ ok: true, nodes: rows, count: rows.length }));
|
|
1979
2000
|
}
|
|
1980
2001
|
|
|
@@ -2185,3 +2206,22 @@ console.log(`
|
|
|
2185
2206
|
║ Health: http://${HOST}:${PORT}/health ║
|
|
2186
2207
|
╚══════════════════════════════════════════════════╝
|
|
2187
2208
|
`);
|
|
2209
|
+
|
|
2210
|
+
// RFC-028 P1 boot banner — vault key configuration status.
|
|
2211
|
+
// F2 invariant: hub MUST boot regardless of vault state. This is
|
|
2212
|
+
// banner-only (informational); errors are raised lazily at vault op
|
|
2213
|
+
// time. needsKeyToOp=true means the operator should set
|
|
2214
|
+
// ANET_HUB_SECRET_VAULT_KEY before vault/provider features will work.
|
|
2215
|
+
try {
|
|
2216
|
+
const { vaultStatusForBoot } = await import("./vault.js");
|
|
2217
|
+
const s = vaultStatusForBoot();
|
|
2218
|
+
if (s.needsKeyToOp) {
|
|
2219
|
+
console.warn("[rfc-028 vault] ⚠️ network_secrets/providers have data BUT ANET_HUB_SECRET_VAULT_KEY is unset — vault ops will throw vault_master_key_missing until you set the env. Generate one: `openssl rand -hex 32` (must match the key used at write time).");
|
|
2220
|
+
} else if (s.configured) {
|
|
2221
|
+
console.log(`[rfc-028 vault] master key configured (tables_have_data=${s.tablesHaveData})`);
|
|
2222
|
+
} else {
|
|
2223
|
+
console.log("[rfc-028 vault] master key not set + no vault rows — vault gating is lazy; boot OK. Set ANET_HUB_SECRET_VAULT_KEY when enabling provider/secret features.");
|
|
2224
|
+
}
|
|
2225
|
+
} catch (e: any) {
|
|
2226
|
+
console.warn(`[rfc-028 vault] boot banner skipped (${e?.message || e})`);
|
|
2227
|
+
}
|
|
@@ -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
|
+
}
|