diff --git a/crates/deckard-app/src/activity_view.rs b/crates/deckard-app/src/activity_view.rs index 2efd4bb..2cb2350 100644 --- a/crates/deckard-app/src/activity_view.rs +++ b/crates/deckard-app/src/activity_view.rs @@ -62,11 +62,44 @@ use crate::widgets::{ }; /// The displayed subject for an action's origin: the agent's handle when an agent acted, "You" -/// when the foreground app did (E2, #182 — one agent in demo scope, named via `Shell::agent_handle`). -fn origin_subject(origin: ProposalOrigin, agent_handle: &str) -> &str { +/// when the foreground app did (E2, #182 — one agent in demo scope, named via `Shell::agent_handle`), +/// and the requesting site's origin string VERBATIM for a dapp (#198 — no prettifying, so the +/// bridge's `unknown-origin` fallback reads as exactly that; a dapp never gets the "You" treatment). +fn origin_subject<'a>(origin: &'a ProposalOrigin, agent_handle: &'a str) -> &'a str { match origin { ProposalOrigin::Agent => agent_handle, ProposalOrigin::App => "You", + ProposalOrigin::Dapp { origin } => origin, + } +} + +/// Map a record's wire origin to the shared Review's origin-rail identity (pure — unit-tested). +/// The #198 trust invariant: a `Dapp`-origin request NEVER renders as "You"; its origin string is +/// shown verbatim. An App-origin MESSAGE still falls back to the payload's display-only +/// `SignMessage.origin` (records from a pre-#198 bridge are tagged `App`); every other App-origin +/// payload is a foreground action → You. Trust badge stays `None` at every call site — we have no +/// site reputation (DESIGN: no speculative site-trust). +fn review_origin<'a>( + origin: &'a ProposalOrigin, + payload: &'a PendingPayloadView, + agent_handle: &'a str, + wallet_name: &'a str, + verb: &'a str, +) -> Origin<'a> { + match origin { + ProposalOrigin::Agent => Origin::Agent { + handle: agent_handle, + }, + ProposalOrigin::Dapp { origin } => Origin::Dapp { domain: origin }, + ProposalOrigin::App => match payload { + PendingPayloadView::Message(m) if !m.origin.is_empty() => { + Origin::Dapp { domain: &m.origin } + } + _ => Origin::You { + account: wallet_name, + verb, + }, + }, } } @@ -550,6 +583,9 @@ impl Shell { let amber = theme::amber(is_dark); let is_agent = record.origin == ProposalOrigin::Agent; + // An agent OR a dapp (#198) initiated — the "→ You" human link applies to both: when a + // card was raised, YOU are the second actor in the chain regardless of who asked. + let third_party = !matches!(record.origin, ProposalOrigin::App); let agent_handle = self.agent_handle(); let proposed = is_proposed(record); let selected = proposed && selected_id == Some(record.request_id); @@ -587,12 +623,15 @@ impl Shell { .flex_1() .child(lead) .child( + // A dapp subject is a verbatim origin string (#198) and can be long — let it + // shrink + ellipsize so it can never push the trailing rail off the pane. div() - .flex_shrink_0() + .min_w_0() + .truncate() .text_sm() .font_weight(FontWeight::MEDIUM) .text_color(fg) - .child(origin_subject(record.origin, &agent_handle).to_string()), + .child(origin_subject(&record.origin, &agent_handle).to_string()), ) .child(div().flex_shrink_0().text_sm().text_color(muted).child("·")) // The verb + object is the part that grows and clamps: it gets the flex space and @@ -610,7 +649,7 @@ impl Shell { // The human only enters the chain when a human was actually needed (a card was raised). // An auto-allowed within-cap action has NO "→ You" — the daemon decided it hands-free. - if is_agent && needed_human { + if third_party && needed_human { let human_verb = match record.lifecycle { ActivityLifecycle::Proposed => "waiting", ActivityLifecycle::Decided { approved: true } | ActivityLifecycle::Executed => { @@ -827,25 +866,15 @@ impl Shell { let wallet_name = self.wallet_name(); let verb = you_verb(&record.payload); - // The request-origin rail — the ONLY thing that changes across origins. An agent proposal - // is the cyan handle; an App-origin dapp MESSAGE carries its domain in the payload, so it - // gets the neutral dapp rail (the one dapp attribution available without the deferred - // tx-origin wire plumbing, ADR-0001); anything else is You. Trust badge is `None` — we - // don't have site reputation (DESIGN: no speculative site-trust). - let origin = match record.origin { - ProposalOrigin::Agent => crate::widgets::Origin::Agent { - handle: &agent_handle, - }, - ProposalOrigin::App => match &record.payload { - PendingPayloadView::Message(m) if !m.origin.is_empty() => { - crate::widgets::Origin::Dapp { domain: &m.origin } - } - _ => crate::widgets::Origin::You { - account: &wallet_name, - verb, - }, - }, - }; + // The request-origin rail — the ONLY thing that changes across origins (#198: the wire + // now carries the dapp origin for transactions too, so the mapper is pure + unit-tested). + let origin = review_origin( + &record.origin, + &record.payload, + &agent_handle, + &wallet_name, + verb, + ); let origin_rail = crate::widgets::origin_header(origin, None, theme); // `theme` is not borrowed past this point — the `self.*(cx)` calls below re-borrow it. @@ -1293,7 +1322,10 @@ fn human_acted(record: &ActivityRecord) -> bool { /// (`reason == None`) yet still required a human, so it must read "you approved", not /// "auto-approved within cap". fn settled_label(record: &ActivityRecord) -> &'static str { - let is_agent = record.origin == ProposalOrigin::Agent; + // An agent OR a dapp asked (#198) — someone other than you initiated, so the settled copy + // must say what happened to THEIR request ("you approved" / "auto-approved within cap"), + // never the first-person "sent" of your own foreground action. + let third_party = !matches!(record.origin, ProposalOrigin::App); match record.lifecycle { ActivityLifecycle::Decided { approved: false } => "not approved", // The approval window lapsed — nobody acted. Read it as such (not "not approved", which @@ -1301,12 +1333,12 @@ fn settled_label(record: &ActivityRecord) -> &'static str { ActivityLifecycle::Expired => "expired", ActivityLifecycle::Decided { approved: true } | ActivityLifecycle::Executed => { if record.auto_allowed { - if is_agent { + if third_party { "auto-approved within cap" } else { "sent" } - } else if is_agent { + } else if third_party { "you approved" } else { "sent" @@ -1566,12 +1598,12 @@ fn request_rail_body( let mono = theme.mono_font_family.clone(); let amber = theme::amber(theme.is_dark()); - // An agent proposes → the cyan origin header. An `App`-origin pending request is ambiguous — - // a foreground action OR a browser/dapp request the daemon always cards (`daemon.rs`) — and - // carries no domain on the record, so the rail stays NEUTRAL rather than claim a false amber - // human origin (two signal colors only). A precise dapp identity + trust badge lands when the - // bridge carries its origin (ADR-0001 / #44). - let header = match rec.origin { + // An agent proposes → the cyan origin header; a dapp request carries its origin on the wire + // (#198) → the neutral dapp identity, verbatim. An `App`-origin pending request stays + // ambiguous — a foreground action OR a browser request from a pre-#198 bridge — and carries + // no domain on the record, so that rail stays NEUTRAL rather than claim a false amber human + // origin (two signal colors only). A trust badge is still deferred (#48 — no site reputation). + let header = match &rec.origin { ProposalOrigin::Agent => origin_header( Origin::Agent { handle: agent_handle, @@ -1579,6 +1611,9 @@ fn request_rail_body( None, theme, ), + ProposalOrigin::Dapp { origin } => { + origin_header(Origin::Dapp { domain: origin }, None, theme) + } ProposalOrigin::App => neutral_request_header(theme), }; @@ -1652,7 +1687,7 @@ fn tx_rail_body(rec: &ActivityRecord, agent_handle: &str, mask: bool, theme: &Th let mono = theme.mono_font_family.clone(); let is_dark = theme.is_dark(); - let actor = origin_subject(rec.origin, agent_handle); + let actor = origin_subject(&rec.origin, agent_handle); let mark = if rec.origin == ProposalOrigin::Agent { agent_mark( actor, @@ -1792,6 +1827,140 @@ mod tests { } } + fn tx_payload() -> PendingPayloadView { + PendingPayloadView::Tx(Intent { + chain_id: 1, + to: Address::repeat_byte(0x22), + token: None, + value: U256::from(1_u64), + calldata: Bytes::new(), + kind: IntentKind::Send, + }) + } + + /// #198 trust invariant: a Dapp-origin Tx renders the origin string VERBATIM on the review + /// rail — never the "You are sending" treatment, and no prettifying (the bridge's literal + /// `unknown-origin` fallback reads as exactly that). + #[test] + fn review_origin_maps_a_dapp_tx_to_the_dapp_rail() { + let tx = tx_payload(); + let dapp = ProposalOrigin::Dapp { + origin: "https://app.example.org".into(), + }; + assert!(matches!( + review_origin(&dapp, &tx, "Kyoto", "Osaka", "sending"), + Origin::Dapp { + domain: "https://app.example.org" + } + )); + let unknown = ProposalOrigin::Dapp { + origin: "unknown-origin".into(), + }; + assert!(matches!( + review_origin(&unknown, &tx, "Kyoto", "Osaka", "sending"), + Origin::Dapp { + domain: "unknown-origin" + } + )); + } + + /// The other two origins are untouched by #198: a foreground App Tx is still You (amber), + /// an agent proposal is still the cyan handle. + #[test] + fn review_origin_keeps_you_and_agent_unchanged() { + let tx = tx_payload(); + assert!(matches!( + review_origin(&ProposalOrigin::App, &tx, "Kyoto", "Osaka", "sending"), + Origin::You { + account: "Osaka", + verb: "sending" + } + )); + assert!(matches!( + review_origin(&ProposalOrigin::Agent, &tx, "Kyoto", "Osaka", "sending"), + Origin::Agent { handle: "Kyoto" } + )); + } + + /// Back-compat + precedence: an App-origin MESSAGE from a pre-#198 bridge still renders the + /// payload's display-only origin, and a Dapp-origin message reads the WIRE origin (the + /// bridge writes both from the same session, so they agree — the wire wins on the card). + #[test] + fn review_origin_message_fallback_and_wire_precedence() { + let msg = PendingPayloadView::Message(SignMessage { + chain_id: 1, + origin: "https://payload.example.org".into(), + kind: SignMessageKind::PersonalSign { + message: Bytes::new(), + }, + }); + // Pre-#198 record: App-tagged, domain only in the payload → still the dapp rail. + assert!(matches!( + review_origin(&ProposalOrigin::App, &msg, "Kyoto", "Osaka", "signing"), + Origin::Dapp { + domain: "https://payload.example.org" + } + )); + // #198 record: the wire origin is authoritative. + let wire = ProposalOrigin::Dapp { + origin: "https://wire.example.org".into(), + }; + assert!(matches!( + review_origin(&wire, &msg, "Kyoto", "Osaka", "signing"), + Origin::Dapp { + domain: "https://wire.example.org" + } + )); + } + + /// The feed-row/receipt subject: "You" for the app, the handle for an agent, and the + /// verbatim origin string for a dapp (#198 — `unknown-origin` shows as `unknown-origin`). + #[test] + fn origin_subject_renders_a_dapp_origin_verbatim() { + assert_eq!(origin_subject(&ProposalOrigin::App, "Kyoto"), "You"); + assert_eq!(origin_subject(&ProposalOrigin::Agent, "Kyoto"), "Kyoto"); + assert_eq!( + origin_subject( + &ProposalOrigin::Dapp { + origin: "https://app.example.org".into() + }, + "Kyoto" + ), + "https://app.example.org" + ); + assert_eq!( + origin_subject( + &ProposalOrigin::Dapp { + origin: "unknown-origin".into() + }, + "Kyoto" + ), + "unknown-origin" + ); + } + + /// #198: a settled dapp record reads like a third-party request ("you approved" / + /// "auto-approved within cap"), never the first-person "sent" of your own action. + #[test] + fn settled_label_treats_a_dapp_as_a_third_party() { + let mut human = record( + 0x0A, + ActivityLifecycle::Executed, + BreachedLimit::PerTxCap, + false, + ); + human.origin = ProposalOrigin::Dapp { + origin: "https://app.example.org".into(), + }; + assert_eq!(settled_label(&human), "you approved"); + + let mut auto = record(0x0B, ActivityLifecycle::Executed, BreachedLimit::None, true); + auto.origin = ProposalOrigin::Dapp { + origin: "unknown-origin".into(), + }; + assert_eq!(settled_label(&auto), "auto-approved within cap"); + } + #[test] fn activity_pending_keeps_only_proposed() { let records = vec![ diff --git a/crates/deckard-browser-bridge/src/lib.rs b/crates/deckard-browser-bridge/src/lib.rs index 67d6441..881bb22 100644 --- a/crates/deckard-browser-bridge/src/lib.rs +++ b/crates/deckard-browser-bridge/src/lib.rs @@ -137,10 +137,13 @@ impl BridgeBackend { } } - async fn send_transaction(&self, intent: Intent) -> Result { + /// `origin` is the requesting site's session origin (#198): the daemon records the + /// transaction as `ProposalOrigin::Dapp`, so the review card and feed attribute it to the + /// site instead of falsely claiming "You are sending". + async fn send_transaction(&self, origin: &str, intent: Intent) -> Result { match self { Self::DevMock { .. } => Ok(dev_tx_hash_for_intent(&intent)), - Self::WalletClient(wallet) => execute_intent_with_daemon(wallet, intent).await, + Self::WalletClient(wallet) => execute_intent_with_daemon(wallet, intent, origin).await, } } } @@ -264,7 +267,7 @@ impl BrowserBridge { let session = self.require_session(origin)?; let (from, intent) = parse_send_transaction_params(params, self.chain_id)?; ensure_same_account(&session.account, &from)?; - self.backend.send_transaction(intent).await + self.backend.send_transaction(origin, intent).await } fn wallet_get_capabilities(&self, params: Value) -> Result { @@ -298,7 +301,11 @@ impl BrowserBridge { ensure_same_account(&session.account, &batch.from)?; let mut tx_hashes = Vec::with_capacity(batch.intents.len()); for intent in &batch.intents { - tx_hashes.push(self.backend.send_transaction(intent.clone()).await?); + tx_hashes.push( + self.backend + .send_transaction(origin, intent.clone()) + .await?, + ); } let id = batch .id @@ -417,9 +424,16 @@ async fn sign_message_with_daemon( message: SignMessage, ) -> Result { let request_id = deckard_wallet_client::SignerClient::request_id_for_message(&message); + // #198: attribute the proposal to the requesting site. The wire origin and the payload's + // display-only `SignMessage.origin` are the same session string, so they can never diverge. let decision = wallet .signer_client() - .propose_message(&message, ProposalOrigin::App) + .propose_message( + &message, + ProposalOrigin::Dapp { + origin: message.origin.clone(), + }, + ) .await .map_err(bridge_daemon_error)?; match decision { @@ -443,11 +457,20 @@ async fn sign_message_with_daemon( async fn execute_intent_with_daemon( wallet: &WalletClient, intent: Intent, + origin: &str, ) -> Result { let request_id = deckard_wallet_client::SignerClient::request_id_for_intent(&intent); + // #198: attribute the transaction to the requesting site — the daemon stores the origin on + // the pending record (display-only; the policy verdict is origin-blind, and a dapp's exact + // ERC-20 approve keeps the same always-raise-a-human-card admission as an in-app one). let decision = wallet .signer_client() - .propose(&intent, ProposalOrigin::App) + .propose( + &intent, + ProposalOrigin::Dapp { + origin: origin.to_string(), + }, + ) .await .map_err(bridge_daemon_error)?; match decision { @@ -1159,10 +1182,9 @@ async fn handle_http_connection( let headers = std::str::from_utf8(&buf[..header_end])?.to_string(); let (method, path) = request_line(&headers)?; - let origin = header_value(&headers, "x-deckard-origin") - .or_else(|| header_value(&headers, "origin")) - .unwrap_or("unknown-origin") - .to_string(); + let origin = session_origin( + header_value(&headers, "x-deckard-origin").or_else(|| header_value(&headers, "origin")), + ); if method == "OPTIONS" { return write_http(&mut stream, 204, "text/plain", "").await; @@ -1249,6 +1271,40 @@ fn header_value<'a>(headers: &'a str, name: &str) -> Option<&'a str> { }) } +/// The longest origin the bridge will attribute a session to. A real web origin +/// (`scheme://host[:port]`) fits comfortably; anything longer is hostile or broken. +const MAX_ORIGIN_LEN: usize = 255; + +/// Resolve the attribution origin for a session (#198). The header value is ATTACKER-SUPPLIED +/// text that becomes the review card / feed subject, so anything that does not look like a web +/// origin collapses to the honest `unknown-origin` fallback: it must be `http(s)://` + a +/// non-empty printable host with no path (a browser `Origin` header never carries one), within +/// [`MAX_ORIGIN_LEN`]. This is shape hygiene, NOT origin verification (#48 — deferred): it stops +/// a crafted header (the literal `You`, control characters, `https://good.site/evil-path`, a +/// paragraph of text) from masquerading as a human subject or garbling the trust surface, while +/// every real origin still renders verbatim. `x-deckard-origin` stays the preferred source +/// because the extension's own POST carries the extension's `origin`, not the page's. +fn session_origin(header: Option<&str>) -> String { + let fallback = || "unknown-origin".to_string(); + let Some(raw) = header else { + return fallback(); + }; + let host = match raw.split_once("://") { + Some(("http" | "https", host)) => host, + _ => return fallback(), + }; + let shaped = raw.len() <= MAX_ORIGIN_LEN + && !host.is_empty() + && host + .bytes() + .all(|b| b.is_ascii_graphic() && b != b'/' && b != b'\\'); + if shaped { + raw.to_string() + } else { + fallback() + } +} + fn host_is_loopback(headers: &str) -> bool { header_value(headers, "host") .map(|host| host.starts_with("127.0.0.1:") || host.starts_with("localhost:")) @@ -1270,6 +1326,55 @@ mod tests { ) } + /// #198 shape hygiene: a real web origin passes VERBATIM; anything that could masquerade as + /// a human subject on the review card / feed (the literal "You", control bytes, a smuggled + /// path, an over-long string, an empty header) collapses to the honest `unknown-origin`. + #[test] + fn session_origin_keeps_real_origins_and_collapses_crafted_ones() { + // Real origins render verbatim — including port, IPv6, and the extension's degenerate + // page-origin fallback. + for good in [ + "https://app.example.org", + "http://127.0.0.1:8765", + "http://[::1]:8545", + "http://unknown.invalid", + ] { + assert_eq!( + session_origin(Some(good)), + good, + "{good} must pass verbatim" + ); + } + // Crafted or broken values collapse to the fallback instead of reaching the trust + // surface: no scheme (the "You" masquerade), embedded spaces, an empty value, control + // characters, a path after the host (a browser Origin never has one), a non-web scheme, + // and an over-long string. + for bad in [ + "You", + "You are sending", + "", + "https://", + "https://app.example.org/evil-path", + "https://app.example.org\\evil", + "https://app.example.org evil", + "https://app.\texample.org", + "chrome-extension://abcdef", + "javascript://alert(1)", + ] { + assert_eq!( + session_origin(Some(bad)), + "unknown-origin", + "{bad:?} must collapse" + ); + } + assert_eq!( + session_origin(Some(&format!("https://{}.example.org", "a".repeat(300)))), + "unknown-origin", + "an over-long origin must collapse" + ); + assert_eq!(session_origin(None), "unknown-origin"); + } + #[test] fn session_creation_and_lookup() { let mut store = DappSessionStore::default(); diff --git a/crates/deckard-contract/src/capabilities.rs b/crates/deckard-contract/src/capabilities.rs index 09b40e5..834887c 100644 --- a/crates/deckard-contract/src/capabilities.rs +++ b/crates/deckard-contract/src/capabilities.rs @@ -61,6 +61,13 @@ pub const CAP_CORE: &str = "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"; +/// Dapp origin attribution on the wire (#198): this build understands +/// [`ProposalOrigin::Dapp`](crate::rpc::ProposalOrigin::Dapp) on `Propose` / `ProposeOrder` / +/// `ProposeMessage`, and echoes it back on pending/activity records. The origin string is +/// display-only attribution — verbatim, never a trust root, never a policy input. Since +/// 2026-07-07; defining doc `docs/build/40-wire-evolution.md` (the #198 registry row). +pub const CAP_ORIGIN_DAPP: &str = "origin.dapp"; + // ───────────────────────── 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. @@ -74,7 +81,11 @@ pub const IMPL_MOCK: &str = "deckard-mock"; /// 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()] + vec![ + CAP_CORE.to_string(), + CAP_MCP_V0_1.to_string(), + CAP_ORIGIN_DAPP.to_string(), + ] } /// Build the [`HelloInfo`] this build answers `Hello` with, tagged with the caller's `impl_name`. @@ -118,6 +129,11 @@ mod tests { // 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"); + // #198: dapp origin attribution ships as a named capability (rule #2 — no version bump). + assert!( + caps.iter().any(|c| c == CAP_ORIGIN_DAPP), + "origin.dapp missing" + ); // Rule #3 hygiene: names are lowercase, whitespace-free, non-empty, and unique. for c in &caps { diff --git a/crates/deckard-contract/src/lib.rs b/crates/deckard-contract/src/lib.rs index e27f056..2324400 100644 --- a/crates/deckard-contract/src/lib.rs +++ b/crates/deckard-contract/src/lib.rs @@ -374,6 +374,13 @@ mod roundtrip_tests { intent: sample_intent(IntentKind::Send), origin: ProposalOrigin::Agent, }); + // #198: a browser dapp's transaction, attributed to the requesting site. + roundtrip(&SignerRequest::Propose { + intent: sample_intent(IntentKind::Send), + origin: ProposalOrigin::Dapp { + origin: "https://app.example.org".into(), + }, + }); roundtrip(&SignerRequest::Execute { request_id: B256::repeat_byte(0x02), }); @@ -404,6 +411,14 @@ mod roundtrip_tests { message: sample_message(), origin: ProposalOrigin::Agent, }); + // #198: a dapp message proposal — the wire origin and the payload's display-only + // `SignMessage.origin` are written from the same bridge session, so they agree. + roundtrip(&SignerRequest::ProposeMessage { + message: sample_message(), + origin: ProposalOrigin::Dapp { + origin: sample_message().origin, + }, + }); roundtrip(&SignerRequest::SignMessage { request_id: B256::repeat_byte(0x09), }); @@ -616,10 +631,46 @@ mod roundtrip_tests { fn proposal_origin_roundtrip_and_default() { roundtrip(&ProposalOrigin::App); roundtrip(&ProposalOrigin::Agent); - // The safe default is App: an un-tagged proposal must never masquerade as an agent. + // #198: the dapp variant carries the site's origin string verbatim — a real web origin + // and the bridge's literal `unknown-origin` fallback both round-trip unprettified. + roundtrip(&ProposalOrigin::Dapp { + origin: "https://app.example.org".into(), + }); + roundtrip(&ProposalOrigin::Dapp { + origin: "unknown-origin".into(), + }); + // The safe default is App: an un-tagged proposal must never masquerade as an agent + // (or a dapp). assert_eq!(ProposalOrigin::default(), ProposalOrigin::App); } + /// #198: a `Dapp`-origin record rides the existing pending/activity wire structs unchanged — + /// the enum variant is the only addition (no struct grew a key). + #[test] + fn dapp_origin_records_roundtrip() { + roundtrip(&PendingRecord { + request_id: B256::repeat_byte(0x03), + status: ApprovalStatus::Pending, + payload: PendingPayloadView::Tx(sample_intent(IntentKind::Send)), + remaining_ms: 60_000, + origin: ProposalOrigin::Dapp { + origin: "https://app.example.org".into(), + }, + }); + roundtrip(&ActivityRecord { + request_id: B256::repeat_byte(0x04), + origin: ProposalOrigin::Dapp { + origin: "unknown-origin".into(), + }, + payload: PendingPayloadView::Tx(sample_intent(IntentKind::Send)), + timestamp_ms: 1, + tx_hash: None, + lifecycle: ActivityLifecycle::Proposed, + reason: BreachedLimit::PerTxCap, + auto_allowed: false, + }); + } + #[test] fn swap_wire_types_roundtrip() { // The order itself, both sign outcomes, every payload view, and a full record. @@ -949,4 +1000,90 @@ mod wire_evolution { serde_json::from_slice(&json).expect("an extra JSON key must be ignored, not rejected"); assert_eq!(back2.impl_name, "deckard-future"); } + + /// #198 under rule E2: adding `ProposalOrigin::Dapp` did not perturb the existing origin + /// frames — `App`/`Agent` stay bare CBOR text strings, and the new variant's own bytes are + /// hand-verifiable (an externally-tagged `{"Dapp":{"origin":…}}` map). + #[test] + fn e2_proposal_origin_frames_are_byte_identical() { + fn cbor_of(origin: &ProposalOrigin) -> Vec { + let mut b = Vec::new(); + ciborium::into_writer(origin, &mut b).expect("cbor encode"); + b + } + + // The pre-#198 frames, computed by hand from the CBOR spec (major type 3 = text string): + // if the new variant ever shifts these, the freeze is broken and this fails. + assert_eq!( + cbor_of(&ProposalOrigin::App), + b"\x63App", + "App frame changed — freeze broken" + ); + assert_eq!( + cbor_of(&ProposalOrigin::Agent), + b"\x65Agent", + "Agent frame changed — freeze broken" + ); + + // The new frame, hand-computed: map(1) { text(4) "Dapp": map(1) { text(6) "origin": + // text(23) "https://app.example.org" } }. + let dapp = ProposalOrigin::Dapp { + origin: "https://app.example.org".into(), + }; + assert_eq!( + cbor_of(&dapp), + b"\xA1\x64Dapp\xA1\x66origin\x77https://app.example.org", + "Dapp frame differs from the hand-computed golden bytes" + ); + + // The golden bytes are the REAL encoding, not a tautology: they decode back, verbatim. + let back: ProposalOrigin = + ciborium::from_reader(&b"\xA1\x64Dapp\xA1\x66origin\x77https://app.example.org"[..]) + .expect("golden decodes"); + assert_eq!(back, dapp); + } + + /// #198 under rule E3: the valve, pointed at the new origin variant. An OLD decoder — one + /// built before `Dapp` existed, modelled here as the pre-#198 two-variant enum — must reject + /// a `Dapp`-tagged frame LOUDLY on both wire surfaces: a decode `Err`, never a silent + /// misparse into `App`/`Agent` and never a panic. Because `origin` is a required field of + /// every pending/activity record, an old peer rejects the whole frame — nothing downstream + /// can act on a mis-attributed record. + #[test] + fn e3_old_decoder_rejects_a_dapp_origin() { + // The pre-#198 wire enum, byte-identical to what an old daemon/app/sidecar decodes. + #[derive(Debug, serde::Deserialize)] + enum OldProposalOrigin { + #[allow(dead_code)] // reason: decode-only stand-in for the pre-#198 peer. + App, + #[allow(dead_code)] // reason: decode-only stand-in for the pre-#198 peer. + Agent, + } + + let dapp = ProposalOrigin::Dapp { + origin: "https://app.example.org".into(), + }; + + // CBOR (the daemon UDS wire). + let mut cbor = Vec::new(); + ciborium::into_writer(&dapp, &mut cbor).unwrap(); + assert!( + ciborium::from_reader::(&cbor[..]).is_err(), + "an old decoder must reject the Dapp tag, not silently accept it" + ); + + // JSON (the MCP wire). + let json = serde_json::to_vec(&dapp).unwrap(); + assert!( + serde_json::from_slice::(&json).is_err(), + "an old decoder must reject the Dapp tag on the JSON surface too" + ); + + // And the old frames still decode on the NEW enum — compatibility is one-way additive. + let app: ProposalOrigin = ciborium::from_reader(&b"\x63App"[..]).expect("App decodes"); + assert_eq!(app, ProposalOrigin::App); + let agent: ProposalOrigin = + ciborium::from_reader(&b"\x65Agent"[..]).expect("Agent decodes"); + assert_eq!(agent, ProposalOrigin::Agent); + } } diff --git a/crates/deckard-contract/src/rpc.rs b/crates/deckard-contract/src/rpc.rs index b93a282..bc27e63 100644 --- a/crates/deckard-contract/src/rpc.rs +++ b/crates/deckard-contract/src/rpc.rs @@ -12,15 +12,26 @@ use crate::policy::Policy; use crate::read_status::ReadStatus; use crate::swap_order::SwapOrder; -/// WHO proposed a pending record: a foreground human action in the app (`App`), or an -/// autonomous agent (the MCP sidecar, `Agent`). Drives the Approvals agent header band and -/// Activity's two-actor chain. `App` is the safe default (the `#[default]` variant) so an -/// un-tagged proposal never masquerades as an agent. -#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +/// WHO proposed a pending record: a foreground human action in the app (`App`), an autonomous +/// agent (the MCP sidecar, `Agent`), or a browser dapp relayed by the bridge (`Dapp`, #198). +/// Drives the Approvals agent header band and Activity's two-actor chain. `App` is the safe +/// default (the `#[default]` variant) so an un-tagged proposal never masquerades as an agent +/// or a dapp. +#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] pub enum ProposalOrigin { #[default] App, Agent, + /// A browser dapp request relayed by the bridge (#198, capability `origin.dapp`). `origin` + /// is the requesting site's web origin exactly as the bridge session keys it (e.g. + /// `https://app.example.org`, or the bridge's literal `unknown-origin` fallback) — + /// display-only attribution, rendered verbatim, never a trust root, and never an input to + /// the policy verdict. Additive per the #31 evolution rules: `App`/`Agent` keep their bare + /// text-string frames, this variant frames as the externally-tagged map + /// `{"Dapp":{"origin":…}}`, and an old decoder rejects the unknown tag loudly (the valve). + Dapp { + origin: String, + }, } /// `deckard-mcp` → `deckard-signerd`. The key-less client only proposes; it never signs. @@ -43,8 +54,9 @@ pub enum SignerRequest { approved: bool, }, /// Policy check, NO signing yet → [`Decision`]. `origin` records WHO proposed (a - /// foreground human app action vs an autonomous agent) so the GUI inbox can render the - /// agent band / two-actor chain; it never affects the policy verdict. + /// foreground human app action, an autonomous agent, or a browser dapp via the bridge) so + /// the GUI inbox can render the agent band / dapp attribution / two-actor chain; it never + /// affects the policy verdict. Propose { intent: Intent, origin: ProposalOrigin, diff --git a/crates/deckard-signerd/src/daemon.rs b/crates/deckard-signerd/src/daemon.rs index bf44c2a..af1198f 100644 --- a/crates/deckard-signerd/src/daemon.rs +++ b/crates/deckard-signerd/src/daemon.rs @@ -547,14 +547,16 @@ impl Daemon { // the normal Send caps path below (the broadcast carries `intent.calldata` as-is). if intent.kind == IntentKind::ContractCall && intent.token.is_none() { if let Some((spender, amount)) = deckard_core::decode_approve(&intent.calldata) { - if matches!(origin, ProposalOrigin::App) { + if matches!(origin, ProposalOrigin::App | ProposalOrigin::Dapp { .. }) { if intent.value != U256::ZERO { return Decision::Deny { reason: deny_reasons::APPROVE_WITH_VALUE.into(), }; } // Browser/dapp approvals are clear-signable as an exact approve tuple and - // must always raise a human card. Agent-origin swap approvals keep the + // must always raise a human card — `Dapp` (#198) rides this branch so a + // dapp approve gets the SAME decision as an in-app one (origin is + // attribution, never authorization). Agent-origin swap approvals keep the // stricter shaped-approve gate below. return self.finish_propose(intent, true, origin); } @@ -1519,7 +1521,7 @@ impl Daemon { status: req.status.clone(), payload: payload_view(&req.payload), remaining_ms, - origin: req.origin, + origin: req.origin.clone(), } }) .collect() @@ -1551,7 +1553,7 @@ impl Daemon { .take(ACTIVITY_FEED_CAP) .map(|(id, req)| ActivityRecord { request_id: *id, - origin: req.origin, + origin: req.origin.clone(), payload: payload_view(&req.payload), timestamp_ms: req.created_ms, tx_hash: if req.cancelled { None } else { req.broadcast }, diff --git a/crates/deckard-signerd/tests/erc20_browser_tx.rs b/crates/deckard-signerd/tests/erc20_browser_tx.rs index 0e7ea18..6a0e2a1 100644 --- a/crates/deckard-signerd/tests/erc20_browser_tx.rs +++ b/crates/deckard-signerd/tests/erc20_browser_tx.rs @@ -1,13 +1,14 @@ mod common; use alloy_primitives::{Address, Bytes, U256}; -use deckard_contract::{Decision, Intent, IntentKind, ProposalOrigin}; +use deckard_contract::{deny_reasons, Decision, Intent, IntentKind, ProposalOrigin}; use deckard_signerd::SignerClient; use common::*; const SEPOLIA: u64 = 11_155_111; const DUMMY_RPC: &str = "http://127.0.0.1:1"; +const PER_TX_CAP: u64 = 50_000_000_000_000_000; // 0.05 ETH (the default policy cap) fn erc20_transfer_intent(token: Address, recipient: Address, amount: u64) -> Intent { Intent { @@ -83,3 +84,115 @@ async fn browser_origin_erc20_approve_is_admitted_as_human_review_transaction() "ERC-20 approve must raise a human card, got {decision:?}" ); } + +/// #198 parity: origin is attribution, never authorization — the same intent must get the SAME +/// decision under `App` and `Dapp` origins. Proven on two daemons with identical vaults + +/// policies (the same mnemonic is sealed in both dirs), so the second propose can never ride the +/// first's idempotent re-propose path. The suite covers every admission route origin can steer: +/// the auto-allow, the over-cap card, and BOTH halves of the exact-approve branch — the one place +/// `daemon.rs` routes on origin, where `Dapp` must ride the App always-raise-a-human-card path +/// (an unwidened `matches!(origin, App)` would silently shunt dapp approves into the agent +/// shaped-approve gate and deny them). +#[tokio::test] +async fn dapp_origin_decisions_match_app_byte_for_byte() { + let dir_app = TempDir::new("dapp-parity-app"); + let dir_dapp = TempDir::new("dapp-parity-dapp"); + let (_w1, recipient) = seal_account0(dir_app.path()); + let (_w2, recipient_dapp) = seal_account0(dir_dapp.path()); + assert_eq!( + recipient, recipient_dapp, + "identical vaults → identical intents" + ); + + let d_app = spawn_daemon(dir_app.path(), DUMMY_RPC, SEPOLIA, &[]); + let d_dapp = spawn_daemon(dir_dapp.path(), DUMMY_RPC, SEPOLIA, &[]); + let app = SignerClient::new(d_app.socket_path.clone()); + let dapp = SignerClient::new(d_dapp.socket_path.clone()); + app.unlock(PASS).await.unwrap(); + dapp.unlock(PASS).await.unwrap(); + + let send = |value: u64| Intent { + chain_id: SEPOLIA, + to: recipient, + token: None, + value: U256::from(value), + calldata: Bytes::new(), + kind: IntentKind::Send, + }; + // The exact-approve branch's value guard: an approve carrying ETH is denied for BOTH. + let approve_with_value = Intent { + value: U256::from(1u64), + ..approve_intent(Address::repeat_byte(0xa0), recipient, 1_000_000) + }; + let cases: Vec<(&str, Intent)> = vec![ + ("within-cap send", send(PER_TX_CAP - 1)), + ("over-cap send", send(PER_TX_CAP + 1)), + ( + "exact ERC-20 approve", + approve_intent(Address::repeat_byte(0xa0), recipient, 1_000_000), + ), + ("approve with value", approve_with_value), + ]; + + for (label, intent) in cases { + let from_app = app.propose(&intent, ProposalOrigin::App).await.unwrap(); + let from_dapp = dapp + .propose( + &intent, + ProposalOrigin::Dapp { + origin: "https://app.example.org".into(), + }, + ) + .await + .unwrap(); + assert_eq!( + from_app, from_dapp, + "{label}: App and Dapp origins must yield byte-identical decisions" + ); + } + + // The parity above must not be vacuous (e.g. everything denied for one dumb shared reason): + // pin the expected shape of each route on the Dapp daemon. + assert_eq!( + dapp.propose( + &send(PER_TX_CAP - 1), + ProposalOrigin::Dapp { + origin: "https://app.example.org".into() + } + ) + .await + .unwrap(), + Decision::Allow, + "a within-cap dapp send auto-allows off mainnet, exactly like App" + ); + assert!( + matches!( + dapp.propose( + &approve_intent(Address::repeat_byte(0xa0), recipient, 1_000_000), + ProposalOrigin::Dapp { + origin: "https://app.example.org".into() + } + ) + .await + .unwrap(), + Decision::NeedsApproval { .. } + ), + "a dapp ERC-20 approve raises a human card (the App branch), never the agent gate's deny" + ); + match dapp + .propose( + &Intent { + value: U256::from(1u64), + ..approve_intent(Address::repeat_byte(0xa0), recipient, 1_000_000) + }, + ProposalOrigin::Dapp { + origin: "https://app.example.org".into(), + }, + ) + .await + .unwrap() + { + Decision::Deny { reason } => assert_eq!(reason, deny_reasons::APPROVE_WITH_VALUE), + other => panic!("an approve carrying ETH must be denied, got {other:?}"), + } +} diff --git a/crates/deckard-signerd/tests/pending_list.rs b/crates/deckard-signerd/tests/pending_list.rs index 7dbabb4..0db463e 100644 --- a/crates/deckard-signerd/tests/pending_list.rs +++ b/crates/deckard-signerd/tests/pending_list.rs @@ -108,6 +108,41 @@ async fn pending_list_reports_agent_origin() { ); } +/// #198 (dapp half): a browser-bridge proposal is tagged `Dapp` with the requesting site's +/// origin string, and BOTH read surfaces — the inbox and the activity feed — carry it back +/// VERBATIM (no prettifying; the attribution the review card renders is exactly what the bridge +/// session sent, `unknown-origin` included). +#[tokio::test] +async fn pending_list_and_feed_report_dapp_origin_verbatim() { + let dir = TempDir::new("pending-list-dapp"); + let (_wallet, to) = seal_account0(dir.path()); + let d = spawn_daemon(dir.path(), DUMMY_RPC, CHAIN, &[]); + let client = SignerClient::new(d.socket_path.clone()); + client.unlock(PASS).await.unwrap(); + + let origin = ProposalOrigin::Dapp { + origin: "https://app.example.org".into(), + }; + let id = pending_id(&client, to, PER_TX_CAP + 1, origin.clone()).await; + + let records = client.pending_list().await.unwrap(); + let rec = records + .iter() + .find(|r| r.request_id == id) + .expect("the proposed record must appear in the inbox"); + assert_eq!( + rec.origin, origin, + "a dapp-proposed record must carry the site's origin string verbatim" + ); + + let feed = client.activity_feed().await.unwrap(); + let rec = feed + .iter() + .find(|r| r.request_id == id) + .expect("the proposed record must appear in the feed"); + assert_eq!(rec.origin, origin, "the feed carries the same attribution"); +} + /// A2: expire-BEFORE-list. With a 1s TTL, a `Pending` record left to go stale is surfaced as /// `Expired` (NOT Pending) with `remaining_ms == 0` — proving `pending_list` runs `expire_stale` /// first, so the inbox never shows a row past its TTL as still pending. diff --git a/docs/build/40-wire-evolution.md b/docs/build/40-wire-evolution.md index a26a8f0..adf767a 100644 --- a/docs/build/40-wire-evolution.md +++ b/docs/build/40-wire-evolution.md @@ -66,6 +66,7 @@ there). A name here is permanent (rule #3). |---|---|---|---| | `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`). | +| `origin.dapp` | stable | 2026-07-07 | This doc (issue #198) — `ProposalOrigin::Dapp { origin }` on `Propose` / `ProposeOrder` / `ProposeMessage`, echoed back on pending/activity records. The origin string is display-only attribution (rendered verbatim, never a trust root, never a policy input); `App`/`Agent` frames are byte-unchanged and an old decoder rejects the `Dapp` tag with `malformed_request` (the rule-#1 valve). | ## Adding a capability (the #198 / #204 extension point) @@ -78,9 +79,12 @@ places, mirroring the deny-vocabulary discipline in `deny_reasons`: 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. +Concretely, **#198** (`ProposalOrigin::Dapp` on the wire) shipped exactly this way — the +`origin.dapp` row above — and **#204** registers its origin variant the same way. An old daemon that +receives a `Dapp`-tagged frame rejects it with `malformed_request` — the correct, safe degradation. +(The reverse direction fails equally loudly: an old *client* reading a new daemon's pending/activity +list that contains one `Dapp`-origin record rejects the whole frame, since `origin` is a required +field of every record. That is the valve working as designed, not a partial decode.) ## How the rules are proven (tests)