@chimix/agentscan 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +475 -0
- package/package.json +56 -0
- package/src/analyze.ts +82 -0
- package/src/checks/budgets.ts +189 -0
- package/src/checks/index.ts +738 -0
- package/src/checks/make.ts +37 -0
- package/src/cli.ts +230 -0
- package/src/commands/check.ts +64 -0
- package/src/commands/explain.ts +64 -0
- package/src/commands/init.ts +42 -0
- package/src/commands/rules.ts +36 -0
- package/src/config/load.ts +26 -0
- package/src/config/schema.ts +63 -0
- package/src/discover/index.ts +888 -0
- package/src/facts/extract.ts +273 -0
- package/src/facts/semver.ts +41 -0
- package/src/facts/types.ts +113 -0
- package/src/report/clipboard.ts +90 -0
- package/src/report/exit-code.ts +36 -0
- package/src/report/json.ts +36 -0
- package/src/report/prompt.ts +102 -0
- package/src/report/safe.ts +0 -0
- package/src/report/score.ts +47 -0
- package/src/report/sort.ts +26 -0
- package/src/report/text.ts +142 -0
- package/src/version.ts +1 -0
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import type { Finding } from "../facts/types";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Build a finding.
|
|
5
|
+
*
|
|
6
|
+
* `id` is `${ruleId}:${subject}` — a public contract: it is what the report
|
|
7
|
+
* prints, what `agentscan explain` resolves, and what `ignoreFindings` matches.
|
|
8
|
+
* Both structural checks and budget checks go through here so there is one
|
|
9
|
+
* place that decides the shape.
|
|
10
|
+
*/
|
|
11
|
+
export function make(
|
|
12
|
+
ruleId: string,
|
|
13
|
+
subject: string,
|
|
14
|
+
args: {
|
|
15
|
+
action: Finding["action"];
|
|
16
|
+
severity: Finding["severity"];
|
|
17
|
+
message: string;
|
|
18
|
+
reason: string;
|
|
19
|
+
evidence: Finding["evidence"];
|
|
20
|
+
suggest?: string;
|
|
21
|
+
},
|
|
22
|
+
): Finding {
|
|
23
|
+
const finding: Finding = {
|
|
24
|
+
id: `${ruleId}:${subject}`,
|
|
25
|
+
ruleId,
|
|
26
|
+
action: args.action,
|
|
27
|
+
severity: args.severity,
|
|
28
|
+
subject,
|
|
29
|
+
message: args.message,
|
|
30
|
+
reason: args.reason,
|
|
31
|
+
evidence: args.evidence,
|
|
32
|
+
};
|
|
33
|
+
if (args.suggest !== undefined) {
|
|
34
|
+
finding.suggest = args.suggest;
|
|
35
|
+
}
|
|
36
|
+
return finding;
|
|
37
|
+
}
|
package/src/cli.ts
ADDED
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
import { parseArgs } from "node:util";
|
|
3
|
+
import { type OutputFormat, runCheck } from "./commands/check";
|
|
4
|
+
import { runExplain } from "./commands/explain";
|
|
5
|
+
import { runInit } from "./commands/init";
|
|
6
|
+
import { runRulesCommand } from "./commands/rules";
|
|
7
|
+
import { copyToClipboard } from "./report/clipboard";
|
|
8
|
+
import { safe } from "./report/safe";
|
|
9
|
+
import type { FailOn } from "./report/exit-code";
|
|
10
|
+
import { VERSION } from "./version";
|
|
11
|
+
|
|
12
|
+
function printHelp(): void {
|
|
13
|
+
const text = `agentscan v${VERSION}
|
|
14
|
+
|
|
15
|
+
Usage:
|
|
16
|
+
agentscan check [dir] [options]
|
|
17
|
+
agentscan explain <findingId> [dir]
|
|
18
|
+
agentscan rules [dir]
|
|
19
|
+
agentscan init [dir] [--force]
|
|
20
|
+
agentscan --version
|
|
21
|
+
agentscan --help
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
check options:
|
|
25
|
+
--json JSON report (alias for --output json)
|
|
26
|
+
--output <format> human (default) | json | prompt
|
|
27
|
+
prompt: a paste-ready handoff for a fixing agent
|
|
28
|
+
--copy Also copy the report to the system clipboard
|
|
29
|
+
--quiet Summary only
|
|
30
|
+
--verbose Show KEEP findings
|
|
31
|
+
--fail-on <level> never | warning | error (default: never)
|
|
32
|
+
--fail-under <0-100> Fail when the score drops below this floor
|
|
33
|
+
--global Also scan global skill dirs
|
|
34
|
+
--config <path> Config file path
|
|
35
|
+
|
|
36
|
+
init options:
|
|
37
|
+
--force Overwrite existing .agentscanrc.json
|
|
38
|
+
`;
|
|
39
|
+
process.stdout.write(`${text}\n`);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function isOutput(value: string): value is OutputFormat {
|
|
43
|
+
return value === "human" || value === "json" || value === "prompt";
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function isFailOn(value: string): value is FailOn {
|
|
47
|
+
return value === "never" || value === "warning" || value === "error";
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export async function main(argv: string[]): Promise<number> {
|
|
51
|
+
let values: {
|
|
52
|
+
json?: boolean;
|
|
53
|
+
output?: string;
|
|
54
|
+
copy?: boolean;
|
|
55
|
+
quiet?: boolean;
|
|
56
|
+
verbose?: boolean;
|
|
57
|
+
"fail-on"?: string;
|
|
58
|
+
"fail-under"?: string;
|
|
59
|
+
global?: boolean;
|
|
60
|
+
config?: string;
|
|
61
|
+
force?: boolean;
|
|
62
|
+
help?: boolean;
|
|
63
|
+
version?: boolean;
|
|
64
|
+
};
|
|
65
|
+
let positionals: string[];
|
|
66
|
+
|
|
67
|
+
try {
|
|
68
|
+
const parsed = parseArgs({
|
|
69
|
+
args: argv,
|
|
70
|
+
options: {
|
|
71
|
+
json: { type: "boolean", default: false },
|
|
72
|
+
output: { type: "string" },
|
|
73
|
+
copy: { type: "boolean", default: false },
|
|
74
|
+
quiet: { type: "boolean", default: false },
|
|
75
|
+
verbose: { type: "boolean", default: false },
|
|
76
|
+
"fail-on": { type: "string" },
|
|
77
|
+
"fail-under": { type: "string" },
|
|
78
|
+
// No default: absent must be `undefined` so `?? config.includeGlobal`
|
|
79
|
+
// in analyze() can fall through. With `default: false` the flag always
|
|
80
|
+
// overwrote the config key, making `includeGlobal` unreachable.
|
|
81
|
+
global: { type: "boolean" },
|
|
82
|
+
config: { type: "string" },
|
|
83
|
+
force: { type: "boolean", default: false },
|
|
84
|
+
help: { type: "boolean", short: "h", default: false },
|
|
85
|
+
version: { type: "boolean", short: "V", default: false },
|
|
86
|
+
},
|
|
87
|
+
allowPositionals: true,
|
|
88
|
+
strict: true,
|
|
89
|
+
});
|
|
90
|
+
values = parsed.values;
|
|
91
|
+
positionals = parsed.positionals;
|
|
92
|
+
} catch (err) {
|
|
93
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
94
|
+
process.stderr.write(`${safe(message)}\n`);
|
|
95
|
+
return 2;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
if (values.version) {
|
|
99
|
+
process.stdout.write(`${VERSION}\n`);
|
|
100
|
+
return 0;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
if (values.help || positionals.length === 0) {
|
|
104
|
+
printHelp();
|
|
105
|
+
return 0;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const command = positionals[0];
|
|
109
|
+
if (command === undefined) {
|
|
110
|
+
printHelp();
|
|
111
|
+
return 0;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
try {
|
|
115
|
+
switch (command) {
|
|
116
|
+
case "check": {
|
|
117
|
+
const dir = positionals[1];
|
|
118
|
+
const failOnRaw = values["fail-on"];
|
|
119
|
+
if (failOnRaw !== undefined && !isFailOn(failOnRaw)) {
|
|
120
|
+
process.stderr.write(
|
|
121
|
+
`Invalid --fail-on value: ${safe(failOnRaw)} (use never|warning|error)\n`,
|
|
122
|
+
);
|
|
123
|
+
return 2;
|
|
124
|
+
}
|
|
125
|
+
const failUnderRaw = values["fail-under"];
|
|
126
|
+
let failUnder: number | undefined;
|
|
127
|
+
if (failUnderRaw !== undefined) {
|
|
128
|
+
failUnder = Number(failUnderRaw);
|
|
129
|
+
if (!Number.isInteger(failUnder) || failUnder < 0 || failUnder > 100) {
|
|
130
|
+
process.stderr.write(
|
|
131
|
+
`Invalid --fail-under value: ${safe(failUnderRaw)} (use 0-100)\n`,
|
|
132
|
+
);
|
|
133
|
+
return 2;
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
const outputRaw = values.output;
|
|
137
|
+
if (outputRaw !== undefined && !isOutput(outputRaw)) {
|
|
138
|
+
process.stderr.write(
|
|
139
|
+
`Invalid --output value: ${safe(outputRaw)} (use human|json|prompt)\n`,
|
|
140
|
+
);
|
|
141
|
+
return 2;
|
|
142
|
+
}
|
|
143
|
+
const result = await runCheck({
|
|
144
|
+
dir,
|
|
145
|
+
json: values.json,
|
|
146
|
+
...(outputRaw === undefined ? {} : { output: outputRaw }),
|
|
147
|
+
quiet: values.quiet,
|
|
148
|
+
verbose: values.verbose,
|
|
149
|
+
failOn: failOnRaw,
|
|
150
|
+
...(failUnder === undefined ? {} : { failUnder }),
|
|
151
|
+
global: values.global,
|
|
152
|
+
configPath: values.config,
|
|
153
|
+
});
|
|
154
|
+
process.stdout.write(result.stdout);
|
|
155
|
+
if (values.copy === true) {
|
|
156
|
+
// stderr, so `--copy` stays composable with `> file` and `| tool`.
|
|
157
|
+
// A clipboard miss does not change the exit code: the scan is what
|
|
158
|
+
// was asked for, and its result is already on stdout.
|
|
159
|
+
const copied = await copyToClipboard(result.stdout);
|
|
160
|
+
process.stderr.write(
|
|
161
|
+
copied.ok
|
|
162
|
+
? `Copied to clipboard via ${copied.tool}.\n`
|
|
163
|
+
: `Could not copy: ${safe(copied.reason)}. Redirect instead: agentscan check > report.md\n`,
|
|
164
|
+
);
|
|
165
|
+
}
|
|
166
|
+
return result.exitCode;
|
|
167
|
+
}
|
|
168
|
+
case "explain": {
|
|
169
|
+
const findingId = positionals[1];
|
|
170
|
+
if (findingId === undefined || findingId.length === 0) {
|
|
171
|
+
process.stderr.write("Usage: agentscan explain <findingId> [dir]\n");
|
|
172
|
+
return 2;
|
|
173
|
+
}
|
|
174
|
+
const result = await runExplain(findingId, {
|
|
175
|
+
dir: positionals[2],
|
|
176
|
+
global: values.global,
|
|
177
|
+
configPath: values.config,
|
|
178
|
+
});
|
|
179
|
+
if (result.stderr.length > 0) {
|
|
180
|
+
process.stderr.write(result.stderr);
|
|
181
|
+
}
|
|
182
|
+
if (result.stdout.length > 0) {
|
|
183
|
+
process.stdout.write(result.stdout);
|
|
184
|
+
}
|
|
185
|
+
return result.exitCode;
|
|
186
|
+
}
|
|
187
|
+
case "rules": {
|
|
188
|
+
const result = await runRulesCommand({
|
|
189
|
+
dir: positionals[1],
|
|
190
|
+
configPath: values.config,
|
|
191
|
+
});
|
|
192
|
+
process.stdout.write(result.stdout);
|
|
193
|
+
return result.exitCode;
|
|
194
|
+
}
|
|
195
|
+
case "init": {
|
|
196
|
+
const result = await runInit(positionals[1], {
|
|
197
|
+
force: values.force,
|
|
198
|
+
});
|
|
199
|
+
if (result.stderr.length > 0) {
|
|
200
|
+
process.stderr.write(result.stderr);
|
|
201
|
+
}
|
|
202
|
+
if (result.stdout.length > 0) {
|
|
203
|
+
process.stdout.write(result.stdout);
|
|
204
|
+
}
|
|
205
|
+
return result.exitCode;
|
|
206
|
+
}
|
|
207
|
+
case "help":
|
|
208
|
+
printHelp();
|
|
209
|
+
return 0;
|
|
210
|
+
case "version":
|
|
211
|
+
process.stdout.write(`${VERSION}\n`);
|
|
212
|
+
return 0;
|
|
213
|
+
default:
|
|
214
|
+
process.stderr.write(`Unknown command: ${safe(command)}\n`);
|
|
215
|
+
printHelp();
|
|
216
|
+
return 2;
|
|
217
|
+
}
|
|
218
|
+
} catch (err) {
|
|
219
|
+
// A fatal message can quote a path from the scanned tree, and a POSIX
|
|
220
|
+
// filename may contain a newline or an escape sequence.
|
|
221
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
222
|
+
process.stderr.write(`${safe(message)}\n`);
|
|
223
|
+
return 2;
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
if (import.meta.main) {
|
|
228
|
+
const code = await main(process.argv.slice(2));
|
|
229
|
+
process.exit(code);
|
|
230
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { analyze } from "../analyze";
|
|
2
|
+
import { exitCode, type FailOn } from "../report/exit-code";
|
|
3
|
+
import { renderJson } from "../report/json";
|
|
4
|
+
import { renderPrompt } from "../report/prompt";
|
|
5
|
+
import { renderText } from "../report/text";
|
|
6
|
+
import { VERSION } from "../version";
|
|
7
|
+
|
|
8
|
+
export type OutputFormat = "human" | "json" | "prompt";
|
|
9
|
+
|
|
10
|
+
export type CheckOptions = {
|
|
11
|
+
dir?: string;
|
|
12
|
+
/** Alias for `output: "json"`, kept for the documented `--json` flag. */
|
|
13
|
+
json?: boolean;
|
|
14
|
+
output?: OutputFormat;
|
|
15
|
+
quiet?: boolean;
|
|
16
|
+
verbose?: boolean;
|
|
17
|
+
failOn?: FailOn;
|
|
18
|
+
/** Fail when the 0-100 score drops below this. */
|
|
19
|
+
failUnder?: number;
|
|
20
|
+
global?: boolean;
|
|
21
|
+
configPath?: string;
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
export type CheckResult = {
|
|
25
|
+
exitCode: number;
|
|
26
|
+
stdout: string;
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Run the full check pipeline (discover → facts → rules + checks → report).
|
|
31
|
+
* Returns exit code + stdout for testability; CLI prints and process.exit.
|
|
32
|
+
*/
|
|
33
|
+
export async function runCheck(options: CheckOptions): Promise<CheckResult> {
|
|
34
|
+
const { root, resolvedFrom, config, facts, findings } = analyze({
|
|
35
|
+
dir: options.dir,
|
|
36
|
+
global: options.global,
|
|
37
|
+
configPath: options.configPath,
|
|
38
|
+
failOn: options.failOn,
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
const format: OutputFormat =
|
|
42
|
+
options.output ?? (options.json === true ? "json" : "human");
|
|
43
|
+
|
|
44
|
+
let stdout: string;
|
|
45
|
+
if (format === "json") {
|
|
46
|
+
stdout = renderJson({ version: VERSION, root, facts, findings, resolvedFrom });
|
|
47
|
+
} else if (format === "prompt") {
|
|
48
|
+
stdout = renderPrompt({ version: VERSION, facts, findings });
|
|
49
|
+
} else {
|
|
50
|
+
stdout = renderText({
|
|
51
|
+
version: VERSION,
|
|
52
|
+
facts,
|
|
53
|
+
findings,
|
|
54
|
+
resolvedFrom,
|
|
55
|
+
verbose: options.verbose ?? false,
|
|
56
|
+
quiet: options.quiet ?? false,
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
return {
|
|
61
|
+
exitCode: exitCode(findings, config.failOn, options.failUnder),
|
|
62
|
+
stdout,
|
|
63
|
+
};
|
|
64
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { analyze } from "../analyze";
|
|
2
|
+
import type { Finding } from "../facts/types";
|
|
3
|
+
import { safe } from "../report/safe";
|
|
4
|
+
|
|
5
|
+
export type ExplainOptions = {
|
|
6
|
+
dir?: string;
|
|
7
|
+
global?: boolean;
|
|
8
|
+
configPath?: string;
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
export type ExplainResult = {
|
|
12
|
+
exitCode: number;
|
|
13
|
+
stdout: string;
|
|
14
|
+
stderr: string;
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Re-run the check pipeline and print details for a finding by stable id.
|
|
19
|
+
* Exit 1 if the finding is not found.
|
|
20
|
+
*/
|
|
21
|
+
export async function runExplain(
|
|
22
|
+
findingId: string,
|
|
23
|
+
options: ExplainOptions = {},
|
|
24
|
+
): Promise<ExplainResult> {
|
|
25
|
+
const { findings } = analyze(options);
|
|
26
|
+
const finding = findings.find((f) => f.id === findingId);
|
|
27
|
+
|
|
28
|
+
if (finding === undefined) {
|
|
29
|
+
return {
|
|
30
|
+
exitCode: 1,
|
|
31
|
+
stdout: "",
|
|
32
|
+
// The argument is echoed back, so it is untrusted like anything else.
|
|
33
|
+
stderr: `Finding not found: ${safe(findingId)}\n`,
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
return {
|
|
38
|
+
exitCode: 0,
|
|
39
|
+
stdout: formatExplain(finding),
|
|
40
|
+
stderr: "",
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function formatExplain(f: Finding): string {
|
|
45
|
+
const lines: string[] = [];
|
|
46
|
+
lines.push(`id: ${safe(f.id)}`);
|
|
47
|
+
lines.push(`action: ${f.action}`);
|
|
48
|
+
lines.push(`severity: ${f.severity}`);
|
|
49
|
+
lines.push(`subject: ${safe(f.subject)}`);
|
|
50
|
+
lines.push(`rule: ${safe(f.ruleId)}`);
|
|
51
|
+
lines.push(`message: ${safe(f.message)}`);
|
|
52
|
+
lines.push(`reason: ${safe(f.reason)}`);
|
|
53
|
+
if (f.evidence.length > 0) {
|
|
54
|
+
const ev = f.evidence
|
|
55
|
+
.map((e) => `${safe(e.kind)} ${safe(e.value)}`)
|
|
56
|
+
.join(" · ");
|
|
57
|
+
lines.push(`evidence: ${ev}`);
|
|
58
|
+
}
|
|
59
|
+
if (f.suggest !== undefined && f.suggest.length > 0) {
|
|
60
|
+
lines.push(`suggest: ${safe(f.suggest)}`);
|
|
61
|
+
}
|
|
62
|
+
lines.push("");
|
|
63
|
+
return lines.join("\n");
|
|
64
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { existsSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { join, resolve } from "node:path";
|
|
3
|
+
import { defaultConfig } from "../config/schema";
|
|
4
|
+
|
|
5
|
+
export type InitOptions = {
|
|
6
|
+
force?: boolean;
|
|
7
|
+
};
|
|
8
|
+
|
|
9
|
+
export type InitResult = {
|
|
10
|
+
exitCode: number;
|
|
11
|
+
stdout: string;
|
|
12
|
+
stderr: string;
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Write `.agentscanrc.json` with defaultConfig.
|
|
17
|
+
* Refuses to overwrite an existing file unless `force` is true.
|
|
18
|
+
*/
|
|
19
|
+
export async function runInit(
|
|
20
|
+
dir?: string,
|
|
21
|
+
options: InitOptions = {},
|
|
22
|
+
): Promise<InitResult> {
|
|
23
|
+
const root = resolve(dir ?? process.cwd());
|
|
24
|
+
const path = join(root, ".agentscanrc.json");
|
|
25
|
+
|
|
26
|
+
if (existsSync(path) && !options.force) {
|
|
27
|
+
return {
|
|
28
|
+
exitCode: 2,
|
|
29
|
+
stdout: "",
|
|
30
|
+
stderr: `Refusing to overwrite existing ${path} (use --force)\n`,
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const body = `${JSON.stringify(defaultConfig, null, 2)}\n`;
|
|
35
|
+
writeFileSync(path, body, "utf8");
|
|
36
|
+
|
|
37
|
+
return {
|
|
38
|
+
exitCode: 0,
|
|
39
|
+
stdout: `Wrote ${path}\n`,
|
|
40
|
+
stderr: "",
|
|
41
|
+
};
|
|
42
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { STRUCTURAL_CHECKS } from "../checks/index";
|
|
2
|
+
import { loadConfig } from "../config/load";
|
|
3
|
+
import { resolveRoot } from "../discover/index";
|
|
4
|
+
|
|
5
|
+
export type RulesCommandOptions = {
|
|
6
|
+
dir?: string;
|
|
7
|
+
configPath?: string;
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
export type RulesCommandResult = {
|
|
11
|
+
exitCode: number;
|
|
12
|
+
stdout: string;
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Print every id that can produce a finding, as `id description`, one per line.
|
|
17
|
+
*
|
|
18
|
+
* The ids are the ones `ignoreRules` takes, and each finding's id starts with
|
|
19
|
+
* one of them, so this is also the index for `ignoreFindings`.
|
|
20
|
+
*/
|
|
21
|
+
export async function runRulesCommand(
|
|
22
|
+
options: RulesCommandOptions = {},
|
|
23
|
+
): Promise<RulesCommandResult> {
|
|
24
|
+
const root = resolveRoot(options.dir ?? process.cwd());
|
|
25
|
+
const config = loadConfig(root, options.configPath);
|
|
26
|
+
|
|
27
|
+
const ignored = new Set(config.ignoreRules);
|
|
28
|
+
const lines = STRUCTURAL_CHECKS.filter((c) => !ignored.has(c.id)).map(
|
|
29
|
+
(c) => `${c.id} ${c.description}`,
|
|
30
|
+
);
|
|
31
|
+
|
|
32
|
+
return {
|
|
33
|
+
exitCode: 0,
|
|
34
|
+
stdout: lines.length > 0 ? `${lines.join("\n")}\n` : "",
|
|
35
|
+
};
|
|
36
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { configSchema, type AgentscanConfig } from "./schema";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Load agentscan config from `.agentscanrc.json` under `root`, or from `configPath`.
|
|
7
|
+
* Missing file → defaults via schema. Invalid JSON throws Error with path (exit 2).
|
|
8
|
+
*/
|
|
9
|
+
export function loadConfig(root: string, configPath?: string): AgentscanConfig {
|
|
10
|
+
const path = configPath ?? join(root, ".agentscanrc.json");
|
|
11
|
+
|
|
12
|
+
if (!existsSync(path)) {
|
|
13
|
+
return configSchema.parse({});
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
let raw: unknown;
|
|
17
|
+
try {
|
|
18
|
+
const text = readFileSync(path, "utf8");
|
|
19
|
+
raw = JSON.parse(text) as unknown;
|
|
20
|
+
} catch (err) {
|
|
21
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
22
|
+
throw new Error(`Invalid config JSON at ${path}: ${message}`);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
return configSchema.parse(raw);
|
|
26
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
|
|
3
|
+
export const defaultThresholds = {
|
|
4
|
+
/** Bytes of skill name+description loaded at startup. The constraint is a
|
|
5
|
+
* character budget (~1-2% of the context window) shared across all skills;
|
|
6
|
+
* past it, descriptions are truncated and matching keywords are lost. */
|
|
7
|
+
skillDescriptionBytes: 16_000,
|
|
8
|
+
skills: 30,
|
|
9
|
+
mcp: 5,
|
|
10
|
+
agentsMdLines: 150,
|
|
11
|
+
claudeMdLines: 200,
|
|
12
|
+
agents: 8,
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
export const defaultConfig = {
|
|
16
|
+
skillPaths: [".agents/skills", ".claude/skills", "skills", ".cursor/skills"],
|
|
17
|
+
mcpPaths: [".mcp.json", ".claude/mcp.json", "mcp.json"],
|
|
18
|
+
policyFiles: ["AGENTS.md", "CLAUDE.md"],
|
|
19
|
+
ignoreSkills: [] as string[],
|
|
20
|
+
ignoreRules: [] as string[],
|
|
21
|
+
ignoreFindings: [] as string[],
|
|
22
|
+
failOn: "never" as const,
|
|
23
|
+
includeGlobal: false,
|
|
24
|
+
requireLock: false,
|
|
25
|
+
thresholds: defaultThresholds,
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
const thresholdsSchema = z.object({
|
|
29
|
+
skillDescriptionBytes: z
|
|
30
|
+
.number()
|
|
31
|
+
.int()
|
|
32
|
+
.positive()
|
|
33
|
+
.default(defaultThresholds.skillDescriptionBytes),
|
|
34
|
+
skills: z.number().int().positive().default(defaultThresholds.skills),
|
|
35
|
+
mcp: z.number().int().positive().default(defaultThresholds.mcp),
|
|
36
|
+
agentsMdLines: z
|
|
37
|
+
.number()
|
|
38
|
+
.int()
|
|
39
|
+
.positive()
|
|
40
|
+
.default(defaultThresholds.agentsMdLines),
|
|
41
|
+
claudeMdLines: z
|
|
42
|
+
.number()
|
|
43
|
+
.int()
|
|
44
|
+
.positive()
|
|
45
|
+
.default(defaultThresholds.claudeMdLines),
|
|
46
|
+
agents: z.number().int().positive().default(defaultThresholds.agents),
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
export const configSchema = z.object({
|
|
50
|
+
skillPaths: z.array(z.string()).default(defaultConfig.skillPaths),
|
|
51
|
+
mcpPaths: z.array(z.string()).default(defaultConfig.mcpPaths),
|
|
52
|
+
policyFiles: z.array(z.string()).default(defaultConfig.policyFiles),
|
|
53
|
+
ignoreSkills: z.array(z.string()).default([]),
|
|
54
|
+
ignoreRules: z.array(z.string()).default([]),
|
|
55
|
+
/** Exact finding ids, the same strings `agentscan explain` takes. */
|
|
56
|
+
ignoreFindings: z.array(z.string()).default([]),
|
|
57
|
+
failOn: z.enum(["never", "warning", "error"]).default("never"),
|
|
58
|
+
includeGlobal: z.boolean().default(false),
|
|
59
|
+
requireLock: z.boolean().default(false),
|
|
60
|
+
thresholds: thresholdsSchema.default(defaultThresholds),
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
export type AgentscanConfig = z.infer<typeof configSchema>;
|