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
@@ -109,7 +109,12 @@ Before running checks, classify the changed files into domains. This avoids runn
109
109
  # Initialize domain flags
110
110
  DOMAINS=""
111
111
 
112
- for f in {CHANGED_FILES}; do
112
+ # {CHANGED_FILES} is a space-separated argument (same contract as {AFFECTED_FILES}
113
+ # in architect.md) — split explicitly on IFS=' ' into an array instead of relying
114
+ # on a bare `for f in {CHANGED_FILES}`, which word-splits on the shell's default
115
+ # IFS (space, tab, AND newline) and would corrupt any path containing a space.
116
+ IFS=' ' read -ra CHANGED_FILES_ARR <<< "{CHANGED_FILES}"
117
+ for f in "${CHANGED_FILES_ARR[@]}"; do
113
118
  case "$f" in
114
119
  *.sql|*migrations/*) DOMAINS="$DOMAINS DATABASE" ;;
115
120
  esac
@@ -144,34 +149,40 @@ grep -lE "asyncio\.shield|asyncio\.wait_for|Task\.cancel" {CHANGED_FILES} 2>/dev
144
149
  grep -lE "^\w+ = (\{\}|\[\]|set\(\)|Lock\(\)|defaultdict|Counter)" {CHANGED_FILES} 2>/dev/null | grep -qE '\.py$' && DOMAINS="$DOMAINS STATE"
145
150
 
146
151
  # Check for capacity constants in worker/infra/browser Python files
147
- for f in $(echo {CHANGED_FILES} | tr ' ' '\n' | grep -E '\.py$' | grep -E 'services/worker/|infra/|browser/'); do
152
+ while IFS= read -r f; do
153
+ [ -z "$f" ] && continue
148
154
  git diff HEAD -- "$f" 2>/dev/null | grep -qE '^\+[A-Za-z_]\w*(MB|SIZE|MAX|LIMIT|THRESHOLD|TIMEOUT)\w*\s*=' && DOMAINS="$DOMAINS CAPACITY" && break
149
- done
155
+ done < <(echo {CHANGED_FILES} | tr ' ' '\n' | grep -E '\.py$' | grep -E 'services/worker/|infra/|browser/')
150
156
 
151
157
  # Check for env var usage in changed files
152
158
  grep -lE "os\.getenv|process\.env" {CHANGED_FILES} 2>/dev/null && DOMAINS="$DOMAINS DEPLOY"
153
159
 
154
160
  # Check for new app.* imports added outside try: blocks in Python files
155
- for f in $(echo {CHANGED_FILES} | tr ' ' '\n' | grep -E '\.py$'); do
161
+ while IFS= read -r f; do
162
+ [ -z "$f" ] && continue
156
163
  NEW_IMPORTS=$(git diff HEAD -- "$f" 2>/dev/null | grep -E '^\+\s*(from app\.|import app\.)' | grep -v '^+++')
157
164
  if [ -n "$NEW_IMPORTS" ]; then
158
165
  DOMAINS="$DOMAINS IMPORT_RESOLUTION"
159
166
  break
160
167
  fi
161
- done
168
+ done < <(echo {CHANGED_FILES} | tr ' ' '\n' | grep -E '\.py$')
162
169
 
163
170
  # Check for runtime UID changes in Dockerfiles or entrypoint scripts
164
- for f in $(echo {CHANGED_FILES} | tr ' ' '\n' | grep -iE 'Dockerfile|entrypoint.*\.sh'); do
171
+ while IFS= read -r f; do
172
+ [ -z "$f" ] && continue
165
173
  git diff HEAD -- "$f" 2>/dev/null | grep -E '^\+.*(USER\s+[^0]|su-exec|gosu|setuid)' | grep -v '^+++' | grep -q . && DOMAINS="$DOMAINS INFRA" && break
166
- done
174
+ done < <(echo {CHANGED_FILES} | tr ' ' '\n' | grep -iE 'Dockerfile|entrypoint.*\.sh')
167
175
 
168
176
  # Check for gate condition narrowing in router Python files (condition removal = fix may be incomplete across siblings)
169
- for f in $(echo {CHANGED_FILES} | tr ' ' '\n' | grep -E '\.py$' | grep -E 'router'); do
177
+ while IFS= read -r f; do
178
+ [ -z "$f" ] && continue
170
179
  git diff HEAD -- "$f" 2>/dev/null | grep -E '^-.*\bif\b.*\bor\b' | grep -v '^---' | grep -q . && DOMAINS="$DOMAINS ROUTER_BUG" && break
171
- done
180
+ done < <(echo {CHANGED_FILES} | tr ' ' '\n' | grep -E '\.py$' | grep -E 'router')
172
181
 
173
182
  # Check for external tool config files (structural schema validation advisory)
174
- for f in {CHANGED_FILES}; do
183
+ # {CHANGED_FILES} is space-separated (see array-split note above) — reuse the
184
+ # same CHANGED_FILES_ARR built above instead of re-splitting.
185
+ for f in "${CHANGED_FILES_ARR[@]}"; do
175
186
  case "$f" in
176
187
  traefik/*|infra/nginx/*|k8s/*|terraform/*)
177
188
  DOMAINS="$DOMAINS CONFIG_SCHEMA" && break ;;
@@ -228,7 +239,11 @@ For every changed file, check:
228
239
 
229
240
  ```bash
230
241
  # Quick automated checks
231
- for f in {CHANGED_FILES}; do
242
+ # {CHANGED_FILES} is a space-separated argument — split explicitly on IFS=' '
243
+ # into an array instead of a bare `for f in {CHANGED_FILES}` (see Step 1.5 for
244
+ # the full rationale; this is a separate bash block so the array is rebuilt here).
245
+ IFS=' ' read -ra CHANGED_FILES_ARR <<< "{CHANGED_FILES}"
246
+ for f in "${CHANGED_FILES_ARR[@]}"; do
232
247
  # f-string SQL
233
248
  grep -nE "f['\"].*SELECT|f['\"].*INSERT|f['\"].*UPDATE|f['\"].*DELETE|f['\"].*WHERE" "$f" 2>/dev/null && echo "SEC: f-string SQL in $f"
234
249
  # Hardcoded secrets
@@ -256,10 +271,11 @@ For new/modified endpoints:
256
271
  3. Does the endpoint check resource ownership (`user_id`) before returning data?
257
272
 
258
273
  ```bash
259
- for f in $(echo {CHANGED_FILES} | tr ' ' '\n' | grep -E 'router|route'); do
274
+ while IFS= read -r f; do
275
+ [ -z "$f" ] && continue
260
276
  grep -nE "CurrentUser|SessionUser|Depends\(get_" "$f" 2>/dev/null
261
277
  grep -nE "@router\.(get|post|put|delete)" "$f" 2>/dev/null
262
- done
278
+ done < <(echo {CHANGED_FILES} | tr ' ' '\n' | grep -E 'router|route')
263
279
  ```
264
280
 
265
281
  ### 2C: Deploy chain (when new env vars are introduced)
@@ -286,11 +302,12 @@ For each `.sql` file:
286
302
  5. **Migration number collision**: Check against origin branch
287
303
 
288
304
  ```bash
289
- for f in $(echo {CHANGED_FILES} | tr ' ' '\n' | grep '\.sql$'); do
305
+ while IFS= read -r f; do
306
+ [ -z "$f" ] && continue
290
307
  grep -nE "ADD COLUMN.*NOT NULL" "$f" | grep -v "DEFAULT" && echo "DB: NOT NULL without DEFAULT in $f"
291
308
  grep -nE "DROP (TABLE|COLUMN|INDEX)" "$f" | grep -v "IF EXISTS" && echo "DB: DROP without IF EXISTS in $f"
292
309
  grep -nE "SELECT.*FROM" "$f" | grep -v "LIMIT" | grep -v "WHERE.*created_at" && echo "DB: possibly unbounded query in $f"
293
- done
310
+ done < <(echo {CHANGED_FILES} | tr ' ' '\n' | grep '\.sql$')
294
311
 
295
312
  # Migration prefix collision scan (Deploy Gate — HIGH)
296
313
  # Runs whenever any SQL file changed. Scans the full infra/migrations/ tree for duplicate
@@ -330,7 +347,8 @@ For each `.tsx`/`.ts` client component:
330
347
  7. **Rules of Hooks**: Do function components avoid early returns before hook calls?
331
348
 
332
349
  ```bash
333
- for f in $(echo {CHANGED_FILES} | tr ' ' '\n' | grep -E '\.tsx?$' | grep -v 'route\.ts'); do
350
+ while IFS= read -r f; do
351
+ [ -z "$f" ] && continue
334
352
  # Check for useEffect without cleanup (basic heuristic)
335
353
  grep -c "useEffect" "$f" 2>/dev/null
336
354
  grep -c "return.*cleanup\|return.*\(\) =>" "$f" 2>/dev/null
@@ -370,15 +388,16 @@ for f in $(echo {CHANGED_FILES} | tr ' ' '\n' | grep -E '\.tsx?$' | grep -v 'rou
370
388
  }
371
389
  in_component && /\buse[A-Z]/ { found_hook=1 }
372
390
  ' FILENAME="$f" "$f" 2>/dev/null
373
- done
391
+ done < <(echo {CHANGED_FILES} | tr ' ' '\n' | grep -E '\.tsx?$' | grep -v 'route\.ts')
374
392
  ```
375
393
 
376
394
  ### 2F: Frontend proxy wiring (client-side fetch calls)
377
395
 
378
396
  ```bash
379
- for f in $(echo {CHANGED_FILES} | tr ' ' '\n' | grep -E '\.(tsx?|jsx?)$' | grep -v 'route\.ts$'); do
397
+ while IFS= read -r f; do
398
+ [ -z "$f" ] && continue
380
399
  grep -nE '(fetch|useSWR|apiFetch)\s*[(<]\s*[`"'"'"']/api/v1/' "$f" 2>/dev/null && echo "PROXY: direct /api/v1/ call in $f — must use /api/ proxy"
381
- done
400
+ done < <(echo {CHANGED_FILES} | tr ' ' '\n' | grep -E '\.(tsx?|jsx?)$' | grep -v 'route\.ts$')
382
401
  ```
383
402
 
384
403
  ### 2G: Cross-service integration (shell scripts with curl/wget)
@@ -413,7 +432,8 @@ The script `verify-host-headers.sh` checks for the `FORGE_INTERNAL_PATTERNS` env
413
432
  **Why this matters**: Every DB connection function in a deploy script must use `${POSTGRES_HOST:-localhost}` (or equivalent env var interpolation), not a bare `"localhost"` string literal. A hardcoded `"localhost"` works in local development but fails in any environment where PostgreSQL runs on a separate host. The pattern is reliably grep-detectable. <!-- Added: forge#303 -->
414
433
 
415
434
  ```bash
416
- for f in $(echo {CHANGED_FILES} | tr ' ' '\n' | grep -E '\.sh$'); do
435
+ while IFS= read -r f; do
436
+ [ -z "$f" ] && continue
417
437
  # Only check files that contain DB connection tools
418
438
  if grep -qE "PGPASSWORD|createdb|psql|pg_dump|pg_restore" "$f" 2>/dev/null; then
419
439
  # Look for new/modified lines with hardcoded localhost in variable assignments
@@ -428,7 +448,7 @@ for f in $(echo {CHANGED_FILES} | tr ' ' '\n' | grep -E '\.sh$'); do
428
448
  echo "$HARDCODED"
429
449
  fi
430
450
  fi
431
- done
451
+ done < <(echo {CHANGED_FILES} | tr ' ' '\n' | grep -E '\.sh$')
432
452
  ```
433
453
 
434
454
  Report any hit as **HIGH** — the DB connection will fail in any environment where PostgreSQL is not on `127.0.0.1:5432`.
@@ -494,7 +514,8 @@ Report each conflict as **HIGH** — a shadowed native command is a silent user-
494
514
  **Severity**: MEDIUM (flags for review, not auto-blocking). The review agent's concurrency auditor verifies with full context.
495
515
 
496
516
  ```bash
497
- for f in $(echo {CHANGED_FILES} | tr ' ' '\n' | grep -E '\.py$'); do
517
+ while IFS= read -r f; do
518
+ [ -z "$f" ] && continue
498
519
  # asyncio.shield usage — outer await may be reached with pending CancelledError
499
520
  grep -nE "asyncio\.shield" "$f" 2>/dev/null && \
500
521
  echo "ASYNC: asyncio.shield in $f — verify outer await isn't reachable with pending CancelledError. Prefer loop.create_task() for fire-and-forget."
@@ -509,7 +530,7 @@ for f in $(echo {CHANGED_FILES} | tr ' ' '\n' | grep -E '\.py$'); do
509
530
  sed -n "$((LINENO+1)),$((LINENO+5))p" "$f" | grep -q "await" && \
510
531
  echo "ASYNC: await in except/finally block at $f:$LINENO — may fail if CancelledError is pending"
511
532
  done
512
- done
533
+ done < <(echo {CHANGED_FILES} | tr ' ' '\n' | grep -E '\.py$')
513
534
  ```
514
535
 
515
536
 
@@ -532,7 +553,8 @@ For each new module-level state variable in the diff:
532
553
  ```
533
554
 
534
555
  ```bash
535
- for f in $(echo {CHANGED_FILES} | tr ' ' '\n' | grep -E '\.py$'); do
556
+ while IFS= read -r f; do
557
+ [ -z "$f" ] && continue
536
558
  # Find new module-level state variables introduced in the diff
537
559
  NEW_VARS=$(git diff HEAD -- "$f" 2>/dev/null | grep -E '^\+[A-Za-z_]\w* = (\{\}|\[\]|set\(\)|Lock\(\)|defaultdict|Counter)' | grep -oE '^[+][A-Za-z_]\w*' | tr -d '+' | sort -u)
538
560
  for new_var in $NEW_VARS; do
@@ -549,7 +571,7 @@ for f in $(echo {CHANGED_FILES} | tr ' ' '\n' | grep -E '\.py$'); do
549
571
  done
550
572
  done
551
573
  done
552
- done
574
+ done < <(echo {CHANGED_FILES} | tr ' ' '\n' | grep -E '\.py$')
553
575
  ```
554
576
 
555
577
  **Severity**: MEDIUM — missing a mutation site is usually a latent bug, not an immediate crash, but it causes incorrect behavior under concurrent load.
@@ -567,11 +589,12 @@ For each changed file in the anti-detection/scraping domains:
567
589
  **1. Identify modified string identifier sets** (frozensets, tuples, or lists of string literals used for detection):
568
590
 
569
591
  ```bash
570
- for f in $(echo {CHANGED_FILES} | tr ' ' '\n' | grep -E '\.py$' | grep -E 'anti_detection/|consumers/|browser/|shared/detection/'); do
592
+ while IFS= read -r f; do
593
+ [ -z "$f" ] && continue
571
594
  echo "=== String sets in $f ==="
572
595
  # Find named constants that are frozenset/tuple/list of strings
573
596
  grep -nE "^[A-Z_]+ = (frozenset|tuple|set)\(\[?['\"]|^[A-Z_]+ = \(['\"]|^[A-Z_]+ = \[['\"]" "$f" 2>/dev/null
574
- done
597
+ done < <(echo {CHANGED_FILES} | tr ' ' '\n' | grep -E '\.py$' | grep -E 'anti_detection/|consumers/|browser/|shared/detection/')
575
598
  ```
576
599
 
577
600
  **2. For each string in a modified set, check if it appears in sibling detection files:**
@@ -579,7 +602,8 @@ done
579
602
  ```bash
580
603
  SERVICE_DIR=$(echo {CHANGED_FILES} | tr ' ' '\n' | grep -E 'anti_detection/|consumers/|browser/' | head -1 | grep -oE '^services/[^/]+/[^/]+' || echo "services/worker/worker")
581
604
 
582
- for f in $(echo {CHANGED_FILES} | tr ' ' '\n' | grep -E '\.py$' | grep -E 'anti_detection/|consumers/|browser/|shared/detection/'); do
605
+ while IFS= read -r f; do
606
+ [ -z "$f" ] && continue
583
607
  # Extract string values from detection sets in this file
584
608
  STRINGS=$(grep -oE "'[a-z_][a-z0-9_-]*'" "$f" 2>/dev/null | tr -d "'" | sort -u)
585
609
  for s in $STRINGS; do
@@ -587,13 +611,14 @@ for f in $(echo {CHANGED_FILES} | tr ' ' '\n' | grep -E '\.py$' | grep -E 'anti_
587
611
  FOUND=$(grep -rl "\"$s\"\|'$s'" $SERVICE_DIR/ 2>/dev/null | grep -v "^$f$" | grep '\.py$' | head -1)
588
612
  [ -z "$FOUND" ] && echo "STR: '$s' in $f appears NOWHERE else in $SERVICE_DIR — possible typo or dead entry"
589
613
  done
590
- done
614
+ done < <(echo {CHANGED_FILES} | tr ' ' '\n' | grep -E '\.py$' | grep -E 'anti_detection/|consumers/|browser/|shared/detection/')
591
615
  ```
592
616
 
593
617
  **3. Check for identifiers mentioned in comments/docstrings of the SAME file that are NOT in the actual set** (catches the `abck` vs `_abck` class of bug):
594
618
 
595
619
  ```bash
596
- for f in $(echo {CHANGED_FILES} | tr ' ' '\n' | grep -E '\.py$' | grep -E 'anti_detection/|consumers/|browser/|shared/detection/'); do
620
+ while IFS= read -r f; do
621
+ [ -z "$f" ] && continue
597
622
  echo "=== Comment/code drift in $f ==="
598
623
  # Extract string literals from actual sets (remove quotes)
599
624
  SET_STRINGS=$(grep -oE "frozenset\(\[([^]]+)\]\)" "$f" 2>/dev/null | grep -oE "'[^']+'" | tr -d "'" | sort -u)
@@ -605,20 +630,25 @@ for f in $(echo {CHANGED_FILES} | tr ' ' '\n' | grep -E '\.py$' | grep -E 'anti_
605
630
  for ref in $COMMENT_REFS; do
606
631
  echo "$SET_STRINGS" | grep -q "^${ref}$" || echo "STR: '$ref' mentioned in comment in $f but NOT found in any string set — possible omission or typo"
607
632
  done
608
- done
633
+ done < <(echo {CHANGED_FILES} | tr ' ' '\n' | grep -E '\.py$' | grep -E 'anti_detection/|consumers/|browser/|shared/detection/')
609
634
  ```
610
635
 
611
636
  **4. Cross-reference sibling sets in related modules** (the primary inter-file check):
612
637
 
613
638
  ```bash
614
639
  # Find all detection-related Python files in the same service
615
- for f in $(echo {CHANGED_FILES} | tr ' ' '\n' | grep -E '\.py$' | grep -E 'anti_detection/|consumers/|browser/'); do
640
+ while IFS= read -r f; do
641
+ [ -z "$f" ] && continue
616
642
  SIBLINGS=$(find $(dirname "$f")/../ -name "*.py" -path "*/anti_detection/*" -o -name "*.py" -path "*/consumers/*" -o -name "*.py" -path "*/browser/*" 2>/dev/null | grep -v "^$f$" | head -10)
617
643
 
618
644
  # Extract detection set names from the changed file
619
645
  SET_NAMES=$(grep -oE "^[A-Z_]+ = (frozenset|tuple|set)" "$f" 2>/dev/null | grep -oE "^[A-Z_]+")
620
646
 
621
- for sib in $SIBLINGS; do
647
+ # $SIBLINGS is one file path per line (`find`) — herestring (not a piped
648
+ # `| while read`, which would run the loop body in a subshell and discard
649
+ # any accumulator set inside it) preserves newline-safety for paths with spaces.
650
+ while IFS= read -r sib; do
651
+ [ -z "$sib" ] && continue
622
652
  # Extract strings from sibling detection sets
623
653
  SIB_STRINGS=$(grep -oE "'[a-z_][a-z0-9_-]*'" "$sib" 2>/dev/null | tr -d "'" | sort -u)
624
654
 
@@ -629,8 +659,8 @@ for f in $(echo {CHANGED_FILES} | tr ' ' '\n' | grep -E '\.py$' | grep -E 'anti_
629
659
  echo "$SIB_STRINGS" | grep -q "^${s}$" || \
630
660
  echo "STR: '$s' in $f not found in sibling file $sib — verify if cross-module consistency is required"
631
661
  done
632
- done
633
- done
662
+ done <<< "$SIBLINGS"
663
+ done < <(echo {CHANGED_FILES} | tr ' ' '\n' | grep -E '\.py$' | grep -E 'anti_detection/|consumers/|browser/')
634
664
  ```
635
665
 
636
666
  **Flag as findings**:
@@ -649,7 +679,8 @@ done
649
679
  For each new or modified capacity constant in the diff:
650
680
 
651
681
  ```bash
652
- for f in $(echo {CHANGED_FILES} | tr ' ' '\n' | grep -E '\.py$' | grep -E 'services/worker/|infra/|browser/'); do
682
+ while IFS= read -r f; do
683
+ [ -z "$f" ] && continue
653
684
  # Find new capacity constant assignments introduced by this diff
654
685
  NEW_CAPS=$(git diff HEAD -- "$f" 2>/dev/null | grep -E '^\+[A-Za-z_]\w*(MB|SIZE|MAX|LIMIT|THRESHOLD|TIMEOUT)\w*\s*=' | grep -oE '^[+][A-Za-z_]\w+' | tr -d '+' | sort -u)
655
686
  for cap_var in $NEW_CAPS; do
@@ -667,7 +698,7 @@ for f in $(echo {CHANGED_FILES} | tr ' ' '\n' | grep -E '\.py$' | grep -E 'servi
667
698
  git log --all -15 --oneline --grep="memory" 2>/dev/null | head -5
668
699
  git log --all -15 --oneline --grep="$(echo $keywords | cut -d' ' -f1)" 2>/dev/null | head -5
669
700
  done
670
- done
701
+ done < <(echo {CHANGED_FILES} | tr ' ' '\n' | grep -E '\.py$' | grep -E 'services/worker/|infra/|browser/')
671
702
  ```
672
703
 
673
704
  **Flag as findings**:
@@ -685,7 +716,8 @@ done
685
716
  **Why this matters**: `appleboy/ssh-action` processes every `script:` field through Go's `text/template` engine **client-side, before SSH transmission**. Any `{{` in the script — including in comments, `docker ps --format` strings, and `docker inspect --format` strings — is interpreted as a Go template directive. If the expression does not resolve against the action's data context (which is an empty map for `appleboy/ssh-action`), the step exits with status 1 **before the script reaches the remote shell**. Shell error handlers (`|| fallback`, `2>/dev/null`, `set -e`) are completely bypassed. `continue-on-error: true` masks the failure silently, producing apparent deploy success with no remote shell output.
686
717
 
687
718
  ```bash
688
- for f in $(echo {CHANGED_FILES} | tr ' ' '\n' | grep -E '\.github/workflows/.*\.yml$'); do
719
+ while IFS= read -r f; do
720
+ [ -z "$f" ] && continue
689
721
  if grep -q "appleboy/ssh-action" "$f"; then
690
722
  TEMPLATE_HITS=$(grep -n "{{" "$f")
691
723
  if [ -n "$TEMPLATE_HITS" ]; then
@@ -702,7 +734,7 @@ for f in $(echo {CHANGED_FILES} | tr ' ' '\n' | grep -E '\.github/workflows/.*\.
702
734
  echo " docker ps --format json | jq -r '.Status'"
703
735
  fi
704
736
  fi
705
- done
737
+ done < <(echo {CHANGED_FILES} | tr ' ' '\n' | grep -E '\.github/workflows/.*\.yml$')
706
738
  ```
707
739
 
708
740
  Report any hit as **CONFIRMED HIGH** — the step will exit 1 before reaching the remote shell.
@@ -718,7 +750,8 @@ Go template directives in `appleboy/ssh-action` `script:` blocks are preprocesse
718
750
  ```bash
719
751
  PR_BASE="${PR_BASE:-staging}"
720
752
 
721
- for f in $(echo {CHANGED_FILES} | tr ' ' '\n' | grep -E '\.py$'); do
753
+ while IFS= read -r f; do
754
+ [ -z "$f" ] && continue
722
755
  # Extract new app.* import statements added in the diff
723
756
  # Only consider lines added (starting with +), skip diff headers (+++)
724
757
  NEW_IMPORTS=$(git diff HEAD -- "$f" 2>/dev/null | grep -E '^\+\s*(from app\.|import app\.)' | grep -v '^+++' | sed 's/^+//')
@@ -752,7 +785,7 @@ for f in $(echo {CHANGED_FILES} | tr ' ' '\n' | grep -E '\.py$'); do
752
785
  fi
753
786
  fi
754
787
  done
755
- done
788
+ done < <(echo {CHANGED_FILES} | tr ' ' '\n' | grep -E '\.py$')
756
789
  ```
757
790
 
758
791
  **Flag as findings**:
@@ -775,20 +808,24 @@ A builder agent working in a milestone-branch worktree has access to milestone-o
775
808
  # Step 1: Identify the service directory from the changed Dockerfile/entrypoint path
776
809
  # e.g. services/worker/Dockerfile → service root = services/worker/
777
810
  SERVICE_DIR=""
778
- for f in $(echo {CHANGED_FILES} | tr ' ' '\n' | grep -iE 'Dockerfile|entrypoint.*\.sh'); do
811
+ while IFS= read -r f; do
812
+ [ -z "$f" ] && continue
779
813
  SERVICE_DIR=$(dirname "$f")
780
814
  break
781
- done
815
+ done < <(echo {CHANGED_FILES} | tr ' ' '\n' | grep -iE 'Dockerfile|entrypoint.*\.sh')
782
816
 
783
817
  if [ -n "$SERVICE_DIR" ]; then
784
818
  # Step 2: Find volume mount points for this service in any docker-compose*.yml
785
819
  COMPOSE_FILES=$(find . -maxdepth 3 -name "docker-compose*.yml" 2>/dev/null | sort)
786
820
  VOLUME_MOUNTS=""
787
- for cf in $COMPOSE_FILES; do
821
+ # $COMPOSE_FILES is one path per line (`find`) — herestring, not a piped
822
+ # `| while read`, so VOLUME_MOUNTS remains visible after the loop exits.
823
+ while IFS= read -r cf; do
824
+ [ -z "$cf" ] && continue
788
825
  # Extract named volume mount points (lines like: - volume_name:/container/path)
789
826
  MOUNTS=$(grep -oE '[a-z_-]+:/[^: ]+' "$cf" 2>/dev/null | grep -v '^#' | cut -d: -f2)
790
827
  VOLUME_MOUNTS="$VOLUME_MOUNTS $MOUNTS"
791
- done
828
+ done <<< "$COMPOSE_FILES"
792
829
 
793
830
  # Step 3: Grep the service directory for filesystem write operations
794
831
  WRITE_OPS=$(grep -rn "mkdir\|write_bytes\|open(.*['\"]w\|makedirs\|shutil\.copy\|shutil\.move" \
@@ -818,7 +855,8 @@ fi
818
855
  ```bash
819
856
  PR_BASE="${PR_BASE:-staging}"
820
857
 
821
- for f in $(echo {CHANGED_FILES} | tr ' ' '\n' | grep -E '\.py$' | grep -E 'router'); do
858
+ while IFS= read -r f; do
859
+ [ -z "$f" ] && continue
822
860
  # Extract removed condition lines from the diff (lines starting with -)
823
861
  REMOVED_CONDITIONS=$(git diff HEAD -- "$f" 2>/dev/null | grep -E '^-.*\bif\b' | grep -v '^---' | sed 's/^-//' | sed 's/^\s*//' | head -5)
