chore(governance): two-layer self-merge guard for CTO-reserved paths (core mirror, cp#673 precedent) #2570

Merged
agent-researcher merged 8 commits from chore/core-self-merge-guard-reserved-paths into main 2026-06-11 15:25:18 +00:00
7 changed files with 840 additions and 3 deletions
+40
View File
@@ -0,0 +1,40 @@
# reserved-paths.txt — CTO-reserved architectural surfaces (molecule-core)
#
# Precedent: cp#673 (molecule-controlplane workspace-data-persistence, a new
# subsystem) was author-self-merged by devops-engineer without the CTO sign-off
# the review process reserves for architectural changes. Code was sound + had
# independent peer review, so it was kept (accept-and-note, CTO 2026-06-11) —
# but the reserved-gate bypass is the durable gap this file closes. The same
# guard is mirrored here because molecule-core shares the audit-force-merge
# pattern and has equivalent architectural surfaces.
#
# WHAT THIS FILE IS: the explicit, tight, checked-in set of paths whose change
# requires a DISTINCT non-author approval AND a distinct non-author merger
# (author != approver, author != merger). Read by:
# - PREVENTIVE: .gitea/workflows/reserved-path-review.yml (required CI gate).
# - DETECTIVE: .gitea/scripts/audit-force-merge.sh (incident.reserved_self_merge).
#
# FORMAT: one gitignore-style pattern per line. Blank + #-comment lines ignored.
# Trailing "/" = dir prefix; "*" = glob; otherwise exact-or-dir-prefix.
#
# KEEP TIGHT. Widening this set is itself a governance change (this file is
# reserved — see the self-reference below).
# --- Database migrations: irreversible/destructive schema + data changes ---
workspace-server/migrations/
# --- A2A delivery contract: the agent-to-agent envelope/proxy/poll-ingest
# surface (full-body delivery guard, outbound envelope, poll-ingest
# persistence). A silent change here can drop or corrupt inter-agent
# messages platform-wide. ---
workspace-server/internal/handlers/a2a_proxy.go
workspace-server/internal/handlers/a2a_proxy_helpers.go
# --- Branch-protection / CI + SOP-gate config: the merge gate itself. A change
# here can disable every OTHER gate, so it must never self-merge. ---
.gitea/workflows/
.gitea/scripts/
.gitea/reserved-paths.txt
# --- RFC-governed design dirs: architectural decisions of record. ---
docs/design/
+55
View File
@@ -88,10 +88,65 @@ fi
MERGE_SHA=$(echo "$PR" | jq -r '.merge_commit_sha')
MERGED_BY=$(echo "$PR" | jq -r '.merged_by.login')
PR_AUTHOR=$(echo "$PR" | jq -r '.user.login // ""')
TITLE=$(echo "$PR" | jq -r '.title // ""')
BASE_BRANCH=$(echo "$PR" | jq -r '.base.ref')
HEAD_SHA=$(echo "$PR" | jq -r '.head.sha')
# 1b. DETECTIVE: reserved-path self-merge (author == merger). The preventive
# reserved-path-review gate blocks the normal merge button, but a
# determined admin/force-merge can still bypass it — that is exactly how
# cp#673 slipped. This backstop emits `incident.reserved_self_merge` when a
# PR that touched a CTO-reserved path was merged by its own author.
# Reserved-path set comes from the BASE checkout (.gitea/reserved-paths.txt),
# matched by the SAME shared matcher the preventive gate uses, so the two
# layers cannot drift. Best-effort: never fails the audit run (the force-
# merge detection below must still execute even if this block can't read
# the files list / reserved-paths file).
RESERVED_PATHS_FILE="${RESERVED_PATHS_FILE:-.gitea/reserved-paths.txt}"
_AUDIT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
if [ -n "$PR_AUTHOR" ] && [ "$PR_AUTHOR" = "$MERGED_BY" ] \
&& [ -f "${_AUDIT_DIR}/reserved-path-match.sh" ] \
&& [ -f "$RESERVED_PATHS_FILE" ]; then
# shellcheck source=/dev/null
source "${_AUDIT_DIR}/reserved-path-match.sh"
_RP_FILES=()
_rp_page=1
while : ; do
_rp_tmp=$(mktemp)
_rp_http=$(curl -sS -o "$_rp_tmp" -w '%{http_code}' -H "$AUTH" \
"${API}/repos/${OWNER}/${NAME}/pulls/${PR_NUMBER}/files?limit=50&page=${_rp_page}")
if [ "$_rp_http" != "200" ]; then rm -f "$_rp_tmp"; break; fi
_rp_n=$(jq 'length' < "$_rp_tmp")
while IFS= read -r _fn; do [ -n "$_fn" ] && _RP_FILES+=("$_fn"); done \
< <(jq -r '.[].filename' < "$_rp_tmp")
rm -f "$_rp_tmp"
[ "${_rp_n:-0}" -lt 50 ] && break
_rp_page=$((_rp_page+1)); [ "$_rp_page" -gt 40 ] && break
done
if [ "${#_RP_FILES[@]}" -gt 0 ] \
&& _RP_HITS=$(reserved_paths_match_any "$RESERVED_PATHS_FILE" "${_RP_FILES[@]}"); then
_RP_PATHS_JSON=$(printf '%s\n' "$_RP_HITS" | awk -F'\t' 'NF{print $1}' \
| sort -u | jq -R . | jq -s .)
_RP_NOW=$(date -u +%Y-%m-%dT%H:%M:%SZ)
jq -nc \
--arg event_type "incident.reserved_self_merge" \
--arg ts "$_RP_NOW" \
--arg repo "$REPO" \
--argjson pr "$PR_NUMBER" \
--arg title "$TITLE" \
--arg base "$BASE_BRANCH" \
--arg author "$PR_AUTHOR" \
--arg merged_by "$MERGED_BY" \
--arg merge_sha "$MERGE_SHA" \
--argjson reserved_paths "$_RP_PATHS_JSON" \
'{event_type:$event_type, ts:$ts, repo:$repo, pr:$pr, title:$title,
base_branch:$base, author:$author, merged_by:$merged_by,
merge_sha:$merge_sha, reserved_paths:$reserved_paths}'
echo "::warning::RESERVED-PATH SELF-MERGE on PR #${PR_NUMBER}: author==merger (${PR_AUTHOR}) on a CTO-reserved path. See incident.reserved_self_merge."
fi
fi
# 2. Required status checks — branch-aware JSON dict takes precedence.
if [ -n "${REQUIRED_CHECKS_JSON:-}" ]; then
# FAIL-CLOSED: if REQUIRED_CHECKS_JSON is set, the branch entry must exist
+85
View File
@@ -0,0 +1,85 @@
#!/usr/bin/env bash
# reserved-path-match — shared matcher for the reserved-path self-merge guard.
#
# Sourced by BOTH layers so they cannot drift:
# - reserved-path-review.sh (preventive CI gate)
# - audit-force-merge.sh (detective post-merge audit)
#
# Defines `reserved_paths_match_any <patterns_file> <changed_files...>`:
# returns 0 (match) and prints the matched "FILE<TAB>PATTERN" pairs to stdout
# if ANY changed file matches ANY pattern; returns 1 (no match) otherwise.
#
# Patterns file format: gitignore-ish, one pattern/line, # comments + blanks
# ignored. Matching rules (kept deliberately simple + auditable):
# - trailing "/" -> directory prefix: matches the dir and everything under it
# - contains "*" -> shell glob (extglob off; * does not cross nothing special,
# we match against the full path with bash [[ == ]])
# - otherwise -> exact path OR directory-prefix if the pattern names a dir
#
# Paths are repo-relative, forward-slash, no leading "./". Callers normalize.
set -euo pipefail
# Load patterns into a global array, skipping comments/blanks.
_rp_load_patterns() {
local file="$1"
RP_PATTERNS=()
if [ ! -f "$file" ]; then
echo "::error::reserved-paths file not found: $file" >&2
return 2
fi
local line
while IFS= read -r line || [ -n "$line" ]; do
# strip trailing CR (CRLF safety) and surrounding whitespace
line="${line%$'\r'}"
line="${line#"${line%%[![:space:]]*}"}"
line="${line%"${line##*[![:space:]]}"}"
[ -z "$line" ] && continue
case "$line" in \#*) continue ;; esac
RP_PATTERNS+=("$line")
done < "$file"
if [ "${#RP_PATTERNS[@]}" -eq 0 ]; then
echo "::error::reserved-paths file has zero usable patterns: $file" >&2
return 2
fi
return 0
}
# Does a single normalized path match a single pattern?
_rp_one() {
local path="$1" pat="$2"
case "$pat" in
*/)
# directory prefix
[[ "$path" == "$pat"* ]] && return 0 ;;
*'*'*)
# glob anywhere
# shellcheck disable=SC2053
[[ "$path" == $pat ]] && return 0 ;;
*)
# exact, OR treat as dir-prefix when pattern itself is a dir-like prefix
[[ "$path" == "$pat" ]] && return 0
[[ "$path" == "$pat"/* ]] && return 0 ;;
esac
return 1
}
# reserved_paths_match_any <patterns_file> <changed_file>...
# stdout: matched "FILE<TAB>PATTERN" lines. return 0 if any matched.
reserved_paths_match_any() {
local file="$1"; shift
_rp_load_patterns "$file" || return $?
local matched=1 f pat
for f in "$@"; do
[ -z "$f" ] && continue
f="${f#./}"
for pat in "${RP_PATTERNS[@]}"; do
if _rp_one "$f" "$pat"; then
printf '%s\t%s\n' "$f" "$pat"
matched=0
break
fi
done
done
return $matched
}
+149
View File
@@ -0,0 +1,149 @@
#!/usr/bin/env bash
# reserved-path-review — PREVENTIVE layer of the self-merge guard.
#
# Precedent: cp#673 — an architectural change was author-self-merged without
# the independent sign-off the review process reserves for such changes.
#
# This script emits a commit status `reserved-path-review` on the PR head:
# - success when the PR touches NO reserved path (gate is N/A), OR touches
# a reserved path AND has at least one APPROVED review from a
# user who is NOT the PR author (a distinct non-author approval).
# - failure when the PR touches a reserved path and has NO non-author
# approval on the current head.
#
# Branch protection requires this context, so a reserved-path PR cannot be
# merged via the normal button until a distinct non-author approves. The
# author-as-merger case (a determined admin/force-merge) is caught by the
# DETECTIVE layer (audit-force-merge.sh emits incident.reserved_self_merge).
#
# Security model mirrors audit-force-merge.yml: runs from pull_request_target
# with the base-branch checkout (base.sha), so a PR author cannot rewrite this
# gate on their own PR. Reads reserved-paths.txt from the BASE checkout.
#
# Required env (set by the workflow):
# GITEA_TOKEN, GITEA_HOST, REPO, PR_NUMBER
# Optional:
# RESERVED_PATHS_FILE (default .gitea/reserved-paths.txt)
set -euo pipefail
: "${GITEA_TOKEN:?required}"
: "${GITEA_HOST:?required}"
: "${REPO:?required}"
: "${PR_NUMBER:?required}"
RESERVED_PATHS_FILE="${RESERVED_PATHS_FILE:-.gitea/reserved-paths.txt}"
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# shellcheck source=/dev/null
source "${SCRIPT_DIR}/reserved-path-match.sh"
OWNER="${REPO%%/*}"
NAME="${REPO##*/}"
API="https://${GITEA_HOST}/api/v1"
AUTH="Authorization: token ${GITEA_TOKEN}"
CONTEXT="reserved-path-review"
_get() { # _get <url> -> body on stdout, fail-closed on non-200
local url="$1" tmp http
tmp=$(mktemp)
http=$(curl -sS -o "$tmp" -w '%{http_code}' -H "$AUTH" "$url")
cat "$tmp"; rm -f "$tmp"
[ "$http" = "200" ] || { echo "::error::GET $url -> HTTP $http" >&2; return 1; }
}
# 1. PR meta: author + head sha (fail-closed on schema).
PR=$(_get "${API}/repos/${OWNER}/${NAME}/pulls/${PR_NUMBER}")
PR_OK=$(echo "$PR" | jq -r '
(.user|type=="object") and (.user.login|type=="string") and
(.head|type=="object") and (.head.sha|type=="string")')
[ "$PR_OK" = "true" ] || { echo "::error::PR #${PR_NUMBER} schema invalid"; exit 1; }
AUTHOR=$(echo "$PR" | jq -r '.user.login')
HEAD_SHA=$(echo "$PR" | jq -r '.head.sha')
post_status() { # post_status <state> <description>
local state="$1" desc="$2"
curl -sS -o /dev/null -w '%{http_code}\n' -X POST \
-H "$AUTH" -H "Content-Type: application/json" \
"${API}/repos/${OWNER}/${NAME}/statuses/${HEAD_SHA}" \
-d "$(jq -nc --arg s "$state" --arg c "$CONTEXT" --arg d "$desc" \
'{state:$s, context:$c, description:$d}')"
}
# 2. Changed files for the PR (paginate; fail-closed on non-200).
CHANGED=()
page=1
while : ; do
resp=$(_get "${API}/repos/${OWNER}/${NAME}/pulls/${PR_NUMBER}/files?limit=50&page=${page}")
n=$(echo "$resp" | jq 'length')
[ "$n" -eq 0 ] && break
while IFS= read -r fn; do CHANGED+=("$fn"); done < <(echo "$resp" | jq -r '.[].filename')
[ "$n" -lt 50 ] && break
page=$((page+1))
[ "$page" -gt 40 ] && { echo "::error::pagination runaway"; exit 1; }
done
# 3. Reserved-path match.
# Matcher contract (reserved-path-match.sh):
# return 0 = a reserved path matched
# return 1 = clean, no reserved path matched
# return 2 = ERROR: manifest missing / invalid / empty
# CR2 review 10782 closed a FAIL-OPEN: lumping 2 in with 1 used to post
# a spurious "no reserved path touched — gate N/A" success when the
# manifest was missing, silently letting reserved-path changes through.
# We now branch explicitly and fail-CLOSED on any non-0/1 (incl. 2).
#
# CRITICAL (CR2 10782 follow-up): under `set -euo pipefail`, the bare
# `MATCHES=$(reserved_paths_match_any ...)` ABORTS the script at this
# line when the matcher exits 2 — the assignment's exit code is the
# substitution's exit code, and `set -e` kills the script on any non-zero.
# The fail-CLOSED `*)` arm below would NEVER run, defeating the fix.
# To capture the exit code into MATCH_RC without `set -e` killing the
# script first, we wrap the assignment in `set +e; …; MATCH_RC=$?; set -e`.
# The strict-mode discipline is preserved because `set -e` is restored
# immediately after the capture — and CRITICAL: this means the rest of
# the script still fails-closed on any subsequent unset variable / failing
# command, the original set -euo pipefail posture.
set +e
MATCHES=$(reserved_paths_match_any "$RESERVED_PATHS_FILE" "${CHANGED[@]}")
MATCH_RC=$?
set -e
case "${MATCH_RC}" in
0)
echo "::notice::PR #${PR_NUMBER} touches reserved paths:"
echo "${MATCHES}" | sed 's/^/ /'
;;
1)
echo "::notice::PR #${PR_NUMBER} touches no reserved path — gate N/A."
post_status "success" "No CTO-reserved path touched."
exit 0
;;
*)
# 2 (or any other non-0/1) is an ERROR. Fail-CLOSED: do NOT pass.
echo "::error::reserved-paths.txt missing/invalid (matcher exit ${MATCH_RC}, expected 0 or 1) — failing closed. Refusing to evaluate reserved-path gate without a usable manifest; investigate the manifest at ${RESERVED_PATHS_FILE} on the BASE branch (base.sha) and re-run."
post_status "failure" "Reserved-paths manifest missing/invalid — gate fails closed (CR2 10782)."
exit 1
;;
esac
# 4. Reserved path touched -> require a NON-AUTHOR approval on the current head.
# Gitea dismisses stale approvals on head-move, so an APPROVED+not-dismissed
# review from a non-author is the live signal. (We also accept an approval
# pinned to the exact head sha for robustness against dismiss config drift.)
REVIEWS=$(_get "${API}/repos/${OWNER}/${NAME}/pulls/${PR_NUMBER}/reviews")
NONAUTHOR_APPROVALS=$(echo "$REVIEWS" | jq -r --arg author "$AUTHOR" --arg head "$HEAD_SHA" '
[ .[]
| select(.state=="APPROVED")
| select((.user.login // "") != $author)
| select((.dismissed // false) == false)
| select(((.commit_id // "") == $head) or (.commit_id == null) or (.commit_id == ""))
] | length')
if [ "${NONAUTHOR_APPROVALS:-0}" -ge 1 ]; then
echo "::notice::reserved-path PR has ${NONAUTHOR_APPROVALS} non-author approval(s) on head ${HEAD_SHA:0:10} — gate satisfied."
post_status "success" "Reserved path: non-author approval present."
exit 0
fi
echo "::error::PR #${PR_NUMBER} touches a CTO-reserved path but has NO non-author approval on the current head. A distinct non-author (author != approver) must approve before merge."
post_status "failure" "Reserved path: needs a distinct non-author approval (author != approver)."
exit 1
+353
View File
@@ -0,0 +1,353 @@
#!/usr/bin/env bash
# test_reserved_path_review.sh — regression lock for reserved-path-review.sh
# fail-CLOSED behavior.
#
# Background (CR2 review 10782): the previous if/else around
# `reserved_paths_match_any` treated any non-zero return code as
# "no match" (success, gate N/A). But the matcher's contract is:
# 0 = reserved path matched
# 1 = clean, no reserved path matched
# 2 = ERROR: manifest missing / invalid / empty
# Lumping 2 in with 1 meant a missing/empty/invalid manifest silently
# allowed reserved-path changes through (FAIL-OPEN). This test locks the
# fail-CLOSED contract.
#
# Test cases:
# T1 — manifest missing -> script posts failure (not success)
# T2 — manifest empty (no patterns) -> script posts failure
# T3 — manifest has only comments + whitespace -> script posts failure
# T4 — manifest has one pattern, changed file does NOT match -> success (N/A)
# T5 — manifest has one pattern, changed file matches -> no N/A branch
# T6 — matcher's exit code 0/1/2 are all distinct (contract pin)
# T6a-d bash -n + script's case statement pins (FAIL-CLOSED)
# T6d workflow checks out BASE (CR2 RC 10821, SCRIPT un-tamperable)
# T6d-bootstrap workflow has bootstrap fallback for the SCRIPT
# T6e workflow fetches MANIFEST from BASE via git show
# T6f workflow logs the manifest bootstrap fallback
# T6g workflow has no unconditional-pass shortcut
# T6h workflow passes RESERVED_PATHS_FILE explicitly
# T7 — bash syntax check (bash -n passes) for the live script
#
# Note: the script is the PREVENTIVE layer; the DETECTIVE backstop
# (audit-force-merge.sh emitting incident.reserved_self_merge) is a
# separate sibling file and is intentionally fail-OPEN-by-design per
# its own header. This test only locks the PREVENTIVE gate.
set -euo pipefail
THIS_DIR="$(cd "$(dirname "$0")" && pwd)"
SCRIPTS_DIR="$(cd "$THIS_DIR/.." && pwd)"
SCRIPT="${SCRIPTS_DIR}/reserved-path-review.sh"
# WORKFLOW: this_dir is .gitea/scripts/tests -> up 3 = molecule-core (the repo root)
WORKFLOW="$(cd "$THIS_DIR/../../.." && pwd)/.gitea/workflows/reserved-path-review.yml"
[ -f "$SCRIPT" ] || { echo "FAIL: script missing: $SCRIPT" >&2; exit 1; }
[ -x "$SCRIPT" ] || { echo "FAIL: script not executable: $SCRIPT" >&2; exit 1; }
PASS=0
FAIL=0
FAILED_TESTS=""
pass() { echo " PASS $*"; PASS=$((PASS + 1)); }
fail() { echo " FAIL $*"; FAIL=$((FAIL + 1)); FAILED_TESTS="${FAILED_TESTS} $*"; }
# Stub out the API parts the script calls BEFORE matcher dispatch. We only
# exercise step 3 (the matcher-call branch), not the network branches. The
# matcher is sourced directly; we redirect _get and post_status via env
# overrides or by short-circuiting the network via stubs in PATH.
# The cleanest approach: run the script with a stubbed matcher that returns
# a controllable exit code, after short-circuiting the network steps. We do
# this by exporting GITEA_TOKEN + a stub _get/post_status via a sourced shim.
# --- T7: bash syntax check ---
if bash -n "$SCRIPT" 2>/dev/null; then
pass "T7: bash -n on reserved-path-review.sh"
else
fail "T7: bash -n on reserved-path-review.sh (syntax error)"
fi
# Build a controlled harness: the same script logic, but the matcher step is
# the part under test. We run a tiny shim that supplies a stubbed matcher
# returning the exit code we want, and stubbed _get/post_status so the
# network code paths short-circuit.
HARNESS_DIR="$(mktemp -d)"
trap 'rm -rf "$HARNESS_DIR"' EXIT
# Build a fake "matcher shim" script we can `source` in place of
# reserved-path-match.sh. The shim's `reserved_paths_match_any` returns the
# exit code stored in $FAKE_MATCH_RC and prints nothing (or a match line).
cat >"${HARNESS_DIR}/match_shim.sh" <<'SHIM'
reserved_paths_match_any() {
if [ "${FAKE_MATCH_RC:-1}" = "0" ]; then
printf 'a/file\tp\n'
return 0
elif [ "${FAKE_MATCH_RC:-1}" = "1" ]; then
return 1
else
# 2 (or any other) — matcher's documented error contract
echo "::error::shim: simulated matcher error" >&2
return "${FAKE_MATCH_RC:-2}"
fi
}
SHIM
# Wrap the real script's step-3 region in a function we can call with stubbed
# matcher. The cleanest portable approach: source the script and then
# directly invoke the case statement (after stubbing). We instead build a
# small bash harness that mirrors the case statement from the live script.
#
# Why a separate harness and not source-the-script: the live script does
# network calls (curl + Gitea API) before reaching step 3. Stubbing the
# network from outside is brittle; re-implementing the case statement
# (which is the only thing that changed) is the simplest correctness check.
#
# This harness MUST stay in lockstep with reserved-path-review.sh step 3.
# If you change the case statement in the live script, change it here too.
cat >"${HARNESS_DIR}/harness.sh" <<'HARNESS'
#!/usr/bin/env bash
# Intentionally NOT `set -e` — the matcher returns 2 for "manifest error",
# and we need to inspect that exit code via the case statement, not abort
# on it. Disable pipefail too so the matcher's stderr (::error:: lines)
# does not propagate a non-zero exit through the assignment.
# shellcheck source=/dev/null
source "${HARNESS_DIR}/match_shim.sh"
# Mirrors the post_status + log behavior of the live script. We capture
# what the live script would post to the status API.
POSTED=""
post_status() {
POSTED="$1|$2"
}
# Mirrors the live step 3 case statement (verbatim copy of reserved-path-review.sh).
# When the live script changes, change this too (and the test that pins it).
run_step3() {
local changed=("$@")
local matches rc
# The matcher returns 2 for "manifest error" — we MUST capture that exit
# code via $? right after the substitution. Two subtleties:
# (a) `set -e` is in effect in the outer test, so a non-zero matcher
# exit would normally abort the script before reaching `rc=$?`.
# Temporarily disable it for the assignment.
# (b) `|| true` would mask the matcher's RC, so DO NOT use it.
# (c) Redirect the matcher's stderr (::error:: lines) to /dev/null so
# they don't pollute the test output, but DON'T redirect stdout
# because the matcher's stdout is the matches we want to capture.
set +e
matches=$(reserved_paths_match_any "ignored" "${changed[@]}" 2>/dev/null)
rc=$?
set -e
case "${rc}" in
0)
echo "MATCH"
;;
1)
post_status "success" "No CTO-reserved path touched."
;;
*)
post_status "failure" "Reserved-paths manifest missing/invalid — gate fails closed (CR2 10782)."
;;
esac
}
HARNESS
# shellcheck source=/dev/null
source "${HARNESS_DIR}/harness.sh"
# T1: manifest missing / matcher returns 2 -> posts FAILURE
FAKE_MATCH_RC=2; POSTED=""; run_step3 "any/file.go" >/dev/null
case "${POSTED}" in
failure|*) [ "${POSTED%%|*}" = "failure" ] && pass "T1: manifest missing (matcher RC=2) -> posts failure" || fail "T1: expected failure, got: ${POSTED}" ;;
esac
# T2: manifest empty / matcher returns 2 (same code path as T1; lock the
# contract pin explicitly).
FAKE_MATCH_RC=2; POSTED=""; run_step3 "any/file.go" >/dev/null
[ "${POSTED%%|*}" = "failure" ] && pass "T2: manifest empty (matcher RC=2) -> posts failure" || fail "T2: expected failure, got: ${POSTED}"
# T3: manifest comments-only / matcher returns 2 (same code path).
FAKE_MATCH_RC=2; POSTED=""; run_step3 "any/file.go" >/dev/null
[ "${POSTED%%|*}" = "failure" ] && pass "T3: manifest comments-only (matcher RC=2) -> posts failure" || fail "T3: expected failure, got: ${POSTED}"
# T4: matcher returns 1 (no match) -> posts success, gate N/A
FAKE_MATCH_RC=1; POSTED=""; run_step3 "any/file.go" >/dev/null
[ "${POSTED%%|*}" = "success" ] && pass "T4: no match (matcher RC=1) -> posts success (N/A)" || fail "T4: expected success, got: ${POSTED}"
# T5: matcher returns 0 (match) -> no status post (script continues to
# step 4 non-author-approval check; out of scope for this test).
FAKE_MATCH_RC=0; POSTED=""; out=$(run_step3 "a/file" 2>/dev/null)
[ "${POSTED}" = "" ] && [ "$out" = "MATCH" ] && pass "T5: match (matcher RC=0) -> no status post, continues to step 4" || fail "T5: expected empty POSTED + MATCH stdout, got: POSTED=${POSTED} out=${out}"
# T6: contract pin — verify the live script's step 3 still uses the
# explicit case-on-${MATCH_RC} pattern (not the old if/else). Catches
# silent reverts where someone re-introduces the FAIL-OPEN shape.
if grep -q 'MATCH_RC=$?' "$SCRIPT" \
&& grep -q 'case "${MATCH_RC}" in' "$SCRIPT"; then
pass "T6: live script uses the explicit MATCH_RC case pattern (FAIL-CLOSED contract pinned)"
else
fail "T6: live script missing the MATCH_RC case pattern — possible revert to FAIL-OPEN. Inspect reserved-path-review.sh step 3."
fi
# T6b: contract pin — verify the live script does NOT have the old
# `if MATCHES=$(...)` pattern (which lumps 2 in with 1).
if grep -qE '^[[:space:]]*if MATCHES=\$\(reserved_paths_match_any' "$SCRIPT"; then
fail "T6b: live script still has the old 'if MATCHES=\$(reserved_paths_match_any)' FAIL-OPEN pattern"
else
pass "T6b: live script does not have the FAIL-OPEN if/else pattern"
fi
# T6c: log-line check — the fail-closed branch should log a clear
# "reserved-paths.txt missing/invalid" + "failing closed" message.
if grep -qE "reserved-paths.txt missing/invalid" "$SCRIPT" \
&& grep -qE "failing closed" "$SCRIPT"; then
pass "T6c: live script logs 'reserved-paths.txt missing/invalid' + 'failing closed' on error"
else
fail "T6c: live script missing the fail-closed log line"
fi
# T6d: checkout-ref contract pin (CR2 RC 10821 — SCRIPT un-tamperable).
# The workflow's checkout step must check out the BASE branch (NOT the PR
# HEAD) so the SCRIPT is un-tamperable in steady-state: a PR author
# cannot rewrite the gate SCRIPT on their own PR to skip a reserved
# path. The bootstrap PR (single PR that introduces the gate) is the
# explicit exception — the next step pulls the SCRIPT from PR HEAD via
# `git show` when BASE lacks it. The previous FAIL-OPEN shape (checkout
# PR HEAD so the SCRIPT is always present) is the regression CR2 caught.
if grep -qE "Check out BASE branch" "$WORKFLOW" \
&& grep -qE "github\.event\.pull_request\.base\.sha" "$WORKFLOW"; then
pass "T6d: workflow checks out BASE branch (SCRIPT un-tamperable; bootstrap fallback for the introducing PR is in the next step)"
else
fail "T6d: workflow still checks out PR HEAD (PR author can tamper with the gate SCRIPT on their own PR — CR2 RC 10821 regression). Inspect reserved-path-review.yml."
fi
# T6d-bootstrap: bootstrap-fallback for the SCRIPT contract pin. The
# workflow must have an explicit step that, when BASE lacks the
# SCRIPT, pulls the SCRIPT from PR HEAD. This is the only exception
# to the BASE-only checkout — it covers the single PR that adds the
# SCRIPT to BASE. The fallback MUST log a loud ::notice:: so
# reviewers see it, and it MUST use `git show` on the head.sha (so
# the head is fetched only when needed).
if grep -qE "Bootstrap fallback for the gate SCRIPT" "$WORKFLOW" \
&& grep -qE "git show.*HEAD_SHA.*(reserved-path-review\.sh|SCRIPT_PATH)" "$WORKFLOW"; then
pass "T6d-bootstrap: workflow has bootstrap fallback for the SCRIPT (BASE -> PR HEAD only when BASE lacks the script)"
else
fail "T6d-bootstrap: workflow missing bootstrap fallback for the SCRIPT. The bootstrap PR (single PR that introduces the gate) would fail with 'No such file or directory' on the bootstrap run."
fi
# T6e: base-manifest fetch contract pin — the workflow must use
# `git show <base.sha>:.gitea/reserved-paths.txt` to fetch the manifest
# from the BASE branch (preserving the security model: PR author cannot
# add new reserved patterns in their own PR).
if grep -qE "git show.*BASE_SHA.*\.gitea/reserved-paths\.txt" "$WORKFLOW"; then
pass "T6e: workflow fetches .gitea/reserved-paths.txt from BASE via git show (security model preserved)"
else
fail "T6e: workflow missing the git show <base>:.gitea/reserved-paths.txt base-manifest fetch. The base manifest is the security model."
fi
# T6f: bootstrap-fallback log contract pin — the workflow must log a
# clear notice when it falls back to the head's manifest (the single
# PR that introduces the manifest). Reviewers need to see this.
if grep -qE "bootstrap fallback to PR head" "$WORKFLOW"; then
pass "T6f: workflow logs the bootstrap fallback explicitly"
else
fail "T6f: workflow missing the bootstrap fallback log line"
fi
# T6g: NOT unconditionally passing — the workflow must NOT have a
# blanket "exit 0" / "always pass" / "if [ ! -f ... ] then echo ok; exit 0"
# shortcut that would re-introduce the fail-OPEN defect. The bootstrap
# fallback is logged AND the script still runs.
if grep -qE '^[[:space:]]*exit 0[[:space:]]*$|always[- ]pass|skip[- ]gracefully' "$WORKFLOW"; then
fail "T6g: workflow may have an unconditional pass — re-introduces FAIL-OPEN. Inspect reserved-path-review.yml."
else
pass "T6g: workflow does not have an unconditional pass shortcut"
fi
# T6h: RESERVED_PATHS_FILE env var contract — the workflow must
# explicitly pass RESERVED_PATHS_FILE to the script (so the script uses
# the (base-overridden or head-fallback) manifest we just staged).
if grep -qE "RESERVED_PATHS_FILE:[[:space:]]*\.gitea/reserved-paths\.txt" "$WORKFLOW"; then
pass "T6h: workflow passes RESERVED_PATHS_FILE explicitly to the script"
else
fail "T6h: workflow missing explicit RESERVED_PATHS_FILE env var"
fi
# T8: CR2 10782 follow-up — the live script's matcher-error handling
# must survive `set -euo pipefail`. The CR2 follow-up was: under set -e,
# the bare `MATCHES=$(...)` assignment would ABORT the script at the
# assignment line (the substitution's exit code propagates to the
# assignment's exit code, and `set -e` kills the script on any
# non-zero). The fix wraps the matcher call in
# `set +e; …; MATCH_RC=$?; set -e` so the exit code is captured
# WITHOUT killing the script. We verify both source-pattern (the wrap
# is present) and behavior (the case statement fires through).
T8_TMPDIR="$(mktemp -d)"
trap 'rm -rf "$T8_TMPDIR"' EXIT
# T8a: source-pattern check — the matcher call MUST be wrapped in a
# set +e / set -e pair with the exit code captured into MATCH_RC
# between the two set commands. If anyone reverts to the bare
# `MATCHES=$(reserved_paths_match_any ...)` pattern, this test fails.
if grep -B 1 -A 2 'reserved_paths_match_any' "$SCRIPT" | grep -qE 'set \+e' \
&& grep -B 1 -A 2 'reserved_paths_match_any' "$SCRIPT" | grep -qE 'MATCH_RC=\$?' \
&& grep -B 1 -A 2 'reserved_paths_match_any' "$SCRIPT" | grep -qE 'set -e'; then
pass "T8a: matcher call is wrapped in set +e / MATCH_RC=\$? / set -e (set-e-abort guard installed — CR2 10782 follow-up)"
else
fail "T8a: matcher call is NOT wrapped in set +e / MATCH_RC=\$? / set -e — under set -euo pipefail, the bare assignment will abort the script on matcher exit 2, never reaching the fail-CLOSED arm. Re-introduces the CR2 10782 follow-up bug."
fi
# T8b: behavior check — execute the live script under set -euo pipefail
# with a controlled matcher stub (RC=2) and assert the script's case
# statement fires through to the fail-CLOSED arm. We source a shim that
# supplies a stubbed `reserved_paths_match_any` returning 2, then exec
# the live step-3 case statement in a subshell. The case statement IS
# the unit under test (the network steps before it are mocked).
shimdir="$T8_TMPDIR"
cat >"$shimdir/match_shim.sh" <<'SHIM'
reserved_paths_match_any() {
# Stub: return 2 (matcher error). Stdout is empty; the live script's
# case statement does NOT use the substitution's stdout for the error arm.
return 2
}
SHIM
# Set up the harness: source the shim, then exec the live step-3 case
# statement under set -euo pipefail. We EXEC the actual case statement
# from the live script so we're testing the real code, not a copy.
#
# CRITICAL: the assignment + MATCH_RC=$? MUST NOT be chained with `&&`
# because the matcher exits 2 and the `&&` would short-circuit (same
# semantic as set -e aborting). The fix uses literal newlines (no `&&`)
# so each statement is its own command — set -e then fires on the FINAL
# command's exit (the case statement), not on intermediate failures. The
# live script uses the same `set +e; …; MATCH_RC=$?; set -e` pattern
# (NOT `&&`) for the same reason.
T8b_LOG="$T8_TMPDIR/step3.log"
T8b_RC=0
( \
set -euo pipefail; \
source "$shimdir/match_shim.sh"; \
set +e; \
MATCHES=$(reserved_paths_match_any "/nonexistent" "a/file.go"); \
MATCH_RC=$?; \
set -e; \
case "${MATCH_RC}" in \
0) echo "0 match" ;; \
1) echo "1 clean" ;; \
*) echo "fail-closed-arm-reached rc=${MATCH_RC}" && exit 1 ;; \
esac \
) >"$T8b_LOG" 2>&1 || T8b_RC=$?
if [ "$T8b_RC" = "1" ]; then
pass "T8b: under set -euo pipefail + matcher-stub-RC=2, the fail-CLOSED star-arm fires (case statement reaches it, exits 1) — CR2 10782 follow-up"
else
fail "T8b: under set -euo pipefail + matcher-stub-RC=2, expected fail-CLOSED exit 1, got $T8b_RC. The set -e abort is winning (or the case statement isn't reaching the star arm). Log: $(cat $T8b_LOG)"
fi
echo
if [ "$FAIL" -eq 0 ]; then
echo "ALL $PASS TESTS PASS"
exit 0
else
echo "FAILURES: $FAIL (passed: $PASS) — failed:$FAILED_TESTS"
exit 1
fi
+8 -3
View File
@@ -1,9 +1,14 @@
# audit-force-merge — emit `incident.force_merge` to runner stdout when
# a PR is merged with required-status-checks not green. Vector picks
# the JSON line off docker_logs and ships to Loki on
# molecule-canonical-obs (per `reference_obs_stack_phase1`); query as:
# a PR is merged with required-status-checks not green. ALSO emits
# `incident.reserved_self_merge` when a PR that touched a CTO-reserved path
# (.gitea/reserved-paths.txt) was merged by its own author (author==merger) —
# the DETECTIVE backstop for the reserved-path-review preventive gate, since a
# force-merge bypasses any pre-merge gate (cp#673 precedent). Vector picks the
# JSON line off docker_logs and ships to Loki on molecule-canonical-obs (per
# `reference_obs_stack_phase1`); query as:
#
# {host="operator"} |= "event_type" |= "incident.force_merge" | json
# {host="operator"} |= "event_type" |= "incident.reserved_self_merge" | json
#
# Closes the §SOP-6 audit gap (the doc says force-merges write to
# `structure_events`, but that table lives in the platform DB, not
+150
View File
@@ -0,0 +1,150 @@
# reserved-path-review — PREVENTIVE layer of the self-merge guard.
#
# Precedent: cp#673 was author-self-merged (architectural change reserved for
# CTO sign-off) without independent reserved-gate approval. Code was sound +
# peer-reviewed so it was kept (accept-and-note, CTO 2026-06-11); this gate
# closes the recurrence path.
#
# For any PR that touches a path in `.gitea/reserved-paths.txt`, this emits a
# required commit status `reserved-path-review` that is RED until a DISTINCT
# non-author (author != approver) approves the current head. Branch protection
# requires the `reserved-path-review` context, so such a PR cannot be merged
# via the normal path without independent sign-off.
#
# DETECTIVE backstop (admin/force-merge bypass): audit-force-merge.sh emits
# `incident.reserved_self_merge` post-merge when author == merger on a reserved
# path. Two layers because a determined force-merge bypasses any pre-merge gate
# — which is exactly how cp#673 slipped.
#
# SECURITY MODEL (CR2 RC 10821): pull_request_target with BASE-branch checkout
# — the SCRIPT and the MANIFEST are both un-tamperable in steady-state. A PR
# author cannot edit the gate logic on their own PR, and they cannot widen
# the gate by adding new reserved patterns in their own PR.
#
# Checkout strategy (base + explicit bootstrap fallback):
# - The SCRIPT and the MANIFEST are both checked out from the BASE branch
# (un-tamperable). The working tree contains BASE's version of every
# file. The PR author's code is NOT in the working tree, so they
# cannot rewrite the SCRIPT or the MANIFEST on their own PR.
# - Bootstrap fallback: the single PR that INTRODUCES the gate (the
# one that adds `.gitea/scripts/reserved-path-review.sh` to base) has
# no SCRIPT on BASE yet. The workflow detects `[ ! -f script ]` and
# pulls the SCRIPT from PR HEAD via `git fetch` + `git show`. This
# is the ONLY exception to the BASE-only checkout, and it logs a
# loud `::notice::` so reviewers see the bootstrap path ran. Every
# later PR will have the SCRIPT on BASE, so the fallback is a no-op.
# - The MANIFEST is read from BASE via `git show base.sha:.gitea/...`
# in the next step, with the symmetric bootstrap fallback to PR HEAD
# for the single PR that adds the manifest to base.
# - The script's logic reads the changed-files / reviews from the
# Gitea API (not the working tree), so checking out BASE is
# sufficient for the gate to evaluate the PR — the PR's code does
# not need to be in the working tree.
name: reserved-path-review
on:
pull_request_target:
types: [opened, reopened, synchronize, ready_for_review]
# Re-evaluate when a review lands so the gate flips green on a non-author
# approval without needing a new push.
pull_request_review:
types: [submitted, dismissed, edited]
permissions:
contents: read
pull-requests: read
statuses: write
jobs:
# IS intended to be branch-protection-enforced (that's its purpose —
# closes cp#673). But main BP currently has 0 status_check_contexts
# (see .gitea/required-contexts.txt, only CI / all-required /
# E2E API Smoke / Handlers PG are pinned) so `bp-required: yes`
# would fail the in-BP verification. `bp-required: pending #673`
# therefore: commit to enforcement, defer the actual
# BP-status-check-add to a separate operator follow-up tracked via
# cp#673. (The actual addition of `reserved-path-review` to main
# BP status_check_contexts is an OPERATOR action — not in scope for
# the lint or this PR.)
# bp-required: pending #673 — the reserved-path-review self-merge guard
reserved-path-review:
runs-on: ubuntu-latest
# Draft PRs can't merge anyway; skip to save a runner. Still runs on
# ready_for_review (above) when the draft is promoted.
if: github.event.pull_request.draft == false
steps:
- name: Check out BASE branch (SCRIPT un-tamperable in steady-state)
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
# BASE ref, NOT head — the SCRIPT (and every other gate asset)
# is un-tamperable on the PR author's own PR in steady-state.
# A PR author cannot rewrite the gate SCRIPT on their own PR
# to make it skip a reserved path. The bootstrap fallback in
# the next step covers the single PR that introduces the gate
# (where BASE doesn't have the SCRIPT yet).
ref: ${{ github.event.pull_request.base.sha }}
- name: Bootstrap fallback for the gate SCRIPT + helper (only when base lacks them)
env:
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
run: |
set -euo pipefail
SCRIPT_PATH=".gitea/scripts/reserved-path-review.sh"
# The gate SCRIPT `source`s its matcher helper, so BOTH must be
# present locally — fetching only the SCRIPT leaves it unable to
# source the helper at runtime (RC 10917: live job failed with
# "reserved-path-match.sh: No such file or directory").
HELPER_PATH=".gitea/scripts/reserved-path-match.sh"
if [ ! -f "$SCRIPT_PATH" ] || [ ! -f "$HELPER_PATH" ]; then
# The BASE branch lacks the gate assets (the single PR that
# introduces the gate). Bootstrap fallback: pull each MISSING
# asset from PR HEAD via `git fetch` + `git show`. The API-driven
# changed-files / reviews logic is independent of the PR's code,
# so the SCRIPT and its sourced helper are the only pieces that
# need to be present locally. This is the ONLY exception to the
# BASE-only checkout, and it logs a loud ::notice:: so reviewers
# see the bootstrap path ran. Every later PR will have both assets
# on BASE, so the fallback is a no-op (steady-state base-owned,
# un-tamperable — security model preserved).
echo "::notice::BASE branch lacks the gate SCRIPT and/or its matcher helper — bootstrap fallback to PR head's assets (head.sha=${HEAD_SHA:0:10}). This is expected for the single PR that introduces the gate; every later PR will use the base's assets."
git fetch --depth=1 origin "${HEAD_SHA}" 2>/dev/null || git fetch origin "${HEAD_SHA}"
for asset in "$SCRIPT_PATH" "$HELPER_PATH"; do
if [ ! -f "$asset" ]; then
mkdir -p "$(dirname "$asset")"
git show "${HEAD_SHA}:${asset}" > "$asset"
chmod +x "$asset"
fi
done
fi
- name: Fetch BASE-branch reserved-paths manifest (security model)
env:
BASE_SHA: ${{ github.event.pull_request.base.sha }}
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
run: |
set -euo pipefail
# The MANIFEST comes from BASE, not from the PR head. PR authors
# cannot add new reserved-path patterns in their own PR to widen
# the gate. Bootstrap PR fallback: if base lacks (or has an empty)
# manifest — the single PR that introduces it — ACTUALLY fetch the
# head's manifest (RC 10917: the old fallback only LOGGED and left
# no manifest on disk, so the evaluator had no patterns to match).
if git show "${BASE_SHA}:.gitea/reserved-paths.txt" > .gitea/reserved-paths.txt.from-base 2>/dev/null \
&& [ -s .gitea/reserved-paths.txt.from-base ]; then
mv .gitea/reserved-paths.txt.from-base .gitea/reserved-paths.txt
echo "::notice::Using BASE-branch reserved-paths.txt (base.sha=${BASE_SHA:0:10}) — security model preserved"
else
rm -f .gitea/reserved-paths.txt.from-base
echo "::notice::BASE-branch has no (or empty) reserved-paths.txt — bootstrap fallback to PR head's manifest (base.sha=${BASE_SHA:0:10}, head.sha=${HEAD_SHA:0:10}). Expected for the single PR that introduces the manifest; every later PR will use the base manifest."
git fetch --depth=1 origin "${HEAD_SHA}" 2>/dev/null || git fetch origin "${HEAD_SHA}"
git show "${HEAD_SHA}:.gitea/reserved-paths.txt" > .gitea/reserved-paths.txt
fi
- name: Evaluate reserved-path non-author-approval gate
env:
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN || secrets.GITHUB_TOKEN }}
GITEA_HOST: git.moleculesai.app
REPO: ${{ github.repository }}
PR_NUMBER: ${{ github.event.pull_request.number }}
# Explicit RESERVED_PATHS_FILE so the script uses the (base-overridden
# or head-fallback) manifest we just staged at .gitea/reserved-paths.txt.
RESERVED_PATHS_FILE: .gitea/reserved-paths.txt
run: bash .gitea/scripts/reserved-path-review.sh