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.
- package/README.md +2 -1
- package/bin/engine/admission.mjs +242 -7
- package/bin/engine/admission.test.mjs +183 -2
- package/bin/engine/phases.mjs +31 -22
- package/bin/engine/resolve.mjs +8 -1
- package/bin/engine/resolve.test.mjs +5 -0
- package/bin/engine-cli.mjs +2 -0
- package/bin/engine.mjs +26 -17
- package/bin/forgedock.mjs +124 -48
- package/bin/hooks/pre-tool-use.mjs +573 -2
- package/bin/journey.mjs +11 -7
- package/bin/labels.json +5 -0
- package/bin/opencode-adapter.mjs +652 -92
- package/bin/orchestrate-preflight.mjs +434 -0
- package/bin/runner.mjs +77 -9
- package/commands/audit-agents.md +75 -26
- package/commands/autopilot.md +4 -4
- package/commands/cleanup.md +10 -5
- package/commands/issue.md +95 -35
- package/commands/orchestrate/phase-1-resolve.md +70 -32
- package/commands/orchestrate/phase-3-dependency.md +167 -22
- package/commands/orchestrate/phase-4-execution.md +691 -62
- package/commands/orchestrate/phase-5-cleanup.md +5 -11
- package/commands/orchestrate/phase-6-report.md +27 -1
- package/commands/orchestrate/safety.md +1 -1
- package/commands/orchestrate.md +39 -1
- package/commands/quality-gate.md +30 -10
- package/commands/review-pr-agents/protocols.md +13 -0
- package/commands/review-pr-agents.md +8 -0
- package/commands/review-pr-staging.md +117 -17
- package/commands/review-pr.md +95 -23
- package/commands/work-on/build/validate.md +25 -4
- package/commands/work-on/close.md +40 -10
- package/commands/work-on/decompose.md +33 -1
- package/commands/work-on/investigate.md +33 -0
- package/commands/work-on/remediate.md +40 -5
- package/commands/work-on/review.md +27 -0
- package/commands/work-on.md +48 -5
- package/docs/CONFIG.md +51 -15
- package/docs/OPENCODE.md +128 -25
- package/docs/spec/review-protocol.md +7 -0
- package/package.json +1 -1
- package/scripts/check-admission-parity.mjs +1 -0
- package/scripts/check-command-side-effects.sh +14 -13
- package/scripts/check-shell-portability.sh +36 -0
- package/scripts/check-spec-markers.sh +21 -11
- package/scripts/checkbox-sections.test.sh +67 -0
- package/scripts/extract-affected-files.sh +85 -5
- 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
|
|
860
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
2673
|
-
*
|
|
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",
|