@chimix/agentscan 0.1.0 → 0.2.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/README.md +28 -17
- package/dist/cli.js +13125 -0
- package/package.json +7 -5
- package/src/analyze.ts +0 -82
- package/src/checks/budgets.ts +0 -189
- package/src/checks/index.ts +0 -738
- package/src/checks/make.ts +0 -37
- package/src/cli.ts +0 -230
- package/src/commands/check.ts +0 -64
- package/src/commands/explain.ts +0 -64
- package/src/commands/init.ts +0 -42
- package/src/commands/rules.ts +0 -36
- package/src/config/load.ts +0 -26
- package/src/config/schema.ts +0 -63
- package/src/discover/index.ts +0 -888
- package/src/facts/extract.ts +0 -273
- package/src/facts/semver.ts +0 -41
- package/src/facts/types.ts +0 -113
- package/src/report/clipboard.ts +0 -90
- package/src/report/exit-code.ts +0 -36
- package/src/report/json.ts +0 -36
- package/src/report/prompt.ts +0 -102
- package/src/report/safe.ts +0 -0
- package/src/report/score.ts +0 -47
- package/src/report/sort.ts +0 -26
- package/src/report/text.ts +0 -142
- package/src/version.ts +0 -1
package/src/discover/index.ts
DELETED
|
@@ -1,888 +0,0 @@
|
|
|
1
|
-
import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
|
|
2
|
-
import { homedir } from "node:os";
|
|
3
|
-
import { isAbsolute, join, resolve } from "node:path";
|
|
4
|
-
import { parse as parseYaml } from "yaml";
|
|
5
|
-
import type { AgentscanConfig } from "../config/schema";
|
|
6
|
-
import type {
|
|
7
|
-
AgentFact,
|
|
8
|
-
ConfigErrorFact,
|
|
9
|
-
HookFact,
|
|
10
|
-
LockedSkillFact,
|
|
11
|
-
McpFact,
|
|
12
|
-
SkillFact,
|
|
13
|
-
} from "../facts/types";
|
|
14
|
-
|
|
15
|
-
const POLICY_CAP = 100_000;
|
|
16
|
-
// The body is read now, not just the frontmatter, so 8 KB truncated most real
|
|
17
|
-
// skills mid-document. Still capped: this reads every skill in the tree.
|
|
18
|
-
const SKILL_MD_CAP = 65_536;
|
|
19
|
-
|
|
20
|
-
export type AgentSurface = {
|
|
21
|
-
skills: SkillFact[];
|
|
22
|
-
agents: AgentFact[];
|
|
23
|
-
hooks: HookFact[];
|
|
24
|
-
mcp: McpFact[];
|
|
25
|
-
policyFiles: { path: string; text: string }[];
|
|
26
|
-
lockedSkills: LockedSkillFact[];
|
|
27
|
-
hasSkillsLock: boolean;
|
|
28
|
-
configErrors: ConfigErrorFact[];
|
|
29
|
-
};
|
|
30
|
-
|
|
31
|
-
/** Walk up from startDir for nearest package.json; throw if none. */
|
|
32
|
-
export function resolveRoot(startDir: string): string {
|
|
33
|
-
let dir = resolve(startDir);
|
|
34
|
-
for (;;) {
|
|
35
|
-
if (existsSync(join(dir, "package.json"))) {
|
|
36
|
-
return dir;
|
|
37
|
-
}
|
|
38
|
-
const parent = resolve(dir, "..");
|
|
39
|
-
if (parent === dir) {
|
|
40
|
-
throw new Error(
|
|
41
|
-
`No package.json found walking up from ${resolve(startDir)}`,
|
|
42
|
-
);
|
|
43
|
-
}
|
|
44
|
-
dir = parent;
|
|
45
|
-
}
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
/**
|
|
49
|
-
* Read a JSON config. A malformed file is a config issue — the thing this tool
|
|
50
|
-
* exists to report — so it is recorded rather than skipped.
|
|
51
|
-
*/
|
|
52
|
-
function readJsonConfig(
|
|
53
|
-
path: string,
|
|
54
|
-
errors: ConfigErrorFact[],
|
|
55
|
-
): unknown | undefined {
|
|
56
|
-
let text: string;
|
|
57
|
-
try {
|
|
58
|
-
text = readFileSync(path, "utf8");
|
|
59
|
-
} catch (err) {
|
|
60
|
-
errors.push({
|
|
61
|
-
path,
|
|
62
|
-
kind: "unreadable",
|
|
63
|
-
detail: err instanceof Error ? err.message : String(err),
|
|
64
|
-
});
|
|
65
|
-
return undefined;
|
|
66
|
-
}
|
|
67
|
-
try {
|
|
68
|
-
return JSON.parse(text) as unknown;
|
|
69
|
-
} catch (err) {
|
|
70
|
-
errors.push({
|
|
71
|
-
path,
|
|
72
|
-
kind: "invalid-json",
|
|
73
|
-
detail: err instanceof Error ? err.message : String(err),
|
|
74
|
-
});
|
|
75
|
-
return undefined;
|
|
76
|
-
}
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
type Frontmatter = {
|
|
80
|
-
hasFrontmatter: boolean;
|
|
81
|
-
/** Everything after the closing fence. */
|
|
82
|
-
body?: string;
|
|
83
|
-
/** The block exists but the parser rejected it — fields are unknown, absent. */
|
|
84
|
-
unparseable?: boolean;
|
|
85
|
-
/** Read failed — say so rather than claiming the file has no frontmatter. */
|
|
86
|
-
unreadable?: boolean;
|
|
87
|
-
name?: string;
|
|
88
|
-
description?: string;
|
|
89
|
-
};
|
|
90
|
-
|
|
91
|
-
/**
|
|
92
|
-
* Bundled files a SKILL.md body points at, under the conventional directories
|
|
93
|
-
* a skill ships supporting files in.
|
|
94
|
-
*
|
|
95
|
-
* The lookbehind rejects a path nested under something else
|
|
96
|
-
* (`packages/web/references/x.md`) and any URL (`https://host/scripts/x.md`).
|
|
97
|
-
*/
|
|
98
|
-
const BUNDLED = "scripts|references|assets|templates|examples";
|
|
99
|
-
const REFERENCE_RE = new RegExp(
|
|
100
|
-
// `./` is an accepted prefix; anything else before the directory name means
|
|
101
|
-
// the path belongs to something other than this skill.
|
|
102
|
-
`(?<![\\w/.-])(?:\\./)?((?:${BUNDLED})/[A-Za-z0-9_./-]+\\.[A-Za-z0-9]{1,10})(?![A-Za-z0-9_.-])`,
|
|
103
|
-
"g",
|
|
104
|
-
);
|
|
105
|
-
|
|
106
|
-
/**
|
|
107
|
-
* Code-block fences, anchored to line start.
|
|
108
|
-
*
|
|
109
|
-
* Three or more backticks or tildes, per CommonMark. The previous version
|
|
110
|
-
* matched an unanchored ``` anywhere, so a mid-line backtick run swallowed the
|
|
111
|
-
* rest of the document and hid real references; and it knew nothing of `~~~` or
|
|
112
|
-
* four-backtick fences, so examples inside those were reported as broken.
|
|
113
|
-
*/
|
|
114
|
-
const FENCE_BLOCK = /^[ \t]*(`{3,}|~{3,})[^\n]*\n[\s\S]*?(?:^[ \t]*\1[ \t]*$|$)/gm;
|
|
115
|
-
/** Inline code: a skill saying `scripts/deploy.sh` is telling the *user* where
|
|
116
|
-
* their file goes, not pointing at one it ships. */
|
|
117
|
-
const INLINE_CODE = /`[^`\n]*`/g;
|
|
118
|
-
/** A URL swallows any path inside it, including one in a query string. */
|
|
119
|
-
const URLS = /\b[a-z][a-z0-9+.-]*:\/\/\S+/gi;
|
|
120
|
-
|
|
121
|
-
/**
|
|
122
|
-
* Bundled files a SKILL.md body points at.
|
|
123
|
-
*
|
|
124
|
-
* Conservative on purpose: a wrong "this skill points at a missing file" costs
|
|
125
|
-
* the reader more than a missed one. Fenced code blocks are stripped first —
|
|
126
|
-
* paths in them illustrate usage rather than pointing anywhere.
|
|
127
|
-
*/
|
|
128
|
-
export function skillReferences(body: string): string[] {
|
|
129
|
-
const prose = body
|
|
130
|
-
.replace(FENCE_BLOCK, "")
|
|
131
|
-
.replace(INLINE_CODE, "")
|
|
132
|
-
.replace(URLS, "");
|
|
133
|
-
const out: string[] = [];
|
|
134
|
-
const seen = new Set<string>();
|
|
135
|
-
for (const match of prose.matchAll(REFERENCE_RE)) {
|
|
136
|
-
const path = match[1];
|
|
137
|
-
if (path === undefined || seen.has(path)) {
|
|
138
|
-
continue;
|
|
139
|
-
}
|
|
140
|
-
seen.add(path);
|
|
141
|
-
out.push(path);
|
|
142
|
-
}
|
|
143
|
-
return out;
|
|
144
|
-
}
|
|
145
|
-
|
|
146
|
-
function scalar(value: unknown): string | undefined {
|
|
147
|
-
if (typeof value === "string") {
|
|
148
|
-
const trimmed = value.trim();
|
|
149
|
-
return trimmed.length > 0 ? trimmed : undefined;
|
|
150
|
-
}
|
|
151
|
-
if (typeof value === "number" || typeof value === "boolean") {
|
|
152
|
-
return String(value);
|
|
153
|
-
}
|
|
154
|
-
return undefined;
|
|
155
|
-
}
|
|
156
|
-
|
|
157
|
-
/**
|
|
158
|
-
* Parse the `---` block with a real YAML parser.
|
|
159
|
-
*
|
|
160
|
-
* This was regex-based, and every fix uncovered another shape it read wrong: an
|
|
161
|
-
* empty `name:` captured the following line, and folded scalars (`description: >`
|
|
162
|
-
* with the text indented beneath) recorded ">" as the value. Tightening the
|
|
163
|
-
* regex only converted a wrong value into a wrong "field is missing" — the file
|
|
164
|
-
* is valid YAML and the description is right there. `yaml` is already a
|
|
165
|
-
* dependency for the rule loader, so this costs nothing new.
|
|
166
|
-
*/
|
|
167
|
-
function readFrontmatter(
|
|
168
|
-
skillMdPath: string,
|
|
169
|
-
errors: ConfigErrorFact[],
|
|
170
|
-
): Frontmatter {
|
|
171
|
-
let text: string;
|
|
172
|
-
let truncated = false;
|
|
173
|
-
try {
|
|
174
|
-
const buf = readFileSync(skillMdPath);
|
|
175
|
-
truncated = buf.length > SKILL_MD_CAP;
|
|
176
|
-
text = buf.subarray(0, SKILL_MD_CAP).toString("utf8");
|
|
177
|
-
// Several Windows editors write a BOM; without stripping it the `---` test
|
|
178
|
-
// fails and a valid file is reported as having no frontmatter.
|
|
179
|
-
if (text.charCodeAt(0) === 0xfeff) {
|
|
180
|
-
text = text.slice(1);
|
|
181
|
-
}
|
|
182
|
-
// CRLF is valid in a SKILL.md, but the closing fence is then "\r\n---" and
|
|
183
|
-
// searching for "\n---" cuts inside it, leaving a stray \r that a strict
|
|
184
|
-
// YAML parser rejects. Normalise before locating the fence.
|
|
185
|
-
text = text.replace(/\r\n/g, "\n");
|
|
186
|
-
} catch (err) {
|
|
187
|
-
errors.push({
|
|
188
|
-
path: skillMdPath,
|
|
189
|
-
kind: "unreadable",
|
|
190
|
-
detail: err instanceof Error ? err.message : String(err),
|
|
191
|
-
});
|
|
192
|
-
return { hasFrontmatter: false, unreadable: true };
|
|
193
|
-
}
|
|
194
|
-
if (!text.startsWith("---")) {
|
|
195
|
-
return { hasFrontmatter: false };
|
|
196
|
-
}
|
|
197
|
-
// The closing fence is a line that is exactly `---` (YAML also allows `...`).
|
|
198
|
-
// Matching a bare "\n---" prefix took a key named `---metadata` for the fence
|
|
199
|
-
// and truncated the block, so a valid `description:` below it read as absent.
|
|
200
|
-
const fence = /\n(?:---|\.\.\.)[ \t]*(?:\n|$)/.exec(text.slice(3));
|
|
201
|
-
if (fence === null) {
|
|
202
|
-
if (truncated) {
|
|
203
|
-
// The block may well be well-formed past the cap; saying it has no
|
|
204
|
-
// frontmatter is a claim about bytes we chose not to read.
|
|
205
|
-
errors.push({
|
|
206
|
-
path: skillMdPath,
|
|
207
|
-
kind: "unexpected-shape",
|
|
208
|
-
detail: `frontmatter not closed within the first ${SKILL_MD_CAP} bytes`,
|
|
209
|
-
});
|
|
210
|
-
return { hasFrontmatter: true, unparseable: true };
|
|
211
|
-
}
|
|
212
|
-
return { hasFrontmatter: false };
|
|
213
|
-
}
|
|
214
|
-
const end = 3 + fence.index;
|
|
215
|
-
|
|
216
|
-
let block: unknown;
|
|
217
|
-
try {
|
|
218
|
-
block = parseYaml(text.slice(3, end)) as unknown;
|
|
219
|
-
} catch (err) {
|
|
220
|
-
// The block exists but the parser rejected it. Claiming "no name" here
|
|
221
|
-
// would be a statement about a file we failed to read — the same false
|
|
222
|
-
// message this parser was rewritten to stop producing. Say what is true:
|
|
223
|
-
// the frontmatter is unparseable.
|
|
224
|
-
errors.push({
|
|
225
|
-
path: skillMdPath,
|
|
226
|
-
kind: "unexpected-shape",
|
|
227
|
-
detail: `frontmatter is not valid YAML: ${
|
|
228
|
-
err instanceof Error ? err.message.split("\n")[0] : String(err)
|
|
229
|
-
}`,
|
|
230
|
-
});
|
|
231
|
-
return { hasFrontmatter: true, unparseable: true };
|
|
232
|
-
}
|
|
233
|
-
if (block === null || typeof block !== "object" || Array.isArray(block)) {
|
|
234
|
-
errors.push({
|
|
235
|
-
path: skillMdPath,
|
|
236
|
-
kind: "unexpected-shape",
|
|
237
|
-
detail: "frontmatter is not a YAML mapping",
|
|
238
|
-
});
|
|
239
|
-
return { hasFrontmatter: true, unparseable: true };
|
|
240
|
-
}
|
|
241
|
-
|
|
242
|
-
const record = block as Record<string, unknown>;
|
|
243
|
-
const out: Frontmatter = { hasFrontmatter: true, body: text.slice(end + 4) };
|
|
244
|
-
const name = scalar(record.name);
|
|
245
|
-
if (name !== undefined) {
|
|
246
|
-
out.name = name;
|
|
247
|
-
}
|
|
248
|
-
const description = scalar(record.description);
|
|
249
|
-
if (description !== undefined) {
|
|
250
|
-
out.description = description;
|
|
251
|
-
}
|
|
252
|
-
return out;
|
|
253
|
-
}
|
|
254
|
-
|
|
255
|
-
/**
|
|
256
|
-
* A reference resolves against the skill's own directory or, failing that, the
|
|
257
|
-
* repo root. Both bases are needed: across 1674 references measured in real
|
|
258
|
-
* projects, 1645 resolved skill-relative and 12 only at the root — checking one
|
|
259
|
-
* base alone would report those 12 as broken.
|
|
260
|
-
*/
|
|
261
|
-
function brokenReferences(
|
|
262
|
-
body: string,
|
|
263
|
-
skillDir: string,
|
|
264
|
-
root: string,
|
|
265
|
-
): string[] {
|
|
266
|
-
// A directory named like a file satisfies existsSync but cannot be read, so
|
|
267
|
-
// the reference is still dead.
|
|
268
|
-
const isFile = (p: string): boolean => {
|
|
269
|
-
try {
|
|
270
|
-
return statSync(p).isFile();
|
|
271
|
-
} catch {
|
|
272
|
-
return false;
|
|
273
|
-
}
|
|
274
|
-
};
|
|
275
|
-
return skillReferences(body).filter(
|
|
276
|
-
(rel) => !isFile(join(skillDir, rel)) && !isFile(join(root, rel)),
|
|
277
|
-
);
|
|
278
|
-
}
|
|
279
|
-
|
|
280
|
-
function discoverSkillsInDir(
|
|
281
|
-
dir: string,
|
|
282
|
-
source: "project" | "global",
|
|
283
|
-
errors: ConfigErrorFact[],
|
|
284
|
-
root: string,
|
|
285
|
-
): SkillFact[] {
|
|
286
|
-
if (!existsSync(dir)) {
|
|
287
|
-
return [];
|
|
288
|
-
}
|
|
289
|
-
let entries: string[];
|
|
290
|
-
try {
|
|
291
|
-
entries = readdirSync(dir);
|
|
292
|
-
} catch (err) {
|
|
293
|
-
// An unreadable skills dir looks exactly like a project with no skills.
|
|
294
|
-
errors.push({
|
|
295
|
-
path: dir,
|
|
296
|
-
kind: "unreadable",
|
|
297
|
-
detail: err instanceof Error ? err.message : String(err),
|
|
298
|
-
});
|
|
299
|
-
return [];
|
|
300
|
-
}
|
|
301
|
-
const skills: SkillFact[] = [];
|
|
302
|
-
for (const name of entries) {
|
|
303
|
-
// `.system` under ~/.codex/skills is a container holding six real skills,
|
|
304
|
-
// not a skill — reporting it suggested deleting them. Mirrors the dotfile
|
|
305
|
-
// filter discoverAgents already has.
|
|
306
|
-
if (name.startsWith(".") || name === "node_modules") {
|
|
307
|
-
continue;
|
|
308
|
-
}
|
|
309
|
-
const skillDir = join(dir, name);
|
|
310
|
-
let st: ReturnType<typeof statSync>;
|
|
311
|
-
try {
|
|
312
|
-
st = statSync(skillDir);
|
|
313
|
-
} catch {
|
|
314
|
-
continue;
|
|
315
|
-
}
|
|
316
|
-
if (!st.isDirectory()) {
|
|
317
|
-
continue;
|
|
318
|
-
}
|
|
319
|
-
const skillMd = join(skillDir, "SKILL.md");
|
|
320
|
-
// existsSync cannot tell ENOENT from EACCES, so on an unreadable directory
|
|
321
|
-
// "no SKILL.md" is a claim about a file we never got to look at.
|
|
322
|
-
let dirReadable = true;
|
|
323
|
-
try {
|
|
324
|
-
readdirSync(skillDir);
|
|
325
|
-
} catch (err) {
|
|
326
|
-
dirReadable = false;
|
|
327
|
-
errors.push({
|
|
328
|
-
path: skillDir,
|
|
329
|
-
kind: "unreadable",
|
|
330
|
-
detail: err instanceof Error ? err.message : String(err),
|
|
331
|
-
});
|
|
332
|
-
}
|
|
333
|
-
const hasSkillMd = dirReadable && existsSync(skillMd);
|
|
334
|
-
const fm = hasSkillMd
|
|
335
|
-
? readFrontmatter(skillMd, errors)
|
|
336
|
-
: { hasFrontmatter: false as const };
|
|
337
|
-
|
|
338
|
-
const fact: SkillFact = {
|
|
339
|
-
id: name,
|
|
340
|
-
path: skillDir,
|
|
341
|
-
source,
|
|
342
|
-
hasSkillMd,
|
|
343
|
-
hasFrontmatter: fm.hasFrontmatter,
|
|
344
|
-
};
|
|
345
|
-
if (fm.unreadable === true || !dirReadable) {
|
|
346
|
-
fact.unreadable = true;
|
|
347
|
-
}
|
|
348
|
-
if (fm.unparseable === true) {
|
|
349
|
-
fact.unparseableFrontmatter = true;
|
|
350
|
-
}
|
|
351
|
-
if (fm.body !== undefined) {
|
|
352
|
-
const broken = brokenReferences(fm.body, skillDir, root);
|
|
353
|
-
if (broken.length > 0) {
|
|
354
|
-
fact.brokenReferences = broken;
|
|
355
|
-
}
|
|
356
|
-
}
|
|
357
|
-
if (fm.description !== undefined) {
|
|
358
|
-
fact.description = fm.description;
|
|
359
|
-
}
|
|
360
|
-
if (fm.name !== undefined) {
|
|
361
|
-
fact.frontmatterName = fm.name;
|
|
362
|
-
}
|
|
363
|
-
skills.push(fact);
|
|
364
|
-
}
|
|
365
|
-
return skills;
|
|
366
|
-
}
|
|
367
|
-
|
|
368
|
-
function parseMcpServers(
|
|
369
|
-
raw: unknown,
|
|
370
|
-
filePath: string,
|
|
371
|
-
errors: ConfigErrorFact[],
|
|
372
|
-
): McpFact[] {
|
|
373
|
-
if (raw === null || typeof raw !== "object" || Array.isArray(raw)) {
|
|
374
|
-
errors.push({
|
|
375
|
-
path: filePath,
|
|
376
|
-
kind: "unexpected-shape",
|
|
377
|
-
detail: "MCP config root is not a JSON object",
|
|
378
|
-
});
|
|
379
|
-
return [];
|
|
380
|
-
}
|
|
381
|
-
const obj = raw as Record<string, unknown>;
|
|
382
|
-
let servers: Record<string, unknown>;
|
|
383
|
-
if (
|
|
384
|
-
"mcpServers" in obj &&
|
|
385
|
-
obj.mcpServers !== null &&
|
|
386
|
-
typeof obj.mcpServers === "object" &&
|
|
387
|
-
!Array.isArray(obj.mcpServers)
|
|
388
|
-
) {
|
|
389
|
-
servers = obj.mcpServers as Record<string, unknown>;
|
|
390
|
-
} else if (
|
|
391
|
-
!("mcpServers" in obj) &&
|
|
392
|
-
Object.keys(obj).length > 0 &&
|
|
393
|
-
// Every value must look like a server entry. Without this a wrapper key —
|
|
394
|
-
// the VS Code `servers` spelling, or `inputs` — becomes a phantom server
|
|
395
|
-
// reported as having no command, while the real servers under it are never
|
|
396
|
-
// inspected.
|
|
397
|
-
Object.values(obj).every(
|
|
398
|
-
(v) =>
|
|
399
|
-
v !== null &&
|
|
400
|
-
typeof v === "object" &&
|
|
401
|
-
!Array.isArray(v) &&
|
|
402
|
-
("command" in v || "url" in v || "type" in v),
|
|
403
|
-
)
|
|
404
|
-
) {
|
|
405
|
-
servers = obj;
|
|
406
|
-
} else {
|
|
407
|
-
errors.push({
|
|
408
|
-
path: filePath,
|
|
409
|
-
kind: "unexpected-shape",
|
|
410
|
-
detail: "no usable `mcpServers` object and the root is not a server map",
|
|
411
|
-
});
|
|
412
|
-
return [];
|
|
413
|
-
}
|
|
414
|
-
|
|
415
|
-
const facts: McpFact[] = [];
|
|
416
|
-
for (const [name, value] of Object.entries(servers)) {
|
|
417
|
-
let hasCommand = false;
|
|
418
|
-
let hasUrl = false;
|
|
419
|
-
let transport: string | undefined;
|
|
420
|
-
const literalEnvKeys: string[] = [];
|
|
421
|
-
if (value !== null && typeof value === "object" && !Array.isArray(value)) {
|
|
422
|
-
const entry = value as Record<string, unknown>;
|
|
423
|
-
hasCommand =
|
|
424
|
-
typeof entry.command === "string" && entry.command.length > 0;
|
|
425
|
-
hasUrl = typeof entry.url === "string" && entry.url.length > 0;
|
|
426
|
-
if (typeof entry.type === "string" && entry.type.length > 0) {
|
|
427
|
-
transport = entry.type;
|
|
428
|
-
}
|
|
429
|
-
const env = entry.env;
|
|
430
|
-
if (env !== null && typeof env === "object" && !Array.isArray(env)) {
|
|
431
|
-
for (const [key, val] of Object.entries(env as Record<string, unknown>)) {
|
|
432
|
-
// Gate on the key, not the value length. PATH, NODE_OPTIONS and
|
|
433
|
-
// LOG_FORMAT are all long and none is a secret — the same "narrow, no
|
|
434
|
-
// entropy guessing" principle SECRET_PATTERNS commits to.
|
|
435
|
-
if (
|
|
436
|
-
typeof val === "string" &&
|
|
437
|
-
val.length > 0 &&
|
|
438
|
-
!val.includes("${") &&
|
|
439
|
-
!val.startsWith("$") &&
|
|
440
|
-
/(?:TOKEN|SECRET|KEY|PASSWORD|PASSWD|CREDENTIAL)S?$/i.test(key)
|
|
441
|
-
) {
|
|
442
|
-
literalEnvKeys.push(key);
|
|
443
|
-
}
|
|
444
|
-
}
|
|
445
|
-
}
|
|
446
|
-
}
|
|
447
|
-
facts.push({
|
|
448
|
-
name,
|
|
449
|
-
path: filePath,
|
|
450
|
-
hasCommand,
|
|
451
|
-
hasUrl,
|
|
452
|
-
...(transport === undefined ? {} : { transport }),
|
|
453
|
-
literalEnvKeys,
|
|
454
|
-
raw: JSON.stringify(value),
|
|
455
|
-
});
|
|
456
|
-
}
|
|
457
|
-
return facts;
|
|
458
|
-
}
|
|
459
|
-
|
|
460
|
-
function discoverMcp(
|
|
461
|
-
root: string,
|
|
462
|
-
mcpPaths: string[],
|
|
463
|
-
errors: ConfigErrorFact[],
|
|
464
|
-
): McpFact[] {
|
|
465
|
-
const facts: McpFact[] = [];
|
|
466
|
-
const seen = new Set<string>();
|
|
467
|
-
for (const rel of mcpPaths) {
|
|
468
|
-
const filePath = join(root, rel);
|
|
469
|
-
if (!existsSync(filePath)) {
|
|
470
|
-
continue;
|
|
471
|
-
}
|
|
472
|
-
const raw = readJsonConfig(filePath, errors);
|
|
473
|
-
if (raw === undefined) {
|
|
474
|
-
continue;
|
|
475
|
-
}
|
|
476
|
-
for (const fact of parseMcpServers(raw, filePath, errors)) {
|
|
477
|
-
const key = `${fact.name}@${fact.path}`;
|
|
478
|
-
if (seen.has(key)) {
|
|
479
|
-
continue;
|
|
480
|
-
}
|
|
481
|
-
seen.add(key);
|
|
482
|
-
facts.push(fact);
|
|
483
|
-
}
|
|
484
|
-
}
|
|
485
|
-
return facts;
|
|
486
|
-
}
|
|
487
|
-
|
|
488
|
-
const INTERPRETERS = /^(?:bash|sh|zsh|python3?|node|bun|deno)$/;
|
|
489
|
-
/** Flags that mean "the next argument is source code, not a file". */
|
|
490
|
-
const INLINE_CODE_FLAGS = /^-(?:e|p|c)$/;
|
|
491
|
-
/** Anything that makes the command a shell program rather than one invocation. */
|
|
492
|
-
const SHELL_METACHARS = /[|;&`]|\$\(|\|\||&&/;
|
|
493
|
-
|
|
494
|
-
/**
|
|
495
|
-
* Pull the script path out of a hook command, so a hook whose script is gone can
|
|
496
|
-
* be reported. Returns undefined whenever the answer is not certain.
|
|
497
|
-
*
|
|
498
|
-
* Deliberately conservative: a wrong "this hook is broken" on a guard hook is as
|
|
499
|
-
* damaging as missing a real one. Shell programs (`a && b`, `$(...)`, pipes) are
|
|
500
|
-
* skipped outright, because a command like `[ ! -f x ] || node x` handles the
|
|
501
|
-
* missing file itself and flagging it would be a false positive.
|
|
502
|
-
*/
|
|
503
|
-
export function hookScriptPath(command: string): string | undefined {
|
|
504
|
-
const trimmed = command.trim();
|
|
505
|
-
if (trimmed.length === 0 || SHELL_METACHARS.test(trimmed)) {
|
|
506
|
-
return undefined;
|
|
507
|
-
}
|
|
508
|
-
|
|
509
|
-
const tokens = trimmed.match(/"[^"]*"|'[^']*'|\S+/g);
|
|
510
|
-
if (tokens === null || tokens.length === 0) {
|
|
511
|
-
return undefined;
|
|
512
|
-
}
|
|
513
|
-
|
|
514
|
-
const unquote = (t: string): string =>
|
|
515
|
-
(t.startsWith('"') && t.endsWith('"')) ||
|
|
516
|
-
(t.startsWith("'") && t.endsWith("'"))
|
|
517
|
-
? t.slice(1, -1)
|
|
518
|
-
: t;
|
|
519
|
-
|
|
520
|
-
let i = 0;
|
|
521
|
-
const first = unquote(tokens[0] as string);
|
|
522
|
-
if (INTERPRETERS.test(first)) {
|
|
523
|
-
i = 1;
|
|
524
|
-
while (i < tokens.length) {
|
|
525
|
-
const flag = unquote(tokens[i] as string);
|
|
526
|
-
if (!flag.startsWith("-")) {
|
|
527
|
-
break;
|
|
528
|
-
}
|
|
529
|
-
// `node -e "<code>"` — the next token is source, never a path
|
|
530
|
-
if (INLINE_CODE_FLAGS.test(flag)) {
|
|
531
|
-
return undefined;
|
|
532
|
-
}
|
|
533
|
-
i += 1;
|
|
534
|
-
}
|
|
535
|
-
}
|
|
536
|
-
|
|
537
|
-
const candidate = tokens[i] === undefined ? undefined : unquote(tokens[i] as string);
|
|
538
|
-
if (candidate === undefined || !candidate.includes("/")) {
|
|
539
|
-
return undefined;
|
|
540
|
-
}
|
|
541
|
-
// A drive-letter path cannot be resolved here; do not guess.
|
|
542
|
-
if (/^[A-Za-z]:[/\\]/.test(candidate)) {
|
|
543
|
-
return undefined;
|
|
544
|
-
}
|
|
545
|
-
// Only CLAUDE_PROJECT_DIR has a defined meaning; other vars are unknowable.
|
|
546
|
-
if (candidate.startsWith("$")) {
|
|
547
|
-
return /^\$(?:CLAUDE_PROJECT_DIR\b|\{CLAUDE_PROJECT_DIR\})/.test(candidate)
|
|
548
|
-
? candidate
|
|
549
|
-
: undefined;
|
|
550
|
-
}
|
|
551
|
-
if (
|
|
552
|
-
candidate.startsWith("/") ||
|
|
553
|
-
candidate.startsWith("./") ||
|
|
554
|
-
candidate.startsWith("../") ||
|
|
555
|
-
candidate.startsWith("~/") ||
|
|
556
|
-
candidate.startsWith(".")
|
|
557
|
-
) {
|
|
558
|
-
return candidate;
|
|
559
|
-
}
|
|
560
|
-
return undefined;
|
|
561
|
-
}
|
|
562
|
-
|
|
563
|
-
function resolveHookScript(
|
|
564
|
-
root: string,
|
|
565
|
-
raw: string,
|
|
566
|
-
): { scriptPath: string; exists: boolean } | undefined {
|
|
567
|
-
const extracted = hookScriptPath(raw);
|
|
568
|
-
if (extracted === undefined) {
|
|
569
|
-
return undefined;
|
|
570
|
-
}
|
|
571
|
-
let expanded = extracted;
|
|
572
|
-
if (expanded.startsWith("~/")) {
|
|
573
|
-
expanded = join(homedir(), expanded.slice(2));
|
|
574
|
-
} else if (expanded.startsWith("$")) {
|
|
575
|
-
// CLAUDE_PROJECT_DIR is the project root by definition
|
|
576
|
-
expanded = expanded.replace(
|
|
577
|
-
/^\$(?:CLAUDE_PROJECT_DIR|\{CLAUDE_PROJECT_DIR\})\/?/,
|
|
578
|
-
"",
|
|
579
|
-
);
|
|
580
|
-
}
|
|
581
|
-
const abs = isAbsolute(expanded) ? expanded : join(root, expanded);
|
|
582
|
-
return { scriptPath: extracted, exists: existsSync(abs) };
|
|
583
|
-
}
|
|
584
|
-
|
|
585
|
-
function discoverHooks(
|
|
586
|
-
root: string,
|
|
587
|
-
errors: ConfigErrorFact[],
|
|
588
|
-
): HookFact[] {
|
|
589
|
-
const files = [
|
|
590
|
-
join(root, ".claude", "settings.json"),
|
|
591
|
-
join(root, ".claude", "settings.local.json"),
|
|
592
|
-
];
|
|
593
|
-
const facts: HookFact[] = [];
|
|
594
|
-
for (const filePath of files) {
|
|
595
|
-
if (!existsSync(filePath)) {
|
|
596
|
-
continue;
|
|
597
|
-
}
|
|
598
|
-
const raw = readJsonConfig(filePath, errors);
|
|
599
|
-
if (raw === undefined) {
|
|
600
|
-
continue;
|
|
601
|
-
}
|
|
602
|
-
if (raw === null || typeof raw !== "object" || Array.isArray(raw)) {
|
|
603
|
-
errors.push({
|
|
604
|
-
path: filePath,
|
|
605
|
-
kind: "unexpected-shape",
|
|
606
|
-
detail: "settings file is not a JSON object",
|
|
607
|
-
});
|
|
608
|
-
continue;
|
|
609
|
-
}
|
|
610
|
-
const hooks = (raw as Record<string, unknown>).hooks;
|
|
611
|
-
if (hooks === undefined) {
|
|
612
|
-
continue;
|
|
613
|
-
}
|
|
614
|
-
if (hooks === null || typeof hooks !== "object" || Array.isArray(hooks)) {
|
|
615
|
-
errors.push({
|
|
616
|
-
path: filePath,
|
|
617
|
-
kind: "unexpected-shape",
|
|
618
|
-
detail: "`hooks` is not an object",
|
|
619
|
-
});
|
|
620
|
-
continue;
|
|
621
|
-
}
|
|
622
|
-
|
|
623
|
-
for (const [event, groups] of Object.entries(
|
|
624
|
-
hooks as Record<string, unknown>,
|
|
625
|
-
)) {
|
|
626
|
-
const commands = collectHookCommands(groups);
|
|
627
|
-
if (commands.length === 0) {
|
|
628
|
-
facts.push({ name: event, path: filePath, event });
|
|
629
|
-
continue;
|
|
630
|
-
}
|
|
631
|
-
for (const command of commands) {
|
|
632
|
-
const fact: HookFact = { name: event, path: filePath, event, command };
|
|
633
|
-
const resolved = resolveHookScript(root, command);
|
|
634
|
-
if (resolved !== undefined) {
|
|
635
|
-
fact.scriptPath = resolved.scriptPath;
|
|
636
|
-
fact.scriptExists = resolved.exists;
|
|
637
|
-
}
|
|
638
|
-
facts.push(fact);
|
|
639
|
-
}
|
|
640
|
-
}
|
|
641
|
-
}
|
|
642
|
-
return facts;
|
|
643
|
-
}
|
|
644
|
-
|
|
645
|
-
/** `hooks[event]` is an array of matcher groups, each with a `hooks` array. */
|
|
646
|
-
function collectHookCommands(groups: unknown): string[] {
|
|
647
|
-
if (!Array.isArray(groups)) {
|
|
648
|
-
return [];
|
|
649
|
-
}
|
|
650
|
-
const out: string[] = [];
|
|
651
|
-
for (const group of groups) {
|
|
652
|
-
if (group === null || typeof group !== "object" || Array.isArray(group)) {
|
|
653
|
-
continue;
|
|
654
|
-
}
|
|
655
|
-
const inner = (group as Record<string, unknown>).hooks;
|
|
656
|
-
if (!Array.isArray(inner)) {
|
|
657
|
-
continue;
|
|
658
|
-
}
|
|
659
|
-
for (const entry of inner) {
|
|
660
|
-
if (entry === null || typeof entry !== "object" || Array.isArray(entry)) {
|
|
661
|
-
continue;
|
|
662
|
-
}
|
|
663
|
-
const command = (entry as Record<string, unknown>).command;
|
|
664
|
-
if (typeof command === "string" && command.length > 0) {
|
|
665
|
-
out.push(command);
|
|
666
|
-
}
|
|
667
|
-
}
|
|
668
|
-
}
|
|
669
|
-
return out;
|
|
670
|
-
}
|
|
671
|
-
|
|
672
|
-
/** Agent definitions nest, so a one-level scan misses `agents/review/x.md`. */
|
|
673
|
-
function agentFiles(dir: string, errors: ConfigErrorFact[]): string[] {
|
|
674
|
-
let entries: string[];
|
|
675
|
-
try {
|
|
676
|
-
entries = readdirSync(dir, { withFileTypes: true }).map((e) =>
|
|
677
|
-
e.isDirectory() ? `${e.name}/` : e.name,
|
|
678
|
-
);
|
|
679
|
-
} catch (err) {
|
|
680
|
-
errors.push({
|
|
681
|
-
path: dir,
|
|
682
|
-
kind: "unreadable",
|
|
683
|
-
detail: err instanceof Error ? err.message : String(err),
|
|
684
|
-
});
|
|
685
|
-
return [];
|
|
686
|
-
}
|
|
687
|
-
const out: string[] = [];
|
|
688
|
-
for (const entry of entries) {
|
|
689
|
-
if (entry.startsWith(".")) {
|
|
690
|
-
continue;
|
|
691
|
-
}
|
|
692
|
-
if (entry.endsWith("/")) {
|
|
693
|
-
out.push(
|
|
694
|
-
...agentFiles(join(dir, entry.slice(0, -1)), errors).map((p) =>
|
|
695
|
-
join(entry.slice(0, -1), p),
|
|
696
|
-
),
|
|
697
|
-
);
|
|
698
|
-
continue;
|
|
699
|
-
}
|
|
700
|
-
out.push(entry);
|
|
701
|
-
}
|
|
702
|
-
return out;
|
|
703
|
-
}
|
|
704
|
-
|
|
705
|
-
function discoverAgents(root: string, errors: ConfigErrorFact[]): AgentFact[] {
|
|
706
|
-
const dir = join(root, ".claude", "agents");
|
|
707
|
-
if (!existsSync(dir)) {
|
|
708
|
-
return [];
|
|
709
|
-
}
|
|
710
|
-
const entries = agentFiles(dir, errors);
|
|
711
|
-
const facts: AgentFact[] = [];
|
|
712
|
-
for (const name of entries) {
|
|
713
|
-
// Agent definitions are markdown; .gitkeep and .DS_Store are not agents,
|
|
714
|
-
// and counting them inflates the budget.agents rule. Dotfiles are already
|
|
715
|
-
// filtered by agentFiles, including in nested directories.
|
|
716
|
-
if (!name.endsWith(".md")) {
|
|
717
|
-
continue;
|
|
718
|
-
}
|
|
719
|
-
const filePath = join(dir, name);
|
|
720
|
-
try {
|
|
721
|
-
const st = statSync(filePath);
|
|
722
|
-
if (!st.isFile()) {
|
|
723
|
-
continue;
|
|
724
|
-
}
|
|
725
|
-
} catch {
|
|
726
|
-
continue;
|
|
727
|
-
}
|
|
728
|
-
const fm = readFrontmatter(filePath, errors);
|
|
729
|
-
const fact: AgentFact = {
|
|
730
|
-
name: name.slice(0, -".md".length),
|
|
731
|
-
path: filePath,
|
|
732
|
-
hasFrontmatter: fm.hasFrontmatter,
|
|
733
|
-
};
|
|
734
|
-
if (fm.unreadable === true) {
|
|
735
|
-
fact.unreadable = true;
|
|
736
|
-
}
|
|
737
|
-
if (fm.unparseable === true) {
|
|
738
|
-
fact.unparseableFrontmatter = true;
|
|
739
|
-
}
|
|
740
|
-
if (fm.name !== undefined) {
|
|
741
|
-
fact.frontmatterName = fm.name;
|
|
742
|
-
}
|
|
743
|
-
if (fm.description !== undefined) {
|
|
744
|
-
fact.description = fm.description;
|
|
745
|
-
}
|
|
746
|
-
facts.push(fact);
|
|
747
|
-
}
|
|
748
|
-
return facts;
|
|
749
|
-
}
|
|
750
|
-
|
|
751
|
-
function discoverPolicyFiles(
|
|
752
|
-
root: string,
|
|
753
|
-
policyFiles: string[],
|
|
754
|
-
errors: ConfigErrorFact[],
|
|
755
|
-
): { path: string; text: string }[] {
|
|
756
|
-
const out: { path: string; text: string }[] = [];
|
|
757
|
-
for (const rel of policyFiles) {
|
|
758
|
-
const filePath = join(root, rel);
|
|
759
|
-
if (!existsSync(filePath)) {
|
|
760
|
-
continue;
|
|
761
|
-
}
|
|
762
|
-
try {
|
|
763
|
-
const buf = readFileSync(filePath);
|
|
764
|
-
const text = buf.subarray(0, POLICY_CAP).toString("utf8");
|
|
765
|
-
out.push({ path: filePath, text });
|
|
766
|
-
} catch (err) {
|
|
767
|
-
errors.push({
|
|
768
|
-
path: filePath,
|
|
769
|
-
kind: "unreadable",
|
|
770
|
-
detail: err instanceof Error ? err.message : String(err),
|
|
771
|
-
});
|
|
772
|
-
}
|
|
773
|
-
}
|
|
774
|
-
return out;
|
|
775
|
-
}
|
|
776
|
-
|
|
777
|
-
/**
|
|
778
|
-
* skills-lock.json — the oracle for which skills are managed installs pinned to
|
|
779
|
-
* an upstream source, and which are local and unpinned.
|
|
780
|
-
*/
|
|
781
|
-
function discoverSkillsLock(
|
|
782
|
-
root: string,
|
|
783
|
-
errors: ConfigErrorFact[],
|
|
784
|
-
): { locked: LockedSkillFact[]; present: boolean } {
|
|
785
|
-
const filePath = join(root, "skills-lock.json");
|
|
786
|
-
if (!existsSync(filePath)) {
|
|
787
|
-
return { locked: [], present: false };
|
|
788
|
-
}
|
|
789
|
-
const raw = readJsonConfig(filePath, errors);
|
|
790
|
-
if (raw === undefined) {
|
|
791
|
-
return { locked: [], present: true };
|
|
792
|
-
}
|
|
793
|
-
if (raw === null || typeof raw !== "object" || Array.isArray(raw)) {
|
|
794
|
-
errors.push({
|
|
795
|
-
path: filePath,
|
|
796
|
-
kind: "unexpected-shape",
|
|
797
|
-
detail: "skills-lock.json is not a JSON object",
|
|
798
|
-
});
|
|
799
|
-
return { locked: [], present: true };
|
|
800
|
-
}
|
|
801
|
-
const skills = (raw as Record<string, unknown>).skills;
|
|
802
|
-
if (skills === null || typeof skills !== "object" || Array.isArray(skills)) {
|
|
803
|
-
errors.push({
|
|
804
|
-
path: filePath,
|
|
805
|
-
kind: "unexpected-shape",
|
|
806
|
-
detail: "skills-lock.json has no `skills` object",
|
|
807
|
-
});
|
|
808
|
-
return { locked: [], present: true };
|
|
809
|
-
}
|
|
810
|
-
|
|
811
|
-
const locked: LockedSkillFact[] = [];
|
|
812
|
-
for (const [id, value] of Object.entries(skills as Record<string, unknown>)) {
|
|
813
|
-
const entry: LockedSkillFact = { id };
|
|
814
|
-
if (value !== null && typeof value === "object" && !Array.isArray(value)) {
|
|
815
|
-
const v = value as Record<string, unknown>;
|
|
816
|
-
if (typeof v.source === "string") {
|
|
817
|
-
entry.source = v.source;
|
|
818
|
-
}
|
|
819
|
-
if (typeof v.skillPath === "string") {
|
|
820
|
-
entry.skillPath = v.skillPath;
|
|
821
|
-
}
|
|
822
|
-
if (typeof v.computedHash === "string") {
|
|
823
|
-
entry.computedHash = v.computedHash;
|
|
824
|
-
}
|
|
825
|
-
}
|
|
826
|
-
locked.push(entry);
|
|
827
|
-
}
|
|
828
|
-
return { locked, present: true };
|
|
829
|
-
}
|
|
830
|
-
|
|
831
|
-
/**
|
|
832
|
-
* Enumerate project (and optionally global) agent surface: skills, agents, hooks,
|
|
833
|
-
* MCP, policy, lockfile. Config files that cannot be parsed are reported through
|
|
834
|
-
* `configErrors` instead of being silently dropped.
|
|
835
|
-
*/
|
|
836
|
-
export function discoverAgentSurface(
|
|
837
|
-
root: string,
|
|
838
|
-
config: AgentscanConfig,
|
|
839
|
-
opts: { includeGlobal: boolean },
|
|
840
|
-
): AgentSurface {
|
|
841
|
-
const configErrors: ConfigErrorFact[] = [];
|
|
842
|
-
const skills: SkillFact[] = [];
|
|
843
|
-
for (const rel of config.skillPaths) {
|
|
844
|
-
skills.push(...discoverSkillsInDir(join(root, rel), "project", configErrors, root));
|
|
845
|
-
}
|
|
846
|
-
|
|
847
|
-
if (opts.includeGlobal) {
|
|
848
|
-
const home = homedir();
|
|
849
|
-
skills.push(
|
|
850
|
-
...discoverSkillsInDir(join(home, ".claude", "skills"), "global", configErrors, root),
|
|
851
|
-
);
|
|
852
|
-
skills.push(
|
|
853
|
-
...discoverSkillsInDir(join(home, ".codex", "skills"), "global", configErrors, root),
|
|
854
|
-
);
|
|
855
|
-
}
|
|
856
|
-
|
|
857
|
-
const lock = discoverSkillsLock(root, configErrors);
|
|
858
|
-
|
|
859
|
-
return {
|
|
860
|
-
skills: dedupeSkillsById(skills),
|
|
861
|
-
agents: discoverAgents(root, configErrors),
|
|
862
|
-
hooks: discoverHooks(root, configErrors),
|
|
863
|
-
mcp: discoverMcp(root, config.mcpPaths, configErrors),
|
|
864
|
-
policyFiles: discoverPolicyFiles(root, config.policyFiles, configErrors),
|
|
865
|
-
lockedSkills: lock.locked,
|
|
866
|
-
hasSkillsLock: lock.present,
|
|
867
|
-
configErrors,
|
|
868
|
-
};
|
|
869
|
-
}
|
|
870
|
-
|
|
871
|
-
/**
|
|
872
|
-
* One SkillFact per id. First path wins (skillPaths order, then global).
|
|
873
|
-
* Prefer project over global when the same id appears later as project (re-scan).
|
|
874
|
-
*/
|
|
875
|
-
function dedupeSkillsById(skills: SkillFact[]): SkillFact[] {
|
|
876
|
-
const byId = new Map<string, SkillFact>();
|
|
877
|
-
for (const skill of skills) {
|
|
878
|
-
const existing = byId.get(skill.id);
|
|
879
|
-
if (!existing) {
|
|
880
|
-
byId.set(skill.id, skill);
|
|
881
|
-
continue;
|
|
882
|
-
}
|
|
883
|
-
if (existing.source === "global" && skill.source === "project") {
|
|
884
|
-
byId.set(skill.id, skill);
|
|
885
|
-
}
|
|
886
|
-
}
|
|
887
|
-
return [...byId.values()];
|
|
888
|
-
}
|