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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

46 changes: 29 additions & 17 deletions THREAT-MODEL.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,24 +14,32 @@ that is not your user is not**. The daemon's Unix socket is `0600` inside a `070
directory and the daemon checks the peer's uid. There is no second boundary inside
your user account:

- **`Resolve` is same-uid honor-system.** The daemon converts guarded writes to
`NeedsApproval`; the app's hold-to-confirm sends `Resolve { approved: true }` over
the socket, and the daemon cannot distinguish that from any other same-uid process
speaking the (documented) CBOR wire. The guardrail therefore constrains
**tool-confined agents** — a Claude Desktop instance whose only hands are the
mcp.v0.1 tools, which deliberately include **no `propose` and no `resolve`** — not
arbitrary code execution as your user. An agent with shell access is same-uid code:
it is on the trusted side of the boundary whether we like it or not, exactly as live
malware is for the keystore (see SECURITY.md's "stated honestly" section).
- **`Resolve` is gated by an unforgeable capability (PRD-01), not the honor system.**
The daemon converts guarded writes to `NeedsApproval`; approval
(`Resolve { approved: true }`) is honoured **only** on the private channel the daemon
inherits from the app that supervises it — a `socketpair` end passed at spawn
(`supervise.rs` mints it, `server.rs` serves it, `daemon.rs` gates on it). A `Resolve`
on the public proposer socket — any *other* same-uid process speaking the documented
CBOR wire — is refused with `resolve_not_authorized`. So a same-uid proposer (including
a fully compromised MCP sidecar) can propose and read, but it **cannot self-approve**;
the mcp.v0.1 tool surface still deliberately includes **no `propose` and no `resolve`**
(defense in depth). The honest residual: an agent with arbitrary **shell** access is
same-uid code that can reach the app's inherited fd anyway (ptrace, `/proc/<pid>/fd`) —
it is on the trusted side of the boundary, exactly as live malware is for the keystore
(see SECURITY.md's "stated honestly" section). The capability closes the *cheap,
wire-level* self-approve that any same-uid process could previously do; it does not
fence off arbitrary code execution as your user.
- **Request-ids are deterministic per intent** (no salt/namespace). Within the
same-uid boundary this is fine — anyone who can compute your request-id can also
just open their own connection. Salted/namespaced request-ids are on the roadmap for
a future multi-principal daemon; they are deliberately not in the frozen v1 wire.

Authenticating the resolver (e.g. a socketpair or cookie handed only to the
supervised app process) is the known hardening step if a second principal ever shares
the socket. Until then: do not point this daemon at meaningful mainnet funds while
running unattended agents with shell access. That sentence is the threat model.
Authenticating the resolver — a `socketpair` capability handed only to the supervised
app — is now **implemented** (PRD-01): the public socket can no longer approve. The
standing caution is therefore narrower: do not point this daemon at meaningful mainnet
funds while running unattended agents with **shell** access, because same-uid code can
still reach the app's capability fd (ptrace / `/proc`). That sentence is the threat
model.

## Prompt injection, and what actually stops it

Expand Down Expand Up @@ -178,10 +186,14 @@ connect.
`spent_today` (`cap_exceeded`). Two within-cap proposals therefore can't both slip past
the daily cap.

*Deferred:* the socket has no second principal today, so there is no resolver
authentication — `Resolve` is honored from any same-uid caller (the boundary section
above; red-team issue #2). Cross-restart spend persistence is also deferred
(`policy_store.rs`): `spent_today` is in-memory and resets on restart.
- **Resolver authentication** (`crates/deckard-signerd/src/{server,daemon,supervise}.rs`,
PRD-01): `Resolve` is honoured only on the private capability channel the daemon
inherits from the supervising app (a `socketpair` end passed at spawn); a `Resolve` on
this public socket is refused with `resolve_not_authorized`. The same-uid peer-cred
check stays as defense-in-depth + logging, not as the approval boundary.

*Deferred:* cross-restart spend persistence (`policy_store.rs`): `spent_today` is
in-memory and resets on restart.

### 3. The policy store (the fence an attacker would want to widen)

Expand Down
13 changes: 9 additions & 4 deletions crates/deckard-app/src/shell.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1476,10 +1476,12 @@ impl Shell {
self.shield_error = None;
cx.notify();
let client = self.signer.client();
// For a NeedsApproval proposal the completed hold IS the approval: resolve, then
// execute (signer::approve_and_execute_blocking). An Allow goes straight to execute.
let control = self.signer.control();
// For a NeedsApproval proposal the completed hold IS the approval: resolve over the
// private capability channel, then execute (signer::approve_and_execute_blocking). An
// Allow goes straight to execute.
let task = cx.background_spawn(async move {
signer::approve_and_execute_blocking(&client, request_id, needs_resolve)
signer::approve_and_execute_blocking(&client, &control, request_id, needs_resolve)
});
cx.spawn(async move |this, cx| {
let res = task.await;
Expand Down Expand Up @@ -1709,8 +1711,11 @@ impl Shell {
self.send_error = None;
cx.notify();
let client = self.signer.client();
let control = self.signer.control();
// For a NeedsApproval proposal the completed hold IS the approval: resolve over the
// private capability channel, then execute (a Resolve on the public socket is refused).
let task = cx.background_spawn(async move {
signer::approve_and_execute_blocking(&client, request_id, needs_resolve)
signer::approve_and_execute_blocking(&client, &control, request_id, needs_resolve)
});
cx.spawn(async move |this, cx| {
let res = task.await;
Expand Down
100 changes: 63 additions & 37 deletions crates/deckard-app/src/signer.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ use std::path::PathBuf;

use alloy_primitives::{Address, Bytes, B256, U256};
use deckard_contract::{Decision, ExecuteResult, Intent, IntentKind, RequestId, UnlockOutcome};
use deckard_signerd::{DaemonSupervisor, SignerClient};
use deckard_signerd::{ControlChannel, DaemonSupervisor, SignerClient};

/// Result of the app's send path (propose, then execute on `Allow`). The path is implemented
/// and unit-tested here; the GUI send screen that calls it is T-UX (out of scope), so no view
Expand Down Expand Up @@ -56,6 +56,12 @@ impl AppSigner {
pub fn client(&self) -> SignerClient {
self.client.clone()
}

/// The private capability channel the daemon authenticates approvals on (PRD-01). The app
/// sends `Resolve` here after a completed hold-to-confirm; the public socket refuses it.
pub fn control(&self) -> ControlChannel {
self._supervisor.control()
}
}

/// Resolve the daemon socket path: the `DECKARD_SOCKET_PATH` override, else the per-uid
Expand Down Expand Up @@ -111,17 +117,20 @@ pub fn send_blocking(client: &SignerClient, intent: &Intent) -> anyhow::Result<S

/// Execute a reviewed proposal, key-less. For a `NeedsApproval` proposal (over-cap, or the
/// daemon's mainnet guardrail downgrading an auto-allow), the completed hold-to-confirm IS
/// the human approval — the app is the wire contract's designated human-facing resolver —
/// so this first sends `Resolve{approved: true}` to flip the `Pending` record to `Allowed`,
/// then `Execute`. For an `Allow` proposal it goes straight to `Execute` (byte-identical to
/// the old path). Blocking; called from a background thread.
/// the human approval — the app is the wire contract's designated human-facing resolver — so
/// this first sends `Resolve{approved: true}` over the **private capability channel** (the only
/// channel the daemon authenticates approvals on, PRD-01) to flip the `Pending` record to
/// `Allowed`, then `Execute` over the public socket. For an `Allow` proposal it goes straight
/// to `Execute`. Blocking; called from a background thread. `execute` stays on the public
/// socket because it only signs an already-`Allowed` record — the authority is the `Resolve`.
pub fn approve_and_execute_blocking(
client: &SignerClient,
control: &ControlChannel,
request_id: RequestId,
needs_resolve: bool,
) -> anyhow::Result<ExecuteResult> {
if needs_resolve {
client.resolve_blocking(request_id, true)?;
control.resolve(request_id, true)?;
}
client.execute_blocking(request_id)
}
Expand Down Expand Up @@ -294,73 +303,90 @@ mod tests {
let _ = std::fs::remove_dir_all(&dir);
}

/// The mainnet-guardrail regression guard: a `NeedsApproval` shield completes via the
/// resolve path — the app sends `Resolve{approved: true}` (the hold-to-confirm is the
/// human approval) and THEN `Execute`, over the socket, signing nothing in-process.
/// The mainnet-guardrail regression guard, post-PRD-01: a `NeedsApproval` shield completes
/// by sending `Resolve{approved: true}` over the **private capability channel** (the
/// hold-to-confirm is the human approval) and THEN `Execute` over the **public socket**,
/// signing nothing in-process. Proves the split: approval authority rides the control
/// channel, execution rides the public socket.
#[test]
fn approve_and_execute_resolves_then_executes_over_the_socket() {
fn approve_resolves_over_control_then_executes_over_public_socket() {
let dir =
std::env::temp_dir().join(format!("deckard-appresolve-test-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let sock = dir.join("signerd.sock");

let seen: Arc<Mutex<Vec<String>>> = Arc::new(Mutex::new(Vec::new()));
let seen_srv = Arc::clone(&seen);
let sock_srv = sock.clone();
let (ready_tx, ready_rx) = mpsc::channel();

// Recording server: two per-call connections (Resolve, then Execute).
let server = std::thread::spawn(move || {
// Public recording server: ONE connection (Execute only — Resolve never arrives here).
let seen_pub = Arc::clone(&seen);
let sock_srv = sock.clone();
let public_server = std::thread::spawn(move || {
let rt = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.unwrap();
rt.block_on(async move {
let listener = tokio::net::UnixListener::bind(&sock_srv).unwrap();
ready_tx.send(()).unwrap();
for _ in 0..2 {
let (mut stream, _) = listener.accept().await.unwrap();
let buf = frame::read_frame(&mut stream).await.unwrap().unwrap();
let req: SignerRequest = frame::decode(&buf).unwrap();
let resp = match &req {
SignerRequest::Resolve { approved, .. } => {
assert!(*approved, "hold-to-confirm must approve, not deny");
seen_srv.lock().unwrap().push("Resolve".into());
SignerResponse::Ack
}
SignerRequest::Execute { .. } => {
seen_srv.lock().unwrap().push("Execute".into());
SignerResponse::Execute(ExecuteResult::Broadcast {
tx_hash: B256::repeat_byte(0xCD),
})
}
other => panic!("unexpected request on the wire: {other:?}"),
};
let body = frame::encode(&resp).unwrap();
frame::write_frame(&mut stream, &body).await.unwrap();
let (mut stream, _) = listener.accept().await.unwrap();
let buf = frame::read_frame(&mut stream).await.unwrap().unwrap();
match frame::decode::<SignerRequest>(&buf).unwrap() {
SignerRequest::Execute { .. } => {
seen_pub.lock().unwrap().push("Execute".into());
let resp = SignerResponse::Execute(ExecuteResult::Broadcast {
tx_hash: B256::repeat_byte(0xCD),
});
let body = frame::encode(&resp).unwrap();
frame::write_frame(&mut stream, &body).await.unwrap();
}
other => panic!("public socket must only see Execute, got {other:?}"),
}
});
});

ready_rx.recv().unwrap();

// Control channel: a real socketpair (minted exactly as the supervisor does). The app
// keeps `control`; a thread plays the daemon end, expecting a single Resolve → Ack.
let (app_end, child_fd) = deckard_signerd::supervise::control_pair().unwrap();
let control = deckard_signerd::ControlChannel::connected(app_end);
let seen_ctrl = Arc::clone(&seen);
let control_server = std::thread::spawn(move || {
let mut daemon_end = std::os::unix::net::UnixStream::from(child_fd);
let buf = frame::read_frame_blocking(&mut daemon_end)
.unwrap()
.unwrap();
match frame::decode::<SignerRequest>(&buf).unwrap() {
SignerRequest::Resolve { approved, .. } => {
assert!(approved, "hold-to-confirm must approve, not deny");
seen_ctrl.lock().unwrap().push("Resolve".into());
let body = frame::encode(&SignerResponse::Ack).unwrap();
frame::write_frame_blocking(&mut daemon_end, &body).unwrap();
}
other => panic!("control channel saw a non-Resolve request: {other:?}"),
}
});

let client = SignerClient::new(sock);
let result =
approve_and_execute_blocking(&client, RequestId::repeat_byte(0x77), true).unwrap();
approve_and_execute_blocking(&client, &control, RequestId::repeat_byte(0x77), true)
.unwrap();

assert_eq!(
result,
ExecuteResult::Broadcast {
tx_hash: B256::repeat_byte(0xCD)
}
);
// Resolve (control) strictly precedes Execute (public): the Ack gates the execute.
assert_eq!(
*seen.lock().unwrap(),
vec!["Resolve".to_string(), "Execute".to_string()]
);

server.join().unwrap();
control_server.join().unwrap();
public_server.join().unwrap();
let _ = std::fs::remove_dir_all(&dir);
}

Expand Down
6 changes: 6 additions & 0 deletions crates/deckard-contract/src/deny_reasons.rs
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,12 @@ pub const DERIVATION_UNVERIFIED: &str = "derivation_unverified";
/// Built without the `shield` feature, so there is no Railgun derivation to grant. Only on the
/// `#[cfg(not(feature = "shield"))]` path.
pub const SHIELD_UNAVAILABLE: &str = "shield_unavailable";
/// A `Resolve` arrived on the public proposer socket, which carries no approval authority.
/// Only the private capability channel the daemon inherits from the supervising app may
/// approve (PRD-01 / THREAT-MODEL residual #1) — so a same-uid proposer can no longer
/// self-approve. The pending record is left untouched (`Pending`); the public caller is
/// refused with this typed denial.
pub const RESOLVE_NOT_AUTHORIZED: &str = "resolve_not_authorized";

// ───────────────────────── Swap v1 (CoW) ─────────────────────────
// Shaped-approve admission + order sign/cancel guards in the daemon, and the swap mock.
Expand Down
5 changes: 3 additions & 2 deletions crates/deckard-contract/tests/deny_vocabulary.rs
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@ const PREFIX_BUILDERS: &[&str] = &[
"broadcast_failed",
];

/// The complete frozen vocabulary: 30 static tags + 4 dynamic-prefix tags. Editing this list
/// The complete frozen vocabulary: 31 static tags + 4 dynamic-prefix tags. Editing this list
/// is the deliberate gate — change it here, in `deny_reasons.rs`, AND (for a real, non-test
/// tag) in `docs/build/31-agent-quickstart.md`. `swap_unsupported_in_mock` is test-surface
/// only and is intentionally absent from the docs table.
Expand Down Expand Up @@ -88,6 +88,7 @@ const FROZEN: &[&str] = &[
r::MALFORMED_REQUEST,
r::DERIVATION_UNVERIFIED,
r::SHIELD_UNAVAILABLE,
r::RESOLVE_NOT_AUTHORIZED,
// swap v1
r::APPROVE_WITH_VALUE,
r::APPROVE_WRONG_SPENDER,
Expand Down Expand Up @@ -428,7 +429,7 @@ fn frozen_set_matches_module_exports() {
fn frozen_set_is_exactly_documented() {
assert_eq!(
FROZEN.len(),
34,
35,
"added/removed a Deny tag? update FROZEN, deny_reasons.rs, and the docs table"
);

Expand Down
6 changes: 4 additions & 2 deletions crates/deckard-signerd/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -62,8 +62,10 @@ serde_json = "1"
alloy = { version = "1", features = ["provider-http", "network", "rpc-types", "signer-local", "eips"] }
alloy-primitives = { workspace = true }

# Peer-cred uid (geteuid) + the single-instance flock.
nix = { version = "0.29", features = ["fs", "user"] }
# Peer-cred uid (geteuid) + the single-instance flock + `fcntl` FD_CLOEXEC hygiene (all under
# `fs`), plus `socket` for the resolver capability channel's `socketpair()` (PRD-01). Same
# crate as before — one new feature flag (`socket`), no new dependency.
nix = { version = "0.29", features = ["fs", "user", "socket"] }
zeroize = "1"
anyhow = "1"

Expand Down
15 changes: 4 additions & 11 deletions crates/deckard-signerd/src/client.rs
Original file line number Diff line number Diff line change
Expand Up @@ -140,17 +140,10 @@ impl SignerClient {
}
}

/// Blocking resolve: close a `NeedsApproval` loop by flipping its `Pending` record
/// to `Allowed` (`approved: true`) or `Denied` (`approved: false`).
pub fn resolve_blocking(&self, request_id: RequestId, approved: bool) -> anyhow::Result<()> {
match self.request_blocking(&SignerRequest::Resolve {
request_id,
approved,
})? {
SignerResponse::Ack => Ok(()),
other => Err(unexpected("Resolve", other)),
}
}
// NOTE: there is deliberately no `resolve` helper on this PUBLIC client. Since PRD-01 the
// daemon refuses `Resolve` on the public proposer socket (`resolve_not_authorized`);
// approval travels the private capability channel only — see
// [`deckard_signerd::ControlChannel::resolve`].

/// Blocking: fetch the read-only Railgun view grant (0zk address + viewing key) for
/// shielded-balance sync. A locked daemon or a failed derivation gate comes back as a
Expand Down
Loading
Loading