bmad-plus 0.21.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 (46) hide show
  1. package/CHANGELOG.md +20 -0
  2. package/README.md +13 -13
  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 +13 -13
  7. package/readme-international/README.es.md +13 -13
  8. package/readme-international/README.fr.md +13 -13
  9. package/src/bmad-plus/packs/pack-dev-studio/categories/implementation/code-review.md +3 -1
  10. package/src/bmad-plus/packs/pack-seo/SKILL.md +3 -1
  11. package/src/bmad-plus/packs/pack-seo/ref/cwv-thresholds.md +2 -2
  12. package/src/bmad-plus/packs/pack-seo/requirements.txt +1 -1
  13. package/src/bmad-plus/packs/pack-seo/scripts/seo_apis.py +72 -30
  14. package/src/bmad-plus/packs/pack-seo/scripts/seo_report.py +5 -6
  15. package/src/bmad-plus/packs/pack-seo/scripts/seo_screenshot.py +176 -14
  16. package/src/bmad-plus/packs/pack-shield/README.md +12 -0
  17. package/src/bmad-plus/packs/pack-shield/SKILL.md +7 -1
  18. package/src/bmad-plus/packs/pack-shield/review-rules/access-control.md +10 -0
  19. package/src/bmad-plus/packs/pack-shield/review-rules/ai-integrations.md +10 -0
  20. package/src/bmad-plus/packs/pack-shield/review-rules/change-and-supply-chain.md +10 -0
  21. package/src/bmad-plus/packs/pack-shield/review-rules/cryptography.md +10 -0
  22. package/src/bmad-plus/packs/pack-shield/review-rules/index.yaml +134 -0
  23. package/src/bmad-plus/packs/pack-shield/review-rules/logging.md +10 -0
  24. package/src/bmad-plus/packs/pack-shield/review-rules/personal-data.md +10 -0
  25. package/src/bmad-plus/packs/pack-shield/shared/ai-processing-register-template.yaml +53 -0
  26. package/src/bmad-plus/packs/pack-shield/shared/ai-processing-register.md +32 -0
  27. package/src/bmad-plus/packs/pack-shield/shared/assurance-case-template.yaml +87 -0
  28. package/src/bmad-plus/packs/pack-shield/shared/assurance-case.md +50 -0
  29. package/src/bmad-plus/packs/pack-shield/shield-orchestrator.md +24 -1
  30. package/src/bmad-plus/skills/bmad-plus-uat/SKILL.md +1 -0
  31. package/src/bmad-plus/skills/bmad-plus-uat/template/page.html +5 -4
  32. package/tools/build/generate-adapters.js +7 -0
  33. package/tools/build/generate.js +14 -0
  34. package/tools/cli/bmad-plus-cli.js +2 -0
  35. package/tools/cli/commands/ai-register.js +63 -0
  36. package/tools/cli/commands/assurance.js +162 -0
  37. package/tools/cli/commands/review.js +10 -3
  38. package/tools/cli/lib/ai-register.js +393 -0
  39. package/tools/cli/lib/assurance.js +822 -0
  40. package/tools/cli/lib/control-refs.js +132 -0
  41. package/tools/cli/lib/installation-health.js +17 -0
  42. package/tools/cli/lib/packs.js +60 -2
  43. package/tools/cli/lib/page-origins.js +582 -0
  44. package/tools/cli/lib/review-rules.js +92 -24
  45. package/tools/cli/lib/review.js +28 -1
  46. package/tools/cli/lib/uat.js +22 -5
