@intentius/chant 0.52.1 → 0.52.2
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/dist/agents/checks.d.ts +35 -0
- package/dist/agents/checks.d.ts.map +1 -0
- package/dist/agents/discover.d.ts +86 -0
- package/dist/agents/discover.d.ts.map +1 -0
- package/dist/agents/importer.d.ts +46 -0
- package/dist/agents/importer.d.ts.map +1 -0
- package/dist/agents/index.d.ts +14 -0
- package/dist/agents/index.d.ts.map +1 -0
- package/dist/agents/types.d.ts +196 -0
- package/dist/agents/types.d.ts.map +1 -0
- package/dist/audit/catalog.d.ts +4 -1
- package/dist/audit/catalog.d.ts.map +1 -1
- package/dist/audit/report.d.ts +8 -0
- package/dist/audit/report.d.ts.map +1 -1
- package/dist/audit/rules-doc.d.ts.map +1 -1
- package/dist/cli/commands/audit-agents.d.ts +83 -0
- package/dist/cli/commands/audit-agents.d.ts.map +1 -0
- package/dist/cli/commands/carve-bridge.d.ts.map +1 -1
- package/dist/cli/commands/carve-emit.d.ts.map +1 -1
- package/dist/cli/commands/import-agents.d.ts +64 -0
- package/dist/cli/commands/import-agents.d.ts.map +1 -0
- package/dist/cli/handlers/carve-emit.d.ts.map +1 -1
- package/dist/cli/handlers/misc.d.ts.map +1 -1
- package/dist/cli/main.d.ts.map +1 -1
- package/dist/cli/registry.d.ts +15 -0
- package/dist/cli/registry.d.ts.map +1 -1
- package/dist/lexicon.d.ts +11 -0
- package/dist/lexicon.d.ts.map +1 -1
- package/dist/terraform/adopt-state.d.ts +4 -1
- package/dist/terraform/adopt-state.d.ts.map +1 -1
- package/dist/terraform/bridge.d.ts.map +1 -1
- package/dist/terraform/tier-map.d.ts +17 -0
- package/dist/terraform/tier-map.d.ts.map +1 -1
- package/dist/yaml.d.ts.map +1 -1
- package/package.json +6 -1
- package/src/agents/checks.test.ts +228 -0
- package/src/agents/checks.ts +429 -0
- package/src/agents/discover.test.ts +310 -0
- package/src/agents/discover.ts +939 -0
- package/src/agents/importer.ts +49 -0
- package/src/agents/index.ts +29 -0
- package/src/agents/types.ts +207 -0
- package/src/audit/catalog.ts +90 -1
- package/src/audit/report.ts +9 -1
- package/src/audit/rules-doc.ts +6 -0
- package/src/cli/commands/audit-agents.test.ts +260 -0
- package/src/cli/commands/audit-agents.ts +387 -0
- package/src/cli/commands/carve-bridge.test.ts +30 -0
- package/src/cli/commands/carve-bridge.ts +19 -1
- package/src/cli/commands/carve-emit.test.ts +72 -1
- package/src/cli/commands/carve-emit.ts +43 -20
- package/src/cli/commands/import-agents.test.ts +208 -0
- package/src/cli/commands/import-agents.ts +196 -0
- package/src/cli/handlers/carve-emit.ts +2 -0
- package/src/cli/handlers/misc.ts +111 -0
- package/src/cli/main.ts +17 -0
- package/src/cli/registry.ts +15 -0
- package/src/lexicon.ts +12 -0
- package/src/terraform/adopt-state.ts +7 -4
- package/src/terraform/aws-resources.test.ts +24 -1
- package/src/terraform/bridge.test.ts +12 -0
- package/src/terraform/bridge.ts +4 -1
- package/src/terraform/tier-map.ts +28 -1
- package/src/yaml.test.ts +54 -0
- package/src/yaml.ts +24 -3
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The command's job is to keep `chant audit --agents` reading like a chant
|
|
3
|
+
* audit report while auditing a different subject, and to be honest about the
|
|
4
|
+
* edges of what it scanned.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { describe, test, expect, beforeEach, afterEach } from "vitest";
|
|
8
|
+
import { mkdirSync, writeFileSync, rmSync, readFileSync } from "fs";
|
|
9
|
+
import { join } from "path";
|
|
10
|
+
import { fileURLToPath } from "url";
|
|
11
|
+
import { tmpdir } from "os";
|
|
12
|
+
import Ajv from "ajv";
|
|
13
|
+
import { auditAgentsCommand, toAuditFindings, siteSummary } from "./audit-agents";
|
|
14
|
+
import type { AgentConfigSite } from "../../agents/types";
|
|
15
|
+
|
|
16
|
+
let home: string;
|
|
17
|
+
let project: string;
|
|
18
|
+
|
|
19
|
+
function writeIn(root: string, rel: string, content: string): void {
|
|
20
|
+
const full = join(root, rel);
|
|
21
|
+
mkdirSync(join(full, ".."), { recursive: true });
|
|
22
|
+
writeFileSync(full, content, "utf-8");
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
beforeEach(() => {
|
|
26
|
+
home = join(tmpdir(), `chant-aa-home-${Math.random().toString(36).slice(2)}`);
|
|
27
|
+
project = join(tmpdir(), `chant-aa-proj-${Math.random().toString(36).slice(2)}`);
|
|
28
|
+
mkdirSync(home, { recursive: true });
|
|
29
|
+
mkdirSync(project, { recursive: true });
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
afterEach(() => {
|
|
33
|
+
rmSync(home, { recursive: true, force: true });
|
|
34
|
+
rmSync(project, { recursive: true, force: true });
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
const run = (opts: Parameters<typeof auditAgentsCommand>[0] = {}) =>
|
|
38
|
+
auditAgentsCommand({ home, projectRoots: [project], platform: "linux", now: "2026-01-01T00:00:00Z", toolVersion: "1.2.3", ...opts });
|
|
39
|
+
|
|
40
|
+
/** A config with one unpinned MCP server — an AGT001 error. */
|
|
41
|
+
function seedUnpinned(): void {
|
|
42
|
+
writeIn(home, ".claude/mcp.json", JSON.stringify({ mcpServers: { a: { command: "npx", args: ["-y", "floating-server"] } } }));
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
describe("toAuditFindings", () => {
|
|
46
|
+
test("projects an agent finding onto the shared audit shape", () => {
|
|
47
|
+
const [f] = toAuditFindings([
|
|
48
|
+
{ checkId: "AGT001", severity: "error", message: "m", file: "/f", siteId: "user-claude", scope: "user", runtime: "claude", entity: "srv" },
|
|
49
|
+
]);
|
|
50
|
+
expect(f).toEqual({ checkId: "AGT001", severity: "error", message: "m", file: "/f", lexicon: "agents", entity: "user-claude › srv" });
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test("falls back to the site id when a finding names no entity", () => {
|
|
54
|
+
const [f] = toAuditFindings([
|
|
55
|
+
{ checkId: "AGT005", severity: "error", message: "m", file: "/f", siteId: "user-claude", scope: "user", runtime: "claude" },
|
|
56
|
+
]);
|
|
57
|
+
expect(f.entity).toBe("user-claude");
|
|
58
|
+
});
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
describe("siteSummary", () => {
|
|
62
|
+
test("pluralizes and omits empty categories", () => {
|
|
63
|
+
const base: AgentConfigSite = {
|
|
64
|
+
id: "x", scope: "user", runtime: "claude", root: "/", sources: [],
|
|
65
|
+
instructions: [{ path: "/a", content: "x", bytes: 1 }],
|
|
66
|
+
mcpServers: [{ name: "a", transport: "stdio", source: "/c" }],
|
|
67
|
+
skills: [], subagents: [], commands: [], plugins: [], env: {}, settings: {},
|
|
68
|
+
};
|
|
69
|
+
expect(siteSummary(base)).toBe("1 instruction file · 1 MCP server");
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
test("says so when a site carries nothing", () => {
|
|
73
|
+
const empty: AgentConfigSite = {
|
|
74
|
+
id: "x", scope: "user", runtime: "claude", root: "/", sources: [],
|
|
75
|
+
instructions: [], mcpServers: [], skills: [], subagents: [], commands: [], plugins: [], env: {}, settings: {},
|
|
76
|
+
};
|
|
77
|
+
expect(siteSummary(empty)).toBe("no content");
|
|
78
|
+
});
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
describe("stylish output", () => {
|
|
82
|
+
test("leads with the inventory, so a clean machine still reports what is configured", () => {
|
|
83
|
+
writeIn(home, "CLAUDE.md", "be brief");
|
|
84
|
+
const result = run();
|
|
85
|
+
expect(result.output).toContain("Found 1 agent config");
|
|
86
|
+
expect(result.output).toContain("user:");
|
|
87
|
+
expect(result.output).toContain("1 instruction file");
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
test("says plainly when nothing is configured", () => {
|
|
91
|
+
expect(run().output).toContain("No agent configuration found.");
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
test("separates merge-worthy from report-only", () => {
|
|
95
|
+
seedUnpinned();
|
|
96
|
+
const output = run().output;
|
|
97
|
+
expect(output).toContain("Merge-worthy:");
|
|
98
|
+
expect(output).toContain("AGT001");
|
|
99
|
+
});
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
describe("coverage notes", () => {
|
|
103
|
+
test("reports registered projects it did not visit", () => {
|
|
104
|
+
const a = join(project, "a");
|
|
105
|
+
const b = join(project, "b");
|
|
106
|
+
mkdirSync(a, { recursive: true });
|
|
107
|
+
mkdirSync(b, { recursive: true });
|
|
108
|
+
writeIn(home, ".claude.json", JSON.stringify({ projects: { [a]: {}, [b]: {} } }));
|
|
109
|
+
writeIn(home, "CLAUDE.md", "x");
|
|
110
|
+
expect(run().output).toContain("2 more are registered in ~/.claude.json");
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
test("does not count registrations whose directory is gone", () => {
|
|
114
|
+
// A deleted project is not a gap the user can close, so counting it would
|
|
115
|
+
// overstate what the scan missed.
|
|
116
|
+
writeIn(home, ".claude.json", JSON.stringify({ projects: { "/definitely/not/here": {} } }));
|
|
117
|
+
writeIn(home, "CLAUDE.md", "x");
|
|
118
|
+
expect(run().output).not.toContain("registered in ~/.claude.json");
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
test("reports a file it could not parse", () => {
|
|
122
|
+
writeIn(home, ".claude/settings.json", "{ broken");
|
|
123
|
+
writeIn(home, "CLAUDE.md", "x");
|
|
124
|
+
expect(run().output).toContain("could not be parsed");
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
test("names the scopes it was told to skip", () => {
|
|
128
|
+
writeIn(home, "CLAUDE.md", "x");
|
|
129
|
+
expect(run({ scopes: ["user"] }).output).toContain("Scopes not scanned: system, project");
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
test("points at --all-projects when projects were missed", () => {
|
|
133
|
+
writeIn(home, ".claude.json", JSON.stringify({ projects: { [home]: {} } }));
|
|
134
|
+
writeIn(home, "CLAUDE.md", "x");
|
|
135
|
+
expect(run().output).toContain("--all-projects");
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
test("states the breadth when many roots were scanned and none were missed", () => {
|
|
139
|
+
// With --all-projects there is no gap to report, but "5 findings" reads very
|
|
140
|
+
// differently across one project than across sixty-five.
|
|
141
|
+
const a = join(project, "a");
|
|
142
|
+
const b = join(project, "b");
|
|
143
|
+
mkdirSync(a, { recursive: true });
|
|
144
|
+
mkdirSync(b, { recursive: true });
|
|
145
|
+
writeIn(a, "CLAUDE.md", "x");
|
|
146
|
+
const output = run({ projectRoots: [a, b] }).output;
|
|
147
|
+
expect(output).toContain("Scanned 2 project roots");
|
|
148
|
+
expect(output).not.toContain("--all-projects");
|
|
149
|
+
});
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
describe("tier and fail-on", () => {
|
|
153
|
+
test("--tier merge-worthy drops report-only findings", () => {
|
|
154
|
+
seedUnpinned();
|
|
155
|
+
const all = run({ tier: "all" }).findings.length;
|
|
156
|
+
const mw = run({ tier: "merge-worthy" }).findings;
|
|
157
|
+
expect(mw.length).toBeLessThan(all);
|
|
158
|
+
expect(mw.every((f) => f.checkId !== "AGT006")).toBe(true);
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
test("exit code is 0 by default even with findings — the scan is read-only friendly", () => {
|
|
162
|
+
seedUnpinned();
|
|
163
|
+
expect(run().exitCode).toBe(0);
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
test("--fail-on merge-worthy exits 1 when a merge-worthy finding exists", () => {
|
|
167
|
+
seedUnpinned();
|
|
168
|
+
expect(run({ failOn: "merge-worthy" }).exitCode).toBe(1);
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
test("--fail-on merge-worthy exits 0 on a clean machine", () => {
|
|
172
|
+
expect(run({ failOn: "merge-worthy" }).exitCode).toBe(0);
|
|
173
|
+
});
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
describe("formats", () => {
|
|
177
|
+
test("json carries the full inventory alongside the findings", () => {
|
|
178
|
+
seedUnpinned();
|
|
179
|
+
const report = JSON.parse(run({ format: "json" }).output);
|
|
180
|
+
expect(report.subject).toBe("agent-configuration");
|
|
181
|
+
expect(report.tool.version).toBe("1.2.3");
|
|
182
|
+
expect(report.sites[0].id).toBe("user-claude");
|
|
183
|
+
expect(report.sites[0].mcpServers[0].name).toBe("a");
|
|
184
|
+
expect(report.coverage.probed.length).toBeGreaterThan(0);
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
test("json reports env var names, never their values", () => {
|
|
188
|
+
writeIn(home, ".claude/settings.json", JSON.stringify({ env: { SECRET_THING: "hunter2" } }));
|
|
189
|
+
const report = JSON.parse(run({ format: "json" }).output);
|
|
190
|
+
expect(report.sites[0].env).toEqual(["SECRET_THING"]);
|
|
191
|
+
expect(run({ format: "json" }).output).not.toContain("hunter2");
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
test("sarif is valid and links each rule to its docs anchor", () => {
|
|
195
|
+
seedUnpinned();
|
|
196
|
+
const sarif = JSON.parse(run({ format: "sarif" }).output);
|
|
197
|
+
expect(sarif.version).toBe("2.1.0");
|
|
198
|
+
const rule = sarif.runs[0].tool.driver.rules.find((r: { id: string }) => r.id === "AGT001");
|
|
199
|
+
expect(rule.helpUri).toBe("https://intentius.io/chant/lint-rules/audit-rules/#agt001");
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
test("sarif carries the same tier/dimension property bag the repository audit emits (#442, #444)", () => {
|
|
203
|
+
seedUnpinned();
|
|
204
|
+
const sarif = JSON.parse(run({ format: "sarif" }).output) as {
|
|
205
|
+
runs: Array<{
|
|
206
|
+
tool: { driver: { rules: Array<{ id: string; help?: { text: string }; properties?: { category?: string; dimension?: string } }> } };
|
|
207
|
+
results: Array<{ ruleId: string; properties?: { tier?: string } }>;
|
|
208
|
+
}>;
|
|
209
|
+
};
|
|
210
|
+
const one = sarif.runs[0];
|
|
211
|
+
expect(one.results.length).toBeGreaterThan(0);
|
|
212
|
+
for (const r of one.results) expect(r.properties?.tier).toMatch(/^(merge-worthy|report-only)$/);
|
|
213
|
+
expect(one.tool.driver.rules.length).toBeGreaterThan(0);
|
|
214
|
+
for (const rule of one.tool.driver.rules) {
|
|
215
|
+
expect(rule.properties?.category).toMatch(/^(security|correctness|best-practice|efficiency)$/);
|
|
216
|
+
expect(rule.properties?.dimension).toBe(rule.properties?.category);
|
|
217
|
+
expect(rule.help?.text.length).toBeGreaterThan(0);
|
|
218
|
+
}
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
test("sarif validates against the real, vendored SARIF 2.1.0 JSON Schema", () => {
|
|
222
|
+
seedUnpinned();
|
|
223
|
+
const schemaPath = fileURLToPath(new URL("./__fixtures__/schemas/sarif-2.1.0.schema.json", import.meta.url));
|
|
224
|
+
const ajv = new Ajv({ strict: false, allErrors: true });
|
|
225
|
+
const validate = ajv.compile(JSON.parse(readFileSync(schemaPath, "utf-8")) as object);
|
|
226
|
+
const valid = validate(JSON.parse(run({ format: "sarif" }).output));
|
|
227
|
+
if (!valid) throw new Error(ajv.errorsText(validate.errors));
|
|
228
|
+
expect(valid).toBe(true);
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
test("markdown and html render without throwing", () => {
|
|
232
|
+
seedUnpinned();
|
|
233
|
+
expect(run({ format: "markdown" }).output).toContain("# Agent configuration audit");
|
|
234
|
+
expect(run({ format: "html" }).output).toContain("<html");
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
test("markdown is one document — the embedded findings report is a section, not a second H1", () => {
|
|
238
|
+
seedUnpinned();
|
|
239
|
+
const md = run({ format: "markdown" }).output;
|
|
240
|
+
expect(md.split("\n").filter((l) => /^# /.test(l))).toEqual(["# Agent configuration audit"]);
|
|
241
|
+
expect(md).toContain("## Inventory");
|
|
242
|
+
expect(md).toContain("## Findings");
|
|
243
|
+
});
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
describe("--output", () => {
|
|
247
|
+
test("writes the report to a file instead of returning it for stdout", () => {
|
|
248
|
+
writeIn(home, "CLAUDE.md", "x");
|
|
249
|
+
const out = join(project, "report.md");
|
|
250
|
+
const result = run({ format: "markdown", output: out });
|
|
251
|
+
expect(result.wroteTo).toBe(out);
|
|
252
|
+
expect(readFileSync(out, "utf-8")).toContain("Agent configuration audit");
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
test("reports a write failure instead of throwing", () => {
|
|
256
|
+
const result = run({ output: join(project, "no", "such", "dir", "r.txt") });
|
|
257
|
+
expect(result.success).toBe(false);
|
|
258
|
+
expect(result.error).toContain("Failed to write");
|
|
259
|
+
});
|
|
260
|
+
});
|
|
@@ -0,0 +1,387 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `chant audit --agents` — audit the agent configuration on this machine.
|
|
3
|
+
*
|
|
4
|
+
* This is a different *subject* from the rest of `chant audit` (a machine, not
|
|
5
|
+
* a repo) but deliberately the same *product*: the same tiering, the same
|
|
6
|
+
* five output formats, the same rule-catalog links. A reader who already knows
|
|
7
|
+
* how to read a chant audit report can read this one, and the HTML/SARIF/JSON
|
|
8
|
+
* consumers downstream need no changes.
|
|
9
|
+
*
|
|
10
|
+
* The bridge is {@link toAuditFindings}: an `AgentFinding` is projected onto
|
|
11
|
+
* the `AuditFinding` shape the renderers already take, with the site id in
|
|
12
|
+
* `entity` so a reader can tell which configuration a finding came from.
|
|
13
|
+
*
|
|
14
|
+
* Two things this command adds that a repo audit has no need for:
|
|
15
|
+
*
|
|
16
|
+
* - **An inventory.** "What is configured on this machine" is the primary
|
|
17
|
+
* question here; findings are secondary. A clean scan should still print
|
|
18
|
+
* the sites it found, because most users have never seen the full list.
|
|
19
|
+
* - **Coverage honesty.** The scan probes a fixed set of locations, so it can
|
|
20
|
+
* state what it looked for and didn't find, what it couldn't parse, and how
|
|
21
|
+
* many registered projects it did not visit. An inventory that quietly
|
|
22
|
+
* omits half the machine is worse than no inventory.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
import { writeFileSync } from "fs";
|
|
26
|
+
import { homedir } from "os";
|
|
27
|
+
import { resolve } from "path";
|
|
28
|
+
import { scanAgentConfigs, unscannedProjectCount } from "../../agents/discover";
|
|
29
|
+
import { checkAgentConfigs } from "../../agents/checks";
|
|
30
|
+
import type { AgentConfigSite, AgentFinding, AgentRuntime, AgentScanResult, AgentScope } from "../../agents/types";
|
|
31
|
+
import { AGENT_RUNTIMES, AGENT_SCOPES } from "../../agents/types";
|
|
32
|
+
import type { AuditFinding } from "../../audit/core";
|
|
33
|
+
import { RULE_CATALOG, ruleDocUrl, type RuleMeta } from "../../audit/catalog";
|
|
34
|
+
import { renderMarkdown } from "../../audit/report";
|
|
35
|
+
import { renderHtml, type ReportTheme } from "../../audit/report-html";
|
|
36
|
+
import { buildReportJson, type AuditSnapshot } from "../../audit/report-model";
|
|
37
|
+
import type { AuditFailOn, AuditFormat, AuditTier } from "./audit";
|
|
38
|
+
|
|
39
|
+
export interface AuditAgentsOptions {
|
|
40
|
+
format?: AuditFormat;
|
|
41
|
+
tier?: AuditTier;
|
|
42
|
+
failOn?: AuditFailOn;
|
|
43
|
+
/** Scopes to scan. Defaults to all three. */
|
|
44
|
+
scopes?: readonly AgentScope[];
|
|
45
|
+
/** Harnesses to scan. Defaults to all five. */
|
|
46
|
+
runtimes?: readonly AgentRuntime[];
|
|
47
|
+
/** Project roots to scan at project scope. Defaults to `[cwd]`. */
|
|
48
|
+
projectRoots?: string[];
|
|
49
|
+
/** Home directory override — injectable so tests scan a fixture tree. */
|
|
50
|
+
home?: string;
|
|
51
|
+
platform?: NodeJS.Platform;
|
|
52
|
+
/** Write the report here instead of returning it for stdout. */
|
|
53
|
+
output?: string;
|
|
54
|
+
theme?: ReportTheme;
|
|
55
|
+
template?: string;
|
|
56
|
+
now?: string;
|
|
57
|
+
toolVersion?: string;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export interface AuditAgentsResult {
|
|
61
|
+
success: boolean;
|
|
62
|
+
output: string;
|
|
63
|
+
findings: AgentFinding[];
|
|
64
|
+
scan: AgentScanResult;
|
|
65
|
+
exitCode: number;
|
|
66
|
+
wroteTo?: string;
|
|
67
|
+
error?: string;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Project an agent finding onto the audit renderers' finding shape.
|
|
72
|
+
*
|
|
73
|
+
* `lexicon` is set to `"agents"` — the renderers only use it as a grouping
|
|
74
|
+
* label, and calling this family what it is keeps a mixed report readable.
|
|
75
|
+
*/
|
|
76
|
+
export function toAuditFindings(findings: AgentFinding[]): AuditFinding[] {
|
|
77
|
+
return findings.map((f) => ({
|
|
78
|
+
checkId: f.checkId,
|
|
79
|
+
severity: f.severity,
|
|
80
|
+
message: f.message,
|
|
81
|
+
file: f.file,
|
|
82
|
+
lexicon: "agents",
|
|
83
|
+
entity: f.entity ? `${f.siteId} › ${f.entity}` : f.siteId,
|
|
84
|
+
}));
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function isMergeWorthy(f: AuditFinding, catalog: Record<string, RuleMeta>): boolean {
|
|
88
|
+
return catalog[f.checkId]?.tier === "merge-worthy";
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function exitCodeFor(findings: AuditFinding[], failOn: AuditFailOn, catalog: Record<string, RuleMeta>): number {
|
|
92
|
+
if (failOn === "merge-worthy") return findings.some((f) => isMergeWorthy(f, catalog)) ? 1 : 0;
|
|
93
|
+
if (failOn === "warning") return findings.some((f) => f.severity === "error" || f.severity === "warning") ? 1 : 0;
|
|
94
|
+
return 0;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** One-line summary of what a site carries, e.g. `3 MCP servers · 11 skills`. */
|
|
98
|
+
export function siteSummary(site: AgentConfigSite): string {
|
|
99
|
+
const parts: string[] = [];
|
|
100
|
+
const plural = (n: number, one: string, many = `${one}s`) => `${n} ${n === 1 ? one : many}`;
|
|
101
|
+
if (site.instructions.length > 0) parts.push(plural(site.instructions.length, "instruction file"));
|
|
102
|
+
if (site.mcpServers.length > 0) parts.push(plural(site.mcpServers.length, "MCP server"));
|
|
103
|
+
if (site.skills.length > 0) parts.push(plural(site.skills.length, "skill"));
|
|
104
|
+
if (site.subagents.length > 0) parts.push(plural(site.subagents.length, "subagent"));
|
|
105
|
+
if (site.commands.length > 0) parts.push(plural(site.commands.length, "command"));
|
|
106
|
+
if (site.plugins.length > 0) parts.push(plural(site.plugins.length, "plugin"));
|
|
107
|
+
const envCount = Object.keys(site.env).length;
|
|
108
|
+
if (envCount > 0) parts.push(plural(envCount, "env var"));
|
|
109
|
+
if (site.model) parts.push(`model ${site.model}`);
|
|
110
|
+
return parts.length > 0 ? parts.join(" · ") : "no content";
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Coverage caveats. Stated even when empty-ish, because the value of an
|
|
115
|
+
* inventory depends entirely on the reader knowing its edges.
|
|
116
|
+
*/
|
|
117
|
+
export function agentCoverageNotes(scan: AgentScanResult, opts: { home: string; projectRoots: string[]; scopes: readonly AgentScope[] }): string[] {
|
|
118
|
+
const notes: string[] = [];
|
|
119
|
+
|
|
120
|
+
if (opts.scopes.includes("project")) {
|
|
121
|
+
const roots = opts.projectRoots.length;
|
|
122
|
+
const unscanned = unscannedProjectCount(opts.home, opts.projectRoots);
|
|
123
|
+
if (unscanned > 0) {
|
|
124
|
+
notes.push(
|
|
125
|
+
`Scanned ${roots} project root${roots === 1 ? "" : "s"}; ` +
|
|
126
|
+
`${unscanned} more ${unscanned === 1 ? "is" : "are"} registered in ~/.claude.json and ${unscanned === 1 ? "was" : "were"} not visited. ` +
|
|
127
|
+
// The path is positional (`chant audit <path>`), not a `--path` flag.
|
|
128
|
+
`Scan one with \`chant audit --agents --scope project <dir>\`, or all of them with \`--all-projects\`.`,
|
|
129
|
+
);
|
|
130
|
+
} else if (roots > 1) {
|
|
131
|
+
// Breadth is worth stating even when nothing was missed — a reader
|
|
132
|
+
// seeing four findings should know whether that was across one project
|
|
133
|
+
// or sixty-five.
|
|
134
|
+
notes.push(`Scanned ${roots} project roots — every project registered in ~/.claude.json whose directory still exists.`);
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
if (scan.unreadable.length > 0) {
|
|
139
|
+
notes.push(
|
|
140
|
+
`${scan.unreadable.length} file${scan.unreadable.length === 1 ? "" : "s"} existed but could not be parsed, so ` +
|
|
141
|
+
`${scan.unreadable.length === 1 ? "its" : "their"} contents are not covered: ` +
|
|
142
|
+
scan.unreadable.map((u) => `${u.path} (${u.reason})`).join("; "),
|
|
143
|
+
);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const skippedScopes = AGENT_SCOPES.filter((s) => !opts.scopes.includes(s));
|
|
147
|
+
if (skippedScopes.length > 0) notes.push(`Scope${skippedScopes.length === 1 ? "" : "s"} not scanned: ${skippedScopes.join(", ")}.`);
|
|
148
|
+
|
|
149
|
+
return notes;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/** Render the inventory section — what was found, before any judgement about it. */
|
|
153
|
+
function renderInventory(scan: AgentScanResult): string[] {
|
|
154
|
+
const lines: string[] = [];
|
|
155
|
+
if (scan.sites.length === 0) {
|
|
156
|
+
lines.push("No agent configuration found.");
|
|
157
|
+
return lines;
|
|
158
|
+
}
|
|
159
|
+
lines.push(`Found ${scan.sites.length} agent config${scan.sites.length === 1 ? "" : "s"}:`);
|
|
160
|
+
let lastScope: AgentScope | undefined;
|
|
161
|
+
for (const site of scan.sites) {
|
|
162
|
+
if (site.scope !== lastScope) {
|
|
163
|
+
lines.push("", ` ${site.scope}:`);
|
|
164
|
+
lastScope = site.scope;
|
|
165
|
+
}
|
|
166
|
+
lines.push(` ${site.runtime.padEnd(9)} ${site.root}`);
|
|
167
|
+
lines.push(` ${siteSummary(site)}`);
|
|
168
|
+
lines.push(` from ${site.sources.length} file${site.sources.length === 1 ? "" : "s"}`);
|
|
169
|
+
}
|
|
170
|
+
return lines;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function renderStylish(scan: AgentScanResult, findings: AuditFinding[], notes: string[], catalog: Record<string, RuleMeta>): string {
|
|
174
|
+
const lines: string[] = [...renderInventory(scan), ""];
|
|
175
|
+
|
|
176
|
+
for (const note of notes) lines.push(`Note: ${note}`);
|
|
177
|
+
if (notes.length > 0) lines.push("");
|
|
178
|
+
|
|
179
|
+
const mw = findings.filter((f) => isMergeWorthy(f, catalog));
|
|
180
|
+
const ro = findings.filter((f) => !isMergeWorthy(f, catalog));
|
|
181
|
+
lines.push(`${findings.length} finding${findings.length === 1 ? "" : "s"} (${mw.length} merge-worthy, ${ro.length} report-only).`);
|
|
182
|
+
|
|
183
|
+
const section = (title: string, list: AuditFinding[]) => {
|
|
184
|
+
if (list.length === 0) return;
|
|
185
|
+
lines.push("", title);
|
|
186
|
+
for (const f of list) {
|
|
187
|
+
lines.push(` [${f.checkId}] ${f.severity} ${f.entity}`);
|
|
188
|
+
lines.push(` ${f.message}`);
|
|
189
|
+
lines.push(` ${f.file}`);
|
|
190
|
+
}
|
|
191
|
+
};
|
|
192
|
+
section("Merge-worthy:", mw);
|
|
193
|
+
section("Report-only:", ro);
|
|
194
|
+
return lines.join("\n");
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* Provenance for the machine-readable and HTML reports. `target` is the home
|
|
199
|
+
* directory rather than a repo URL, and `files` lists the config sources that
|
|
200
|
+
* contributed — the machine-scope analogue of "which workflow files were read".
|
|
201
|
+
*/
|
|
202
|
+
function buildSnapshot(scan: AgentScanResult, home: string, opts: AuditAgentsOptions): AuditSnapshot {
|
|
203
|
+
return {
|
|
204
|
+
target: home,
|
|
205
|
+
files: scan.sites.flatMap((s) => s.sources),
|
|
206
|
+
generatedAt: opts.now ?? new Date().toISOString(),
|
|
207
|
+
toolVersion: opts.toolVersion ?? "0.0.0",
|
|
208
|
+
};
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/** The JSON report: the full inventory plus findings, so a consumer can do its own analysis. */
|
|
212
|
+
function renderJson(
|
|
213
|
+
scan: AgentScanResult,
|
|
214
|
+
agentFindings: AgentFinding[],
|
|
215
|
+
findings: AuditFinding[],
|
|
216
|
+
notes: string[],
|
|
217
|
+
catalog: Record<string, RuleMeta>,
|
|
218
|
+
snapshot: AuditSnapshot,
|
|
219
|
+
): string {
|
|
220
|
+
const base = buildReportJson(findings, { snapshot, toolVersion: snapshot.toolVersion, catalog });
|
|
221
|
+
return JSON.stringify(
|
|
222
|
+
{
|
|
223
|
+
...base,
|
|
224
|
+
subject: "agent-configuration",
|
|
225
|
+
notes,
|
|
226
|
+
sites: scan.sites.map((site) => ({
|
|
227
|
+
id: site.id,
|
|
228
|
+
scope: site.scope,
|
|
229
|
+
runtime: site.runtime,
|
|
230
|
+
root: site.root,
|
|
231
|
+
sources: site.sources,
|
|
232
|
+
summary: siteSummary(site),
|
|
233
|
+
instructions: site.instructions.map((i) => ({ path: i.path, bytes: i.bytes })),
|
|
234
|
+
mcpServers: site.mcpServers,
|
|
235
|
+
skills: site.skills.map((s) => ({ name: s.name, origin: s.origin, path: s.path, source: s.source, ref: s.ref })),
|
|
236
|
+
subagents: site.subagents,
|
|
237
|
+
commands: site.commands,
|
|
238
|
+
plugins: site.plugins,
|
|
239
|
+
env: Object.keys(site.env),
|
|
240
|
+
permissions: site.permissions,
|
|
241
|
+
model: site.model,
|
|
242
|
+
})),
|
|
243
|
+
agentFindings,
|
|
244
|
+
coverage: { probed: scan.probed, unreadable: scan.unreadable },
|
|
245
|
+
},
|
|
246
|
+
null,
|
|
247
|
+
2,
|
|
248
|
+
);
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/**
|
|
252
|
+
* SARIF (2.1.0) export for the agent scan. Same property bag the repository
|
|
253
|
+
* audit emits (#442, #444) — `category`/`dimension` per rule and `tier` per
|
|
254
|
+
* result — so a consumer that triages one SARIF file can triage the other
|
|
255
|
+
* without special-casing the driver. `helpUri` points at the rule's own docs
|
|
256
|
+
* anchor rather than its authority citation: an AGT finding is about a local
|
|
257
|
+
* file, and the chant page is what explains what to do with it.
|
|
258
|
+
*/
|
|
259
|
+
function renderSarif(findings: AuditFinding[], catalog: Record<string, RuleMeta>): string {
|
|
260
|
+
const ids = [...new Set(findings.map((f) => f.checkId))].sort();
|
|
261
|
+
return JSON.stringify(
|
|
262
|
+
{
|
|
263
|
+
$schema: "https://json.schemastore.org/sarif-2.1.0.json",
|
|
264
|
+
version: "2.1.0",
|
|
265
|
+
runs: [
|
|
266
|
+
{
|
|
267
|
+
tool: {
|
|
268
|
+
driver: {
|
|
269
|
+
name: "chant audit --agents",
|
|
270
|
+
informationUri: "https://intentius.io/chant/cli/audit/",
|
|
271
|
+
rules: ids.map((id) => {
|
|
272
|
+
const m = catalog[id];
|
|
273
|
+
return {
|
|
274
|
+
id,
|
|
275
|
+
name: m?.title ?? id,
|
|
276
|
+
shortDescription: { text: m?.title ?? id },
|
|
277
|
+
fullDescription: { text: m?.remediation ?? "" },
|
|
278
|
+
...(m?.remediation ? { help: { text: m.remediation } } : {}),
|
|
279
|
+
helpUri: ruleDocUrl(id),
|
|
280
|
+
...(m?.category ? { properties: { category: m.category, dimension: m.category } } : {}),
|
|
281
|
+
};
|
|
282
|
+
}),
|
|
283
|
+
},
|
|
284
|
+
},
|
|
285
|
+
results: findings.map((f) => {
|
|
286
|
+
const tier = catalog[f.checkId]?.tier;
|
|
287
|
+
return {
|
|
288
|
+
ruleId: f.checkId,
|
|
289
|
+
level: f.severity === "error" ? "error" : f.severity === "warning" ? "warning" : "note",
|
|
290
|
+
message: { text: f.message },
|
|
291
|
+
locations: [{ physicalLocation: { artifactLocation: { uri: f.file } } }],
|
|
292
|
+
...(tier ? { properties: { tier } } : {}),
|
|
293
|
+
};
|
|
294
|
+
}),
|
|
295
|
+
},
|
|
296
|
+
],
|
|
297
|
+
},
|
|
298
|
+
null,
|
|
299
|
+
2,
|
|
300
|
+
);
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
/**
|
|
304
|
+
* Scan the machine's agent configuration and render a report.
|
|
305
|
+
*
|
|
306
|
+
* Pure with respect to the filesystem apart from the scan itself and an
|
|
307
|
+
* optional `--output` write, so it can be driven from tests against a fixture
|
|
308
|
+
* home directory.
|
|
309
|
+
*/
|
|
310
|
+
export function auditAgentsCommand(opts: AuditAgentsOptions = {}): AuditAgentsResult {
|
|
311
|
+
const home = opts.home ?? homedir();
|
|
312
|
+
const scopes = opts.scopes ?? AGENT_SCOPES;
|
|
313
|
+
const runtimes = opts.runtimes ?? AGENT_RUNTIMES;
|
|
314
|
+
const projectRoots = (opts.projectRoots ?? [process.cwd()]).map((p) => resolve(p));
|
|
315
|
+
const format = opts.format ?? "stylish";
|
|
316
|
+
const tier = opts.tier ?? "all";
|
|
317
|
+
const failOn = opts.failOn ?? "none";
|
|
318
|
+
const catalog = RULE_CATALOG;
|
|
319
|
+
|
|
320
|
+
const scan = scanAgentConfigs({ scopes, runtimes, projectRoots, home, platform: opts.platform });
|
|
321
|
+
const all = checkAgentConfigs(scan);
|
|
322
|
+
const agentFindings = tier === "merge-worthy" ? all.filter((f) => catalog[f.checkId]?.tier === "merge-worthy") : all;
|
|
323
|
+
const findings = toAuditFindings(agentFindings);
|
|
324
|
+
const notes = agentCoverageNotes(scan, { home, projectRoots, scopes });
|
|
325
|
+
|
|
326
|
+
const snapshot = buildSnapshot(scan, home, opts);
|
|
327
|
+
|
|
328
|
+
let output: string;
|
|
329
|
+
switch (format) {
|
|
330
|
+
case "json":
|
|
331
|
+
output = renderJson(scan, agentFindings, findings, notes, catalog, snapshot);
|
|
332
|
+
break;
|
|
333
|
+
case "sarif":
|
|
334
|
+
output = renderSarif(findings, catalog);
|
|
335
|
+
break;
|
|
336
|
+
case "markdown":
|
|
337
|
+
output = [
|
|
338
|
+
"# Agent configuration audit",
|
|
339
|
+
"",
|
|
340
|
+
"## Inventory",
|
|
341
|
+
"",
|
|
342
|
+
"```",
|
|
343
|
+
renderInventory(scan).join("\n"),
|
|
344
|
+
"```",
|
|
345
|
+
"",
|
|
346
|
+
renderMarkdown(findings, { target: home, notes, catalog, title: "Findings", headingLevel: 2 }),
|
|
347
|
+
].join("\n");
|
|
348
|
+
break;
|
|
349
|
+
case "html":
|
|
350
|
+
output = renderHtml(findings, {
|
|
351
|
+
notes,
|
|
352
|
+
catalog,
|
|
353
|
+
snapshot,
|
|
354
|
+
theme: opts.theme,
|
|
355
|
+
template: opts.template,
|
|
356
|
+
});
|
|
357
|
+
break;
|
|
358
|
+
default:
|
|
359
|
+
output = renderStylish(scan, findings, notes, catalog);
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
let wroteTo: string | undefined;
|
|
363
|
+
if (opts.output) {
|
|
364
|
+
try {
|
|
365
|
+
writeFileSync(opts.output, output, "utf-8");
|
|
366
|
+
wroteTo = opts.output;
|
|
367
|
+
} catch (err) {
|
|
368
|
+
return {
|
|
369
|
+
success: false,
|
|
370
|
+
output: "",
|
|
371
|
+
findings: agentFindings,
|
|
372
|
+
scan,
|
|
373
|
+
exitCode: 1,
|
|
374
|
+
error: `Failed to write ${opts.output}: ${err instanceof Error ? err.message : String(err)}`,
|
|
375
|
+
};
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
return {
|
|
380
|
+
success: true,
|
|
381
|
+
output,
|
|
382
|
+
findings: agentFindings,
|
|
383
|
+
scan,
|
|
384
|
+
exitCode: exitCodeFor(findings, failOn, catalog),
|
|
385
|
+
wroteTo,
|
|
386
|
+
};
|
|
387
|
+
}
|
|
@@ -194,6 +194,36 @@ describe("carveBridge", () => {
|
|
|
194
194
|
}
|
|
195
195
|
});
|
|
196
196
|
|
|
197
|
+
test("refuses a target whose identity attribute is a dotted path (#2015)", async () => {
|
|
198
|
+
if (!parserAvailable) return;
|
|
199
|
+
const dir = mkdtempSync(join(tmpdir(), "chant-bridge-k8s-"));
|
|
200
|
+
try {
|
|
201
|
+
// `kubernetes_manifest` identifies itself by `manifest.metadata.name`.
|
|
202
|
+
// Rendering that into a data body produced `manifest.metadata.name = "x"`,
|
|
203
|
+
// which is not valid HCL — and the provider has no manifest data source.
|
|
204
|
+
writeFileSync(
|
|
205
|
+
join(dir, "main.tf"),
|
|
206
|
+
`resource "kubernetes_manifest" "demo" {\n` +
|
|
207
|
+
` manifest = {\n` +
|
|
208
|
+
` apiVersion = "v1"\n` +
|
|
209
|
+
` kind = "ConfigMap"\n` +
|
|
210
|
+
` metadata = { name = "demo-config" }\n` +
|
|
211
|
+
` }\n` +
|
|
212
|
+
`}\n`,
|
|
213
|
+
);
|
|
214
|
+
const out = join(dir, "carveout");
|
|
215
|
+
const res = await carveBridge({ from: dir, select: "kubernetes_manifest.demo", output: out });
|
|
216
|
+
|
|
217
|
+
expect(res.ok).toBe(false);
|
|
218
|
+
expect(res.error).toContain("cannot be bridged");
|
|
219
|
+
expect(res.error).toContain("manifest.metadata.name");
|
|
220
|
+
expect(res.plan).toBeUndefined();
|
|
221
|
+
expect(existsSync(out)).toBe(false);
|
|
222
|
+
} finally {
|
|
223
|
+
rmSync(dir, { recursive: true, force: true });
|
|
224
|
+
}
|
|
225
|
+
});
|
|
226
|
+
|
|
197
227
|
test("formatCarveBridge summarizes data sources, rewires, and safety", async () => {
|
|
198
228
|
if (!parserAvailable) return;
|
|
199
229
|
await withEstate(async (dir) => {
|