Skip to content

Safe Hands: Solana transaction authorization suite (T0 guard + T1 builder + T1 Squads proposer) - #112

Draft
Pratiikpy wants to merge 79 commits into
zeroclaw-labs:mainfrom
Pratiikpy:safe-hands
Draft

Safe Hands: Solana transaction authorization suite (T0 guard + T1 builder + T1 Squads proposer)#112
Pratiikpy wants to merge 79 commits into
zeroclaw-labs:mainfrom
Pratiikpy:safe-hands

Conversation

@Pratiikpy

Copy link
Copy Markdown

What this is

Three wasm32-wasip2 tool components + a shared core that give a ZeroClaw agent a complete, safe path for moving Solana money:

  • solana-tx-authorize (T0): decode → declared-intent binding → deterministic policy → simulate → ALLOW/REVIEW/DENY/UNKNOWN with reason codes. Holds nothing, builds nothing.
  • spl-transfer-build (T1): unsigned SOL/SPL transfers (ATA-aware, memo) that refuse out-of-policy requests at build time.
  • squads-proposal-build (T1): builds unsigned Squads v4 proposals — but only after independently re-running the entire policy evaluation from its own operator config. A caller-supplied ALLOW is never trusted (SH-TRUST-FORGED).

The agent proposes, a Squads multisig disposes. No component holds a key or signs anything.

Prove it in one command (offline)

just prove-safety

All unit tests + a 20-fixture YAML attack arena (amount/recipient/mint swaps, hidden transfers, authority handovers, unlimited delegates, durable nonces, fail-closed paths, and a forged caller-supplied ALLOW) + clippy -D warnings on host and wasm32-wasip2 + release builds of all three components.

