Skip to content

feat(crosscheck): refuse a model image that does not attest the dispatched harness - #276

Merged
ruby-dlee merged 3 commits into
mainfrom
fm/crosscheck-image-harness-guard
Aug 20, 2026
Merged

feat(crosscheck): refuse a model image that does not attest the dispatched harness#276
ruby-dlee merged 3 commits into
mainfrom
fm/crosscheck-image-harness-guard

Conversation

@ruby-dlee

Copy link
Copy Markdown
Owner

The gap and its history

The Azure crosscheck compartment lane boots a model VM from the gallery image named by model_image_id and dispatches a reviewer harness (pi or codex) onto it. The image build writes attestation tags onto the managed image it distributes: pi-tarball-sha256, node-tarball-sha256, codex-cli-sha256, claude-cli-sha256, every digest taken from the pinned closure in docs/azure-crosscheck/model-image-closure.json through distribute.artifactTags in docs/azure-crosscheck/model-image.json.

Nothing read them. PR #246 recorded this as an open gap, and docs/azure-crosscheck.md still carried the line "nothing reads those tags" on main.

#246 exists because of what that costs. Every Pi reviewer that reached a live model VM died on pi: command not found, one paid VM per attempt, because admission never compared the harness it was about to dispatch against what the configured image actually carries. The tag was the only thing that could have caught it, and it was write-only.

The lane is currently off ("enabled": false in $FM_HOME/config/crosscheck-azure.json), which is what makes this the right moment to close the read: flipping enabled back to true is exactly the event this guard exists for.

Note on the current image, so this PR does not re-propagate a refuted claim: the image in model_image_id today (gallery version 1.0.1787092687, from managed image img-fm7c799d-ccm-1.0.1787091895) DOES carry pi, tagged pi-tarball-sha256 a69a1859... and node-tarball-sha256 d60acfe0..., matching the tracked closure for pi-coding-agent 0.84.1 and Node v22.23.2. The lane is off because of the flag, not because of any image problem. This change asserts nothing to the contrary; it makes admission able to check that for itself instead of a document asserting it.

What the guard does

require_model_image_attests_harness(azure, config["harness"]) in bin/fm-crosscheck-azure.py, called from _run_azure_review_in_lane after the lane is held and the foundation preflight passes, and before the capacity reservation, before any staged blob, and before the model VM. A refusal therefore costs nothing.

  • Resolves the harness to its required tags: pi binds pi-tarball-sha256 and node-tarball-sha256; codex binds codex-cli-sha256. Pi ships a #!/usr/bin/env node entrypoint and declares engines.node >= 22.19.0, so an image carrying pi without the pinned Node fails the reviewer at launch for the same reason and at the same cost as an image carrying no pi.
  • Reads the configured image's own tags with one read-only ARM GET (az rest --method get, the idiom the file already uses), and follows the version's source managed image exactly once when a required tag is absent there, because the build tags the managed image and gallery promotion is a separate operator step that need not carry artifactTags.
  • Refuses on ABSENCE, not only on mismatch. Absence is the exact failure mode that burned VMs.
  • Where the closure pins a digest it compares against it rather than merely checking presence, and the refusal names which digest disagreed and carries both sides.
  • A harness with no attestation mapping (the retired claude lane, an empty or unexpected value) is refused rather than defaulting to admitted.

The fail-closed choice

An unreadable image, an unreadable source managed image, an unreadable tag object, and an unreadable pinned closure are all refusals. This guard exists to stand between a wrong image and a paid VM, so it may never admit on ambiguity. The one case deliberately NOT treated as ambiguity is an ARM resource that reports no tags at all: that is not unreadable, it is an image that attests nothing, so it refuses as absence with the absence message.

The refusal surfaces as CrosscheckToolError rather than a hard error, so the reviewer roster rotates. That is correct here and not a softening: the same image can legitimately attest a different harness, which is exactly what rotation is for. With no reviewers left it raises as any other tool failure does.

Scope

Preflight refusal only. When the check passes the lane does exactly what it did before: no changed parameters, no changed provisioning, no changed identity or ledger fields. The ordinary worker lane is untouched, and bin/fm-worker-authority.py / bin/fm-worker-lifecycle.py and their suites are untouched.

