Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -130,7 +130,7 @@ cargo build -p deckard-mcp # one-time: builds ./target/debug/deckard-mcp (n

The demo policy (`policy.demo.json`, installed at `~/.deckard/demo/policy.json`) caps
per-tx at **0.1 ETH** and daily at **0.5 ETH** with `require_approval: OverCap`. A shield
**within** cap auto-allows (on the Sepolia fork the mainnet guardrail is inactive). A
**within** cap auto-allows (on the Sepolia fork — an exempt testnet id — the auto-approval guardrail is inactive). A
shield **over** cap returns `NeedsApproval` — and there's no approval card in this alpha
(the app only resolves shields it proposed itself, so an agent/CLI-proposed over-cap shield
can't be approved from the app). To proceed, either **lower the amount** under the per-tx
Expand Down
11 changes: 6 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,8 @@ trustless by construction, open source.

**The falsifiable claim:** *a prompt-injected agent cannot move your funds* — every write is
policy-gated inside a **separate signer process** that holds the key (the agent surface is
key-less), and on **mainnet every auto-allow is downgraded to an in-app human approval**.
key-less), and on **every real-value chain every auto-allow is downgraded to an in-app human
approval** (hands-free runs only on explicit testnet/dev chains, for the demo).
That is the whole design; if you can cross a boundary it claims, that's a vulnerability — see
[`THREAT-MODEL.md`](THREAT-MODEL.md) and [`SECURITY.md`](SECURITY.md).

