coldstar-signer-mcp
Provides policy-controlled transaction signing and sending on Solana, enabling an AI agent to transact without holding the root key. Supports automatic signing for in-policy transactions, air-gapped human approval for escalations, rejection of disallowed transactions, daily spending caps, and SOL transfers.
Click on "Deploy Server".
Wait a few minutes for the server to deploy. Once ready, it will show a "Started" state.
In the chat, type
@followed by the MCP server name and your instructions, e.g., "@coldstar-signer-mcpSend 0.1 SOL to 7xKXtg2CW87d97TXJSDpbD5jBkheTqA83TZRuJosgAsU"
That's it! The server will respond to your query, and you can continue using it as needed.
Here is a step-by-step guide with screenshots.
coldstar-agent-signer
Agent-safe signing for Coldstar. Lets an AI agent transact on Solana without ever holding the root key: routine, in-policy transactions auto-sign on a cold-rooted session key; out-of-policy transactions escalate to air-gapped human approval; disallowed/injected transactions are rejected.
Three layers, all here and tested: a pure policy engine, a fail-closed transaction decoder, and two ways to hold the wallet — ColdstarWallet (a drop-in for Solana Agent Kit's BaseWallet) and coldstar-signer-mcp (an MCP server for Claude, Cursor, or any MCP client). Beta, devnet.

That runs here: npm run demo. Real policy engine, real decoder, real Ed25519 session key, no network. The compromised agent asks correctly and is refused anyway, and the signature it wanted never exists.
Why this shape
The decision logic (src/policy/evaluate.ts) is a pure function over a normalized TxIntent — no I/O, no @solana/* imports. That's deliberate: the part that must be correct is exhaustively unit-testable without a validator, a network, or a wallet. Parsing a raw transaction into { outSol, recipients, instructions } is the adapter's job, where a bug is a reliability issue, not a security hole.
raw tx ──(project + parseTx, fail-closed)──▶ TxIntent ──(evaluate)──▶ AUTO_SIGN | ESCALATE | REJECT
│
AUTO_SIGN ─▶ cold-rooted session signer
ESCALATE ─▶ air-gapped device (QR)
REJECT ─▶ no signatureRelated MCP server: BotWallet MCP Server
What's here
File | Status |
| ✅ types: |
| ✅ the pure policy engine |
| ✅ 7 unit tests incl. the compromised-agent case |
| ✅ example policy (fill the pubkeys) |
| ✅ decoder: decompiled message → |
| ✅ 17 unit tests incl. the fail-closed cases |
| ✅ associated-token-account derivation, verified against |
| ✅ 25 tests built with the real SPL library: the USDC hole, Approve, per-mint caps |
| ✅ 11 tests built with |
| ✅ 19 tests: priority fees counted as spending, token-account rent, stake, memo |
| ✅ web3.js |
| ✅ |
| ✅ 17 tests: the three decisions, daily cap, batch atomicity, fail-closed edges |
| ✅ |
| ✅ MCP server: |
| ✅ |
| ✅ 10 tests over an in-memory MCP client |
| ✅ posture (b), opt-in: |
| ✅ 9 tests with an injected simulator |
| ✅ |
| ✅ 7 tests incl. cap-holds-across-wallet-restart |
| ✅ |
| ✅ 7 tests incl. the delete-the-file attack |
| ✅ the root-signed policy envelope: |
| ✅ |
| ✅ the same signer in Python for an offline machine with no Node; signing needs no dependencies, opening an encrypted root needs |
| ✅ the wire format, so any language can produce a valid envelope |
| ✅ 7 tests: the Python output verifies in TypeScript, and both sign identical bytes |
| ✅ on-chain revocation: signed memo marker, |
| ✅ the cold-side tool refuses to read the root key on a networked machine |
| ✅ the root is Argon2id + AES-256-GCM at rest, and opens in the Python tool |
| ✅ 11 tests against containers written by Coldstar's own encryptor |
| ✅ 7 tests: byte-identical to Coldstar's |
| ✅ delegates to Coldstar's |
| ✅ what this protects, what it does not, and how to build an air gap that earns the name |
| ✅ |
| ✅ 13 tests incl. forged-marker and unreachable-chain cases |
| ✅ 11 tests: tamper, wrong root, wrong session, expiry, canonical ordering, wallet refuses bad envelopes |
The root signs the policy, not the transaction
The cold root never signs transactions for the agent. It signs a policy envelope once, on the air-gapped machine: the policy, the one session public key it applies to, an issue time, and an expiry, canonically encoded and Ed25519-signed. The online signer verifies that signature at startup and refuses to run if the policy was edited, the session key is not the one named, the envelope has expired, or (when pinned) the root is not the expected one.
# once, on the AIR-GAPPED machine: encrypt the root, then destroy the plaintext
coldstar-encrypt-key --in root.json --out /media/cold/root.coldstar.json && rm -P root.json
# every time you issue a grant (root keyfile never leaves this machine)
coldstar-sign-policy --root /media/cold/root.coldstar.json --policy coldstar.policy.json \
--session <session pubkey> --expires 7d > envelope.json
# carry envelope.json across the gap (QR / file), then on the online host:
COLDSTAR_POLICY=envelope.json COLDSTAR_ROOT_PUBKEY=<root pubkey> COLDSTAR_REQUIRE_ENVELOPE=1 coldstar-signer-mcpThe root key at rest
Until 0.6.0 this package read its root from a plaintext solana-keygen file, which is the exact thing Coldstar exists to argue against. It no longer does. coldstar-encrypt-key writes the same container the Coldstar signer uses, so a key encrypted by either tool opens in the other:
{ "version": 1, "salt": "…", "nonce": "…", "ciphertext": "…", "public_key": "9QZ…" }Argon2id (v1.3, m = 64 MiB, t = 3, p = 4) turns the passphrase and a fresh 32-byte salt into a 32-byte key; AES-256-GCM under a fresh 12-byte nonce encrypts the 32-byte Ed25519 seed, with the tag appended. public_key is there so tooling can tell you which root a file holds, and check it is the one you meant, before spending 64 MB on a derivation.
The passphrase is never an argument, because arguments are visible in ps and land in shell history. coldstar-sign-policy prompts with echo off at a terminal, reads one line when stdin is a pipe, and honours COLDSTAR_PASSPHRASE while warning that a variable is inherited by every child process. A wrong passphrase fails the GCM tag and is reported as such; nothing is written to stdout that could be mistaken for a good envelope.
A plaintext root still works, so nobody's setup breaks on upgrade, but it prints a warning every time. Lose the passphrase and the key is gone. That is the design, not a gap in it.
Reading a wallet Coldstar already made
This package is meant to be Coldstar with an agent-usable interface, not a second
product with its own file formats. COLDSTAR-PARITY.md records exactly where the
two agree and where they do not.
coldstar-sign-policy --root accepts any key file Coldstar can produce: the
current container, the older libsodium one from Coldstar's PyNaCl era, and
containers whose fields are byte arrays rather than encoded strings. Point
coldstar-encrypt-key at an old one to convert it, which is the same migration
Coldstar's own wallet performs when it opens a legacy wallet.
Passphrase rules are Coldstar's, not ours: twelve characters, upper, lower and a digit. They apply when a passphrase is set, never when an existing file is opened, so a key you already have cannot be locked away by a rule change.
Letting Coldstar's signer hold the key
Node cannot lock memory. Coldstar's Rust signer can, and does: it keeps the
decrypted key in an mlocked buffer that is zeroized on drop, including on
panic. When the solana-signer binary is present, coldstar-sign-policy hands
it the signing and the plaintext key never enters this process.
export COLDSTAR_SIGNER_BIN=/path/to/coldstar/secure_signer/target/release/solana-signer
coldstar-sign-policy --root root.coldstar.json … # says which signer it usedIt is also found on PATH. The container and passphrase go to it over stdin as
one JSON line, never as arguments, since arguments appear in ps and shell
history. --no-rust-signer forces the in-process path, which is the default when
the binary is absent because it is why the air-gapped machine needs neither Node
nor a package installer.
Crossing the gap as a QR code
Everything Coldstar moves across the gap is wrapped the same way, and its phone
app decodes exactly that wrapper. --wire puts the grant in it:
coldstar-sign-policy … --wire > grant.json
# {"type":"policy_envelope","version":"1.0","data":"<base64 envelope>"}The output is checked byte for byte against Coldstar's own build_envelope. The
app itself does not know the policy_envelope type yet, so teaching it that is
the remaining step, upstream.
Revoking a grant early
An envelope expires on its own, but a grant you want dead now needs revocation, and the check has to live where an attacker cannot quietly delete it. It lives on Solana: an authority publishes a signed memo naming the session key, and the signer reads the chain before every decision.
coldstar-sign-policy … --revoker <hot pubkey> # version 2 envelope, revocable without the safe
coldstar-revoke --authority revoker.json --session <session pubkey> # emergency stopTurn the check on with checkRevocation: true (or COLDSTAR_CHECK_REVOCATION=1). Revoked is a hard REJECT; a chain the signer cannot reach is an ESCALATE, so cutting the signer off from RPC stops the agent rather than freeing it. Running signers notice within their freshness window, 60 seconds by default.
Be clear about what this stops. It stops a compromised agent, which is the case this package exists for: a prompt-injected agent proposes transactions through the signer, and a revoked grant refuses all of them. It does not stop someone who has stolen the session secret key. They do not need this package at all; they can sign with web3.js directly, and no off-chain control can stop them. The answer to a stolen key is to keep funds behind an on-chain program that enforces membership itself, which on Solana today means Squads spending limits. We would rather say that plainly than let it be assumed.
In code: ColdstarWallet.fromEnvelope({ envelope, expectedRoot, session, rpcUrl, checkRevocation: true }).
No Node on the air-gapped machine?
A machine that earns the name is usually a minimal install or a read-only live image. Those have python3; they often do not have Node, and pip install wants the network you just removed. So the same signer ships as one Python file:
./tools/coldstar_sign_policy.py --root root.coldstar.json --policy coldstar.policy.json \
--session <session pubkey> --expires 7d > envelope.jsonIt reads the same encrypted container, produces byte-identical envelopes, uses PyNaCl when installed and a vendored RFC 8032 Ed25519 otherwise, and runs the same air-gap check. Signing needs only the standard library; opening an encrypted root needs cryptography (42+, which carries both Argon2id and AES-256-GCM) or argon2-cffi beside it, so install that before the machine goes offline. The format is written down in ENVELOPE-SPEC.md so a third implementation is possible; src/policy/crossLanguage.test.ts runs the real script and verifies its output with the real verifier, and asserts both languages sign the same bytes.
The vendored fallback is not constant-time. That is stated in the file and is the reason it is scoped to the air-gapped machine, where there is no attacker present to observe timing. A bare, unsigned coldstar.policy.json still works for tests and devnet; set COLDSTAR_REQUIRE_ENVELOPE=1 anywhere it matters.
Use it from any MCP client (Claude, Cursor, …)
The same wallet as an MCP server. The model gets five tools and never a key; every outcome is returned as data (signed / escalated / rejected), so an agent can read the reason and stop instead of retrying.
{
"mcpServers": {
"coldstar": {
"command": "npx",
"args": ["-y", "-p", "coldstar-agent-signer", "coldstar-signer-mcp"],
"env": {
"RPC_URL": "https://api.devnet.solana.com",
"COLDSTAR_POLICY": "/abs/path/coldstar.policy.json",
"COLDSTAR_SESSION_KEYFILE": "/abs/path/session.json"
}
}
}
}In Claude Code that is one command:
claude mcp add coldstar \
-e COLDSTAR_POLICY=/abs/path/envelope.json \
-e COLDSTAR_SESSION_KEYFILE=/abs/path/session.json \
-e COLDSTAR_ROOT_PUBKEY=<root pubkey> \
-e COLDSTAR_REQUIRE_ENVELOPE=1 \
-- npx -y -p coldstar-agent-signer coldstar-signer-mcpPoint COLDSTAR_POLICY at a root-signed envelope rather than a bare policy file, and pin the root. A bare policy is a file the agent's own host can edit; an envelope is not.
Tool | What it does |
| Session address, the policy, today's spend against the daily cap |
| Evaluate a base64 transaction; returns AUTO_SIGN / ESCALATE / REJECT and why. Signs nothing. |
| Sign under policy. |
| As above, then broadcast if signed; an RPC failure returns |
| Build + evaluate + send a SOL transfer from the session wallet ( |
Set COLDSTAR_CHAIN_LEDGER=1 to put Solana behind the daily cap. The local file records everything this signer approved (including transactions that have not landed); the chain records everything that actually landed and cannot be deleted. The wallet uses the larger of the two, so an attacker who deletes the ledger file does not get a fresh daily allowance.
The binary keeps the daily-spend ledger in COLDSTAR_LEDGER (default ./.coldstar-ledger.json) so the cap survives restarts. COLDSTAR_SESSION_KEYFILE is a solana-keygen-style JSON byte array; COLDSTAR_SESSION_KEY (base58) also works. It is the session key. The root key has no environment variable because it never lives on this machine.
Install
npm install coldstar-agent-signer @solana/web3.js tweetnacl@solana/web3.js and tweetnacl are peer dependencies (Solana Agent Kit already brings both). Published on npm as coldstar-agent-signer (unscoped); npm install github:ExpertVagabond/coldstar-agent-signer also works and tracks main.
Read THREAT-MODEL.md before trusting this with anything. It states plainly what the design does not protect against: no secure element, a keylogger on the offline machine defeats it entirely, a stolen session key is beyond any local control, and the code is unaudited.
Status: beta, devnet. The signing core and policy engine are in scope for Coldstar's planned independent audit. Run it against devnet, read the policy file before you trust it with anything, and see the posture note below.
Layering with Squads
Coldstar's honest limit is that it cannot stop someone who has stolen the session key: that person signs with web3.js and never touches this code. Squads fixes exactly that, and Coldstar fixes what Squads leaves open, so the two compose better than either alone.
Alone | Together | |
Squads | On-chain spending limits the program enforces, but members typically sign from a hot browser wallet (its docs route Ledger through Phantom or Solflare with blind signing). | Members can be cold-rooted session keys under local policy. |
Coldstar | Cold root, local policy, escalation to a human — all defeated by a stolen session key. | A stolen key is bounded by the on-chain limit, whoever holds it. |
Put the funds in a Squads vault, make the session key a member with a spending limit, and Coldstar decodes spending_limit_use so its own per-transaction, daily and per-mint limits apply on top of the chain's. Set allowPrograms to include SQDS4ep65T869zMMBKyuUq6aD6EgTu8psMjkvj52pCf.
Crucially, Squads is not one opaque allowlisted program. The instructions that would let an agent raise its own ceiling are separate instructions, so they are named and refused: multisig_add_spending_limit, multisig_remove_spending_limit, config_transaction_execute, vault_transaction_execute, proposal_create and proposal_vote all escalate to a human. An agent can spend inside its limit and cannot change the limit.

npm run demo:squads runs that. Every instruction is built with the real @sqds/multisig SDK, so it exercises the decoder against what the program actually dispatches on, and nothing is submitted — the multisig is a derived address.
Nothing here needs Squads' permission: the v4 program is AGPL and permissionless on mainnet and devnet, and the tests build their instructions with @sqds/multisig itself.
examples/squads-devnet-demo.mjs runs the whole thing against real devnet — it creates the multisig, adds a spending limit naming the agent's session key, funds the vault, and then shows the three outcomes:
── 1. the agent spends 0.005 SOL, inside both bounds
[policy] AUTO_SIGN within policy
-> landed on chain, payee received 0.005 SOL from the vault
── 2. the agent asks for 0.04 SOL — the VAULT would allow it, the local policy does not
[policy] ESCALATE amount 0.04 SOL exceeds escalate threshold 0.01
-> no signature exists, so this never reached the chain
── 3. the agent tries to raise its own ceiling
[policy] ESCALATE squads multisig_add_spending_limit (raises the agent's own ceiling)
-> refused before signingRun it with node examples/squads-devnet-demo.mjs --funder <devnet keyfile>. It costs about 0.03 SOL and reuses the multisig on re-runs.
What Coldstar can read
An integration, for a policy signer, is a decoder. A program it cannot read is either escalate-everything, which makes the agent useless, or trust-blindly, which makes the limits a decoration. So the list of decoded programs is the list of things an agent can actually do:
Program | What Coldstar does with it |
System | SOL transfers decoded exactly; unknown discriminants escalate |
SPL Token / Token-2022 |
|
Associated Token Account | Creating a payee's account is the ordinary first payment, so it is decoded rather than escalated. Rent is charged against the limits, because creating accounts in a loop is a slow drain |
Compute Budget | The priority fee is computed from the unit limit and price and counted as spending. It is real SOL and it is not a transfer, so nothing else would have caught it |
Stake | Delegation, deactivation and merging move nothing. |
Memo | Read as moving nothing |
Squads v4 |
|
Anything else | Bounded only by the program allowlist. Turn on |
Decoding is not permission. A program still has to be in allowPrograms; being decoded only means the limits mean something once it is.
The air gap is the part people get wrong
Switching Wi-Fi off is not an air gap, and neither is a machine you also browse on. coldstar-sign-policy refuses to read the root key when the machine has a live network interface, prints what it found, and requires --allow-network to proceed anyway. It is a guard rail, not proof: it cannot see a VM's isolation, a tether attached later, or a radio the OS does not enumerate. The setup that earns the name is in THREAT-MODEL.md.
SPL tokens, and a hole that used to be here (read this)
Until 0.3.0 allowTokens was declared in the schema and never read. Allowlisting the Token program so an agent could pay in USDC therefore switched off every amount control, because the SOL limits count lamports and a token transfer moves none. A policy reading "allowTokens": ["SOL"] looked restrictive and bounded nothing. If you ran an earlier version with the Token program allowlisted, treat that wallet as having had no token limits at all.
Now:
allowTokensis enforced. It holds"SOL"and/or mint addresses; a movement of anything else escalates. A policy that omits"SOL"no longer moves SOL either.tokenLimitssets per-transaction and daily caps per mint, in base units as strings (USDC has 6 decimals, so"25000000"is 25 USDC). Base units and strings because money and floats do not mix.Destinations are token accounts, not wallets, so the wallet derives the associated token account of every
allowRecipients× mint pair and checks against those. You rarely need to writeallowTokenAccountsby hand.The daily per-mint total persists in the ledger and, with
COLDSTAR_CHAIN_LEDGER=1, is cross-checked against token balance deltas on chain.
Instructions that cannot be bounded are refused, which means ESCALATE, so a human sees them: Approve and ApproveChecked (a delegate can drain later with no further signing — this was the sharpest edge), SetAuthority, MintTo, Burn, CloseAccount, and any discriminant the decoder does not know. A bare Transfer also escalates: its accounts do not include the mint, so the asset cannot be identified and allowTokens cannot be applied. Use TransferChecked, which is the recommended instruction anyway.
Posture on non-System programs (read this)
A swap through an allowlisted program such as Jupiter cannot be statically decoded to a SOL amount; the real number depends on routing and on-chain state. This release ships posture (a): trust the program allowlist. Allowlisting a program means "I accept that this program can move funds within its own logic." Per-transaction and daily caps therefore bound bare SOL transfers, not what an allowlisted program does internally. Keep allowPrograms short. You will almost always need the ComputeBudget program (ComputeBudget111111111111111111111111111111) in it: most SDKs, Solana Agent Kit included, prepend a priority-fee instruction to every transaction, and it moves no lamports. Posture (b) is available opt-in: pass preflight: { simulate: rpcSimulator(rpcUrl) } to ColdstarWallet (or set COLDSTAR_SIMULATE=1 for the MCP binary) and any transaction touching a non-System program is simulated first; the fee payer's measured debit is applied to the per-transaction limit, escalate threshold, and daily cap when it exceeds the static figure, and a failed simulation escalates. Simulation can still diverge from execution, so this narrows the gap rather than closing it. The decision seam is documented at classifyOpaqueProgram in src/adapter/parseTx.ts.
Use it from Solana Agent Kit
ColdstarWallet implements the same five methods as the kit's KeypairWallet, so it is a one-line swap:
import { Keypair } from "@solana/web3.js";
import { ColdstarWallet, ColdstarEscalation, ColdstarRejected } from "coldstar-agent-signer";
import policy from "./coldstar.policy.json";
// The SESSION key. Disposable; authorised by the cold root's policy envelope.
// The root key is never in this process.
const session = Keypair.fromSecretKey(bs58.decode(process.env.COLDSTAR_SESSION_KEY!));
const wallet = new ColdstarWallet({
policy,
session,
rpcUrl: process.env.RPC_URL!,
onEscalate: async (tx, reason) => {
// Hand the unsigned tx to the air-gapped device (QR) and return the signed
// tx if a human approves, or null to decline. Omit to make ESCALATE throw.
return null;
},
onDecision: (d) => console.log(d.decision, d.reason),
});
const agent = new SolanaAgentKit(wallet, process.env.RPC_URL!, {});Pass ledger: new FileSpendLedger("/var/lib/coldstar/ledger.json") so the daily cap survives a restart; the default in-memory ledger is for tests and throwaway sessions. Every signTransaction / signAllTransactions / signAndSendTransaction call is projected, parsed, and evaluated first. AUTO_SIGN signs with the session key. ESCALATE calls onEscalate, and throws ColdstarEscalation (carrying the unsigned tx as base64 for the QR hand-off) if it declines. REJECT throws ColdstarRejected and no signature ever exists. Batches are atomic on rejection: if any transaction in signAllTransactions is rejected, none are signed. signMessage is refused unless allowMessageSigning: true, because off-chain signatures can authorise things the transaction policy never sees.
The adapter is fail-closed, on purpose
evaluate() enforces the recipient allowlist only when outSol > 0. So an adapter
that reports 0 for an instruction it could not decode would turn an unknown transfer
into an AUTO_SIGN. Under-reporting outSol is the one way parseTx.ts can lose keys.
parseTx() therefore returns a ParseResult, never a bare TxIntent. Anything it cannot
fully account for — an unknown System discriminant, a truncated lamports field, missing
accounts — yields { ok: false, reason }, and callers must treat that as ESCALATE.
"Could not be accounted for" is not "safe".
The posture for non-System programs (trust the program allowlist, decided for the devnet
release) is documented at the classifyOpaqueProgram seam in src/adapter/parseTx.ts and in
the install section above.
Run the tests
npm install
npm test # vitest runThe three demo scenarios (devnet only)
Per Solana convention, the demo runs on devnet first — never mainnet.
AUTO_SIGN — agent does an in-policy Jupiter swap / small transfer to an allowlisted recipient → signs on the session key, no human.
ESCALATE — agent proposes an over-limit transfer → hand-off to the air-gapped device for human approval (QR).
REJECT — a prompt-injected / hijacked agent proposes a transfer to a blocklisted address → no signature is ever produced. This is the money shot.
Policy model
See coldstar.policy.json. Evaluation order (first match wins): blocklist → program allowlist → escalate-threshold → per-tx limit → recipient allowlist → daily cap → auto-sign. Blocklist is checked first and unconditionally, so it beats an otherwise-in-policy transaction.
This server cannot be deployed
Maintenance
Related MCP Connectors
Wallet-signed Solana RPC for AI agents. No API keys, LLM-safe amounts, pay-per-call in SOL.
Native Solana staking for AI agents. 26 MCP tools, one-shot signing, webhooks.
Safe Solana swaps for AI agents with risk checks, unsigned transactions, x402, and 3-bp fees.
Supervised API-write gateway for AI agents with policy, human approval and execution receipts.
Related MCP Servers
- AlicenseAqualityCmaintenanceCryptographic proof of consent for AI agents. Sign before you act. Policy engine enforces spending caps, action whitelists, and escalation rules. Independently verifiable by anyone.102Apache 2.0
- AlicenseAqualityCmaintenanceEnables AI agents to manage USDC wallets on Solana, allowing them to send payments, create invoices, and access paid APIs within human-defined spending limits. It uses threshold signatures to provide agents with financial autonomy while ensuring secure oversight and transaction approval.368 npm4Apache 2.0
- AlicenseAqualityDmaintenanceEnables AI agents to check balances and send transactions across multiple blockchains with automatic spending limit protection and policy enforcement.3MIT
- AlicenseNot gradedqualityDmaintenanceEnables AI agents to make autonomous payments on Solana with on-chain enforced spending limits, preventing unauthorized fund drainage.01MIT