This document states what the agent-facing surface (the deckard-signerd daemon, the
deckard-mcp sidecar, and the GPUI app's approval path) defends against, what it
deliberately does not defend against, and where the honest tradeoffs are. The
at-rest keystore model and the engineering lint policy live in
SECURITY.md; this file covers the runtime trust boundaries that an
agent integration creates. When the two disagree, this file is wrong — fix it.
Everything below assumes one boundary: code running as your user is trusted; code
that is not your user is not. The daemon's Unix socket is 0600 inside a 0700
directory and the daemon checks the peer's uid. There is no second boundary inside
your user account:
Resolveis gated by an unforgeable capability (PRD-01), not the honor system. The daemon converts guarded writes toNeedsApproval; approval (Resolve { approved: true }) is honoured only on the private channel the daemon inherits from the app that supervises it — asocketpairend passed at spawn (supervise.rsmints it,server.rsserves it,daemon.rsgates on it). AResolveon the public proposer socket — any other same-uid process speaking the documented CBOR wire — is refused withresolve_not_authorized. So a same-uid proposer (including a fully compromised MCP sidecar) can propose and read, but it cannot self-approve; the mcp.v0.1 tool surface still deliberately includes noproposeand noresolve(defense in depth). The honest residual: an agent with arbitrary shell access is same-uid code that can reach the app's inherited fd anyway (ptrace,/proc/<pid>/fd) — it is on the trusted side of the boundary, exactly as live malware is for the keystore (see SECURITY.md's "stated honestly" section). The capability closes the cheap, wire-level self-approve that any same-uid process could previously do; it does not fence off arbitrary code execution as your user.- Request-ids are deterministic per intent (no salt/namespace). Within the same-uid boundary this is fine — anyone who can compute your request-id can also just open their own connection. Salted/namespaced request-ids are on the roadmap for a future multi-principal daemon; they are deliberately not in the frozen v1 wire.
Authenticating the resolver — a socketpair capability handed only to the supervised
app — is now implemented (PRD-01): the public socket can no longer approve. The
standing caution is therefore narrower: do not point this daemon at meaningful mainnet
funds while running unattended agents with shell access, because same-uid code can
still reach the app's capability fd (ptrace / /proc). That sentence is the threat
model.
An LLM agent is an untrusted input channel: anything it reads (a web page, a README, an email) can instruct it. We assume the agent will eventually be injected and design so that the blast radius is bounded daemon-side, never by prompt discipline:
- Policy gates every write in the daemon. Allowlist, per-tx cap, daily cap, and approval mode are evaluated by the daemon process that owns the key — the sidecar and the app only propose.
- The auto-approval guardrail removes hands-free spend on every real-value chain. See below.
- The launch tool surface is 6 tools (
mcp.v0.1): no rawpropose(intents are constructed daemon-side from typedshieldarguments and the Shield target is pre-checked against the canonical RelayAdapt address), and noresolve(an injected agent cannot approve its own request). - Reasons are redacted at the daemon boundary. Error/
reasonstrings cross into agent transcripts that leave your machine; every embedded URL is scrubbed toscheme://host[:port]before it leaves the daemon (transport errors love to echo the full RPC URL, API key and all). This is canary-tested end-to-end.
The guardrail is default-deny: while the daemon signs for any real-value chain —
every chain EXCEPT an explicit exempt allowlist of testnet/dev ids (Sepolia 11155111,
local anvil 31337) — every auto-Allow is downgraded to NeedsApproval. An UNKNOWN
chain-id is treated as real-value and guarded too, so configuring a new real chain (Base,
OP, …) can never silently turn the brake off. The default OverCap policy ships with an
empty (= any-recipient) allowlist, so without this a within-cap injected write would move
real funds with zero human contact. The downgrade happens in the daemon,
post-policy-evaluation; a human approves via the app's hold-to-confirm (Resolve), and
Deny is never upgraded.
The override env var is DECKARD_I_KNOW_THIS_IS_MAINNET=1. (The name is kept for
back-compat; despite it, the override now disarms the guardrail on any real-value
chain, not just mainnet — set it only if that is what you mean.) This paragraph is its only
documentation, deliberately: the variable's name never appears in daemon reason strings,
tool responses, tool descriptions, or demo-check output (asserted by the
transcript-hygiene tests) — a guardrail whose disable instructions are printed to the agent
is a speed bump, not a control. Set it only if you are a human operator who has read this
file and wants policy-capped hands-free writes on a real chain anyway.
Honest limits of the guardrail:
- It guards against a misconfigured-chain hands-free spend, not all hands-free spend.
On the exempt testnet/dev ids the guardrail is off by design (the demo runs
hands-free), and there the policy caps are the only brake — within-cap auto-Allow to any
recipient still happens. The default policy is still
OverCapwith an empty allowlist; this guardrail does not change that. If you operate on an exempt chain you care about, setApprovalMode::Alwaysin your policy. - It is a same-uid speed bump, per the boundary section above.
deckard-mcp is spending-key-less: it can never sign. It does, however, handle
the Railgun viewing key (the daemon's RailgunViewGrant returns it alongside the
0zk address the sidecar needs for shield-recipient derivation). The viewing key is a
privacy secret — it decrypts your shielded transaction history. Discipline: it is
held in Zeroizing, has a redacting Debug, and never appears in any tool response,
log line, or error string (covered by the transcript allowlist scan). Compromise of
the sidecar process leaks privacy, not funds.
The daemon serializes all requests behind one mutex and holds it across the
broadcast RPC call, which is bounded at 30s. A stop (or any other request) issued
while a broadcast is in flight queues behind it — worst case, STOP takes effect up
to ~30s late. Lock/STOP still zeroizes the key when it runs; nothing new can be
approved in the window because the same mutex blocks new requests too. This is a
known, deliberate v1 simplification (it also gives us execute-idempotency for free);
moving the broadcast off-lock is the first post-launch daemon change and is seeded as
a red-team issue.
The sections above are the boundary-level argument; this is the same argument walked one surface at a time. For each: what an attacker controls, what stops them in the code today, what's deferred. Every claim names the file that backs it. Where a surface is already covered above, this links rather than repeats.
Controls: the full argument to any tool, the order and timing of calls, and — for a truly hostile client, not just an injected-but-honest one — the raw CBOR it speaks to the sidecar's stdin. It does not control the daemon or any key.
Stopped by:
- The tool surface is 6 read/propose tools, no
propose/resolve/simulate(crates/deckard-mcp/src/server.rs):wallet_address,wallet_balance,policy_get,shield,execute,revoke_all. There is no tool that submits an arbitrary intent (a rawproposewould let the client hand the daemon junk calldata) and no tool that approves a request — so an injected agent literally has no hand that can self-authorize a guarded write.shieldtakes a typedamount_ethstring and the daemon builds the intent against the canonical RelayAdapt address; the client never supplies ato. - Secret-shaped flags are refused before clap parses them
(
crates/deckard-mcp/src/secrets.rs,reject_secret_flags): any--…key,--…seed,--…token,--passphrase=…etc. is rejected delimiter-aware (every-/_component is checked), and the flag's value is never echoed into the refusal — so a pasted credential can't be laundered into a tool transcript or shell history even by mistake. The sidecar is key-less by architecture; this is the belt-and-suspenders. - A connect-time chain probe runs before any real intent
(
crates/deckard-mcp/src/sidecar.rs,ensure_chain): a deliberately-undecodableSendwhose only purpose is to make the daemon answerchain_mismatch(checked before the policy gate, beforelocked, and storing no pending record). A demo sidecar pointed at the mainnet daemon (or vice versa) fails with an actionable error instead of silently proposing on the wrong chain. The probe is side-effect-free and can never itself be executed. - Typed failures never echo a payload (
crates/deckard-mcp/src/failure.rs): every error an agent can hit is a staticproblem + cause + fix; the two retry-traps (broadcast_timeout,already_executed) say do NOT retry explicitly, because an LLM's default instinct on error is to retry and a blind retry of a broadcast can double-spend. The one path that could carry daemon text (from_deny_reason's fallthrough) only forwards areasonthat was already URL-redacted daemon-side, and theunexpectedhelper deliberately drops the response body it can't vouch for.
Deferred: nothing on this surface is load-bearing for the boundary — the client is untrusted by design and the daemon is the enforcement point. Tool-surface growth (send, unshield) re-opens this section and each new tool inherits the no-self-approve rule.
Controls: anything a same-uid process can: open the socket, send well-formed or malformed frames, flood requests. A different-uid process controls nothing — it can't connect.
Stopped by:
-
Peer-uid check + filesystem perms (
crates/deckard-signerd/src/socket.rs,crates/deckard-signerd/src/auth.rs,server.rs): the socket is0600inside a0700dir (chmod'd explicitly, not left to umask), and every accepted connection is gated onpeer_cred().uid()matching our euid — a foreign uid, including root, is dropped. A single-instanceflockkeeps a second daemon from racing on the socket. -
Bounded framing (
crates/deckard-signerd/src/frame.rs): a 4-byte big-endian length prefix caps each CBOR body at 1 MiB, so a hostile client can't make the daemon allocate unbounded memory; a truncated prefix (1–3 bytes then EOF) is an explicit error, distinct from a clean between-frames close, so a half-frame can't be mis-handled. A frame that won't decode gets onemalformed_requestreply and the connection closes — and the raw frame bytes arezeroized after decode because anUnlockframe carries the passphrase (server.rs). -
One mutex serializes every request (
crates/deckard-signerd/src/server.rs,daemon.rs):proposeandexecutecan't race, which is also what gives execute its idempotency for free. (The cost of holding it across broadcast is the STOP-latency tradeoff — see its section above.) -
Per-request TOCTOU re-checks at
execute(crates/deckard-signerd/src/daemon.rs,execute): anAllowis not a ticket. At sign time the daemon re-checks the vault is still unlocked (a STOP that landed first wins →revoked), the request hasn't already broadcast (already_executedidempotency keyed on the storedbroadcasthash), it isn't past its approval TTL (expired), and — for an auto-allow, not a human-approved overage — that it's still within the caps against the currentspent_today(cap_exceeded). Two within-cap proposals therefore can't both slip past the daily cap. -
Resolver authentication (
crates/deckard-signerd/src/{server,daemon,supervise}.rs, PRD-01):Resolveis honoured only on the private capability channel the daemon inherits from the supervising app (asocketpairend passed at spawn); aResolveon this public socket is refused withresolve_not_authorized. The same-uid peer-cred check stays as defense-in-depth + logging, not as the approval boundary.
Deferred: cross-restart spend persistence (policy_store.rs): spent_today is
in-memory and resets on restart.
Controls: an attacker who can already write your config dir is same-uid and past the boundary — but the failure modes of loading the file still matter, because a corrupt or absent policy must never silently become a permissive one.
Stopped by (crates/deckard-signerd/src/policy_store.rs): the file is read once at
boot into memory (there is no SetPolicy mutation API on the wire, so a connected
client can't widen the fence at runtime). A missing file falls back to the built-in
default quietly (normal first run); a file that exists but won't parse falls back
loudly — a ⚠ POLICY FALLBACK line naming the path and the default it's now running
— precisely so a typo'd policy.json can't silently drop you onto the any-recipient
default. On every load, spent_today_wei is forced to zero and revoked to false: the
daemon never trusts an on-disk spend counter and always boots armed (the brake is a
live STOP, not a persisted flag). The demo policy is install-if-absent, never an
overwrite.
Out of scope for this file by design — the Argon2id + XChaCha20-Poly1305 envelope, the
no-oracle unlock, and the "stated honestly" live-malware limit are in
SECURITY.md. The only runtime note: a wrong passphrase, a tampered
vault, and a read error all collapse to one BadPassphrase outcome with no key held
(daemon.rs, unlock) — no unlock oracle leaks across the socket.
Controls: an attacker who reaches this path is, again, same-uid (the app is the designated resolver; see the boundary section). The interesting question is whether the honest path is sound.
Stopped by:
- Hold-to-confirm →
Resolve{approved: true}→Execute(crates/deckard-app/src/shell.rs): the completed hold is the human approval; the app is the wire contract's designated resolver, so it sendsResolve{approved: true}only after the hold completes, and leaving the Shield surface cancels an in-progress hold so a stale timer can't fire a confirm after the screen is gone. - The auto-approval guardrail downgrade happens daemon-side, not app-side
(
crates/deckard-signerd/src/daemon.rs,propose+guardrail_active): on any real-value chain (every chain except the exempt testnet/dev allowlist) without the override, every auto-AllowbecomesPendingin the daemon, so the human-approval requirement doesn't depend on the app rendering a card correctly — a buggy or bypassed UI still can't produce a hands-free real-chain write.Denyis never upgraded. - Chain-id resolution fails loud (
crates/deckard-app/src/settings.rs,resolve_chain_id): the daemon's chain is resolvedenv > settings > default(mainnet)once at startup and pinned into the daemon's env, so the reader and signer agree. An unparsableDECKARD_CHAIN_IDis a deliberate startup panic — silently ignoring a typo likesepoliawould resolve toward mainnet, the wrong direction.
Deferred: the reader/signer RPC split is a known seam — a Settings RPC re-point
respawns only the read provider; the daemon keeps its launch RPC
(shell.rs::respawn_provider, documented inline). There's no send UI yet so nothing
broadcasts through a diverged endpoint, but it's worth attacking (red-team issue #3).
| # | Risk | Status |
|---|---|---|
| 1 | Same-uid code self-approves via Resolve (or speaks the wire directly) |
Accepted v1 boundary — documented above; resolver authentication is the roadmap fix |
| 2 | STOP queues ≤30s behind an in-flight broadcast | Accepted v1 tradeoff — first post-launch daemon PR |
| 3 | Exempt testnet/dev chains have no guardrail (policy caps only); the default policy is still OverCap so within-cap auto-Allow to any recipient happens there |
Narrowed (#76: real-value & unknown chains are now guarded by default) — on exempt chains use ApprovalMode::Always |
| 4 | Viewing-key compromise in the sidecar leaks shielded history (not funds) | Mitigated (Zeroizing, no-output discipline, scan-tested) |
| 5 | Reason redaction is URL-shaped-token-based; a credential echoed in a non-URL form would pass | Mitigated for realistic transport-error shapes (tested); allowlist scan is the backstop |
| 6 | Deterministic request-ids allow same-uid intent-collision games | Accepted within the uid boundary; salted ids on roadmap |
| 7 | Rollback / replay of an older genuine vault.bin: a same-uid attacker drops an older valid copy back over the current file (it isn't a forgery, so the AEAD can't flag it) |
Accepted residual for alpha. Evaluated and deferred (ADR 0004, #71): rollback needs filesystem write, which is same-uid and already inside this file's conceded boundary, and the vault's only rollback worst-case is reverting a passphrase/KDF rotation (the seed is constant, balances are on-chain). Revisit if the threat model rises (mainnet keys, multi-user, untrusted backup/sync) |
If you can demonstrate an attack that crosses a boundary this file claims holds — that's a vulnerability. Please report it via SECURITY.md.