Expand Down Expand Up @@ -117,7 +118,7 @@ cargo build -p deckard-mcp # one-time: builds ./target/debug/decka
```

> The demo policy caps per-tx at **0.1 ETH**; a shield **within** cap auto-allows on the Sepolia
> fork (the mainnet guardrail is inactive there), an **over-cap** shield returns `NeedsApproval`.
> fork (the guardrail is inactive on exempt testnet/dev chains), an **over-cap** shield returns `NeedsApproval`.
> Each `just demo` is a **fresh fork** — re-run `just demo-fund` and re-shield every time. Full
> demo mechanics, the env-var table, and over-cap behavior live in
> [`CONTRIBUTING.md`](CONTRIBUTING.md#demo--local-chain-dev-loop).
Expand Down Expand Up @@ -208,9 +209,9 @@ Real, and already built:
- **Key-less agent surface** (`deckard-mcp`): the MCP process holds no key material — it only proposes
typed intents to the daemon, which evaluates policy and signs. The `mcp.v0.1` profile excludes raw
`propose`/`resolve` by design.
- **Mainnet guardrail:** while the daemon signs for chain 1, every auto-allow is downgraded to an in-app
human approval (its override is documented in [`THREAT-MODEL.md`](THREAT-MODEL.md) only, never echoed to
the agent).
- **Auto-approval guardrail:** on every real-value chain (every chain except an explicit testnet/dev
allowlist), every auto-allow is downgraded to an in-app human approval (its override is documented in
[`THREAT-MODEL.md`](THREAT-MODEL.md) only, never echoed to the agent).
- **Helios light-client verified reads** — no third-party RPC is trusted by default.
- **Keystore at rest** = Argon2id key derivation + an XChaCha20-Poly1305 envelope; secrets stay in `Zeroizing`.
- `deckard-core` is `#![forbid(unsafe_code)]`; the workspace lint policy denies `todo!`, `dbg!`, and ignored
Expand Down
4 changes: 2 additions & 2 deletions STATUS.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ Legend: ✅ done + tested · 🟡 partial / integrated-not-finished · 🧪 spik
|---|---|---|---|
| `deckard-contract` | ✅ `Intent`/`Decision`/`Policy` + `ReadStatus` + `calldata_ok` non-empty invariant | 32 | `da29a37` `9e19e9a` `a0a37fd` |
| `deckard-core` | ✅ `EthProvider` (C1) + balances/Multicall3 (C2) + encrypted keystore (C3) + Helios verified reads + key-less shield builder + KAT-gated Railgun seed→0zk viewing-key derivation (`railgun_keys.rs`) | 13+ | `e1aa079` `42e04ad` `57f21bc` `9e19e9a` `3aae92d` |
| `deckard-signerd` | ✅ process-isolated signer daemon + policy gate + STOP/zeroize + Helios read + calldata broadcast + **mainnet guardrail** (chain-1 auto-Allow → `NeedsApproval`, resolved by the app's hold-to-confirm) + RelayAdapt pre-check + **reason/RPC redaction** | `daemon_e2e` · `parity` · `anvil_e2e` · `shield_e2e` (#[ignore]) | `a24f62c` `9e19e9a` `2f28b8b` `72ad5cb` |
| `deckard-signerd` | ✅ process-isolated signer daemon + policy gate + STOP/zeroize + Helios read + calldata broadcast + **auto-approval guardrail** (default-deny: auto-Allow → `NeedsApproval` on every real-value chain, resolved by the app's hold-to-confirm) + RelayAdapt pre-check + **reason/RPC redaction** | `daemon_e2e` · `parity` · `anvil_e2e` · `shield_e2e` (#[ignore]) | `a24f62c` `9e19e9a` `2f28b8b` `72ad5cb` |
| `deckard-mcp` | ✅ key-less CLI + MCP stdio sidecar (`mcp.v0.1` 6-tool profile: `deckard_wallet_address` / `wallet_balance` / `policy_get` / `shield` / `execute` / `revoke_all`); holds no key, proposes Intents to the daemon socket; secret-flag hard-reject + transcript canary scan | `acceptance` 9, all run by default against a *mock signerd* (T1 six-tool profile · T6 within-cap shield → mock `tx_hash` · T7/T9 secret-free transcript) | `a82cc38` |
| `deckard-app` (GPUI) | ✅ onboarding / portfolio / receive / palette / settings + **shield view** (compose → review → hold-to-confirm + shielded-balance composition + privacy mask) + `ReadStatus` badge + socket signer client + **env plumbing** (`DECKARD_CONFIG_DIR`/`SOCKET_PATH`/`CHAIN_ID`/`RPC_URL`, `DECKARD_VERIFIED_READS`, `DECKARD_DEMO_FORK_BLOCK`) + **"DEMO FORK — not mainnet" banner**; 🟡 Send UI gated ("next release"), Swap ⬜ | `send_path` | C1–C3 + the shield/MCP/demo work |

Expand Down Expand Up @@ -78,7 +78,7 @@ The reachability + visible-state gaps the 2026-06-06 audit flagged are now built
- **`vendor/eip-1193-provider`** native-only fork (dodges a `wasm-bindgen` exact-pin conflict) + **railgun license** (no upstream license field, same R1e) — resolve both before ship. [`10`]
- Shield is instant (no client proof); spend proving ~10s cold / ~halved with `parallel` → a "spending…" UX for unshield. [`10`]
- Daemon holds its mutex across a broadcast (documented v1 tradeoff). MCP and railgun key-derivation for balance-display have **landed**; the receive-watcher (auto-detect → auto-shield) is still deferred.
- On mainnet (chain 1) the signer's **guardrail** downgrades every auto-Allow to `NeedsApproval`, resolved only by the app's hold-to-confirm — so a prompt-injected MCP client can't move real funds hands-free. The override env var is documented **only** in `THREAT-MODEL.md` and never appears in a reason string.
- On every real-value chain (every chain except an explicit exempt testnet/dev allowlist; an unknown chain-id is treated as real-value) the signer's **auto-approval guardrail** downgrades every auto-Allow to `NeedsApproval`, resolved only by the app's hold-to-confirm — so a prompt-injected MCP client can't move real funds hands-free. The override env var is documented **only** in `THREAT-MODEL.md` and never appears in a reason string.

## Deferred → `docs/research/roadmap.md`

Expand Down
60 changes: 34 additions & 26 deletions THREAT-MODEL.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ discipline:
1. **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*.
2. **The mainnet guardrail removes hands-free spend on chain 1.** See below.
2. **The auto-approval guardrail removes hands-free spend on every real-value chain.** See below.
3. **The launch tool surface is 6 tools** (`mcp.v0.1`): no raw `propose` (intents are
constructed daemon-side from typed `shield` arguments and the Shield target is
pre-checked against the canonical RelayAdapt address), and no `resolve` (an
Expand All @@ -61,27 +61,35 @@ discipline:
`scheme://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 mainnet guardrail and its override

While the daemon signs for `chain_id == 1`, **every auto-Allow is downgraded to
`NeedsApproval`** — 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`**. 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 mainnet writes anyway.
## The auto-approval guardrail and its override

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 is **chain-1 only**. On any other chain (Polygon, Arbitrum, …) the policy caps
are the only brake on auto-Allow. Treat non-mainnet chains you care about like
mainnet: set `ApprovalMode::Always` in your policy.
- 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 `OverCap` with an empty allowlist;
this guardrail does not change that. If you operate on an exempt chain you care about, set
`ApprovalMode::Always` in your policy.
- It is a **same-uid speed bump**, per the boundary section above.

## The viewing key in the sidecar
Expand Down Expand Up @@ -232,12 +240,12 @@ designated resolver; see the boundary section). The interesting question is whet
app is the wire contract's designated resolver, so it sends `Resolve{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 mainnet guardrail downgrade happens daemon-side, not app-side**
(`crates/deckard-signerd/src/daemon.rs`, `propose` + `mainnet_guardrail_active`): on
chain 1 without the override, every auto-`Allow` becomes `Pending` *in 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 mainnet write. `Deny` is never
upgraded.
- **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-`Allow` becomes `Pending` *in 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. `Deny` is never upgraded.
- **Chain-id resolution fails loud** (`crates/deckard-app/src/settings.rs`,
`resolve_chain_id`): the daemon's chain is resolved `env > settings > default(mainnet)`
*once* at startup and pinned into the daemon's env, so the reader and signer agree. An
Expand All @@ -255,7 +263,7 @@ broadcasts through a diverged endpoint, but it's worth attacking (red-team issue
|---|------|--------|
| 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 | Non-mainnet chains have no guardrail (policy caps only) | Documented — use `ApprovalMode::Always` for chains you care about |
| 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 |
Expand Down
2 changes: 1 addition & 1 deletion crates/deckard-app/src/shell.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1684,7 +1684,7 @@ impl Shell {
needs_resolve: false,
});
}
// NeedsApproval (over-cap, or the daemon's mainnet guardrail): the review card +
// NeedsApproval (over-cap, or the daemon's auto-approval guardrail): the review card +
// hold-to-confirm ARE the human approval surface — the hold resolves the pending
// record, then executes.
Ok((intent, recipient, Decision::NeedsApproval { request_id })) => {
Expand Down
2 changes: 1 addition & 1 deletion crates/deckard-app/src/signer.rs
Original file line number Diff line number Diff line change
Expand Up @@ -121,7 +121,7 @@ pub fn send_blocking(client: &SignerClient, intent: &Intent) -> anyhow::Result<S
}

