diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 064a7f1..fb695e3 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -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 diff --git a/README.md b/README.md index edd1f02..c2c1418 100644 --- a/README.md +++ b/README.md @@ -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). @@ -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). @@ -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 diff --git a/STATUS.md b/STATUS.md index 928fe3f..f9e5e54 100644 --- a/STATUS.md +++ b/STATUS.md @@ -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 | @@ -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` diff --git a/THREAT-MODEL.md b/THREAT-MODEL.md index fde1a8e..dcc3c07 100644 --- a/THREAT-MODEL.md +++ b/THREAT-MODEL.md @@ -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 @@ -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 @@ -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 @@ -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 | diff --git a/crates/deckard-app/src/shell.rs b/crates/deckard-app/src/shell.rs index be42d53..4675efe 100644 --- a/crates/deckard-app/src/shell.rs +++ b/crates/deckard-app/src/shell.rs @@ -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 })) => { diff --git a/crates/deckard-app/src/signer.rs b/crates/deckard-app/src/signer.rs index 743fc1d..e604b68 100644 --- a/crates/deckard-app/src/signer.rs +++ b/crates/deckard-app/src/signer.rs @@ -121,7 +121,7 @@ pub fn send_blocking(client: &SignerClient, intent: &Intent) -> anyhow::Result crate::socket::default_socket_path(), }; - let mainnet_override = std::env::var("DECKARD_I_KNOW_THIS_IS_MAINNET") + // The autonomy override (disarms the auto-approval guardrail on ANY real-value chain). + // Env-var NAME kept for back-compat; its broadened meaning is documented only in + // THREAT-MODEL.md. Never echo this name into a client-visible string. + let autonomy_override = std::env::var("DECKARD_I_KNOW_THIS_IS_MAINNET") .map(|v| v.trim() == "1") .unwrap_or(false); @@ -77,7 +83,7 @@ impl Config { chain_id, config_dir, socket_path, - mainnet_override, + autonomy_override, }) } diff --git a/crates/deckard-signerd/src/daemon.rs b/crates/deckard-signerd/src/daemon.rs index 0143ff4..54b9556 100644 --- a/crates/deckard-signerd/src/daemon.rs +++ b/crates/deckard-signerd/src/daemon.rs @@ -73,6 +73,27 @@ fn relay_adapt(chain_id: u64) -> Option
{ } } +/// Explicitly **exempt** chain ids: public testnets and local dev / fork ids where hands-free +/// agent spend is allowed by default (the `just demo` Sepolia fork and the `just qa` anvil +/// vault run hands-free here). This is an allowlist of ids we trust to move no real value, not +/// a proof — a fork could reuse a mainnet id, which is exactly why it is a fixed exempt-list, +/// not a heuristic. Everything NOT here — mainnet (1), L2 mainnets (Base 8453, OP 10, Arbitrum +/// 42161, …), and any UNKNOWN id — is treated as real-value, so the [`Daemon::guardrail_active`] +/// brake stays armed. DEFAULT-DENY: a new real chain needs no code change to stay safe. +/// +/// SAFETY: extend ONLY with testnet / local-dev ids. A mainnet or L2-mainnet id here is a +/// fund-loss fail-open bug (pinned by `exempt_list_excludes_real_chains` in tests/guardrail.rs). +/// (chain 0 is refused at config time, so it never reaches the predicate.) +const TESTNET_FORK_CHAIN_IDS: &[u64] = &[ + 11_155_111, // Sepolia (and the demo's Sepolia fork, which preserves this id) + 31_337, // anvil / hardhat default (the `just qa` vault + most e2e suites) +]; + +/// Whether `chain_id` is on the exempt testnet/dev allowlist — see [`TESTNET_FORK_CHAIN_IDS`]. +pub(crate) fn is_testnet_or_fork(chain_id: u64) -> bool { + TESTNET_FORK_CHAIN_IDS.contains(&chain_id) +} + /// `Locked` holds no key; `Unlocked` owns the decrypted vault (dropped — and zeroized — on /// lock/STOP) plus its cached primary address. enum VaultState { @@ -124,10 +145,10 @@ struct PendingReq { /// pass or an explicit `cancel_order`), not a successful execute. Keeps the feed from reading /// a cancelled swap as `Executed` — a cancel is the opposite of the order going through. cancelled: bool, - /// True ONLY when this record was auto-allowed hands-free at propose time (within cap, off - /// mainnet). A mainnet-guardrail hold and an over-cap card are both `false` (a human is in the - /// loop) even though neither breached a cap — so the feed can say "auto-approved within cap" - /// vs "you approved" HONESTLY, instead of inferring it from the (absent) breach reason. + /// True ONLY when this record was auto-allowed hands-free at propose time (within cap, on an + /// exempt testnet/dev chain). A guardrail hold and an over-cap card are both `false` (a human + /// is in the loop) even though neither breached a cap — so the feed can say "auto-approved + /// within cap" vs "you approved" HONESTLY, instead of inferring it from the (absent) breach reason. auto_allowed: bool, } @@ -497,8 +518,9 @@ impl Daemon { } // Admitted: an allowance tx ALWAYS raises a human card (it is part of the swap, // and "every swap raises an approval card"). Pass `always_needs_card = true` so it - // is stored `Pending` and NEVER auto-broadcast — even off mainnet, where the Send - // caps path would otherwise auto-allow a value-0 ContractCall hands-free. + // is stored `Pending` and NEVER auto-broadcast — even on an exempt testnet/dev + // chain, where the Send caps path would otherwise auto-allow a value-0 + // ContractCall hands-free. return self.finish_propose(intent, true, origin); } } @@ -537,10 +559,10 @@ impl Daemon { /// /// `always_needs_card`: when `true` (the shaped-approve path) the record is stored `Pending` /// unconditionally — the allowance tx is part of the swap and must raise a human card, never - /// auto-broadcast (off mainnet the Send caps path would otherwise auto-allow a value-0 - /// ContractCall hands-free). The shaped-approve prechecks already are its policy gate, so we - /// skip `evaluate` (allowlist/caps gate value transfers, not the relayer approval) and only - /// honour the STOP brake. When `false` (Send/Shield) the ONE shared `evaluate` (+ mainnet + /// auto-broadcast (on an exempt testnet/dev chain the Send caps path would otherwise auto-allow + /// a value-0 ContractCall hands-free). The shaped-approve prechecks already are its policy gate, + /// so we skip `evaluate` (allowlist/caps gate value transfers, not the relayer approval) and only + /// honour the STOP brake. When `false` (Send/Shield) the ONE shared `evaluate` (+ auto-approval /// guardrail) decides as before. /// /// `origin` is stored on the new record for the inbox display only — it never changes the @@ -590,15 +612,16 @@ impl Daemon { // The shaped-approve path forces a card (see the fn doc): store `Pending` after only the // STOP brake check — never auto-allow an allowance tx. // - // Otherwise the ONE shared decision function decides, with the mainnet guardrail - // (post-`evaluate`, mock/daemon parity carve-out): on chain 1, unless the operator set - // the override (see `Config::mainnet_override` — its env var is documented only in - // THREAT-MODEL.md and must never appear in a reason), EVERY auto-Allow is downgraded to - // `NeedsApproval`. The default policy is `OverCap` with an empty (= any-recipient) - // allowlist, so without this a prompt-injected client could move real funds hands-free - // within the caps. A human resolver (the app's hold-to-confirm) flips it to `Allowed` via - // `Resolve`. Like `locked`/`chain_mismatch`, this is a process-level check the pure policy - // can't express — the parity contract with `MockSigner` covers `evaluate` only. + // Otherwise the ONE shared decision function decides, with the auto-approval guardrail + // (post-`evaluate`, mock/daemon parity carve-out): on every real-value chain (default-deny, + // i.e. any chain NOT on the exempt testnet/dev list), unless the operator set the override + // (see `Config::autonomy_override` — its env var is documented only in THREAT-MODEL.md and + // must never appear in a reason), EVERY auto-Allow is downgraded to `NeedsApproval`. The + // default policy is `OverCap` with an empty (= any-recipient) allowlist, so without this a + // prompt-injected client could move real funds hands-free within the caps. A human resolver + // (the app's hold-to-confirm) flips it to `Allowed` via `Resolve`. Like + // `locked`/`chain_mismatch`, this is a process-level check the pure policy can't express — + // the parity contract with `MockSigner` covers `evaluate` only. let status = if always_needs_card { if self.policy.revoked { return Decision::Deny { @@ -609,9 +632,9 @@ impl Daemon { } else { match evaluate(intent, &self.policy) { deny @ Decision::Deny { .. } => return deny, - Decision::Allow if self.mainnet_guardrail_active() => { + Decision::Allow if self.guardrail_active() => { eprintln!( - "signerd: mainnet guardrail — auto-allow downgraded to NeedsApproval \ + "signerd: auto-approval guardrail — auto-allow downgraded to NeedsApproval \ (approve in the Deckard app)" ); ApprovalStatus::Pending @@ -630,7 +653,7 @@ impl Daemon { } else { breach_for(intent, &self.policy) }; - // Hands-free ONLY when the genuine within-cap auto-allow stored `Allowed` (the mainnet + // Hands-free ONLY when the genuine within-cap auto-allow stored `Allowed` (the auto-approval // guardrail and over-cap both store `Pending`, the shaped-approve card forces `Pending`), // so a later human `Resolve` to `Allowed` never flips this true. let auto_allowed = status == ApprovalStatus::Allowed; @@ -1357,11 +1380,17 @@ impl Daemon { } } - /// Whether the chain-1 guardrail is armed: the daemon signs for mainnet and the - /// operator has NOT set the override. While armed, no auto-Allow exists — every - /// within-policy write still requires a human `Resolve` (the app's hold-to-confirm). - fn mainnet_guardrail_active(&self) -> bool { - self.cfg.chain_id == 1 && !self.cfg.mainnet_override + /// Whether the **auto-approval guardrail** is armed for the daemon's configured chain: + /// while armed, no auto-Allow exists — every within-policy write still requires a human + /// `Resolve` (the app's hold-to-confirm), regardless of proposal origin. + /// + /// DEFAULT-DENY: armed on EVERY chain except an explicit exempt testnet/dev id + /// ([`is_testnet_or_fork`]), unless the operator set the autonomy override + /// (`autonomy_override`, documented only in THREAT-MODEL.md). An unknown chain-id is + /// treated as real-value → armed (fail safe). This closes the misconfigured-real-chain + /// fail-open: configuring a real L2 (Base/OP/…) can never silently turn the brake off. + fn guardrail_active(&self) -> bool { + !self.cfg.autonomy_override && !is_testnet_or_fork(self.cfg.chain_id) } /// Reset the daily spend window when the UTC day ticks over. @@ -1504,6 +1533,32 @@ fn one_line(e: &anyhow::Error) -> String { .collect() } +#[cfg(test)] +mod exempt_list_tests { + //! Regression pin for the auto-approval guardrail's exempt allowlist. The one way this + //! fail-open by construction is a real-value chain id sneaking onto the exempt list, so we + //! pin the negative: mainnet and the major L2 mainnets are NEVER exempt, and the two ids the + //! demo/qa harnesses depend on ARE. A future fat-finger that adds `1` (or `8453`) fails CI here. + use super::is_testnet_or_fork; + + #[test] + fn exempt_list_excludes_real_chains() { + for real in [1u64, 8453, 10, 42161, 137, 999_999] { + assert!( + !is_testnet_or_fork(real), + "chain {real} must NOT be exempt — guardrail must stay armed (fail-safe)" + ); + } + } + + #[test] + fn exempt_list_includes_demo_and_qa_chains() { + // Sepolia (demo fork) + anvil/hardhat default (qa vault + e2e suites) must stay hands-free. + assert!(is_testnet_or_fork(11_155_111)); + assert!(is_testnet_or_fork(31_337)); + } +} + #[cfg(test)] mod stop_selection_tests { //! Regression guard for the STOP-integrity fix: STOP must cancel a SIGNED order on-chain even diff --git a/crates/deckard-signerd/tests/guardrail.rs b/crates/deckard-signerd/tests/guardrail.rs index e41fb6a..4b42369 100644 --- a/crates/deckard-signerd/tests/guardrail.rs +++ b/crates/deckard-signerd/tests/guardrail.rs @@ -1,11 +1,13 @@ -//! The chain-1 (mainnet) guardrail: while the daemon signs for chain 1 and the operator has -//! NOT set the override env var, EVERY auto-Allow is downgraded to `NeedsApproval` — so no -//! prompt-injected client can move mainnet funds hands-free within the caps. On any other -//! chain (and with the override set) behavior is unchanged. +//! The auto-approval guardrail (DEFAULT-DENY): while the daemon signs for any real-value chain +//! — every chain EXCEPT the explicit exempt testnet/dev allowlist (Sepolia 11155111, anvil +//! 31337) — and the operator has NOT set the override env var, EVERY auto-Allow is downgraded to +//! `NeedsApproval`, so no prompt-injected client can move real funds hands-free within the caps. +//! On an exempt testnet/dev chain (and with the override set) within-cap auto-allow is hands-free +//! by design, so the demo can run. An UNKNOWN chain-id is treated as real-value → guarded (fail safe). //! -//! Full matrix: `ApprovalMode × chain × override`, plus the resolve path (the app's -//! hold-to-confirm is the human approval that re-enables execution) and the hygiene rule -//! that the override env var's NAME never appears in any client-visible string. +//! Full matrix: `ApprovalMode × chain × override`, plus the resolve + execute paths (the app's +//! hold-to-confirm is the human approval that re-enables execution) and the hygiene rule that the +//! override env var's NAME never appears in any client-visible string. mod common; @@ -23,6 +25,11 @@ use common::*; const DUMMY_RPC: &str = "http://127.0.0.1:1"; const PER_TX_CAP: u64 = 50_000_000_000_000_000; // 0.05 ETH const SEPOLIA: u64 = 11_155_111; +/// Base mainnet — a real-value, NON-mainnet chain. The whole point of #76: the guardrail must +/// fire here too, not just on chain 1. +const BASE: u64 = 8453; +/// An arbitrary chain id the daemon has never heard of — must be treated as real-value (fail safe). +const UNKNOWN_CHAIN: u64 = 999_999; /// The name of the override env var, assembled so this test file can assert it never leaks /// without itself being a grep-able instruction for an agent reading test output. @@ -88,41 +95,61 @@ fn is_needs_approval(d: &Decision) -> bool { matches!(d, Decision::NeedsApproval { .. }) } -#[tokio::test] -async fn matrix_chain1_without_override_kills_every_auto_allow() { - // chain 1, override OFF: no decision may be a bare Allow, in ANY approval mode. +/// The core safety property, parameterized by chain: on a real-value / unknown `chain` with the +/// override OFF, NO decision may be a bare `Allow` in ANY approval mode — within-cap downgrades to +/// `NeedsApproval`; over-cap stays a `Deny` under `Never` (the guardrail only downgrades Allows, it +/// never upgrades a Deny). +async fn assert_guardrail_kills_auto_allow(chain: u64) { for mode in [ ApprovalMode::Never, ApprovalMode::OverCap, ApprovalMode::Always, ] { let label = format!("{mode:?}"); - let (within, over) = classify(mode, 1, false).await; + let (within, over) = classify(mode, chain, false).await; assert!( is_needs_approval(&within), - "{label}: within-cap on chain 1 must be NeedsApproval, got {within:?}" + "{label}: within-cap on chain {chain} must be NeedsApproval, got {within:?}" ); match label.as_str() { - // Never raises no card: over-cap stays a deny (the guardrail only downgrades - // Allows, it never upgrades a Deny). "Never" => assert_eq!( over, Decision::Deny { reason: "over_cap".into() }, - "Never/over-cap must stay Deny" + "Never/over-cap must stay Deny on chain {chain}" ), _ => assert!( is_needs_approval(&over), - "{label}: over-cap on chain 1 must be NeedsApproval, got {over:?}" + "{label}: over-cap on chain {chain} must be NeedsApproval, got {over:?}" ), } } } #[tokio::test] -async fn matrix_other_chains_unchanged() { - // Sepolia (the demo chain): classification is exactly the pre-guardrail behavior. +async fn matrix_chain1_without_override_kills_every_auto_allow() { + // Mainnet (chain 1): the original guardrail behavior — unchanged. + assert_guardrail_kills_auto_allow(1).await; +} + +#[tokio::test] +async fn matrix_real_nonmainnet_chain_kills_auto_allow() { + // #76 acceptance pin: a real-value NON-mainnet chain (Base 8453) must be guarded exactly + // like mainnet — within-cap auto-Allow → NeedsApproval, no hands-free spend. + assert_guardrail_kills_auto_allow(BASE).await; +} + +#[tokio::test] +async fn matrix_unknown_chain_id_fails_safe() { + // An id the daemon has never heard of is treated as real-value → guarded (fail safe). + assert_guardrail_kills_auto_allow(UNKNOWN_CHAIN).await; +} + +#[tokio::test] +async fn matrix_exempt_testnet_chain_stays_hands_free() { + // Sepolia (the demo chain, an EXEMPT testnet id): within-cap auto-allow is hands-free by + // design — classification is exactly the pre-guardrail behavior, so the demo still runs. let (within, over) = classify(ApprovalMode::OverCap, SEPOLIA, false).await; assert_eq!(within, Decision::Allow); assert!(is_needs_approval(&over)); @@ -141,14 +168,58 @@ async fn matrix_other_chains_unchanged() { } #[tokio::test] -async fn matrix_override_restores_chain1_auto_allow() { - // chain 1 + override: behaves like any other chain (the operator took the wheel). - let (within, over) = classify(ApprovalMode::OverCap, 1, true).await; - assert_eq!(within, Decision::Allow); - assert!(is_needs_approval(&over)); +async fn matrix_override_restores_auto_allow() { + // override set: the operator took the wheel, so a guarded chain behaves like an exempt one + // (within-cap auto-allows again). Pin it on BOTH mainnet (1) and a real L2 (Base 8453) — the + // override now disarms the guardrail on ANY real-value chain, not just mainnet. + for chain in [1u64, BASE] { + let (within, over) = classify(ApprovalMode::OverCap, chain, true).await; + assert_eq!( + within, + Decision::Allow, + "override should auto-allow on chain {chain}" + ); + assert!( + is_needs_approval(&over), + "over-cap still needs approval on chain {chain}" + ); - let (within, _) = classify(ApprovalMode::Never, 1, true).await; - assert_eq!(within, Decision::Allow); + let (within, _) = classify(ApprovalMode::Never, chain, true).await; + assert_eq!( + within, + Decision::Allow, + "override+Never auto-allows on chain {chain}" + ); + } +} + +#[tokio::test] +async fn guardrail_blocks_execute_on_real_chain() { + // #76 exec-level pin: on a real-value NON-mainnet chain (Base 8453), a within-cap agent + // propose downgrades to NeedsApproval AND `execute` is refused at the approval gate + // (`not_approved`) BEFORE any human resolve — the brake actually blocks the broadcast, not + // just the classification label. + let dir = TempDir::new("guardrail-base-exec"); + let (_wallet, to) = seal_account0(dir.path()); + write_policy(dir.path(), ApprovalMode::OverCap); + let var = override_var(); + let d = spawn_daemon(dir.path(), DUMMY_RPC, BASE, &[(var.as_str(), "0")]); + let client = SignerClient::new(d.socket_path.clone()); + client.unlock(PASS).await.unwrap(); + + let intent = send(BASE, to, 1_000); // within cap — would auto-allow but for the guardrail + let id = match client + .propose(&intent, ProposalOrigin::Agent) + .await + .unwrap() + { + Decision::NeedsApproval { request_id } => request_id, + other => panic!("within-cap on Base must downgrade to NeedsApproval, got {other:?}"), + }; + match client.execute(id).await.unwrap() { + ExecuteResult::Denied { reason } => assert_eq!(reason, "not_approved"), + other => panic!("execute on Base must be refused at the approval gate, got {other:?}"), + } } #[tokio::test] diff --git a/crates/deckard-signerd/tests/parity.rs b/crates/deckard-signerd/tests/parity.rs index 2c02d7b..c38e946 100644 --- a/crates/deckard-signerd/tests/parity.rs +++ b/crates/deckard-signerd/tests/parity.rs @@ -8,11 +8,11 @@ //! don't fire and both sides reduce to `evaluate` — exactly the apples-to-apples parity //! contract. //! -//! Explicit carve-out: the daemon's chain-1 guardrail (auto-Allow → `NeedsApproval` on -//! mainnet, `tests/guardrail.rs`) is a PROCESS-level check on daemon state (the configured -//! chain id and the operator override), exactly like `locked` — it lives outside `evaluate` -//! by design, so the mock is not expected to mirror it. These vectors use chain 31337, -//! where it never fires. +//! Explicit carve-out: the daemon's auto-approval guardrail (auto-Allow → `NeedsApproval` on any +//! real-value chain — every chain except the exempt testnet/dev allowlist — `tests/guardrail.rs`) +//! is a PROCESS-level check on daemon state (the configured chain id and the operator override), +//! exactly like `locked` — it lives outside `evaluate` by design, so the mock is not expected to +//! mirror it. These vectors use chain 31337 (an exempt id), where it never fires. use alloy_primitives::{Address, Bytes, B256, U256}; use deckard_contract::{ diff --git a/crates/deckard-wallet-client/src/failure.rs b/crates/deckard-wallet-client/src/failure.rs index 0089980..9460af7 100644 --- a/crates/deckard-wallet-client/src/failure.rs +++ b/crates/deckard-wallet-client/src/failure.rs @@ -155,7 +155,7 @@ pub fn from_deny_reason(reason: &str, config_dir: Option<&Path>) -> Failure { ), deny_reasons::NOT_APPROVED => Failure::new( "this request needs a human approval before it can execute", - "the policy (or the mainnet guardrail) classified it NeedsApproval and no \ + "the policy (or the auto-approval guardrail) classified it NeedsApproval and no \ human has approved it yet", "a human must approve this in the Deckard app's Approvals queue (⌘⇧A); then \ retry deckard_execute, or lower the amount under the policy per-tx cap or \ diff --git a/docs/agent-authorization-map.md b/docs/agent-authorization-map.md index bf05de5..2184fee 100644 --- a/docs/agent-authorization-map.md +++ b/docs/agent-authorization-map.md @@ -42,7 +42,7 @@ learn how the agent plus approval loop actually feels. - Activity feed + see-and-stop view: **#60** - Agent loop (poll balance, propose shield): **#61** -It uses only what exists: the policy gate (`auto_shield_min`, `OverCap`), the mainnet guardrail, STOP. No +It uses only what exists: the policy gate (`auto_shield_min`, `OverCap`), the auto-approval guardrail, STOP. No new authority. ## What is deferred (and why) diff --git a/docs/build/32-agent-loop-prompt.md b/docs/build/32-agent-loop-prompt.md index db323be..4c51224 100644 --- a/docs/build/32-agent-loop-prompt.md +++ b/docs/build/32-agent-loop-prompt.md @@ -54,7 +54,7 @@ You run this with the six tools from the quickstart — read them first if you h > record as-is (a pending card stays one card, an already-broadcast shield answers > `already_executed`) — it can never double-shield. > - `decision: "allow"` → call `deckard_execute` with the `request_id`. Report the `tx_hash`. -> - `decision: "needs_approval"` → the amount is over a cap (or you're on mainnet). **Do not +> - `decision: "needs_approval"` → the amount is over a cap (or you're on a real-value chain). **Do not > re-propose and do not lower the amount on your own.** Remember this `request_id` **and the > balance you saw when you proposed it**. Tell the human it is waiting in the Deckard app's > Activity feed — the **"Needs you"** band (⌘K → Activity, or ⌘⇧A) — for them to approve. Keep @@ -187,7 +187,7 @@ unlocked** in the app, `just demo-fund` funded. Then: - The thinking agent stays **key-less**. You propose; the daemon signs. You never hold a key. - v1 limits are **software-enforced** by the policy gate + human approval. On a fork/testnet a - within-cap shield auto-allows hands-free so the demo can run; on mainnet the guardrail forces a + within-cap shield auto-allows hands-free so the demo can run; on every real-value chain the guardrail forces a human approval for *every* write. The limits are real but enforced by software, not by the chain — never tell the user a cap "cannot be exceeded." - Never ask for a seed phrase, private key, or passphrase — no tool accepts one.