@thejoseki/clawform 2.3.1 → 2.3.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.
Files changed (48) hide show
  1. package/README.md +141 -120
  2. package/bin/clawform.js +161 -151
  3. package/package.json +65 -65
  4. package/src/aws/catalog.js +116 -116
  5. package/src/aws/changeset.js +231 -231
  6. package/src/aws/drift.js +59 -59
  7. package/src/aws/exports.js +213 -213
  8. package/src/aws/inventory.js +156 -156
  9. package/src/commands/activate.js +137 -105
  10. package/src/commands/ci-init.js +393 -212
  11. package/src/commands/cost-report.js +163 -163
  12. package/src/commands/deactivate.js +9 -2
  13. package/src/commands/deploy.js +413 -413
  14. package/src/commands/diff.js +39 -39
  15. package/src/commands/drift.js +35 -35
  16. package/src/commands/init-plugin.js +6 -6
  17. package/src/commands/init.js +360 -360
  18. package/src/commands/rollback.js +126 -126
  19. package/src/commands/status.js +147 -147
  20. package/src/commands/sync.js +50 -50
  21. package/src/commands/update.js +24 -0
  22. package/src/commands/validate.js +349 -349
  23. package/src/hooks/block-dangerous.js +62 -62
  24. package/src/hooks/cfn-lint-after-edit-eval.js +30 -30
  25. package/src/hooks/cfn-lint-after-edit.js +81 -81
  26. package/src/hooks/check-catalog-fresh-eval.js +63 -63
  27. package/src/hooks/check-catalog-fresh.js +33 -33
  28. package/src/hooks/session-context-eval.js +81 -81
  29. package/src/hooks/session-context.js +41 -41
  30. package/src/hooks/subagent-context-eval.js +44 -44
  31. package/src/hooks/subagent-context.js +48 -48
  32. package/src/lib/audit-synthesis.js +24 -24
  33. package/src/lib/aws-client.js +15 -15
  34. package/src/lib/cfn-yaml.js +92 -92
  35. package/src/lib/config.js +76 -76
  36. package/src/lib/constants.js +85 -85
  37. package/src/lib/defaults.js +16 -16
  38. package/src/lib/install-workflow.js +28 -28
  39. package/src/lib/kit-source.js +43 -5
  40. package/src/lib/license-client.js +84 -84
  41. package/src/lib/license-config.js +162 -162
  42. package/src/lib/license-store.js +43 -8
  43. package/src/lib/license.js +163 -150
  44. package/src/lib/lint-overrides.js +226 -226
  45. package/src/lib/logger.js +16 -16
  46. package/src/lib/project-config.js +145 -145
  47. package/src/lib/providers/polar.js +215 -215
  48. package/src/lib/session-state.js +91 -91
