forgedock 1.0.21 → 1.0.22

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 (44) hide show
  1. package/README.md +134 -80
  2. package/bin/demo.mjs +386 -0
  3. package/bin/forgedock.mjs +357 -30
  4. package/bin/init-detect.mjs +95 -0
  5. package/bin/runner.mjs +780 -0
  6. package/commands/cleanup.md +85 -39
  7. package/commands/deploy-info.md +6 -2
  8. package/commands/forge-stats.md +1 -1
  9. package/commands/optimize.md +1 -1
  10. package/commands/orchestrate.md +320 -26
  11. package/commands/quality-gate.md +91 -49
  12. package/commands/review-pr-agents.md +62 -38
  13. package/commands/review-pr.md +33 -2
  14. package/commands/scope.md +11 -3
  15. package/commands/security-audit.md +24 -16
  16. package/commands/sync-ecosystem.md +2 -2
  17. package/commands/work-on/build/architect.md +47 -3
  18. package/commands/work-on/build/context.md +36 -5
  19. package/commands/work-on/build/validate.md +16 -7
  20. package/commands/work-on/close.md +129 -1
  21. package/commands/work-on/investigate.md +24 -1
  22. package/commands/work-on.md +226 -8
  23. package/docs/site/annotations-explained.md +55 -0
  24. package/docs/site/command-learning-path.md +104 -0
  25. package/docs/site/command-reference.md +3 -0
  26. package/docs/site/getting-started.md +36 -14
  27. package/docs/site/troubleshooting.md +208 -0
  28. package/docs/spec/forge-protocol-v1.md +768 -0
  29. package/examples/forgedock-demo/README.md +113 -0
  30. package/examples/forgedock-demo/bootstrap.sh +125 -0
  31. package/examples/forgedock-demo/issues/01-bug-delete-missing-auth.md +37 -0
  32. package/examples/forgedock-demo/issues/02-feature-filter-notes.md +37 -0
  33. package/examples/forgedock-demo/issues/03-refactor-router-table.md +33 -0
  34. package/examples/forgedock-demo/issues/04-perf-index-lookup.md +35 -0
  35. package/examples/forgedock-demo/issues/05-docs-api-reference.md +39 -0
  36. package/examples/forgedock-demo/labels.json +21 -0
  37. package/examples/forgedock-demo/package.json +16 -0
  38. package/examples/forgedock-demo/scripts/smoke.js +61 -0
  39. package/examples/forgedock-demo/src/auth.js +28 -0
  40. package/examples/forgedock-demo/src/db.js +66 -0
  41. package/examples/forgedock-demo/src/routes/notes.js +64 -0
  42. package/examples/forgedock-demo/src/server.js +79 -0
  43. package/package.json +5 -1
  44. package/templates/forge.yaml.minimal +44 -0
@@ -31,6 +31,11 @@ gh pr diff [PR_NUMBER] --name-only
31
31
  gh pr diff [PR_NUMBER]
32
32
  ```
33
33
 
34
+ **Hot-spot prior**:
35
+ [CHURN_CONTEXT]
36
+
37
+ If a file you are reviewing is listed above as a hot-spot, apply deeper scrutiny to it — high historical churn correlates with defect density. Prefer tracing that file's full code paths (Evidence-Based Review Protocol §2) over a quick pattern scan, and weight ambiguous findings in hot-spot files toward LIKELY rather than POSSIBLE.
38
+
34
39
  ### 2. Dynamic Exploration
35
40
  - From each changed file, follow imports and function calls
36
41
  - Trace data flows across service boundaries (API → Redis → Worker)
@@ -201,9 +206,10 @@ For each changed file, check:
201
206
 
202
207
  FastAPI's default validation error response exposes internal field names, types, and constraints. Attackers can enumerate the complete request schema by sending malformed payloads.
203
208
  ```bash
204
- for f in $MODEL_FILES; do
209
+ while IFS= read -r f; do
210
+ [ -z "$f" ] && continue
205
211
  grep -rn "RequestValidationError\|validation_exception_handler\|@app.exception_handler" services/api/app/main.py services/api/app/ 2>/dev/null | head -5
206
- done
212
+ done <<< "$MODEL_FILES"
207
213
  # If no custom handler found AND the PR adds new Pydantic models or FastAPI endpoints:
208
214
  # LIKELY MEDIUM — FastAPI default exposes internal field names/types/constraints to any caller
209
215
  # Safe pattern: catch RequestValidationError and return only {"detail": "Invalid request"} or a sanitized message
@@ -216,12 +222,13 @@ For each changed file, check:
216
222
 
217
223
  Unauthenticated health endpoints are expected to be public — the Auth agent's auth-dependency check does not apply. The relevant question is whether they reveal more than `{"status": "ok"}`.
218
224
  ```bash
219
- for f in $HEALTH_FILES; do
225
+ while IFS= read -r f; do
226
+ [ -z "$f" ] && continue
220
227
  # Find unauthenticated endpoints (no Depends() in signature) and check return content
221
228
  grep -A25 "@router\.\(get\|head\)\|async def health\|async def ping\|async def live\|async def ready\|async def status" "$f" | \
222
229
  grep -iE "version|host|port|redis|postgres|db_url|tesseract|pillow|PIL\.__version__|software|engine|server" && \
223
230
  echo "SEC: health endpoint in $f returns operational details. Reveals software versions (enables CVE targeting) or connectivity state (enables DoS confirmation). Safe pattern: return only {\"status\": \"ok\"} or {\"status\": \"degraded\"} — no version strings, no host/port, no connectivity details."
224
- done
231
+ done <<< "$HEALTH_FILES"
225
232
  ```
226
233
  **Confidence**: `LIKELY` — version strings may be intentionally scoped to internal-only endpoints; verify auth status of the endpoint.
