Skip to content

Lane B: deckard-mcp sidecar (mcp.v0.1, 6 tools) + signerd hardening - #16

Merged
hellno merged 6 commits into
mainfrom
hellno/mcp-sidecar
Jun 11, 2026
Merged

Lane B: deckard-mcp sidecar (mcp.v0.1, 6 tools) + signerd hardening#16
hellno merged 6 commits into
mainfrom
hellno/mcp-sidecar

Conversation

@hellno

@hellno hellno commented Jun 10, 2026

Copy link
Copy Markdown
Owner

Work package B of the approved open-source-demo plan (W1 + the signerd hardening rows).

What ships

New crate crates/deckard-mcp — the key-less agent surface

  • One binary, CLI + --mcp stdio server (rmcp v1.7 + clap, both approved deps). Key-less: every write is an Intent over the async SignerClient; no key material, no RPC client, no Helios in this process.
  • mcp.v0.1 = exactly 6 collision-safe tools: deckard_wallet_address, deckard_wallet_balance, deckard_policy_get, deckard_shield, deckard_execute, deckard_revoke_all. Raw propose + simulate stay cut (spec carries the amendment note).
  • amount_eth = decimal string, exact parse; wallet_balance omits shielded with the honest "unavailable — read it in the Deckard app (v1 limitation)".
  • Tool descriptions are the agent's docs (units, app-running+unlocked preconditions, shield→request_id→execute sequencing, do-NOT-retry-on-timeout, STOP) — keyword-asserted by T1.
  • Typed error catalog: problem+cause+fix for socket-missing, NoVault ≠ locked, expired, revoked, unknown_request, already_executed ("do NOT retry; vary the amount"), broadcast-timeout ("status UNKNOWN — do NOT retry"), chain_mismatch, …
  • Connect-time chain probe (side-effect-free undecodable propose): wrong-chain daemon → actionable "re-run deckard-mcp install --demo" error.
  • install [--demo]: prints the Claude Desktop entry with the absolute binary path; writes only with --write + an explicit y; --demo emits the ~/.deckard/demo env block (socket/config dir, DECKARD_CHAIN_ID=11155111, DECKARD_RPC_URL=http://127.0.0.1:8545) — key-less, no secret enters Claude's config.
  • Viewing key from RailgunViewGrant is Zeroizing'd on receipt, dropped immediately, never in any response/log (transcript-asserted).
  • Secret-shaped flags hard-rejected before clap parses, value never echoed.

signerd hardening

  • Chain-1 guardrail: chain_id==1 without the override env var (named once, in THREAT-MODEL territory — never in reasons/responses) downgrades every auto-Allow → NeedsApproval. Matrix test: ApprovalMode × chain × override. Mock/daemon parity carve-out documented in parity.rs.
  • App regression guard included (the lane-C critical slice): the app now treats its hold-to-confirm as the human approval — NeedsApproval shields show the review card and confirm runs Resolve{approved:true}Execute (signer::approve_and_execute_blocking, wire-asserted). The app's mainnet shield keeps working with the guardrail armed.
  • Shield.to == RelayAdapt(chain) daemon pre-check (closes the policy.rs deferral); address table parity-pinned against railgun::chain_config.
  • Reason/log redaction: every daemon reason funnels through one_linesanitize_reason (URL → scheme://host); e2e canary with a key-bearing RPC URL.
  • Loud policy fallback: a policy.json that exists but doesn't parse now shouts before running the default any-recipient policy.
  • Two-client interleaved daemon_e2e: app unlock → MCP propose/approve → MCP STOP cuts both clients → app re-unlock invalidates stale request ids with explanatory unknown_request errors.

Acceptance (spec T1–T4, T6–T9 as amended)

Mock-daemon suite in CI, hard 20s timeout on every stdio read, real deckard-mcp --mcp child over real JSON-RPC:

  • T1 exactly-6-tools + keyword-bearing descriptions · T2 free reads, honest shielded string · T3/T4 over-cap shield → needs_approval → execute denied · T6 within-cap shield → execute → tx_hash (+ replay refused) · T7 secret flags rejected w/o echo + poisoned DECKARD_RPC_URL env never in transcript · T8 STOP then execute denied · T9 structural allowlist transcript walk (hex permitted only in tx_hash/request_id, both sides) + seeded canary (planted key in a forced broadcast error must be flagged — proving the scanner; the real daemon's redaction is pinned in signerd's tests).

DoD evidence

  • cargo fmt --all --check — clean
  • clippy -D warnings, default and --features tray configs — clean
  • cargo test --locked --workspace117 passed, 1 ignored (the fork-pinned shield_e2e stays #[ignore])
  • New deps: rmcp 1.7, clap 4 (approved); lockfile additions are those two families only
  • CI: linux workspace jobs pick the crate up; macOS builds it as a shipping binary

Coordination note (lane C)

This PR includes the minimal app-side resolve path the guardrail requires (D: shell.rs/signer.rs). Lane C (env plumbing/fork pin/banner) touches the same files — whoever lands second rebases; the slice here is small and surgical.

hellno added 6 commits June 10, 2026 18:39
…ion, loud policy fallback

- chain-1 guardrail: every auto-Allow on mainnet downgrades to NeedsApproval
  unless the (THREAT-MODEL-only) operator override is set; matrix test
  ApprovalMode × chain × override; override var never in client-visible text
- app regression guard: hold-to-confirm IS the human approval — NeedsApproval
  shields now stash the review card and confirm resolves-then-executes
  (signer::approve_and_execute_blocking, wire-asserted by a recording server)
- daemon Shield.to == RelayAdapt(chain) pre-check (policy.rs defers it by
  charter; table parity-pinned against railgun::chain_config)
- every reason/log line URL-redacted at the daemon boundary (sanitize_reason
  in one_line) + end-to-end canary on a key-bearing RPC URL
- load_policy falls back LOUDLY when policy.json exists but doesn't parse
- two-client interleaved daemon_e2e: app unlock → MCP propose/approve →
  STOP cuts both → re-unlock invalidates stale request ids with clear errors
…6 tools)

One binary, two faces (the splits-cli pattern): a clap CLI (balance / address /
policy / shield --amount-eth / execute / stop / install [--demo] [--write]) and
an rmcp v1.7 stdio server exposing the launch profile of 6 deckard_-prefixed
tools. Key-less by construction: every write is an Intent proposed over the
async SignerClient to deckard-signerd; the one transient secret (the Railgun
viewing key riding in RailgunViewGrant) is Zeroizing'd on receipt and never
echoed or logged.

- amount_eth is a decimal STRING, parsed exactly (no f64, no wei JSON numbers)
- wallet_balance omits shielded: honest 'unavailable — read it in the Deckard
  app (v1 limitation)' string, never a fake 0
- tool descriptions are the agent's docs: units, preconditions (app running +
  unlocked), shield→request_id→execute sequencing, do-NOT-retry-on-timeout,
  revoke_all = STOP; T1 asserts they stay keyword-bearing
- typed error catalog: every failure is problem+cause+fix (socket-missing,
  NoVault distinct from locked, expired, revoked, unknown_request,
  already_executed, broadcast-timeout = status UNKNOWN do-not-retry, …)
- connect-time chain probe: a wrong-chain daemon yields an actionable
  'install --demo' error instead of a late confusing deny
- secret-shaped flags hard-rejected pre-clap, value never echoed (T7)
- install [--demo]: prints (writes only with --write + confirmation) the
  Claude Desktop entry with the absolute binary path; --demo emits the
  ~/.deckard/demo env block (socket/config dir, chain 11155111, local RPC)
- acceptance suite (CI mock-daemon, hard timeouts on every read): T1-T4,
  T6-T9 amended — T9 is a structural allowlist transcript walk (tx_hash/
  request_id permitted both sides) + seeded canary + poisoned-env case
- workspace member + CI: linux workspace jobs pick it up; macOS builds the
  sidecar as a shipping binary; 30-mcp-shape.md gets the mcp.v0.1 amendment
  note (propose/simulate cut, rationale + roadmap home)

deps: rmcp 1.7, clap 4 (both approved)
Thread one resolved runtime config through the app so `just demo` can point
every process at a local anvil fork without a rebuild (W2, lane C):

- chain id: env DECKARD_CHAIN_ID > settings > default, removing the hardcoded
  DAEMON/SHIELD_CHAIN_ID consts; threaded to the daemon launch, shield builder,
  and Railgun sync (the supervisor no longer clobbers the env — the app resolves
  it first and passes the resolved id through).
- config dir: deckard_core::config_dir() now honors DECKARD_CONFIG_DIR (empty =
  treated as unset); Settings::path() routes through it so vault + settings +
  policy share one isolated dir (kills the NoVault split).
- socket: the app honors DECKARD_SOCKET_PATH so a demo daemon never loses the
  single-instance flock to an everyday Deckard (which would silently attach the
  demo app + MCP to the mainnet daemon).
- rpc: Settings::effective_rpc() gains the DECKARD_RPC_URL override; the resolved
  runtime config is logged once with the RPC redacted (redact_url made pub).
- fork sync: DECKARD_DEMO_FORK_BLOCK pins the shielded SubsquidSyncer
  (.with_latest_block, like shield_e2e); runtime DECKARD_VERIFIED_READS=0 makes
  both the app's EthProvider and the daemon (read_public_balance + server
  priming) skip mainnet-only Helios, tagged Unsynced, so Balance doesn't stall
  against a fork — no GPUI rebuild needed (new deckard-core/src/env.rs).
