forgedock 1.7.0 → 1.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. package/README.md +2 -1
  2. package/bin/engine/admission.mjs +242 -7
  3. package/bin/engine/admission.test.mjs +183 -2
  4. package/bin/engine/phases.mjs +31 -22
  5. package/bin/engine/resolve.mjs +8 -1
  6. package/bin/engine/resolve.test.mjs +5 -0
  7. package/bin/engine-cli.mjs +2 -0
  8. package/bin/engine.mjs +26 -17
  9. package/bin/forgedock.mjs +124 -48
  10. package/bin/hooks/pre-tool-use.mjs +573 -2
  11. package/bin/journey.mjs +11 -7
  12. package/bin/labels.json +5 -0
  13. package/bin/opencode-adapter.mjs +652 -92
  14. package/bin/orchestrate-preflight.mjs +434 -0
  15. package/bin/runner.mjs +77 -9
  16. package/commands/audit-agents.md +75 -26
  17. package/commands/autopilot.md +4 -4
  18. package/commands/cleanup.md +10 -5
  19. package/commands/issue.md +95 -35
  20. package/commands/orchestrate/phase-1-resolve.md +70 -32
  21. package/commands/orchestrate/phase-3-dependency.md +167 -22
  22. package/commands/orchestrate/phase-4-execution.md +691 -62
  23. package/commands/orchestrate/phase-5-cleanup.md +5 -11
  24. package/commands/orchestrate/phase-6-report.md +27 -1
  25. package/commands/orchestrate/safety.md +1 -1
  26. package/commands/orchestrate.md +39 -1
  27. package/commands/quality-gate.md +30 -10
  28. package/commands/review-pr-agents/protocols.md +13 -0
  29. package/commands/review-pr-agents.md +8 -0
  30. package/commands/review-pr-staging.md +117 -17
  31. package/commands/review-pr.md +95 -23
  32. package/commands/work-on/build/validate.md +25 -4
  33. package/commands/work-on/close.md +40 -10
  34. package/commands/work-on/decompose.md +33 -1
  35. package/commands/work-on/investigate.md +33 -0
  36. package/commands/work-on/remediate.md +40 -5
  37. package/commands/work-on/review.md +27 -0
  38. package/commands/work-on.md +48 -5
  39. package/docs/CONFIG.md +51 -15
  40. package/docs/OPENCODE.md +128 -25
  41. package/docs/spec/review-protocol.md +7 -0
  42. package/package.json +1 -1
  43. package/scripts/check-admission-parity.mjs +1 -0
  44. package/scripts/check-command-side-effects.sh +14 -13
  45. package/scripts/check-shell-portability.sh +36 -0
  46. package/scripts/check-spec-markers.sh +21 -11
  47. package/scripts/checkbox-sections.test.sh +67 -0
  48. package/scripts/extract-affected-files.sh +85 -5
  49. package/scripts/extract-affected-files.test.sh +154 -2
@@ -304,6 +304,148 @@ const FIND_ROOT_TOKEN_RE = /^\/(?:[a-zA-Z]\/?|\.{1,2}\/?|\/)?$/;
304
304
  */
