bmad-plus 0.20.0 → 0.22.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 (50) hide show
  1. package/CHANGELOG.md +41 -0
  2. package/README.md +14 -14
  3. package/SECURITY.md +62 -0
  4. package/osint-agent-package/skills/bmad-osint-investigate/osint/scripts/_http.py +68 -24
  5. package/package.json +1 -1
  6. package/readme-international/README.de.md +14 -14
  7. package/readme-international/README.es.md +14 -14
  8. package/readme-international/README.fr.md +14 -14
  9. package/src/bmad-plus/agents/agent-quality/SKILL.md +1 -1
  10. package/src/bmad-plus/packs/pack-dev-studio/categories/implementation/code-review.md +26 -5
  11. package/src/bmad-plus/packs/pack-seo/SKILL.md +3 -1
  12. package/src/bmad-plus/packs/pack-seo/ref/cwv-thresholds.md +2 -2
  13. package/src/bmad-plus/packs/pack-seo/requirements.txt +1 -1
  14. package/src/bmad-plus/packs/pack-seo/scripts/seo_apis.py +72 -30
  15. package/src/bmad-plus/packs/pack-seo/scripts/seo_crawl.py +36 -24
  16. package/src/bmad-plus/packs/pack-seo/scripts/seo_fetch.py +179 -59
  17. package/src/bmad-plus/packs/pack-seo/scripts/seo_report.py +5 -6
  18. package/src/bmad-plus/packs/pack-seo/scripts/seo_screenshot.py +176 -14
  19. package/src/bmad-plus/packs/pack-shield/README.md +12 -0
  20. package/src/bmad-plus/packs/pack-shield/SKILL.md +7 -1
  21. package/src/bmad-plus/packs/pack-shield/review-rules/access-control.md +10 -0
  22. package/src/bmad-plus/packs/pack-shield/review-rules/ai-integrations.md +10 -0
  23. package/src/bmad-plus/packs/pack-shield/review-rules/change-and-supply-chain.md +10 -0
  24. package/src/bmad-plus/packs/pack-shield/review-rules/cryptography.md +10 -0
  25. package/src/bmad-plus/packs/pack-shield/review-rules/index.yaml +134 -0
  26. package/src/bmad-plus/packs/pack-shield/review-rules/logging.md +10 -0
  27. package/src/bmad-plus/packs/pack-shield/review-rules/personal-data.md +10 -0
  28. package/src/bmad-plus/packs/pack-shield/shared/ai-processing-register-template.yaml +53 -0
  29. package/src/bmad-plus/packs/pack-shield/shared/ai-processing-register.md +32 -0
  30. package/src/bmad-plus/packs/pack-shield/shared/assurance-case-template.yaml +87 -0
  31. package/src/bmad-plus/packs/pack-shield/shared/assurance-case.md +50 -0
  32. package/src/bmad-plus/packs/pack-shield/shield-orchestrator.md +24 -1
  33. package/src/bmad-plus/skills/bmad-plus-uat/SKILL.md +1 -0
  34. package/src/bmad-plus/skills/bmad-plus-uat/template/page.html +5 -4
  35. package/tools/build/generate-adapters.js +7 -0
  36. package/tools/build/generate.js +14 -0
  37. package/tools/cli/bmad-plus-cli.js +2 -0
  38. package/tools/cli/commands/ai-register.js +63 -0
  39. package/tools/cli/commands/assurance.js +162 -0
  40. package/tools/cli/commands/review.js +141 -7
  41. package/tools/cli/lib/ai-register.js +393 -0
  42. package/tools/cli/lib/assurance.js +822 -0
  43. package/tools/cli/lib/control-refs.js +132 -0
  44. package/tools/cli/lib/installation-health.js +17 -0
  45. package/tools/cli/lib/packs.js +60 -2
  46. package/tools/cli/lib/page-origins.js +582 -0
  47. package/tools/cli/lib/review-rules.js +124 -26
  48. package/tools/cli/lib/review.js +493 -10
  49. package/tools/cli/lib/uat.js +22 -5
  50. package/tools/cli/review-rules/index.yaml +9 -0
