Skip to content

Commit 4ce4101

Browse files
committed
fix(step7): the witness needs positive controls, or refusal proves nothing
Two holes in the proof, not in the mechanism. The authority table exercised three of the four states. R15 defines all four, and `NOT_FROZEN+false` was the one nobody checked — so a future tree in which a reader quietly authorised it would have passed the gate. It is in the table now, and `DEAD_AUTHORITY` has a twin that breaks `execbinding.t0_at` instead of `hostqual.bind_t0`: the gate's message claims enforcement on both readers, and a control that only breaks the first left half that claim resting on nothing. Both mutations must now be caught naming all three forbidden states. The campaign-swap witness asserted only that a swapped campaign is inadmissible. A `session_admissibility` returning `admissible: False` unconditionally would have satisfied it — the witness would have read "nothing is admissible" as "the swap was caught". It now requires the unchanged campaign to survive preflight to postflight first, and requires the swapped one to be refused by a reason naming the campaign link, so a broken closing probe cannot stand in for the continuity check. Two mutations prove each half bites: removing only the preflight/current campaign-link comparison while leaving `check_campaign_link` intact, and a postflight that refuses everything. A mutation that changes no bytes now raises rather than passing as a second positive control nobody reads as one. 15 controls, 0 failed. The probes are unchanged: full chain rc 0, #354 alone rc 1 on three predicates, #354 without the tools rc 1 on one. 30d2f32 is untouched. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018xhcg5opoFbSTdYHpkSXCh
1 parent 81b3a13 commit 4ce4101

2 files changed

Lines changed: 131 additions & 22 deletions

File tree

