Skip to content

Commit e5bfb6c

Browse files
laulpoganclaude
andauthored
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>
1 parent 97c47de commit e5bfb6c

10 files changed

Lines changed: 204 additions & 29 deletions

File tree

SANDBOX_HARDEN_SPEC.md

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,31 @@ plan → gate#1 → develop → in-situ test → gate#2 → loop).
9494
sandbox/loopback relay can't be named in a `wire dial nick@host:port` handle.
9595
Recommend relaxing for local scope (V0_13_2 E4). Workaround shipped in docs: invite flow.
9696

97+
## E4 — loopback handle port support (iteration 2, branch e4-local-relay, operator-authorized 2026-06-29)
98+
99+
Tier HIGH (trust-path: federation handle domain validation). Goal: `wire dial nick@127.0.0.1:8771`
100+
reaches a loopback relay. Scope = **loopback only** (the clean boundary: loopback ⟹ http is certain;
101+
internal-DNS-name ⟹ http/https is a guess → those keep using the invite flow).
102+
103+
Design:
104+
1. `is_valid_domain` (pair_profile.rs:163): split optional `:port` (rsplit_once); if port present,
105+
accept ONLY when host is loopback (`localhost` / 127.0.0.0/8); validate port 1..=65535 + host labels.
106+
Non-loopback + port stays REJECTED (preserves port-less public-handle convention). No-port unchanged.
107+
2. New `relay_url_for_domain(domain)``http://` for loopback host, else `https://`. Replaces the 3
108+
`format!("https://{}", domain)` fallback sites (pair_profile.rs:266, cli/pairing.rs:1176, mcp.rs:1730).
109+
3. `is_known_relay_domain` (pairing.rs:685): loopback = implicitly-known (suppress spurious phishing warn).
110+
111+
TDD contract (runnable check):
112+
- parse_handle Ok: `n@127.0.0.1:8771`, `n@localhost:8771`, `n@127.0.0.1` (already), `n@wireup.net` (unchanged).
113+
- parse_handle Err: `n@evil.com:1337`, `n@wireup.net:8443` (non-loopback+port), `n@127.0.0.1:0`,
114+
`n@127.0.0.1:99999`, `n@:8771`, `n@127.0.0.1:abc`.
115+
- relay_url_for_domain: `127.0.0.1:8771`→http, `localhost:9`→http, `wireup.net`→https (public unchanged).
116+
- in-situ: `wire dial n@127.0.0.1:<port>` to a local relay → pairs + message lands.
117+
118+
Gate#1 (design persona review): security + protocol/compat — DISPATCHED. Key risk under review:
119+
SSRF via `tool_add`/auto-dial on untrusted input hitting a loopback port (note: wire already GETs
120+
well-known on any operator-chosen domain; loopback is the sensitive subset, response not returned to attacker).
121+
97122
## Gate ledger
98123
- Gate#1 (plan): folded into gate#2 (de-scoped to MEDIUM — no trust-path code change).
99124
- Gate#2 (built-thing review over diff): **PASS after fixes.** 3 parallel Sonnet reviewers

docs/SANDBOXES.md

Lines changed: 23 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -44,28 +44,32 @@ message between two *separate* container identities, see the
4444
"custom-port relays" note below — on a non-wireup relay you must pair with the
4545
**invite flow**, not `wire dial <handle>`.
4646

47-
## Custom-port / loopback / sandbox relays — use the invite flow
47+
## Custom-port relays — loopback by handle (E4), or the invite flow
4848