Already run live

  • Full 3-tool flow in a real ZeroClaw agent (kimi-k3) on devnet, ending in an on-chain Squads proposal that was approved and executed (0.05 SOL moved) — signatures in EVIDENCE.md.
  • Byte-exact encodings vs @solana/web3.js (message, ATA) and the official @sqds/multisig SDK (PDAs, both instruction encodings, Squads' inner TransactionMessage format).

Notes for review

  • Layout: libs/safe-hands-core + three plugins/* mirroring redact-text (pure core, thin wasm shim, crate-type = ["cdylib", "rlib"], committed Cargo.lock, vendored wit untouched).
  • Permissions: only config_read + http_client.
  • Happy to adjust layout/naming — opening early for feedback per the bounty guidance.

Pratiikpy added 30 commits July 21, 2026 07:30
…nding, T22 rules, velocity, fee caps (31 tests)
…ts, intent binding, injection-proof (8 tests + wasip2 component, 494KB)
…ilder policy pre-check + roundtrip invariant (6 tests, 514KB component)
…+ forged-verdict rejection + Squads v4 proposals (6 tests incl. SH-TRUST-FORGED)
…+ live devnet multisig + full 3-tool flow proven
…harness

- decode: resolve v0 Address Lookup Tables explicitly; fail closed when
  loaded addresses are missing or count-mismatched; preserve signer/
  writable flags and program-id-by-index after resolution
- policy: aggregate per-transaction spend caps and exact intent-amount
  matching across all transfers, closing the split-transfer cap bypass
- tlv: correct Token-2022 AccountType::Mint marker (1, not 2), with an
  independent golden test
- authorize: require fresh, structurally complete simulation evidence
  (context.slot + getSlot within max_slot_age); missing evidence -> UNKNOWN
- rpc: MockTransport records (method, params) pairs for exact assertions
- conformance: supply context.slot + getSlot in the mock so the 20-fixture
  arena matches the hardened simulate() contract (20/20 passing)
- LICENSE: MIT (the repo previously shipped no license text) — satisfies
  the submission's MIT requirement; holder "Safe Hands contributors"
- .gitignore: ignore env files, private keys / Solana keypairs, local
  scratch, and node_modules; no blanket *.json so YAML fixtures and
  registry.json stay tracked
Matches upstream CI (tools/ci/validate_components.sh) locked builds. Fixes
the misleading "offline" claim: tests mock RPC (no live network), but cargo
still fetches dependencies on a fresh clone, so --offline is not forced.
…+ decoder fuzz

A property-based fuzz of the decoder (arbitrary bytes -> Ok/Err, never panic)
found a real panic on hostile input: a v0 or legacy message header whose
readonly counts exceed the key partition underflowed the writable/signer
computation, and legacy parsing indexed account keys without a bounds check.
Both now validate and fail closed.

- decode: enforce `readonly_signed <= num_signers` and
  `num_signers + readonly_unsigned <= key_count` for v0 and legacy; bounds-check
  every legacy account index instead of indexing directly
- policy/tests: 6 property-based security invariants (under-cap allows;
  split-aggregate over-cap never allows; disallowed recipient never allows;
  authority-change always denies; signed never allows; evaluation deterministic)
- roundtrip: decoder totality fuzz (1024 cases) + committed regression seed

Verified: core 66, authorize 11, spl 6, squads 6, conformance 20/20.
Clone-and-run reproduction of the safety claim without `just`: host tests
(property invariants + decoder fuzz), the 20-fixture attack arena, and all
three wasm32-wasip2 release builds. No keys, no funds, no live network.
…n on devnet

Turns the three-plugin authorization suite into a running merchant desk:
issue a USDC invoice, confirm it from finalized chain evidence, and prepare
an approval-gated refund through Squads.

payment-verify (T0, new):
  A wasm32-wasip2 component cannot persist a byte, so nothing is stored.
  The invoice reference is derived from (merchant, order_id, salt) and is
  off-curve by construction, so no key for it can exist. Issuing and
  checking an invoice are therefore the same call: an unpaid order returns
  its payment link, a paid one returns finalized evidence. Two independent
  RPC endpoints must agree; disagreement is UNKNOWN, never a merge.

  Rejected: Token-2022, delegated authority, split and multi-owner
  payments, duplicates, wrong mint/recipient/reference, failed
  transactions. A reference attached to an unrelated transaction is
  ignored rather than escalated, so it cannot be used to force manual
  review.

  merchant_owner, invoice_salt and the settlement mint are host-only. A
  live run had the model invent a mint address; with no argument to
  supply, an injection cannot invoice in a lookalike token.

durable nonce:
  A recent blockhash dies in ~90s, which is shorter than a human takes to
  approve a refund. spl-transfer-build can pin validity to a nonce account
  instead. It takes two independent operator opt-ins — the nonce account
  in allowed_nonce_accounts and advance_nonce in allowed_instructions —
  and the decoder now enforces that AdvanceNonceAccount is instruction 0,
  which was previously only asserted in a comment.

Tier 1 layering:
  Solana Pay URL construction and the operator workflow are a skill, not
  compiled code. Two SOPs: a cron-triggered invoice watch and a
  manual-only refund with a confirmation gate on every funds-touching
  step.

Findings from running it against devnet, each now covered by a test:
  - The canonical Solana Pay shape was being rejected. Attaching the
    reference makes the RPC render the multisig variant of
    TransferChecked, with the payer under multisigAuthority and the
    reference listed as a signer despite signing nothing. A genuine SPL
    multisig is still refused, at the owner-must-have-signed check.
  - Lateness masked the amount. A payment both late and short reported
    only LATE. The amount now wins and `late` rides on the evidence. A
    non-positive expiry means no expiry.
  - Blank optional values, in arguments and in config, were treated as
    real values and turned unset options into hard failures.

Fixtures 21-23 add nonce-allowlisted ALLOW, refund redirect DENY, and
refund inflation DENY. stage_local.py no longer skips plugins missing
from a hardcoded list.

105 core tests, 23 conformance fixtures, clippy clean on host and
wasm32-wasip2. Live devnet record in EVIDENCE-merchant.md; EVIDENCE.md
remains historical pre-remediation data and is not evidence for this
build.
The merchant loop was previously only exercised through `zeroclaw agent -m`
on the CLI. This records it running end to end over a real Telegram chat
against the daemon: charge -> payment link -> customer pays -> PAID with
payer and signature, with the trace confirming payment-verify was actually
invoked rather than answered from memory.
…ation

Records the attack the brief requires, run against the live agent on a real
channel: a relayed customer message asking to redirect a refund and skip
multisig approval. Two independent layers refuse it — the agent declines and
names the attempt, and the builder denies the same request with
SH-DENY-RECIPIENT-003 when it is forced through the tools deliberately. The
second is the one that holds if the model is compromised.

Also records both SOPs loading and validating against the runtime, with the
confirmation gates parsed on exactly the funds-touching steps.
…ortuguese

The rule read 'default to Portuguese; switch to English if they write in
English', and the model answered English messages in Portuguese anyway. The
wording was not the problem — a long Portuguese conversation history is a
strong prior and outweighed the instruction. A fresh session obeyed the same
skill correctly.

The rule now leads with mirroring the message being answered rather than a
house default. Verified over Telegram switching both ways inside one
conversation after /new.
solana-tx-authorize already returned a decision_id committing to the exact
transaction bytes, the policy, the verdict and the reason codes. Nothing
could check it. `--verify` recomputes all of it: decodes the transaction,
canonicalises the policy, re-runs the engine, and re-derives the id. A
reviewer does not have to trust our ALLOW, and does not have to trust us
either — they recompute it.

Verified against a live devnet decision (all 5 checks pass) and against a
forged one: swapping the intent recipient while still claiming ALLOW
re-derives DENY with SH-INTENT-RECIPIENT-031, so the forgery cannot pass.

Building it surfaced that the verdict is not a pure function of (bytes,
policy) as the decision_id shape suggests — it also depends on the
caller-declared intent and on simulation evidence from RPC. The receipt now
carries both, with simulation marked as attested rather than recomputed,
because pretending otherwise would let a receipt claim a verdict its inputs
do not support.

Also adds Kani proof harnesses for the authorization invariants
(src/policy/proofs.rs, compiled only under cargo kani) and declares the
kani cfg so normal builds and clippy are unaffected.
…s stated honestly

Documents `just verify-receipt` (re-derive any decision, including the
forgery case) and the Kani harnesses. The Kani section says plainly that the
harnesses compile and CBMC starts on them but no run has reached a verdict
here, so they are proof obligations rather than proofs — the verified claims
remain the ones prove-safety covers.
…JSON

The first harness called Policy::from_json, which pulls an entire serde parse
into the solver. CBMC spent over an hour walking allocation paths inside it
and never reached a verdict. Building the same Policy by hand removes that
cost and leaves the harness exploring the decision logic, which is the part
actually under proof.

Also trims the symbolic amount set to the cap boundary — cap-1, cap, cap+1 —
since u128::MAX added solver cost without adding a distinct branch. The module
docs now record both limits and why they exist.
No harness reached a verdict. Three rounds of tuning moved the bottleneck but
never terminated, so the claims are labelled proof obligations and the README
points at prove-safety as the verified set.

The negative result is specific enough to be useful: the cost is not the
decision logic but the heap collections it reads from. evaluate resolves an
allowlist by looking a String up in a BTreeSet<String>, and CBMC must model
the tree's node linking. Dropping the JSON parse cut exploration from 1.2 MB
to 107 KB of log and moved the stall from serde into BTreeSet; shrinking the
collections did not move it again.

The fix is structural — split evaluate into fact resolution over collections
and a pure decision over resolved booleans, then prove the decision. That is a
refactor of audited code and is deliberately not being done late to chase a
result.
cargo-mutants broke the policy engine 76 ways and reran the suite. Four
breakages went undetected, and two of them were load-bearing:

  policy.rs:302  the packet-size guard. Flipping > to >= survived, so the
                 boundary was never pinned. A transaction of exactly
                 max_transaction_bytes — Solana's 1232-byte MTU, a legitimate
                 maximum-size transaction — could have started being rejected
                 with every test still green.

  policy.rs:25   Verdict::as_str could return "xyzzy". Not cosmetic: that
                 string feeds the decision_id hash every receipt commits to
                 and that --verify re-derives.

Two more surfaced on the instruction allowlist: no test drove a known program
carrying an unlisted instruction, and no test covered an operator who removes
transfer_checked from allowed_instructions. Both now have tests.

Survivors in the fee block are left alone deliberately. Policy::from_json
rejects the fee section in v0.1, so that code is unreachable through the
public API; a test that reached it through a back door would assert nothing
about the shipped product. Said so in a comment where the next reader will
find it.

Also adds audit: libs/safe-hands-core
audit: plugins/payment-verify
audit: plugins/solana-tx-authorize
audit: plugins/spl-transfer-build
audit: plugins/squads-proposal-build
audit: conformance, now part of prove-safety: cargo-audit across all six
lockfiles, failing on any new advisory. RUSTSEC-2025-0141 (bincode
unmaintained) is accepted knowingly — Solana's wire format IS bincode-1, and
2/3 changed it, so upgrading would silently change the bytes this project
exists to encode exactly.
…declare what they import

Mutation testing on the full 76-mutant run left 16 survivors, not the 4 an
earlier partial run suggested. Six were real and now have tests:

  recipient_matches_intent could be replaced wholesale with `true` and the
  unit suite passed — no test drove a transfer whose recipient disagreed with
  the declared intent. The conformance arena covers it; this crate's own tests
  did not, and mutants only reruns this crate.

  policy_from_config's emptiness guard could be inverted — the fail-closed
  path every plugin depends on when no policy is configured.

  Plus the ATA-matching branch, and both arms of the action/mint consistency
  check (SPL and bare SOL).

Eleven survivors remain and are equivalent mutants, not gaps: ten sit in the
fee block, which Policy::from_json rejects outright in v0.1 so nothing can
reach it, and one is a redundant emptiness guard that from_json would reject
anyway.

Adds tools/ci/verify_capabilities.py and `just verify-capabilities`, now in
prove-safety. Every other check in this repository reads the source; this one
reads the compiled .wasm an operator installs and asserts it imports no
capability beyond its declared permissions. All four components import
wasi:http and nothing else of consequence — no wasi:filesystem, no
wasi:sockets. "Holds no keys and stores nothing" is now a property of the
artifact, checked, rather than a claim about the code.
Until now nothing here ever ran the artifact. cargo test links the plugin
crates as rlibs and calls their Rust directly; the conformance arena does the
same. The .wasm an operator installs was built, linted, and never executed by
anything but ZeroClaw itself — a real gap for a security tool, because the
source is what we audit and the component is what refuses the payment.

component-test loads each staged component into wasmtime, grants exactly the
imports the ZeroClaw host grants (WASI p2 plus outbound HTTP, no preopened
directory), implements the zeroclaw:plugin/logging host side, and calls
execute() across the component boundary. Six refusals asserted: garbage bytes,
no policy, no merchant, a single RPC endpoint, and a forged ALLOW handed to
the proposer.

build.rs regenerates a gate-free copy of wit/v0 on every build, because
wasmtime's host bindgen has no features option for @unstable items. Generating
it rather than checking in a second copy means a contract change breaks the
build instead of drifting silently.

Two things this surfaced, both in the harness rather than the product: a DENY
verdict returned with success: true is the authorizer working, not erroring,
and the builder reads mint decimals from chain before evaluating policy, so
its recipient and cap refusals need a live endpoint and cannot be asserted
offline. Both are written down where the next reader will hit them.
…gine

Two libFuzzer targets, both clean on first run:

  decode   6,874,508 executions, 0 crashes
  policy   2,551,685 executions, 0 crashes

The decoder target asserts more than absence of panics — it asserts decoding
is deterministic. The same bytes must yield the same facts on a second call,
because decision_id binds a verdict to exactly those bytes; a decoder that
answered differently on a second look would make that commitment meaningless.

The policy target is the harness the stalled Kani proofs were reaching for,
run by a tool that terminates. Kani could not finish because CBMC models
BTreeSet<String> node internals symbolically (its issue #1251); libFuzzer
executes concretely, so the collections that blocked verification cost
nothing. It asserts ALLOW is unreachable for an unlisted recipient, and that
anything allowed cleared every hard invariant — unsigned, no authority change,
within cap, no Token-2022 extension.

Weaker than a proof, because it explores rather than exhausts. Stronger than
a test, because nobody chose the inputs.

Kept out of prove-safety deliberately: fuzzing runs for as long as you give
it, and a gate has to terminate. `just fuzz <target> <seconds>`.
@Pratiikpy

Copy link
Copy Markdown
Author

Keeping this as a draft deliberately, and flagging why so it isn't read as an abandoned PR.

The Superteam Brasil listing was updated to say that registry PRs are not submissions and that registry merges happen separately after judging, with maintainers inviting the strongest implementation per plugin family. So this branch is not being pushed for review during the bounty window — the submission is a showcase post, and this PR is here as the code it links to.

The branch is current and the components are unchanged. Two things have been added since the last push, both aimed at making the work checkable rather than at moving this PR along:

Happy to pick this up whenever maintainers want to look at it post-judging, and equally happy to rebase or split it per whatever the registry process ends up being.

xaorao2 and others added 29 commits August 6, 2026 07:27
verify_capabilities.py reads the import table out of each compiled component
with wasm-tools, which is not present on ubuntu-latest. Everything else in the
gate passed; this was the only missing runner dependency.
The 58s cut showed the Telegram session only. The new cut adds the executed
Squads multisig payout on Solana Explorer and the green prove-safety CI run,
so the on-chain and offline evidence are both visible in the video rather than
only claimed in the README.
The README says what Safe Hands establishes. This says what it does not,
at the same level of precision:

- the verification boundary drawn explicitly: verdict() is proven exhaustively
  over all 42 ResolvedFacts fields, resolve()<->evaluate() agreement is
  proptested rather than proven, and decode() is only fuzzed. An attacker who
  could make the decoder mis-describe a transaction would defeat all twelve
  proofs without touching them, so that is named as the weakest link.
- intent binding closes bytes-vs-declared-intent, not declared-intent-vs-what
  -the-operator-meant. The Squads approval covers that gap with a process
  control, not a technical one.
- the surfaces we refuse rather than handle, and why each is hard.
- the fixtures are regression tests, not a red team, and saying otherwise
  would be dishonest.

Also records that Kani and cargo-fuzz do not run on Windows, so contributors
there cannot reproduce the proofs or the fuzzing locally.
…uild hashes

Two gaps the ROADMAP names, closed.

SH_PROPTEST_CASES: the case count was hard-coded at 512, which is the right
number for a gate that runs on every change but made a longer soak impossible
to ask for. It is now a floor, not a ceiling:

    SH_PROPTEST_CASES=200000 cargo test

CI keeps 512 and stays fast.

Artifact provenance: the README publishes hashes from a Windows build with an
honest caveat that a Linux build may differ. CI now publishes its own hashes
from a clean runner in the job summary, so both are visible and comparable
rather than a reader having to take one platform's word for it.
Found by running the suite at 200k cases instead of 512.

split_aggregate_over_cap_never_allows drew 1..8 amounts and then discarded any
draw whose sum missed the cap. Every single-transfer draw is discarded by
construction, and most small ones are too, so proptest exhausted its 1024
global-reject budget and aborted:

    Test aborted: Too many global rejects
      successes: 3286
      global rejects: 1024 at prop_assume!(sum > 25_000_000)

The property never failed - it could not run. This is the backbone of the
split-bypass claim, and it was only ever exercised a few hundred times because
it aborted the moment anyone asked for more.

Now the draw is topped up to clear the cap instead of being thrown away. No
rejects, every transfer still at or under the per-tx cap so the aggregate rule
remains the only one that can fire, and the property runs 200k cases in 1.6s.

Whole suite green at SH_PROPTEST_CASES=200000: 194 passed, 0 failed.
The verification-debt section claimed fuzzing was too short. It now says what
happened when that was acted on: a property that could not run at scale, found
within minutes of raising the budget, and fixed.
decode() is the weakest link in the verification chain and the ROADMAP says
so: verdict() is proven exhaustively, the model/engine agreement is proptested,
and everything downstream trusts whatever shape the decoder returns. An
attacker who could make it mis-describe a transaction would defeat all twelve
Kani proofs without touching one of them.

Its only adversarial coverage was a libFuzzer target that cannot run on
Windows, plus a uniform-random property. Random bytes essentially never
produce a plausible header followed by a hostile length, which is exactly the
shape a hand-rolled cursor is most likely to trust.

These start from a real transfer message and damage it:

  - every truncation, byte by byte, including empty
  - single-byte corruption anywhere in the first 400 bytes
  - a shortvec claiming up to 65535 elements in front of a real message
  - every value of the versioned-transaction prefix byte
  - arbitrary runs spliced in behind a well-formed head

Two invariants everywhere: never panic (in the component a panic is a trap,
and a caller reading a trap as anything but refuse has failed open), and
decode twice must agree (decision_id binds a verdict to exactly these bytes,
so a decoder that answered differently would void that commitment).

No findings: 6 properties green at SH_PROPTEST_CASES=200000. The value is that
they now run wherever cargo test runs, rather than only where libFuzzer does.
The twelve harnesses in policy/resolved/proofs.rs are the strongest claim in
the project - ALLOW-requires-every-hard-check stated over the entire decision
space rather than sampled. Nothing was running them.

Kani is Linux/macOS only, so every contributor on Windows was taking the word
'proven' on trust, and so was every reader of the README. CI is Linux; there
was no reason for this gap except that nobody had closed it.

Now they run on every push. If a change to the engine breaks an invariant, or
the heap-free model drifts from evaluate() in a way the proptest agreement
misses, this fails.
The ROADMAP admits the prompt boundary is tested hardest because it is the
cheapest to simulate, and that this is a poor reason. Safe Hands trusts three
things it does not control and only one of them is a chat message.

The RPC endpoint supplies mint decimals, simulation results and the current
slot. Decimals decide what '25' means and simulation is a hard gate, so a
wrong answer there is not noise. Covered: a simulation slot ahead of the
chain, a stale one, eight shapes of malformed evidence (missing context, null
result, a string where a slot belongs, a JSON-RPC error, a failed simulation),
an unreachable endpoint, and a mint the endpoint will not describe.

The operator policy arrives as JSON from host config. Covered: empty, null,
non-object, unterminated, 2000 nested brackets, a 5000-element allowlist,
negative and 1e400 caps, a duplicate key whose second value is permissive, and
homoglyph addresses.

One invariant throughout: degrade to refusal, never to permission. An endpoint
that lies, stalls, contradicts itself or disappears must never yield a verdict
more permissive than an honest one would have.

All 7 pass unchanged - the behaviour was already right. The value is that it
is now pinned, and that the untested boundaries named in the ROADMAP are no
longer untested.
Kani now runs in CI, so the proofs are discharged on every push rather than
resting on a local run. Trust-boundary chaos is covered for the RPC and the
policy document.

Left explicitly open: paid human red-teaming, and a model that has been
compromised rather than merely fooled.
The top item in the ROADMAP. decode() is the weakest link in the chain -
verdict() is proven exhaustively, everything downstream trusts whatever shape
the decoder returns, and an attacker who could make it mis-describe a
transaction would defeat all twelve authorization proofs without touching one.

Proving it outright is out of reach: it parses heap structures, which is what
made the earlier attempt to model-check evaluate() directly fail to terminate.
What is in reach is the property that matters most, over a bounded input:

  no byte string up to N bytes makes the decoder panic

Worth a theorem rather than a fuzzing result because of where this runs. In a
wasm32-wasip2 component a panic is a trap, and a host that reads a trap as
anything but refuse has failed open. Err is a correct answer; unwinding is not
an answer at all.

Three harnesses: no-panic to 8 bytes, no-panic to 3, and decoding-is-a-function
to 6. The bounds are small because Kani explores the whole input space, so
every byte costs exponentially. They reach past the signature-vector shortvec,
the version byte and the message header - where a truncated buffer and a
lying length prefix are both in play. Beyond the bound the same properties are
asserted by decode_hostile.rs and the libFuzzer target.

CI runs these as a separate non-blocking job. Symbolic execution through a
parser can fail to terminate in a way the policy proofs cannot, and a
speculative proof must not be able to turn the gate red. The policy job is now
pinned to its own harnesses so it cannot regress.
The ROADMAP names this as untested and it is the harder case. Every existing
injection test assumes a model that has been tricked - still trying to do its
job, given bad information. A compromised model is not confused. It supplies a
transaction, an intent that describes it exactly, a passing simulation, and
effect evidence that agrees with both. Every consistency check the engine can
run against the caller's own story succeeds. Nothing is out of place.

What it cannot supply is the operator's policy: caps, the recipient allowlist
and the asset list come from host config, which the host injects and strips
from caller arguments. So the property is not 'can the engine spot a lie' -
against a competent liar it cannot - but that a perfectly consistent story
which violates policy is still refused.

Seven cases, each with a truthful intent: unlisted recipient, over cap,
unlisted asset, a split spend whose intent declares the true aggregate, an
asserted passing simulation, and the same violation told honestly and then
dishonestly - neither is allowed. Plus a control case that must be ALLOW, so a
future change cannot make these pass by refusing everything.

If any of them ever fails, intent matching has been promoted from a second
check into a substitute for the first, and a compromised model could authorize
itself.
Both were listed as open. One is closed; the other is started and honestly
described as started - bounded proofs of the property that matters, running
non-blocking because symbolic execution through a parser may not terminate.
The ROADMAP calls intent semantics unsolved. That is true of the whole problem
and lazy about the half that is tractable - which was already proven, with the
proofs never given their meaning.

Authorization has three objects, not two: what the operator meant (M), the
policy they wrote (P), and the transaction (T). The usual industry check is T
against a declaration the agent supplies, which is the weakest comparison
available, because a compromised agent supplies both and they agree.

This project checks T against P. It does not check P against M. Saying only
'we do intent binding' hides which of those is which.

INTENT.md defines the denotation of a policy as a set of permitted effect
vectors - net balance movement from simulated before/after state, not the
instruction list, because an instruction list says what a transaction claims
to do and balance movement says what it did. Soundness is then:

    evaluate(P, T) = ALLOW  =>  effects(T) in [[P]]

That is not asserted, it is the conjunction of four existing Kani harnesses,
each exhaustive over all 42 fields. The document names which clause each one
discharges. Completeness is explicitly not claimed: refusing something
permissible is a usability cost, allowing something impermissible is the
failure this exists to prevent.

P against M stays open, and no amount of testing closes it - M exists only in
someone's head. Two things would narrow it without solving it: rendering the
policy back to the operator in their own terms, and making the Squads approval
show effects rather than instructions.
…hat measured

N = 8 did not finish inside 90 minutes on CI. That is a result, not a setback,
and it is worth recording rather than quietly deleting: the obstacle to
proving this decoder is the shape of the code, not the amount of proof effort
available.

Symbolic execution has to carry every path through a hand-rolled cursor that
reads length prefixes out of the same buffer it is bounded by. A decoder built
from bounded, heap-free steps would be provable at a useful width. This one is
not, at any width worth having.

So the bounds drop to 4 bytes and the unwind to 8 - enough to cover the
signature shortvec, the version byte and the start of the header, which is
where a truncated buffer and a lying length prefix are both in play. Beyond
that the property is asserted by decode_hostile.rs and the libFuzzer target,
which is testing rather than proof and is described as such.

This strengthens ROADMAP item 8.1 rather than satisfying it: the first move is
to restructure decode until it can be proven, not to keep raising N.
…st of my own

Two independent hostile reviewers were pointed at this branch. Both found
things. These are the ones that are straightforwardly wrong and fixable.

1. Squads: refuse a Controlled multisig.

squads.rs parsed config_authority and nothing ever read it. On a Controlled
multisig that single key can call multisig_add_member, change the threshold,
and rotate the config authority - no vote, no proposal. So the Initiate-only
check this plugin performs proves nothing there: whoever holds that authority
can grant themselves Vote and Execute and approve their own proposal, while
the tool prints 'the agent holds no keys and cannot approve'.

The central safety claim is only true on an Autonomous multisig, so refuse
rather than emit a proposal under a guarantee that does not hold.

2. payment-verify: two endpoints must actually be two endpoints.

Config was validated with starts_with("https://") on each URL and nothing
else. rpc_url == rpc_url_fallback was accepted, queried one provider twice,
and the receipt still reported 'primary and fallback agreed'. A copy-paste in
host config silently turned a 2-of-2 corroboration gate into 1-of-1 while
still claiming corroboration. Identical URLs now drop the fallback, so the
result is honestly single-sourced.

3. chaos_boundaries.rs: my own test asserted nothing.

Added earlier in this session. Every hostile policy assertion sat behind
 and not one of the sixteen
documents parses, so the bodies never ran - the test reduced to 'from_json
does not panic'. The reasoning inside was also inverted: it searched for a
zero cap as though that were permissive, when zero is the strictest value
there is.

Rewritten to assert the real contract - hostile documents are rejected - plus
two positive controls: the shipped policy shape must still parse, and an
honest RPC endpoint must still reach Ok. Without those, a change that broke
parsing or the mock would make the whole file pass by refusing everything,
which is precisely the failure it was written to prevent.

237 tests, clippy clean on all three crates.
An independent review checked the docs against the code. These are the places
they did not match. Correcting them is more useful than the claims were.

EVIDENCE-proofs.md said the program-admission rule was 'held to a proof rather
than a test'. The exact opposite is true: it is the one rule the proof model
does not implement, so the harnesses say nothing about it. That was the worst
line in the documentation and it is now a correction in place rather than a
quiet edit.

ROADMAP.md now records the model/engine divergence in full: which two
functions disagree, that every observed divergence is model-stricter so there
is no exploit, and why the proofs still do not transfer - reading 'the model
never allows X' as a statement about the engine needs engine-ALLOW implies
model-ALLOW, which is false. Also records that 17 of 34 boolean fields are
never exercised by the agreement tests, so raising the case count cannot find
this.

Corrected three claims I wrote earlier in this session: the decoder proofs do
not reach past the message header (a legacy message needs 37 bytes to
deserialize, so every input they explore returns Err - they prove the length
guard does not panic), the chaos policy tests pinned nothing until they were
rewritten, and 'seven cases' was six plus a control.

README: the proven-engine headline now says verdict function, which is what is
actually proven; the pasted Kani output was stale at 8 harnesses and is now
12. Fixture counts said 20, 23 and 28 in different files; all now say 28,
which is what is on disk.
Effect analysis diffs balances, so an SPL Approve CPI'd from an admitted
program grants an unlimited delegate while moving nothing - zero movements,
inside any cap, reachable ALLOW. The vault is drained afterwards, outside this
system. FreezeAccount and SetAuthority(CloseAccount) are invisible the same
way. The claim that the worst case is the operator's cap was wrong; the worst
case is an unbounded standing delegate.

And the mitigation for it is unavailable: neither T1 builder populates
facts.effects, so under an effects.required policy both return UNKNOWN instead
of a proposal. The transactions the decoder cannot read are exactly the ones
that cannot be routed to a human through Squads.

Writing these down rather than fixing them under time pressure. A reader
should get them from the roadmap, not from an exploit.
…roke

The config_authority check added in the previous commit broke three existing
tests, because the shared multisig fixture had always built a Controlled
multisig - config_authority set to [8u8; 32]. Nothing had ever depended on
that being Autonomous, so nothing had noticed.

The fixture now builds an Autonomous multisig, which is the only shape this
tool is willing to propose against, and a new test patches only the
config_authority bytes of that same fixture to assert the refusal fires and
names its reason.

Building those bytes by hand instead - as an earlier draft did - tested the
account parser rather than the rule: it refused, but for a malformed-account
reason, which would have passed a weaker assertion while covering nothing.

12 tests in this crate, clippy clean on host and wasm32-wasip2.
The most serious finding from the adversarial review, and the one the docs got
most wrong.

Effect analysis diffs balances. An SPL Approve, CPI'd out of a program the
operator has admitted, moves zero lamports and zero tokens: every Movement is
zero, the transaction sits inside any per-transaction cap, and it can reach
ALLOW. What it leaves is a delegate entitled to the whole balance, drained
later, outside this system, with no policy consulted. FreezeAccount and
SetAuthority(CloseAccount) were invisible the same way.

parse_token_account read mint, owner, amount and state, and skipped exactly the
fields that matter here: delegate at 72..108, delegated_amount at 121..129,
close_authority at 129..165.

effects::authority_changes compares those four across the transaction and
reports the accounts where they moved. It reports the fact and does not decide
what to do about it - which changes are acceptable is the policy's business,
and revocation is reported alongside grant for that reason.

Deliberately built as a separate function rather than a field on Balance: the
struct is constructed in a dozen places across tests and callers, and widening
it to close a security hole would have meant touching all of them under time
pressure.

Seven tests, including a positive control - an ordinary transfer must NOT be
reported, or a function that returned everything handed to it would pass every
other case. Accounts on only one side are excluded: creation and closure are
already visible as movements, and reporting them here would flag every ATA
creation.

The README's claim that the worst case is the operator's cap was false. The
worst case was an unbounded standing delegate, and the amount at risk was the
account rather than the transaction.
…e roadmap

Also states plainly what is still missing: authority_changes reports the fact,
and nothing yet turns that report into a denial. Observable is not enforced.
Completes the previous commit. Detecting the grant and then allowing the
transaction would have been worse than not detecting it - the report would sit
in a receipt nobody reads while the verdict stayed ALLOW.

observe() carries the finding on Effects::authority_changed, and
solana-tx-authorize folds a non-empty result into facts.authority_change: a
fact the engine already denies at policy.rs:485 and the heap-free model already
denies at resolved.rs:163.

Reusing that fact rather than adding a rule was the point. A new rule needs a
new ResolvedFacts field, and the model and engine already diverge on the
admitted-program path. Adding a second divergence inside the very subsystem
where the proofs are known not to transfer would have widened the hole this
work exists to close.

So an SPL Approve CPI'd from an admitted program - zero movements, inside every
cap, previously reachable ALLOW - is now DENY, by a rule that was already
proven.
The load-bearing joint of the whole verification story, and it was broken.

Twelve Kani harnesses prove properties of resolved::verdict(). That is only a
statement about policy::evaluate() - the function operators run - if the two
agree. On the admitted-program path they did not: evaluate() forgives an
unfamiliar program when effects are required, present and the operator named
it; resolve() denied unconditionally. Engine Allow, model Deny, on the
repository's own ALLOW fixture.

Nothing was exploitable - every divergence ran model-stricter. But the
implication needed for the proofs to transfer is engine-ALLOW => model-ALLOW,
and that was false, so on the one path where the engine deliberately permits a
program nobody decoded, twelve proofs said nothing.

The carve-out is now mirrored in the model, and tests/model_agreement.rs pins
it by varying the instruction list - which neither existing agreement check
ever does. That is why they could not catch this: 17 of 34 boolean fields,
has_unknown_program among them, were unreachable at any case count.

Checked the tests actually bite: reverting the model change makes them fail
with both verdicts printed, and restoring it makes them pass.

248 core tests, clippy clean.
Under an effects-required policy both builders returned UNKNOWN rather than a
draft, because neither ever populated facts.effects. The consequence was
backwards: the transactions whose effects matter most - the ones the decoder
cannot read - were exactly the ones that could never reach a human through
Squads. The mitigation was unavailable precisely when it was needed.

Both now observe effects from the same RPC on the same terms as
solana-tx-authorize, and fold an authority change into facts.authority_change
so a delegate grant denies here too rather than only at the guard.

Evidence that cannot be obtained still stays absent, so the engine reports
UNKNOWN instead of reading silence as 'nothing moved' - which was the correct
half of the original behaviour and is preserved.
The decoder proofs are deliberately non-blocking: symbolic execution
through a hand-rolled parser can fail to terminate, which the policy
proofs cannot, since that model is heap-free by construction.

`continue-on-error: true` on the job does not cover the case that
actually happens. The job hits its own `timeout-minutes` and GitHub
marks it *cancelled*, and a cancelled job makes the run cancelled no
matter what the flag says. So the run at HEAD read red while every
substantive job in it was green:

  prove-safety (offline gate)        success
  machine-checked proofs (kani)      success   12/12 harnesses, 414 checks
  supply-chain audit                 success
  decoder proofs (non-blocking)      cancelled  <- the whole run's verdict

Bounding the step with `timeout` instead makes it exit 124, which is an
ordinary failure, which `continue-on-error` on the step does absorb.

The summary now states which way it went rather than staying silent. If
the proof runs out of time that is written into the job summary, next to
a pointer at tests/decode_hostile.rs, which asserts the same property on
every platform inside the blocking gate. Only the exhaustive form is
lost, and saying so is the point.
The roadmap said "the bound is now 4 bytes" directly after recording that
N = 8 did not terminate. A reader takes that to mean 4 does terminate.
It does not.

Two independent CI observations, both on the shipped bounds of 4/3/4:

  run 31078266306  job cancelled at its 45-minute limit, ~30 of those
                   minutes spent in the solver
  run 31089942044  bounded step exited 124 at the 25-minute mark

Neither emitted a single `VERIFICATION:- SUCCESSFUL` line, and neither
printed a harness summary. So the honest statement is not that some
smaller N works, it is that none of the ones tried do.

The property itself is not abandoned. tests/decode_hostile.rs asserts
decode-never-panics on every platform inside the blocking gate, and the
fuzzer has 2M inputs behind it. What is missing is the exhaustive form -
the theorem - and the difference between a test and a theorem is exactly
the distinction this project asks to be judged on. Saying "covered by
tests, not by a theorem" costs a claim and keeps the argument intact.

This changes no code. It removes an implication the measurements do not
support.
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