From d97b474e8ec13b509a03d4dc3bf6e91d94d893dc Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Thu, 17 Sep 2026 18:32:31 +0100 Subject: [PATCH 01/12] feat(core): add GatewayTrust, the cross-machine gateway allowlist An in-memory, deny-by-default set of trusted remote device-ids, keyed per individual device rather than per remote machine since wire-mesh-core's relay-hub protocol carries no field attributing a gossiped entry or relayed request to its originating gateway connection. Not yet wired into the hub forwarding/session paths. --- src/core/gateway-trust.ts | 37 +++++++++++++++++++ src/test/gateway-trust.test.ts | 67 ++++++++++++++++++++++++++++++++++ 2 files changed, 104 insertions(+) create mode 100644 src/core/gateway-trust.ts create mode 100644 src/test/gateway-trust.test.ts diff --git a/src/core/gateway-trust.ts b/src/core/gateway-trust.ts new file mode 100644 index 00000000..6216d226 --- /dev/null +++ b/src/core/gateway-trust.ts @@ -0,0 +1,37 @@ +/** + * GatewayTrust -- the cross-machine trust boundary (agent-comms#156, agent-comms#153's third leg): an allowlist of remote device-ids this machine's gateway will advertise its local agents to, accept forwarded hub traffic from, and route outbound hub requests to. Deny-all by default: empty until an operator explicitly trusts at least one remote device, the same no-CA pin-the-key model ordinary peer connections already use. + * + * In-memory only, deliberately mirroring the precedent set by v1's own FederationManager.trustedFingerprints (retired with federation.ts, agent-comms#4232b08) -- neither persists to disk, so trust is re-established each run rather than carried across restarts. This isn't a gap being deferred: v1 never persisted its own equivalent allowlist either, so no existing behaviour is being narrowed by keeping this one in memory too. + * + * Keyed by individual device-id, not by "one entry per remote machine": wire-mesh-core's relay-hub protocol (relay-hub.ts, gossip-frame, relay-data-frame) carries no field identifying which remote gateway connection a given directory entry or relayed request actually originated from -- only the entry/request's own device-id, which may be an ordinary local peer forwarded on a remote machine's behalf rather than that machine's own coordinator. Gating per individual device-id is therefore the finest-grained, and only wire-protocol-honest, trust boundary actually implementable without a wire-mesh-core protocol change (deliberately out of scope here, matching agent-comms#156's own "gating the hub itself is out of scope" framing) -- confirmed as the intended granularity by hub-session.ts's own pre-existing isStateMutatingMessage doc comment, which already named this exact gap as "agent-comms#156's own future deliverable" of "per-peer" admission control. An operator who wants every local peer on a remote machine reachable trusts each of that machine's device-ids individually, not just its coordinator's. + */ +export class GatewayTrust { + private readonly trusted = new Set(); + + /** Marks a remote device-id (hex, case-insensitive) as trusted: this side will merge its gossiped directory entries, dispatch its relayed requests, and route outbound hub requests to it. Idempotent. */ + add(deviceHex: string): void { + this.trusted.add(deviceHex.toLowerCase()); + } + + /** Withdraws a previously trusted device-id (hex, case-insensitive). A no-op if it was never trusted. Mirrors FederationManager.removeTrustedFingerprint's own precedent: already-merged directory entries and in-flight requests are unaffected -- this governs future traffic only. */ + remove(deviceHex: string): void { + this.trusted.delete(deviceHex.toLowerCase()); + } + + /** Every currently trusted device-id, lowercase hex, in insertion order. */ + list(): string[] { + return [...this.trusted]; + } + + /** Whether the given device-id (hex, case-insensitive) is currently trusted. */ + isTrusted(deviceHex: string): boolean { + return this.trusted.has(deviceHex.toLowerCase()); + } + + /** + * Whether at least one remote device is currently trusted -- the outbound gossip gate. wire-mesh-core's relay-hub broadcasts a gossiped advert to every connected hub peer with no per-recipient targeting (RelayHub.handleConnection's own "re-broadcasts each gossip frame to every other connected client"), so "advertise local agents only to allowlisted remote gateways" can only be approximated at the coarse granularity this side actually controls: don't advertise anything at all until the operator has opted in by trusting at least one remote device. Once true, an advertisement still reaches every hub-connected peer, trusted or not -- the per-device isTrusted() check above is what keeps this side from ACTING on anything an untrusted peer sends back, which is the boundary that actually matters. + */ + hasAny(): boolean { + return this.trusted.size > 0; + } +} diff --git a/src/test/gateway-trust.test.ts b/src/test/gateway-trust.test.ts new file mode 100644 index 00000000..d5c34ea5 --- /dev/null +++ b/src/test/gateway-trust.test.ts @@ -0,0 +1,67 @@ +/** + * Direct unit tests for GatewayTrust -- the cross-machine trust boundary's own allowlist (agent-comms#156), tested standalone against no real transport or hub socket, mirroring coordinator-gateway.test.ts's own approach for the sibling gateway-lifecycle class. + */ +import { describe, expect, it } from "vitest"; +import { GatewayTrust } from "../core/gateway-trust.js"; + +describe("GatewayTrust", () => { + it("trusts nobody by default", () => { + const trust = new GatewayTrust(); + expect(trust.hasAny()).toBe(false); + expect(trust.isTrusted("aabbcc")).toBe(false); + expect(trust.list()).toEqual([]); + }); + + it("trusts a device once added", () => { + const trust = new GatewayTrust(); + trust.add("AABBCC"); + expect(trust.isTrusted("aabbcc")).toBe(true); + expect(trust.hasAny()).toBe(true); + expect(trust.list()).toEqual(["aabbcc"]); + }); + + it("normalises hex case so add and isTrusted agree regardless of casing", () => { + const trust = new GatewayTrust(); + trust.add("AaBbCc"); + expect(trust.isTrusted("aabbcc")).toBe(true); + expect(trust.isTrusted("AABBCC")).toBe(true); + }); + + it("is idempotent: adding the same device twice keeps it listed once", () => { + const trust = new GatewayTrust(); + trust.add("aabbcc"); + trust.add("AABBCC"); + expect(trust.list()).toEqual(["aabbcc"]); + }); + + it("stops trusting a device once removed", () => { + const trust = new GatewayTrust(); + trust.add("aabbcc"); + trust.remove("AABBCC"); + expect(trust.isTrusted("aabbcc")).toBe(false); + expect(trust.list()).toEqual([]); + }); + + it("removing a device that was never trusted is a safe no-op", () => { + const trust = new GatewayTrust(); + expect(() => { + trust.remove("aabbcc"); + }).not.toThrow(); + expect(trust.list()).toEqual([]); + }); + + it("hasAny reflects removal down to empty", () => { + const trust = new GatewayTrust(); + trust.add("aabbcc"); + trust.remove("aabbcc"); + expect(trust.hasAny()).toBe(false); + }); + + it("lists every currently trusted device once each device-id is trusted, unaffected by another device's own trust/untrust", () => { + const trust = new GatewayTrust(); + trust.add("aabbcc"); + trust.add("ddeeff"); + trust.remove("aabbcc"); + expect(trust.list()).toEqual(["ddeeff"]); + }); +}); From e56683b03cc55102cf7bc52a71d9b3ed0d4ee141 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Thu, 17 Sep 2026 18:42:22 +0100 Subject: [PATCH 02/12] feat(core): gate hub gossip forwarding on gateway trust forwardAdvertsToHub/pushHubCatchUp now refuse to advertise anything onto the hub until at least one remote device is trusted. Coarse by necessity: wire-mesh-core's relay-hub broadcasts a gossip frame to every connected peer with no per-recipient targeting, so this approximates "advertise only to allowlisted gateways" as "advertise nothing until the operator has opted in by trusting someone". --- src/core/hub-forwarding.ts | 9 ++-- src/test/hub-forwarding.test.ts | 91 +++++++++++++++++++++++++++++++++ 2 files changed, 97 insertions(+), 3 deletions(-) create mode 100644 src/test/hub-forwarding.test.ts diff --git a/src/core/hub-forwarding.ts b/src/core/hub-forwarding.ts index cfd73681..5a75d56f 100644 --- a/src/core/hub-forwarding.ts +++ b/src/core/hub-forwarding.ts @@ -15,13 +15,15 @@ import type { import type { HubSession } from "./hub-session.js"; import { AGENT_SELF_GOSSIP_KEY } from "./wire-mesh-transport.js"; -/** Forwards every directory entry carrying an agent/self extension onto hub, if hub currently holds a live connection -- a no-op otherwise, which is every local peer except whichever one is currently the gateway. Called both from WireMeshTransport's own watchForDisconnect (as each local peer session's directory changes) and from connectHub's own initial catch-up push, so this side's already-known local devices reach the hub immediately on taking over the gateway role rather than waiting for their own next periodic gossip tick. Forwards unconditionally on every call (no caching or dedup against a previous call), mirroring relay-hub.ts's own "forward every gossip frame, as received" philosophy: an advert's own snapshot-seconds/addresses are meant to keep propagating as a liveness heartbeat, so suppressing a "duplicate" would silently stop legitimate freshness updates from reaching remote gateways. A forward that fails is reported via onError and swallowed, matching every other best-effort gossip send in this codebase. */ +/** Forwards every directory entry carrying an agent/self extension onto hub, if hub currently holds a live connection AND at least one remote gateway is currently trusted -- a no-op otherwise. The trust gate (agent-comms#156, GatewayTrust.hasAny's own doc) is coarse by necessity: wire-mesh-core's relay-hub broadcasts a gossip frame to every connected peer with no per-recipient targeting, so "advertise only to allowlisted remote gateways" can only be approximated as "advertise nothing at all until the operator has trusted someone" -- it is not a per-recipient filter. Called both from WireMeshTransport's own watchForDisconnect (as each local peer session's directory changes) and from connectHub's own initial catch-up push, so this side's already-known local devices reach the hub immediately on taking over the gateway role rather than waiting for their own next periodic gossip tick. Forwards unconditionally on every call once past the gates above (no caching or dedup against a previous call), mirroring relay-hub.ts's own "forward every gossip frame, as received" philosophy: an advert's own snapshot-seconds/addresses are meant to keep propagating as a liveness heartbeat, so suppressing a "duplicate" would silently stop legitimate freshness updates from reaching remote gateways. A forward that fails is reported via onError and swallowed, matching every other best-effort gossip send in this codebase. */ export function forwardAdvertsToHub( hub: Readonly>, directory: readonly DirectoryEntry[], onError: ((error: Error) => void) | undefined, + hasAnyTrustedGateway: () => boolean, ): void { if (!hub.isConnected) return; + if (!hasAnyTrustedGateway()) return; const eligible = directory.filter( (entry) => entry.advert[AGENT_SELF_GOSSIP_KEY] !== undefined, ); @@ -31,17 +33,18 @@ export function forwardAdvertsToHub( }); } -/** Catches the hub up with every local device already known at the moment this side becomes the gateway (connectHub's own trailing call, right after hub.connect resolves) -- without this, a device whose own last gossip arrived before this coordinator took over the gateway role would never be (re-)advertised until its own next periodic gossip tick (hub-side state is rebuilt from scratch on every takeover, per coordinator-gateway.ts's own class doc). Reuses forwardAdvertsToHub's own eligibility filter, so only ever a visible, agent/self-bearing device is pushed, exactly as an ordinary directory-change forward would. */ +/** Catches the hub up with every local device already known at the moment this side becomes the gateway (connectHub's own trailing call, right after hub.connect resolves) -- without this, a device whose own last gossip arrived before this coordinator took over the gateway role would never be (re-)advertised until its own next periodic gossip tick (hub-side state is rebuilt from scratch on every takeover, per coordinator-gateway.ts's own class doc). Reuses forwardAdvertsToHub's own eligibility filter and trust gate, so only ever a visible, agent/self-bearing device is pushed, and only once a remote gateway is trusted, exactly as an ordinary directory-change forward would. */ export function pushHubCatchUp( hub: Readonly>, knownDevices: ReadonlyMap>, onError: ((error: Error) => void) | undefined, + hasAnyTrustedGateway: () => boolean, ): void { const catchUp = [...knownDevices.values()].map((advert) => ({ device: advert.device, advert, })); - forwardAdvertsToHub(hub, catchUp, onError); + forwardAdvertsToHub(hub, catchUp, onError, hasAnyTrustedGateway); } /** Routes a room-domain request through the hub's relay-connect/relay-data pairing when memberId isn't a local peer session -- WireMeshTransport.sendRoomRequest's own fallback, since the member may be a remote agent reachable only via this machine's gateway connection (agent-comms#155's local-to-remote leg). Resolves the same not_connected outcome sendRoomRequest already returned before the hub existed at all when this side isn't currently the gateway. */ diff --git a/src/test/hub-forwarding.test.ts b/src/test/hub-forwarding.test.ts new file mode 100644 index 00000000..09b91854 --- /dev/null +++ b/src/test/hub-forwarding.test.ts @@ -0,0 +1,91 @@ +/** + * Direct unit tests for forwardAdvertsToHub/pushHubCatchUp's own gateway-trust gate (agent-comms#156) -- previously only exercised indirectly through gateway-forwarding.integration.test.ts's real-hub harness. Uses a fake hub object (isConnected/advertiseDevices) rather than a real HubSession, mirroring gossip-directory.test.ts's own standalone-fixture approach for the sibling gossip-merge function. + */ +import { describe, expect, it, vi } from "vitest"; +import { + forwardAdvertsToHub, + pushHubCatchUp, +} from "../core/hub-forwarding.js"; +import type { DirectoryEntry } from "wire-mesh-core/domain/mesh-session"; +import type { PeerAdvert } from "wire-mesh-core/generated/protocol"; + +const DEVICE_ID_HEX_LENGTH = 64; +const DEVICE_A_HEX = "a".repeat(DEVICE_ID_HEX_LENGTH); + +function deviceIdBytes(hex: string): Uint8Array { + const bytes = new Uint8Array(new ArrayBuffer(hex.length / 2)); + for (let i = 0; i < bytes.length; i += 1) { + bytes[i] = Number.parseInt(hex.slice(i * 2, i * 2 + 2), 16); + } + return bytes; +} + +function advert(): PeerAdvert { + return { addresses: [], "snapshot-seconds": 1 } as unknown as PeerAdvert; +} + +function entry(hex: string): DirectoryEntry { + return { + device: deviceIdBytes(hex), + advert: { ...advert(), "agent/self": {} } as unknown as PeerAdvert, + }; +} + +function fakeHub(): { + isConnected: boolean; + advertiseDevices: ReturnType< + typeof vi.fn<(entries: readonly DirectoryEntry[]) => Promise> + >; +} { + return { + isConnected: true, + advertiseDevices: vi.fn().mockResolvedValue(undefined), + }; +} + +describe("forwardAdvertsToHub -- gateway-trust gate", () => { + it("does not advertise anything when no remote gateway is trusted", () => { + const hub = fakeHub(); + + forwardAdvertsToHub(hub, [entry(DEVICE_A_HEX)], undefined, () => false); + + expect(hub.advertiseDevices).not.toHaveBeenCalled(); + }); + + it("advertises once at least one remote gateway is trusted", () => { + const hub = fakeHub(); + + forwardAdvertsToHub(hub, [entry(DEVICE_A_HEX)], undefined, () => true); + + expect(hub.advertiseDevices).toHaveBeenCalledTimes(1); + }); + + it("still does nothing when not connected, even when trusted", () => { + const hub = fakeHub(); + hub.isConnected = false; + + forwardAdvertsToHub(hub, [entry(DEVICE_A_HEX)], undefined, () => true); + + expect(hub.advertiseDevices).not.toHaveBeenCalled(); + }); +}); + +describe("pushHubCatchUp -- gateway-trust gate", () => { + it("does not push the catch-up when no remote gateway is trusted", () => { + const hub = fakeHub(); + const knownDevices = new Map([[DEVICE_A_HEX, entry(DEVICE_A_HEX).advert]]); + + pushHubCatchUp(hub, knownDevices, undefined, () => false); + + expect(hub.advertiseDevices).not.toHaveBeenCalled(); + }); + + it("pushes the catch-up once at least one remote gateway is trusted", () => { + const hub = fakeHub(); + const knownDevices = new Map([[DEVICE_A_HEX, entry(DEVICE_A_HEX).advert]]); + + pushHubCatchUp(hub, knownDevices, undefined, () => true); + + expect(hub.advertiseDevices).toHaveBeenCalledTimes(1); + }); +}); From 8befce8a597c4446592d1edd86f0f07872e21a59 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Thu, 17 Sep 2026 18:42:38 +0100 Subject: [PATCH 03/12] feat(core): gate hub session dispatch and directory merge on gateway trust HubSessionDeps gains isTrusted; consume() now refuses to dispatch a relayed request from an untrusted device-id (a room-domain request gets an explicit unauthorized error, a legacy frame is silently acked and dropped), and connect()'s own directory-merge loop drops any entry whose device-id isn't trusted before it ever reaches onDirectory. The state_sync/state_update filter stays unconditional even for a now-trusted sender: gateway trust means "worth acting on", not "may directly overwrite this side's mesh state". --- src/core/hub-session.ts | 24 +++++++++++++++++++----- 1 file changed, 19 insertions(+), 5 deletions(-) diff --git a/src/core/hub-session.ts b/src/core/hub-session.ts index 30b798d9..7f0a98c8 100644 --- a/src/core/hub-session.ts +++ b/src/core/hub-session.ts @@ -54,6 +54,8 @@ export interface HubSessionDeps { request: IncomingManageRequest, handle: Readonly, ) => Promise; + /** The gateway trust boundary (agent-comms#156): whether the given device-id (hex) is currently trusted. Checked against every gossiped directory entry's own device and every relayed request's own fromDevice before this side merges or dispatches it -- see consume()/connect()'s own doc comments for exactly where and why. */ + isTrusted: (deviceHex: string) => boolean; } export class HubSession { @@ -108,13 +110,15 @@ export class HubSession { this.session = session; this.connection = connection; this.deps.trackForShutdown(session); - // Merge the hub's directory (its catch-up arrives as the first session events) and keep refreshing it on every subsequent one. Every remote entry is also surfaced via onDirectory, so the transport can merge it into its own mesh-wide knownDevices (agent-comms#155's remote-directory-merge leg). + // Merge the hub's directory (its catch-up arrives as the first session events) and keep refreshing it on every subsequent one. Every trusted remote entry is also surfaced via onDirectory, so the transport can merge it into its own mesh-wide knownDevices (agent-comms#155's remote-directory-merge leg). Filtered to isTrusted (agent-comms#156) before either hubPeersKnown tracking or onDirectory sees it: an untrusted device's gossiped presence is not merely withheld from listAgents, it is never even recorded as "known" here, so nothing downstream can act on it via any path this class exposes. void (async () => { const ownHex = deviceIdToHex(identity.deviceId); for await (const event of session.events) { if (this.deps.isShuttingDown()) break; const remoteEntries = event.directory.filter( - (entry) => deviceIdToHex(entry.device) !== ownHex, + (entry) => + deviceIdToHex(entry.device) !== ownHex && + this.deps.isTrusted(deviceIdToHex(entry.device)), ); for (const entry of remoteEntries) { this.hubPeersKnown.add(deviceIdToHex(entry.device)); @@ -129,7 +133,7 @@ export class HubSession { })(); } - /** Dispatches inbound relayed manage-requests: each is handled with a handle keyed by the SENDING device (request.fromDevice names it on relay-routed requests), so every downstream consumer sees the true origin, never the hub -- the same discipline extends to a real room-domain verb (agent-comms#155's "remote to local" leg) as it already applied to the legacy opaque-frame path. A legacy FRAME_VERB carrying state_sync/state_update is dropped before ever reaching onMessage/applyPatch -- see isStateMutatingMessage's own doc for why: the hub has no per-peer admission control yet (that lands in agent-comms#156), so accepting one from an arbitrary hub peer would let it directly patch this side's mesh state (a security review finding on agent-comms#169). A real room-domain verb (room.send, room.join, room.notify, ...) carries no equivalent risk -- it is independently gated by its own room:member capability token, verified regardless of which transport path it arrived over -- so it is dispatched to the same roomVerbHandlers a local peer session's own drainSession uses, via handleRoomRequest, against THIS side's own local mesh state. Known limitation, inherited from wire-mesh-core's own session layer rather than something agent-comms can fix here: a session tracks at most one active relay pairing per remote device (mesh-session.ts's own single relayPeerDevice slot), so a request relayed here is dispatched as "addressed to this gateway's own agent" unconditionally -- there is no target-device disambiguation available to route it on to a DIFFERENT local peer this gateway also advertises. Forwarding this gateway's own agent's traffic is therefore correct; a remote request genuinely meant for another local peer behind this same gateway is not yet distinguishable from one meant for this gateway's own agent. */ + /** Dispatches inbound relayed manage-requests: each is handled with a handle keyed by the SENDING device (request.fromDevice names it on relay-routed requests), so every downstream consumer sees the true origin, never the hub -- the same discipline extends to a real room-domain verb (agent-comms#155's "remote to local" leg) as it already applied to the legacy opaque-frame path. Every request is first checked against the gateway trust boundary (agent-comms#156, deps.isTrusted): a request with no fromDevice at all (senderHex falls back to the literal string "hub-peer", never a real trusted device-id) or an unrecognised fromDevice is never dispatched to either path below -- a legacy FRAME_VERB message is silently dropped (matching isStateMutatingMessage's own swallow-and-ack style, so an untrusted sender learns nothing about why), and a room-domain request gets an explicit `unauthorized` error rather than being dispatched, so its caller fails fast instead of waiting out HUB_ROOM_REQUEST_TIMEOUT_MS's local-session-side counterpart. A legacy FRAME_VERB carrying state_sync/state_update is dropped before ever reaching onMessage/applyPatch even from an otherwise-trusted sender -- see isStateMutatingMessage's own doc for why: gateway trust says "this device's traffic is worth acting on," not "this device may directly overwrite this side's mesh state," which is a strictly stronger claim the trust boundary here was never meant to grant (a security review finding on agent-comms#169). A real room-domain verb (room.send, room.join, room.notify, ...) carries no equivalent risk -- it is independently gated by its own room:member capability token, verified regardless of which transport path it arrived over -- so a trusted sender's request is dispatched to the same roomVerbHandlers a local peer session's own drainSession uses, via handleRoomRequest, against THIS side's own local mesh state. Known limitation, inherited from wire-mesh-core's own session layer rather than something agent-comms can fix here: a session tracks at most one active relay pairing per remote device (mesh-session.ts's own single relayPeerDevice slot), so a request relayed here is dispatched as "addressed to this gateway's own agent" unconditionally -- there is no target-device disambiguation available to route it on to a DIFFERENT local peer this gateway also advertises. Forwarding this gateway's own agent's traffic is therefore correct; a remote request genuinely meant for another local peer behind this same gateway is not yet distinguishable from one meant for this gateway's own agent. */ private consume(session: AcceptedMeshSession): void { void (async () => { for await (const request of session.incomingManageRequests) { @@ -138,8 +142,18 @@ export class HubSession { request.fromDevice !== undefined ? deviceIdToHex(request.fromDevice) : "hub-peer"; - this.hubPeersKnown.add(senderHex); const handle: Readonly = { id: senderHex }; + if (!this.deps.isTrusted(senderHex)) { + if (request.command.verb === FRAME_VERB) { + await request.respond({ result: "ok" }).catch(() => undefined); + continue; + } + await request + .respond({ result: "error", code: "unauthorized" }) + .catch(() => undefined); + continue; + } + this.hubPeersKnown.add(senderHex); if (request.command.verb === FRAME_VERB) { const message = extractMessage(request.command); if (message !== undefined && !isStateMutatingMessage(message)) { @@ -217,7 +231,7 @@ function hexToBytes(hex: string): Uint8Array { return bytes; } -/** A state_sync or state_update carries authority to directly overwrite or patch this side's own mesh state (agents, rooms, messages, deliveries) -- on an ordinary peer session that authority is meaningful because the peer already passed connect_request/introduce approval or the coordinator's own trusted mesh membership. A hub-relayed sender has passed neither: today's hub accepts any self-generated identity and gates nothing per-peer (agent-comms#156's own future deliverable), so treating its state_sync/state_update as equally authoritative would let an arbitrary hub peer inject an outcome indistinguishable from a genuine mesh event, e.g. a spoofed inbound delivery. Every other legacy message method this session might relay is already inert on receipt (PeerLifecycle's own handleDataMessage only reacts to these two), so filtering exactly these two is a complete fix for this specific path, not a partial one. */ +/** A state_sync or state_update carries authority to directly overwrite or patch this side's own mesh state (agents, rooms, messages, deliveries) -- on an ordinary peer session that authority is meaningful because the peer already passed connect_request/introduce approval or the coordinator's own trusted mesh membership. A hub-relayed sender has passed neither, and gateway trust (agent-comms#156, consume()'s own isTrusted gate) doesn't grant it either: being on the allowlist means "this device's traffic is worth acting on," not "this device may directly overwrite this side's mesh state" -- a strictly stronger claim no hub-relayed sender has ever been asked to prove, since the hub itself still accepts any self-generated identity with no admission control of its own (gating the hub is deliberately out of scope for agent-comms#156). Treating a trusted sender's state_sync/state_update as equally authoritative would let it inject an outcome indistinguishable from a genuine mesh event, e.g. a spoofed inbound delivery -- so this filter stays unconditional, applied even to a sender consume() has already let past the trust gate. Every other legacy message method this session might relay is already inert on receipt (PeerLifecycle's own handleDataMessage only reacts to these two), so filtering exactly these two is a complete fix for this specific path, not a partial one. */ function isStateMutatingMessage(message: MeshMessage): boolean { return message.method === "state_sync" || message.method === "state_update"; } From 77031e55c1b9e44e8e26d710c1ee0e951d0496f0 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Thu, 17 Sep 2026 18:57:33 +0100 Subject: [PATCH 04/12] docs(core): escape angle-bracket type refs in GatewayTrust doc comments Pick<...> written literally in a doc comment is parsed as malformed HTML by the tsdoc linter; wrap it in backticks instead, and reference the retiring federation.ts commit by hash rather than a bare hyphen number that reads like a GitHub issue reference. --- src/core/gateway-trust.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/core/gateway-trust.ts b/src/core/gateway-trust.ts index 6216d226..d119f7df 100644 --- a/src/core/gateway-trust.ts +++ b/src/core/gateway-trust.ts @@ -1,10 +1,13 @@ /** * GatewayTrust -- the cross-machine trust boundary (agent-comms#156, agent-comms#153's third leg): an allowlist of remote device-ids this machine's gateway will advertise its local agents to, accept forwarded hub traffic from, and route outbound hub requests to. Deny-all by default: empty until an operator explicitly trusts at least one remote device, the same no-CA pin-the-key model ordinary peer connections already use. * - * In-memory only, deliberately mirroring the precedent set by v1's own FederationManager.trustedFingerprints (retired with federation.ts, agent-comms#4232b08) -- neither persists to disk, so trust is re-established each run rather than carried across restarts. This isn't a gap being deferred: v1 never persisted its own equivalent allowlist either, so no existing behaviour is being narrowed by keeping this one in memory too. + * In-memory only, deliberately mirroring the precedent set by v1's own FederationManager.trustedFingerprints (retired with federation.ts, commit 4232b08) -- neither persists to disk, so trust is re-established each run rather than carried across restarts. This isn't a gap being deferred: v1 never persisted its own equivalent allowlist either, so no existing behaviour is being narrowed by keeping this one in memory too. * * Keyed by individual device-id, not by "one entry per remote machine": wire-mesh-core's relay-hub protocol (relay-hub.ts, gossip-frame, relay-data-frame) carries no field identifying which remote gateway connection a given directory entry or relayed request actually originated from -- only the entry/request's own device-id, which may be an ordinary local peer forwarded on a remote machine's behalf rather than that machine's own coordinator. Gating per individual device-id is therefore the finest-grained, and only wire-protocol-honest, trust boundary actually implementable without a wire-mesh-core protocol change (deliberately out of scope here, matching agent-comms#156's own "gating the hub itself is out of scope" framing) -- confirmed as the intended granularity by hub-session.ts's own pre-existing isStateMutatingMessage doc comment, which already named this exact gap as "agent-comms#156's own future deliverable" of "per-peer" admission control. An operator who wants every local peer on a remote machine reachable trusts each of that machine's device-ids individually, not just its coordinator's. */ +/** The read-only slice of GatewayTrust every consumer of the trust boundary actually needs (WireMeshTransport, HubSession, hub-forwarding.ts) -- named so call sites that only ever read trust decisions, never mutate them, don't repeat the same `Pick` inline at every field/parameter that takes one. */ +export type GatewayTrustReader = Pick; + export class GatewayTrust { private readonly trusted = new Set(); From a3fc8bd138e6d948f8c15b56431d9075d5c09853 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Thu, 17 Sep 2026 18:57:45 +0100 Subject: [PATCH 05/12] feat(core): wire GatewayTrust through MeshStore and WireMeshTransport MeshStore owns one GatewayTrust instance (public, mirroring discovery) and exposes addTrustedGateway/removeTrustedGateway/listTrustedGateways. WireMeshTransport takes it as a trailing constructor argument (defaulting to a fresh, empty instance for every existing call site), passes isTrusted into HubSession, hasAny into both hub-forwarding call sites via connectHubGateway (hub-forwarding.ts's own connect-then-catch-up helper), and gates sendRoomRequest's own hub fallback on the target device being trusted so an untrusted target fails fast with an unauthorized outcome rather than waiting out the hub's own silent-drop timeout. bridge-mesh.ts and test-transport.ts both pass store.gatewayTrust through so the store's own add/remove calls and the transport's own gates read the same set. Extracts listener-registry.ts (addListener/removeListener/listListeners/ advertisedAddresses, previously inline in WireMeshTransport) to stay under this repo's max-lines cap, the same reason hub-session.ts, connection-approval.ts, room-router.ts, peer-lifecycle.ts, and gossip-directory.ts were each split from the same file -- a pure extraction, unchanged behaviour. --- src/core/bridge-mesh.ts | 1 + src/core/hub-forwarding.ts | 16 ++++- src/core/listener-registry.ts | 89 ++++++++++++++++++++++++++++ src/core/mesh-store.ts | 23 +++++++ src/core/wire-mesh-transport.ts | 102 ++++++++++++++++---------------- src/test/test-transport.ts | 1 + 6 files changed, 179 insertions(+), 53 deletions(-) create mode 100644 src/core/listener-registry.ts diff --git a/src/core/bridge-mesh.ts b/src/core/bridge-mesh.ts index 6e613a7f..3e8510c8 100644 --- a/src/core/bridge-mesh.ts +++ b/src/core/bridge-mesh.ts @@ -79,6 +79,7 @@ export function createBridgeMeshSyncFromIdentity( () => store.hostedRooms, dataStorage, () => store.selfAgentAdvert, + store.gatewayTrust, ), ); const versionChecker = new VersionDriftChecker({ diff --git a/src/core/hub-forwarding.ts b/src/core/hub-forwarding.ts index 5a75d56f..2aa8a04c 100644 --- a/src/core/hub-forwarding.ts +++ b/src/core/hub-forwarding.ts @@ -47,7 +47,7 @@ export function pushHubCatchUp( forwardAdvertsToHub(hub, catchUp, onError, hasAnyTrustedGateway); } -/** Routes a room-domain request through the hub's relay-connect/relay-data pairing when memberId isn't a local peer session -- WireMeshTransport.sendRoomRequest's own fallback, since the member may be a remote agent reachable only via this machine's gateway connection (agent-comms#155's local-to-remote leg). Resolves the same not_connected outcome sendRoomRequest already returned before the hub existed at all when this side isn't currently the gateway. */ +/** Routes a room-domain request through the hub's relay-connect/relay-data pairing when memberId isn't a local peer session -- WireMeshTransport.sendRoomRequest's own fallback, since the member may be a remote agent reachable only via this machine's gateway connection (agent-comms#155's local-to-remote leg). WireMeshTransport.sendRoomRequest itself gates memberId against the gateway trust boundary (agent-comms#156) before ever calling this, so by the time this runs memberId is already known-trusted -- this function stays focused on the hub-connectivity outcome alone. Resolves the same not_connected outcome sendRoomRequest already returned before the hub existed at all when this side isn't currently the gateway. */ export async function routeRoomRequestViaHub( hub: Readonly>, memberId: string, @@ -58,3 +58,17 @@ export async function routeRoomRequestViaHub( if (!hub.isConnected) return { result: "error", code: "not_connected" }; return hub.sendRoomRequest(memberId, command, scope, token); } + +/** Dials the hub and immediately pushes a catch-up of every already-known local device onto it (agent-comms#154's own hub-connection-establishment sequence, kept together here rather than split across two call-site statements in WireMeshTransport.connectHub) -- without the trailing catch-up, a device whose own last gossip arrived before this coordinator took over the gateway role would stay invisible on the hub until its own next periodic gossip tick. */ +export async function connectHubGateway( + hub: Readonly< + Pick + >, + url: string, + knownDevices: ReadonlyMap>, + onError: ((error: Error) => void) | undefined, + hasAnyTrustedGateway: () => boolean, +): Promise { + await hub.connect(url); + pushHubCatchUp(hub, knownDevices, onError, hasAnyTrustedGateway); +} diff --git a/src/core/listener-registry.ts b/src/core/listener-registry.ts new file mode 100644 index 00000000..cb6f8da6 --- /dev/null +++ b/src/core/listener-registry.ts @@ -0,0 +1,89 @@ +/** + * listener-registry -- WireMeshTransport's own operator-registered listener bookkeeping (addListener/removeListener/listListeners, plus the advertisedAddresses list they feed), split out under the repo's max-lines cap the same reason hub-session.ts, connection-approval.ts, room-router.ts, peer-lifecycle.ts, and gossip-directory.ts were each split from the same file. Free functions over a TrackedListener registry (WireMeshTransport's own coordinatorListeners Map, passed in directly) rather than a class, since WireMeshTransport already owns and mutates that Map itself and every other piece of split-out state in this file follows the same shape. Deliberately excludes the default bootstrap coordinator listener (startDataServer/becomeCoordinator's own concern) -- this registry only ever tracks listeners an operator explicitly registered via addListener. + */ + +import { nanoid } from "./nanoid.js"; +import type { + Connection, + Listener, + Transport, +} from "wire-mesh-core/ports/transport"; +import type { ListenerInfo, ListenerPolicy } from "./transport.js"; + +/** Length of the random id minted for a tracked listener -- shared with WireMeshTransport's own becomeCoordinator, which mints an id for the default bootstrap listener the same way, outside this registry. */ +export const LISTENER_ID_LENGTH = 8; + +export interface TrackedListener { + listener: Listener; + policy: ListenerPolicy; + host: string; + port: number; + isDefault: boolean; +} + +/** Reads the port a listener actually bound, from its own reported address -- never the port it was asked to bind, which is 0 whenever the caller wanted the OS to assign a free one. Bookkeeping that stores the requested port instead silently reports 0 for every OS-assigned listener. */ +export function listenerPort(listener: Readonly): number { + const port = listener.address.split(":").pop(); + return port === undefined ? 0 : Number(port); +} + +/** Registers a new listener at host:port under the given policy, tracking it in coordinatorListeners (mutated in place) and dispatching every accepted connection to onAccepted -- WireMeshTransport.addListener's own body, unchanged in behaviour. */ +export async function registerListener( + wireTransport: Readonly>, + coordinatorListeners: Map, + host: string, + port: number, + policy: ListenerPolicy, + onAccepted: (connection: Readonly) => void, +): Promise { + const id = nanoid(LISTENER_ID_LENGTH); + const listener = await wireTransport.listen( + `${host}:${String(port)}`, + onAccepted, + ); + coordinatorListeners.set(id, { + listener, + policy, + host, + port: listenerPort(listener), + isDefault: false, + }); + return id; +} + +/** Closes and untracks a previously registered listener -- WireMeshTransport.removeListener's own body, unchanged in behaviour. Throws if id names the default bootstrap listener (never registered through registerListener, so never removable through this path); a no-op if id names no tracked listener at all. */ +export async function unregisterListener( + coordinatorListeners: Map, + defaultListenerId: string | undefined, + id: string, +): Promise { + if (id === defaultListenerId) { + throw new Error("Cannot remove the default listener"); + } + const tracked = coordinatorListeners.get(id); + if (tracked === undefined) return; + coordinatorListeners.delete(id); + await tracked.listener.close(); +} + +/** Every currently tracked listener, in the shape ListenerInfo exposes externally -- WireMeshTransport.listListeners's own body, unchanged in behaviour. */ +export function listTrackedListeners( + coordinatorListeners: ReadonlyMap>, +): ListenerInfo[] { + return [...coordinatorListeners.entries()].map(([id, tracked]) => ({ + id, + host: tracked.host, + port: tracked.port, + policy: tracked.policy, + isDefault: tracked.isDefault, + })); +} + +/** This side's own directly-reachable "host:port" candidates (wire-mesh#38), passed into every acceptMeshSession call's own self-advert -- WireMeshTransport's own advertisedAddresses getter body, unchanged in behaviour. Deliberately excludes the default bootstrap coordinator listener -- it always binds COORDINATOR_HOST (127.0.0.1, hardcoded, never configurable), which is meaningless to advertise to a remote peer -- and includes only listeners an operator explicitly registered via addListener, which by construction represent a deliberate "make me reachable from elsewhere" declaration. */ +export function advertisedListenerAddresses( + coordinatorListeners: ReadonlyMap>, +): string[] { + return [...coordinatorListeners.values()] + .filter((tracked) => !tracked.isDefault) + .map((tracked) => `${tracked.host}:${String(tracked.port)}`); +} diff --git a/src/core/mesh-store.ts b/src/core/mesh-store.ts index 5d2b93cc..673c7556 100644 --- a/src/core/mesh-store.ts +++ b/src/core/mesh-store.ts @@ -17,6 +17,7 @@ import { TailscaleDiscoveryBackend } from "./discovery-tailscale.js"; import { COORDINATOR_HOST, DEFAULT_HUB_URL } from "./mesh-store-shared.js"; import type { MeshStoreIdentity } from "./mesh-store-shared.js"; import { CoordinatorGateway } from "./coordinator-gateway.js"; +import { GatewayTrust } from "./gateway-trust.js"; import { DeliveryEngine } from "./delivery-engine.js"; import { RoomProtocol } from "./room-protocol.js"; import { RoomMessaging } from "./room-messaging.js"; @@ -97,6 +98,9 @@ export class MeshStore implements CommsStore { discovery: DiscoveryManager; + /** The cross-machine trust boundary (agent-comms#156) -- constructed once here (mirroring discovery above) and shared with WireMeshTransport by every construction site (bridge-mesh.ts, test-transport.ts) that passes it into WireMeshTransport's own constructor, so store.addTrustedGateway() and the transport's own hub-forwarding/hub-session gates read the exact same in-memory set. Public so those construction sites can reach it; addTrustedGateway/removeTrustedGateway/listTrustedGateways below are the methods CommsTool actually calls through MeshOnlyFeatures. */ + readonly gatewayTrust = new GatewayTrust(); + private readonly deliveryEngine: DeliveryEngine; private readonly roomProtocol: RoomProtocol; private readonly roomMessaging: RoomMessaging; @@ -801,6 +805,25 @@ export class MeshStore implements CommsStore { return this.discovery.getVisibility(adapter); } + // ----------------------------------------------------------------------- + // Gateway trust (agent-comms#156) -- the cross-machine trust boundary + // ----------------------------------------------------------------------- + + /** Trusts a remote device-id (hex): this store's own gateway (once it becomes the coordinator) will advertise onto the hub, merge this device's gossiped directory entries, dispatch its relayed requests, and route outbound hub requests to it. See GatewayTrust's own class doc for why trust is keyed per device-id rather than per remote machine, and why it doesn't survive a restart. */ + addTrustedGateway(deviceHex: string): void { + this.gatewayTrust.add(deviceHex); + } + + /** Withdraws trust from a remote device-id (hex). A no-op if it was never trusted. */ + removeTrustedGateway(deviceHex: string): void { + this.gatewayTrust.remove(deviceHex); + } + + /** Every currently trusted remote device-id (hex). */ + listTrustedGateways(): string[] { + return this.gatewayTrust.list(); + } + // ----------------------------------------------------------------------- // Listener management (coordinator only) // ----------------------------------------------------------------------- diff --git a/src/core/wire-mesh-transport.ts b/src/core/wire-mesh-transport.ts index 61f65d10..11ef7a81 100644 --- a/src/core/wire-mesh-transport.ts +++ b/src/core/wire-mesh-transport.ts @@ -12,10 +12,20 @@ import { createTlsTransport } from "wire-mesh-core/adapters/tls-transport"; import * as bindRetry from "./bind-retry.js"; import { connectWsUrl } from "./ws-dial.js"; import { HubSession } from "./hub-session.js"; +import { GatewayTrust, type GatewayTrustReader } from "./gateway-trust.js"; +import { + advertisedListenerAddresses, + LISTENER_ID_LENGTH, + listenerPort, + listTrackedListeners, + registerListener, + unregisterListener, + type TrackedListener, +} from "./listener-registry.js"; import { findPresenceAdvert, mergeKnownDevices } from "./gossip-directory.js"; import { + connectHubGateway, forwardAdvertsToHub, - pushHubCatchUp, routeRoomRequestViaHub, } from "./hub-forwarding.js"; import { @@ -83,9 +93,6 @@ const PRESENCE_READVERTISE_INTERVAL_SECONDS = 20; const PRESENCE_READVERTISE_INTERVAL_MS = PRESENCE_READVERTISE_INTERVAL_SECONDS * MS_PER_SECOND; -/** Length of the random id minted for a tracked listener (the coordinator's own bootstrap listener, or one registered via addListener). */ -const LISTENER_ID_LENGTH = 8; - /** The domain-qualified gossip extension key this transport reads/writes presence under, per wire-mesh's own gossip-extension-namespacing convention (spec/CONVENTIONS.md): `/`, never a bare name a second application's own extension could collide with. */ export const PRESENCE_GOSSIP_KEY = "presence/status"; @@ -136,12 +143,6 @@ export function buildCommand(message: MeshMessage): ManageCommand { return { verb: FRAME_VERB, params: { message } }; } -/** Reads the port a listener actually bound, from its own reported address -- never the port it was asked to bind, which is 0 whenever the caller wanted the OS to assign a free one. Bookkeeping that stores the requested port instead silently reports 0 for every OS-assigned listener. */ -function listenerPort(listener: Readonly): number { - const port = listener.address.split(":").pop(); - return port === undefined ? 0 : Number(port); -} - // --------------------------------------------------------------------------- // Internal session bookkeeping // --------------------------------------------------------------------------- @@ -163,14 +164,6 @@ interface PendingConnection { timeoutHandle: ReturnType; } -interface TrackedListener { - listener: Listener; - policy: ListenerPolicy; - host: string; - port: number; - isDefault: boolean; -} - // --------------------------------------------------------------------------- // WireMeshTransport // --------------------------------------------------------------------------- @@ -259,6 +252,12 @@ export class WireMeshTransport implements MeshTransport { /** Every peer this side has ever received a frame from, keyed by device-id hex, tracking the raw wire-mesh-core Connection each frame arrived on -- what sendDataFrame needs, since neither AcceptedMeshSession nor MeshSession exposes a generic "send an arbitrary frame" method the way the raw Connection itself does. Registered eagerly on the very first frame from a connection (including one still in quarantine, e.g. before connect_request approval) so a later sendDataFrame call can reach it -- handleDataFrame's own trust gate (peerSessions.has) is what actually decides whether to act on anything received this way, not this map. */ private readonly connectionsByPeer = new Map(); + /** The cross-machine trust boundary (agent-comms#156): gates outbound gossip advertisement (hasAny), inbound directory merge/request dispatch, and outbound targeted hub requests (both isTrusted) -- see GatewayTrust's own class doc. Defaults to a fresh, empty (deny-all) instance when no caller wires one in, matching every existing construction site that predates this feature. */ + private readonly gatewayTrust: GatewayTrustReader; + /** Bound once here so both hub-forwarding call sites (watchForDisconnect, connectHub) can pass a plain reference rather than repeating an inline arrow. */ + private readonly hasAnyTrustedGateway = (): boolean => + this.gatewayTrust.hasAny(); + constructor( events: Readonly, identity: Readonly, @@ -269,6 +268,7 @@ export class WireMeshTransport implements MeshTransport { getHostedRooms?: () => readonly HostedRoomAdvert[], dataStorage?: KeyValueStorage, getSelfAgentAdvert?: () => AgentSelfAdvert | undefined, + gatewayTrust: Readonly = new GatewayTrust(), ) { this.events = events; this.wireTransport = createTlsTransport({ @@ -276,6 +276,7 @@ export class WireMeshTransport implements MeshTransport { privateKeyPem: identity.privateKey, }); this.identityReady = toIdentityPort(identity); + this.gatewayTrust = gatewayTrust; // Built before this.hub below (roomRouter has no dependency on it) so the hub can be wired with a direct this.roomRouter.handleRequest reference rather than a lazy closure. this.roomRouter = createRoomRouter({ events, @@ -294,6 +295,7 @@ export class WireMeshTransport implements MeshTransport { mergeKnownDevices(this.knownDevices, entries); }, handleRoomRequest: this.roomRouter.handleRequest, + isTrusted: (deviceHex) => this.gatewayTrust.isTrusted(deviceHex), }); this.pendingConnectionTimeoutMs = pendingConnectionTimeoutMs; this.getCurrentPresence = getCurrentPresence; @@ -549,7 +551,12 @@ export class WireMeshTransport implements MeshTransport { void (async () => { for await (const event of session.events) { mergeKnownDevices(this.knownDevices, event.directory); - forwardAdvertsToHub(this.hub, event.directory, this.events.onError); + forwardAdvertsToHub( + this.hub, + event.directory, + this.events.onError, + this.hasAnyTrustedGateway, + ); const presence = findPresenceAdvert(deviceIdHex, event.directory); if (presence !== undefined) { this.events.onPresenceAdvert(handle, presence); @@ -791,6 +798,9 @@ export class WireMeshTransport implements MeshTransport { if (session !== undefined) { return session.sendManageRequest(command, scope, undefined, token); } + if (!this.gatewayTrust.isTrusted(memberId)) { + return { result: "error", code: "unauthorized" }; + } return routeRoomRequestViaHub(this.hub, memberId, command, scope, token); } @@ -902,50 +912,33 @@ export class WireMeshTransport implements MeshTransport { port: number, policy: ListenerPolicy, ): Promise { - const id = nanoid(LISTENER_ID_LENGTH); - const listener = await this.wireTransport.listen( - `${host}:${String(port)}`, + return registerListener( + this.wireTransport, + this.coordinatorListeners, + host, + port, + policy, (connection) => { void this.handleAcceptedConnection(connection, policy, false, true); }, ); - this.coordinatorListeners.set(id, { - listener, - policy, - host, - port: listenerPort(listener), - isDefault: false, - }); - return id; } async removeListener(id: string): Promise { - if (id === this.defaultListenerId) { - throw new Error("Cannot remove the default listener"); - } - const tracked = this.coordinatorListeners.get(id); - if (tracked === undefined) return; - this.coordinatorListeners.delete(id); - await tracked.listener.close(); + await unregisterListener( + this.coordinatorListeners, + this.defaultListenerId, + id, + ); } listListeners(): ListenerInfo[] { - return [...this.coordinatorListeners.entries()].map(([id, tracked]) => ({ - id, - host: tracked.host, - port: tracked.port, - policy: tracked.policy, - isDefault: tracked.isDefault, - })); + return listTrackedListeners(this.coordinatorListeners); } - /** - * This side's own directly-reachable "host:port" candidates (wire-mesh#38), passed into every acceptMeshSession call's own self-advert. Deliberately excludes the default bootstrap coordinator listener -- it always binds COORDINATOR_HOST (127.0.0.1, hardcoded, never configurable), which is meaningless to advertise to a remote peer -- and includes only listeners an operator explicitly registered via addListener, which by construction represent a deliberate "make me reachable from elsewhere" declaration. - */ + /** This side's own directly-reachable "host:port" candidates (wire-mesh#38), passed into every acceptMeshSession call's own self-advert -- see advertisedListenerAddresses' own doc for what's included/excluded and why. */ private get advertisedAddresses(): string[] { - return [...this.coordinatorListeners.values()] - .filter((tracked) => !tracked.isDefault) - .map((tracked) => `${tracked.host}:${String(tracked.port)}`); + return advertisedListenerAddresses(this.coordinatorListeners); } // ----------------------------------------------------------------------- @@ -953,8 +946,13 @@ export class WireMeshTransport implements MeshTransport { // ----------------------------------------------------------------------- async connectHub(url: string): Promise { - await this.hub.connect(url); - pushHubCatchUp(this.hub, this.knownDevices, this.events.onError); + await connectHubGateway( + this.hub, + url, + this.knownDevices, + this.events.onError, + this.hasAnyTrustedGateway, + ); } async disconnectHub(): Promise { diff --git a/src/test/test-transport.ts b/src/test/test-transport.ts index 310d1bf3..639e63b4 100644 --- a/src/test/test-transport.ts +++ b/src/test/test-transport.ts @@ -51,6 +51,7 @@ export async function wireTestTransport( undefined, dataStorage, () => store.selfAgentAdvert, + store.gatewayTrust, ), ); store.setIdentity({ From aca4b3caac7b452f527d03e74ffd417bc508b598 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Thu, 17 Sep 2026 18:57:55 +0100 Subject: [PATCH 06/12] test(core): trust remote devices explicitly in gateway forwarding tests Gateway trust (agent-comms#156) is deny-all by default, so every scenario here now trusts the specific remote device-ids it needs before expecting forwarding, directory merge, or DM routing to happen -- trust is keyed per device, not per remote machine, so a non-gateway local peer (a2) needs its own device-id trusted even though its own machine's gateway (a1) is trusted separately. --- src/test/gateway-forwarding.integration.test.ts | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/src/test/gateway-forwarding.integration.test.ts b/src/test/gateway-forwarding.integration.test.ts index 504c7d1c..614dc646 100644 --- a/src/test/gateway-forwarding.integration.test.ts +++ b/src/test/gateway-forwarding.integration.test.ts @@ -2,6 +2,8 @@ * Integration tests for the gateway actually forwarding between the local mesh and the hub (agent-comms#155), the core leg of the cross-machine mesh epic (#153) on top of #154's own connection-lifecycle-only gateway. Two independent local meshes ("machine A" and "machine B"), each its own coordinator dialling a shared real relay hub (createRelayHub, the same domain logic the production mesh.exadev.io Durable Object runs, served over local WebSockets via hub-helpers.ts) -- neither mesh is ever directly connected to the other, so anything either side learns about the other can only have arrived via the hub. * * Machine A's coordinator (a1) additionally has a second, ordinary local peer (a2) -- proving outbound advertisement and the remote-directory merge work for a local peer that is NOT itself the gateway, not just for the gateway's own agent (which #154 already exercised end-to-end before this issue). + * + * Every gateway here explicitly trusts the specific remote device-ids it needs to (agent-comms#156's own gateway allowlist, added after these tests first landed): the allowlist is deny-all by default, so every test below establishes trust before expecting forwarding/merge/routing to happen -- see gateway-trust.integration.test.ts for the deny-by-default behaviour itself (untrusted forwarding/merge/routing all failing) rather than re-proving it per test here. */ import { afterEach, describe, expect, it } from "vitest"; @@ -74,6 +76,10 @@ describe("gateway forwarding", () => { await b1.init(); cleanups.push(async () => b1.shutdown()); + // Deny-all by default (agent-comms#156): a1 needs at least one trusted device to forward anything at all onto the hub, and b1 needs a2's own device-id specifically trusted to merge a2's directory entry (trust is keyed per device, not per remote machine -- see GatewayTrust's own class doc). + a1.addTrustedGateway(b1.peerId); + b1.addTrustedGateway(a2.peerId); + await waitFor( "b1 (a separate machine's gateway) to learn of a2 (a non-gateway local peer on machine A) via the hub", async () => { @@ -163,6 +169,10 @@ describe("gateway forwarding", () => { await b1.init(); cleanups.push(async () => b1.shutdown()); + // Deny-all by default (agent-comms#156) -- a1 needs at least one trusted device to forward at all, and b1 needs the visible control peer's own device-id trusted so its arrival can prove gossip had time to propagate. Trusting only aVisible (never aHidden/aGhost) doesn't weaken this test: hidden/ghost are already filtered out at the source (forwardAdvertsToHub's own AGENT_SELF_GOSSIP_KEY eligibility check, upstream of the trust gate), so this proves the trust gate isn't the reason they never arrive. + a1.addTrustedGateway(b1.peerId); + b1.addTrustedGateway(aVisible.peerId); + await waitFor( "b1 to see the visible control peer, proving gossip had time to propagate", async () => { @@ -206,6 +216,10 @@ describe("gateway forwarding", () => { tags: [], }); + // Deny-all by default (agent-comms#156) -- mutual trust: a1 must trust b1 to accept b1's incoming DM request (consume()'s own isTrusted gate on fromDevice), and b1 must trust a1 for sendRoomRequest's own outbound isTrusted gate to route the request (and the later DM) to a1 at all. + a1.addTrustedGateway(b1.peerId); + b1.addTrustedGateway(a1.peerId); + // Two-round DM consent (section 6), exactly like the local-mesh-only dm-admission.integration.test.ts -- b1's own outbound request is what's actually exercised via the new hub-relay fallback, since a1 is not a local peer of b1's own mesh. const dmPath = dmRoomPath(b1.peerId, a1.peerId); const requestPromise = b1.requestDmAccess(a1.peerId); From 70f7c94137ba001f8df26a3bab775985e99a3314 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Thu, 17 Sep 2026 18:58:03 +0100 Subject: [PATCH 07/12] test(core): establish mutual gateway trust in hub-session integration tests Deny-all by default (agent-comms#156) means the two transports here no longer discover or message each other, or exercise the state_sync/state_update filter, without each explicitly trusting the other's device-id first. Strengthens the #169 security-finding test along the way: it now proves the filter still applies even to a hub peer this side has explicitly trusted, not merely an anonymous one. --- src/test/hub-mode-session.integration.test.ts | 78 ++++++++++++++++--- 1 file changed, 68 insertions(+), 10 deletions(-) diff --git a/src/test/hub-mode-session.integration.test.ts b/src/test/hub-mode-session.integration.test.ts index 88c69584..894a5e3e 100644 --- a/src/test/hub-mode-session.integration.test.ts +++ b/src/test/hub-mode-session.integration.test.ts @@ -1,10 +1,11 @@ -// Integration: two real agent-comms WireMeshTransports discovering each other and exchanging messages through a real wire-mesh relay hub (createRelayHub -- the same domain logic the production mesh.exadev.io Durable Object runs) served over local WebSockets. This is agent-comms#151's own acceptance shape: the hub connection is a relay, not a coordinator -- no connect_request/introduce approval applies, peers discover each other via the hub's gossip forwarding + catch-up, and messages ride relay pairings (sendManageRequest's own targetDevice routing). +// Integration: two real agent-comms WireMeshTransports discovering each other and exchanging messages through a real wire-mesh relay hub (createRelayHub -- the same domain logic the production mesh.exadev.io Durable Object runs) served over local WebSockets. This is agent-comms#151's own acceptance shape: the hub connection is a relay, not a coordinator -- no connect_request/introduce approval applies, peers discover each other via the hub's gossip forwarding + catch-up, and messages ride relay pairings (sendManageRequest's own targetDevice routing). Every transport here is constructed with an explicit GatewayTrust mutually trusting the other side's device-id (agent-comms#156's own deny-by-default trust boundary): without it, hub-session.ts's own directory-merge and consume() gates would drop the other side's gossip/messages entirely before any of this file's own assertions could run. import { afterEach, describe, expect, it } from "vitest"; import { realHubOverWs, waitForCondition } from "./hub-helpers.js"; import { generateIdentity } from "../core/identity.js"; import { deviceIdToHex } from "wire-mesh-core/domain/device-id"; import { WireMeshTransport } from "../core/wire-mesh-transport.js"; +import { GatewayTrust } from "../core/gateway-trust.js"; import type { ConnectionHandle, TransportEvents } from "../core/transport.js"; import type { MeshMessage } from "../core/wire-protocol.js"; @@ -50,14 +51,43 @@ describe("connectToHub", () => { const eventsA = recordingEvents(); const eventsB = recordingEvents(); - const transportA = new WireMeshTransport(eventsA, generateIdentity()); - const transportB = new WireMeshTransport(eventsB, generateIdentity()); + const identityA = generateIdentity(); + const identityB = generateIdentity(); + const deviceA = deviceIdToHex(Uint8Array.from(identityA.deviceId)); + const deviceB = deviceIdToHex(Uint8Array.from(identityB.deviceId)); + // Gateway trust (agent-comms#156) is deny-all by default: each side's own hub-session directory-merge and consume() gates would otherwise drop the other's gossip/messages entirely, so hub.peers() would never populate and onMessage would never fire. Mutual trust here for both. + const gatewayTrustA = new GatewayTrust(); + gatewayTrustA.add(deviceB); + const gatewayTrustB = new GatewayTrust(); + gatewayTrustB.add(deviceA); + const transportA = new WireMeshTransport( + eventsA, + identityA, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + gatewayTrustA, + ); + const transportB = new WireMeshTransport( + eventsB, + identityB, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + gatewayTrustB, + ); await transportA.hub.connect(hub.url); await transportB.hub.connect(hub.url); // Discovery: each side's hubPeers should eventually list the other. - const deviceA = await transportA.hub.ownDeviceHex(); - const deviceB = await transportB.hub.ownDeviceHex(); await waitForCondition(() => { return ( transportA.hub.peers().includes(deviceB) && @@ -83,19 +113,47 @@ describe("connectToHub", () => { await transportB.shutdown(); }); - it("never applies a state_sync or state_update relayed by an unauthenticated hub peer (agent-comms#169 security finding: real per-peer admission lands in #156, but nothing today should let any hub peer patch local mesh state)", async () => { + it('never applies a state_sync or state_update relayed by a hub peer, even one this side now explicitly trusts (agent-comms#169 security finding: real per-peer admission landed in #156, but gateway trust means "this device\'s traffic is worth acting on", not "this device may directly overwrite this side\'s mesh state")', async () => { const hub = await realHubOverWs(); cleanups.push(hub.close); const eventsA = recordingEvents(); const eventsB = recordingEvents(); - const transportA = new WireMeshTransport(eventsA, generateIdentity()); - const transportB = new WireMeshTransport(eventsB, generateIdentity()); + const identityA = generateIdentity(); + const identityB = generateIdentity(); + const deviceA = deviceIdToHex(Uint8Array.from(identityA.deviceId)); + const deviceB = deviceIdToHex(Uint8Array.from(identityB.deviceId)); + const gatewayTrustA = new GatewayTrust(); + gatewayTrustA.add(deviceB); + const gatewayTrustB = new GatewayTrust(); + gatewayTrustB.add(deviceA); + const transportA = new WireMeshTransport( + eventsA, + identityA, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + gatewayTrustA, + ); + const transportB = new WireMeshTransport( + eventsB, + identityB, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + gatewayTrustB, + ); await transportA.hub.connect(hub.url); await transportB.hub.connect(hub.url); - const deviceA = await transportA.hub.ownDeviceHex(); - const deviceB = await transportB.hub.ownDeviceHex(); await waitForCondition(() => { return ( transportA.hub.peers().includes(deviceB) && From 5fb54097c6e4afd2dd223311cda13616b0fa1eff Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Thu, 17 Sep 2026 18:58:12 +0100 Subject: [PATCH 08/12] test(core): cover sendRoomRequest's gateway-trust gate in its own file An untrusted target now resolves unauthorized before ever touching the hub; a trusted one falls through to the pre-existing not_connected outcome. Split into wire-mesh-transport-gateway-trust.test.ts rather than growing wire-mesh-transport.test.ts past this repo's max-lines cap, the same reason wire-mesh-transport-hub.test.ts and wire-mesh-transport-shutdown-unref.test.ts were split before it. --- .../wire-mesh-transport-gateway-trust.test.ts | 70 +++++++++++++++++++ src/test/wire-mesh-transport.test.ts | 15 +--- 2 files changed, 71 insertions(+), 14 deletions(-) create mode 100644 src/test/wire-mesh-transport-gateway-trust.test.ts diff --git a/src/test/wire-mesh-transport-gateway-trust.test.ts b/src/test/wire-mesh-transport-gateway-trust.test.ts new file mode 100644 index 00000000..b0f70237 --- /dev/null +++ b/src/test/wire-mesh-transport-gateway-trust.test.ts @@ -0,0 +1,70 @@ +/** + * WireMeshTransport.sendRoomRequest's own gateway-trust gate (agent-comms#156) -- split out of wire-mesh-transport.test.ts to stay under this repo's max-lines cap, the same reason wire-mesh-transport-hub.test.ts and wire-mesh-transport-shutdown-unref.test.ts were split. + */ +import { test, describe, expect } from "vitest"; +import { generateIdentity } from "../core/identity.js"; +import { WireMeshTransport } from "../core/wire-mesh-transport.js"; +import { GatewayTrust } from "../core/gateway-trust.js"; +import type { TransportEvents } from "../core/transport.js"; + +function noopEvents(): TransportEvents { + return { + onMessage: () => undefined, + onPeerConnected: () => undefined, + onPeerDisconnected: () => undefined, + onIntroduction: () => undefined, + onConnectionRequest: () => undefined, + onPeerList: () => undefined, + onPeerJoined: () => undefined, + onBecomeCoordinator: () => undefined, + onRevocationAnnounce: () => undefined, + onPresenceAdvert: () => undefined, + }; +} + +describe("WireMeshTransport.sendRoomRequest -- gateway trust", () => { + test("resolves unauthorized without ever touching the hub when the target device isn't trusted", async () => { + const identity = generateIdentity(); + // No GatewayTrust passed -- defaults to a fresh, empty (deny-all) instance. + const transport = new WireMeshTransport(noopEvents(), identity); + try { + const outcome = await transport.sendRoomRequest( + "nobody-home", + { verb: "room.send", params: {} }, + { kind: "agent-comms-mesh" }, + ); + expect(outcome).toEqual({ result: "error", code: "unauthorized" }); + } finally { + await transport.shutdown(); + } + }); + + test("falls through to the ordinary not_connected outcome once the target device is trusted", async () => { + const identity = generateIdentity(); + const gatewayTrust = new GatewayTrust(); + gatewayTrust.add("nobody-home"); + const transport = new WireMeshTransport( + noopEvents(), + identity, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + gatewayTrust, + ); + try { + const outcome = await transport.sendRoomRequest( + "nobody-home", + { verb: "room.send", params: {} }, + { kind: "agent-comms-mesh" }, + ); + // Trusted, but there is no live local session and this transport was never dialled into a hub -- routeRoomRequestViaHub's own not_connected outcome, unaffected by the trust gate once it's passed. + expect(outcome).toEqual({ result: "error", code: "not_connected" }); + } finally { + await transport.shutdown(); + } + }); +}); diff --git a/src/test/wire-mesh-transport.test.ts b/src/test/wire-mesh-transport.test.ts index 5bc07918..ae247d5e 100644 --- a/src/test/wire-mesh-transport.test.ts +++ b/src/test/wire-mesh-transport.test.ts @@ -755,20 +755,7 @@ describe("WireMeshTransport send/sendRoomRequest to an unknown or broken peer", } }); - test("sendRoomRequest to a member with no live session resolves not_connected rather than throwing", async () => { - const identity = generateIdentity(); - const transport = new WireMeshTransport(noopEvents(), identity); - try { - const outcome = await transport.sendRoomRequest( - "nobody-home", - { verb: "room.send", params: {} }, - { kind: "agent-comms-mesh" }, - ); - expect(outcome).toEqual({ result: "error", code: "not_connected" }); - } finally { - await transport.shutdown(); - } - }); + // sendRoomRequest's own gateway-trust gate (agent-comms#156) has its own dedicated coverage in wire-mesh-transport-gateway-trust.test.ts, split out the same reason wire-mesh-transport-hub.test.ts/wire-mesh-transport-shutdown-unref.test.ts already were. // A test proving send()'s own catch/onError path (a send failing against a session whose remote end just closed) was attempted the same way and hit the identical flakiness as the gossip-failure test above -- watchForDisconnect's own cleanup consistently won the race against a still-tracked-but-broken session. Left as a documented gap for the same reason. }); From 9c52770bf34df3856c9e835522ed84d5f43dbcf4 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Thu, 17 Sep 2026 18:58:39 +0100 Subject: [PATCH 09/12] style(core): apply eslint/prettier autofix to hub-forwarding.test.ts Removes a now-redundant double type cast on a test fixture and reflows an import that now fits on one line. --- src/test/hub-forwarding.test.ts | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/src/test/hub-forwarding.test.ts b/src/test/hub-forwarding.test.ts index 09b91854..8d8df3dc 100644 --- a/src/test/hub-forwarding.test.ts +++ b/src/test/hub-forwarding.test.ts @@ -2,10 +2,7 @@ * Direct unit tests for forwardAdvertsToHub/pushHubCatchUp's own gateway-trust gate (agent-comms#156) -- previously only exercised indirectly through gateway-forwarding.integration.test.ts's real-hub harness. Uses a fake hub object (isConnected/advertiseDevices) rather than a real HubSession, mirroring gossip-directory.test.ts's own standalone-fixture approach for the sibling gossip-merge function. */ import { describe, expect, it, vi } from "vitest"; -import { - forwardAdvertsToHub, - pushHubCatchUp, -} from "../core/hub-forwarding.js"; +import { forwardAdvertsToHub, pushHubCatchUp } from "../core/hub-forwarding.js"; import type { DirectoryEntry } from "wire-mesh-core/domain/mesh-session"; import type { PeerAdvert } from "wire-mesh-core/generated/protocol"; @@ -27,7 +24,7 @@ function advert(): PeerAdvert { function entry(hex: string): DirectoryEntry { return { device: deviceIdBytes(hex), - advert: { ...advert(), "agent/self": {} } as unknown as PeerAdvert, + advert: { ...advert(), "agent/self": {} }, }; } From 75034eb5b40a88ef0b4fadac0b33ef9318df2f66 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Thu, 17 Sep 2026 19:03:22 +0100 Subject: [PATCH 10/12] feat(core): expose gateway trust as gateway_trust/gateway_untrust/gateway_list_trusted tool actions Follows mesh_set_visibility/mesh_get_visibility's own established shape: three CommsAction variants, a device field on the shared MCP tool parameter schema, buildAction parsing with an exhaustiveness check against every action literal, and three MeshOnlyFeatures-gated CommsTool handlers delegating to MeshStore's own addTrustedGateway/removeTrustedGateway/listTrustedGateways. --- src/core/bridge.ts | 15 +++ src/core/tool.ts | 61 +++++++++++ src/core/types.ts | 9 ++ src/test/gateway-trust-tool.test.ts | 153 ++++++++++++++++++++++++++++ 4 files changed, 238 insertions(+) create mode 100644 src/test/gateway-trust-tool.test.ts diff --git a/src/core/bridge.ts b/src/core/bridge.ts index c92f77b1..b2590ab4 100644 --- a/src/core/bridge.ts +++ b/src/core/bridge.ts @@ -67,6 +67,9 @@ export const MCP_TOOL_PARAMS = z.object({ "mesh_listeners", "mesh_set_visibility", "mesh_get_visibility", + "gateway_trust", + "gateway_untrust", + "gateway_list_trusted", ]), name: z.string().optional(), visibility: VisibilityEnum.optional(), @@ -95,6 +98,8 @@ export const MCP_TOOL_PARAMS = z.object({ capability: z.string().optional(), expires: z.number().optional(), delegationsRemaining: z.number().optional(), + /** A remote gateway's own device-id (hex), for gateway_trust/gateway_untrust. */ + device: z.string().optional(), }); export type ToolParams = z.infer; @@ -376,6 +381,16 @@ export function buildAction(params: Record): CommsAction { }; return result; } + case "gateway_trust": + if (p.device === undefined) + throw new BuildActionError("gateway_trust", "device"); + return { action: "gateway_trust", device: p.device }; + case "gateway_untrust": + if (p.device === undefined) + throw new BuildActionError("gateway_untrust", "device"); + return { action: "gateway_untrust", device: p.device }; + case "gateway_list_trusted": + return { action: "gateway_list_trusted" }; default: return p.action satisfies never; } diff --git a/src/core/tool.ts b/src/core/tool.ts index 0636c513..d062634f 100644 --- a/src/core/tool.ts +++ b/src/core/tool.ts @@ -103,6 +103,9 @@ export interface MeshOnlyFeatures { connectToRemote?: (host: string, port: number) => Promise; setVisibility?: (level: MeshVisibility, adapter?: string) => Promise; getVisibility?: (adapter?: string) => MeshVisibility; + addTrustedGateway?: (deviceHex: string) => void; + removeTrustedGateway?: (deviceHex: string) => void; + listTrustedGateways?: () => string[]; } /** Uniform "this bridge isn't backed by a mesh transport" result for a MeshOnlyFeatures method that isn't present on the current store. */ @@ -229,6 +232,12 @@ export class CommsTool { return await this.meshSetVisibility(action); case "mesh_get_visibility": return this.meshGetVisibility(action); + case "gateway_trust": + return this.gatewayTrust(action); + case "gateway_untrust": + return this.gatewayUntrust(action); + case "gateway_list_trusted": + return this.gatewayListTrusted(); default: return { content: `Unknown action: ${JSON.stringify(action).slice(0, UNKNOWN_ACTION_PREVIEW_LENGTH)}`, @@ -639,6 +648,58 @@ export class CommsTool { }; } + private gatewayTrust( + action: CommsAction & { action: "gateway_trust" }, + ): CommsResult { + if (!this.store.addTrustedGateway) { + return { + content: "Gateway trust is not available on this store.", + isError: true, + }; + } + this.store.addTrustedGateway(action.device); + return { + content: `Trusted remote gateway device ${action.device}.`, + isError: false, + }; + } + + private gatewayUntrust( + action: CommsAction & { action: "gateway_untrust" }, + ): CommsResult { + if (!this.store.removeTrustedGateway) { + return { + content: "Gateway trust is not available on this store.", + isError: true, + }; + } + this.store.removeTrustedGateway(action.device); + return { + content: `Untrusted remote gateway device ${action.device}.`, + isError: false, + }; + } + + private gatewayListTrusted(): CommsResult { + if (!this.store.listTrustedGateways) { + return { + content: "Gateway trust is not available on this store.", + isError: true, + }; + } + const trusted = this.store.listTrustedGateways(); + if (trusted.length === 0) { + return { + content: "No remote gateway devices are trusted.", + isError: false, + }; + } + return { + content: `Trusted remote gateway devices:\n${trusted.map((device) => ` ${device}`).join("\n")}`, + isError: false, + }; + } + private async meshConnect( _ctx: Readonly, action: CommsAction & { action: "mesh_connect" }, diff --git a/src/core/types.ts b/src/core/types.ts index 66d83396..15e4dd0b 100644 --- a/src/core/types.ts +++ b/src/core/types.ts @@ -426,6 +426,15 @@ export const CommsActionSchema = defineSchema( adapter: z.string().optional(), }), z.object({ action: z.literal("mesh_get_visibility") }), + z.object({ + action: z.literal("gateway_trust"), + device: z.string(), + }), + z.object({ + action: z.literal("gateway_untrust"), + device: z.string(), + }), + z.object({ action: z.literal("gateway_list_trusted") }), ]), ); export type CommsAction = z.infer; diff --git a/src/test/gateway-trust-tool.test.ts b/src/test/gateway-trust-tool.test.ts new file mode 100644 index 00000000..832f1add --- /dev/null +++ b/src/test/gateway-trust-tool.test.ts @@ -0,0 +1,153 @@ +/** + * CommsTool's gateway-trust actions (agent-comms#156) -- add/remove/list a trusted remote device-id, mirroring visibility.integration.test.ts's own "CommsTool visibility actions" pattern for the sibling mesh-only, MeshOnlyFeatures-gated config surface. + */ +import { test, describe, expect } from "vitest"; +import { MeshStore } from "../core/mesh-store.js"; +import { CommsTool } from "../core/tool.js"; +import { buildAction } from "../core/bridge.js"; +import { wireTestTransport } from "./test-transport.js"; + +const TEST_PORT = 0; +const DEVICE_HEX = "aabbccdd"; + +describe("CommsTool gateway trust actions", () => { + test("gateway_trust adds a device to the allowlist", async () => { + const store = new MeshStore(TEST_PORT); + await wireTestTransport(store); + await store.init(); + const agent = await store.registerAgent({ + name: "gateway-trust-test", + harness: "test", + cwd: "/test", + pid: process.pid, + visibility: "visible", + tags: [], + }); + const tool = new CommsTool(store); + + const result = await tool.handle( + { agentId: agent.id, harness: "test", cwd: "/test", pid: process.pid }, + { action: "gateway_trust", device: DEVICE_HEX }, + ); + + expect(result.isError, result.content).toBe(false); + expect(result.content).toContain(DEVICE_HEX); + expect(store.listTrustedGateways()).toEqual([DEVICE_HEX]); + + await store.shutdown(); + }); + + test("gateway_untrust removes a device from the allowlist", async () => { + const store = new MeshStore(TEST_PORT); + await wireTestTransport(store); + await store.init(); + const agent = await store.registerAgent({ + name: "gateway-untrust-test", + harness: "test", + cwd: "/test", + pid: process.pid, + visibility: "visible", + tags: [], + }); + const tool = new CommsTool(store); + store.addTrustedGateway(DEVICE_HEX); + + const result = await tool.handle( + { agentId: agent.id, harness: "test", cwd: "/test", pid: process.pid }, + { action: "gateway_untrust", device: DEVICE_HEX }, + ); + + expect(result.isError, result.content).toBe(false); + expect(store.listTrustedGateways()).toEqual([]); + + await store.shutdown(); + }); + + test("gateway_list_trusted lists every currently trusted device", async () => { + const store = new MeshStore(TEST_PORT); + await wireTestTransport(store); + await store.init(); + const agent = await store.registerAgent({ + name: "gateway-list-test", + harness: "test", + cwd: "/test", + pid: process.pid, + visibility: "visible", + tags: [], + }); + const tool = new CommsTool(store); + store.addTrustedGateway(DEVICE_HEX); + + const result = await tool.handle( + { agentId: agent.id, harness: "test", cwd: "/test", pid: process.pid }, + { action: "gateway_list_trusted" }, + ); + + expect(result.isError, result.content).toBe(false); + expect(result.content).toContain(DEVICE_HEX); + + await store.shutdown(); + }); + + test("gateway_list_trusted reports none trusted by default", async () => { + const store = new MeshStore(TEST_PORT); + await wireTestTransport(store); + await store.init(); + const agent = await store.registerAgent({ + name: "gateway-list-empty-test", + harness: "test", + cwd: "/test", + pid: process.pid, + visibility: "visible", + tags: [], + }); + const tool = new CommsTool(store); + + const result = await tool.handle( + { agentId: agent.id, harness: "test", cwd: "/test", pid: process.pid }, + { action: "gateway_list_trusted" }, + ); + + expect(result.isError, result.content).toBe(false); + expect(store.listTrustedGateways()).toEqual([]); + + await store.shutdown(); + }); +}); + +describe("buildAction gateway trust parsing", () => { + test("buildAction parses gateway_trust", () => { + const action = buildAction({ + action: "gateway_trust", + device: DEVICE_HEX, + }); + expect(action.action).toBe("gateway_trust"); + if (action.action === "gateway_trust") { + expect(action.device).toBe(DEVICE_HEX); + } + }); + + test("buildAction parses gateway_untrust", () => { + const action = buildAction({ + action: "gateway_untrust", + device: DEVICE_HEX, + }); + expect(action.action).toBe("gateway_untrust"); + if (action.action === "gateway_untrust") { + expect(action.device).toBe(DEVICE_HEX); + } + }); + + test("buildAction parses gateway_list_trusted", () => { + const action = buildAction({ action: "gateway_list_trusted" }); + expect(action.action).toBe("gateway_list_trusted"); + }); + + test("buildAction throws for gateway_trust without device", () => { + expect(() => buildAction({ action: "gateway_trust" })).toThrow(/device/); + }); + + test("buildAction throws for gateway_untrust without device", () => { + expect(() => buildAction({ action: "gateway_untrust" })).toThrow(/device/); + }); +}); From 58870be8739a7c923e6d85b60201e5a144d3dc4c Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Thu, 17 Sep 2026 19:11:40 +0100 Subject: [PATCH 11/12] fix(core): gate readvertiseGossip's own hub-directed self-advert on gateway trust readvertiseGossip's periodic tick called sendGossipUpdate on every live session unconditionally, including the hub's own session -- a completely separate mechanism from forwardAdvertsToHub's own already-gated advertiseDevices path, and one that bypassed the gateway trust boundary entirely. A visible local agent's presence, hosted rooms, and self-advert leaked onto the hub the moment the periodic gossip timer fired, regardless of whether any remote gateway was trusted. Caught by gateway-trust.integration.test.ts's own deny-by-default coverage. Fixed via HubSession.ownsSession, so the loop can identify and gate only the hub's own session without ever exposing the raw session object. --- src/core/hub-session.ts | 5 +++++ src/core/wire-mesh-transport.ts | 11 +++++------ 2 files changed, 10 insertions(+), 6 deletions(-) diff --git a/src/core/hub-session.ts b/src/core/hub-session.ts index 7f0a98c8..b866dd39 100644 --- a/src/core/hub-session.ts +++ b/src/core/hub-session.ts @@ -77,6 +77,11 @@ export class HubSession { return this.session !== undefined; } + /** Whether the given session object is this side's own currently-held hub session -- lets WireMeshTransport distinguish the hub's session from an ordinary local-peer session within its own allSessions bookkeeping (agent-comms#156's own readvertiseGossip gate needs this) without this class ever exposing the raw session object itself. */ + ownsSession(session: AcceptedMeshSession): boolean { + return this.session === session; + } + /** Drops the held hub connection. A no-op if none is live (connect() was never called, disconnect() already ran, or the hub itself already closed the session). */ async disconnect(): Promise { const session = this.session; diff --git a/src/core/wire-mesh-transport.ts b/src/core/wire-mesh-transport.ts index 11ef7a81..824ad5ea 100644 --- a/src/core/wire-mesh-transport.ts +++ b/src/core/wire-mesh-transport.ts @@ -254,9 +254,6 @@ export class WireMeshTransport implements MeshTransport { /** The cross-machine trust boundary (agent-comms#156): gates outbound gossip advertisement (hasAny), inbound directory merge/request dispatch, and outbound targeted hub requests (both isTrusted) -- see GatewayTrust's own class doc. Defaults to a fresh, empty (deny-all) instance when no caller wires one in, matching every existing construction site that predates this feature. */ private readonly gatewayTrust: GatewayTrustReader; - /** Bound once here so both hub-forwarding call sites (watchForDisconnect, connectHub) can pass a plain reference rather than repeating an inline arrow. */ - private readonly hasAnyTrustedGateway = (): boolean => - this.gatewayTrust.hasAny(); constructor( events: Readonly, @@ -360,7 +357,7 @@ export class WireMeshTransport implements MeshTransport { } } - /** Re-sends this side's own current presence status and currently-hosted rooms, together, onto every live session's gossip self-advert -- one gossip frame per tick carrying whichever of the two sources is wired in, rather than a separate frame per fact. A session that fails to send (mid-disconnect, most likely -- watchForDisconnect will independently notice and clean it up) is reported via onError and skipped, not allowed to stop the tick from reaching the rest of allSessions: a periodic broadcast to N peers is N independent operations, not one atomic unit. A no-op tick (neither source wired in, or no sessions exist yet) is expected and silent. */ + /** Re-sends this side's own current presence status and currently-hosted rooms, together, onto every live session's gossip self-advert -- one gossip frame per tick carrying whichever of the two sources is wired in, rather than a separate frame per fact. A session that fails to send (mid-disconnect, most likely -- watchForDisconnect will independently notice and clean it up) is reported via onError and skipped, not allowed to stop the tick from reaching the rest of allSessions: a periodic broadcast to N peers is N independent operations, not one atomic unit. A no-op tick (neither source wired in, or no sessions exist yet) is expected and silent. The hub's own session (agent-comms#156) is gated separately from every ordinary local-peer session in allSessions: local mesh trust is a different layer (connect_request/introduce approval already gated it before it ever joined allSessions), but the hub session is a broadcast to every connected hub peer, trusted or not, and would otherwise leak this side's own presence/hosted-rooms/self-agent advert onto the hub regardless of GatewayTrust -- forwardAdvertsToHub/pushHubCatchUp's own hasAny gate exists to prevent exactly this for OTHER local peers' adverts, and this side's own self-advert deserves the identical gate, not a bypass. */ private readvertiseGossip(): void { const extensions: Record = {}; const status = this.getCurrentPresence?.(); @@ -373,6 +370,8 @@ export class WireMeshTransport implements MeshTransport { extensions[AGENT_SELF_GOSSIP_KEY] = selfAgentAdvert; if (Object.keys(extensions).length === 0) return; for (const session of this.allSessions) { + if (this.hub.ownsSession(session) && !this.gatewayTrust.hasAny()) + continue; session.sendGossipUpdate(extensions).catch((error: unknown) => { this.events.onError?.( error instanceof Error ? error : new Error(String(error)), @@ -555,7 +554,7 @@ export class WireMeshTransport implements MeshTransport { this.hub, event.directory, this.events.onError, - this.hasAnyTrustedGateway, + () => this.gatewayTrust.hasAny(), ); const presence = findPresenceAdvert(deviceIdHex, event.directory); if (presence !== undefined) { @@ -951,7 +950,7 @@ export class WireMeshTransport implements MeshTransport { url, this.knownDevices, this.events.onError, - this.hasAnyTrustedGateway, + () => this.gatewayTrust.hasAny(), ); } From 4ad262b6d22ef674ec0699097ce45dfaa82799ce Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Thu, 17 Sep 2026 19:11:53 +0100 Subject: [PATCH 12/12] test(core): prove gateway trust's deny-by-default posture end to end Three real-hub scenarios: a remote agent's own directory entry never merges without explicit trust even once a trusted control agent proves gossip propagation had time; an untrusted side's own local agents never reach the hub at all, regardless of whether the far side would have accepted them; and a room request to an untrusted device is refused immediately rather than hanging on the hub's own silent-drop timeout. This is the test that caught readvertiseGossip's own trust-gate bypass in the preceding commit. --- src/test/gateway-trust.integration.test.ts | 172 +++++++++++++++++++++ 1 file changed, 172 insertions(+) create mode 100644 src/test/gateway-trust.integration.test.ts diff --git a/src/test/gateway-trust.integration.test.ts b/src/test/gateway-trust.integration.test.ts new file mode 100644 index 00000000..ec0309dc --- /dev/null +++ b/src/test/gateway-trust.integration.test.ts @@ -0,0 +1,172 @@ +/** + * Integration tests for the gateway allowlist's own deny-by-default posture (agent-comms#156), the trust-boundary leg of the cross-machine mesh epic (#153) on top of #155's own (now trust-gated) forwarding. Same two-independent-local-meshes-over-a-real-hub harness as gateway-forwarding.integration.test.ts, but every scenario here proves the ABSENCE of forwarding/merge/routing before any trust is established, complementing that file's own coverage of the PRESENT-trust happy path. + */ + +import { afterEach, describe, expect, it } from "vitest"; +import { MeshStore } from "../core/mesh-store.js"; +import { realHubOverWs } from "./hub-helpers.js"; +import { wireTestTransport } from "./test-transport.js"; + +let nextPort = 22_500; +function freshPort(): number { + nextPort += 1; + return nextPort; +} + +/** Short enough that a gossip re-advertisement fires within a test's own poll budget, matching gateway-forwarding.integration.test.ts's own choice. */ +const FAST_GOSSIP_INTERVAL_MS = 50; + +const POLL_ATTEMPTS = 100; +const POLL_INTERVAL_MS = 50; +const sleep = async (ms: number): Promise => + new Promise((resolve) => { + setTimeout(resolve, ms); + }); + +async function waitFor( + what: string, + check: () => Promise, +): Promise { + for (let i = 0; i < POLL_ATTEMPTS; i++) { + if (await check()) return; + await sleep(POLL_INTERVAL_MS); + } + expect(false, `timed out waiting for ${what}`).toBeTruthy(); +} + +const cleanups: (() => Promise)[] = []; + +afterEach(async () => { + for (const close of cleanups.splice(0)) { + await close(); + } +}); + +describe("gateway trust -- deny by default", () => { + it("never merges a remote agent's directory entry when this side hasn't trusted it, even once a trusted control agent proves propagation had time", async () => { + const hub = await realHubOverWs(); + cleanups.push(hub.close); + + const a1 = new MeshStore(freshPort(), hub.url); + await wireTestTransport(a1, undefined, undefined, FAST_GOSSIP_INTERVAL_MS); + await a1.init(); + cleanups.push(async () => a1.shutdown()); + await a1.registerAgent({ + name: "untrusted-a1", + harness: "test", + cwd: "/test/a1", + pid: process.pid, + visibility: "visible", + tags: [], + }); + + const b1 = new MeshStore(freshPort(), hub.url); + await wireTestTransport(b1, undefined, undefined, FAST_GOSSIP_INTERVAL_MS); + await b1.init(); + cleanups.push(async () => b1.shutdown()); + + const c1 = new MeshStore(freshPort(), hub.url); + await wireTestTransport(c1, undefined, undefined, FAST_GOSSIP_INTERVAL_MS); + await c1.init(); + cleanups.push(async () => c1.shutdown()); + await c1.registerAgent({ + name: "trusted-control-c1", + harness: "test", + cwd: "/test/c1", + pid: process.pid, + visibility: "visible", + tags: [], + }); + + // a1 needs at least one trusted device to forward at all (the outbound gate); c1 fills that role so a1's own gossip reaches the hub, without ever trusting b1 specifically. b1 trusts c1 (proving propagation genuinely had time) but never trusts a1. + a1.addTrustedGateway(c1.peerId); + c1.addTrustedGateway(a1.peerId); + b1.addTrustedGateway(c1.peerId); + c1.addTrustedGateway(b1.peerId); + + await waitFor("b1 to see the trusted control agent c1", async () => { + const agents = await b1.listAgents(b1.peerId); + return agents.some((agent) => agent.id === c1.peerId); + }); + + const agents = await b1.listAgents(b1.peerId); + expect(agents.some((agent) => agent.id === a1.peerId)).toBe(false); + }); + + it("never advertises anything onto the hub while this side has trusted nobody, even for a peer another side would have accepted", async () => { + const hub = await realHubOverWs(); + cleanups.push(hub.close); + + const a1 = new MeshStore(freshPort(), hub.url); + await wireTestTransport(a1, undefined, undefined, FAST_GOSSIP_INTERVAL_MS); + await a1.init(); + cleanups.push(async () => a1.shutdown()); + await a1.registerAgent({ + name: "never-advertised-a1", + harness: "test", + cwd: "/test/a1", + pid: process.pid, + visibility: "visible", + tags: [], + }); + // a1 trusts nobody at all -- the outbound gate (hasAny) should withhold every advertisement. + + const b1 = new MeshStore(freshPort(), hub.url); + await wireTestTransport(b1, undefined, undefined, FAST_GOSSIP_INTERVAL_MS); + await b1.init(); + cleanups.push(async () => b1.shutdown()); + b1.addTrustedGateway(a1.peerId); + + const c1 = new MeshStore(freshPort(), hub.url); + await wireTestTransport(c1, undefined, undefined, FAST_GOSSIP_INTERVAL_MS); + await c1.init(); + cleanups.push(async () => c1.shutdown()); + await c1.registerAgent({ + name: "trusted-control-c1", + harness: "test", + cwd: "/test/c1", + pid: process.pid, + visibility: "visible", + tags: [], + }); + b1.addTrustedGateway(c1.peerId); + c1.addTrustedGateway(b1.peerId); + + await waitFor( + "b1 to see c1, proving gossip had time to reach it", + async () => { + const agents = await b1.listAgents(b1.peerId); + return agents.some((agent) => agent.id === c1.peerId); + }, + ); + + const agents = await b1.listAgents(b1.peerId); + expect(agents.some((agent) => agent.id === a1.peerId)).toBe(false); + }); + + it("refuses to route a request to an untrusted remote device, without ever reaching the hub", async () => { + const hub = await realHubOverWs(); + cleanups.push(hub.close); + + const a1 = new MeshStore(freshPort(), hub.url); + await wireTestTransport(a1, undefined, undefined, FAST_GOSSIP_INTERVAL_MS); + await a1.init(); + cleanups.push(async () => a1.shutdown()); + await a1.registerAgent({ + name: "a1-gateway", + harness: "test", + cwd: "/test/a1", + pid: process.pid, + visibility: "visible", + tags: [], + }); + + const b1 = new MeshStore(freshPort(), hub.url); + await wireTestTransport(b1, undefined, undefined, FAST_GOSSIP_INTERVAL_MS); + await b1.init(); + cleanups.push(async () => b1.shutdown()); + // b1 never trusts a1 -- requestDmAccess must be refused fast, not hang out sendRoomRequest's own hub timeout. + + await expect(b1.requestDmAccess(a1.peerId)).rejects.toThrow(/unauthorized/); + }); +});