diff --git a/crates/deckard-contract/src/capabilities.rs b/crates/deckard-contract/src/capabilities.rs new file mode 100644 index 0000000..09b40e5 --- /dev/null +++ b/crates/deckard-contract/src/capabilities.rs @@ -0,0 +1,150 @@ +//! # Wire capability registry — the single source of truth (issue #31) +//! +//! What *kinds* of request does this build of the Deckard socket API understand? This module +//! answers that, and is the one place the answer lives. Every implementation builds its `Hello` +//! answer from [`hello_info`] — the daemon and the MCP acceptance mock over the wire +//! ([`SignerRequest::Hello`] → [`SignerResponse::Hello`]), and the contract +//! [`MockSigner`](crate::MockSigner) via its `hello()` — so a capability name, or the wire +//! `spec_version`, can never drift between implementations. That parity is the whole point of +//! capability discovery: an agent asks *does this build understand request kind X?* and gets the +//! same answer no matter which implementation is behind the socket. +//! +//! [`SignerRequest::Hello`]: crate::rpc::SignerRequest::Hello +//! [`SignerResponse::Hello`]: crate::rpc::SignerResponse::Hello +//! [`deckard-signerd`]: https://docs.rs/deckard-signerd +//! +//! ## The five evolution rules (normative — full text in `docs/build/40-wire-evolution.md`) +//! +//! 1. **Capability discovery, not version negotiation.** `Hello` returns [`HelloInfo`]; an old +//! peer that predates a variant answers with the existing decode error — that IS the compat +//! valve. +//! 2. **Date-version, bump only on a breaking change.** [`SPEC_VERSION`] is `YYYY-MM-DD`. +//! Additions ship under a new capability NAME (below), *never* a version bump. +//! 3. **Protobuf discipline on the CBOR.** A capability name is forever: never reused, renamed, +//! or retyped. Decoders ignore unknown struct keys and reject unknown enum variants. +//! 4. **Two distinguishable failures.** "unsupported message/capability" = a frame-decode error; +//! "supported but refused" = a [`Decision::Deny`](crate::Decision) from the frozen +//! [`deny_reasons`](crate::deny_reasons) vocabulary. +//! 5. **In-repo home.** This module + the build doc are that home. +//! +//! ## Adding a capability (the extension point #198 / #204 use) +//! +//! Registering a new request *kind* or origin variant is one edit here plus one row in the +//! `docs/build/40-wire-evolution.md` table — mirroring the deny-vocabulary discipline in +//! [`deny_reasons`](crate::deny_reasons): +//! +//! 1. add a `pub const CAP_… : &str = "…";` with a doc comment (what it means, since when), +//! 2. push it into [`capabilities`] in registry order (append; never reorder or reuse a name), +//! 3. add its row to the build-doc registry table. +//! +//! `spec_version` does **not** change (rule #2). `baseline_capabilities_present_and_wellformed` +//! guards the shape. + +use crate::rpc::HelloInfo; + +/// The wire spec's date-version, `YYYY-MM-DD` (rule #2). This is the date the capability-discovery +/// mechanism + these evolution rules were introduced — the wire's baseline. It is bumped **only** +/// on a genuinely breaking change (a removed/renamed/retyped map key, or changed semantics of an +/// existing frame). Additive capabilities never touch it. +pub const SPEC_VERSION: &str = "2026-07-07"; + +// ───────────────────────── Capability names ───────────────────────── +// A name is forever (rule #3): never reuse, rename, or retype one. A retired capability's name is +// left retired, never recycled. Keep these in registry order and mirror the build-doc table. + +/// The shipped socket API — the frozen `deckard-contract` request/response set (unlock, propose, +/// execute, status, revoke_all, policy_get, address, balance, pending/activity, …). Since the +/// contract freeze; defining doc `docs/build/30-mcp-shape.md`. +pub const CAP_CORE: &str = "core"; + +/// The `mcp.v0.1` agent-tool profile — the key-less MCP sidecar's tool surface over `core`. +/// Defining doc `docs/build/31-agent-quickstart.md` (the tool list is drift-guarded there). +pub const CAP_MCP_V0_1: &str = "mcp.v0.1"; + +// ───────────────────────── Implementation names ───────────────────────── +// `impl_name` is informational only (rule #1: no code path branches on it). Each implementation +// reports its own honest name; the capabilities + spec_version are what must match across them. + +/// `impl_name` the production daemon reports. +pub const IMPL_SIGNERD: &str = "deckard-signerd"; +/// `impl_name` the in-memory mocks report (the contract `MockSigner`, the MCP acceptance mock). +pub const IMPL_MOCK: &str = "deckard-mock"; + +/// The capability names this build understands, in registry order. Building the `Hello` reply +/// from this one function is what keeps every implementation's answer identical. +#[must_use] +pub fn capabilities() -> Vec { + vec![CAP_CORE.to_string(), CAP_MCP_V0_1.to_string()] +} + +/// Build the [`HelloInfo`] this build answers `Hello` with, tagged with the caller's `impl_name`. +/// +/// The daemon calls this with [`IMPL_SIGNERD`]; the mocks call it with [`IMPL_MOCK`]. Only +/// `impl_name` differs between them — `spec_version` and `capabilities` are byte-identical, which +/// is the parity contract capability discovery exists to guarantee. +#[must_use] +pub fn hello_info(impl_name: &str) -> HelloInfo { + HelloInfo { + spec_version: SPEC_VERSION.to_string(), + capabilities: capabilities(), + impl_name: impl_name.to_string(), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// `SPEC_VERSION` is a real `YYYY-MM-DD` date-string (rule #2). Validated without a regex dep: + /// three `-`-separated groups of 4/2/2 ASCII digits. + fn is_iso_date(s: &str) -> bool { + let parts: Vec<&str> = s.split('-').collect(); + matches!(parts.as_slice(), [y, m, d] + if y.len() == 4 && m.len() == 2 && d.len() == 2 + && [y, m, d].iter().all(|p| p.bytes().all(|b| b.is_ascii_digit()))) + } + + #[test] + fn spec_version_is_a_date() { + assert!( + is_iso_date(SPEC_VERSION), + "SPEC_VERSION must be YYYY-MM-DD, got {SPEC_VERSION:?}" + ); + } + + #[test] + fn baseline_capabilities_present_and_wellformed() { + let caps = capabilities(); + // The baseline registry the issue pins: core + the mcp.v0.1 profile. + assert!(caps.iter().any(|c| c == CAP_CORE), "core missing"); + assert!(caps.iter().any(|c| c == CAP_MCP_V0_1), "mcp.v0.1 missing"); + + // Rule #3 hygiene: names are lowercase, whitespace-free, non-empty, and unique. + for c in &caps { + assert!(!c.is_empty(), "empty capability name"); + assert!( + c.chars() + .all(|ch| ch.is_ascii_lowercase() || ch.is_ascii_digit() || ch == '.'), + "capability {c:?} must be lowercase [a-z0-9.]" + ); + } + let mut sorted = caps.clone(); + sorted.sort(); + sorted.dedup(); + assert_eq!(sorted.len(), caps.len(), "duplicate capability name"); + } + + #[test] + fn hello_info_is_single_sourced() { + // Whoever calls it, spec_version + capabilities are identical; only impl_name varies. + let daemon = hello_info(IMPL_SIGNERD); + let mock = hello_info(IMPL_MOCK); + assert_eq!(daemon.spec_version, mock.spec_version); + assert_eq!(daemon.capabilities, mock.capabilities); + assert_eq!(daemon.spec_version, SPEC_VERSION); + assert_eq!(daemon.capabilities, capabilities()); + assert_eq!(daemon.impl_name, IMPL_SIGNERD); + assert_eq!(mock.impl_name, IMPL_MOCK); + assert_ne!(daemon.impl_name, mock.impl_name); + } +} diff --git a/crates/deckard-contract/src/lib.rs b/crates/deckard-contract/src/lib.rs index 726f62b..e27f056 100644 --- a/crates/deckard-contract/src/lib.rs +++ b/crates/deckard-contract/src/lib.rs @@ -22,6 +22,7 @@ //! way: a bare number literal above `u64::MAX` — routine for wei (> ~18.4 ETH) — is parsed //! as a float and rejected on decode. CBOR has no such limit. +pub mod capabilities; pub mod clear_signing; pub mod decision; pub mod deny_reasons; @@ -53,7 +54,7 @@ pub use policy::{ pub use read_status::ReadStatus; pub use rpc::{ ActivityLifecycle, ActivityRecord, ApprovalRisk, ApprovalStatus, BalanceReport, BreachedLimit, - ExecuteResult, PendingPayloadView, PendingRecord, ProposalOrigin, RailgunViewGrant, + ExecuteResult, HelloInfo, PendingPayloadView, PendingRecord, ProposalOrigin, RailgunViewGrant, SignMessageResult, SignOrderResult, SignerRequest, SignerResponse, StatusView, UnlockOutcome, }; pub use shield_status::ShieldStatus; @@ -414,6 +415,9 @@ mod roundtrip_tests { }); roundtrip(&SignerRequest::PendingList); roundtrip(&SignerRequest::ActivityFeed); + // The additive capability-discovery request (#31): a unit variant, so it frames as a bare + // CBOR/JSON tag exactly like the other unit requests above. + roundtrip(&SignerRequest::Hello); } #[test] @@ -474,6 +478,11 @@ mod roundtrip_tests { lifecycle: ActivityLifecycle::Executed, })); roundtrip(&SignerResponse::Ack); + // The additive capability-discovery reply (#31): built from the single-source registry so + // the wire shape is exactly what every implementation returns. + roundtrip(&SignerResponse::Hello(capabilities::hello_info( + capabilities::IMPL_SIGNERD, + ))); roundtrip(&SignerResponse::Policy(sample_policy())); roundtrip(&SignerResponse::Address(Address::repeat_byte(0x11))); roundtrip(&SignerResponse::Balance(BalanceReport { @@ -755,3 +764,189 @@ mod roundtrip_tests { assert!(failed.is_terminal()); } } + +/// The five wire-evolution rules (#31), proven at the contract layer. These are the definitive +/// proofs the rules hold; `deckard-signerd/tests/hello.rs` adds the daemon-socket half (Hello +/// answered while `Locked`, and the daemon surviving a bad frame with nothing signed). +/// +/// - **E1** — the `Hello` reply shape. +/// - **E2** — existing frames encode byte-identically after adding `Hello` (freeze holds). +/// - **E3** — an unknown enum variant is rejected LOUDLY (that rejection is the compat valve). +/// - **E4** — an unknown struct key is ignored (the additive counterpart to E3). +#[cfg(test)] +mod wire_evolution { + use super::*; + use serde::Serialize; + + /// E1: the `Hello` answer — `spec_version` is a real `YYYY-MM-DD`, and `capabilities` is a + /// superset of the baseline `{core, mcp.v0.1}`. Feature detection needs nothing else; no code + /// branches on `impl_name`. Both the daemon and every mock build this from the same registry, + /// so proving the builder's shape here proves the shape of every implementation's reply. + #[test] + fn e1_hello_reply_shape() { + let info = capabilities::hello_info(capabilities::IMPL_SIGNERD); + + // spec_version matches ^\d{4}-\d{2}-\d{2}$ (validated without a regex dependency). + let parts: Vec<&str> = info.spec_version.split('-').collect(); + assert_eq!(parts.len(), 3, "spec_version must be YYYY-MM-DD"); + assert_eq!(parts[0].len(), 4, "year must be 4 digits"); + assert_eq!(parts[1].len(), 2, "month must be 2 digits"); + assert_eq!(parts[2].len(), 2, "day must be 2 digits"); + assert!( + parts.iter().all(|p| p.bytes().all(|b| b.is_ascii_digit())), + "spec_version must be all digits + dashes: {:?}", + info.spec_version + ); + + // capabilities ⊇ {core, mcp.v0.1}. + assert!( + info.capabilities + .iter() + .any(|c| c == capabilities::CAP_CORE), + "capabilities must include `core`" + ); + assert!( + info.capabilities + .iter() + .any(|c| c == capabilities::CAP_MCP_V0_1), + "capabilities must include `mcp.v0.1`" + ); + + assert_eq!(info.impl_name, capabilities::IMPL_SIGNERD); + } + + /// E2: adding the `Hello` variant did not perturb the encoding of any existing frame — the + /// freeze holds, so an old-peer replay of a pre-`Hello` frame is byte-identical. + /// + /// The wire enums are externally tagged, so every variant is keyed by NAME; a sibling variant + /// appended at the end cannot shift an existing variant's bytes. The real freeze proof is the + /// hand-verifiable golden bytes below — for the unit requests (a bare CBOR text string of the + /// variant name) and for a data-carrying frame (`Balance`, a `{tag: {field}}` map). The + /// `signer_request_roundtrip` / `signer_response_roundtrip` fixtures complement this by + /// asserting each value re-encodes deterministically; they were *extended* with the new `Hello` + /// value, and every pre-existing assertion in them is unchanged. + #[test] + fn e2_existing_frames_are_byte_identical() { + // A unit variant frames as one CBOR text string: 0x60|len, then the ASCII name. + fn cbor_of(req: &SignerRequest) -> Vec { + let mut b = Vec::new(); + ciborium::into_writer(req, &mut b).expect("cbor encode"); + b + } + + // Golden bytes for three pre-existing unit requests, computed by hand from the CBOR spec + // (major type 3 = text string). If `Hello` (or anything) ever shifts these, the freeze is + // broken and this fails. + assert_eq!( + cbor_of(&SignerRequest::PolicyGet), + b"\x69PolicyGet", + "PolicyGet frame changed — freeze broken" + ); + assert_eq!( + cbor_of(&SignerRequest::RevokeAll), + b"\x69RevokeAll", + "RevokeAll frame changed — freeze broken" + ); + assert_eq!( + cbor_of(&SignerRequest::ActivityFeed), + b"\x6CActivityFeed", + "ActivityFeed frame changed — freeze broken" + ); + + // The golden bytes are the REAL encoding (decode them back), not a tautology, and the new + // `Hello` frame slots in as just another unit-tag text string next to them. + let policy_get: SignerRequest = + ciborium::from_reader(&b"\x69PolicyGet"[..]).expect("golden decodes"); + assert_eq!(policy_get, SignerRequest::PolicyGet); + assert_eq!(cbor_of(&SignerRequest::Hello), b"\x65Hello"); + + // A data-carrying frame is pinned too: `Balance { shielded: true }` is the externally-tagged + // map `{"Balance": {"shielded": true}}` → CBOR `A1 67 "Balance" A1 68 "shielded" F5` (F5 = + // true). Golden-pinned so a field/variant reshuffle can't silently change it, and it never + // leaks the new variant's tag into its own bytes. + let balance = cbor_of(&SignerRequest::Balance { shielded: true }); + assert_eq!( + balance, b"\xA1\x67Balance\xA1\x68shielded\xF5", + "Balance frame changed — freeze broken" + ); + assert!( + !balance.windows(5).any(|w| w == b"Hello"), + "the Hello tag leaked into an unrelated frame" + ); + } + + /// E3: the backward-compat valve. A hypothetical FUTURE wire is a superset of today's + /// requests; an old decoder (today's [`SignerRequest`]) must reject a variant it has never + /// heard of LOUDLY — a decode `Err`, never a silent misparse and never a panic. That loud + /// rejection is exactly how an old daemon answers a new request kind (rules #1/#3). Because + /// the decode fails before any value materialises, nothing downstream can act on it — nothing + /// is signed (the daemon-socket half of that is pinned in `deckard-signerd/tests/hello.rs`). + #[test] + fn e3_unknown_variant_is_rejected_loudly() { + // A future superset: one new unit kind, one new data-carrying kind. + #[derive(Serialize)] + enum FutureRequest { + QuantumSend, + Teleport { to: u64 }, + } + + // CBOR (the daemon UDS wire): both an unknown unit tag and an unknown struct tag error. + let mut unit = Vec::new(); + ciborium::into_writer(&FutureRequest::QuantumSend, &mut unit).unwrap(); + assert!( + ciborium::from_reader::(&unit[..]).is_err(), + "an unknown unit variant must be rejected, not silently accepted" + ); + + let mut data = Vec::new(); + ciborium::into_writer(&FutureRequest::Teleport { to: 7 }, &mut data).unwrap(); + assert!( + ciborium::from_reader::(&data[..]).is_err(), + "an unknown data variant must be rejected" + ); + + // JSON (the MCP wire) must reject it too. + let json = serde_json::to_vec(&FutureRequest::QuantumSend).unwrap(); + assert!( + serde_json::from_slice::(&json).is_err(), + "an unknown variant must be rejected on the JSON surface too" + ); + } + + /// E4: the additive counterpart to E3. A future producer adds a field to a wire struct; an + /// old decoder (today's [`HelloInfo`]) must IGNORE the unknown key and still decode — the wire + /// structs carry no `deny_unknown_fields`, on purpose (rule #3). This is what lets a newer + /// daemon grow a `HelloInfo` field without breaking older clients. + #[test] + fn e4_unknown_struct_key_is_ignored() { + // A HelloInfo a newer daemon emits, with a field this build has never heard of. + #[derive(Serialize)] + struct HelloInfoPlus { + spec_version: String, + capabilities: Vec, + impl_name: String, + future_field: u64, + } + let plus = HelloInfoPlus { + spec_version: "2099-01-01".to_string(), + capabilities: vec![capabilities::CAP_CORE.to_string()], + impl_name: "deckard-future".to_string(), + future_field: 42, + }; + + // CBOR: the extra key is skipped, the known fields decode. + let mut cbor = Vec::new(); + ciborium::into_writer(&plus, &mut cbor).unwrap(); + let back: HelloInfo = ciborium::from_reader(&cbor[..]) + .expect("an extra CBOR key must be ignored, not rejected"); + assert_eq!(back.spec_version, "2099-01-01"); + assert_eq!(back.impl_name, "deckard-future"); + assert_eq!(back.capabilities, vec![capabilities::CAP_CORE.to_string()]); + + // JSON: same rule on the MCP surface. + let json = serde_json::to_vec(&plus).unwrap(); + let back2: HelloInfo = + serde_json::from_slice(&json).expect("an extra JSON key must be ignored, not rejected"); + assert_eq!(back2.impl_name, "deckard-future"); + } +} diff --git a/crates/deckard-contract/src/mock.rs b/crates/deckard-contract/src/mock.rs index 9674ebe..90a1b00 100644 --- a/crates/deckard-contract/src/mock.rs +++ b/crates/deckard-contract/src/mock.rs @@ -106,6 +106,16 @@ impl MockSigner { Bytes::from(vec![0xCD_u8; 65]) } + /// Answer capability discovery (#31) exactly as the daemon does. Built from the single-source + /// [`crate::capabilities::hello_info`], tagged with the mock's `impl_name` — so its + /// `spec_version` + `capabilities` are byte-identical to the daemon's `Hello` reply (parity by + /// construction) and only `impl_name` differs. State-independent, like the daemon's arm: the + /// answer is the same whether the mock is `revoked` (its `Locked` stand-in) or not. + #[must_use] + pub fn hello(&self) -> crate::rpc::HelloInfo { + crate::capabilities::hello_info(crate::capabilities::IMPL_MOCK) + } + /// Set the injected unix-secs clock used by `propose_order` (setup helper for the /// `valid_to` horizon check). Mirrors the daemon's `now_unix()` being made injectable. pub fn set_now(&self, now: u64) { @@ -487,6 +497,23 @@ mod tests { use crate::policy::{Allowlist, ApprovalMode, Effect, Rule, POLICY_VERSION}; use alloy_primitives::Bytes; + /// #31 parity: the mock answers `Hello` from the SAME single-source registry the daemon uses, + /// so its capabilities + spec_version are byte-identical to the daemon's reply — only + /// `impl_name` differs — and the answer is state-independent (revoking, the mock's `Locked` + /// stand-in, does not change it). + #[test] + fn hello_matches_the_single_source_registry() { + let mock = MockSigner::new(policy(50, 1000, 0, ApprovalMode::OverCap)); + let info = mock.hello(); + let daemon = crate::capabilities::hello_info(crate::capabilities::IMPL_SIGNERD); + assert_eq!(info.capabilities, daemon.capabilities); + assert_eq!(info.spec_version, daemon.spec_version); + assert_eq!(info.impl_name, crate::capabilities::IMPL_MOCK); + assert_ne!(info.impl_name, daemon.impl_name); + mock.revoke_all(); + assert_eq!(mock.hello(), info, "Hello must not depend on mock state"); + } + // --- builders ------------------------------------------------------------------- /// A v1 policy whose `Send`/`Shield`/`Swap` rules all share the approval `mode`, with the diff --git a/crates/deckard-contract/src/rpc.rs b/crates/deckard-contract/src/rpc.rs index cda5e07..b93a282 100644 --- a/crates/deckard-contract/src/rpc.rs +++ b/crates/deckard-contract/src/rpc.rs @@ -94,6 +94,16 @@ pub enum SignerRequest { /// so the GUI can show what the agent *did*, not only what is pending. The handler expires /// stale rows first, so the feed never shows a lapsed card as still pending. ActivityFeed, + /// Capability discovery (#31) → [`SignerResponse::Hello`]\([`HelloInfo`]\). The daemon + /// answers with the wire `spec_version`, the capability NAMES it understands, and a free-form + /// `impl_name`. Answered in **every** state, including `Locked`: it reveals no vault state, no + /// policy contents, and no key material. + /// + /// This is the newest variant on purpose. An **old** daemon that predates it fails to decode + /// the unknown enum variant and returns the existing frame-decode error — that loud rejection + /// *is* the backward-compatibility valve (evolution rules #1/#3), and it already ships. No + /// existing frame changes; the round-trip fixtures prove the freeze holds. + Hello, } /// `deckard-signerd` → `deckard-mcp`. One variant per request shape. @@ -121,6 +131,35 @@ pub enum SignerResponse { Pending(Vec), /// Reply to `ActivityFeed`: the activity ledger, newest-first. Activity(Vec), + /// Reply to [`SignerRequest::Hello`]: the capability-discovery [`HelloInfo`]. + Hello(HelloInfo), +} + +/// The answer to [`SignerRequest::Hello`] — wire-contract capability discovery (#31). +/// +/// Reveals only three things, none of them sensitive: the wire's date-`spec_version`, the +/// capability NAMES this build understands, and a free-form `impl_name`. It carries **no** vault +/// state, **no** policy contents, and **no** key material, which is why the daemon answers it in +/// every state including `Locked`. +/// +/// The single source of truth for the contents is [`crate::capabilities`] — both the real daemon +/// and every mock build this via [`crate::capabilities::hello_info`], so a capability name can +/// never drift between implementations. Adding a capability is one edit there (never a +/// `spec_version` bump); see the evolution rules in `docs/build/40-wire-evolution.md`. +/// +/// **Feature detection reads `capabilities` ONLY.** `impl_name` is informational; per evolution +/// rule #1 no code path may branch on it. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct HelloInfo { + /// Wire spec date-version, `YYYY-MM-DD`. Bumped **only** on a breaking change (rule #2): + /// additive capabilities ship under a new name here, never a version bump. + pub spec_version: String, + /// The capability names this build understands (e.g. `core`, `mcp.v0.1`). A new request + /// *kind* ships as a new NAME in this list; names are never reused, renamed, or retyped. + pub capabilities: Vec, + /// Which implementation answered (`deckard-signerd`, a mock, …). Informational ONLY — never + /// a feature-detection signal, never branched on. + pub impl_name: String, } /// A read-only Railgun grant: the 0zk `address` + the `viewing_key` (hex). NOT the spending diff --git a/crates/deckard-mcp/tests/common/mod.rs b/crates/deckard-mcp/tests/common/mod.rs index 0ab72d1..a5e8abe 100644 --- a/crates/deckard-mcp/tests/common/mod.rs +++ b/crates/deckard-mcp/tests/common/mod.rs @@ -292,6 +292,15 @@ impl MockState { // The MCP surface never reads the activity feed (it's a GUI-only surface); the mock // just answers an empty ledger so the request shape stays covered. SignerRequest::ActivityFeed => SignerResponse::Activity(Vec::new()), + // Capability discovery (#31): built from the SAME single-source registry the real + // daemon uses, so the mock advertises identical capabilities + spec_version (only + // impl_name differs). This is the parity that lets the MCP harness stand in for the + // daemon on a `Hello` probe. + SignerRequest::Hello => { + SignerResponse::Hello(deckard_contract::capabilities::hello_info( + deckard_contract::capabilities::IMPL_MOCK, + )) + } } } diff --git a/crates/deckard-signerd/src/daemon.rs b/crates/deckard-signerd/src/daemon.rs index 8ef5700..bf44c2a 100644 --- a/crates/deckard-signerd/src/daemon.rs +++ b/crates/deckard-signerd/src/daemon.rs @@ -377,6 +377,16 @@ impl Daemon { } SignerRequest::PendingList => SignerResponse::Pending(self.pending_list()), SignerRequest::ActivityFeed => SignerResponse::Activity(self.activity_feed()), + // Capability discovery (#31). Answered in EVERY state — note this arm reads no + // `self.state`, so it replies identically whether `Locked` or `Unlocked` — and reveals + // ONLY the wire spec_version + capability names + impl_name (no vault state, no policy + // contents, no key material). Built from the single-source registry so the daemon's + // answer can never drift from the mock's (parity by construction). + SignerRequest::Hello => { + SignerResponse::Hello(deckard_contract::capabilities::hello_info( + deckard_contract::capabilities::IMPL_SIGNERD, + )) + } } } diff --git a/crates/deckard-signerd/tests/hello.rs b/crates/deckard-signerd/tests/hello.rs new file mode 100644 index 0000000..f400609 --- /dev/null +++ b/crates/deckard-signerd/tests/hello.rs @@ -0,0 +1,135 @@ +//! The daemon-socket half of the wire-evolution rules (#31). The contract-layer proofs live in +//! `deckard-contract` (`wire_evolution`: E1 shape, E2 byte-identity, E3 unknown-variant rejection, +//! E4 unknown-key tolerance). Here we prove the two properties that only a *running daemon* can +//! show: +//! +//! 1. `Hello` is answered in **every** state, including `Locked` — capability discovery reveals +//! the capability names but no vault state, and it does so from the single-source registry so +//! the daemon's answer is byte-identical to what the mocks return (parity by construction). +//! 2. An unknown request *kind* on the wire is the backward-compat valve in action: the daemon +//! rejects the frame LOUDLY (`malformed_request`), never panics, signs NOTHING, and keeps +//! serving — a new peer talking to an old daemon degrades safely. + +mod common; + +use std::path::Path; + +use deckard_contract::{ + capabilities, deny_reasons, Decision, HelloInfo, SignerRequest, SignerResponse, UnlockOutcome, +}; +use deckard_signerd::{frame, SignerClient}; + +use common::*; + +const CHAIN: u64 = 31337; +/// Hello + the bad-frame reject never touch a chain, so a dead RPC is fine. +const DUMMY_RPC: &str = "http://127.0.0.1:1"; + +/// `spec_version` is a real `YYYY-MM-DD` (validated without a regex dep). +fn is_iso_date(s: &str) -> bool { + let parts: Vec<&str> = s.split('-').collect(); + matches!(parts.as_slice(), [y, m, d] + if y.len() == 4 && m.len() == 2 && d.len() == 2 + && [y, m, d].iter().all(|p| p.bytes().all(|b| b.is_ascii_digit()))) +} + +/// Round-trip a `Hello` over the public socket and unwrap the [`HelloInfo`]. +async fn hello(client: &SignerClient) -> HelloInfo { + match client.request(&SignerRequest::Hello).await.expect("hello") { + SignerResponse::Hello(info) => info, + other => panic!("expected a Hello reply, got {other:?}"), + } +} + +/// Send a raw frame carrying a request *kind* the daemon has never heard of, and return the +/// daemon's reply. Simulates a newer peer proposing a future variant to today's daemon. +async fn send_unknown_kind(socket: &Path) -> SignerResponse { + use tokio::net::UnixStream; + + // A future wire superset. Its `WarpDrive` tag is not a `SignerRequest` variant, so the daemon + // fails to decode the frame — exactly the old-daemon-meets-new-request path (#31 rules #1/#3). + #[derive(serde::Serialize)] + enum FutureRequest { + WarpDrive, + } + + let mut stream = UnixStream::connect(socket).await.expect("connect socket"); + let body = frame::encode(&FutureRequest::WarpDrive).expect("encode future frame"); + frame::write_frame(&mut stream, &body) + .await + .expect("write future frame"); + let resp = frame::read_frame(&mut stream) + .await + .expect("read reply") + .expect("daemon replied before closing"); + frame::decode(&resp).expect("decode reply") +} + +#[tokio::test] +async fn hello_answers_in_every_state_and_survives_a_bad_frame() { + let dir = TempDir::new("hello"); + let (wallet, _recipient) = seal_account0(dir.path()); + let d = spawn_daemon(dir.path(), DUMMY_RPC, CHAIN, &[]); + let client = SignerClient::new(d.socket_path.clone()); + + // (1) Answered while LOCKED (the daemon starts locked; we have not unlocked). The reply carries + // the capability names + spec_version + impl_name and nothing else, and it equals the + // single-source registry — so the daemon can never drift from the mocks (parity contract). + let locked = hello(&client).await; + assert!(is_iso_date(&locked.spec_version), "spec_version not a date"); + assert!( + locked + .capabilities + .iter() + .any(|c| c == capabilities::CAP_CORE), + "capabilities must include core" + ); + assert!( + locked + .capabilities + .iter() + .any(|c| c == capabilities::CAP_MCP_V0_1), + "capabilities must include mcp.v0.1" + ); + assert_eq!(locked.impl_name, capabilities::IMPL_SIGNERD); + assert_eq!( + locked, + capabilities::hello_info(capabilities::IMPL_SIGNERD), + "the daemon's Hello must equal the single-source registry" + ); + + // (2) Unlock, then Hello again → identical: it is answered in every state, and unlocking never + // changes the advertised capabilities (discovery is not state-dependent). + assert_eq!( + client.unlock(PASS).await.expect("unlock"), + UnlockOutcome::Unlocked { address: wallet } + ); + let unlocked = hello(&client).await; + assert_eq!(locked, unlocked, "Hello must be identical across states"); + + // (3) A future request kind → the daemon rejects the frame LOUDLY and does not panic. This is + // the compat valve: an old daemon answers a new kind with the existing frame-decode error. + match send_unknown_kind(&d.socket_path).await { + SignerResponse::Decision(Decision::Deny { reason }) => { + assert_eq!(reason, deny_reasons::MALFORMED_REQUEST); + } + other => panic!("expected Deny{{malformed_request}}, got {other:?}"), + } + + // (4) The daemon survived: a fresh Hello still answers identically, and — the security-critical + // part — the malformed frame signed and recorded NOTHING (the activity ledger is still empty + // even though the daemon is unlocked and *could* have signed). + assert_eq!( + hello(&client).await, + unlocked, + "daemon must keep serving after a bad frame" + ); + assert!( + client + .activity_feed() + .await + .expect("activity feed") + .is_empty(), + "a malformed frame must sign and record nothing" + ); +} diff --git a/docs/build/40-wire-evolution.md b/docs/build/40-wire-evolution.md new file mode 100644 index 0000000..a26a8f0 --- /dev/null +++ b/docs/build/40-wire-evolution.md @@ -0,0 +1,107 @@ +# Wire-contract evolution — capability discovery + the five rules + +> The rules for how the frozen `deckard-contract` wire (Intent / Decision / Policy / SignerRequest, +> CBOR over the UDS and JSON over MCP) is allowed to grow. Issue **#31**. The registry and the +> rules live in code (`crates/deckard-contract/src/capabilities.rs`) and here; this doc is their +> prose home. Additive changes ship under this doc; a breaking change would need a new spec version. + +## Why capability discovery (and not a version handshake) + +The wire is frozen by convention with no version field. Upcoming features add new request *kinds* +(EIP-7702 session keys, x402, dapp-origin proposals — **#198** / **#204** / **#32** / **#34**). +Without written evolution rules, each one is an ad-hoc breaking change to a contract we call frozen. + +We add exactly one request: + +```rust +SignerRequest::Hello // -> SignerResponse::Hello(HelloInfo { + // spec_version: "YYYY-MM-DD", + // capabilities: Vec, // e.g. ["core", "mcp.v0.1"] + // impl_name: String, // informational; never branched on + // }) +``` + +A client asks *does this daemon understand request kind X?* and reads the `capabilities` list. There +is **no negotiated handshake** (à la MCP ≤2025-11). We chose discovery over negotiation because MCP +itself is moving away from the handshake, and the backward-compat valve here needs **zero** migration: + +> An **old** daemon that receives `Hello` fails to decode the unknown enum variant and returns the +> existing frame-decode error. That failure *is* the backward-compat valve, and it already ships. No +> existing frame changes; the round-trip fixtures prove the freeze holds. + +`Hello` is answered in **every** daemon state, including `Locked` — it reveals capability names only, +never vault state, policy contents, or key material. + +Grounded in verified prior art: RFC 9987 (ssh-agent — 20 years unversioned, later formalized with +named extensions + a generic-failure valve), MCP date-versioning, protobuf never-reuse discipline, +EIP-5792 capability maps, varlink `GetInfo`. + +## The five rules (normative) + +1. **Capability discovery, not version negotiation.** `Hello` returns `HelloInfo` as above. Old peers + answer a variant they predate with the existing decode error — that rejection is the valve. +2. **Date-version (`YYYY-MM-DD`), bump only on a breaking change.** Additions ship under a new + capability *name*, **never** a version bump. Capability names are never reused. `spec_version` + moves only when an existing frame changes shape or meaning in a non-additive way. +3. **Protobuf discipline on the CBOR.** Map-key names are forever: never reuse, rename, or retype a + key; a removed key's name goes to a `reserved` note and is never recycled. Decoders **MUST ignore + unknown struct keys** and **MUST reject unknown enum variants**. (The wire structs carry no + `deny_unknown_fields`, on purpose — that is what makes struct growth additive.) +4. **Two distinguishable failures.** "unsupported message / capability" is a *frame-decode error* + (`malformed_request` on the wire). "supported but failed / refused" is a `Decision::Deny { reason }` + drawn from the frozen deny-reason vocabulary (`deny_reasons`, issue #28). A caller can always tell + *"this daemon can't do that"* from *"this daemon won't do that."* +5. **In-repo home.** The rules + the capability registry live in this repo: + `crates/deckard-contract/src/capabilities.rs` (the code) and this doc (the prose). A standalone + spec document, CDDL schema, golden-vector files, and crates.io publication are deferred until a + second, independent implementer appears. + +## Capability registry + +The baseline capabilities every current build advertises. This table is the human-readable mirror of +`capabilities()` in `capabilities.rs`; keep them in lockstep (add a row here when you add a const +there). A name here is permanent (rule #3). + +| Capability | Status | Since | Defining doc | +|---|---|---|---| +| `core` | stable | 2026-06-05 | [`30-mcp-shape.md`](30-mcp-shape.md) — the frozen `deckard-contract` socket API (unlock / propose / execute / status / revoke_all / policy_get / address / balance / pending / activity). | +| `mcp.v0.1` | stable | 2026-06-10 | [`31-agent-quickstart.md`](31-agent-quickstart.md) — the key-less MCP sidecar's agent-tool profile over `core` (its tool list is drift-guarded by a test in `deckard-mcp`). | + +## Adding a capability (the #198 / #204 extension point) + +Registering a new request *kind* or origin variant is deliberately small — one edit in each of three +places, mirroring the deny-vocabulary discipline in `deny_reasons`: + +1. **`capabilities.rs`** — add `pub const CAP_… : &str = "…";` (with a doc comment: meaning + since), + and push it into `capabilities()` in registry order (append; never reorder or reuse a name). +2. **This table** — add its row (capability · status · since · defining doc). +3. **The wire, additively** — a new `SignerRequest` / enum variant is appended; old peers reject it + via the valve (rule #1). `spec_version` does **not** change (rule #2). + +Concretely, **#198** (`ProposalOrigin::Dapp` on the wire) and **#204** register their new origin +variant as a capability name here, then ship the enum variant additively. An old daemon that receives +a `Dapp`-tagged frame rejects it with `malformed_request` — the correct, safe degradation. + +## How the rules are proven (tests) + +- **E1 — `Hello` shape** (`deckard-contract`, `wire_evolution::e1_…`): `spec_version` matches + `^\d{4}-\d{2}-\d{2}$`; `capabilities ⊇ {core, mcp.v0.1}`. +- **E2 — freeze holds** (`e2_…` + the untouched `signer_request_roundtrip` / `signer_response_roundtrip` + fixtures): existing frames encode byte-identically after `Hello` was added; golden bytes pin the unit + requests. +- **E3 — unknown variant rejected loudly** (`e3_…`, and `deckard-signerd/tests/hello.rs` at the socket): + a future variant decodes to an `Err`, never a silent misparse or panic; the daemon replies + `malformed_request`, keeps serving, and signs nothing. +- **E4 — unknown struct key ignored** (`e4_…`): a newer producer's extra `HelloInfo` field is skipped + and the known fields still decode. +- **Parity** (`capabilities::tests` + `deckard-signerd/tests/hello.rs`): the daemon and the mocks build + `Hello` from the one `hello_info()` source, so their `spec_version` + `capabilities` are identical. + +## Not in scope (deferred) + +Extractable standalone spec document, CDDL schema (RFC 8610), golden-vector files, crates.io +publication, a standalone spec repo, an ERC draft. Any change to existing frames or to the deny-reason +vocabulary (that is #28). The daemon's active chain id in `HelloInfo` is **not** included: the in-scope +rule is "capability names only — no state," and a wrong-chain client is already refused at first +`Propose` with `Deny{chain_mismatch}`. It can be added additively later — precisely by these rules — +if first-contact chain detection proves worth the state it exposes. diff --git a/docs/build/README.md b/docs/build/README.md index dd569a0..3e87c08 100644 --- a/docs/build/README.md +++ b/docs/build/README.md @@ -21,6 +21,7 @@ live on mainnet, agent-driven (Claude Desktop via MCP), shielded via Railgun, ve | [`30-mcp-shape.md`](30-mcp-shape.md) | The **agent surface** (one binary = CLI + MCP server, key-less) **and the freeze-first contract**. Contract + daemon socket built; the `deckard-mcp` binary is not yet. | | [`31-agent-quickstart.md`](31-agent-quickstart.md) | The **canonical agent-facing quickstart**: install one-liner, the 6 `mcp.v0.1` tools, policy fields, every deny reason with its fix, demo constants. The tool list is drift-guarded by a test in `deckard-mcp`. | | [`32-agent-loop-prompt.md`](32-agent-loop-prompt.md) | The **first agent demo runner** (#61): a Claude Desktop MCP prompt that loops — poll balance → detect a deposit → shield within the policy → execute — idempotent via the agent's own context, backpressure on over-cap (the human approves in the Activity feed, #60), STOP-aware. | +| [`40-wire-evolution.md`](40-wire-evolution.md) | The **wire-contract evolution rules** (#31): `Hello` capability discovery, the five additive-evolution rules, and the capability registry (`core`, `mcp.v0.1`). The keystone for new request kinds (7702, x402 #32/#34, dapp-origin #198/#204). Registry mirrored in `deckard-contract/src/capabilities.rs`. | ## Build order (what gates what)