Compare commits

..
Author SHA1 Message Date
Jason Woltje d55cbd264e fix(git): accept http/https as one scheme class in comment URL verification (#991)
issue-comment.sh and pr-review.sh verify a durable write by pinning the
provider-returned object URL's origin and full path. The origin included the
SCHEME verbatim. On a Gitea whose ROOT_URL is configured `http://` while every
client reaches it over `https://`, the provider returns `http://` object URLs,
so the comparison rejects the provider's own truthful answer about a write that
LANDED. The failure is deterministic, not intermittent: every comment, every
time, on such a deployment.

The scheme was never what the check defends. The forgeries it exists to catch —
look-alike host, decoy path prefix, wrong owner/repo/kind/number — all vary the
HOST or the PATH. Both stay strict. `http` and `https` now collapse to one
scheme class; any other scheme (file:, ftp:, javascript:) stays distinguishing,
and an EXPLICIT non-default port still distinguishes, because a different port
is a different service on the same host.

Consequences of the bug, both observed:

- The wrapper reports failure on a comment that is durably on the issue/PR, and
  attributes it to #865 ("no durable comment created"). The write landed; the
  citation is wrong. Reproduced here: the harness's persisted state contains the
  record while the wrapper exits 1.
- pr-review.sh's comment path is worse. On a host where no seat can create a
  review OBJECT, comment-form is the only gate-16 review record obtainable, and
  this check refuses all of it.

Test gap this closes: every URL fixture in both harnesses was `https://`, and
every negative case varied only host or path. The one axis that fails in
production had zero coverage — the fixtures encoded the assumption that breaks.
Added, in both suites:

- scheme-downgrade (http vs https, otherwise correct) — must be ACCEPTED. Fails
  against the unmodified wrappers, passes against the fixed ones; verified in
  both directions, and the negative control's captured output is the #865
  misattribution above.
- explicit non-default port (`:8443`) — must stay REJECTED.
- non-web scheme (`ftp://`) — must stay REJECTED.

Also fixes test-issue-comment-readback.sh hermeticity (#1007), without which the
suite cannot run on any seat that has a per-agent Gitea token: detect-platform's
step-0 identity lookup reads ~/.config/mosaic/gitea-tokens/<identity>, outside
both XDG_CONFIG_HOME and MOSAIC_CREDENTIALS_FILE, so the suite resolved a
PRODUCTION credential and died at HTTP 401 before case 1. Same two-part fix
already merged for test-pr-review-gitea-comment.sh in #1006: a sandboxed HOME
plus an empty REPO-LOCAL mosaic.gitIdentity to shadow the global. Note the
env-var route does NOT work — detect-platform.sh reads `${MOSAIC_GIT_IDENTITY:-}`
and `:-` treats set-but-empty identically to unset.

The owner-side half of #991 (setting the deployment's Gitea ROOT_URL to https)
is not in scope here and is not made unnecessary by this change; this makes the
wrappers correct against a deployment that returns either scheme.
2026-07-31 06:22:46 -05:00
mos-dt-0andMos 826a8b3b26 fix(git): pr-review.sh — surface the provider's stated reason, drop the hardcoded #865 attribution (#1006)
Co-authored-by: mos-dt-0 <[email protected]>
2026-07-31 10:52:58 +00:00
mos-dt-0andMos a4280b9c98 fix(wake): #984 fatal source guard + #985 absorb re-scan — #973 follow-up batch (#1001)
Co-authored-by: mos-dt-0 <[email protected]>
2026-07-31 10:16:22 +00:00
Mos 4fb44f6345 fix(wake): three-valued grep verdicts — has_match/count_lines across all ten suites (closes #973) (#983) 2026-07-31 08:39:04 +00:00
17 changed files with 427 additions and 99 deletions
@@ -254,15 +254,32 @@ from urllib.parse import urlparse
def _origin_and_path(url):
# Normalize a URL to (scheme, host, effective-port) + comment path. The port
# defaults to the scheme's default (80 http / 443 otherwise) so an implicit
# port and its explicit default form compare equal.
# Normalize a URL to (scheme-class, host, distinguishing-port) + comment path.
#
# #991: http and https collapse into ONE scheme class ("web"). A Gitea whose
# ROOT_URL is configured http:// returns http:// object URLs even when every
# client reaches it over https://, so a scheme-strict comparison rejects the
# provider's own correct answer about a write that landed — a deterministic
# false negative on every comment posted against such a deployment. The
# scheme is also not what this check defends: the forgeries it exists to
# catch (look-alike host, decoy path prefix, wrong owner/repo/number) all
# vary the HOST or the PATH, both of which stay strict below. Any OTHER
# scheme (file:, ftp:, javascript:) remains distinguishing and is rejected.
#
# Port: an implicit port and its own scheme's default compare equal, so
# http://h == https://h. An EXPLICIT non-default port still distinguishes,
# because a different port is a different service on the same host.
parsed = urlparse(url or "")
scheme = (parsed.scheme or "").lower()
host = (parsed.hostname or "").lower()
default_port = 80 if scheme == "http" else 443
port = parsed.port if parsed.port is not None else default_port
return (scheme, host, port), parsed.path.rstrip("/")
if scheme in ("http", "https"):
scheme_class = "web"
default_port = 80 if scheme == "http" else 443
port = None if parsed.port in (None, default_port) else parsed.port
else:
scheme_class = scheme
port = parsed.port
return (scheme_class, host, port), parsed.path.rstrip("/")
try:
@@ -109,6 +109,55 @@ else
detect_platform >/dev/null
fi
# Render the provider's own explanation for a failed request, for appending to
# an error message (#1004). Every HTTP arm in this file already has the response
# body on disk; without this it was discarded unread at exactly the moment the
# caller needed it, which pushes an operator toward re-issuing the request by
# hand to find out what the server said. Gitea returns {"message": "..."} on a
# refusal; anything unparseable falls back to a truncated raw first line so a
# proxy's HTML error page still says something. Prints "" when there is nothing
# to add, so callers can interpolate unconditionally.
#
# Args: $1 = path to the response body file.
gitea_error_detail() {
local body_file="$1"
[[ -s "$body_file" ]] || return 0
python3 - "$body_file" <<'PY' 2>/dev/null || true
import json
import sys
LIMIT = 300
try:
with open(sys.argv[1], encoding="utf-8", errors="replace") as response:
raw = response.read().strip()
except OSError:
raise SystemExit(0)
if not raw:
raise SystemExit(0)
detail = ""
try:
parsed = json.loads(raw)
if isinstance(parsed, dict):
for key in ("message", "error", "errors"):
value = parsed.get(key)
if isinstance(value, str) and value.strip():
detail = value.strip()
break
if isinstance(value, list) and value:
detail = "; ".join(str(item) for item in value).strip()
break
except ValueError:
pass
if not detail:
detail = raw.splitlines()[0].strip()
if not detail:
raise SystemExit(0)
if len(detail) > LIMIT:
detail = detail[:LIMIT] + "..."
print(f" — provider said: {detail}")
PY
}
# Post a comment to a Gitea PR (PR comments ARE issue comments) via the
# supported REST API and verify it against a PROVIDER-RETURNED created id. The
# write is a direct POST that returns the created comment object, so we learn
@@ -150,7 +199,7 @@ print(json.dumps({"body": os.environ["COMMENT_BODY"]}))
return 1
fi
if [[ "$write_status" != "201" ]]; then
echo "Error: Gitea comment write failed with HTTP $write_status" >&2
echo "Error: Gitea comment write failed with HTTP $write_status$(gitea_error_detail "$write_file")" >&2
return 1
fi
@@ -179,7 +228,7 @@ PY
return 1
fi
if [[ "$readback_status" != "200" ]]; then
echo "Error: Gitea comment read-back failed with HTTP $readback_status" >&2
echo "Error: Gitea comment read-back failed with HTTP $readback_status$(gitea_error_detail "$readback_file")" >&2
return 1
fi
@@ -194,15 +243,35 @@ from urllib.parse import urlparse
def _origin_and_path(url):
# Normalize a URL to (scheme, host, effective-port) + comment path. The port
# defaults to the scheme's default (80 http / 443 otherwise) so an implicit
# port and its explicit default form compare equal.
# Normalize a URL to (scheme-class, host, distinguishing-port) + comment path.
#
# #991: http and https collapse into ONE scheme class ("web"). A Gitea whose
# ROOT_URL is configured http:// returns http:// object URLs even when every
# client reaches it over https://, so a scheme-strict comparison rejects the
# provider's own correct answer about a comment that landed — a deterministic
# false negative on EVERY review comment posted against such a deployment.
# That matters more here than anywhere else: on a host where no seat can
# create a review OBJECT, the comment-form review record this path produces
# is the only gate-16 evidence available, and this check refuses all of it.
# The scheme is also not what the check defends: the forgeries it exists to
# catch (look-alike host, decoy path prefix, wrong owner/repo/kind/number)
# all vary the HOST or the PATH, both of which stay strict below. Any OTHER
# scheme (file:, ftp:, javascript:) remains distinguishing and is rejected.
#
# Port: an implicit port and its own scheme's default compare equal, so
# http://h == https://h. An EXPLICIT non-default port still distinguishes,
# because a different port is a different service on the same host.
parsed = urlparse(url or "")
scheme = (parsed.scheme or "").lower()
host = (parsed.hostname or "").lower()
default_port = 80 if scheme == "http" else 443
port = parsed.port if parsed.port is not None else default_port
return (scheme, host, port), parsed.path.rstrip("/")
if scheme in ("http", "https"):
scheme_class = "web"
default_port = 80 if scheme == "http" else 443
port = None if parsed.port in (None, default_port) else parsed.port
else:
scheme_class = scheme
port = parsed.port
return (scheme_class, host, port), parsed.path.rstrip("/")
try:
@@ -370,7 +439,7 @@ gitea_authenticated_login() {
return 1
fi
if [[ "$status" != "200" ]]; then
echo "Error: Gitea authenticated-identity read failed with HTTP $status" >&2
echo "Error: Gitea authenticated-identity read failed with HTTP $status$(gitea_error_detail "$response_file")" >&2
return 1
fi
@@ -407,7 +476,7 @@ gitea_read_pr_head_into() {
return 1
fi
if [[ "$status" != "200" ]]; then
echo "Error: Gitea PR head read failed with HTTP $status" >&2
echo "Error: Gitea PR head read failed with HTTP $status$(gitea_error_detail "$pr_file")" >&2
return 1
fi
python3 - "$pr_file" <<'PY'
@@ -497,7 +566,7 @@ print(json.dumps({
fi
# Gitea returns 200 (occasionally 201) with the created review object.
if [[ "$write_status" != "200" && "$write_status" != "201" ]]; then
echo "Error: Gitea review submit failed with HTTP $write_status (#865: no durable review created)" >&2
echo "Error: Gitea review submit failed with HTTP $write_status$(gitea_error_detail "$write_file")" >&2
return 1
fi
@@ -526,7 +595,7 @@ PY
return 1
fi
if [[ "$readback_status" != "200" ]]; then
echo "Error: Gitea review read-back failed with HTTP $readback_status" >&2
echo "Error: Gitea review read-back failed with HTTP $readback_status$(gitea_error_detail "$readback_file")" >&2
return 1
fi
@@ -41,7 +41,13 @@
# that other host's credential cross-host;
# 10. leaves NO temp files behind (POST/GET bodies + metadata) on either the
# success or the failure path — nested function-scoped RETURN traps do not
# clobber each other and every scratch file is removed on all exit paths.
# clobber each other and every scratch file is removed on all exit paths;
# 11. (#991) ACCEPTS a record whose provider-returned URL differs from the repo
# remote ONLY in http-vs-https — the standing state on a deployment whose
# Gitea ROOT_URL is misconfigured — while still REJECTING a different
# explicit port and a non-web scheme. Every prior fixture here was https://
# and every negative varied only host or path, so the one axis that fails
# in production had zero coverage: the fixtures encoded the assumption.
set -euo pipefail
@@ -61,15 +67,29 @@ STATE_FILE="$WORK_DIR/comments.json"
# A dedicated scratch dir the wrapper is pointed at via TMPDIR, so the leak
# check can assert every POST/GET body + metadata temp file is cleaned up.
TMP_SCRATCH="$WORK_DIR/scratch"
# #1007: a sandboxed HOME. detect-platform.sh's step-0 per-agent identity lookup
# reads ~/.config/mosaic/gitea-tokens/<identity>, which is OUTSIDE both
# XDG_CONFIG_HOME and MOSAIC_CREDENTIALS_FILE — so on any seat that has a real
# per-agent token the suite resolves a PRODUCTION credential and dies at the
# authenticated-identity read (HTTP 401) before reaching case 1. Pointing HOME
# at the work dir keeps that lookup inside the sandbox.
HOME_DIR="$WORK_DIR/home"
cleanup() {
rm -rf "$WORK_DIR"
}
trap cleanup EXIT
mkdir -p "$REPO_DIR" "$BIN_DIR" "$XDG_DIR" "$TMP_SCRATCH"
mkdir -p "$REPO_DIR" "$BIN_DIR" "$XDG_DIR" "$TMP_SCRATCH" "$HOME_DIR"
git -C "$REPO_DIR" init -q
git -C "$REPO_DIR" remote add origin https://git.mosaicstack.dev/mosaicstack/stack.git
# #1007: shadow any GLOBAL `mosaic.gitIdentity` with an empty REPO-LOCAL value.
# A repo-local key shadows the global and reads back empty at rc=0, which is the
# only way to make step 0 fall through inside a sandbox. Note the env-var route
# (MOSAIC_GIT_IDENTITY="") does NOT work: detect-platform.sh reads it with
# `${MOSAIC_GIT_IDENTITY:-}`, and `:-` treats set-but-empty identically to
# unset, so setting it empty is silently the same as not setting it at all.
git -C "$REPO_DIR" config mosaic.gitIdentity ""
ISSUE_NUMBER=7
REPO_SLUG="mosaicstack/stack"
@@ -266,6 +286,19 @@ elif mode == "url-wrong-repo":
elif mode == "url-suffix-injection":
# Prefix-injected: a bare endswith("/<slug>/issues/7") test would ACCEPT this.
issue_url = f"https://git.mosaicstack.dev/deceptive/{repo}/issues/7"
elif mode == "url-scheme-downgrade":
# #991, and the ONLY one of these modes that must be ACCEPTED. A Gitea whose
# ROOT_URL is http:// returns http:// object URLs for a repo cloned over
# https://. Same host, same path, correct record — the provider is telling
# the truth about a write that landed.
issue_url = f"http://git.mosaicstack.dev/{repo}/issues/7"
elif mode == "url-wrong-port":
# An EXPLICIT non-default port is a different service on the same host and
# must stay distinguishing — collapsing the scheme must not collapse this.
issue_url = f"https://git.mosaicstack.dev:8443/{repo}/issues/7"
elif mode == "url-non-web-scheme":
# Only http/https collapse. Any other scheme stays distinguishing.
issue_url = f"ftp://git.mosaicstack.dev/{repo}/issues/7"
record = {
"id": new_id,
"body": body,
@@ -365,6 +398,7 @@ run_comment() {
(
cd "$REPO_DIR"
PATH="$BIN_DIR:$PATH" \
HOME="$HOME_DIR" \
TMPDIR="$TMP_SCRATCH" \
XDG_CONFIG_HOME="$XDG_DIR" \
MOSAIC_CREDENTIALS_FILE="$CREDENTIALS_FILE" \
@@ -545,13 +579,17 @@ if grep -q " $ACTING_LOGIN\$" "$AUTH_LOG"; then
fi
assert_no_temp_leak "cross-host"
# Cases 7-10 (#865 Blocker 3): the created record's id/author/body are all
# correct, but its provider-returned issue_url is forged. Verification pins the
# URL's ORIGIN (scheme+host+effective-port) and its FULL path (deployment prefix
# + exact owner/repo + kind + number), so each forgery must FAIL CLOSED. A bare
# endswith/suffix test would wrongly accept the look-alike-host and
# prefix-injection variants.
for bad_mode in url-wrong-host url-wrong-owner url-wrong-repo url-suffix-injection; do
# Cases 7-12 (#865 Blocker 3): the created record's id/author/body are all
# correct, but its provider-returned issue_url does not belong to this issue on
# this provider/repo. Verification pins the URL's ORIGIN (scheme-class + host +
# explicit non-default port) and its FULL path (deployment prefix + exact
# owner/repo + kind + number), so each must FAIL CLOSED. A bare endswith/suffix
# test would wrongly accept the look-alike-host and prefix-injection variants.
# url-wrong-port and url-non-web-scheme (#991) bound the scheme relaxation from
# the other side: collapsing http/https must not also collapse a different port
# or a different scheme family.
for bad_mode in url-wrong-host url-wrong-owner url-wrong-repo url-suffix-injection \
url-wrong-port url-non-web-scheme; do
if run_comment "$bad_mode"; then
echo "FAIL: forged comment URL ($bad_mode) was accepted" >&2
cat "$OUTPUT_FILE" >&2
@@ -568,4 +606,19 @@ done
# issue_url (already exercised by Case 1's fresh-success), so the tightened check
# is not rejecting genuine writes.
# Case 13 (#991): the deployment's ROOT_URL is http:// while the repo remote is
# https://, so the provider returns an http:// issue_url for a record that is
# otherwise entirely correct. This is not a forgery — it is the provider's own
# truthful answer about a write that landed — and a scheme-strict comparison
# rejects it deterministically, on EVERY comment, converting a successful write
# into a reported failure. It must be ACCEPTED. Host, path, owner, repo, kind
# and number all remain strict; only the http/https distinction is relaxed.
run_comment url-scheme-downgrade
grep -q 'Added and verified comment on Gitea issue #7 (comment ID 1)' "$OUTPUT_FILE"
# The write really happened and was read back by exact id — this case passes
# through the same POST/GET chain as case 1, not around it.
grep -q "^POST $API_BASE/issues/7/comments$" "$CURL_LOG"
grep -q "^GET $API_BASE/issues/comments/1$" "$CURL_LOG"
assert_no_temp_leak "url-scheme-downgrade"
echo "issue-comment.sh REST create + exact-id read-back regression passed"
@@ -58,6 +58,9 @@ CREDENTIALS_FILE="$WORK_DIR/credentials.json"
# A dedicated scratch dir the wrapper is pointed at via TMPDIR, so the leak
# check can assert every POST/GET body + metadata temp file is cleaned up.
TMP_SCRATCH="$WORK_DIR/scratch"
# Sandboxed HOME so nothing under the real $HOME (notably the per-slot Gitea token
# store at ~/.config/mosaic/secrets/gitea-tokens/) is reachable from the wrapper.
HOME_DIR="$WORK_DIR/home"
cleanup() {
rm -rf "$WORK_DIR"
@@ -78,9 +81,18 @@ OVERRIDE_TOKEN="override-token-placeholder"
CROSS_HOST_LOGIN="foreign-host-reviewer"
CROSS_HOST_TOKEN="cross-host-token-placeholder"
mkdir -p "$REPO_DIR" "$BIN_DIR" "$XDG_DIR" "$STATE_DIR" "$TMP_SCRATCH"
mkdir -p "$REPO_DIR" "$BIN_DIR" "$XDG_DIR" "$STATE_DIR" "$TMP_SCRATCH" "$HOME_DIR"
git -C "$REPO_DIR" init -q
git -C "$REPO_DIR" remote add origin https://git.mosaicstack.dev/mosaicstack/stack.git
# HERMETICITY: get_gitea_token() step 0 resolves a per-agent identity from
# `git config --get mosaic.gitIdentity`, which on a provisioned agent seat is set
# GLOBALLY and therefore leaks into this fresh repo. It then reads a REAL per-slot
# token from $HOME and returns it WITHOUT ever consulting MOSAIC_CREDENTIALS_FILE,
# so the fixture credentials below are silently ignored and the suite runs against
# production credentials. An empty repo-local value shadows the global one and reads
# back as empty at rc=0, restoring the shared-credential path this suite intends to
# exercise. Paired with the sandboxed HOME in run_review().
git -C "$REPO_DIR" config mosaic.gitIdentity ""
# tea config: the override login carries its own token here. The default login
# name ("mosaicstack") is deliberately absent, so the no-override default path
@@ -266,6 +278,24 @@ submitted = json.loads(os.environ["PR_REVIEW_PAYLOAD"])
with open(state_path, encoding="utf-8") as handle:
reviews = json.load(handle)
# review-refused-422 (#1004): the server REFUSES the submit outright with a
# definite, correct, machine-readable reason in the body — the shape Gitea
# returns when the acting credential authored the PR. Nothing is created. The
# wrapper must surface what the server said and must NOT relabel this as the
# #865 silent-no-op defect class, which is precisely what it is not.
if mode == "review-refused-422":
print("422")
print(json.dumps({"message": "Cannot approve your own pull request"}))
raise SystemExit(0)
# review-refused-html (#1004): a non-JSON error body, as a fronting proxy or
# gateway emits. The detail extraction must degrade to the first raw line rather
# than silently dropping the only explanation available.
if mode == "review-refused-html":
print("502")
print("<html><head><title>502 Bad Gateway</title></head>\n<body>nginx</body></html>")
raise SystemExit(0)
# no-op-concurrent-review: the wrapper's own submit is SUPPRESSED (200, no
# created object) even though a concurrent same-identity, same-state review at
# the same head already exists. Nothing is persisted; no created id to verify.
@@ -406,6 +436,19 @@ elif mode == "comment-url-wrong-repo":
elif mode == "comment-url-suffix-injection":
# Prefix-injected: a bare endswith("/<slug>/pulls/123") test would ACCEPT it.
pr_url = f"{_origin}/deceptive{_slug}/pulls/123"
elif mode == "comment-url-wrong-port":
# #991 bound: an EXPLICIT non-default port is a different service on the same
# host. Relaxing http-vs-https must NOT relax this.
pr_url = f"{_p.scheme}://{_p.hostname}:8443{_slug}/pulls/123"
elif mode == "comment-url-non-web-scheme":
# #991 bound: ONLY http/https collapse; any other scheme stays distinguishing.
pr_url = f"ftp://{_p.netloc}{_slug}/pulls/123"
elif mode == "comment-url-scheme-downgrade":
# #991, and the only URL mode here that must be ACCEPTED. A Gitea whose
# ROOT_URL is http:// returns http:// object URLs for a repo reached over
# https://. Same host, same path, correct record — a truthful provider
# answer about a comment that landed, not a forgery.
pr_url = f"http://{_p.netloc}{_slug}/pulls/123"
elif mode == "comment-mixed-case-slug":
# #875: EXPECTED_REPO_SLUG is taken verbatim from GITEA_API_BASE and can be
# mixed-case (e.g. "USC/uconnect"), but Gitea canonicalizes the returned
@@ -517,6 +560,8 @@ run_review() {
cd "$REPO_DIR"
PATH="$BIN_DIR:$PATH" \
TMPDIR="$TMP_SCRATCH" \
HOME="$HOME_DIR" \
MOSAIC_GIT_IDENTITY="" \
XDG_CONFIG_HOME="$XDG_DIR" \
MOSAIC_CREDENTIALS_FILE="$CREDENTIALS_FILE" \
PR_REVIEW_TEA_LOG="$TEA_LOG" \
@@ -858,11 +903,16 @@ fi
assert_no_temp_leak "review-body-reuse"
# Cases 12-15 (#865 Blocker 3): a PR comment whose id/author/body are all correct
# but whose provider-returned pull_request_url is forged must FAIL CLOSED.
# Verification pins the URL's ORIGIN (scheme+host+effective-port) and FULL path
# (deployment prefix + exact owner/repo + kind + number); a bare endswith/suffix
# test would wrongly accept the look-alike-host and prefix-injection variants.
for bad_mode in comment-url-wrong-host comment-url-wrong-owner comment-url-wrong-repo comment-url-suffix-injection; do
# but whose provider-returned pull_request_url does not belong to this PR must
# FAIL CLOSED. Verification pins the URL's ORIGIN (scheme-class + host + explicit
# non-default port) and FULL path (deployment prefix + exact owner/repo + kind +
# number); a bare endswith/suffix test would wrongly accept the look-alike-host
# and prefix-injection variants. comment-url-wrong-port and
# comment-url-non-web-scheme (#991) bound the scheme relaxation from the other
# side: collapsing http/https must not also collapse a different port or a
# different scheme family.
for bad_mode in comment-url-wrong-host comment-url-wrong-owner comment-url-wrong-repo \
comment-url-suffix-injection comment-url-wrong-port comment-url-non-web-scheme; do
if run_review "$bad_mode" comment durable-body; then
echo "FAIL: forged comment URL ($bad_mode) was accepted" >&2
cat "$OUTPUT_FILE" >&2
@@ -888,6 +938,19 @@ run_review comment-mixed-case-slug comment durable-body https://git.mosaicstack.
grep -q 'Added and verified comment on Gitea PR #123' "$OUTPUT_FILE"
assert_no_temp_leak "comment-mixed-case-slug"
# Case 15c (#991): the deployment's Gitea ROOT_URL is http:// while every client
# reaches it over https://, so the provider returns an http:// pull_request_url
# for a comment that is otherwise entirely correct. Same class as 15b — a
# legitimate provider response, not a spoof — and a scheme-strict compare
# rejects it on EVERY comment, deterministically. That is not a cosmetic false
# negative here: on a host where no seat can create a review OBJECT, this
# comment-form record is the only gate-16 evidence obtainable, and the wrapper
# refuses all of it while the comment sits durably on the PR. Host, path, owner,
# repo, kind and number stay strict; only http-vs-https is relaxed.
run_review comment-url-scheme-downgrade comment durable-body
grep -q 'Added and verified comment on Gitea PR #123' "$OUTPUT_FILE"
assert_no_temp_leak "comment-url-scheme-downgrade"
# Case 16 (#865 ITEM 1, current-head TOCTOU): the PR head advances between the
# pre-submit head read (which pins the review) and the post-verify re-read. The
# review is genuinely created and verified as pinned to the OLD head, but the
@@ -940,4 +1003,44 @@ if grep -q 'Approved and verified' "$OUTPUT_FILE"; then
fi
assert_no_temp_leak "review-body-null"
# Case 19 (#1004): an outright server REFUSAL must report the provider's own
# reason and must NOT be relabelled as the #865 silent-no-op defect class. The
# old arm hardcoded "(#865: no durable review created)" for EVERY non-2xx, so a
# 422/403/404 — all of them definite, correct refusals the server explained in
# the discarded body — arrived at the caller wearing the name of the one defect
# they are not. That misdirection is what makes an operator re-issue the request
# by hand against the live object to find out what actually happened.
if run_review review-refused-422 approve; then
echo "FAIL: approve reported success when the server refused the submit" >&2
cat "$OUTPUT_FILE" >&2
exit 1
fi
grep -q 'HTTP 422' "$OUTPUT_FILE"
if ! grep -q 'Cannot approve your own pull request' "$OUTPUT_FILE"; then
echo "FAIL: the provider's stated reason was discarded (#1004)" >&2
cat "$OUTPUT_FILE" >&2
exit 1
fi
if grep -q '#865: no durable review created' "$OUTPUT_FILE"; then
echo "FAIL: a server refusal was misattributed to the #865 defect class (#1004)" >&2
cat "$OUTPUT_FILE" >&2
exit 1
fi
assert_no_temp_leak "review-refused-422"
# Case 20 (#1004): a non-JSON error body (a fronting proxy's HTML page) must
# still yield something the caller can act on, rather than a bare status code.
if run_review review-refused-html approve; then
echo "FAIL: approve reported success on a 502" >&2
cat "$OUTPUT_FILE" >&2
exit 1
fi
grep -q 'HTTP 502' "$OUTPUT_FILE"
if ! grep -q '502 Bad Gateway' "$OUTPUT_FILE"; then
echo "FAIL: a non-JSON error body was dropped instead of degrading to its first line (#1004)" >&2
cat "$OUTPUT_FILE" >&2
exit 1
fi
assert_no_temp_leak "review-refused-html"
echo "pr-review.sh REST review + comment create/read-back regression passed"
@@ -33,7 +33,11 @@ set -uo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# #973: three-valued grep assertion helpers (has_match/count_lines); init saves real stderr for abort loudness.
# shellcheck disable=SC1091
. "$SCRIPT_DIR/_wake-common.sh" && wake_assert_init
if ! . "$SCRIPT_DIR/_wake-common.sh"; then
echo "WAKE-ASSERT INIT ABORT: cannot source _wake-common.sh — suite ran ZERO wake assertions (#984)" >&2
exit 97
fi
wake_assert_init
BEACON="$SCRIPT_DIR/beacon.sh"
command -v jq >/dev/null 2>&1 || {
@@ -31,7 +31,11 @@ set -uo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# #973: three-valued grep assertion helpers (has_match/count_lines); init saves real stderr for abort loudness.
# shellcheck disable=SC1091
. "$SCRIPT_DIR/_wake-common.sh" && wake_assert_init
if ! . "$SCRIPT_DIR/_wake-common.sh"; then
echo "WAKE-ASSERT INIT ABORT: cannot source _wake-common.sh — suite ran ZERO wake assertions (#984)" >&2
exit 97
fi
wake_assert_init
DET="$SCRIPT_DIR/detector.sh"
STORE="$SCRIPT_DIR/store.sh"
@@ -37,7 +37,11 @@ set -uo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# #973: three-valued grep assertion helpers (has_match/count_lines); init saves real stderr for abort loudness.
# shellcheck disable=SC1091
. "$SCRIPT_DIR/_wake-common.sh" && wake_assert_init
if ! . "$SCRIPT_DIR/_wake-common.sh"; then
echo "WAKE-ASSERT INIT ABORT: cannot source _wake-common.sh — suite ran ZERO wake assertions (#984)" >&2
exit 97
fi
wake_assert_init
STORE="$SCRIPT_DIR/store.sh"
DIGEST="$SCRIPT_DIR/digest.sh"
SIGN="$SCRIPT_DIR/sign.sh"
@@ -119,7 +119,11 @@ set -uo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# #973: three-valued grep assertion helpers (has_match/count_lines); init saves real stderr for abort loudness.
# shellcheck disable=SC1091
. "$SCRIPT_DIR/_wake-common.sh" && wake_assert_init
if ! . "$SCRIPT_DIR/_wake-common.sh"; then
echo "WAKE-ASSERT INIT ABORT: cannot source _wake-common.sh — suite ran ZERO wake assertions (#984)" >&2
exit 97
fi
wake_assert_init
DIGEST="$SCRIPT_DIR/digest.sh"
command -v jq >/dev/null 2>&1 || {
@@ -27,7 +27,11 @@ set -uo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# #973: three-valued grep assertion helpers (has_match/count_lines); init saves real stderr for abort loudness.
# shellcheck disable=SC1091
. "$SCRIPT_DIR/_wake-common.sh" && wake_assert_init
if ! . "$SCRIPT_DIR/_wake-common.sh"; then
echo "WAKE-ASSERT INIT ABORT: cannot source _wake-common.sh — suite ran ZERO wake assertions (#984)" >&2
exit 97
fi
wake_assert_init
ORACLE="$SCRIPT_DIR/fn-oracle.sh"
DET="$SCRIPT_DIR/detector.sh"
@@ -36,7 +36,11 @@ set -uo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# #973: three-valued grep assertion helpers (has_match/count_lines); init saves real stderr for abort loudness.
# shellcheck disable=SC1091
. "$SCRIPT_DIR/_wake-common.sh" && wake_assert_init
if ! . "$SCRIPT_DIR/_wake-common.sh"; then
echo "WAKE-ASSERT INIT ABORT: cannot source _wake-common.sh — suite ran ZERO wake assertions (#984)" >&2
exit 97
fi
wake_assert_init
WI="$SCRIPT_DIR/wake-install.sh"
BEACON="$SCRIPT_DIR/beacon.sh"
FRAMEWORK_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)"
@@ -48,7 +48,11 @@ set -uo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# #973: three-valued grep assertion helpers (has_match/count_lines); init saves real stderr for abort loudness.
# shellcheck disable=SC1091
. "$SCRIPT_DIR/_wake-common.sh" && wake_assert_init
if ! . "$SCRIPT_DIR/_wake-common.sh"; then
echo "WAKE-ASSERT INIT ABORT: cannot source _wake-common.sh — suite ran ZERO wake assertions (#984)" >&2
exit 97
fi
wake_assert_init
PRE="$SCRIPT_DIR/preimage.sh"
STORE="$SCRIPT_DIR/store.sh"
DET="$SCRIPT_DIR/detector.sh"
@@ -31,7 +31,11 @@ set -uo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# #973: three-valued grep assertion helpers (has_match/count_lines); init saves real stderr for abort loudness.
# shellcheck disable=SC1091
. "$SCRIPT_DIR/_wake-common.sh" && wake_assert_init
if ! . "$SCRIPT_DIR/_wake-common.sh"; then
echo "WAKE-ASSERT INIT ABORT: cannot source _wake-common.sh — suite ran ZERO wake assertions (#984)" >&2
exit 97
fi
wake_assert_init
RECON="$SCRIPT_DIR/reconcile.sh"
STORE="$SCRIPT_DIR/store.sh"
DET="$SCRIPT_DIR/detector.sh"
@@ -44,7 +44,11 @@ set -uo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# #973: three-valued grep assertion helpers (has_match/count_lines); init saves real stderr for abort loudness.
# shellcheck disable=SC1091
. "$SCRIPT_DIR/_wake-common.sh" && wake_assert_init
if ! . "$SCRIPT_DIR/_wake-common.sh"; then
echo "WAKE-ASSERT INIT ABORT: cannot source _wake-common.sh — suite ran ZERO wake assertions (#984)" >&2
exit 97
fi
wake_assert_init
STORE="$SCRIPT_DIR/store.sh"
ACK="$SCRIPT_DIR/ack.sh"
@@ -34,7 +34,11 @@ set -uo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# #973: three-valued grep assertion helpers (has_match/count_lines); init saves real stderr for abort loudness.
# shellcheck disable=SC1091
. "$SCRIPT_DIR/_wake-common.sh" && wake_assert_init
if ! . "$SCRIPT_DIR/_wake-common.sh"; then
echo "WAKE-ASSERT INIT ABORT: cannot source _wake-common.sh — suite ran ZERO wake assertions (#984)" >&2
exit 97
fi
wake_assert_init
STORE="$SCRIPT_DIR/store.sh"
command -v jq >/dev/null 2>&1 || {
@@ -10,8 +10,9 @@ comparison come from independent code paths.
Subcommands (all print sorted, stable output; non-zero exit on any failure):
expected The expected coordinate set from the ARTIFACT: one
"<helper> <file>:<line+3>" row per denominator row (+3 = the
uniform header shift the converter applied; converter-verified).
"<helper> <file>:<line+7>" row per denominator row (+7 = 3
converter header lines + 4 lines from the #984 source guard,
uniform across all ten suites).
Multi-grep lines stay ONE coordinate.
static The converted-site inventory from the SOURCE TEXT at the current
@@ -23,9 +24,9 @@ Subcommands (all print sorted, stable output; non-zero exit on any failure):
from the text.)
arms The forced-error arm list: the 19 denominator canaries plus one
E-form arm (store-ack:733→736, a $(count_lines) capture compared
afterward — the A6 shape) plus one F-form arm (quarantine:560→563,
the multi-grep pipeline capture), as "<helper> <file>:<line+3>
E-form arm (store-ack:733→740, a $(count_lines) capture compared
afterward — the A6 shape) plus one F-form arm (quarantine:560→567,
the multi-grep pipeline capture), as "<helper> <file>:<line+7>
<form>". Both extras are asserted to exist in the artifact with
the expected form — a renumber that moved them fails here, not
silently downstream.
@@ -33,7 +34,8 @@ Subcommands (all print sorted, stable output; non-zero exit on any failure):
sweep Residual sweep: the denominator's own classifier (ported from the
frozen derivation) over the ten suites at the current tree must
find ZERO unconverted verdict-form grep sites; and, IN THE SAME
RUN, six per-form specimens planted into a temp copy of a real
RUN, eight specimens (six per-form + two absorb-branch probes, #985)
planted into a temp copy of a real
suite must ALL be found with their correct forms — an instrument
that reports zero must first be seen finding what it claims to
find (A5).
@@ -50,7 +52,9 @@ HERE = Path(__file__).resolve().parent
WAKE = HERE.parent
ART = HERE / "denominator-089615f.json"
HEADER_SHIFT = 3 # converter inserted 3 header lines after SCRIPT_DIR in every suite
HEADER_SHIFT = 7 # 3 converter header lines after SCRIPT_DIR + 4 lines from the
# #984 source guard (1-line `. _wake-common.sh && wake_assert_init` became a 5-line
# guarded block) — both uniform across all ten suites, both above every site.
# The two hand-picked extra arms (base coordinates; forms asserted at load).
EXTRA_ARMS = [
@@ -68,10 +72,14 @@ RX_ASSIGN_SUB = re.compile(r'=\s*"?\$\(.*grep')
RX_IF = re.compile(r"^\s*(el)?if\s+.*grep")
RX_GREP = re.compile(r"(^|[^A-Za-z0-9_.-])grep([^A-Za-z0-9_.-]|$)")
def polarity(line):
m = RX_FAIL_SAME.search(line)
return "OR" if m.group(1) == "||" else "AND"
# grep in COMMAND position: at line start or after a command separator / subshell
# opener / shell keyword / `!`. Quote-unaware by design — a quoted "grep" after a
# separator reads as a command and lands the line in residual, which fails LOUD;
# the absorb direction (note) is the one that must never fire on a real verdict.
RX_GREP_CMD = re.compile(
r"(?:^|[;|&(`]|\$\(|\bif\b|\belif\b|\bthen\b|\belse\b|\bdo\b|\bwhile\b|\buntil\b|!)"
r"\s*grep(?:\s|$)"
)
def classify(lines):
@@ -137,6 +145,28 @@ def classify(lines):
return sites, dispo
def residual_sites(lines):
"""classify() plus the absorb decision — the ONE path both sweep legs share.
A classified site is absorbed as a note only when its line carries a wake
helper token AND the line shows no grep in command position: a converted
line whose PATTERN argument merely contains the word grep. A helper line
that also runs a real grep verdict (has_match ... && grep -q SECRET ... &&
fail) stays residual (#985). Multi-line forms anchor the site at the line
containing grep, so a command-position grep on a continuation line never
shares its line with the helper token and stays residual by construction.
"""
sites, dispo = classify(lines)
residual, notes = [], []
for ln, form, text in sites:
line = lines[ln - 1]
if RX_HELPER.search(line) and not RX_GREP_CMD.search(line):
notes.append((ln, form, text))
else:
residual.append((ln, form, text))
return residual, notes, dispo
def load_art():
art = json.loads(ART.read_text())
assert art["total"] == 261 == len(art["rows"]), "artifact self-consistency"
@@ -199,13 +229,20 @@ def cmd_arms():
return 0
# (expected classify form, expected disposition through residual_sites, snippet)
PLANTS = [
("A-same-line", ['grep -q needle haystack || fail "plant-A"']),
("B-cont-operator", ["grep -q needle haystack ||", ' fail "plant-B"']),
("C-cont-backslash", ["grep -q needle \\", ' haystack || fail "plant-C"']),
("D-if-form", ["if ! grep -q needle haystack; then", ' fail "plant-D"', "fi"]),
("E-count-capture", ['[ "$(grep -c needle haystack)" = "1" ] || fail "plant-E"']),
("F-extract-capture", ['val="$(grep needle haystack)"']),
("A-same-line", "residual", ['grep -q needle haystack || fail "plant-A"']),
("B-cont-operator", "residual", ["grep -q needle haystack ||", ' fail "plant-B"']),
("C-cont-backslash", "residual", ["grep -q needle \\", ' haystack || fail "plant-C"']),
("D-if-form", "residual", ["if ! grep -q needle haystack; then", ' fail "plant-D"', "fi"]),
("E-count-capture", "residual", ['[ "$(grep -c needle haystack)" = "1" ] || fail "plant-E"']),
("F-extract-capture", "residual", ['val="$(grep needle haystack)"']),
# G: a converted line that ALSO runs a raw grep verdict — the helper token
# must not absorb it (#985)
("A-same-line", "residual", ['has_match -q needle "$F" && grep -q SECRET "$F" && fail "plant-G"']),
# H: negative control — helper whose PATTERN argument is the word grep;
# must be absorbed as a note, never residual
("A-same-line", "note", ['has_match -q "grep" haystack || fail "plant-H"']),
]
@@ -215,22 +252,20 @@ def cmd_sweep():
# leg 1: real suites at the current tree must be residual-free
for f in suite_files(art):
lines = (WAKE / f).read_text().split("\n")
sites, _dispo = classify(lines)
residual = []
for ln, form, text in sites:
if RX_HELPER.search(lines[ln - 1]):
# converted line whose PATTERN argument contains the word grep:
# not an unconverted site, but never silently absorbed either
print(f"SWEEP-NOTE {f}:{ln} converted line matches grep-token ({form}): {text[:80]}")
continue
residual.append((ln, form, text))
residual, notes, _dispo = residual_sites((WAKE / f).read_text().split("\n"))
for ln, form, text in notes:
# converted line whose PATTERN argument contains the word grep:
# not an unconverted site, but never silently absorbed either
print(f"SWEEP-NOTE {f}:{ln} converted line matches grep-token ({form}): {text[:80]}")
for ln, form, text in residual:
print(f"SWEEP-RESIDUAL {f}:{ln} {form}: {text[:100]}")
bad += 1
print(f"SWEEP {f}: {len(residual)} residual verdict site(s)")
# leg 2, SAME RUN: the instrument must find six per-form plants
# leg 2, SAME RUN, SAME PATH as leg 1: the instrument must find every plant
# with the right form AND the right absorb disposition — plants G/H exercise
# the absorb branch itself, so this leg must go through residual_sites(),
# not raw classify()
donor = suite_files(art)[0]
with tempfile.TemporaryDirectory() as td:
planted = Path(td) / donor
@@ -238,25 +273,28 @@ def cmd_sweep():
base_lines = planted.read_text().split("\n")
offset = len(base_lines)
expect = {}
for form, snippet in PLANTS:
expect[offset + 1] = form # first physical line of each plant
for form, dispo, snippet in PLANTS:
expect[offset + 1] = (form, dispo) # first physical line of each plant
base_lines.extend(snippet)
offset = len(base_lines)
planted.write_text("\n".join(base_lines))
sites, _ = classify(planted.read_text().split("\n"))
found = {ln: form for ln, form, _t in sites if ln in expect}
unexpected = [(ln, form) for ln, form, _t in sites if ln not in expect]
hits = sum(1 for ln, form in expect.items() if found.get(ln) == form)
print(f"SWEEP-PLANTS found={hits}/6 in planted copy of {donor}")
if hits != 6:
for ln, form in sorted(expect.items()):
got = found.get(ln, "<missed>")
if got != form:
print(f"SWEEP-PLANT-MISS line {ln}: expected {form}, got {got}")
residual, notes, _ = residual_sites(planted.read_text().split("\n"))
found = {ln: (form, "residual") for ln, form, _t in residual}
found.update({ln: (form, "note") for ln, form, _t in notes})
unexpected = [(ln, form) for ln, form, _t in residual if ln not in expect]
hits = sum(1 for ln, want in expect.items() if found.get(ln) == want)
n_plants = len(PLANTS)
print(f"SWEEP-PLANTS found={hits}/{n_plants} in planted copy of {donor}")
if hits != n_plants:
for ln, want in sorted(expect.items()):
got = found.get(ln, ("<missed>", "<missed>"))
if got != want:
print(f"SWEEP-PLANT-MISS line {ln}: expected {want}, got {got}")
bad += 1
if unexpected:
# the donor is a converted suite: any non-plant site the sweep finds
# in the copy contradicts the zero it just reported on the original
# the donor is a converted suite: any non-plant RESIDUAL site in the
# copy contradicts the zero leg 1 just reported on the original
# (non-plant notes mirror leg 1's treatment: printed there, not bad)
for ln, form in unexpected:
print(f"SWEEP-PLANT-UNEXPECTED {donor}(copy):{ln} {form}")
bad += 1
@@ -26,12 +26,12 @@
#
# Verified guard per site (line numbers at branch tip, +3 header shift):
count_lines test-wake-beacon.sh:350 — red-path summary; guard `[ -s "$FAILFILE" ]` at line 349; template execution measured by microtest C11; text verified by static inventory
count_lines test-wake-detector.sh:702 — red-path summary; guard `[ -s "$FAILFILE" ]` at line 701; template execution measured by microtest C11; text verified by static inventory
count_lines test-wake-digest-hmac.sh:434 — red-path summary; guard `[ -s "$FAILFILE" ]` at line 433; template execution measured by microtest C11; text verified by static inventory
count_lines test-wake-digest-quarantine.sh:584 — red-path summary; guard `[ -s "$FAILFILE" ]` at line 583; template execution measured by microtest C11; text verified by static inventory
count_lines test-wake-fn-oracle.sh:132 — red-path summary; guard `[ -s "$FAILFILE" ]` at line 131; template execution measured by microtest C11; text verified by static inventory
count_lines test-wake-install.sh:434 — red-path summary; guard `[ -s "$FAILFILE" ]` at line 433; template execution measured by microtest C11; text verified by static inventory
count_lines test-wake-reconcile.sh:389 — red-path summary; guard `[ -s "$FAILFILE" ]` at line 388; template execution measured by microtest C11; text verified by static inventory
count_lines test-wake-store-ack.sh:741 — red-path summary; guard `[ -s "$FAILFILE" ]` at line 740; template execution measured by microtest C11; text verified by static inventory
count_lines test-wake-store-enqueue-race.sh:208 — red-path summary (with "#927 TOCTOU reproduced (RED)" tail); guard `[ -s "$FAILFILE" ]` at line 207; template execution measured by microtest C11; text verified by static inventory
count_lines test-wake-beacon.sh:354 — red-path summary; guard `[ -s "$FAILFILE" ]` at line 353; template execution measured by microtest C11; text verified by static inventory
count_lines test-wake-detector.sh:706 — red-path summary; guard `[ -s "$FAILFILE" ]` at line 705; template execution measured by microtest C11; text verified by static inventory
count_lines test-wake-digest-hmac.sh:438 — red-path summary; guard `[ -s "$FAILFILE" ]` at line 437; template execution measured by microtest C11; text verified by static inventory
count_lines test-wake-digest-quarantine.sh:588 — red-path summary; guard `[ -s "$FAILFILE" ]` at line 587; template execution measured by microtest C11; text verified by static inventory
count_lines test-wake-fn-oracle.sh:136 — red-path summary; guard `[ -s "$FAILFILE" ]` at line 135; template execution measured by microtest C11; text verified by static inventory
count_lines test-wake-install.sh:438 — red-path summary; guard `[ -s "$FAILFILE" ]` at line 437; template execution measured by microtest C11; text verified by static inventory
count_lines test-wake-reconcile.sh:393 — red-path summary; guard `[ -s "$FAILFILE" ]` at line 392; template execution measured by microtest C11; text verified by static inventory
count_lines test-wake-store-ack.sh:745 — red-path summary; guard `[ -s "$FAILFILE" ]` at line 744; template execution measured by microtest C11; text verified by static inventory
count_lines test-wake-store-enqueue-race.sh:212 — red-path summary (with "#927 TOCTOU reproduced (RED)" tail); guard `[ -s "$FAILFILE" ]` at line 211; template execution measured by microtest C11; text verified by static inventory
@@ -4,8 +4,8 @@
#
# 0. instrument self-test (microtest) — no validate evidence is trusted
# before the instrument itself has been proven, including its abort arms.
# 1. expected set: 261 coordinates from the FROZEN artifact (+3 header
# shift), count asserted against the number declared below BEFORE any
# 1. expected set: 261 coordinates from the FROZEN artifact (+7 header
# shift: 3 converter lines + 4 #984 guard lines), count asserted against the number declared below BEFORE any
# suite runs.
# 2. static inventory: converted call sites re-derived from SOURCE TEXT,
# must equal the expected set exactly (amendment ONE, leg 1 — the
@@ -33,7 +33,8 @@
# site's ledger row must already be present (the append lands before the
# grep).
# 6. residual sweep: the denominator's own classifier finds zero unconverted
# verdict greps in the suites — and six per-form plants in the same run.
# verdict greps in the suites — and eight plants (six per-form + two
# absorb-branch probes, #985) in the same run.
#
# Output discipline (A10): every line that reports on a suite names the file
# under test; exit codes are reported before failure counts.
@@ -180,8 +181,15 @@ while read -r helper site form; do
bad="$bad no-ARMED-line"
printf '%s\n' "$out" | grep -q "WAKE-ASSERT ABORT: ${helper} at ${site}: grep exit" ||
bad="$bad no-ABORT-line"
printf '%s\n' "$out" | grep -Eq "$(sentinel_for "$f")" &&
bad="$bad sentinel-emitted"
# AND-polarity check (a match is the defect): a grep error (rc>=2) must be
# its own loud arm — it cannot fall through as "no sentinel = pass".
rc_sent=0
printf '%s\n' "$out" | grep -Eq "$(sentinel_for "$f")" || rc_sent=$?
case "$rc_sent" in
0) bad="$bad sentinel-emitted" ;;
1) : ;;
*) bad="$bad sentinel-grep-error-rc=$rc_sent" ;;
esac
grep -q "^${helper} ${site}\$" "$aled" ||
bad="$bad no-ledger-row"
if [ -z "$bad" ]; then