@vola-trebla/croak 0.0.0-stage → 0.1.0

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.
Files changed (60) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +283 -2
  3. package/dist/cli.js +373 -0
  4. package/dist/crypto/der.js +136 -0
  5. package/dist/crypto/fields.js +60 -0
  6. package/dist/crypto/registry-signature.js +88 -0
  7. package/dist/crypto/rekor.js +70 -0
  8. package/dist/crypto/sigstore.js +121 -0
  9. package/dist/detectors/git-config.js +137 -0
  10. package/dist/detectors/hidden-code.js +58 -0
  11. package/dist/detectors/ide-autorun.js +136 -0
  12. package/dist/detectors/index.js +20 -0
  13. package/dist/detectors/lifecycle-script.js +337 -0
  14. package/dist/detectors/node-modules-present.js +32 -0
  15. package/dist/detectors/obfuscation.js +124 -0
  16. package/dist/detectors/unscanned.js +41 -0
  17. package/dist/hook/hook.js +391 -0
  18. package/dist/hook/install-commands.js +927 -0
  19. package/dist/install/content-cache.js +94 -0
  20. package/dist/install/content.js +42 -0
  21. package/dist/install/detectors.js +79 -0
  22. package/dist/install/installed.js +99 -0
  23. package/dist/install/lockfile.js +72 -0
  24. package/dist/install/metadata.js +159 -0
  25. package/dist/install/plan.js +31 -0
  26. package/dist/install/pnpm-builds.js +111 -0
  27. package/dist/install/pnpm-lockfile.js +102 -0
  28. package/dist/install/popular.js +177 -0
  29. package/dist/install/project.js +223 -0
  30. package/dist/install/provenance.js +77 -0
  31. package/dist/install/registry.js +274 -0
  32. package/dist/install/report.js +26 -0
  33. package/dist/install/sanitize.js +88 -0
  34. package/dist/install/scan.js +302 -0
  35. package/dist/install/signature.js +62 -0
  36. package/dist/install/tar.js +178 -0
  37. package/dist/install/workspace.js +31 -0
  38. package/dist/install/yaml.js +282 -0
  39. package/dist/llm/client.js +22 -0
  40. package/dist/llm/cost.js +26 -0
  41. package/dist/llm/diff.js +114 -0
  42. package/dist/llm/escalate.js +14 -0
  43. package/dist/llm/plan-step.js +91 -0
  44. package/dist/llm/select.js +21 -0
  45. package/dist/llm/triage.js +111 -0
  46. package/dist/repo-report.js +95 -0
  47. package/dist/repo-snapshot.js +157 -0
  48. package/dist/scan.js +48 -0
  49. package/dist/signals/code.js +136 -0
  50. package/dist/signals/git-config.js +58 -0
  51. package/dist/signals/hidden.js +77 -0
  52. package/dist/signals/jsonc.js +100 -0
  53. package/dist/signals/obfuscation.js +98 -0
  54. package/dist/signals/shell.js +717 -0
  55. package/dist/signals/text.js +2 -0
  56. package/dist/ui.js +12 -0
  57. package/dist/verdict.js +20 -0
  58. package/package.json +71 -4
  59. package/scripts/croak-hook.sh +59 -0
  60. package/scripts/croak-install.sh +262 -0
