fix(git-tools): refuse no-ci assertion without an attributable identity
ci/woodpecker/pr/ci Pipeline was successful

The --no-ci-expected admin-success path accepted an asserted-by=unknown
pass when MOSAIC_GIT_IDENTITY was unset or empty, so the queue-clear
assertion and its NO_CI_ASSERTED audit record named no one. Refuse such
callers with a distinct ASSERTION_UNATTRIBUTABLE outcome (exit 78, audit
recorded) before queue-clear and before the permission lookup, so an
unattributable caller never triggers that network call. Document exit 78
in the usage text and the tools reference; pin the path with a
regression case (admin stub + env -u MOSAIC_GIT_IDENTITY).
This commit is contained in:
2026-08-23 12:52:44 -05:00
parent 28f4002a70
commit 187d70be12
3 changed files with 57 additions and 3 deletions
@@ -111,7 +111,7 @@ approve path carries the trap.) `pr-review.sh` sends the correct token for the d
Whatever you use, re-read `GET /pulls/{n}/reviews` and assert the state before reporting a verdict Whatever you use, re-read `GET /pulls/{n}/reviews` and assert the state before reporting a verdict
placed. placed.
The guard exits nonzero for any provider-asserted non-green, missing, or malformed CI state. If credentials or the provider are unavailable, it emits `CANNOT_ASSERT` and writes a JSONL audit record. Push degrades to exit 0 so recovery work is not bricked; merge holds with retryable exit 75 until the provider recovers, then self-clears without manual reset. Neither outcome is evidence that CI was clear. For a repository with no CI configured at all, `pr-merge.sh --no-ci-expected` is the sanctioned merge path: it forwards to `ci-queue-wait.sh --no-ci-expected`, which reclassifies a zero-context merge head as queue-clear only when the acting token holds repository admin, and records the assertion (or its refusal) in the same JSONL audit log. `pr-merge.sh` automatically inspects the exact PR head repository and full commit SHA rather than its `main` base; this also handles fork PRs without branch-name ambiguity. Pass `--expect-head <approved-full-sha>` to bind a commit-specific review or merge-gate verdict; Gitea uses atomic `head_commit_id` and GitHub uses `--match-head-commit`. The guard exits nonzero for any provider-asserted non-green, missing, or malformed CI state. If credentials or the provider are unavailable, it emits `CANNOT_ASSERT` and writes a JSONL audit record. Push degrades to exit 0 so recovery work is not bricked; merge holds with retryable exit 75 until the provider recovers, then self-clears without manual reset. Neither outcome is evidence that CI was clear. For a repository with no CI configured at all, `pr-merge.sh --no-ci-expected` is the sanctioned merge path: it forwards to `ci-queue-wait.sh --no-ci-expected`, which reclassifies a zero-context merge head as queue-clear only when the acting token holds repository admin and `MOSAIC_GIT_IDENTITY` names the asserting identity (a caller without one is refused with exit 78 before the admin lookup), and records the assertion (or its refusal) in the same JSONL audit log. `pr-merge.sh` automatically inspects the exact PR head repository and full commit SHA rather than its `main` base; this also handles fork PRs without branch-name ambiguity. Pass `--expect-head <approved-full-sha>` to bind a commit-specific review or merge-gate verdict; Gitea uses atomic `head_commit_id` and GitHub uses `--match-head-commit`.
### Code Review (Codex) ### Code Review (Codex)
@@ -28,7 +28,7 @@ Options:
-i, --interval SECONDS Poll interval in seconds (default: 15) -i, --interval SECONDS Poll interval in seconds (default: 15)
--purpose VALUE Log context: push|merge (default: merge) --purpose VALUE Log context: push|merge (default: merge)
--require-status Fail if no CI status contexts are present --require-status Fail if no CI status contexts are present
--no-ci-expected Assert this repository has no CI configured: a merge guard on a zero-context head becomes queue-clear (requires the acting token to hold repository admin) --no-ci-expected Assert this repository has no CI configured: a merge guard on a zero-context head becomes queue-clear (requires the acting token to hold repository admin); refused with exit 78 when MOSAIC_GIT_IDENTITY is unset or empty
-h, --help Show this help -h, --help Show this help
Examples: Examples:
@@ -597,7 +597,18 @@ while true; do
# the ONLY state the flag reclassifies: a pending or failed # the ONLY state the flag reclassifies: a pending or failed
# context still holds or fails exactly as without it, and a # context still holds or fails exactly as without it, and a
# non-admin token is refused rather than trusted. # non-admin token is refused rather than trusted.
ASSERTED_BY="${MOSAIC_GIT_IDENTITY:-unknown}" # The assertion must name an asserting identity: "unknown"
# attributes nothing, so a caller with MOSAIC_GIT_IDENTITY
# unset or empty is refused (exit 78) BEFORE the permission
# lookup -- an unattributable caller never triggers that
# network call.
if [[ -z "${MOSAIC_GIT_IDENTITY:-}" ]]; then
record_assertion_event "ASSERTION_UNATTRIBUTABLE" "actor-unattributable" "unknown" \
|| echo "Warning: could not write the ASSERTION_UNATTRIBUTABLE audit record; the refusal itself stands." >&2
echo "Error: ASSERTION_UNATTRIBUTABLE state=no-status purpose=merge asserted-by=unknown reason=no-ci-expected branch=${BRANCH}; --no-ci-expected requires MOSAIC_GIT_IDENTITY to name the asserting identity and it is unset or empty (exit 78)." >&2
exit 78
fi
ASSERTED_BY="${MOSAIC_GIT_IDENTITY}"
ADMIN_STATE=2 ADMIN_STATE=2
if [[ "$PLATFORM" == "github" ]]; then if [[ "$PLATFORM" == "github" ]]; then
if github_repo_admin_state "$OWNER" "$REPO"; then ADMIN_STATE=0; else ADMIN_STATE=$?; fi if github_repo_admin_state "$OWNER" "$REPO"; then ADMIN_STATE=0; else ADMIN_STATE=$?; fi
@@ -19,6 +19,9 @@
# unchanged; no admin consultation on push. # unchanged; no admin consultation on push.
# (g) flag + admin lookup unreachable -> CANNOT_ASSERT hold (75), # (g) flag + admin lookup unreachable -> CANNOT_ASSERT hold (75),
# not a silent pass and not a refusal. # not a silent pass and not a refusal.
# (h) flag + admin stub + NO MOSAIC_GIT_IDENTITY -> refusal BEFORE
# queue-clear and BEFORE the admin lookup: exit 78, no queue-clear
# line, an ASSERTION_UNATTRIBUTABLE JSONL record, no repos/ call.
set -u set -u
@@ -111,6 +114,25 @@ run_guard() {
) )
} }
# The suite exports test-identity globally, so the unattributable-caller
# case must strip it from the child environment at invocation with env -u,
# not rely on the export order.
run_guard_no_identity() {
local name="$1"; shift
(
cd "$REPO_DIR" || exit
export PATH="$STUB_DIR:$PATH"
export MOSAIC_CREDENTIALS_FILE="$WORK_DIR/no-credentials.json"
export MOSAIC_CI_QUEUE_AUDIT_LOG="$WORK_DIR/audit-$name.jsonl"
export MOSAIC_STUB_URL_LOG="$URL_LOG"
export GITEA_TOKEN="stub-token"
export GITEA_URL="https://git.example.test"
export MOSAIC_GIT_IDENTITY="test-identity"
env -u MOSAIC_GIT_IDENTITY \
"$SCRIPT_DIR/ci-queue-wait.sh" -B main -t 3 -i 1 "$@"
)
}
expect_rc() { expect_rc() {
local name="$1" want="$2" got="$3" local name="$1" want="$2" got="$3"
if [[ "$want" == "not3" ]]; then if [[ "$want" == "not3" ]]; then
@@ -272,6 +294,27 @@ if ! grep -q '"outcome":"CANNOT_ASSERT"' "$WORK_DIR/audit-g.jsonl" 2>/dev/null;
failures=$((failures + 1)) failures=$((failures + 1))
fi fi
# (h) flag + admin stub + no asserting identity -> refusal before queue-clear
# and before the admin lookup: rc 78, no queue-clear line, an
# ASSERTION_UNATTRIBUTABLE JSONL record, and zero repos/ network calls.
: > "$URL_LOG"
set +e
out_h=$(MOSAIC_STUB_STATUS_MODE=no-status MOSAIC_STUB_ADMIN_MODE=admin run_guard_no_identity h --purpose merge --no-ci-expected 2>&1)
rc_h=$?
set -u
if expect_rc h 78 "$rc_h"; then
expect_text h "ASSERTION_UNATTRIBUTABLE state=no-status purpose=merge asserted-by=unknown reason=no-ci-expected branch=main" "$out_h"
expect_text h "queue-clear" "$out_h" absent
if ! grep -q '"outcome":"ASSERTION_UNATTRIBUTABLE"' "$WORK_DIR/audit-h.jsonl" 2>/dev/null; then
echo "FAIL h: expected an ASSERTION_UNATTRIBUTABLE JSONL audit record" >&2
failures=$((failures + 1))
fi
fi
if repo_root_fetched; then
echo "FAIL h: an unattributable caller must not trigger the permission lookup" >&2
failures=$((failures + 1))
fi
if [[ "$failures" -ne 0 ]]; then if [[ "$failures" -ne 0 ]]; then
echo "ci-queue-wait no-ci-expected regression failed ($failures assertions)" >&2 echo "ci-queue-wait no-ci-expected regression failed ($failures assertions)" >&2
exit 1 exit 1