Skip to content

0.50.0 — three audit rounds, each aimed at the one before it - #145

Closed
VickyXAI wants to merge 21 commits into
mainfrom
fix/audit-r2
Closed

VickyXAI wants to merge 21 commits into
mainfrom
fix/audit-r2

Conversation

@VickyXAI

@VickyXAI VickyXAI commented Sep 9, 2026

Copy link
Copy Markdown
Contributor

Round 2 of the audit loop, aimed at round 1. 13 commits, 694 tests.

Why this round exists

0.49.0's 37 fixes were written by six agents working in parallel. This round went looking for what that costs, and found the shape immediately: each agent hardened the rail it was looking at. The quote guard landed on video and image but not music and speech. The in-flight booking landed on Base and the account rail but not Solana, the default chain. Music's Solana call passed no onQuote at all, so the guard hook fired against nobody while the transfer was signed. Every one was a money path and every one passed CI.

The honest headline

Ten angles, 52 raw findings, 30 confirmed after adversarial verification, and not one is a P0 or a P1 — 0.49.0's own list had one of each. The completeness critic's verdict, which I acted on rather than commissioning a round 3:

Do not commission a round 3 as another ten-angle sweep — the general search is spent. Instead: fix the 11 P2s, then run ONE bounded, mechanical rail-parity matrix, which is a table someone fills in, not an audit. Then ship.

The table

test/rail-parity.test.ts states, per paid tool and per rail, which treatments a paid call needs: quote checked before signing, re-reservation at the real price, in-flight booking, honest give-up wording, correct ledger figure. A cell is a claim about the source, so a rail-specific guard that lands without its siblings turns the file red. It also pins the division that is deliberate (the seven tools whose 402 the SDK owns must not grow a quote guard — they cannot see the quote) and fails when a paid tool is missing from the table. It found four more gaps on its first run, all fixed here.

The money paths

Funded wallet moved off its chain 0.49.0's own P0 fix stored the new Solana key and deleted the session file — which is what getChain() keys on — so the continuity pin was never written and the next start moved a funded Base user onto an empty Solana wallet
Two wallets minted at once The cache was assigned after the await, reachable from two entry points; one caller could get a funding QR for an address whose key was discarded
Duplicate orders The card re-enabled an armed Place button mid-submit, and treated every transport failure as "nothing happened"
Preview bound not enforced "Signed at the worst fill you saw" held inside one call; preview and confirm are two, and the confirm re-walked a fresh book
Billed chat booked $0 The account rail short-circuited the settled-cost wrapper, so a billed-then-dropped stream read as a free failure
Unguarded signing music, speech and realface signed whatever the 402 quoted
Unbooked charges Solana give-up booked nothing in video and music; speech, image and realface had no in-flight tracking at all
Ledger 2x hot The reserve was booked as spend: an agent capped at $1.00 on blockrun_rpc was cut off after 250 calls having spent $0.50
Self-refilling cap delegate wrote spent: 0, and delegate is a tool the model can call
Undisclosed allowance The Polymarket approval prompt never said the default grant is unlimited
Guard armed on nothing The relayer's double-send lock fired on credential failures that happen before the batch exists, and its 4xx detector never saw a CLOB ApiError's status

Saying the true thing

"Your wallet needs funding" no longer appears on messages that say nothing was charged — including this repo's own unreadable-quote refusal, which told a wallet holding $1,000 to top up. Account-rail errors are classified at all now: the status boundary excluded a following dot, which is what keeps $402.50 from reading as a status and also what made every BlockRun account API error: 502. fall through silently. A locked keychain is no longer reported as a missing wallet. Card top-up is no longer offered where it does not exist.

Verification

  • npm test: 717 pass, 0 fail (was 636 on main)
  • npm run typecheck, npm run build: clean
  • npm run verify:prices: exit 0 — 0 under-reserving on either chain; the catalogue sweep now names the free-list members it cannot verify instead of counting them as checked
  • Context figures resynced: 12,767 tokens full, 5,270 trading

Refuted, and not changed

Two findings were refuted by their verifiers, and one of the critic's own was refuted by a live probe: it filed phone.ts's unknown-reserve comment as citing a dead route, but /v1/phone/numbers/search answers 402 today, so the comment is accurate.

Round 3 (added after review)

The critic's narrow list, run. It was right that the sweep was spent and wrong about what the list contained: the keychain item turned up a live destroy path, and the polymarket item turned out to be documentation rather than deletion.

