@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,41 @@
1
+ import { isInNodeModules } from "./node-modules-present.js";
2
+ /** Code that a JS toolchain, an IDE, or git may execute or load. */
3
+ const CODE = /\.(?:[cm]?[jt]sx?|json|sh|bash|zsh|py|rb|pl|ps1|gyp)$/;
4
+ const LOCKFILES = new Set(["package-lock.json", "npm-shrinkwrap.json"]);
5
+ /** Type declarations are never executed, and lib.dom.d.ts alone is over 1 MB. */
6
+ const DECLARATIONS = /\.d\.[cm]?ts$/;
7
+ /**
8
+ * Safety floor: a code file too large to read was not scanned, so the repo is
9
+ * not clean. Padding a config past the read limit must not hide a payload.
10
+ */
11
+ export function unscannedWith({ jsonIsData, downgradeInstalled, }) {
12
+ return (repo) => repo.entries
13
+ .filter((entry) => entry.kind === "oversized")
14
+ .filter((entry) => {
15
+ const name = entry.path.slice(entry.path.lastIndexOf("/") + 1);
16
+ return (entry.path.startsWith(".git/") ||
17
+ /(?:^|\/)\.husky\//.test(entry.path) ||
18
+ (CODE.test(name) && !LOCKFILES.has(name) && !DECLARATIONS.test(name)));
19
+ })
20
+ .map((entry) => {
21
+ const name = entry.path.slice(entry.path.lastIndexOf("/") + 1);
22
+ const data = jsonIsData && name.endsWith(".json") && name !== "package.json";
23
+ // Bundles over the limit are normal in installed dependencies; the
24
+ // presence of node_modules itself is flagged by repo/node-modules-present.
25
+ const installed = downgradeInstalled && isInNodeModules(entry.path);
26
+ return {
27
+ ruleId: "repo/unscanned",
28
+ severity: installed || data ? "info" : "warn",
29
+ file: entry.path,
30
+ message: data
31
+ ? `${entry.size} bytes exceeds the read limit; not scanned (JSON data, not executed)`
32
+ : installed
33
+ ? `${entry.size} bytes exceeds the read limit; not scanned (installed dependency)`
34
+ : `${entry.size} bytes exceeds the read limit; not scanned`,
35
+ };
36
+ });
37
+ }
38
+ export const unscanned = unscannedWith({
39
+ jsonIsData: false,
40
+ downgradeInstalled: true,
41
+ });
@@ -0,0 +1,391 @@
1
+ import { posix } from "node:path";
2
+ import { workspaceRoot } from "../install/workspace.js";
3
+ import { parseYamlDocuments } from "../install/yaml.js";
4
+ import { installIntents } from "./install-commands.js";
5
+ export class HookInputError extends Error {
6
+ constructor(message) {
7
+ super(message);
8
+ this.name = "HookInputError";
9
+ }
10
+ }
11
+ function isRecord(value) {
12
+ return typeof value === "object" && value !== null && !Array.isArray(value);
13
+ }
14
+ /** The Bash command and directory from a PreToolUse event, or undefined for other tools. */
15
+ function hookCommand(input) {
16
+ let data;
17
+ try {
18
+ data = JSON.parse(input);
19
+ }
20
+ catch {
21
+ throw new HookInputError("the hook input is not JSON");
22
+ }
23
+ if (!isRecord(data))
24
+ throw new HookInputError("the hook input is not an object");
25
+ if (data.tool_name !== "Bash")
26
+ return undefined;
27
+ const command = isRecord(data.tool_input)
28
+ ? data.tool_input.command
29
+ : undefined;
30
+ if (typeof command !== "string" ||
31
+ typeof data.cwd !== "string" ||
32
+ !data.cwd.startsWith("/"))
33
+ throw new HookInputError("the hook input has no Bash command or absolute cwd");
34
+ return { command, cwd: data.cwd };
35
+ }
36
+ /** Text that goes to the shell as one tab-separated field. */
37
+ const SAFE = /^[^\t\n\r\0]+$/;
38
+ const isSafeSpec = (spec) => SAFE.test(spec) && !spec.startsWith("-");
39
+ /**
40
+ * npm's local prefix: the nearest directory, from `dir` up, with a
41
+ * package.json or a node_modules folder (as @npmcli/config does); `dir` itself
42
+ * when there is none. That is where npm installs, not where the agent is.
43
+ */
44
+ function npmPrefix(dir, exists) {
45
+ for (let at = dir;; at = posix.dirname(at)) {
46
+ if (exists(posix.join(at, "package.json")) ||
47
+ exists(posix.join(at, "node_modules")))
48
+ return at;
49
+ if (at === "/")
50
+ return dir;
51
+ }
52
+ }
53
+ /**
54
+ * A registry that pnpm-workspace.yaml (pnpm 10+ settings) points pnpm at, or
55
+ * a setting there that runs the repo's code or trusts another server, or
56
+ * undefined.
57
+ */
58
+ function workspaceRegistry(root, deps) {
59
+ const file = posix.join(root, "pnpm-workspace.yaml");
60
+ if (!deps.exists(file))
61
+ return undefined;
62
+ let docs;
63
+ try {
64
+ docs = parseYamlDocuments(deps.readFile(file));
65
+ }
66
+ catch {
67
+ return `${file} cannot be read`;
68
+ }
69
+ for (const doc of docs) {
70
+ if (!isRecord(doc))
71
+ continue;
72
+ for (const key of Object.keys(doc)) {
73
+ if (REGISTRY_KEY.test(key) ||
74
+ key === "registries" ||
75
+ key === "namedRegistries")
76
+ return `${file} sets ${key}`;
77
+ if (WORKSPACE_RUNNER_KEY.test(key))
78
+ return `${file} sets ${key}, which changes what pnpm runs or trusts`;
79
+ }
80
+ }
81
+ return undefined;
82
+ }
83
+ const PUBLIC_REGISTRY = /^https:\/\/registry\.npmjs\.org\/?$/;
84
+ const REGISTRY_KEY = /(?:^|:)registry$|^replace-registry-host$/;
85
+ /**
86
+ * Keys that load another config croak would not read, change what npm or
87
+ * pnpm runs scripts and git with, or let a proxy or certificate answer for
88
+ * the registry: with them, a warning the user approves would still run the
89
+ * repo's own shell, preload or binary, or packages croak never saw.
90
+ */
91
+ const RUNNER_KEY = /^(?:userconfig|globalconfig|script-shell|node-options|shell|git|init-module|shell-emulator|global-pnpmfile|pnpmfile|proxy|https?-proxy|ca|cafile|cert|key|strict-ssl)$/;
92
+ /**
93
+ * The same settings as pnpm-workspace.yaml writes them (M6 review): a shell or
94
+ * preload for scripts, a pnpmfile outside the project, and a proxy or
95
+ * certificate setting that lets another server answer for the registry.
96
+ */
97
+ const WORKSPACE_RUNNER_KEY = /^(?:scriptShell|nodeOptions|shellEmulator|globalPnpmfile|proxy|https?Proxy|ca|cafile|cert|key|strictSsl|userconfig|globalconfig)$/;
98
+ /** Where npm config sends npm somewhere croak does not check (D34), or undefined. */
99
+ function foreignRegistry(prefix, deps) {
100
+ const env = Object.entries(deps.env ?? {});
101
+ // npm exports npm_config_userconfig when it launches a program; the file it
102
+ // names is checked like any other npmrc, rather than refused outright.
103
+ const named = env
104
+ .filter(([key]) => /^npm_config_(?:userconfig|globalconfig)$/i.test(key))
105
+ .map(([, value]) => value ?? "");
106
+ const project = posix.join(prefix, ".npmrc");
107
+ for (const file of [project, posix.join(deps.home, ".npmrc"), ...named]) {
108
+ if (file === "" || !deps.exists(file))
109
+ continue;
110
+ let text;
111
+ try {
112
+ text = deps.readFile(file);
113
+ }
114
+ catch {
115
+ return `${file} cannot be read`;
116
+ }
117
+ for (const raw of text.split("\n")) {
118
+ const line = raw.trim();
119
+ if (line === "" || /^[;#]/.test(line))
120
+ continue;
121
+ // ini sections group keys croak does not read: refuse rather than guess.
122
+ if (line.startsWith("["))
123
+ return `${file} uses [sections]`;
124
+ const eq = line.indexOf("=");
125
+ if (eq === -1)
126
+ continue;
127
+ // Quotes around a key or value are stripped, as npm's ini parser does.
128
+ const unquote = (part) => part.trim().replace(/^(["'])(.*)\1$/, "$2");
129
+ const key = unquote(line.slice(0, eq));
130
+ const value = unquote(line.slice(eq + 1));
131
+ if (REGISTRY_KEY.test(key) && !PUBLIC_REGISTRY.test(value))
132
+ return `${file} sets ${key}`;
133
+ // The project's npmrc comes with the repo; the user's own files do not.
134
+ if (file === project && RUNNER_KEY.test(key))
135
+ return `${file} sets ${key}, which changes what npm runs or trusts`;
136
+ }
137
+ }
138
+ for (const [key, value] of env) {
139
+ const name = key.toLowerCase();
140
+ if (name.startsWith("npm_config_") &&
141
+ REGISTRY_KEY.test(name.slice(11).replace(/_/g, "-")) &&
142
+ !PUBLIC_REGISTRY.test(value ?? ""))
143
+ return `${key} is set`;
144
+ }
145
+ return undefined;
146
+ }
147
+ export function hookPlan(input, deps) {
148
+ const target = hookCommand(input);
149
+ if (target === undefined)
150
+ return { intents: [], checks: [] };
151
+ const intents = [];
152
+ const checks = [];
153
+ const unchecked = (command, reason) => {
154
+ intents.push({ kind: "unchecked", command, reason });
155
+ };
156
+ for (const intent of installIntents(target.command, target.cwd, deps.home)) {
157
+ if (intent.kind === "unchecked") {
158
+ intents.push(intent);
159
+ continue;
160
+ }
161
+ const packages = "packages" in intent ? intent.packages : [];
162
+ const manager = intent.manager ?? "npm";
163
+ const verb = manager === "pnpm"
164
+ ? {
165
+ install: "pnpm add",
166
+ update: "pnpm update",
167
+ ci: "pnpm install --frozen-lockfile",
168
+ run: "pnpm dlx",
169
+ }[intent.kind]
170
+ : {
171
+ install: "npm install",
172
+ update: "npm update",
173
+ ci: "npm ci",
174
+ run: "npx",
175
+ }[intent.kind];
176
+ const label = [verb, ...packages, "in", intent.dir].join(" ");
177
+ if (!SAFE.test(intent.dir) || !packages.every(isSafeSpec)) {
178
+ unchecked(label, "a package name or path in it cannot be checked safely");
179
+ continue;
180
+ }
181
+ if (!deps.exists(intent.dir)) {
182
+ unchecked(label, "its directory does not exist yet: create it in one command and install in the next, so croak can check the install");
183
+ continue;
184
+ }
185
+ const global = intent.kind === "install" && intent.global;
186
+ // A workspace installs at its root (D38): check there, with every member.
187
+ const local = npmPrefix(intent.dir, deps.exists);
188
+ const root = intent.kind === "run" || global
189
+ ? undefined
190
+ : workspaceRoot(local, manager, deps);
191
+ const prefix = root ?? local;
192
+ const registry = foreignRegistry(prefix, deps) ??
193
+ (manager === "pnpm" ? workspaceRegistry(prefix, deps) : undefined);
194
+ if (registry !== undefined) {
195
+ unchecked(label, `${manager} here uses a registry croak does not check (${registry}); croak checks the public npm registry only`);
196
+ continue;
197
+ }
198
+ if (manager === "npm" &&
199
+ intent.kind !== "run" &&
200
+ !global &&
201
+ deps.exists(posix.join(prefix, "npm-shrinkwrap.json"))) {
202
+ unchecked(label, "the project has an npm-shrinkwrap.json, which croak does not check yet");
203
+ continue;
204
+ }
205
+ let mode;
206
+ let specs = packages;
207
+ if (intent.kind === "ci")
208
+ mode = "ci";
209
+ else if (intent.kind === "run") {
210
+ // npx runs the prefix's own bin when it has one: no download.
211
+ // pnpm dlx always downloads.
212
+ specs = packages.filter((spec) => manager === "pnpm" ||
213
+ spec.includes("@", spec.startsWith("@") ? 1 : 0) ||
214
+ spec.includes("/") ||
215
+ !deps.exists(posix.join(prefix, "node_modules", ".bin", spec)));
216
+ if (specs.length === 0)
217
+ continue;
218
+ mode = "empty";
219
+ }
220
+ else if (global)
221
+ mode = "empty";
222
+ else if (deps.exists(posix.join(prefix, "package.json")))
223
+ mode = intent.kind === "update" ? "update" : "project";
224
+ else if (packages.length > 0)
225
+ mode = "empty";
226
+ else
227
+ continue; // npm install without a package.json installs nothing
228
+ intents.push(intent);
229
+ checks.push({
230
+ id: checks.length + 1,
231
+ manager,
232
+ mode,
233
+ // Where npm stands: croak-install finds the root itself, and checks
234
+ // that this folder is one of its members (M6 review).
235
+ dir: mode === "empty" ? intent.dir : local,
236
+ packages: specs,
237
+ label,
238
+ });
239
+ }
240
+ return { intents, checks };
241
+ }
242
+ /** One line per check for the shell: check <id> <manager> <mode> <dir> <package>… (tab-separated). */
243
+ export function planLines(plan) {
244
+ return plan.checks
245
+ .map((c) => ["check", String(c.id), c.manager, c.mode, c.dir, ...c.packages].join("\t"))
246
+ .map((line) => `${line}\n`)
247
+ .join("");
248
+ }
249
+ const MAX_FINDINGS = 6;
250
+ const DO_NOT_BYPASS = "Do not work around this check (another package manager, a different command, or editing files to install it); tell the user what croak found and let them decide.";
251
+ /** One check's result as a verdict, with the findings worth showing. */
252
+ function judge(result) {
253
+ // croak-install's own lines (and its did-you-mean hints) say more than npm's log.
254
+ const tail = (text) => {
255
+ const lines = text.trim().split("\n");
256
+ const own = lines.filter((l) => /^croak-install:|did you mean/.test(l.trim()));
257
+ return (own.length > 0 ? own : lines.slice(-4))
258
+ .map((l) => l.trim())
259
+ .join(" ")
260
+ .slice(0, 400);
261
+ };
262
+ if (result.code === 2 || result.code === undefined || result.code > 3)
263
+ return {
264
+ verdict: "block",
265
+ lines: [tail(result.stderr) || "croak-install could not run"],
266
+ };
267
+ let report;
268
+ try {
269
+ report = JSON.parse(result.stdout);
270
+ }
271
+ catch {
272
+ return {
273
+ verdict: "block",
274
+ lines: [
275
+ `the check did not finish: ${tail(result.stderr) || "no report"}`,
276
+ ],
277
+ };
278
+ }
279
+ if (!isRecord(report) || report.status !== "complete")
280
+ return {
281
+ verdict: "block",
282
+ lines: [
283
+ `the check is incomplete: ${isRecord(report) && typeof report.reason === "string" ? report.reason : "unknown reason"}`,
284
+ ],
285
+ };
286
+ const findings = Array.isArray(report.findings)
287
+ ? report.findings.filter(isRecord)
288
+ : [];
289
+ const shown = findings
290
+ .filter((f) => f.severity === "block" || f.severity === "warn")
291
+ .sort((a, b) => a.severity === b.severity ? 0 : a.severity === "block" ? -1 : 1);
292
+ const lines = shown
293
+ .slice(0, MAX_FINDINGS)
294
+ .map((f) => `${f.severity === "block" ? "πŸ”΄" : "🟑"} ${String(f.file)} ${String(f.ruleId)}: ${String(f.message)}`);
295
+ if (shown.length > MAX_FINDINGS)
296
+ lines.push(`…and ${shown.length - MAX_FINDINGS} more (run croak-install yourself to see all)`);
297
+ const verdict = report.verdict === "block" || report.verdict === "warn"
298
+ ? report.verdict
299
+ : "allow";
300
+ return { verdict, lines };
301
+ }
302
+ export function hookDecide(intents, results) {
303
+ const blocked = [];
304
+ const warned = [];
305
+ for (const intent of intents)
306
+ if (intent.kind === "unchecked")
307
+ blocked.push(`${intent.command.slice(0, 120)}: not checked, because ${intent.reason}.`);
308
+ for (const result of results) {
309
+ const { verdict, lines } = judge(result);
310
+ const entry = [
311
+ `${result.check.label}:`,
312
+ ...lines.map((l) => ` ${l}`),
313
+ ].join("\n");
314
+ if (verdict === "block")
315
+ blocked.push(entry);
316
+ else if (verdict === "warn")
317
+ warned.push(entry);
318
+ }
319
+ const output = (permissionDecision, reason) => ({
320
+ hookSpecificOutput: {
321
+ hookEventName: "PreToolUse",
322
+ permissionDecision,
323
+ permissionDecisionReason: reason,
324
+ },
325
+ });
326
+ if (blocked.length > 0)
327
+ return output("deny", `🐸 croak blocked this command before it ran.\n${[...blocked, ...warned].join("\n")}\n${DO_NOT_BYPASS}`);
328
+ if (warned.length > 0)
329
+ return output("ask", `🐸 croak found things to review before this install runs (not a safe verdict):\n${warned.join("\n")}`);
330
+ return undefined;
331
+ }
332
+ /**
333
+ * `croak hook plan <input.json>` prints the plan: a `plan <json>` line, then
334
+ * the check lines. `croak hook decide <work>` reads that plan and each
335
+ * check's <id>.code / <id>.out / <id>.err from the work directory and prints
336
+ * the hook output, or nothing. Any error is the caller's cue to deny.
337
+ */
338
+ export function runHook(args, deps) {
339
+ const [step, path] = args;
340
+ if (path === undefined || (step !== "plan" && step !== "decide"))
341
+ return {
342
+ code: 2,
343
+ stdout: "",
344
+ stderr: "usage: croak hook plan <input.json> | croak hook decide <work-dir>\n",
345
+ };
346
+ try {
347
+ return step === "plan" ? planStep(path, deps) : decideStep(path, deps);
348
+ }
349
+ catch (error) {
350
+ if (!(error instanceof HookInputError))
351
+ throw error;
352
+ return { code: 2, stdout: "", stderr: `${error.message}\n` };
353
+ }
354
+ }
355
+ function planStep(path, deps) {
356
+ const plan = hookPlan(deps.readFile(path), deps);
357
+ return {
358
+ code: 0,
359
+ stdout: `plan\t${JSON.stringify(plan)}\n${planLines(plan)}`,
360
+ stderr: "",
361
+ };
362
+ }
363
+ function decideStep(path, deps) {
364
+ const first = deps.readFile(posix.join(path, "plan")).split("\n")[0] ?? "";
365
+ if (!first.startsWith("plan\t"))
366
+ throw new HookInputError("the plan file is missing its plan line");
367
+ const plan = JSON.parse(first.slice("plan\t".length));
368
+ const read = (name) => {
369
+ try {
370
+ return deps.readFile(posix.join(path, name));
371
+ }
372
+ catch {
373
+ return undefined; // a missing result is judged as a check that did not run
374
+ }
375
+ };
376
+ const results = plan.checks.map((check) => {
377
+ const code = Number.parseInt(read(`${check.id}.code`) ?? "", 10);
378
+ return {
379
+ check,
380
+ code: Number.isNaN(code) ? undefined : code,
381
+ stdout: read(`${check.id}.out`) ?? "",
382
+ stderr: read(`${check.id}.err`) ?? "",
383
+ };
384
+ });
385
+ const output = hookDecide(plan.intents, results);
386
+ return {
387
+ code: 0,
388
+ stdout: output === undefined ? "" : `${JSON.stringify(output)}\n`,
389
+ stderr: "",
390
+ };
391
+ }