Conversation
…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
6 tasks
Contributor
Author
|
Superseded by #150 (0.51.0), which carries every commit of this branch. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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
onQuoteat 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:
The table
test/rail-parity.test.tsstates, 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
getChain()keys on — so the continuity pin was never written and the next start moved a funded Base user onto an empty Solana walletblockrun_rpcwas cut off after 250 calls having spent $0.50delegatewrotespent: 0, anddelegateis a tool the model can callApiError's statusSaying 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.50from reading as a status and also what made everyBlockRun 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 onmain)npm run typecheck,npm run build: cleannpm 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 checkedRefuted, 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/searchanswers 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.
~/.blockrun/.sessionoverwrote a funded key in the keychainexistsSync; 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.saveWalletis a non-atomic write, so an interrupted one is enough. Both rails now ask whether the file holds a key, whichgetChain()already asked twice with comments saying why.keychainDeleteanswered differently on Linuxl1-auth-1271.tstold the next maintainer to delete the live credential pathderiveApiCreds— so following that instruction stops all trading.isErrorbranch was redacted at all — a thrown exception printed the raw message and stack.smoke-speech.tscharged the wallet for being runlimit: 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.ts—applyClobProxyOncemutatesaxios.defaultsprocess-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'sPOLYMARKET_CLOB_PROXY.test/scripts-redaction.test.ts— one redaction, and no script may grow its own.test/scripts-spend-gate.test.ts— anything inscripts/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.
measure-tool-schema.mjsmeasured a JSON-RPC error as zero tokensrpc()never looked atmsg.error, callers destructure{ result }, so an error reply becameundefined→result?.tools ?? []→ "0 tokens across 0 tools" at exit 0. With--svgthat reaches the context-cost cards as "0.0K tokens" and, sincecutdivides by the total, a literal "NaN% less". A server answeringinitializewith an error — the foreign-server case this tool advertises — is all it takes.buf += chunkon a Buffer, so an em dash split across a 16KB pipe boundary became U+FFFD.JSON.parsestill succeeds; only the number is wrong. The test that pins these figures measures throughInMemoryTransportand never touches this reader, so it would have surfaced as an unexplainable README diff.stamp-server-json.mjsannounced a stamp it had not made0.0.0-templatesurvives (valid semver, somcp-publisher validatepasses) → 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.mjspublished an empty release body through a symlinkimport.meta.urlagainst a non-realpath'dargv[1]. Node realpaths the ESM main entry, so from any symlinked checkout the CLI half did nothing at exit 0 — andpublish.ymlguards onif ! 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.mjswrote unvalidated remote JSON into the READMEString(value)intosrc="…"andalt="…"with no escaping, in files an unattended bot commits and pushes to the default branch weekly withcontents: 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
--checkalso 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, anddocs/mcp-schema-overhead.mdkept an unpinned second copy of the profile-cost table.scripts/is also in the typecheck now. These files import fromsrc/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