@@ -0,0 +1,274 @@
1
+ import { createHash } from "node:crypto";
2
+ /**
3
+ * A minimal npm registry client: one packument per package, read with a time
4
+ * limit and a size limit. Package names come from untrusted lockfiles, so they
5
+ * are validated against npm's naming rules before they become a URL.
6
+ */
7
+ export const REGISTRY = "https://registry.npmjs.org";
8
+ // @prisma/client's package document alone is 68 MB (2026-10).
9
+ const DEFAULT_MAX_BYTES = 200 * 1024 * 1024;
10
+ const DEFAULT_TIMEOUT_MS = 15_000;
11
+ // Legacy packages may contain capitals (JSONStream); new ones may not.
12
+ const NAME = /^(?:@[a-zA-Z0-9-~][a-zA-Z0-9-._~]*\/)?[a-zA-Z0-9-~][a-zA-Z0-9-._~]*$/;
13
+ const DAY_MS = 24 * 60 * 60 * 1000;
14
+ const MAX_TARBALL_BYTES = 100 * 1024 * 1024;
15
+ const SRI_ALGORITHMS = ["sha512", "sha384", "sha256", "sha1"];
16
+ export class RegistryError extends Error {
17
+ constructor(message) {
18
+ super(message);
19
+ this.name = "RegistryError";
20
+ }
21
+ }
22
+ export function packageUrl(name) {
23
+ if (name.length > 214 || !NAME.test(name) || name.includes("..")) {
24
+ throw new RegistryError(`not a valid npm package name: ${JSON.stringify(name)}`);
25
+ }
26
+ return `${REGISTRY}/${name.startsWith("@") ? name.replace("/", "%2F") : name}`;
27
+ }
28
+ async function readCappedBytes(response, maxBytes) {
29
+ const reader = response.body?.getReader();
30
+ if (reader === undefined)
31
+ return Buffer.alloc(0);
32
+ const chunks = [];
33
+ let total = 0;
34
+ for (;;) {
35
+ let chunk;
36
+ try {
37
+ chunk = await reader.read();
38
+ }
39
+ catch (error) {
40
+ // A timeout or a reset after the headers: the check is incomplete, not a crash.
41
+ throw new RegistryError(`reading the response: ${error instanceof Error ? error.message : String(error)}`);
42
+ }
43
+ const { done, value } = chunk;
44
+ if (done)
45
+ break;
46
+ total += value.byteLength;
47
+ if (total > maxBytes) {
48
+ await reader.cancel();
49
+ throw new RegistryError(`response too large (over ${maxBytes} bytes)`);
50
+ }
51
+ chunks.push(value);
52
+ }
53
+ return Buffer.concat(chunks);
54
+ }
55
+ async function readCapped(response, maxBytes) {
56
+ return (await readCappedBytes(response, maxBytes)).toString("utf8");
57
+ }
58
+ /** A JSON document from the registry, or "not-found" on 404. */
59
+ async function getJson(url, label, options, maxBytes) {
60
+ const doFetch = options.fetch ?? fetch;
61
+ let response;
62
+ try {
63
+ response = await doFetch(url, {
64
+ headers: { accept: "application/json" },
65
+ signal: AbortSignal.timeout(options.timeoutMs ?? DEFAULT_TIMEOUT_MS),
66
+ });
67
+ }
68
+ catch (error) {
69
+ throw new RegistryError(`${label}: ${error instanceof Error ? error.message : String(error)}`);
70
+ }
71
+ if (response.status === 404)
72
+ return "not-found";
73
+ if (!response.ok)
74
+ throw new RegistryError(`${label}: registry answered ${response.status}`);
75
+ const text = await readCapped(response, maxBytes);
76
+ try {
77
+ return JSON.parse(text);
78
+ }
79
+ catch {
80
+ throw new RegistryError(`${label}: registry response is not JSON`);
81
+ }
82
+ }
83
+ /** The package document, or "not-found" when the registry has no such package. */
84
+ export async function fetchPackument(name, options = {}) {
85
+ return getJson(packageUrl(name), name, options, options.maxBytes ?? DEFAULT_MAX_BYTES);
86
+ }
87
+ // A provenance bundle is ~10 KB; a version has two attestations.
88
+ const MAX_ATTESTATIONS_BYTES = 2 * 1024 * 1024;
89
+ const VERSION = /^[0-9A-Za-z.+-]{1,256}$/;
90
+ /**
91
+ * A version's SLSA provenance bundle from the registry's attestations
92
+ * endpoint, or "missing" when there is none. The URL is built here, never
93
+ * taken from the packument's dist.attestations.url.
94
+ */
95
+ export async function fetchProvenanceBundle(name, version, options = {}) {
96
+ if (!VERSION.test(version) || version.includes(".."))
97
+ throw new RegistryError(`not a valid npm version: ${JSON.stringify(version)}`);
98
+ const path = packageUrl(name).slice(REGISTRY.length + 1);
99
+ const doc = await getJson(`${REGISTRY}/-/npm/v1/attestations/${path}@${version}`, `${name}@${version} attestations`, options, MAX_ATTESTATIONS_BYTES);
100
+ const list = isRecord(doc) && Array.isArray(doc.attestations) ? doc.attestations : [];
101
+ const slsa = list.find((a) => isRecord(a) &&
102
+ typeof a.predicateType === "string" &&
103
+ a.predicateType.startsWith("https://slsa.dev/provenance/"));
104
+ return isRecord(slsa) && slsa.bundle !== undefined ? slsa.bundle : "missing";
105
+ }
106
+ function isRecord(value) {
107
+ return typeof value === "object" && value !== null && !Array.isArray(value);
108
+ }
109
+ const date = (value) => {
110
+ if (typeof value !== "string")
111
+ return undefined;
112
+ const parsed = new Date(value);
113
+ return Number.isNaN(parsed.getTime()) ? undefined : parsed;
114
+ };
115
+ /** Days a version must have been public to count as history (D25's 7 days). */
116
+ export const SETTLED_DAYS = 7;
117
+ const SEMVER = /^(\d{1,15})\.(\d{1,15})\.(\d{1,15})(?:-([0-9A-Za-z.-]{1,100}))?(?:\+.*)?$/;
118
+ /** Semver order (prereleases before their release); undefined if either is not semver. */
119
+ export function compareVersions(a, b) {
120
+ const x = SEMVER.exec(a);
121
+ const y = SEMVER.exec(b);
122
+ if (x === null || y === null)
123
+ return undefined;
124
+ for (let i = 1; i <= 3; i++) {
125
+ const d = Number(x[i]) - Number(y[i]);
126
+ if (d !== 0)
127
+ return d;
128
+ }
129
+ const [px, py] = [x[4], y[4]];
130
+ if (px === py)
131
+ return 0;
132
+ if (px === undefined)
133
+ return 1;
134
+ if (py === undefined)
135
+ return -1;
136
+ return px < py ? -1 : 1;
137
+ }
138
+ /** The highest settled version below `version`, else the newest settled one. */
139
+ function predecessor(version, earlier) {
140
+ const below = earlier.filter((v) => (compareVersions(v.version, version) ?? 1) < 0);
141
+ return (below.reduce((best, v) => best === undefined ||
142
+ (compareVersions(v.version, best.version) ?? 0) > 0
143
+ ? v
144
+ : best, undefined) ?? earlier[0]);
145
+ }
146
+ const INSTALL_SCRIPTS = ["preinstall", "install", "postinstall"];
147
+ function versionInfo(meta) {
148
+ const record = isRecord(meta) ? meta : {};
149
+ const dist = isRecord(record.dist) ? record.dist : {};
150
+ const user = isRecord(record._npmUser) ? record._npmUser : {};
151
+ const scripts = isRecord(record.scripts) ? record.scripts : {};
152
+ const deps = [record.dependencies, record.optionalDependencies].flatMap((d) => (isRecord(d) ? Object.keys(d) : []));
153
+ return {
154
+ hasProvenance: isRecord(dist.attestations) && dist.attestations.provenance !== undefined,
155
+ integrity: typeof dist.integrity === "string" ? dist.integrity : undefined,
156
+ publisher: typeof user.name === "string" ? user.name : undefined,
157
+ installScripts: INSTALL_SCRIPTS.filter((name) => typeof scripts[name] === "string"),
158
+ installCommands: INSTALL_SCRIPTS.filter((name) => typeof scripts[name] === "string")
159
+ .map((name) => `${name}: ${String(scripts[name])}`)
160
+ .join("\n"),
161
+ dependencies: [...new Set(deps)].sort(),
162
+ };
163
+ }
164
+ /** What the registry says about one version, or undefined if it does not list it. */
165
+ export function versionFacts(packument, version, now = new Date()) {
166
+ if (!isRecord(packument) ||
167
+ !isRecord(packument.versions) ||
168
+ !(version in packument.versions))
169
+ return undefined;
170
+ const versions = packument.versions;
171
+ const meta = versions[version];
172
+ const dist = isRecord(meta) && isRecord(meta.dist) ? meta.dist : {};
173
+ const time = isRecord(packument.time) ? packument.time : {};
174
+ const published = date(time[version]);
175
+ const earlier = Object.keys(versions)
176
+ .filter((v) => v !== version)
177
+ .map((v) => ({ v, at: date(time[v]) }))
178
+ .filter((e) => e.at !== undefined &&
179
+ published !== undefined &&
180
+ published.getTime() - e.at.getTime() >= SETTLED_DAYS * DAY_MS)
181
+ .sort((a, b) => b.at.getTime() - a.at.getTime())
182
+ .slice(0, 10)
183
+ .map(({ v }) => ({ version: v, ...versionInfo(versions[v]) }));
184
+ return {
185
+ created: date(time.created),
186
+ published,
187
+ ageDays: published === undefined
188
+ ? undefined
189
+ : Math.floor((now.getTime() - published.getTime()) / DAY_MS),
190
+ ...versionInfo(meta),
191
+ signatures: dist.signatures,
192
+ earlier,
193
+ previous: predecessor(version, earlier),
194
+ };
195
+ }
196
+ /** A version's tarball URL and integrity, from its packument (the LLM diff's "before"). */
197
+ export function tarballOf(packument, version) {
198
+ if (!isRecord(packument) || !isRecord(packument.versions))
199
+ return undefined;
200
+ const meta = packument.versions[version];
201
+ const dist = isRecord(meta) && isRecord(meta.dist) ? meta.dist : undefined;
202
+ if (dist === undefined || typeof dist.tarball !== "string")
203
+ return undefined;
204
+ return {
205
+ url: dist.tarball,
206
+ integrity: typeof dist.integrity === "string" ? dist.integrity : undefined,
207
+ };
208
+ }
209
+ /** The downloaded tarball does not match the lockfile's integrity hash. */
210
+ export class IntegrityError extends Error {
211
+ constructor(message) {
212
+ super(message);
213
+ this.name = "IntegrityError";
214
+ }
215
+ }
216
+ /** Checks data against an SRI string ("sha512-…"), using the strongest algorithm it lists. */
217
+ export function verifyIntegrity(data, integrity) {
218
+ const hashes = new Map(integrity
219
+ .trim()
220
+ .split(/\s+/)
221
+ .map((token) => [
222
+ token.slice(0, token.indexOf("-")),
223
+ token.slice(token.indexOf("-") + 1),
224
+ ]));
225
+ const algorithm = SRI_ALGORITHMS.find((alg) => hashes.has(alg));
226
+ if (algorithm === undefined)
227
+ throw new IntegrityError(`unsupported integrity format: ${integrity.slice(0, 40)}`);
228
+ const actual = createHash(algorithm).update(data).digest("base64");
229
+ if (actual !== hashes.get(algorithm))
230
+ throw new IntegrityError(`${algorithm} does not match the lockfile`);
231
+ }
232
+ /**
233
+ * The tarball URL if `resolved` points at the public registry over https,
234
+ * with no credentials or explicit port. One predicate for what croak
235
+ * downloads, checks online, and reports as a non-registry source, so a
236
+ * package can never fall between them unchecked.
237
+ */
238
+ export function registryTarball(resolved) {
239
+ if (resolved === undefined || !URL.canParse(resolved))
240
+ return undefined;
241
+ const url = new URL(resolved);
242
+ return url.protocol === "https:" &&
243
+ url.hostname === new URL(REGISTRY).hostname &&
244
+ url.port === "" &&
245
+ url.username === "" &&
246
+ url.password === ""
247
+ ? url
248
+ : undefined;
249
+ }
250
+ /**
251
+ * Downloads a tarball from the public registry (https only) with a timeout
252
+ * and a 100 MB cap, and verifies it against the lockfile's integrity hash.
253
+ */
254
+ export async function fetchTarball(url, integrity, options = {}) {
255
+ const tarball = registryTarball(url);
256
+ if (tarball === undefined)
257
+ throw new RegistryError(`refusing to download a tarball from outside ${REGISTRY}: ${url}`);
258
+ const doFetch = options.fetch ?? fetch;
259
+ let response;
260
+ try {
261
+ response = await doFetch(tarball.href, {
262
+ signal: AbortSignal.timeout(options.timeoutMs ?? DEFAULT_TIMEOUT_MS * 4),
263
+ });
264
+ }
265
+ catch (error) {
266
+ throw new RegistryError(`${url}: ${error instanceof Error ? error.message : String(error)}`);
267
+ }
268
+ if (!response.ok)
269
+ throw new RegistryError(`${url}: registry answered ${response.status}`);
270
+ const data = await readCappedBytes(response, options.maxBytes ?? MAX_TARBALL_BYTES);
271
+ if (integrity !== undefined)
272
+ verifyIntegrity(data, integrity);
273
+ return data;
274
+ }
@@ -0,0 +1,26 @@
1
+ import { REPORT_SCHEMA, renderVerdict, safe, } from "../repo-report.js";
2
+ import { INCOMPLETE_ICON } from "../ui.js";
3
+ export function renderPlan(outcome, options) {
4
+ const title = "croak plan";
5
+ if (outcome.status === "incomplete") {
6
+ const status = options.emoji
7
+ ? `— ${INCOMPLETE_ICON} incomplete`
8
+ : "- INCOMPLETE";
9
+ return [
10
+ options.emoji ? `🐸 ${title} ${status}` : `${title} ${status}`,
11
+ "",
12
+ ` could not finish: ${safe(outcome.reason)}`,
13
+ " An incomplete check is not a clean check.",
14
+ "",
15
+ ].join("\n");
16
+ }
17
+ return renderVerdict({
18
+ title,
19
+ verdict: outcome.verdict,
20
+ findings: outcome.findings,
21
+ footer: `${outcome.added} new and ${outcome.changed} updated packages checked · ${Math.round(outcome.durationMs)} ms${outcome.llm === undefined ? "" : ` · ${outcome.llm.model}: ${outcome.llm.calls} call(s), $${outcome.llm.costUsd.toFixed(4)}`}`,
22
+ }, options);
23
+ }
24
+ export function planToJson(outcome) {
25
+ return `${JSON.stringify({ schema: REPORT_SCHEMA, kind: "install-plan", ...outcome }, null, 2)}\n`;
26
+ }
@@ -0,0 +1,88 @@
1
+ import { stripBom } from "../signals/text.js";
2
+ import { parseYamlDocuments } from "./yaml.js";
3
+ /**
4
+ * What the planning wrapper may hand to a package manager from an untrusted
5
+ * project (M6, D38). Planning must run no project code, but pnpm runs code
6
+ * before it resolves anything: corepack downloads and runs the pnpm version a
7
+ * project's `packageManager` names (checked: pnpm 12 ran pnpm 9.15.0, even
8
+ * with manage-package-manager-versions=false), and pnpm-workspace.yaml can
9
+ * load pnpmfiles and config dependencies. So the root manifest loses its
10
+ * package-manager pins, and the workspace file keeps only settings that shape
11
+ * resolution. Both come out as JSON, which YAML reads too.
12
+ */
13
+ export class SanitizeError extends Error {
14
+ constructor(message) {
15
+ super(message);
16
+ this.name = "SanitizeError";
17
+ }
18
+ }
19
+ function isRecord(value) {
20
+ return typeof value === "object" && value !== null && !Array.isArray(value);
21
+ }
22
+ /** The root package.json without the fields that make corepack fetch a package manager. */
23
+ export function sanitizeRootManifest(text) {
24
+ let manifest;
25
+ try {
26
+ manifest = JSON.parse(stripBom(text));
27
+ }
28
+ catch {
29
+ throw new SanitizeError("package.json is not valid JSON");
30
+ }
31
+ if (!isRecord(manifest))
32
+ throw new SanitizeError("package.json is not an object");
33
+ const { packageManager: _pm, devEngines: _de, pnpm, ...rest } = manifest;
34
+ // pnpm 10 reads settings from the root manifest's "pnpm" field too.
35
+ const kept = isRecord(pnpm)
36
+ ? {
37
+ ...rest,
38
+ pnpm: Object.fromEntries(Object.entries(pnpm).filter(([key]) => WORKSPACE_KEYS.has(key))),
39
+ }
40
+ : rest;
41
+ return `${JSON.stringify(kept, null, 2)}\n`;
42
+ }
43
+ /** Settings that only shape what pnpm resolves; nothing that loads or runs code, or moves the registry. */
44
+ const WORKSPACE_KEYS = new Set([
45
+ "packages",
46
+ "catalog",
47
+ "catalogs",
48
+ "overrides",
49
+ "packageExtensions",
50
+ "peerDependencyRules",
51
+ "autoInstallPeers",
52
+ "dedupePeerDependents",
53
+ "excludeLinksFromLockfile",
54
+ "resolutionMode",
55
+ "linkWorkspacePackages",
56
+ "injectWorkspacePackages",
57
+ "sharedWorkspaceLockfile",
58
+ "supportedArchitectures",
59
+ "ignoredOptionalDependencies",
60
+ ]);
61
+ /** YAML scalars come back as strings; pnpm wants booleans where YAML had them. */
62
+ function typed(value) {
63
+ if (value === "true" || value === "True" || value === "TRUE")
64
+ return true;
65
+ if (value === "false" || value === "False" || value === "FALSE")
66
+ return false;
67
+ if (Array.isArray(value))
68
+ return value.map(typed);
69
+ if (isRecord(value))
70
+ return Object.fromEntries(Object.entries(value).map(([k, v]) => [k, typed(v)]));
71
+ return value;
72
+ }
73
+ export function sanitizePnpmWorkspace(text) {
74
+ let docs;
75
+ try {
76
+ docs = parseYamlDocuments(text);
77
+ }
78
+ catch (error) {
79
+ throw new SanitizeError(`pnpm-workspace.yaml: ${error instanceof Error ? error.message : String(error)}`);
80
+ }
81
+ if (docs.length > 1)
82
+ throw new SanitizeError("pnpm-workspace.yaml has more than one document");
83
+ const doc = docs[0] ?? {};
84
+ if (!isRecord(doc))
85
+ throw new SanitizeError("pnpm-workspace.yaml is not a mapping");
86
+ const kept = Object.fromEntries(Object.entries(doc).filter(([key]) => WORKSPACE_KEYS.has(key)));
87
+ return `${JSON.stringify(typed(kept), null, 2)}\n`;
88
+ }
@@ -0,0 +1,302 @@
1
+ import { performance } from "node:perf_hooks";
2
+ import { verifyProvenance, } from "../crypto/sigstore.js";
3
+ import { llmStep } from "../llm/plan-step.js";
4
+ import { aggregate } from "../verdict.js";
5
+ import { contentFindings } from "./content.js";
6
+ import { installDetectors } from "./detectors.js";
7
+ import { installsOn, LockfileError, parseLockfile, } from "./lockfile.js";
8
+ import { addedDependencyFindings, metadataFindings } from "./metadata.js";
9
+ import { diffLockfiles } from "./plan.js";
10
+ import { applyBuildPolicy } from "./pnpm-builds.js";
11
+ import { isPnpmLockfile, parsePnpmLockfile } from "./pnpm-lockfile.js";
12
+ import { provenanceBaseline, provenanceFindings, } from "./provenance.js";
13
+ import { fetchPackument, fetchProvenanceBundle, fetchTarball, IntegrityError, RegistryError, registryTarball, versionFacts, } from "./registry.js";
14
+ import { signatureFindings } from "./signature.js";
15
+ import { snapshotTarball, TarballError } from "./tar.js";
16
+ /** An npm package-lock.json or a pnpm-lock.yaml, told apart by their first lines. */
17
+ export function parseAnyLockfile(text) {
18
+ return isPnpmLockfile(text) ? parsePnpmLockfile(text) : parseLockfile(text);
19
+ }
20
+ /**
21
+ * Judges what an install would bring in, from the project's lockfile before
22
+ * and the lockfile npm planned (`--package-lock-only --ignore-scripts`).
23
+ * croak itself runs no package manager; it only reads the two files.
24
+ */
25
+ export function planInstall(beforeText, afterText, detectors = installDetectors) {
26
+ const started = performance.now();
27
+ const parse = (text, which) => {
28
+ try {
29
+ return parseAnyLockfile(text);
30
+ }
31
+ catch (error) {
32
+ if (error instanceof LockfileError)
33
+ throw new LockfileError(`${which} lockfile: ${error.message}`);
34
+ throw error;
35
+ }
36
+ };
37
+ try {
38
+ const before = beforeText === undefined ? undefined : parse(beforeText, "before");
39
+ const changes = diffLockfiles(before, parse(afterText, "after"));
40
+ const findings = detectors.flatMap((detect) => detect(changes));
41
+ return {
42
+ status: "complete",
43
+ verdict: aggregate(findings),
44
+ findings,
45
+ added: changes.filter((c) => c.kind === "added").length,
46
+ changed: changes.filter((c) => c.kind === "changed").length,
47
+ durationMs: performance.now() - started,
48
+ };
49
+ }
50
+ catch (error) {
51
+ if (!(error instanceof LockfileError))
52
+ throw error;
53
+ return {
54
+ status: "incomplete",
55
+ reason: error.message,
56
+ durationMs: performance.now() - started,
57
+ };
58
+ }
59
+ }
60
+ const DEFAULT_MAX_DOWNLOAD_BYTES = 300 * 1024 * 1024;
61
+ /** The plan is too large to inspect within the download budget. */
62
+ class BudgetError extends Error {
63
+ }
64
+ const keyOf = (s) => `${s.name}@${s.version}`;
65
+ /**
66
+ * Provenance findings (D42, D43) for planned versions the registry lists
67
+ * with provenance: each one's bundle, and its previous version's, fetched
68
+ * and verified, then compared.
69
+ */
70
+ async function provenanceOf(known, options, fetchOptions) {
71
+ const plans = known.flatMap(({ change, facts }) => {
72
+ if (facts === undefined || facts === "not-found" || !facts.hasProvenance)
73
+ return [];
74
+ const subject = (version, integrity) => ({
75
+ name: change.entry.name,
76
+ version,
77
+ integrity: integrity ?? "",
78
+ });
79
+ const before = provenanceBaseline(facts);
80
+ const current = subject(change.entry.version ?? "", facts.integrity);
81
+ const previous = before === undefined
82
+ ? undefined
83
+ : subject(before.version, before.integrity);
84
+ // Both bundles are immutable for these tarballs, and so are both
85
+ // versions' install scripts: the findings can be remembered.
86
+ const label = `provenance ${keyOf(current)} after ${previous === undefined ? "none" : `${keyOf(previous)} ${previous.integrity}`}`;
87
+ const cached = options.cache?.get(current.integrity, label);
88
+ return [{ change, facts, before, current, previous, label, cached }];
89
+ });
90
+ const subjects = new Map();
91
+ for (const plan of plans)
92
+ if (plan.cached === undefined)
93
+ for (const s of [plan.current, plan.previous])
94
+ if (s !== undefined)
95
+ subjects.set(keyOf(s), s);
96
+ const list = [...subjects.values()];
97
+ const bundles = await pool(list, options.concurrency ?? 8, (s) => fetchProvenanceBundle(s.name, s.version, fetchOptions));
98
+ const checked = new Map(list.map((s, i) => [
99
+ keyOf(s),
100
+ bundles[i] === "missing"
101
+ ? "missing"
102
+ : verifyProvenance(bundles[i], s, options.sigstore),
103
+ ]));
104
+ return plans.flatMap((plan) => {
105
+ if (plan.cached !== undefined)
106
+ return plan.cached;
107
+ const { change, facts, before, current, previous } = plan;
108
+ const now = checked.get(keyOf(current)) ?? "missing";
109
+ const then = previous === undefined
110
+ ? undefined
111
+ : (checked.get(keyOf(previous)) ?? "missing");
112
+ const findings = provenanceFindings(change, now, before === undefined || then === undefined
113
+ ? undefined
114
+ : {
115
+ version: before.version,
116
+ provenance: then,
117
+ installCommands: before.installCommands,
118
+ }, facts.installCommands);
119
+ // A bundle the registry did not serve may be served next time.
120
+ if (now !== "missing" && then !== "missing")
121
+ options.cache?.set(current.integrity, plan.label, findings);
122
+ return findings;
123
+ });
124
+ }
125
+ /** Runs `work` over `items` with at most `limit` in flight. */
126
+ async function pool(items, limit, work) {
127
+ const results = new Array(items.length);
128
+ let next = 0;
129
+ const lane = async () => {
130
+ while (next < items.length) {
131
+ const index = next++;
132
+ results[index] = await work(items[index]);
133
+ }
134
+ };
135
+ await Promise.all(Array.from({ length: Math.min(limit, items.length) }, lane));
136
+ return results;
137
+ }
138
+ /**
139
+ * planInstall plus npm registry signals (D26) for every new or changed version
140
+ * that comes from the public registry. Any registry failure makes the whole
141
+ * check incomplete: an agent must not proceed on a partial answer.
142
+ */
143
+ export async function planInstallOnline(beforeText, afterText, options = {}) {
144
+ const started = performance.now();
145
+ const local = planInstall(beforeText, afterText);
146
+ if (local.status === "incomplete")
147
+ return local;
148
+ const before = beforeText === undefined ? undefined : parseAnyLockfile(beforeText);
149
+ const fromRegistry = diffLockfiles(before, parseAnyLockfile(afterText)).filter((c) => !c.entry.link && registryTarball(c.entry.resolved) !== undefined);
150
+ // npm skips platform packages for other systems, so they are not checked.
151
+ const platform = options.platform ?? {
152
+ os: process.platform,
153
+ cpu: process.arch,
154
+ };
155
+ const changes = fromRegistry.filter((c) => installsOn(c.entry, platform));
156
+ const skipped = fromRegistry.length - changes.length;
157
+ const notes = skipped === 0
158
+ ? []
159
+ : [
160
+ {
161
+ ruleId: "install/other-platform",
162
+ severity: "info",
163
+ file: "package-lock.json",
164
+ message: `${skipped} package(s) built for other platforms not checked: the package manager will not install them on ${platform.os}-${platform.cpu}`,
165
+ },
166
+ ];
167
+ const names = [...new Set(changes.map((c) => c.entry.name))];
168
+ try {
169
+ const fetchOptions = options.fetch === undefined ? {} : { fetch: options.fetch };
170
+ const docs = await pool(names, options.concurrency ?? 8, (name) => fetchPackument(name, fetchOptions));
171
+ const byName = new Map(names.map((name, i) => [name, docs[i]]));
172
+ const now = options.now ?? new Date();
173
+ const created = new Map();
174
+ for (const [name, doc] of byName) {
175
+ if (doc !== "not-found")
176
+ created.set(name, versionFacts(doc, firstVersion(doc), now)?.created);
177
+ }
178
+ const known = changes.map((change) => {
179
+ const doc = byName.get(change.entry.name);
180
+ return {
181
+ change,
182
+ facts: doc === "not-found"
183
+ ? "not-found"
184
+ : versionFacts(doc, change.entry.version ?? "", now),
185
+ };
186
+ });
187
+ const remote = known.flatMap(({ change: c, facts }) => {
188
+ const deps = facts === undefined || facts === "not-found"
189
+ ? []
190
+ : addedDependencyFindings(c, facts, created, now);
191
+ const signature = facts === undefined || facts === "not-found"
192
+ ? []
193
+ : signatureFindings(c, facts, options.registryKeys);
194
+ return [...metadataFindings(c, facts, now), ...signature, ...deps];
195
+ });
196
+ // Both only read the registry; neither waits for the other.
197
+ const [provenance, content] = await Promise.all([
198
+ provenanceOf(known, options, fetchOptions),
199
+ options.content === false ? [] : contentOf(changes, options),
200
+ ]);
201
+ const all = [
202
+ ...local.findings,
203
+ ...notes,
204
+ ...remote,
205
+ ...provenance,
206
+ ...content,
207
+ ];
208
+ // Before the LLM step: a script pnpm will not run needs no second opinion.
209
+ const ruled = options.builds === undefined
210
+ ? all
211
+ : applyBuildPolicy(all, options.builds);
212
+ const step = options.llm === undefined || options.content === false
213
+ ? undefined
214
+ : await llmStep(ruled, changes, byName, options.llm, fetchOptions);
215
+ const findings = step?.findings ?? ruled;
216
+ return {
217
+ ...local,
218
+ verdict: aggregate(findings),
219
+ findings,
220
+ ...(step === undefined || options.llm === undefined
221
+ ? {}
222
+ : {
223
+ llm: {
224
+ model: options.llm.model,
225
+ calls: step.calls,
226
+ costUsd: step.costUsd,
227
+ },
228
+ }),
229
+ durationMs: performance.now() - started,
230
+ };
231
+ }
232
+ catch (error) {
233
+ if (error instanceof RegistryError) {
234
+ return {
235
+ status: "incomplete",
236
+ reason: `npm registry: ${error.message}`,
237
+ durationMs: performance.now() - started,
238
+ };
239
+ }
240
+ if (error instanceof TarballError || error instanceof BudgetError) {
241
+ return {
242
+ status: "incomplete",
243
+ reason: `package contents: ${error.message}`,
244
+ durationMs: performance.now() - started,
245
+ };
246
+ }
247
+ throw error;
248
+ }
249
+ }
250
+ /** Any listed version, to read package-level facts such as the creation date. */
251
+ function firstVersion(doc) {
252
+ const versions = typeof doc === "object" && doc !== null && "versions" in doc
253
+ ? doc.versions
254
+ : undefined;
255
+ return typeof versions === "object" && versions !== null
256
+ ? (Object.keys(versions)[0] ?? "")
257
+ : "";
258
+ }
259
+ /**
260
+ * Downloads each registry tarball (verified against the lockfile's integrity),
261
+ * reads it in memory, and runs the content detectors. A tampered tarball is a
262
+ * block; a plan over the download budget is incomplete, never partially clean.
263
+ */
264
+ async function contentOf(changes, options) {
265
+ const budget = options.maxDownloadBytes ?? DEFAULT_MAX_DOWNLOAD_BYTES;
266
+ let downloaded = 0;
267
+ const fetchOptions = options.fetch === undefined ? {} : { fetch: options.fetch };
268
+ const results = await pool(changes, 4, async (change) => {
269
+ const { entry } = change;
270
+ const label = `${entry.name}@${entry.version ?? "?"}`;
271
+ const cached = entry.integrity === undefined
272
+ ? undefined
273
+ : options.cache?.get(entry.integrity, label);
274
+ if (cached !== undefined)
275
+ return cached;
276
+ let data;
277
+ try {
278
+ data = await fetchTarball(entry.resolved ?? "", entry.integrity, fetchOptions);
279
+ }
280
+ catch (error) {
281
+ if (!(error instanceof IntegrityError))
282
+ throw error;
283
+ return [
284
+ {
285
+ ruleId: "install/integrity",
286
+ severity: "block",
287
+ file: label,
288
+ message: `the downloaded tarball does not match the lockfile (${error.message}): tampered in transit or at the source`,
289
+ },
290
+ ];
291
+ }
292
+ downloaded += data.length;
293
+ if (downloaded > budget)
294
+ throw new BudgetError(`the plan downloads more than ${budget} bytes of tarballs`);
295
+ const findings = contentFindings(label, snapshotTarball(data));
296
+ // Only after the download verified against this hash and was read whole.
297
+ if (entry.integrity !== undefined)
298
+ options.cache?.set(entry.integrity, label, findings);
299
+ return findings;
300
+ });
301
+ return results.flat();
302
+ }