forgedock 1.8.3 → 1.9.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 (38) hide show
  1. package/bin/engine/admission.mjs +23 -4
  2. package/bin/engine/admission.test.mjs +37 -0
  3. package/commands/orchestrate/phase-1-resolve.md +40 -24
  4. package/commands/orchestrate/phase-3-dependency.md +56 -11
  5. package/commands/orchestrate/phase-4-execution.md +351 -111
  6. package/commands/orchestrate/phase-5-cleanup.md +17 -0
  7. package/commands/orchestrate/phase-6-report.md +1 -1
  8. package/commands/quality-gate.md +51 -0
  9. package/commands/recover-orphans.md +3 -3
  10. package/commands/review-pr.md +92 -19
  11. package/commands/work-on/build/context.md +25 -4
  12. package/commands/work-on/build/validate.md +1 -0
  13. package/commands/work-on/build.md +29 -9
  14. package/commands/work-on/close.md +107 -40
  15. package/commands/work-on/decompose.md +16 -7
  16. package/commands/work-on/investigate.md +16 -7
  17. package/commands/work-on/remediate.md +18 -4
  18. package/commands/work-on/review.md +51 -19
  19. package/commands/work-on.md +68 -9
  20. package/docs/CODEX.md +12 -0
  21. package/docs/CONFIG.md +49 -1
  22. package/docs/RELEASE-NOTES.md +52 -0
  23. package/package.json +1 -1
  24. package/scripts/amplification-breaker.sh +89 -0
  25. package/scripts/amplification-breaker.test.sh +39 -0
  26. package/scripts/check-admission-parity.mjs +74 -1
  27. package/scripts/check-spec-markers.sh +67 -3
  28. package/scripts/classify-finding.sh +120 -0
  29. package/scripts/classify-finding.test.sh +74 -0
  30. package/scripts/close-dossier-no-main-commit.test.sh +139 -0
  31. package/scripts/forge-root.test.sh +84 -27
  32. package/scripts/lint-build-validate-files.sh +29 -0
  33. package/scripts/lint-build-validate-files.test.sh +25 -0
  34. package/scripts/lint-dispatch-prompt.test.sh +2 -1
  35. package/scripts/recovery-claim-live.sh +73 -0
  36. package/scripts/recovery-claim-live.test.sh +66 -0
  37. package/scripts/verify-phase-trail.sh +183 -4
  38. package/scripts/verify-phase-trail.test.sh +211 -1
@@ -0,0 +1,29 @@
1
+ #!/usr/bin/env bash
2
+ # SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
3
+ # SPDX-License-Identifier: AGPL-3.0-or-later
4
+ # Lint: every work-on:build:validate Skill( invocation in build.md must carry --files,
5
+ # and the B6.5 repair section must contain an explicit one (forge#3254).
6
+ # Usage: bash scripts/lint-build-validate-files.sh [path/to/build.md]
7
+ set -uo pipefail
8
+ HERE="$(cd "$(dirname "$0")" && pwd)"
9
+ SPEC="${1:-$HERE/../commands/work-on/build.md}"
10
+ [ -f "$SPEC" ] || { echo "lint-build-validate-files: spec not found: $SPEC" >&2; exit 2; }
11
+ rc=0
12
+ calls=$(grep -n 'Skill(skill=.*work-on:build:validate' "$SPEC" || true)
13
+ if [ -z "$calls" ]; then
14
+ echo "FAIL: no work-on:build:validate Skill( invocation found in $SPEC" >&2
15
+ exit 1
16
+ fi
17
+ bad=$(printf '%s\n' "$calls" | grep -vF -- '--files' || true)
18
+ if [ -n "$bad" ]; then
19
+ printf '%s\n' "$bad" | while IFS= read -r l; do
20
+ echo "FAIL: $SPEC:${l%%:*}: validate invocation lacks --files" >&2
21
+ done
22
+ rc=1
23
+ fi
24
+ if ! awk '/Phase B6\.5/{f=1} f' "$SPEC" | grep 'Skill(skill=.*work-on:build:validate' | grep -qF -- '--files'; then
25
+ echo "FAIL: $SPEC: B6.5 repair section has no explicit validate invocation carrying --files" >&2
26
+ rc=1
27
+ fi
28
+ [ "$rc" -eq 0 ] && echo "OK: all validate invocations carry --files"
29
+ exit "$rc"
@@ -0,0 +1,25 @@
1
+ #!/usr/bin/env bash
2
+ # SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
3
+ # SPDX-License-Identifier: AGPL-3.0-or-later
4
+ # Fixtures for lint-build-validate-files.sh (forge#3254). Run: bash scripts/lint-build-validate-files.test.sh
5
+ set -uo pipefail
6
+ HERE="$(cd "$(dirname "$0")" && pwd)"
7
+ LINT="$HERE/lint-build-validate-files.sh"
8
+ T="$(mktemp -d)"; trap 'rm -rf "$T"' EXIT
9
+ PASS=0; FAILN=0
10
+ check() { # name want_rc file
11
+ bash "$LINT" "$3" >"$T/out" 2>&1; rc=$?
12
+ if [ "$rc" -eq "$2" ]; then PASS=$((PASS+1)); else FAILN=$((FAILN+1)); echo "FAIL: $1 (rc=$rc want $2)"; cat "$T/out"; fi
13
+ }
14
+ V='Skill(skill="{FORGE_SKILL_PREFIX}work-on:build:validate", args="{NUMBER} --worktree {W}'
15
+ printf '## Phase B6\n%s --files \\"{CHANGED_FILES}\\"")\n## Phase B6.5\n%s --files \\"{CHANGED_FILES}\\"")\n' "$V" "$V" > "$T/ok.md"
16
+ check "all carry --files" 0 "$T/ok.md"
17
+ printf '## Phase B6\n%s --files \\"x\\"")\n## Phase B6.5\n%s")\n' "$V" "$V" > "$T/bad1.md"
18
+ check "one invocation lacks --files" 1 "$T/bad1.md"
19
+ printf '## Phase B6\n%s --files \\"x\\"")\n## Phase B6.5\nfollowed by work-on:build:validate with the same args\n' "$V" > "$T/bad2.md"
20
+ check "B6.5 has no explicit invocation" 1 "$T/bad2.md"
21
+ printf 'nothing here\n' > "$T/bad3.md"
22
+ check "no invocations at all" 1 "$T/bad3.md"
23
+ check "real build.md" 0 "$HERE/../commands/work-on/build.md"
24
+ echo "passed=$PASS failed=$FAILN"
25
+ [ "$FAILN" -eq 0 ]
@@ -229,7 +229,8 @@ check "mid-line CR not stripped" 1 "$T/p25"
229
229
  # 10. Spec snippet enforcement (forge#3070): extract the lint gate from the spec and run it in a loop.
