forgedock 1.8.4 → 1.9.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/engine/admission.mjs +23 -4
- package/bin/engine/admission.test.mjs +37 -0
- package/commands/orchestrate/phase-1-resolve.md +40 -24
- package/commands/orchestrate/phase-3-dependency.md +56 -11
- package/commands/orchestrate/phase-4-execution.md +359 -111
- package/commands/orchestrate/phase-5-cleanup.md +17 -0
- package/commands/orchestrate/phase-6-report.md +1 -1
- package/commands/quality-gate.md +80 -0
- package/commands/recover-orphans.md +3 -3
- package/commands/review-pr.md +98 -16
- package/commands/work-on/build/context.md +25 -4
- package/commands/work-on/build/validate.md +16 -9
- package/commands/work-on/build.md +26 -9
- package/commands/work-on/close.md +104 -40
- package/commands/work-on/decompose.md +13 -7
- package/commands/work-on/investigate.md +13 -7
- package/commands/work-on/remediate.md +15 -4
- package/commands/work-on/review.md +48 -19
- package/commands/work-on.md +62 -9
- package/docs/CODEX.md +12 -0
- package/docs/CONFIG.md +49 -1
- package/docs/RELEASE-NOTES.md +61 -0
- package/package.json +1 -1
- package/scripts/amplification-breaker.sh +89 -0
- package/scripts/amplification-breaker.test.sh +39 -0
- package/scripts/check-admission-parity.mjs +74 -1
- package/scripts/check-spec-bash.sh +120 -0
- package/scripts/check-spec-bash.test.sh +90 -0
- package/scripts/check-spec-markers.sh +67 -3
- package/scripts/classify-finding.sh +123 -0
- package/scripts/classify-finding.test.sh +77 -0
- package/scripts/close-dossier-no-main-commit.test.sh +139 -0
- package/scripts/forge-root.test.sh +84 -27
- package/scripts/lint-build-validate-files.sh +29 -0
- package/scripts/lint-build-validate-files.test.sh +25 -0
- package/scripts/lint-dispatch-prompt.test.sh +2 -1
- package/scripts/recovery-claim-live.sh +73 -0
- package/scripts/recovery-claim-live.test.sh +66 -0
- package/scripts/verify-phase-trail.sh +183 -4
- package/scripts/verify-phase-trail.test.sh +211 -1
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
* Usage: node scripts/check-admission-parity.mjs
|
|
14
14
|
*/
|
|
15
15
|
|
|
16
|
-
import { readFileSync } from "node:fs";
|
|
16
|
+
import { readFileSync, readdirSync } from "node:fs";
|
|
17
17
|
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
18
18
|
import path from "node:path";
|
|
19
19
|
|
|
@@ -78,6 +78,56 @@ function extractBashMirrorPresets(specText) {
|
|
|
78
78
|
return presets;
|
|
79
79
|
}
|
|
80
80
|
|
|
81
|
+
/** Recursively list *.md files under a directory. */
|
|
82
|
+
function listMarkdown(dir) {
|
|
83
|
+
return readdirSync(dir, { withFileTypes: true }).flatMap((entry) => {
|
|
84
|
+
const full = path.join(dir, entry.name);
|
|
85
|
+
if (entry.isDirectory()) return listMarkdown(full);
|
|
86
|
+
return entry.name.endsWith(".md") ? [full] : [];
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Collect identifiers the orchestrate specs name as admission.mjs APIs:
|
|
92
|
+
* (a) names destructured from an admission.mjs import snippet (`({ a, b }) =>`), and (b) backticked `name()` calls
|
|
93
|
+
* on any line that mentions admission.mjs. Returns [{ name, file, line }], plus
|
|
94
|
+
* `{ unattributed: true, file, line }` for destructure snippets that name no module.
|
|
95
|
+
*
|
|
96
|
+
* Heuristic limits: a destructure snippet is attributed to the first `engine/<name>.mjs` path found between its
|
|
97
|
+
* `.then(({` and the next `.then(({` (or end of text), capped at 1500 chars. A snippet naming no module in that
|
|
98
|
+
* window is reported as unattributable (the lint fails closed). The backtick-call rule is line-scoped: it only
|
|
99
|
+
* fires on lines that themselves mention admission.mjs, so `plan*()` calls in other prose are not linted.
|
|
100
|
+
*/
|
|
101
|
+
function extractSpecNamedIdentifiers(file, text) {
|
|
102
|
+
const found = [];
|
|
103
|
+
const lineOf = (index) => text.slice(0, index).split("\n").length;
|
|
104
|
+
// The module loaded by a snippet is the first `engine/<name>.mjs` path passed after its `.then(({...}) =>`.
|
|
105
|
+
const destructure = /\.then\(\(\s*\{([^}]*)\}\s*\)\s*=>/g;
|
|
106
|
+
const starts = [...text.matchAll(destructure)].map((x) => x.index);
|
|
107
|
+
let m;
|
|
108
|
+
while ((m = destructure.exec(text))) {
|
|
109
|
+
const next = starts.find((s) => s > m.index) ?? text.length;
|
|
110
|
+
const target = text.slice(m.index, Math.min(next, m.index + 1500)).match(/engine\/([\w-]+)\.mjs/)?.[1];
|
|
111
|
+
if (!target) {
|
|
112
|
+
found.push({ unattributed: true, file, line: lineOf(m.index) });
|
|
113
|
+
continue;
|
|
114
|
+
}
|
|
115
|
+
if (target !== "admission") continue;
|
|
116
|
+
for (const part of m[1].split(",")) {
|
|
117
|
+
const name = part.trim().split(/\s*:\s*/)[0];
|
|
118
|
+
if (/^[A-Za-z_$][\w$]*$/.test(name)) found.push({ name, file, line: lineOf(m.index) });
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
const lines = text.split("\n");
|
|
122
|
+
lines.forEach((lineText, i) => {
|
|
123
|
+
if (!lineText.includes("admission.mjs")) return;
|
|
124
|
+
for (const call of lineText.matchAll(/`([A-Za-z_$][\w$]*)\(\)`/g)) {
|
|
125
|
+
found.push({ name: call[1], file, line: i + 1 });
|
|
126
|
+
}
|
|
127
|
+
});
|
|
128
|
+
return found;
|
|
129
|
+
}
|
|
130
|
+
|
|
81
131
|
async function main() {
|
|
82
132
|
let admissionModule;
|
|
83
133
|
try {
|
|
@@ -149,6 +199,28 @@ async function main() {
|
|
|
149
199
|
}
|
|
150
200
|
}
|
|
151
201
|
|
|
202
|
+
// Spec-lint: every admission.mjs identifier named in commands/orchestrate/** must be exported.
|
|
203
|
+
const specDir = path.join(REPO_ROOT, "commands/orchestrate");
|
|
204
|
+
let namedCount = 0;
|
|
205
|
+
for (const file of listMarkdown(specDir)) {
|
|
206
|
+
for (const { name, line, unattributed } of extractSpecNamedIdentifiers(file, readFileSync(file, "utf8"))) {
|
|
207
|
+
if (unattributed) {
|
|
208
|
+
console.error(
|
|
209
|
+
`UNATTRIBUTABLE: ${path.relative(REPO_ROOT, file)}:${line} could not attribute .then(({...}) => snippet to an engine/<module>.mjs path`,
|
|
210
|
+
);
|
|
211
|
+
failed = true;
|
|
212
|
+
continue;
|
|
213
|
+
}
|
|
214
|
+
namedCount++;
|
|
215
|
+
if (!(name in admissionModule)) {
|
|
216
|
+
console.error(
|
|
217
|
+
`MISSING EXPORT: ${path.relative(REPO_ROOT, file)}:${line} names "${name}" from admission.mjs, but it is not exported`,
|
|
218
|
+
);
|
|
219
|
+
failed = true;
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
152
224
|
if (failed) {
|
|
153
225
|
console.error("");
|
|
154
226
|
console.error(
|
|
@@ -160,6 +232,7 @@ async function main() {
|
|
|
160
232
|
process.exit(1);
|
|
161
233
|
}
|
|
162
234
|
|
|
235
|
+
console.log(`OK: all ${namedCount} admission.mjs identifiers named in commands/orchestrate/** are exported`);
|
|
163
236
|
console.log(
|
|
164
237
|
`OK: admission.mjs CASCADE_PRESETS matches the phase-4-execution.md bash mirror for: ${mjsNames.join(", ")}`,
|
|
165
238
|
);
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
|
|
3
|
+
# SPDX-License-Identifier: AGPL-3.0-or-later
|
|
4
|
+
#
|
|
5
|
+
# check-spec-bash.sh — Syntax-check the ```bash fenced blocks inside Markdown spec files.
|
|
6
|
+
#
|
|
7
|
+
# ForgeDock's pipeline is bash embedded in Markdown, and the quality gate's executable
|
|
8
|
+
# checks skip .md files. Spec bash therefore merged without ever being parsed, and
|
|
9
|
+
# reviewers became the first thing to run it (2026-10-08 audit). This script runs
|
|
10
|
+
# `bash -n` on each fenced block after neutralising spec notation, and optionally
|
|
11
|
+
# `shellcheck -S error`.
|
|
12
|
+
#
|
|
13
|
+
# Usage:
|
|
14
|
+
# check-spec-bash.sh [--base <git-ref>] [--shellcheck] <file.md>...
|
|
15
|
+
#
|
|
16
|
+
# --base <ref> Only check blocks that contain a line changed relative to <ref>
|
|
17
|
+
# (`git diff -U0 <ref> -- <file>`), so pre-existing blocks never fail
|
|
18
|
+
# a new change. Without --base every block is checked.
|
|
19
|
+
# --shellcheck Also run `shellcheck -S error -s bash` when installed. Advisory: its hits are
|
|
20
|
+
# reported as WARN and never fail the run (spec blocks are often fragments).
|
|
21
|
+
#
|
|
22
|
+
# Spec notation that is not bash is neutralised before parsing:
|
|
23
|
+
# {PLACEHOLDER} / {FORGE_SKILL_PREFIX}x → a plain word
|
|
24
|
+
# blocks containing pseudo tool calls (Skill( / Agent( / Task( / Read( ) → SKIPPED
|
|
25
|
+
# a block whose preceding non-blank line is exactly `<!-- allowlist:check-spec-bash -->` → SKIPPED
|
|
26
|
+
# (for intentional fragments, e.g. an `if` opened in one block and closed in the next)
|
|
27
|
+
#
|
|
28
|
+
# Output: one line per checked block, `OK|FAIL|WARN|SKIP <file>:<start-line> <reason>`,
|
|
29
|
+
# then `SPEC-BASH: checked=N failed=F skipped=S shellcheck_warnings=W`. Only bash -n fails a run.
|
|
30
|
+
# Exit codes: 0 no failures, 1 at least one block failed, 2 usage error.
|
|
31
|
+
#
|
|
32
|
+
# Portable: bash 3.2, BSD/GNU awk and sed. Requires git only with --base.
|
|
33
|
+
|
|
34
|
+
set -u
|
|
35
|
+
|
|
36
|
+
BASE=""; SHELLCHECK=0; FILES=""
|
|
37
|
+
usage() { echo "ERROR: Usage: check-spec-bash.sh [--base <git-ref>] [--shellcheck] <file.md>..." >&2; exit 2; }
|
|
38
|
+
|
|
39
|
+
while [ "$#" -gt 0 ]; do
|
|
40
|
+
case "$1" in
|
|
41
|
+
--base) [ "$#" -ge 2 ] || usage; BASE="$2"; shift 2 ;;
|
|
42
|
+
--shellcheck) SHELLCHECK=1; shift ;;
|
|
43
|
+
--*) usage ;;
|
|
44
|
+
*) FILES="$FILES
|
|
45
|
+
$1"; shift ;;
|
|
46
|
+
esac
|
|
47
|
+
done
|
|
48
|
+
[ -n "$FILES" ] || usage
|
|
49
|
+
|
|
50
|
+
TMPD=$(mktemp -d "${TMPDIR:-/tmp}/check-spec-bash.XXXXXX") || exit 2
|
|
51
|
+
trap 'rm -rf "$TMPD"' EXIT
|
|
52
|
+
|
|
53
|
+
checked=0; failed=0; skipped=0; warned=0
|
|
54
|
+
|
|
55
|
+
changed_lines() { # changed_lines <file> → one new-side line number per line
|
|
56
|
+
git diff -U0 "$BASE" -- "$1" 2>/dev/null | awk '
|
|
57
|
+
/^@@/ { split($3, a, ","); s = substr(a[1], 2) + 0; n = (a[2] == "") ? 1 : a[2] + 0
|
|
58
|
+
for (i = 0; i < n; i++) print s + i }'
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
while IFS= read -r f; do
|
|
62
|
+
[ -n "$f" ] || continue
|
|
63
|
+
case "$f" in *.md) ;; *) continue ;; esac
|
|
64
|
+
[ -f "$f" ] || { echo "SKIP $f missing"; continue; }
|
|
65
|
+
|
|
66
|
+
CH="$TMPD/changed"; : > "$CH"
|
|
67
|
+
if [ -n "$BASE" ]; then
|
|
68
|
+
changed_lines "$f" > "$CH"
|
|
69
|
+
[ -s "$CH" ] || continue
|
|
70
|
+
fi
|
|
71
|
+
|
|
72
|
+
# Split the file into one file per ```bash block, named by its opening line number.
|
|
73
|
+
rm -f "$TMPD"/blk.*
|
|
74
|
+
awk -v d="$TMPD" '
|
|
75
|
+
!inb && /^[ \t]*```(bash|sh)[ \t]*$/ { inb = 1; start = NR; out = d "/blk." NR; printf "" > out
|
|
76
|
+
if (prev ~ /^[ \t]*<!--[ \t]*allowlist:check-spec-bash[ \t]*-->[ \t]*$/) print "#ALLOWLISTED" > out
|
|
77
|
+
next }
|
|
78
|
+
inb && /^[ \t]*```[ \t]*$/ { inb = 0; close(out); print start "\t" NR > (d "/ranges"); next }
|
|
79
|
+
inb { print > out; next }
|
|
80
|
+
{ if ($0 !~ /^[ \t]*$/) prev = $0 }
|
|
81
|
+
' "$f"
|
|
82
|
+
[ -f "$TMPD/ranges" ] || continue
|
|
83
|
+
|
|
84
|
+
while IFS="$(printf '\t')" read -r start end; do
|
|
85
|
+
blk="$TMPD/blk.$start"
|
|
86
|
+
if [ -n "$BASE" ]; then
|
|
87
|
+
hit=$(awk -v s="$start" -v e="$end" '$1 >= s && $1 <= e { print "y"; exit }' "$CH")
|
|
88
|
+
[ "$hit" = "y" ] || continue
|
|
89
|
+
fi
|
|
90
|
+
if head -1 "$blk" | grep -q '^#ALLOWLISTED$'; then
|
|
91
|
+
skipped=$((skipped + 1)); echo "SKIP $f:$start allowlisted fragment"; continue
|
|
92
|
+
fi
|
|
93
|
+
if grep -Eq '(^|[^A-Za-z_])(Skill|Agent|Task|Read)\(' "$blk"; then
|
|
94
|
+
skipped=$((skipped + 1)); echo "SKIP $f:$start pseudo tool call"; continue
|
|
95
|
+
fi
|
|
96
|
+
# Neutralise {PLACEHOLDER} notation (and {FORGE_SKILL_PREFIX}name) into a variable expansion,
|
|
97
|
+
# so tests like [ -z "{X}" ] are not misread as constant strings.
|
|
98
|
+
sed -E 's/\{[A-Z][A-Z0-9_]*\}/${PH}/g' "$blk" > "$blk.sh"
|
|
99
|
+
checked=$((checked + 1))
|
|
100
|
+
if ! err=$(bash -n "$blk.sh" 2>&1); then
|
|
101
|
+
failed=$((failed + 1))
|
|
102
|
+
echo "FAIL $f:$start bash -n: $(printf '%s' "$err" | head -1 | sed "s|$blk.sh|block|")"
|
|
103
|
+
continue
|
|
104
|
+
fi
|
|
105
|
+
if [ "$SHELLCHECK" = 1 ] && command -v shellcheck >/dev/null 2>&1; then
|
|
106
|
+
if ! err=$(shellcheck -S error -s bash "$blk.sh" 2>&1); then
|
|
107
|
+
warned=$((warned + 1))
|
|
108
|
+
echo "WARN $f:$start shellcheck: $(printf '%s' "$err" | grep -m1 -E 'SC[0-9]+' || printf '%s' "$err" | head -1)"
|
|
109
|
+
continue
|
|
110
|
+
fi
|
|
111
|
+
fi
|
|
112
|
+
echo "OK $f:$start"
|
|
113
|
+
done < "$TMPD/ranges"
|
|
114
|
+
rm -f "$TMPD/ranges"
|
|
115
|
+
done <<EOF
|
|
116
|
+
$FILES
|
|
117
|
+
EOF
|
|
118
|
+
|
|
119
|
+
echo "SPEC-BASH: checked=$checked failed=$failed skipped=$skipped shellcheck_warnings=$warned"
|
|
120
|
+
[ "$failed" -eq 0 ]
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
|
|
3
|
+
# SPDX-License-Identifier: AGPL-3.0-or-later
|
|
4
|
+
#
|
|
5
|
+
# Fixtures for scripts/check-spec-bash.sh. Run: bash scripts/check-spec-bash.test.sh (bash 3.2 compatible)
|
|
6
|
+
|
|
7
|
+
set -u
|
|
8
|
+
DIR=$(cd "$(dirname "$0")" && pwd)
|
|
9
|
+
S="$DIR/check-spec-bash.sh"
|
|
10
|
+
T=$(mktemp -d "${TMPDIR:-/tmp}/check-spec-bash-test.XXXXXX")
|
|
11
|
+
trap 'rm -rf "$T"' EXIT
|
|
12
|
+
pass=0; fail=0
|
|
13
|
+
FENCE='```'
|
|
14
|
+
|
|
15
|
+
check() { # check <want_rc> <want_substring> <desc> -- args...
|
|
16
|
+
want_rc="$1"; want="$2"; desc="$3"; shift 4
|
|
17
|
+
out=$(cd "$T" && bash "$S" "$@" 2>&1); rc=$?
|
|
18
|
+
case "$out" in *"$want"*) ok=1 ;; *) ok=0 ;; esac
|
|
19
|
+
if [ "$rc" = "$want_rc" ] && [ "$ok" = 1 ]; then pass=$((pass+1)); else fail=$((fail+1)); echo "FAIL: $desc (rc=$rc)"; printf '%s\n' "$out" | sed 's/^/ /'; fi
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
cat > "$T/good.md" <<EOF
|
|
23
|
+
# Good
|
|
24
|
+
${FENCE}bash
|
|
25
|
+
if [ -z "{GH_REPO}" ]; then echo none; fi
|
|
26
|
+
gh issue view {NUMBER} -R {GH_REPO} --json labels
|
|
27
|
+
${FENCE}
|
|
28
|
+
Prose with it's apostrophe and an unbalanced ( paren.
|
|
29
|
+
EOF
|
|
30
|
+
|
|
31
|
+
cat > "$T/bad.md" <<EOF
|
|
32
|
+
# Bad
|
|
33
|
+
${FENCE}bash
|
|
34
|
+
if [ -n "\$X" ]; then
|
|
35
|
+
echo "unterminated
|
|
36
|
+
fi
|
|
37
|
+
${FENCE}
|
|
38
|
+
EOF
|
|
39
|
+
|
|
40
|
+
cat > "$T/pseudo.md" <<EOF
|
|
41
|
+
${FENCE}bash
|
|
42
|
+
OUT=\$(Skill(skill="x", args="y"))
|
|
43
|
+
${FENCE}
|
|
44
|
+
EOF
|
|
45
|
+
|
|
46
|
+
cat > "$T/allow.md" <<EOF
|
|
47
|
+
<!-- allowlist:check-spec-bash -->
|
|
48
|
+
${FENCE}bash
|
|
49
|
+
if true; then
|
|
50
|
+
${FENCE}
|
|
51
|
+
EOF
|
|
52
|
+
|
|
53
|
+
cat > "$T/prose-mention.md" <<EOF
|
|
54
|
+
Use \`<!-- allowlist:check-spec-bash -->\` before an intentional fragment.
|
|
55
|
+
${FENCE}bash
|
|
56
|
+
if true; then
|
|
57
|
+
${FENCE}
|
|
58
|
+
EOF
|
|
59
|
+
|
|
60
|
+
cat > "$T/notbash.md" <<EOF
|
|
61
|
+
${FENCE}yaml
|
|
62
|
+
key: [unclosed
|
|
63
|
+
${FENCE}
|
|
64
|
+
EOF
|
|
65
|
+
|
|
66
|
+
check 0 "OK good.md:2" "valid block with placeholders passes" -- good.md
|
|
67
|
+
check 1 "FAIL bad.md:2 bash -n" "unterminated quote fails" -- bad.md
|
|
68
|
+
check 0 "SKIP pseudo.md:1 pseudo tool" "pseudo tool call skipped" -- pseudo.md
|
|
69
|
+
check 0 "SKIP allow.md:2 allowlisted" "allowlisted fragment skipped" -- allow.md
|
|
70
|
+
check 0 "checked=0 failed=0" "non-bash fence ignored" -- notbash.md
|
|
71
|
+
check 1 "FAIL prose-mention.md:2" "prose mention of the marker does not allowlist" -- prose-mention.md
|
|
72
|
+
check 1 "checked=2 failed=1 skipped=1" "multi-file summary" -- good.md bad.md pseudo.md
|
|
73
|
+
check 2 "" "no files is a usage error" --
|
|
74
|
+
check 2 "" "unknown flag" -- --bogus good.md
|
|
75
|
+
|
|
76
|
+
# --base: only blocks touching changed lines are checked, so pre-existing breakage never fails a change.
|
|
77
|
+
( cd "$T" && git init -q . && git -c user.email=t@t -c user.name=t add bad.md good.md && git -c user.email=t@t -c user.name=t commit -q -m base )
|
|
78
|
+
check 0 "checked=0 failed=0" "--base with no change checks nothing" -- --base HEAD bad.md good.md
|
|
79
|
+
printf '\nnew prose line\n' >> "$T/bad.md"
|
|
80
|
+
check 0 "checked=0 failed=0" "--base: prose change outside a broken block passes" -- --base HEAD bad.md
|
|
81
|
+
cat >> "$T/good.md" <<EOF
|
|
82
|
+
${FENCE}bash
|
|
83
|
+
for x in a b; do
|
|
84
|
+
echo "\$x"
|
|
85
|
+
${FENCE}
|
|
86
|
+
EOF
|
|
87
|
+
check 1 "FAIL good.md:" "--base: newly added broken block fails" -- --base HEAD good.md
|
|
88
|
+
|
|
89
|
+
echo "check-spec-bash.test.sh: passed=$pass failed=$fail"
|
|
90
|
+
[ "$fail" -eq 0 ]
|
|
@@ -131,6 +131,7 @@ MARKER_REGISTRY="
|
|
|
131
131
|
MEMORY_INDEXED
|
|
132
132
|
MILESTONE_INDEX
|
|
133
133
|
MODEL_TIER_NOTE
|
|
134
|
+
NOTE_DISPOSITION
|
|
134
135
|
ORPHAN_ESCALATED
|
|
135
136
|
ORPHAN_RECOVERED
|
|
136
137
|
PATTERN
|
|
@@ -138,6 +139,8 @@ MARKER_REGISTRY="
|
|
|
138
139
|
PHASE_COMPLETE
|
|
139
140
|
PHASE_TRAIL_ERROR
|
|
140
141
|
PHASE_TRAIL_FAILED
|
|
142
|
+
PHASE_TRAIL_OVERRIDE
|
|
143
|
+
PHASE_TRAIL_OVERRIDE_APPLIED
|
|
141
144
|
PHASE_TRAIL_RELEASED
|
|
142
145
|
PLAN_DAG
|
|
143
146
|
PRIOR_DECISIONS
|
|
@@ -150,6 +153,7 @@ MARKER_REGISTRY="
|
|
|
150
153
|
RECOVERY_CLAIM
|
|
151
154
|
RECOVERY_CLAIM_RELEASED
|
|
152
155
|
REMEDIATION
|
|
156
|
+
REREVIEW_DISPATCHED
|
|
153
157
|
REREVIEW_SKIPPED
|
|
154
158
|
REVIEW
|
|
155
159
|
REVIEWER
|
|
@@ -289,9 +293,69 @@ fi
|
|
|
289
293
|
# ---------------------------------------------------------------------------
|
|
290
294
|
|
|
291
295
|
P4_SPEC="$COMMANDS_DIR/orchestrate/phase-4-execution.md"
|
|
292
|
-
if [ -f "$P4_SPEC" ]
|
|
293
|
-
|
|
294
|
-
|
|
296
|
+
if [ -f "$P4_SPEC" ]; then
|
|
297
|
+
# ABSENT arm of classify_predecessor_state() (scoped to that function body): text from a line-start "ABSENT)"
|
|
298
|
+
# inside that function body (an ABSENT) arm elsewhere is ignored) up to the first ";;", whitespace-collapsed so one-line and multi-line
|
|
299
|
+
# forms reduce to the same span.
|
|
300
|
+
ABSENT_ARM=$(awk '
|
|
301
|
+
/^classify_predecessor_state\(\)[[:space:]]*\{/ { infn = 1; next }
|
|
302
|
+
infn && !inarm && /^\}/ { infn = 0 }
|
|
303
|
+
infn && !inarm && /^[[:space:]]*ABSENT\)/ { inarm = 1 }
|
|
304
|
+
inarm {
|
|
305
|
+
line = $0
|
|
306
|
+
# Comments are not code: skip full-line comments and drop a trailing
|
|
307
|
+
# whitespace-preceded "#..." so comment text cannot satisfy or perturb
|
|
308
|
+
# the assertions below (not quote-aware; the arm text has no quoted "#").
|
|
309
|
+
if (line ~ /^[[:space:]]*#/) next
|
|
310
|
+
sub(/[[:space:]]+#.*$/, "", line)
|
|
311
|
+
idx = index(line, ";;")
|
|
312
|
+
if (idx > 0) { buf = buf " " substr(line, 1, idx - 1); exit }
|
|
313
|
+
buf = buf " " line
|
|
314
|
+
}
|
|
315
|
+
END { print buf }
|
|
316
|
+
' "$P4_SPEC" | tr -s '[:space:]' ' ' || true)
|
|
317
|
+
# WIRE:PROVEN — manual mutation in a temp copy of commands/: renamed hold_merged_trail, renamed the ABSENT arm, ABSENT->echo DONE, and moved the comment before add-label; each fired its matching HIGH violation; comment-only GATED fires HIGH, comment-only DONE/reverify_merged_trail does not, comment-only gh issue comment before add-label does not count, real arm still passes; a decoy earlier ABSENT) arm outside classify_predecessor_state does not change the result, removing the function declaration fires ABSENT arm missing, and add-label=needs-human / single-quoted forms are accepted
|
|
318
|
+
if [ -z "${ABSENT_ARM// /}" ]; then
|
|
319
|
+
echo "HIGH | $P4_SPEC | ABSENT arm missing from classify_predecessor_state (label-only hold must be classified explicitly)" >&2
|
|
320
|
+
VIOLATIONS=$((VIOLATIONS + 1))
|
|
321
|
+
else
|
|
322
|
+
if [[ "$ABSENT_ARM" == *reverify_merged_trail* ]] || [[ "$ABSENT_ARM" == *DONE* ]]; then
|
|
323
|
+
echo "HIGH | $P4_SPEC | merged + needs-human + ABSENT classifies DONE / calls the verifier (must fail closed to GATED)" >&2
|
|
324
|
+
VIOLATIONS=$((VIOLATIONS + 1))
|
|
325
|
+
fi
|
|
326
|
+
if ! [[ "$ABSENT_ARM" =~ (echo|printf)[^\;]*GATED ]]; then
|
|
327
|
+
echo "HIGH | $P4_SPEC | ABSENT arm does not resolve to GATED" >&2
|
|
328
|
+
VIOLATIONS=$((VIOLATIONS + 1))
|
|
329
|
+
fi
|
|
330
|
+
fi
|
|
331
|
+
|
|
332
|
+
# hold_merged_trail() (forge#3223): the needs-human label must be added
|
|
333
|
+
# before the FORGE:PHASE_TRAIL_FAILED comment is posted, so a comment can
|
|
334
|
+
# never exist without the label (comment-without-label reads as a release).
|
|
335
|
+
HOLD_ORDER=$(awk '
|
|
336
|
+
/^hold_merged_trail\(\)[[:space:]]*\{/ { infn = 1; found = 1; next }
|
|
337
|
+
infn && /^\}/ { infn = 0 }
|
|
338
|
+
infn {
|
|
339
|
+
line = $0
|
|
340
|
+
if (line ~ /^[[:space:]]*#/) next
|
|
341
|
+
sub(/[[:space:]]+#.*$/, "", line)
|
|
342
|
+
if (!cmt && (line ~ /gh issue comment/ || line ~ /FORGE:PHASE_TRAIL_FAILED/)) cmt = NR
|
|
343
|
+
if (!lbl && line ~ /add-label(=|[[:space:]]+)[\047"]?needs-human/) lbl = NR
|
|
344
|
+
}
|
|
345
|
+
END { printf "%d %d %d\n", found + 0, cmt + 0, lbl + 0 }
|
|
346
|
+
' "$P4_SPEC" || true)
|
|
347
|
+
set -- $HOLD_ORDER
|
|
348
|
+
HOLD_FOUND="${1:-0}"; HOLD_CMT="${2:-0}"; HOLD_LBL="${3:-0}"
|
|
349
|
+
if [ "$HOLD_FOUND" -eq 0 ]; then
|
|
350
|
+
echo "HIGH | $P4_SPEC | hold_merged_trail() function missing" >&2
|
|
351
|
+
VIOLATIONS=$((VIOLATIONS + 1))
|
|
352
|
+
elif [ "$HOLD_CMT" -eq 0 ] || [ "$HOLD_LBL" -eq 0 ]; then
|
|
353
|
+
echo "HIGH | $P4_SPEC | hold_merged_trail() lacks the PHASE_TRAIL_FAILED comment or the needs-human add-label" >&2
|
|
354
|
+
VIOLATIONS=$((VIOLATIONS + 1))
|
|
355
|
+
elif [ "$HOLD_LBL" -ge "$HOLD_CMT" ]; then
|
|
356
|
+
echo "HIGH | $P4_SPEC | hold_merged_trail() posts the PHASE_TRAIL_FAILED comment before adding needs-human (label must come first)" >&2
|
|
357
|
+
VIOLATIONS=$((VIOLATIONS + 1))
|
|
358
|
+
fi
|
|
295
359
|
fi
|
|
296
360
|
|
|
297
361
|
# ---------------------------------------------------------------------------
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
|
|
3
|
+
# SPDX-License-Identifier: AGPL-3.0-or-later
|
|
4
|
+
#
|
|
5
|
+
# classify-finding.sh — Deterministic review-pr §6B.5 note disposition.
|
|
6
|
+
# Decides whether one deduped review finding is filed
|
|
7
|
+
# as a standalone `review-finding` issue (ISSUE) or
|
|
8
|
+
# handled as a non-blocking note (NOTE).
|
|
9
|
+
#
|
|
10
|
+
# Usage:
|
|
11
|
+
# classify-finding.sh --severity <S> [--confidence <C>] [--agent <A>]
|
|
12
|
+
# [--lineage none|review-finding] [--text <T> | --text-file <F>]
|
|
13
|
+
#
|
|
14
|
+
# --severity CRITICAL | HIGH | MEDIUM | LOW (case-insensitive). Missing or
|
|
15
|
+
# unparseable → ISSUE (fail toward filing, never drop).
|
|
16
|
+
# --confidence CONFIRMED | LIKELY | POSSIBLE (case-insensitive). Missing is
|
|
17
|
+
# treated as not-POSSIBLE.
|
|
18
|
+
# --agent The `**Agent**:` value of the finding (e.g. "Security", "Auth").
|
|
19
|
+
# --lineage `review-finding` when the PR's linked issue (MERGE_ISSUE) carries
|
|
20
|
+
# the `review-finding` label — i.e. this PR is itself a fix for a
|
|
21
|
+
# review finding. Default `none`.
|
|
22
|
+
# --text/--text-file Finding title + body + affected file paths, used for the
|
|
23
|
+
# content-based safety exemption.
|
|
24
|
+
#
|
|
25
|
+
# Output (stdout, one line): `ISSUE <reason>` or `NOTE <reason>`.
|
|
26
|
+
# Exit codes: 0 classified, 2 usage error.
|
|
27
|
+
#
|
|
28
|
+
# Rules (forge#3060, tightened after the 2026-10-08 cascade audit):
|
|
29
|
+
# 1. HIGH/CRITICAL, or unparseable severity → ISSUE.
|
|
30
|
+
# 2. Safety exemption is CONTENT-based: the text matches the security/billing
|
|
31
|
+
# keyword set, or the finding came from a dedicated, signal-selected domain
|
|
32
|
+
# agent (Auth, Billing, Concurrency, Database) and is MEDIUM+ or CONFIRMED. Origin from the always-on
|
|
33
|
+
# "General Security & Quality" agent alone NO LONGER exempts a finding —
|
|
34
|
+
# that agent runs on every PR, so origin-based exemption filed nearly every
|
|
35
|
+
# LOW note it raised (50 of 60 would-be notes in the audited batch).
|
|
36
|
+
# 3. Review-finding lineage (a fix for a finding): only MEDIUM findings that are
|
|
37
|
+
# CONFIRMED, or LIKELY and safety-exempt, become issues. LOW is always a
|
|
38
|
+
# NOTE — a fix for a finding must not mint a new generation of findings.
|
|
39
|
+
# 4. Otherwise: LOW, or POSSIBLE below HIGH, is a NOTE unless safety-exempt.
|
|
40
|
+
#
|
|
41
|
+
# NOTEs are never discarded silently: review-pr §6B.5 fixes them in-PR, lists
|
|
42
|
+
# them in the PR body, or records a drop reason in the review summary.
|
|
43
|
+
#
|
|
44
|
+
# Portable: bash 3.2 (macOS /bin/bash), BSD and GNU grep/tr. No network.
|
|
45
|
+
|
|
46
|
+
set -u
|
|
47
|
+
|
|
48
|
+
SEVERITY=""
|
|
49
|
+
CONFIDENCE=""
|
|
50
|
+
AGENT=""
|
|
51
|
+
LINEAGE="none"
|
|
52
|
+
TEXT=""
|
|
53
|
+
|
|
54
|
+
usage() {
|
|
55
|
+
echo "ERROR: Usage: classify-finding.sh --severity <S> [--confidence <C>] [--agent <A>] [--lineage none|review-finding] [--text <T> | --text-file <F>]" >&2
|
|
56
|
+
exit 2
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
while [ "$#" -gt 0 ]; do
|
|
60
|
+
case "$1" in
|
|
61
|
+
--severity) [ "$#" -ge 2 ] || usage; SEVERITY="$2"; shift 2 ;;
|
|
62
|
+
--confidence) [ "$#" -ge 2 ] || usage; CONFIDENCE="$2"; shift 2 ;;
|
|
63
|
+
--agent) [ "$#" -ge 2 ] || usage; AGENT="$2"; shift 2 ;;
|
|
64
|
+
--lineage) [ "$#" -ge 2 ] || usage; LINEAGE="$2"; shift 2 ;;
|
|
65
|
+
--text) [ "$#" -ge 2 ] || usage; TEXT="$2"; shift 2 ;;
|
|
66
|
+
--text-file)
|
|
67
|
+
[ "$#" -ge 2 ] || usage
|
|
68
|
+
[ -r "$2" ] || { echo "ERROR: --text-file not readable: $2" >&2; exit 2; }
|
|
69
|
+
TEXT=$(cat "$2"); shift 2 ;;
|
|
70
|
+
*) usage ;;
|
|
71
|
+
esac
|
|
72
|
+
done
|
|
73
|
+
|
|
74
|
+
case "$LINEAGE" in
|
|
75
|
+
none|review-finding) ;;
|
|
76
|
+
*) echo "ERROR: --lineage must be 'none' or 'review-finding' (got '$LINEAGE')" >&2; exit 2 ;;
|
|
77
|
+
esac
|
|
78
|
+
|
|
79
|
+
upper() { printf '%s' "$1" | tr '[:lower:]' '[:upper:]' | tr -d '[:space:]'; }
|
|
80
|
+
SEV=$(upper "$SEVERITY")
|
|
81
|
+
CONF=$(upper "$CONFIDENCE")
|
|
82
|
+
|
|
83
|
+
case "$SEV" in
|
|
84
|
+
CRITICAL|HIGH) echo "ISSUE severity-$SEV"; exit 0 ;;
|
|
85
|
+
MEDIUM|LOW) ;;
|
|
86
|
+
*) echo "ISSUE unparseable-severity"; exit 0 ;;
|
|
87
|
+
esac
|
|
88
|
+
|
|
89
|
+
# Safety exemption. Keywords match whole words, where `_`, `-`, `/` and other
|
|
90
|
+
# punctuation separate words (so `auth_service` matches, `author` does not).
|
|
91
|
+
SAFETY=""
|
|
92
|
+
KW='security|auth|authz|authn|billing|payment|stripe|charge|invoice|injection|xss|csrf|ssrf|idor|secret|secrets|credential|credentials|permission|permissions|sql|token|password|redact'
|
|
93
|
+
if printf '%s' "$TEXT" | tr '[:upper:]' '[:lower:]' | tr -c 'a-z0-9\n' ' ' | grep -Eqw "($KW)"; then
|
|
94
|
+
SAFETY="keyword"
|
|
95
|
+
fi
|
|
96
|
+
# A dedicated domain agent rescues a finding only when it is MEDIUM+ or CONFIRMED:
|
|
97
|
+
# a LOW/POSSIBLE finding stays a NOTE whichever reviewer raised it.
|
|
98
|
+
AGENT_LC=$(printf '%s' "$AGENT" | tr '[:upper:]' '[:lower:]')
|
|
99
|
+
case "$AGENT_LC" in
|
|
100
|
+
auth*|billing*|concurrency*|database*)
|
|
101
|
+
if [ "$SEV" = "MEDIUM" ] || [ "$CONF" = "CONFIRMED" ]; then SAFETY="${SAFETY:-domain-agent}"; fi ;;
|
|
102
|
+
esac
|
|
103
|
+
|
|
104
|
+
if [ "$LINEAGE" = "review-finding" ]; then
|
|
105
|
+
if [ "$SEV" = "MEDIUM" ] && { [ "$CONF" = "CONFIRMED" ] || { [ -n "$SAFETY" ] && [ "$CONF" = "LIKELY" ]; }; }; then
|
|
106
|
+
echo "ISSUE lineage-medium-${CONF:-UNKNOWN}"
|
|
107
|
+
else
|
|
108
|
+
echo "NOTE lineage-${SEV}-${CONF:-UNKNOWN}"
|
|
109
|
+
fi
|
|
110
|
+
exit 0
|
|
111
|
+
fi
|
|
112
|
+
|
|
113
|
+
if [ "$SEV" = "LOW" ] || [ "$CONF" = "POSSIBLE" ]; then
|
|
114
|
+
if [ -n "$SAFETY" ]; then
|
|
115
|
+
echo "ISSUE safety-exemption-$SAFETY"
|
|
116
|
+
else
|
|
117
|
+
echo "NOTE ${SEV}-${CONF:-UNKNOWN}"
|
|
118
|
+
fi
|
|
119
|
+
exit 0
|
|
120
|
+
fi
|
|
121
|
+
|
|
122
|
+
echo "ISSUE ${SEV}-${CONF:-UNKNOWN}"
|
|
123
|
+
exit 0
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
|
|
3
|
+
# SPDX-License-Identifier: AGPL-3.0-or-later
|
|
4
|
+
#
|
|
5
|
+
# Fixtures for scripts/classify-finding.sh (review-pr §6B.5 note disposition).
|
|
6
|
+
# Run: bash scripts/classify-finding.test.sh (bash 3.2 compatible)
|
|
7
|
+
|
|
8
|
+
set -u
|
|
9
|
+
DIR=$(cd "$(dirname "$0")" && pwd)
|
|
10
|
+
S="$DIR/classify-finding.sh"
|
|
11
|
+
pass=0; fail=0
|
|
12
|
+
|
|
13
|
+
expect() { # expect <ISSUE|NOTE> <description> -- args...
|
|
14
|
+
want="$1"; desc="$2"; shift 3
|
|
15
|
+
got=$(bash "$S" "$@" 2>/dev/null | cut -d' ' -f1)
|
|
16
|
+
if [ "$got" = "$want" ]; then pass=$((pass+1)); else fail=$((fail+1)); echo "FAIL: $desc (want $want, got '${got}')"; fi
|
|
17
|
+
}
|
|
18
|
+
expect_exit() { # expect_exit <code> <description> -- args...
|
|
19
|
+
want="$1"; desc="$2"; shift 3
|
|
20
|
+
bash "$S" "$@" >/dev/null 2>&1; got=$?
|
|
21
|
+
if [ "$got" = "$want" ]; then pass=$((pass+1)); else fail=$((fail+1)); echo "FAIL: $desc (want exit $want, got $got)"; fi
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
# --- Rule 1: high severity and unparseable severity always file ---
|
|
25
|
+
expect ISSUE "CRITICAL files" -- --severity CRITICAL --confidence POSSIBLE
|
|
26
|
+
expect ISSUE "HIGH POSSIBLE files" -- --severity HIGH --confidence POSSIBLE
|
|
27
|
+
expect ISSUE "HIGH on lineage files" -- --severity HIGH --confidence LIKELY --lineage review-finding
|
|
28
|
+
expect ISSUE "missing severity files" -- --severity "" --confidence LIKELY
|
|
29
|
+
expect ISSUE "unknown severity files" -- --severity INFO
|
|
30
|
+
expect ISSUE "lowercase high files" -- --severity high
|
|
31
|
+
|
|
32
|
+
# --- Rule 4: non-lineage ---
|
|
33
|
+
expect ISSUE "MEDIUM CONFIRMED files" -- --severity MEDIUM --confidence CONFIRMED --text "loop exits early"
|
|
34
|
+
expect ISSUE "MEDIUM LIKELY files" -- --severity MEDIUM --confidence LIKELY --text "loop exits early"
|
|
35
|
+
expect NOTE "MEDIUM POSSIBLE no-safety note" -- --severity MEDIUM --confidence POSSIBLE --text "loop exits early"
|
|
36
|
+
expect NOTE "LOW CONFIRMED no-safety note" -- --severity LOW --confidence CONFIRMED --text "stale comment wording"
|
|
37
|
+
expect NOTE "LOW from Security agent, no keyword, is a NOTE (audit fix)" -- --severity LOW --confidence CONFIRMED --agent "Security" --text "comment wording drifted"
|
|
38
|
+
expect NOTE "LOW from General Security & Quality, no keyword" -- --severity LOW --confidence LIKELY --agent "General Security & Quality" --text "duplicated predicate in NOTE count"
|
|
39
|
+
|
|
40
|
+
# --- Safety exemption (content-based) ---
|
|
41
|
+
expect ISSUE "LOW with injection keyword" -- --severity LOW --confidence POSSIBLE --text "shell injection via unquoted var"
|
|
42
|
+
expect ISSUE "LOW with auth_service path" -- --severity LOW --confidence LIKELY --text "src/auth_service.py:12 missing check"
|
|
43
|
+
expect ISSUE "LOW with Secrets (case)" -- --severity LOW --confidence LIKELY --text "Secrets leak to log"
|
|
44
|
+
expect ISSUE "LOW with permission-check" -- --severity LOW --confidence LIKELY --text "permission-check skipped"
|
|
45
|
+
expect NOTE "'author' is not 'auth'" -- --severity LOW --confidence LIKELY --text "author field unused"
|
|
46
|
+
expect NOTE "'tokenizer' is not 'token'" -- --severity LOW --confidence LIKELY --text "tokenizer splits oddly"
|
|
47
|
+
expect ISSUE "LOW CONFIRMED from Auth domain agent" -- --severity LOW --confidence CONFIRMED --agent "Auth" --text "nit"
|
|
48
|
+
expect ISSUE "MEDIUM POSSIBLE from Billing agent" -- --severity MEDIUM --confidence POSSIBLE --agent "billing" --text "nit"
|
|
49
|
+
expect NOTE "LOW LIKELY from Auth domain agent is a NOTE" -- --severity LOW --confidence LIKELY --agent "Auth" --text "nit"
|
|
50
|
+
expect NOTE "LOW POSSIBLE from Database agent is a NOTE" -- --severity LOW --confidence POSSIBLE --agent "Database" --text "nit"
|
|
51
|
+
expect NOTE "LOW POSSIBLE Concurrency reviewer is a NOTE (AlterLab #34671)" -- --severity LOW --confidence POSSIBLE --agent "Concurrency reviewer" --text "lock edge cases"
|
|
52
|
+
expect ISSUE "LOW CONFIRMED Concurrency reviewer files" -- --severity LOW --confidence CONFIRMED --agent "Concurrency reviewer" --text "lock edge cases"
|
|
53
|
+
expect ISSUE "keyword still rescues LOW POSSIBLE from domain agent" -- --severity LOW --confidence POSSIBLE --agent "Database" --text "sql injection"
|
|
54
|
+
|
|
55
|
+
# --- Rule 3: review-finding lineage (fix for a finding) ---
|
|
56
|
+
expect NOTE "lineage LOW CONFIRMED note" -- --severity LOW --confidence CONFIRMED --lineage review-finding --text "nit"
|
|
57
|
+
expect NOTE "lineage LOW security keyword still note" -- --severity LOW --confidence CONFIRMED --lineage review-finding --text "injection nit"
|
|
58
|
+
expect ISSUE "lineage MEDIUM CONFIRMED files" -- --severity MEDIUM --confidence CONFIRMED --lineage review-finding --text "logic bug"
|
|
59
|
+
expect NOTE "lineage MEDIUM LIKELY no-safety note" -- --severity MEDIUM --confidence LIKELY --lineage review-finding --text "logic bug"
|
|
60
|
+
expect ISSUE "lineage MEDIUM LIKELY with keyword files" -- --severity MEDIUM --confidence LIKELY --lineage review-finding --text "token leaks"
|
|
61
|
+
expect NOTE "lineage MEDIUM POSSIBLE with keyword note" -- --severity MEDIUM --confidence POSSIBLE --lineage review-finding --text "token leaks"
|
|
62
|
+
|
|
63
|
+
# --- text-file input ---
|
|
64
|
+
TMPF=$(mktemp "${TMPDIR:-/tmp}/classify-finding-test.XXXXXX")
|
|
65
|
+
printf 'Title: unchecked xss sink\n' > "$TMPF"
|
|
66
|
+
expect ISSUE "text-file keyword" -- --severity LOW --confidence LIKELY --text-file "$TMPF"
|
|
67
|
+
rm -f "$TMPF"
|
|
68
|
+
|
|
69
|
+
# --- usage errors ---
|
|
70
|
+
expect_exit 2 "unknown flag" -- --bogus x
|
|
71
|
+
expect_exit 2 "bad lineage" -- --severity LOW --lineage P3
|
|
72
|
+
expect_exit 2 "missing flag value" -- --severity
|
|
73
|
+
expect_exit 2 "unreadable text-file" -- --severity LOW --text-file /nonexistent/x
|
|
74
|
+
expect_exit 0 "valid call exits 0" -- --severity LOW
|
|
75
|
+
|
|
76
|
+
echo "classify-finding.test.sh: passed=$pass failed=$fail"
|
|
77
|
+
[ "$fail" -eq 0 ]
|