@llblab/pi-telegram 0.24.2 → 0.24.4
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/AGENTS.md +6 -6
- package/BACKLOG.md +12 -0
- package/CHANGELOG.md +12 -0
- package/README.md +8 -4
- package/docs/architecture.md +6 -6
- package/docs/multi-instance-bus.md +5 -5
- package/docs/public-api.md +7 -3
- package/index.ts +40 -4
- package/lib/bindings.ts +21 -10
- package/lib/bus-follower.ts +5 -1
- package/lib/config.ts +51 -0
- package/lib/locks.ts +7 -0
- package/lib/menu-settings.ts +94 -6
- package/lib/outbound-attachments.ts +10 -15
- package/lib/pi.ts +6 -0
- package/lib/prompts.ts +126 -8
- package/lib/queue.ts +27 -3
- package/lib/routing.ts +301 -256
- package/lib/runtime.ts +6 -1
- package/lib/telegram-api.ts +100 -1
- package/package.json +10 -7
- package/scripts/audit-dependencies.ts +78 -0
- package/scripts/dependency-audit-policy.ts +300 -0
package/lib/runtime.ts
CHANGED
|
@@ -455,7 +455,12 @@ export function startTelegramTypingLoop(
|
|
|
455
455
|
state.typingLoopKey = nextKey;
|
|
456
456
|
const sendTyping = (): void => {
|
|
457
457
|
const activeDeps = state.typingLoopDeps;
|
|
458
|
-
if (
|
|
458
|
+
if (
|
|
459
|
+
!activeDeps ||
|
|
460
|
+
activeDeps.chatId === undefined ||
|
|
461
|
+
activeDeps.chatId === 0 ||
|
|
462
|
+
state.typingInFlight
|
|
463
|
+
)
|
|
459
464
|
return;
|
|
460
465
|
const targetChatId = activeDeps.chatId;
|
|
461
466
|
const threadParams = getTelegramTypingLoopThreadParams(activeDeps.target);
|
package/lib/telegram-api.ts
CHANGED
|
@@ -333,6 +333,7 @@ interface TelegramApiResponse<T> {
|
|
|
333
333
|
export interface TelegramApiCallOptions {
|
|
334
334
|
signal?: AbortSignal;
|
|
335
335
|
maxAttempts?: number;
|
|
336
|
+
retryRateLimit?: boolean;
|
|
336
337
|
retrySafety?: "safe" | "non-idempotent";
|
|
337
338
|
retryBaseDelayMs?: number;
|
|
338
339
|
sleep?: (ms: number) => Promise<void>;
|
|
@@ -435,6 +436,9 @@ export interface TelegramBridgeApiRuntimeDeps {
|
|
|
435
436
|
error: unknown,
|
|
436
437
|
details?: Record<string, unknown>,
|
|
437
438
|
) => void;
|
|
439
|
+
now?: () => number;
|
|
440
|
+
chatActionMinIntervalMs?: number;
|
|
441
|
+
chatActionMaxGates?: number;
|
|
438
442
|
}
|
|
439
443
|
|
|
440
444
|
export interface TelegramBridgeApiRuntime {
|
|
@@ -995,7 +999,13 @@ async function callTelegramWithRetry<TResponse>(
|
|
|
995
999
|
),
|
|
996
1000
|
);
|
|
997
1001
|
} catch (error) {
|
|
998
|
-
const retryable =
|
|
1002
|
+
const retryable =
|
|
1003
|
+
isRetryableTelegramApiError(error) &&
|
|
1004
|
+
!(
|
|
1005
|
+
options?.retryRateLimit === false &&
|
|
1006
|
+
error instanceof TelegramApiHttpError &&
|
|
1007
|
+
error.status === 429
|
|
1008
|
+
);
|
|
999
1009
|
if (!retrySafe) {
|
|
1000
1010
|
if (error instanceof TelegramApiHttpError && error.status === 429) {
|
|
1001
1011
|
if (attempt >= maxAttempts - 1) throw error;
|
|
@@ -1321,11 +1331,100 @@ export function createDefaultTelegramBridgeApiRuntime(deps: {
|
|
|
1321
1331
|
export function createTelegramBridgeApiRuntime(
|
|
1322
1332
|
deps: TelegramBridgeApiRuntimeDeps,
|
|
1323
1333
|
): TelegramBridgeApiRuntime {
|
|
1334
|
+
const now = deps.now ?? Date.now;
|
|
1335
|
+
const chatActionMinIntervalMs = Math.max(
|
|
1336
|
+
0,
|
|
1337
|
+
deps.chatActionMinIntervalMs ?? 2_000,
|
|
1338
|
+
);
|
|
1339
|
+
const chatActionMaxGates = Math.max(1, deps.chatActionMaxGates ?? 256);
|
|
1340
|
+
const chatActionGates = new Map<
|
|
1341
|
+
string,
|
|
1342
|
+
{ inFlight?: Promise<unknown>; notBeforeMs: number }
|
|
1343
|
+
>();
|
|
1344
|
+
const getChatActionKey = (
|
|
1345
|
+
method: string,
|
|
1346
|
+
body: Record<string, unknown>,
|
|
1347
|
+
): string | undefined => {
|
|
1348
|
+
if (method !== "sendChatAction") return undefined;
|
|
1349
|
+
const chatId = body.chat_id;
|
|
1350
|
+
const action = body.action;
|
|
1351
|
+
if (
|
|
1352
|
+
(typeof chatId !== "number" && typeof chatId !== "string") ||
|
|
1353
|
+
typeof action !== "string"
|
|
1354
|
+
) {
|
|
1355
|
+
return undefined;
|
|
1356
|
+
}
|
|
1357
|
+
const threadId = body.message_thread_id;
|
|
1358
|
+
return `${String(chatId)}:${
|
|
1359
|
+
typeof threadId === "number" || typeof threadId === "string"
|
|
1360
|
+
? String(threadId)
|
|
1361
|
+
: "all"
|
|
1362
|
+
}:${action}`;
|
|
1363
|
+
};
|
|
1324
1364
|
const callRecorded = async <TResponse>(
|
|
1325
1365
|
method: string,
|
|
1326
1366
|
body: Record<string, unknown>,
|
|
1327
1367
|
options?: TelegramApiCallOptions,
|
|
1328
1368
|
): Promise<TResponse> => {
|
|
1369
|
+
const chatActionKey = getChatActionKey(method, body);
|
|
1370
|
+
if (chatActionKey) {
|
|
1371
|
+
const nowMs = now();
|
|
1372
|
+
for (const [key, candidate] of chatActionGates) {
|
|
1373
|
+
if (!candidate.inFlight && nowMs >= candidate.notBeforeMs) {
|
|
1374
|
+
chatActionGates.delete(key);
|
|
1375
|
+
}
|
|
1376
|
+
}
|
|
1377
|
+
let gate = chatActionGates.get(chatActionKey);
|
|
1378
|
+
if (!gate) {
|
|
1379
|
+
if (chatActionGates.size >= chatActionMaxGates) return true as TResponse;
|
|
1380
|
+
gate = { notBeforeMs: 0 };
|
|
1381
|
+
chatActionGates.set(chatActionKey, gate);
|
|
1382
|
+
}
|
|
1383
|
+
if (gate.inFlight) return (await gate.inFlight) as TResponse;
|
|
1384
|
+
if (now() < gate.notBeforeMs) return true as TResponse;
|
|
1385
|
+
let request: Promise<TResponse>;
|
|
1386
|
+
request = Promise.resolve()
|
|
1387
|
+
.then(() =>
|
|
1388
|
+
deps.client.call<TResponse>(method, body, {
|
|
1389
|
+
...options,
|
|
1390
|
+
retryRateLimit: false,
|
|
1391
|
+
}),
|
|
1392
|
+
)
|
|
1393
|
+
.then((result) => {
|
|
1394
|
+
gate.notBeforeMs = now() + chatActionMinIntervalMs;
|
|
1395
|
+
return result;
|
|
1396
|
+
})
|
|
1397
|
+
.catch((error: unknown) => {
|
|
1398
|
+
if (error instanceof TelegramApiHttpError && error.status === 429) {
|
|
1399
|
+
const retryAfterMs = Math.max(
|
|
1400
|
+
chatActionMinIntervalMs,
|
|
1401
|
+
(error.retryAfterSeconds ?? 0) * 1_000,
|
|
1402
|
+
);
|
|
1403
|
+
gate.notBeforeMs = now() + retryAfterMs;
|
|
1404
|
+
deps.recordRuntimeEvent(
|
|
1405
|
+
"api",
|
|
1406
|
+
error,
|
|
1407
|
+
withTelegramTransportDiagnostics(error, {
|
|
1408
|
+
method,
|
|
1409
|
+
rateLimited: true,
|
|
1410
|
+
retryAfterMs,
|
|
1411
|
+
}),
|
|
1412
|
+
);
|
|
1413
|
+
return true as TResponse;
|
|
1414
|
+
}
|
|
1415
|
+
deps.recordRuntimeEvent(
|
|
1416
|
+
"api",
|
|
1417
|
+
error,
|
|
1418
|
+
withTelegramTransportDiagnostics(error, { method }),
|
|
1419
|
+
);
|
|
1420
|
+
throw error;
|
|
1421
|
+
})
|
|
1422
|
+
.finally(() => {
|
|
1423
|
+
if (gate.inFlight === request) gate.inFlight = undefined;
|
|
1424
|
+
});
|
|
1425
|
+
gate.inFlight = request;
|
|
1426
|
+
return request;
|
|
1427
|
+
}
|
|
1329
1428
|
try {
|
|
1330
1429
|
return await deps.client.call<TResponse>(method, body, options);
|
|
1331
1430
|
} catch (error) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@llblab/pi-telegram",
|
|
3
|
-
"version": "0.24.
|
|
3
|
+
"version": "0.24.4",
|
|
4
4
|
"private": false,
|
|
5
5
|
"publishConfig": {
|
|
6
6
|
"access": "public"
|
|
@@ -30,7 +30,7 @@
|
|
|
30
30
|
"test": "node --experimental-strip-types --test --test-reporter=dot tests/*.test.ts",
|
|
31
31
|
"test:verbose": "node --experimental-strip-types --test --test-reporter=spec tests/*.test.ts",
|
|
32
32
|
"typecheck": "tsc --noEmit",
|
|
33
|
-
"audit": "
|
|
33
|
+
"audit": "node --experimental-strip-types scripts/audit-dependencies.ts",
|
|
34
34
|
"pack:check": "npm pack --dry-run",
|
|
35
35
|
"validate": "npm run typecheck && npm test && npm run audit && npm run pack:check"
|
|
36
36
|
},
|
|
@@ -43,6 +43,7 @@
|
|
|
43
43
|
"BACKLOG.md",
|
|
44
44
|
"CHANGELOG.md",
|
|
45
45
|
"docs/",
|
|
46
|
+
"scripts/",
|
|
46
47
|
"screenshot.png"
|
|
47
48
|
],
|
|
48
49
|
"exports": {
|
|
@@ -75,22 +76,24 @@
|
|
|
75
76
|
"typescript": "latest"
|
|
76
77
|
},
|
|
77
78
|
"overrides": {
|
|
78
|
-
"
|
|
79
|
+
"brace-expansion": "5.0.7",
|
|
80
|
+
"protobufjs": "7.6.5",
|
|
79
81
|
"undici": "8.5.0",
|
|
80
82
|
"ws": "8.21.0",
|
|
81
83
|
"@earendil-works/pi-coding-agent": {
|
|
82
|
-
"
|
|
84
|
+
"brace-expansion": "5.0.7",
|
|
85
|
+
"protobufjs": "7.6.5",
|
|
83
86
|
"undici": "8.5.0",
|
|
84
87
|
"ws": "8.21.0",
|
|
85
88
|
"@google/genai": {
|
|
86
|
-
"protobufjs": "7.6.
|
|
89
|
+
"protobufjs": "7.6.5",
|
|
87
90
|
"ws": "8.21.0"
|
|
88
91
|
},
|
|
89
92
|
"@earendil-works/pi-ai": {
|
|
90
|
-
"protobufjs": "7.6.
|
|
93
|
+
"protobufjs": "7.6.5",
|
|
91
94
|
"ws": "8.21.0",
|
|
92
95
|
"@google/genai": {
|
|
93
|
-
"protobufjs": "7.6.
|
|
96
|
+
"protobufjs": "7.6.5",
|
|
94
97
|
"ws": "8.21.0"
|
|
95
98
|
}
|
|
96
99
|
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Dependency audit command adapter
|
|
3
|
+
* Runs raw npm audit, prints its output, and applies the fail-closed repository policy
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { spawnSync } from "node:child_process";
|
|
7
|
+
import { readFileSync } from "node:fs";
|
|
8
|
+
import path from "node:path";
|
|
9
|
+
|
|
10
|
+
import {
|
|
11
|
+
evaluateDependencyAudit,
|
|
12
|
+
type AuditReport,
|
|
13
|
+
} from "./dependency-audit-policy.ts";
|
|
14
|
+
|
|
15
|
+
function readInstalledPackageVersion(root: string, nodePath: string): string {
|
|
16
|
+
if (
|
|
17
|
+
path.isAbsolute(nodePath) ||
|
|
18
|
+
nodePath.includes("..") ||
|
|
19
|
+
!nodePath.startsWith("node_modules/")
|
|
20
|
+
) {
|
|
21
|
+
throw new Error(`unsafe installed package path: ${nodePath}`);
|
|
22
|
+
}
|
|
23
|
+
const packageJsonPath = path.join(root, nodePath, "package.json");
|
|
24
|
+
const parsed = JSON.parse(readFileSync(packageJsonPath, "utf8")) as {
|
|
25
|
+
version?: unknown;
|
|
26
|
+
};
|
|
27
|
+
if (typeof parsed.version !== "string") {
|
|
28
|
+
throw new Error(`installed package has no valid version: ${nodePath}`);
|
|
29
|
+
}
|
|
30
|
+
return parsed.version;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function run(): void {
|
|
34
|
+
const result = spawnSync("npm", ["audit", "--json"], {
|
|
35
|
+
cwd: process.cwd(),
|
|
36
|
+
encoding: "utf8",
|
|
37
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
38
|
+
});
|
|
39
|
+
if (result.stdout) process.stdout.write(result.stdout);
|
|
40
|
+
if (result.stderr) process.stderr.write(result.stderr);
|
|
41
|
+
if (result.error) throw result.error;
|
|
42
|
+
if (result.signal || (result.status !== 0 && result.status !== 1)) {
|
|
43
|
+
throw new Error(
|
|
44
|
+
`npm audit command failed: status=${String(result.status)} signal=${String(result.signal)}`,
|
|
45
|
+
);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
let report: AuditReport;
|
|
49
|
+
try {
|
|
50
|
+
report = JSON.parse(result.stdout) as AuditReport;
|
|
51
|
+
} catch (error) {
|
|
52
|
+
throw new Error(`could not parse npm audit JSON: ${String(error)}`);
|
|
53
|
+
}
|
|
54
|
+
const evaluation = evaluateDependencyAudit(
|
|
55
|
+
report,
|
|
56
|
+
(nodePath) => readInstalledPackageVersion(process.cwd(), nodePath),
|
|
57
|
+
);
|
|
58
|
+
const expectedStatus = evaluation.vulnerabilityCount === 0 ? 0 : 1;
|
|
59
|
+
if (result.status !== expectedStatus) {
|
|
60
|
+
throw new Error(
|
|
61
|
+
`npm audit exit status mismatch: expected ${expectedStatus}, got ${String(result.status)}`,
|
|
62
|
+
);
|
|
63
|
+
}
|
|
64
|
+
if (evaluation.vulnerabilityCount === 0) {
|
|
65
|
+
console.log("Dependency audit passed with zero vulnerabilities.");
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
console.warn(
|
|
69
|
+
`Accepted ${evaluation.vulnerabilityCount} audit graph entries rooted only in approved sources ${evaluation.acceptedAdvisorySources.join(", ")}; exception expires after 2026-08-21 UTC.`,
|
|
70
|
+
);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
try {
|
|
74
|
+
run();
|
|
75
|
+
} catch (error) {
|
|
76
|
+
console.error(error instanceof Error ? error.message : String(error));
|
|
77
|
+
process.exitCode = 1;
|
|
78
|
+
}
|
|
@@ -0,0 +1,300 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Fail-closed dependency audit policy
|
|
3
|
+
* Zones: repository validation, dependency security
|
|
4
|
+
* Validates the exact expiring Pi-shrinkwrap exception and installed package evidence
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
const EXCEPTION_EXPIRES_AT = Date.parse("2026-08-22T00:00:00Z");
|
|
8
|
+
|
|
9
|
+
interface AuditAdvisory {
|
|
10
|
+
source: number;
|
|
11
|
+
name: string;
|
|
12
|
+
url: string;
|
|
13
|
+
severity: string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
interface AuditVulnerability {
|
|
17
|
+
name: string;
|
|
18
|
+
severity: string;
|
|
19
|
+
via: Array<string | AuditAdvisory>;
|
|
20
|
+
nodes: string[];
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface AuditReport {
|
|
24
|
+
error?: unknown;
|
|
25
|
+
metadata?: {
|
|
26
|
+
vulnerabilities?: {
|
|
27
|
+
info?: number;
|
|
28
|
+
low?: number;
|
|
29
|
+
moderate?: number;
|
|
30
|
+
high?: number;
|
|
31
|
+
critical?: number;
|
|
32
|
+
total?: number;
|
|
33
|
+
};
|
|
34
|
+
};
|
|
35
|
+
vulnerabilities?: Record<string, AuditVulnerability>;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
interface AllowedAdvisory {
|
|
39
|
+
source: number;
|
|
40
|
+
packageName: string;
|
|
41
|
+
version: string;
|
|
42
|
+
severity: string;
|
|
43
|
+
url: string;
|
|
44
|
+
nodes: readonly string[];
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const ALLOWED_ADVISORIES = new Map<number, AllowedAdvisory>([
|
|
48
|
+
[
|
|
49
|
+
1123898,
|
|
50
|
+
{
|
|
51
|
+
source: 1123898,
|
|
52
|
+
packageName: "brace-expansion",
|
|
53
|
+
version: "5.0.6",
|
|
54
|
+
severity: "high",
|
|
55
|
+
url: "https://github.com/advisories/GHSA-3jxr-9vmj-r5cp",
|
|
56
|
+
nodes: [
|
|
57
|
+
"node_modules/@earendil-works/pi-coding-agent/node_modules/brace-expansion",
|
|
58
|
+
],
|
|
59
|
+
},
|
|
60
|
+
],
|
|
61
|
+
[
|
|
62
|
+
1123964,
|
|
63
|
+
{
|
|
64
|
+
source: 1123964,
|
|
65
|
+
packageName: "protobufjs",
|
|
66
|
+
version: "7.6.4",
|
|
67
|
+
severity: "moderate",
|
|
68
|
+
url: "https://github.com/advisories/GHSA-j3f2-48v5-ccww",
|
|
69
|
+
nodes: [
|
|
70
|
+
"node_modules/@earendil-works/pi-coding-agent/node_modules/protobufjs",
|
|
71
|
+
],
|
|
72
|
+
},
|
|
73
|
+
],
|
|
74
|
+
]);
|
|
75
|
+
|
|
76
|
+
const ALLOWED_GRAPH: Readonly<Record<string, readonly string[]>> = {
|
|
77
|
+
"brace-expansion": [],
|
|
78
|
+
protobufjs: [],
|
|
79
|
+
"@google/genai": ["protobufjs"],
|
|
80
|
+
"@earendil-works/pi-ai": ["@google/genai"],
|
|
81
|
+
"@earendil-works/pi-agent-core": ["@earendil-works/pi-ai"],
|
|
82
|
+
"@earendil-works/pi-coding-agent": [
|
|
83
|
+
"@earendil-works/pi-agent-core",
|
|
84
|
+
"@earendil-works/pi-ai",
|
|
85
|
+
],
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
const ALLOWED_GRAPH_SEVERITIES: Readonly<Record<string, string>> = {
|
|
89
|
+
"brace-expansion": "high",
|
|
90
|
+
protobufjs: "moderate",
|
|
91
|
+
"@google/genai": "moderate",
|
|
92
|
+
"@earendil-works/pi-ai": "moderate",
|
|
93
|
+
"@earendil-works/pi-agent-core": "moderate",
|
|
94
|
+
"@earendil-works/pi-coding-agent": "moderate",
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
const ALLOWED_GRAPH_NODES: Readonly<Record<string, readonly string[]>> = {
|
|
98
|
+
"brace-expansion": [
|
|
99
|
+
"node_modules/@earendil-works/pi-coding-agent/node_modules/brace-expansion",
|
|
100
|
+
],
|
|
101
|
+
protobufjs: [
|
|
102
|
+
"node_modules/@earendil-works/pi-coding-agent/node_modules/protobufjs",
|
|
103
|
+
],
|
|
104
|
+
"@google/genai": [
|
|
105
|
+
"node_modules/@google/genai",
|
|
106
|
+
"node_modules/@earendil-works/pi-coding-agent/node_modules/@google/genai",
|
|
107
|
+
],
|
|
108
|
+
"@earendil-works/pi-ai": [
|
|
109
|
+
"node_modules/@earendil-works/pi-ai",
|
|
110
|
+
"node_modules/@earendil-works/pi-coding-agent/node_modules/@earendil-works/pi-ai",
|
|
111
|
+
],
|
|
112
|
+
"@earendil-works/pi-agent-core": [
|
|
113
|
+
"node_modules/@earendil-works/pi-agent-core",
|
|
114
|
+
"node_modules/@earendil-works/pi-coding-agent/node_modules/@earendil-works/pi-agent-core",
|
|
115
|
+
],
|
|
116
|
+
"@earendil-works/pi-coding-agent": [
|
|
117
|
+
"node_modules/@earendil-works/pi-coding-agent",
|
|
118
|
+
],
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
export interface AuditEvaluation {
|
|
122
|
+
acceptedAdvisorySources: number[];
|
|
123
|
+
vulnerabilityCount: number;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function hasExactMembers(actual: readonly string[], expected: readonly string[]): boolean {
|
|
127
|
+
return (
|
|
128
|
+
actual.length === expected.length &&
|
|
129
|
+
new Set(actual).size === actual.length &&
|
|
130
|
+
expected.every((value) => actual.includes(value))
|
|
131
|
+
);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
export function evaluateDependencyAudit(
|
|
135
|
+
report: AuditReport,
|
|
136
|
+
readInstalledVersion: (nodePath: string) => string,
|
|
137
|
+
nowMs = Date.now(),
|
|
138
|
+
): AuditEvaluation {
|
|
139
|
+
if (report.error !== undefined) {
|
|
140
|
+
throw new Error("npm audit returned an error payload");
|
|
141
|
+
}
|
|
142
|
+
const vulnerabilities = report.vulnerabilities;
|
|
143
|
+
if (!vulnerabilities || typeof vulnerabilities !== "object") {
|
|
144
|
+
throw new Error("npm audit output is missing vulnerabilities");
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
const entries = Object.entries(vulnerabilities);
|
|
148
|
+
const counts = report.metadata?.vulnerabilities;
|
|
149
|
+
if (!counts) {
|
|
150
|
+
throw new Error("npm audit output is missing vulnerability metadata");
|
|
151
|
+
}
|
|
152
|
+
const countKeys = [
|
|
153
|
+
"info",
|
|
154
|
+
"low",
|
|
155
|
+
"moderate",
|
|
156
|
+
"high",
|
|
157
|
+
"critical",
|
|
158
|
+
"total",
|
|
159
|
+
] as const;
|
|
160
|
+
for (const key of countKeys) {
|
|
161
|
+
if (!Number.isInteger(counts[key]) || (counts[key] ?? -1) < 0) {
|
|
162
|
+
throw new Error(`npm audit metadata has invalid ${key} count`);
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
const severityTotal =
|
|
166
|
+
(counts.info ?? 0) +
|
|
167
|
+
(counts.low ?? 0) +
|
|
168
|
+
(counts.moderate ?? 0) +
|
|
169
|
+
(counts.high ?? 0) +
|
|
170
|
+
(counts.critical ?? 0);
|
|
171
|
+
if (severityTotal !== counts.total || counts.total !== entries.length) {
|
|
172
|
+
throw new Error(
|
|
173
|
+
`npm audit vulnerability total mismatch: metadata=${String(counts.total)}, severities=${severityTotal}, graph=${entries.length}`,
|
|
174
|
+
);
|
|
175
|
+
}
|
|
176
|
+
if (entries.length === 0) {
|
|
177
|
+
return { acceptedAdvisorySources: [], vulnerabilityCount: 0 };
|
|
178
|
+
}
|
|
179
|
+
if (nowMs >= EXCEPTION_EXPIRES_AT) {
|
|
180
|
+
throw new Error(
|
|
181
|
+
"approved dependency audit exception expired at 2026-08-22T00:00:00Z",
|
|
182
|
+
);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
const acceptedSources = new Set<number>();
|
|
186
|
+
for (const [name, vulnerability] of entries) {
|
|
187
|
+
if (vulnerability.name !== name) {
|
|
188
|
+
throw new Error(`npm audit graph key/name mismatch for ${name}`);
|
|
189
|
+
}
|
|
190
|
+
const allowedParents = ALLOWED_GRAPH[name];
|
|
191
|
+
const allowedNodes = ALLOWED_GRAPH_NODES[name];
|
|
192
|
+
const allowedSeverity = ALLOWED_GRAPH_SEVERITIES[name];
|
|
193
|
+
if (!allowedParents || !allowedNodes || !allowedSeverity) {
|
|
194
|
+
throw new Error(`unapproved vulnerable package: ${name}`);
|
|
195
|
+
}
|
|
196
|
+
if (vulnerability.severity !== allowedSeverity) {
|
|
197
|
+
throw new Error(
|
|
198
|
+
`unapproved severity for ${name}: expected ${allowedSeverity}, got ${vulnerability.severity}`,
|
|
199
|
+
);
|
|
200
|
+
}
|
|
201
|
+
if (!Array.isArray(vulnerability.via) || !Array.isArray(vulnerability.nodes)) {
|
|
202
|
+
throw new Error(`malformed npm audit graph entry for ${name}`);
|
|
203
|
+
}
|
|
204
|
+
if (!hasExactMembers(vulnerability.nodes, allowedNodes)) {
|
|
205
|
+
throw new Error(
|
|
206
|
+
`audit graph paths differ for ${name}: expected ${allowedNodes.join(",")}, got ${vulnerability.nodes.join(",")}`,
|
|
207
|
+
);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
const parentEdges = vulnerability.via.filter(
|
|
211
|
+
(via): via is string => typeof via === "string",
|
|
212
|
+
);
|
|
213
|
+
const advisories = vulnerability.via.filter(
|
|
214
|
+
(via): via is AuditAdvisory => typeof via !== "string",
|
|
215
|
+
);
|
|
216
|
+
if (allowedParents.length > 0) {
|
|
217
|
+
if (advisories.length > 0 || !hasExactMembers(parentEdges, allowedParents)) {
|
|
218
|
+
throw new Error(
|
|
219
|
+
`audit graph edges differ for ${name}: expected ${allowedParents.join(",")}, got ${parentEdges.join(",")}`,
|
|
220
|
+
);
|
|
221
|
+
}
|
|
222
|
+
for (const parent of parentEdges) {
|
|
223
|
+
if (!vulnerabilities[parent]) {
|
|
224
|
+
throw new Error(`missing npm audit graph node: ${parent}`);
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
continue;
|
|
228
|
+
}
|
|
229
|
+
if (parentEdges.length > 0 || advisories.length !== 1) {
|
|
230
|
+
throw new Error(`audit leaf shape differs for ${name}`);
|
|
231
|
+
}
|
|
232
|
+
const advisory = advisories[0];
|
|
233
|
+
const allowed = ALLOWED_ADVISORIES.get(advisory.source);
|
|
234
|
+
if (
|
|
235
|
+
!allowed ||
|
|
236
|
+
advisory.name !== allowed.packageName ||
|
|
237
|
+
advisory.url !== allowed.url ||
|
|
238
|
+
advisory.severity !== allowed.severity ||
|
|
239
|
+
name !== allowed.packageName ||
|
|
240
|
+
vulnerability.severity !== allowed.severity
|
|
241
|
+
) {
|
|
242
|
+
throw new Error(
|
|
243
|
+
`unapproved advisory for ${name}: source=${String(advisory.source)} url=${advisory.url}`,
|
|
244
|
+
);
|
|
245
|
+
}
|
|
246
|
+
acceptedSources.add(advisory.source);
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
const rootsByPackage = new Map<string, Set<number>>();
|
|
250
|
+
const resolveRoots = (name: string, stack: Set<string>): Set<number> => {
|
|
251
|
+
const cached = rootsByPackage.get(name);
|
|
252
|
+
if (cached) return cached;
|
|
253
|
+
if (stack.has(name)) throw new Error(`cycle in npm audit graph at ${name}`);
|
|
254
|
+
const vulnerability = vulnerabilities[name];
|
|
255
|
+
if (!vulnerability) throw new Error(`missing npm audit graph node: ${name}`);
|
|
256
|
+
const nextStack = new Set(stack).add(name);
|
|
257
|
+
const roots = new Set<number>();
|
|
258
|
+
for (const via of vulnerability.via) {
|
|
259
|
+
if (typeof via === "string") {
|
|
260
|
+
for (const source of resolveRoots(via, nextStack)) roots.add(source);
|
|
261
|
+
} else {
|
|
262
|
+
roots.add(via.source);
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
if (roots.size === 0) {
|
|
266
|
+
throw new Error(`npm audit graph node has no approved advisory root: ${name}`);
|
|
267
|
+
}
|
|
268
|
+
rootsByPackage.set(name, roots);
|
|
269
|
+
return roots;
|
|
270
|
+
};
|
|
271
|
+
|
|
272
|
+
for (const name of Object.keys(vulnerabilities)) resolveRoots(name, new Set());
|
|
273
|
+
|
|
274
|
+
for (const source of acceptedSources) {
|
|
275
|
+
const allowed = ALLOWED_ADVISORIES.get(source);
|
|
276
|
+
if (!allowed) throw new Error(`missing policy for advisory source ${source}`);
|
|
277
|
+
const vulnerability = vulnerabilities[allowed.packageName];
|
|
278
|
+
if (!vulnerability) {
|
|
279
|
+
throw new Error(`missing leaf package for advisory source ${source}`);
|
|
280
|
+
}
|
|
281
|
+
for (const nodePath of vulnerability.nodes) {
|
|
282
|
+
if (!allowed.nodes.includes(nodePath)) {
|
|
283
|
+
throw new Error(
|
|
284
|
+
`unapproved installed path for advisory source ${source}: ${nodePath}`,
|
|
285
|
+
);
|
|
286
|
+
}
|
|
287
|
+
const version = readInstalledVersion(nodePath);
|
|
288
|
+
if (version !== allowed.version) {
|
|
289
|
+
throw new Error(
|
|
290
|
+
`unapproved installed version at ${nodePath}: expected ${allowed.version}, got ${version}`,
|
|
291
|
+
);
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
return {
|
|
297
|
+
acceptedAdvisorySources: [...acceptedSources].sort((a, b) => a - b),
|
|
298
|
+
vulnerabilityCount: entries.length,
|
|
299
|
+
};
|
|
300
|
+
}
|