fix(git-tools): refuse no-ci assertion without an attributable identity
ci/woodpecker/pr/ci Pipeline was successful
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:
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user