Test evidence

tests/fm-crosscheck-azure.test.sh goes from 15 units to 16. Registrations in this suite are bare words that exit 0 on a typo, so every registered name was checked against every definition: 16 defined, 16 registered, no name registered-but-undefined, none defined-but-unregistered, no duplicates. All 16 execute and print ok -.

Run as CI runs it, never as a bare bash:

$ python3 tests/run-one.py bash tests/fm-crosscheck-azure.test.sh
== tests/fm-crosscheck-azure.test.sh ==
ok - Azure Crosscheck static contracts separate model, tool, verifier, identity, and cleanup
ok - the adapter and guest agree on the exact seven-parameter contract
ok - Azure selection is explicit, local-default, and unsafe config fails closed
ok - the reviewer model derives the exact Foundry host and the claude host lane is retired
ok - the Azure GLM credential lane packages models.json under the endpoint allowlist and the claude lane is gone
ok - wrong head, stale endpoint, shared VM, network, credential, and generation outcomes fail closed
ok - review generation binds the executing account and ambiguous cleanup never becomes absence
ok - host bridge rejects hostile evidence and requires distinct cleaned exact-head tool/verifier attempts
ok - manifest transport bounds hold through the real producer and consumer
ok - the safety-shutdown expiry script renders real newlines with the exact unit text
ok - networkless replay proves exact pytest mutations and denies command, symlink, marker, and parent substitution
ok - the model compartment reserves and releases exact shared allocator capacity and honors queued refusals
ok - reviewer lanes admit FIFO, spread families deterministically, prune dead waiters, and never write auth back
ok - image and network-policy mutations are declaration-owned and refuse without exact confirmations
ok - admission refuses a model image that does not attest the dispatched reviewer harness
ok - operator documentation enumerates malicious, concurrency, fault, force-push, and cloud-default acceptance
Azure Crosscheck tests passed.
rc=0

The new unit is hermetic: it substitutes m.az with an exact-resource-id ARM responder that records every URL, and reads the expected digests out of the tracked closure file rather than from a constant, so it cannot pass by comparing a literal to itself. It covers, in order:

  • (a) an image whose source managed image carries the pinned digests admits, the guard returns exactly the tags it proved, the source is followed exactly once for two tags, and each resource is read at its own api-version; the same image also admits from its own tags with no source read at all.
  • (b) a MISSING tag refuses, with the harness, the tag, the image id, and refusing before any model VM in the message; missing node-tarball-sha256 refuses on its own.
  • (c) a MISMATCHED digest refuses with its own string naming which closure key disagreed and carrying both values, asserted for each of the two Pi digests separately; the mismatch message is asserted NOT to say absent.
  • (d) unreadable image, unreadable source, non-dict resource body, and three hostile tags shapes (a string, a list, an object with a non-string value) all refuse; a resource with no tags key refuses as absence.
  • (e) an image attesting a complete Pi closure and no codex digest refuses a codex dispatch, while a codex-attesting image admits codex; claude and other unmapped harnesses refuse.
  • an unreadable pinned closure refuses.
  • CALL SITE, read from the AST so a comment cannot keep it green: the lane calls the guard exactly once, before reserve_model_capacity, upload_blob, provision_model_vm, and submit_model_run, and passes config["harness"] rather than a literal.
  • the declaration still writes every tag the guard reads, and the closure still pins every key it compares.
  • the owning doc records the guard, says the tags are load-bearing, carries the honest limit, and no longer says nothing reads those tags.

Mutation proofs

Five mutations, each applied to a clean tree, run through tests/run-one.py, proven red, then reverted with git checkout -- and the tree confirmed clean.

M1: delete the guard call from _run_azure_review_in_lane.

rc=1
AssertionError: ('the lane does not call the image attestation guard', [])
not ok - model image harness attestation guard failed

M2: make absence admit (if value is None: continue).

rc=1
AssertionError: the guard admitted harness 'pi'
not ok - model image harness attestation guard failed

M3: make an unreadable read admit (an unreadable ARM GET returns the pinned closure digests as if the image had been read and attested).