What Why it matters
An empty ~/.blockrun/.session overwrote a funded key in the keychain The gate deciding whether to read the keychain asked existsSync; the loaders behind it trim the file and treat whitespace as no key. Zero-byte file: present to the gate, absent to the loader — keychain skipped, new wallet minted, mirror-back overwrote the funded entry. saveWallet is a non-atomic write, so an interrupted one is enough. Both rails now ask whether the file holds a key, which getChain() already asked twice with comments saying why.
keychainDelete answered differently on Linux Documents "gone, including was never there"; only macOS honoured it. Zero callers, which is why nothing caught it.
l1-auth-1271.ts told the next maintainer to delete the live credential path Its header still described the ERC-7739 wrapped L1 signature as the workaround in force and signed off with "delete this module when upstream is fixed". The wrap was the wrong diagnosis, both call sites derive as a plain EOA, and the module has since become the home of deriveApiCreds — so following that instruction stops all trading.
The live e2e scripts printed the wallet address they promised to hide Four scripts, four different regexes. The two that move real money matched 64-hex only, which is a transaction hash; a 40-hex address went through untouched, and the withdrawal error text really does carry one. Only the isError branch was redacted at all — a thrown exception printed the raw message and stack.
smoke-speech.ts charged the wallet for being run No flag, limit: null, header advertising "$0.001" for a run that ends with a $0.0525 sound effect.

Three new guards came out of it, in the spirit of the rail-parity matrix — each pins an assumption that previously lived only in a comment:

  • test/axios-scope.test.tsapplyClobProxyOnce mutates axios.defaults process-wide and cannot be scoped, because clob-client-v2 reaches for the hoisted axios itself. The safety argument is that only Polymarket shares it. Now a non-Polymarket axios import turns the suite red instead of silently routing that module through an operator's POLYMARKET_CLOB_PROXY.
  • test/scripts-redaction.test.ts — one redaction, and no script may grow its own.
  • test/scripts-spend-gate.test.ts — anything in scripts/ that registers a tool handler needs a confirm gate and a budget cap. Static on purpose: a behavioural test would charge the wallet the day the gate broke.

The release automation

Four scripts nobody had audited, each able to produce confident output from a failure. Found by a read-only audit subagent, every finding re-verified before fixing.

What Why it matters
measure-tool-schema.mjs measured a JSON-RPC error as zero tokens rpc() never looked at msg.error, callers destructure { result }, so an error reply became undefinedresult?.tools ?? [] → "0 tokens across 0 tools" at exit 0. With --svg that reaches the context-cost cards as "0.0K tokens" and, since cut divides by the total, a literal "NaN% less". A server answering initialize with an error — the foreign-server case this tool advertises — is all it takes.
…and decoded stdout per chunk buf += chunk on a Buffer, so an em dash split across a 16KB pipe boundary became U+FFFD. JSON.parse still succeeds; only the number is wrong. The test that pins these figures measures through InMemoryTransport and never touches this reader, so it would have surfaced as an unexplainable README diff.
stamp-server-json.mjs announced a stamp it had not made No matching package entry → nothing stamped → the template's 0.0.0-template survives (valid semver, so mcp-publisher validate passes) → the success line prints anyway → the registry entry points PulseMCP, Glama and the rest at an npm version that does not exist. The input was guarded against a placeholder; the output was not.
changelog-section.mjs published an empty release body through a symlink It compared a realpath'd import.meta.url against a non-realpath'd argv[1]. Node realpaths the ESM main entry, so from any symlinked checkout the CLI half did nothing at exit 0 — and publish.yml guards on if ! node scripts/changelog-section.mjs "$VERSION", so exit 0 with empty stdout skips the fallback. Verified: nothing printed before, the section prints now.
sync-brand-numbers.mjs wrote unvalidated remote JSON into the README String(value) into src="…" and alt="…" with no escaping, in files an unattended bot commits and pushes to the default branch weekly with contents: write. A quote or an angle bracket closes the attribute and injects markup into every consuming repo. Values are now checked at the point of use and escaped on top; verified with a quote-carrying value (exit 1, nothing written).

Its --check also stopped listing stale fenced markers and then declaring everything up to date. Two documentation claims nothing was watching are covered now: the README said "same 20 tools either way" two lines under a marker rendering 19, and docs/mcp-schema-overhead.md kept an unpinned second copy of the profile-cost table.

scripts/ is also in the typecheck now. These files import from src/ and were checked by nothing, so a changed signature would first surface while running against a funded wallet. Adding them surfaced no errors.

🤖 Generated with Claude Code

https://claude.ai/code/session_01RNqnahSKcBqQemPn5TaMLg

VickyXAI and others added 20 commits September 9, 2026 09:05
…count-rail errors that classify

Three items verified by hand after the 0.49.0 audit, plus the test file its
tail called out.

- blockrun_wallet action:"delegate" wrote spent: 0 unconditionally, so an
  agent that had exhausted its allocation could re-delegate its own agent_id
  and start again — and delegate is a tool the model can call. The ledger now
  carries across a re-delegation and the response says what carried over; a
  limit is the operator's to raise, spend is not theirs to erase. The global
  cap was never bypassable this way, so the sub-cap simply meant nothing.
