@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.
- package/LICENSE +21 -0
- package/README.md +283 -2
- package/dist/cli.js +373 -0
- package/dist/crypto/der.js +136 -0
- package/dist/crypto/fields.js +60 -0
- package/dist/crypto/registry-signature.js +88 -0
- package/dist/crypto/rekor.js +70 -0
- package/dist/crypto/sigstore.js +121 -0
- package/dist/detectors/git-config.js +137 -0
- package/dist/detectors/hidden-code.js +58 -0
- package/dist/detectors/ide-autorun.js +136 -0
- package/dist/detectors/index.js +20 -0
- package/dist/detectors/lifecycle-script.js +337 -0
- package/dist/detectors/node-modules-present.js +32 -0
- package/dist/detectors/obfuscation.js +124 -0
- package/dist/detectors/unscanned.js +41 -0
- package/dist/hook/hook.js +391 -0
- package/dist/hook/install-commands.js +927 -0
- package/dist/install/content-cache.js +94 -0
- package/dist/install/content.js +42 -0
- package/dist/install/detectors.js +79 -0
- package/dist/install/installed.js +99 -0
- package/dist/install/lockfile.js +72 -0
- package/dist/install/metadata.js +159 -0
- package/dist/install/plan.js +31 -0
- package/dist/install/pnpm-builds.js +111 -0
- package/dist/install/pnpm-lockfile.js +102 -0
- package/dist/install/popular.js +177 -0
- package/dist/install/project.js +223 -0
- package/dist/install/provenance.js +77 -0
- package/dist/install/registry.js +274 -0
- package/dist/install/report.js +26 -0
- package/dist/install/sanitize.js +88 -0
- package/dist/install/scan.js +302 -0
- package/dist/install/signature.js +62 -0
- package/dist/install/tar.js +178 -0
- package/dist/install/workspace.js +31 -0
- package/dist/install/yaml.js +282 -0
- package/dist/llm/client.js +22 -0
- package/dist/llm/cost.js +26 -0
- package/dist/llm/diff.js +114 -0
- package/dist/llm/escalate.js +14 -0
- package/dist/llm/plan-step.js +91 -0
- package/dist/llm/select.js +21 -0
- package/dist/llm/triage.js +111 -0
- package/dist/repo-report.js +95 -0
- package/dist/repo-snapshot.js +157 -0
- package/dist/scan.js +48 -0
- package/dist/signals/code.js +136 -0
- package/dist/signals/git-config.js +58 -0
- package/dist/signals/hidden.js +77 -0
- package/dist/signals/jsonc.js +100 -0
- package/dist/signals/obfuscation.js +98 -0
- package/dist/signals/shell.js +717 -0
- package/dist/signals/text.js +2 -0
- package/dist/ui.js +12 -0
- package/dist/verdict.js +20 -0
- package/package.json +71 -4
- package/scripts/croak-hook.sh +59 -0
- 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
|
+
}
|