227
234
  **Severity**: MEDIUM for software version strings (direct CVE targeting input); LOW for DB/Redis connectivity state (useful for DoS confirmation, not exploit).
@@ -231,10 +238,11 @@ For each changed file, check:
231
238
 
232
239
  Git SHAs and deployment topology exposed via response headers enable targeted CVE research and attack planning.
233
240
  ```bash
234
- for f in $MIDDLEWARE_FILES; do
241
+ while IFS= read -r f; do
242
+ [ -z "$f" ] && continue
235
243
  grep -nE "X-App-Version|X-Deployment-Color|X-.*-SHA|X-.*-Build|X-.*-Commit|Server:|X-Powered-By" "$f" 2>/dev/null && \
236
244
  echo "SEC: response header in $f leaks infrastructure details. Git SHA enables targeted CVE diffing against public commits. Deployment color reveals blue/green topology. Safe pattern: omit these headers or gate them behind an internal-only middleware."
237
- done
245
+ done <<< "$MIDDLEWARE_FILES"
238
246
  ```
239
247
  **Confidence**: `LIKELY` — header may be intentional for internal observability tooling behind an auth gate; verify it applies to all responses.
240
248
  **Severity**: LOW — information disclosure accelerates other attacks but is not directly exploitable without an additional vulnerability.