@@ -0,0 +1,162 @@
1
+ /**
2
+ * Security assurance cases: start one from the Shield template, run the checks that produce
3
+ * evidence, verify a case against them.
4
+ */
5
+ 'use strict';
6
+
7
+ const fs = require('node:fs');
8
+ const path = require('node:path');
9
+ const assurance = require('../lib/assurance');
10
+
11
+ const ACTIONS = ['init', 'run', 'verify'];
12
+
13
+ function print(json, payload, lines) {
14
+ if (json) console.log(JSON.stringify({ schemaVersion: 1, ...payload }, null, 2));
15
+ else for (const line of lines) console.log(line);
16
+ }
17
+
18
+ /** `--emit-check` writes a JSON file atomically, never over the case or its ledger. */
19
+ function writeCheck(projectDir, value, kase, ledger, result) {
20
+ const file = path.resolve(projectDir, value);
21
+ if (!/\.json$/i.test(file)) throw new Error('--emit-check writes a .json file');
22
+ const protectedFiles = [path.resolve(projectDir, kase.file), ledger];
23
+ if (protectedFiles.some((taken) => path.relative(taken, file) === ''))
24
+ throw new Error('--emit-check must not overwrite the case or its ledger');
25
+ if (fs.existsSync(file) && !fs.statSync(file).isFile())
26
+ throw new Error(`--emit-check: ${file} is not a file`);
27
+ fs.mkdirSync(path.dirname(file), { recursive: true });
28
+ const temporary = `${file}.${process.pid}.tmp`;
29
+ fs.writeFileSync(temporary, `${JSON.stringify(result, null, 2)}\n`, { flag: 'wx' });
30
+ try {
31
+ fs.renameSync(temporary, file);
32
+ } catch (error) {
33
+ fs.rmSync(temporary, { force: true });
34
+ throw error;
35
+ }
36
+ return file;
37
+ }
38
+
39
+ function initAction(projectDir, caseFile, json) {
40
+ const { file, id } = assurance.initCase(projectDir, caseFile);
41
+ print(json, { action: 'init', case: id, file }, [
42
+ `${file}: case ${id} started from the Shield template.`,
43
+ 'Replace every example claim and check with those of the project, commit it, then run it.',
44
+ ]);
45
+ }
46
+
47
+ async function runAction(projectDir, kase, options, json) {
48
+ const { file, results, head, authenticated } = await assurance.runChecks(projectDir, kase, {
49
+ dir: options.dir,
50
+ only: options.check,
51
+ });
52
+ const failed = results.filter((result) => result.failures.length);
53
+ print(
54
+ json,
55
+ {
56
+ action: 'run',
57
+ case: kase.id,
58
+ ledger: file,
59
+ head,
60
+ authenticated,
61
+ results: results.map(({ check, failures, record }) => ({
62
+ check,
63
+ status: failures.length ? 'failed' : 'passed',
64
+ failures,
65
+ sequence: record.sequence,
66
+ exitCode: record.exitCode,
67
+ revision: record.revision,
68
+ dirty: record.dirty,
69
+ sha256: record.sha256,
70
+ })),
71
+ },
72
+ [
73
+ `${file}`,
74
+ ...results.map(
75
+ ({ check, failures, record }) =>
76
+ ` ${failures.length ? 'failed' : 'passed'} ${check.padEnd(24)} exit ${record.exitCode ?? '-'} in ${Math.round(record.durationMs / 100) / 10} s${failures.length ? ` — ${failures.join('; ')}` : ''}`
77
+ ),
78
+ ...(results.some(({ record }) => record.dirty !== false)
79
+ ? [
80
+ ' Ran on uncommitted changes, untracked files or outside git: a case bound to a commit will not accept these runs.',
81
+ ]
82
+ : []),
83
+ `Recorded ${results.length} run(s)${authenticated ? ', authenticated by key' : ''}; ledger head ${head}.`,
84
+ `Keep the head outside the ledger and verify with bmad-plus assurance verify ${kase.file} --ledger-head ${head}.`,
85
+ ]
86
+ );
87
+ process.exitCode = failed.length ? 1 : 0;
88
+ }
89
+
90
+ function verifyAction(projectDir, kase, options, json) {
91
+ const verdict = assurance.verifyCase(projectDir, kase, {
92
+ dir: options.dir,
93
+ head: options.ledgerHead ?? null,
94
+ });
95
+ let check = null;
96
+ if (options.emitCheck)
97
+ check = writeCheck(
98
+ projectDir,
99
+ options.emitCheck,
100
+ kase,
101
+ assurance.ledgerFile(projectDir, options.dir, kase.id),
102
+ assurance.checkResult(verdict)
103
+ );
104
+ print(
105
+ json,
106
+ { action: 'verify', ...verdict, check },
107
+ [
108
+ `${kase.id}: ${verdict.status} — ${verdict.claims.filter((c) => c.status === 'supported').length}/${verdict.claims.length} claim(s) supported, ${verdict.ledger.runs} recorded run(s)`,
109
+ ...verdict.reasons.map((reason) => ` - ${reason}`),
110
+ ...Object.entries(verdict.checks)
111
+ .filter(([, judged]) => judged.status !== 'passed')
112
+ .map(([id, judged]) => ` ${judged.status.padEnd(7)} ${id}: ${judged.reasons.join('; ')}`),
113
+ ...verdict.claims
114
+ .filter((claim) => claim.status !== 'supported')
115
+ .map((claim) => ` unsupported claim ${claim.id}: ${claim.reasons.join('; ')}`),
116
+ ...(verdict.controls.supported.length
117
+ ? [` controls with executed evidence: ${verdict.controls.supported.join(', ')}`]
118
+ : []),
119
+ check ? ` check: ${check}` : '',
120
+ ].filter(Boolean)
121
+ );
122
+ process.exitCode = verdict.exitCode;
123
+ }
124
+
125
+ module.exports = {
126
+ command: 'assurance <action> <case>',
127
+ description: 'Security assurance case bound to executed checks: init, run, verify',
128
+ options: [
129
+ ['-d, --directory <path>', 'Project directory'],
130
+ ['--dir <path>', 'Evidence folder inside the project', assurance.DEFAULT_DIR],
131
+ ['--check <id>', 'run: only this check (repeatable)', (v, all) => [...all, v], []],
132
+ ['--ledger-head <sha256>', 'verify: fail unless the ledger still holds this record'],
133
+ ['--emit-check <file>', 'verify: also write the verdict as a JSON check result for CI'],
134
+ ['--json', 'Machine-readable output'],
135
+ ],
136
+ action: async (action, caseFile, options = {}) => {
137
+ const projectDir = path.resolve(options.directory || process.cwd());
138
+ const json = Boolean(options.json);
139
+ const settings = { dir: options.dir || assurance.DEFAULT_DIR, check: options.check || [] };
140
+ try {
141
+ if (!ACTIONS.includes(action))
142
+ throw new Error(`unknown action "${action}" (${ACTIONS.join(', ')})`);
143
+ if (action === 'init') return initAction(projectDir, caseFile, json);
144
+ const kase = assurance.loadCase(projectDir, caseFile);
145
+ if (action === 'run') await runAction(projectDir, kase, settings, json);
146
+ else
147
+ verifyAction(
148
+ projectDir,
149
+ kase,
150
+ { ...settings, emitCheck: options.emitCheck, ledgerHead: options.ledgerHead },
151
+ json
152
+ );
153
+ } catch (error) {
154
+ if (json)
155
+ console.log(
156
+ JSON.stringify({ schemaVersion: 1, status: 'error', message: error.message }, null, 2)
157
+ );
158
+ else console.error(`assurance: ${error.message}`);
159
+ process.exitCode = 3;
160
+ }
161
+ },
162
+ };
@@ -7,7 +7,7 @@ const review = require('../lib/review');
7
7
  const reviewRules = require('../lib/review-rules');
