You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
feat(pairing): dial loopback relays by handle (E4) + close MCP poisoned-card gap (#358)
* feat(pairing): dial loopback relays by handle (E4) + close MCP poisoned-card gap
`wire dial nick@127.0.0.1:8771` now reaches a local-dev / sandbox relay. The
federation-handle domain validator rejected any `:port`, so a custom-port
loopback relay couldn't be named in a handle at all (the V0_13_2 E4 item).
- `endpoints::is_loopback_host` — one shared predicate (exact `127.0.0.1` /
`localhost` / `::1`) now used by `infer_scope_from_url`, the validator, the
URL builder, and the phishing-warning suppression, so scheme and scope
classification can't drift.
- `is_valid_domain` accepts a `host:port` authority ONLY for a loopback host
(port 1..=65535). Non-loopback host+port stays rejected — public handles are
port-less; a public relay on a custom port belongs behind a 443 TLS edge.
- new `relay_url_for_domain` picks `http://` for a loopback authority, `https://`
otherwise, replacing the 4 unconditional `format!("https://{domain}")` fallback
sites (pair_profile, cli/pairing, cli/setup `wire up --relay`, mcp tool_add).
- `is_known_relay_domain` treats loopback as known — no spurious cross-relay
phishing warning for the operator's own machine.
Security (from the adversarial design review):
- MCP `tool_add` now runs the same poisoned-card DID-key fingerprint hard-refuse
the CLI `cmd_add` had — a rogue relay serving a card whose key != its claimed
DID is rejected on BOTH paths (was CLI-only; E4's loopback path made the MCP
gap newly reachable).
- THREAT_MODEL T14 documents the loopback-SSRF trade-off (prompt-injected dial to
a loopback port); bilateral accept + the fingerprint refuse remain the gates.
Tests: loopback parse / non-loopback reject / port-range unit tests +
relay_url_for_domain scheme regression guard; in-situ `wire dial nick@127.0.0.1:PORT`
pair + message round-trip over http loopback. Full lib suite green (615).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XmVWMe6fE54U1drShE1yMT
* fix(pairing): address E4 gate-2 review — drop ::1, honest predicate doc, +tests
Built-thing review of the E4 change (3 MINOR, no blockers):
- Exclude IPv6 `::1` from `is_loopback_host`: it was accepted by the validator
but `relay_url_for_domain` produced a bracket-less, malformed `http://::1:port`.
IPv6 loopback handles aren't a real use case and need `[::1]:port` bracketing
the handle path doesn't carry — reject cleanly instead. (`::1` was vestigial in
`infer_scope_from_url` anyway: its host-extraction never yields a bare `::1`.)
- Correct the `is_loopback_host` doc: it's the single predicate for the E4
TRUST-PATH gates, not literally every loopback check — `session.rs::url_is_loopback`
is a separate, deliberately-broader /8 predicate for same-box session discovery.
- Tests: reject `bob@:8771` (empty host) and `bob@::1:8771` (IPv6 exclusion).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XmVWMe6fE54U1drShE1yMT
---------
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
handles (so `wire dial` reaches a local-dev / sandbox relay) widens this residual:
213
+
a prompt-injected agent told to dial `foo@127.0.0.1:<port>` now makes a
214
+
`GET http://127.0.0.1:<port>/.well-known/wire/agent` against an arbitrary loopback
215
+
port (blind SSRF — the response is verified-or-discarded locally, never returned to
216
+
the attacker; non-loopback `host:port` stays rejected, so the surface is loopback
217
+
only). The bilateral `wire_accept` gate is unaffected — no pair completes without
218
+
operator consent — and the poisoned-card key/DID-fingerprint hard-refuse now fires
219
+
on the MCP `tool_add` path too (parity with CLI `cmd_add`), so a rogue loopback
220
+
relay serving a substituted card is rejected. A host wanting a tighter gate can key
221
+
off the loopback target before letting an agent auto-dial; wire surfaces the dial
222
+
target in the tool args so the host has the hook.
223
+
211
224
## Threat T13 — relay process compromise leaks to other host workloads
212
225
213
226
**Threat:** the wire relay process (or any wire process) is exploited via a memory-safety bug in a Rust dependency, an axum/hyper HTTP CVE, or a malicious crate in the supply chain. Attacker now has code execution as the user that owns the wire process. On a shared host running wire alongside other workloads (a Spark box running forge / slancha-api / training pipelines / SSH keys / Anthropic API keys / etc.), this is a *lateral movement* problem distinct from the wire protocol's threat model.
| E4 | domain validator rejects loopback/IP → `dial`/`add` can't express `nick@127.0.0.1:8771`|✅ DONE (2026-06-29) |`is_valid_domain` accepts a loopback `host:port` (exact `127.0.0.1`/`localhost` via shared `endpoints::is_loopback_host`); new `relay_url_for_domain` picks `http://` for loopback at all 4 URL sites; loopback phishing-warn suppressed; MCP `tool_add` gained the CLI's poisoned-card fingerprint refuse; SSRF trade-off noted in THREAT_MODEL T14|
41
41
| E5 |`dial <peer>` on already-pinned returns `already_pinned`, won't refresh endpoints → peer that binds local AFTER pairing can't upgrade | bug |`wire repin/refresh <peer>`|
42
42
| E2 | daemon never serviced a bind-relay'd local slot (`run_sync_pull` pulled only the primary endpoint) | 🟡 rc6 | pull ALL self endpoints with per-slot cursors; resilient to one slot erroring |
43
43
| E1 |`wire up` doesn't register a local session / auto-start|detect local relay → same-box defaults to federation | feature ||
"REFUSING to pair `{handle}` — the resolved card's key fingerprint ({}) does not match its claimed DID `{peer_did}` (poisoned discovery: the relay served a card whose key ≠ its identity). Verify with the peer out-of-band.",
1732
+
peer_fp.as_deref().unwrap_or("?")
1733
+
));
1734
+
}
1735
+
1719
1736
let peer_handle = crate::agent_card::display_handle_from_did(&peer_did).to_string();
0 commit comments