@@ -1,8 +1,9 @@
1
1
  /**
2
- * Review checklists chosen by path. Built-in rules ship with the CLI; a project adds,
3
- * replaces or disables rules in `_bmad/review-rules.yaml`. Every rule whose patterns match
4
- * a file applies to it — rules add up, so a broad project rule never silences the built-in
5
- * ones by accident. The resolved set is hashed into the review scope.
2
+ * Review checklists chosen by path. Built-in rules ship with the CLI; an installed pack may
3
+ * add its own (Shield's compliance rules); a project adds, replaces or disables rules in
4
+ * `_bmad/review-rules.yaml`. Every rule whose patterns match a file applies to it — rules
5
+ * add up, so a broad project rule never silences the built-in ones by accident. A rule may
6
+ * name the compliance controls it examines. The resolved set is hashed into the review scope.
6
7
  */
7
8
  'use strict';
8
9
 
@@ -11,12 +12,16 @@ const path = require('node:path');
11
12
  const crypto = require('node:crypto');
12
13
  const yaml = require('js-yaml');
13
14
  const { compile, matchesAny } = require('./glob');
15
+ const { parseControls } = require('./control-refs');
16
+ const { PACKS } = require('./packs');
14
17
 
15
18
  const SCHEMA = 'bmad-plus/review-rules/1';
16
19
  const BUILTIN_DIR = path.join(__dirname, '..', 'review-rules');
17
20
  const PROJECT_FILE = path.join('_bmad', 'review-rules.yaml');
21
+ /** Where an installed pack keeps its rules, below its own folder in `.agents/skills`. */
22
+ const PACK_RULES_DIR = 'review-rules';
18
23
  const RULE_ID = /^[a-z0-9][a-z0-9-]{0,60}$/;
19
- const RULE_KEYS = ['id', 'title', 'group', 'globs', 'doc'];
24
+ const RULE_KEYS = ['id', 'title', 'group', 'globs', 'doc', 'controls'];
20
25
  const MAX_DOC_BYTES = 64 * 1024;
21
26
  const MAX_INDEX_BYTES = 256 * 1024;
22
27
 