8
8
 
9
9
  const ID = /^[a-z0-9][a-z0-9.-]{0,80}$/;
10
- const ACTIONS = ['scope', 'anchor', 'gate', 'compare', 'rules'];
10
+ const ACTIONS = ['scope', 'anchor', 'gate', 'continue', 'compare', 'rules'];
11
11
 
12
12
  function fail(message) {
13
13
  throw new Error(message);
@@ -28,6 +28,48 @@ function writeJson(file, value) {
28
28
  fs.writeFileSync(file, `${JSON.stringify(value, null, 2)}\n`);
29
29
  }
30
30
 
31
+ /** A reader of the file sees the previous version or the new one, never half of it. */
32
+ function writeAtomically(file, text) {
33
+ fs.mkdirSync(path.dirname(file), { recursive: true });
34
+ const temporary = `${file}.${process.pid}.tmp`;
35
+ fs.writeFileSync(temporary, text, { flag: 'wx' });
36
+ try {
37
+ fs.renameSync(temporary, file);
38
+ } catch (error) {
39
+ fs.rmSync(temporary, { force: true });
40
+ throw error;
41
+ }
42
+ }
43
+
44
+ const inside = (dir, file) => {
45
+ const relative = path.relative(dir, file);
46
+ return relative !== '' && !relative.startsWith('..') && !path.isAbsolute(relative);
47
+ };
48
+
49
+ /**
50
+ * Where `--emit-check` writes: a JSON file, resolved against the project, never the evidence
51
+ * itself. A working-tree scope selects untracked files, so its check stays in the review
52
+ * folder or out of the project: anywhere else it would enter the change it reports on.
53
+ */
54
+ function checkTarget(projectDir, value, paths, scope) {
55
+ const file = path.resolve(projectDir, value);
56
+ if (!/\.json$/i.test(file)) fail('--emit-check writes a .json file');
57
+ const evidence = Object.entries(paths).filter(([key]) => key !== 'root');
58
+ if (evidence.some(([, taken]) => path.relative(taken, file) === ''))
59
+ fail('--emit-check must not overwrite the review evidence');
60
+ if (
61
+ scope.identity.workspace &&
62
+ inside(projectDir, file) &&
63
+ !inside(path.dirname(paths.root), file)
64
+ )
65
+ fail(
66
+ `--emit-check: a working-tree review writes its check in ${path.relative(projectDir, path.dirname(paths.root)) || '.'} or outside the project, where it cannot enter the reviewed change`
67
+ );
68
+ if (fs.existsSync(file) && !fs.statSync(file).isFile())
69
+ fail(`--emit-check: ${file} is not a file`);
70
+ return file;
71
+ }
72
+
31
73
  function print(json, payload, lines) {
32
74
  if (json) console.log(JSON.stringify({ schemaVersion: 1, ...payload }, null, 2));
33
75
  else for (const line of lines) console.log(line);
@@ -46,6 +88,75 @@ function loadReview(projectDir, dir, id) {
46
88
  return { paths, scope };
47
89
  }
48
90
 
91
+ /** One line of observed usage: what the host reported, nothing estimated. */
92
+ function describeUsage(stop, usage) {
93
+ const parts = [
94
+ `stopped: ${stop || 'unstated'}`,
95
+ `${usage.passes ?? '?'}/${usage.plannedPasses ?? '?'} pass(es)`,
96
+ `${usage.unitsAttempted}/${usage.units} unit(s) attempted in ${usage.attempts} attempt(s), ${usage.failedAttempts} failed`,
97
+ ];
98
+ if (usage.tokens !== undefined) parts.push(`${usage.tokens} tokens`);
99
+ if (usage.durationMs !== undefined) parts.push(`${Math.round(usage.durationMs / 1000)} s`);
100
+ return parts.join(', ');
101
+ }
102
+
103
+ /**
104
+ * `review continue <id>`: what an interrupted review still owes, against the same sealed
105
+ * scope. Refused when the code or the rules moved — a new scope and a compare answer that.
106
+ */
107
+ function continueAction(projectDir, dir, id, paths, scope, json) {
108
+ const drift = review.scopeDrift(projectDir, scope, {
109
+ ruleset: reviewRules.loadRuleset(projectDir),
110
+ outputDir: dir,
111
+ });
112
+ if (drift.length) {
113
+ const next = [
114
+ `bmad-plus review scope <new-id> (same options as ${id})`,
115
+ `bmad-plus review compare <new-id> --since ${id}`,
116
+ ];
117
+ print(json, { action: 'continue', id, status: 'moved', drift, next }, [
118
+ `${id} cannot continue: the sealed scope no longer describes the code.`,
119
+ ...drift.map((reason) => ` - ${reason}`),
120
+ ` Seal a new scope and compare: ${next.join(', then ')}.`,
121
+ ]);
122
+ process.exitCode = 3;
123
+ return undefined;
124
+ }
125
+ const coverage = readJson(paths.coverage, 'coverage.json');
126
+ const coverageErrors = coverage ? review.validateCoverage(coverage, scope) : [];
127
+ if (coverageErrors.length) {
128
+ print(json, { action: 'continue', id, status: 'error', errors: coverageErrors }, [
129
+ `${id}: coverage.json must be valid before the review continues`,
130
+ ...coverageErrors.map((e) => ` error ${e}`),
131
+ ]);
132
+ process.exitCode = 3;
133
+ return undefined;
134
+ }
135
+ const findings = readJson(paths.findings, 'findings.json');
136
+ const findingErrors = findings ? review.validateFindings(findings, scope) : [];
137
+ const anchored =
138
+ findings && !findingErrors.length ? review.anchorFindings(projectDir, scope, findings) : null;
139
+ const packet = review.remainingWork({ scope, coverage, anchored, findingErrors });
140
+ writeJson(paths.continue, packet);
141
+ const c = packet.counts;
142
+ const nothing = !c.files && !c.requote && !c.findingErrors && !c.passes;
143
+ print(json, { action: 'continue', id, status: 'ready', file: paths.continue, counts: c }, [
144
+ `${paths.continue} — same scope ${scope.sha256.slice(0, 12)}`,
145
+ `${c.files} file(s) in ${c.units} unit(s) to review, ${c.requote} finding(s) to requote, ${c.passes} pass(es) left`,
146
+ ...packet.units.map((unit) => ` ${unit.id.padEnd(5)} ${unit.paths.join(', ')}`),
147
+ ...packet.abandoned.map(
148
+ (file) => ` abandoned ${file.path} (${file.unit}): three failed attempts; it stays failed`
149
+ ),
150
+ ...packet.requote.map((f) => ` requote ${f.id} ${f.path} (${f.status})`),
151
+ ...packet.findingErrors.map((e) => ` fix ${e}`),
152
+ nothing
153
+ ? `Nothing remains: once coverage.json records the run as completed, run bmad-plus review gate ${id}.`
154
+ : 'Add to the same coverage.json and findings.json; set run.stop when this session ends.',
155
+ ]);
156
+ process.exitCode = 0;
157
+ return undefined;
158
+ }
159
+
49
160
  /** `review rules [path]`: the effective rule set, or the rules one path would get. */
50
161
  function rulesAction(projectDir, target, json) {
51
162
  const ruleset = reviewRules.loadRuleset(projectDir);
@@ -58,21 +169,30 @@ function rulesAction(projectDir, target, json) {
58
169
  action: 'rules',
59
170
  path: file,
60
171
  sha256: ruleset.sha256,
172
+ packFiles: ruleset.packFiles,
61
173
  projectFile: ruleset.projectFile,
62
174
  disabled: ruleset.disabled,
63
- rules: listed.map(({ id, title, layer, globs, source }) => ({
175
+ rules: listed.map(({ id, title, group, layer, pack, globs, controls, source }) => ({
64
176
  id,
65
177
  title,
178
+ group,
66
179
  layer,
180
+ ...(pack ? { pack } : {}),
67
181
  globs,
182
+ controls,
68
183
  source,
69
184
  })),
185
+ controls: reviewRules.controlsOf(
186
+ ruleset,
187
+ listed.map((rule) => rule.id)
188
+ ),
70
189
  },
71
190
  [
72
- `rule set ${ruleset.sha256.slice(0, 12)}${ruleset.projectFile ? ` (built-in + ${ruleset.projectFile})` : ' (built-in)'}`,
191
+ `rule set ${ruleset.sha256.slice(0, 12)} (${['built-in', ...ruleset.packFiles, ...(ruleset.projectFile ? [ruleset.projectFile] : [])].join(' + ')})`,
73
192
  ...(file ? [`${file} gets ${listed.length} rule(s):`] : []),
74
193
  ...listed.map(
75
- (rule) => ` ${rule.id.padEnd(24)} ${rule.layer.padEnd(8)} ${rule.globs.join(' ')}`
194
+ (rule) =>
195
+ ` ${rule.id.padEnd(24)} ${rule.group.padEnd(12)} ${rule.layer.padEnd(8)} ${rule.globs.join(' ')}${rule.controls.length ? `\n ${''.padEnd(24)} controls: ${rule.controls.join(' ')}` : ''}`
76
196
  ),
77
197
  ...ruleset.disabled.map((id) => ` ${id.padEnd(24)} disabled by the project`),
78
198
  ]
@@ -81,7 +201,7 @@ function rulesAction(projectDir, target, json) {
81
201
 
82
202
  module.exports = {
83
203
  command: 'review <action> [id]',
84
- description: 'Code review evidence: scope, anchor, gate, compare, rules',
204
+ description: 'Code review evidence: scope, anchor, gate, continue, compare, rules',
85
205
  options: [
86
206
  ['-d, --directory <path>', 'Project directory'],
87
207
  ['--dir <path>', 'Review folder inside the project', review.DEFAULT_DIR],
@@ -95,6 +215,7 @@ module.exports = {
95
215
  `Review depth: ${Object.keys(review.EFFORTS).join(', ')} (default medium)`,
96
216
  ],
97
217
  ['--since <id>', 'compare: the earlier review to compare against'],
218
+ ['--emit-check <file>', 'gate: also write the verdict as a JSON check result for CI'],
98
219
  ['--json', 'Machine-readable output'],
99
220
  ],
100
221
  action: (action, id, options = {}) => {
@@ -116,6 +237,7 @@ module.exports = {
116
237
  include: options.include,
117
238
  exclude: options.exclude,
118
239
  effort: options.effort,
240
+ outputDir: dir,
119
241
  ruleset,
120
242
  });
121
243
  const paths = review.layout(projectDir, dir, id);
@@ -201,21 +323,33 @@ module.exports = {
201
323
  if (findings && !review.validateFindings(findings, scope).length)
202
324
  anchored = review.anchorFindings(projectDir, scope, findings);
203
325
  const verdict = review.reviewGate({ scope, findings, coverage, anchored });
326
+ let check = null;
327
+ if (options.emitCheck) {
328
+ check = checkTarget(projectDir, options.emitCheck, paths, scope);
329
+ writeAtomically(
330
+ check,
331
+ `${JSON.stringify(review.checkResult({ id, scope, verdict, anchored }), null, 2)}\n`
332
+ );
333
+ }
204
334
  print(
205
335
  json,
206
- { action, id, ...verdict },
336
+ { action, id, ...verdict, check },
207
337
  [
208
338
  `${id}: ${verdict.status} — coverage ${verdict.coverage.completed}/${verdict.coverage.selected} completed (${verdict.coverage.waived} waived), ${verdict.findings.open} open finding(s), ${verdict.findings.refuted} refuted`,
209
339
  ...verdict.reasons.map((reason) => ` - ${reason}`),
340
+ verdict.usage ? ` run: ${describeUsage(verdict.stop, verdict.usage)}` : '',
341
+ check ? ` check: ${check}` : '',
210
342
  verdict.status === 'clean'
211
343
  ? ' clean: no open finding within a fully covered scope. It does not prove the code correct.'
212
344
  : '',
213
345
  ].filter(Boolean)
214
346
  );
215
- process.exitCode = { clean: 0, findings: 1, incomplete: 2 }[verdict.status];
347
+ process.exitCode = review.GATE_EXIT[verdict.status];
216
348
  return undefined;
217
349
  }
218
350
 
351
+ if (action === 'continue') return continueAction(projectDir, dir, id, paths, scope, json);
352
+
219
353
  // compare: this review against an earlier one.
220
354
  const sinceId = requireId(options.since, '--since <earlier review id>');
221
355
  if (sinceId === id) fail('--since must name a different review');