forgedock 1.0.20 → 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 (49) hide show
  1. package/README.md +146 -77
  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/ci-audit.md +469 -0
  7. package/commands/cleanup.md +85 -39
  8. package/commands/deploy-info.md +6 -2
  9. package/commands/forge-stats.md +1 -1
  10. package/commands/optimize.md +1 -1
  11. package/commands/orchestrate.md +684 -33
  12. package/commands/quality-gate.md +166 -46
  13. package/commands/review-pr-agents.md +106 -34
  14. package/commands/review-pr.md +33 -2
  15. package/commands/scope.md +11 -3
  16. package/commands/security-audit.md +24 -16
  17. package/commands/sync-ecosystem.md +2 -2
  18. package/commands/work-on/build/architect.md +47 -3
  19. package/commands/work-on/build/context.md +36 -5
  20. package/commands/work-on/build/validate.md +16 -7
  21. package/commands/work-on/close.md +129 -1
  22. package/commands/work-on/investigate.md +24 -1
  23. package/commands/work-on.md +226 -8
  24. package/docs/comparison.md +135 -0
  25. package/docs/design/developer-objections-ai-context-tools.md +244 -0
  26. package/docs/design/messaging-angles-research.md +179 -0
  27. package/docs/design/show-hn-draft.md +86 -0
  28. package/docs/site/annotations-explained.md +55 -0
  29. package/docs/site/command-learning-path.md +104 -0
  30. package/docs/site/command-reference.md +3 -0
  31. package/docs/site/getting-started.md +36 -14
  32. package/docs/site/troubleshooting.md +208 -0
  33. package/docs/spec/forge-protocol-v1.md +768 -0
  34. package/examples/forgedock-demo/README.md +113 -0
  35. package/examples/forgedock-demo/bootstrap.sh +125 -0
  36. package/examples/forgedock-demo/issues/01-bug-delete-missing-auth.md +37 -0
  37. package/examples/forgedock-demo/issues/02-feature-filter-notes.md +37 -0
  38. package/examples/forgedock-demo/issues/03-refactor-router-table.md +33 -0
  39. package/examples/forgedock-demo/issues/04-perf-index-lookup.md +35 -0
  40. package/examples/forgedock-demo/issues/05-docs-api-reference.md +39 -0
  41. package/examples/forgedock-demo/labels.json +21 -0
  42. package/examples/forgedock-demo/package.json +16 -0
  43. package/examples/forgedock-demo/scripts/smoke.js +61 -0
  44. package/examples/forgedock-demo/src/auth.js +28 -0
  45. package/examples/forgedock-demo/src/db.js +66 -0
  46. package/examples/forgedock-demo/src/routes/notes.js +64 -0
  47. package/examples/forgedock-demo/src/server.js +79 -0
  48. package/package.json +6 -2
  49. 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)
