toolmoat 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (97) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +302 -2
  3. package/dist/cli.d.ts +2 -0
  4. package/dist/cli.js +80 -0
  5. package/dist/clients/configs.d.ts +124 -0
  6. package/dist/clients/configs.js +403 -0
  7. package/dist/commands/approve.d.ts +15 -0
  8. package/dist/commands/approve.js +128 -0
  9. package/dist/commands/diff.d.ts +6 -0
  10. package/dist/commands/diff.js +114 -0
  11. package/dist/commands/pins.d.ts +18 -0
  12. package/dist/commands/pins.js +88 -0
  13. package/dist/commands/proxy.d.ts +24 -0
  14. package/dist/commands/proxy.js +144 -0
  15. package/dist/commands/rules.d.ts +15 -0
  16. package/dist/commands/rules.js +173 -0
  17. package/dist/commands/scan.d.ts +57 -0
  18. package/dist/commands/scan.js +323 -0
  19. package/dist/commands/unwrap.d.ts +5 -0
  20. package/dist/commands/unwrap.js +69 -0
  21. package/dist/commands/wrap.d.ts +131 -0
  22. package/dist/commands/wrap.js +518 -0
  23. package/dist/index.d.ts +20 -0
  24. package/dist/index.js +13 -0
  25. package/dist/patterns/indicators.d.ts +41 -0
  26. package/dist/patterns/indicators.js +85 -0
  27. package/dist/patterns/secrets.d.ts +20 -0
  28. package/dist/patterns/secrets.js +70 -0
  29. package/dist/proxy/audit.d.ts +53 -0
  30. package/dist/proxy/audit.js +79 -0
  31. package/dist/proxy/canonical.d.ts +13 -0
  32. package/dist/proxy/canonical.js +34 -0
  33. package/dist/proxy/diff.d.ts +22 -0
  34. package/dist/proxy/diff.js +124 -0
  35. package/dist/proxy/fingerprint.d.ts +34 -0
  36. package/dist/proxy/fingerprint.js +140 -0
  37. package/dist/proxy/firewall.d.ts +132 -0
  38. package/dist/proxy/firewall.js +665 -0
  39. package/dist/proxy/framing.d.ts +45 -0
  40. package/dist/proxy/framing.js +86 -0
  41. package/dist/proxy/lockfile.d.ts +58 -0
  42. package/dist/proxy/lockfile.js +258 -0
  43. package/dist/proxy/names.d.ts +27 -0
  44. package/dist/proxy/names.js +175 -0
  45. package/dist/proxy/policy.d.ts +72 -0
  46. package/dist/proxy/policy.js +86 -0
  47. package/dist/proxy/proxy.d.ts +18 -0
  48. package/dist/proxy/proxy.js +237 -0
  49. package/dist/proxy/spawn.d.ts +51 -0
  50. package/dist/proxy/spawn.js +110 -0
  51. package/dist/report/index.d.ts +35 -0
  52. package/dist/report/index.js +25 -0
  53. package/dist/report/json.d.ts +22 -0
  54. package/dist/report/json.js +40 -0
  55. package/dist/report/markdown.d.ts +12 -0
  56. package/dist/report/markdown.js +87 -0
  57. package/dist/report/sarif.d.ts +89 -0
  58. package/dist/report/sarif.js +142 -0
  59. package/dist/report/text.d.ts +16 -0
  60. package/dist/report/text.js +206 -0
  61. package/dist/rules/code.d.ts +15 -0
  62. package/dist/rules/code.js +729 -0
  63. package/dist/rules/config.d.ts +3 -0
  64. package/dist/rules/config.js +2164 -0
  65. package/dist/rules/index.d.ts +3 -0
  66. package/dist/rules/index.js +8 -0
  67. package/dist/rules/secrets.d.ts +2 -0
  68. package/dist/rules/secrets.js +108 -0
  69. package/dist/rules/text.d.ts +2 -0
  70. package/dist/rules/text.js +1698 -0
  71. package/dist/scan/discover.d.ts +37 -0
  72. package/dist/scan/discover.js +525 -0
  73. package/dist/scan/engine.d.ts +24 -0
  74. package/dist/scan/engine.js +111 -0
  75. package/dist/scan/ignore.d.ts +28 -0
  76. package/dist/scan/ignore.js +46 -0
  77. package/dist/types.d.ts +98 -0
  78. package/dist/types.js +25 -0
  79. package/dist/util/colors.d.ts +12 -0
  80. package/dist/util/colors.js +24 -0
  81. package/dist/util/frontmatter.d.ts +20 -0
  82. package/dist/util/frontmatter.js +136 -0
  83. package/dist/util/glob.d.ts +7 -0
  84. package/dist/util/glob.js +44 -0
  85. package/dist/util/hash.d.ts +3 -0
  86. package/dist/util/hash.js +24 -0
  87. package/dist/util/jsonc.d.ts +14 -0
  88. package/dist/util/jsonc.js +113 -0
  89. package/dist/util/paths.d.ts +9 -0
  90. package/dist/util/paths.js +37 -0
  91. package/dist/util/text.d.ts +29 -0
  92. package/dist/util/text.js +96 -0
  93. package/dist/util/toml-lite.d.ts +40 -0
  94. package/dist/util/toml-lite.js +507 -0
  95. package/dist/version.d.ts +1 -0
  96. package/dist/version.js +1 -0
  97. package/package.json +57 -4
