Skip to content
Open
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
28 changes: 28 additions & 0 deletions docs/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -111,8 +111,36 @@ MCP server wrapping an Agent instance. Exposes all agent operations as MCP tools
10. SenderKeys.decrypt(record, header, ciphertext) → plaintext
11. Signature verified with A's Ed25519 public key
12. Message stored in SQLite, event emitted
13. Inbound event bridge fires (see below)
```

### Inbound event bridge

After a group message is authenticated, decrypted, and persisted, the
`GroupManager` emits two typed events in addition to the legacy
`group:message` (preserved unchanged for existing listeners):

- `inbound:message` — `PrivateInboundMessageEvent`. Local-only. Carries the
decrypted plaintext plus enough context (`messageId`, `groupId`,
`senderPublicKey`, `senderFingerprint`, `timestamp`, `receivedAt`) for an
agent runtime to decide **act | ask | ignore**. Buffered in the
per-`Agent` `InboundEventQueue` so poll-based consumers (MCP, CLI) don't
drop events between polls.
- `activity:message` — `PublicActivityEvent`. Metadata-only
(`groupIdHex`, `senderFingerprint`, `timestamp`, `byteLength`). Safe for
public logs, census, future heartbeat/dashboard. Emitted live; never
queued.

Neither event is emitted on rejected paths (invalid signature, unknown
member, failed decryption, unknown group). Direct messages do not yet fire
these events — the DM receive path is fail-closed until Double Ratchet
lands; the types already model `kind: 'dm'` for forward compatibility.

MCP exposes a poll-style tool `get_pending_inbound_events` that drains the
queue. The tool serializes events through `toInboundEventDTO`, which
hex-encodes key material and base64-encodes plaintext so no raw
`Uint8Array` ever passes through `JSON.stringify`.

### TTYA Visitor Chat

```
Expand Down
24 changes: 24 additions & 0 deletions packages/core/src/events/inbound.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
export type InboundMessageKind = 'group' | 'dm';

// Local-only. Carries decrypted plaintext. Never serialized to public
// surfaces (census, heartbeat, logs). Consumers: owner's agent runtime.
export interface PrivateInboundMessageEvent {
kind: InboundMessageKind;
messageId: string;
groupId?: Uint8Array;
senderPublicKey: Uint8Array;
senderFingerprint: string;
plaintext: Uint8Array;
timestamp: number;
receivedAt: number;
}

// Metadata-only. Safe for public logs, census, future heartbeat/dashboard.
// Never contains plaintext or ciphertext bytes.
export interface PublicActivityEvent {
kind: InboundMessageKind;
groupIdHex?: string;
senderFingerprint: string;
timestamp: number;
byteLength: number;
}
5 changes: 5 additions & 0 deletions packages/core/src/events/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
export type {
InboundMessageKind,
PrivateInboundMessageEvent,
PublicActivityEvent,
} from './inbound.js';
1 change: 1 addition & 0 deletions packages/core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,3 +8,4 @@ export {
} from './identity.js';
export * from './crypto/index.js';
export * from './protocol/index.js';
export * from './events/index.js';
50 changes: 50 additions & 0 deletions packages/mcp/src/__tests__/inbound-dto.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import { describe, it, expect } from 'vitest';
import type { PrivateInboundMessageEvent } from '@networkselfmd/node';
import { toInboundEventDTO } from '../tools/messaging.js';

const base: PrivateInboundMessageEvent = {
kind: 'group',
messageId: 'm1',
groupId: new Uint8Array([0xde, 0xad]),
senderPublicKey: new Uint8Array([0xbe, 0xef, 0x01]),
senderFingerprint: 'fp1',
plaintext: new TextEncoder().encode('hi'),
timestamp: 10,
receivedAt: 20,
};

describe('toInboundEventDTO', () => {
it('hex-encodes groupId and senderPublicKey; always sets plaintextBase64', () => {
const dto = toInboundEventDTO(base);
expect(dto.groupIdHex).toBe('dead');
expect(dto.senderPublicKeyHex).toBe('beef01');
expect(dto.plaintextBase64).toBe(Buffer.from('hi').toString('base64'));
});

it('sets plaintextUtf8 only for valid UTF-8', () => {
expect(toInboundEventDTO(base).plaintextUtf8).toBe('hi');

const bad = toInboundEventDTO({
...base,
plaintext: new Uint8Array([0xc3, 0x28]), // invalid UTF-8
});
expect(bad.plaintextUtf8).toBeUndefined();
expect(bad.plaintextBase64).toBe(Buffer.from([0xc3, 0x28]).toString('base64'));
});

it('omits groupIdHex when no groupId is present', () => {
const dto = toInboundEventDTO({ ...base, groupId: undefined, kind: 'dm' });
expect(dto.groupIdHex).toBeUndefined();
expect(dto.kind).toBe('dm');
});

it('JSON.stringify does not leak numeric-keyed byte objects', () => {
const dto = toInboundEventDTO(base);
const serialized = JSON.stringify(dto);
expect(serialized).not.toMatch(/"0":\s*\d+,\s*"1":\s*\d+/);
expect(serialized).toContain('beef01');
expect(serialized).toContain('dead');
// plaintext bytes must not appear as raw numeric arrays either.
expect(serialized).not.toContain('"plaintext":');
});
});
52 changes: 51 additions & 1 deletion packages/mcp/src/tools/messaging.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,39 @@
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { z } from 'zod';
import type { Agent } from '@networkselfmd/node';
import type { Agent, PrivateInboundMessageEvent } from '@networkselfmd/node';

export interface InboundEventDTO {
kind: 'group' | 'dm';
messageId: string;
groupIdHex?: string;
senderPublicKeyHex: string;
senderFingerprint: string;
plaintextUtf8?: string;
plaintextBase64: string;
timestamp: number;
receivedAt: number;
}

export function toInboundEventDTO(ev: PrivateInboundMessageEvent): InboundEventDTO {
const dto: InboundEventDTO = {
kind: ev.kind,
messageId: ev.messageId,
groupIdHex: ev.groupId ? Buffer.from(ev.groupId).toString('hex') : undefined,
senderPublicKeyHex: Buffer.from(ev.senderPublicKey).toString('hex'),
senderFingerprint: ev.senderFingerprint,
plaintextBase64: Buffer.from(ev.plaintext).toString('base64'),
timestamp: ev.timestamp,
receivedAt: ev.receivedAt,
};
// Strict UTF-8 decode. If plaintext isn't valid UTF-8, omit the field —
// consumers fall back to plaintextBase64.
try {
dto.plaintextUtf8 = new TextDecoder('utf-8', { fatal: true }).decode(ev.plaintext);
} catch {
// non-UTF-8 payload — plaintextUtf8 stays undefined
}
return dto;
}

export function registerMessagingTools(server: McpServer, agent: Agent): void {
server.tool(
Expand Down Expand Up @@ -39,6 +72,23 @@ export function registerMessagingTools(server: McpServer, agent: Agent): void {
},
);

server.tool(
'get_pending_inbound_events',
'Owner-private, local-only. Drains pending inbound (authenticated, decrypted) message events for the owner\'s agent runtime so it can decide act | ask | ignore. Results may contain plaintext — do NOT forward them to public dashboards, census, heartbeat, shared logs, or any non-owner surface.',
{
limit: z.number().optional().describe('Maximum number of events to drain (default 50)'),
},
async ({ limit }) => {
const events = agent.inboundQueue.drain(limit ?? 50);
return {
content: [{
type: 'text' as const,
text: JSON.stringify({ events: events.map(toInboundEventDTO) }),
}],
};
},
);

server.tool(
'read_messages',
'Read recent messages from a group or direct conversation',
Expand Down
49 changes: 1 addition & 48 deletions packages/node/src/__tests__/group-signatures.test.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,10 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { EventEmitter } from 'node:events';
import { mkdtempSync, rmSync } from 'node:fs';
import { join } from 'node:path';
import { tmpdir } from 'node:os';
import { randomBytes } from 'node:crypto';
import {
fingerprintFromPublicKey,
generateIdentity,
signMessage,
MessageType,
SenderKeys,
Expand All @@ -26,52 +24,7 @@ import {
} from '../storage/index.js';
import { GroupManager } from '../groups/group-manager.js';
import { PeerSession } from '../network/connection.js';

function makeIdentity(displayName: string): AgentIdentity {
return generateIdentity(displayName);
}

function makeMockSocket() {
const handlers = new Map<string, Array<(...args: unknown[]) => void>>();
return {
write: () => true,
end: () => {},
destroy: () => {},
on: (event: string, handler: (...args: unknown[]) => void) => {
const arr = handlers.get(event) ?? [];
arr.push(handler);
handlers.set(event, arr);
},
removeAllListeners: (event?: string) => {
if (event) handlers.delete(event);
else handlers.clear();
},
remotePublicKey: randomBytes(32),
};
}

function makeMockSession(peerIdentity: AgentIdentity): PeerSession {
const session = new PeerSession(makeMockSocket());
session.setVerified(
peerIdentity.edPublicKey,
peerIdentity.fingerprint,
peerIdentity.displayName,
);
return session;
}

function makeMockSwarm(options: {
sessions?: PeerSession[];
sessionByFingerprint?: Map<string, PeerSession>;
} = {}) {
const emitter = new EventEmitter();
return Object.assign(emitter, {
getSession: (fingerprint: string) => options.sessionByFingerprint?.get(fingerprint),
getAllSessions: (): PeerSession[] => options.sessions ?? [],
join: async () => undefined,
leave: async () => undefined,
});
}
import { makeIdentity, makeMockSession, makeMockSwarm } from './test-utils/group-harness.js';

describe('GroupManager signature + admin gating', () => {
let dataDir: string;
Expand Down
Loading