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
@@ -29,7 +29,9 @@ arbitrary branch. Read the intended behavior and applicable project constraints.
29
29
  beside the scope: it holds, once each, the review rules that apply to the
30
30
  selected paths — built-in rules by language and file kind, plus the project's
31
31
  own from `_bmad/review-rules.yaml`, which can add, replace or disable rules.
32
- `bmad-plus review rules <path>` shows which rules a file gets. Inventory
32
+ `bmad-plus review rules <path>` shows which rules a file gets. Each unit
33
+ lists its rule groups (`code`, `data`, `delivery` or the project's own):
34
+ parallel reviewers may split a unit by group. Inventory
33
35
  related callers, tests and requirements. Preserve existing edits and record
34
36
  unavailable context. A supplied diff may omit the surrounding behavior needed
35
37
  to evaluate it.
@@ -73,8 +75,20 @@ arbitrary branch. Read the intended behavior and applicable project constraints.
73
75
  when a selected file is unaccounted for or a finding is not anchored, `findings`
74
76
  or `clean` otherwise. `clean` covers the reviewed scope only. A finding that
75
77
  applies a checklist rule names it in `rule`; the CLI refuses a rule that does
76
- not apply to that path. The anchored record replaces credential-like values
77
- quoted in a finding with `[REDACTED]` and counts them.
78
+ not apply to that path. A finding that breaks a compliance control lists it in
79
+ `controls`, chosen among the controls `scope.json` records for that file; the
80
+ CLI refuses any other and carries them into the check result. The anchored record replaces credential-like values
81
+ quoted in a finding with `[REDACTED]` and counts them. Record the session in
82
+ `coverage.json` under `run`: `stop` (`completed`, `budget`, `time-limit`,
83
+ `failure-streak` or `interrupted`, with a `detail` unless completed), the
84
+ `passes` actually run, every unit attempt in order (`unit`, the rule `group`
85
+ when reviewers split by group, `completed` or `failed` with a reason), and
86
+ tokens or duration only when the host reports them — never estimate them.
87
+ After three failed attempts in a row on the same unit or group, stop
88
+ retrying it: mark its files failed. A stopped review is never clean. In CI,
89
+ `bmad-plus review gate <id> --emit-check <file.json>` also writes the verdict
90
+ as a check result (`bmad-plus/review-check/1`, with a GitHub check-runs
91
+ payload); the exit code stays the verdict.
78
92
  9. On a second review of the same work, compare it with the earlier one:
79
93
  `bmad-plus review compare <id> --since <earlier-id>` writes `compare.json`
80
94
  with each finding new, persisting, refuted, resolved or not reviewed. An
@@ -95,7 +109,14 @@ or an untested requirement.
95
109
 
96
110
  ## Continue
97
111
 
98
- Compare the current diff and input hashes with the reviewed snapshot. Preserve
99
- prior findings and check their resolution against actual changes. Re-review
112
+ With a sealed scope, run `bmad-plus review continue <id>` first. When the code,
113
+ its head ref or the review rules moved, it refuses: seal a new scope and compare
114
+ it with this one (step 9) instead of continuing. Otherwise `continue.json` lists
115
+ the units, files and rule groups still owed, the files abandoned after three
116
+ failures (they stay failed), the findings to requote and the passes left; add to
117
+ the same `coverage.json` and `findings.json` and record the new `run.stop`.
118
+
119
+ Without a sealed scope, compare the current diff and input hashes with the
120
+ reviewed snapshot. Preserve prior findings and check their resolution against actual changes. Re-review
100
121
  affected behavior and invalidate conclusions that relied on changed inputs;
101
122
  do not rerun unchanged checks merely to refresh the report date.
@@ -100,7 +100,9 @@ Run Scout and Judge **simultaneously** on each discovered page:
100
100
  **Optional**: Run `scripts/seo_apis.py --all <url>` for live PageSpeed + CrUX data.
101
101
 
102
102
  Use `scripts/seo_parse.py <file> --url <url> --json` on fetched HTML.
103
- Use `scripts/seo_screenshot.py <url> --viewport mobile` for visual audit.
103
+ Use `scripts/seo_screenshot.py <url> --viewport mobile` for visual audit. The browser only
104
+ reaches the audited host: third-party resources are blocked and listed in `blocked_requests`,
105
+ so judge a missing CDN image or font as an effect of the capture, not of the site.
104
106
 
105
107
  ### Phase 3 — AI Readiness & GEO
106
108
  **Agent**: Judge (GEO Analyst role)
@@ -73,8 +73,8 @@
73
73
 
74
74
  ## API Tools
75
75
  ```bash
76
- # PageSpeed Insights API
77
- curl "https://www.googleapis.com/pagespeedonline/v5/runPagespeed?url=URL&key=API_KEY"
76
+ # PageSpeed Insights API: the key goes in a header, never in the URL (or use scripts/seo_apis.py)
77
+ curl -H "x-goog-api-key: $GOOGLE_API_KEY" "https://www.googleapis.com/pagespeedonline/v5/runPagespeed?url=URL"
78
78
 
79
79
  # Lighthouse CLI
80
80
  npx lighthouse URL --output json --output-path report.json
@@ -14,4 +14,4 @@ lxml==6.1.1
14
14
 
15
15
  # Screenshot capture (optional — only for seo_screenshot.py)
16
16
  # Uncomment and run: playwright install chromium
17
- # playwright>=1.40.0
17
+ # playwright>=1.48.0 # route_web_socket guards WebSockets
@@ -10,6 +10,9 @@ Connects to:
10
10
  Requires: GOOGLE_API_KEY environment variable (free, no OAuth).
11
11
  Get one at: https://console.cloud.google.com/apis/credentials
12
12
 
13
+ The key travels in the x-goog-api-key header only: never in a request URL, where proxies
14
+ and server logs keep it, and never in a result, a log line or an error message.
15
+
13
16
  Author: Laurent Rochetta
14
17
  License: MIT
15
18
  """
@@ -17,6 +20,7 @@ License: MIT
17
20
  import argparse
18
21
  import json
19
22
  import os
23
+ import re
20
24
  import sys
21
25
  from typing import Optional
22
26
 
@@ -27,12 +31,56 @@ except ImportError:
27
31
  sys.exit(1)
28
32
 
29
33
 
30
- API_KEY = os.environ.get("GOOGLE_API_KEY", "")
34
+ API_KEY = os.environ.get("GOOGLE_API_KEY", "").strip()
35
+ KEY_HEADER = "x-goog-api-key"
36
+ # Printable ASCII without spaces: anything else cannot be a header value, and the error
37
+ # requests would raise for it quotes the value.
38
+ KEY_SHAPE = re.compile(r"[\x21-\x7e]+")
31
39
 
32
40
  PSI_ENDPOINT = "https://www.googleapis.com/pagespeedonline/v5/runPagespeed"
33
41
  CRUX_ENDPOINT = "https://chromeuxreport.googleapis.com/v1/records:queryRecord"
34
42
  RICH_RESULTS_ENDPOINT = "https://searchconsole.googleapis.com/v1/urlTestingTools/mobileFriendlyTest:run"
35
43
 
44
+ SESSION = requests.Session()
45
+
46
+
47
+ def _key_problem() -> Optional[str]:
48
+ """Why no request can be sent, without ever quoting the key; None when it can."""
49
+ if not API_KEY:
50
+ return "GOOGLE_API_KEY not set. Get one at https://console.cloud.google.com/apis/credentials"
51
+ if not KEY_SHAPE.fullmatch(API_KEY):
52
+ return "GOOGLE_API_KEY is malformed: an API key is printable ASCII without spaces"
53
+ return None
54
+
55
+
56
+ def _redact(text: str) -> str:
57
+ """Remove the key from text headed for a result, a log or the terminal."""
58
+ return text.replace(API_KEY, "[GOOGLE_API_KEY]") if API_KEY else text
59
+
60
+
61
+ def _failure(api: str, error: Exception) -> dict:
62
+ return {"error": _redact(f"{api} request failed: {error}")}
63
+
64
+
65
+ def _send(method: str, endpoint: str, timeout: int, **body):
66
+ """
67
+ Send one request to a Google API with the key in its header.
68
+
69
+ Redirects are refused, not followed: requests would carry this header to whatever host
70
+ a redirect names (it strips only Authorization), and no Google API answers with one.
71
+ """
72
+ response = SESSION.request(
73
+ method,
74
+ endpoint,
75
+ headers={KEY_HEADER: API_KEY},
76
+ timeout=timeout,
77
+ allow_redirects=False,
78
+ **body,
79
+ )
80
+ if 300 <= response.status_code < 400:
81
+ raise requests.HTTPError(f"unexpected redirect ({response.status_code}), not followed", response=response)
82
+ return response
83
+
36
84
 
37
85
  # ── PageSpeed Insights ─────────────────────────────────────────────
38
86
 
@@ -48,25 +96,25 @@ def run_pagespeed(url: str, strategy: str = "mobile", categories: Optional[list]
48
96
  Returns:
49
97
  Structured result with scores, audits, and opportunities
50
98
  """
51
- if not API_KEY:
52
- return {"error": "GOOGLE_API_KEY not set. Get one at https://console.cloud.google.com/apis/credentials"}
99
+ problem = _key_problem()
100
+ if problem:
101
+ return {"error": problem}
53
102
 
54
103
  if categories is None:
55
104
  categories = ["PERFORMANCE", "ACCESSIBILITY", "BEST_PRACTICES", "SEO"]
56
105
 
57
106
  params = {
58
107
  "url": url,
59
- "key": API_KEY,
60
108
  "strategy": strategy,
61
109
  "category": categories,
62
110
  }
63
111
 
64
112
  try:
65
- response = requests.get(PSI_ENDPOINT, params=params, timeout=120)
113
+ response = _send("GET", PSI_ENDPOINT, 120, params=params)
66
114
  response.raise_for_status()
67
115
  data = response.json()
68
- except requests.RequestException as e:
69
- return {"error": f"PSI API request failed: {e}"}
116
+ except (requests.RequestException, ValueError) as e:
117
+ return _failure("PSI API", e)
70
118
 
71
119
  # Extract scores
72
120
  result = {
@@ -161,8 +209,9 @@ def run_crux(url: str, form_factor: str = "PHONE") -> dict:
161
209
  Returns:
162
210
  Field CWV data at 75th percentile
163
211
  """
164
- if not API_KEY:
165
- return {"error": "GOOGLE_API_KEY not set"}
212
+ problem = _key_problem()
213
+ if problem:
214
+ return {"error": problem}
166
215
 
167
216
  # Try URL-level first, fall back to origin
168
217
  from urllib.parse import urlparse
@@ -175,28 +224,20 @@ def run_crux(url: str, form_factor: str = "PHONE") -> dict:
175
224
  }
176
225
 
177
226
  try:
178
- response = requests.post(
179
- f"{CRUX_ENDPOINT}?key={API_KEY}",
180
- json=payload,
181
- timeout=30,
182
- )
227
+ response = _send("POST", CRUX_ENDPOINT, 30, json=payload)
183
228
 
184
229
  if response.status_code == 404:
185
230
  # No URL-level data, try origin
186
231
  payload = {"origin": origin, "formFactor": form_factor}
187
- response = requests.post(
188
- f"{CRUX_ENDPOINT}?key={API_KEY}",
189
- json=payload,
190
- timeout=30,
191
- )
232
+ response = _send("POST", CRUX_ENDPOINT, 30, json=payload)
192
233
 
193
234
  if response.status_code == 404:
194
235
  return {"error": f"No CrUX data available for {url} (not enough traffic)"}
195
236
 
196
237
  response.raise_for_status()
197
238
  data = response.json()
198
- except requests.RequestException as e:
199
- return {"error": f"CrUX API request failed: {e}"}
239
+ except (requests.RequestException, ValueError) as e:
240
+ return _failure("CrUX API", e)
200
241
 
201
242
  result = {
202
243
  "url": url,
@@ -264,21 +305,18 @@ def run_rich_results_test(url: str) -> dict:
264
305
  Returns:
265
306
  Mobile-friendly status and detected structured data
266
307
  """
267
- if not API_KEY:
268
- return {"error": "GOOGLE_API_KEY not set"}
308
+ problem = _key_problem()
309
+ if problem:
310
+ return {"error": problem}
269
311
 
270
312
  payload = {"url": url}
271
313
 
272
314
  try:
273
- response = requests.post(
274
- f"{RICH_RESULTS_ENDPOINT}?key={API_KEY}",
275
- json=payload,
276
- timeout=60,
277
- )
315
+ response = _send("POST", RICH_RESULTS_ENDPOINT, 60, json=payload)
278
316
  response.raise_for_status()
279
317
  data = response.json()
280
- except requests.RequestException as e:
281
- return {"error": f"URL Testing API request failed: {e}"}
318
+ except (requests.RequestException, ValueError) as e:
319
+ return _failure("URL Testing API", e)
282
320
 
283
321
  result = {
284
322
  "url": url,
@@ -383,6 +421,10 @@ def main():
383
421
  print(" Enable: PageSpeed Insights API + Chrome UX Report API", file=sys.stderr)
384
422
  print(" Set: export GOOGLE_API_KEY=your_key", file=sys.stderr)
385
423
  sys.exit(1)
424
+ problem = _key_problem()
425
+ if problem:
426
+ print(f"⚠️ {problem}", file=sys.stderr)
427
+ sys.exit(1)
386
428
 
387
429
  if args.all:
388
430
  result = run_all(args.url)
@@ -32,13 +32,14 @@ except ImportError:
32
32
  )
33
33
  sys.exit(1)
34
34
 
35
- # Reuse the hardened SSRF guard from seo_fetch (same package/directory).
35
+ # Reuse the hardened SSRF guard and pinned transport from seo_fetch
36
+ # (same package/directory).
36
37
  sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
37
38
  try:
38
- from seo_fetch import is_safe_url
39
+ from seo_fetch import ALLOWED_SCHEMES, UnsafeURLError, create_session
39
40
  except ImportError:
40
41
  print(
41
- "Error: seo_fetch.is_safe_url is required (SSRF protection). "
42
+ "Error: seo_fetch.create_session is required (SSRF protection). "
42
43
  "Ensure seo_fetch.py is present alongside seo_crawl.py.",
43
44
  file=sys.stderr,
44
45
  )
@@ -65,6 +66,8 @@ USER_AGENT = (
65
66
  "(KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36 BMADSEOEngine/2.0"
66
67
  )
67
68
 
69
+ MAX_REDIRECTS = 5
70
+
68
71
 
69
72
  class SEOCrawler:
70
73
  """Recursive mini-crawler for SEO site structure analysis."""
@@ -82,6 +85,7 @@ class SEOCrawler:
82
85
  self.sitemap_urls: list = []
83
86
  self.robots_txt: Optional[str] = None
84
87
  self.errors: list = []
88
+ self.session = create_session()
85
89
 
86
90
  def normalize_url(self, url: str) -> str:
87
91
  """Normalize URL for deduplication."""
@@ -96,42 +100,50 @@ class SEOCrawler:
96
100
  def _safe_get(self, url: str, error_prefix: str = "Blocked"):
97
101
  """GET a URL, following redirects manually with per-hop SSRF revalidation.
98
102
 
99
- Redirects are followed with allow_redirects=False so is_safe_url() runs on
100
- EVERY hop: a public URL that 302s to an internal/metadata endpoint
101
- (redirect-based SSRF) is refused. Returns the final requests.Response, or
102
- None (with an entry appended to self.errors) when a hop is unsafe or too
103
- many redirects occur. Used by fetch(), fetch_robots_txt() and
104
- parse_sitemap() so every network path shares the same guard.
103
+ Every request goes through the pinned session from seo_fetch, which
104
+ validates the target and connects to the address it validated (no DNS
105
+ rebinding between check and connect). Redirects are followed with
106
+ allow_redirects=False so each hop is validated the same way: a public URL
107
+ that 302s to an internal/metadata endpoint (redirect-based SSRF) is
108
+ refused. Returns the final requests.Response, or None (with an entry
109
+ appended to self.errors) when a hop is unsafe or too many redirects
110
+ occur. Used by fetch(), fetch_robots_txt() and parse_sitemap() so every
111
+ network path shares the same guard.
105
112
  """
106
- if not is_safe_url(url):
107
- self.errors.append(
108
- {"url": url, "error": f"{error_prefix}: private/internal URL (SSRF protection)"}
109
- )
110
- return None
111
113
  current_url = url
112
114
  hops = 0
113
115
  while True:
114
- response = requests.get(
115
- current_url,
116
- headers={"User-Agent": USER_AGENT},
117
- timeout=self.timeout,
118
- allow_redirects=False,
119
- )
116
+ try:
117
+ response = self.session.get(
118
+ current_url,
119
+ headers={"User-Agent": USER_AGENT},
120
+ timeout=self.timeout,
121
+ allow_redirects=False,
122
+ )
123
+ except UnsafeURLError as e:
124
+ prefix = error_prefix if current_url == url else f"{error_prefix} redirect"
125
+ self.errors.append(
126
+ {"url": current_url, "error": f"{prefix}: {e} (SSRF protection)"}
127
+ )
128
+ return None
120
129
  if not response.is_redirect:
121
130
  return response
122
131
  location = response.headers.get("Location")
123
132
  if not location:
124
133
  return response
134
+ response.close()
125
135
  next_url = urljoin(current_url, location)
126
- if urlparse(next_url).scheme not in ("http", "https") or not is_safe_url(next_url):
136
+ if urlparse(next_url).scheme not in ALLOWED_SCHEMES:
127
137
  self.errors.append(
128
138
  {"url": next_url,
129
- "error": f"{error_prefix} redirect: non-HTTP(S) or private/internal URL (SSRF protection)"}
139
+ "error": f"{error_prefix} redirect: non-HTTP(S) URL (SSRF protection)"}
130
140
  )
131
141
  return None
132
142
  hops += 1
133
- if hops > 5:
134
- self.errors.append({"url": current_url, "error": "Too many redirects (max 5)"})
143
+ if hops > MAX_REDIRECTS:
144
+ self.errors.append(
145
+ {"url": current_url, "error": f"Too many redirects (max {MAX_REDIRECTS})"}
146
+ )
135
147
  return None
136
148
  current_url = next_url
137
149