@@ -0,0 +1,37 @@
1
+ import type { FileKind, ScanFile, SkippedFile } from '../types.js';
2
+ import type { IgnoreConfig } from './ignore.js';
3
+ export interface DiscoverOptions {
4
+ /** Files or directories to scan; a leading ~ is expanded. */
5
+ paths: string[];
6
+ /** Also scan the known agent locations (skills, plugins, hooks, MCP client configs) that exist. */
7
+ useDefaults: boolean;
8
+ cwd: string;
9
+ /** Walk into node_modules, virtualenvs and caches too. .git is always skipped. */
10
+ deep?: boolean;
11
+ /** Files larger than this are reported as skipped. Default 2 MiB. */
12
+ maxFileBytes?: number;
13
+ ignore?: IgnoreConfig;
14
+ /** Stop after this many files. Default 20000. */
15
+ maxFiles?: number;
16
+ }
17
+ export interface DiscoverResult {
18
+ files: ScanFile[];
19
+ skipped: SkippedFile[];
20
+ /** What was actually scanned, as display paths, for the report header. */
21
+ roots: string[];
22
+ }
23
+ /**
24
+ * Finds the files to scan: the explicit paths, plus the known agent locations
25
+ * when useDefaults is set. Walks iteratively, never follows directory symlinks
26
+ * found during the walk (except symlinked skill folders directly inside a known
27
+ * skills directory), skips vendored and cache directories unless deep is set,
28
+ * and reports large, binary and unreadable files as skipped.
29
+ */
30
+ export declare function discover(options: DiscoverOptions): Promise<DiscoverResult>;
31
+ /**
32
+ * What a file is to an agent, from its path and (for generic JSON and
33
+ * extensionless files) its content. Returns null for files toolmoat does not
34
+ * scan. Skill and plugin membership is looked up on disk in the directories
35
+ * above the file; content is read from disk when it is needed and not given.
36
+ */
37
+ export declare function classifyFile(absPath: string, content?: string): FileKind | null;
@@ -0,0 +1,525 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { CLIENT_CONFIGS, pathKey } from '../clients/configs.js';
4
+ import { isRecord, parseJsonc } from '../util/jsonc.js';
5
+ import { expandHome, homeDir } from '../util/paths.js';
6
+ import { printable, truncate } from '../util/text.js';
7
+ import { isPathIgnored } from './ignore.js';
8
+ const MIB = 1024 * 1024;
9
+ const DEFAULT_MAX_FILE_BYTES = 2 * MIB;
10
+ const DEFAULT_MAX_FILES = 20_000;
11
+ const READ_CONCURRENCY = 16;
12
+ const BINARY_SNIFF_BYTES = 8192;
13
+ const SKIP_DIRS = new Set(['.git', 'node_modules', '.venv', 'venv', '__pycache__', '.tox', '.mypy_cache', '.pytest_cache', '.next', '.turbo', '.cache']);
14
+ const PROMPT_DIRS = new Set(['commands', 'agents', 'rules', 'prompts']);
15
+ const MARKDOWN_EXTS = new Set(['.md', '.mdx', '.markdown', '.mdc']);
16
+ const SCRIPT_EXTS = new Set([
17
+ '.sh', '.bash', '.zsh', '.fish', '.ps1', '.psm1', '.bat', '.cmd', '.py', '.js', '.mjs', '.cjs',
18
+ '.ts', '.mts', '.cts', '.jsx', '.tsx', '.rb', '.pl', '.php', '.lua',
19
+ ]);
20
+ const AGENT_MARKDOWN = new Set(['claude.md', 'claude.local.md', 'agents.md', 'gemini.md']);
21
+ const DOC_STEMS = new Set(['readme', 'changelog', 'contributing', 'license', 'security', 'code_of_conduct']);
22
+ const RULE_FILES = new Set(['.cursorrules', '.windsurfrules']);
23
+ const MCP_CONFIG_NAMES = new Set(['.mcp.json', 'mcp.json', 'claude_desktop_config.json', 'mcp_config.json', '.claude.json']);
24
+ const CLAUDE_USER = 'Claude Code (user)';
25
+ const USER_LOCATIONS = [
26
+ { segments: ['.claude', 'skills'], label: CLAUDE_USER, skills: true },
27
+ { segments: ['.claude', 'plugins'], label: CLAUDE_USER },
28
+ { segments: ['.claude', 'commands'], label: CLAUDE_USER },
29
+ { segments: ['.claude', 'agents'], label: CLAUDE_USER },
30
+ { segments: ['.claude', 'hooks'], label: CLAUDE_USER },
31
+ { segments: ['.claude', 'CLAUDE.md'], label: CLAUDE_USER },
32
+ { segments: ['.claude', 'settings.json'], label: CLAUDE_USER },
33
+ { segments: ['.claude', 'settings.local.json'], label: CLAUDE_USER },
34
+ { segments: ['.codex', 'skills'], label: 'Codex', skills: true },
35
+ { segments: ['.agents', 'skills'], label: 'Agent skills (user)', skills: true },
36
+ { segments: ['.gemini', 'extensions'], label: 'Gemini CLI' },
37
+ ];
38
+ const PROJECT_LOCATIONS = [
39
+ { segments: ['.claude', 'settings.json'] },
40
+ { segments: ['.claude', 'settings.local.json'] },
41
+ { segments: ['.claude', 'skills'], skills: true },
42
+ { segments: ['.claude', 'commands'] },
43
+ { segments: ['.claude', 'agents'] },
44
+ { segments: ['.claude', 'hooks'] },
45
+ { segments: ['.claude', 'CLAUDE.md'] },
46
+ { segments: ['CLAUDE.md'] },
47
+ { segments: ['CLAUDE.local.md'] },
48
+ { segments: ['AGENTS.md'] },
49
+ { segments: ['GEMINI.md'] },
50
+ { segments: ['.agents', 'skills'], skills: true },
51
+ ];
52
+ /**
53
+ * Finds the files to scan: the explicit paths, plus the known agent locations
54
+ * when useDefaults is set. Walks iteratively, never follows directory symlinks
55
+ * found during the walk (except symlinked skill folders directly inside a known
56
+ * skills directory), skips vendored and cache directories unless deep is set,
57
+ * and reports large, binary and unreadable files as skipped.
58
+ */
59
+ export async function discover(options) {
60
+ const cwd = path.resolve(options.cwd);
61
+ const state = {
62
+ cwd,
63
+ home: homeDir(),
64
+ deep: options.deep ?? false,
65
+ maxFileBytes: options.maxFileBytes ?? DEFAULT_MAX_FILE_BYTES,
66
+ maxFiles: options.maxFiles ?? DEFAULT_MAX_FILES,
67
+ ignore: options.ignore,
68
+ configs: new Map(CLIENT_CONFIGS.map((location) => [pathKey(location.resolve(cwd)), location])),
69
+ candidates: [],
70
+ skipped: [],
71
+ roots: [],
72
+ seenFiles: new Set(),
73
+ seenDirs: new Set(),
74
+ capped: false,
75
+ };
76
+ for (const raw of options.paths) {
77
+ const abs = path.resolve(cwd, expandHome(raw));
78
+ collectRoot({ abs, label: rootLabel(abs), skills: false, explicit: true }, state);
79
+ }
80
+ if (options.useDefaults) {
81
+ for (const root of defaultRoots(cwd, state.home))
82
+ collectRoot(root, state);
83
+ }
84
+ const loaded = await mapPool(state.candidates, READ_CONCURRENCY, (candidate) => load(candidate, state));
85
+ const files = [];
86
+ const skipped = [...state.skipped];
87
+ for (const item of loaded) {
88
+ if (!item)
89
+ continue;
90
+ if ('reason' in item)
91
+ skipped.push(item);
92
+ else
93
+ files.push(item);
94
+ }
95
+ return { files, skipped, roots: state.roots };
96
+ }
97
+ function defaultRoots(cwd, home) {
98
+ const project = path.basename(cwd) || 'project';
99
+ const roots = [];
100
+ for (const location of USER_LOCATIONS) {
101
+ roots.push({ abs: path.join(home, ...location.segments), label: location.label ?? CLAUDE_USER, skills: location.skills ?? false, explicit: false });
102
+ }
103
+ for (const location of CLIENT_CONFIGS) {
104
+ const label = location.scope === 'user' ? location.label : project;
105
+ roots.push({ abs: location.resolve(cwd), label, skills: false, explicit: false });
106
+ }
107
+ for (const location of PROJECT_LOCATIONS) {
108
+ roots.push({ abs: path.join(cwd, ...location.segments), label: project, skills: location.skills ?? false, explicit: false });
109
+ }
110
+ return roots;
111
+ }
112
+ function rootLabel(abs) {
113
+ return path.basename(abs) || toForwardSlashes(abs);
114
+ }
115
+ function collectRoot(root, state) {
116
+ if (state.capped)
117
+ return;
118
+ let stat;
119
+ try {
120
+ stat = fs.statSync(root.abs);
121
+ }
122
+ catch {
123
+ if (root.explicit)
124
+ state.skipped.push({ path: displayPathFor(root.abs, state), reason: 'not found' });
125
+ return;
126
+ }
127
+ const display = displayPathFor(root.abs, state);
128
+ if (stat.isDirectory()) {
129
+ if (state.seenDirs.has(pathKey(root.abs)) || isDirIgnored(root.abs, state))
130
+ return;
131
+ state.roots.push(display);
132
+ const ctx = contextAbove(root.abs, 0);
133
+ walk(root, { ...ctx, inPromptDir: ctx.inPromptDir || PROMPT_DIRS.has(path.basename(root.abs).toLowerCase()) }, state);
134
+ }
135
+ else if (stat.isFile()) {
136
+ if (state.seenFiles.has(pathKey(root.abs)) || (state.ignore && isPathIgnored(display, state.ignore)))
137
+ return;
138
+ state.roots.push(display);
139
+ const fileRoot = root.explicit ? { ...root, label: rootLabel(path.dirname(root.abs)) } : root;
140
+ addCandidate(root.abs, contextAbove(path.dirname(root.abs), 3, true), fileRoot, state);
141
+ }
142
+ else if (root.explicit) {
143
+ state.skipped.push({ path: display, reason: 'not a regular file or directory' });
144
+ }
145
+ }
146
+ function walk(root, base, state) {
147
+ const stack = [{ dir: root.abs, ctx: base, depth: 0 }];
148
+ while (stack.length > 0 && !state.capped) {
149
+ const { dir, ctx: inherited, depth } = stack.pop();
150
+ const key = pathKey(dir);
151
+ if (state.seenDirs.has(key))
152
+ continue;
153
+ state.seenDirs.add(key);
154
+ let entries;
155
+ try {
156
+ entries = fs.readdirSync(dir, { withFileTypes: true });
157
+ }
158
+ catch (error) {
159
+ state.skipped.push({ path: displayPathFor(dir, state), reason: `unreadable directory (${errorCode(error)})` });
160
+ continue;
161
+ }
162
+ entries.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
163
+ const ctx = enterDir(dir, entries, inherited);
164
+ const subdirs = [];
165
+ for (const entry of entries) {
166
+ if (state.capped)
167
+ break;
168
+ const abs = path.join(dir, entry.name);
169
+ if (entry.isDirectory()) {
170
+ if (!skipDirName(entry.name, state.deep) && !isDirIgnored(abs, state)) {
171
+ subdirs.push({ dir: abs, ctx: childContext(ctx, entry.name), depth: depth + 1 });
172
+ }
173
+ }
174
+ else if (entry.isSymbolicLink()) {
175
+ visitLink(abs, entry.name, ctx, root, depth, subdirs, state);
176
+ }
177
+ else if (entry.isFile()) {
178
+ addCandidate(abs, ctx, root, state);
179
+ }
180
+ }
181
+ for (let i = subdirs.length - 1; i >= 0; i--)
182
+ stack.push(subdirs[i]);
183
+ }
184
+ }
185
+ function visitLink(abs, name, ctx, root, depth, subdirs, state) {
186
+ let stat;
187
+ try {
188
+ stat = fs.statSync(abs);
189
+ }
190
+ catch {
191
+ if (kindFromName(abs, ctx) !== null)
192
+ state.skipped.push({ path: displayPathFor(abs, state), reason: 'broken symbolic link' });
193
+ return;
194
+ }
195
+ if (stat.isFile()) {
196
+ addCandidate(abs, ctx, root, state);
197
+ return;
198
+ }
199
+ if (!stat.isDirectory() || skipDirName(name, state.deep) || isDirIgnored(abs, state))
200
+ return;
201
+ if (root.skills && depth === 0) {
202
+ subdirs.push({ dir: abs, ctx: childContext(ctx, name), depth: depth + 1 });
203
+ return;
204
+ }
205
+ state.skipped.push({ path: displayPathFor(abs, state), reason: 'symbolic link to a directory, not followed (scan its target directly)' });
206
+ }
207
+ function skipDirName(name, deep) {
208
+ return deep ? name === '.git' : SKIP_DIRS.has(name);
209
+ }
210
+ function isDirIgnored(abs, state) {
211
+ if (!state.ignore || state.ignore.paths.length === 0)
212
+ return false;
213
+ const display = displayPathFor(abs, state);
214
+ return isPathIgnored(display, state.ignore) || isPathIgnored(`${display}/`, state.ignore);
215
+ }
216
+ /** Context for the files of a directory, given its entries: does it hold SKILL.md or a plugin manifest? */
217
+ function enterDir(dir, entries, inherited) {
218
+ let ctx = inherited;
219
+ if (entries.some((e) => e.name.toLowerCase() === 'skill.md' && !e.isDirectory()))
220
+ ctx = { ...ctx, skill: path.basename(dir) };
221
+ if (entries.some((e) => e.name === '.claude-plugin' && e.isDirectory())) {
222
+ const plugin = pluginName(dir);
223
+ if (plugin !== null)
224
+ ctx = { ...ctx, plugin };
225
+ }
226
+ return ctx;
227
+ }
228
+ function childContext(ctx, name) {
229
+ if (ctx.inPromptDir || !PROMPT_DIRS.has(name.toLowerCase()))
230
+ return ctx;
231
+ return { ...ctx, inPromptDir: true };
232
+ }
233
+ /** The plugin name from dir/.claude-plugin/plugin.json, the directory name when it has none, or null when there is no manifest. */
234
+ function pluginName(dir) {
235
+ const manifest = path.join(dir, '.claude-plugin', 'plugin.json');
236
+ let text;
237
+ try {
238
+ if (fs.statSync(manifest).size > MIB)
239
+ return path.basename(dir);
240
+ text = fs.readFileSync(manifest, 'utf8');
241
+ }
242
+ catch {
243
+ return null;
244
+ }
245
+ const data = parseJsonc(text);
246
+ const name = isRecord(data) && typeof data.name === 'string' ? truncate(printable(data.name.trim()), 80) : '';
247
+ return name || path.basename(dir);
248
+ }
249
+ /**
250
+ * Skill, plugin and prompt-directory context from the directories above a
251
+ * scan root (dir itself included when includeSelf is set), so that scanning
252
+ * one file or subfolder of a skill still attributes it to the skill. Looks at
253
+ * most six levels up and never at or above the home directory.
254
+ */
255
+ function contextAbove(dir, promptLevels, includeSelf = false) {
256
+ const ctx = { inPromptDir: false };
257
+ const home = pathKey(homeDir());
258
+ let current = path.resolve(includeSelf ? dir : path.dirname(dir));
259
+ if (!includeSelf && current === path.resolve(dir))
260
+ return ctx;
261
+ for (let level = 0; level < 6; level++) {
262
+ if (pathKey(current) === home)
263
+ break;
264
+ const name = path.basename(current).toLowerCase();
265
+ if (level < promptLevels && PROMPT_DIRS.has(name))
266
+ ctx.inPromptDir = true;
267
+ if (ctx.skill === undefined && hasSkillFile(current))
268
+ ctx.skill = path.basename(current);
269
+ if (ctx.plugin === undefined) {
270
+ const plugin = pluginName(current);
271
+ if (plugin !== null)
272
+ ctx.plugin = plugin;
273
+ }
274
+ const parent = path.dirname(current);
275
+ if (parent === current)
276
+ break;
277
+ current = parent;
278
+ }
279
+ return ctx;
280
+ }
281
+ function hasSkillFile(dir) {
282
+ return fs.existsSync(path.join(dir, 'SKILL.md')) || fs.existsSync(path.join(dir, 'skill.md'));
283
+ }
284
+ function addCandidate(abs, ctx, root, state) {
285
+ const key = pathKey(abs);
286
+ if (state.seenFiles.has(key))
287
+ return;
288
+ const nameKind = kindFromName(abs, ctx);
289
+ const explicitFile = root.explicit && pathKey(root.abs) === key;
290
+ if (nameKind === null && !explicitFile)
291
+ return;
292
+ const displayPath = displayPathFor(abs, state);
293
+ if (state.ignore && isPathIgnored(displayPath, state.ignore))
294
+ return;
295
+ state.seenFiles.add(key);
296
+ if (state.candidates.length >= state.maxFiles) {
297
+ state.capped = true;
298
+ state.skipped.push({
299
+ path: displayPathFor(root.abs, state),
300
+ reason: `stopped after ${state.maxFiles} files (file limit); files after this point were not scanned`,
301
+ });
302
+ return;
303
+ }
304
+ state.candidates.push({ abs, displayPath, ctx, root, nameKind, explicitFile });
305
+ }
306
+ async function load(candidate, state) {
307
+ const { abs, displayPath } = candidate;
308
+ const skip = (reason) => ({ path: displayPath, reason });
309
+ const known = state.configs.get(pathKey(abs));
310
+ const limit = Math.max(state.maxFileBytes, known?.maxBytes ?? 0);
311
+ const byContent = candidate.nameKind === 'content' && !candidate.explicitFile;
312
+ const extensionless = path.extname(abs) === '';
313
+ let handle;
314
+ try {
315
+ handle = await fs.promises.open(abs, 'r');
316
+ }
317
+ catch (error) {
318
+ return skip(`unreadable (${errorCode(error)})`);
319
+ }
320
+ let buffer;
321
+ try {
322
+ const { size } = await handle.stat();
323
+ // Extensionless files are only scripts with a shebang; most are licences, Makefiles or binaries.
324
+ if (byContent && extensionless && (await readHead(handle, 2)) !== '#!')
325
+ return null;
326
+ if (size > limit) {
327
+ // Oversized generic JSON is data, not something an agent loads by that name.
328
+ if (byContent && !extensionless)
329
+ return null;
330
+ return skip(`larger than ${formatBytes(limit)}`);
331
+ }
332
+ buffer = await handle.readFile();
333
+ }
334
+ catch (error) {
335
+ return skip(`unreadable (${errorCode(error)})`);
336
+ }
337
+ finally {
338
+ await handle.close().catch(() => undefined);
339
+ }
340
+ const content = decodeText(buffer);
341
+ if (content === null)
342
+ return skip('binary file');
343
+ let kind = candidate.nameKind === 'content' ? kindFromContent(abs, content) : candidate.nameKind;
344
+ if (kind === null) {
345
+ if (!candidate.explicitFile)
346
+ return null;
347
+ kind = 'instructions';
348
+ }
349
+ const rootRelPath = path.relative(candidate.root.abs, abs).split(path.sep).join('/') || path.basename(abs);
350
+ return { path: abs, displayPath, kind, content, target: targetFor(candidate, known), rootRelPath };
351
+ }
352
+ function targetFor(candidate, known) {
353
+ if (known)
354
+ return known.label;
355
+ if (candidate.ctx.skill !== undefined)
356
+ return `skill:${candidate.ctx.skill}`;
357
+ if (candidate.ctx.plugin !== undefined)
358
+ return `plugin:${candidate.ctx.plugin}`;
359
+ return candidate.root.label;
360
+ }
361
+ /** The first bytes of an open file, read by position so a later readFile still starts at offset 0. */
362
+ async function readHead(handle, bytes) {
363
+ const buffer = Buffer.alloc(bytes);
364
+ const { bytesRead } = await handle.read(buffer, 0, bytes, 0);
365
+ return buffer.subarray(0, bytesRead).toString('latin1');
366
+ }
367
+ /**
368
+ * Decodes UTF-8, or UTF-16 when the file starts with a byte-order mark (as
369
+ * Windows PowerShell writes scripts), and drops a leading BOM. Returns null for
370
+ * binary content: a NUL byte in the first 8 KiB of a file without a UTF-16 BOM.
371
+ */
372
+ function decodeText(buffer) {
373
+ if (buffer.length >= 2 && buffer[0] === 0xff && buffer[1] === 0xfe) {
374
+ return new TextDecoder('utf-16le').decode(buffer.subarray(2));
375
+ }
376
+ if (buffer.length >= 2 && buffer[0] === 0xfe && buffer[1] === 0xff) {
377
+ const body = Buffer.from(buffer.subarray(2, 2 + ((buffer.length - 2) & ~1)));
378
+ return new TextDecoder('utf-16le').decode(body.swap16());
379
+ }
380
+ if (buffer.subarray(0, BINARY_SNIFF_BYTES).includes(0))
381
+ return null;
382
+ const text = buffer.toString('utf8');
383
+ return text.charCodeAt(0) === 0xfeff ? text.slice(1) : text;
384
+ }
385
+ function formatBytes(bytes) {
386
+ if (bytes >= MIB && bytes % MIB === 0)
387
+ return `${bytes / MIB} MiB`;
388
+ if (bytes >= MIB)
389
+ return `${(bytes / MIB).toFixed(1)} MiB`;
390
+ if (bytes >= 1024)
391
+ return `${Math.round(bytes / 1024)} KiB`;
392
+ return `${bytes} bytes`;
393
+ }
394
+ function errorCode(error) {
395
+ return error.code ?? (error instanceof Error ? error.message : String(error));
396
+ }
397
+ function toForwardSlashes(p) {
398
+ return p.split(path.sep).join('/');
399
+ }
400
+ /** Relative path with forward slashes when abs is inside base ('' for base itself), otherwise null. */
401
+ function relativeInside(base, abs) {
402
+ const rel = path.relative(base, abs);
403
+ if (rel === '')
404
+ return '';
405
+ if (rel === '..' || rel.startsWith(`..${path.sep}`) || path.isAbsolute(rel))
406
+ return null;
407
+ return toForwardSlashes(rel);
408
+ }
409
+ /** Relative to cwd when inside it, '~/...' when inside the home directory, otherwise absolute; always forward slashes. */
410
+ function displayPathFor(abs, state) {
411
+ const inCwd = relativeInside(state.cwd, abs);
412
+ if (inCwd !== null)
413
+ return inCwd || '.';
414
+ const inHome = relativeInside(state.home, abs);
415
+ if (inHome !== null)
416
+ return inHome ? `~/${inHome}` : '~';
417
+ return toForwardSlashes(abs);
418
+ }
419
+ /**
420
+ * What a file is to an agent, from its path and (for generic JSON and
421
+ * extensionless files) its content. Returns null for files toolmoat does not
422
+ * scan. Skill and plugin membership is looked up on disk in the directories
423
+ * above the file; content is read from disk when it is needed and not given.
424
+ */
425
+ export function classifyFile(absPath, content) {
426
+ const ctx = contextAbove(path.dirname(absPath), 3, true);
427
+ const kind = kindFromName(absPath, ctx);
428
+ if (kind !== 'content')
429
+ return kind;
430
+ const text = content ?? readSmallText(absPath);
431
+ return text === null ? null : kindFromContent(absPath, text);
432
+ }
433
+ function readSmallText(file) {
434
+ try {
435
+ if (fs.statSync(file).size > DEFAULT_MAX_FILE_BYTES)
436
+ return null;
437
+ return decodeText(fs.readFileSync(file));
438
+ }
439
+ catch {
440
+ return null;
441
+ }
442
+ }
443
+ function kindFromName(absPath, ctx) {
444
+ const lower = path.basename(absPath).toLowerCase();
445
+ const ext = path.extname(lower);
446
+ const parent = path.basename(path.dirname(absPath)).toLowerCase();
447
+ if (lower === 'skill.md')
448
+ return 'skill';
449
+ if (MARKDOWN_EXTS.has(ext))
450
+ return markdownKind(lower, ctx);
451
+ if (RULE_FILES.has(lower))
452
+ return 'instructions';
453
+ if (SCRIPT_EXTS.has(ext))
454
+ return 'script';
455
+ if (ext === '.json')
456
+ return jsonKind(lower, parent);
457
+ if (ext === '.toml') {
458
+ if (lower === 'pyproject.toml')
459
+ return 'package-manifest';
460
+ return lower === 'config.toml' && parent === '.codex' ? 'mcp-config' : null;
461
+ }
462
+ if (lower === 'requirements.txt')
463
+ return 'package-manifest';
464
+ if (ext === '')
465
+ return 'content';
466
+ return null;
467
+ }
468
+ function markdownKind(lower, ctx) {
469
+ if (AGENT_MARKDOWN.has(lower) || ctx.inPromptDir)
470
+ return 'instructions';
471
+ const stem = lower.slice(0, lower.indexOf('.') === -1 ? lower.length : lower.indexOf('.'));
472
+ if (DOC_STEMS.has(stem))
473
+ return 'docs';
474
+ return ctx.skill !== undefined || ctx.plugin !== undefined ? 'instructions' : 'docs';
475
+ }
476
+ function jsonKind(lower, parent) {
477
+ if (lower === 'package.json')
478
+ return 'package-manifest';
479
+ if (parent === '.claude-plugin' && (lower === 'plugin.json' || lower === 'marketplace.json'))
480
+ return 'plugin-manifest';
481
+ if (parent === '.claude' && (lower === 'settings.json' || lower === 'settings.local.json'))
482
+ return 'agent-settings';
483
+ if (lower === 'hooks.json')
484
+ return 'agent-settings';
485
+ if (MCP_CONFIG_NAMES.has(lower))
486
+ return 'mcp-config';
487
+ if (parent === '.gemini' && lower === 'settings.json')
488
+ return 'mcp-config';
489
+ return 'content';
490
+ }
491
+ /**
492
+ * Generic JSON is an MCP config when it has top-level mcpServers, and agent
493
+ * settings when it has Claude-style hooks (event names such as PreToolUse) or
494
+ * a permissions block. Extensionless files are scripts when they start with #!.
495
+ */
496
+ function kindFromContent(absPath, content) {
497
+ const ext = path.extname(absPath).toLowerCase();
498
+ if (ext === '')
499
+ return content.startsWith('#!') ? 'script' : null;
500
+ if (ext !== '.json')
501
+ return null;
502
+ const data = parseJsonc(content);
503
+ if (!isRecord(data))
504
+ return null;
505
+ if (data.mcpServers !== undefined)
506
+ return 'mcp-config';
507
+ if (isRecord(data.hooks) && Object.keys(data.hooks).some((key) => /^[A-Z][A-Za-z]{2,40}$/.test(key)))
508
+ return 'agent-settings';
509
+ if (isRecord(data.permissions) && ['allow', 'deny', 'ask', 'defaultMode'].some((key) => key in data.permissions)) {
510
+ return 'agent-settings';
511
+ }
512
+ return null;
513
+ }
514
+ async function mapPool(items, limit, fn) {
515
+ const results = new Array(items.length);
516
+ let next = 0;
517
+ const worker = async () => {
518
+ while (next < items.length) {
519
+ const index = next++;
520
+ results[index] = await fn(items[index]);
521
+ }
522
+ };
523
+ await Promise.all(Array.from({ length: Math.min(limit, items.length) }, worker));
524
+ return results;
525
+ }
@@ -0,0 +1,24 @@
1
+ import type { Finding, Rule, RuleMatch, ScanFile, Severity } from '../types.js';
2
+ import type { IgnoreConfig } from './ignore.js';
3
+ export interface EngineOptions {
4
+ rules?: readonly Rule[];
5
+ ignore?: IgnoreConfig;
6
+ /** Drop findings below this severity. */
7
+ minSeverity?: Severity;
8
+ /** Called when a rule throws. The scan carries on without that rule's output for the file. */
9
+ onRuleError?: (rule: Rule, file: ScanFile, error: unknown) => void;
10
+ }
11
+ /**
12
+ * Test code in plugins and MCP servers is full of deliberately hostile strings
13
+ * and is not what the agent runs, so its findings drop one level (critical
14
+ * still shows as high). Judged on the path inside the scanned root, so scanning
15
+ * a directory that itself lives under test/ is not affected.
16
+ */
17
+ export declare function isTestPath(relPath: string): boolean;
18
+ export declare function scanFile(file: ScanFile, options?: EngineOptions): Finding[];
19
+ export declare function scanFiles(files: readonly ScanFile[], options?: EngineOptions): Finding[];
20
+ export declare function toFinding(rule: Rule, file: ScanFile, match: RuleMatch): Finding;
21
+ /** Redacts secrets, escapes invisible characters and trims long lines to a window around the column. */
22
+ export declare function makeSnippet(raw: string | undefined, column?: number): string | undefined;
23
+ export declare function sortFindings(findings: Finding[]): Finding[];
24
+ export declare function countBySeverity(findings: readonly Finding[]): Record<Severity, number>;