@vola-trebla/croak 0.0.0-stage β†’ 0.1.1

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 +347 -0
  4. package/dist/crypto/der.js +123 -0
  5. package/dist/crypto/fields.js +49 -0
  6. package/dist/crypto/registry-signature.js +76 -0
  7. package/dist/crypto/rekor.js +57 -0
  8. package/dist/crypto/sigstore.js +112 -0
  9. package/dist/detectors/git-config.js +133 -0
  10. package/dist/detectors/hidden-code.js +51 -0
  11. package/dist/detectors/ide-autorun.js +127 -0
  12. package/dist/detectors/index.js +19 -0
  13. package/dist/detectors/lifecycle-script.js +298 -0
  14. package/dist/detectors/node-modules-present.js +25 -0
  15. package/dist/detectors/obfuscation.js +98 -0
  16. package/dist/detectors/unscanned.js +33 -0
  17. package/dist/hook/hook.js +348 -0
  18. package/dist/hook/install-commands.js +892 -0
  19. package/dist/install/content-cache.js +86 -0
  20. package/dist/install/content.js +32 -0
  21. package/dist/install/detectors.js +73 -0
  22. package/dist/install/installed.js +83 -0
  23. package/dist/install/lockfile.js +65 -0
  24. package/dist/install/metadata.js +129 -0
  25. package/dist/install/plan.js +21 -0
  26. package/dist/install/pnpm-builds.js +98 -0
  27. package/dist/install/pnpm-lockfile.js +88 -0
  28. package/dist/install/popular.js +172 -0
  29. package/dist/install/project.js +203 -0
  30. package/dist/install/provenance.js +56 -0
  31. package/dist/install/registry.js +241 -0
  32. package/dist/install/report.js +26 -0
  33. package/dist/install/sanitize.js +74 -0
  34. package/dist/install/scan.js +271 -0
  35. package/dist/install/signature.js +55 -0
  36. package/dist/install/tar.js +171 -0
  37. package/dist/install/workspace.js +30 -0
  38. package/dist/install/yaml.js +256 -0
  39. package/dist/llm/client.js +14 -0
  40. package/dist/llm/cost.js +24 -0
  41. package/dist/llm/diff.js +100 -0
  42. package/dist/llm/escalate.js +14 -0
  43. package/dist/llm/plan-step.js +86 -0
  44. package/dist/llm/select.js +15 -0
  45. package/dist/llm/triage.js +101 -0
  46. package/dist/repo-report.js +84 -0
  47. package/dist/repo-snapshot.js +140 -0
  48. package/dist/scan.js +43 -0
  49. package/dist/signals/code.js +107 -0
  50. package/dist/signals/git-config.js +51 -0
  51. package/dist/signals/hidden.js +60 -0
  52. package/dist/signals/jsonc.js +90 -0
  53. package/dist/signals/obfuscation.js +87 -0
  54. package/dist/signals/shell.js +677 -0
  55. package/dist/signals/text.js +1 -0
  56. package/dist/ui.js +11 -0
  57. package/dist/verdict.js +19 -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,348 @@
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
+ function hookCommand(input) {
15
+ let data;
16
+ try {
17
+ data = JSON.parse(input);
18
+ }
19
+ catch {
20
+ throw new HookInputError("the hook input is not JSON");
21
+ }
22
+ if (!isRecord(data))
23
+ throw new HookInputError("the hook input is not an object");
24
+ if (data.tool_name !== "Bash")
25
+ return undefined;
26
+ const command = isRecord(data.tool_input)
27
+ ? data.tool_input.command
28
+ : undefined;
29
+ if (typeof command !== "string" ||
30
+ typeof data.cwd !== "string" ||
31
+ !data.cwd.startsWith("/"))
32
+ throw new HookInputError("the hook input has no Bash command or absolute cwd");
33
+ return { command, cwd: data.cwd };
34
+ }
35
+ const SAFE = /^[^\t\n\r\0]+$/;
36
+ const isSafeSpec = (spec) => SAFE.test(spec) && !spec.startsWith("-");
37
+ function npmPrefix(dir, exists) {
38
+ for (let at = dir;; at = posix.dirname(at)) {
39
+ if (exists(posix.join(at, "package.json")) ||
40
+ exists(posix.join(at, "node_modules")))
41
+ return at;
42
+ if (at === "/")
43
+ return dir;
44
+ }
45
+ }
46
+ function workspaceRegistry(root, deps) {
47
+ const file = posix.join(root, "pnpm-workspace.yaml");
48
+ if (!deps.exists(file))
49
+ return undefined;
50
+ let docs;
51
+ try {
52
+ docs = parseYamlDocuments(deps.readFile(file));
53
+ }
54
+ catch {
55
+ return `${file} cannot be read`;
56
+ }
57
+ for (const doc of docs) {
58
+ if (!isRecord(doc))
59
+ continue;
60
+ for (const key of Object.keys(doc)) {
61
+ if (REGISTRY_KEY.test(key) ||
62
+ key === "registries" ||
63
+ key === "namedRegistries")
64
+ return `${file} sets ${key}`;
65
+ if (WORKSPACE_RUNNER_KEY.test(key))
66
+ return `${file} sets ${key}, which changes what pnpm runs or trusts`;
67
+ }
68
+ }
69
+ return undefined;
70
+ }
71
+ const PUBLIC_REGISTRY = /^https:\/\/registry\.npmjs\.org\/?$/;
72
+ const REGISTRY_KEY = /(?:^|:)registry$|^replace-registry-host$/;
73
+ 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)$/;
74
+ const WORKSPACE_RUNNER_KEY = /^(?:scriptShell|nodeOptions|shellEmulator|globalPnpmfile|proxy|https?Proxy|ca|cafile|cert|key|strictSsl|userconfig|globalconfig)$/;
75
+ function foreignRegistry(prefix, deps) {
76
+ const env = Object.entries(deps.env ?? {});
77
+ const named = env
78
+ .filter(([key]) => /^npm_config_(?:userconfig|globalconfig)$/i.test(key))
79
+ .map(([, value]) => value ?? "");
80
+ const project = posix.join(prefix, ".npmrc");
81
+ for (const file of [project, posix.join(deps.home, ".npmrc"), ...named]) {
82
+ if (file === "" || !deps.exists(file))
83
+ continue;
84
+ let text;
85
+ try {
86
+ text = deps.readFile(file);
87
+ }
88
+ catch {
89
+ return `${file} cannot be read`;
90
+ }
91
+ for (const raw of text.split("\n")) {
92
+ const line = raw.trim();
93
+ if (line === "" || /^[;#]/.test(line))
94
+ continue;
95
+ if (line.startsWith("["))
96
+ return `${file} uses [sections]`;
97
+ const eq = line.indexOf("=");
98
+ if (eq === -1)
99
+ continue;
100
+ const unquote = (part) => part.trim().replace(/^(["'])(.*)\1$/, "$2");
101
+ const key = unquote(line.slice(0, eq));
102
+ const value = unquote(line.slice(eq + 1));
103
+ if (REGISTRY_KEY.test(key) && !PUBLIC_REGISTRY.test(value))
104
+ return `${file} sets ${key}`;
105
+ if (file === project && RUNNER_KEY.test(key))
106
+ return `${file} sets ${key}, which changes what npm runs or trusts`;
107
+ }
108
+ }
109
+ for (const [key, value] of env) {
110
+ const name = key.toLowerCase();
111
+ if (name.startsWith("npm_config_") &&
112
+ REGISTRY_KEY.test(name.slice(11).replace(/_/g, "-")) &&
113
+ !PUBLIC_REGISTRY.test(value ?? ""))
114
+ return `${key} is set`;
115
+ }
116
+ return undefined;
117
+ }
118
+ export function hookPlan(input, deps) {
119
+ const target = hookCommand(input);
120
+ if (target === undefined)
121
+ return { intents: [], checks: [] };
122
+ const intents = [];
123
+ const checks = [];
124
+ const unchecked = (command, reason) => {
125
+ intents.push({ kind: "unchecked", command, reason });
126
+ };
127
+ for (const intent of installIntents(target.command, target.cwd, deps.home)) {
128
+ if (intent.kind === "unchecked") {
129
+ intents.push(intent);
130
+ continue;
131
+ }
132
+ const packages = "packages" in intent ? intent.packages : [];
133
+ const manager = intent.manager ?? "npm";
134
+ const verb = manager === "pnpm"
135
+ ? {
136
+ install: "pnpm add",
137
+ update: "pnpm update",
138
+ ci: "pnpm install --frozen-lockfile",
139
+ run: "pnpm dlx",
140
+ }[intent.kind]
141
+ : {
142
+ install: "npm install",
143
+ update: "npm update",
144
+ ci: "npm ci",
145
+ run: "npx",
146
+ }[intent.kind];
147
+ const label = [verb, ...packages, "in", intent.dir].join(" ");
148
+ if (!SAFE.test(intent.dir) || !packages.every(isSafeSpec)) {
149
+ unchecked(label, "a package name or path in it cannot be checked safely");
150
+ continue;
151
+ }
152
+ if (!deps.exists(intent.dir)) {
153
+ unchecked(label, "its directory does not exist yet: create it in one command and install in the next, so croak can check the install");
154
+ continue;
155
+ }
156
+ const global = intent.kind === "install" && intent.global;
157
+ const local = npmPrefix(intent.dir, deps.exists);
158
+ const root = intent.kind === "run" || global
159
+ ? undefined
160
+ : workspaceRoot(local, manager, deps);
161
+ const prefix = root ?? local;
162
+ const registry = foreignRegistry(prefix, deps) ??
163
+ (manager === "pnpm" ? workspaceRegistry(prefix, deps) : undefined);
164
+ if (registry !== undefined) {
165
+ unchecked(label, `${manager} here uses a registry croak does not check (${registry}); croak checks the public npm registry only`);
166
+ continue;
167
+ }
168
+ if (manager === "npm" &&
169
+ intent.kind !== "run" &&
170
+ !global &&
171
+ deps.exists(posix.join(prefix, "npm-shrinkwrap.json"))) {
172
+ unchecked(label, "the project has an npm-shrinkwrap.json, which croak does not check yet");
173
+ continue;
174
+ }
175
+ let mode;
176
+ let specs = packages;
177
+ if (intent.kind === "ci")
178
+ mode = "ci";
179
+ else if (intent.kind === "run") {
180
+ specs = packages.filter((spec) => manager === "pnpm" ||
181
+ spec.includes("@", spec.startsWith("@") ? 1 : 0) ||
182
+ spec.includes("/") ||
183
+ !deps.exists(posix.join(prefix, "node_modules", ".bin", spec)));
184
+ if (specs.length === 0)
185
+ continue;
186
+ mode = "empty";
187
+ }
188
+ else if (global)
189
+ mode = "empty";
190
+ else if (deps.exists(posix.join(prefix, "package.json")))
191
+ mode = intent.kind === "update" ? "update" : "project";
192
+ else if (packages.length > 0)
193
+ mode = "empty";
194
+ else
195
+ continue;
196
+ intents.push(intent);
197
+ checks.push({
198
+ id: checks.length + 1,
199
+ manager,
200
+ mode,
201
+ dir: mode === "empty" ? intent.dir : local,
202
+ packages: specs,
203
+ label,
204
+ });
205
+ }
206
+ return { intents, checks };
207
+ }
208
+ export function planLines(plan) {
209
+ return plan.checks
210
+ .map((c) => ["check", String(c.id), c.manager, c.mode, c.dir, ...c.packages].join("\t"))
211
+ .map((line) => `${line}\n`)
212
+ .join("");
213
+ }
214
+ const MAX_FINDINGS = 6;
215
+ 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.";
216
+ function judge(result) {
217
+ const tail = (text) => {
218
+ const lines = text.trim().split("\n");
219
+ const own = lines.filter((l) => /^croak-install:|did you mean/.test(l.trim()));
220
+ return (own.length > 0 ? own : lines.slice(-4))
221
+ .map((l) => l.trim())
222
+ .join(" ")
223
+ .slice(0, 400);
224
+ };
225
+ if (result.code === 2 || result.code === undefined || result.code > 3)
226
+ return {
227
+ verdict: "block",
228
+ lines: [tail(result.stderr) || "croak-install could not run"],
229
+ };
230
+ let report;
231
+ try {
232
+ report = JSON.parse(result.stdout);
233
+ }
234
+ catch {
235
+ return {
236
+ verdict: "block",
237
+ lines: [
238
+ `the check did not finish: ${tail(result.stderr) || "no report"}`,
239
+ ],
240
+ };
241
+ }
242
+ if (!isRecord(report) || report.status !== "complete")
243
+ return {
244
+ verdict: "block",
245
+ lines: [
246
+ `the check is incomplete: ${isRecord(report) && typeof report.reason === "string" ? report.reason : "unknown reason"}`,
247
+ ],
248
+ };
249
+ const findings = Array.isArray(report.findings)
250
+ ? report.findings.filter(isRecord)
251
+ : [];
252
+ const shown = findings
253
+ .filter((f) => f.severity === "block" || f.severity === "warn")
254
+ .sort((a, b) => a.severity === b.severity ? 0 : a.severity === "block" ? -1 : 1);
255
+ const lines = shown
256
+ .slice(0, MAX_FINDINGS)
257
+ .map((f) => `${f.severity === "block" ? "πŸ”΄" : "🟑"} ${String(f.file)} ${String(f.ruleId)}: ${String(f.message)}`);
258
+ if (shown.length > MAX_FINDINGS)
259
+ lines.push(`…and ${shown.length - MAX_FINDINGS} more (run croak-install yourself to see all)`);
260
+ const verdict = report.verdict === "block" || report.verdict === "warn"
261
+ ? report.verdict
262
+ : "allow";
263
+ return { verdict, lines };
264
+ }
265
+ export function hookDecide(intents, results) {
266
+ const blocked = [];
267
+ const warned = [];
268
+ for (const intent of intents)
269
+ if (intent.kind === "unchecked")
270
+ blocked.push(`${intent.command.slice(0, 120)}: not checked, because ${intent.reason}.`);
271
+ for (const result of results) {
272
+ const { verdict, lines } = judge(result);
273
+ const entry = [
274
+ `${result.check.label}:`,
275
+ ...lines.map((l) => ` ${l}`),
276
+ ].join("\n");
277
+ if (verdict === "block")
278
+ blocked.push(entry);
279
+ else if (verdict === "warn")
280
+ warned.push(entry);
281
+ }
282
+ const output = (permissionDecision, reason) => ({
283
+ hookSpecificOutput: {
284
+ hookEventName: "PreToolUse",
285
+ permissionDecision,
286
+ permissionDecisionReason: reason,
287
+ },
288
+ });
289
+ if (blocked.length > 0)
290
+ return output("deny", `🐸 croak blocked this command before it ran.\n${[...blocked, ...warned].join("\n")}\n${DO_NOT_BYPASS}`);
291
+ if (warned.length > 0)
292
+ return output("ask", `🐸 croak found things to review before this install runs (not a safe verdict):\n${warned.join("\n")}`);
293
+ return undefined;
294
+ }
295
+ export function runHook(args, deps) {
296
+ const [step, path] = args;
297
+ if (path === undefined || (step !== "plan" && step !== "decide"))
298
+ return {
299
+ code: 2,
300
+ stdout: "",
301
+ stderr: "usage: croak hook plan <input.json> | croak hook decide <work-dir>\n",
302
+ };
303
+ try {
304
+ return step === "plan" ? planStep(path, deps) : decideStep(path, deps);
305
+ }
306
+ catch (error) {
307
+ if (!(error instanceof HookInputError))
308
+ throw error;
309
+ return { code: 2, stdout: "", stderr: `${error.message}\n` };
310
+ }
311
+ }
312
+ function planStep(path, deps) {
313
+ const plan = hookPlan(deps.readFile(path), deps);
314
+ return {
315
+ code: 0,
316
+ stdout: `plan\t${JSON.stringify(plan)}\n${planLines(plan)}`,
317
+ stderr: "",
318
+ };
319
+ }
320
+ function decideStep(path, deps) {
321
+ const first = deps.readFile(posix.join(path, "plan")).split("\n")[0] ?? "";
322
+ if (!first.startsWith("plan\t"))
323
+ throw new HookInputError("the plan file is missing its plan line");
324
+ const plan = JSON.parse(first.slice("plan\t".length));
325
+ const read = (name) => {
326
+ try {
327
+ return deps.readFile(posix.join(path, name));
328
+ }
329
+ catch {
330
+ return undefined;
331
+ }
332
+ };
333
+ const results = plan.checks.map((check) => {
334
+ const code = Number.parseInt(read(`${check.id}.code`) ?? "", 10);
335
+ return {
336
+ check,
337
+ code: Number.isNaN(code) ? undefined : code,
338
+ stdout: read(`${check.id}.out`) ?? "",
339
+ stderr: read(`${check.id}.err`) ?? "",
340
+ };
341
+ });
342
+ const output = hookDecide(plan.intents, results);
343
+ return {
344
+ code: 0,
345
+ stdout: output === undefined ? "" : `${JSON.stringify(output)}\n`,
346
+ stderr: "",
347
+ };
348
+ }