@@ -259,11 +267,12 @@ CONFIG_FILES=$(gh pr diff [PR_NUMBER] --name-only | grep -E "(traefik/|infra/ngi
259
267
 
260
268
  **A. Port bind address audit** (when `docker-compose*.yml` is in diff — trigger: `$INFRA_FILES` non-empty)
261
269
  ```bash
262
- for f in $INFRA_FILES; do
270
+ while IFS= read -r f; do
271
+ [ -z "$f" ] && continue
263
272
  # Flag non-web ports bound to 0.0.0.0 (no explicit host binding = binds to 0.0.0.0 by default)
264
273
  grep -nE "^\s+- \"[0-9]+:[0-9]+\"" "$f" | grep -v "127\.0\.0\.1\|::1" | grep -vE "\"(80|443):" && \
265
274
  echo "SEC: port published on 0.0.0.0 in $f — verify UFW/firewall is the ONLY protection. Docker daemon restart can reset iptables and expose the port directly."
266
- done
275
+ done <<< "$INFRA_FILES"
267
276
  ```
268
277
  **Confidence**: `LIKELY` — a firewall rule may exist that provides protection; flag for verification rather than hard-block.
269
278
  **Severity**: HIGH for database/internal service ports (5432, 6432, 6379); MEDIUM for other non-web ports.
@@ -271,10 +280,11 @@ done
271
280
 
272
281
  **B. Missing USER directive** (when any `Dockerfile` is in diff — trigger: `$DOCKERFILE_FILES` non-empty)
273
282
  ```bash
274
- for f in $DOCKERFILE_FILES; do
283
+ while IFS= read -r f; do
284
+ [ -z "$f" ] && continue
275
285
  grep -q "^USER " "$f" || \
276
286
  echo "SEC: CONFIRMED — no USER directive in $f. Container runs as UID 0. For services that process user-supplied URLs or untrusted input (worker, Playwright), running as root significantly lowers the bar for container escape."
277
- done
287
+ done <<< "$DOCKERFILE_FILES"
278
288
  ```
279
289
  **Confidence**: `CONFIRMED` — absence of USER directive is an objective, verifiable fact.
280
290
  **Severity**: HIGH for services processing untrusted input; MEDIUM for internal-only services.
@@ -282,10 +292,11 @@ done
282
292
 
283
293
  **C. Response header information disclosure** (when Python middleware/main.py adds response headers — trigger: `$HEADER_FILES` non-empty)
284
294
  ```bash
285
- for f in $HEADER_FILES; do
295
+ while IFS= read -r f; do
296
+ [ -z "$f" ] && continue
286
297
  grep -nE "X-App-Version|X-Deployment-Color|Server:|X-Powered-By|X-.*-SHA|X-.*-Build" "$f" 2>/dev/null && \
287
298
  echo "SEC: response header in $f leaks infrastructure details. Git SHA enables targeted CVE diffing against public commits. Deployment color reveals blue/green topology to attackers."
288
- done
299
+ done <<< "$HEADER_FILES"
289
300
  ```
290
301
  **Confidence**: `CONFIRMED` — header name reveals infra details by definition.
291
302
  **Severity**: MEDIUM — information disclosure accelerates other attacks but is not directly exploitable.
@@ -293,11 +304,12 @@ done
293
304
 
294
305
  **D. CI security scan gating** (when `.github/workflows/*.yml` is in diff — trigger: `$WORKFLOW_FILES` non-empty)
295
306
  ```bash
296
- for f in $WORKFLOW_FILES; do
307
+ while IFS= read -r f; do
308
+ [ -z "$f" ] && continue
297
309
  # Find security scanner steps with continue-on-error: true
298
310
  grep -B5 "continue-on-error: true" "$f" | grep -iE "trivy|snyk|security|cve|scan|grype|dockle" && \
299
311
  echo "SEC: security scanner in $f has continue-on-error: true — CVEs will be logged but will NEVER block a deploy. Remove continue-on-error or add a severity threshold."
300
- done
312
+ done <<< "$WORKFLOW_FILES"
301
313
  ```
302
314
  **Confidence**: `CONFIRMED` — `continue-on-error: true` on a security scanner step is objectively a non-blocking scan.
303
315
  **Severity**: HIGH — security scanner provides zero protection when it cannot block.
@@ -305,10 +317,11 @@ done
305
317
 
306
318
  **E. In-memory rate limiter** (when TypeScript files are in diff — trigger: `$TS_FILES` non-empty)
307
319
  ```bash
308
- for f in $TS_FILES; do
320
+ while IFS= read -r f; do
321
+ [ -z "$f" ] && continue
309
322
  grep -nE "new Map\(\)|=\s*\{\}|= new Map" "$f" 2>/dev/null | grep -iE "rate|limit|attempt|count|throttle|window" && \
310
323
  echo "SEC: in-memory rate limiter detected in $f — resets on every container restart, not shared across blue/green replicas. Use Redis (via Upstash, ioredis, or the existing Redis client) for distributed rate limiting."
311
- done
324
+ done <<< "$TS_FILES"
312
325
  ```
313
326
  **Confidence**: `LIKELY` — in-memory store may be intentional for non-critical paths; flag for verification.
314
327
  **Severity**: MEDIUM — bypass requires restarting the container or hitting a different replica; not trivially exploitable but provides no protection in blue/green deploys.
@@ -316,10 +329,11 @@ done
316
329
 
317
330
  **F. Cookie security flags** (when TypeScript files are in diff — trigger: `$TS_FILES` non-empty)
318
331
  ```bash
319
- for f in $TS_FILES; do
332
+ while IFS= read -r f; do
333
+ [ -z "$f" ] && continue
320
334
  grep -nE "httpOnly:\s*false|secure:\s*false|sameSite.*['\"]none['\"]" "$f" 2>/dev/null && \
321
335
  echo "SEC: cookie in $f has weakened security flag. httpOnly: false exposes cookie to JS (XSS risk). secure: false allows transmission over HTTP. sameSite: none without Secure enables CSRF."
322
- done
336
+ done <<< "$TS_FILES"
323
337
  ```
324
338
  **Confidence**: `CONFIRMED` — explicit `false`/`none` values are objective findings.
325
339
  **Severity**: HIGH for `httpOnly: false` (direct XSS vector for session cookies); MEDIUM for `secure: false` or `sameSite: none`.
@@ -327,11 +341,12 @@ done
327
341
 
328
342
  **G. Fallback credential scan in config files** (when config files are in diff — trigger: `$CONFIG_FILES` non-empty)
329
343
  ```bash
330
- for f in $CONFIG_FILES; do
344
+ while IFS= read -r f; do
345
+ [ -z "$f" ] && continue
331
346
  # Shell-style fallback: ${VAR:-default} — flag non-trivial fallback values that may be credentials
332
347
  grep -nE "\$\{[A-Z_]+:-[^}]{4,}\}" "$f" | grep -ivE "(localhost|127\.0\.0\.1|0\.0\.0\.0|true|false|^[0-9]+$|/tmp|/var|/etc)" && \
333
348
  echo "SEC: potential credential fallback in $f — verify fallback value is safe if env var is absent"
334
- done
349
+ done <<< "$CONFIG_FILES"
335
350
  ```
336
351
  For each hit: read the fallback value.
337
352
  - Contains `$apr1$`, `$2b$`, `$bcrypt$`, `$argon2`, or hash-like long alphanumeric with `$` separators: **CONFIRMED HIGH** — htpasswd/bcrypt placeholder; active if env var is absent.
@@ -345,15 +360,17 @@ For each hit: read the fallback value.
345
360
 
346
361
  **H. Runtime UID × volume ownership** (when any Dockerfile is in diff AND diff contains `USER`, `su-exec`, `gosu`, or `setuid` — trigger: `$DOCKERFILE_FILES` non-empty AND UID change detected)
347
362
  ```bash
348
- UID_CHANGE=$(for f in $DOCKERFILE_FILES; do
363
+ UID_CHANGE=$(while IFS= read -r f; do
364
+ [ -z "$f" ] && continue
349
365
  gh pr diff [PR_NUMBER] -- "$f" 2>/dev/null | grep -E '^\+.*(USER\s+[^0]|su-exec|gosu|setuid)' | grep -v '^+++' && break
350
- done)
366
+ done <<< "$DOCKERFILE_FILES")
351
367
  if [ -n "$UID_CHANGE" ]; then
352
368
  echo "UID change detected — cross-referencing named volume mounts..."
353
369
  COMPOSE_FILES=$(find . -maxdepth 2 -name "docker-compose*.yml" 2>/dev/null | sort)
354
- for cf in $COMPOSE_FILES; do
370
+ while IFS= read -r cf; do
371
+ [ -z "$cf" ] && continue
355
372
  grep -A2 "volumes:" "$cf" | grep -E "^\s+\w.*:" | grep -v "^--$" && echo " ^ in $cf"
356
- done
373
+ done <<< "$COMPOSE_FILES"
357
374
  fi
358
375
  ```
359
376
  For each named volume found: determine its container mount point. Named volumes are created as **root-owned** by Docker at first use — a privilege drop (root → UID N) without a `chown` before the drop will silently break any service path that calls `mkdir`, `write_bytes`, `open(... 'w')`, or `os.makedirs` under that mount point.
@@ -1414,17 +1431,19 @@ If no infra context is configured above, derive the deployment model from the ch
1414
1431
  OLD_VALUES=$(gh pr diff [PR_NUMBER] | grep "^\-" | grep -oP '"status"\s*:\s*"\K[^"]+' | sort -u)
1415
1432
 
1416
1433
  # Search all non-application consumer locations for those old values
1417
- for val in $OLD_VALUES; do
1434
+ while IFS= read -r val; do
1435
+ [ -z "$val" ] && continue
1418
1436
  echo "=== Searching for old response value: $val ==="
1419
1437
  grep -rn "$val" scripts/ infra/ .github/ traefik/ 2>/dev/null | grep -v "^Binary"
1420
1438
  grep -rn "$val" docker-compose*.yml 2>/dev/null
1421
- done
1439
+ done <<< "$OLD_VALUES"
1422
1440
 
1423
1441
  # Also search for endpoint path references in consumer locations
1424
1442
  HEALTH_PATHS=$(gh pr diff [PR_NUMBER] | grep "^\+" | grep -oP '@\w+\.(?:get|post)\("\K[^"]+(?:health|status|ping|ready|live|readiness|liveness)[^"]*')
1425
- for path in $HEALTH_PATHS; do
1443
+ while IFS= read -r path; do
1444
+ [ -z "$path" ] && continue
1426
1445
  grep -rn "$path" scripts/ infra/ .github/ traefik/ docker-compose*.yml 2>/dev/null | grep -v "^Binary"
1427
- done
1446
+ done <<< "$HEALTH_PATHS"
1428
1447
  ```
1429
1448
 
1430
1449
  **For each hit, classify:**
@@ -1522,13 +1541,14 @@ If no infra context is configured above, derive the deployment model from the ch
1522
1541
 
1523
1542
  **Scan ALL `{{` occurrences in every `script:` block of every `appleboy/ssh-action` step in changed files:**
1524
1543
  ```bash
1525
- for f in $(gh pr diff [PR_NUMBER] --name-only | grep -E '\.github/workflows/.*\.yml$'); do
1544
+ while IFS= read -r f; do
1545
+ [ -z "$f" ] && continue
1526
1546
  if grep -q "appleboy/ssh-action" "$f"; then
1527
1547
  echo "=== appleboy/ssh-action found in $f ==="
1528
1548
  # Show all {{ occurrences with line numbers
1529
1549
  grep -n "{{" "$f"
1530
1550
  fi
1531
- done
1551
+ done < <(gh pr diff [PR_NUMBER] --name-only | grep -E '\.github/workflows/.*\.yml$')
1532
1552
  ```
1533
1553
 
1534
1554
  **For every `{{` found, classify and flag:**
@@ -1549,10 +1569,11 @@ If no infra context is configured above, derive the deployment model from the ch
1549
1569
  **Scan changed config files for credential fallbacks:**
1550
1570
  ```bash
1551
1571
  CONFIG_FILES=$(gh pr diff [PR_NUMBER] --name-only | grep -E "(traefik/|infra/nginx/|infra/|\.github/workflows/).*(\.ya?ml|\.toml|\.json|\.conf|\.ini)$" | head -20)
1552
- for f in $CONFIG_FILES; do
1572
+ while IFS= read -r f; do
1573
+ [ -z "$f" ] && continue
1553
1574
  # Shell-style fallback: ${VAR:-default} — filter out safe non-credential defaults
1554
1575
  grep -nE "\$\{[A-Z_]+:-[^}]{4,}\}" "$f" | grep -ivE "(localhost|127\.0\.0\.1|0\.0\.0\.0|true|false|^[0-9]+$|/tmp|/var|/etc)" | head -20
1555
- done
1576
+ done <<< "$CONFIG_FILES"
1556
1577
  ```
1557
1578
 
1558
1579
  **For each hit, classify the fallback value:**
@@ -1588,9 +1609,10 @@ If no infra context is configured above, derive the deployment model from the ch
1588
1609
  **Scan for host-side database tool invocations:**
1589
1610
  ```bash
1590
1611
  DEPLOY_FILES=$(gh pr diff [PR_NUMBER] --name-only | grep -E "(\.github/workflows/.*\.yml$|scripts/deploy.*\.sh$|docker-entrypoint.*\.sh$)")
1591
- for f in $DEPLOY_FILES; do
1612
+ while IFS= read -r f; do
1613
+ [ -z "$f" ] && continue
1592
1614
  grep -n "\bpsql\b\|\bcreatedb\b\|\bpg_dump\b\|\bpg_restore\b" "$f" | grep -v "docker exec" | head -20
1593
- done
1615
+ done <<< "$DEPLOY_FILES"
1594
1616
  ```
1595
1617
 
1596
1618
  **For each match, classify:**
@@ -1633,15 +1655,17 @@ If no infra context is configured above, derive the deployment model from the ch
1633
1655
  COMPOSE_INFRA_FILES=$(gh pr diff [PR_NUMBER] --name-only | grep -E "docker-compose.*\.yml$")
1634
1656
 
1635
1657
  # For each config file: read the full file (not just the diff) to understand the structure
1636
- for f in $CONFIG_TOOL_FILES; do
1658
+ while IFS= read -r f; do
1659
+ [ -z "$f" ] && continue
1637
1660
  echo "=== Reading full config structure: $f ==="
1638
1661
  cat "$f"
1639
- done
1662
+ done <<< "$CONFIG_TOOL_FILES"
1640
1663
 
1641
1664
  # Identify tool version from docker-compose (for version-aware schema reasoning)
1642
- for f in $COMPOSE_INFRA_FILES; do
1665
+ while IFS= read -r f; do
1666
+ [ -z "$f" ] && continue
1643
1667
  grep -E "image:\s*(traefik|nginx|haproxy|envoy|caddy|terraform):" "$f" 2>/dev/null && echo " ^ in $f"
1644
- done
1668
+ done <<< "$COMPOSE_INFRA_FILES"
1645
1669
  ```
1646
1670
 
1647
1671
  **Severity classification**:
@@ -624,6 +624,36 @@ echo "$DIFF" | grep -cE "subprocess|os\.system|eval\(|exec\(|pickle|yaml\.load[^
624
624
  echo "$FILES" | grep -cE "^sdk/|openapi.*\.json$|openapi-versions/" && echo " SDK_OPENAPI" || true
625
625
  ```
626
626
 
627
+ **Churn (hot-spot) signal**: The signals above are all derived from the current diff content — none of them measure historical change frequency, which is one of the strongest empirical predictors of defect density. Compute a bounded per-file churn tier for the PR's changed files only (never repo-wide) and carry it forward as `CHURN_CONTEXT` for Phase 3C:
628
+
629
+ ```bash
630
+ CHURN_WINDOW="90 days ago" # named constant — must match the same window used in architect.md Phase A5
631
+ CHURN_CONTEXT=""
632
+ # Herestring (not a piped `| while read`) — a pipe would run the loop body in a
633
+ # subshell in bash, silently discarding CHURN_CONTEXT once the loop exits. $FILES
634
+ # is one path per line (gh pr diff --name-only), so `read -r` per line is safe
635
+ # even when a path contains embedded spaces.
636
+ while IFS= read -r FILE; do
637
+ [ -z "$FILE" ] && continue
638
+ COMMITS=$(git log --oneline --since="$CHURN_WINDOW" -- "$FILE" 2>/dev/null | wc -l)
639
+ if [ "$COMMITS" -ge 15 ]; then
640
+ # Real newline appended via $'\n' (ANSI-C quoting), not a literal backslash-n.
641
+ # A literal "\n" would only be interpreted by `echo -e` — it survives
642
+ # unprocessed into Phase 3C's raw template substitution ([CHURN_CONTEXT] ->
643
+ # $CHURN_CONTEXT), which does not reprocess escapes, so with 2+ HOT files
644
+ # multi-hotspot PRs would render squished in the agent prompt. A real
645
+ # newline here makes the variable correct for every consumer.
646
+ CHURN_CONTEXT="${CHURN_CONTEXT}${FILE} (${COMMITS} commits in last 90 days — HOT)"$'\n'
647
+ fi
648
+ done <<< "$FILES"
649
+ if [ -z "$CHURN_CONTEXT" ]; then
650
+ CHURN_CONTEXT="No hot-spot files detected (all changed files under 15 commits in the last 90 days)."
651
+ fi
652
+ echo "$CHURN_CONTEXT"
653
+ ```
654
+
655
+ Tiers (fixed thresholds, identical to `architect.md` Phase A5): **HOT** = 15+ commits / 90 days, **MEDIUM** = 5–14, **LOW** = 0–4. Only HOT-tier files are listed in `CHURN_CONTEXT` — this keeps the agent prompt signal short and high-value rather than dumping a churn count for every file. This does not change agent selection (3B) — it is a scrutiny prior surfaced to whichever agents are already selected, substituted as `[CHURN_CONTEXT]` in Phase 3C.
656
+
627
657
  ### 3B: Select Agents
628
658
 
629
659
  | Risk Signal | Required Agents |
@@ -687,8 +717,9 @@ This file contains the Evidence-Based Review Protocol, Structured Findings Proto
687
717
  2. Substitute: `[PR_NUMBER]`, `[REVIEW_SHA]`, `[REVIEW_SHA_SHORT]`, `[TITLE]`, relevant files list
688
718
  3. Substitute: `[PROJECT_NAME]` → `$PROJECT_NAME`, `[PROJECT_CONTEXT]` → `$PROJECT_CONTEXT`, `[TECH_STACK]` → `$TECH_STACK`
689
719
  4. Substitute per-agent domain context: `[DOMAIN_CONTEXT]` → the agent's matching key from `forge.yaml → review.domains` (e.g., `$DOMAIN_CONTEXT_AUTH` for the auth agent, `$DOMAIN_CONTEXT_BILLING` for the billing agent)
690
- 5. If Phase 2.5 found broken assumptions, append them to the agent's prompt as "Pre-found integration issues to verify"
691
- 6. Launch via `Task` tool with the resolved model (default `"sonnet"`, fallback `"opus"` if rate-limited)
720
+ 5. Substitute the shared hot-spot prior: `[CHURN_CONTEXT]` → `$CHURN_CONTEXT` (computed once in Phase 3A, same value for every agent — this is a PR-level fact, not a per-domain config value, so it is NOT read from `forge.yaml`)
721
+ 6. If Phase 2.5 found broken assumptions, append them to the agent's prompt as "Pre-found integration issues to verify"
722
+ 7. Launch via `Task` tool with the resolved model (default `"sonnet"`, fallback `"opus"` if rate-limited)
692
723
 
693
724
  **CRITICAL**: Launch ALL selected agents in a SINGLE message using multiple Task tool calls. Each agent posts findings directly to the PR via `gh pr comment`.
694
725
 
package/commands/scope.md CHANGED
@@ -79,13 +79,21 @@ Limit `DOMAIN_TERMS` to the 10 most specific (skip generic words: "fix", "bug",
79
79
  Search `REPO_PATH` for each keyword to find candidate files:
80
80
 
81
81
  ```bash
82
+ # {TARGET_FILES} and {DOMAIN_TERMS} are space-separated arguments (same contract
83
+ # as {AFFECTED_FILES} elsewhere) — split explicitly on IFS=' ' into arrays instead
84
+ # of a bare `for x in {PLACEHOLDER}`, which word-splits on the shell's default IFS
85
+ # (space, tab, AND newline) and would corrupt any file path or compound domain
86
+ # term containing a space.
87
+ IFS=' ' read -ra TARGET_FILES_ARR <<< "{TARGET_FILES}"
88
+ IFS=' ' read -ra DOMAIN_TERMS_ARR <<< "{DOMAIN_TERMS}"
89
+
82
90
  # For each file explicitly named in the issue
83
- for file in {TARGET_FILES}; do
91
+ for file in "${TARGET_FILES_ARR[@]}"; do
84
92
  find "$REPO_PATH" -name "$file" -not -path "*/.git/*" -not -path "*/node_modules/*" 2>/dev/null
85
93
  done
86
94
 
87
95
  # For each domain term: grep for references
88
- for term in {DOMAIN_TERMS}; do
96
+ for term in "${DOMAIN_TERMS_ARR[@]}"; do
89
97
  grep -rl "$term" "$REPO_PATH" \
90
98
  --include="*.py" --include="*.ts" --include="*.tsx" \
91
99
  --include="*.md" --include="*.yml" --include="*.yaml" \
@@ -96,7 +104,7 @@ done
96
104
 
97
105
  # Git log — files recently changed alongside the named files
98
106
  if [ -n "{TARGET_FILES}" ]; then
99
- for file in {TARGET_FILES}; do
107
+ for file in "${TARGET_FILES_ARR[@]}"; do
100
108
  git -C "$REPO_PATH" log --oneline -20 -- "$file" \
101
109
  --name-only --format="" 2>/dev/null | grep -v "^$" | sort -u | head -10
102
110
  done
@@ -110,13 +110,14 @@ fi
110
110
  **Check**:
111
111
  ```bash
112
112
  DOCKER_COMPOSE_FILES=$(find "$REPO_PATH" -name "docker-compose*.yml" -not -path "*/.git/*" 2>/dev/null)
113
- for f in $DOCKER_COMPOSE_FILES; do
113
+ while IFS= read -r f; do
114
+ [ -z "$f" ] && continue
114
115
  HITS=$(grep -n '0\.0\.0\.0:[0-9]' "$f" 2>/dev/null | grep -v '^\s*#' || true)
115
116
  if [ -n "$HITS" ]; then
116
117
  echo "FINDING [1A] $f:"
117
118
  echo "$HITS"
118
119
  fi
119
- done
120
+ done <<< "$DOCKER_COMPOSE_FILES"
120
121
  ```
121
122
 
122
123
  **Confirm**: For each hit, verify the port is NOT a web-facing port (80, 443, 8080, 3000). Ports like 6432 (PgBouncer), 6379 (Redis), 5432 (Postgres), 27017 (MongoDB) bound to `0.0.0.0` are confirmed findings.
@@ -134,12 +135,13 @@ done
134
135
  **Check**:
135
136
  ```bash
136
137
  DOCKERFILES=$(find "$REPO_PATH" -name "Dockerfile*" -not -path "*/.git/*" 2>/dev/null)
137
- for f in $DOCKERFILES; do
138
+ while IFS= read -r f; do
139
+ [ -z "$f" ] && continue
138
140
  # Skip if USER directive is present (anywhere in the file, case-insensitive)
139
141
  if ! grep -iq '^\s*USER\s' "$f" 2>/dev/null; then
140
142
  echo "FINDING [1B] $f: no USER directive — container runs as root"
141
143
  fi
142
- done
144
+ done <<< "$DOCKERFILES"
143
145
  ```
144
146
 
145
147
  **Confirm**: Check if the Dockerfile is for a long-running service (not a one-shot build image). If service container with no USER → confirmed finding.
@@ -157,14 +159,15 @@ done
157
159
  **Check**:
158
160
  ```bash
159
161
  DOCKER_COMPOSE_FILES=$(find "$REPO_PATH" -name "docker-compose*.yml" -not -path "*/.git/*" 2>/dev/null)
160
- for f in $DOCKER_COMPOSE_FILES; do
162
+ while IFS= read -r f; do
163
+ [ -z "$f" ] && continue
161
164
  # Redis without requirepass
162
165
  REDIS_NO_AUTH=$(grep -A5 'image:.*redis' "$f" 2>/dev/null | grep -v 'requirepass\|password' | grep -c 'redis' || true)
163
166
  # Postgres without POSTGRES_PASSWORD
164
167
  PG_NO_AUTH=$(awk '/image:.*postgres/{found=1} found && /POSTGRES_PASSWORD/{found=0} found && /^[[:space:]]*-[[:space:]]*[A-Z]/{print}' "$f" 2>/dev/null | grep -v 'POSTGRES_PASSWORD' | head -3 || true)
165
168
 
166
169
  [ -n "$PG_NO_AUTH" ] && echo "FINDING [1C] $f: Postgres service may lack POSTGRES_PASSWORD"
167
- done
170
+ done <<< "$DOCKER_COMPOSE_FILES"
168
171
  ```
169
172
 
170
173
  **Manual review**: Check for `POSTGRES_PASSWORD` and `requirepass` in compose environment sections. Flag any service using a blank/trivial password as a documented value.
@@ -184,7 +187,8 @@ done
184
187
  **Check**:
185
188
  ```bash
186
189
  WORKFLOW_FILES=$(find "$REPO_PATH/.github/workflows" -name "*.yml" 2>/dev/null)
187
- for f in $WORKFLOW_FILES; do
190
+ while IFS= read -r f; do
191
+ [ -z "$f" ] && continue
188
192
  # Find security scanner steps then check if continue-on-error follows within 5 lines
189
193
  awk '
190
194
  /trivy|snyk|owasp|dependency.check|gitleaks|semgrep|gosec|bandit/ {
@@ -195,7 +199,7 @@ for f in $WORKFLOW_FILES; do
195
199
  print FILENAME ":" NR ": continue-on-error: true near security scanner at line " scanner_line
196
200
  }
197
201
  ' FILENAME="$f" "$f" 2>/dev/null || true
198
- done
202
+ done <<< "$WORKFLOW_FILES"
199
203
  ```
200
204
 
201
205
  **Confirm**: Verify the flagged step is indeed a security scanner step (not a notification or deploy step). Confirmed if scanner produces failure exit codes that are being swallowed.
@@ -213,10 +217,11 @@ done
213
217
  **Check**:
214
218
  ```bash
215
219
  CONFIG_FILES=$(find "$REPO_PATH/infra" "$REPO_PATH/services" -name "*.conf" -o -name "*.yml" -o -name "nginx.conf" 2>/dev/null | grep -v '.git')
216
- for f in $CONFIG_FILES; do
220
+ while IFS= read -r f; do
221
+ [ -z "$f" ] && continue
217
222
  HITS=$(grep -n '^\s*#.*\(deny\|allow\s\|auth_basic\|auth_request\|satisfy all\|limit_req\|access_by_lua\)' "$f" 2>/dev/null || true)
218
223
  [ -n "$HITS" ] && echo "FINDING [2B] $f:" && echo "$HITS"
219
- done
224
+ done <<< "$CONFIG_FILES"
220
225
  ```
221
226
 
222
227
  **Confirm**: Distinguish intentional comments (documentation) from disabled access control rules. Confirmed if the commented line is a functional directive that would restrict access.
@@ -236,10 +241,11 @@ done
236
241
  **Check**:
237
242
  ```bash
238
243
  CSP_FILES=$(grep -rl "Content-Security-Policy\|script-src" "$REPO_PATH" --include="*.js" --include="*.ts" --include="*.json" --include="*.conf" --include="*.yml" 2>/dev/null | grep -v '.git' | grep -v node_modules)
239
- for f in $CSP_FILES; do
244
+ while IFS= read -r f; do
245
+ [ -z "$f" ] && continue
240
246
  HITS=$(grep -n "script-src.*unsafe-inline\|unsafe-inline.*script-src" "$f" 2>/dev/null || true)
241
247
  [ -n "$HITS" ] && echo "FINDING [3A] $f:" && echo "$HITS"
242
- done
248
+ done <<< "$CSP_FILES"
243
249
  ```
244
250
 
245
251
  **Confirm**: Verify the CSP header applies to the production site (not just dev/storybook). Check if a nonce-based approach is already in use (nonce + unsafe-inline is still a finding but lower severity).
@@ -257,10 +263,11 @@ done
257
263
  **Check**:
258
264
  ```bash
259
265
  HEADER_FILES=$(grep -rl "X-App-Version\|X-Deployment-Color\|X-Git-Sha\|X-Build-Id\|Server:" "$REPO_PATH" --include="*.py" --include="*.ts" --include="*.js" --include="*.conf" 2>/dev/null | grep -v '.git' | grep -v node_modules)
260
- for f in $HEADER_FILES; do
266
+ while IFS= read -r f; do
267
+ [ -z "$f" ] && continue
261
268
  HITS=$(grep -n '"X-App-Version"\|"X-Deployment-Color"\|"X-Git-Sha"\|"X-Build-Id"\|"Server:' "$f" 2>/dev/null || true)
262
269
  [ -n "$HITS" ] && echo "FINDING [3B] $f:" && echo "$HITS"
263
- done
270
+ done <<< "$HEADER_FILES"
264
271
  ```
265
272
 
266
273
  **Confirm**: Check if the header is set in a response (not just read). If set in outbound responses → confirmed finding. Internal-only APIs (not publicly routed) are `priority:P3`; public APIs are `priority:P2`.
@@ -435,14 +442,15 @@ payment_intent.payment_failed
435
442
  **Check**:
436
443
  ```bash
437
444
  WEBHOOK_FILES=$(grep -rl "stripe\|webhook" "$REPO_PATH/services" --include="*.py" 2>/dev/null | grep -v '.git' | head -10)
438
- for f in $WEBHOOK_FILES; do
445
+ while IFS= read -r f; do
446
+ [ -z "$f" ] && continue
439
447
  REQUIRED_EVENTS="customer.subscription.deleted invoice.payment_failed charge.refunded charge.dispute.created"
440
448
  for event in $REQUIRED_EVENTS; do
441
449
  if ! grep -q "\"$event\"\|'$event'" "$f" 2>/dev/null; then
442
450
  echo "FINDING [4A] $f: Stripe event '$event' not handled"
443
451
  fi
444
452
  done
445
- done
453
+ done <<< "$WEBHOOK_FILES"
446
454
  ```
447
455
 
448
456
  **Confirm**: Verify the event type appears in a handler dispatch (not just a comment). Confirmed if the event would change user entitlements or financial state but has no handler.
@@ -444,7 +444,7 @@ npm version {minor|patch|major} --no-git-tag-version
444
444
  # 3. Commit the version bump
445
445
  NEW_VERSION=$(grep '"version"' package.json | grep -oP '\d+\.\d+\.\d+')
446
446
  git add package.json package-lock.json
447
- git commit -m "chore: bump version to ${NEW_VERSION}"
447
+ git commit -s -m "chore: bump version to ${NEW_VERSION}"
448
448
  git push origin main
449
449
 
450
450
  # 4. Create GitHub Release (triggers {PUBLISH_WORKFLOW} automatically)
@@ -495,7 +495,7 @@ cd {SUBPATH} && npm version {minor|patch} --no-git-tag-version && cd -
495
495
 
496
496
  # Commit and push
497
497
  git add {SUBPATH}/pyproject.toml {SUBPATH}/package.json {SUBPATH}/package-lock.json
498
- git commit -m "chore({COMPONENT}): bump version to {NEW_VERSION}"
498
+ git commit -s -m "chore({COMPONENT}): bump version to {NEW_VERSION}"
499
499
  git push origin fix/{COMPONENT}-version-bump
500
500
 
501
501
  # Create PR to {STAGING_BRANCH}, merge, then trigger publish from main
@@ -176,6 +176,11 @@ if [ -n "$DEVDOCS_PATH" ] && [ -f "$INDEX_PATH" ]; then
176
176
  ISSUE_LABELS=$(gh issue view {NUMBER} -R {GH_REPO} --json labels \
177
177
  --jq '[.labels[].name] | join(" ")' 2>/dev/null || echo "")
178
178
  echo "Issue labels: ${ISSUE_LABELS:-none}"
179
+ # Separate newline-joined list for the loop below — GitHub label names CAN
180
+ # contain spaces (e.g. "good first issue"), so the space-joined $ISSUE_LABELS
181
+ # above is only safe for display, never for iteration.
182
+ ISSUE_LABELS_LIST=$(gh issue view {NUMBER} -R {GH_REPO} --json labels \
183
+ --jq '.labels[].name' 2>/dev/null || echo "")
179
184
 
180
185
  # Read index content
181
186
  INDEX_CONTENT=$(cat "$INDEX_PATH" 2>/dev/null || echo "")
@@ -190,7 +195,12 @@ if [ -n "$DEVDOCS_PATH" ] && [ -f "$INDEX_PATH" ]; then
190
195
 
191
196
  # Extract domain blocks matching any issue label keyword
192
197
  DOMAIN_PATHS=""
193
- for label in $ISSUE_LABELS; do
198
+ # $ISSUE_LABELS_LIST is one label per line — herestring (not a piped
199
+ # `| while read`, which would run the loop body in a subshell and discard
200
+ # DOMAIN_PATHS once the loop exits) preserves newline-safety for labels
201
+ # containing spaces.
202
+ while IFS= read -r label; do
203
+ [ -z "$label" ] && continue
194
204
  KEYWORD=$(echo "$label" | sed 's/^workflow://; s/^priority://; s/^review-finding$//')
195
205
  [ -z "$KEYWORD" ] && continue
196
206
  # Sanitize KEYWORD before AWK injection — strip chars that would break awk regex delimiters
@@ -207,7 +217,7 @@ if [ -n "$DEVDOCS_PATH" ] && [ -f "$INDEX_PATH" ]; then
207
217
  echo "Domain '${SAFE_KEYWORD}' matched — adding docs: $(echo "$BLOCK_PATHS" | tr '\n' ' ')"
208
218
  DOMAIN_PATHS="${DOMAIN_PATHS}${BLOCK_PATHS}"$'\n'
209
219
  fi
210
- done
220
+ done <<< "$ISSUE_LABELS_LIST"
211
221
 
212
222
  # Combine always_load + domain paths (deduplicate)
213
223
  ALL_PATHS=$(printf "%s\n%s" "$ALWAYS_LOAD_PATHS" "$DOMAIN_PATHS" \
@@ -448,11 +458,45 @@ For each non-obvious interaction or potential failure mode:
448
458
  - Suggest a mitigation or check
449
459
 
450
460
  Focus on:
451
- - Files that have changed recently (check `git log --oneline -10 -- {FILE}`)
461
+ - Churn / hot-spot files (see below) — high historical change frequency correlates with defect density
452
462
  - Paths that are called in both sync and async contexts
453
463
  - Any place where the change touches a serialization boundary (JSON in/out, DB read/write)
454
464
  - Unused or dormant code that the change might activate
455
465
 
466
+ ### Churn (Hot-Spot) Signal
467
+
468
+ Recency alone (did the file appear in the last N commits?) is a weak, binary proxy. Compute a bounded per-file **churn tier** instead — how often each affected file has actually changed over a fixed window — and fold it into the risk table below.
469
+
470
+ For each file in `{AFFECTED_FILES}` (never repo-wide — this must stay O(affected files) to fit the Phase A0–A5 time budget):
471
+
472
+ ```bash
473
+ CHURN_WINDOW="90 days ago" # named constant — must match the same window used in review-pr.md Phase 3A
474
+
475
+ # {AFFECTED_FILES} is a space-separated argument (see --files contract at the top
476
+ # of this file), not newline-separated like review-pr.md's $FILES — so a herestring
477
+ # `while read` line-loop would misparse it as a single line. Split explicitly on
478
+ # IFS=' ' into an array instead of relying on a bare `for FILE in {AFFECTED_FILES}`
479
+ # (which word-splits on the shell's default IFS — space, tab, AND newline). This
480
+ # keeps the existing space-separated contract intact while narrowing the splitting
481
+ # surface to spaces only.
482
+ IFS=' ' read -ra AFFECTED_FILES_ARR <<< "{AFFECTED_FILES}"
483
+ for FILE in "${AFFECTED_FILES_ARR[@]}"; do
484
+ COMMITS=$(git log --oneline --since="$CHURN_WINDOW" -- "$FILE" | wc -l)
485
+ if [ "$COMMITS" -ge 15 ]; then TIER="HOT"
486
+ elif [ "$COMMITS" -ge 5 ]; then TIER="MEDIUM"
487
+ else TIER="LOW"
488
+ fi
489
+ echo "$FILE: $TIER ($COMMITS commits in last 90 days)"
490
+ done
491
+ ```
492
+
493
+ **Tiers** (fixed thresholds — keep identical to `review-pr.md` Phase 3A so both phases agree on what "hot" means):
494
+ - **HOT**: 15+ commits in the window
495
+ - **MEDIUM**: 5–14 commits in the window
496
+ - **LOW**: 0–4 commits in the window
497
+
498
+ Any file classified **HOT** MUST get an explicit row in the Risk Assessment table below (e.g. "High historical churn — {N} commits in last 90 days — changes here have a higher defect-density prior"), rated at least MEDIUM severity, with a mitigation such as "extra reviewer scrutiny" or "prefer smaller, isolated diffs to this file." MEDIUM-tier files may be noted inline without a dedicated row. LOW-tier files need no mention.
499
+
456
500
  ---
457
501
 
458
502
  ## Output Format