@@ -142,6 +147,7 @@ Append this block at the very end of your comment (after the `---` footer line,
142
147
  | API Design | `API` |
143
148
  | Database & Migration | `DB` |
144
149
  | Infrastructure | `INFRA` |
150
+ | Config Schema | `CFG` |
145
151
 
146
152
  ### Example
147
153
 
@@ -200,9 +206,10 @@ For each changed file, check:
200
206
 
201
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.
202
208
  ```bash
203
- for f in $MODEL_FILES; do
209
+ while IFS= read -r f; do
210
+ [ -z "$f" ] && continue
204
211
  grep -rn "RequestValidationError\|validation_exception_handler\|@app.exception_handler" services/api/app/main.py services/api/app/ 2>/dev/null | head -5
205
- done
212
+ done <<< "$MODEL_FILES"
206
213
  # If no custom handler found AND the PR adds new Pydantic models or FastAPI endpoints:
207
214
  # LIKELY MEDIUM — FastAPI default exposes internal field names/types/constraints to any caller
208
215
  # Safe pattern: catch RequestValidationError and return only {"detail": "Invalid request"} or a sanitized message
@@ -215,12 +222,13 @@ For each changed file, check:
215
222
 
216
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"}`.
217
224
  ```bash
218
- for f in $HEALTH_FILES; do
225
+ while IFS= read -r f; do
226
+ [ -z "$f" ] && continue
219
227
  # Find unauthenticated endpoints (no Depends() in signature) and check return content
220
228
  grep -A25 "@router\.\(get\|head\)\|async def health\|async def ping\|async def live\|async def ready\|async def status" "$f" | \
221
229
  grep -iE "version|host|port|redis|postgres|db_url|tesseract|pillow|PIL\.__version__|software|engine|server" && \
222
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."
223
- done
231
+ done <<< "$HEALTH_FILES"
224
232
  ```
225
233
  **Confidence**: `LIKELY` — version strings may be intentionally scoped to internal-only endpoints; verify auth status of the endpoint.
226
234
  **Severity**: MEDIUM for software version strings (direct CVE targeting input); LOW for DB/Redis connectivity state (useful for DoS confirmation, not exploit).
@@ -230,10 +238,11 @@ For each changed file, check:
230
238
 
231
239
  Git SHAs and deployment topology exposed via response headers enable targeted CVE research and attack planning.
232
240
  ```bash
233
- for f in $MIDDLEWARE_FILES; do
241
+ while IFS= read -r f; do
242
+ [ -z "$f" ] && continue
234
243
  grep -nE "X-App-Version|X-Deployment-Color|X-.*-SHA|X-.*-Build|X-.*-Commit|Server:|X-Powered-By" "$f" 2>/dev/null && \
235
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."
236
- done
245
+ done <<< "$MIDDLEWARE_FILES"
237
246
  ```
238
247
  **Confidence**: `LIKELY` — header may be intentional for internal observability tooling behind an auth gate; verify it applies to all responses.
239
248
  **Severity**: LOW — information disclosure accelerates other attacks but is not directly exploitable without an additional vulnerability.
@@ -258,11 +267,12 @@ CONFIG_FILES=$(gh pr diff [PR_NUMBER] --name-only | grep -E "(traefik/|infra/ngi
258
267
 
259
268
  **A. Port bind address audit** (when `docker-compose*.yml` is in diff — trigger: `$INFRA_FILES` non-empty)
260
269
  ```bash
261
- for f in $INFRA_FILES; do
270
+ while IFS= read -r f; do
271
+ [ -z "$f" ] && continue
262
272
  # Flag non-web ports bound to 0.0.0.0 (no explicit host binding = binds to 0.0.0.0 by default)
263
273
  grep -nE "^\s+- \"[0-9]+:[0-9]+\"" "$f" | grep -v "127\.0\.0\.1\|::1" | grep -vE "\"(80|443):" && \
264
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."
265
- done
275
+ done <<< "$INFRA_FILES"
266
276
  ```
267
277
  **Confidence**: `LIKELY` — a firewall rule may exist that provides protection; flag for verification rather than hard-block.
268
278
  **Severity**: HIGH for database/internal service ports (5432, 6432, 6379); MEDIUM for other non-web ports.
@@ -270,10 +280,11 @@ done
270
280
 
271
281
  **B. Missing USER directive** (when any `Dockerfile` is in diff — trigger: `$DOCKERFILE_FILES` non-empty)
272
282
  ```bash
273
- for f in $DOCKERFILE_FILES; do
283
+ while IFS= read -r f; do
284
+ [ -z "$f" ] && continue
274
285
  grep -q "^USER " "$f" || \
275
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."
276
- done
287
+ done <<< "$DOCKERFILE_FILES"
277
288
  ```
278
289
  **Confidence**: `CONFIRMED` — absence of USER directive is an objective, verifiable fact.
279
290
  **Severity**: HIGH for services processing untrusted input; MEDIUM for internal-only services.
@@ -281,10 +292,11 @@ done
281
292
 
282
293
  **C. Response header information disclosure** (when Python middleware/main.py adds response headers — trigger: `$HEADER_FILES` non-empty)
283
294
  ```bash
284
- for f in $HEADER_FILES; do
295
+ while IFS= read -r f; do
296
+ [ -z "$f" ] && continue
285
297
  grep -nE "X-App-Version|X-Deployment-Color|Server:|X-Powered-By|X-.*-SHA|X-.*-Build" "$f" 2>/dev/null && \
286
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."
287
- done
299
+ done <<< "$HEADER_FILES"
288
300
  ```
289
301
  **Confidence**: `CONFIRMED` — header name reveals infra details by definition.
290
302
  **Severity**: MEDIUM — information disclosure accelerates other attacks but is not directly exploitable.
@@ -292,11 +304,12 @@ done
292
304
 
293
305
  **D. CI security scan gating** (when `.github/workflows/*.yml` is in diff — trigger: `$WORKFLOW_FILES` non-empty)
294
306
  ```bash
295
- for f in $WORKFLOW_FILES; do
307
+ while IFS= read -r f; do
308
+ [ -z "$f" ] && continue
296
309
  # Find security scanner steps with continue-on-error: true
297
310
  grep -B5 "continue-on-error: true" "$f" | grep -iE "trivy|snyk|security|cve|scan|grype|dockle" && \
298
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."
299
- done
312
+ done <<< "$WORKFLOW_FILES"
300
313
  ```
301
314
  **Confidence**: `CONFIRMED` — `continue-on-error: true` on a security scanner step is objectively a non-blocking scan.
302
315
  **Severity**: HIGH — security scanner provides zero protection when it cannot block.
@@ -304,10 +317,11 @@ done
304
317
 
305
318
  **E. In-memory rate limiter** (when TypeScript files are in diff — trigger: `$TS_FILES` non-empty)
306
319
  ```bash
307
- for f in $TS_FILES; do
320
+ while IFS= read -r f; do
321
+ [ -z "$f" ] && continue
308
322
  grep -nE "new Map\(\)|=\s*\{\}|= new Map" "$f" 2>/dev/null | grep -iE "rate|limit|attempt|count|throttle|window" && \
309
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."
310
- done
324
+ done <<< "$TS_FILES"
311
325
  ```
312
326
  **Confidence**: `LIKELY` — in-memory store may be intentional for non-critical paths; flag for verification.
313
327
  **Severity**: MEDIUM — bypass requires restarting the container or hitting a different replica; not trivially exploitable but provides no protection in blue/green deploys.
@@ -315,10 +329,11 @@ done
315
329
 
316
330
  **F. Cookie security flags** (when TypeScript files are in diff — trigger: `$TS_FILES` non-empty)
317
331
  ```bash
318
- for f in $TS_FILES; do
332
+ while IFS= read -r f; do
333
+ [ -z "$f" ] && continue
319
334
  grep -nE "httpOnly:\s*false|secure:\s*false|sameSite.*['\"]none['\"]" "$f" 2>/dev/null && \
320
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."
321
- done
336
+ done <<< "$TS_FILES"
322
337
  ```
323
338
  **Confidence**: `CONFIRMED` — explicit `false`/`none` values are objective findings.
324
339
  **Severity**: HIGH for `httpOnly: false` (direct XSS vector for session cookies); MEDIUM for `secure: false` or `sameSite: none`.
@@ -326,11 +341,12 @@ done
326
341
 
327
342
  **G. Fallback credential scan in config files** (when config files are in diff — trigger: `$CONFIG_FILES` non-empty)
328
343
  ```bash
329
- for f in $CONFIG_FILES; do
344
+ while IFS= read -r f; do
345
+ [ -z "$f" ] && continue
330
346
  # Shell-style fallback: ${VAR:-default} — flag non-trivial fallback values that may be credentials
331
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)" && \
332
348
  echo "SEC: potential credential fallback in $f — verify fallback value is safe if env var is absent"
333
- done
349
+ done <<< "$CONFIG_FILES"
334
350
  ```
335
351
  For each hit: read the fallback value.
336
352
  - Contains `$apr1$`, `$2b$`, `$bcrypt$`, `$argon2`, or hash-like long alphanumeric with `$` separators: **CONFIRMED HIGH** — htpasswd/bcrypt placeholder; active if env var is absent.
@@ -344,15 +360,17 @@ For each hit: read the fallback value.
344
360
 
345
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)
346
362
  ```bash
347
- UID_CHANGE=$(for f in $DOCKERFILE_FILES; do
363
+ UID_CHANGE=$(while IFS= read -r f; do
364
+ [ -z "$f" ] && continue
348
365
  gh pr diff [PR_NUMBER] -- "$f" 2>/dev/null | grep -E '^\+.*(USER\s+[^0]|su-exec|gosu|setuid)' | grep -v '^+++' && break
349
- done)
366
+ done <<< "$DOCKERFILE_FILES")
350
367
  if [ -n "$UID_CHANGE" ]; then
351
368
  echo "UID change detected — cross-referencing named volume mounts..."
352
369
  COMPOSE_FILES=$(find . -maxdepth 2 -name "docker-compose*.yml" 2>/dev/null | sort)
353
- for cf in $COMPOSE_FILES; do
370
+ while IFS= read -r cf; do
371
+ [ -z "$cf" ] && continue
354
372
  grep -A2 "volumes:" "$cf" | grep -E "^\s+\w.*:" | grep -v "^--$" && echo " ^ in $cf"
355
- done
373
+ done <<< "$COMPOSE_FILES"
356
374
  fi
357
375
  ```
358
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.
@@ -1413,17 +1431,19 @@ If no infra context is configured above, derive the deployment model from the ch
1413
1431
  OLD_VALUES=$(gh pr diff [PR_NUMBER] | grep "^\-" | grep -oP '"status"\s*:\s*"\K[^"]+' | sort -u)
1414
1432
 
1415
1433
  # Search all non-application consumer locations for those old values
1416
- for val in $OLD_VALUES; do
1434
+ while IFS= read -r val; do
1435
+ [ -z "$val" ] && continue
1417
1436
  echo "=== Searching for old response value: $val ==="
1418
1437
  grep -rn "$val" scripts/ infra/ .github/ traefik/ 2>/dev/null | grep -v "^Binary"
1419
1438
  grep -rn "$val" docker-compose*.yml 2>/dev/null
1420
- done
1439
+ done <<< "$OLD_VALUES"
1421
1440
 
1422
1441
  # Also search for endpoint path references in consumer locations
1423
1442
  HEALTH_PATHS=$(gh pr diff [PR_NUMBER] | grep "^\+" | grep -oP '@\w+\.(?:get|post)\("\K[^"]+(?:health|status|ping|ready|live|readiness|liveness)[^"]*')
1424
- for path in $HEALTH_PATHS; do
1443
+ while IFS= read -r path; do
1444
+ [ -z "$path" ] && continue
1425
1445
  grep -rn "$path" scripts/ infra/ .github/ traefik/ docker-compose*.yml 2>/dev/null | grep -v "^Binary"
1426
- done
1446
+ done <<< "$HEALTH_PATHS"
1427
1447
  ```
1428
1448
 
1429
1449
  **For each hit, classify:**
@@ -1521,13 +1541,14 @@ If no infra context is configured above, derive the deployment model from the ch
1521
1541
 
1522
1542
  **Scan ALL `{{` occurrences in every `script:` block of every `appleboy/ssh-action` step in changed files:**
1523
1543
  ```bash
1524
- 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
1525
1546
  if grep -q "appleboy/ssh-action" "$f"; then
1526
1547
  echo "=== appleboy/ssh-action found in $f ==="
1527
1548
  # Show all {{ occurrences with line numbers
1528
1549
  grep -n "{{" "$f"
1529
1550
  fi
1530
- done
1551
+ done < <(gh pr diff [PR_NUMBER] --name-only | grep -E '\.github/workflows/.*\.yml$')
1531
1552
  ```
1532
1553
 
1533
1554
  **For every `{{` found, classify and flag:**
@@ -1548,10 +1569,11 @@ If no infra context is configured above, derive the deployment model from the ch
1548
1569
  **Scan changed config files for credential fallbacks:**
1549
1570
  ```bash
1550
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)
1551
- for f in $CONFIG_FILES; do
1572
+ while IFS= read -r f; do
1573
+ [ -z "$f" ] && continue
1552
1574
  # Shell-style fallback: ${VAR:-default} — filter out safe non-credential defaults
1553
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
1554
- done
1576
+ done <<< "$CONFIG_FILES"
1555
1577
  ```
1556
1578
 
1557
1579
  **For each hit, classify the fallback value:**
@@ -1587,9 +1609,10 @@ If no infra context is configured above, derive the deployment model from the ch
1587
1609
  **Scan for host-side database tool invocations:**
1588
1610
  ```bash
1589
1611
  DEPLOY_FILES=$(gh pr diff [PR_NUMBER] --name-only | grep -E "(\.github/workflows/.*\.yml$|scripts/deploy.*\.sh$|docker-entrypoint.*\.sh$)")
1590
- for f in $DEPLOY_FILES; do
1612
+ while IFS= read -r f; do
1613
+ [ -z "$f" ] && continue
1591
1614
  grep -n "\bpsql\b\|\bcreatedb\b\|\bpg_dump\b\|\bpg_restore\b" "$f" | grep -v "docker exec" | head -20
1592
- done
1615
+ done <<< "$DEPLOY_FILES"
1593
1616
  ```
1594
1617
 
1595
1618
  **For each match, classify:**
@@ -1605,6 +1628,54 @@ If no infra context is configured above, derive the deployment model from the ch
1605
1628
 
1606
1629
  Host-side port bindings to Docker containers are unreliable in SSH deploy contexts. Deploy scripts that invoke database tools directly cannot depend on `localhost:5432` being reachable; the same operation via `docker exec` bypasses the host network entirely and always succeeds when the container is running. <!-- Added: forge#322 -->
1607
1630
 
1631
+ 15. **External tool config schema validation** (when any config file consumed by an external tool is in the diff — trigger: files under `traefik/`, `infra/nginx/`, `k8s/`, `terraform/`, `*.conf`, `*.toml`, or any `docker-compose*.yml` with non-trivial changes to service definitions):
1632
+
1633
+ **Principle: Logical correctness ≠ structural correctness.** A config can have the right values in the wrong nesting and be silently rejected by the external tool — producing zero error logs in some tools (e.g. Traefik v3 ignores unrecognized nesting silently). The INFRA agent must verify both dimensions:
1634
+ - **Logical correctness** (covered by existing checks): Are the values coherent? Do names match across files? Is the wiring consistent with the deploy pipeline?
1635
+ - **Structural correctness** (this check): Does the YAML/TOML/HCL structure match what the tool's actual schema expects? Are directives nested at the correct depth? Do required parent keys exist?
1636
+
1637
+ **For each external tool config file in the diff**:
1638
+
1639
+ 1. **Identify the tool and version**: Read the `image:` tag in `docker-compose*.yml` for the tool (e.g., `traefik:v3.1`, `nginx:1.25`). If the tool version cannot be determined from the diff, note it as unknown.
1640
+
1641
+ 2. **Reason about the schema**: Ask — "Does this change's structure match what `{tool} {version}` actually expects?" Key questions:
1642
+ - Is this directive/key nested under the correct parent section?
1643
+ - Does this version of the tool use a different config syntax than a prior version? (Version migrations often change required nesting — e.g., Traefik v2 → v3 moved many config keys to different parent sections)
1644
+ - Are any required sibling keys missing from the changed section?
1645
+ - Does the tool expect this value at file scope, or under a named block?
1646
+
1647
+ 3. **Cross-reference against sibling config files**: If the project uses dynamic config loading (e.g., `traefik/dynamic/`), verify that a key defined in the static config is not also required in the dynamic config (or vice versa) for the feature to activate.
1648
+
1649
+ 4. **Flag structural mismatches**: If any key or nesting does not match the tool's expected schema for the declared version, flag as **CONFIRMED HIGH**. Structural config mismatches are silently rejected at runtime — no startup crash, no error log, the feature simply does not activate.
1650
+
1651
+ ```bash
1652
+ # Identify tool config files in the diff
1653
+ CONFIG_TOOL_FILES=$(gh pr diff [PR_NUMBER] --name-only | grep -E \
1654
+ "(traefik/|infra/nginx/|k8s/|terraform/|.*\.conf$|.*\.toml$)" | head -20)
1655
+ COMPOSE_INFRA_FILES=$(gh pr diff [PR_NUMBER] --name-only | grep -E "docker-compose.*\.yml$")
1656
+
1657
+ # For each config file: read the full file (not just the diff) to understand the structure
1658
+ while IFS= read -r f; do
1659
+ [ -z "$f" ] && continue
1660
+ echo "=== Reading full config structure: $f ==="
1661
+ cat "$f"
1662
+ done <<< "$CONFIG_TOOL_FILES"
1663
+
1664
+ # Identify tool version from docker-compose (for version-aware schema reasoning)
1665
+ while IFS= read -r f; do
1666
+ [ -z "$f" ] && continue
1667
+ grep -E "image:\s*(traefik|nginx|haproxy|envoy|caddy|terraform):" "$f" 2>/dev/null && echo " ^ in $f"
1668
+ done <<< "$COMPOSE_INFRA_FILES"
1669
+ ```
1670
+
1671
+ **Severity classification**:
1672
+ - Config key present but under wrong parent section: **CONFIRMED HIGH** — key is silently ignored; feature does not activate
1673
+ - Required sibling key missing from changed section: **CONFIRMED HIGH** — partial config is silently incomplete
1674
+ - Version mismatch (config uses v2 syntax on v3 tool): **CONFIRMED HIGH** — v3 silently ignores v2 keys in most tools
1675
+ - Structural ambiguity (key could plausibly be correct but version cannot be determined): **LIKELY MEDIUM** — flag for human verification with schema reference
1676
+
1677
+ **Do NOT rely solely on logical correctness checks when structural correctness has not been verified.** A config that names services correctly, uses coherent values, and matches the deploy script's expectations can still be silently rejected if the nesting does not match the tool's schema. Both dimensions must be verified independently. <!-- Added: forge#1104 -->
1678
+
1608
1679
  ## Post Findings
1609
1680
  ```bash
1610
1681
  gh pr comment [PR_NUMBER] --body "$(cat <<'EOF'
@@ -1679,5 +1750,6 @@ EOF
1679
1750
  | appleboy/ssh-action Go template safety | Item 12 | COVERED | #226 |
1680
1751
  | Insecure config file defaults | Item 13 | COVERED | #301 |
1681
1752
  | Host-side database tool invocations | Item 14 | COVERED | #322 |
1753
+ | External tool config schema validation (structural correctness) | Item 15 | COVERED | #1104 |
1682
1754
  | Docker image tag pinning (mutable tags) | — | GAP | |
1683
1755
  | Network policy / inter-container isolation | — | GAP | |
@@ -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