@@ -1,349 +1,349 @@
1
- import { readFile, writeFile, mkdtemp, rm } from 'node:fs/promises';
2
- import { existsSync } from 'node:fs';
3
- import { join, dirname } from 'node:path';
4
- import { fileURLToPath } from 'node:url';
5
- import { tmpdir } from 'node:os';
6
- import { execa } from 'execa';
7
- import { ValidateTemplateCommand } from '@aws-sdk/client-cloudformation';
8
- import { logger } from '../lib/logger.js';
9
- import { cfnClient } from '../lib/aws-client.js';
10
- import {
11
- loadOverrides,
12
- filterExpired,
13
- injectSuppressions,
14
- detectUnauthorizedSuppressions,
15
- } from '../lib/lint-overrides.js';
16
- import { assertLicensed } from '../lib/license.js';
17
- import { resolveClient as resolveLicenseClient } from '../lib/license-client.js';
18
-
19
- const LINT_LEVEL = { Error: 'E', Warning: 'W', Informational: 'I' };
20
- const OVERRIDES_PATH = join(process.cwd(), 'rules', 'lint-overrides.md');
21
- const PKG_ROOT = join(dirname(fileURLToPath(import.meta.url)), '..', '..');
22
-
23
- // cfn-guard rules ship with the clawform package. A consumer project that
24
- // installed clawform (plugin cache / node_modules) has no `rules.guard/` in
25
- // cwd — resolving relative to cwd used to make cfn-guard exit non-zero
26
- // ("missing rules dir"), which was counted as a CRITICAL and failed every
27
- // validate for exactly the installed-plugin case. Resolve from the package
28
- // root; a project-local `rules.guard/` (consumer-authored rules) wins when
29
- // present so consumers can extend without forking.
30
- export function resolveGuardRulesDir({ cwd = process.cwd(), pkgRoot = PKG_ROOT } = {}) {
31
- const local = join(cwd, 'rules.guard');
32
- if (existsSync(local)) return local;
33
- return join(pkgRoot, 'rules.guard');
34
- }
35
-
36
- export default async function validate({ file, skipAws = false }) {
37
- await assertLicensed({ command: 'validate', client: resolveLicenseClient() });
38
- logger.step(`clawform validate ${file}`);
39
- const body = await readFile(file, 'utf8');
40
-
41
- const lint = await runCfnLint(file);
42
- const nag = await runCfnNag(file);
43
-
44
- // Wave 3 overrides hook: load the audited allowlist, partition expired,
45
- // inject active suppressions into a temp copy for cfn-guard, then run the
46
- // anti-rot check against the ORIGINAL template (inline suppressions without
47
- // an overrides row are folded into the critical count and gate /deploy).
48
- const overrides = loadOverrides(OVERRIDES_PATH);
49
- const { active, expired } = filterExpired(overrides, new Date());
50
- for (const e of expired) {
51
- logger.warn(
52
- `lint-override expired ${e.ruleId} (scope=${e.scopeGlob}, expired ${e.expires.toISOString().slice(0, 10)}). ` +
53
- `Re-evaluate in rules/lint-overrides.md or remove the row.`,
54
- );
55
- }
56
-
57
- const { guard, unauthorized } = await runCfnGuardWithOverrides(file, body, active);
58
-
59
- for (const u of unauthorized) {
60
- logger.error(`unauthorized inline SuppressedRules: ${u.resourceId} suppresses ${u.ruleId} with no rules/lint-overrides.md row`);
61
- }
62
-
63
- const aws = skipAws ? null : await runValidateTemplate(body);
64
-
65
- const critical =
66
- (lint?.errors ?? 0) + (nag?.failures ?? 0) + (guard?.failures ?? 0) + (aws?.errors ?? 0)
67
- + unauthorized.length;
68
- const warnings = (lint?.warnings ?? 0) + (nag?.warnings ?? 0) + (guard?.warnings ?? 0);
69
- // A "skipped" tool contributes 0 errors AND 0 warnings under nullish-coalescing —
70
- // before this gate, a fresh dev box with no cfn-lint / cfn-nag installed would
71
- // print "All checks passed" exit 0 even though the only thing that ran was the
72
- // shallow CloudFormation ValidateTemplate. That silently bypasses every Layer-2
73
- // rule the rule files promise (W11/W12/F2/W2/W9/W35/W74/W2001/...).
74
- const skipped = [
75
- lint?.skipped ? 'cfn-lint' : null,
76
- nag?.skipped ? 'cfn-nag' : null,
77
- guard?.skipped ? 'cfn-guard' : null,
78
- !aws ? 'ValidateTemplate (--skip-aws)' : null,
79
- ].filter(Boolean);
80
-
81
- logger.info('');
82
- logger.info('Summary:');
83
- logger.info(` cfn-lint ${formatCount(lint)}`);
84
- logger.info(` cfn-nag ${formatCount(nag)}`);
85
- logger.info(` cfn-guard ${formatCount(guard)}`);
86
- logger.info(` ValidateTemplate ${formatCount(aws)}`);
87
- // Surface unauthorized inline suppressions in the Summary so a clean
88
- // lint/nag/guard run still gives the user a visible cause for exit 1.
89
- logger.info(` lint-overrides ${unauthorized.length === 0 ? '✓ clean' : `🔴 ${unauthorized.length} unauthorized suppression(s)`}`);
90
-
91
- if (critical > 0) {
92
- logger.error(`${critical} CRITICAL finding(s). Fix before /deploy.`);
93
- process.exit(1);
94
- } else if (warnings > 0) {
95
- logger.warn(`${warnings} warning(s). Review then proceed.`);
96
- } else if (skipped.length > 0) {
97
- // Don't print "All checks passed" when we didn't actually run every check.
98
- logger.warn(
99
- `Partial pass — ${skipped.length} tool(s) skipped: ${skipped.join(', ')}. ` +
100
- `Install missing tools (pip install cfn-lint / gem install cfn-nag / cargo install cfn-guard --locked) before relying on this as a deploy gate.`,
101
- );
102
- } else {
103
- logger.ok('All checks passed.');
104
- }
105
- }
106
-
107
- function formatCount(r) {
108
- if (!r) return '⊘ skipped';
109
- if (r.skipped) return `⊘ skipped (${r.skipReason})`;
110
- const e = r.errors ?? r.failures ?? 0;
111
- const w = r.warnings ?? 0;
112
- if (e === 0 && w === 0) return '✓ clean';
113
- return `${e ? '🔴' : '🟡'} ${e} error(s), ${w} warning(s)`;
114
- }
115
-
116
- // Exported for unit tests (validate-runners.test.js). The default export is
117
- // still the orchestrator — these helpers stay internal to validate(), but the
118
- // runner tests need to drive them directly with a mocked execa.
119
- export async function runCfnLint(file) {
120
- const result = await execa('cfn-lint', ['--format', 'json', file], { reject: false });
121
- if (toolMissing(result)) {
122
- logger.warn('cfn-lint not found. Install: pip install cfn-lint');
123
- return { skipped: true, skipReason: 'not installed' };
124
- }
125
- const stdout = result.stdout ?? '';
126
- const stderr = result.stderr ?? '';
127
-
128
- // M4 — cfn-lint exits non-zero on rule-engine errors (bad template path,
129
- // unrecognized flag, broken plugin). Previously we fell through to JSON.parse
130
- // and either crashed (M5) or silently returned 0/0. Surface a real error so
131
- // /deploy is gated. cfn-lint ALSO exits non-zero when it just found
132
- // findings — those are handled below via parsed JSON, so we only fail loud
133
- // when stdout has no parseable findings AND stderr looks like a tool error.
134
- // Empty stdout is valid (no findings) — short-circuit before the JSON path.
135
- if (!stdout.trim()) {
136
- if (result.failed && stderr.trim()) {
137
- const firstLine = stderr.split(/\r?\n/)[0].trim();
138
- return { errors: 1, warnings: 0, message: `cfn-lint exited non-zero: ${firstLine}` };
139
- }
140
- return { errors: 0, warnings: 0 };
141
- }
142
-
143
- // M5 — guard JSON.parse so a malformed stdout (rule-engine crashed mid-output,
144
- // or cfn-lint printed a non-JSON banner) surfaces as a finding rather than
145
- // crashing the whole validate command.
146
- let findings;
147
- try {
148
- findings = JSON.parse(stdout);
149
- } catch {
150
- const preview = stdout.slice(0, 100).replace(/\s+/g, ' ');
151
- return {
152
- errors: 1,
153
- warnings: 0,
154
- message: `cfn-lint returned non-JSON output (length ${stdout.length}): ${preview}`,
155
- };
156
- }
157
- // P1 — JSON.parse can succeed on shapes that aren't a findings array
158
- // (e.g. cfn-lint plugin returning `{ "error": "..." }`, or stdout being
159
- // a bare `null`/string/number from a wrapped script). Surface as a
160
- // warning so /deploy is not silently gated on a shape we can't iterate.
161
- if (!Array.isArray(findings)) {
162
- logger.warn(`cfn-lint returned unexpected JSON shape: ${typeof findings}`);
163
- return { errors: 0, warnings: 1 };
164
- }
165
- let errors = 0;
166
- let warnings = 0;
167
- for (const f of findings) {
168
- const lvl = LINT_LEVEL[f.Level] ?? 'I';
169
- if (lvl === 'E') {
170
- errors++;
171
- logger.error(`cfn-lint ${f.Rule?.Id ?? ''} ${f.Location?.Start ? `L${f.Location.Start.LineNumber}` : ''}: ${f.Message}`);
172
- } else if (lvl === 'W') {
173
- warnings++;
174
- logger.warn(`cfn-lint ${f.Rule?.Id ?? ''}: ${f.Message}`);
175
- }
176
- }
177
- return { errors, warnings };
178
- }
179
-
180
- export async function runCfnNag(file) {
181
- const cmd = process.platform === 'win32' ? 'cfn_nag_scan.bat' : 'cfn_nag_scan';
182
- // P3 — use the short form `-o json` for the output-format flag. Both `-o`
183
- // and `--output-format` are valid cfn-nag flags, but the test suite asserts
184
- // the short form so that a future refactor doesn't silently drop into
185
- // cfn-nag's default text output (which would trip the M5 JSON.parse catch
186
- // as a false-positive critical finding on every run).
187
- const result = await execa(cmd, ['--input-path', file, '-o', 'json'], { reject: false });
188
- if (toolMissing(result)) {
189
- logger.warn('cfn-nag not found. Install: gem install cfn-nag');
190
- return { skipped: true, skipReason: 'not installed' };
191
- }
192
- const stdout = result.stdout ?? '';
193
- const stderr = result.stderr ?? '';
194
-
195
- // M4 — cfn-nag exits non-zero when violations are found AND when the tool
196
- // itself fails (broken ruby env, malformed template path). cfn-nag with no
197
- // findings emits a JSON array, so empty stdout typically means the tool
198
- // crashed before producing output — surface that loudly instead of masking
199
- // it as failures: 0.
200
- if (!stdout.trim()) {
201
- if (result.failed && stderr.trim()) {
202
- const firstLine = stderr.split(/\r?\n/)[0].trim();
203
- return { failures: 1, warnings: 0, message: `cfn-nag exited non-zero: ${firstLine}` };
204
- }
205
- // P2 — exit 0 with empty stdout is suspicious: even a clean cfn-nag run
206
- // emits `[]` (or the per-file JSON array). True empty stdout usually means
207
- // a wrapper/redirect ate the output. Emit an observability nudge but
208
- // don't false-fail — we already know the tool exited clean.
209
- logger.warn('cfn-nag produced no output (expected JSON findings array, empty)');
210
- return { failures: 0, warnings: 0 };
211
- }
212
-
213
- // M5 — guard JSON.parse so a malformed stdout (ruby stack-trace, banner
214
- // text from a flag mismatch) surfaces as a finding rather than crashing
215
- // the whole validate command.
216
- let parsed;
217
- try {
218
- parsed = JSON.parse(stdout);
219
- } catch {
220
- const preview = stdout.slice(0, 100).replace(/\s+/g, ' ');
221
- return {
222
- failures: 1,
223
- warnings: 0,
224
- message: `cfn-nag returned non-JSON output (length ${stdout.length}): ${preview}`,
225
- };
226
- }
227
- // P1 — even with successful JSON parse, the shape must be the per-file
228
- // findings array. cfn-nag has historically returned `{ "violations": [...] }`
229
- // in some wrappers and a bare object in error cases; either crashes the
230
- // for..of below. Surface as a warning instead of an unhandled TypeError.
231
- if (!Array.isArray(parsed)) {
232
- logger.warn(`cfn-nag returned unexpected JSON shape: ${typeof parsed}`);
233
- return { failures: 0, warnings: 1 };
234
- }
235
- let failures = 0;
236
- let warnings = 0;
237
- for (const entry of parsed) {
238
- for (const v of entry.file_results?.violations ?? []) {
239
- if (v.type === 'FAIL') {
240
- failures++;
241
- logger.error(`cfn-nag ${v.id}: ${v.message} (${v.logical_resource_ids?.join(', ') ?? ''})`);
242
- } else if (v.type === 'WARN') {
243
- warnings++;
244
- logger.warn(`cfn-nag ${v.id}: ${v.message}`);
245
- }
246
- }
247
- }
248
- return { failures, warnings };
249
- }
250
-
251
- // Wraps runCfnGuard with the Wave 3 overrides flow: when there are no active
252
- // overrides, run cfn-guard against the original file (preserves the test
253
- // surface of runCfnGuard). When there are, write the injected copy to a
254
- // tmpdir and run cfn-guard against that temp path; clean up regardless.
255
- // Also runs the anti-rot detector against the ORIGINAL template body.
256
- async function runCfnGuardWithOverrides(file, body, activeOverrides) {
257
- const unauthorized = detectUnauthorizedSuppressions(body, activeOverrides);
258
- if (activeOverrides.length === 0) {
259
- return { guard: await runCfnGuard(file), unauthorized };
260
- }
261
- const injected = injectSuppressions(body, activeOverrides);
262
- const dir = await mkdtemp(join(tmpdir(), 'clawform-validate-'));
263
- const tempPath = join(dir, 'template.yaml');
264
- try {
265
- await writeFile(tempPath, injected, 'utf8');
266
- const guard = await runCfnGuard(tempPath);
267
- return { guard, unauthorized };
268
- } finally {
269
- await rm(dir, { recursive: true, force: true });
270
- }
271
- }
272
-
273
- async function runCfnGuard(file) {
274
- // Probe for the binary first so a missing install yields a clean "skipped"
275
- // instead of confusing rule-engine output.
276
- const probe = await execa('cfn-guard', ['--version'], { reject: false });
277
- if (toolMissing(probe)) {
278
- logger.warn('cfn-guard not found. Install: cargo install cfn-guard --locked');
279
- return { skipped: true, skipReason: 'not installed' };
280
- }
281
-
282
- const result = await execa(
283
- 'cfn-guard',
284
- [
285
- 'validate',
286
- '-d',
287
- file,
288
- '-r',
289
- resolveGuardRulesDir(),
290
- '--show-summary',
291
- 'all',
292
- '--output-format',
293
- 'single-line-summary',
294
- ],
295
- { reject: false },
296
- );
297
-
298
- const stdout = result.stdout ?? '';
299
- const stderr = result.stderr ?? '';
300
-
301
- // cfn-guard exits non-zero when any rule fails. Each failing rule line is
302
- // surfaced as a critical finding; cfn-guard 3.x does not have a "warning"
303
- // severity, so everything that fires is counted as a failure.
304
- let failures = 0;
305
- const warnings = 0;
306
- if (result.exitCode !== 0) {
307
- const lines = stdout.split(/\r?\n/).filter((l) => l.trim());
308
- for (const line of lines) {
309
- // single-line-summary emits one line per rule outcome; only count FAIL.
310
- if (/\bFAIL\b/i.test(line) || /Status\s*=\s*FAIL/i.test(line)) {
311
- failures++;
312
- logger.error(`cfn-guard ${line.trim()}`);
313
- }
314
- }
315
- if (failures === 0) {
316
- // Non-zero exit but no parseable FAIL line — surface raw output so the
317
- // user can debug rule-engine errors (bad rule syntax, missing rules dir).
318
- failures = 1;
319
- logger.error(`cfn-guard: ${stderr.trim() || stdout.trim() || 'non-zero exit'}`);
320
- }
321
- }
322
- return { failures, warnings };
323
- }
324
-
325
- function toolMissing(r) {
326
- if (!r.failed) return false;
327
- const stderr = r.stderr ?? '';
328
- const msg = r.shortMessage ?? r.message ?? '';
329
- // Linux/Mac: spawn ENOENT. Windows cmd: "is not recognized as an internal or external command".
330
- return r.code === 'ENOENT'
331
- || r.errno === -2
332
- || /\bENOENT\b/i.test(msg)
333
- || /is not recognized as an internal or external command/i.test(stderr)
334
- || /command not found/i.test(stderr);
335
- }
336
-
337
- async function runValidateTemplate(body) {
338
- try {
339
- const client = cfnClient();
340
- const out = await client.send(new ValidateTemplateCommand({ TemplateBody: body }));
341
- if (out.Capabilities?.length) {
342
- logger.info(`Template requires capabilities: ${out.Capabilities.join(', ')} — pass via --capabilities on deploy.`);
343
- }
344
- return { errors: 0, warnings: 0 };
345
- } catch (err) {
346
- logger.error(`ValidateTemplate: ${err.message}`);
347
- return { errors: 1, warnings: 0 };
348
- }
349
- }
1
+ import { readFile, writeFile, mkdtemp, rm } from 'node:fs/promises';
2
+ import { existsSync } from 'node:fs';
3
+ import { join, dirname } from 'node:path';
4
+ import { fileURLToPath } from 'node:url';
5
+ import { tmpdir } from 'node:os';
6
+ import { execa } from 'execa';
7
+ import { ValidateTemplateCommand } from '@aws-sdk/client-cloudformation';
8
+ import { logger } from '../lib/logger.js';
9
+ import { cfnClient } from '../lib/aws-client.js';
10
+ import {
11
+ loadOverrides,
12
+ filterExpired,
13
+ injectSuppressions,
14
+ detectUnauthorizedSuppressions,
15
+ } from '../lib/lint-overrides.js';
16
+ import { assertLicensed } from '../lib/license.js';
17
+ import { resolveClient as resolveLicenseClient } from '../lib/license-client.js';
18
+
19
+ const LINT_LEVEL = { Error: 'E', Warning: 'W', Informational: 'I' };
20
+ const OVERRIDES_PATH = join(process.cwd(), 'rules', 'lint-overrides.md');
21
+ const PKG_ROOT = join(dirname(fileURLToPath(import.meta.url)), '..', '..');
22
+
23
+ // cfn-guard rules ship with the clawform package. A consumer project that
24
+ // installed clawform (plugin cache / node_modules) has no `rules.guard/` in
25
+ // cwd — resolving relative to cwd used to make cfn-guard exit non-zero
26
+ // ("missing rules dir"), which was counted as a CRITICAL and failed every
27
+ // validate for exactly the installed-plugin case. Resolve from the package
28
+ // root; a project-local `rules.guard/` (consumer-authored rules) wins when
29
+ // present so consumers can extend without forking.
30
+ export function resolveGuardRulesDir({ cwd = process.cwd(), pkgRoot = PKG_ROOT } = {}) {
31
+ const local = join(cwd, 'rules.guard');
32
+ if (existsSync(local)) return local;
33
+ return join(pkgRoot, 'rules.guard');
34
+ }
35
+
36
+ export default async function validate({ file, skipAws = false }) {
37
+ await assertLicensed({ command: 'validate', client: resolveLicenseClient() });
38
+ logger.step(`clawform validate ${file}`);
39
+ const body = await readFile(file, 'utf8');
40
+
41
+ const lint = await runCfnLint(file);
42
+ const nag = await runCfnNag(file);
43
+
44
+ // Wave 3 overrides hook: load the audited allowlist, partition expired,
45
+ // inject active suppressions into a temp copy for cfn-guard, then run the
46
+ // anti-rot check against the ORIGINAL template (inline suppressions without
47
+ // an overrides row are folded into the critical count and gate /deploy).
48
+ const overrides = loadOverrides(OVERRIDES_PATH);
49
+ const { active, expired } = filterExpired(overrides, new Date());
50
+ for (const e of expired) {
51
+ logger.warn(
52
+ `lint-override expired ${e.ruleId} (scope=${e.scopeGlob}, expired ${e.expires.toISOString().slice(0, 10)}). ` +
53
+ `Re-evaluate in rules/lint-overrides.md or remove the row.`,
54
+ );
55
+ }
56
+
57
+ const { guard, unauthorized } = await runCfnGuardWithOverrides(file, body, active);
58
+
59
+ for (const u of unauthorized) {
60
+ logger.error(`unauthorized inline SuppressedRules: ${u.resourceId} suppresses ${u.ruleId} with no rules/lint-overrides.md row`);
61
+ }
62
+
63
+ const aws = skipAws ? null : await runValidateTemplate(body);
64
+
65
+ const critical =
66
+ (lint?.errors ?? 0) + (nag?.failures ?? 0) + (guard?.failures ?? 0) + (aws?.errors ?? 0)
67
+ + unauthorized.length;
68
+ const warnings = (lint?.warnings ?? 0) + (nag?.warnings ?? 0) + (guard?.warnings ?? 0);
69
+ // A "skipped" tool contributes 0 errors AND 0 warnings under nullish-coalescing —
70
+ // before this gate, a fresh dev box with no cfn-lint / cfn-nag installed would
71
+ // print "All checks passed" exit 0 even though the only thing that ran was the
72
+ // shallow CloudFormation ValidateTemplate. That silently bypasses every Layer-2
73
+ // rule the rule files promise (W11/W12/F2/W2/W9/W35/W74/W2001/...).
74
+ const skipped = [
75
+ lint?.skipped ? 'cfn-lint' : null,
76
+ nag?.skipped ? 'cfn-nag' : null,
77
+ guard?.skipped ? 'cfn-guard' : null,
78
+ !aws ? 'ValidateTemplate (--skip-aws)' : null,
79
+ ].filter(Boolean);
80
+
81
+ logger.info('');
82
+ logger.info('Summary:');
83
+ logger.info(` cfn-lint ${formatCount(lint)}`);
84
+ logger.info(` cfn-nag ${formatCount(nag)}`);
85
+ logger.info(` cfn-guard ${formatCount(guard)}`);
86
+ logger.info(` ValidateTemplate ${formatCount(aws)}`);
87
+ // Surface unauthorized inline suppressions in the Summary so a clean
88
+ // lint/nag/guard run still gives the user a visible cause for exit 1.
89
+ logger.info(` lint-overrides ${unauthorized.length === 0 ? '✓ clean' : `🔴 ${unauthorized.length} unauthorized suppression(s)`}`);
90
+
91
+ if (critical > 0) {
92
+ logger.error(`${critical} CRITICAL finding(s). Fix before /deploy.`);
93
+ process.exit(1);
94
+ } else if (warnings > 0) {
95
+ logger.warn(`${warnings} warning(s). Review then proceed.`);
96
+ } else if (skipped.length > 0) {
97
+ // Don't print "All checks passed" when we didn't actually run every check.
98
+ logger.warn(
99
+ `Partial pass — ${skipped.length} tool(s) skipped: ${skipped.join(', ')}. ` +
100
+ `Install missing tools (pip install cfn-lint / gem install cfn-nag / cargo install cfn-guard --locked) before relying on this as a deploy gate.`,
101
+ );
102
+ } else {
103
+ logger.ok('All checks passed.');
104
+ }
105
+ }
106
+
107
+ function formatCount(r) {
108
+ if (!r) return '⊘ skipped';
109
+ if (r.skipped) return `⊘ skipped (${r.skipReason})`;
110
+ const e = r.errors ?? r.failures ?? 0;
111
+ const w = r.warnings ?? 0;
112
+ if (e === 0 && w === 0) return '✓ clean';
113
+ return `${e ? '🔴' : '🟡'} ${e} error(s), ${w} warning(s)`;
114
+ }
115
+
116
+ // Exported for unit tests (validate-runners.test.js). The default export is
117
+ // still the orchestrator — these helpers stay internal to validate(), but the
118
+ // runner tests need to drive them directly with a mocked execa.
119
+ export async function runCfnLint(file) {
120
+ const result = await execa('cfn-lint', ['--format', 'json', file], { reject: false });
121
+ if (toolMissing(result)) {
122
+ logger.warn('cfn-lint not found. Install: pip install cfn-lint');
123
+ return { skipped: true, skipReason: 'not installed' };
124
+ }
125
+ const stdout = result.stdout ?? '';
126
+ const stderr = result.stderr ?? '';
127
+
128
+ // M4 — cfn-lint exits non-zero on rule-engine errors (bad template path,
129
+ // unrecognized flag, broken plugin). Previously we fell through to JSON.parse
130
+ // and either crashed (M5) or silently returned 0/0. Surface a real error so
131
+ // /deploy is gated. cfn-lint ALSO exits non-zero when it just found
132
+ // findings — those are handled below via parsed JSON, so we only fail loud
133
+ // when stdout has no parseable findings AND stderr looks like a tool error.
134
+ // Empty stdout is valid (no findings) — short-circuit before the JSON path.
135
+ if (!stdout.trim()) {
136
+ if (result.failed && stderr.trim()) {
137
+ const firstLine = stderr.split(/\r?\n/)[0].trim();
138
+ return { errors: 1, warnings: 0, message: `cfn-lint exited non-zero: ${firstLine}` };
139
+ }
140
+ return { errors: 0, warnings: 0 };
141
+ }
142
+
143
+ // M5 — guard JSON.parse so a malformed stdout (rule-engine crashed mid-output,
144
+ // or cfn-lint printed a non-JSON banner) surfaces as a finding rather than
145
+ // crashing the whole validate command.
146
+ let findings;
147
+ try {
148
+ findings = JSON.parse(stdout);
149
+ } catch {
150
+ const preview = stdout.slice(0, 100).replace(/\s+/g, ' ');
151
+ return {
152
+ errors: 1,
153
+ warnings: 0,
154
+ message: `cfn-lint returned non-JSON output (length ${stdout.length}): ${preview}`,
155
+ };
156
+ }
157
+ // P1 — JSON.parse can succeed on shapes that aren't a findings array
158
+ // (e.g. cfn-lint plugin returning `{ "error": "..." }`, or stdout being
159
+ // a bare `null`/string/number from a wrapped script). Surface as a
160
+ // warning so /deploy is not silently gated on a shape we can't iterate.
161
+ if (!Array.isArray(findings)) {
162
+ logger.warn(`cfn-lint returned unexpected JSON shape: ${typeof findings}`);
163
+ return { errors: 0, warnings: 1 };
164
+ }
165
+ let errors = 0;
166
+ let warnings = 0;
167
+ for (const f of findings) {
168
+ const lvl = LINT_LEVEL[f.Level] ?? 'I';
169
+ if (lvl === 'E') {
170
+ errors++;
171
+ logger.error(`cfn-lint ${f.Rule?.Id ?? ''} ${f.Location?.Start ? `L${f.Location.Start.LineNumber}` : ''}: ${f.Message}`);
172
+ } else if (lvl === 'W') {
173
+ warnings++;
174
+ logger.warn(`cfn-lint ${f.Rule?.Id ?? ''}: ${f.Message}`);
175
+ }
176
+ }
177
+ return { errors, warnings };
178
+ }
179
+
180
+ export async function runCfnNag(file) {
181
+ const cmd = process.platform === 'win32' ? 'cfn_nag_scan.bat' : 'cfn_nag_scan';
182
+ // P3 — use the short form `-o json` for the output-format flag. Both `-o`
183
+ // and `--output-format` are valid cfn-nag flags, but the test suite asserts
184
+ // the short form so that a future refactor doesn't silently drop into
185
+ // cfn-nag's default text output (which would trip the M5 JSON.parse catch
186
+ // as a false-positive critical finding on every run).
187
+ const result = await execa(cmd, ['--input-path', file, '-o', 'json'], { reject: false });
188
+ if (toolMissing(result)) {
189
+ logger.warn('cfn-nag not found. Install: gem install cfn-nag');
190
+ return { skipped: true, skipReason: 'not installed' };
191
+ }
192
+ const stdout = result.stdout ?? '';
193
+ const stderr = result.stderr ?? '';
194
+
195
+ // M4 — cfn-nag exits non-zero when violations are found AND when the tool
196
+ // itself fails (broken ruby env, malformed template path). cfn-nag with no
197
+ // findings emits a JSON array, so empty stdout typically means the tool
198
+ // crashed before producing output — surface that loudly instead of masking
199
+ // it as failures: 0.
200
+ if (!stdout.trim()) {
201
+ if (result.failed && stderr.trim()) {
202
+ const firstLine = stderr.split(/\r?\n/)[0].trim();
203
+ return { failures: 1, warnings: 0, message: `cfn-nag exited non-zero: ${firstLine}` };
204
+ }
205
+ // P2 — exit 0 with empty stdout is suspicious: even a clean cfn-nag run
206
+ // emits `[]` (or the per-file JSON array). True empty stdout usually means
207
+ // a wrapper/redirect ate the output. Emit an observability nudge but
208
+ // don't false-fail — we already know the tool exited clean.
209
+ logger.warn('cfn-nag produced no output (expected JSON findings array, empty)');
210
+ return { failures: 0, warnings: 0 };
211
+ }
212
+
213
+ // M5 — guard JSON.parse so a malformed stdout (ruby stack-trace, banner
214
+ // text from a flag mismatch) surfaces as a finding rather than crashing
215
+ // the whole validate command.
216
+ let parsed;
217
+ try {
218
+ parsed = JSON.parse(stdout);
219
+ } catch {
220
+ const preview = stdout.slice(0, 100).replace(/\s+/g, ' ');
221
+ return {
222
+ failures: 1,
223
+ warnings: 0,
224
+ message: `cfn-nag returned non-JSON output (length ${stdout.length}): ${preview}`,
225
+ };
226
+ }
227
+ // P1 — even with successful JSON parse, the shape must be the per-file
228
+ // findings array. cfn-nag has historically returned `{ "violations": [...] }`
229
+ // in some wrappers and a bare object in error cases; either crashes the
230
+ // for..of below. Surface as a warning instead of an unhandled TypeError.
231
+ if (!Array.isArray(parsed)) {
232
+ logger.warn(`cfn-nag returned unexpected JSON shape: ${typeof parsed}`);
233
+ return { failures: 0, warnings: 1 };
234
+ }
235
+ let failures = 0;
236
+ let warnings = 0;
237
+ for (const entry of parsed) {
238
+ for (const v of entry.file_results?.violations ?? []) {
239
+ if (v.type === 'FAIL') {
240
+ failures++;
241
+ logger.error(`cfn-nag ${v.id}: ${v.message} (${v.logical_resource_ids?.join(', ') ?? ''})`);
242
+ } else if (v.type === 'WARN') {
243
+ warnings++;
244
+ logger.warn(`cfn-nag ${v.id}: ${v.message}`);
245
+ }
246
+ }
247
+ }
248
+ return { failures, warnings };
249
+ }
250
+
251
+ // Wraps runCfnGuard with the Wave 3 overrides flow: when there are no active
252
+ // overrides, run cfn-guard against the original file (preserves the test
253
+ // surface of runCfnGuard). When there are, write the injected copy to a
254
+ // tmpdir and run cfn-guard against that temp path; clean up regardless.
255
+ // Also runs the anti-rot detector against the ORIGINAL template body.
256
+ async function runCfnGuardWithOverrides(file, body, activeOverrides) {
257
+ const unauthorized = detectUnauthorizedSuppressions(body, activeOverrides);
258
+ if (activeOverrides.length === 0) {
259
+ return { guard: await runCfnGuard(file), unauthorized };
260
+ }
261
+ const injected = injectSuppressions(body, activeOverrides);
262
+ const dir = await mkdtemp(join(tmpdir(), 'clawform-validate-'));
263
+ const tempPath = join(dir, 'template.yaml');
264
+ try {
265
+ await writeFile(tempPath, injected, 'utf8');
266
+ const guard = await runCfnGuard(tempPath);
267
+ return { guard, unauthorized };
268
+ } finally {
269
+ await rm(dir, { recursive: true, force: true });
270
+ }
271
+ }
272
+
273
+ async function runCfnGuard(file) {
274
+ // Probe for the binary first so a missing install yields a clean "skipped"
275
+ // instead of confusing rule-engine output.
276
+ const probe = await execa('cfn-guard', ['--version'], { reject: false });
277
+ if (toolMissing(probe)) {
278
+ logger.warn('cfn-guard not found. Install: cargo install cfn-guard --locked');
279
+ return { skipped: true, skipReason: 'not installed' };
280
+ }
281
+
282
+ const result = await execa(
283
+ 'cfn-guard',
284
+ [
285
+ 'validate',
286
+ '-d',
287
+ file,
288
+ '-r',
289
+ resolveGuardRulesDir(),
290
+ '--show-summary',
291
+ 'all',
292
+ '--output-format',
293
+ 'single-line-summary',
294
+ ],
295
+ { reject: false },
296
+ );
297
+
298
+ const stdout = result.stdout ?? '';
299
+ const stderr = result.stderr ?? '';
300
+
301
+ // cfn-guard exits non-zero when any rule fails. Each failing rule line is
302
+ // surfaced as a critical finding; cfn-guard 3.x does not have a "warning"
303
+ // severity, so everything that fires is counted as a failure.
304
+ let failures = 0;
305
+ const warnings = 0;
306
+ if (result.exitCode !== 0) {
307
+ const lines = stdout.split(/\r?\n/).filter((l) => l.trim());
308
+ for (const line of lines) {
309
+ // single-line-summary emits one line per rule outcome; only count FAIL.
310
+ if (/\bFAIL\b/i.test(line) || /Status\s*=\s*FAIL/i.test(line)) {
311
+ failures++;
312
+ logger.error(`cfn-guard ${line.trim()}`);
313
+ }
314
+ }
315
+ if (failures === 0) {
316
+ // Non-zero exit but no parseable FAIL line — surface raw output so the
317
+ // user can debug rule-engine errors (bad rule syntax, missing rules dir).
318
+ failures = 1;
319
+ logger.error(`cfn-guard: ${stderr.trim() || stdout.trim() || 'non-zero exit'}`);
320
+ }
321
+ }
322
+ return { failures, warnings };
323
+ }
324
+
325
+ function toolMissing(r) {
326
+ if (!r.failed) return false;
327
+ const stderr = r.stderr ?? '';
328
+ const msg = r.shortMessage ?? r.message ?? '';
329
+ // Linux/Mac: spawn ENOENT. Windows cmd: "is not recognized as an internal or external command".
330
+ return r.code === 'ENOENT'
331
+ || r.errno === -2
332
+ || /\bENOENT\b/i.test(msg)
333
+ || /is not recognized as an internal or external command/i.test(stderr)
334
+ || /command not found/i.test(stderr);
335
+ }
336
+
337
+ async function runValidateTemplate(body) {
338
+ try {
339
+ const client = cfnClient();
340
+ const out = await client.send(new ValidateTemplateCommand({ TemplateBody: body }));
341
+ if (out.Capabilities?.length) {
342
+ logger.info(`Template requires capabilities: ${out.Capabilities.join(', ')} — pass via --capabilities on deploy.`);
343
+ }
344
+ return { errors: 0, warnings: 0 };
345
+ } catch (err) {
346
+ logger.error(`ValidateTemplate: ${err.message}`);
347
+ return { errors: 1, warnings: 0 };
348
+ }
349
+ }