Skip to content

fix(verifier): enforce ConsequentBundlePinned via targetProofCid lookup#13

Merged
TSavo merged 1 commit into
mainfrom
fix/verifier-enforce-target-proof-cid
May 2, 2026
Merged

fix(verifier): enforce ConsequentBundlePinned via targetProofCid lookup#13
TSavo merged 1 commit into
mainfrom
fix/verifier-enforce-target-proof-cid

Conversation

@TSavo
Copy link
Copy Markdown
Owner

@TSavo TSavo commented May 2, 2026

Summary

Closes the shim-poisoning vector promoted to a normative example in #10
(protocol/specs/2026-04-30-ir-formal-grammar.md, § "Bridge target
pinning: the shim-poisoning vector"). The Rust verifier now enforces
INVARIANT BridgeDeclaration.ConsequentBundlePinned:

forall b: BridgeDeclaration, P: ProofBundle .
  AcceptedAsConsequentFor(P, b)  =>  Cid(P) = b.targetProofCid

The vector this closes

A bridge carries two outbound CIDs into the target side: the antecedent
(targetContractCid) and the consequent (targetProofCid). Without
enforcing the second pin, the verifier accepts any .proof bundle that
happens to contain a contract member matching targetContractCid,
regardless of which binary the bundle was minted for. An attacker can
mint a poisoned bundle whose contract member is byte-equal to the
bridge's pin; syntactically the discharge looks fine, semantically the
guarantee is broken.

This PR adds the one CID-equality check the spec calls "the entire
mitigation": after resolving the consequent contract, the verifier
checks that the contract is a member of the bundle the bridge pinned,
and refuses any other bundle as a substitute.

What changed

  • MementoPool tracks bundle_members: BTreeMap<bundle_cid, set<member_cid>>.
    Multi-valued because the same member can legitimately appear in two
    bundles (one honest, one poisoned); last-writer-wins would silently
    swap them. load_all_proofs populates this from the .proof file's
    content hash.
  • CallSite gains bridge_target_proof_cid: Option<String>.
    enumerate_callsites extracts it from body.targetProofCid.
  • resolve_target::run gates resolution on the pin: mismatch -> reject
    with BridgeTargetProofCidMismatch { expected, actual }-shaped
    error.

Behavior on missing field

targetProofCid is REQUIRED by the current grammar, but bridges minted
before the field was normative MAY lack it. To preserve back-compat:

  • bridge_target_proof_cid = None -> bridge is accepted with a soft
    warning to stderr (warning: bridge X has no targetProofCid; ConsequentBundlePinned not enforced). Matches the existing
    eprintln!("warning: ...") pattern in runner.rs /
    solvers/plan.rs.
  • bridge_target_proof_cid = Some(_) -> ConsequentBundlePinned is
    enforced fully. Mismatch is fail-closed.

New bridges from the current producer MUST set the field; the warning
makes the gap visible to operators rather than hiding it.

Test coverage added

In implementations/rust/provekit-verifier/tests/resolve_target.rs:

  • rejects_when_target_proof_cid_does_not_match_bundle -- spec
    scenario A, the shim-poisoning case. Member resolves but its bundle
    CID differs from the pin. Must reject.
  • rejects_when_pinned_bundle_is_not_loaded -- pin references a
    bundle not in the pool. Must reject (fail-closed).
  • accepts_when_target_proof_cid_matches_bundle -- spec scenario B.
    Pin matches. Must accept.
  • accepts_when_target_proof_cid_is_none_back_compat -- legacy
    bridge, no field. Must accept (soft-warning path).

Sanity-checked by reverting just the resolve_target.rs gate and
confirming the two reject-tests fail while the two accept-tests still
pass.

Test plan

  • cargo test --release --manifest-path implementations/rust/Cargo.toml -p provekit-verifier --test resolve_target -- 12/12 pass
  • Sanity check: revert resolve_target.rs, confirm reject-tests now fail, restore
  • Full crate test suite green except for one pre-existing unrelated failure (smt_emitter::var_with_empty_name_returns_err, fails on main too)

Related

Closes the shim-poisoning vector promoted to a normative example in PR
#10 (protocol/specs/2026-04-30-ir-formal-grammar.md, "Bridge target
pinning"). Without this gate, an attacker can mint a poisoned `.proof`
bundle whose contract member is byte-equal to the bridge's
`targetContractCid`; the verifier accepts the substitution because
syntactically the obligation looks discharged. With the gate, a bridge
is bound not just to a contract shape but to a specific consequent
bundle, and substitution is rejected before any discharge logic runs.

Changes:

- `MementoPool` gains `bundle_members: BTreeMap<String, BTreeSet<String>>`
  mapping `.proof` bundle CID to the set of member CIDs it contained.
  Multi-valued because the same member CID can legitimately appear in
  more than one bundle; last-writer-wins would silently swap honest for
  poisoned. `load_all_proofs` populates the map keyed by the bundle's
  content hash (`derived_full`).
- `CallSite` gains `bridge_target_proof_cid: Option<String>`.
  `enumerate_callsites` reads it from `body.targetProofCid`; absent /
  empty maps to `None` for back-compat with bridges minted before the
  field was normative.
- `resolve_target::run` gates the resolution on
  `BridgeDeclaration.ConsequentBundlePinned`. If
  `bridge_target_proof_cid = Some(expected)`, the resolved contract
  must be a member of `pool.bundle_members[expected]` or the bridge is
  rejected with `BridgeTargetProofCidMismatch`. A pinned bundle that
  isn't loaded at all is also a mismatch (fail-closed). If
  `bridge_target_proof_cid = None`, the bridge is accepted with a soft
  warning to stderr (matches existing `eprintln!("warning: ...")`
  pattern in `runner.rs` and `solvers/plan.rs`); back-compat path for
  pre-grammar bridges, but operators see what isn't being checked.

Tests added in `tests/resolve_target.rs`:

- `rejects_when_target_proof_cid_does_not_match_bundle`: shim-poisoning
  scenario A from the spec; member resolves but its bundle CID differs
  from the bridge's pin. Must reject.
- `rejects_when_pinned_bundle_is_not_loaded`: pin references a bundle
  not present in the pool. Must reject.
- `accepts_when_target_proof_cid_matches_bundle`: scenario B; pin
  matches. Must accept.
- `accepts_when_target_proof_cid_is_none_back_compat`: legacy bridge
  with no field. Must accept (with stderr warning).

Sanity-checked the tests are meaningful by reverting just
`resolve_target.rs` and confirming the two reject-tests fail while the
accept-tests still pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings May 2, 2026 16:29
@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented May 2, 2026

Warning

Rate limit exceeded

@TSavo has exceeded the limit for the number of commits that can be reviewed per hour. Please wait 52 minutes and 26 seconds before requesting another review.

To keep reviews running without waiting, you can enable usage-based add-on for your organization. This allows additional reviews beyond the hourly cap. Account admins can enable it under billing.

⌛ How to resolve this issue?

After the wait time has elapsed, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

We recommend that you space out your commits to avoid hitting the rate limit.

🚦 How do rate limits work?

CodeRabbit enforces hourly rate limits for each developer per organization.

Our paid plans have higher rate limits than the trial, open-source and free plans. In all cases, we re-allow further reviews after a brief timeout.

Please see our FAQ for further information.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: e184782a-35e1-4a18-b3b3-82a806800a26

📥 Commits

Reviewing files that changed from the base of the PR and between 5763082 and ae2c68c.

📒 Files selected for processing (5)
  • implementations/rust/provekit-verifier/src/enumerate_callsites.rs
  • implementations/rust/provekit-verifier/src/load_all_proofs.rs
  • implementations/rust/provekit-verifier/src/resolve_target.rs
  • implementations/rust/provekit-verifier/src/types.rs
  • implementations/rust/provekit-verifier/tests/resolve_target.rs
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/verifier-enforce-target-proof-cid

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share
Review rate limit: 0/1 reviews remaining, refill in 52 minutes and 26 seconds.

Comment @coderabbitai help to get the list of available commands and usage tips.

@TSavo TSavo merged commit 2bf9b3a into main May 2, 2026
5 of 6 checks passed
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR updates the Rust verifier to enforce the bridge's forward pin (targetProofCid) when resolving a consequent contract, closing the shim-poisoning substitution path described in the spec. It fits into the verifier pipeline by carrying bundle-pin metadata from callsite enumeration through proof loading into resolve_target, where the actual gate is applied.

Changes:

  • Added bundle membership tracking to MementoPool and populated it while loading .proof files.
  • Extended CallSite to carry bridge_target_proof_cid and extracted targetProofCid from bridge bodies.
  • Added resolve_target checks and new tests for accept/reject cases around pinned bundles and back-compat behavior.

Reviewed changes

Copilot reviewed 5 out of 5 changed files in this pull request and generated 3 comments.

Show a summary per file
File Description
implementations/rust/provekit-verifier/tests/resolve_target.rs Adds unit tests for forward-pin enforcement, missing bundle rejection, matching bundle acceptance, and legacy fallback.
implementations/rust/provekit-verifier/src/types.rs Extends verifier pipeline data structures with bundle-membership indexing and optional bridge proof-pin metadata.
implementations/rust/provekit-verifier/src/resolve_target.rs Enforces that resolved target contracts come from the bridge-pinned .proof bundle, with a legacy warning path.
implementations/rust/provekit-verifier/src/load_all_proofs.rs Records per-bundle member CIDs while loading proofs so resolve_target can validate pinned-bundle membership.
implementations/rust/provekit-verifier/src/enumerate_callsites.rs Extracts targetProofCid from bridge JSON and carries it into generated CallSites.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +111 to +118
// bridges without this field are tolerated at load time but
// can't have ConsequentBundlePinned enforced; resolve_target
// emits a soft warning in that case.
let bridge_target_proof_cid = bbody
.get("targetProofCid")
.and_then(|v| v.as_str())
.filter(|s| !s.is_empty())
.map(|s| s.to_string());
Comment on lines +58 to +67
None => {
// Back-compat: legacy bridges that pre-date `targetProofCid`
// are loadable but cannot have ConsequentBundlePinned
// enforced. New bridges MUST set the field; flag the gap so
// operators can see what isn't being checked.
eprintln!(
"warning: bridge {} has no targetProofCid; \
ConsequentBundlePinned not enforced (back-compat path)",
cs.bridge_ir_name
);
Comment on lines +169 to +172
pool.bundle_members
.entry(derived_full.clone())
.or_default()
.insert(cid.clone());
Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: ae2c68c990

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

let bridge_target_proof_cid = bbody
.get("targetProofCid")
.and_then(|v| v.as_str())
.filter(|s| !s.is_empty())
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Reject empty targetProofCid instead of treating as missing

Filtering out empty targetProofCid values converts "" into None, which sends the callsite down the back-compat branch in resolve_target and skips ConsequentBundlePinned enforcement. This creates an easy bypass for the new pinning check whenever a bridge envelope is malformed-but-loadable with an empty string (field present but not usable), because the verifier will accept it with only a warning rather than fail closed.

Useful? React with 👍 / 👎.

TSavo added a commit that referenced this pull request May 5, 2026
…366) (#387)

* fix(baselines): resolve P1 review concerns from #293/#326 inline

Closes #366 partial (generator + BridgeV14 kit work).

P1 concerns addressed:

#1 Critical (CodeRabbit) — apply_builtin dead ctor0 branch: removed the
unreachable `a == 0` guard introduced by `let a = if arity == 0 { 1 }`.
Now arity=0 (serde default) correctly means unary; the ctor0 path is gone.

#5 Major (Copilot) — gte double-wrap: the old gte arm applied
ctor1(left, ctor1(sig, s)) in both branches of a dead if/else.
Fixed to ctor1(left, s) — the property applied directly to the input
variable, not to the builtin result.

#9 + #11 (CodeRabbit Major) — rust in lang list causes duplicate
rust-std-baseline-v1: removed "rust" from the hard-coded langs array.
rust is already minted by provekit-baseline-rust-std (#292). rust.toml
kept as reference but is not read by this binary.

#13 (Copilot) — silent skip on missing config changed to panic: a
missing config is always a repo error, not a runtime skip.

#14 (Copilot) — unknown predicate kind silently skipped changed to
panic: unknown kinds indicate a stale config or missing handler, not
a soft warning.

#15 (Copilot Major) — missing gte.args.left defaulting to signature
changed to panic: malformed configs must not silently produce
wrong-but-passing baselines.

#19 (CodeRabbit Major) — PHP Minter.php mintBridgeV14 now validates
and normalizes the tagged-union target before signing. Extra keys are
stripped; missing kind or cid fails with InvalidArgumentException.

#20 (CodeRabbit Critical) — Python bridge_v14.py missing `import base64`
added. The base64 module is used at line 101 but was not imported.

BridgeV14 kit work (from #326, unique to that branch):
- C kit: bridge_v14.c + self_contracts.h header
- PHP kit: Minter.php (+ validation fix above)
- Per-kit BridgeV14 in cpp, csharp, go, python, ruby, swift, ts, zig

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* chore(baselines): regenerate 11 per-kit std baseline catalogs

Baselines minted by `mint-all-baselines` against the corrected
orchestrator (P1 fixes from previous commit). Rust excluded from this
binary — handled by provekit-baseline-rust-std.

11 kits minted: c, cpp, csharp, go, java, php, python, ruby, swift,
typescript, zig.

CIDs:
  c          blake3-512:6a30a452...
  cpp        blake3-512:64fc0845...
  csharp     blake3-512:e221722e...
  go         blake3-512:efa64a6c...
  java       blake3-512:22a41119...
  php        blake3-512:93028afd...
  python     blake3-512:224e0cbe...
  ruby       blake3-512:1c52edaf...
  swift      blake3-512:313ab922...
  typescript blake3-512:9da2bdff...
  zig        blake3-512:5f7e1f73...

These are byte-deterministic: same seed (FOUNDATION_V0_SEED = [0x42; 32]),
same configs, same produced_at timestamp. Regeneration on any platform
with the corrected binary must produce identical CIDs.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* chore(baseline-std): silence dead-code warnings + document rust.toml exclusion

- Rename ctorN -> ctor_n (snake_case, fixes non_snake_case warning)
- Add #[allow(dead_code)] to ctor0/ctor2 with explanatory comment; they are
  scaffolding for future N-ary predicate kinds, not bugs
- Add comment to configs/rust.toml explaining it is intentionally excluded from
  the mint_all_baselines langs list — Rust baselines are owned by
  provekit-baseline-rust-std (#292) to avoid duplicate rust-std-baseline-v1

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
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.

2 participants