- locked surfacing: an external STOP/revoke (e.g. an MCP client) bounces the app
  to the unlock gate with clear copy.
- fork banner: an amber "DEMO FORK — not mainnet" caution in the status strip
  (DESIGN rule 7: amber alert icon carries the signal, text stays neutral).

Pure unit tests back every env override. Lane B already shipped the daemon-side
env handling, the chain-1 guardrail, and the resolve path (NeedsApproval ->
hold-to-confirm -> Resolve+Execute); this stacks on top.

DoD: cargo fmt clean; clippy -D warnings on default + --features tray; cargo
test --workspace 128 passed / 1 ignored; no new or changed dependencies.
…IBUTING (W2 lane D)

Work Package D of the OSS launch — the reproducible local-chain demo + contributor
dev loop on top of lanes B (deckard-mcp) and C (app env plumbing + fork pin).

- justfile: shared demo_* vars + three recipes:
  - demo: anvil Sepolia fork @ pinned block 10822990 (RPC_URL_SEPOLIA, fixed port
    8545, DECKARD_DEMO_FORK_BLOCK override), process trap + double-run guard,
    install-if-absent policy, verified-reads off, isolated ~/.deckard/demo, fork banner.
  - demo-fund [addr]: anvil_setBalance 10 ETH; no-arg queries the daemon Address.
  - demo-check: doctor with defined exit codes (0/10-16) + platform-conditional fixes;
    probes the daemon via deckard-mcp address (requires unlock; PolicyGet does not),
    diffs PolicyGet vs the intended demo policy (OverCap renders 'over_cap'), prints
    chain + policy + auto-allow state.