/// Execute a reviewed proposal, key-less. For a `NeedsApproval` proposal (over-cap, or the
/// daemon's mainnet guardrail downgrading an auto-allow), the completed hold-to-confirm IS
/// daemon's auto-approval guardrail downgrading an auto-allow), the completed hold-to-confirm IS
/// the human approval — the app is the wire contract's designated human-facing resolver — so
/// this first sends `Resolve{approved: true}` over the **private capability channel** (the only
/// channel the daemon authenticates approvals on, PRD-01) to flip the `Pending` record to
Expand Down
8 changes: 4 additions & 4 deletions crates/deckard-contract/src/rpc.rs
Original file line number Diff line number Diff line change
Expand Up @@ -207,7 +207,7 @@ pub enum PendingPayloadView {
}

/// Where an [`ActivityRecord`] sits in its lifecycle: `Proposed` (stored, awaiting a human
/// decision — an over-cap or mainnet-guardrail card, still approvable), `Decided` (a verdict
/// decision — an over-cap or auto-approval-guardrail card, still approvable), `Decided` (a verdict
/// landed — `approved: true` for an auto-allow-within-cap or a human approval; `approved:
/// false` for a denial or a STOP revoke — both cases where **a human acted**), `Expired` (the
/// approval window lapsed with **no human action**), or `Executed` (signed + broadcast, so the
Expand Down Expand Up @@ -239,13 +239,13 @@ pub enum ActivityLifecycle {

/// Which spending fence a proposal breached, recomputed by the daemon at record-write time
/// (a read of data it already holds) so the feed can cite the **actual** cap hit — never a
/// hardcoded "over per-tx cap". `None` for a within-cap auto-allow or a mainnet-guardrail hold
/// (no cap was breached; the hold is the guardrail, not a cap). This is display-only and lives
/// hardcoded "over per-tx cap". `None` for a within-cap auto-allow or an auto-approval-guardrail
/// hold (no cap was breached; the hold is the guardrail, not a cap). This is display-only and lives
/// OFF the verdict path: [`evaluate`](crate::evaluate) still collapses both caps into one
/// `over` bool and returns no reason — that frozen function is unchanged.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
pub enum BreachedLimit {
/// No cap breached — a within-cap auto-allow, or a mainnet-guardrail hold.
/// No cap breached — a within-cap auto-allow, or an auto-approval-guardrail hold.
#[default]
None,
/// The per-transaction ceiling.
Expand Down
Loading
Loading