4949
A wire **federation handle** is `nick@domain` and assumes a public, port-less
50-
HTTPS domain (e.g. `alice@wireup.net`, implicitly `:443`). The domain validator
51-
(`src/pair_profile.rs::is_valid_domain`) **rejects any `:port` suffix** — the `:`
52-
fails its per-label character check — so a custom-port relay can't be named in a
53-
handle at all. (A bare single-label host like `nick@relay` *passes* the validator
54-
but then fails at DNS/TCP, not with a clean error.) So inside a sandbox where the
55-
relay is `http://relay:8770` or `http://127.0.0.1:8771`:
56-
57-
```text
58-
wire dial knit-ash@relay:8770 -> error: domain "relay:8770" invalid
59-
— must be lowercase ASCII, dot-separated
50+
HTTPS domain (e.g. `alice@wireup.net`, implicitly `:443`).
51+
52+
**Loopback relays are dialable by handle (E4).** `is_valid_domain` accepts a
53+
`:port` suffix when the host is a loopback literal (`127.0.0.1` / `localhost`),
54+
and the client speaks `http://` to it (local relays don't terminate TLS). So a
55+
same-box / loopback sandbox relay works directly:
56+
57+
```bash
58+
wire dial knit-ash@127.0.0.1:8771 "hello" # resolves over http://127.0.0.1:8771
6059
```
6160

62-
**This is expected.** Pair via the invite flow instead, which carries the full
63-
relay URL (port included) in the invite token and bypasses handle resolution
64-
entirely — validated container-to-container on a `:8770` relay:
61+
Non-loopback hosts stay port-less (`evil.com:8443` is rejected) — a public relay
62+
on a custom port should sit behind a 443 TLS edge (Cloudflare Tunnel / Caddy).
63+
64+
**A custom-port relay reached by an internal DNS name** (e.g. the Docker
65+
service name `relay.wire.local:8770`) still can't be a handle: http-vs-https
66+
isn't inferable from the name. Pair via the **invite flow**, which carries the
67+
full relay URL (scheme + port) in the token — validated container-to-container
68+
on a `:8770` relay:
6569

6670
```bash
6771
# On peer A (already `wire up`-ed against the local relay):
68-
A_INVITE=$(wire invite --relay http://relay:8770 --json | jq -r .invite_url)
72+
A_INVITE=$(wire invite --relay http://relay.wire.local:8770 --json | jq -r .invite_url)
6973

7074
# On peer B:
7175
wire accept-invite "$A_INVITE" # pins A, exchanges signed cards
@@ -76,10 +80,10 @@ Same-box sibling sessions (one `$WIRE_HOME`, distinct `WIRE_SESSION_ID`) can use
7680
bare-nick `wire dial` directly — they resolve as local sisters, not federation.
7781
See `scripts/hello-world-validate.sh` for that path.
7882

79-
> Limitation tracked: relaxing `is_valid_domain` to accept `host:port` for
80-
> local/loopback scope (so `wire dial` works to a sandbox relay) is the E4 item
81-
> in `docs/V0_13_2_PLATFORM_HARDENING.md`. Until that lands, the invite flow is
82-
> the supported sandbox-relay pairing path.
83+
> Security note: loopback handle dialing widens the prompt-injection SSRF
84+
> surface to loopback ports (T14 in `docs/THREAT_MODEL.md`); the bilateral
85+
> `wire_accept` gate and the poisoned-card fingerprint hard-refuse (now on the
86+
> MCP path too) still apply.
8387
8488
## OpenShell (egress-policy sandboxes)
8589

docs/THREAT_MODEL.md

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -208,6 +208,19 @@ in hostile-host scenarios. Documented as host responsibility. Operators
208208
choosing an MCP host should prefer one with explicit user-confirmation
209209
primitives for trust-mutating tools.
210210

211+
**E4 trade-off (loopback handle ports, 2026-06-29):** allowing `nick@127.0.0.1:PORT`
212+
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+
211224
## Threat T13 — relay process compromise leaks to other host workloads
212225

213226
**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.

docs/V0_13_2_PLATFORM_HARDENING.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,7 @@ Full rc10 Windows matrix GREEN (glossy): B + E2-bidirectional + pair-all-local +
3737
| id | gap | tier | note |
3838
|----|-----|------|------|
3939
| E3 | `add-peer-slot` REPLACES endpoints, doesn't merge → clobbers federation route (data loss) | 🟡 rc5 | now additive — upsert by relay_url into peer `endpoints[]` |
40-
| E4 | domain validator rejects loopback/IP → `dial`/`add` can't express `nick@127.0.0.1:8771` | bug | relax validator when scope=local |
40+
| 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 |
4141
| 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>` |
4242
| 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 |
4343
| E1 | `wire up` doesn't register a local session / auto-start|detect local relay → same-box defaults to federation | feature | |