- policy.demo.json (root): per-tx 0.1 / daily 0.5 ETH, auto_shield_min 0.01, OverCap,
  empty allowlist; decimal-string wei + _comment docs. Loads clean (no fallback).
- CONTRIBUTING: 'Demo / local-chain dev loop' (macOS + Linux) + DECKARD_* env table;
  refreshed the stale 'deckard-mcp not built' status bullet.

Verified: DoD green (fmt, just check both configs, 128 tests, no dep changes); all
recipe paths exercised live against a Sepolia archive fork; Railgun sync measured ~10s
(shield_e2e). Codex 2-round adversarial review: 2 blockers found + fixed, re-verified.
Adversarial pre-merge review of PR #16 (P2s):
- sanitize_reason sliced at rfind's byte index + 1, which lands mid-codepoint
  when multibyte punctuation (curly quote, ellipsis) precedes a URL scheme —
  a panic in the daemon's error-formatting path. Advance by char::len_utf8.
- THREAT-MODEL.md was referenced by the guardrail code/tests but missing.
  Documents the same-uid trust boundary (Resolve is honor-system), prompt
  injection model, the mainnet-guardrail override (its single documentation
  site), viewing-key discipline, and the STOP-latency v1 tradeoff.
…, zeroize, secret-flag matching, demo chain guard, frame EOF

Codex (GPT-5.5 xhigh) adversarial review of PR #16, P2s + P3:
- app: non-empty unparsable DECKARD_CHAIN_ID is now a loud startup failure
  (was: silent fall-through toward mainnet; signerd/mcp already hard-error)
- signerd: propose checks chain_mismatch BEFORE locked (needs no key), so the
  sidecar's connect-time chain probe is conclusive even against a locked
  daemon; mcp mock reordered for parity and the probe now caches on 'locked'
- signerd client: zeroize the encoded request (Unlock passphrase) and raw
  response (RailgunViewGrant viewing key) frames after use
- mcp: secret-flag rejection is delimiter-aware (--rpc-api-key, --private_key,
  --railgun-viewing-key now refused before clap can echo the value)
- justfile: 'just demo' aborts if the local fork reports a chain id other
  than 11155111 (wrong-chain upstream can no longer launch a false demo)
- signerd frame: a truncated length prefix is a malformed-frame error, not a
  clean EOF

Deferred (pre-existing, not a PR-16 regression): settings RPC re-point only
respawns the read provider while the signer daemon keeps its launch RPC —
seeded as a red-team issue in Lane E.
@hellno
hellno merged commit 2edc6d5 into main Jun 11, 2026
3 of 4 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant