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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
145 changes: 90 additions & 55 deletions src/core/hub-session.ts

Large diffs are not rendered by default.

5 changes: 4 additions & 1 deletion src/core/wire-mesh-transport.ts
Original file line number Diff line number Diff line change
Expand Up @@ -258,7 +258,7 @@ 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<string, Connection>();

/** 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. */
/** The cross-machine trust boundary (agent-comms#156): gates outbound gossip advertisement (hasAny), inbound directory merge (isTrusted or isTrustedPrincipal, wired into HubSession as isTrustedForDirectory since agent-comms#192), the legacy per-device frame path (isTrusted), and outbound targeted hub requests (isTrusted); see GatewayTrust's own class doc. A real room-domain manage-request relayed through the hub is never gated on this at all since agent-comms#192: hub-session.ts's own dispatchHubRequest relies purely on that verb's own capability-token verification instead. 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(
Expand Down Expand Up @@ -299,6 +299,9 @@ export class WireMeshTransport implements MeshTransport {
},
handleRoomRequest: this.roomRouter.handleRequest,
isTrusted: (deviceHex) => this.gatewayTrust.isTrusted(deviceHex),
isTrustedForDirectory: (deviceHex) =>
this.gatewayTrust.isTrusted(deviceHex) ||
this.gatewayTrust.isTrustedPrincipal(deviceHex),
forwardToLocalPeer: sendToLocalPeer.bind(null, this.peerSessions),
});
this.pendingConnectionTimeoutMs = pendingConnectionTimeoutMs;
Expand Down
49 changes: 49 additions & 0 deletions src/test/hub-mode-session.integration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,55 @@ describe("connectToHub", () => {
await transportB.shutdown();
});

it("discovers a peer via gossip when that peer is trusted only as a principal, not as a bare device (agent-comms#192's own directory-merge widening)", async () => {
const hub = await realHubOverWs();
cleanups.push(hub.close);

const eventsA = recordingEvents();
const eventsB = recordingEvents();
const identityA = generateIdentity();
const identityB = generateIdentity();
const deviceA = deviceIdToHex(Uint8Array.from(identityA.deviceId));
const deviceB = deviceIdToHex(Uint8Array.from(identityB.deviceId));
// B trusts A's device as a PRINCIPAL (addPrincipal), never on the bare-device allowlist (add) -- proving connect()'s own directory-merge filter now accepts isTrustedForDirectory (bare-device OR principal), not only the original bare-device isTrusted. A trusts nothing at all: whether B's own gossip surfaces here has nothing to do with what A trusts, only with what B's own incoming filter accepts.
const gatewayTrustA = new GatewayTrust();
const gatewayTrustB = new GatewayTrust();
gatewayTrustB.addPrincipal(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);

await waitForCondition(() => transportB.hub.peers().includes(deviceA));
// The bare-device allowlist is untouched by this widening: A was never added() to gatewayTrustB, only addPrincipal()'d, so isTrusted(deviceA) itself must still read false even though isTrustedForDirectory let the gossip through.
expect(gatewayTrustB.isTrusted(deviceA)).toBe(false);

await transportA.shutdown();
await transportB.shutdown();
});

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);
Expand Down
130 changes: 130 additions & 0 deletions src/test/hub-room-domain-verb-trust-bypass.integration.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
/**
* End-to-end proof of agent-comms#192 over a real wire-mesh relay hub (the same domain logic the production mesh.exadev.io Durable Object runs, served over local WebSockets via hub-helpers.ts): a real room.join from a device the receiving gateway's own GatewayTrust does NOT trust reaches the deliberate human-approval flow instead of being rejected outright by the coarse bare-device gateway allowlist. Under the pre-#192 code, the owner's own consume() would answer this request with an immediate unauthorized outcome, and owner.listPendingRoomJoins() would never show it at all.
*
* Only a hub connection between owner and requester is ever established, deliberately never a direct local peer connection: hub mode carries no legacy full-state-sync (room-join-admission.test.ts's own header comment explains why that path makes "a room I've never heard of" untestable over an ordinary two-peer local mesh), so this is the one topology where the requester genuinely has never heard of the owner's room before sending a real wire-level room.join for it.
*/

