This project is a set of Solana plugins for ZeroClaw, and every custody claim it makes rests on the host enforcing what it says it enforces. So the host got audited rather than assumed.
Ten defects were found, verified, and reported upstream. All ten are public issues, linked below. This document is a summary and a pattern analysis; the issues carry the code, the exploit paths and the suggested fixes.
Ten is this audit's count, not the project's total, and the two are easy to read as a contradiction. This was ONE structured pass over one commit, and its ten confirmed findings are the number quoted here and in the README. The project's total upstream contribution is larger and counts different things: issues filed while BUILDING against the host, which is how the WhatsApp policy bug, the inert approval timeout, the vendored-WIT drift and the Cloud approval-token leak were all found, plus the pull requests that followed. Read live from the API rather than restated here, since both figures move as maintainers triage:
gh search issues --repo zeroclaw-labs/zeroclaw --author belumume --limit 100 --json labels,state
gh search prs --repo zeroclaw-labs/zeroclaw --author belumume --limit 50 --json state,title
At the last read, 2026-08-10, that returned eighteen issues, sixteen rated priority:p1, seven
carrying status:accepted and twelve status:in-progress, and five pull requests of which one is
merged. If a figure elsewhere disagrees with what those commands print, the commands are right.
Audited at zeroclaw-labs/zeroclaw commit 63f432da (v0.8.3-182), a Rust workspace of 1064 source
files across 31 crates. Both figures were re-derived against that exact tree on 2026-08-19 and both
hold. Re-derive rather than trusting this sentence, and read .truncated in the same breath as the
count, because the API caps a large tree and a capped walk returns a smaller number that looks like
a measurement:
gh api "repos/zeroclaw-labs/zeroclaw/git/trees/63f432da?recursive=1" \
--jq '{crates: [.tree[]|select(.path|endswith("Cargo.toml"))]|length,
sources: [.tree[]|select(.path|endswith(".rs"))]|length,
truncated: .truncated}'Two reasons.
The plugins here are deny-by-default and push the real spending bound onto an audited on-chain program, precisely so that a host-side or model-side compromise cannot move funds. That argument is only honest if someone has actually looked at the host-side controls rather than trusting them. Six of the ten findings are host-side authorization or audit gaps, which is the layer the custody design deliberately does not rely on.
And one finding is a live risk to any operator running this stack, including this one. See the credential-exposure entry below.
| Issue | Finding | Severity |
|---|---|---|
| #9387 | Interactive approval responses are accepted from any chat member on Telegram, Slack, Lark and Matrix | high |
| #9386 | A Gemini API key in the request URL survives error sanitization and is posted into the originating chat | high |
| #9389 | Unauthenticated POST /api/pair keys its lockout on an attacker-supplied header |
high |
| #9390 | Emergency stop is a CLI-only state file that no runtime path reads | high |
| #9391 | Command audit logging defaults to enabled and writes nothing | high |
| #9392 | LINE group messages skip the allowlist and the pairing handshake | high |
| #9393 | Bluesky and Reddit have no sender authorization and no central gate covers them | medium |
| #9394 | [gateway.pairing_dashboard] is accepted and entirely unread; pairing codes never expire |
medium |
| #9395 | Plugin wasi:http egress has no destination policy and no configuration knob |
medium |
| #9396 | The CLI approval prompt renders tool arguments without stripping control characters | medium |
Two further issues predate this audit and come from the same work:
#9348 (WhatsApp Web ignores its chat
policies under mode = "business", and an empty allowed_groups admits every group) and
#9366 (approval_timeout_secs accepted and
never read). Both have PRs:
#9382 and
#9385.
Five of these are the same defect wearing different clothes. An operator writes a safety setting, the config schema validates it, the value is stored and serialized, and no runtime path ever reads it:
- emergency stop (#9390) writes a state file nothing consults
[security.audit](#9391) defaults to enabled and constructs no logger[gateway.pairing_dashboard](#9394) is parsed and never consulted- plugin
wasi:httpegress (#9395) has no knob at all, while the built-in HTTP tool has one approval_timeout_secs(#9366) is read on other transports and not on WhatsApp Web
An accepted-and-inert control is worse than an absent one. An absent control is visible: the operator knows they have no emergency stop. An inert one is invisible, and the operator makes decisions believing they are protected. This is a design-review finding rather than five bugs, and it predicts where the sixth will be: any setting whose only test asserts that it parses.
The custody model in this project is built on the opposite principle. The spending bound is a
Solana Foundation Allowances delegation, so the rejection of an over-cap transfer is produced by an
audited on-chain program and is visible as a failed transaction with custom program error 0x12c
(300, AmountExceedsLimit, sourced in MAINNET-PROOF.md).
It cannot be inert, because the chain either rejected the transfer or it did not.
The ratio matters more than the count.
| stage | candidates |
|---|---|
| investigated across six dimensions | 128 |
| dropped at the hunt stage as already-defended, out of scope, or duplicate | 107 (84%) |
| raised to adversarial verification | 21 |
| confirmed | 10 (7.8% of all candidates) |
| partial: real defect, original claim corrected | 11 |
| refuted outright | 0 |
The counts are the hunt agents' own returned dropped_count and finding totals summed across the
six dimensions, so the stages close: 107 dropped plus 21 raised is the 128 investigated, and the 21
raised split into 10 confirmed plus 11 partial.
The six dimensions were fail-open policy, untrusted-input-to-model, approval bypass, capability and sandbox escape, secret handling, and blast radius of the dangerous built-in tools. Each hunter was required to look for an existing guard and to DROP the candidate on finding one, which is where 107 candidates died.
Each survivor then went to a verifier told to refute it, and to treat a paraphrased quote as a refutation on its own. Nothing was refuted outright, which is expected downstream of an 84% pre-screen, but eleven came back partial, and one finding could be corrected in more than one of these ways: six had their severity lowered, eight had at least one named exploit step falsified, and two were reclassified as documented-by-design. The three counts therefore overlap rather than partitioning the eleven. Those eleven are not reported here, because a corrected finding still needs its correction verified before it is worth a maintainer's time.
Concretely, verification killed claims like these:
- a chain in which one message permanently unsupervises
shell, falsified because the channel path builds its approval manager withnon_interactive_shell_requires_approval: false, soshellshort-circuits to NotRequired and never prompts at all - a plugin-egress bug, shown to be documented behaviour by the project's own book, which states that
a tool holding the
http_clientgrant may reach whatever its code decides - a privilege bypass via a forged
<tool_result>envelope, shown to be context poisoning plus a host misparse rather than an approval bypass, because approval is a pre-execution hook over a live operator answer and is never read back from history text
#9386 is the one that affects anyone running this stack with a Google-keyed provider. The chain, with each link either read from source or tested:
- the API key is sent as a
?key=query parameter - reqwest's error
Displaycarries the full URL including that query string, which was tested against a closed port rather than assumed sanitize_api_errordoes not strip it: the scrubber matches seven token prefixes andAIzais not among them. Reproduced, alongside a positive control proving the same call does redact ansk-prefixed key, so the failure is the sanitizer and not the test- the sanitized-but-not-really text is formatted into a user-facing message and sent to the originating chat, which may be a group
- the outbound leak detector has an
AIzapattern but is not on that code path
No attacker is required. A DNS blip does it.
The obvious rebuttal is that the leak detector would have caught it if it were wired onto that
path. It would not. The detector's only Google pattern
is AIza[a-zA-Z0-9_-]{35}, while keys issued by AI Studio today carry an AQ. prefix. That
character class excludes ., so the pattern fails at the third character and cannot match a
current key at all. Enabling the mitigation everyone assumes exists would have changed nothing
here, which is why the fix below targets the property rather than the pattern list.
The recommended fix upstream is the property rather than the list: strip the query string from any
URL appearing in error text, instead of adding one more vendor prefix, because the next provider to
put a credential in a URL will not be caught by an AIza entry.
The dimension prompts, the rules of evidence, and the dedup list are in
scripts/audit/host-security-audit.workflow.js, committed here so a clone gets them. It is not a
black box: the hunters are told what counts as a finding, what to drop, and that three real defects
beat thirty theoretical ones. The script is a Claude Code dynamic workflow, so running it as-is needs
that harness; reading it needs nothing.
Each issue is independently checkable without running any of it. Every one cites exact file:line
locations and quotes the deciding code, so a reader can open the same lines at the same commit and
judge for themselves.