src/cli/mod.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ mod upgrade;
3333

3434
pub(crate) use comms::here_summary;
3535
pub(crate) use comms::parse_deadline_until;
36+
pub(crate) use pairing::resolved_key_fingerprint;
3637
pub(crate) use relay::cmd_bind_relay;
3738
pub use relay::error_smells_like_slot_4xx;
3839
pub use relay::run_sync_pull;

src/cli/pairing.rs

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -663,7 +663,7 @@ fn print_resolved_profile(resolved: &Value) {
663663
/// a mismatch means the relay served a card whose key does not match its own
664664
/// DID (a poisoned-discovery red flag). `matches` is false when the card has no
665665
/// usable key or `did_fp` is empty. Pure → unit-tested.
666-
fn resolved_key_fingerprint(card: &Value, did: &str) -> (Option<String>, String, bool) {
666+
pub(crate) fn resolved_key_fingerprint(card: &Value, did: &str) -> (Option<String>, String, bool) {
667667
let did_fp = did.rsplit('-').next().unwrap_or("").to_string();
668668
let computed = card
669669
.get("verify_keys")
@@ -686,6 +686,17 @@ fn is_known_relay_domain(peer_domain: &str, our_relay_url: &str) -> bool {
686686
// Hard-coded known-good list. wireup.net is the default relay.
687687
const KNOWN_GOOD: &[&str] = &["wireup.net", "wire.laulpogan.com"];
688688
let peer_domain = peer_domain.trim().to_ascii_lowercase();
689+
// E4: a loopback authority is the operator's OWN machine — there is no
690+
// off-box relay to impersonate, so suppress the cross-relay phishing
691+
// warning. (`peer_domain` carries the port, e.g. `127.0.0.1:8771`; strip it
692+
// before the loopback check.)
693+
let peer_host = peer_domain
694+
.rsplit_once(':')
695+
.map(|(h, _)| h)
696+
.unwrap_or(peer_domain.as_str());
697+
if crate::endpoints::is_loopback_host(peer_host) {
698+
return true;
699+
}
689700
if KNOWN_GOOD.iter().any(|k| *k == peer_domain) {
690701
return true;
691702
}
@@ -1173,7 +1184,7 @@ pub(super) fn cmd_add(
11731184
.and_then(Value::as_str)
11741185
.map(str::to_string)
11751186
.or_else(|| relay_override.map(str::to_string))
1176-
.unwrap_or_else(|| format!("https://{}", parsed.domain));
1187+
.unwrap_or_else(|| crate::pair_profile::relay_url_for_domain(&parsed.domain));
11771188

11781189
// 3. Pin peer in trust + relay-state. slot_token will arrive via ack.
11791190
config::update_trust(|trust| {

src/cli/setup.rs

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,11 @@ pub(crate) fn cmd_up(
3030
if r.starts_with("http://") || r.starts_with("https://") {
3131
r.to_string()
3232
} else {
33-
format!("https://{r}")
33+
// E4: schemeless relay arg → http:// for a loopback authority
34+
// (local relays speak plaintext), https:// otherwise. Without
35+
// this, `wire up --relay 127.0.0.1:8771` would publish an
36+
// https:// self-relay and the loopback slot would be dead.
37+
crate::pair_profile::relay_url_for_domain(r)
3438
}
3539
}
3640
None => crate::pair_invite::DEFAULT_RELAY.to_string(),

src/endpoints.rs

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -483,13 +483,32 @@ pub fn infer_scope_from_url(url: &str) -> EndpointScope {
483483
.split(':')
484484
.next()
485485
.unwrap_or("");
486-
if host == "127.0.0.1" || host == "localhost" || host == "::1" {
486+
if is_loopback_host(host) {
487487
EndpointScope::Local
488488
} else {
489489
EndpointScope::Federation
490490
}
491491
}
492492

493+
/// True iff `host` (no scheme, no port) is a loopback address the E4 trust-path
494+
/// gates treat as `Local` scope: `infer_scope_from_url`, the handle validator +
495+
/// URL builder (`pair_profile::is_valid_domain` / `relay_url_for_domain`), and
496+
/// the `is_known_relay_domain` phishing-warning suppression all key off THIS one
497+
/// predicate so scheme + scope can never disagree. Keeping one predicate is
498+
/// load-bearing: if these gates disagreed on "loopback", a handle could parse +
499+
/// get an `http://` URL while being classified `Federation` (advertised off-box).
500+
///
501+
/// IPv4 `127.0.0.1` + `localhost` only. IPv6 `::1` is intentionally excluded — an
502+
/// IPv6 authority needs bracketing (`[::1]:port`) the handle/URL path doesn't
503+
/// carry, so `nick@::1:port` is rejected rather than half-accepted into a
504+
/// malformed `http://::1:port`; use `127.0.0.1` for a loopback handle. Do not
505+
/// broaden the IPv4 set to the full /8 here without updating every caller. (NB:
506+
/// `session.rs::url_is_loopback` is a SEPARATE, deliberately-broader /8 predicate
507+
/// for same-box session discovery — not a trust gate.)
508+
pub fn is_loopback_host(host: &str) -> bool {
509+
host == "127.0.0.1" || host == "localhost"
510+
}
511+
493512
/// True iff this endpoint set is reachable ONLY from the same box — every
494513
/// endpoint resolves (by URL) to a loopback/`Local` or `Uds` address, with no
495514
/// off-box `Federation`/LAN host. A peer pinned this way can't complete a

src/mcp.rs

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1716,6 +1716,23 @@ fn tool_add(args: &Value) -> Result<Value, String> {
17161716
.and_then(Value::as_str)
17171717
.ok_or("resolved missing did")?
17181718
.to_string();
1719+
1720+
// Poisoned-discovery hard-refuse — parity with CLI `cmd_add` (#247 finding 4).
1721+
// A relay can serve a card with a VALID self-signature (so resolve_handle's
1722+
// verify passes) whose key does NOT hash to the fingerprint in its claimed
1723+
// DID — identity substitution. The CLI refused this; the MCP path did not, so
1724+
// an agent-driven `wire_dial` would pin it. E4 widens the reachable relay set
1725+
// (loopback), making the gap newly exploitable via a rogue local relay — so
1726+
// close it on both paths.
1727+
let (peer_fp, _did_fp, fp_matches) =
1728+
crate::cli::resolved_key_fingerprint(&peer_card, &peer_did);
1729+
if peer_fp.is_some() && !fp_matches {
1730+
return Err(format!(
1731+
"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+
17191736
let peer_handle = crate::agent_card::display_handle_from_did(&peer_did).to_string();
17201737
let peer_slot_id = resolved
17211738
.get("slot_id")
@@ -1727,7 +1744,7 @@ fn tool_add(args: &Value) -> Result<Value, String> {
17271744
.and_then(Value::as_str)
17281745
.map(str::to_string)
17291746
.or_else(|| relay_override.map(str::to_string))
1730-
.unwrap_or_else(|| format!("https://{}", parsed.domain));
1747+
.unwrap_or_else(|| crate::pair_profile::relay_url_for_domain(&parsed.domain));
17311748

17321749
// Pin peer in trust + relay-state. slot_token arrives via ack later.
17331750
crate::config::update_trust(|trust| {

0 commit comments

Comments
 (0)