@intentius/chant 0.52.1 → 0.53.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/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/cdk/advise.d.ts +29 -0
- package/dist/cdk/advise.d.ts.map +1 -0
- package/dist/cdk/assembly.d.ts +38 -0
- package/dist/cdk/assembly.d.ts.map +1 -0
- package/dist/cdk/graph.d.ts +68 -0
- package/dist/cdk/graph.d.ts.map +1 -0
- package/dist/cdk/tier-map.d.ts +44 -0
- package/dist/cdk/tier-map.d.ts.map +1 -0
- package/dist/cdk/types.d.ts +114 -0
- package/dist/cdk/types.d.ts.map +1 -0
- 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-apply.d.ts.map +1 -1
- 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/carve.d.ts +48 -6
- package/dist/cli/commands/carve.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/carve.d.ts +5 -4
- package/dist/cli/handlers/carve.d.ts.map +1 -1
- package/dist/cli/handlers/lifecycle.d.ts +10 -0
- package/dist/cli/handlers/lifecycle.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/identity.d.ts +196 -0
- package/dist/identity.d.ts.map +1 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/lexicon.d.ts +52 -0
- package/dist/lexicon.d.ts.map +1 -1
- package/dist/terraform/adopt-state.d.ts +17 -63
- package/dist/terraform/adopt-state.d.ts.map +1 -1
- package/dist/terraform/aws-resources.d.ts +1 -1
- package/dist/terraform/bridge.d.ts.map +1 -1
- package/dist/terraform/carve-provider.d.ts +142 -0
- package/dist/terraform/carve-provider.d.ts.map +1 -0
- package/dist/terraform/carve.d.ts +36 -3
- package/dist/terraform/carve.d.ts.map +1 -1
- package/dist/terraform/emit-source.d.ts +25 -0
- package/dist/terraform/emit-source.d.ts.map +1 -0
- package/dist/terraform/graduate.d.ts +11 -1
- package/dist/terraform/graduate.d.ts.map +1 -1
- package/dist/terraform/providers/aws.d.ts +19 -0
- package/dist/terraform/providers/aws.d.ts.map +1 -0
- package/dist/terraform/providers/gcp.d.ts +41 -0
- package/dist/terraform/providers/gcp.d.ts.map +1 -0
- package/dist/terraform/providers/index.d.ts +15 -0
- package/dist/terraform/providers/index.d.ts.map +1 -0
- package/dist/terraform/providers/kubernetes.d.ts +29 -0
- package/dist/terraform/providers/kubernetes.d.ts.map +1 -0
- package/dist/terraform/score.d.ts +70 -4
- package/dist/terraform/score.d.ts.map +1 -1
- package/dist/terraform/tier-map.d.ts +38 -26
- package/dist/terraform/tier-map.d.ts.map +1 -1
- package/dist/terraform/types.d.ts +6 -0
- package/dist/terraform/types.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/cdk/__fixtures__/cdk.out/AppStack.template.json +171 -0
- package/src/cdk/__fixtures__/cdk.out/DataStack.template.json +90 -0
- package/src/cdk/__fixtures__/cdk.out/cdk.out +1 -0
- package/src/cdk/__fixtures__/cdk.out/manifest.json +30 -0
- package/src/cdk/__fixtures__/cdk.out/tree.json +201 -0
- package/src/cdk/__fixtures__/cdk.out-dummy/LookupStack.template.json +29 -0
- package/src/cdk/__fixtures__/cdk.out-dummy/manifest.json +26 -0
- package/src/cdk/advise.test.ts +208 -0
- package/src/cdk/advise.ts +44 -0
- package/src/cdk/assembly.ts +133 -0
- package/src/cdk/graph.test.ts +206 -0
- package/src/cdk/graph.ts +525 -0
- package/src/cdk/tier-map.ts +71 -0
- package/src/cdk/types.ts +115 -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-apply.ts +20 -4
- package/src/cli/commands/carve-bridge.test.ts +30 -0
- package/src/cli/commands/carve-bridge.ts +24 -2
- package/src/cli/commands/carve-emit-k8s.test.ts +262 -0
- package/src/cli/commands/carve-emit-provider.test.ts +207 -0
- package/src/cli/commands/carve-emit.test.ts +72 -1
- package/src/cli/commands/carve-emit.ts +55 -28
- package/src/cli/commands/carve.ts +139 -36
- 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 +8 -1
- package/src/cli/handlers/carve.ts +8 -7
- package/src/cli/handlers/lifecycle.test.ts +187 -1
- package/src/cli/handlers/lifecycle.ts +125 -1
- package/src/cli/handlers/misc.ts +111 -0
- package/src/cli/main.ts +29 -5
- package/src/cli/registry.ts +15 -0
- package/src/identity.test.ts +199 -0
- package/src/identity.ts +346 -0
- package/src/index.ts +1 -0
- package/src/lexicon.ts +65 -0
- package/src/terraform/__fixtures__/gcp-estate/main.tf +60 -0
- package/src/terraform/adopt-state.test.ts +131 -0
- package/src/terraform/adopt-state.ts +22 -167
- package/src/terraform/aws-resources.test.ts +55 -16
- package/src/terraform/aws-resources.ts +1 -1
- package/src/terraform/bridge.test.ts +12 -0
- package/src/terraform/bridge.ts +4 -1
- package/src/terraform/carve-provider.test.ts +155 -0
- package/src/terraform/carve-provider.ts +237 -0
- package/src/terraform/carve.test.ts +55 -1
- package/src/terraform/carve.ts +0 -0
- package/src/terraform/emit-source.ts +39 -0
- package/src/terraform/graduate.test.ts +37 -0
- package/src/terraform/graduate.ts +55 -7
- package/src/terraform/graph.ts +3 -3
- package/src/terraform/providers/aws.ts +169 -0
- package/src/terraform/providers/gcp.test.ts +228 -0
- package/src/terraform/providers/gcp.ts +329 -0
- package/src/terraform/providers/index.ts +21 -0
- package/src/terraform/providers/kubernetes.ts +224 -0
- package/src/terraform/score.ts +111 -25
- package/src/terraform/tier-map.ts +55 -90
- package/src/terraform/types.ts +6 -0
- package/src/yaml.test.ts +54 -0
- package/src/yaml.ts +24 -3
|
@@ -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
|
+
}
|
|
@@ -11,10 +11,12 @@
|
|
|
11
11
|
|
|
12
12
|
import { existsSync, statSync, mkdirSync, readFileSync, writeFileSync } from "fs";
|
|
13
13
|
import { join } from "path";
|
|
14
|
+
import { cdkNotSupported, isCloudAssembly } from "../../cdk/assembly";
|
|
14
15
|
import { parseTerraformDir, Hcl2JsonNotInstalled } from "../../terraform/parse";
|
|
15
16
|
import { boundaryReport } from "../../terraform/carve";
|
|
16
17
|
import { graduationPlan, stampOwnershipIntoSource, type GraduationPlan } from "../../terraform/graduate";
|
|
17
18
|
import { resolveCarveManifest, updateCarveManifest } from "../../terraform/manifest";
|
|
19
|
+
import { resolveEmitProvider } from "../../terraform/carve-provider";
|
|
18
20
|
|
|
19
21
|
export interface CarveApplyOptions {
|
|
20
22
|
from?: string;
|
|
@@ -48,6 +50,7 @@ export async function carveApply(opts: CarveApplyOptions): Promise<CarveApplyRes
|
|
|
48
50
|
if (!existsSync(opts.from) || !statSync(opts.from).isDirectory()) {
|
|
49
51
|
return { ok: false, error: `Not a directory: ${opts.from}` };
|
|
50
52
|
}
|
|
53
|
+
if (isCloudAssembly(opts.from)) return { ok: false, error: cdkNotSupported(opts.from, "apply") };
|
|
51
54
|
|
|
52
55
|
// Compose with the manifest emit/bridge persisted: it supplies the target
|
|
53
56
|
// (and tfstate) when --select is absent, and its persisted boundary stands in
|
|
@@ -63,7 +66,12 @@ export async function carveApply(opts: CarveApplyOptions): Promise<CarveApplyRes
|
|
|
63
66
|
const graph = await parseTerraformDir(opts.from, { statePath });
|
|
64
67
|
const report = boundaryReport(graph, select) ?? resolved.manifest?.boundary ?? null;
|
|
65
68
|
if (!report) return { ok: false, error: `${select} not found in ${opts.from}` };
|
|
66
|
-
|
|
69
|
+
// The carved type decides what the apply step even looks like: a
|
|
70
|
+
// CloudFormation deploy for aws, a cluster apply for k8s (#999). The
|
|
71
|
+
// manifest records the type; the graph has it when there is no manifest.
|
|
72
|
+
const tfType = resolved.manifest?.tfType ?? graph.nodes.find((n) => n.address === select)?.type;
|
|
73
|
+
const lexicon = tfType ? resolveEmitProvider(tfType)?.lexicon : undefined;
|
|
74
|
+
plan = graduationPlan(report, { stack: opts.stack, env: opts.env, lexicon });
|
|
67
75
|
} catch (err) {
|
|
68
76
|
if (err instanceof Hcl2JsonNotInstalled) return { ok: false, error: err.message };
|
|
69
77
|
return { ok: false, error: `Failed to plan graduation: ${err instanceof Error ? err.message : String(err)}` };
|
|
@@ -85,7 +93,15 @@ export async function carveApply(opts: CarveApplyOptions): Promise<CarveApplyRes
|
|
|
85
93
|
for (const file of files) {
|
|
86
94
|
if (!existsSync(file)) return { ok: false, error: `Emitted source not found: ${file}` };
|
|
87
95
|
const result = stampOwnershipIntoSource(readFileSync(file, "utf-8"), plan.ownershipTags);
|
|
88
|
-
if (!result)
|
|
96
|
+
if (!result) {
|
|
97
|
+
return {
|
|
98
|
+
ok: false,
|
|
99
|
+
error:
|
|
100
|
+
`No constructor to stamp in ${file}. Tag-based ownership is a property of a native constructor call; ` +
|
|
101
|
+
`on a Kubernetes object the marker is a label the k8s build merges in (defaultLabels), not a tag in the ` +
|
|
102
|
+
`emitted source. Re-run without --write-source to get the marker and the graduation plan.`,
|
|
103
|
+
};
|
|
104
|
+
}
|
|
89
105
|
if (result.changed) writeFileSync(file, result.content);
|
|
90
106
|
stamped.push(file);
|
|
91
107
|
}
|
|
@@ -114,7 +130,7 @@ function renderGraduationDoc(plan: GraduationPlan): string {
|
|
|
114
130
|
L.push("");
|
|
115
131
|
L.push(`Ownership marker: stack=${plan.marker.stack}${plan.marker.env ? `, env=${plan.marker.env}` : ""}`);
|
|
116
132
|
L.push("");
|
|
117
|
-
L.push(
|
|
133
|
+
L.push(`## Ownership ${plan.markerKind} (stamped on apply so chant owns the resource)`);
|
|
118
134
|
for (const [k, v] of Object.entries(plan.ownershipTags)) L.push(`- ${k} = ${v}`);
|
|
119
135
|
L.push("");
|
|
120
136
|
L.push("## Steps");
|
|
@@ -133,7 +149,7 @@ export function formatCarveApply(result: CarveApplyResult): string {
|
|
|
133
149
|
const L: string[] = [];
|
|
134
150
|
L.push(`Apply graduation for ${p.target} (dial-turn: observe → apply)${result.selectFromManifest ? " — target from the carve manifest" : ""}.`);
|
|
135
151
|
L.push(` Ownership marker: stack=${p.marker.stack}${p.marker.env ? `, env=${p.marker.env}` : ""}`);
|
|
136
|
-
L.push(
|
|
152
|
+
L.push(` Ownership ${p.markerKind} (stamped on apply):`);
|
|
137
153
|
for (const [k, v] of Object.entries(p.ownershipTags)) L.push(` ${k} = ${v}`);
|
|
138
154
|
L.push("");
|
|
139
155
|
for (const s of p.steps) L.push(` ${s}`);
|
|
@@ -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) => {
|
|
@@ -11,10 +11,11 @@
|
|
|
11
11
|
|
|
12
12
|
import { existsSync, statSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from "fs";
|
|
13
13
|
import { join, basename, relative, resolve } from "path";
|
|
14
|
+
import { cdkNotSupported, isCloudAssembly } from "../../cdk/assembly";
|
|
14
15
|
import { parseTerraformDir, Hcl2JsonNotInstalled } from "../../terraform/parse";
|
|
15
16
|
import { boundaryReport, type CarveReport } from "../../terraform/carve";
|
|
16
17
|
import { generateBridge, type BridgePlan, type CarvedIdentity } from "../../terraform/bridge";
|
|
17
|
-
import {
|
|
18
|
+
import { identityAttrOf, canBridge } from "../../terraform/tier-map";
|
|
18
19
|
import { resolveCarveManifest, writeCarveManifest, type CarveManifest } from "../../terraform/manifest";
|
|
19
20
|
import { newFileDiff, unifiedDiff } from "../../terraform/unified-diff";
|
|
20
21
|
|
|
@@ -57,6 +58,7 @@ export async function carveBridge(opts: CarveBridgeOptions): Promise<CarveBridge
|
|
|
57
58
|
if (!existsSync(opts.from) || !statSync(opts.from).isDirectory()) {
|
|
58
59
|
return { ok: false, error: `Not a directory: ${opts.from}` };
|
|
59
60
|
}
|
|
61
|
+
if (isCloudAssembly(opts.from)) return { ok: false, error: cdkNotSupported(opts.from, "bridge") };
|
|
60
62
|
|
|
61
63
|
// Compose with the carve state manifest `carve emit` persisted: without
|
|
62
64
|
// --select it supplies the target (and tfstate), with it it is updated below.
|
|
@@ -74,11 +76,31 @@ export async function carveBridge(opts: CarveBridgeOptions): Promise<CarveBridge
|
|
|
74
76
|
if (!found) return { ok: false, error: `${select} not found in ${opts.from}` };
|
|
75
77
|
report = found;
|
|
76
78
|
|
|
79
|
+
// Refuse a carve set whose data source cannot be written as valid HCL: a
|
|
80
|
+
// dotted identity attribute is a path into nested blocks, and a data body
|
|
81
|
+
// is flat `attr = value`. `kubernetes_manifest` is the standing case — the
|
|
82
|
+
// provider has no `kubernetes_manifest` data source either; its generic
|
|
83
|
+
// read is `kubernetes_resource`, a different shape. Emit adopting the type
|
|
84
|
+
// (#999) does not lift this: reading a manifest out of state and reading it
|
|
85
|
+
// back through a data source are separate problems (#2034).
|
|
86
|
+
const carvedTypes = report.carveSet.map((m) => m.type).filter((t): t is string => t !== undefined);
|
|
87
|
+
const unbridgeable = [...new Set(carvedTypes.filter((t) => !canBridge(t)))];
|
|
88
|
+
if (unbridgeable.length) {
|
|
89
|
+
return {
|
|
90
|
+
ok: false,
|
|
91
|
+
error:
|
|
92
|
+
`${select} cannot be bridged: ${unbridgeable
|
|
93
|
+
.map((t) => `${t} identifies itself by \`${identityAttrOf(t)}\``)
|
|
94
|
+
.join(", ")}, a path into nested blocks that a Terraform data source body cannot express. ` +
|
|
95
|
+
`Bridging these types needs a data-source mapping — see chant issue #2034.`,
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
|
|
77
99
|
// Physical identities for the carved resources, for the data sources.
|
|
78
100
|
const identities = new Map<string, CarvedIdentity>();
|
|
79
101
|
for (const node of graph.nodes) {
|
|
80
102
|
if (node.type && node.identity) {
|
|
81
|
-
identities.set(node.address, { attr:
|
|
103
|
+
identities.set(node.address, { attr: identityAttrOf(node.type), value: node.identity });
|
|
82
104
|
}
|
|
83
105
|
}
|
|
84
106
|
|