824
862
 
@@ -841,7 +879,7 @@ for f in $(echo {CHANGED_FILES} | tr ' ' '\n' | grep -E '\.py$' | grep -E 'route
841
879
  fi
842
880
  done
843
881
  fi
844
- done
882
+ done < <(echo {CHANGED_FILES} | tr ' ' '\n' | grep -E '\.py$' | grep -E 'router')
845
883
  ```
846
884
 
847
885
  **Flag as findings**:
@@ -872,7 +910,11 @@ if [ -n "$TOOL_FILES" ]; then
872
910
  WORKFLOW_DIR="{WORKTREE_PATH}/.github/workflows"
873
911
  CI_MISSING=""
874
912
 
875
- for f in $TOOL_FILES; do
913
+ # $TOOL_FILES is one path per line (already newline-split above via `tr`) —
914
+ # herestring, not a piped `| while read`, so CI_MISSING remains visible
915
+ # after the loop exits.
916
+ while IFS= read -r f; do
917
+ [ -z "$f" ] && continue
876
918
  case "$f" in
877
919
  traefik/*)
878
920
  grep -rlE "traefik.*validate|traefik.*check" "$WORKFLOW_DIR" 2>/dev/null | grep -q . || \
@@ -892,7 +934,7 @@ if [ -n "$TOOL_FILES" ]; then
892
934
  CI_MISSING="$CI_MISSING terraform"
893
935
  ;;
894
936
  esac
895
- done
937
+ done <<< "$TOOL_FILES"
896
938
 
897
939
  # Deduplicate
898
940
  CI_MISSING=$(echo "$CI_MISSING" | tr ' ' '\n' | sort -u | grep -v '^$' | tr '\n' ' ')