@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.
Files changed (149) hide show
  1. package/dist/agents/checks.d.ts +35 -0
  2. package/dist/agents/checks.d.ts.map +1 -0
  3. package/dist/agents/discover.d.ts +86 -0
  4. package/dist/agents/discover.d.ts.map +1 -0
  5. package/dist/agents/importer.d.ts +46 -0
  6. package/dist/agents/importer.d.ts.map +1 -0
  7. package/dist/agents/index.d.ts +14 -0
  8. package/dist/agents/index.d.ts.map +1 -0
  9. package/dist/agents/types.d.ts +196 -0
  10. package/dist/agents/types.d.ts.map +1 -0
  11. package/dist/audit/catalog.d.ts +4 -1
  12. package/dist/audit/catalog.d.ts.map +1 -1
  13. package/dist/audit/report.d.ts +8 -0
  14. package/dist/audit/report.d.ts.map +1 -1
  15. package/dist/audit/rules-doc.d.ts.map +1 -1
  16. package/dist/cdk/advise.d.ts +29 -0
  17. package/dist/cdk/advise.d.ts.map +1 -0
  18. package/dist/cdk/assembly.d.ts +38 -0
  19. package/dist/cdk/assembly.d.ts.map +1 -0
  20. package/dist/cdk/graph.d.ts +68 -0
  21. package/dist/cdk/graph.d.ts.map +1 -0
  22. package/dist/cdk/tier-map.d.ts +44 -0
  23. package/dist/cdk/tier-map.d.ts.map +1 -0
  24. package/dist/cdk/types.d.ts +114 -0
  25. package/dist/cdk/types.d.ts.map +1 -0
  26. package/dist/cli/commands/audit-agents.d.ts +83 -0
  27. package/dist/cli/commands/audit-agents.d.ts.map +1 -0
  28. package/dist/cli/commands/carve-apply.d.ts.map +1 -1
  29. package/dist/cli/commands/carve-bridge.d.ts.map +1 -1
  30. package/dist/cli/commands/carve-emit.d.ts.map +1 -1
  31. package/dist/cli/commands/carve.d.ts +48 -6
  32. package/dist/cli/commands/carve.d.ts.map +1 -1
  33. package/dist/cli/commands/import-agents.d.ts +64 -0
  34. package/dist/cli/commands/import-agents.d.ts.map +1 -0
  35. package/dist/cli/handlers/carve-emit.d.ts.map +1 -1
  36. package/dist/cli/handlers/carve.d.ts +5 -4
  37. package/dist/cli/handlers/carve.d.ts.map +1 -1
  38. package/dist/cli/handlers/lifecycle.d.ts +10 -0
  39. package/dist/cli/handlers/lifecycle.d.ts.map +1 -1
  40. package/dist/cli/handlers/misc.d.ts.map +1 -1
  41. package/dist/cli/main.d.ts.map +1 -1
  42. package/dist/cli/registry.d.ts +15 -0
  43. package/dist/cli/registry.d.ts.map +1 -1
  44. package/dist/identity.d.ts +196 -0
  45. package/dist/identity.d.ts.map +1 -0
  46. package/dist/index.d.ts +1 -0
  47. package/dist/index.d.ts.map +1 -1
  48. package/dist/lexicon.d.ts +52 -0
  49. package/dist/lexicon.d.ts.map +1 -1
  50. package/dist/terraform/adopt-state.d.ts +17 -63
  51. package/dist/terraform/adopt-state.d.ts.map +1 -1
  52. package/dist/terraform/aws-resources.d.ts +1 -1
  53. package/dist/terraform/bridge.d.ts.map +1 -1
  54. package/dist/terraform/carve-provider.d.ts +142 -0
  55. package/dist/terraform/carve-provider.d.ts.map +1 -0
  56. package/dist/terraform/carve.d.ts +36 -3
  57. package/dist/terraform/carve.d.ts.map +1 -1
  58. package/dist/terraform/emit-source.d.ts +25 -0
  59. package/dist/terraform/emit-source.d.ts.map +1 -0
  60. package/dist/terraform/graduate.d.ts +11 -1
  61. package/dist/terraform/graduate.d.ts.map +1 -1
  62. package/dist/terraform/providers/aws.d.ts +19 -0
  63. package/dist/terraform/providers/aws.d.ts.map +1 -0
  64. package/dist/terraform/providers/gcp.d.ts +41 -0
  65. package/dist/terraform/providers/gcp.d.ts.map +1 -0
  66. package/dist/terraform/providers/index.d.ts +15 -0
  67. package/dist/terraform/providers/index.d.ts.map +1 -0
  68. package/dist/terraform/providers/kubernetes.d.ts +29 -0
  69. package/dist/terraform/providers/kubernetes.d.ts.map +1 -0
  70. package/dist/terraform/score.d.ts +70 -4
  71. package/dist/terraform/score.d.ts.map +1 -1
  72. package/dist/terraform/tier-map.d.ts +38 -26
  73. package/dist/terraform/tier-map.d.ts.map +1 -1
  74. package/dist/terraform/types.d.ts +6 -0
  75. package/dist/terraform/types.d.ts.map +1 -1
  76. package/dist/yaml.d.ts.map +1 -1
  77. package/package.json +6 -1
  78. package/src/agents/checks.test.ts +228 -0
  79. package/src/agents/checks.ts +429 -0
  80. package/src/agents/discover.test.ts +310 -0
  81. package/src/agents/discover.ts +939 -0
  82. package/src/agents/importer.ts +49 -0
  83. package/src/agents/index.ts +29 -0
  84. package/src/agents/types.ts +207 -0
  85. package/src/audit/catalog.ts +90 -1
  86. package/src/audit/report.ts +9 -1
  87. package/src/audit/rules-doc.ts +6 -0
  88. package/src/cdk/__fixtures__/cdk.out/AppStack.template.json +171 -0
  89. package/src/cdk/__fixtures__/cdk.out/DataStack.template.json +90 -0
  90. package/src/cdk/__fixtures__/cdk.out/cdk.out +1 -0
  91. package/src/cdk/__fixtures__/cdk.out/manifest.json +30 -0
  92. package/src/cdk/__fixtures__/cdk.out/tree.json +201 -0
  93. package/src/cdk/__fixtures__/cdk.out-dummy/LookupStack.template.json +29 -0
  94. package/src/cdk/__fixtures__/cdk.out-dummy/manifest.json +26 -0
  95. package/src/cdk/advise.test.ts +208 -0
  96. package/src/cdk/advise.ts +44 -0
  97. package/src/cdk/assembly.ts +133 -0
  98. package/src/cdk/graph.test.ts +206 -0
  99. package/src/cdk/graph.ts +525 -0
  100. package/src/cdk/tier-map.ts +71 -0
  101. package/src/cdk/types.ts +115 -0
  102. package/src/cli/commands/audit-agents.test.ts +260 -0
  103. package/src/cli/commands/audit-agents.ts +387 -0
  104. package/src/cli/commands/carve-apply.ts +20 -4
  105. package/src/cli/commands/carve-bridge.test.ts +30 -0
  106. package/src/cli/commands/carve-bridge.ts +24 -2
  107. package/src/cli/commands/carve-emit-k8s.test.ts +262 -0
  108. package/src/cli/commands/carve-emit-provider.test.ts +207 -0
  109. package/src/cli/commands/carve-emit.test.ts +72 -1
  110. package/src/cli/commands/carve-emit.ts +55 -28
  111. package/src/cli/commands/carve.ts +139 -36
  112. package/src/cli/commands/import-agents.test.ts +208 -0
  113. package/src/cli/commands/import-agents.ts +196 -0
  114. package/src/cli/handlers/carve-emit.ts +8 -1
  115. package/src/cli/handlers/carve.ts +8 -7
  116. package/src/cli/handlers/lifecycle.test.ts +187 -1
  117. package/src/cli/handlers/lifecycle.ts +125 -1
  118. package/src/cli/handlers/misc.ts +111 -0
  119. package/src/cli/main.ts +29 -5
  120. package/src/cli/registry.ts +15 -0
  121. package/src/identity.test.ts +199 -0
  122. package/src/identity.ts +346 -0
  123. package/src/index.ts +1 -0
  124. package/src/lexicon.ts +65 -0
  125. package/src/terraform/__fixtures__/gcp-estate/main.tf +60 -0
  126. package/src/terraform/adopt-state.test.ts +131 -0
  127. package/src/terraform/adopt-state.ts +22 -167
  128. package/src/terraform/aws-resources.test.ts +55 -16
  129. package/src/terraform/aws-resources.ts +1 -1
  130. package/src/terraform/bridge.test.ts +12 -0
  131. package/src/terraform/bridge.ts +4 -1
  132. package/src/terraform/carve-provider.test.ts +155 -0
  133. package/src/terraform/carve-provider.ts +237 -0
  134. package/src/terraform/carve.test.ts +55 -1
  135. package/src/terraform/carve.ts +0 -0
  136. package/src/terraform/emit-source.ts +39 -0
  137. package/src/terraform/graduate.test.ts +37 -0
  138. package/src/terraform/graduate.ts +55 -7
  139. package/src/terraform/graph.ts +3 -3
  140. package/src/terraform/providers/aws.ts +169 -0
  141. package/src/terraform/providers/gcp.test.ts +228 -0
  142. package/src/terraform/providers/gcp.ts +329 -0
  143. package/src/terraform/providers/index.ts +21 -0
  144. package/src/terraform/providers/kubernetes.ts +224 -0
  145. package/src/terraform/score.ts +111 -25
  146. package/src/terraform/tier-map.ts +55 -90
  147. package/src/terraform/types.ts +6 -0
  148. package/src/yaml.test.ts +54 -0
  149. 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
- plan = graduationPlan(report, { stack: opts.stack, env: opts.env });
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) return { ok: false, error: `No constructor to stamp in ${file}.` };
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("## Ownership tags (stamped on apply so chant owns the resource)");
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(" Ownership tags (stamped on apply):");
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 { IDENTITY_ATTR } from "../../terraform/tier-map";
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: IDENTITY_ATTR[node.type], value: node.identity });
103
+ identities.set(node.address, { attr: identityAttrOf(node.type), value: node.identity });
82
104
  }
83
105
  }
84
106