@@ -47,8 +52,8 @@ function parseLayer(root, indexName, layer) {
47
52
  if (!doc || doc.schema !== SCHEMA) throw new Error(`${where}: schema must be "${SCHEMA}"`);
48
53
  const extra = Object.keys(doc).filter((key) => !['schema', 'rules', 'disable'].includes(key));
49
54
  if (extra.length) throw new Error(`${where}: unknown key(s) ${extra.join(', ')}`);
50
- if (layer === 'builtin' && doc.disable)
51
- throw new Error(`${where}: built-in rules cannot disable`);
55
+ if (layer !== 'project' && doc.disable)
56
+ throw new Error(`${where}: only a project's rules can disable`);
52
57
  const disable = doc.disable || [];
53
58
  if (!Array.isArray(disable) || !disable.every((id) => typeof id === 'string'))
54
59
  throw new Error(`${where}: disable must be a list of rule ids`);
@@ -74,6 +79,7 @@ function parseLayer(root, indexName, layer) {
74
79
  )
75
80
  throw new Error(`${at}: globs must be a non-empty list of patterns`);
76
81
  for (const glob of rule.globs) compile(glob);
82
+ const controls = rule.controls === undefined ? [] : parseControls(rule.controls, at);
77
83
  if (!/\.md$/i.test(String(rule.doc))) throw new Error(`${at}: doc must be a Markdown file`);
78
84
  const text = readConfined(root, rule.doc, MAX_DOC_BYTES, at).trim();
79
85
  if (!text) throw new Error(`${at}: ${rule.doc} is empty`);
@@ -83,6 +89,7 @@ function parseLayer(root, indexName, layer) {
83
89
  // A rule family reviewers can split by; a rule without one is a family of its own.
84
90
  group: rule.group === undefined ? rule.id : rule.group,
85
91
  globs: [...rule.globs],
92
+ controls,
86
93
  layer,
87
94
  source: rule.doc,
88
95
  text,
@@ -91,13 +98,35 @@ function parseLayer(root, indexName, layer) {
91
98
  return { rules: parsed, disable };
92
99
  }
93
100
 
101
+ /** Rule indexes of the packs installed in the project, in pack order. */
102
+ function installedPackLayers(projectDir) {
103
+ const layers = [];
104
+ for (const [pack, { packDir }] of Object.entries(PACKS)) {
105
+ if (!packDir) continue;
106
+ const relative = ['.agents', 'skills', packDir, PACK_RULES_DIR, 'index.yaml'];
107
+ if (fs.existsSync(path.join(projectDir, ...relative)))
108
+ layers.push({ pack, file: relative.join('/') });
109
+ }
110
+ return layers;
111
+ }
112
+
94
113
  /**
95
- * The effective rule set for a project: built-in rules, then the project's own. A project
96
- * rule with a built-in id replaces it; `disable` removes rules by id and is reported.
114
+ * The effective rule set for a project: built-in rules, then those of installed packs, then
115
+ * the project's own. A pack only adds; a project rule with an existing id replaces it, and
116
+ * `disable` removes rules by id and is reported.
97
117
  */
98
118
  function loadRuleset(projectDir, { builtinDir = BUILTIN_DIR } = {}) {
99
119
  const builtin = parseLayer(builtinDir, 'index.yaml', 'builtin');
100
120
  const ordered = new Map(builtin.rules.map((rule) => [rule.id, rule]));
121
+ const packFiles = installedPackLayers(projectDir);
122
+ for (const { pack, file } of packFiles) {
123
+ const layer = parseLayer(path.join(projectDir, path.dirname(file)), 'index.yaml', 'pack');
124
+ for (const rule of layer.rules) {
125
+ if (ordered.has(rule.id))
126
+ throw new Error(`${pack} review rules: rule ${rule.id} already exists; a pack only adds`);
127
+ ordered.set(rule.id, { ...rule, pack });
128
+ }
129
+ }
101
130
  let disabled = [];
102
131
  const projectIndex = path.join(projectDir, PROJECT_FILE);
103
132
  let project = null;
@@ -111,16 +140,20 @@ function loadRuleset(projectDir, { builtinDir = BUILTIN_DIR } = {}) {
111
140
  for (const id of disabled) ordered.delete(id);
112
141
  }
113
142
  const list = [...ordered.values()];
143
+ // Controls join the fingerprint only when a rule names some, so a rule set without any
144
+ // keeps the hash it had before rules could carry controls.
114
145
  const fingerprint = list.map((rule) => [
115
146
  rule.id,
116
147
  rule.group,
117
148
  rule.layer,
118
149
  rule.globs,
119
150
  sha256(rule.text),
151
+ ...(rule.controls.length ? [rule.controls] : []),
120
152
  ]);
121
153
  return {
122
154
  rules: list,
123
155
  disabled,
156
+ packFiles: packFiles.map(({ file }) => file),
124
157
  projectFile: project ? PROJECT_FILE.split(path.sep).join('/') : null,
125
158
  sha256: sha256(JSON.stringify({ rules: fingerprint, disabled })),
126
159
  };
@@ -142,35 +175,68 @@ function groupsOf(ruleset, ids) {
142
175
  }
143
176
 
144
177
  /**
145
- * The reviewer's checklist for a scope: each applicable rule once, with the files it
146
- * covers. Rules that match nothing in the scope are left out.
178
+ * The compliance controls a set of rule ids examines, each with the rules that name it, in
179
+ * rule order. A control touched is a prompt to look at its requirement, not a finding.
180
+ */
181
+ function controlsOf(ruleset, ids) {
182
+ const controls = new Map();
183
+ for (const rule of ruleset.rules) {
184
+ if (!ids.includes(rule.id)) continue;
185
+ for (const control of rule.controls)
186
+ controls.set(control, [...(controls.get(control) || []), rule.id]);
187
+ }
188
+ return [...controls].map(([id, rules]) => ({ id, rules }));
189
+ }
190
+
191
+ /**
192
+ * The reviewer's checklist for a scope: the controls the change touches, then each
193
+ * applicable rule once, with the files it covers. Rules that match nothing are left out.
147
194
  */
148
195
  function checklist(ruleset, byPath, { title = 'Review checklist' } = {}) {
149
196
  const files = new Map();
150
197
  for (const [file, ids] of Object.entries(byPath)) {
151
198
  for (const id of ids) files.set(id, [...(files.get(id) || []), file]);
152
199
  }
153
- const sections = ruleset.rules
154
- .filter((rule) => files.has(rule.id))
155
- .map((rule) =>
156
- [
157
- `## ${rule.title} \`${rule.id}\`${rule.layer === 'project' ? ' (project rule)' : ''}`,
200
+ const applied = ruleset.rules.filter((rule) => files.has(rule.id));
201
+ const controls = controlsOf(
202
+ ruleset,
203
+ applied.map((rule) => rule.id)
204
+ );
205
+ const touched = controls.length
206
+ ? [
207
+ '## Controls this change touches',
158
208
  '',
159
- `Group: \`${rule.group}\``,
209
+ ...controls.map(
210
+ ({ id, rules }) => `- \`${id}\` — ${rules.map((rule) => `\`${rule}\``).join(', ')}`
211
+ ),
160
212
  '',
161
- `Applies to: ${files
162
- .get(rule.id)
163
- .map((file) => `\`${file}\``)
164
- .join(', ')}`,
213
+ 'List in the `controls` of a finding the control it breaks; a control listed here is not a finding.',
165
214
  '',
166
- rule.text,
167
- ].join('\n')
168
- );
215
+ ]
216
+ : [];
217
+ const sections = applied.map((rule) =>
218
+ [
219
+ `## ${rule.title} \`${rule.id}\`${rule.layer === 'project' ? ' (project rule)' : ''}${rule.pack ? ` (${rule.pack} rule)` : ''}`,
220
+ '',
221
+ `Group: \`${rule.group}\``,
222
+ ...(rule.controls.length
223
+ ? ['', `Controls: ${rule.controls.map((control) => `\`${control}\``).join(', ')}`]
224
+ : []),
225
+ '',
226
+ `Applies to: ${files
227
+ .get(rule.id)
228
+ .map((file) => `\`${file}\``)
229
+ .join(', ')}`,
230
+ '',
231
+ rule.text,
232
+ ].join('\n')
233
+ );
169
234
  return [
170
235
  `# ${title}`,
171
236
  '',
172
237
  `Rule set ${ruleset.sha256.slice(0, 12)}. Report a finding only with its quoted code; a rule is a prompt to look, not a finding.`,
173
238
  '',
239
+ ...touched,
174
240
  ...sections.flatMap((section) => [section, '']),
175
241
  ].join('\n');
176
242
  }
@@ -179,8 +245,10 @@ module.exports = {
179
245
  SCHEMA,
180
246
  BUILTIN_DIR,
181
247
  PROJECT_FILE,
248
+ PACK_RULES_DIR,
182
249
  loadRuleset,
183
250
  rulesFor,
184
251
  groupsOf,
252
+ controlsOf,
185
253
  checklist,
186
254
  };
@@ -295,6 +295,9 @@ function buildScope(projectDir, options = {}) {
295
295
  const ruleset = options.ruleset || rules.loadRuleset(projectDir);
296
296
  for (const item of selected) {
297
297
  item.rules = rules.rulesFor(ruleset, item.path);
298
+ // Kept only when a rule names controls, so a scope without any keeps its digest.
299
+ const controls = rules.controlsOf(ruleset, item.rules).map(({ id }) => id);
300
+ if (controls.length) item.controls = controls;
298
301
  // A commit fixes the content under review; a working tree does not, so its bytes are sealed.
299
302
  if (options.workspace) item.sha256 = contentDigest(projectDir, item.path);
300
303
  }
@@ -312,8 +315,14 @@ function buildScope(projectDir, options = {}) {
312
315
  };
313
316
  const units = planUnits(selected, options.unitLimits).map((unit) => {
314
317
  const ids = [...new Set(unit.paths.flatMap((p) => selected.find((s) => s.path === p).rules))];
318
+ const controls = rules.controlsOf(ruleset, ids).map(({ id }) => id);
315
319
  // Rule groups let parallel reviewers split one unit by rule family.
316
- return { ...unit, rules: ids, groups: rules.groupsOf(ruleset, ids) };
320
+ return {
321
+ ...unit,
322
+ rules: ids,
323
+ groups: rules.groupsOf(ruleset, ids),
324
+ ...(controls.length ? { controls } : {}),
325
+ };
317
326
  });
318
327
  const lines = selected.reduce((n, item) => n + item.added + item.removed, 0);
319
328
  const plan = reviewPlan(effort, lines, units.length);
@@ -349,6 +358,7 @@ const FINDING_KEYS = [
349
358
  'refutation',
350
359
  'fix',
351
360
  'rule',
361
+ 'controls',
352
362
  ];
353
363
 
354
364
  /** Unknown values are errors, never coerced: a wrong enum is a wrong finding. */
@@ -395,6 +405,21 @@ function validateFindings(doc, scope) {
395
405
  // A finding may name the checklist rule that led to it; the rule must apply to that file.
396
406
  if (finding.rule !== undefined && entry && !(entry.rules || []).includes(finding.rule))
397
407
  errors.push(`${at}: rule "${finding.rule}" does not apply to ${finding.path}`);
408
+ // It may name the controls it breaks: only those the rules for that file examine.
409
+ if (finding.controls !== undefined) {
410
+ if (
411
+ !Array.isArray(finding.controls) ||
412
+ !finding.controls.length ||
413
+ !finding.controls.every((control) => typeof control === 'string')
414
+ )
415
+ errors.push(`${at}: controls must be a non-empty list of control ids`);
416
+ else if (entry)
417
+ for (const control of finding.controls)
418
+ if (!(entry.controls || []).includes(control))
419
+ errors.push(
420
+ `${at}: control "${control}" is not examined by the rules for ${finding.path}`
421
+ );
422
+ }
398
423
  }
399
424
  return errors;
400
425
  }
@@ -755,6 +780,7 @@ function checkResult({ id, scope, verdict, anchored }) {
755
780
  confidence: f.confidence,
756
781
  disposition: f.disposition,
757
782
  ...(f.rule !== undefined ? { rule: f.rule } : {}),
783
+ ...(f.controls !== undefined ? { controls: f.controls } : {}),
758
784
  anchor: f.location.status,
759
785
  lineStart: f.location.status === 'located' ? f.location.lineStart : null,
760
786
  lineEnd: f.location.status === 'located' ? f.location.lineEnd : null,
@@ -934,6 +960,7 @@ function remainingWork({ scope, coverage, anchored, findingErrors = [] }) {
934
960
  id: unit.id,
935
961
  paths: unit.paths.filter((file) => pending.has(file)),
936
962
  rules: unit.rules,
963
+ ...(unit.controls ? { controls: unit.controls } : {}),
937
964
  // Rule groups another reviewer already completed are not handed out again.
938
965
  ...(unit.groups
939
966
  ? { groups: unit.groups.filter((group) => !done.has(`${unit.id}/${group.id}`)) }
@@ -5,6 +5,7 @@ const fs = require('node:fs');
5
5
  const path = require('node:path');
6
6
  const crypto = require('node:crypto');
7
7
  const { redactFields } = require('./redact');
8
+ const { foreignAddresses } = require('./page-origins');
8
9
 
9
10
  const SPEC_SCHEMA = 'bmad-plus/uat-spec/2';
10
11
  const RESULTS_SCHEMA = 'bmad-plus/uat-results/2';
@@ -176,6 +177,9 @@ function checkMarkup(value, where, errors) {
176
177
  rest = rest.replace(match[0], '');
177
178
  }
178
179
  if (rest.includes('<')) errors.push(`${where}: a "<" is left outside any allowed tag`);
180
+ // The page renders this text as markup: it is read as the template is, so the page loads
181
+ // nothing from another origin whatever the allowed tags come to be.
182
+ for (const found of foreignAddresses(value)) errors.push(`${where}: ${found}`);
179
183
  }
180
184
 
181
185
  function eachMarkup(spec, visit) {
@@ -575,22 +579,34 @@ const PAGE_GUARANTEES = [
575
579
  what: 'the exported JSON is the run as answered, and nothing is exported before a run exists',
576
580
  markers: ['download(runId + ".json", JSON.stringify(run, null, 2))'],
577
581
  },
582
+ {
583
+ id: 'no-third-party-requests',
584
+ what: 'opening and using the page asks nothing of any origin but its own: no remote font, stylesheet, script, image or frame',
585
+ markers: [],
586
+ // An absence has no marker: the template is read as a browser reads it, and every address
587
+ // it would reach on another origin, in markup, style or script, is refused by name.
588
+ refuses: foreignAddresses,
589
+ },
578
590
  ];
579
591
 
580
592
  /**
581
593
  * Which guarantees a template carries: every marker present, in the required order where one
582
- * is set. Checked on the TEMPLATE, never on the built page: a recipe's own text must not be
583
- * able to satisfy or defeat a marker.
594
+ * is set, and nothing it refuses. Checked on the TEMPLATE, never on the built page: a recipe's
595
+ * own text must not be able to satisfy or defeat a marker.
584
596
  */
585
597
  function pageGuarantees(template) {
586
598
  const missing = [];
587
599
  for (const guarantee of PAGE_GUARANTEES) {
588
- const lost = guarantee.markers.filter((marker) => !template.includes(marker));
600
+ const lost = guarantee.markers
601
+ .filter((marker) => !template.includes(marker))
602
+ .map((marker) => `missing ${marker}`);
589
603
  if (!lost.length && guarantee.order) {
590
604
  const positions = guarantee.order.map((marker) => template.indexOf(marker));
591
605
  if (positions.some((at) => at < 0) || positions.some((at, i) => i && at < positions[i - 1]))
592
- lost.push(`order ${guarantee.order.join(' → ')}`);
606
+ lost.push(`missing order ${guarantee.order.join(' → ')}`);
593
607
  }
608
+ if (guarantee.refuses)
609
+ for (const found of guarantee.refuses(template)) lost.push(`carries ${found}`);
594
610
  if (lost.length) missing.push({ id: guarantee.id, what: guarantee.what, lost });
595
611
  }
596
612
  const carried = PAGE_GUARANTEES.map((g) => g.id).filter(
@@ -607,7 +623,7 @@ function buildPage(spec, options = {}) {
607
623
  if (!guarantees.ok) {
608
624
  throw new Error(
609
625
  `the page template (${options.template ? 'the template given' : TEMPLATE}) lost a guarantee — not built: ${guarantees.missing
610
- .map((entry) => `${entry.id}: ${entry.what} — missing ${entry.lost.join(', ')}`)
626
+ .map((entry) => `${entry.id}: ${entry.what} — ${entry.lost.join(', ')}`)
611
627
  .join('; ')}`
612
628
  );
613
629
  }
@@ -1013,6 +1029,7 @@ module.exports = {
1013
1029
  TRIAGE_SCHEMA,
1014
1030
  DEFAULT_DIR,
1015
1031
  DEFAULT_BUDGET,
1032
+ ALLOWED_TAGS,
1016
1033
  STATES,
1017
1034
  TRIAGE_CLASSES,
1018
1035
  TRIAGE_DECISIONS,