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/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/gateway-trust.ts b/src/core/gateway-trust.ts new file mode 100644 index 00000000..d119f7df --- /dev/null +++ b/src/core/gateway-trust.ts @@ -0,0 +1,40 @@ +/** + * 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, 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(); + + /** 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/core/hub-forwarding.ts b/src/core/hub-forwarding.ts index cfd73681..2aa8a04c 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,20 +33,21 @@ 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. */ +/** 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, @@ -55,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/hub-session.ts b/src/core/hub-session.ts index 30b798d9..b866dd39 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 { @@ -75,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; @@ -108,13 +115,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 +138,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 +147,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 +236,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"; } 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/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/core/wire-mesh-transport.ts b/src/core/wire-mesh-transport.ts index 61f65d10..824ad5ea 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,9 @@ 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; + constructor( events: Readonly, identity: Readonly, @@ -269,6 +265,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 +273,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 +292,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; @@ -358,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?.(); @@ -371,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)), @@ -549,7 +550,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.gatewayTrust.hasAny(), + ); const presence = findPresenceAdvert(deviceIdHex, event.directory); if (presence !== undefined) { this.events.onPresenceAdvert(handle, presence); @@ -791,6 +797,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 +911,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 +945,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.gatewayTrust.hasAny(), + ); } async disconnectHub(): Promise { 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); 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/); + }); +}); 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/); + }); +}); 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"]); + }); +}); diff --git a/src/test/hub-forwarding.test.ts b/src/test/hub-forwarding.test.ts new file mode 100644 index 00000000..8d8df3dc --- /dev/null +++ b/src/test/hub-forwarding.test.ts @@ -0,0 +1,88 @@ +/** + * 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": {} }, + }; +} + +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); + }); +}); 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) && 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({ 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. });