scripts/step7/mergegate.py

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,7 @@ def w(name, doc):
9999
# --- W3/W4: the authority state must bite on both readers -------------------
100100
for label, status, flag, must_pass in (("FROZEN+false", "FROZEN.", "false", False),
101101
("NOT_FROZEN+true", "NOT_FROZEN.", "true", False),
102+
("NOT_FROZEN+false", "NOT_FROZEN.", "false", False),
102103
("FROZEN+true", "FROZEN.", "true", True)):
103104
body = "```text\nStatus:\n %s\n collection_authorized: %s\n```\n" % (status, flag)
104105
root, commit = repo_with("t0-" + flag + status[:4], {"t0.md": body})
@@ -199,9 +200,20 @@ def link_for(binding_file, root, commit_sha, payload_file, att_file, blob_sha):
199200
if not pre["eligible"]:
200201
failures.append("a correctly linked session was refused: " + str(pre["reasons"])[:120])
201202
ppath = w("preflight.json", pre)
203+
# The positive half first. Without it a postflight hard-wired to inadmissible
204+
# would satisfy the negative half, and this witness would read "everything is
205+
# refused" as "the swap is refused" — a broken closing probe standing in for the
206+
# check it was meant to prove.
207+
kept = hq.session_admissibility(bpath, qpath, ppath, mpath, cand, probe, l2, r2)
208+
if not kept["admissible"]:
209+
failures.append("an unchanged campaign was inadmissible at postflight: "
210+
+ str(kept.get("reasons"))[:140])
202211
after = hq.session_admissibility(bpath, qpath, ppath, mpath, cand, probe, l3, r3)
203212
if after["admissible"]:
204213
failures.append("a campaign swapped between preflight and postflight was admissible")
214+
elif not any("campaign link" in str(r) for r in after.get("reasons", ())):
215+
failures.append("the swapped campaign was refused, but by no reason naming the campaign "
216+
"link: " + str(after.get("reasons"))[:140])
205217
206218
print(json.dumps(failures))
207219
"""
@@ -309,9 +321,11 @@ def gate(repo: Path, commit: str) -> list[dict[str, object]]:
309321
results.append(check(
310322
"step7_machinery_enforces", not refusals,
311323
"; ".join(refusals) if refusals else
312-
"the target tree's own tools were run and refused all four attacks: a link naming "
313-
"another binding, a freeze edited after linking, FROZEN with collection_authorized "
314-
"false, and a campaign swapped between preflight and postflight"))
324+
"the target tree's own tools were run: both readers refused all three forbidden "
325+
"authority states and accepted FROZEN+true; a link naming another binding and a "
326+
"freeze edited after linking were refused; an unchanged campaign survived "
327+
"preflight to postflight and a swapped one was refused by campaign-link "
328+
"continuity"))
315329

316330
note = (blob(repo, commit, STEP7_NOTE) or b"").decode("utf-8", "replace")
317331
revoked = "AUTOMATIC AUTHORISATION OF THE FIRST STEP-7 COLLECTION IS REVOKED" in note

tests/test_step7_mergegate.py

Lines changed: 114 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -7,17 +7,22 @@
77
mergegate-exact-binding-field the digest is read at each artifact's own path
88
mergegate-decoy-digest the right digest in a field nobody binds is not a binding
99
mergegate-noop-campaign-link a permissive link check must not satisfy the gate
10-
mergegate-dead-authority an authority check that always passes must not satisfy it
10+
mergegate-dead-authority hostqual authorising anything must not satisfy it
11+
mergegate-dead-authority-eb nor must execbinding — the claim covers both readers
12+
mergegate-dead-continuity nor must a postflight that stops comparing campaign links
13+
mergegate-always-inadmissible nor must one that refuses every campaign
1114
mergegate-missing-machinery the tools must be there at all
1215
mergegate-lingering-auto the revoked automatic authority must stay revoked
1316
mergegate-unfrozen-t0 an unfrozen or unauthorised T0 is not merged as frozen
1417
mergegate-reads-t0-digest the expected digest comes from T0, not from a constant
1518
control-inventory-complete this list and the executed set are the same set
1619
17-
Fixtures ship the real tools, and two controls mutate them. An earlier revision
18-
shipped a stub whose check_campaign_link was a bare pass and called that world
19-
compliant: the fixture demonstrated the false positive it was meant to exclude.
20-
Checking for the name of a mechanism is not checking the mechanism.
20+
Fixtures ship the real tools, and four controls mutate one enforcement point
21+
each. An earlier revision shipped a stub whose check_campaign_link was a bare
22+
pass and called that world compliant: the fixture demonstrated the false
23+
positive it was meant to exclude. Checking for the name of a mechanism is not
24+
checking the mechanism, and a witness that only ever sees refusals is not
25+
checking one either — hence a positive control beside each negative one.
2126
2227
Failures print FAIL[<check>]: <detail>; nothing stops at the first one.
2328
@@ -94,8 +99,11 @@ def guarded(check: str, control: Callable[[], None]) -> None:
9499
lambda d: {"bindings": {"measurement_harness_digest": d}},
95100
}
96101

97-
# Mutations appended to the real hostqual: a later definition wins, so each stub
98-
# disables exactly one enforcement point and leaves the rest genuine.
102+
HOSTQUAL, EXECBINDING = mg.STEP7_TOOLS[1], mg.STEP7_TOOLS[2]
103+
104+
# Mutations of the real tools, one enforcement point each. Appending a definition
105+
# is enough where a later definition wins; the continuity check lives inside a
106+
# larger function, so that one is a targeted edit of its condition.
99107
NOOP_LINK = '''
100108
101109
def check_campaign_link(link, binding_path, repo):
@@ -110,6 +118,33 @@ def bind_t0(repo, path, commit):
110118
return block, check("t0", True, "stubbed: always authorised")
111119
'''
112120

121+
# The second reader. The gate's message claims enforcement on both, so a control
122+
# that only breaks the first leaves half of that claim unproved.
123+
DEAD_AUTHORITY_EB = '''
124+
125+
def t0_at(repo, path, commit):
126+
return {"commit": commit, "path": path, "blob_sha": "b" * 40, "sha256": "f" * 64,
127+
"status": "FROZEN", "collection_authorized": True}
128+
'''
129+
130+
# The mutation the positive postflight control exists to catch: a tool that
131+
# refuses every campaign satisfies a negative-only witness, which would then be
132+
# reading "nothing is admissible" as "the swap was caught".
133+
ALWAYS_INADMISSIBLE = '''
134+
135+
def session_admissibility(*args, **kwargs):
136+
return {"admissible": False, "reasons": ["stubbed: nothing is ever admissible"]}
137+
'''
138+
139+
CONTINUITY_CONDITION = ('preflight.get("campaign_link_sha256") != '
140+
"sha256_file(campaign_link_path)")
141+
142+
143+
def dead_continuity(src: str) -> str:
144+
"""Leave check_campaign_link working and remove only the comparison of the
145+
preflight's campaign link with this pass's."""
146+
return src.replace(CONTINUITY_CONDITION, "False")
147+
113148

114149
def real_tools() -> dict[str, str]:
115150
"""The tools as they actually are. A fixture that shipped a stub would prove
@@ -140,16 +175,23 @@ def run(*args: str) -> None:
140175
def world(tmp: Path, *, frozen: bool = True, authorized: bool = True,
141176
instrument: str = "print('instrument')" + NL, rebound: bool = True,
142177
machinery: bool = True, auto_authority: bool = False, decoy: bool = False,
143-
wrong_field: bool = False, mutate: str = "", name: str = "w") -> tuple[Path, str]:
178+
wrong_field: bool = False, mutate: dict[str, object] | None = None,
179+
name: str = "w") -> tuple[Path, str]:
144180
"""A synthetic target tree, and the commit a merge into it would produce."""
145181
repo = tmp / name
146182
files = {eb.INSTRUMENT_SOURCES[0]: instrument,
147183
eb.INSTRUMENT_SOURCES[1]: '{"decisive": []}' + NL,
148184
mg.STEP7_NOTE: NOTE_AUTO if auto_authority else NOTE_REVOKED}
149185
if machinery:
150186
tools = real_tools()
151-
if mutate:
152-
tools[mg.STEP7_TOOLS[1]] = tools[mg.STEP7_TOOLS[1]] + mutate
187+
for tool, change in (mutate or {}).items():
188+
before = tools[tool]
189+
tools[tool] = change(before) if callable(change) else before + str(change)
190+
if tools[tool] == before:
191+
# A mutation that lands nowhere turns an attack control into a
192+
# second positive control that nobody reads as one.
193+
raise AssertionError(f"the mutation for {tool} changed nothing; the source it "
194+
"targets has moved")
153195
files.update(tools)
154196
else:
155197
files[mg.STEP7_TOOLS[0]] = "# capture only" + NL
@@ -269,7 +311,7 @@ def control_decoy_digest() -> None:
269311
def control_noop_campaign_link() -> None:
270312
"""The attack the previous revision of this gate could not see."""
271313
with tempfile.TemporaryDirectory() as raw:
272-
repo, commit = world(Path(raw), mutate=NOOP_LINK, name="noop")
314+
repo, commit = world(Path(raw), mutate={HOSTQUAL: NOOP_LINK}, name="noop")
273315
if verdicts(repo, commit).get("step7_machinery_enforces") != "fail":
274316
fail("mergegate-noop-campaign-link",
275317
"a permissive check_campaign_link satisfied the gate; the name of a mechanism "
@@ -284,20 +326,70 @@ def control_noop_campaign_link() -> None:
284326
"that got through")
285327

