@thejoseki/clawform 0.0.1 → 2.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +95 -4
- package/README.md +93 -10
- package/bin/clawform.js +151 -16
- package/package.json +47 -11
- package/src/aws/catalog.js +116 -0
- package/src/aws/changeset.js +231 -0
- package/src/aws/drift.js +59 -0
- package/src/aws/exports.js +213 -0
- package/src/aws/inventory.js +156 -0
- package/src/commands/activate.js +105 -0
- package/src/commands/ci-init.js +212 -0
- package/src/commands/cost-report.js +166 -0
- package/src/commands/deactivate.js +36 -0
- package/src/commands/deploy.js +413 -0
- package/src/commands/diff.js +39 -0
- package/src/commands/drift.js +35 -0
- package/src/commands/init-plugin.js +168 -0
- package/src/commands/init.js +360 -0
- package/src/commands/license-status.js +28 -0
- package/src/commands/rollback.js +126 -0
- package/src/commands/status.js +150 -0
- package/src/commands/sync.js +53 -0
- package/src/commands/validate.js +349 -0
- package/src/hooks/block-dangerous-eval.js +160 -0
- package/src/hooks/block-dangerous.js +62 -0
- package/src/hooks/cfn-lint-after-edit-eval.js +30 -0
- package/src/hooks/cfn-lint-after-edit.js +81 -0
- package/src/hooks/check-catalog-fresh-eval.js +63 -0
- package/src/hooks/check-catalog-fresh.js +33 -0
- package/src/hooks/session-context-eval.js +81 -0
- package/src/hooks/session-context.js +41 -0
- package/src/hooks/subagent-context-eval.js +44 -0
- package/src/hooks/subagent-context.js +48 -0
- package/src/lib/audit-synthesis.js +24 -0
- package/src/lib/aws-client.js +15 -0
- package/src/lib/cfn-yaml.js +92 -0
- package/src/lib/config.js +76 -0
- package/src/lib/constants.js +85 -0
- package/src/lib/defaults.js +16 -0
- package/src/lib/fingerprint.js +65 -0
- package/src/lib/install-workflow.js +28 -0
- package/src/lib/kit-source.js +81 -0
- package/src/lib/license-client.js +84 -0
- package/src/lib/license-config.js +162 -0
- package/src/lib/license-store.js +107 -0
- package/src/lib/license.js +150 -0
- package/src/lib/lint-overrides.js +226 -0
- package/src/lib/logger.js +17 -0
- package/src/lib/payload.js +74 -0
- package/src/lib/project-config.js +145 -0
- package/src/lib/providers/polar.js +215 -0
- package/src/lib/rename-compat.js +50 -0
- package/src/lib/session-state.js +91 -0
- package/src/lib/trial-claim.js +65 -0
- package/src/lib/watermark.js +65 -0
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { mkdir, writeFile } from 'node:fs/promises';
|
|
2
|
+
import { dirname, join } from 'node:path';
|
|
3
|
+
import { fileURLToPath } from 'node:url';
|
|
4
|
+
import { logger } from '../lib/logger.js';
|
|
5
|
+
import { loadConfig } from '../lib/config.js';
|
|
6
|
+
import { loadProjectConfig, resolveProjectDir } from '../lib/project-config.js';
|
|
7
|
+
import { cfnClient } from '../lib/aws-client.js';
|
|
8
|
+
import { fetchExports, groupExports, renderCatalog } from '../aws/catalog.js';
|
|
9
|
+
import { fetchStacks, classifyStacks, renderInventory } from '../aws/inventory.js';
|
|
10
|
+
import { ensureWorkflowInstalled } from '../lib/install-workflow.js';
|
|
11
|
+
import { CATALOG_DIR_REL } from '../lib/constants.js';
|
|
12
|
+
import { assertLicensed } from '../lib/license.js';
|
|
13
|
+
import { resolveClient as resolveLicenseClient } from '../lib/license-client.js';
|
|
14
|
+
|
|
15
|
+
const REPO_ROOT = join(dirname(fileURLToPath(import.meta.url)), '..', '..');
|
|
16
|
+
|
|
17
|
+
export default async function sync(opts = {}) {
|
|
18
|
+
await assertLicensed({ command: 'sync', client: resolveLicenseClient() });
|
|
19
|
+
const cfg = loadConfig(opts);
|
|
20
|
+
logger.step('clawform sync — refresh catalog + inventory');
|
|
21
|
+
logger.info(`Profile: ${cfg.AWS_PROFILE} Region: ${cfg.AWS_REGION}`);
|
|
22
|
+
const client = cfnClient(opts);
|
|
23
|
+
|
|
24
|
+
const meta = {
|
|
25
|
+
generatedAt: new Date().toISOString(),
|
|
26
|
+
account: cfg.ACCOUNT_DEV || '(unknown)',
|
|
27
|
+
region: cfg.AWS_REGION,
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
logger.info('Fetching exports + stacks in parallel…');
|
|
31
|
+
const [exports, stacks] = await Promise.all([fetchExports(client), fetchStacks(client)]);
|
|
32
|
+
logger.ok(`Got ${exports.length} exports, ${stacks.length} active stacks`);
|
|
33
|
+
|
|
34
|
+
const projectDir = resolveProjectDir();
|
|
35
|
+
const catalogDir = join(projectDir, CATALOG_DIR_REL);
|
|
36
|
+
const projectCfg = loadProjectConfig({ projectDir });
|
|
37
|
+
const catalogMd = renderCatalog(groupExports(exports), { ...meta, total: exports.length });
|
|
38
|
+
const classified = classifyStacks(stacks, new Date(), projectCfg.legacy_prefixes);
|
|
39
|
+
const inventoryMd = renderInventory(classified, meta);
|
|
40
|
+
|
|
41
|
+
await mkdir(catalogDir, { recursive: true });
|
|
42
|
+
await writeFile(join(catalogDir, 'outputs-catalog.md'), catalogMd);
|
|
43
|
+
await writeFile(join(catalogDir, 'stacks-inventory.md'), inventoryMd);
|
|
44
|
+
logger.ok(`${CATALOG_DIR_REL}/outputs-catalog.md (${exports.length} exports)`);
|
|
45
|
+
logger.ok(
|
|
46
|
+
`${CATALOG_DIR_REL}/stacks-inventory.md (legacy=${classified.legacy.length}, serverless=${classified.serverless.length}, active=${classified.active.length}, stale=${classified.stale.length})`,
|
|
47
|
+
);
|
|
48
|
+
|
|
49
|
+
const workflowResult = ensureWorkflowInstalled({ repoRoot: REPO_ROOT, projectDir });
|
|
50
|
+
if (workflowResult.installed) {
|
|
51
|
+
logger.ok('.claude/workflows/audit-all-stacks.js installed — invoke it via `/workflows` or `/audit-all-stacks`');
|
|
52
|
+
}
|
|
53
|
+
}
|
|
@@ -0,0 +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
|
+
}
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
import { LEGACY_OVERRIDE_PHRASE } from '../lib/constants.js';
|
|
2
|
+
import { buildLegacyPrefixRegex } from '../lib/project-config.js';
|
|
3
|
+
|
|
4
|
+
// Running a script file hands the guard an opaque blob: the commands inside
|
|
5
|
+
// were written elsewhere and cannot be inspected. Warn rather than block —
|
|
6
|
+
// blocking every `bash x.sh` would refuse ordinary build scripts in any
|
|
7
|
+
// project, and a guard people switch off protects nothing.
|
|
8
|
+
const OPAQUE_SCRIPT_RE = /\b(?:ba|z)?sh\s+(?!-)[^\s;&|]*\.sh\b/i;
|
|
9
|
+
|
|
10
|
+
const MODIFYING_VERBS = /\b(update-stack|create-stack|create-change-set|execute-change-set|delete-stack|set-stack-policy|update-termination-protection|continue-update-rollback)\b/;
|
|
11
|
+
|
|
12
|
+
// MCP tool surface from `awslabs.aws-api-mcp-server`. Two naming forms:
|
|
13
|
+
// standalone server: mcp__<server-key>__<tool> e.g. mcp__aws-api-mcp-server__call_aws
|
|
14
|
+
// plugin-bundled: mcp__plugin_<plugin>_<server>__<tool>
|
|
15
|
+
// Wildcard covers both. Only `call_aws` mutates AWS; `suggest_aws_commands` is
|
|
16
|
+
// read-only LLM text generation and not intercepted.
|
|
17
|
+
//
|
|
18
|
+
// This is the AUTHORITATIVE gate: the PreToolUse matcher in hooks/hooks.json
|
|
19
|
+
// ("mcp__.*__call_aws") only decides which events get routed here; this regex
|
|
20
|
+
// re-checks the routed event so a too-broad matcher still fails safe.
|
|
21
|
+
const MCP_CALL_AWS_RE = /^mcp__.+__call_aws$/;
|
|
22
|
+
|
|
23
|
+
// Built-in blockers — always fire, regardless of consumer config. These are Clawform's
|
|
24
|
+
// invariant convictions, not customer-specific data.
|
|
25
|
+
const BUILTIN_BLOCKERS = [
|
|
26
|
+
{
|
|
27
|
+
hit: (c) => /\baws\s+cloudformation\s+delete-stack\b/i.test(c),
|
|
28
|
+
reason:
|
|
29
|
+
'Direct `aws cloudformation delete-stack` is blocked by Clawform. ' +
|
|
30
|
+
'Use the `/rollback` slash command (it inspects retained data resources first).',
|
|
31
|
+
},
|
|
32
|
+
{
|
|
33
|
+
hit: (c) => /\baws\s+s3\s+rb\b.*--force\b/i.test(c),
|
|
34
|
+
reason: 'Force-deleting S3 buckets is blocked. Empty + delete via the bucket\'s CloudFormation stack instead.',
|
|
35
|
+
},
|
|
36
|
+
// Destructive service-level deletes below bypass the stack lifecycle — and
|
|
37
|
+
// with it DeletionPolicy, changesets, and the confirm flow. Single-object /
|
|
38
|
+
// reversible variants (s3 rm of one key, delete-secret with its recovery
|
|
39
|
+
// window, kms disable-key, ec2 stop-instances) stay allowed.
|
|
40
|
+
{
|
|
41
|
+
hit: (c) => /\baws\s+s3\s+rm\b(?=.*--recursive\b)/i.test(c),
|
|
42
|
+
reason:
|
|
43
|
+
'Recursive S3 delete is blocked (same blast radius as `s3 rb --force`). ' +
|
|
44
|
+
'Manage the bucket through its CloudFormation stack; single-object `aws s3 rm` is allowed.',
|
|
45
|
+
},
|
|
46
|
+
{
|
|
47
|
+
hit: (c) => /\baws\s+rds\s+delete-db-(instance|cluster)\b/i.test(c),
|
|
48
|
+
reason:
|
|
49
|
+
'Deleting RDS instances/clusters directly is blocked — it sidesteps the stack\'s ' +
|
|
50
|
+
'DeletionPolicy/snapshot handling. Retire the resource through its CloudFormation stack (/rollback for stuck stacks).',
|
|
51
|
+
},
|
|
52
|
+
{
|
|
53
|
+
hit: (c) => /\baws\s+dynamodb\s+delete-table\b/i.test(c),
|
|
54
|
+
reason:
|
|
55
|
+
'Deleting DynamoDB tables directly is blocked. Remove the table through its ' +
|
|
56
|
+
'CloudFormation stack so DeletionPolicy applies; item-level deletes are allowed.',
|
|
57
|
+
},
|
|
58
|
+
{
|
|
59
|
+
hit: (c) => /\baws\s+secretsmanager\s+delete-secret\b(?=.*--force-delete-without-recovery\b)/i.test(c),
|
|
60
|
+
reason:
|
|
61
|
+
'Force-deleting a secret skips the 7-30 day recovery window. Plain `delete-secret` ' +
|
|
62
|
+
'(recoverable) is allowed; permanent removal goes through the secret\'s CloudFormation stack.',
|
|
63
|
+
},
|
|
64
|
+
{
|
|
65
|
+
hit: (c) => /\baws\s+kms\s+schedule-key-deletion\b/i.test(c),
|
|
66
|
+
reason:
|
|
67
|
+
'Scheduling KMS key deletion is blocked — everything encrypted under the key dies with it. ' +
|
|
68
|
+
'Use `kms disable-key` to take it out of service, and retire keys through their CloudFormation stack.',
|
|
69
|
+
},
|
|
70
|
+
{
|
|
71
|
+
hit: (c) => /\baws\s+ec2\s+terminate-instances\b/i.test(c),
|
|
72
|
+
reason:
|
|
73
|
+
'Terminating EC2 instances directly is blocked — stack-managed instances must go through ' +
|
|
74
|
+
'their CloudFormation stack. `ec2 stop-instances` is allowed.',
|
|
75
|
+
},
|
|
76
|
+
{
|
|
77
|
+
// Every iam delete-* subcommand: identity plumbing a stack created should
|
|
78
|
+
// die with its stack. Reads and reversible detach/remove stay allowed.
|
|
79
|
+
hit: (c) => /\baws\s+iam\s+delete-[a-z-]+/i.test(c),
|
|
80
|
+
reason:
|
|
81
|
+
'Deleting IAM resources directly is blocked — roles/policies/users a CloudFormation stack ' +
|
|
82
|
+
'created must be removed through that stack. Reads and `iam detach-*` are allowed.',
|
|
83
|
+
},
|
|
84
|
+
{
|
|
85
|
+
// The same destructive operations reached through an SDK instead of the
|
|
86
|
+
// CLI — boto3's delete_stack, the JS client's deleteStack, and friends.
|
|
87
|
+
// The parenthesis is what distinguishes a call from prose mentioning it.
|
|
88
|
+
hit: (c) => /\b(delete_stack|delete_db_instance|delete_db_cluster|delete_table|schedule_key_deletion|deleteStack|deleteDBInstance|deleteDBCluster|deleteTable|scheduleKeyDeletion)\s*\(/.test(c),
|
|
89
|
+
reason:
|
|
90
|
+
'Destructive AWS SDK calls are blocked for the same reason as the CLI equivalents — ' +
|
|
91
|
+
'they bypass the stack lifecycle. Remove the resource through its CloudFormation stack.',
|
|
92
|
+
},
|
|
93
|
+
{
|
|
94
|
+
hit: (c) => /\baws\s+s3api\s+delete-bucket\b/i.test(c),
|
|
95
|
+
reason:
|
|
96
|
+
'Deleting S3 buckets via s3api is blocked (same rule as `s3 rb`). Remove the bucket ' +
|
|
97
|
+
'through its CloudFormation stack; object-level `s3api delete-object` is allowed.',
|
|
98
|
+
},
|
|
99
|
+
];
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Pure evaluator for the PreToolUse hook. Returns { block, reason? }.
|
|
103
|
+
*
|
|
104
|
+
* @param event PreToolUse event from Claude Code (Bash or mcp__*__call_aws).
|
|
105
|
+
* @param config { legacyPrefixes: string[] } — the consumer's frozen-prefix list.
|
|
106
|
+
* Defaults to [] so existing built-in-blocker tests don't need rewiring
|
|
107
|
+
* and so a consumer with no clawform.config.json still gets the built-ins.
|
|
108
|
+
*/
|
|
109
|
+
export function evaluate(event, config = {}) {
|
|
110
|
+
const legacyPrefixes = Array.isArray(config.legacyPrefixes) ? config.legacyPrefixes : [];
|
|
111
|
+
const legacyRe = buildLegacyPrefixRegex(legacyPrefixes);
|
|
112
|
+
const legacyDocList = legacyPrefixes.map((p) => `${p}-*`).join(', ');
|
|
113
|
+
|
|
114
|
+
const commands = extractCommands(event);
|
|
115
|
+
let opaque = null;
|
|
116
|
+
for (const cmd of commands) {
|
|
117
|
+
for (const b of BUILTIN_BLOCKERS) {
|
|
118
|
+
if (b.hit(cmd)) return { block: true, reason: b.reason };
|
|
119
|
+
}
|
|
120
|
+
// Remembered, not returned yet: a block found on a later command must win
|
|
121
|
+
// over a warning found on an earlier one.
|
|
122
|
+
if (!opaque && OPAQUE_SCRIPT_RE.test(cmd)) opaque = cmd;
|
|
123
|
+
if (legacyRe && MODIFYING_VERBS.test(cmd) && legacyRe.test(cmd) && !LEGACY_OVERRIDE_PHRASE.test(cmd)) {
|
|
124
|
+
return {
|
|
125
|
+
block: true,
|
|
126
|
+
reason:
|
|
127
|
+
`Modify on a legacy stack (${legacyDocList}) detected — frozen by your clawform.config.json legacy_prefixes list ` +
|
|
128
|
+
'(see rules/legacy-freeze.md and docs/config-schema.md). ' +
|
|
129
|
+
'Read-only commands (describe-*, list-*) are fine. To override, prefix the command with `# FORCE LEGACY: <reason>` on its own line.',
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
if (opaque) {
|
|
135
|
+
return {
|
|
136
|
+
block: false,
|
|
137
|
+
warn: true,
|
|
138
|
+
reason:
|
|
139
|
+
`Clawform cannot read the contents of the script in "${opaque.trim()}" — commands inside a ` +
|
|
140
|
+
'script file are invisible to this guard. Review it before letting the agent run it. ' +
|
|
141
|
+
'(The guard inspects commands it can see; it is a deterrent, not a sandbox.)',
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
return { block: false };
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function extractCommands(event) {
|
|
148
|
+
const toolName = event?.tool_name;
|
|
149
|
+
if (toolName === 'Bash') {
|
|
150
|
+
const c = event?.tool_input?.command;
|
|
151
|
+
return typeof c === 'string' ? [c] : [];
|
|
152
|
+
}
|
|
153
|
+
if (typeof toolName === 'string' && MCP_CALL_AWS_RE.test(toolName)) {
|
|
154
|
+
const c = event?.tool_input?.cli_command;
|
|
155
|
+
if (typeof c === 'string') return [c];
|
|
156
|
+
if (Array.isArray(c)) return c.filter((x) => typeof x === 'string');
|
|
157
|
+
return [];
|
|
158
|
+
}
|
|
159
|
+
return [];
|
|
160
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Clawform PreToolUse hook — CLI entry. Reads JSON event from stdin, resolves
|
|
4
|
+
* the consumer's project root from the EVENT's `cwd` field (Claude Code sets
|
|
5
|
+
* this on every hook event), and delegates to the pure `evaluate` in
|
|
6
|
+
* block-dangerous-eval.js. Exits 2 with stderr on block.
|
|
7
|
+
*
|
|
8
|
+
* Why `event.cwd` instead of `process.env.CLAUDE_PROJECT_DIR`: the Claude Code
|
|
9
|
+
* hook docs (anthropic.com/docs/claude-code/hooks) document `cwd` as part of
|
|
10
|
+
* the stdin event payload but make no guarantee about CLAUDE_PROJECT_DIR being
|
|
11
|
+
* set in the hook process env, particularly when the hook is registered via a
|
|
12
|
+
* plugin's `hooks.json` (where `${CLAUDE_PLUGIN_ROOT}` substitutes into the
|
|
13
|
+
* command path but the runtime env is undocumented). Relying on the env var
|
|
14
|
+
* here risks a non-Acme consumer silently inheriting THIS repo's dogfood config
|
|
15
|
+
* — exactly the security bug the composable refactor exists to prevent.
|
|
16
|
+
* Fallback: process.env.CLAUDE_PROJECT_DIR → process.cwd() (the latter is
|
|
17
|
+
* useful for dev-mode invocations from the repo root).
|
|
18
|
+
*
|
|
19
|
+
* Fail-LOUD on event-parse error → write to stderr + exit 0 so a buggy hook
|
|
20
|
+
* never silently disables the safety net (the alternative — fail closed —
|
|
21
|
+
* would deny every Bash call on any unrelated parse problem).
|
|
22
|
+
*
|
|
23
|
+
* Fail-LOUD on config-load error → write to stderr + continue with an empty
|
|
24
|
+
* legacy-prefix list. Built-in blockers (delete-stack, s3 rb --force) still
|
|
25
|
+
* fire; only the legacy-freeze gate is dropped, which is the correct outcome
|
|
26
|
+
* when the consumer's clawform.config.json is broken or missing.
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
import { readFileSync } from 'node:fs';
|
|
30
|
+
import { evaluate } from './block-dangerous-eval.js';
|
|
31
|
+
import { loadProjectConfig } from '../lib/project-config.js';
|
|
32
|
+
|
|
33
|
+
let event;
|
|
34
|
+
try {
|
|
35
|
+
event = JSON.parse(readFileSync(0, 'utf8'));
|
|
36
|
+
} catch (err) {
|
|
37
|
+
process.stderr.write(`[clawform hook] event parse failed (allowing tool call): ${err.message}\n`);
|
|
38
|
+
process.exit(0);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const projectDir = (typeof event?.cwd === 'string' && event.cwd) || process.env.CLAUDE_PROJECT_DIR || process.cwd();
|
|
42
|
+
|
|
43
|
+
let legacyPrefixes = [];
|
|
44
|
+
try {
|
|
45
|
+
const cfg = loadProjectConfig({ projectDir });
|
|
46
|
+
legacyPrefixes = cfg.legacy_prefixes || [];
|
|
47
|
+
} catch (err) {
|
|
48
|
+
process.stderr.write(`[clawform hook] consumer config load failed at ${projectDir} (legacy-freeze gate disabled, built-ins still fire): ${err.message}\n`);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const { block, warn, reason } = evaluate(event, { legacyPrefixes });
|
|
52
|
+
if (block) {
|
|
53
|
+
process.stderr.write(`[clawform hook] ${reason}\n`);
|
|
54
|
+
process.exit(2);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// Advisory: the command runs, but the operator is told what the guard could
|
|
58
|
+
// not see. Same posture as the catalog-freshness hook.
|
|
59
|
+
if (warn) {
|
|
60
|
+
process.stderr.write(`[clawform hook] ${reason}\n`);
|
|
61
|
+
process.exit(0);
|
|
62
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
// Pure decision logic for the PostToolUse cfn-lint hook. Given a
|
|
2
|
+
// PostToolUse event, returns { lint, file?, reason? } so the CLI shim
|
|
3
|
+
// knows whether (and on which file) to spawn cfn-lint. Exported separately
|
|
4
|
+
// so unit tests don't need to spawn anything.
|
|
5
|
+
//
|
|
6
|
+
// Trigger rules (intentionally narrow — we don't want to lint every YAML
|
|
7
|
+
// the user touches):
|
|
8
|
+
// tool_name ∈ {Edit, Write, MultiEdit}
|
|
9
|
+
// tool_input.file_path is a string ending in .yaml or .yml
|
|
10
|
+
// the path contains a `cfn/` or `templates/` segment (path-separator agnostic)
|
|
11
|
+
//
|
|
12
|
+
// The path-segment check is what makes the hook safe to ship as a global
|
|
13
|
+
// PostToolUse matcher: editing some-other-product/serverless.yaml elsewhere
|
|
14
|
+
// won't trip cfn-lint just because it's a YAML file.
|
|
15
|
+
|
|
16
|
+
const EDIT_TOOLS = new Set(['Edit', 'Write', 'MultiEdit']);
|
|
17
|
+
const YAML_RE = /\.ya?ml$/i;
|
|
18
|
+
// `[/\\]` covers POSIX and Windows separators; `(^|...)` matches either an
|
|
19
|
+
// initial segment or a sub-segment. `(?=[/\\])` ensures it's a directory.
|
|
20
|
+
const CFN_DIR_RE = /(^|[/\\])(cfn|templates)(?=[/\\])/i;
|
|
21
|
+
|
|
22
|
+
export function shouldLint(event) {
|
|
23
|
+
const toolName = event?.tool_name;
|
|
24
|
+
if (!EDIT_TOOLS.has(toolName)) return { lint: false };
|
|
25
|
+
const file = event?.tool_input?.file_path;
|
|
26
|
+
if (typeof file !== 'string') return { lint: false };
|
|
27
|
+
if (!YAML_RE.test(file)) return { lint: false };
|
|
28
|
+
if (!CFN_DIR_RE.test(file)) return { lint: false };
|
|
29
|
+
return { lint: true, file };
|
|
30
|
+
}
|