A local signing daemon that lets software agents use a blockchain key they can never see.
SignBox sits between an AI agent (or any automated tool) and a blockchain private key. The agent submits transactions it would like to sign; SignBox inspects them, checks them against a deterministic policy, and either signs or refuses. The agent never touches the key — it only ever holds a limited capability: asking for a signature.
Status: spec draft v0.4 · Phase 1 XPR MVP functionally complete — engine, keystore, daemon, XPR signing, quotas, on-chain policy contract + anti-rollback cache, interactive ESR onboarding, hash-chained audit, and an MCP server for agents — all implemented and tested (190 tests). Not yet production-ready (Phase 2 hardening pending). First target chain: XPR Network.
LLM-based agents are probabilistic. Prompt injection, poisoned tools or plain misbehavior can make an agent do things it was never supposed to do. If that agent holds a private key, one successful attack means the key — and everything it controls — is gone.
Giving an agent a key is easy. Giving an agent bounded, revocable, auditable signing power is not. That is what SignBox does.
To the agent, SignBox is a black box:
agent / LLM
│
│ plain JSON actions (never bytes, never a hash)
▼
┌───────────────────────────┐
│ SignBox │
│ │
│ 1. validate the JSON │
│ 2. apply the policy │ policy source of truth:
│ 3. sign or refuse │◄──── on-chain contract,
│ │ controlled by a superior
└───────────┬───────────────┘ authority — never by the agent
│
▼
signed transaction | refusal
- The agent submits raw, unserialized JSON — a readable list of actions. Packed transactions, hex blobs and bare digests are rejected categorically.
- It receives a single final answer: a signed transaction, or a refusal with a safe reason. Nothing else ever leaves the box.
- The decision is made by a deterministic policy engine — never by an LLM, never by a heuristic. Same input, same policy, same decision. A policy lives on-chain and can only be changed by the superior authority (a human wallet), never by the agent.
SignBox behaves like a headless programmatic wallet: it receives a readable transaction proposal — exactly as a human wallet receives a signing request — validates it, serializes it itself, and signs it with the key it protects. The only difference from a human wallet is that the approval tap is replaced by a policy.
The security consequence: compromising the agent no longer compromises the key. A fully hijacked agent is reduced to proposing transactions; its maximum blast radius is whatever the policy already allows. The cost of an attack moves from "convince an LLM" (easy) to "take over the host machine" (hard).
- Deny by default — an empty or missing policy authorizes nothing.
- The key never leaves the daemon — no API, log, error or CLI command returns it. Keys are stored encrypted (Argon2id + XChaCha20-Poly1305) and live only in locked memory buffers while in use.
- Full decoding or refusal — SignBox never signs anything it cannot completely read: no opaque blobs, no unknown fields, no surprise second action buried in a transaction.
- Fail closed — any error, timeout, ambiguity or unknown value results in a refusal. There is no "probably fine" path.
- No LLM in the decision loop — the agent can propose and ask; only the policy engine decides.
- Exact money math — amounts are integers of minimal units (
bigint), never floating point. Comparisons require an exact symbol and precision match; lookalike symbols and decimal tricks are refused, not coerced. - Signing and broadcasting are separate permissions — a policy can allow signing while forbidding network effects.
Honesty matters in a security tool:
- The boundary between agent and key is OS-level isolation (separate users, socket permissions, peer credentials). A root attacker — or anything running as the daemon's own user — is outside the software guarantee. Hardware-grade non-exportability requires an HSM/TPM (planned, Phase 3). See
docs/deployment-hardening.mdfor how to make that isolation a hard wall (separate users / container, filesystem permissions, systemd, ptrace). - Rate limits and daily caps are enforced locally and are best-effort; anything that must be absolutely guaranteed belongs on-chain.
- SignBox protects the key, not the agent's other data. What an agent legitimately knows, it can still leak.
| Runtime signing | Onboarding / administration | |
|---|---|---|
| Who signs | SignBox, with the agent's key | A human authority, with their own wallet |
| Used for | day-to-day agent transactions | creating agents, changing policies, rotating keys |
| Mechanism | policy check, then sign in-process | signing request encoded as a QR code, scanned and approved in the authority's wallet |
| Key exposure | key stays inside the daemon | SignBox never holds the authority's key |
The agent's key signs only what the policy allows. Everything administrative — including changing the policy itself — requires the external authority's wallet.
signbox agent create walks the onboarding (interactively, or via flags for scripts):
$ signbox agent create
Chain: 1) XPR Network (default)
Network: 1) mainnet 2) testnet (default)
Authority account (your account name): superdev
Agent account name: superagent
Mode: 1) create a new account (default) 2) onboard an existing one
Key export policy: 1) non-exportable (recommended) 2) encrypted-backup-only
keystore passphrase: ****
SignBox then:
- generates the agent's key locally and seals it in a temporary encrypted container (nothing active yet);
- builds a signing request (ESR) — create the account with
owner/activeunder the authority's control, add a dedicated permission holding the agent's key, register an empty deny-all policy — and shows it as a QR code; - the authority scans and signs it in their own wallet (SignBox never holds the authority's key);
- after confirming on-chain that what landed matches the request exactly, SignBox promotes the key to active. A timeout or any mismatch destroys the temp container — the key is never orphaned and never lost.
The superior authority pays the account's RAM. linkauth (the chain-enforced coarse bound that complements the fine-grained policy) is left to the developer. The dedicated permission is inert on-chain until linked — the intended deny-by-default posture.
{
"schemaVersion": 1,
"default": "deny",
"maxActionsPerTransaction": 1,
"chain": { "name": "XPR", "chainId": "71ee83bc…" },
"rules": [
{
"id": "allow-small-xpr-tips",
"effect": "allow",
"match": {
"contract": "eosio.token",
"action": "transfer",
"data.from": "$agent",
"data.quantity.symbol": "XPR",
"data.quantity.amount": { "lte": "1000.0000" },
"data.to": { "notIn": ["blocked.gm"] }
},
"limits": {
"maxPerTransaction": "1000.0000 XPR",
"maxPerDay": "5000.0000 XPR",
"maxCountPerRecipientPerHour": 3
}
}
]
}Declarative, versioned, JSON-Schema-validated, no executable code. An explicit deny always beats an allow; anything not explicitly allowed is refused. Value and count limits aggregate across a transaction's actions, and maxActionsPerTransaction defaults to 1 — so a multi-action transaction can neither multiply a limit nor slip in an extra action unless the policy explicitly allows it.
A policy is not a local file. It lives on-chain, in the central signbox contract — one row per agent account, holding the superior authority, the agent's dedicated permission, a monotonic version, and the policy's canonical JSON + hash. Only the agent's authority can create or change it, by signing an on-chain transaction from an external wallet (never a key SignBox holds).
That is what makes the policy tamper-proof: a compromised agent — or anything with write access to the daemon's host — cannot alter it, because the only gate that matters is the contract's on-chain requireAuth(authority), not a filesystem permission.
The daemon reads the on-chain policy through a local cache that:
- verifies the policy's hash and canonical form before trusting it;
- never accepts a lower version than it has already seen (anti-rollback), so a lying RPC or a restored database can't silently downgrade to a more permissive policy;
- refreshes every 30 s, and re-confirms a value-moving policy within 10 s before signing;
- fails closed if the policy can't be confirmed.
Configuration is zero-config by default. The daemon uses conventional paths under ~/.signbox/ (keystores, local state, sockets), and serves an agent simply by holding its encrypted keystore. An optional config file exists only for advanced deployment settings — RPC endpoints, the contract account, socket paths — never for agents or policies, which come from the keystores and the chain.
Phase 1 (XPR MVP) — component status:
| Component | Status |
|---|---|
| Deterministic policy engine (deny-by-default, deny>allow, exact-integer limits) | ✅ done |
| Encrypted-file keystore (Argon2id + XChaCha20-Poly1305, metadata-bound) | ✅ done |
| Unserialized-JSON transaction decoding & normalization (INV-014) | ✅ done |
| Unix-socket daemon: authenticated decision pipeline, kill-switch | ✅ done |
XPR runtime signing via @proton/js (WYSIWYS round-trip, chain-id pinning) |
✅ done |
| Stateful quota journal (SQLite, atomic reserve/commit) | ✅ done |
CLI (inspect/explain/sign/push, doctor, daemon, key, agent) |
✅ done |
| On-chain policy contract (AssemblyScript) + anti-rollback policy cache | ✅ done |
ESR onboarding (agent create, interactive) |
✅ done |
Hash-chained audit log (audit tail/query/verify) |
✅ done |
MCP server + llms.txt / llms-full.txt for agents |
✅ done |
Phase 1 (XPR MVP) is functionally complete. Remaining before production: the Phase 2 hardening below.
Later phases:
| Phase | Scope | Status |
|---|---|---|
| 2 — Hardening | Dedicated OS user, native peer credentials, RPC quorum, signed releases, fuzzing, external audit | planned |
| 3 — High assurance | HSM/TPM/PKCS#11 backends, attestation, administrative multisig | planned |
| 4 — More chains | Additional ChainAdapter implementations (the core is chain-agnostic from day one) |
planned |
Requirements: Node.js ≥ 22.
Build and install from source (puts signbox and signbox-mcp on your PATH):
npm ci
npm run build
npm link # or: npm install -g .
signbox --versionOr install a released tarball (see Releases):
npm install -g signbox-0.1.0.tgzEvery vX.Y.Z git tag triggers the release workflow, which builds, tests, and
attaches signbox-X.Y.Z.tgz to the GitHub Release.
npm install
npm test # unit + adversarial test suite (178 tests)
npm run typecheck # strict TypeScript, no emit
npm run build # compile to ./distThe on-chain contract is a separate sub-project under contract/ (AssemblyScript / proton-tsc); see contract/BUILD.md for its pinned toolchain and npm test (vert).
The test suite includes the specification's adversarial set (§17.4): second-action injection, wrong token contract, homograph symbols, incorrect decimals, chain-id substitution, keystore tampering, concurrent quota-cap bypass, and policy-cache rollback.
The full architecture and threat model live in docs/signbox-spec-v0.4-complete.md. Start with §1.1 — it is the reading key for everything else.
Not yet licensed for public use — this repository is under active early development.