rc=1
AssertionError: the guard admitted harness 'pi'
not ok - model image harness attestation guard failed

M4: presence only, a mismatched digest admits (if value != pinned becomes if False).

rc=1
AssertionError: the guard admitted harness 'pi'
not ok - model image harness attestation guard failed

M5: the call site checks a hardcoded harness (require_model_image_attests_harness(azure, "pi")).

rc=1
AssertionError: Constant(value='pi')
not ok - model image harness attestation guard failed

After reverting all five, the suite is green again at 16/16.

A weaker earlier form of M3 (an unreadable read returning empty tags) also went red, but by degrading to the absence refusal rather than by admitting; it was replaced with the admitting form above so the proof shows a real admission being caught.

bin/fm-lint.sh was run to completion over the full repo.

Honest limits

  • This proves the IMAGE attests a harness. It does not prove the harness runs. The guard reads what the build recorded about the image. A Pi review actually completing on that image is a separate claim, and this change does not make it.
  • The tags are only as good as the build that wrote them. An operator who tags an image by hand defeats this, as does any path that writes artifactTags outside bin/fm-crosscheck-azure-image.sh.
  • The two ARM api-version strings (2023-07-03 for gallery image versions, 2024-03-01 for managed images) were not exercised against live ARM in this session; the lane is off and no billable or live Azure call was made. If either were wrong, az rest fails and the guard REFUSES, which is the safe direction, and the refusal prints the ARM diagnostic verbatim.
  • The guard was not run against the real image. Everything here is hermetic.
  • The retired claude lane is refused rather than checked. The image still carries claude-cli-sha256, but the adapter has no claude credential lane, so mapping it would be dead code that reads as support.
  • If a gallery version carries a stale tag while its source managed image carries the right one, the guard refuses on the version's value. That is deliberate: the version is what boots.

@ruby-dlee
ruby-dlee merged commit dffe921 into main Aug 20, 2026
13 checks passed
@ruby-dlee

Copy link
Copy Markdown
Owner Author

Recorded after merge: the honest limit in this PR's body saying the two ARM api-version strings were never exercised against live ARM is now RETIRED. An independent review exercised them read-only against the real resources.

Both api-versions are valid: az provider show --namespace Microsoft.Compute lists 2023-07-03 among galleries/images/versions and 2024-03-01 among images.

Against the configured image, the guard behaves as intended on real data:

  • GET of gallery version 1.0.1787092687 at 2023-07-03 returns "tags": {} and a source id of img-fm7c799d-ccm-1.0.1787091895.
  • GET of that managed image at 2024-03-01 returns pi-tarball-sha256 a69a1859, node-tarball-sha256 d60acfe0, codex-cli-sha256 0246e2e7, all equal to the tracked closure.
  • The real require_model_image_attests_harness against the real image with the real az: pi ADMITS on both digests, codex ADMITS, claude REFUSES (no mapping), and pointing it at the historical pre-Pi managed image img-fm7c799d-ccm-1.0.0 REFUSES with attestation tag 'pi-tarball-sha256' is absent - exactly the scenario feat(crosscheck): carry the Pi reviewer closure in the Azure model image #246 was opened for.
  • A deliberately bad api-version returns NoRegisteredProviderFound with rc=1, which the guard turns into model image is unreadable, so the fail-closed argument holds by construction rather than by assumption.

One consequence worth stating because it changes how the code should be read: the source-follow is LOAD-BEARING IN PRODUCTION, not a corner case. The live gallery version carries no tags at all, so every real dispatch takes two GETs and the follow is the step that actually proves the harness.

Two follow-ups the same review named, neither blocking and neither implemented here:

  1. The proven digests are computed and discarded (the call site throws the return value away), so the ledger cannot later answer which attestation admitted a run. Stamping attested alongside model_image_id in the review identity is free evidence.
  2. model-guest-sha256 is written by the same artifactTags expression and has ZERO readers repo-wide. The adapter digests its own tracked guest script instead, so an image baked with a stale guest is still not detected at admission - the identical class of gap this PR closes for pi and codex.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant