Resolver holds a private key and signs with it. That is the product, and it makes the threat model wider than a read-only tool's, so it is worth being precise about what actually bounds the damage.
Four properties do the work, and all four are enforced in code rather than by convention:
-
The key is reachable from exactly one file.
src/wallet/keypair.tsreads it once at construction and never again. It is never logged, never serialised, never returned by any accessor, andtoJSONis overridden to emit[redacted]. The class exposes one signing method and it takes bytes — nothing in it knows what a transaction is. -
The model cannot express a transaction. Every state change is an
Intent, a closed union insrc/kernel/domain.ts. There is no method anywhere that accepts a pre-built transaction from a model, so "drain the wallet" has no representation in the type a completion can produce. -
Every intent passes a pure, deny-by-default gate.
src/wallet/policy.tstakes no network, no ambient clock and no model. An intent that matches no rule is denied. Budgets are denominated in lamports over a window, not in call counts, and the renewal reserve is a floor nothing may spend into. -
Every action is journalled before it is submitted. A crash between signing and confirming leaves a
submittedrow, and the next pass reconciles it against the chain rather than repeating it.
A pull request that adds a path from a model output to a signer without passing
through Intent and Policy will be closed on that basis alone, independent of
quality. The same applies to anything that makes a burn autonomous.
Beyond custody, the realistic threat model is about credential exposure, resolution spoofing and misleading output.
| Class | Example | Severity |
|---|---|---|
| Credential leak | An API key reaching a log file, an error message, a cache entry, or a crash report | High |
| Prompt injection | Desk or DAS response text steering the operator loop into calling a tool with chosen arguments | High |
| Tool-surface escape | A tool handler reaching the filesystem, the shell, or an arbitrary host | High |
| Key exposure | The signing key reaching a log line, an error message, a crash report or a cache entry | Critical |
| Policy bypass | A code path that reaches a signer without forming an Intent and passing the gate |
Critical |
| Resolution spoofing | An unverified sol-record steering a payment to an address the name's owner does not control |
High |
| Data poisoning | A protocol API returning a fabricated APY that the ranker presents without discounting | Medium |
| Denial of service | A desk response that hangs the discovery fan-out or exhausts memory | Medium |
| Output integrity | A rendering or rounding bug that materially misstates a rate or a risk score | Medium |
Open a private advisory on github.com/kyle-vbc/resolver, or contact @kyle-vbc. Include the affected version, a description, and a reproduction. Do not open a public issue for anything in the High rows above.
Expect an acknowledgement within 72 hours and a fix or a documented mitigation within 14 days. You will be credited in the release notes unless you ask not to be.
A tool that reads the chain and a tool that moves funds have different failure modes. A bug in this one produces a wrong number. A bug in the other produces a wrong transfer. Keeping the two apart means the worst outcome of a compromised dependency, a hostile upstream API, or a prompt injection through a protocol's own response body is bad analysis — never a signature.
Concretely:
Keypair,Transaction,VersionedTransaction,signTransactionandsendTransactionappear nowhere insrc/. Grep for them; the result is empty, and keeping it empty is the review bar.- The chain layer (
src/venues/) issues JSON-RPC reads only:getSlot,getBalance,getTokenAccounts,getStakeAccounts, account data reads, and DAS asset lookups. That list is the wholeChainReaderinterface insrc/kernel/domain.ts, and it is the only door to the chain. - The operator's tool registry in
src/ops/tools.tsis a fixed list of read and analysis tools. There is no shell tool, no filesystem write tool, and no generic fetch tool.
Every command's output is a description of what you could do. Executing it is your job, in your wallet, on a venue you chose.
Keys are read from the environment only: HELIUS_API_KEY (or
RESOLVER_HELIUS_KEY) and ANTHROPIC_API_KEY. Nothing that can be committed holds
a secret — config.json and .resolver.json are for wallet, mandate, constraints
and endpoints.
config set refuses credentials. src/cli/commands/config.ts runs a guard
before any write. It rejects on two independent grounds:
- The key name matches
api-key,secret,token,password,private,seedormnemonic, or the value has a recognisable provider shape (sk-…,hf_…,ghp_…,xox[baprs]-…, or a bare UUID). - The value is a URL that embeds a credential. This matters because
rpcUrlis a legitimate setting and every RPC provider ships its key inside the URL, so a name-only check would let the key through the front door.urlCredential()rejects userinfo (https://user:pass@host), a credential-bearing query parameter (api-key,key,token,access_token,auth), and an opaque path segment of 24 or more URL-safe characters — the shape QuickNode and Alchemy use.
A bare endpoint and a base58 wallet address remain settable. The intended way to use your own node with a keyed URL is the environment:
export RESOLVER_RPC_URL='https://your-host/?api-key=…'Writes are validated before they land: the candidate config is written to a
temporary home, re-parsed through loadConfig, and only then renamed into
place, so an invalid file cannot be left behind.
src/common/journal.ts is the only logger. Every log record's fields pass
through redact() before serialisation:
- Any key matching
^(x[-_])?(api[-_]?key|apikey|authorization|bearer|token|access[-_]?token|secret|private[-_]?key|seed|mnemonic|passphrase|password)$is replaced with[redacted]. The optionalx-prefix is deliberate:x-api-keyis the header the Anthropic client actually sends, and an anchored^api-key$would miss the single name most likely to be logged by mistake. - Every string value is scrubbed for
?api-key=…,&key=…,&token=…,&access_token=…and&auth=…, and forscheme://user:pass@authority credentials. - The walk is recursive over nested objects and arrays, with cycle detection and
a depth cap. A flat pass looked correct while leaving
{ req: { url } }and{ urls: [...] }untouched, which is exactly the shape a debug log takes.
Redaction covers logs, not your shell. RESOLVER_RPC_URL printed by env is your
own business.
- Closed tool set. The operator's tools are a fixed registry. Inputs are
schema-validated before a handler runs, and handler failures are wrapped as
ResolverErrorrather than surfaced raw to the model. - Untrusted upstream data. Desk and DAS responses are parsed with zod
schemas via
fetchJson(), not trusted structurally. A shape mismatch is adesk/unavailablefault, not a silently coerced value. Token names and symbols from DAS are treated as display strings: trimmed on ingest, truncated at render, and never interpolated into the operator's system prompt, which is a static directive plus a fixed session header. - Errors are redacted twice.
src/venues/transport.tsscrubs URLs, response bodies and cause text before anResolverErroris constructed — including a literal replacement of the configured Helius key — so a stack trace printed by a wrapper script cannot carry a credential out of the process. - Bounded fan-out. Discovery runs
settleAllatmaxConcurrentDeskswith a 12-second per-desk timeout, under a shared rate limiter. A hanging or hostile endpoint costs that one desk's opportunities and nothing else; the desk reports itself down inresolver desksand the run continues. - No dynamic code. No
eval, nonew Function, no dynamicimport()of a path derived from input, no plugin loader. - Single failure path. Errors exit through one function with one shape and a stable exit code per class, so a supervising script can distinguish "your key is wrong" (3, do not retry) from "the upstream is down" (4, retry later).
- Resolver does not verify that a protocol's reported APY is true.
confidencerecords what was measured versus what was reported, and the ranker discounts accordingly, but a protocol that lies to its own public API produces a wrong ranking. - Resolver does not audit the protocols its desks describe.
ExposureProfile.auditedByreports what the protocol claims. An audit is not a guarantee. - The simulator is a model. Its distribution is a shape, not a forecast.
- Nothing here is financial advice. Every position a desk describes can lose money.
The current minor line receives fixes. Older lines do not.