305
305
  const SHELL_METACHAR_SPLIT_RE = /[;&|()$`]+/;
306
306
 
307
+ // ---------------------------------------------------------------------------
308
+ // Rule 8 constants (issue #2856)
309
+ //
310
+ // Rule 7 is intentionally reserved for the mktemp-discipline guard from
311
+ // issue #2686, which exists on milestone/engine-v2-harness but has not yet
312
+ // reached staging (tracked by #2857). That guard is already written, reviewed,
313
+ // and hardened by a follow-up fix on that branch; taking its number here would
314
+ // force #2857 to renumber already-reviewed code during a cross-branch
315
+ // recovery. The gap on staging is deliberate — do not reuse the number.
316
+ //
317
+ // Declared here — above the top-level `await main()` call — for the same
318
+ // temporal-dead-zone reason documented for the Rule 5 constants above: a
319
+ // `const` declared textually after that `await` is still in its TDZ when
320
+ // `main()` first runs, and reading it from `checkRmDangerousTarget()` would
321
+ // throw a ReferenceError straight into main()'s fail-open catch, silently
322
+ // defeating the rule while every test still passes.
323
+ // ---------------------------------------------------------------------------
324
+
325
+ /**
326
+ * Match an `rm`/`rmdir` target that Claude Code's built-in "Dangerous rm
327
+ * operation" gate hard-stops on as being "on critical path" (at most one
328
+ * path segment below the filesystem root) or "on working directory or its
329
+ * ancestor" (`.` / `..`).
330
+ *
331
+ * Branch 1 — root-anchored, at most one segment deep: `/`, `//`, `/probe.txt`,
332
+ * `/c`, `/tmp/`, `/*`. In practice this is overwhelmingly a missing slash —
333
+ * `/probe.txt` written where `/tmp/probe.txt` was meant.
334
+ *
335
+ * Branch 2 — the working directory or its parent: `.`, `..`.
336
+ *
337
+ * BOTH branches carry their optional trailing-slash form, added here in the
338
+ * same commit. Rule 5's analogous regex needed two separate follow-up PRs
339
+ * (#2113, then P1 #2213) precisely because a trailing-slash variant was added
340
+ * to one alternative and not its sibling — `/c/` and then `/./`, `/../` each
341
+ * reached the identical dangerous shape while being wrongly ALLOWED. Any
342
+ * future widening of one branch must be applied to the other in the same
343
+ * change.
344
+ *
345
+ * Deliberately does NOT match anything two or more segments deep:
346
+ * `/tmp/pr_a.txt`, `./build/out.js`, and `/c/Users/.../repo` are ordinary
347
+ * targets and must be allowed.
348
+ */
349
+ const RM_DANGEROUS_TARGET_RE = /^(?:\/[^/]*\/?|\.{1,2}\/?)$/;
350
+
351
+ /**
352
+ * Match an `rm`/`rmdir` target of the form `$VAR/<suffix>` or `${VAR}/<suffix>`
353
+ * — the shape the built-in gate reports as "on possibly-empty variable path".
354
+ * If `VAR` is unset the target expands to a root-anchored path (`$UNSET/*`
355
+ * becomes `/*`), which is the critical-path case with an extra step.
356
+ *
357
+ * Capture group 1 is the variable name, used to check whether it was assigned
358
+ * earlier in the same command string (see `isVariableAssignedInCommand()`).
359
+ *
360
+ * The trailing `/` is load-bearing and is what keeps this branch narrow: a
361
+ * bare `rm -f "$BODY_FILE"` expands to `rm -f ""` when unset, which is
362
+ * harmless, and is the single most common cleanup shape in this pipeline.
363
+ * Only a variable followed by a path separator can synthesize a root path.
364
+ */
365
+ const RM_VARIABLE_ROOT_TARGET_RE = /^\$\{?([A-Za-z_][A-Za-z0-9_]*)\}?\/.+$/;
366
+
367
+ // These path variables are initialized by the shell, runner, or ForgeDock
368
+ // before an agent command runs, so they cannot be the unset-variable shape
369
+ // this branch guards against.
370
+ const RM_EXTERNALLY_ASSIGNED_VARIABLES = new Set([
371
+ "HOME",
372
+ "PWD",
373
+ "OLDPWD",
374
+ "TMPDIR",
375
+ "TMP",
376
+ "TEMP",
377
+ "REPO_PATH",
378
+ "FORGE_HOME",
379
+ "RUNNER_TEMP",
380
+ "GITHUB_WORKSPACE",
381
+ ]);
382
+
383
+ /**
384
+ * Command names this rule anchors on, in command position only.
385
+ */
386
+ const RM_COMMAND_NAMES = new Set(["rm", "rmdir"]);
387
+
388
+ /**
389
+ * Leading tokens that precede the real command name and must be stripped
390
+ * before command-position anchoring: `sudo`, `doas`, and `env`.
391
+ */
392
+ const RM_COMMAND_PREFIXES = new Set(["sudo", "doas", "env"]);
393
+
394
+ /**
395
+ * `sudo` short flags that consume the following token as their value, so the
396
+ * value is not mistaken for the command name (`sudo -u root rm ...`).
397
+ */
398
+ const SUDO_VALUE_FLAGS = new Set(["-u", "-g", "-p", "-C", "-h", "-r", "-t"]);
399
+
400
+ /**
401
+ * A leading `NAME=value` environment assignment in command position.
402
+ */
403
+ const ENV_ASSIGNMENT_TOKEN_RE = /^[A-Za-z_][A-Za-z0-9_]*=/;
404
+
405
+ /**
406
+ * Shell characters that separate one command from the next. Unlike
407
+ * `SHELL_METACHAR_SPLIT_RE` this deliberately EXCLUDES `$`, because Rule 8
408
+ * has to read `$SOMEDIR/*` as an intact target token — splitting on `$` would
409
+ * destroy the variable branch entirely. A trailing `$` left over from a
410
+ * command-substitution opener (`$(`) is stripped separately by
411
+ * `extractCommandSegments()`.
412
+ */
413
+ const RM_SEGMENT_SPLIT_RE = /[;&|`]+|\$\(/;
414
+
415
+ /**
416
+ * Match a heredoc redirection and capture its delimiter, so heredoc BODIES can
417
+ * be excluded from command-position scanning. Handles `<<EOF`, `<<-EOF`,
418
+ * `<<'EOF'`, and `<<"EOF"`.
419
+ *
420
+ * This matters more for this rule than it did for Rule 5: `rm` appears in far
421
+ * more prose than `find` does — issue bodies, PR comments, and the
422
+ * documentation of this very rule are routinely piped to `gh` through a
423
+ * heredoc. Scanning a heredoc body would deny the pipeline's own reporting.
424
+ * The command line AFTER the heredoc terminator is still scanned, which is
425
+ * exactly the stall shape reported in #2843.
426
+ */
427
+ const HEREDOC_OPEN_RE = /(<<-?)\s*(?:'([^']+)'|"([^"]+)"|([A-Za-z_][A-Za-z0-9_]*))/;
428
+
429
+ // ---------------------------------------------------------------------------
430
+ // Rule 7 constants (issue #2686) — content-staging temp-path (mktemp) guard.
431
+ // ---------------------------------------------------------------------------
432
+
433
+ const CONTENT_STAGING_FLAGS = new Set(["--body-file", "--notes-file"]);
434
+ const CONTENT_STAGING_SHORT_FLAG = "-F";
435
+ const COLLISION_SAFE_MARKERS = [
436
+ /mktemp/i,
437
+ /\$\$/,
438
+ /\$\{?RANDOM\b/,
439
+ /X{3,}/,
440
+ /\$\(\s*date\b/i,
441
+ /%date%/i,
442
+ ];
443
+ const SAFE_TEMP_ROOT_RE =
444
+ /(^|[/\\])\.forgedock[/\\]tmp(?:[/\\]|$)|[/\\][0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}[/\\]scratchpad(?:[/\\]|$)/i;
445
+ const SYSTEM_TMP_POSIX_RE = /^\/tmp\//;
446
+ const SYSTEM_TMP_ENV_RE = /^\$\{?TMPDIR\b[^/\\]*[/\\]/i;
447
+ const SYSTEM_TMP_WIN_RE = /^%(?:TEMP|TMP)%[/\\]/i;
448
+
307
449
  // ---------------------------------------------------------------------------
308
450
  // Attribution guard constants (Rule 6)
309
451
  // Declared here — above the top-level `await main()` call — so they are
@@ -393,6 +535,29 @@ async function main() {
393
535
  return;
394
536
  }
395
537
 
538
+ // --- Rule 7: content-staging temp-path (mktemp) guard ---
539
+ const tempStagingViolation = checkTempPathStaging(command);
540
+ if (tempStagingViolation) {
541
+ process.stderr.write(tempStagingViolation);
542
+ process.exit(2);
543
+ return;
544
+ }
545
+
546
+ // --- Rule 8: Dangerous `rm` target guard ---
547
+ // Deliberately checked BEFORE the isForgeDockManagedCwd() gate below, for
548
+ // the same reason as Rule 5 — and more acutely. Agents stage temp files
549
+ // from git worktrees and scratch directories that carry no
550
+ // forge.yaml/.forgedock marker, and those are exactly the contexts where
551
+ // the built-in "Dangerous rm operation" gate stalls an unattended run. A
552
+ // dispatch placed after the gate would guard nothing where it matters
553
+ // (issue #2856).
554
+ const rmTargetViolation = checkRmDangerousTarget(command);
555
+ if (rmTargetViolation) {
556
+ process.stderr.write(rmTargetViolation);
557
+ process.exit(2);
558
+ return;
559
+ }
560
+
396
561
  // Only enforce Rules 1-4 and 6 inside a ForgeDock-managed project — this hook
397
562
  // installs globally (~/.claude/settings.json), so without this guard those
398
563
  // rules would fire in every unrelated repo on the machine (issue #1591).
@@ -853,11 +1018,36 @@ function checkGistVisibility(command) {
853
1018
  * @param {string} command
854
1019
  * @returns {string[]} Flattened list of logical token strings, in order.
855
1020
  */
1021
+ /**
1022
+ * The embedded-whitespace discriminator shared by `extractLogicalTokens()`
1023
+ * (Rule 5) and `extractCommandSegments()` (Rule 8).
1024
+ *
1025
+ * A token counts as INERT argument text — text that must be passed through
1026
+ * unsplit, because its quoting proves it was meant as one opaque argument
1027
+ * rather than as shell syntax — only when the quoting actually spans embedded
1028
+ * whitespace. `quoted` alone is NOT sufficient: `tokenizeCommand()` sets it
1029
+ * the moment ANY quote character appears, including a degenerate empty pair
1030
+ * (`""`, `''`) glued onto otherwise-unquoted text, which is how `cd /tmp;""find /`
1031
+ * bypassed Rule 5 (issue #2059).
1032
+ *
1033
+ * Deliberately defined ONCE and called from both extractors. Two
1034
+ * independently-derived copies of this predicate is the exact failure mode of
1035
+ * issue #2213 (P1), where a regex alternation was widened on one branch and
1036
+ * not on its sibling, leaving a live bypass behind an apparently-fixed guard.
1037
+ *
1038
+ * @param {{value: string, quoted: boolean}} token
1039
+ * @returns {boolean}
1040
+ */
1041
+ function isInertQuotedToken({ value, quoted }) {
1042
+ return Boolean(quoted) && /\s/.test(value);
1043
+ }
1044
+
856
1045
  function extractLogicalTokens(command) {
857
1046
  const raw = tokenizeCommand(command);
858
1047
  const logical = [];
859
- for (const { value, quoted } of raw) {
860
- if (quoted && /\s/.test(value)) {
1048
+ for (const token of raw) {
1049
+ const { value } = token;
1050
+ if (isInertQuotedToken(token)) {
861
1051
  logical.push(value);
862
1052
  continue;
863
1053
  }
@@ -956,6 +1146,387 @@ function checkFindRoot(command) {
956
1146
  return null;
957
1147
  }
958
1148
 
1149
+ // ---------------------------------------------------------------------------
1150
+ // Rule 7: Content-staging temp-path (mktemp) guard (issue #2686)
1151
+ // ---------------------------------------------------------------------------
1152
+
1153
+ function isFixedSystemTempPath(value) {
1154
+ if (!value) return false;
1155
+ const v = String(value);
1156
+ if (COLLISION_SAFE_MARKERS.some((re) => re.test(v))) return false;
1157
+ if (SAFE_TEMP_ROOT_RE.test(v)) return false;
1158
+ return SYSTEM_TMP_POSIX_RE.test(v) || SYSTEM_TMP_ENV_RE.test(v) || SYSTEM_TMP_WIN_RE.test(v);
1159
+ }
1160
+
1161
+ function tempStagingMessage(path, where) {
1162
+ return [
1163
+ `[ForgeDock] BLOCKED: fixed temp path "${path}" staged for a gh body (${where}).`,
1164
+ ``,
1165
+ `Concurrent orchestrated agents share this host's system temp dir. A fixed`,
1166
+ `literal path like this is clobbered mid-flight when a second agent writes`,
1167
+ `the same path — the wrong body is then posted to the PR/issue (the #2672`,
1168
+ `cross-agent content collision; the prose mktemp mandate from forge#2198`,
1169
+ `degraded, so this rule makes it deterministic).`,
1170
+ ``,
1171
+ `Fix: stage the body to a mktemp-created path instead, e.g.:`,
1172
+ ` BODY=$(mktemp); printf '%s' "$content" > "$BODY"; gh ... --body-file "$BODY"`,
1173
+ ``,
1174
+ `Exception: set FORGE_ALLOW_FIXED_TMP=1 in your shell environment BEFORE`,
1175
+ `starting Claude Code if you intentionally need a fixed temp path.`,
1176
+ ].join("\n");
1177
+ }
1178
+
1179
+ function checkTempPathStaging(command) {
1180
+ if (!command) return null;
1181
+ if (process.env.FORGE_ALLOW_FIXED_TMP === "1") return null;
1182
+
1183
+ const bare = command.replace(/["'\\]/g, "");
1184
+ if (!/\/tmp\/|\$\{?TMPDIR|%TE?MP%/i.test(bare)) return null;
1185
+
1186
+ const tokens = tokenizeCommand(command);
1187
+ for (let i = 0; i < tokens.length; i++) {
1188
+ const { value: tok } = tokens[i];
1189
+ let val = null;
1190
+ let where = tok;
1191
+ if (CONTENT_STAGING_FLAGS.has(tok) || tok === CONTENT_STAGING_SHORT_FLAG) {
1192
+ val = i + 1 < tokens.length ? tokens[i + 1].value : null;
1193
+ } else if (!/\s/.test(tok)) {
1194
+ for (const flag of CONTENT_STAGING_FLAGS) {
1195
+ const eq = `${flag}=`;
1196
+ if (tok.startsWith(eq)) { val = tok.slice(eq.length); where = flag; break; }
1197
+ }
1198
+ if (val === null && tok.startsWith(CONTENT_STAGING_SHORT_FLAG) && tok.length > CONTENT_STAGING_SHORT_FLAG.length) {
1199
+ val = tok.slice(CONTENT_STAGING_SHORT_FLAG.length);
1200
+ where = CONTENT_STAGING_SHORT_FLAG;
1201
+ }
1202
+ }
1203
+ if (val && isFixedSystemTempPath(val)) return tempStagingMessage(val, where);
1204
+ }
1205
+
1206
+ const invokesGh = tokens.some(({ value }) => value === "gh");
1207
+ if (invokesGh) {
1208
+ for (let i = 0; i < tokens.length; i++) {
1209
+ const { value: tok, quoted } = tokens[i];
1210
+ if (quoted && /\s/.test(tok)) continue;
1211
+ let target = null;
1212
+ if (tok === ">" || tok === ">>") {
1213
+ target = i + 1 < tokens.length ? tokens[i + 1].value : null;
1214
+ } else if (/^>>?[^>&]/.test(tok)) {
1215
+ target = tok.replace(/^>>?/, "");
1216
+ }
1217
+ if (target && isFixedSystemTempPath(target)) return tempStagingMessage(target, "redirect");
1218
+ }
1219
+ }
1220
+
1221
+ return null;
1222
+ }
1223
+
1224
+ // ---------------------------------------------------------------------------
1225
+ // Rule 8: Dangerous `rm` target guard (issue #2856)
1226
+ //
1227
+ // See the Rule 8 constants block above for why this is Rule 8 and not Rule 7.
1228
+ // ---------------------------------------------------------------------------
1229
+
1230
+ /**
1231
+ * Strip heredoc BODIES from a command, returning only the lines that are
1232
+ * really command text.
1233
+ *
1234
+ * A heredoc body is data, not shell syntax — the pipeline routinely pipes
1235
+ * issue bodies, PR comments, and review findings to `gh` through one. Because
1236
+ * `rm` appears in prose far more often than `find` ever did, scanning those
1237
+ * bodies would deny the pipeline's own reporting, including any comment that
1238
+ * documents this rule. The line AFTER the heredoc terminator is retained,
1239
+ * which is precisely the stall shape reported in #2843: a `gh` comment
1240
+ * followed by a cleanup `rm` of a root-level path.
1241
+ *
1242
+ * @param {string} command
1243
+ * @returns {string[]} Command lines with heredoc bodies and terminators removed.
1244
+ */
1245
+ function stripHeredocBodies(command) {
1246
+ const lines = command.split(/\r?\n/);
1247
+ const kept = [];
1248
+ let heredoc = null;
1249
+
1250
+ for (const line of lines) {
1251
+ if (heredoc !== null) {
1252
+ // Bash requires terminators at column 0 for <<, or after tabs only for <<-.
1253
+ const terminator = heredoc.stripTabs ? line.replace(/^\t*/, "") : line;
1254
+ if (terminator === heredoc.delimiter) heredoc = null;
1255
+ continue;
1256
+ }
1257
+ kept.push(line);
1258
+ const opened = HEREDOC_OPEN_RE.exec(line);
1259
+ if (opened) {
1260
+ heredoc = {
1261
+ delimiter: opened[2] || opened[3] || opened[4],
1262
+ stripTabs: opened[1] === "<<-",
1263
+ };
1264
+ }
1265
+ }
1266
+
1267
+ return kept;
1268
+ }
1269
+
1270
+ /**
1271
+ * Split a command into per-segment token arrays, preserving both the segment
1272
+ * boundaries and any `$` inside tokens.
1273
+ *
1274
+ * This is a deliberate sibling of `extractLogicalTokens()` rather than a reuse
1275
+ * of it, because that function is wrong for this rule in two independent ways:
1276
+ *
1277
+ * 1. It splits on `$` (via `SHELL_METACHAR_SPLIT_RE`), so `$SOMEDIR/*` would
1278
+ * arrive as `SOMEDIR/*` and the possibly-empty-variable branch could never
1279
+ * see that a variable was involved at all. Rule 5 never cared, because
1280
+ * `find` roots are literal paths.
1281
+ * 2. It flattens the entire command into one token stream, discarding the
1282
+ * segment boundaries that command-position anchoring depends on. Without
1283
+ * them there is no way to tell `rm -f /x` (first token of a segment) from
1284
+ * `git rm --cached x` or `docker rm -f c` (second token).
1285
+ *
1286
+ * Segmentation is done at three levels so a command name glued to a separator
1287
+ * with no whitespace is still recovered (the metacharacter-adjacency bypass of
1288
+ * issue #2034, review findings SEC-1/SEC-2): newlines, then `;`/`&`/`|`/
1289
+ * backticks, and command-substitution openers inside unquoted tokens. Bare
1290
+ * parentheses are valid path characters, so they must remain part of an
1291
+ * operand rather than creating a bogus command segment.
1292
+ *
1293
+ * Inert quoted tokens (see `isInertQuotedToken()`) are passed through unsplit
1294
+ * and kept in place, so `rm -f "/tmp/quoted path.txt"` still resolves a target
1295
+ * while `--search "dangerous rm / permission prompt"` stays a single opaque
1296
+ * argument that can never be read as a command.
1297
+ *
1298
+ * @param {string} command
1299
+ * @returns {string[][]} One token array per shell segment, in order.
1300
+ */
1301
+ function extractCommandSegments(command) {
1302
+ const segments = [];
1303
+ let current = [];
1304
+
1305
+ const flush = () => {
1306
+ if (current.length > 0) segments.push(current);
1307
+ current = [];
1308
+ };
1309
+
1310
+ for (const line of stripHeredocBodies(command)) {
1311
+ // Each physical line starts a new command segment.
1312
+ flush();
1313
+
1314
+ for (const token of tokenizeCommand(line)) {
1315
+ if (isInertQuotedToken(token)) {
1316
+ current.push(token.value);
1317
+ continue;
1318
+ }
1319
+
1320
+ const pieces = token.value.split(RM_SEGMENT_SPLIT_RE);
1321
+ for (let i = 0; i < pieces.length; i++) {
1322
+ // Anything after the first piece followed a separator, so it begins a
1323
+ // new segment (`cd /foo&&rm -f /bar.txt`, `$(rm -rf /)`).
1324
+ if (i > 0) flush();
1325
+ // A trailing `$` is the remains of a command-substitution opener
1326
+ // (`$(rm ...` splits to `$` + `rm`). Strip it so the command name is
1327
+ // recovered in command position. A LEADING `$` is preserved — that is
1328
+ // the variable branch's whole signal.
1329
+ const piece = pieces[i].replace(/\$+$/, "");
1330
+ if (piece.length > 0) current.push(piece);
1331
+ }
1332
+ }
1333
+ }
1334
+
1335
+ flush();
1336
+ return segments;
1337
+ }
1338
+
1339
+ /**
1340
+ * Whether `name` is assigned somewhere in the command string, as a shell
1341
+ * variable assignment in command or prefix position (`NAME=`, `export NAME=`).
1342
+ *
1343
+ * Used to keep the possibly-empty-variable branch narrow. `rm -rf $SOMEDIR/*`
1344
+ * with `SOMEDIR` never assigned expands to a root-level wipe; the same shape
1345
+ * with `D=/tmp/x` earlier in the command is ordinary, correct cleanup and must
1346
+ * be allowed. When in doubt this rule ALLOWS: a missed deny surfaces as a
1347
+ * visible approval prompt the operator can act on, whereas a false deny breaks
1348
+ * the agent's own cleanup silently.
1349
+ *
1350
+ * @param {string} command
1351
+ * @param {string} name
1352
+ * @returns {boolean}
1353
+ */
1354
+ function isVariableAssignedInCommand(command, name) {
1355
+ return RM_EXTERNALLY_ASSIGNED_VARIABLES.has(name)
1356
+ || new RegExp(`(?:^|[\\s;&|(])${name}=`).test(command);
1357
+ }
1358
+
1359
+ /**
1360
+ * Resolve the `rm`/`rmdir` operands in a single command segment, or null if
1361
+ * the segment is not an `rm` invocation in COMMAND POSITION.
1362
+ *
1363
+ * Command-position anchoring is mandatory, not an optimization. Matching `rm`
1364
+ * anywhere in the command is what produced the reporter's own false positive —
1365
+ * a legitimate `gh issue list --search "dangerous rm / permission prompt"` was
1366
+ * denied because a token after `rm` happened to be `/`. It is also the only
1367
+ * thing separating this rule from `git rm --cached` and `docker rm -f`.
1368
+ *
1369
+ * Leading environment assignments (`TMPDIR=/x rm ...`) and privilege/prefix
1370
+ * wrappers (`sudo`, `doas`, `env`) are stripped first, including `sudo` flags
1371
+ * and the values of the `sudo` short flags that take one.
1372
+ *
1373
+ * Flag handling covers space-separated, clustered, and long forms uniformly
1374
+ * (`-f`, `-rf`, `-r -f`, `--force`), and honours the `--` end-of-options
1375
+ * marker. Assuming flags are space-separated is what required the follow-up
1376
+ * fix `0109b53f` on #2686's detector.
1377
+ *
1378
+ * ALL positional operands are returned, not just the first: every operand of
1379
+ * `rm` is a deletion target, so `rm -f /tmp/a.txt /probe.txt` must be caught
1380
+ * on its second operand.
1381
+ *
1382
+ * @param {string[]} segment
1383
+ * @returns {string[]|null} Positional targets, or null if not an `rm` command.
1384
+ */
1385
+ function resolveRmTargets(segment) {
1386
+ let i = 0;
1387
+
1388
+ // Strip leading `NAME=value` assignments and prefix wrappers, in any order
1389
+ // (`TMPDIR=/x sudo rm ...`, `sudo TMPDIR=/x rm ...`).
1390
+ for (;;) {
1391
+ const token = segment[i];
1392
+ if (token === undefined) return null;
1393
+
1394
+ if (ENV_ASSIGNMENT_TOKEN_RE.test(token)) { i++; continue; }
1395
+
1396
+ if (RM_COMMAND_PREFIXES.has(token.toLowerCase())) {
1397
+ i++;
1398
+ // Consume the wrapper's own flags, and the value of any short flag that
1399
+ // takes one, so `sudo -u root rm ...` still anchors on `rm`.
1400
+ while (segment[i] !== undefined && segment[i].startsWith("-")) {
1401
+ const flag = segment[i];
1402
+ i++;
1403
+ if (SUDO_VALUE_FLAGS.has(flag) && segment[i] !== undefined) i++;
1404
+ }
1405
+ continue;
1406
+ }
1407
+
1408
+ break;
1409
+ }
1410
+
1411
+ // Command-position anchor. Case-insensitive on the command NAME only
1412
+ // (Windows resolves `RM` to the same binary), never on the path operands —
1413
+ // POSIX paths are case-sensitive. This mirrors Rule 5's split exactly.
1414
+ const name = segment[i];
1415
+ if (name === undefined || !RM_COMMAND_NAMES.has(name.toLowerCase())) return null;
1416
+ i++;
1417
+
1418
+ const targets = [];
1419
+ let optionsEnded = false;
1420
+ for (; i < segment.length; i++) {
1421
+ const token = segment[i];
1422
+ if (!optionsEnded) {
1423
+ if (token === "--") { optionsEnded = true; continue; }
1424
+ // Covers `-f`, `-rf` (clustered), and `--force` (long) uniformly.
1425
+ if (token.startsWith("-") && token.length > 1) continue;
1426
+ }
1427
+ targets.push(token);
1428
+ }
1429
+
1430
+ return targets;
1431
+ }
1432
+
1433
+ /**
1434
+ * Check whether a Bash command deletes a target whose SHAPE trips Claude
1435
+ * Code's built-in "Dangerous rm operation" gate.
1436
+ *
1437
+ * That gate sits ABOVE the permission layer: a `Bash(rm:*)` allow rule,
1438
+ * `--dangerously-skip-permissions`, and a hook returning
1439
+ * `permissionDecision: "allow"` all fail to clear it. A hook DENY does clear
1440
+ * it, because the agent never reaches the built-in check. That asymmetry is
1441
+ * the entire reason this rule exists. In an unattended run — headless, cron,
1442
+ * or an orchestrated sub-agent — the gate renders a prompt nobody can answer
1443
+ * and the run hangs indefinitely rather than failing. Denying early is
1444
+ * strictly better: identical outcome for a genuinely dangerous command, but a
1445
+ * recoverable, self-describing error instead of a silent overnight stall.
1446
+ *
1447
+ * In practice the trigger is almost never a real attempt to delete the root —
1448
+ * it is a missing slash, `/probe.txt` written where `/tmp/probe.txt` was
1449
+ * meant. Guidance-only mitigations were attempted twice (#2198, then #2843)
1450
+ * and both failed, because guidance cannot bind a probabilistic
1451
+ * path-construction slip. Hence a mechanical guard (#2856).
1452
+ *
1453
+ * Like Rule 5, this rule is checked BEFORE the `isForgeDockManagedCwd()` gate
1454
+ * and has NO operator override.
1455
+ *
1456
+ * @param {string} command
1457
+ * @returns {string|null} Error message to show, or null if allowed.
1458
+ */
1459
+ function checkRmDangerousTarget(command) {
1460
+ if (!command) return null;
1461
+
1462
+ // Cheap pre-filter, mirroring checkFindRoot(): skip tokenization entirely
1463
+ // for the overwhelming majority of commands that mention no deletion at
1464
+ // all. Quotes and backslashes are stripped first so an empty quote pair
1465
+ // (`"r"m`) or a backslash-escape (`r\m` — real bash for the plain word)
1466
+ // cannot cause a false "nothing here" short-circuit (issue #2059).
1467
+ if (!/rm/i.test(command.replace(/["'\\]/g, ""))) return null;
1468
+
1469
+ for (const segment of extractCommandSegments(command)) {
1470
+ const targets = resolveRmTargets(segment);
1471
+ if (!targets) continue;
1472
+
1473
+ for (const target of targets) {
1474
+ const variable = RM_VARIABLE_ROOT_TARGET_RE.exec(target);
1475
+ if (variable && !isVariableAssignedInCommand(command, variable[1])) {
1476
+ return rmDenyMessage(
1477
+ target,
1478
+ `"$${variable[1]}" is never assigned in this command, so if it is unset the`,
1479
+ `target expands to a root-level path and deletes the wrong tree.`,
1480
+ );
1481
+ }
1482
+
1483
+ if (RM_DANGEROUS_TARGET_RE.test(target)) {
1484
+ return rmDenyMessage(
1485
+ target,
1486
+ `"${target}" is at most one path segment below the filesystem root (or is`,
1487
+ `the working directory / its parent).`,
1488
+ );
1489
+ }
1490
+ }
1491
+ }
1492
+
1493
+ return null;
1494
+ }
1495
+
1496
+ /**
1497
+ * Build the Rule 8 deny message.
1498
+ *
1499
+ * The message is part of the rule's contract, not prose. Its whole value is
1500
+ * that an unattended run self-heals in one turn instead of stalling, so it
1501
+ * MUST name the concrete rewrite (`mktemp` / a `/tmp/<name>` path) and MUST
1502
+ * tell the agent not to escalate to a human — there is no human to escalate to
1503
+ * in the runs this rule protects. A message that only refuses reproduces the
1504
+ * original stall with extra steps.
1505
+ *
1506
+ * @param {string} target
1507
+ * @param {...string} reason Lines explaining why this specific target matched.
1508
+ * @returns {string}
1509
+ */
1510
+ function rmDenyMessage(target, ...reason) {
1511
+ return [
1512
+ `[ForgeDock] BLOCKED: dangerous \`rm\` target "${target}".`,
1513
+ ``,
1514
+ ...reason,
1515
+ ``,
1516
+ `Claude Code enforces this shape above the permission layer, so no allow`,
1517
+ `rule or bypass flag clears it — it renders an approval prompt that an`,
1518
+ `unattended run can never answer, and the run hangs indefinitely. This`,
1519
+ `hook denies it instead, so you get a recoverable error (issue #2856).`,
1520
+ ``,
1521
+ `Fix: put the file at least two segments deep. If this was meant to be a`,
1522
+ `temp file, the path is almost certainly missing a slash — use "/tmp/<name>"`,
1523
+ `(or better, a path created by mktemp) instead of "${target}".`,
1524
+ `Then re-run the command. Do not ask the user to approve this.`,
1525
+ ``,
1526
+ `No override exists for this rule.`,
1527
+ ].join("\n");
1528
+ }
1529
+
959
1530
  // ---------------------------------------------------------------------------
960
1531
  // Rule 6: Attribution guard
961
1532
  // ---------------------------------------------------------------------------
package/bin/journey.mjs CHANGED
@@ -2313,7 +2313,14 @@ export async function forge(ctx) {
2313
2313
  const stats = await lstat(target);
2314
2314
  if (stats.isSymbolicLink()) {
2315
2315
  const current = await readlink(target);
2316
- if (current === file) {
2316
+ // Target-string equality is not proof of link health: readlink() returns
2317
+ // the bytes stored at creation time and carries no readability guarantee.
2318
+ // An MSYS-provenance link on Windows stores the byte-identical correct
2319
+ // target while a native Windows process cannot traverse it (forge#2620).
2320
+ // Without the traversability probe those dead links land in skipped++ and
2321
+ // the repair branch below — which handles exactly this case — is
2322
+ // unreachable for every already-installed link (forge#2836).
2323
+ if (current === file && await isSymlinkTraversable(target)) {
2317
2324
  skipped++;
2318
2325
  } else {
2319
2326
  let relinked = false;
@@ -2666,14 +2673,11 @@ function readForgedockVersion(forgeHome) {
2666
2673
 
2667
2674
  /**
2668
2675
  * Write a machine-readable install-receipt.json to {ctx.home}/.forge/ after a
2669
- * successful install (runJourney) or update (bin/forgedock.mjs's
2676
+ * successful install (runJourney) or refresh (bin/forgedock.mjs's
2670
2677
  * relinkAndHint — shared by both update() branches: the git-clone
2671
2678
  * fast-forward path and the npm version-check path). Note: re-running
2672
- * `npx forgedock install` on an already-managed-active repo takes the
2673
- * statusScreen() short-circuit instead of runJourney()/relinkAndHint() — that
2674
- * path does not refresh the receipt (it also does not touch forge() or
2675
- * anything else, so this is consistent with the rest of that short-circuit's
2676
- * no-op behavior, not a gap specific to this feature). See docs/CONFIG.md
2679
+ * `npx forgedock install` on an already-managed-active repo uses
2680
+ * relinkAndHint() rather than re-running the configuration journey. See docs/CONFIG.md
2677
2681
  * "Install Receipt" for the schema.
2678
2682
  *
2679
2683
  * Deliberately narrow field set — no PII/secrets: no process.env values, no
package/bin/labels.json CHANGED
@@ -79,6 +79,11 @@
79
79
  "color": "D93F0B",
80
80
  "description": "Defect or improvement found during automated PR review. Managed by ForgeDock."
81
81
  },
82
+ {
83
+ "name": "review-degraded",
84
+ "color": "E4E669",
85
+ "description": "PR review panel was incomplete; re-review required before deployment. Managed by ForgeDock."
86
+ },
82
87
  {
83
88
  "name": "needs-validation",
84
89
  "color": "FBCA04",