import { afterEach, describe, expect, it } from "vitest";
import { MeshStore } from "../core/mesh-store.js";
import { wireTestTransportWithHub } from "./test-transport.js";
import { realHubOverWs, waitForCondition } from "./hub-helpers.js";

const cleanups: (() => Promise<void>)[] = [];

afterEach(async () => {
for (const close of cleanups.splice(0)) {
await close();
}
});

/** Wires up an owner and a requester MeshStore, each connected to the same real hub, with the requester trusting the owner's device (the outbound leg WireMeshTransport.sendRoomRequest needs before it will even attempt routing via the hub) but the owner trusting nothing at all -- the absence that is the whole point of every test in this file. Deliberately never calls MeshStore.init(): that method's own local-mesh coordinator election (connectToCoordinator/becomeCoordinator against the real, well-known port 19876) has nothing to do with hub mode and risks colliding with an unrelated coordinator already running on the machine -- hub-mode-session.integration.test.ts's own raw-WireMeshTransport tests never call it either, for the same reason. */
async function connectedOwnerAndRequester(hubUrl: string): Promise<{
owner: MeshStore;
requester: MeshStore;
}> {
const owner = new MeshStore();
const { transport: ownerTransport } = await wireTestTransportWithHub(owner);
await owner.registerAgent({
name: "owner",
harness: "test",
cwd: "/test/owner",
pid: process.pid,
visibility: "visible",
tags: [],
});

const requester = new MeshStore();
const { transport: requesterTransport } =
await wireTestTransportWithHub(requester);
await requester.registerAgent({
name: "requester",
harness: "test",
cwd: "/test/requester",
pid: process.pid,
visibility: "visible",
tags: [],
});

requester.gatewayTrust.add(owner.peerId);
expect(owner.gatewayTrust.hasAny()).toBe(false);

await ownerTransport.connectHub?.(hubUrl);
await requesterTransport.connectHub?.(hubUrl);
await waitForCondition(
() => ownerTransport.hub.isConnected && requesterTransport.hub.isConnected,
);

return { owner, requester };
}

describe("hub-relayed room.join versus the gateway trust boundary", () => {
it("reaches the owner's human-approval flow, and grants membership on acceptance, even though the owner's gateway does not trust the requester's device at all", async () => {
const hub = await realHubOverWs();
cleanups.push(hub.close);
const { owner, requester } = await connectedOwnerAndRequester(hub.url);

const room = await owner.createRoom({
name: "general",
type: "public",
owner: owner.peerId,
description: "",
});
expect(await requester.getRoom(room.id)).toBeUndefined();

const joinPromise = requester.joinRoom(room.id, requester.peerId);

await waitForCondition(() =>
owner
.listPendingRoomJoins()
.some(
(pending) =>
pending.roomPath === room.id &&
pending.requesterId === requester.peerId,
),
);

owner.acceptRoomJoin(room.id, requester.peerId);
const joined = await joinPromise;

expect(joined.id).toBe(room.id);
expect(joined.members.includes(requester.peerId)).toBe(true);
// hubPeersKnown (peers()) stays "gateway-trusted hub peers" even after a real, successful room-domain admission: the requester's own device was never on the owner's bare-device allowlist, only its capability token was ever verified, so it must never surface as a "known" hub peer just because a room-domain request from it happened to succeed.
expect(owner.gatewayTrust.hasAny()).toBe(false);

await requester.shutdown();
await owner.shutdown();
});

it("still lets the owner reject the same untrusted-gateway request through the ordinary human-decision outcome, not a gateway-level error", async () => {
const hub = await realHubOverWs();
cleanups.push(hub.close);
const { owner, requester } = await connectedOwnerAndRequester(hub.url);

const room = await owner.createRoom({
name: "private-room",
type: "public",
owner: owner.peerId,
description: "",
});

const joinPromise = requester.joinRoom(room.id, requester.peerId);

await waitForCondition(() =>
owner
.listPendingRoomJoins()
.some(
(pending) =>
pending.roomPath === room.id &&
pending.requesterId === requester.peerId,
),
);

owner.rejectRoomJoin(room.id, requester.peerId, "not today");

await expect(joinPromise).rejects.toThrow();
expect(await requester.getRoom(room.id)).toBeUndefined();

await requester.shutdown();
await owner.shutdown();
});
});
Loading
Loading