- The status-code boundary excluded a following dot outright, which kept
  $402.50 from reading as a status but also meant every account-rail message
  ('BlockRun account API error: 502.', the SDK's own shape) fell through
  unclassified: the wallet rail got guidance, the account rail got none. A dot
  NOT followed by a digit is punctuation; a dot followed by one is a decimal
  point and still disqualifies. One shared STATUS_END constant now, so the
  three matchers cannot drift.
- raw-call.ts — the rail switch for eight path-based tools — had no test on
  either rail. Eight now, covering which rail runs, that the other is never
  touched, and that paidUsd stays null (never 0) on a wallet call, since
  recordActualSpend books a real charge as free for 0.
- docs/mcp-schema-overhead.md re-measured at 0.49.0 (19 tools, 12,657), with a
  line saying the README card is the source of truth and this page is dated
  prose.

654 tests, typecheck clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RNqnahSKcBqQemPn5TaMLg
…o the route's own limit, and correct four stale claims

Six more from the audit tail, each read end to end first.

- The model cache was a single shared list with no key, so blockrun_wallet
  action:"chain" left the previous gateway's catalogue in place for the rest of
  the 5-minute TTL — and the two gateways do not serve the same one (78 chat
  models on Base, 83 on Solana, measured 2026-09-09). Keyed by rail+chain now,
  with the in-flight fetch shared between the tool and the resource instead of
  both issuing it.
- blockrun_models was annotated openWorldHint:false while fetching the live
  catalogue over the network. It is readOnlyOpenWorld: the hint describes
  whether the tool reaches outside the process, and this one does.
- VIDEO_POLL_TIMEOUT_MS was 90s against a gateway poll route that declares
  maxDuration = 60, so 30s of the signed authorization's window was spent on a
  request the server had already abandoned. The invariant test now states the
  real unclamped worst case (budget + interval + timeout), which is what its
  own comment always said.
- README FAQ said 'a few media/paid tools settle on Base only (noted above)':
  not media, and not noted. It names them — blockrun_defi, blockrun_modal and
  native Anthropic chat.
- budget_action accepts 'check' and DEFAULTS to it; neither the examples nor
  the field description said so.
- skills/blockrun claimed 'No API keys, no accounts' — the account rail has
  existed since 0.46.0.

654 tests, typecheck clean. README card and docs/mcp-schema-overhead.md
re-measured (12,686 full / 5,189 trading).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RNqnahSKcBqQemPn5TaMLg
…not only what it is for

The setup prompt explained the purpose ('settle YOUR signed orders from the
deposit wallet') and never the size: the default grants an UNLIMITED pUSD
allowance to four collateral spenders plus all-or-nothing ERC-1155 operator
rights to five contracts. That is standard for Polymarket and it is exactly the
kind of thing to state before the signature rather than after.

POLYMARKET_BOUNDED_APPROVALS has existed all along and nothing surfaced it at
the moment of consent; the prompt now names the amount on both branches and
points at the bound, with a test pinning both wordings.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RNqnahSKcBqQemPn5TaMLg
Found by the round-2 audit, which exists because each round must regression-hunt
the last one's fixes. Both are about a funded wallet.

- The continuity pin was written by asking getChain again after provisioning and
  comparing. Under BLOCKRUN_KEYCHAIN=strict that question cannot be answered at
  that point: minting stores the key and DELETES .solana-session, so the file
  check misses and the keychain probe returns the value memoised before the
  mint. It answered "base" both times, no pin was written, and the next start
  found the stored key and moved a funded Base user onto an empty Solana wallet
  — the 0.32.3 failure CHAIN_AUTO_FILE exists to prevent. The pin is now written
  off the provisioning FACT (did THIS call mint the other chain's wallet), which
  is local and cache-free, and the probes are dropped after a mint so a
  same-process reader is honest too.

- ensureSolanaWallet assigned its cache only after awaiting createSolanaWallet,
  so two overlapping callers both minted; 0.49.0 made that reachable from two
  entry points at once (the blockrun://wallet resource and action:"setup"), and
  last-writer-wins means one caller gets a funding QR for an address whose key
  was discarded. Single-flighted — but the rejection is deliberately NOT cached,
  or unlocking a keychain and retrying would stay broken until restart, which is
  the poisoning 0.49.0 removed when it stopped memoising a miss.

Both were untested: chain-precedence runs with the keychain off and
keychain-precedence mocks persistKey to a no-op, so neither could see the strict
delete. Six new tests cover the strict-mode pin, the fresh-install direction,
the no-mint case, concurrent callers, and a rejection that must not stick.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RNqnahSKcBqQemPn5TaMLg
…default chain too

0.49.0 taught the media tools two things and taught them per-rail, so Solana —
the default chain since 0.46.0 — ended up with neither. Round-2 audit.

- blockrun_music (both wallet rails) and blockrun_speech (Base) signed whatever
  the 402 quoted: no sanity check, no re-reservation against the cap. Music's
  Solana call passed no onQuote at all, so the helper's guard hook fired against
  nobody and the SPL transfer was signed for whatever the quote said. Both now
  run assertQuoteNearEstimate and re-reserve the real amount before signing,
  the way video and image already did.

- Giving up while a paid request may still be in flight booked the charge on
  Base (paidPollInFlight) and on the account rail (BilledJobError), and nothing
  on Solana — where the shared helper's own message says a poll in flight at the
  deadline can settle server-side. A settled Solana render therefore moved no
  budget at all and the caller was invited to pay for it again. Both tools now
  book the quote conservatively there and say the charge MAY have gone through,
  instead of the Base-only "No payment was taken".

- formatError told users to fund their wallet on messages that say in the same
  breath that nothing was charged: the uncharged markers gated only the
  "payment" keyword clause, so a bare 402, "balance" or "insufficient" still
  earned the footer. Two of this repo's own messages did it — the video tool's
  unreadable-quote refusal (a wallet holding $1,000 told to top up) and
  RealFace's "No payment taken", which was not even in the marker list. The
  markers now cover every phrasing the four authors use and gate the whole
  branch. A genuinely empty wallet still gets the advice.

Ten new tests: five for Solana rail parity, five for the funding footer,
including negative controls on both sides.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RNqnahSKcBqQemPn5TaMLg
…r booked at zero

Round-2 audit, the last of the P2s. All three are the same shape: 0.49.0 taught
one surface that "we do not know whether it landed" and left its siblings
saying "nothing happened".

- The order card's stale-amount guard wrote place.disabled on every input
  event, including while a submit was in flight, so nudging the amount up and
  back down during the CLOB round-trip re-enabled an ARMED button reading
  "Submitting…" — one more click placed a second identical real-money order
  with no confirmation. The card now tracks submitting and outcome-unknown
  explicitly, disables the amount field during a submit, and re-arms only when
  the failure says nothing was signed. A throw is transport-level, which is
  exactly when the order may already be live, so it now warns instead of
  inviting a retry; a declined consent prompt still restores the card, because
  that one really did sign nothing.

- withSettledCost short-circuited the account rail with no try/catch, so
  onSettledThrow never fired there: a chat call the gateway accepted, billed and
  then dropped mid-stream booked $0 and read as a free failure, whose obvious
  next step is to pay for it again. The account rail now reports a billed
  failure with the amount unknown, the ledger books the estimate, and the note
  points at the dashboard for the exact figure.

- blockrun_realface had no in-flight tracking on its paid POST on any of the
  three rails, so an abort after the gateway settled left a real charge unbooked
  and the reservation released. It gets the same flag and the same wording video
  and music already had.

The order card's two post-failure predicates moved out of the DOM into
apps/order-safety.ts and are unit-tested, replacing a test that grepped two
string literals out of the minified bundle.

673 tests, typecheck and build clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RNqnahSKcBqQemPn5TaMLg
tx-fee.ts has stated the rule since 0.40.1 — reserve high, book accurate, and
do not collapse the two numbers — and exactly one file honoured it. Every
path-based tool passed its RESERVE (base + TRANSACTION_FEE_USD, $0.002, rounded
against us on purpose) as recordActualSpend's fallback, and on the wallet rail
there is never a settled figure to override it, so the ledger booked the
reserve on every call.

Measured with unauthenticated 402 probes, no payment header:

  route                     reserved   Base      Solana
  rpc/ethereum (single)     $0.0040    $0.0030   $0.0020
  pm/*                      $0.0095    $0.0085   $0.0075
  phone/lookup              $0.0120    $0.0110   $0.0100
  search (max_results=10)   $0.2645    $0.2635   $0.2625

On Solana, the default chain since 0.46.0, the gateway charges no transaction
fee at all — so that is $0.002 of invented spend per call. An agent delegated a
$1.00 cap making only rpc calls was cut off after 250 of them having actually
spent $0.50, and action:"report" showed $1.00. The same inflated figure is what
the spend-confirmation dialog showed the human.

ledgerFallback() in raw-call.ts, next to the rail switch it depends on, converts
a reserve into the observed charge for the seven tools that route through it.
The gate is untouched and still reserves the higher figure; the result is
clamped so it can never exceed the reserve. Found by the round-2 audit's
completeness critic, which noted that both prior rounds walked past it.

678 tests, typecheck clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RNqnahSKcBqQemPn5TaMLg
…s first run

Round 2's regressions had one fingerprint: six agents fixed 0.49.0 in parallel
and each hardened the rail it was looking at. The quote guard landed on video
and image but not music and speech; the in-flight booking on Base and the
account rail but not Solana, the default chain; music's Solana call passed no
onQuote at all, so the helper's guard hook fired against nobody. Every one was a
money path and every one passed CI.

So this is the table the completeness critic asked for instead of a round 3:
every paid tool, every rail it serves, every treatment a paid call needs — quote
guard, re-reserve at the real price, in-flight booking, honest give-up wording,
ledger figure. A cell is a claim about the source, and adding a rail-specific
guard without filling in its siblings turns the file red. It also asserts the
DIVISION is deliberate: the seven tools whose 402 the SDK owns must NOT grow a
quote guard, because they cannot see the quote, and it fails if a paid tool is
missing from the table altogether.

It found four gaps on its first run, all now fixed:

- realface had no quote check on any rail and never re-reserved at the quoted
  price — it read the 402 amount and signed it five lines later. payAndPostJson
  takes an onQuote hook, wired on both the Base and Solana rails and to both
  enrollment calls; the account rail has no 402 to check and says so.
- speech booked nothing when it gave up, though its own message already said a
  charge MAY have settled. It books conservatively now, and only when the
  timeout happened AFTER the signature went out — a timeout on the unpaid 402
  probe charges nothing and must not invent spend.
- image had no in-flight tracking, so an abort after the gateway settled left a
  real charge unbooked and released the reservation.

686 tests, typecheck and build clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RNqnahSKcBqQemPn5TaMLg
… stale claims

Round-2 P3s.

- getSolanaUsdcBalance took the balance query off the SDK in 0.49.0 and dropped
  SOLANA_RPC_HEADERS with it, so a private RPC that authenticates by header
  answered 401 and every balance read as "unavailable". Same parse the SDK does,
  same failure mode on malformed JSON.

- resolveSolanaKey collapses "absent" and "the keychain would not open" into
  undefined, so buildSolanaClient told a user with a funded, locked wallet to run
  setup — advice that invites a second wallet. ensureSolanaWallet already refuses
  to mint on that distinction; solanaKeyUnavailableReason() exposes it so the
  sync callers can say the same thing.

- blockrun_video and blockrun_realface told account-rail users their "wallet is
  out of funds" and offered a card top-up for a wallet that is not paying.
  Music and speech got the isApiKeyMode branch in 0.49.0; these two did not.

- The Stanford demo preflight asserted a nine-tool trading profile including
  surf, so it now always fails; skills/rpc still routed agents to a removed
  tool; index.ts's own comment cited the pre-removal counts; and
  blockrun_markets described polymarket/wallets/profiles as a POST batch route,
  which 404s — GET returns the 402 (probed).

Not changed: the completeness critic filed phone.ts's unknown-reserve comment as
citing a route that no longer exists. /v1/phone/numbers/search answers 402 today,
so the comment is accurate and the finding is refuted.

686 tests. Context figures resynced after the description edits (12,698 full).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RNqnahSKcBqQemPn5TaMLg
Round-2 P3s.

- publish.yml curled `releases/latest` of a third-party binary straight into
  tar and then into /usr/local/bin, in the job that holds the npm publish
  token. Its contents could change between two runs of the same commit with
  nothing here recording it. Pinned to v1.8.1 and checksum-verified against the
  same release: pinning is the part that matters, the checksum only proves the
  download is intact.

- solanaPaidPost parsed the settled 200 body unguarded, so a truncated or
  aborted payload threw AFTER the money moved and the caller reported a failure
  with nothing booked — the one direction that must never happen. The 200 is
  the settlement; the charge is handed back whether or not the body parsed.

- The wallet card's primary CTA read "Buy USDC with card" on Solana, where card
  top-up is Base-only: the user clicked, watched "Minting link…", and landed on
  a plain-text refusal. It now says what the active chain can actually do.

- The order card labelled the worst fill "signed max/min", which reads as a
  guarantee about the order about to be placed. Place re-walks a fresh book on
  the server, so the bound is from THIS quote and the label now says so.

- blockrun_image's quote guard was untested: deleting it left every test green.
  Two tests now pin the refusal, the tolerance, and that image calls the guard
  on the one rail that surfaces a 402 amount.

686 tests, typecheck and build clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RNqnahSKcBqQemPn5TaMLg
…is unknown

The last two round-2 P3s.

- sendWalletBatch had `await getRelayClient()` inside its try. That call derives
  CLOB credentials and creates a builder key — real network calls that happen
  before the RelayClient exists, so they cannot have signed or posted anything —
  and their failures armed the double-send guard, wedging the user behind a
  five-minute deadline for a transfer nothing had signed. It is hoisted out.
  The 4xx detector also read only the JSON shape, while the CLOB SDK's ApiError
  puts its code on a `.status` property and leaves the message bare, so every
  definite refusal it raises looked ambiguous; it now reads the property, the
  JSON shape, and a bare "HTTP 403" in the text.

- The catalogue sweep claimed to check every FREE_CHAT_MODELS member against the
  live price, and could only check the ones the catalogue returns: six are
  absent from both gateways today, and two more were skipped by the
  `available === false` guard. A free member is now checked even when marked
  unavailable — "retired today" does not promise "still free when it returns" —
  and one the catalogue does not list is reported as UNVERIFIED rather than
  counted as checked. Delisting is not death here (gpt-oss-120b is the
  gateway's own free fallback and answers while absent from the catalogue), so
  settling one costs a real POST and stays a human's call. The comment in
  constants.ts that overclaimed the coverage now says exactly this.

690 tests, typecheck clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RNqnahSKcBqQemPn5TaMLg
…, not just inside one call

"Signed at the worst fill you saw" held within ONE call: the book walk that
produced the preview also set the signed limit. But the preview and the confirm
are two calls, and the confirm re-walks a fresh book — so a book that moved in
between was signed at a price the card never displayed, which is the direction
it moves exactly when it matters.

blockrun_polymarket takes an optional max_fill_price for market orders: the
worst fill the caller was shown. A walk that comes out worse is refused before
anything is signed, with the two prices named and nothing charged. Buy is a
ceiling, sell a floor. Absent, behaviour is unchanged and the walk stands on its
own — this is a bound the caller opts into, not a new failure mode for callers
who do not pass it. The order card carries its own displayed figure
automatically, so the guarantee now holds for the surface that makes it.

Four tests: a book that moved against the quote, one that moved in the user's
favour (a better price is not a reason to refuse), the sell side's inverted
comparison, and the unchanged no-bound path.

694 tests. Context figures resynced (12,767 full).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RNqnahSKcBqQemPn5TaMLg
0.49.0's thirty-seven fixes were written by six agents in parallel, and this
round went looking for what that costs. It found the shape immediately: each
agent had hardened the rail it was looking at. Thirty findings survived
adversarial verification and NOT ONE was a P0 or a P1 — 0.49.0's own list had
one of each — so the general search is spent. What is not spent is that class,
so the last change here is a table rather than a fix: test/rail-parity.test.ts
states which treatments every paid tool needs on every rail, and turns red when
a rail-specific guard lands without its siblings. It found four more gaps on
its first run.

Minor, not patch: max_fill_price is a new input, the ledger now books the
observed charge rather than the reserve, and the order card refuses submits it
used to allow.

694 tests, typecheck, build and verify:prices all green (0 under-reserving on
either chain; the catalogue sweep now names the free-list members it cannot
verify instead of counting them as checked).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RNqnahSKcBqQemPn5TaMLg
… keychain

The gate deciding whether to consult the OS keychain asked `existsSync`. The
loaders on the far side of it -- the SDK's resolveFromFiles() and
loadSolanaWallet() -- both `.trim()` the file and treat whitespace as NO KEY.

So a zero-byte session file read as PRESENT to the gate and ABSENT to the
loader, and the two disagreed in the one direction that costs money: the
keychain was skipped, a brand new wallet was minted, and the persistKey() call
immediately after overwrote the keychain entry that still held the funded key.
No delete call is involved and nothing is printed. saveWallet() is a plain
non-atomic writeFileSync, so an interrupted write, a full disk, a restore
tool's placeholder or a stray shell redirect all leave exactly this file.

Both rails now ask whether the file HOLDS a key. getChain() already asked it
that way in two places, each with a comment explaining why existsSync is the
wrong question; these were the two callers that had not. An unreadable file
counts as present: we cannot tell whether it holds a key, and guessing toward
the keychain is how a stale entry shadows a live wallet -- the loader then
hits the same unreadable file and fails loudly, which is the outcome we want.

Also fixes keychainDelete's platform asymmetry. It documents "true when the
entry is gone, including was never there" and only macOS honoured that;
LINUX_ITEM_NOT_FOUND was defined in the file and unused, so `secret-tool clear`
on a miss reported the key as still in the keychain when it was not. It had
zero callers, which is why nothing caught it.

Tests: both empty-file cases are red before this change and green after
(verified by reverting each gate in turn); the file-outranks-keychain
precedence test stays green in both directions, so rotation by replacing
~/.blockrun/.session is unaffected. keychain-delete.test.ts covers both
backends without spawning a real helper.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RNqnahSKcBqQemPn5TaMLg
…to delete the live credential path

The file header described the ERC-7739 wrapped L1 signature as the workaround
in force and signed off with "if fixed upstream, delete this module and use
client.createOrDeriveApiKey()".

Both halves are now wrong, and following the second one breaks trading. The
wrap was the wrong diagnosis of clob-client-v2#65: the CLOB rejects the
wrapped envelope with "Invalid L1 Request headers", L2 creds are bound to the
owner EOA even in POLY_1271 mode (matching rs-clob-client-v2 src/auth.rs), and
both call sites pass sigType 0. The long note at the buildClobClient() call
site in client.ts has said so since; the module header never caught up. And
the module has meanwhile become the home of deriveApiCreds(), which every
Polymarket action needs -- so deleting it on a version bump removes credential
derivation entirely.

Header rewritten to say what is true: the wrapped path is a tested reference
implementation of the envelope, correct about the bytes and wrong about what
the server wants, reachable by no caller. deriveApiCreds's own doc no longer
offers sigType 3 as if it were a working alternative.

Also adds test/axios-scope.test.ts. applyClobProxyOnce() sets
axios.defaults.httpsAgent process-wide, which is unavoidable -- clob-client-v2
reaches for the hoisted axios itself, so there is no instance to scope. The
whole safety argument is that only Polymarket shares that axios, and it lived
in a comment. Now a non-Polymarket axios import turns the suite red instead of
silently routing that module's traffic through an operator's
POLYMARKET_CLOB_PROXY. Verified by adding an import to utils/http.ts and
watching it fail. The second case pins that the Finland default is a HOST, not
a proxy, so axios.defaults stays untouched unless an operator opts in.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RNqnahSKcBqQemPn5TaMLg
…omised to hide

Three of the four Polymarket e2e scripts state in their own doc comment that
wallet addresses and transaction ids are never printed. Each implemented that
with a different regex:

  e2e-readonly    0x[hex]{40}   -> <wallet>     addresses only
  e2e-approve     0x[hex]{40,}  -> <redacted>   both, unlabelled
  e2e-live        0x[hex]{64}   -> <tx>         hashes only
  e2e-withdraw    0x[hex]{64}   -> <tx>         hashes only

The last two are the ones that move real money, and {64} does not match a
40-hex address. withdraw.ts interpolates the bridge response into its error
text and that response carries `address.evm`, so the address printed in full.

Only the isError branch was guarded at all. A thrown exception -- a network
failure inside fetchPositions(), a viem revert -- bypassed redaction entirely
and Node printed the raw message and stack to stderr.

One redaction now, in scripts/redact.ts, longest-match-first in a single pass
so a hash cannot be half-eaten by the address rule, applied on every exit path
including uncaught throws. A 32-byte private key comes out as <tx>:
mislabelled, but not printed, which is the direction that matters.

test/scripts-redaction.test.ts covers the helper and fails if any e2e script
grows its own 0x[a-fA-F0-9]{...} regex again.

scripts/ is now in tsconfig include. These files import from src/ and were
typechecked by nothing, so a changed signature in withdrawFunds or
redeemPosition would first show up while running against a funded wallet.
Adding them surfaced no errors.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RNqnahSKcBqQemPn5TaMLg
`npx tsx scripts/smoke-speech.ts` registered the real blockrun_speech handler
and immediately spent from the machine-global wallet at ~/.blockrun/.session.
No flag, no prompt, and `limit: null` so nothing capped it. Its header said
"real $0.001 speak"; the run ends with a $0.0525 sound effect, fifty times
that, and about $0.054 total.

This repo has already lost $0.42 to a paid handler that was run because it
looked like a read. Nothing sitting in scripts/ should spend money by being
run.

It now refuses without --confirm (or BLOCKRUN_SMOKE_CONFIRM=1), names the real
total in the refusal, and sets a $0.15 budget cap as a second backstop for a
moved price or a doubled retry. Verified: a bare run exits 1 having charged
nothing.

test/scripts-spend-gate.test.ts pins this for the next script like it —
anything in scripts/ that calls register…Tool() must carry a confirm gate
ahead of its first paid call and a numeric budget limit. The assertions are
static on purpose: a test that proved the gate by running the script would
charge the wallet the day the gate regressed, which is the failure it exists
to catch. Verified by setting limit back to null and watching it fail.

The polymarket e2e scripts are deliberately out of scope and the test says so:
they reach paid paths through utils/ rather than a handler, run only as
explicitly named npm targets, and carry their own bounds.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RNqnahSKcBqQemPn5TaMLg
…out failing

Four scripts that had never been audited, each able to produce confident
output from a failure.

measure-tool-schema.mjs
- rpc() resolved on any reply with a matching id and never looked at
  msg.error. Callers destructure `{ result }`, so an error reply handed them
  undefined -> `result?.tools ?? []` -> a measurement of ZERO tokens across
  ZERO tools, printed as a real figure at exit 0. With --svg that reaches
  assets/context-cost*.svg as "0.0K tokens" and, since `cut` divides by the
  total, a literal "NaN% less". A server answering initialize with an error --
  the foreign-server case this tool advertises, when the package wants auth --
  is all it takes. Now rejects, naming the method and the error.
- stdout was decoded per chunk (`buf += chunk` on a Buffer), so a multi-byte
  character split across a boundary became U+FFFD. JSON.parse still succeeds,
  so only the number comes out wrong. The payload is ~50KB against a 16KB pipe
  buffer and these descriptions are full of em dashes. The in-process test
  that pins these figures uses InMemoryTransport and never exercises this
  reader, so it would have surfaced as an unexplainable README diff.
  setEncoding("utf8").
- a profile that lists no tools is now a failure, not a measurement of zero.

stamp-server-json.mjs stamped nothing when no package entry matched pkg.name,
left the template's "0.0.0-template" (valid semver, so mcp-publisher validate
passes it), and printed "Stamped server.json → …" regardless. publish.yml
would then point PulseMCP, Glama and the rest at an npm version that does not
exist. The input was guarded against a placeholder; the output was not.
Verified by renaming the identifier and watching it exit 1.

changelog-section.mjs and measure-tool-schema.mjs both compared a realpath'd
import.meta.url against a non-realpath'd process.argv[1]. Node realpaths the
ESM main entry, so from any checkout reached through a symlink (macOS
/tmp -> /private/tmp, npm link) the CLI half silently did nothing at exit 0.
publish.yml guards on `if ! node scripts/changelog-section.mjs "$VERSION"`, so
exit 0 with empty stdout skips the generic fallback and publishes a release
with an EMPTY body -- the one thing that file's header promises cannot happen.
Verified: through a symlinked path it printed nothing and exited 0 before,
prints the section now.

sync-brand-numbers.mjs rendered remote values with String(value) and
interpolated them into src="…" and alt="…" with no escaping. Those files are
README.md, CONTRIBUTING.md and skills/*/SKILL.md, and brand-sync.yml commits
and pushes them to the default branch weekly, unattended, with contents:write.
A value carrying a quote or an angle bracket closed the attribute and injected
markup into every consuming repo; write access to the awesome-blockrun mirror
was enough to reach them. Rendered values are now checked at the point of use
(a finite number or a short plain label) and escaped on top. Checked at use
rather than over the whole artifact because the payload legitimately carries
prose fields we never render. Verified with a quote-carrying value: exit 1,
nothing written. Its --check also no longer lists stale fenced markers and
then declares everything up to date, and "keys in use" no longer counts
mcp.tools and mcp.tools@badge as two.

Documentation claims nothing was watching: README said "same 20 tools either
way" two lines under a marker rendering 19 (now the marker, so the sync keeps
it right), and docs/mcp-schema-overhead.md kept a second copy of the
profile-cost table that no test pinned. Both covered now, plus the profile
list itself, hardcoded in the script and the test, which would have left a
newly added profile measured and pinned by neither.

Found by a read-only audit subagent; every finding re-verified here before
fixing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RNqnahSKcBqQemPn5TaMLg
…and call the SDK directly

Two defects, one of which produces a broken tool.

The dead references. `src/tools/surf.ts` went away with the tool in 0.49.0,
and CONTRIBUTING still named it four times: as the template to copy in step 1
of "Adding a new MCP tool", as the reference example of the path-based
pattern, and as the example for the sync payment call. Step 1 was literally
uncopyable. `skills/surf/SKILL.md` was offered as the structural template for
a new skill; it is now a retirement map for a removed tool, which is the one
thing a new skill should not be modelled on.

The one that costs money. The x402 section documented
`client.getWithPaymentRaw(endpoint, params)` and
`client.requestWithPaymentRaw(endpoint, body)` as how a tool makes a paid
call. No tool in src/ has called those directly for some time, and for a
reason raw-call.ts states in its own header: there are THREE payment rails and
the SDK knows two. On the account rail requestWithPaymentRaw degrades to a
plain Bearer fetch and throws away the x-blockrun-cost-usd response header, so
a tool built that way silently does not support API-key users and cannot say
what the call cost. utils/raw-call.ts is the single entry point that exists so
no tool chooses a rail for itself -- and per-tool rail divergence is the exact
fingerprint of every money bug rounds 1 through 3 found.

Now documented as rawGet/rawPost with the SDK-direct path called out as wrong
and the ledgerFallback() note, since the gate and the ledger are deliberately
different numbers and Solana has no gateway tx fee.

Also repoints the examples at src/tools/markets.ts and
skills/prediction-markets/SKILL.md, both of which exist and both of which
demonstrate what the surrounding sentence claims, and drops the retired surf
from raw-call.ts's own list of its callers.

test/doc-file-refs.test.ts is the guard: every `src|test|skills|scripts|apps|
assets`-rooted path named in README, CONTRIBUTING or docs/ must exist; the
raw-call guidance must stay in CONTRIBUTING; and every path-based tool must
actually import raw-call and must not call the SDK raw methods directly.
Verified against the pre-fix CONTRIBUTING: tests 1 and 2 both fail.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RNqnahSKcBqQemPn5TaMLg
…found a P0

The 0.50.0 entry opened as "a second audit round" and stated that the general
search was spent. Two more rounds have since landed in the same unreleased
version, and round 3 found a path that destroys a funded wallet key -- so the
release now contradicts its own headline.

Reframed honestly: the general SWEEP was spent, and another pass over the same
files would have returned docs and cosmetics. What was not spent was the
surfaces no sweep had opened, and the class where a comment states a contract
the code does not honour on some branch or platform. Comments cannot fail a
test, which is why nothing had ever checked them and why four of round 3's
five findings were that shape.

Version stays 0.50.0: it is unreleased (npm latest is 0.49.0), so these are
additional contents of the same release, not a new one.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RNqnahSKcBqQemPn5TaMLg
@VickyXAI VickyXAI changed the title 0.50.0 — a second audit round, aimed at the first one 0.50.0 — three audit rounds, each aimed at the one before it Sep 9, 2026
@VickyXAI

Copy link
Copy Markdown
Contributor Author

Superseded by #150 (0.51.0), which carries every commit of this branch.

@VickyXAI VickyXAI closed this Sep 15, 2026
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