230
230
  # Gate = from the bootstrap marker to the end of its fenced code block (not a hard-coded count of
231
231
  # top-level fi lines, which breaks whenever the bootstrap gains or loses a conditional).
232
- awk '/^# FORGE_ROOT bootstrap/{f=1} f&&/^```/{exit} f{print}' "$SPEC" > "$T/gate"
232
+ # Several spec blocks carry the bootstrap marker (e.g. the recovery-claim gate, forge#3172); pick the one holding the lint gate.
233
+ awk '/^# FORGE_ROOT bootstrap/{f=1;buf=""} f&&/^```/{if(buf ~ /LINT_REFUSED_ISSUES/){printf "%s",buf;exit} f=0} f{buf=buf $0 "\n"}' "$SPEC" > "$T/gate"
233
234
  [ -s "$T/gate" ] || { FAILN=$((FAILN+1)); echo "FAIL: could not extract spec lint gate"; }
234
235
  gate_refused() { # prompt-file home -> prints refusal entries or LAUNCHED
235
236
  ( LINT_REFUSED_ISSUES=(); RENDERED_PROMPT="$(cat "$1")"; FORGEDOCK_HOME="$2"; unset FORGE_HOME; reset_vals
@@ -0,0 +1,73 @@
1
+ #!/usr/bin/env bash
2
+ # SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
3
+ # SPDX-License-Identifier: AGPL-3.0-or-later
4
+ #
5
+ # recovery-claim-live.sh — is an issue currently claimed by a /recover-orphans sweep?
6
+ #
7
+ # Single source of truth for the dispatcher-side check of FORGE:RECOVERY_CLAIM, used by
8
+ # /work-on Phase 0B and /orchestrate Phase 4 so a dispatcher never re-enters an issue a sweep is
9
+ # resuming. Same semantics as `claim_orphan` in commands/recover-orphans.md: a claim is LIVE when it
10
+ # is an unreleased `<!-- FORGE:RECOVERY_CLAIM -->` comment, its `updated_at` is within
11
+ # RECOVERY_CLAIM_TTL_MIN (default 30) minutes, and no `FORGE:RECOVERY_CLAIM_RELEASED` comment names
12
+ # its sweep id. FORGE:HEARTBEAT is deliberately NOT counted: a dispatcher must not defer to another
13
+ # dispatcher's heartbeat (that is the lease/claims-board job).
14
+ #
15
+ # Usage:
16
+ # recovery-claim-live.sh <issue> -R <owner/repo> [--exempt-sweep <sweep-id>]
17
+ #
18
+ # --exempt-sweep Ignore the claim held by this sweep id (the calling sweep's own claim), so the
19
+ # holder's inline /work-on is not blocked by its own claim. A claim with no
20
+ # parsable sweep id is never exempt (and `unknown` is rejected as an exemption id).
21
+ #
22
+ # Output (stdout): CLAIM: LIVE <sweep-id> | CLAIM: FREE | CLAIM: ERROR
23
+ # Exit codes: 0 free, 1 live, 2 error (unreadable comments or usage; fails closed — callers must
24
+ # treat 2 like "live" and defer, never like "free").
25
+ #
26
+ # Trust note: any commenter's claim marker counts (same as claim_orphan). A spoofed claim can only
27
+ # defer an issue for RECOVERY_CLAIM_TTL_MIN unless it is refreshed.
28
+ # END-HELP (`-h` prints the header up to this line; keep it last)
29
+
30
+ set -uo pipefail
31
+
32
+ ISSUE=""; REPO=""; EXEMPT=""
33
+ err() { echo "CLAIM: ERROR"; echo "$1" >&2; exit 2; }
34
+
35
+ while [ $# -gt 0 ]; do
36
+ case "$1" in
37
+ -R|--repo)
38
+ [ $# -ge 2 ] && [ -n "${2:-}" ] || err "usage error: $1 needs a value"
39
+ REPO="$2"; shift 2 ;;
40
+ --exempt-sweep)
41
+ [ $# -ge 2 ] && [ -n "${2:-}" ] || err "usage error: $1 needs a value"
42
+ [ "$2" != "unknown" ] || err "usage error: --exempt-sweep 'unknown' is the no-sweep-id sentinel and cannot exempt a claim"
43
+ EXEMPT="$2"; shift 2 ;;
44
+ -h|--help) awk 'NR >= 5 { if (/^# END-HELP/) exit; print }' "$0"; exit 0 ;;
45
+ *)
46
+ if [ -z "$ISSUE" ] && [[ "$1" =~ ^[0-9]+$ ]]; then ISSUE="$1"; shift
47
+ else err "usage error: unexpected argument '$1'"; fi ;;
48
+ esac
49
+ done
50
+ [ -n "$ISSUE" ] && [ -n "$REPO" ] || err "usage: recovery-claim-live.sh <issue> -R <owner/repo> [--exempt-sweep <id>]"
51
+
52
+ TTL="${RECOVERY_CLAIM_TTL_MIN:-30}"
53
+ [[ "$TTL" =~ ^[0-9]+$ ]] || TTL=30
54
+ CUTOFF=$(( $(date -u +%s) - TTL * 60 ))
55
+
56
+ # pipefail: a gh failure (rate limit, 403) must not look like "no comments".
57
+ COMMENTS=$(gh api --paginate "repos/${REPO}/issues/${ISSUE}/comments" 2>/dev/null | jq -s 'add // []') \
58
+ || err "could not read comments for #${ISSUE}"
59
+
60
+ LIVE=$(echo "$COMMENTS" | jq -r --argjson cutoff "$CUTOFF" --arg exempt "$EXEMPT" '
61
+ def sweepid: ((.body | capture("Sweep: (?<id>[^ \n*]+)")?) // {id: "unknown"}).id;
62
+ # "unknown" is the no-id sentinel, never a matchable id: an unparsable release frees nothing (fail closed).
63
+ ([.[] | select(.body | contains("FORGE:RECOVERY_CLAIM_RELEASED")) | sweepid] | map(select(. != "unknown"))) as $rel
64
+ | [ .[] | select(.body | contains("<!-- FORGE:RECOVERY_CLAIM -->"))
65
+ | select((.body | contains("FORGE:RECOVERY_CLAIM_RELEASED")) | not)
66
+ | select(((.updated_at // "") | fromdateiso8601? // 9999999999) >= $cutoff)
67
+ | select(sweepid as $sid | ($rel | index($sid)) == null)
68
+ | select($exempt == "" or sweepid == "unknown" or sweepid != $exempt)
69
+ | sweepid ] | first // empty') \
70
+ || err "could not evaluate recovery claims for #${ISSUE}"
71
+
72
+ if [ -n "$LIVE" ]; then echo "CLAIM: LIVE ${LIVE}"; exit 1; fi
73
+ echo "CLAIM: FREE"; exit 0
@@ -0,0 +1,66 @@
1
+ #!/usr/bin/env bash
2
+ # recovery-claim-live.test.sh — tests for scripts/recovery-claim-live.sh
3
+ # No network: `gh` is mocked on PATH; MOCK_GH_JSON names a fixture holding the JSON array
4
+ # `gh api .../comments` would return. MOCK_GH_FAIL=1 simulates an outage.
5
+ #
6
+ # Usage: bash scripts/recovery-claim-live.test.sh
7
+ # SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
8
+ # SPDX-License-Identifier: AGPL-3.0-or-later
9
+
10
+ set -uo pipefail
11
+ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
12
+ CHECK="$SCRIPT_DIR/recovery-claim-live.sh"
13
+ TMP_BIN=$(mktemp -d); TMP_FX=$(mktemp -d)
14
+ trap 'rm -rf "$TMP_BIN" "$TMP_FX"' EXIT
15
+
16
+ cat > "$TMP_BIN/gh" <<'MOCK'
17
+ #!/usr/bin/env bash
18
+ if [ "${MOCK_GH_FAIL:-}" = "1" ]; then echo "mock gh: outage" >&2; exit 1; fi
19
+ cat "$MOCK_GH_JSON"
20
+ MOCK
21
+ chmod +x "$TMP_BIN/gh"
22
+ export PATH="$TMP_BIN:$PATH"
23
+
24
+ PASS=0; FAILN=0
25
+ ok() { PASS=$((PASS+1)); echo "ok - $1"; }
26
+ bad() { FAILN=$((FAILN+1)); echo "FAIL - $1"; }
27
+
28
+ now_iso() { date -u +%Y-%m-%dT%H:%M:%SZ; }
29
+ old_iso() { date -u -d '2 hours ago' +%Y-%m-%dT%H:%M:%SZ 2>/dev/null || date -u -v-2H +%Y-%m-%dT%H:%M:%SZ; }
30
+
31
+ # fx <name> <json-array>
32
+ fx() { echo "$2" > "$TMP_FX/$1.json"; echo "$TMP_FX/$1.json"; }
33
+ claim() { jq -cn --arg id "$1" --arg t "$2" '{body:("<!-- FORGE:RECOVERY_CLAIM -->\n**Sweep: "+$id+"**\nHolder: /recover-orphans"), updated_at:$t}'; }
34
+ release() { jq -cn --arg id "$1" --arg t "$2" '{body:("<!-- FORGE:RECOVERY_CLAIM_RELEASED -->\n**Sweep: "+$id+"**\nReleased"), updated_at:$t}'; }
35
+
36
+ run() { local f="$1"; shift; OUT=$(MOCK_GH_JSON="$f" bash "$CHECK" 7 -R o/r "$@" 2>/dev/null); RC=$?; }
37
+ expect() { # name rc needle
38
+ [ "$RC" -eq "$2" ] && echo "$OUT" | grep -q "$3" && ok "$1" || bad "$1 (rc=$RC out=$OUT)"
39
+ }
40
+
41
+ N=$(now_iso); O=$(old_iso)
42
+
43
+ run "$(fx live "[$(claim sweep-A "$N")]")"; expect "fresh claim is LIVE" 1 "CLAIM: LIVE sweep-A"
44
+ run "$(fx exp "[$(claim sweep-A "$O")]")"; expect "expired claim is FREE" 0 "CLAIM: FREE"
45
+ run "$(fx rel "[$(claim sweep-A "$N"), $(release sweep-A "$N")]")"; expect "released claim is FREE" 0 "CLAIM: FREE"
46
+ run "$(fx relother "[$(claim sweep-A "$N"), $(release sweep-B "$N")]")"; expect "release of another sweep does not free the claim" 1 "CLAIM: LIVE sweep-A"
47
+ run "$(fx unkrel "[{\"body\":\"<!-- FORGE:RECOVERY_CLAIM -->\\nno sweep line\",\"updated_at\":\"$N\"}, {\"body\":\"<!-- FORGE:RECOVERY_CLAIM_RELEASED -->\\nno sweep line\",\"updated_at\":\"$N\"}]")"; expect "release with no sweep id does not free an id-less claim" 1 "CLAIM: LIVE unknown"
48
+ run "$(fx own "[$(claim sweep-A "$N")]")" --exempt-sweep sweep-A; expect "own sweep id is exempt" 0 "CLAIM: FREE"
49
+ run "$(fx other "[$(claim sweep-A "$N")]")" --exempt-sweep sweep-B; expect "other sweep id is not exempt" 1 "CLAIM: LIVE sweep-A"
50
+ run "$(fx two "[$(claim sweep-A "$N"), $(claim sweep-B "$N")]")" --exempt-sweep sweep-A; expect "exempting A still sees live B" 1 "CLAIM: LIVE sweep-B"
51
+ run "$(fx unk "[{\"body\":\"<!-- FORGE:RECOVERY_CLAIM -->\\nno sweep line\",\"updated_at\":\"$N\"}]")" --exempt-sweep sweep-A; expect "claim with no sweep id is never exempt" 1 "CLAIM: LIVE"
52
+ run "$(fx unk2 "[{\"body\":\"<!-- FORGE:RECOVERY_CLAIM -->\\nno sweep line\",\"updated_at\":\"$N\"}]")" --exempt-sweep unknown; expect "--exempt-sweep unknown is rejected" 2 "CLAIM: ERROR"
53
+ run "$(fx none '[]')"; expect "no comments is FREE" 0 "CLAIM: FREE"
54
+ run "$(fx hb "[{\"body\":\"<!-- FORGE:HEARTBEAT -->\\nPhase 3\",\"updated_at\":\"$N\"}]")"; expect "heartbeat alone is not a recovery claim" 0 "CLAIM: FREE"
55
+ run "$(fx bad "[{\"body\":\"<!-- FORGE:RECOVERY_CLAIM -->\\nno sweep line\",\"updated_at\":\"$N\"}]")"; expect "malformed claim fails closed (LIVE)" 1 "CLAIM: LIVE"
56
+ run "$(fx nodate "[{\"body\":\"<!-- FORGE:RECOVERY_CLAIM -->\\n**Sweep: sweep-A**\",\"updated_at\":\"garbage\"}]")"; expect "unparsable updated_at fails closed (LIVE)" 1 "CLAIM: LIVE"
57
+
58
+ OUT=$(MOCK_GH_FAIL=1 MOCK_GH_JSON=/dev/null bash "$CHECK" 7 -R o/r 2>/dev/null); RC=$?
59
+ expect "gh outage is ERROR (exit 2)" 2 "CLAIM: ERROR"
60
+ OUT=$(MOCK_GH_JSON="$(fx junk 'not json')" bash "$CHECK" 7 -R o/r 2>/dev/null); RC=$?
61
+ expect "unparsable response is ERROR (exit 2)" 2 "CLAIM: ERROR"
62
+ OUT=$(bash "$CHECK" 2>/dev/null); RC=$?; expect "no args is a usage error" 2 "CLAIM: ERROR"
63
+ OUT=$(bash "$CHECK" 7 -R 2>/dev/null); RC=$?; expect "-R without value is a usage error" 2 "CLAIM: ERROR"
64
+
65
+ echo "passed=$PASS failed=$FAILN"
66
+ [ "$FAILN" -eq 0 ]
@@ -9,7 +9,7 @@
9
9
  # verdict alone after phases were silently skipped.
10
10
  #
11
11
  # Usage:
12
- # verify-phase-trail.sh <issue> -R <owner/repo> [--docs-only] [--code-diff] [--head-tree <sha>]
12
+ # verify-phase-trail.sh <issue> -R <owner/repo> [--docs-only] [--code-diff] [--head-tree <sha>] [--head-sha <sha>]
13
13
  #
14
14
  # --docs-only The diff is documentation-only; the FORGE:QUALITY_GATE marker
15
15
  # is not required.
@@ -22,6 +22,9 @@
22
22
  # An empty or non-hex value is a usage error (exit 2, fails closed).
23
23
  # Scope: this binds the PASS to the built tree; it is not tamper-proof, because the
24
24
  # pipeline agent posts under the same trusted identity and could compute the tree itself.
25
+ # --head-sha Commit id (40 or 64 hex) of the head being gated. Enables the human break-glass
26
+ # override (below); without it an override comment is never evaluated. An empty or
27
+ # non-hex value is a usage error (exit 2, fails closed).
25
28
  #
26
29
  # Requirements by COMPLEXITY_BAND (read from the FORGE:FAST_PATH comment).
27
30
  # The authoritative table is the `case "$EFFECTIVE"` block below; this summary must be
@@ -38,9 +41,14 @@
38
41
  # PHASE_TRAIL: PASS|FAIL|ERROR
39
42
  # BAND: <band>
40
43
  # MISSING: <marker> -> <phase to re-run> (one line per missing artifact)
44
+ # PHASE_TRAIL: OVERRIDDEN (exit 0 via an accepted break-glass override)
45
+ # WAIVED: <marker list> (the MISSING set the override waived)
46
+ # OVERRIDE: approver=<login> head=<sha> missing=<list> reason=<sanitised>
47
+ # (the ONLY source callers use to record the override)
41
48
  #
42
- # Exit codes: 0 pass, 1 one or more artifacts missing, 2 could not
49
+ # Exit codes: 0 pass (or accepted override), 1 one or more artifacts missing, 2 could not
43
50
  # read the issue (fails closed — an unreadable trail is never treated as a pass).
51
+ # Exit 2 is NEVER overridable: override evaluation only runs on the exit-1 (MISSING) path.
44
52
  # Callers route on the exit code: 1 -> re-run the MISSING phases; 2 (or 127 when the
45
53
  # verifier itself cannot be resolved) -> infrastructure BLOCKED, never "missing phases".
46
54
  #
@@ -62,11 +70,36 @@
62
70
  # Identities outside that set -- e.g. a human with author_association CONTRIBUTOR/NONE/FIRST_TIME_CONTRIBUTOR
63
71
  # (an external contributor running the pipeline under their own login) -- are NOT trusted, so their
64
72
  # markers are ignored and the gate reports them MISSING. To accept such an identity add its login to
65
- # FORGE_TRAIL_TRUSTED_LOGINS, or widen FORGE_TRAIL_TRUSTED_ASSOCIATIONS (e.g. add CONTRIBUTOR). On FAIL the
73
+ # FORGE_TRAIL_TRUSTED_LOGINS (do NOT widen FORGE_TRAIL_TRUSTED_ASSOCIATIONS to CONTRIBUTOR: that lets any
74
+ # contributor forge FORGE:* trail markers). On FAIL the
66
75
  # script prints a NOTE when untrusted-author FORGE markers were seen, so this is diagnosable (#3123).
67
76
  # Limits: "Bot" trusts any GitHub App/bot that can comment on the repo (set
68
77
  # FORGE_TRAIL_TRUSTED_ASSOCIATIONS and FORGE_TRAIL_TRUSTED_LOGINS to tighten);
69
78
  # COLLABORATOR includes read-level collaborators; login matching is case-sensitive.
79
+ # The override prefilter uses FORGE_TRAIL_OVERRIDE_ASSOCIATIONS (default: the trail list). Widen THAT variable,
80
+ # not the trail list, for concealed-membership approvers; it never affects which FORGE markers count toward the
81
+ # gate. Blast radius of widening FORGE_TRAIL_TRUSTED_ASSOCIATIONS: every listed association can forge trail markers.
82
+ #
83
+ # Break-glass override (#3152): a misfiring gate can be cleared by a HUMAN, never by the pipeline.
84
+ # A comment whose body starts with `<!-- FORGE:PHASE_TRAIL_OVERRIDE -->` and carries the lines
85
+ # **Head**: <full head sha>
86
+ # **Missing**: <marker names, comma separated>
87
+ # **Reason**: <one line>
88
+ # is honoured only when ALL of these hold (any error, null field or API failure rejects it and the
89
+ # gate stays at exit 1; never a pass):
90
+ # - user.type == "User" (allowlist: Bots and null users are rejected) with a plain login;
91
+ # - updated_at == created_at (an edited comment is rejected: tamper evidence);
92
+ # - the author has repo permission admin or write (collaborators/<login>/permission), not mere
93
+ # org membership or read access;
94
+ # - the author is NOT a pipeline identity: FORGE_TRAIL_PIPELINE_LOGINS (comma-separated, case-insensitive)
95
+ # plus the author of every trusted FORGE marker comment (BUILDER, INVESTIGATOR, CONTRACT, ...), so a
96
+ # pipeline running under a human token cannot approve its own gate. A solo operator therefore needs a
97
+ # second human with write access to post the override;
98
+ # - **Head** equals --head-sha and **Missing** equals the current MISSING marker-name set exactly
99
+ # (a new commit or a different failure invalidates it);
100
+ # - created_at is later than the latest trusted FORGE:BUILDER:COMPLETE update time.
101
+ # The reason is sanitised (control chars stripped, comment markers neutralised, whitespace collapsed,
102
+ # capped at 200 chars); an empty reason rejects the override.
70
103
  # END-HELP (`-h` prints the header up to this line; keep it last)
71
104
 
72
105
  set -uo pipefail
@@ -77,6 +110,8 @@ DOCS_ONLY=0
77
110
  CODE_DIFF=0
78
111
  HEAD_TREE=""
79
112
  HEAD_TREE_SET=0
113
+ HEAD_SHA=""
114
+ HEAD_SHA_SET=0
80
115
 
81
116
  while [ $# -gt 0 ]; do
82
117
  case "$1" in
@@ -88,6 +123,9 @@ while [ $# -gt 0 ]; do
88
123
  --head-tree)
89
124
  if [ $# -lt 2 ]; then echo "PHASE_TRAIL: ERROR"; echo "usage error: $1 needs a value" >&2; exit 2; fi
90
125
  HEAD_TREE="$2"; HEAD_TREE_SET=1; shift 2 ;;
126
+ --head-sha)
127
+ if [ $# -lt 2 ]; then echo "PHASE_TRAIL: ERROR"; echo "usage error: $1 needs a value" >&2; exit 2; fi
128
+ HEAD_SHA="$2"; HEAD_SHA_SET=1; shift 2 ;;
91
129
  -h|--help) awk 'NR >= 5 { if (/^# END-HELP/) exit; print }' "$0"; exit 0 ;;
92
130
  *)
93
131
  if [ -z "$ISSUE" ] && [[ "$1" =~ ^[0-9]+$ ]]; then ISSUE="$1"; shift
@@ -98,7 +136,7 @@ done
98
136
 
99
137
  if [ -z "$ISSUE" ] || [ -z "$REPO" ]; then
100
138
  echo "PHASE_TRAIL: ERROR"
101
- echo "usage: verify-phase-trail.sh <issue> -R <owner/repo> [--docs-only] [--code-diff] [--head-tree <sha>]" >&2
139
+ echo "usage: verify-phase-trail.sh <issue> -R <owner/repo> [--docs-only] [--code-diff] [--head-tree <sha>] [--head-sha <sha>]" >&2
102
140
  exit 2
103
141
  fi
104
142
  if [ "$HEAD_TREE_SET" = "1" ]; then
@@ -111,6 +149,16 @@ if [ "$HEAD_TREE_SET" = "1" ]; then
111
149
  fi
112
150
  fi
113
151
 
152
+ if [ "$HEAD_SHA_SET" = "1" ]; then
153
+ # Fail closed: a caller that could not resolve the head passes an empty value and must not silently skip the binding.
154
+ HEAD_SHA=$(printf '%s' "$HEAD_SHA" | tr 'A-F' 'a-f')
155
+ if ! [[ "$HEAD_SHA" =~ ^([0-9a-f]{40}|[0-9a-f]{64})$ ]]; then
156
+ echo "PHASE_TRAIL: ERROR"
157
+ echo "usage error: --head-sha needs a 40 or 64 hex commit id" >&2
158
+ exit 2
159
+ fi
160
+ fi
161
+
114
162
  RAW=$(gh api "repos/${REPO}/issues/${ISSUE}/comments" --paginate 2>/dev/null) || {
115
163
  echo "PHASE_TRAIL: ERROR"
116
164
  echo "could not read comments for ${REPO}#${ISSUE}" >&2
@@ -130,6 +178,10 @@ RAW=$(printf '%s' "$RAW" | jq -s 'if all(.[]; type == "array" and all(.[]; type
130
178
  # One line per comment, newlines folded to \x1f so a marker and its sentinel can
131
179
  # be matched within the SAME comment.
132
180
  TRUSTED_ASSOC="${FORGE_TRAIL_TRUSTED_ASSOCIATIONS-OWNER,MEMBER,COLLABORATOR}"
181
+ # Separate trust decision (forge#3350): the break-glass override prefilter has its own list so it can be widened
182
+ # (e.g. CONTRIBUTOR for concealed-membership approvers) without loosening the trail-marker gate. Defaults to the
183
+ # trail list; ${VAR-default} (no colon) so an explicitly empty value yields no override candidates (fail closed).
184
+ OVERRIDE_ASSOC="${FORGE_TRAIL_OVERRIDE_ASSOCIATIONS-$TRUSTED_ASSOC}"
133
185
  TRUSTED_LOGINS="${FORGE_TRAIL_TRUSTED_LOGINS-}"
134
186
  COMMENTS=$(printf '%s' "$RAW" | jq -r --arg assoc "$TRUSTED_ASSOC" --arg logins "$TRUSTED_LOGINS" '
135
187
  ($assoc | split(",") | map(gsub("^\\s+|\\s+$"; "")) | map(select(length > 0))) as $A
@@ -263,6 +315,133 @@ if [ "${#MISSING[@]}" -eq 0 ]; then
263
315
  exit 0
264
316
  fi
265
317
 
318
+ # --- Break-glass override (#3152) ---------------------------------------------------------------
319
+ # Reached ONLY with a non-empty MISSING set (exit-1 path); every `exit 2` above precedes it.
320
+ # Prints the OVERRIDE/WAIVED lines and returns 0 only when a comment passes every rule; any failure
321
+ # (jq error, permission API error, empty field) returns 1 and the caller falls through to FAIL.
322
+ # Name set normaliser shared by BOTH sides of the comparison: drops ` -> action` and `(qualifier)`,
323
+ # trims, de-duplicates, sorts, joins with commas. bash 3.2/BSD portable.
324
+ norm_set() {
325
+ sed -e 's/ -> .*$//' -e 's/([^)]*)//g' -e 's/^[[:space:]]*//' -e 's/[[:space:]]*$//' \
326
+ | grep -v '^$' | LC_ALL=C sort -u | paste -sd, -
327
+ }
328
+
329
+ evaluate_override() {
330
+ [ "$HEAD_SHA_SET" = "1" ] || return 1
331
+ local cur_set cands pipeline_logins login created head missing reason perm n=0 total=0 seen_logins="" llogin
332
+ cur_set=$(printf '%s\n' "${MISSING[@]}" | norm_set) || return 1
333
+ [ -n "$cur_set" ] || return 1
334
+ # BUILDER exists but its completion time is unknown -> cannot prove the override is newer: reject.
335
+ if has '<!-- FORGE:BUILDER -->' && [ -z "$BUILD_AT" ]; then return 1; fi
336
+ # Pipeline identity set (configured + derived): authors of trusted FORGE marker comments other than overrides.
337
+ # The token this verifier runs under is the pipeline's identity too (forge#3269): a pipeline using a human
338
+ # token with no markers yet must not approve its own override. Lookup failure adds nothing (derived set still applies).
339
+ local self_login extra_logins marker_floor
340
+ self_login=$(gh api user --jq '.login' 2>/dev/null || true)
341
+ case "$self_login" in *[!A-Za-z0-9_-]*) self_login="" ;; esac
342
+ extra_logins="${FORGE_TRAIL_PIPELINE_LOGINS-}${self_login:+,$self_login}"
343
+ pipeline_logins=$(printf '%s' "$RAW" | jq -r --arg assoc "$TRUSTED_ASSOC" --arg logins "$TRUSTED_LOGINS" --arg extra "$extra_logins" '
344
+ ($assoc | split(",") | map(gsub("^\\s+|\\s+$"; "")) | map(select(length > 0))) as $A
345
+ | ($logins | split(",") | map(gsub("^\\s+|\\s+$"; "")) | map(select(length > 0))) as $L
346
+ | ($extra | split(",") | map(gsub("^\\s+|\\s+$"; "") | ascii_downcase) | map(select(length > 0))) as $E
347
+ | ( [ .[]
348
+ | select(
349
+ ((.author_association // "") as $x | $A | index($x) != null)
350
+ or ((.user.type // "") == "Bot")
351
+ or ((.user.login // "") as $x | $L | index($x) != null))
352
+ | select((.body // "") | startswith("<!-- FORGE:") and (startswith("<!-- FORGE:PHASE_TRAIL_OVERRIDE -->") | not))
353
+ | (.user.login // empty) | ascii_downcase ] + $E ) | unique | .[]' 2>/dev/null) || return 1
354
+ # Time floor (forge#3271): the latest trusted BUILDER:COMPLETE. With none, fall back to the newest trusted
355
+ # non-override FORGE marker so an override still has to post-date the pipeline's own activity.
356
+ marker_floor="$BUILD_AT"
357
+ if [ -z "$marker_floor" ]; then
358
+ marker_floor=$(printf '%s' "$RAW" | jq -r --arg assoc "$TRUSTED_ASSOC" --arg logins "$TRUSTED_LOGINS" '
359
+ ($assoc | split(",") | map(gsub("^\\s+|\\s+$"; "")) | map(select(length > 0))) as $A
360
+ | ($logins | split(",") | map(gsub("^\\s+|\\s+$"; "")) | map(select(length > 0))) as $L
361
+ | [ .[]
362
+ | select(((.author_association // "") as $x | $A | index($x) != null)
363
+ or ((.user.type // "") == "Bot")
364
+ or ((.user.login // "") as $x | $L | index($x) != null))
365
+ | select((.body // "") | startswith("<!-- FORGE:") and (startswith("<!-- FORGE:PHASE_TRAIL_OVERRIDE -->") | not))
366
+ | .created_at // empty ] | sort | .[-1] // empty' 2>/dev/null) || return 1
367
+ fi
368
+ # Candidates, newest first, as TSV: login created_at head missing reason (reason last, others never empty).
369
+ # The author_association prefilter uses its own list ($OVERRIDE_ASSOC, default = trail list) and is only an
370
+ # optimization (forge#3279): the permission API stays authoritative. A real write/admin approver with concealed
371
+ # org membership can show CONTRIBUTOR/NONE and is filtered out here; widen FORGE_TRAIL_OVERRIDE_ASSOCIATIONS to
372
+ # accept them (never the trail list). An empty list yields no candidates (fail closed). Filtered candidates are counted in a NOTE.
373
+ # One jq pass (forge#3351): the eligibility predicate is defined once (`eligible`); the first output line is the
374
+ # count of eligible comments dropped by the association prefilter, the rest are the candidate TSV rows.
375
+ local assoc_dropped out
376
+ out=$(printf '%s' "$RAW" | jq -r --arg assoc "$OVERRIDE_ASSOC" --arg bt "$marker_floor" '
377
+ ($assoc | split(",") | map(gsub("^\\s+|\\s+$"; "")) | map(select(length > 0))) as $A
378
+ | def clean: explode | map(select(. >= 32 and . != 127 and (. < 128 or . > 159) and (. < 8203 or . > 8207) and (. < 8232 or . > 8238) and (. < 8288 or . > 8297) and . != 65279)) | implode;
379
+ def eligible:
380
+ select((.body // "") | startswith("<!-- FORGE:PHASE_TRAIL_OVERRIDE -->"))
381
+ | select((.user.type // "") == "User")
382
+ | select(((.user.login // "") | test("^[A-Za-z0-9_-]+$")))
383
+ | select((.created_at // "") != "" and (.updated_at // "") != "" and .created_at == .updated_at)
384
+ | select($bt == "" or .created_at > $bt);
385
+ def assoc_ok: (.author_association // "") as $x | $A | index($x) != null;
386
+ ( [ .[] | eligible | select(assoc_ok | not) ] | length ) as $dropped
387
+ | ( [ .[]
388
+ | eligible
389
+ | select(assoc_ok)
390
+ | (.body | split("\n") | map(sub("\r$"; ""))) as $ln
391
+ | { login: .user.login, at: .created_at,
392
+ head: ([$ln[] | capture("^\\*\\*Head\\*\\*: *(?<v>[0-9A-Fa-f]{40,64}) *$")? | .v] | .[0] // "-"),
393
+ missing: ([$ln[] | capture("^\\*\\*Missing\\*\\*: *(?<v>.+)$")? | .v] | .[0] // "-"),
394
+ reason: ([$ln[] | capture("^\\*\\*Reason\\*\\*: *(?<v>.*)$")? | .v] | .[0] // "")
395
+ | clean | gsub("<!--"; "<!-/-") | gsub("-->"; "-/->") | gsub("`"; "'"'"'") | gsub("@"; "(at)")
396
+ | gsub("\\s+"; " ") | sub("^ "; "") | sub(" $"; "") | .[0:200] } ]
397
+ | sort_by(.at) | reverse ) as $rows
398
+ | "#DROPPED\t\($dropped)",
399
+ ($rows[] | [.login, .at, .head, .missing, (if .reason == "" then "-" else .reason end)] | @tsv)' 2>/dev/null) || return 1
400
+ assoc_dropped=$(printf '%s\n' "$out" | head -n 1 | cut -f2)
401
+ case "$assoc_dropped" in ''|*[!0-9]*) assoc_dropped=0 ;; esac
402
+ if [ "$assoc_dropped" -gt 0 ]; then echo "NOTE: ${assoc_dropped} override candidate(s) ignored: author_association not in override-approver set" >&2; fi
403
+ cands=$(printf '%s\n' "$out" | tail -n +2)
404
+ [ -n "$cands" ] || return 1
405
+ while IFS=$'\t' read -r login created head missing reason; do
406
+ [ -n "$login" ] || continue
407
+ [ "$reason" != "-" ] && [ -n "$reason" ] || continue
408
+ reason=$(printf '%s' "$reason" | tr -d '[:cntrl:]') # belt and braces: cntrl (incl. ESC) stripped again
409
+ [ -n "$reason" ] || continue
410
+ [ "$(printf '%s' "$head" | tr 'A-F' 'a-f')" = "$HEAD_SHA" ] || continue
411
+ [ "$(printf '%s' "$missing" | tr ',' '\n' | norm_set)" = "$cur_set" ] || continue
412
+ if printf '%s\n' "$pipeline_logins" | grep -Fx -- "$(printf '%s' "$login" | tr 'A-Z' 'a-z')" >/dev/null; then continue; fi
413
+ # forge#3279/#3306: dedupe distinct logins (bash 3.2-portable newline list, no associative arrays); a login seen
414
+ # before was already rejected (an accepted one returns), so repeat comments cost no slot and no API call.
415
+ # Cap accounting: a DEFINITIVE negative (read/triage/none) is free so read-level members cannot crowd out an older
416
+ # real approver; only UNREADABLE lookups spend the 5-slot error cap, and 25 distinct lookups is a hard ceiling.
417
+ llogin=$(printf '%s' "$login" | tr 'A-Z' 'a-z')
418
+ if printf '%s\n' "$seen_logins" | grep -Fx -- "$llogin" >/dev/null; then continue; fi
419
+ total=$((total+1)); [ "$total" -le 25 ] || { echo "NOTE: break-glass override: more than 25 distinct candidate logins; extra ones ignored" >&2; break; } # bound API calls
420
+ seen_logins="${seen_logins}${llogin}"$'\n'
421
+ # Login already matched ^[A-Za-z0-9_-]+$ in jq, so it is safe to interpolate into the API path.
422
+ perm=$(gh api "repos/${REPO}/collaborators/${login}/permission" 2>/dev/null | jq -r '.permission // empty' 2>/dev/null) || perm=""
423
+ case "$perm" in
424
+ admin|write) ;;
425
+ "")
426
+ echo "NOTE: break-glass override by ${login} rejected: permission='unreadable' (needs admin/write; the lookup needs a token with push access)" >&2
427
+ n=$((n+1)); [ "$n" -le 5 ] || { echo "NOTE: break-glass override: more than 5 unreadable permission lookups; extra candidates ignored" >&2; break; }
428
+ continue ;;
429
+ *) echo "NOTE: break-glass override by ${login} rejected: permission='${perm}' (needs admin/write)" >&2; continue ;;
430
+ esac
431
+ echo "PHASE_TRAIL: OVERRIDDEN"
432
+ echo "BAND: ${BAND:-UNKNOWN}"
433
+ echo "WAIVED: ${cur_set}"
434
+ echo "OVERRIDE: approver=${login} head=${HEAD_SHA} missing=${cur_set} reason=${reason}"
435
+ return 0
436
+ done <<< "$cands"
437
+ return 1
438
+ }
439
+
440
+ if evaluate_override; then
441
+ [ -n "${BAND_NOTE:-}" ] && echo "NOTE: $BAND_NOTE"
442
+ exit 0
443
+ fi
444
+
266
445
  echo "PHASE_TRAIL: FAIL"
267
446
  echo "BAND: ${BAND:-UNKNOWN}"
268
447
  for m in "${MISSING[@]}"; do echo "MISSING: $m"; done