@llblab/pi-telegram 0.24.2 → 0.24.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +2 -2
- package/BACKLOG.md +12 -0
- package/CHANGELOG.md +6 -0
- package/README.md +2 -0
- package/docs/architecture.md +1 -1
- package/index.ts +3 -0
- package/lib/queue.ts +27 -3
- 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/AGENTS.md
CHANGED
|
@@ -120,7 +120,7 @@ The core product loop is mobile continuation: start or supervise work in the ter
|
|
|
120
120
|
- `telegram_voice` and `telegram_button` are not Pi tools; keep prompts/docs explicit that agents should author markup while voice synthesis provider extensions own TTS/OGG conversion, and pi-telegram owns button routing plus Telegram delivery
|
|
121
121
|
- Voice reply policy and prompt context are owned by pi-telegram's `telegram.json` `voice.replyMode`: missing, invalid, `hidden`, and legacy `manual` config resolve to `hidden` and add no `[voice]` context; `mirror` adds `[voice] delivery: automatic voice` only for voice/audio input, while `always` adds that same single factual line to every Telegram turn. Place voice context after `[outputs]` when handler output exists, otherwise after `[attachments]`; provider prompt contributions are optional provider-specific additions, not the default policy or delivery-composition channel.
|
|
122
122
|
- Optional `telegram.json` `time` may add `[time] YYYY-MM-DD HH:mm:ss <timezone>` to Telegram-originated prompts for wall-clock context. It is hidden by default, uses `time.injectionMode` values `hidden|always|interval`, stores `time.interval` in milliseconds, uses the system timezone, and should render last after `[attachments]`, `[outputs]`, and `[voice]` sections. The Settings row `🕒 Time injection: hidden|always|interval` controls `time.injectionMode` only.
|
|
123
|
-
- Agent activity status is native-only for the release path: Telegram uses `sendChatAction(typing)` and renders its native `…typing` indicator as the automatic work signal. When a connected instance starts any real agent run, send that indicator to the instance's own thread (and aggregate `All` surface), including Telegram turns, local/TUI prompts, and autonomous continuations such as Grow Loop; stop it at agent/session completion. This signal is independent from terminal `Active`, which remains scoped to Telegram-owned turns. Confirmed manual `/compact` and automatic compaction tied to an active Telegram turn also use native activity. Do not send activity merely for startup/connect/reload/recovery or add extra in-chat work surfaces without a new explicit design decision.
|
|
123
|
+
- Agent activity status is native-only for the release path: Telegram uses `sendChatAction(typing)` and renders its native `…typing` indicator as the automatic work signal. When a connected instance starts any real agent run, send that indicator to the instance's own thread (and aggregate `All` surface), including Telegram turns, local/TUI prompts, and autonomous continuations such as Grow Loop; stop it at agent/session completion. Keep at most one chat action in flight per loop, coalesce identical leader-side actions across follower traffic, and treat Telegram 429 `retry_after` as a shared suppression window rather than a delayed retry or persistent terminal error. This signal is independent from terminal `Active`, which remains scoped to Telegram-owned turns. Confirmed manual `/compact` and automatic compaction tied to an active Telegram turn also use native activity. Do not send activity merely for startup/connect/reload/recovery or add extra in-chat work surfaces without a new explicit design decision.
|
|
124
124
|
- Voice reply mode Settings UI standard: the top-level Settings row is `👄 Voice reply: hidden|mirror|always`; `hidden` is the true default, persists no `voice.replyMode`, adds no voice context, and still allows explicit agent-authored `telegram_voice`; `mirror` activates automatic voice context only for voice/audio input, and `always` activates it for every Telegram turn. The submenu title is `👄 Voice reply mode:`; choice buttons use lowercase labels with a model-style active dot (`🟢 hidden`, `🟢 mirror`) rather than per-mode emoji; the explanatory submenu body uses compact HTML-code bullets such as `<code>-</code> <code>hidden</code> (default): ...`. Preserve this wording/icons unless the operator explicitly asks to redesign it
|
|
125
125
|
- Outbound voice delivery is one fallback pipeline: configured `outboundHandlers` with `type: "voice"` run first in `telegram.json` order, then programmatic voice handlers, then registered voice synthesis providers as zero-config progressive fallbacks; provider extensions must not override operator-configured handlers
|
|
126
126
|
- `telegram_voice` text is arbitrary TTS-target text and the prompt should show only forms that include text to speak: `<!-- telegram_voice text="Short summary" -->` or `<!-- telegram_voice: Short summary -->`. Avoid raw Markdown/code/tables in voice text.
|
|
@@ -149,7 +149,7 @@ The core product loop is mobile continuation: start or supervise work in the ter
|
|
|
149
149
|
|
|
150
150
|
- Treat queue handling, compaction interaction, and lifecycle-hook state transitions as regression-prone areas; validate them after changing dispatch logic
|
|
151
151
|
- Keep the standard `npm run typecheck` gate strict about unused locals and parameters so stale imports and abandoned adapters fail validation instead of accumulating silently
|
|
152
|
-
- Keep push/PR CI on minimum Node `22.19.0` across Ubuntu, macOS, and Windows with typecheck, tests, and package dry-run on every OS; run audit once on Ubuntu, require the native Windows named-pipe roundtrip to execute, and keep Unix-only skips explicit by protected boundary.
|
|
152
|
+
- Keep push/PR CI on minimum Node `22.19.0` across Ubuntu, macOS, and Windows with typecheck, tests, and package dry-run on every OS; run audit once on Ubuntu, require the native Windows named-pipe roundtrip to execute, and keep Unix-only skips explicit by protected boundary. Through 2026-08-21 UTC only, the audit gate may accept the exact two Pi-shrinkwrap advisories documented in `BACKLOG.md`; it must verify source/GHSA/package/version/path/parent graph, fail every unknown finding, and fail closed at expiry.
|
|
153
153
|
- Route important runtime failures through the recent runtime event recorder so `/telegram-status` remains useful for post-mortem debugging. Keep the compact TUI status bar bounded to the generic `error` state; detailed error messages belong only in diagnostics/logs, never inline beside the status label.
|
|
154
154
|
- Treat remaining Markdown-to-HTML rendering as Telegram UI/compat output work, not generic Markdown rendering or assistant reply delivery
|
|
155
155
|
- Preserve literal code content in Telegram rendering
|
package/BACKLOG.md
CHANGED
|
@@ -2,6 +2,18 @@
|
|
|
2
2
|
|
|
3
3
|
_This backlog tracks only open release-relevant work: hotfixes, bounded maintenance, live runtime verification, evidence-gated Telegram client follow-ups, and upstream Pi API blockers. Completed outcomes and validation evidence belong in `CHANGELOG.md`, not in this queue._
|
|
4
4
|
|
|
5
|
+
## P0 — Expiring Pi Shrinkwrap Audit Exception
|
|
6
|
+
|
|
7
|
+
Deadline: 2026-08-21 UTC. The validation gate intentionally fails at `2026-08-22T00:00:00Z` if either exception remains.
|
|
8
|
+
|
|
9
|
+
Context: `@earendil-works/pi-coding-agent@0.80.6` publishes its own `npm-shrinkwrap.json`, which prevents this consumer package from replacing two installed vulnerable copies. The repository temporarily permits only `brace-expansion@5.0.6` / source `1123898` / `GHSA-3jxr-9vmj-r5cp` and `protobufjs@7.6.4` / source `1123964` / `GHSA-j3f2-48v5-ccww`, plus parent findings whose complete audit graph resolves exclusively to those sources. `npm run audit` verifies the exact graph, installed paths and versions, and expiry; every unknown or changed finding fails closed.
|
|
10
|
+
|
|
11
|
+
Open work:
|
|
12
|
+
|
|
13
|
+
- [ ] Upgrade to a Pi release whose published shrinkwrap installs `brace-expansion>=5.0.7` and `protobufjs>=7.6.5`, remove the exception policy, and restore a zero-finding raw `npm audit` before the deadline.
|
|
14
|
+
|
|
15
|
+
Done when: a clean `npm ci` followed by raw `npm audit` reports zero vulnerabilities and the expiring policy/overrides are removed.
|
|
16
|
+
|
|
5
17
|
## P1 — Native Windows Runtime Smoke
|
|
6
18
|
|
|
7
19
|
Context: Deterministic ownership, persistence, recovery, process, and named-pipe coverage passes on native hosted Windows. A live Telegram client remains the only unverified platform boundary and may be exercised in a later release cycle rather than tied to a specific version.
|
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,12 @@
|
|
|
2
2
|
|
|
3
3
|
## Unreleased
|
|
4
4
|
|
|
5
|
+
## 0.24.3: Queue And Typing Reliability Hotfix
|
|
6
|
+
|
|
7
|
+
- `Typing Backpressure`: Serialized each native activity loop to one in-flight action and coalesced identical leader-side chat actions across direct and follower traffic. Telegram 429 `retry_after` now opens a shared suppression window without delayed retry fan-out or terminal-status projection, while non-rate-limit retries and diagnostics remain intact. Impact: concurrent Threaded Mode activity no longer amplifies `sendChatAction` throttling into repeated `Telegram Error` states.
|
|
8
|
+
- `Queue Lifecycle`: Agent-end cleanup ignores only stale-context status failures after typing teardown, and control settlement is fenced to its originating deferred-dispatch generation. Impact: real status failures still reject, while callbacks from a replaced session cannot update status or dispatch queued work through an old context.
|
|
9
|
+
- `Dependency Audit`: Added a fail-closed exception through 2026-08-21 UTC for only `brace-expansion@5.0.6` (`GHSA-3jxr-9vmj-r5cp`) and `protobufjs@7.6.4` (`GHSA-j3f2-48v5-ccww`) copies pinned inside Pi's published shrinkwrap. Impact: all unknown findings, graph/path/version drift, and any still-present exception at the deadline fail validation while an upstream Pi release is pending.
|
|
10
|
+
|
|
5
11
|
## 0.24.2: Context Compression Hotfix
|
|
6
12
|
|
|
7
13
|
- `Project Context`: Consolidated the `0.24.1` release narrative into final domain outcomes, reduced the backlog to concrete open work, and made minimum-Node Ubuntu/macOS/Windows CI a durable engineering contract. Impact: future agents receive a smaller, current project model without losing release behavior or platform-validation requirements.
|
package/README.md
CHANGED
|
@@ -278,6 +278,8 @@ Full validation:
|
|
|
278
278
|
npm run validate
|
|
279
279
|
```
|
|
280
280
|
|
|
281
|
+
`npm run audit` executes raw npm audit and then applies a fail-closed temporary policy for two findings pinned inside Pi's published shrinkwrap: `brace-expansion@5.0.6` (`GHSA-3jxr-9vmj-r5cp`) and `protobufjs@7.6.4` (`GHSA-j3f2-48v5-ccww`). It verifies exact advisory sources, graph, installed paths/versions, and rejects every other finding. The exception expires after 2026-08-21 UTC; see [BACKLOG.md](./BACKLOG.md).
|
|
282
|
+
|
|
281
283
|
Project context:
|
|
282
284
|
|
|
283
285
|
- [AGENTS.md](./AGENTS.md) — engineering and runtime conventions.
|
package/docs/architecture.md
CHANGED
|
@@ -251,7 +251,7 @@ Native typing during compaction follows connected-instance activity rather than
|
|
|
251
251
|
- Automatic/session compaction without an active Telegram turn uses the connected instance's assigned target; an unconnected instance sends nothing.
|
|
252
252
|
- Thread-targeted typing is sent to the concrete thread and mirrored to `All` as the aggregate activity surface; completion, timeout, and shutdown stop the keyed loop.
|
|
253
253
|
|
|
254
|
-
At every connected instance `agent_start`, the lifecycle binding starts Telegram's native `…typing` indicator in that instance's assigned target, whether the run came from Telegram, the local TUI, or an autonomous continuation such as Grow Loop. Terminal `Active` remains Telegram-turn-specific; the native indicator answers the separate question of whether the instance is doing agent work. Assistant message start/update hooks still re-arm it during Telegram-owned turns so transient provider/model errors do not leave a continuing run without activity feedback, and agent/session completion stops it.
|
|
254
|
+
At every connected instance `agent_start`, the lifecycle binding starts Telegram's native `…typing` indicator in that instance's assigned target, whether the run came from Telegram, the local TUI, or an autonomous continuation such as Grow Loop. Terminal `Active` remains Telegram-turn-specific; the native indicator answers the separate question of whether the instance is doing agent work. Each loop keeps one action in flight, while the leader API runtime coalesces identical chat/thread/action calls across local and follower traffic for two seconds; expired gates prune opportunistically and at most 256 currently active keys are retained. A Telegram 429 response opens the exact action's shared `retry_after` suppression window without scheduling delayed retries or projecting expected activity throttling as a terminal status error. Assistant message start/update hooks still re-arm it during Telegram-owned turns so transient provider/model errors do not leave a continuing run without activity feedback, and agent/session completion stops it.
|
|
255
255
|
|
|
256
256
|
### Rendering And Delivery
|
|
257
257
|
|
package/index.ts
CHANGED
|
@@ -502,6 +502,9 @@ export default function (pi: Pi.ExtensionAPI) {
|
|
|
502
502
|
isIdle,
|
|
503
503
|
hasPendingMessages,
|
|
504
504
|
hasDispatchContext: deferredQueueDispatchRuntime.isBound,
|
|
505
|
+
getDispatchGeneration: deferredQueueDispatchRuntime.getGeneration,
|
|
506
|
+
isDispatchGenerationActive:
|
|
507
|
+
deferredQueueDispatchRuntime.isGenerationActive,
|
|
505
508
|
isQueueItemTransportActive(item) {
|
|
506
509
|
return telegramTransportStampRuntime.isActive(item.transportStamp);
|
|
507
510
|
},
|
package/lib/queue.ts
CHANGED
|
@@ -1258,20 +1258,27 @@ export async function handleTelegramAgentEndRuntime<
|
|
|
1258
1258
|
const isDeliveryActive = (): boolean =>
|
|
1259
1259
|
deps.isSessionActive?.() !== false &&
|
|
1260
1260
|
(!turn || deps.isTurnTransportActive?.(turn) !== false);
|
|
1261
|
+
const updateStatusIgnoringStaleContext = (): void => {
|
|
1262
|
+
try {
|
|
1263
|
+
deps.updateStatus();
|
|
1264
|
+
} catch (error) {
|
|
1265
|
+
if (!isTelegramStaleContextError(error)) throw error;
|
|
1266
|
+
}
|
|
1267
|
+
};
|
|
1261
1268
|
if (!isDeliveryActive()) {
|
|
1262
1269
|
deps.resetRuntimeState();
|
|
1263
|
-
|
|
1270
|
+
updateStatusIgnoringStaleContext();
|
|
1264
1271
|
deps.dispatchNextQueuedTelegramTurn();
|
|
1265
1272
|
return;
|
|
1266
1273
|
}
|
|
1267
1274
|
deps.resetRuntimeState();
|
|
1268
1275
|
await deps.waitForTypingIdle?.();
|
|
1269
1276
|
if (!isDeliveryActive()) {
|
|
1270
|
-
|
|
1277
|
+
updateStatusIgnoringStaleContext();
|
|
1271
1278
|
deps.dispatchNextQueuedTelegramTurn();
|
|
1272
1279
|
return;
|
|
1273
1280
|
}
|
|
1274
|
-
|
|
1281
|
+
updateStatusIgnoringStaleContext();
|
|
1275
1282
|
const endPlan = buildTelegramAgentEndPlan({
|
|
1276
1283
|
hasTurn: !!turn,
|
|
1277
1284
|
stopReason: assistant.stopReason,
|
|
@@ -2117,6 +2124,8 @@ export interface TelegramDeferredQueueDispatchRuntime<TContext = unknown> {
|
|
|
2117
2124
|
bind: (ctx: TContext) => void;
|
|
2118
2125
|
unbind: () => void;
|
|
2119
2126
|
isBound: () => boolean;
|
|
2127
|
+
getGeneration: () => number;
|
|
2128
|
+
isGenerationActive: (generation: number) => boolean;
|
|
2120
2129
|
request: (dispatchNextQueuedTelegramTurn: (ctx: TContext) => void) => void;
|
|
2121
2130
|
}
|
|
2122
2131
|
|
|
@@ -2149,6 +2158,9 @@ export function createTelegramDeferredQueueDispatchRuntime<TContext = unknown>(
|
|
|
2149
2158
|
clearTimers();
|
|
2150
2159
|
},
|
|
2151
2160
|
isBound: () => boundContext !== undefined,
|
|
2161
|
+
getGeneration: () => generation,
|
|
2162
|
+
isGenerationActive: (expectedGeneration) =>
|
|
2163
|
+
boundContext !== undefined && generation === expectedGeneration,
|
|
2152
2164
|
request: (dispatchNextQueuedTelegramTurn) => {
|
|
2153
2165
|
if (boundContext === undefined) return;
|
|
2154
2166
|
const scheduledGeneration = generation;
|
|
@@ -2265,6 +2277,8 @@ export interface TelegramQueueDispatchControllerDeps<
|
|
|
2265
2277
|
setQueuedItems: (items: TelegramQueueItem<TContext>[]) => void;
|
|
2266
2278
|
canDispatch: (ctx: TContext) => boolean;
|
|
2267
2279
|
hasDispatchContext?: () => boolean;
|
|
2280
|
+
getDispatchGeneration?: () => number;
|
|
2281
|
+
isDispatchGenerationActive?: (generation: number) => boolean;
|
|
2268
2282
|
updateStatus: (ctx: TContext, error?: string) => void;
|
|
2269
2283
|
sendTextReply: TelegramControlRuntimeDeps<TContext>["sendTextReply"];
|
|
2270
2284
|
onPromptDispatchStart: (ctx: TContext, chatId: number) => void;
|
|
@@ -2318,6 +2332,8 @@ export function createTelegramQueueDispatchRuntime<TContext = unknown>(
|
|
|
2318
2332
|
hasPendingMessages: deps.hasPendingMessages,
|
|
2319
2333
|
}),
|
|
2320
2334
|
hasDispatchContext: deps.hasDispatchContext,
|
|
2335
|
+
getDispatchGeneration: deps.getDispatchGeneration,
|
|
2336
|
+
isDispatchGenerationActive: deps.isDispatchGenerationActive,
|
|
2321
2337
|
updateStatus: deps.updateStatus,
|
|
2322
2338
|
sendTextReply: deps.sendTextReply,
|
|
2323
2339
|
onPromptDispatchStart: deps.onPromptDispatchStart,
|
|
@@ -2363,6 +2379,7 @@ export function createTelegramQueueDispatchController<TContext = unknown>(
|
|
|
2363
2379
|
executeTelegramQueueDispatchPlan(dispatchPlan, {
|
|
2364
2380
|
executeControlItem: (item) => {
|
|
2365
2381
|
controlDispatchPending = true;
|
|
2382
|
+
const dispatchGeneration = deps.getDispatchGeneration?.();
|
|
2366
2383
|
deps.updateStatus(ctx);
|
|
2367
2384
|
void executeTelegramControlItemRuntime(item, {
|
|
2368
2385
|
ctx,
|
|
@@ -2371,6 +2388,13 @@ export function createTelegramQueueDispatchController<TContext = unknown>(
|
|
|
2371
2388
|
onSettled: () => {
|
|
2372
2389
|
controlDispatchPending = false;
|
|
2373
2390
|
if (deps.hasDispatchContext && !deps.hasDispatchContext()) return;
|
|
2391
|
+
if (
|
|
2392
|
+
dispatchGeneration !== undefined &&
|
|
2393
|
+
deps.isDispatchGenerationActive &&
|
|
2394
|
+
!deps.isDispatchGenerationActive(dispatchGeneration)
|
|
2395
|
+
) {
|
|
2396
|
+
return;
|
|
2397
|
+
}
|
|
2374
2398
|
deps.updateStatus(ctx);
|
|
2375
2399
|
controller.dispatchNext(ctx);
|
|
2376
2400
|
},
|
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.3",
|
|
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
|
+
}
|