286328

329+
def _dead_authority(check_name: str, tool: str, stub: str, reader: str) -> None:
330+
"""Both readers carry the authority state machine, and the gate says so.
331+
A control that breaks one of them leaves the other half of that claim
332+
standing on nothing."""
333+
with tempfile.TemporaryDirectory() as raw:
334+
repo, commit = world(Path(raw), mutate={tool: stub}, name="deadauth")
335+
if verdicts(repo, commit).get("step7_machinery_enforces") != "fail":
336+
fail(check_name, f"a {reader} authority check that always passes satisfied the gate")
337+
return
338+
detail = details(repo, commit)["step7_machinery_enforces"]
339+
wrongly = [s for s in ("FROZEN+false", "NOT_FROZEN+true", "NOT_FROZEN+false")
340+
if f"{reader} accepted {s}" in detail]
341+
if len(wrongly) != 3:
342+
fail(check_name, f"refused, but named {wrongly} rather than all three forbidden "
343+
f"states: {detail}")
344+
return
345+
ok(check_name,
346+
f"a tree whose {reader} authorises anything is refused, and the refusal names every "
347+
"forbidden state it accepted: FROZEN+false, NOT_FROZEN+true, NOT_FROZEN+false")
348+
349+
287350
def control_dead_authority() -> None:
351+
_dead_authority("mergegate-dead-authority", HOSTQUAL, DEAD_AUTHORITY, "hostqual")
352+
353+
354+
def control_dead_authority_eb() -> None:
355+
_dead_authority("mergegate-dead-authority-eb", EXECBINDING, DEAD_AUTHORITY_EB, "execbinding")
356+
357+
358+
def control_dead_continuity() -> None:
359+
"""The campaign-swap witness needs its own sensitivity proof: a postflight
360+
that refuses everything would satisfy a negative-only control."""
288361
with tempfile.TemporaryDirectory() as raw:
289-
repo, commit = world(Path(raw), mutate=DEAD_AUTHORITY, name="deadauth")
362+
repo, commit = world(Path(raw), mutate={HOSTQUAL: dead_continuity}, name="deadcont")
290363
if verdicts(repo, commit).get("step7_machinery_enforces") != "fail":
291-
fail("mergegate-dead-authority",
292-
"a bind_t0 that always returns pass satisfied the gate")
364+
fail("mergegate-dead-continuity",
365+
"a postflight that no longer compares the preflight's campaign link with "
366+
"this pass's satisfied the gate; the swap witness proves nothing")
293367
return
294368
detail = details(repo, commit)["step7_machinery_enforces"]
295-
if "accepted" not in detail:
296-
fail("mergegate-dead-authority", f"refused for an unrelated reason: {detail}")
369+
if "swapped" not in detail:
370+
fail("mergegate-dead-continuity", f"refused for an unrelated reason: {detail}")
371+
return
372+
ok("mergegate-dead-continuity",
373+
"removing only the preflight/postflight campaign-link comparison, and leaving "
374+
"check_campaign_link intact, is caught and named as the swap becoming admissible")
375+
376+
377+
def control_always_inadmissible() -> None:
378+
"""A refusal is only evidence if acceptance was possible."""
379+
with tempfile.TemporaryDirectory() as raw:
380+
repo, commit = world(Path(raw), mutate={HOSTQUAL: ALWAYS_INADMISSIBLE}, name="noadmit")
381+
if verdicts(repo, commit).get("step7_machinery_enforces") != "fail":
382+
fail("mergegate-always-inadmissible",
383+
"a postflight that refuses every campaign satisfied the gate; the swap "
384+
"witness was reading a blanket refusal as enforcement")
385+
return
386+
detail = details(repo, commit)["step7_machinery_enforces"]
387+
if "unchanged campaign was inadmissible" not in detail:
388+
fail("mergegate-always-inadmissible", f"refused for another reason: {detail}")
297389
return
298-
ok("mergegate-dead-authority",
299-
"a tree whose authority check always passes is refused, and the refusal says which "
300-
"state it wrongly accepted")
390+
ok("mergegate-always-inadmissible",
391+
"the unchanged campaign must survive preflight to postflight, so a tool that refuses "
392+
"everything cannot pose as one that caught the swap")
301393

302394

303395
def control_missing_machinery() -> None:
@@ -384,6 +476,9 @@ def control_inventory_complete() -> None:
384476
("mergegate-decoy-digest", control_decoy_digest),
385477
("mergegate-noop-campaign-link", control_noop_campaign_link),
386478
("mergegate-dead-authority", control_dead_authority),
479+
("mergegate-dead-authority-eb", control_dead_authority_eb),
480+
("mergegate-dead-continuity", control_dead_continuity),
481+
("mergegate-always-inadmissible", control_always_inadmissible),
387482
("mergegate-missing-machinery", control_missing_machinery),
388483
("mergegate-lingering-auto", control_lingering_auto),
389484
("mergegate-unfrozen-t0", control_unfrozen_t0),

0 commit comments

Comments
 (0)