forgedock 1.0.7 → 1.0.8

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.
package/bin/forgedock.mjs CHANGED
@@ -13,6 +13,7 @@ import {
13
13
  } from "path";
14
14
  import {
15
15
  mkdir,
16
+ rm,
16
17
  symlink,
17
18
  copyFile,
18
19
  readlink,
@@ -3653,14 +3654,51 @@ async function docsInit() {
3653
3654
  console.log(` Files: ${templateFiles.length} seed files`);
3654
3655
  console.log("");
3655
3656
 
3656
- await mkdir(targetDir, { recursive: true });
3657
+ // Use resolvedTarget (canonical path) rather than the raw targetDir string. The
3658
+ // containment check above already resolved symlinks and validated the path; using the
3659
+ // canonical form here closes the TOCTOU window to the narrowest possible interval
3660
+ // and ensures mkdir does not follow a symlink that was NOT present when we checked.
3661
+ //
3662
+ // TOCTOU residual: a narrow window remains between the realpath check and this mkdir
3663
+ // on POSIX systems — a symlink planted at an intermediate path component during that
3664
+ // window could cause mkdir to create directories outside the project boundary.
3665
+ // Full elimination requires O_NOFOLLOW semantics (kernel-level, not available from
3666
+ // Node.js user-space). We mitigate with a post-create re-validation below (SEC-5).
3667
+ await mkdir(resolvedTarget, { recursive: true });
3668
+
3669
+ // SEC-5: Post-create re-validation — detect symlink planted during the TOCTOU window.
3670
+ // After mkdir succeeds, call realpath() on the now-existing directory to get its
3671
+ // canonical on-disk location. If that location lies outside the project root a
3672
+ // symlink was planted between the containment check and the mkdir call. In that case
3673
+ // we remove the directory tree that was created (wherever it landed) and abort.
3674
+ try {
3675
+ const postMkdirReal = await realpath(resolvedTarget);
3676
+ if (postMkdirReal !== realCwd && !postMkdirReal.startsWith(realCwd + sep)) {
3677
+ await rm(postMkdirReal, { recursive: true, force: true }).catch(() => {});
3678
+ console.log(
3679
+ `${RED}Error: devdocs directory escaped the project boundary after creation (TOCTOU).${RESET}`,
3680
+ );
3681
+ console.log(` Created at: ${postMkdirReal}`);
3682
+ console.log(` Project: ${realCwd}`);
3683
+ console.log(
3684
+ ` A symlink was likely planted at an intermediate path component between the`,
3685
+ );
3686
+ console.log(
3687
+ ` containment check and the mkdir call. The created directory has been removed.`,
3688
+ );
3689
+ process.exit(1);
3690
+ }
3691
+ } catch {
3692
+ // realpath() failure after a successful mkdir is unexpected; proceed — the directory
3693
+ // was created by us and the normal containment check already passed.
3694
+ }
3657
3695
 
3658
3696
  let copied = 0;
3659
3697
  let skipped = 0;
3660
3698
 
3661
3699
  for (const srcFile of templateFiles) {
3662
3700
  const rel = relative(templatesDir, srcFile);
3663
- const destFile = join(targetDir, rel);
3701
+ const destFile = join(resolvedTarget, rel);
3664
3702
  const destDir = dirname(destFile);
3665
3703
 
3666
3704
  await mkdir(destDir, { recursive: true });
@@ -13,6 +13,12 @@ argument-hint: [--fix | --recon-only | --fix --limit 5 | --dry-run]
13
13
  - `{EMEMO_PATH}` ← `services.ememo_path` (optional) — path on production server to open eMemo files
14
14
  - `{BILLING_ENABLED}` ← `billing.enabled` (optional, default `false`) — set to `true` to enable Stripe data collection in the Analytics Snapshot agent
15
15
 
16
+ Resolve `{BILLING_ENABLED}` from `forge.yaml` before executing any phase:
17
+
18
+ ```bash
19
+ BILLING_ENABLED=$(yq '.billing.enabled // false' "$(git rev-parse --show-toplevel)/forge.yaml" 2>/dev/null || echo 'false')
20
+ ```
21
+
16
22
  You are an autonomous improvement engine for this project. Your job is to **find what's wrong, create trackable issues, and optionally fix the highest-impact ones** — all in a single cycle. Every cycle leaves the platform measurably better than before.
17
23
 
18
24
  **This is designed to run repeatedly.** Each cycle builds on the last — issues created in cycle N get fixed in cycle N+1. The platform compounds improvements over time.
@@ -156,9 +156,9 @@ done
156
156
  git diff --stat origin/$TARGET..origin/$SOURCE
157
157
 
158
158
  # Read layout paths from forge.yaml, fall back to AlterLab defaults
159
- LAYOUT_API=$(yq '.review.layout.api // "services/api"' $PROJECT_ROOT/forge.yaml 2>/dev/null || echo "services/api")
160
- LAYOUT_WORKER=$(yq '.review.layout.worker // "services/worker"' $PROJECT_ROOT/forge.yaml 2>/dev/null || echo "services/worker")
161
- LAYOUT_PAGES=$(yq '.review.layout.pages // "web/src/app"' $PROJECT_ROOT/forge.yaml 2>/dev/null || echo "web/src/app")
159
+ LAYOUT_API=$(yq '.review.layout.api // "services/api"' forge.yaml 2>/dev/null || echo "services/api")
160
+ LAYOUT_WORKER=$(yq '.review.layout.worker // "services/worker"' forge.yaml 2>/dev/null || echo "services/worker")
161
+ LAYOUT_PAGES=$(yq '.review.layout.pages // "web/src/app"' forge.yaml 2>/dev/null || echo "web/src/app")
162
162
  # Derive web root from pages root (strip trailing /src/app or similar suffix if present)
163
163
  LAYOUT_WEB=$(echo "$LAYOUT_PAGES" | sed 's|/src/app$||; s|/src$||')
164
164
 
@@ -180,7 +180,7 @@ done | sort
180
180
 
181
181
  ```bash
182
182
  # Read migrations path from forge.yaml, fall back to infra/migrations
183
- LAYOUT_MIGRATIONS=$(yq '.review.layout.migrations // "infra/migrations"' $PROJECT_ROOT/forge.yaml 2>/dev/null || echo "infra/migrations")
183
+ LAYOUT_MIGRATIONS=$(yq '.review.layout.migrations // "infra/migrations"' forge.yaml 2>/dev/null || echo "infra/migrations")
184
184
 
185
185
  # Are there new migrations?
186
186
  MIGRATIONS=$(git diff --name-only origin/$TARGET..origin/$SOURCE | grep "^${LAYOUT_MIGRATIONS}/" | sort)
@@ -220,18 +220,18 @@ Analyze the deploy payload and flag risks:
220
220
  # Check for new env vars that might not be in the configured secrets backend
221
221
  NEW_ENV=$(git diff origin/$TARGET..origin/$SOURCE -- '.env.example' | grep "^+" | grep -v "^+++" | grep -v "^#")
222
222
  if [ -n "$NEW_ENV" ]; then
223
- echo "⚠️ NEW ENVIRONMENT VARIABLES — verify they exist in your secrets backend ({deploy.secrets_backend}):"
223
+ echo "⚠️ NEW ENVIRONMENT VARIABLES — verify they exist in your secrets backend ({DEPLOY_SECRETS_BACKEND}):"
224
224
  echo "$NEW_ENV"
225
225
  # Cross-check with SOPS chain (only when deploy.secrets_backend == "sops")
226
226
  # If your project uses a different backend, skip this block and verify manually.
227
- if [ "{deploy.secrets_backend}" = "sops" ]; then
227
+ if [ "{DEPLOY_SECRETS_BACKEND}" = "sops" ]; then
228
228
  for var in $(echo "$NEW_ENV" | grep -oP '^\+\K[A-Z_]+'); do
229
229
  if ! grep -q "$var" scripts/decrypt-secrets.sh 2>/dev/null; then
230
230
  echo " ❌ $var NOT in decrypt-secrets.sh ENV_MAPPING"
231
231
  fi
232
232
  done
233
233
  else
234
- echo " ℹ️ SOPS chain check skipped — deploy.secrets_backend is '{deploy.secrets_backend}'. Verify new vars in your configured backend."
234
+ echo " ℹ️ SOPS chain check skipped — deploy.secrets_backend is '{DEPLOY_SECRETS_BACKEND}'. Verify new vars in your configured backend."
235
235
  fi
236
236
  fi
237
237
  ```
@@ -249,7 +249,7 @@ Generate a checklist based on what's in the deploy:
249
249
  - [ ] All PRs in this deploy have been reviewed
250
250
  - [ ] No open `needs-human` issues blocking deploy
251
251
  - [ ] {If migrations} Database backup verified
252
- - [ ] {If new env vars} Secrets backend ({deploy.secrets_backend}) updated with new vars; verify via your configured secrets chain
252
+ - [ ] {If new env vars} Secrets backend ({DEPLOY_SECRETS_BACKEND}) updated with new vars; verify via your configured secrets chain
253
253
  - [ ] {If traefik changes} Routing tested locally
254
254
 
255
255
  ### Deploy Command
@@ -824,9 +824,9 @@ All tool commands are read from `forge.yaml → verification.commands`; each ste
824
824
 
825
825
  ```bash
826
826
  # Read toolchain commands from forge.yaml
827
- TS_TYPECHECK=$(awk '/^ commands:/{f=1;next} f && /^[^ \t]/{exit} f' forge.yaml 2>/dev/null | awk '/^ typescript:/{f=1;next} f && /^ [^ \t]/{exit} f' | grep 'typecheck:' | head -1 | sed "s/.*typecheck: *['\"]//;s/['\"].*//")
828
- TS_BUILD=$(awk '/^ commands:/{f=1;next} f && /^[^ \t]/{exit} f' forge.yaml 2>/dev/null | awk '/^ typescript:/{f=1;next} f && /^ [^ \t]/{exit} f' | grep 'build:' | head -1 | sed "s/.*build: *['\"]//;s/['\"].*//")
829
- PYTHON_FORMAT=$(awk '/^ commands:/{f=1;next} f && /^[^ \t]/{exit} f' forge.yaml 2>/dev/null | awk '/^ python:/{f=1;next} f && /^ [^ \t]/{exit} f' | grep 'format:' | head -1 | sed "s/.*format: *['\"]//;s/['\"].*//")
827
+ TS_TYPECHECK=$(yq '.verification.commands.typescript.typecheck // ""' forge.yaml 2>/dev/null || echo '')
828
+ TS_BUILD=$(yq '.verification.commands.typescript.build // ""' forge.yaml 2>/dev/null || echo '')
829
+ PYTHON_FORMAT=$(yq '.verification.commands.python.format // ""' forge.yaml 2>/dev/null || echo '')
830
830
 
831
831
  # Check if this is a milestone batch with TypeScript changes
832
832
  MILESTONE_BRANCH="milestone/{milestone_slug}"
@@ -14,6 +14,22 @@ Performs comprehensive review of `staging` before merging to `main`. Handles lar
14
14
 
15
15
  ---
16
16
 
17
+ ## Config Resolution
18
+
19
+ Read `forge.yaml` to resolve branch names before running any commands:
20
+
21
+ ```bash
22
+ CONFIG_FILE="${FORGE_CONFIG:-forge.yaml}"
23
+ GH_REPO=$(yq '.project.owner + "/" + .project.repo' "$CONFIG_FILE")
24
+ GH_FLAG="-R $GH_REPO"
25
+ DEFAULT_BRANCH=$(yq '.branches.default' "$CONFIG_FILE")
26
+ STAGING_BRANCH=$(yq '.branches.staging' "$CONFIG_FILE")
27
+ ```
28
+
29
+ All `$DEFAULT_BRANCH` and `$STAGING_BRANCH` references below are populated from `forge.yaml`.
30
+
31
+ ---
32
+
17
33
  ## Evidence-Based Review Protocol (ALL Agents)
18
34
 
19
35
  ### Diff-First Approach
@@ -70,22 +86,6 @@ Include ALL findings (CONFIRMED, LIKELY, POSSIBLE). One line per finding, sequen
70
86
 
71
87
  ---
72
88
 
73
- ## Config Resolution
74
-
75
- Read `forge.yaml` to resolve branch names before running any commands:
76
-
77
- ```bash
78
- CONFIG_FILE="${FORGE_CONFIG:-forge.yaml}"
79
- GH_REPO=$(yq '.project.owner + "/" + .project.repo' "$CONFIG_FILE")
80
- GH_FLAG="-R $GH_REPO"
81
- DEFAULT_BRANCH=$(yq '.branches.default' "$CONFIG_FILE")
82
- STAGING_BRANCH=$(yq '.branches.staging' "$CONFIG_FILE")
83
- ```
84
-
85
- All `$DEFAULT_BRANCH` and `$STAGING_BRANCH` references below are populated from `forge.yaml`.
86
-
87
- ---
88
-
89
89
  ## Phase 0A: Open Review-Finding Gate (BLOCKING — runs before scope analysis)
90
90
 
91
91
  **Purpose**: Prevent deploying commits that have known, unfixed review findings. The review system catches bugs before merging; this gate ensures the merge path acts on that information.
@@ -213,8 +213,8 @@ Create review chunks by priority: Billing/Pricing (CRITICAL) → Security/Auth (
213
213
  Read `forge.yaml → verification.commands.python` for project-specific tool commands:
214
214
 
215
215
  ```bash
216
- PYTHON_FORMAT=$(awk '/^ commands:/{f=1;next} f && /^[^ \t]/{exit} f' forge.yaml 2>/dev/null | awk '/^ python:/{f=1;next} f && /^ [^ \t]/{exit} f' | grep 'format:' | head -1 | sed "s/.*format: *['\"]//;s/['\"].*//")
217
- PYTHON_LINT=$(awk '/^ commands:/{f=1;next} f && /^[^ \t]/{exit} f' forge.yaml 2>/dev/null | awk '/^ python:/{f=1;next} f && /^ [^ \t]/{exit} f' | grep 'lint:' | head -1 | sed "s/.*lint: *['\"]//;s/['\"].*//")
216
+ PYTHON_FORMAT=$(yq '.verification.commands.python.format // ""' forge.yaml 2>/dev/null || echo '')
217
+ PYTHON_LINT=$(yq '.verification.commands.python.lint // ""' forge.yaml 2>/dev/null || echo '')
218
218
 
219
219
  if [ -n "$PYTHON_FORMAT" ]; then
220
220
  eval "$PYTHON_FORMAT" 2>&1 | head -30
@@ -234,8 +234,8 @@ fi
234
234
  Read `forge.yaml → verification.commands.typescript` for project-specific tool commands:
235
235
 
236
236
  ```bash
237
- TS_TYPECHECK=$(awk '/^ commands:/{f=1;next} f && /^[^ \t]/{exit} f' forge.yaml 2>/dev/null | awk '/^ typescript:/{f=1;next} f && /^ [^ \t]/{exit} f' | grep 'typecheck:' | head -1 | sed "s/.*typecheck: *['\"]//;s/['\"].*//")
238
- TS_BUILD=$(awk '/^ commands:/{f=1;next} f && /^[^ \t]/{exit} f' forge.yaml 2>/dev/null | awk '/^ typescript:/{f=1;next} f && /^ [^ \t]/{exit} f' | grep 'build:' | head -1 | sed "s/.*build: *['\"]//;s/['\"].*//")
237
+ TS_TYPECHECK=$(yq '.verification.commands.typescript.typecheck // ""' forge.yaml 2>/dev/null || echo '')
238
+ TS_BUILD=$(yq '.verification.commands.typescript.build // ""' forge.yaml 2>/dev/null || echo '')
239
239
 
240
240
  if [ -n "$TS_TYPECHECK" ]; then
241
241
  eval "$TS_TYPECHECK" 2>&1
@@ -261,7 +261,7 @@ Build failure is BLOCKING — deploy WILL fail. Typecheck alone misses SSG/prere
261
261
  Read `forge.yaml → verification.commands.python.test`:
262
262
 
263
263
  ```bash
264
- PYTHON_TEST=$(awk '/^ commands:/{f=1;next} f && /^[^ \t]/{exit} f' forge.yaml 2>/dev/null | awk '/^ python:/{f=1;next} f && /^ [^ \t]/{exit} f' | grep 'test:' | head -1 | sed "s/.*test: *['\"]//;s/['\"].*//")
264
+ PYTHON_TEST=$(yq '.verification.commands.python.test // ""' forge.yaml 2>/dev/null || echo '')
265
265
 
266
266
  if [ -n "$PYTHON_TEST" ]; then
267
267
  eval "$PYTHON_TEST" 2>&1 | tail -50
@@ -161,9 +161,9 @@ Read `forge.yaml → verification.commands.python` for project-specific tool com
161
161
 
162
162
  ```bash
163
163
  # Read toolchain commands from forge.yaml
164
- PYTHON_FORMAT=$(awk '/^ commands:/{f=1;next} f && /^[^ \t]/{exit} f' forge.yaml 2>/dev/null | awk '/^ python:/{f=1;next} f && /^ [^ \t]/{exit} f' | grep 'format:' | head -1 | sed "s/.*format: *['\"]//;s/['\"].*//")
165
- PYTHON_LINT=$(awk '/^ commands:/{f=1;next} f && /^[^ \t]/{exit} f' forge.yaml 2>/dev/null | awk '/^ python:/{f=1;next} f && /^ [^ \t]/{exit} f' | grep 'lint:' | head -1 | sed "s/.*lint: *['\"]//;s/['\"].*//")
166
- PYTHON_TYPECHECK=$(awk '/^ commands:/{f=1;next} f && /^[^ \t]/{exit} f' forge.yaml 2>/dev/null | awk '/^ python:/{f=1;next} f && /^ [^ \t]/{exit} f' | grep 'typecheck:' | head -1 | sed "s/.*typecheck: *['\"]//;s/['\"].*//")
164
+ PYTHON_FORMAT=$(yq '.verification.commands.python.format // ""' forge.yaml 2>/dev/null || echo '')
165
+ PYTHON_LINT=$(yq '.verification.commands.python.lint // ""' forge.yaml 2>/dev/null || echo '')
166
+ PYTHON_TYPECHECK=$(yq '.verification.commands.python.typecheck // ""' forge.yaml 2>/dev/null || echo '')
167
167
 
168
168
  # Run format check
169
169
  if [ -n "$PYTHON_FORMAT" ]; then
@@ -187,7 +187,7 @@ else
187
187
  fi
188
188
 
189
189
  # Always run compile check on changed Python files (fast, language-universal)
190
- for f in $(gh pr diff $ARGUMENTS --name-only | grep '\.py$'); do
190
+ gh pr diff $ARGUMENTS --name-only | grep '\.py$' | while IFS= read -r f; do
191
191
  python3 -m py_compile "$f" 2>&1
192
192
  done
193
193
  ```
@@ -197,9 +197,9 @@ done
197
197
  Read `forge.yaml → verification.commands.typescript` for project-specific tool commands:
198
198
 
199
199
  ```bash
200
- TS_FORMAT=$(awk '/^ commands:/{f=1;next} f && /^[^ \t]/{exit} f' forge.yaml 2>/dev/null | awk '/^ typescript:/{f=1;next} f && /^ [^ \t]/{exit} f' | grep 'format:' | head -1 | sed "s/.*format: *['\"]//;s/['\"].*//")
201
- TS_LINT=$(awk '/^ commands:/{f=1;next} f && /^[^ \t]/{exit} f' forge.yaml 2>/dev/null | awk '/^ typescript:/{f=1;next} f && /^ [^ \t]/{exit} f' | grep 'lint:' | head -1 | sed "s/.*lint: *['\"]//;s/['\"].*//")
202
- TS_TYPECHECK=$(awk '/^ commands:/{f=1;next} f && /^[^ \t]/{exit} f' forge.yaml 2>/dev/null | awk '/^ typescript:/{f=1;next} f && /^ [^ \t]/{exit} f' | grep 'typecheck:' | head -1 | sed "s/.*typecheck: *['\"]//;s/['\"].*//")
200
+ TS_FORMAT=$(yq '.verification.commands.typescript.format // ""' forge.yaml 2>/dev/null || echo '')
201
+ TS_LINT=$(yq '.verification.commands.typescript.lint // ""' forge.yaml 2>/dev/null || echo '')
202
+ TS_TYPECHECK=$(yq '.verification.commands.typescript.typecheck // ""' forge.yaml 2>/dev/null || echo '')
203
203
 
204
204
  # Run format check
205
205
  if [ -n "$TS_FORMAT" ]; then
@@ -243,7 +243,7 @@ gh pr diff $ARGUMENTS | grep -oE "['\"][A-Za-z0-9+/=]{40,}['\"]" | head -10
243
243
 
244
244
  ### 2F: SQL Migration Validation (if *.sql changed)
245
245
  ```bash
246
- for sql_file in $(gh pr diff $ARGUMENTS --name-only | grep "\.sql$"); do
246
+ gh pr diff $ARGUMENTS --name-only | grep "\.sql$" | while IFS= read -r sql_file; do
247
247
  grep -E "FOR UPDATE" "$sql_file" | grep -qE "(SUM|COUNT|AVG|MIN|MAX)\s*\(" && echo "ERROR: FOR UPDATE with aggregate"
248
248
  grep -qE "DROP (TABLE|COLUMN|INDEX)" "$sql_file" && ! grep -qE "IF EXISTS" "$sql_file" && echo "WARNING: DROP without IF EXISTS"
249
249
  grep -qE "ALTER TABLE.*ADD COLUMN.*NOT NULL" "$sql_file" && ! grep -qE "DEFAULT" "$sql_file" && echo "WARNING: NOT NULL without DEFAULT"
@@ -263,7 +263,7 @@ Read `forge.yaml → verification.commands.{lang}.test` for the project's test c
263
263
  ```bash
264
264
  # Check each configured language for a test command
265
265
  for lang in python typescript go rust; do
266
- TEST_CMD=$(awk '/^ commands:/{f=1;next} f && /^[^ \t]/{exit} f' forge.yaml 2>/dev/null | awk "/^ ${lang}:/{f=1;next} f && /^ [^ \t]/{exit} f" | grep 'test:' | head -1 | sed "s/.*test: *['\"]//;s/['\"].*//")
266
+ TEST_CMD=$(yq ".verification.commands.${lang}.test // \"\"" forge.yaml 2>/dev/null || echo '')
267
267
  if [ -n "$TEST_CMD" ]; then
268
268
  echo "=== Running tests (${lang}): ${TEST_CMD} ==="
269
269
  eval "$TEST_CMD" 2>&1 | tail -30
@@ -273,8 +273,8 @@ for lang in python typescript go rust; do
273
273
  done
274
274
 
275
275
  # If no test commands were configured, log explicitly
276
- PYTHON_TEST=$(awk '/^ commands:/{f=1;next} f && /^[^ \t]/{exit} f' forge.yaml 2>/dev/null | awk '/^ python:/{f=1;next} f && /^ [^ \t]/{exit} f' | grep 'test:' | head -1 | sed "s/.*test: *['\"]//;s/['\"].*//")
277
- TS_TEST=$(awk '/^ commands:/{f=1;next} f && /^[^ \t]/{exit} f' forge.yaml 2>/dev/null | awk '/^ typescript:/{f=1;next} f && /^ [^ \t]/{exit} f' | grep 'test:' | head -1 | sed "s/.*test: *['\"]//;s/['\"].*//")
276
+ PYTHON_TEST=$(yq '.verification.commands.python.test // ""' forge.yaml 2>/dev/null || echo '')
277
+ TS_TEST=$(yq '.verification.commands.typescript.test // ""' forge.yaml 2>/dev/null || echo '')
278
278
  if [ -z "$PYTHON_TEST" ] && [ -z "$TS_TEST" ]; then
279
279
  echo "SKIPPED — no test commands configured in verification.commands"
280
280
  fi
@@ -303,8 +303,8 @@ Read `forge.yaml → verification.commands.typescript.typecheck` and `.build`:
303
303
  ```bash
304
304
  gh pr checkout $ARGUMENTS --detach 2>/dev/null
305
305
 
306
- TS_TYPECHECK=$(awk '/^ commands:/{f=1;next} f && /^[^ \t]/{exit} f' forge.yaml 2>/dev/null | awk '/^ typescript:/{f=1;next} f && /^ [^ \t]/{exit} f' | grep 'typecheck:' | head -1 | sed "s/.*typecheck: *['\"]//;s/['\"].*//")
307
- TS_BUILD=$(awk '/^ commands:/{f=1;next} f && /^[^ \t]/{exit} f' forge.yaml 2>/dev/null | awk '/^ typescript:/{f=1;next} f && /^ [^ \t]/{exit} f' | grep 'build:' | head -1 | sed "s/.*build: *['\"]//;s/['\"].*//")
306
+ TS_TYPECHECK=$(yq '.verification.commands.typescript.typecheck // ""' forge.yaml 2>/dev/null || echo '')
307
+ TS_BUILD=$(yq '.verification.commands.typescript.build // ""' forge.yaml 2>/dev/null || echo '')
308
308
 
309
309
  if [ -n "$TS_TYPECHECK" ]; then
310
310
  eval "$TS_TYPECHECK" 2>&1
@@ -337,10 +337,10 @@ Read `forge.yaml → verification.commands.python.format` and `.build`:
337
337
  gh pr checkout $ARGUMENTS --detach 2>/dev/null
338
338
 
339
339
  # Compile-check all changed Python files (language-universal — no config needed)
340
- for f in $(echo "$CHANGED_FILES" | grep '\.py$'); do python3 -m py_compile "$f" 2>&1; done
340
+ echo "$CHANGED_FILES" | grep '\.py$' | while IFS= read -r f; do python3 -m py_compile "$f" 2>&1; done
341
341
 
342
342
  if [ "$REQUIRES_FULL_BUILD" = "true" ]; then
343
- PYTHON_FORMAT=$(awk '/^ commands:/{f=1;next} f && /^[^ \t]/{exit} f' forge.yaml 2>/dev/null | awk '/^ python:/{f=1;next} f && /^ [^ \t]/{exit} f' | grep 'format:' | head -1 | sed "s/.*format: *['\"]//;s/['\"].*//")
343
+ PYTHON_FORMAT=$(yq '.verification.commands.python.format // ""' forge.yaml 2>/dev/null || echo '')
344
344
  if [ -n "$PYTHON_FORMAT" ]; then
345
345
  eval "$PYTHON_FORMAT" 2>&1
346
346
  else
@@ -495,7 +495,7 @@ fi
495
495
  # Python scoping hazard check — local imports that shadow module-level names
496
496
  # A local `import X` makes X a local variable for the ENTIRE function scope.
497
497
  # Any reference to X ABOVE the local import will crash with UnboundLocalError.
498
- for f in $(echo "$CHANGED_FILES" | grep -E '\.py$'); do
498
+ echo "$CHANGED_FILES" | grep -E '\.py$' | while IFS= read -r f; do
499
499
  echo "=== Python Scoping Check: $f ==="
500
500
  # Find function-scoped imports (indented import statements)
501
501
  grep -nE "^\s+import [a-z]" "$f" 2>/dev/null | while read line; do
@@ -509,7 +509,7 @@ for f in $(echo "$CHANGED_FILES" | grep -E '\.py$'); do
509
509
  done
510
510
 
511
511
  # Config file assumption check (baked into Docker image vs volume-mounted)
512
- for f in $(echo "$CHANGED_FILES" | grep -E "config/.*\.(json|yaml|yml)$"); do
512
+ echo "$CHANGED_FILES" | grep -E "config/.*\.(json|yaml|yml)$" | while IFS= read -r f; do
513
513
  echo "=== Config File: $f ==="
514
514
  grep -n "$(dirname $f)" docker-compose.yml 2>/dev/null || echo "WARNING: Config dir may not be mounted — changes may require --build"
515
515
  grep -n "$(dirname $f)" services/*/Dockerfile 2>/dev/null || true
@@ -122,7 +122,7 @@ for f in {reverted_python_files}; do
122
122
  done
123
123
 
124
124
  # Python format check (config-driven)
125
- PYTHON_FORMAT=$(awk '/^ commands:/{f=1;next} f && /^[^ \t]/{exit} f' forge.yaml 2>/dev/null | awk '/^ python:/{f=1;next} f && /^ [^ \t]/{exit} f' | grep 'format:' | head -1 | sed "s/.*format: *['\"]//;s/['\"].*//")
125
+ PYTHON_FORMAT=$(yq '.verification.commands.python.format // ""' forge.yaml 2>/dev/null || echo '')
126
126
  if [ -n "$PYTHON_FORMAT" ]; then
127
127
  eval "$PYTHON_FORMAT" 2>&1 | head -20
128
128
  else
@@ -130,7 +130,7 @@ else
130
130
  fi
131
131
 
132
132
  # TypeScript typecheck (config-driven)
133
- TS_TYPECHECK=$(awk '/^ commands:/{f=1;next} f && /^[^ \t]/{exit} f' forge.yaml 2>/dev/null | awk '/^ typescript:/{f=1;next} f && /^ [^ \t]/{exit} f' | grep 'typecheck:' | head -1 | sed "s/.*typecheck: *['\"]//;s/['\"].*//")
133
+ TS_TYPECHECK=$(yq '.verification.commands.typescript.typecheck // ""' forge.yaml 2>/dev/null || echo '')
134
134
  if [ -n "$TS_TYPECHECK" ]; then
135
135
  eval "$TS_TYPECHECK" 2>&1 | head -20
136
136
  else
@@ -94,7 +94,7 @@ Run after quality gate passes. All tool commands are read from `forge.yaml → v
94
94
  ```bash
95
95
  cd {WORKTREE_PATH}
96
96
 
97
- PYTHON_FORMAT=$(awk '/^ commands:/{f=1;next} f && /^[^ \t]/{exit} f' forge.yaml 2>/dev/null | awk '/^ python:/{f=1;next} f && /^ [^ \t]/{exit} f' | grep 'format:' | head -1 | sed "s/.*format: *['\"]//;s/['\"].*//")
97
+ PYTHON_FORMAT=$(yq '.verification.commands.python.format // ""' forge.yaml 2>/dev/null || echo '')
98
98
  if [ -n "$PYTHON_FORMAT" ]; then
99
99
  eval "$PYTHON_FORMAT" 2>&1
100
100
  else
@@ -110,9 +110,9 @@ Failures in `py_compile` are BLOCKING — fix before continuing.
110
110
  ```bash
111
111
  cd {WORKTREE_PATH}
112
112
 
113
- TS_FORMAT=$(awk '/^ commands:/{f=1;next} f && /^[^ \t]/{exit} f' forge.yaml 2>/dev/null | awk '/^ typescript:/{f=1;next} f && /^ [^ \t]/{exit} f' | grep 'format:' | head -1 | sed "s/.*format: *['\"]//;s/['\"].*//")
114
- TS_TYPECHECK=$(awk '/^ commands:/{f=1;next} f && /^[^ \t]/{exit} f' forge.yaml 2>/dev/null | awk '/^ typescript:/{f=1;next} f && /^ [^ \t]/{exit} f' | grep 'typecheck:' | head -1 | sed "s/.*typecheck: *['\"]//;s/['\"].*//")
115
- TS_BUILD=$(awk '/^ commands:/{f=1;next} f && /^[^ \t]/{exit} f' forge.yaml 2>/dev/null | awk '/^ typescript:/{f=1;next} f && /^ [^ \t]/{exit} f' | grep 'build:' | head -1 | sed "s/.*build: *['\"]//;s/['\"].*//")
113
+ TS_FORMAT=$(yq '.verification.commands.typescript.format // ""' forge.yaml 2>/dev/null || echo '')
114
+ TS_TYPECHECK=$(yq '.verification.commands.typescript.typecheck // ""' forge.yaml 2>/dev/null || echo '')
115
+ TS_BUILD=$(yq '.verification.commands.typescript.build // ""' forge.yaml 2>/dev/null || echo '')
116
116
 
117
117
  if [ -n "$TS_FORMAT" ]; then
118
118
  eval "$TS_FORMAT" 2>&1
@@ -214,7 +214,24 @@ Fix HIGH/MEDIUM findings. Max 2 iterations. Skip for 1-file config/docs edits.
214
214
  All client-side fetch/useSWR/apiFetch must use `/api/...` proxy routes, NEVER `/api/v1/...` directly.
215
215
 
216
216
  ### 3I: Deployment completeness check (MANDATORY)
217
- New env vars must be in: `.env.example` + SOPS `prod.enc.yaml` + `decrypt-secrets.sh` ENV_MAPPING + `env_validation.py` (if API).
217
+ Skip if no new env vars introduced.
218
+
219
+ For each new env var, verify present in ALL required locations:
220
+
221
+ | Location | Required for |
222
+ |----------|-------------|
223
+ | `.env.example` | All new vars |
224
+ | Secrets backend (see `deploy.secrets_backend`) | Secret vars — skip if backend is `none` or unset |
225
+ | `app/env_validation.py` | API service vars (if project has one) |
226
+
227
+ **Secrets backend check** *(trigger: `deploy.secrets_backend == "sops"`)*:
228
+
229
+ If the project uses SOPS, verify the new var is present in all SOPS chain locations:
230
+ - `infra/secrets/prod.enc.yaml` — SOPS-encrypted secret store
231
+ - `infra/decrypt-secrets.sh` ENV_MAPPING — maps SOPS key to env var name
232
+
233
+ If `deploy.secrets_backend` is absent or not `sops`, skip these checks and log:
234
+ > `SKIP: SOPS chain check — deploy.secrets_backend is not "sops". Configure deploy.secrets_backend in forge.yaml to enable.`
218
235
 
219
236
  ### 3J: Commit
220
237
  Conventional prefix (fix/feat/refactor/docs). Reference #{NUMBER} in the commit message.
@@ -753,7 +753,7 @@ All tool commands are read from `forge.yaml → verification.commands`. When a k
753
753
  ```bash
754
754
  cd {WORKTREE_PATH}
755
755
 
756
- PYTHON_FORMAT=$(awk '/^ commands:/{f=1;next} f && /^[^ \t]/{exit} f' forge.yaml 2>/dev/null | awk '/^ python:/{f=1;next} f && /^ [^ \t]/{exit} f' | grep 'format:' | head -1 | sed "s/.*format: *['\"]//;s/['\"].*//")
756
+ PYTHON_FORMAT=$(yq '.verification.commands.python.format // ""' forge.yaml 2>/dev/null || echo '')
757
757
  if [ -n "$PYTHON_FORMAT" ]; then
758
758
  eval "$PYTHON_FORMAT" 2>&1
759
759
  else
@@ -769,9 +769,9 @@ python -m py_compile {PYTHON_FILES}
769
769
  ```bash
770
770
  cd {WORKTREE_PATH}
771
771
 
772
- TS_FORMAT=$(awk '/^ commands:/{f=1;next} f && /^[^ \t]/{exit} f' forge.yaml 2>/dev/null | awk '/^ typescript:/{f=1;next} f && /^ [^ \t]/{exit} f' | grep 'format:' | head -1 | sed "s/.*format: *['\"]//;s/['\"].*//")
773
- TS_TYPECHECK=$(awk '/^ commands:/{f=1;next} f && /^[^ \t]/{exit} f' forge.yaml 2>/dev/null | awk '/^ typescript:/{f=1;next} f && /^ [^ \t]/{exit} f' | grep 'typecheck:' | head -1 | sed "s/.*typecheck: *['\"]//;s/['\"].*//")
774
- TS_BUILD=$(awk '/^ commands:/{f=1;next} f && /^[^ \t]/{exit} f' forge.yaml 2>/dev/null | awk '/^ typescript:/{f=1;next} f && /^ [^ \t]/{exit} f' | grep 'build:' | head -1 | sed "s/.*build: *['\"]//;s/['\"].*//")
772
+ TS_FORMAT=$(yq '.verification.commands.typescript.format // ""' forge.yaml 2>/dev/null || echo '')
773
+ TS_TYPECHECK=$(yq '.verification.commands.typescript.typecheck // ""' forge.yaml 2>/dev/null || echo '')
774
+ TS_BUILD=$(yq '.verification.commands.typescript.build // ""' forge.yaml 2>/dev/null || echo '')
775
775
 
776
776
  if [ -n "$TS_FORMAT" ]; then
777
777
  eval "$TS_FORMAT" 2>&1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "forgedock",
3
- "version": "1.0.7",
3
+ "version": "1.0.8",
4
4
  "description": "GitHub as a knowledge graph for AI agents. Autonomous dev pipeline for Claude Code.",
5
5
  "bin": {
6
6
  "forgedock": "bin/forgedock.mjs"
@@ -49,12 +49,25 @@ WARNINGS=0
49
49
 
50
50
  # --- Next.js Route Handler registration check ---
51
51
  while read -r f; do
52
- URL_PATH=$(echo "$f" | sed "s|^${FORGE_PAGES_ROOT}||; s|/route\\.ts\$||; s|\\[.*\\]|*|g")
52
+ # Use shell parameter expansion instead of sed to avoid interpreting FORGE_PAGES_ROOT
53
+ # as a sed pattern (values containing | or other metacharacters break the substitution).
54
+ stripped="${f#${FORGE_PAGES_ROOT}/}"
55
+ stripped="${stripped%/route.ts}"
56
+ # Replace dynamic segments [param] with * using a loop (no regex needed)
57
+ URL_PATH=""
58
+ IFS='/' read -ra SEGMENTS <<< "$stripped"
59
+ for seg in "${SEGMENTS[@]}"; do
60
+ case "$seg" in
61
+ \[*\]) URL_PATH="${URL_PATH}/*" ;;
62
+ *) URL_PATH="${URL_PATH}/${seg}" ;;
63
+ esac
64
+ done
53
65
  ROUTE_SEGMENT=$(echo "$URL_PATH" | sed 's|/api/v1/||; s|/.*||')
54
66
 
55
67
  # Check if next.config.js has a rewrite that might shadow this route
56
68
  if [ -f "$REPO_ROOT/web/next.config.js" ]; then
57
- if grep -q "$ROUTE_SEGMENT" "$REPO_ROOT/web/next.config.js" 2>/dev/null; then
69
+ # Use -F (fixed string) so ROUTE_SEGMENT is not interpreted as a regex pattern
70
+ if grep -qF "$ROUTE_SEGMENT" "$REPO_ROOT/web/next.config.js" 2>/dev/null; then
58
71
  echo "WARNING: Route handler $f ($URL_PATH) — next.config.js references '$ROUTE_SEGMENT', may shadow this route"
59
72
  WARNINGS=$((WARNINGS + 1))
60
73
  else
@@ -64,12 +77,19 @@ while read -r f; do
64
77
 
65
78
  # Check if nginx routes this path to Next.js or directly to backend
66
79
  if [ -f "$REPO_ROOT/infra/nginx/nginx.conf" ]; then
67
- if grep -q "location.*$ROUTE_SEGMENT" "$REPO_ROOT/infra/nginx/nginx.conf" 2>/dev/null; then
80
+ # Check for location blocks mentioning ROUTE_SEGMENT — use two -F searches to avoid
81
+ # embedding the variable in a regex: first find lines with "location", then filter
82
+ # for the literal segment. This avoids ROUTE_SEGMENT being interpreted as a BRE.
83
+ if grep -F "location" "$REPO_ROOT/infra/nginx/nginx.conf" 2>/dev/null | grep -qF "$ROUTE_SEGMENT"; then
68
84
  echo "WARNING: Route handler $f ($URL_PATH) — nginx.conf has a location block for '$ROUTE_SEGMENT', may bypass Next.js"
69
85
  WARNINGS=$((WARNINGS + 1))
70
86
  fi
71
87
  fi
72
- done < <(echo "$FILES" | grep -E "^${FORGE_PAGES_ROOT}/api/.*route\\.ts$") || true
88
+ done < <(echo "$FILES" | while IFS= read -r line; do
89
+ case "$line" in
90
+ "${FORGE_PAGES_ROOT}/api/"*"route.ts") echo "$line" ;;
91
+ esac
92
+ done) || true
73
93
 
74
94
  # --- Python API Router registration check ---
75
95
  while read -r f; do
@@ -77,7 +97,8 @@ while read -r f; do
77
97
  MAIN_PY="$REPO_ROOT/${FORGE_API_MAIN}"
78
98
 
79
99
  if [ -f "$MAIN_PY" ]; then
80
- if grep -q "$ROUTER_NAME" "$MAIN_PY" 2>/dev/null; then
100
+ # Use -F (fixed string) so ROUTER_NAME is not interpreted as a regex pattern
101
+ if grep -qF "$ROUTER_NAME" "$MAIN_PY" 2>/dev/null; then
81
102
  echo "OK: API Router '$ROUTER_NAME' ($f) — registered in $(basename "$MAIN_PY")"
82
103
  else
83
104
  echo "BLOCKING: API Router '$ROUTER_NAME' ($f) — NOT found in $(basename "$MAIN_PY"). Router will not be reachable."
@@ -95,7 +116,8 @@ while read -r f; do
95
116
  MAIN_PY="$REPO_ROOT/${FORGE_API_MAIN}"
96
117
 
97
118
  if [ -f "$MAIN_PY" ]; then
98
- if grep -q "$MIDDLEWARE_NAME" "$MAIN_PY" 2>/dev/null; then
119
+ # Use -F (fixed string) so MIDDLEWARE_NAME is not interpreted as a regex pattern
120
+ if grep -qF "$MIDDLEWARE_NAME" "$MAIN_PY" 2>/dev/null; then
99
121
  echo "OK: Middleware '$MIDDLEWARE_NAME' ($f) — registered in $(basename "$MAIN_PY")"
100
122
  else
101
123
  echo "WARNING: Middleware '$MIDDLEWARE_NAME' ($f) — not found in $(basename "$MAIN_PY"). May not be active."
@@ -108,7 +130,8 @@ done < <(echo "$FILES" | grep -E "^${FORGE_API_MIDDLEWARE_DIR}/.*\\.py$") || tru
108
130
  while read -r f; do
109
131
  MODULE_NAME=$(basename "$f" .py)
110
132
 
111
- IMPORTERS=$(grep -rl "$MODULE_NAME" "$REPO_ROOT/${FORGE_API_SERVICES_DIR}/" "$REPO_ROOT/${FORGE_WORKER_DIR}/" 2>/dev/null | head -5)
133
+ # Use -F (fixed string) so MODULE_NAME is not interpreted as a regex pattern
134
+ IMPORTERS=$(grep -rlF "$MODULE_NAME" "$REPO_ROOT/${FORGE_API_SERVICES_DIR}/" "$REPO_ROOT/${FORGE_WORKER_DIR}/" 2>/dev/null | head -5)
112
135
  if [ -n "$IMPORTERS" ]; then
113
136
  echo "OK: Shared module '$MODULE_NAME' ($f) — imported by services"
114
137
  else
@@ -121,7 +144,9 @@ done < <(echo "$FILES" | grep -E "^shared/.*\.py$") || true
121
144
  while read -r f; do
122
145
  COMPONENT_NAME=$(basename "$f" .tsx)
123
146
 
124
- IMPORTERS=$(grep -rl "$COMPONENT_NAME" "$REPO_ROOT/${FORGE_PAGES_ROOT}/" "$REPO_ROOT/${FORGE_COMPONENTS_DIR}/" 2>/dev/null | grep -v "$f" | head -3)
147
+ # Use -F (fixed string) for both the component name search and the self-exclusion
148
+ # to prevent file paths and component names from being interpreted as regex patterns
149
+ IMPORTERS=$(grep -rlF "$COMPONENT_NAME" "$REPO_ROOT/${FORGE_PAGES_ROOT}/" "$REPO_ROOT/${FORGE_COMPONENTS_DIR}/" 2>/dev/null | grep -vF "$f" | head -3)
125
150
  if [ -n "$IMPORTERS" ]; then
126
151
  echo "OK: Component '$COMPONENT_NAME' ($f) — imported by other files"
127
152
  else
@@ -80,7 +80,9 @@ if [ -n "$NEW_MAPPINGS" ]; then
80
80
 
81
81
  # For each new mapping, verify the SOPS path structure exists
82
82
  # (We can't decrypt SOPS to verify values, but we can check the mapping format is valid)
83
- echo "$NEW_MAPPINGS" | while IFS= read -r mapping; do
83
+ # Use process substitution instead of a pipe so WARNINGS increments persist in the parent shell.
84
+ # (echo ... | while read creates a subshell — counter changes are lost on subshell exit.)
85
+ while IFS= read -r mapping; do
84
86
  VAR_NAME=$(echo "$mapping" | grep -oE '^"[A-Z_]+"' | tr -d '"')
85
87
  SOPS_SECTION=$(echo "$mapping" | grep -oE '\("([a-z_]+)"' | head -1 | tr -d '("')
86
88
  SOPS_KEY=$(echo "$mapping" | grep -oE ', "([a-z_]+)"\)' | tr -d ', ")')
@@ -91,7 +93,7 @@ if [ -n "$NEW_MAPPINGS" ]; then
91
93
  echo "WARNING: Malformed ENV_MAPPING entry: $mapping"
92
94
  WARNINGS=$((WARNINGS + 1))
93
95
  fi
94
- done
96
+ done < <(echo "$NEW_MAPPINGS")
95
97
  else
96
98
  echo "OK: No new ENV_MAPPING entries in diff"
97
99
  fi