agentinel 0.0.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.
- package/LICENSE +21 -0
- package/README.md +104 -0
- package/data/malware-names.json.gz +0 -0
- package/dist/asen.js +1838 -0
- package/package.json +56 -0
package/dist/asen.js
ADDED
|
@@ -0,0 +1,1838 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// src/cli/args.ts
|
|
4
|
+
var VALUE_TAKING_FLAGS = /* @__PURE__ */ new Set(["--reason"]);
|
|
5
|
+
function positionals(args) {
|
|
6
|
+
const found = [];
|
|
7
|
+
for (let index = 0; index < args.length; index += 1) {
|
|
8
|
+
const arg = args[index];
|
|
9
|
+
if (arg.startsWith("-")) {
|
|
10
|
+
if (VALUE_TAKING_FLAGS.has(arg)) {
|
|
11
|
+
index += 1;
|
|
12
|
+
}
|
|
13
|
+
continue;
|
|
14
|
+
}
|
|
15
|
+
found.push(arg);
|
|
16
|
+
}
|
|
17
|
+
return found;
|
|
18
|
+
}
|
|
19
|
+
function readFlag(args, flag) {
|
|
20
|
+
const index = args.indexOf(flag);
|
|
21
|
+
if (index !== -1) {
|
|
22
|
+
return args[index + 1];
|
|
23
|
+
}
|
|
24
|
+
const inline = args.find((arg) => arg.startsWith(`${flag}=`));
|
|
25
|
+
return inline ? inline.slice(flag.length + 1) : void 0;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// src/checks/package-guard/staged-deps.ts
|
|
29
|
+
import { execFileSync } from "child_process";
|
|
30
|
+
var DEP_FIELDS = ["dependencies", "devDependencies", "optionalDependencies"];
|
|
31
|
+
function newStagedDependencies(repoRoot) {
|
|
32
|
+
const names = [];
|
|
33
|
+
for (const path of stagedManifestPaths(repoRoot)) {
|
|
34
|
+
const staged = gitJson(repoRoot, `:${path}`);
|
|
35
|
+
if (!staged) {
|
|
36
|
+
continue;
|
|
37
|
+
}
|
|
38
|
+
const before = collectNames(gitJson(repoRoot, `HEAD:${path}`) ?? {});
|
|
39
|
+
for (const name of collectNames(staged)) {
|
|
40
|
+
if (!before.has(name) && !names.includes(name)) {
|
|
41
|
+
names.push(name);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
return names;
|
|
46
|
+
}
|
|
47
|
+
function stagedManifestPaths(repoRoot) {
|
|
48
|
+
const output = git(repoRoot, ["diff", "--cached", "--name-only", "-z", "--diff-filter=ACMR"]);
|
|
49
|
+
if (output === null) {
|
|
50
|
+
return [];
|
|
51
|
+
}
|
|
52
|
+
return output.split("\0").filter((path) => path === "package.json" || path.endsWith("/package.json")).filter((path) => !isInsideDependencies(path));
|
|
53
|
+
}
|
|
54
|
+
function isInsideDependencies(path) {
|
|
55
|
+
return path.split("/").includes("node_modules");
|
|
56
|
+
}
|
|
57
|
+
function repoRootOrCwd() {
|
|
58
|
+
const root = git(process.cwd(), ["rev-parse", "--show-toplevel"]);
|
|
59
|
+
return root === null ? process.cwd() : root.trim();
|
|
60
|
+
}
|
|
61
|
+
function collectNames(manifest) {
|
|
62
|
+
const names = /* @__PURE__ */ new Set();
|
|
63
|
+
for (const field of DEP_FIELDS) {
|
|
64
|
+
const section = manifest[field];
|
|
65
|
+
if (typeof section === "object" && section !== null) {
|
|
66
|
+
for (const name of Object.keys(section)) {
|
|
67
|
+
names.add(name);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
return names;
|
|
72
|
+
}
|
|
73
|
+
function gitJson(repoRoot, revision) {
|
|
74
|
+
const text = git(repoRoot, ["show", revision]);
|
|
75
|
+
if (text === null) {
|
|
76
|
+
return null;
|
|
77
|
+
}
|
|
78
|
+
try {
|
|
79
|
+
const parsed = JSON.parse(text);
|
|
80
|
+
return typeof parsed === "object" && parsed !== null ? parsed : null;
|
|
81
|
+
} catch {
|
|
82
|
+
return null;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
function git(cwd, args) {
|
|
86
|
+
try {
|
|
87
|
+
return execFileSync("git", args, {
|
|
88
|
+
cwd,
|
|
89
|
+
encoding: "utf8",
|
|
90
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
91
|
+
maxBuffer: 10 * 1024 * 1024
|
|
92
|
+
});
|
|
93
|
+
} catch {
|
|
94
|
+
return null;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// src/config/load.ts
|
|
99
|
+
import { readFileSync, writeFileSync, existsSync } from "fs";
|
|
100
|
+
import { join } from "path";
|
|
101
|
+
|
|
102
|
+
// src/config/schema.ts
|
|
103
|
+
var CONFIG_FILENAME = ".agentinel.json";
|
|
104
|
+
function defaultConfig() {
|
|
105
|
+
return { mode: "warn", allow: [] };
|
|
106
|
+
}
|
|
107
|
+
function parseConfig(raw, warn2 = () => {
|
|
108
|
+
}) {
|
|
109
|
+
const config = defaultConfig();
|
|
110
|
+
if (typeof raw !== "object" || raw === null) {
|
|
111
|
+
return config;
|
|
112
|
+
}
|
|
113
|
+
const source = raw;
|
|
114
|
+
if (source.mode === "strict" || source.mode === "warn") {
|
|
115
|
+
config.mode = source.mode;
|
|
116
|
+
} else if (source.mode !== void 0) {
|
|
117
|
+
warn2(
|
|
118
|
+
`unknown mode ${JSON.stringify(source.mode)} in ${CONFIG_FILENAME}, falling back to "warn". Valid modes are "warn" and "strict".`
|
|
119
|
+
);
|
|
120
|
+
}
|
|
121
|
+
if (Array.isArray(source.allow)) {
|
|
122
|
+
for (const entry of source.allow) {
|
|
123
|
+
if (typeof entry !== "object" || entry === null) continue;
|
|
124
|
+
const { name, reason, date } = entry;
|
|
125
|
+
if (typeof name !== "string" || name.length === 0) continue;
|
|
126
|
+
config.allow.push({
|
|
127
|
+
name,
|
|
128
|
+
reason: typeof reason === "string" ? reason : "",
|
|
129
|
+
date: typeof date === "string" ? date : ""
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
return config;
|
|
134
|
+
}
|
|
135
|
+
function isAllowlisted(config, name) {
|
|
136
|
+
const entry = config.allow.find((candidate) => candidate.name === name);
|
|
137
|
+
return entry ? { allowed: true, reason: entry.reason } : { allowed: false };
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// src/config/load.ts
|
|
141
|
+
var ConfigError = class extends Error {
|
|
142
|
+
};
|
|
143
|
+
function configPath(repoRoot) {
|
|
144
|
+
return join(repoRoot, CONFIG_FILENAME);
|
|
145
|
+
}
|
|
146
|
+
function loadConfig(repoRoot) {
|
|
147
|
+
const path = configPath(repoRoot);
|
|
148
|
+
if (!existsSync(path)) {
|
|
149
|
+
return defaultConfig();
|
|
150
|
+
}
|
|
151
|
+
let text;
|
|
152
|
+
try {
|
|
153
|
+
text = readFileSync(path, "utf8");
|
|
154
|
+
} catch (error) {
|
|
155
|
+
throw new ConfigError(`could not read ${CONFIG_FILENAME}: ${describe(error)}`);
|
|
156
|
+
}
|
|
157
|
+
let raw;
|
|
158
|
+
try {
|
|
159
|
+
raw = JSON.parse(text);
|
|
160
|
+
} catch {
|
|
161
|
+
throw new ConfigError(`${CONFIG_FILENAME} is not valid JSON, fix it or delete it`);
|
|
162
|
+
}
|
|
163
|
+
return parseConfig(raw, (message) => process.stderr.write(`agentinel: ${message}
|
|
164
|
+
`));
|
|
165
|
+
}
|
|
166
|
+
function saveConfig(repoRoot, config) {
|
|
167
|
+
writeFileSync(configPath(repoRoot), JSON.stringify(config, null, 2) + "\n", "utf8");
|
|
168
|
+
}
|
|
169
|
+
function describe(error) {
|
|
170
|
+
return error instanceof Error ? error.message : String(error);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// src/commands/allow.ts
|
|
174
|
+
function runAllow(name, reason) {
|
|
175
|
+
if (!name) {
|
|
176
|
+
console.error('usage: asen allow <pkg> --reason "why this package is trusted"');
|
|
177
|
+
return 1;
|
|
178
|
+
}
|
|
179
|
+
if (!reason || reason.trim().length === 0) {
|
|
180
|
+
console.error(
|
|
181
|
+
`a reason is required, so anyone reading the config later knows why ${name} was trusted`
|
|
182
|
+
);
|
|
183
|
+
console.error(` asen allow ${name} --reason "published by me, expected to be new"`);
|
|
184
|
+
return 1;
|
|
185
|
+
}
|
|
186
|
+
const repoRoot = repoRootOrCwd();
|
|
187
|
+
const config = loadConfig(repoRoot);
|
|
188
|
+
if (config.allow.some((entry) => entry.name === name)) {
|
|
189
|
+
console.log(`${name} is already allowlisted`);
|
|
190
|
+
return 0;
|
|
191
|
+
}
|
|
192
|
+
config.allow.push({
|
|
193
|
+
name,
|
|
194
|
+
reason: reason.trim(),
|
|
195
|
+
date: (/* @__PURE__ */ new Date()).toISOString().slice(0, 10)
|
|
196
|
+
});
|
|
197
|
+
saveConfig(repoRoot, config);
|
|
198
|
+
console.log(`allowlisted ${name} in .agentinel.json`);
|
|
199
|
+
return 0;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// src/types.ts
|
|
203
|
+
var MAX_AGE_DAYS = 30;
|
|
204
|
+
var MIN_MONTHLY_DOWNLOADS = 1e3;
|
|
205
|
+
var SIZE_JUMP_RATIO = 3;
|
|
206
|
+
function isRisky(verdict) {
|
|
207
|
+
return verdict.kind === "flagged" || verdict.kind === "not-found";
|
|
208
|
+
}
|
|
209
|
+
function isConfirmed(reason) {
|
|
210
|
+
return reason.kind === "known-malware" || reason.kind === "security-hold";
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// src/checks/package-guard/http.ts
|
|
214
|
+
var TIMEOUT_MS = 1e4;
|
|
215
|
+
var ATTEMPTS = 2;
|
|
216
|
+
var RequestFailed = class extends Error {
|
|
217
|
+
constructor(message, timedOut) {
|
|
218
|
+
super(message);
|
|
219
|
+
this.timedOut = timedOut;
|
|
220
|
+
}
|
|
221
|
+
timedOut;
|
|
222
|
+
};
|
|
223
|
+
async function get(url) {
|
|
224
|
+
let last = null;
|
|
225
|
+
for (let attempt = 0; attempt < ATTEMPTS; attempt += 1) {
|
|
226
|
+
try {
|
|
227
|
+
return await fetch(url, { signal: AbortSignal.timeout(TIMEOUT_MS) });
|
|
228
|
+
} catch (error) {
|
|
229
|
+
last = new RequestFailed(describe2(error), isTimeout(error));
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
throw last ?? new RequestFailed("request failed", false);
|
|
233
|
+
}
|
|
234
|
+
function isTimeout(error) {
|
|
235
|
+
if (!(error instanceof RequestFailed)) {
|
|
236
|
+
if (!(error instanceof Error)) {
|
|
237
|
+
return false;
|
|
238
|
+
}
|
|
239
|
+
if (error.name === "TimeoutError" || error.name === "AbortError") {
|
|
240
|
+
return true;
|
|
241
|
+
}
|
|
242
|
+
return isTimeout(error.cause);
|
|
243
|
+
}
|
|
244
|
+
return error.timedOut;
|
|
245
|
+
}
|
|
246
|
+
function describe2(error) {
|
|
247
|
+
return error instanceof Error ? error.message : String(error);
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
// src/checks/package-guard/downloads.ts
|
|
251
|
+
var DOWNLOADS_URL = "https://api.npmjs.org/downloads/point/last-month";
|
|
252
|
+
async function fetchDownloads(name) {
|
|
253
|
+
const url = `${DOWNLOADS_URL}/${name.replace("/", "%2F")}`;
|
|
254
|
+
let response;
|
|
255
|
+
try {
|
|
256
|
+
response = await get(url);
|
|
257
|
+
} catch (error) {
|
|
258
|
+
return { kind: "unavailable", reason: describeFailure(error) };
|
|
259
|
+
}
|
|
260
|
+
if (response.status === 404) {
|
|
261
|
+
return { kind: "no-data" };
|
|
262
|
+
}
|
|
263
|
+
if (!response.ok) {
|
|
264
|
+
return { kind: "unavailable", reason: `downloads API returned ${response.status}` };
|
|
265
|
+
}
|
|
266
|
+
let body;
|
|
267
|
+
try {
|
|
268
|
+
body = await response.json();
|
|
269
|
+
} catch (error) {
|
|
270
|
+
return { kind: "unavailable", reason: describeFailure(error) };
|
|
271
|
+
}
|
|
272
|
+
const lastMonth = readDownloadCount(body);
|
|
273
|
+
if (lastMonth === null) {
|
|
274
|
+
return { kind: "unavailable", reason: "downloads response had no usable count" };
|
|
275
|
+
}
|
|
276
|
+
return { kind: "found", lastMonth };
|
|
277
|
+
}
|
|
278
|
+
function readDownloadCount(body) {
|
|
279
|
+
if (typeof body !== "object" || body === null) {
|
|
280
|
+
return null;
|
|
281
|
+
}
|
|
282
|
+
const downloads2 = body.downloads;
|
|
283
|
+
if (typeof downloads2 !== "number" || !Number.isFinite(downloads2)) {
|
|
284
|
+
return null;
|
|
285
|
+
}
|
|
286
|
+
return downloads2;
|
|
287
|
+
}
|
|
288
|
+
function describeFailure(error) {
|
|
289
|
+
return isTimeout(error) ? "downloads request timed out" : "could not reach the npm downloads API";
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
// src/checks/package-guard/malware.ts
|
|
293
|
+
import { existsSync as existsSync2, readFileSync as readFileSync2 } from "fs";
|
|
294
|
+
import { dirname, join as join2 } from "path";
|
|
295
|
+
import { fileURLToPath } from "url";
|
|
296
|
+
import { gunzipSync } from "zlib";
|
|
297
|
+
var cache = null;
|
|
298
|
+
function list() {
|
|
299
|
+
if (cache === null) {
|
|
300
|
+
cache = load();
|
|
301
|
+
}
|
|
302
|
+
return cache;
|
|
303
|
+
}
|
|
304
|
+
function isKnownMalware(name, version) {
|
|
305
|
+
const versions = list()[name];
|
|
306
|
+
if (versions === void 0) {
|
|
307
|
+
return false;
|
|
308
|
+
}
|
|
309
|
+
if (versions.length === 0) {
|
|
310
|
+
return true;
|
|
311
|
+
}
|
|
312
|
+
return version ? versions.includes(version) : false;
|
|
313
|
+
}
|
|
314
|
+
function load() {
|
|
315
|
+
const path = listPath();
|
|
316
|
+
if (path === null) {
|
|
317
|
+
return {};
|
|
318
|
+
}
|
|
319
|
+
try {
|
|
320
|
+
const raw = gunzipSync(readFileSync2(path)).toString("utf8");
|
|
321
|
+
const parsed = JSON.parse(raw);
|
|
322
|
+
return typeof parsed === "object" && parsed !== null ? parsed : {};
|
|
323
|
+
} catch {
|
|
324
|
+
return {};
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
function listPath() {
|
|
328
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
329
|
+
const candidates = [
|
|
330
|
+
join2(here, "..", "data", "malware-names.json.gz"),
|
|
331
|
+
join2(here, "..", "..", "data", "malware-names.json.gz"),
|
|
332
|
+
join2(here, "..", "..", "..", "data", "malware-names.json.gz")
|
|
333
|
+
];
|
|
334
|
+
return candidates.find((path) => existsSync2(path)) ?? null;
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
// src/checks/package-guard/parse-install.ts
|
|
338
|
+
var PACKAGE_MANAGERS = /* @__PURE__ */ new Set(["npm", "pnpm", "yarn", "bun"]);
|
|
339
|
+
var INSTALL_SUBCOMMANDS = /* @__PURE__ */ new Set([
|
|
340
|
+
"install",
|
|
341
|
+
"i",
|
|
342
|
+
"add",
|
|
343
|
+
"in",
|
|
344
|
+
"ins",
|
|
345
|
+
"inst",
|
|
346
|
+
"insta",
|
|
347
|
+
"instal"
|
|
348
|
+
]);
|
|
349
|
+
var EXECUTE_SUBCOMMANDS = /* @__PURE__ */ new Set(["dlx", "exec"]);
|
|
350
|
+
var EXECUTE_COMMANDS = /* @__PURE__ */ new Set(["npx", "bunx", "pnpx"]);
|
|
351
|
+
var LOCKFILE_SUBCOMMANDS = /* @__PURE__ */ new Set(["ci", "install", "i"]);
|
|
352
|
+
var VALUE_TAKING_FLAGS2 = /* @__PURE__ */ new Set([
|
|
353
|
+
"--registry",
|
|
354
|
+
"--prefix",
|
|
355
|
+
"--workspace",
|
|
356
|
+
"-w",
|
|
357
|
+
"--package",
|
|
358
|
+
"-p",
|
|
359
|
+
"--call",
|
|
360
|
+
"-c"
|
|
361
|
+
]);
|
|
362
|
+
var PACKAGE_NAME = /^(?:@[a-z0-9-~][a-z0-9-._~]*\/)?[a-z0-9-~][a-z0-9-._~]*$/i;
|
|
363
|
+
function isValidPackageName(token) {
|
|
364
|
+
if (token.length === 0 || token.length > 214) {
|
|
365
|
+
return false;
|
|
366
|
+
}
|
|
367
|
+
return PACKAGE_NAME.test(token);
|
|
368
|
+
}
|
|
369
|
+
var NOTHING = { installs: [], executes: [], lockfile: false };
|
|
370
|
+
function parseCommand(command) {
|
|
371
|
+
const intent = { installs: [], executes: [], lockfile: false };
|
|
372
|
+
for (const segment of splitSegments(command)) {
|
|
373
|
+
const found = parseSegment(segment);
|
|
374
|
+
for (const name of found.installs) {
|
|
375
|
+
if (!intent.installs.includes(name)) intent.installs.push(name);
|
|
376
|
+
}
|
|
377
|
+
for (const name of found.executes) {
|
|
378
|
+
if (!intent.executes.includes(name)) intent.executes.push(name);
|
|
379
|
+
}
|
|
380
|
+
if (found.lockfile) intent.lockfile = true;
|
|
381
|
+
}
|
|
382
|
+
return intent;
|
|
383
|
+
}
|
|
384
|
+
function splitSegments(command) {
|
|
385
|
+
return command.split(/&&|\|\||[;|&\n]/).map((segment) => segment.trim()).filter((segment) => segment.length > 0);
|
|
386
|
+
}
|
|
387
|
+
function parseSegment(segment) {
|
|
388
|
+
const tokens = tokenize(segment);
|
|
389
|
+
const head = tokens[0];
|
|
390
|
+
if (head === void 0) {
|
|
391
|
+
return NOTHING;
|
|
392
|
+
}
|
|
393
|
+
if (EXECUTE_COMMANDS.has(head)) {
|
|
394
|
+
return { installs: [], executes: namesAfter(tokens, 1, 1), lockfile: false };
|
|
395
|
+
}
|
|
396
|
+
if (!PACKAGE_MANAGERS.has(head)) {
|
|
397
|
+
return NOTHING;
|
|
398
|
+
}
|
|
399
|
+
let index = 1;
|
|
400
|
+
while (index < tokens.length && isFlag(tokens[index])) {
|
|
401
|
+
index += consumedBy(tokens[index]);
|
|
402
|
+
}
|
|
403
|
+
const subcommand = tokens[index];
|
|
404
|
+
if (subcommand === void 0) {
|
|
405
|
+
return head === "yarn" ? { ...NOTHING, lockfile: true } : NOTHING;
|
|
406
|
+
}
|
|
407
|
+
index += 1;
|
|
408
|
+
if (EXECUTE_SUBCOMMANDS.has(subcommand)) {
|
|
409
|
+
return { installs: [], executes: namesAfter(tokens, index, 1), lockfile: false };
|
|
410
|
+
}
|
|
411
|
+
if (!INSTALL_SUBCOMMANDS.has(subcommand) && !LOCKFILE_SUBCOMMANDS.has(subcommand)) {
|
|
412
|
+
return NOTHING;
|
|
413
|
+
}
|
|
414
|
+
const named = namesAfter(tokens, index, Infinity);
|
|
415
|
+
if (named.length === 0 && positionalCount(tokens, index) === 0) {
|
|
416
|
+
return LOCKFILE_SUBCOMMANDS.has(subcommand) ? { ...NOTHING, lockfile: true } : NOTHING;
|
|
417
|
+
}
|
|
418
|
+
return { installs: named, executes: [], lockfile: false };
|
|
419
|
+
}
|
|
420
|
+
function namesAfter(tokens, start, limit) {
|
|
421
|
+
const names = [];
|
|
422
|
+
for (let index = start; index < tokens.length && names.length < limit; index += 1) {
|
|
423
|
+
const token = tokens[index];
|
|
424
|
+
if (token === "--") {
|
|
425
|
+
continue;
|
|
426
|
+
}
|
|
427
|
+
if (isFlag(token)) {
|
|
428
|
+
index += consumedBy(token) - 1;
|
|
429
|
+
continue;
|
|
430
|
+
}
|
|
431
|
+
const name = packageNameFrom(token);
|
|
432
|
+
if (name !== null) {
|
|
433
|
+
names.push(name);
|
|
434
|
+
} else if (limit === 1) {
|
|
435
|
+
return names;
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
return names;
|
|
439
|
+
}
|
|
440
|
+
function positionalCount(tokens, start) {
|
|
441
|
+
let count = 0;
|
|
442
|
+
for (let index = start; index < tokens.length; index += 1) {
|
|
443
|
+
const token = tokens[index];
|
|
444
|
+
if (isFlag(token)) {
|
|
445
|
+
index += consumedBy(token) - 1;
|
|
446
|
+
continue;
|
|
447
|
+
}
|
|
448
|
+
if (token !== "--") count += 1;
|
|
449
|
+
}
|
|
450
|
+
return count;
|
|
451
|
+
}
|
|
452
|
+
function tokenize(segment) {
|
|
453
|
+
const tokens = [];
|
|
454
|
+
const pattern = /"([^"]*)"|'([^']*)'|(\S+)/g;
|
|
455
|
+
let match = pattern.exec(segment);
|
|
456
|
+
while (match !== null) {
|
|
457
|
+
tokens.push(match[1] ?? match[2] ?? match[3] ?? "");
|
|
458
|
+
match = pattern.exec(segment);
|
|
459
|
+
}
|
|
460
|
+
return tokens;
|
|
461
|
+
}
|
|
462
|
+
function isFlag(token) {
|
|
463
|
+
return token.startsWith("-") && token.length > 1;
|
|
464
|
+
}
|
|
465
|
+
function consumedBy(flag) {
|
|
466
|
+
if (flag.includes("=")) {
|
|
467
|
+
return 1;
|
|
468
|
+
}
|
|
469
|
+
return VALUE_TAKING_FLAGS2.has(flag) ? 2 : 1;
|
|
470
|
+
}
|
|
471
|
+
function packageNameFrom(token) {
|
|
472
|
+
const separator = token.startsWith("@") ? token.indexOf("@", 1) : token.indexOf("@");
|
|
473
|
+
if (separator === -1) {
|
|
474
|
+
return isValidPackageName(token) ? token : null;
|
|
475
|
+
}
|
|
476
|
+
const specifier = token.slice(separator + 1);
|
|
477
|
+
if (specifier.startsWith("npm:")) {
|
|
478
|
+
return packageNameFrom(specifier.slice("npm:".length));
|
|
479
|
+
}
|
|
480
|
+
const name = token.slice(0, separator);
|
|
481
|
+
return isValidPackageName(name) ? name : null;
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
// src/checks/package-guard/registry.ts
|
|
485
|
+
var REGISTRY_URL = "https://registry.npmjs.org";
|
|
486
|
+
async function fetchRegistry(name) {
|
|
487
|
+
const url = `${REGISTRY_URL}/${name.replace("/", "%2F")}`;
|
|
488
|
+
let response;
|
|
489
|
+
try {
|
|
490
|
+
response = await get(url);
|
|
491
|
+
} catch (error) {
|
|
492
|
+
return { kind: "unavailable", reason: describeFailure2(error) };
|
|
493
|
+
}
|
|
494
|
+
if (response.status === 404) {
|
|
495
|
+
return { kind: "not-found" };
|
|
496
|
+
}
|
|
497
|
+
if (!response.ok) {
|
|
498
|
+
return { kind: "unavailable", reason: `registry returned ${response.status}` };
|
|
499
|
+
}
|
|
500
|
+
let body;
|
|
501
|
+
try {
|
|
502
|
+
body = await response.json();
|
|
503
|
+
} catch (error) {
|
|
504
|
+
return { kind: "unavailable", reason: describeFailure2(error) };
|
|
505
|
+
}
|
|
506
|
+
const facts = readFacts(body);
|
|
507
|
+
if (facts === null) {
|
|
508
|
+
return { kind: "unavailable", reason: "registry response had no usable creation date" };
|
|
509
|
+
}
|
|
510
|
+
return { kind: "found", facts };
|
|
511
|
+
}
|
|
512
|
+
function readFacts(body) {
|
|
513
|
+
if (typeof body !== "object" || body === null) {
|
|
514
|
+
return null;
|
|
515
|
+
}
|
|
516
|
+
const doc = body;
|
|
517
|
+
const created = readCreatedDate(doc);
|
|
518
|
+
if (created === null) {
|
|
519
|
+
return null;
|
|
520
|
+
}
|
|
521
|
+
const versions = asRecord(doc.versions) ?? {};
|
|
522
|
+
const order = versionsInPublishOrder(doc, versions);
|
|
523
|
+
const latestName = latestVersionName(doc, order);
|
|
524
|
+
const latest = latestName ? asRecord(versions[latestName]) : null;
|
|
525
|
+
const previousName = order[order.indexOf(latestName ?? "") - 1];
|
|
526
|
+
const previous = previousName ? asRecord(versions[previousName]) : null;
|
|
527
|
+
const latestPublisher = publisherOf(latest);
|
|
528
|
+
const priorPublishers = order.filter((v) => v !== latestName).map((v) => publisherOf(asRecord(versions[v]))).filter((p) => p !== null);
|
|
529
|
+
return {
|
|
530
|
+
created,
|
|
531
|
+
latestVersion: latestName,
|
|
532
|
+
securityHold: isSecurityHold(doc, latestName),
|
|
533
|
+
versionCount: Object.keys(versions).length,
|
|
534
|
+
hasRepository: Boolean(latest?.repository ?? doc.repository),
|
|
535
|
+
latestPublisher,
|
|
536
|
+
priorPublishers,
|
|
537
|
+
unpackedSize: sizeOf(latest),
|
|
538
|
+
previousUnpackedSize: sizeOf(previous),
|
|
539
|
+
latestIsSmallBump: isSmallBump(previousName, latestName)
|
|
540
|
+
};
|
|
541
|
+
}
|
|
542
|
+
function isSecurityHold(doc, latestName) {
|
|
543
|
+
if (typeof latestName === "string" && latestName.endsWith("-security")) {
|
|
544
|
+
return true;
|
|
545
|
+
}
|
|
546
|
+
const description = doc.description;
|
|
547
|
+
return typeof description === "string" && description.toLowerCase().includes("security holding");
|
|
548
|
+
}
|
|
549
|
+
function publisherOf(version) {
|
|
550
|
+
const user = asRecord(version?._npmUser);
|
|
551
|
+
const name = user?.name;
|
|
552
|
+
return typeof name === "string" ? name : null;
|
|
553
|
+
}
|
|
554
|
+
function sizeOf(version) {
|
|
555
|
+
const dist = asRecord(version?.dist);
|
|
556
|
+
const size2 = dist?.unpackedSize;
|
|
557
|
+
return typeof size2 === "number" && Number.isFinite(size2) ? size2 : null;
|
|
558
|
+
}
|
|
559
|
+
function versionsInPublishOrder(doc, versions) {
|
|
560
|
+
const time = asRecord(doc.time) ?? {};
|
|
561
|
+
return Object.keys(time).filter((key) => key !== "created" && key !== "modified" && key in versions).sort((a, b) => Date.parse(String(time[a])) - Date.parse(String(time[b])));
|
|
562
|
+
}
|
|
563
|
+
function latestVersionName(doc, order) {
|
|
564
|
+
const tags = asRecord(doc["dist-tags"]);
|
|
565
|
+
const latest = tags?.latest;
|
|
566
|
+
if (typeof latest === "string") {
|
|
567
|
+
return latest;
|
|
568
|
+
}
|
|
569
|
+
return order.length > 0 ? order[order.length - 1] ?? null : null;
|
|
570
|
+
}
|
|
571
|
+
function isSmallBump(previous, latest) {
|
|
572
|
+
if (!previous || !latest) {
|
|
573
|
+
return false;
|
|
574
|
+
}
|
|
575
|
+
const major = (v) => v.split(".")[0];
|
|
576
|
+
return major(previous) === major(latest);
|
|
577
|
+
}
|
|
578
|
+
function readCreatedDate(doc) {
|
|
579
|
+
const time = asRecord(doc.time);
|
|
580
|
+
const created = time?.created;
|
|
581
|
+
if (typeof created !== "string") {
|
|
582
|
+
return null;
|
|
583
|
+
}
|
|
584
|
+
const parsed = new Date(created);
|
|
585
|
+
return Number.isNaN(parsed.getTime()) ? null : parsed;
|
|
586
|
+
}
|
|
587
|
+
function asRecord(value) {
|
|
588
|
+
return typeof value === "object" && value !== null ? value : null;
|
|
589
|
+
}
|
|
590
|
+
function describeFailure2(error) {
|
|
591
|
+
return isTimeout(error) ? "registry request timed out" : "could not reach the npm registry";
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
// src/checks/package-guard/evaluate.ts
|
|
595
|
+
var MS_PER_DAY = 864e5;
|
|
596
|
+
function evaluate(name, registry, downloads2, config, now = /* @__PURE__ */ new Date()) {
|
|
597
|
+
const allowed = isAllowlisted(config, name);
|
|
598
|
+
if (allowed.allowed) {
|
|
599
|
+
return { kind: "allowlisted", name, reason: allowed.reason };
|
|
600
|
+
}
|
|
601
|
+
const version = registry.kind === "found" ? registry.facts.latestVersion : null;
|
|
602
|
+
const confirmed = isKnownMalware(name, version) ? [{ kind: "known-malware" }] : [];
|
|
603
|
+
if (registry.kind === "not-found") {
|
|
604
|
+
return confirmed.length > 0 ? { kind: "flagged", name, reasons: confirmed } : { kind: "not-found", name };
|
|
605
|
+
}
|
|
606
|
+
if (registry.kind === "unavailable") {
|
|
607
|
+
return confirmed.length > 0 ? { kind: "flagged", name, reasons: confirmed } : { kind: "skipped", name, reason: registry.reason };
|
|
608
|
+
}
|
|
609
|
+
if (downloads2.kind === "unavailable") {
|
|
610
|
+
return confirmed.length > 0 ? { kind: "flagged", name, reasons: confirmed } : { kind: "skipped", name, reason: downloads2.reason };
|
|
611
|
+
}
|
|
612
|
+
const facts = registry.facts;
|
|
613
|
+
const lastMonth = downloads2.kind === "no-data" ? 0 : downloads2.lastMonth;
|
|
614
|
+
const ageDays = Math.floor((now.getTime() - facts.created.getTime()) / MS_PER_DAY);
|
|
615
|
+
const reasons = [...confirmed];
|
|
616
|
+
if (facts.securityHold) {
|
|
617
|
+
reasons.push({ kind: "security-hold" });
|
|
618
|
+
}
|
|
619
|
+
if (ageDays < MAX_AGE_DAYS && lastMonth < MIN_MONTHLY_DOWNLOADS) {
|
|
620
|
+
reasons.push({ kind: "new-and-unpopular", ageDays, downloads: lastMonth });
|
|
621
|
+
}
|
|
622
|
+
if (!facts.hasRepository && facts.versionCount === 1 && lastMonth < MIN_MONTHLY_DOWNLOADS) {
|
|
623
|
+
reasons.push({ kind: "no-track-record", downloads: lastMonth });
|
|
624
|
+
}
|
|
625
|
+
const jump = sizeJump(facts.unpackedSize, facts.previousUnpackedSize, facts.latestIsSmallBump);
|
|
626
|
+
if (jump) {
|
|
627
|
+
reasons.push(jump);
|
|
628
|
+
}
|
|
629
|
+
const drift = publisherDrift(facts.latestPublisher, facts.priorPublishers);
|
|
630
|
+
if (drift && reasons.length > 0) {
|
|
631
|
+
reasons.push(drift);
|
|
632
|
+
}
|
|
633
|
+
return reasons.length > 0 ? { kind: "flagged", name, reasons } : { kind: "clean", name };
|
|
634
|
+
}
|
|
635
|
+
function publisherDrift(latest, prior) {
|
|
636
|
+
if (!latest || prior.length < 3) {
|
|
637
|
+
return null;
|
|
638
|
+
}
|
|
639
|
+
const recent = prior.slice(-5);
|
|
640
|
+
const machine = (name) => /github|actions|bot|ci|npm-cli|semantic-release/i.test(name);
|
|
641
|
+
const allPriorWereMachines = recent.every(machine);
|
|
642
|
+
const allPriorWereSamePerson = recent.every((p) => p === recent[0]) && !machine(recent[0]);
|
|
643
|
+
if (allPriorWereMachines && !machine(latest)) {
|
|
644
|
+
return { kind: "publisher-drift", before: recent[recent.length - 1], now: latest };
|
|
645
|
+
}
|
|
646
|
+
if (allPriorWereSamePerson && latest !== recent[0]) {
|
|
647
|
+
return { kind: "publisher-drift", before: recent[0], now: latest };
|
|
648
|
+
}
|
|
649
|
+
return null;
|
|
650
|
+
}
|
|
651
|
+
function sizeJump(now, before, smallBump) {
|
|
652
|
+
if (!smallBump || now === null || before === null || before === 0) {
|
|
653
|
+
return null;
|
|
654
|
+
}
|
|
655
|
+
return now / before >= SIZE_JUMP_RATIO ? { kind: "size-jump", before, now } : null;
|
|
656
|
+
}
|
|
657
|
+
function needsRegistryLookup(downloads2) {
|
|
658
|
+
if (downloads2.kind === "found") {
|
|
659
|
+
return downloads2.lastMonth < MIN_MONTHLY_DOWNLOADS;
|
|
660
|
+
}
|
|
661
|
+
return true;
|
|
662
|
+
}
|
|
663
|
+
var CONCURRENCY = 5;
|
|
664
|
+
async function checkPackages(names, config, depth = "thorough") {
|
|
665
|
+
const unique = [...new Set(names)];
|
|
666
|
+
const now = /* @__PURE__ */ new Date();
|
|
667
|
+
const verdicts = new Array(unique.length);
|
|
668
|
+
let next = 0;
|
|
669
|
+
async function worker() {
|
|
670
|
+
while (next < unique.length) {
|
|
671
|
+
const index = next;
|
|
672
|
+
next += 1;
|
|
673
|
+
const name = unique[index];
|
|
674
|
+
try {
|
|
675
|
+
verdicts[index] = await checkOne(name, config, now, depth);
|
|
676
|
+
} catch (error) {
|
|
677
|
+
const reason = error instanceof Error ? error.message : String(error);
|
|
678
|
+
verdicts[index] = { kind: "skipped", name, reason };
|
|
679
|
+
}
|
|
680
|
+
}
|
|
681
|
+
}
|
|
682
|
+
const workers = Math.min(CONCURRENCY, unique.length);
|
|
683
|
+
await Promise.all(Array.from({ length: workers }, () => worker()));
|
|
684
|
+
return verdicts;
|
|
685
|
+
}
|
|
686
|
+
async function checkOne(name, config, now, depth) {
|
|
687
|
+
const allowed = isAllowlisted(config, name);
|
|
688
|
+
if (allowed.allowed) {
|
|
689
|
+
return { kind: "allowlisted", name, reason: allowed.reason };
|
|
690
|
+
}
|
|
691
|
+
if (!isValidPackageName(name)) {
|
|
692
|
+
return { kind: "skipped", name, reason: "not a valid npm package name" };
|
|
693
|
+
}
|
|
694
|
+
if (isKnownMalware(name, null)) {
|
|
695
|
+
return { kind: "flagged", name, reasons: [{ kind: "known-malware" }] };
|
|
696
|
+
}
|
|
697
|
+
const downloads2 = await fetchDownloads(name);
|
|
698
|
+
if (depth === "quick" && !needsRegistryLookup(downloads2)) {
|
|
699
|
+
return { kind: "clean", name };
|
|
700
|
+
}
|
|
701
|
+
const registry = await fetchRegistry(name);
|
|
702
|
+
return evaluate(name, registry, downloads2, config, now);
|
|
703
|
+
}
|
|
704
|
+
async function scan(named, tree, config) {
|
|
705
|
+
const namedVerdicts = await checkPackages(named, config, "thorough");
|
|
706
|
+
const treeVerdicts = scanForKnownMalware(tree, config);
|
|
707
|
+
return [...namedVerdicts, ...treeVerdicts];
|
|
708
|
+
}
|
|
709
|
+
function scanForKnownMalware(tree, config) {
|
|
710
|
+
const verdicts = [];
|
|
711
|
+
for (const { name, version } of tree) {
|
|
712
|
+
if (isAllowlisted(config, name).allowed) {
|
|
713
|
+
continue;
|
|
714
|
+
}
|
|
715
|
+
if (isKnownMalware(name, version)) {
|
|
716
|
+
verdicts.push({ kind: "flagged", name, reasons: [{ kind: "known-malware" }] });
|
|
717
|
+
}
|
|
718
|
+
}
|
|
719
|
+
return verdicts;
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
// src/checks/package-guard/lockfile.ts
|
|
723
|
+
import { execFileSync as execFileSync2 } from "child_process";
|
|
724
|
+
import { existsSync as existsSync3, readFileSync as readFileSync3 } from "fs";
|
|
725
|
+
import { join as join3 } from "path";
|
|
726
|
+
function packagesInLockfile(repoRoot) {
|
|
727
|
+
const path = join3(repoRoot, "package-lock.json");
|
|
728
|
+
if (!existsSync3(path)) {
|
|
729
|
+
return [];
|
|
730
|
+
}
|
|
731
|
+
return packagesInLockText(readFileOrNull(path));
|
|
732
|
+
}
|
|
733
|
+
function stagedLockfilePackages(repoRoot) {
|
|
734
|
+
let text = null;
|
|
735
|
+
try {
|
|
736
|
+
text = execFileSync2("git", ["show", ":package-lock.json"], {
|
|
737
|
+
cwd: repoRoot,
|
|
738
|
+
encoding: "utf8",
|
|
739
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
740
|
+
maxBuffer: 64 * 1024 * 1024
|
|
741
|
+
});
|
|
742
|
+
} catch {
|
|
743
|
+
return [];
|
|
744
|
+
}
|
|
745
|
+
return packagesInLockText(text);
|
|
746
|
+
}
|
|
747
|
+
function packagesInLockText(text) {
|
|
748
|
+
if (text === null) {
|
|
749
|
+
return [];
|
|
750
|
+
}
|
|
751
|
+
let lock;
|
|
752
|
+
try {
|
|
753
|
+
lock = JSON.parse(text);
|
|
754
|
+
} catch {
|
|
755
|
+
return [];
|
|
756
|
+
}
|
|
757
|
+
if (typeof lock !== "object" || lock === null) {
|
|
758
|
+
return [];
|
|
759
|
+
}
|
|
760
|
+
const found = /* @__PURE__ */ new Map();
|
|
761
|
+
collectFromPackages(lock, found);
|
|
762
|
+
collectFromDependencies(lock, found);
|
|
763
|
+
return [...found.values()];
|
|
764
|
+
}
|
|
765
|
+
function readFileOrNull(path) {
|
|
766
|
+
try {
|
|
767
|
+
return readFileSync3(path, "utf8");
|
|
768
|
+
} catch {
|
|
769
|
+
return null;
|
|
770
|
+
}
|
|
771
|
+
}
|
|
772
|
+
function collectFromPackages(lock, found) {
|
|
773
|
+
const packages = lock.packages;
|
|
774
|
+
if (typeof packages !== "object" || packages === null) {
|
|
775
|
+
return;
|
|
776
|
+
}
|
|
777
|
+
const entries = packages;
|
|
778
|
+
for (const key of Object.keys(entries)) {
|
|
779
|
+
if (key === "") continue;
|
|
780
|
+
const marker = key.lastIndexOf("node_modules/");
|
|
781
|
+
if (marker === -1) continue;
|
|
782
|
+
const name = key.slice(marker + "node_modules/".length);
|
|
783
|
+
if (!name) continue;
|
|
784
|
+
const entry = entries[key];
|
|
785
|
+
const version = typeof entry === "object" && entry !== null ? String(entry.version ?? "") : "";
|
|
786
|
+
found.set(name, { name, version });
|
|
787
|
+
}
|
|
788
|
+
}
|
|
789
|
+
function collectFromDependencies(lock, found) {
|
|
790
|
+
const walk = (node) => {
|
|
791
|
+
if (typeof node !== "object" || node === null) return;
|
|
792
|
+
const deps = node.dependencies;
|
|
793
|
+
if (typeof deps !== "object" || deps === null) return;
|
|
794
|
+
for (const [name, child] of Object.entries(deps)) {
|
|
795
|
+
const version = typeof child === "object" && child !== null ? String(child.version ?? "") : "";
|
|
796
|
+
found.set(name, { name, version });
|
|
797
|
+
walk(child);
|
|
798
|
+
}
|
|
799
|
+
};
|
|
800
|
+
walk(lock);
|
|
801
|
+
}
|
|
802
|
+
|
|
803
|
+
// src/output/format.ts
|
|
804
|
+
var RESET = "\x1B[0m";
|
|
805
|
+
var YELLOW = "\x1B[33m";
|
|
806
|
+
var RED = "\x1B[31m";
|
|
807
|
+
var DIM = "\x1B[2m";
|
|
808
|
+
var WIDTH = 46;
|
|
809
|
+
var TITLE = "agentinel";
|
|
810
|
+
function supportsColor(stream = process.stdout) {
|
|
811
|
+
return stream.isTTY === true && !process.env.NO_COLOR;
|
|
812
|
+
}
|
|
813
|
+
function describeReason(reason) {
|
|
814
|
+
switch (reason.kind) {
|
|
815
|
+
case "known-malware":
|
|
816
|
+
return "listed as malware on the public advisory database";
|
|
817
|
+
case "security-hold":
|
|
818
|
+
return "npm has taken this package down for security";
|
|
819
|
+
case "new-and-unpopular":
|
|
820
|
+
return `registered ${days(reason.ageDays)} ago, ${downloads(reason.downloads)} last month`;
|
|
821
|
+
case "publisher-drift":
|
|
822
|
+
return `published by ${reason.now}, but previous versions came from ${reason.before}`;
|
|
823
|
+
case "no-track-record":
|
|
824
|
+
return `no repository, only one version ever, ${downloads(reason.downloads)} last month`;
|
|
825
|
+
case "size-jump":
|
|
826
|
+
return `a small version bump, but the code grew from ${size(reason.before)} to ${size(reason.now)}`;
|
|
827
|
+
}
|
|
828
|
+
}
|
|
829
|
+
function banner(verdict) {
|
|
830
|
+
switch (verdict.kind) {
|
|
831
|
+
case "clean":
|
|
832
|
+
case "allowlisted":
|
|
833
|
+
return null;
|
|
834
|
+
case "flagged": {
|
|
835
|
+
const confirmed = verdict.reasons.some(isConfirmed);
|
|
836
|
+
const body = [verdict.name, ""];
|
|
837
|
+
for (const reason of verdict.reasons) {
|
|
838
|
+
body.push(`- ${describeReason(reason)}`);
|
|
839
|
+
}
|
|
840
|
+
if (!confirmed) {
|
|
841
|
+
body.push("", "This is the pattern slopsquatting attacks use.");
|
|
842
|
+
}
|
|
843
|
+
return {
|
|
844
|
+
heading: confirmed ? "MALICIOUS PACKAGE" : "SUSPICIOUS PACKAGE",
|
|
845
|
+
body,
|
|
846
|
+
action: confirmed ? "Do not install this. It is known malware." : `Trust it: asen allow ${verdict.name} --reason "..."`,
|
|
847
|
+
tone: confirmed ? "bad" : "warn"
|
|
848
|
+
};
|
|
849
|
+
}
|
|
850
|
+
case "not-found":
|
|
851
|
+
return {
|
|
852
|
+
heading: "PACKAGE DOES NOT EXIST",
|
|
853
|
+
body: [
|
|
854
|
+
verdict.name,
|
|
855
|
+
"",
|
|
856
|
+
"No package by that name is published on npm, so this install will fail.",
|
|
857
|
+
"A name an agent invented is exactly what a slopsquatter waits to register."
|
|
858
|
+
],
|
|
859
|
+
action: "Check the spelling before installing anything under this name.",
|
|
860
|
+
tone: "bad"
|
|
861
|
+
};
|
|
862
|
+
case "skipped":
|
|
863
|
+
return {
|
|
864
|
+
heading: "CHECK SKIPPED",
|
|
865
|
+
body: [verdict.name, "", verdict.reason, "Nothing was blocked."],
|
|
866
|
+
tone: "quiet"
|
|
867
|
+
};
|
|
868
|
+
}
|
|
869
|
+
}
|
|
870
|
+
function draw(b, paint) {
|
|
871
|
+
const inner = WIDTH - 4;
|
|
872
|
+
const text = inner - 1;
|
|
873
|
+
const color = b.tone === "bad" ? RED : b.tone === "warn" ? YELLOW : DIM;
|
|
874
|
+
const top = paint(color, `\u256D\u2500 ${TITLE} ${"\u2500".repeat(WIDTH - TITLE.length - 5)}\u256E`);
|
|
875
|
+
const bottom = paint(color, `\u2570${"\u2500".repeat(WIDTH - 2)}\u256F`);
|
|
876
|
+
const rows = [];
|
|
877
|
+
const push = (line, styled) => {
|
|
878
|
+
const edge = paint(color, "\u2502");
|
|
879
|
+
const pad = " ".repeat(Math.max(0, inner - line.length));
|
|
880
|
+
rows.push(`${edge} ${styled ?? line}${pad}${edge}`);
|
|
881
|
+
};
|
|
882
|
+
push(b.heading, paint(color, b.heading));
|
|
883
|
+
push("");
|
|
884
|
+
for (const line of b.body) {
|
|
885
|
+
if (line === "") {
|
|
886
|
+
push("");
|
|
887
|
+
continue;
|
|
888
|
+
}
|
|
889
|
+
for (const wrapped of wrap(line, text)) {
|
|
890
|
+
push(wrapped);
|
|
891
|
+
}
|
|
892
|
+
}
|
|
893
|
+
const box = [top, ...rows, bottom].join("\n");
|
|
894
|
+
return b.action ? `${box}
|
|
895
|
+
${paint(DIM, b.action)}` : box;
|
|
896
|
+
}
|
|
897
|
+
function wrap(line, width) {
|
|
898
|
+
const lines = [];
|
|
899
|
+
let current = "";
|
|
900
|
+
for (const word of line.split(" ")) {
|
|
901
|
+
if (current === "") {
|
|
902
|
+
current = word;
|
|
903
|
+
} else if (`${current} ${word}`.length <= width) {
|
|
904
|
+
current += ` ${word}`;
|
|
905
|
+
} else {
|
|
906
|
+
lines.push(current);
|
|
907
|
+
current = word;
|
|
908
|
+
}
|
|
909
|
+
}
|
|
910
|
+
if (current !== "") {
|
|
911
|
+
lines.push(current);
|
|
912
|
+
}
|
|
913
|
+
return lines.flatMap(
|
|
914
|
+
(l) => l.length <= width ? [l] : l.match(new RegExp(`.{1,${width}}`, "g")) ?? [l]
|
|
915
|
+
);
|
|
916
|
+
}
|
|
917
|
+
function formatVerdict(verdict, stream = process.stdout) {
|
|
918
|
+
const b = banner(verdict);
|
|
919
|
+
if (b === null) {
|
|
920
|
+
return null;
|
|
921
|
+
}
|
|
922
|
+
const color = supportsColor(stream);
|
|
923
|
+
const paint = (code, text) => color ? `${code}${text}${RESET}` : text;
|
|
924
|
+
return draw(b, paint);
|
|
925
|
+
}
|
|
926
|
+
function plainVerdict(verdict) {
|
|
927
|
+
const b = banner(verdict);
|
|
928
|
+
return b === null ? null : draw(b, (_code, text) => text);
|
|
929
|
+
}
|
|
930
|
+
function plainSummary(verdicts) {
|
|
931
|
+
const blocks = verdicts.map(plainVerdict).filter((block) => block !== null);
|
|
932
|
+
return blocks.length === 0 ? null : blocks.join("\n\n");
|
|
933
|
+
}
|
|
934
|
+
function denyReason(verdicts) {
|
|
935
|
+
const problems = [];
|
|
936
|
+
for (const verdict of verdicts) {
|
|
937
|
+
if (verdict.kind === "flagged") {
|
|
938
|
+
const why = verdict.reasons.map(describeReason).join("; ");
|
|
939
|
+
problems.push(`${verdict.name} (${why})`);
|
|
940
|
+
} else if (verdict.kind === "not-found") {
|
|
941
|
+
problems.push(`${verdict.name} (does not exist on the npm registry)`);
|
|
942
|
+
}
|
|
943
|
+
}
|
|
944
|
+
if (problems.length === 0) {
|
|
945
|
+
return "Blocked by agentinel.";
|
|
946
|
+
}
|
|
947
|
+
return `Blocked by agentinel: ${problems.join(", ")}. Verify the package, pick an established alternative, or allowlist it with \`asen allow <pkg> --reason "..."\`.`;
|
|
948
|
+
}
|
|
949
|
+
function days(count) {
|
|
950
|
+
return count === 1 ? "1 day" : `${count} days`;
|
|
951
|
+
}
|
|
952
|
+
function downloads(count) {
|
|
953
|
+
return count === 1 ? "1 download" : `${count.toLocaleString("en-US")} downloads`;
|
|
954
|
+
}
|
|
955
|
+
function size(bytes) {
|
|
956
|
+
const kb = bytes / 1024;
|
|
957
|
+
return kb >= 1024 ? `${(kb / 1024).toFixed(1)}MB` : `${Math.round(kb)}KB`;
|
|
958
|
+
}
|
|
959
|
+
|
|
960
|
+
// src/commands/check.ts
|
|
961
|
+
async function runCheck(names) {
|
|
962
|
+
const repoRoot = repoRootOrCwd();
|
|
963
|
+
const config = loadConfig(repoRoot);
|
|
964
|
+
let named;
|
|
965
|
+
let tree;
|
|
966
|
+
if (names.length > 0) {
|
|
967
|
+
named = names;
|
|
968
|
+
tree = [];
|
|
969
|
+
} else {
|
|
970
|
+
named = newStagedDependencies(repoRoot);
|
|
971
|
+
tree = stagedLockfilePackages(repoRoot).filter((entry) => !named.includes(entry.name));
|
|
972
|
+
}
|
|
973
|
+
if (named.length === 0 && tree.length === 0) {
|
|
974
|
+
console.log("no new packages to check");
|
|
975
|
+
return 0;
|
|
976
|
+
}
|
|
977
|
+
const verdicts = await scan(named, tree, config);
|
|
978
|
+
let risky = 0;
|
|
979
|
+
let skipped = 0;
|
|
980
|
+
for (const verdict of verdicts) {
|
|
981
|
+
if (isRisky(verdict)) {
|
|
982
|
+
risky += 1;
|
|
983
|
+
}
|
|
984
|
+
if (verdict.kind === "skipped") {
|
|
985
|
+
skipped += 1;
|
|
986
|
+
}
|
|
987
|
+
const message = formatVerdict(verdict);
|
|
988
|
+
if (message) {
|
|
989
|
+
console.log("");
|
|
990
|
+
console.log(message);
|
|
991
|
+
}
|
|
992
|
+
}
|
|
993
|
+
if (risky === 0) {
|
|
994
|
+
const checked = verdicts.length - skipped;
|
|
995
|
+
if (skipped > 0) {
|
|
996
|
+
console.log(
|
|
997
|
+
`checked ${checked} package(s), nothing suspicious. ${skipped} could not be checked`
|
|
998
|
+
);
|
|
999
|
+
} else {
|
|
1000
|
+
console.log(`checked ${checked} package(s), nothing suspicious`);
|
|
1001
|
+
}
|
|
1002
|
+
return 0;
|
|
1003
|
+
}
|
|
1004
|
+
return 1;
|
|
1005
|
+
}
|
|
1006
|
+
|
|
1007
|
+
// src/commands/init.ts
|
|
1008
|
+
import { execFileSync as execFileSync3 } from "child_process";
|
|
1009
|
+
import { chmodSync as chmodSync2, existsSync as existsSync5, mkdirSync as mkdirSync2, readFileSync as readFileSync5, writeFileSync as writeFileSync3 } from "fs";
|
|
1010
|
+
import { homedir as homedir2 } from "os";
|
|
1011
|
+
import { isAbsolute, join as join5 } from "path";
|
|
1012
|
+
|
|
1013
|
+
// src/platform.ts
|
|
1014
|
+
function isWindows() {
|
|
1015
|
+
return process.platform === "win32";
|
|
1016
|
+
}
|
|
1017
|
+
function npmCommand() {
|
|
1018
|
+
return isWindows() ? { file: "npm.cmd", shell: true } : { file: "npm", shell: false };
|
|
1019
|
+
}
|
|
1020
|
+
|
|
1021
|
+
// src/commands/shim.ts
|
|
1022
|
+
import { chmodSync, existsSync as existsSync4, mkdirSync, readFileSync as readFileSync4, rmSync, writeFileSync as writeFileSync2 } from "fs";
|
|
1023
|
+
import { homedir } from "os";
|
|
1024
|
+
import { join as join4 } from "path";
|
|
1025
|
+
var CLIENTS = ["npm", "npx", "pnpm", "yarn", "bun"];
|
|
1026
|
+
var BLOCK_EXIT_CODE = 2;
|
|
1027
|
+
var RC_MARKER = "# agentinel";
|
|
1028
|
+
function currentTarget() {
|
|
1029
|
+
return { home: homedir(), shell: process.env.SHELL ?? "", platform: process.platform };
|
|
1030
|
+
}
|
|
1031
|
+
function shimDirectory(home) {
|
|
1032
|
+
return join4(home, ".agentinel", "bin");
|
|
1033
|
+
}
|
|
1034
|
+
function onWindows(target) {
|
|
1035
|
+
return target.platform === "win32";
|
|
1036
|
+
}
|
|
1037
|
+
function shimFileName(client, target) {
|
|
1038
|
+
return onWindows(target) ? `${client}.cmd` : client;
|
|
1039
|
+
}
|
|
1040
|
+
function startupFilePath(target) {
|
|
1041
|
+
if (onWindows(target)) {
|
|
1042
|
+
return join4(target.home, "Documents", "PowerShell", "Microsoft.PowerShell_profile.ps1");
|
|
1043
|
+
}
|
|
1044
|
+
if (target.shell.includes("zsh")) {
|
|
1045
|
+
return join4(target.home, ".zshrc");
|
|
1046
|
+
}
|
|
1047
|
+
if (target.shell.includes("bash")) {
|
|
1048
|
+
return join4(target.home, ".bashrc");
|
|
1049
|
+
}
|
|
1050
|
+
return join4(target.home, ".profile");
|
|
1051
|
+
}
|
|
1052
|
+
function installShim(repoRoot, target = currentTarget()) {
|
|
1053
|
+
const dir = shimDirectory(target.home);
|
|
1054
|
+
mkdirSync(dir, { recursive: true });
|
|
1055
|
+
for (const client of CLIENTS) {
|
|
1056
|
+
const path = join4(dir, shimFileName(client, target));
|
|
1057
|
+
writeFileSync2(path, shimScript(client, target), "utf8");
|
|
1058
|
+
if (!onWindows(target)) {
|
|
1059
|
+
chmodSync(path, 493);
|
|
1060
|
+
}
|
|
1061
|
+
}
|
|
1062
|
+
console.log(`wrote shims for ${CLIENTS.join(", ")} in ${dir}`);
|
|
1063
|
+
addPathLine(target);
|
|
1064
|
+
const mode = loadConfig(repoRoot).mode;
|
|
1065
|
+
console.log(
|
|
1066
|
+
mode === "strict" ? "Mode is strict, so a risky package typed at the terminal will be blocked." : "Mode is warn, so a risky package typed at the terminal will be reported, not blocked."
|
|
1067
|
+
);
|
|
1068
|
+
console.log("Open a new terminal, or run `asen unshim` to undo this.");
|
|
1069
|
+
return 0;
|
|
1070
|
+
}
|
|
1071
|
+
function removeShim(target = currentTarget()) {
|
|
1072
|
+
const dir = shimDirectory(target.home);
|
|
1073
|
+
rmSync(dir, { recursive: true, force: true });
|
|
1074
|
+
console.log(`removed ${dir}`);
|
|
1075
|
+
removePathLine(target);
|
|
1076
|
+
return 0;
|
|
1077
|
+
}
|
|
1078
|
+
function pathLine(target) {
|
|
1079
|
+
if (onWindows(target)) {
|
|
1080
|
+
return `$env:Path = "$HOME\\.agentinel\\bin;" + $env:Path ${RC_MARKER}
|
|
1081
|
+
`;
|
|
1082
|
+
}
|
|
1083
|
+
return `export PATH="$HOME/.agentinel/bin:$PATH" ${RC_MARKER}
|
|
1084
|
+
`;
|
|
1085
|
+
}
|
|
1086
|
+
function addPathLine(target) {
|
|
1087
|
+
const path = startupFilePath(target);
|
|
1088
|
+
const existing = existsSync4(path) ? readFileSync4(path, "utf8") : "";
|
|
1089
|
+
if (existing.includes(RC_MARKER)) {
|
|
1090
|
+
console.log(`${path} already puts the shims on PATH, left alone`);
|
|
1091
|
+
return;
|
|
1092
|
+
}
|
|
1093
|
+
mkdirSync(join4(path, ".."), { recursive: true });
|
|
1094
|
+
const separator = existing === "" || existing.endsWith("\n") ? "" : "\n";
|
|
1095
|
+
writeFileSync2(path, `${existing}${separator}${pathLine(target)}`, "utf8");
|
|
1096
|
+
console.log(`added the shims to PATH in ${path}`);
|
|
1097
|
+
}
|
|
1098
|
+
function removePathLine(target) {
|
|
1099
|
+
const path = startupFilePath(target);
|
|
1100
|
+
if (!existsSync4(path)) {
|
|
1101
|
+
return;
|
|
1102
|
+
}
|
|
1103
|
+
const lines = readFileSync4(path, "utf8").split("\n");
|
|
1104
|
+
const kept = lines.filter((line) => !line.includes(RC_MARKER));
|
|
1105
|
+
if (kept.length === lines.length) {
|
|
1106
|
+
return;
|
|
1107
|
+
}
|
|
1108
|
+
writeFileSync2(path, kept.join("\n"), "utf8");
|
|
1109
|
+
console.log(`removed the PATH line from ${path}`);
|
|
1110
|
+
}
|
|
1111
|
+
function shimScript(client, target) {
|
|
1112
|
+
return onWindows(target) ? windowsShim(client) : posixShim(client);
|
|
1113
|
+
}
|
|
1114
|
+
function posixShim(client) {
|
|
1115
|
+
return `#!/bin/sh
|
|
1116
|
+
# agentinel shim for ${client}. Checks what the command would pull from the npm registry, then runs
|
|
1117
|
+
# the real ${client}. Undo with: asen unshim
|
|
1118
|
+
#
|
|
1119
|
+
# This script fails open on purpose. If agentinel is missing or broken, ${client} still runs.
|
|
1120
|
+
|
|
1121
|
+
client="${client}"
|
|
1122
|
+
|
|
1123
|
+
# Our own directory, taken from the script's path rather than from $HOME, which may not be set.
|
|
1124
|
+
shim_dir=$(CDPATH= cd -- "$(dirname -- "$0")" 2>/dev/null && pwd)
|
|
1125
|
+
|
|
1126
|
+
# PATH with our directory removed. The real client is whatever that resolves to. Skipping this is
|
|
1127
|
+
# how a shim finds itself and recurses until the shell runs out of processes.
|
|
1128
|
+
real_path=""
|
|
1129
|
+
saved_ifs=$IFS
|
|
1130
|
+
IFS=:
|
|
1131
|
+
for entry in $PATH; do
|
|
1132
|
+
if [ -z "$entry" ] || [ "$entry" = "$shim_dir" ]; then
|
|
1133
|
+
continue
|
|
1134
|
+
fi
|
|
1135
|
+
if [ -z "$real_path" ]; then
|
|
1136
|
+
real_path="$entry"
|
|
1137
|
+
else
|
|
1138
|
+
real_path="$real_path:$entry"
|
|
1139
|
+
fi
|
|
1140
|
+
done
|
|
1141
|
+
IFS=$saved_ifs
|
|
1142
|
+
|
|
1143
|
+
real=$(PATH="$real_path" command -v "$client" 2>/dev/null)
|
|
1144
|
+
if [ -z "$real" ]; then
|
|
1145
|
+
echo "agentinel: $client is not installed" >&2
|
|
1146
|
+
exit 127
|
|
1147
|
+
fi
|
|
1148
|
+
|
|
1149
|
+
if [ -n "$AGENTINEL_SKIP" ]; then
|
|
1150
|
+
exec "$real" "$@"
|
|
1151
|
+
fi
|
|
1152
|
+
|
|
1153
|
+
# Prefer the copy installed in the project, since it starts faster than a global resolve.
|
|
1154
|
+
if [ -x "./node_modules/.bin/asen" ]; then
|
|
1155
|
+
asen="./node_modules/.bin/asen"
|
|
1156
|
+
else
|
|
1157
|
+
asen=$(PATH="$real_path" command -v asen 2>/dev/null)
|
|
1158
|
+
fi
|
|
1159
|
+
|
|
1160
|
+
if [ -n "$asen" ]; then
|
|
1161
|
+
# stdin is closed for the check so it can never swallow input meant for the real client.
|
|
1162
|
+
"$asen" check-command "$client $*" < /dev/null
|
|
1163
|
+
status=$?
|
|
1164
|
+
# ${BLOCK_EXIT_CODE} means agentinel decided to stop this. Any other non-zero means agentinel
|
|
1165
|
+
# itself failed, and that must not stop the user's command.
|
|
1166
|
+
if [ "$status" -eq ${BLOCK_EXIT_CODE} ]; then
|
|
1167
|
+
exit 1
|
|
1168
|
+
fi
|
|
1169
|
+
fi
|
|
1170
|
+
|
|
1171
|
+
exec "$real" "$@"
|
|
1172
|
+
`;
|
|
1173
|
+
}
|
|
1174
|
+
function windowsShim(client) {
|
|
1175
|
+
return [
|
|
1176
|
+
`@echo off`,
|
|
1177
|
+
`rem agentinel shim for ${client}. Fails open: if agentinel is missing or broken, ${client} still runs.`,
|
|
1178
|
+
`rem Undo with: asen unshim`,
|
|
1179
|
+
`setlocal enabledelayedexpansion`,
|
|
1180
|
+
``,
|
|
1181
|
+
`set "real="`,
|
|
1182
|
+
`for /f "delims=" %%i in ('where ${client} 2^>nul') do (`,
|
|
1183
|
+
` echo %%i | findstr /i /c:".agentinel\\bin" >nul`,
|
|
1184
|
+
` if errorlevel 1 if not defined real set "real=%%i"`,
|
|
1185
|
+
`)`,
|
|
1186
|
+
``,
|
|
1187
|
+
`if not defined real (`,
|
|
1188
|
+
` echo agentinel: ${client} is not installed 1>&2`,
|
|
1189
|
+
` exit /b 127`,
|
|
1190
|
+
`)`,
|
|
1191
|
+
``,
|
|
1192
|
+
`if defined AGENTINEL_SKIP goto run`,
|
|
1193
|
+
``,
|
|
1194
|
+
`set "asen="`,
|
|
1195
|
+
`if exist "node_modules\\.bin\\asen.cmd" set "asen=node_modules\\.bin\\asen.cmd"`,
|
|
1196
|
+
`if not defined asen for /f "delims=" %%a in ('where asen 2^>nul') do if not defined asen set "asen=%%a"`,
|
|
1197
|
+
``,
|
|
1198
|
+
`if defined asen (`,
|
|
1199
|
+
` call "!asen!" check-command "${client} %*" <nul`,
|
|
1200
|
+
` if "!errorlevel!"=="${BLOCK_EXIT_CODE}" exit /b 1`,
|
|
1201
|
+
`)`,
|
|
1202
|
+
``,
|
|
1203
|
+
`:run`,
|
|
1204
|
+
`"!real!" %*`,
|
|
1205
|
+
`exit /b !errorlevel!`,
|
|
1206
|
+
``
|
|
1207
|
+
].join("\r\n");
|
|
1208
|
+
}
|
|
1209
|
+
async function runCheckCommand(command) {
|
|
1210
|
+
if (!command) {
|
|
1211
|
+
return 0;
|
|
1212
|
+
}
|
|
1213
|
+
const { installs, executes } = parseCommand(command);
|
|
1214
|
+
const names = [.../* @__PURE__ */ new Set([...installs, ...executes])];
|
|
1215
|
+
if (names.length === 0) {
|
|
1216
|
+
return 0;
|
|
1217
|
+
}
|
|
1218
|
+
const repoRoot = repoRootOrCwd();
|
|
1219
|
+
const config = loadConfig(repoRoot);
|
|
1220
|
+
const verdicts = await checkPackages(names, config);
|
|
1221
|
+
for (const verdict of verdicts) {
|
|
1222
|
+
const message = formatVerdict(verdict, process.stderr);
|
|
1223
|
+
if (message) {
|
|
1224
|
+
console.error("");
|
|
1225
|
+
console.error(message);
|
|
1226
|
+
}
|
|
1227
|
+
}
|
|
1228
|
+
const risky = verdicts.filter(isRisky);
|
|
1229
|
+
if (config.mode === "strict" && risky.length > 0) {
|
|
1230
|
+
console.error("");
|
|
1231
|
+
console.error(denyReason(risky));
|
|
1232
|
+
return BLOCK_EXIT_CODE;
|
|
1233
|
+
}
|
|
1234
|
+
return 0;
|
|
1235
|
+
}
|
|
1236
|
+
|
|
1237
|
+
// src/commands/init.ts
|
|
1238
|
+
var HOOK_MARKER = "agentinel";
|
|
1239
|
+
var HOOK_SUBCOMMAND = "hook claude-code";
|
|
1240
|
+
function runInit(options = {}) {
|
|
1241
|
+
const repoRoot = repoRootOrCwd();
|
|
1242
|
+
writeConfig(repoRoot);
|
|
1243
|
+
wireClaudeCodeHook(repoRoot, claudeCodeCommand(repoRoot));
|
|
1244
|
+
wireOtherAgents(repoRoot, agentHookCommand(repoRoot));
|
|
1245
|
+
wirePreCommitHook(repoRoot, gitHookCommand(repoRoot));
|
|
1246
|
+
if (options.shim) {
|
|
1247
|
+
installShim(repoRoot, options.shimTarget);
|
|
1248
|
+
}
|
|
1249
|
+
console.log("\nagentinel is set up. New npm packages will be checked before they land.");
|
|
1250
|
+
console.log('Default mode is warn. Set "mode": "strict" in .agentinel.json to block instead.');
|
|
1251
|
+
if (!hasLocalInstall(repoRoot)) {
|
|
1252
|
+
console.log("\nThe hook runs on every command, and going through npx each time is slow.");
|
|
1253
|
+
console.log("For faster hooks, add it to the repo and run init again:");
|
|
1254
|
+
console.log(" npm install --save-dev agentinel && npx asen init");
|
|
1255
|
+
}
|
|
1256
|
+
return 0;
|
|
1257
|
+
}
|
|
1258
|
+
function hasLocalInstall(repoRoot) {
|
|
1259
|
+
return existsSync5(join5(repoRoot, "node_modules", ".bin", "asen"));
|
|
1260
|
+
}
|
|
1261
|
+
function claudeCodeCommand(repoRoot) {
|
|
1262
|
+
if (isWindows()) {
|
|
1263
|
+
return "npx agentinel";
|
|
1264
|
+
}
|
|
1265
|
+
return hasLocalInstall(repoRoot) ? '"$CLAUDE_PROJECT_DIR"/node_modules/.bin/asen' : "npx agentinel";
|
|
1266
|
+
}
|
|
1267
|
+
function gitHookCommand(repoRoot) {
|
|
1268
|
+
return hasLocalInstall(repoRoot) ? "./node_modules/.bin/asen" : "npx agentinel";
|
|
1269
|
+
}
|
|
1270
|
+
function writeConfig(repoRoot) {
|
|
1271
|
+
const path = configPath(repoRoot);
|
|
1272
|
+
if (existsSync5(path)) {
|
|
1273
|
+
console.log(".agentinel.json already exists, left alone");
|
|
1274
|
+
return;
|
|
1275
|
+
}
|
|
1276
|
+
saveConfig(repoRoot, defaultConfig());
|
|
1277
|
+
console.log("wrote .agentinel.json");
|
|
1278
|
+
}
|
|
1279
|
+
function wireClaudeCodeHook(repoRoot, command) {
|
|
1280
|
+
const dir = join5(repoRoot, ".claude");
|
|
1281
|
+
const path = join5(dir, "settings.json");
|
|
1282
|
+
let settings = {};
|
|
1283
|
+
if (existsSync5(path)) {
|
|
1284
|
+
try {
|
|
1285
|
+
const parsed = JSON.parse(readFileSync5(path, "utf8"));
|
|
1286
|
+
if (typeof parsed === "object" && parsed !== null) {
|
|
1287
|
+
settings = parsed;
|
|
1288
|
+
}
|
|
1289
|
+
} catch {
|
|
1290
|
+
console.log(".claude/settings.json is not valid JSON, skipping the Claude Code hook");
|
|
1291
|
+
return;
|
|
1292
|
+
}
|
|
1293
|
+
}
|
|
1294
|
+
const hooks = asRecord2(settings.hooks) ?? {};
|
|
1295
|
+
const preToolUse = Array.isArray(hooks.PreToolUse) ? hooks.PreToolUse : [];
|
|
1296
|
+
if (alreadyRegistered(preToolUse)) {
|
|
1297
|
+
console.log("Claude Code hook already registered, left alone");
|
|
1298
|
+
return;
|
|
1299
|
+
}
|
|
1300
|
+
preToolUse.push({
|
|
1301
|
+
matcher: "Bash",
|
|
1302
|
+
hooks: [{ type: "command", command: `${command} hook claude-code` }]
|
|
1303
|
+
});
|
|
1304
|
+
hooks.PreToolUse = preToolUse;
|
|
1305
|
+
settings.hooks = hooks;
|
|
1306
|
+
mkdirSync2(dir, { recursive: true });
|
|
1307
|
+
writeFileSync3(path, JSON.stringify(settings, null, 2) + "\n", "utf8");
|
|
1308
|
+
console.log("registered the Claude Code PreToolUse hook in .claude/settings.json");
|
|
1309
|
+
}
|
|
1310
|
+
function agentHookCommand(repoRoot) {
|
|
1311
|
+
if (isWindows()) {
|
|
1312
|
+
return "npx agentinel";
|
|
1313
|
+
}
|
|
1314
|
+
return hasLocalInstall(repoRoot) ? '"$(git rev-parse --show-toplevel)"/node_modules/.bin/asen' : "npx agentinel";
|
|
1315
|
+
}
|
|
1316
|
+
function wireOtherAgents(repoRoot, command) {
|
|
1317
|
+
if (uses(repoRoot, ".codex")) {
|
|
1318
|
+
wireCodexHook(repoRoot, command);
|
|
1319
|
+
}
|
|
1320
|
+
if (uses(repoRoot, ".copilot") || existsSync5(join5(repoRoot, ".github", "hooks"))) {
|
|
1321
|
+
wireCopilotHook(repoRoot, command);
|
|
1322
|
+
}
|
|
1323
|
+
if (uses(repoRoot, ".gemini")) {
|
|
1324
|
+
wireGeminiHook(repoRoot, command);
|
|
1325
|
+
}
|
|
1326
|
+
}
|
|
1327
|
+
function uses(repoRoot, dir) {
|
|
1328
|
+
return existsSync5(join5(repoRoot, dir)) || existsSync5(join5(homedir2(), dir));
|
|
1329
|
+
}
|
|
1330
|
+
function wireCodexHook(repoRoot, command) {
|
|
1331
|
+
const path = join5(repoRoot, ".codex", "hooks.json");
|
|
1332
|
+
const file = readJson(path);
|
|
1333
|
+
if (file === null) {
|
|
1334
|
+
console.log(".codex/hooks.json is not valid JSON, skipping the Codex hook");
|
|
1335
|
+
return;
|
|
1336
|
+
}
|
|
1337
|
+
const hooks = asRecord2(file.hooks) ?? {};
|
|
1338
|
+
const preToolUse = Array.isArray(hooks.PreToolUse) ? hooks.PreToolUse : [];
|
|
1339
|
+
if (registers(preToolUse, "codex")) {
|
|
1340
|
+
console.log("Codex hook already registered, left alone");
|
|
1341
|
+
return;
|
|
1342
|
+
}
|
|
1343
|
+
preToolUse.push({
|
|
1344
|
+
matcher: "Bash",
|
|
1345
|
+
hooks: [{ type: "command", command: `${command} hook codex` }]
|
|
1346
|
+
});
|
|
1347
|
+
hooks.PreToolUse = preToolUse;
|
|
1348
|
+
file.hooks = hooks;
|
|
1349
|
+
writeJson(path, file);
|
|
1350
|
+
console.log("registered the Codex PreToolUse hook in .codex/hooks.json");
|
|
1351
|
+
}
|
|
1352
|
+
function wireCopilotHook(repoRoot, command) {
|
|
1353
|
+
const path = join5(repoRoot, ".github", "hooks", "agentinel.json");
|
|
1354
|
+
const file = readJson(path);
|
|
1355
|
+
if (file === null) {
|
|
1356
|
+
console.log(".github/hooks/agentinel.json is not valid JSON, skipping the Copilot hook");
|
|
1357
|
+
return;
|
|
1358
|
+
}
|
|
1359
|
+
const hooks = asRecord2(file.hooks) ?? {};
|
|
1360
|
+
const preToolUse = Array.isArray(hooks.preToolUse) ? hooks.preToolUse : [];
|
|
1361
|
+
if (registers(preToolUse, "copilot")) {
|
|
1362
|
+
console.log("Copilot hook already registered, left alone");
|
|
1363
|
+
return;
|
|
1364
|
+
}
|
|
1365
|
+
preToolUse.push({ type: "command", matcher: "bash", bash: `${command} hook copilot` });
|
|
1366
|
+
hooks.preToolUse = preToolUse;
|
|
1367
|
+
file.version = 1;
|
|
1368
|
+
file.hooks = hooks;
|
|
1369
|
+
writeJson(path, file);
|
|
1370
|
+
console.log("registered the Copilot preToolUse hook in .github/hooks/agentinel.json");
|
|
1371
|
+
}
|
|
1372
|
+
function wireGeminiHook(repoRoot, command) {
|
|
1373
|
+
const path = join5(repoRoot, ".gemini", "settings.json");
|
|
1374
|
+
const file = readJson(path);
|
|
1375
|
+
if (file === null) {
|
|
1376
|
+
console.log(".gemini/settings.json is not valid JSON, skipping the Gemini hook");
|
|
1377
|
+
return;
|
|
1378
|
+
}
|
|
1379
|
+
const hooks = asRecord2(file.hooks) ?? {};
|
|
1380
|
+
const beforeTool = Array.isArray(hooks.BeforeTool) ? hooks.BeforeTool : [];
|
|
1381
|
+
if (registers(beforeTool, "gemini")) {
|
|
1382
|
+
console.log("Gemini hook already registered, left alone");
|
|
1383
|
+
return;
|
|
1384
|
+
}
|
|
1385
|
+
beforeTool.push({
|
|
1386
|
+
matcher: "run_shell_command",
|
|
1387
|
+
hooks: [{ type: "command", command: `${command} hook gemini` }]
|
|
1388
|
+
});
|
|
1389
|
+
hooks.BeforeTool = beforeTool;
|
|
1390
|
+
file.hooks = hooks;
|
|
1391
|
+
writeJson(path, file);
|
|
1392
|
+
console.log("registered the Gemini BeforeTool hook in .gemini/settings.json");
|
|
1393
|
+
}
|
|
1394
|
+
function readJson(path) {
|
|
1395
|
+
if (!existsSync5(path)) {
|
|
1396
|
+
return {};
|
|
1397
|
+
}
|
|
1398
|
+
try {
|
|
1399
|
+
return asRecord2(JSON.parse(readFileSync5(path, "utf8"))) ?? {};
|
|
1400
|
+
} catch {
|
|
1401
|
+
return null;
|
|
1402
|
+
}
|
|
1403
|
+
}
|
|
1404
|
+
function writeJson(path, value) {
|
|
1405
|
+
mkdirSync2(join5(path, ".."), { recursive: true });
|
|
1406
|
+
writeFileSync3(path, JSON.stringify(value, null, 2) + "\n", "utf8");
|
|
1407
|
+
}
|
|
1408
|
+
function registers(entries, kind) {
|
|
1409
|
+
return JSON.stringify(entries).includes(`hook ${kind}`);
|
|
1410
|
+
}
|
|
1411
|
+
function git2(repoRoot, args) {
|
|
1412
|
+
try {
|
|
1413
|
+
return execFileSync3("git", args, {
|
|
1414
|
+
cwd: repoRoot,
|
|
1415
|
+
encoding: "utf8",
|
|
1416
|
+
stdio: ["ignore", "pipe", "ignore"]
|
|
1417
|
+
}).trim();
|
|
1418
|
+
} catch {
|
|
1419
|
+
return null;
|
|
1420
|
+
}
|
|
1421
|
+
}
|
|
1422
|
+
function hooksDirectory(repoRoot) {
|
|
1423
|
+
const configured = git2(repoRoot, ["config", "--get", "core.hooksPath"]);
|
|
1424
|
+
if (configured) {
|
|
1425
|
+
return isAbsolute(configured) ? configured : join5(repoRoot, configured);
|
|
1426
|
+
}
|
|
1427
|
+
const path = git2(repoRoot, ["rev-parse", "--git-path", "hooks"]);
|
|
1428
|
+
if (path) {
|
|
1429
|
+
return isAbsolute(path) ? path : join5(repoRoot, path);
|
|
1430
|
+
}
|
|
1431
|
+
return join5(repoRoot, ".git", "hooks");
|
|
1432
|
+
}
|
|
1433
|
+
function wirePreCommitHook(repoRoot, command) {
|
|
1434
|
+
if (!existsSync5(join5(repoRoot, ".git"))) {
|
|
1435
|
+
console.log("not a git repo, skipping the pre-commit hook");
|
|
1436
|
+
return;
|
|
1437
|
+
}
|
|
1438
|
+
const dir = hooksDirectory(repoRoot);
|
|
1439
|
+
const path = join5(dir, "pre-commit");
|
|
1440
|
+
const script = `#!/bin/sh
|
|
1441
|
+
# ${HOOK_MARKER}: check newly added npm packages before they get committed
|
|
1442
|
+
${command} hook pre-commit
|
|
1443
|
+
`;
|
|
1444
|
+
if (existsSync5(path)) {
|
|
1445
|
+
const existing = readFileSync5(path, "utf8");
|
|
1446
|
+
if (existing.includes(HOOK_MARKER)) {
|
|
1447
|
+
console.log("pre-commit hook already installed, left alone");
|
|
1448
|
+
return;
|
|
1449
|
+
}
|
|
1450
|
+
console.log("a pre-commit hook already exists, not overwriting it");
|
|
1451
|
+
console.log(`add this line to ${path}:
|
|
1452
|
+
${command} hook pre-commit`);
|
|
1453
|
+
return;
|
|
1454
|
+
}
|
|
1455
|
+
mkdirSync2(dir, { recursive: true });
|
|
1456
|
+
writeFileSync3(path, script, "utf8");
|
|
1457
|
+
chmodSync2(path, 493);
|
|
1458
|
+
console.log(`installed the git pre-commit hook in ${dir}`);
|
|
1459
|
+
}
|
|
1460
|
+
function alreadyRegistered(preToolUse) {
|
|
1461
|
+
return JSON.stringify(preToolUse).includes(`${HOOK_SUBCOMMAND}`);
|
|
1462
|
+
}
|
|
1463
|
+
function asRecord2(value) {
|
|
1464
|
+
return typeof value === "object" && value !== null ? value : null;
|
|
1465
|
+
}
|
|
1466
|
+
|
|
1467
|
+
// src/hooks/claude-code.ts
|
|
1468
|
+
import { existsSync as existsSync6 } from "fs";
|
|
1469
|
+
import { join as join6 } from "path";
|
|
1470
|
+
|
|
1471
|
+
// src/checks/package-guard/resolve.ts
|
|
1472
|
+
import { execFileSync as execFileSync4 } from "child_process";
|
|
1473
|
+
var TIMEOUT_MS2 = 2e4;
|
|
1474
|
+
function resolveInstall(repoRoot, packages) {
|
|
1475
|
+
if (packages.length === 0) {
|
|
1476
|
+
return [];
|
|
1477
|
+
}
|
|
1478
|
+
const npm = npmCommand();
|
|
1479
|
+
let output;
|
|
1480
|
+
try {
|
|
1481
|
+
output = execFileSync4(
|
|
1482
|
+
npm.file,
|
|
1483
|
+
["install", "--dry-run", "--no-audit", "--no-fund", ...packages],
|
|
1484
|
+
{
|
|
1485
|
+
cwd: repoRoot,
|
|
1486
|
+
encoding: "utf8",
|
|
1487
|
+
timeout: TIMEOUT_MS2,
|
|
1488
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
1489
|
+
shell: npm.shell,
|
|
1490
|
+
env: { ...process.env, NO_COLOR: "1" }
|
|
1491
|
+
}
|
|
1492
|
+
);
|
|
1493
|
+
} catch (error) {
|
|
1494
|
+
const stdout = error.stdout;
|
|
1495
|
+
output = typeof stdout === "string" ? stdout : "";
|
|
1496
|
+
}
|
|
1497
|
+
return parseAddLines(output);
|
|
1498
|
+
}
|
|
1499
|
+
function parseAddLines(output) {
|
|
1500
|
+
const found = /* @__PURE__ */ new Map();
|
|
1501
|
+
for (const line of output.split("\n")) {
|
|
1502
|
+
const match = /^add\s+(\S+)\s+(\S+)/.exec(line.trim());
|
|
1503
|
+
if (match?.[1] && match[2]) {
|
|
1504
|
+
found.set(match[1], { name: match[1], version: match[2] });
|
|
1505
|
+
}
|
|
1506
|
+
}
|
|
1507
|
+
return [...found.values()];
|
|
1508
|
+
}
|
|
1509
|
+
|
|
1510
|
+
// src/hooks/claude-code.ts
|
|
1511
|
+
async function runClaudeCodeHook() {
|
|
1512
|
+
const payload = await readStdinJson();
|
|
1513
|
+
const command = extractCommand(payload);
|
|
1514
|
+
if (!command) {
|
|
1515
|
+
return;
|
|
1516
|
+
}
|
|
1517
|
+
const repoRoot = typeof payload?.cwd === "string" ? payload.cwd : repoRootOrCwd();
|
|
1518
|
+
const { named, tree } = candidatesFor(command, repoRoot);
|
|
1519
|
+
if (named.length === 0 && tree.length === 0) {
|
|
1520
|
+
return;
|
|
1521
|
+
}
|
|
1522
|
+
const config = loadConfig(repoRoot);
|
|
1523
|
+
const verdicts = await scan(named, tree, config);
|
|
1524
|
+
const risky = verdicts.filter(isRisky);
|
|
1525
|
+
if (config.mode === "strict" && risky.length > 0) {
|
|
1526
|
+
emit({
|
|
1527
|
+
hookSpecificOutput: {
|
|
1528
|
+
hookEventName: "PreToolUse",
|
|
1529
|
+
permissionDecision: "deny",
|
|
1530
|
+
permissionDecisionReason: denyReason(risky)
|
|
1531
|
+
}
|
|
1532
|
+
});
|
|
1533
|
+
return;
|
|
1534
|
+
}
|
|
1535
|
+
warn(verdicts);
|
|
1536
|
+
}
|
|
1537
|
+
function candidatesFor(command, repoRoot) {
|
|
1538
|
+
const intent = parseCommand(command);
|
|
1539
|
+
const executes = intent.executes.filter((name) => !isLocalTool(repoRoot, name));
|
|
1540
|
+
const named = [.../* @__PURE__ */ new Set([...intent.installs, ...executes])];
|
|
1541
|
+
const resolved = intent.installs.length > 0 ? resolveInstall(repoRoot, intent.installs) : [];
|
|
1542
|
+
const fromLockfile = intent.lockfile ? packagesInLockfile(repoRoot) : [];
|
|
1543
|
+
const tree = /* @__PURE__ */ new Map();
|
|
1544
|
+
for (const entry of [...resolved, ...fromLockfile]) {
|
|
1545
|
+
if (!named.includes(entry.name)) {
|
|
1546
|
+
tree.set(entry.name, entry);
|
|
1547
|
+
}
|
|
1548
|
+
}
|
|
1549
|
+
return { named, tree: [...tree.values()] };
|
|
1550
|
+
}
|
|
1551
|
+
function isLocalTool(repoRoot, name) {
|
|
1552
|
+
const binary = name.includes("/") ? name.split("/").pop() : name;
|
|
1553
|
+
return existsSync6(join6(repoRoot, "node_modules", ".bin", binary));
|
|
1554
|
+
}
|
|
1555
|
+
function warn(verdicts) {
|
|
1556
|
+
const summary = plainSummary(verdicts);
|
|
1557
|
+
if (summary === null) {
|
|
1558
|
+
return;
|
|
1559
|
+
}
|
|
1560
|
+
emit({
|
|
1561
|
+
systemMessage: summary,
|
|
1562
|
+
hookSpecificOutput: {
|
|
1563
|
+
hookEventName: "PreToolUse",
|
|
1564
|
+
additionalContext: summary
|
|
1565
|
+
}
|
|
1566
|
+
});
|
|
1567
|
+
}
|
|
1568
|
+
function emit(output) {
|
|
1569
|
+
process.stdout.write(JSON.stringify(output) + "\n");
|
|
1570
|
+
}
|
|
1571
|
+
function extractCommand(payload) {
|
|
1572
|
+
if (!payload || payload.tool_name !== "Bash") {
|
|
1573
|
+
return null;
|
|
1574
|
+
}
|
|
1575
|
+
const input = payload.tool_input;
|
|
1576
|
+
if (typeof input !== "object" || input === null) {
|
|
1577
|
+
return null;
|
|
1578
|
+
}
|
|
1579
|
+
const command = input.command;
|
|
1580
|
+
return typeof command === "string" ? command : null;
|
|
1581
|
+
}
|
|
1582
|
+
async function readStdinJson() {
|
|
1583
|
+
const chunks = [];
|
|
1584
|
+
for await (const chunk of process.stdin) {
|
|
1585
|
+
chunks.push(Buffer.from(chunk));
|
|
1586
|
+
}
|
|
1587
|
+
const text = Buffer.concat(chunks).toString("utf8").trim();
|
|
1588
|
+
if (!text) {
|
|
1589
|
+
return null;
|
|
1590
|
+
}
|
|
1591
|
+
try {
|
|
1592
|
+
const parsed = JSON.parse(text);
|
|
1593
|
+
return typeof parsed === "object" && parsed !== null ? parsed : null;
|
|
1594
|
+
} catch {
|
|
1595
|
+
return null;
|
|
1596
|
+
}
|
|
1597
|
+
}
|
|
1598
|
+
|
|
1599
|
+
// src/hooks/agents.ts
|
|
1600
|
+
var AGENT_KINDS = ["claude-code", "codex", "copilot", "gemini"];
|
|
1601
|
+
function isAgentKind(value) {
|
|
1602
|
+
return AGENT_KINDS.includes(value);
|
|
1603
|
+
}
|
|
1604
|
+
var SHELL_TOOLS = /* @__PURE__ */ new Set(["bash", "shell", "run_shell_command", "execute_bash"]);
|
|
1605
|
+
var SHAPES = {
|
|
1606
|
+
// stdout JSON on exit 0. `systemMessage` reaches the user, `additionalContext` reaches the model.
|
|
1607
|
+
"claude-code": {
|
|
1608
|
+
command: (payload) => snakeCommand(payload),
|
|
1609
|
+
warn: (summary) => ({
|
|
1610
|
+
systemMessage: summary,
|
|
1611
|
+
hookSpecificOutput: { hookEventName: "PreToolUse", additionalContext: summary }
|
|
1612
|
+
}),
|
|
1613
|
+
deny: (reason) => ({
|
|
1614
|
+
hookSpecificOutput: {
|
|
1615
|
+
hookEventName: "PreToolUse",
|
|
1616
|
+
permissionDecision: "deny",
|
|
1617
|
+
permissionDecisionReason: reason
|
|
1618
|
+
}
|
|
1619
|
+
})
|
|
1620
|
+
},
|
|
1621
|
+
// Same envelope as Claude Code, and the same snake_case payload. Codex documents
|
|
1622
|
+
// `additionalContext` but no user-facing message field, so in warn mode the agent is told and the
|
|
1623
|
+
// user reads it through the agent.
|
|
1624
|
+
codex: {
|
|
1625
|
+
command: (payload) => snakeCommand(payload),
|
|
1626
|
+
warn: (summary) => ({
|
|
1627
|
+
hookSpecificOutput: { hookEventName: "PreToolUse", additionalContext: summary }
|
|
1628
|
+
}),
|
|
1629
|
+
deny: (reason) => ({
|
|
1630
|
+
hookSpecificOutput: {
|
|
1631
|
+
hookEventName: "PreToolUse",
|
|
1632
|
+
permissionDecision: "deny",
|
|
1633
|
+
permissionDecisionReason: reason
|
|
1634
|
+
}
|
|
1635
|
+
})
|
|
1636
|
+
},
|
|
1637
|
+
// camelCase payload, and permission fields are the only documented output: there is no
|
|
1638
|
+
// `additionalContext` or `systemMessage` on preToolUse. So warn mode uses `ask`, the one channel
|
|
1639
|
+
// that puts our text in front of the person before the install runs. It is not a block, the user
|
|
1640
|
+
// decides. A clean or merely skipped check stays silent, so nothing extra is ever prompted for.
|
|
1641
|
+
copilot: {
|
|
1642
|
+
command: (payload) => camelCommand(payload),
|
|
1643
|
+
warn: (summary, risky) => risky ? { permissionDecision: "ask", permissionDecisionReason: summary } : null,
|
|
1644
|
+
deny: (reason) => ({ permissionDecision: "deny", permissionDecisionReason: reason })
|
|
1645
|
+
},
|
|
1646
|
+
// BeforeTool. A flat `decision`/`reason` pair rather than an envelope, and `systemMessage` is
|
|
1647
|
+
// shown to the user immediately, which is what carries warn mode here.
|
|
1648
|
+
gemini: {
|
|
1649
|
+
command: (payload) => snakeCommand(payload),
|
|
1650
|
+
warn: (summary) => ({ systemMessage: summary }),
|
|
1651
|
+
deny: (reason) => ({ decision: "deny", reason, systemMessage: reason })
|
|
1652
|
+
}
|
|
1653
|
+
};
|
|
1654
|
+
async function runAgentHook(kind) {
|
|
1655
|
+
try {
|
|
1656
|
+
await check(kind);
|
|
1657
|
+
} catch (error) {
|
|
1658
|
+
process.stderr.write(`agentinel: check skipped (${describe3(error)})
|
|
1659
|
+
`);
|
|
1660
|
+
}
|
|
1661
|
+
}
|
|
1662
|
+
async function check(kind) {
|
|
1663
|
+
const shape = SHAPES[kind];
|
|
1664
|
+
const payload = await readStdinJson2();
|
|
1665
|
+
if (!payload) {
|
|
1666
|
+
return;
|
|
1667
|
+
}
|
|
1668
|
+
const command = shape.command(payload);
|
|
1669
|
+
if (!command) {
|
|
1670
|
+
return;
|
|
1671
|
+
}
|
|
1672
|
+
const repoRoot = typeof payload.cwd === "string" ? payload.cwd : repoRootOrCwd();
|
|
1673
|
+
const { named, tree } = candidatesFor(command, repoRoot);
|
|
1674
|
+
if (named.length === 0 && tree.length === 0) {
|
|
1675
|
+
return;
|
|
1676
|
+
}
|
|
1677
|
+
const config = loadConfig(repoRoot);
|
|
1678
|
+
const verdicts = await scan(named, tree, config);
|
|
1679
|
+
const risky = verdicts.filter(isRisky);
|
|
1680
|
+
if (config.mode === "strict" && risky.length > 0) {
|
|
1681
|
+
emit2(shape.deny(denyReason(risky)));
|
|
1682
|
+
return;
|
|
1683
|
+
}
|
|
1684
|
+
const summary = plainSummary(verdicts);
|
|
1685
|
+
if (summary === null) {
|
|
1686
|
+
return;
|
|
1687
|
+
}
|
|
1688
|
+
const output = shape.warn(summary, risky.length > 0);
|
|
1689
|
+
if (output !== null) {
|
|
1690
|
+
emit2(output);
|
|
1691
|
+
}
|
|
1692
|
+
}
|
|
1693
|
+
function snakeCommand(payload) {
|
|
1694
|
+
return shellCommand(payload.tool_name, payload.tool_input);
|
|
1695
|
+
}
|
|
1696
|
+
function camelCommand(payload) {
|
|
1697
|
+
return shellCommand(payload.toolName, payload.toolArgs);
|
|
1698
|
+
}
|
|
1699
|
+
function shellCommand(toolName, args) {
|
|
1700
|
+
if (typeof toolName !== "string" || !SHELL_TOOLS.has(toolName.toLowerCase())) {
|
|
1701
|
+
return null;
|
|
1702
|
+
}
|
|
1703
|
+
if (typeof args !== "object" || args === null) {
|
|
1704
|
+
return null;
|
|
1705
|
+
}
|
|
1706
|
+
const command = args.command;
|
|
1707
|
+
if (typeof command === "string") {
|
|
1708
|
+
return command;
|
|
1709
|
+
}
|
|
1710
|
+
if (Array.isArray(command) && command.every((part) => typeof part === "string")) {
|
|
1711
|
+
return command.join(" ");
|
|
1712
|
+
}
|
|
1713
|
+
return null;
|
|
1714
|
+
}
|
|
1715
|
+
function emit2(output) {
|
|
1716
|
+
process.stdout.write(JSON.stringify(output) + "\n");
|
|
1717
|
+
}
|
|
1718
|
+
async function readStdinJson2() {
|
|
1719
|
+
const chunks = [];
|
|
1720
|
+
for await (const chunk of process.stdin) {
|
|
1721
|
+
chunks.push(Buffer.from(chunk));
|
|
1722
|
+
}
|
|
1723
|
+
const text = Buffer.concat(chunks).toString("utf8").trim();
|
|
1724
|
+
if (!text) {
|
|
1725
|
+
return null;
|
|
1726
|
+
}
|
|
1727
|
+
try {
|
|
1728
|
+
const parsed = JSON.parse(text);
|
|
1729
|
+
return typeof parsed === "object" && parsed !== null ? parsed : null;
|
|
1730
|
+
} catch {
|
|
1731
|
+
return null;
|
|
1732
|
+
}
|
|
1733
|
+
}
|
|
1734
|
+
function describe3(error) {
|
|
1735
|
+
return error instanceof Error ? error.message : String(error);
|
|
1736
|
+
}
|
|
1737
|
+
|
|
1738
|
+
// src/hooks/pre-commit.ts
|
|
1739
|
+
async function scanStagedPackages(repoRoot) {
|
|
1740
|
+
const named = newStagedDependencies(repoRoot);
|
|
1741
|
+
const tree = stagedLockfilePackages(repoRoot).filter((entry) => !named.includes(entry.name));
|
|
1742
|
+
if (named.length === 0 && tree.length === 0) {
|
|
1743
|
+
return [];
|
|
1744
|
+
}
|
|
1745
|
+
return scan(named, tree, loadConfig(repoRoot));
|
|
1746
|
+
}
|
|
1747
|
+
async function runPreCommitHook() {
|
|
1748
|
+
const repoRoot = repoRootOrCwd();
|
|
1749
|
+
const verdicts = await scanStagedPackages(repoRoot);
|
|
1750
|
+
for (const verdict of verdicts) {
|
|
1751
|
+
const message = formatVerdict(verdict, process.stderr);
|
|
1752
|
+
if (message) {
|
|
1753
|
+
process.stderr.write("\n" + message + "\n");
|
|
1754
|
+
}
|
|
1755
|
+
}
|
|
1756
|
+
if (!verdicts.some(isRisky)) {
|
|
1757
|
+
return 0;
|
|
1758
|
+
}
|
|
1759
|
+
if (loadConfig(repoRoot).mode === "strict") {
|
|
1760
|
+
process.stderr.write("\ncommit blocked by agentinel (strict mode)\n");
|
|
1761
|
+
return 1;
|
|
1762
|
+
}
|
|
1763
|
+
return 0;
|
|
1764
|
+
}
|
|
1765
|
+
|
|
1766
|
+
// bin/asen.ts
|
|
1767
|
+
var USAGE = `asen, a guard for AI coding agent workflows
|
|
1768
|
+
|
|
1769
|
+
asen init [--shim] set up the hooks and config in this repo
|
|
1770
|
+
asen check [pkg...] check staged dependencies, or specific packages
|
|
1771
|
+
asen allow <pkg> --reason "..." allowlist a flagged package, with a logged reason
|
|
1772
|
+
asen unshim remove the PATH shims that --shim installed
|
|
1773
|
+
`;
|
|
1774
|
+
async function main(argv2) {
|
|
1775
|
+
const [command, ...rest] = argv2;
|
|
1776
|
+
switch (command) {
|
|
1777
|
+
case "init":
|
|
1778
|
+
return runInit({ shim: rest.includes("--shim") });
|
|
1779
|
+
case "check":
|
|
1780
|
+
return runCheck(positionals(rest));
|
|
1781
|
+
case "allow":
|
|
1782
|
+
return runAllow(positionals(rest)[0], readFlag(rest, "--reason"));
|
|
1783
|
+
case "unshim":
|
|
1784
|
+
return removeShim();
|
|
1785
|
+
// Not documented in the usage text on purpose. These are what the installed hooks call.
|
|
1786
|
+
case "hook":
|
|
1787
|
+
return runHook(rest[0]);
|
|
1788
|
+
// What the PATH shims call, with the whole command line as one argument.
|
|
1789
|
+
case "check-command":
|
|
1790
|
+
return runCheckCommand(rest[0]);
|
|
1791
|
+
case "--help":
|
|
1792
|
+
case "-h":
|
|
1793
|
+
case void 0:
|
|
1794
|
+
console.log(USAGE);
|
|
1795
|
+
return 0;
|
|
1796
|
+
default:
|
|
1797
|
+
console.error(`unknown command: ${command}
|
|
1798
|
+
`);
|
|
1799
|
+
console.error(USAGE);
|
|
1800
|
+
return 1;
|
|
1801
|
+
}
|
|
1802
|
+
}
|
|
1803
|
+
async function runHook(name) {
|
|
1804
|
+
if (name === "claude-code") {
|
|
1805
|
+
await runClaudeCodeHook();
|
|
1806
|
+
return 0;
|
|
1807
|
+
}
|
|
1808
|
+
if (name && isAgentKind(name)) {
|
|
1809
|
+
await runAgentHook(name);
|
|
1810
|
+
return 0;
|
|
1811
|
+
}
|
|
1812
|
+
if (name === "pre-commit") {
|
|
1813
|
+
return runPreCommitHook();
|
|
1814
|
+
}
|
|
1815
|
+
console.error(`unknown hook: ${name}`);
|
|
1816
|
+
return 0;
|
|
1817
|
+
}
|
|
1818
|
+
var argv = process.argv.slice(2);
|
|
1819
|
+
function failsOpen(args) {
|
|
1820
|
+
return args[0] === "hook" || args[0] === "check-command";
|
|
1821
|
+
}
|
|
1822
|
+
main(argv).then((code) => {
|
|
1823
|
+
process.exitCode = code;
|
|
1824
|
+
}).catch((error) => {
|
|
1825
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
1826
|
+
if (error instanceof ConfigError) {
|
|
1827
|
+
console.error(`agentinel: ${message}`);
|
|
1828
|
+
process.exitCode = failsOpen(argv) ? 0 : 1;
|
|
1829
|
+
return;
|
|
1830
|
+
}
|
|
1831
|
+
if (failsOpen(argv)) {
|
|
1832
|
+
console.error(`agentinel: check skipped (${message})`);
|
|
1833
|
+
process.exitCode = 0;
|
|
1834
|
+
return;
|
|
1835
|
+
}
|
|
1836
|
+
console.error(`agentinel: ${message}`);
|
|
1837
|
+
process.exitCode = 1;
|
|
1838
|
+
});
|