From 8f786aeff74828e7c6d4779c023abc29eb3b916f Mon Sep 17 00:00:00 2001 From: Ray Svitla <130174948+raysvitla@users.noreply.github.com> Date: Sat, 25 Apr 2026 09:25:36 +0100 Subject: [PATCH 1/8] feat: add fail-closed reasons + structural validator for inbound events MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extend the policy reason vocabulary with the four fail-closed tokens the hardened gate will need: - malformed-event: structural fields missing/wrong type - unknown-event-kind: kind is neither 'group' nor 'dm' - duplicate-event: same messageId already evaluated successfully - not-a-member: gate-level membership recheck failed (defence-in-depth) POLICY_REASONS exports the full, sorted vocabulary so tests can lock the public surface — any future addition or rename surfaces as a deliberate diff. validateInboundEvent (packages/node/src/policy/validate.ts) is a pure, strict, defence-against-direct-injection structural check. The function intentionally takes `unknown` so the trust boundary holds even when TypeScript would otherwise vouch for the input. AgentPolicy.decide() keeps its typed input; the gate (next commit) is responsible for calling validate() first and short-circuiting fail-closed paths before invoking decide(). Adds 13 tests covering null/non-object input, unknown kind, missing/empty required fields, wrong types on plaintext/senderPublicKey, non-finite numbers, missing groupId for group events, prototype-pollution-style inputs, and the stability of POLICY_REASONS itself. Co-Authored-By: Claude Opus 4.7 --- packages/core/src/policy/index.ts | 3 + packages/core/src/policy/types.ts | 30 ++++- .../src/__tests__/policy-validate.test.ts | 119 ++++++++++++++++++ packages/node/src/policy/validate.ts | 87 +++++++++++++ 4 files changed, 238 insertions(+), 1 deletion(-) create mode 100644 packages/node/src/__tests__/policy-validate.test.ts create mode 100644 packages/node/src/policy/validate.ts diff --git a/packages/core/src/policy/index.ts b/packages/core/src/policy/index.ts index a40289e..b4ccfd6 100644 --- a/packages/core/src/policy/index.ts +++ b/packages/core/src/policy/index.ts @@ -1,6 +1,9 @@ export type { PolicyAction, PolicyReason, + PolicyDecisionReason, + PolicyFailClosedReason, PolicyDecision, PolicyConfig, } from './types.js'; +export { POLICY_REASONS } from './types.js'; diff --git a/packages/core/src/policy/types.ts b/packages/core/src/policy/types.ts index 3abc9ab..1813db1 100644 --- a/packages/core/src/policy/types.ts +++ b/packages/core/src/policy/types.ts @@ -1,7 +1,11 @@ export type PolicyAction = 'act' | 'ask' | 'ignore'; // Fixed, metadata-safe reason tokens. Never include plaintext content. -export type PolicyReason = +// +// "decision" reasons describe the agent runtime's intent for an event that +// passed all structural and authorization checks. "fail-closed" reasons +// describe events that did not — those events MUST NOT proceed downstream. +export type PolicyDecisionReason = | 'not-addressed' | 'addressed-and-trusted' | 'addressed-unknown-sender' @@ -10,6 +14,30 @@ export type PolicyReason = | 'interest-hit' | 'trusted-no-signal'; +export type PolicyFailClosedReason = + | 'malformed-event' + | 'unknown-event-kind' + | 'duplicate-event' + | 'not-a-member'; + +export type PolicyReason = PolicyDecisionReason | PolicyFailClosedReason; + +// Stable, exhaustive list of every reason token the policy gate may emit. +// Used by tests to lock the public reason vocabulary. +export const POLICY_REASONS: readonly PolicyReason[] = [ + 'not-addressed', + 'addressed-and-trusted', + 'addressed-unknown-sender', + 'addressed-matches-interest', + 'trusted-interest-hit', + 'interest-hit', + 'trusted-no-signal', + 'malformed-event', + 'unknown-event-kind', + 'duplicate-event', + 'not-a-member', +] as const; + // Output of AgentPolicy.decide — safe to log in full; contains no plaintext. export interface PolicyDecision { messageId: string; diff --git a/packages/node/src/__tests__/policy-validate.test.ts b/packages/node/src/__tests__/policy-validate.test.ts new file mode 100644 index 0000000..9980d91 --- /dev/null +++ b/packages/node/src/__tests__/policy-validate.test.ts @@ -0,0 +1,119 @@ +import { describe, it, expect } from 'vitest'; +import { POLICY_REASONS } from '@networkselfmd/core'; +import type { PrivateInboundMessageEvent, PolicyReason } from '@networkselfmd/core'; +import { validateInboundEvent } from '../policy/validate.js'; + +const VALID_GROUP_EVENT: PrivateInboundMessageEvent = { + kind: 'group', + messageId: 'm-1', + groupId: new Uint8Array([0xaa, 0xbb]), + senderPublicKey: new Uint8Array(32), + senderFingerprint: 'fp1', + plaintext: new TextEncoder().encode('hi'), + timestamp: 1, + receivedAt: 2, +}; + +describe('validateInboundEvent — fail-closed structural checks', () => { + it('accepts a well-formed group event', () => { + const r = validateInboundEvent(VALID_GROUP_EVENT); + expect(r.ok).toBe(true); + if (r.ok) expect(r.ev.kind).toBe('group'); + }); + + it('accepts a well-formed dm event (groupId optional)', () => { + const dm: PrivateInboundMessageEvent = { ...VALID_GROUP_EVENT, kind: 'dm', groupId: undefined }; + const r = validateInboundEvent(dm); + expect(r.ok).toBe(true); + }); + + it('rejects null / non-object payload as malformed-event', () => { + expect(validateInboundEvent(null)).toEqual({ ok: false, reason: 'malformed-event' }); + expect(validateInboundEvent(undefined)).toEqual({ ok: false, reason: 'malformed-event' }); + expect(validateInboundEvent(42)).toEqual({ ok: false, reason: 'malformed-event' }); + expect(validateInboundEvent('a')).toEqual({ ok: false, reason: 'malformed-event' }); + expect(validateInboundEvent([])).toEqual({ ok: false, reason: 'malformed-event' }); + }); + + it('rejects an unknown kind as unknown-event-kind', () => { + const r = validateInboundEvent({ ...VALID_GROUP_EVENT, kind: 'invite' }); + expect(r).toEqual({ ok: false, reason: 'unknown-event-kind' }); + }); + + it('rejects missing messageId', () => { + const r = validateInboundEvent({ ...VALID_GROUP_EVENT, messageId: undefined }); + expect(r).toEqual({ ok: false, reason: 'malformed-event' }); + }); + + it('rejects empty messageId / fingerprint', () => { + expect(validateInboundEvent({ ...VALID_GROUP_EVENT, messageId: '' })) + .toEqual({ ok: false, reason: 'malformed-event' }); + expect(validateInboundEvent({ ...VALID_GROUP_EVENT, senderFingerprint: '' })) + .toEqual({ ok: false, reason: 'malformed-event' }); + }); + + it('rejects plaintext that is not a Uint8Array', () => { + expect(validateInboundEvent({ ...VALID_GROUP_EVENT, plaintext: 'oops' as unknown as Uint8Array })) + .toEqual({ ok: false, reason: 'malformed-event' }); + expect(validateInboundEvent({ ...VALID_GROUP_EVENT, plaintext: [1, 2, 3] as unknown as Uint8Array })) + .toEqual({ ok: false, reason: 'malformed-event' }); + }); + + it('rejects senderPublicKey that is not a Uint8Array', () => { + const r = validateInboundEvent({ + ...VALID_GROUP_EVENT, + senderPublicKey: 'beefcafe' as unknown as Uint8Array, + }); + expect(r).toEqual({ ok: false, reason: 'malformed-event' }); + }); + + it('rejects non-finite numeric fields', () => { + expect(validateInboundEvent({ ...VALID_GROUP_EVENT, timestamp: NaN })) + .toEqual({ ok: false, reason: 'malformed-event' }); + expect(validateInboundEvent({ ...VALID_GROUP_EVENT, receivedAt: Infinity })) + .toEqual({ ok: false, reason: 'malformed-event' }); + expect(validateInboundEvent({ ...VALID_GROUP_EVENT, timestamp: 'now' as unknown as number })) + .toEqual({ ok: false, reason: 'malformed-event' }); + }); + + it('rejects group events without a groupId', () => { + const r = validateInboundEvent({ ...VALID_GROUP_EVENT, groupId: undefined }); + expect(r).toEqual({ ok: false, reason: 'malformed-event' }); + }); + + it('rejects groupId that is the wrong type', () => { + const r = validateInboundEvent({ ...VALID_GROUP_EVENT, groupId: 'aabb' as unknown as Uint8Array }); + expect(r).toEqual({ ok: false, reason: 'malformed-event' }); + }); + + it('rejects a payload with extra prototype pollution attempts safely', () => { + const evil = Object.create({ kind: 'group' }); + evil.messageId = 'm'; + // No senderFingerprint, no plaintext, etc. → malformed. + const r = validateInboundEvent(evil); + expect(r.ok).toBe(false); + }); +}); + +describe('POLICY_REASONS — stable vocabulary', () => { + it('contains all decision and fail-closed reasons; no duplicates', () => { + const set = new Set(POLICY_REASONS); + expect(set.size).toBe(POLICY_REASONS.length); + // The exact set is the public contract; locking it here means a future + // rename or addition shows up in this test as a deliberate change. + const expected: ReadonlyArray = [ + 'not-addressed', + 'addressed-and-trusted', + 'addressed-unknown-sender', + 'addressed-matches-interest', + 'trusted-interest-hit', + 'interest-hit', + 'trusted-no-signal', + 'malformed-event', + 'unknown-event-kind', + 'duplicate-event', + 'not-a-member', + ]; + expect([...set].sort()).toEqual([...expected].sort()); + }); +}); diff --git a/packages/node/src/policy/validate.ts b/packages/node/src/policy/validate.ts new file mode 100644 index 0000000..c3c9d92 --- /dev/null +++ b/packages/node/src/policy/validate.ts @@ -0,0 +1,87 @@ +import type { + InboundMessageKind, + PrivateInboundMessageEvent, + PolicyFailClosedReason, +} from '@networkselfmd/core'; + +// Result of validating an opaque inbound payload. `ok: true` narrows the +// payload to a fully-typed PrivateInboundMessageEvent. Otherwise `reason` +// is a fail-closed PolicyReason that the gate must surface to the audit +// log without ever invoking AgentPolicy.decide(). +export type ValidationResult = + | { ok: true; ev: PrivateInboundMessageEvent } + | { ok: false; reason: Extract }; + +const VALID_KINDS: ReadonlySet = new Set(['group', 'dm']); + +// Strict, structural validation. Pure. No I/O, no logging. +// +// We accept `unknown` deliberately — the gate is a trust boundary even +// against in-process callers. TypeScript prevents most accidental misuse, +// but the gate must still hold under direct injection (test code, future +// transports). We never trust the type system at this seam. +export function validateInboundEvent(raw: unknown): ValidationResult { + if (!isPlainObject(raw)) { + return { ok: false, reason: 'malformed-event' }; + } + + const kind = (raw as { kind?: unknown }).kind; + if (typeof kind !== 'string') { + return { ok: false, reason: 'malformed-event' }; + } + if (!VALID_KINDS.has(kind as InboundMessageKind)) { + return { ok: false, reason: 'unknown-event-kind' }; + } + + if ( + !isString((raw as { messageId?: unknown }).messageId) || + !isString((raw as { senderFingerprint?: unknown }).senderFingerprint) || + !isUint8Array((raw as { senderPublicKey?: unknown }).senderPublicKey) || + !isUint8Array((raw as { plaintext?: unknown }).plaintext) || + !isFiniteNumber((raw as { timestamp?: unknown }).timestamp) || + !isFiniteNumber((raw as { receivedAt?: unknown }).receivedAt) + ) { + return { ok: false, reason: 'malformed-event' }; + } + + // groupId is optional but, when present, must be a Uint8Array. + const groupId = (raw as { groupId?: unknown }).groupId; + if (groupId !== undefined && !isUint8Array(groupId)) { + return { ok: false, reason: 'malformed-event' }; + } + + // For 'group' kind, groupId is required. + if (kind === 'group' && !isUint8Array(groupId)) { + return { ok: false, reason: 'malformed-event' }; + } + + // Empty messageId/fingerprint would let an attacker forge a + // collision-prone audit identity. Reject them as malformed. + if ( + (raw as { messageId: string }).messageId.length === 0 || + (raw as { senderFingerprint: string }).senderFingerprint.length === 0 + ) { + return { ok: false, reason: 'malformed-event' }; + } + + return { + ok: true, + ev: raw as unknown as PrivateInboundMessageEvent, + }; +} + +function isPlainObject(v: unknown): v is Record { + return typeof v === 'object' && v !== null && !Array.isArray(v); +} + +function isString(v: unknown): v is string { + return typeof v === 'string'; +} + +function isFiniteNumber(v: unknown): v is number { + return typeof v === 'number' && Number.isFinite(v); +} + +function isUint8Array(v: unknown): v is Uint8Array { + return v instanceof Uint8Array; +} From 393feb82c234c9808943994bc8a3ffa5430da33b Mon Sep 17 00:00:00 2001 From: Ray Svitla <130174948+raysvitla@users.noreply.github.com> Date: Sat, 25 Apr 2026 09:29:13 +0100 Subject: [PATCH 2/8] feat: add PolicyGate with audit log and retry-safe dedup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PolicyGate is the new chokepoint between authenticated/decrypted/persisted inbound events and any agent-runtime side effect. Order of operations: 1. validateInboundEvent (structural; fail-closed: malformed-event / unknown-event-kind). Never invokes AgentPolicy.decide on invalid input. 2. dedup check (fail-closed: duplicate-event). 3. for group events, isMember(...) recheck via injected predicate (fail-closed: not-a-member). Defence-in-depth against direct in-process injection. 4. AgentPolicy.decide(ev) — pure. 5. audit.record(entry) — metadata-only PolicyAuditEntry. 6. mark messageId in dedup ONLY here, post-audit. If any earlier step fails, or audit.record throws, the messageId is NOT remembered, so a legitimate retry is re-evaluated rather than silently denied. 7. emit('decision', decision); return GateOutcome. PolicyAuditEntry (in @networkselfmd/core) is metadata-only by construction: eventKind, optional messageId / groupIdHex / senderFingerprint, byteLength of plaintext (size only — no content), action, reason, decision booleans, gateRejected flag. New fields require explicit review against the privacy invariant. redactPlaintext() is the canonical helper for any new code that handles plaintext but needs to log size. PolicyAuditLog is an in-memory bounded ring buffer (default 1000). No persistence in this PR — durability is a follow-up concern. 10 unit tests cover happy path, all four fail-closed branches, dedup ordering, FIFO eviction at capacity, retry-poison invariant for validation-fail / membership-fail / audit-throw paths, and a canary proving plaintext never appears in the audit entry's serialized form. Co-Authored-By: Claude Opus 4.7 --- packages/core/src/policy/audit.ts | 44 +++ packages/core/src/policy/index.ts | 2 + .../src/__tests__/policy-gate-unit.test.ts | 263 ++++++++++++++++++ packages/node/src/policy/audit-log.ts | 49 ++++ packages/node/src/policy/policy-gate.ts | 239 ++++++++++++++++ 5 files changed, 597 insertions(+) create mode 100644 packages/core/src/policy/audit.ts create mode 100644 packages/node/src/__tests__/policy-gate-unit.test.ts create mode 100644 packages/node/src/policy/audit-log.ts create mode 100644 packages/node/src/policy/policy-gate.ts diff --git a/packages/core/src/policy/audit.ts b/packages/core/src/policy/audit.ts new file mode 100644 index 0000000..c8f6e53 --- /dev/null +++ b/packages/core/src/policy/audit.ts @@ -0,0 +1,44 @@ +import type { PolicyAction, PolicyReason } from './types.js'; + +// Metadata-only audit entry. Captures enough context to debug a decision +// post-mortem (event kind, peer, group, size, decision, reason, time) +// WITHOUT carrying any plaintext, ciphertext, key bytes, or other +// content-bearing material. +// +// Privacy invariant: every field on this type must be safe to log, +// serialize over MCP, persist to disk, or include in a public-ish debug +// dump. New fields require explicit review against that invariant. +export interface PolicyAuditEntry { + // Audit row id — generated by the audit log, not by the policy itself. + auditId: string; + // Wall-clock receipt time, supplied by the gate. + receivedAt: number; + // Event metadata — all optional because malformed events may lack any + // of these fields. Always metadata-only when present. + eventKind: 'group' | 'dm' | 'unknown'; + messageId?: string; + groupIdHex?: string; + senderFingerprint?: string; + // Length of the (decrypted) plaintext payload in bytes. Reveals message + // size only — not content. 0 when no plaintext is available + // (malformed-event, etc.). + byteLength: number; + // Policy outcome. + action: PolicyAction; + reason: PolicyReason; + // Decision detail. Defaults to false / [] for fail-closed paths so the + // shape stays uniform. + addressedToMe: boolean; + senderTrusted: boolean; + matchedInterests: string[]; + // True when the gate itself rejected before AgentPolicy.decide() ran + // (validation failed, duplicate, non-member). Useful to distinguish + // pure-policy decisions from structural/auth rejections. + gateRejected: boolean; +} + +// Helper to summarize a Uint8Array's length without exposing any byte. Use +// in any new code path that handles plaintext/ciphertext/keys. +export function redactPlaintext(bytes: Uint8Array | undefined): { byteLength: number } { + return { byteLength: bytes ? bytes.byteLength : 0 }; +} diff --git a/packages/core/src/policy/index.ts b/packages/core/src/policy/index.ts index b4ccfd6..9d823da 100644 --- a/packages/core/src/policy/index.ts +++ b/packages/core/src/policy/index.ts @@ -7,3 +7,5 @@ export type { PolicyConfig, } from './types.js'; export { POLICY_REASONS } from './types.js'; +export type { PolicyAuditEntry } from './audit.js'; +export { redactPlaintext } from './audit.js'; diff --git a/packages/node/src/__tests__/policy-gate-unit.test.ts b/packages/node/src/__tests__/policy-gate-unit.test.ts new file mode 100644 index 0000000..12f4e42 --- /dev/null +++ b/packages/node/src/__tests__/policy-gate-unit.test.ts @@ -0,0 +1,263 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import type { AgentIdentity, PrivateInboundMessageEvent } from '@networkselfmd/core'; +import { PolicyGate } from '../policy/policy-gate.js'; +import { PolicyAuditLog } from '../policy/audit-log.js'; +import { AgentPolicy } from '../policy/agent-policy.js'; +import { InboundEventQueue } from '../events/inbound-queue.js'; +import type { Agent } from '../agent.js'; +import { makeIdentity } from './test-utils/group-harness.js'; + +function makeFakeAgent(identity: AgentIdentity): Agent { + return { identity, inboundQueue: new InboundEventQueue() } as unknown as Agent; +} + +function makeEvent(params: { + alice: AgentIdentity; + bob: AgentIdentity; + groupId?: Uint8Array; + kind?: 'group' | 'dm'; + messageId?: string; + plaintext?: string | Uint8Array; +}): PrivateInboundMessageEvent { + const plaintext = + typeof params.plaintext === 'string' + ? new TextEncoder().encode(params.plaintext) + : (params.plaintext ?? new TextEncoder().encode('hello')); + return { + kind: params.kind ?? 'group', + messageId: params.messageId ?? 'm-' + Math.random().toString(36).slice(2), + groupId: params.kind === 'dm' ? undefined : (params.groupId ?? new Uint8Array([0xaa])), + senderPublicKey: params.bob.edPublicKey, + senderFingerprint: params.bob.fingerprint, + plaintext, + timestamp: 1, + receivedAt: 2, + }; +} + +interface Harness { + alice: AgentIdentity; + bob: AgentIdentity; + groupId: Uint8Array; + audit: PolicyAuditLog; + policy: AgentPolicy; + members: Set; // hex sender pubkey + gate: PolicyGate; +} + +function buildHarness(): Harness { + const alice = makeIdentity('Alice'); + const bob = makeIdentity('Bob'); + const groupId = new Uint8Array(32).fill(0x77); + const audit = new PolicyAuditLog({ max: 16 }); + const policy = new AgentPolicy({ + agent: makeFakeAgent(alice), + // requireMention: false treats every group event as addressed → decide + // returns 'ask' / addressed-unknown-sender so the gate's `allowed` + // boolean is true for happy-path tests. Failure-mode tests assert on + // `reason` directly. + config: { mentionPrefixLen: 8, requireMention: false }, + }); + // Membership predicate: bob is a member of `groupId`. Alice too. + const members = new Set([ + Buffer.from(alice.edPublicKey).toString('hex'), + Buffer.from(bob.edPublicKey).toString('hex'), + ]); + const gate = new PolicyGate({ + policy, + audit, + isMember: (gid, pk) => + Buffer.from(gid).equals(Buffer.from(groupId)) && + members.has(Buffer.from(pk).toString('hex')), + }); + return { alice, bob, groupId, audit, policy, members, gate }; +} + +describe('PolicyGate.evaluate — happy path', () => { + it('allows a valid group event from a member; records audit; emits decision', () => { + const h = buildHarness(); + const decisions: unknown[] = []; + h.gate.on('decision', (d) => decisions.push(d)); + + const ev = makeEvent({ alice: h.alice, bob: h.bob, groupId: h.groupId, plaintext: 'hi there' }); + const out = h.gate.evaluate(ev); + + expect(out.allowed).toBe(true); + if (!out.allowed) throw new Error('unreachable'); + expect(out.decision.action).toBe('ask'); // requireMention:false → addressed-unknown-sender + expect(out.entry.gateRejected).toBe(false); + expect(out.entry.eventKind).toBe('group'); + expect(out.entry.byteLength).toBe('hi there'.length); + expect(h.audit.recent()).toHaveLength(1); + expect(decisions).toHaveLength(1); + }); +}); + +describe('PolicyGate.evaluate — fail-closed paths', () => { + let h: Harness; + beforeEach(() => { + h = buildHarness(); + }); + + it('rejects malformed (non-object) input as malformed-event; never calls decide', () => { + let decideCalls = 0; + const origDecide = h.policy.decide.bind(h.policy); + h.policy.decide = (ev) => { + decideCalls++; + return origDecide(ev); + }; + const out = h.gate.evaluate(null); + expect(out.allowed).toBe(false); + if (out.allowed) throw new Error('unreachable'); + expect(out.reason).toBe('malformed-event'); + expect(out.entry.gateRejected).toBe(true); + expect(decideCalls).toBe(0); + }); + + it('rejects unknown kind as unknown-event-kind', () => { + const out = h.gate.evaluate({ + ...makeEvent({ alice: h.alice, bob: h.bob, groupId: h.groupId }), + kind: 'spam', + }); + expect(out.allowed).toBe(false); + if (out.allowed) throw new Error('unreachable'); + expect(out.reason).toBe('unknown-event-kind'); + expect(out.entry.eventKind).toBe('unknown'); + }); + + it('rejects a non-member sender on a group event as not-a-member', () => { + // Make Bob suddenly NOT a member. + h.members.delete(Buffer.from(h.bob.edPublicKey).toString('hex')); + const out = h.gate.evaluate(makeEvent({ alice: h.alice, bob: h.bob, groupId: h.groupId })); + expect(out.allowed).toBe(false); + if (out.allowed) throw new Error('unreachable'); + expect(out.reason).toBe('not-a-member'); + expect(out.entry.gateRejected).toBe(true); + }); +}); + +describe('PolicyGate dedup — retry-poison invariant', () => { + let h: Harness; + beforeEach(() => { + h = buildHarness(); + }); + + it('marks messageId after a successful evaluation; second occurrence is duplicate-event', () => { + const ev = makeEvent({ alice: h.alice, bob: h.bob, groupId: h.groupId, messageId: 'shared' }); + const first = h.gate.evaluate(ev); + expect(first.allowed).toBe(true); + expect(h.gate.isDuplicate('shared')).toBe(true); + + const second = h.gate.evaluate(ev); + expect(second.allowed).toBe(false); + if (second.allowed) throw new Error('unreachable'); + expect(second.reason).toBe('duplicate-event'); + expect(h.audit.recent()).toHaveLength(2); + }); + + it('does NOT mark dedup when validation fails; legitimate retry with same messageId proceeds', () => { + // First attempt: malformed (missing required fields), but happens to + // carry a messageId. Validation fail → no dedup poisoning. + h.gate.evaluate({ kind: 'group', messageId: 'shared-id' }); + expect(h.gate.isDuplicate('shared-id')).toBe(false); + + // Second attempt: well-formed event with same messageId. Must be + // evaluated normally, not denied as duplicate. + const ev = makeEvent({ + alice: h.alice, + bob: h.bob, + groupId: h.groupId, + messageId: 'shared-id', + }); + const out = h.gate.evaluate(ev); + expect(out.allowed).toBe(true); + }); + + it('does NOT mark dedup when membership recheck fails; same messageId can be re-evaluated after admission', () => { + h.members.delete(Buffer.from(h.bob.edPublicKey).toString('hex')); + const ev = makeEvent({ + alice: h.alice, + bob: h.bob, + groupId: h.groupId, + messageId: 'mid', + }); + const first = h.gate.evaluate(ev); + expect(first.allowed).toBe(false); + expect(h.gate.isDuplicate('mid')).toBe(false); + + // Bob re-admitted. + h.members.add(Buffer.from(h.bob.edPublicKey).toString('hex')); + const second = h.gate.evaluate(ev); + expect(second.allowed).toBe(true); + }); + + it('does NOT mark dedup when audit.record throws; retry succeeds', () => { + let allowOnce = false; + const realRecord = h.audit.record.bind(h.audit); + h.audit.record = (entry) => { + if (!allowOnce && !entry.gateRejected) { + // Fail the first SUCCESSFUL audit only — not the reject audits. + throw new Error('disk full'); + } + return realRecord(entry); + }; + + const ev = makeEvent({ + alice: h.alice, + bob: h.bob, + groupId: h.groupId, + messageId: 'crash-id', + }); + expect(() => h.gate.evaluate(ev)).toThrow(/disk full/); + expect(h.gate.isDuplicate('crash-id')).toBe(false); + + allowOnce = true; + const out = h.gate.evaluate(ev); + expect(out.allowed).toBe(true); + }); + + it('evicts oldest messageId when dedup capacity is exceeded', () => { + const small = new PolicyGate({ + policy: h.policy, + audit: h.audit, + isMember: (gid, pk) => + Buffer.from(gid).equals(Buffer.from(h.groupId)) && + h.members.has(Buffer.from(pk).toString('hex')), + dedupSize: 2, + }); + for (const id of ['a', 'b', 'c']) { + const ev = makeEvent({ + alice: h.alice, + bob: h.bob, + groupId: h.groupId, + messageId: id, + }); + small.evaluate(ev); + } + expect(small.isDuplicate('a')).toBe(false); // evicted + expect(small.isDuplicate('b')).toBe(true); + expect(small.isDuplicate('c')).toBe(true); + }); +}); + +describe('PolicyAuditLog — entries are metadata-only', () => { + it('never carries a plaintext field; byteLength is the only size signal', () => { + const h = buildHarness(); + const canary = 'audit-canary-zzz'; + const ev = makeEvent({ + alice: h.alice, + bob: h.bob, + groupId: h.groupId, + messageId: 'mc', + plaintext: canary, + }); + h.gate.evaluate(ev); + const entries = h.audit.recent(); + expect(entries).toHaveLength(1); + const entry = entries[0]; + expect(entry).not.toHaveProperty('plaintext'); + expect(entry).not.toHaveProperty('content'); + expect(entry.byteLength).toBe(canary.length); + expect(JSON.stringify(entry)).not.toContain(canary); + }); +}); diff --git a/packages/node/src/policy/audit-log.ts b/packages/node/src/policy/audit-log.ts new file mode 100644 index 0000000..aca4ac5 --- /dev/null +++ b/packages/node/src/policy/audit-log.ts @@ -0,0 +1,49 @@ +import type { PolicyAuditEntry } from '@networkselfmd/core'; + +export interface PolicyAuditLogOptions { + // Maximum number of entries to retain. Older entries are dropped FIFO. + // Default: 1000. + max?: number; +} + +// Bounded in-memory ring buffer of policy decisions. Privacy: every +// PolicyAuditEntry is metadata-only by construction (see audit.ts). The +// audit log itself adds no content fields. +// +// This is intentionally non-persistent. Durability would require a SQLite +// migration; that lives in a follow-up PR. The in-memory log is enough +// for live debug, MCP recent-N reads, and post-mortem within a single +// process lifetime. +export class PolicyAuditLog { + private buf: PolicyAuditEntry[] = []; + private max: number; + + constructor(options: PolicyAuditLogOptions = {}) { + this.max = Math.max(1, options.max ?? 1000); + } + + // Append an entry. Returns the entry as recorded (callers can use the + // returned reference but must NOT mutate it; the log holds a reference + // to the same object). + record(entry: PolicyAuditEntry): PolicyAuditEntry { + this.buf.push(entry); + if (this.buf.length > this.max) { + this.buf.splice(0, this.buf.length - this.max); + } + return entry; + } + + // Most recent N entries, newest last. Returns a copy. + recent(limit?: number): PolicyAuditEntry[] { + if (limit === undefined || limit >= this.buf.length) return this.buf.slice(); + return this.buf.slice(this.buf.length - limit); + } + + size(): number { + return this.buf.length; + } + + clear(): void { + this.buf = []; + } +} diff --git a/packages/node/src/policy/policy-gate.ts b/packages/node/src/policy/policy-gate.ts new file mode 100644 index 0000000..693c788 --- /dev/null +++ b/packages/node/src/policy/policy-gate.ts @@ -0,0 +1,239 @@ +import { EventEmitter } from 'node:events'; +import { createId } from '@paralleldrive/cuid2'; +import type { + PolicyAuditEntry, + PolicyDecision, + PolicyReason, + PrivateInboundMessageEvent, +} from '@networkselfmd/core'; +import type { AgentPolicy } from './agent-policy.js'; +import type { PolicyAuditLog } from './audit-log.js'; +import { validateInboundEvent } from './validate.js'; + +// External membership predicate. Inverted dependency keeps the gate +// trivially testable — pass any function, no real GroupRepository +// needed. Production wiring uses GroupRepository.getMembers under the +// hood. +export type IsMemberFn = (groupId: Uint8Array, publicKey: Uint8Array) => boolean; + +export interface PolicyGateOptions { + policy: AgentPolicy; + audit: PolicyAuditLog; + isMember: IsMemberFn; + // Maximum messageIds to remember for dedup. Default: 10000. + dedupSize?: number; + // Injectable clock + id generator for deterministic tests. + now?: () => number; + auditIdGen?: () => string; +} + +// What a gate run returns. `allowed: true` means downstream side effects +// (inboundQueue.push, public re-emit) MUST happen. `allowed: false` means +// they MUST NOT — and the audit entry already records why. +export type GateOutcome = + | { + allowed: true; + ev: PrivateInboundMessageEvent; + decision: PolicyDecision; + entry: PolicyAuditEntry; + } + | { + allowed: false; + reason: PolicyReason; + entry: PolicyAuditEntry; + }; + +// PolicyGate: the chokepoint between authenticated/decrypted/persisted +// inbound events and any agent-runtime side effect. +// +// Order of operations (`evaluate`): +// 1. validate structure (fail-closed: malformed-event / unknown-event-kind) +// 2. check dedup set (fail-closed: duplicate-event) +// 3. for group events, recheck membership via injected predicate +// (fail-closed: not-a-member) +// 4. invoke AgentPolicy.decide (pure; never throws on validated input) +// 5. record audit entry +// 6. mark messageId in dedup set — **only here**, after every prior +// step has succeeded. If audit.record throws, dedup is not poisoned +// and a legitimate retry will be re-evaluated. +// 7. emit('decision', decision); return GateOutcome +// +// `decide()` is never called on malformed/duplicate/non-member input — +// AgentPolicy stays pure and operates only on structurally valid events +// from authenticated peers. +export class PolicyGate extends EventEmitter { + private policy: AgentPolicy; + private audit: PolicyAuditLog; + private isMember: IsMemberFn; + private dedupSize: number; + private now: () => number; + private auditIdGen: () => string; + + // FIFO dedup: insertion-ordered Set of messageIds that have completed + // a successful evaluation. Eviction at dedupSize+1 (oldest dropped). + private dedup: Set = new Set(); + + constructor(opts: PolicyGateOptions) { + super(); + this.policy = opts.policy; + this.audit = opts.audit; + this.isMember = opts.isMember; + this.dedupSize = Math.max(1, opts.dedupSize ?? 10000); + this.now = opts.now ?? Date.now; + this.auditIdGen = opts.auditIdGen ?? createId; + } + + evaluate(raw: unknown): GateOutcome { + const receivedAt = this.now(); + + // --- Step 1: structural validation --- + const validation = validateInboundEvent(raw); + if (!validation.ok) { + const entry = this.recordReject(raw, receivedAt, validation.reason); + return { allowed: false, reason: validation.reason, entry }; + } + const ev = validation.ev; + + // --- Step 2: dedup --- + if (this.dedup.has(ev.messageId)) { + const entry = this.recordRejectFromEvent(ev, receivedAt, 'duplicate-event'); + return { allowed: false, reason: 'duplicate-event', entry }; + } + + // --- Step 3: membership recheck (group events only) --- + if (ev.kind === 'group') { + if (!ev.groupId) { + // Defensive: validate already requires groupId for 'group' kind, + // but be explicit so a future relaxation doesn't open a hole. + const entry = this.recordRejectFromEvent(ev, receivedAt, 'malformed-event'); + return { allowed: false, reason: 'malformed-event', entry }; + } + if (!this.isMember(ev.groupId, ev.senderPublicKey)) { + const entry = this.recordRejectFromEvent(ev, receivedAt, 'not-a-member'); + return { allowed: false, reason: 'not-a-member', entry }; + } + } + + // --- Step 4: pure decision --- + const decision = this.policy.decide(ev); + + // --- Step 5: record audit (metadata-only) --- + const entry: PolicyAuditEntry = { + auditId: this.auditIdGen(), + receivedAt, + eventKind: ev.kind, + messageId: ev.messageId, + groupIdHex: ev.groupId ? Buffer.from(ev.groupId).toString('hex') : undefined, + senderFingerprint: ev.senderFingerprint, + byteLength: ev.plaintext.byteLength, + action: decision.action, + reason: decision.reason, + addressedToMe: decision.addressedToMe, + senderTrusted: decision.senderTrusted, + matchedInterests: decision.matchedInterests.slice(), + gateRejected: false, + }; + this.audit.record(entry); + + // --- Step 6: mark dedup ONLY now (post-audit) --- + this.markDedup(ev.messageId); + + // --- Step 7: emit and return --- + this.emit('decision', decision); + + return decision.action === 'ignore' + ? { allowed: false, reason: decision.reason, entry } + : { allowed: true, ev, decision, entry }; + } + + // For tests / introspection. Not for production paths. + isDuplicate(messageId: string): boolean { + return this.dedup.has(messageId); + } + + dedupCount(): number { + return this.dedup.size; + } + + private markDedup(messageId: string): void { + this.dedup.add(messageId); + if (this.dedup.size > this.dedupSize) { + // Evict oldest insertion. Set preserves insertion order. + const oldest = this.dedup.values().next().value; + if (oldest !== undefined) this.dedup.delete(oldest); + } + } + + // Build an audit entry for a rejected raw payload (validation failure). + // Pulls best-effort metadata from raw without trusting it. + private recordReject( + raw: unknown, + receivedAt: number, + reason: PolicyReason, + ): PolicyAuditEntry { + const obj = + typeof raw === 'object' && raw !== null && !Array.isArray(raw) + ? (raw as Record) + : undefined; + const kindRaw = obj?.kind; + const eventKind: PolicyAuditEntry['eventKind'] = + kindRaw === 'group' || kindRaw === 'dm' ? kindRaw : 'unknown'; + const messageId = typeof obj?.messageId === 'string' && obj.messageId.length > 0 + ? (obj.messageId as string) + : undefined; + const senderFingerprint = + typeof obj?.senderFingerprint === 'string' && obj.senderFingerprint.length > 0 + ? (obj.senderFingerprint as string) + : undefined; + const groupIdRaw = obj?.groupId; + const groupIdHex = + groupIdRaw instanceof Uint8Array ? Buffer.from(groupIdRaw).toString('hex') : undefined; + const plaintext = obj?.plaintext; + const byteLength = plaintext instanceof Uint8Array ? plaintext.byteLength : 0; + + const entry: PolicyAuditEntry = { + auditId: this.auditIdGen(), + receivedAt, + eventKind, + messageId, + groupIdHex, + senderFingerprint, + byteLength, + action: 'ignore', + reason, + addressedToMe: false, + senderTrusted: false, + matchedInterests: [], + gateRejected: true, + }; + this.audit.record(entry); + return entry; + } + + // Build a reject audit entry from a validated event (post-validation + // gate failures: duplicate-event, not-a-member, defensive + // malformed-event after validate). + private recordRejectFromEvent( + ev: PrivateInboundMessageEvent, + receivedAt: number, + reason: PolicyReason, + ): PolicyAuditEntry { + const entry: PolicyAuditEntry = { + auditId: this.auditIdGen(), + receivedAt, + eventKind: ev.kind, + messageId: ev.messageId, + groupIdHex: ev.groupId ? Buffer.from(ev.groupId).toString('hex') : undefined, + senderFingerprint: ev.senderFingerprint, + byteLength: ev.plaintext.byteLength, + action: 'ignore', + reason, + addressedToMe: false, + senderTrusted: false, + matchedInterests: [], + gateRejected: true, + }; + this.audit.record(entry); + return entry; + } +} From 0f20420a0bdaf4c780daf36cb4582085dd7df915 Mon Sep 17 00:00:00 2001 From: Ray Svitla <130174948+raysvitla@users.noreply.github.com> Date: Sat, 25 Apr 2026 09:32:27 +0100 Subject: [PATCH 3/8] feat: route inbound:message through PolicyGate inside Agent MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The inboundQueue.push() and Agent-level inbound:message re-emission are now both downstream of PolicyGate.evaluate(). They run only when the gate returns allowed: true. Validation, dedup, membership recheck, and the pure decision all happen first; the audit entry is recorded regardless of the outcome. - Agent constructs PolicyAuditLog, AgentPolicy, and PolicyGate during start() once repos and identity are ready. - AgentOptions gains policyConfig (initial gate configuration; defaults to {}, which means AgentPolicy.decide returns ignore/not-addressed for unaddressed/untrusted/no-interest events — unconfigured agents do not surface noise) and policyAuditMax (audit ring buffer capacity). - agent.setPolicyConfig(config) updates the gate's policy in place; decisions are pure over (config, identity, event) so the next event picks it up without resetting audit/dedup. - agent.policy / agent.policyGate / agent.policyAudit are exposed for introspection. - Two new Agent-level events: 'policy:decision' (live, decision payload) and 'policy:audit' (live, full PolicyAuditEntry — useful when the gate rejected before decide() ran). - Legacy 'group:message' from GroupManager still fires unchanged (additive, no listener regressions). Public API extension: AgentPolicy.setConfig replaces the internal config in place; existing tests and the AgentPolicy.decide signature are unchanged. Co-Authored-By: Claude Opus 4.7 --- packages/node/src/agent.ts | 76 +++++++++++++++++++++++- packages/node/src/index.ts | 13 ++++ packages/node/src/policy/agent-policy.ts | 8 +++ 3 files changed, 95 insertions(+), 2 deletions(-) diff --git a/packages/node/src/agent.ts b/packages/node/src/agent.ts index a671537..1fcb052 100644 --- a/packages/node/src/agent.ts +++ b/packages/node/src/agent.ts @@ -16,11 +16,17 @@ import type { SenderKeyDistributionMessage, GroupEncryptedMessage, GroupManagementMessage, + PolicyAuditEntry, + PolicyConfig, + PolicyDecision, PrivateInboundMessageEvent, PublicActivityEvent, } from '@networkselfmd/core'; import { MessageType } from '@networkselfmd/core'; import { InboundEventQueue } from './events/inbound-queue.js'; +import { AgentPolicy } from './policy/agent-policy.js'; +import { PolicyAuditLog } from './policy/audit-log.js'; +import { PolicyGate } from './policy/policy-gate.js'; import { AgentDatabase, IdentityRepository, @@ -39,6 +45,14 @@ export interface AgentOptions { passphrase?: string; displayName?: string; bootstrap?: Array<{ host: string; port: number }>; + // Initial policy gate configuration. When omitted, defaults to {} which + // is intentionally restrictive: AgentPolicy.decide returns 'ignore' / + // 'not-addressed' for events with no @-mention, no trusted sender, and + // no interest hit, so unconfigured agents do not surface noise to the + // inbound queue. Tighten/loosen at runtime via agent.setPolicyConfig(). + policyConfig?: PolicyConfig; + // Audit log capacity (entries). Default: 1000. + policyAuditMax?: number; } export interface MemberInfo { @@ -64,6 +78,14 @@ export class Agent extends EventEmitter { groups: Map = new Map(); isRunning = false; readonly inboundQueue: InboundEventQueue = new InboundEventQueue(); + // Policy machinery — constructed in start() once the database/repos and + // identity are ready. The gate is the single chokepoint between + // GroupManager's authenticated `inbound:message` events and any + // agent-runtime side effect (queue push, public re-emit). See + // docs/POLICY.md. + policy!: AgentPolicy; + policyAudit!: PolicyAuditLog; + policyGate!: PolicyGate; private options: AgentOptions; private database!: AgentDatabase; @@ -113,6 +135,28 @@ export class Agent extends EventEmitter { peers: this.peerRepo, }); + // Init policy machinery. The gate sits between GroupManager events + // and any agent-runtime side effect; see setupGroupManagerEvents. + this.policyAudit = new PolicyAuditLog({ max: this.options.policyAuditMax }); + this.policy = new AgentPolicy({ + agent: this, + config: this.options.policyConfig ?? {}, + }); + this.policyGate = new PolicyGate({ + policy: this.policy, + audit: this.policyAudit, + isMember: (groupId, publicKey) => { + const members = this.groupRepo.getMembers(groupId); + for (const m of members) { + if (bytesEqual(new Uint8Array(m.public_key), publicKey)) return true; + } + return false; + }, + }); + this.policyGate.on('decision', (decision: PolicyDecision) => { + this.emit('policy:decision', decision); + }); + // Wire up events this.setupSwarmEvents(); this.setupRouterHandlers(); @@ -282,6 +326,15 @@ export class Agent extends EventEmitter { })); } + // ---- Policy ---- + + // Update the policy gate's configuration in place. Decisions are pure + // over (config, identity, event), so this takes effect on the very + // next event without needing to rebuild the gate or reset audit/dedup. + setPolicyConfig(config: PolicyConfig): void { + this.policy.setConfig(config); + } + // ---- Peers ---- listPeers(): PeerInfo[] { @@ -482,9 +535,20 @@ export class Agent extends EventEmitter { this.emit('group:keysRotated', data); }); + // POLICY GATE — every authenticated/decrypted/persisted inbound + // event from GroupManager passes through here before any + // agent-runtime side effect. The gate runs validation, dedup, + // membership recheck, and AgentPolicy.decide(); records a + // metadata-only audit entry; and only on `allowed: true` does the + // event reach the inbound queue and external listeners. See + // docs/POLICY.md for the lifecycle. this.groupManager.on('inbound:message', (ev: PrivateInboundMessageEvent) => { - this.inboundQueue.push(ev); - this.emit('inbound:message', ev); + const outcome = this.policyGate.evaluate(ev); + this.emit('policy:audit', outcome.entry); + if (outcome.allowed) { + this.inboundQueue.push(outcome.ev); + this.emit('inbound:message', outcome.ev); + } }); this.groupManager.on('activity:message', (ev: PublicActivityEvent) => { @@ -536,3 +600,11 @@ function hexToBytes(hex: string): Uint8Array { } return bytes; } + +function bytesEqual(a: Uint8Array, b: Uint8Array): boolean { + if (a.length !== b.length) return false; + for (let i = 0; i < a.length; i++) { + if (a[i] !== b[i]) return false; + } + return true; +} diff --git a/packages/node/src/index.ts b/packages/node/src/index.ts index b3693c5..f26d5b5 100644 --- a/packages/node/src/index.ts +++ b/packages/node/src/index.ts @@ -25,9 +25,22 @@ export type { PublicActivityEvent, PolicyAction, PolicyReason, + PolicyDecisionReason, + PolicyFailClosedReason, PolicyDecision, PolicyConfig, + PolicyAuditEntry, } from '@networkselfmd/core'; +export { POLICY_REASONS, redactPlaintext } from '@networkselfmd/core'; export { AgentPolicy } from './policy/agent-policy.js'; export type { AgentPolicyOptions } from './policy/agent-policy.js'; + +export { PolicyGate } from './policy/policy-gate.js'; +export type { PolicyGateOptions, GateOutcome, IsMemberFn } from './policy/policy-gate.js'; + +export { PolicyAuditLog } from './policy/audit-log.js'; +export type { PolicyAuditLogOptions } from './policy/audit-log.js'; + +export { validateInboundEvent } from './policy/validate.js'; +export type { ValidationResult } from './policy/validate.js'; diff --git a/packages/node/src/policy/agent-policy.ts b/packages/node/src/policy/agent-policy.ts index 9735a1b..bb241ed 100644 --- a/packages/node/src/policy/agent-policy.ts +++ b/packages/node/src/policy/agent-policy.ts @@ -43,6 +43,14 @@ export class AgentPolicy extends EventEmitter { this.unsubscribe = undefined; } + // Replace the active configuration. decide() reads `this.config` per + // call, so the new config takes effect on the next decision without + // reconstructing the policy or resetting any external state (audit, + // dedup) owned by the gate. + setConfig(config: PolicyConfig): void { + this.config = config; + } + decide(ev: PrivateInboundMessageEvent): PolicyDecision { const text = tryDecodeUtf8(ev.plaintext); const fingerprint = this.agent.identity?.fingerprint; From 97afa5ef0905ca1207fe69b65fb5703b232ff013 Mon Sep 17 00:00:00 2001 From: Ray Svitla <130174948+raysvitla@users.noreply.github.com> Date: Sat, 25 Apr 2026 09:34:10 +0100 Subject: [PATCH 4/8] test: adversarial coverage for hardened inbound policy gate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Drives the full Agent inbound flow (post-decrypt GroupManager event → PolicyGate → queue / emission / audit) and asserts the nine adversarial scenarios from the hardening mission: 1. allowed member event reaches the queue and emits inbound:message 2. denied non-member event blocked at the gate (reason=not-a-member) 3. unknown event kind fails closed (reason=unknown-event-kind) 4. malformed event fails closed across five mutation shapes (null, missing fields, wrong plaintext type, NaN timestamp, empty messageId) 5. duplicate (same messageId) does not double-emit nor double-queue 5b. dedup does NOT poison a retry that follows a transient malformed failure on the same messageId 6. plaintext canary appears in neither audit entries nor decisions when serialized 7. POLICY_REASONS exports a stable, locked vocabulary 8. ordering: policy:audit fires BEFORE the queue push and before the public inbound:message event (gate is post-audit only on success) 9. queue is post-gate: denied events from all four paths leave it empty Tests use the existing hyperswarm/hyperdht mocks so a real Agent.start() runs without network. Inbound events are injected by emitting on the Agent's internal GroupManager — exactly the production seam the gate subscribes to. 10 new test cases. Total node-package tests: 110 (was 100). Co-Authored-By: Claude Opus 4.7 --- .../__tests__/policy-gate-adversarial.test.ts | 292 ++++++++++++++++++ 1 file changed, 292 insertions(+) create mode 100644 packages/node/src/__tests__/policy-gate-adversarial.test.ts diff --git a/packages/node/src/__tests__/policy-gate-adversarial.test.ts b/packages/node/src/__tests__/policy-gate-adversarial.test.ts new file mode 100644 index 0000000..5cd2e3e --- /dev/null +++ b/packages/node/src/__tests__/policy-gate-adversarial.test.ts @@ -0,0 +1,292 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { mkdtempSync, rmSync } from 'node:fs'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; +import type { + AgentIdentity, + PolicyAuditEntry, + PolicyDecision, + PolicyReason, + PrivateInboundMessageEvent, +} from '@networkselfmd/core'; + +// Mock the network layer so Agent.start() works without DHT/sockets. +vi.mock('hyperswarm', () => { + return { + default: class MockHyperswarm { + on() {} + join() { + return { flushed: () => Promise.resolve() }; + } + leave() { + return Promise.resolve(); + } + destroy() { + return Promise.resolve(); + } + }, + }; +}); +vi.mock('hyperdht', () => { + return { default: class MockHyperDHT {} }; +}); + +import { Agent } from '../agent.js'; +import { makeIdentity } from './test-utils/group-harness.js'; + +interface Harness { + agent: Agent; + bob: AgentIdentity; + groupId: Uint8Array; + decisions: PolicyDecision[]; + audits: PolicyAuditEntry[]; + inbound: PrivateInboundMessageEvent[]; + cleanup: () => Promise; +} + +async function buildHarness(): Promise { + const dataDir = mkdtempSync(join(tmpdir(), 'nsmd-policy-adv-')); + const agent = new Agent({ + dataDir, + displayName: 'Alice', + // Permissive default for these adversarial tests so happy-path + // events flow through and we can observe denial behavior in isolation. + policyConfig: { requireMention: false, mentionPrefixLen: 8 }, + }); + await agent.start(); + + // Bob is a member of a group that Alice (the agent) belongs to. + const bob = makeIdentity('Bob'); + const groupId = new Uint8Array(32).fill(0xdc); + // Seed Alice's local group state directly through GroupManager paths. + // We simulate Alice having created/joined a group with Bob as member. + // Doing it via repos avoids running the full swarm/handshake. + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const groupRepo = (agent as any).groupRepo; + groupRepo.create(groupId, 'adv-test', 'admin'); + groupRepo.addMember(groupId, agent.identity.edPublicKey, 'admin'); + groupRepo.addMember(groupId, bob.edPublicKey, 'member'); + + const decisions: PolicyDecision[] = []; + const audits: PolicyAuditEntry[] = []; + const inbound: PrivateInboundMessageEvent[] = []; + agent.on('policy:decision', (d: PolicyDecision) => decisions.push(d)); + agent.on('policy:audit', (e: PolicyAuditEntry) => audits.push(e)); + agent.on('inbound:message', (ev: PrivateInboundMessageEvent) => inbound.push(ev)); + + const cleanup = async () => { + await agent.stop(); + rmSync(dataDir, { recursive: true, force: true }); + }; + + return { agent, bob, groupId, decisions, audits, inbound, cleanup }; +} + +function buildGroupEvent(params: { + bob: AgentIdentity; + groupId: Uint8Array; + messageId?: string; + plaintext?: string; +}): PrivateInboundMessageEvent { + return { + kind: 'group', + messageId: params.messageId ?? 'mid-' + Math.random().toString(36).slice(2), + groupId: params.groupId, + senderPublicKey: params.bob.edPublicKey, + senderFingerprint: params.bob.fingerprint, + plaintext: new TextEncoder().encode(params.plaintext ?? 'hello'), + timestamp: 1, + receivedAt: 2, + }; +} + +// Helper to drive the Agent's inbound flow by emitting on its internal +// GroupManager — exactly how production code reaches the gate post- +// decrypt/post-persist. We never bypass the gate in these tests. +function inject(agent: Agent, ev: PrivateInboundMessageEvent | unknown): void { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const gm = (agent as any).groupManager; + gm.emit('inbound:message', ev); +} + +describe('Adversarial: PolicyGate integration in Agent', () => { + let h: Harness; + + beforeEach(async () => { + h = await buildHarness(); + }); + + afterEach(async () => { + await h.cleanup(); + }); + + it('1. allowed member event proceeds: queue + public emit + audit', () => { + const ev = buildGroupEvent({ bob: h.bob, groupId: h.groupId, plaintext: 'hi all' }); + inject(h.agent, ev); + + expect(h.inbound).toHaveLength(1); + expect(h.agent.inboundQueue.size()).toBe(1); + expect(h.audits).toHaveLength(1); + expect(h.audits[0].gateRejected).toBe(false); + expect(h.decisions).toHaveLength(1); + expect(h.decisions[0].action).toBe('ask'); // requireMention:false → addressed-unknown-sender + }); + + it('2. denied non-member event is blocked: no queue, no emit, audit reason=not-a-member', () => { + const stranger = makeIdentity('Stranger'); + const ev = buildGroupEvent({ bob: stranger, groupId: h.groupId }); + inject(h.agent, ev); + + expect(h.inbound).toHaveLength(0); + expect(h.agent.inboundQueue.size()).toBe(0); + expect(h.audits).toHaveLength(1); + expect(h.audits[0].reason).toBe('not-a-member'); + expect(h.audits[0].gateRejected).toBe(true); + }); + + it('3. unknown event kind fails closed: no queue, no emit, audit reason=unknown-event-kind', () => { + inject(h.agent, { + ...buildGroupEvent({ bob: h.bob, groupId: h.groupId }), + kind: 'rumor', + }); + expect(h.inbound).toHaveLength(0); + expect(h.audits[0].reason).toBe('unknown-event-kind'); + expect(h.audits[0].eventKind).toBe('unknown'); + }); + + it('4. malformed event fails closed: no queue, no emit, audit reason=malformed-event', () => { + // Inject several deliberate malformations. + inject(h.agent, null); + inject(h.agent, { kind: 'group' /* missing required fields */ }); + inject(h.agent, { + ...buildGroupEvent({ bob: h.bob, groupId: h.groupId }), + plaintext: 'definitely not a Uint8Array', + }); + inject(h.agent, { + ...buildGroupEvent({ bob: h.bob, groupId: h.groupId }), + timestamp: NaN, + }); + inject(h.agent, { + ...buildGroupEvent({ bob: h.bob, groupId: h.groupId }), + messageId: '', + }); + + expect(h.inbound).toHaveLength(0); + expect(h.agent.inboundQueue.size()).toBe(0); + expect(h.audits.every((e) => e.reason === 'malformed-event' && e.gateRejected)).toBe(true); + expect(h.audits).toHaveLength(5); + }); + + it('5. duplicate (same messageId) event does not cause double side effect', () => { + const ev = buildGroupEvent({ bob: h.bob, groupId: h.groupId, messageId: 'dup-1' }); + inject(h.agent, ev); + inject(h.agent, ev); + + // First was allowed; second was denied as duplicate-event. Side + // effects (queue / emit) must not have fired twice. + expect(h.inbound).toHaveLength(1); + expect(h.agent.inboundQueue.size()).toBe(1); + expect(h.audits).toHaveLength(2); + expect(h.audits[0].gateRejected).toBe(false); + expect(h.audits[1].reason).toBe('duplicate-event'); + expect(h.audits[1].gateRejected).toBe(true); + }); + + it('5b. dedup does NOT poison retries after a transient validation failure', () => { + // Same messageId arrives first as malformed (validation fail), then + // properly. The legitimate retry must proceed. + inject(h.agent, { kind: 'group', messageId: 'tx-1' }); + expect(h.audits[0].reason).toBe('malformed-event'); + + const ev = buildGroupEvent({ + bob: h.bob, + groupId: h.groupId, + messageId: 'tx-1', + plaintext: 'retry that worked', + }); + inject(h.agent, ev); + expect(h.inbound).toHaveLength(1); + expect(h.audits[1].gateRejected).toBe(false); + }); + + it('6. plaintext content never appears in audit / decision payloads', () => { + const canary = 'adv-canary-CONFIDENTIAL-9f'; + const ev = buildGroupEvent({ + bob: h.bob, + groupId: h.groupId, + plaintext: canary, + }); + inject(h.agent, ev); + + for (const e of h.audits) { + expect(JSON.stringify(e)).not.toContain(canary); + } + for (const d of h.decisions) { + expect(JSON.stringify(d)).not.toContain(canary); + } + }); + + it('7. policy reason codes are stable (locked vocabulary)', async () => { + const { POLICY_REASONS } = await import('@networkselfmd/core'); + const reasons = new Set(POLICY_REASONS); + expect(reasons.size).toBe(11); + // Sanity-check a few of the most-load-bearing ones. + expect(reasons.has('not-addressed')).toBe(true); + expect(reasons.has('addressed-and-trusted')).toBe(true); + expect(reasons.has('malformed-event')).toBe(true); + expect(reasons.has('unknown-event-kind')).toBe(true); + expect(reasons.has('duplicate-event')).toBe(true); + expect(reasons.has('not-a-member')).toBe(true); + }); + + it('8. policy gate runs BEFORE the queue/emission (ordering)', () => { + // Subscribe to all three signals in registration order. The gate's + // policy:audit MUST appear before inbound:message and before + // inboundQueue.size() bumps. We capture timestamps via ordering. + const order: string[] = []; + const audit2: PolicyAuditEntry[] = []; + h.agent.on('policy:audit', (e: PolicyAuditEntry) => { + order.push('audit'); + audit2.push(e); + }); + h.agent.on('inbound:message', () => order.push('inbound')); + // Snapshot queue size at each emission. + const sizes: number[] = []; + h.agent.inboundQueue.on(() => { + order.push('queue.push'); + sizes.push(h.agent.inboundQueue.size()); + }); + + const ev = buildGroupEvent({ bob: h.bob, groupId: h.groupId, messageId: 'order-1' }); + inject(h.agent, ev); + + // audit MUST be the first signal — the gate writes the audit row + // before pushing to the queue. The order between queue.push and + // inbound EventEmitter is set by the wiring code; both happen after + // audit either way, so we just assert audit is index 0. + expect(order[0]).toBe('audit'); + expect(order).toContain('queue.push'); + expect(order).toContain('inbound'); + expect(audit2).toHaveLength(1); + expect(audit2[0].gateRejected).toBe(false); + }); + + it('9. denied events never reach inboundQueue (queue is post-gate)', () => { + // Run all four denial paths and assert the queue stays empty. + // a) malformed + inject(h.agent, null); + // b) unknown kind + inject(h.agent, { ...buildGroupEvent({ bob: h.bob, groupId: h.groupId }), kind: 'noise' }); + // c) non-member + inject(h.agent, buildGroupEvent({ bob: makeIdentity('Stranger'), groupId: h.groupId })); + // d) duplicate (allow once, then re-inject) + const ev = buildGroupEvent({ bob: h.bob, groupId: h.groupId, messageId: 'd' }); + inject(h.agent, ev); + inject(h.agent, ev); + + // Only one event should have made it to the queue (the unique allowed one). + expect(h.agent.inboundQueue.size()).toBe(1); + expect(h.inbound).toHaveLength(1); + expect(h.inbound[0].messageId).toBe('d'); + }); +}); From 4d4bec82b82d62bef0b5b6e0fca213e49469cd19 Mon Sep 17 00:00:00 2001 From: Ray Svitla <130174948+raysvitla@users.noreply.github.com> Date: Sat, 25 Apr 2026 09:35:32 +0100 Subject: [PATCH 5/8] test: deterministic fuzz coverage for PolicyGate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three property tests with seeded mulberry32 RNG (no fast-check dep): 1. Malformed payloads never throw, never proceed (N=500). For each iteration the gate must (a) return a structured GateOutcome and (b) deny — allowed:false. Every iteration adds one audit entry. 2. Random plaintext never leaks into audit/log output (N=500). Each event embeds a canary token ('PLAINTEXT-CANARY-TOKEN-ZZ') in plaintext; for the duration of the loop console.log/info/warn/error/debug and process.stdout.write/stderr.write are intercepted. The canary must appear in zero captured lines and zero audit-entry serializations. 3. byteLength tracks plaintext.byteLength exactly; PolicyAuditEntry keys are locked. The third assertion — Object.keys(entry).sort() — fails loudly the moment anyone adds a new (potentially content-bearing) field to PolicyAuditEntry, which is the privacy contract. Seeds are pinned (printed in failure messages) so a CI flake is exactly reproducible locally. Total node-package tests: 113 (was 110). Co-Authored-By: Claude Opus 4.7 --- .../src/__tests__/policy-gate-fuzz.test.ts | 283 ++++++++++++++++++ 1 file changed, 283 insertions(+) create mode 100644 packages/node/src/__tests__/policy-gate-fuzz.test.ts diff --git a/packages/node/src/__tests__/policy-gate-fuzz.test.ts b/packages/node/src/__tests__/policy-gate-fuzz.test.ts new file mode 100644 index 0000000..a36e602 --- /dev/null +++ b/packages/node/src/__tests__/policy-gate-fuzz.test.ts @@ -0,0 +1,283 @@ +import { describe, it, expect } from 'vitest'; +import type { AgentIdentity, PolicyAuditEntry, PrivateInboundMessageEvent } from '@networkselfmd/core'; +import { PolicyGate } from '../policy/policy-gate.js'; +import { PolicyAuditLog } from '../policy/audit-log.js'; +import { AgentPolicy } from '../policy/agent-policy.js'; +import { InboundEventQueue } from '../events/inbound-queue.js'; +import type { Agent } from '../agent.js'; +import { makeIdentity } from './test-utils/group-harness.js'; + +// Deterministic seeded PRNG (mulberry32). Failures are reproducible by +// re-running with the same seed — printed in the assertion when an +// iteration fails, so a CI flake can be replayed locally. +function mulberry32(seed: number): () => number { + let s = seed >>> 0; + return () => { + s = (s + 0x6d2b79f5) >>> 0; + let t = s; + t = Math.imul(t ^ (t >>> 15), t | 1); + t ^= t + Math.imul(t ^ (t >>> 7), t | 61); + return ((t ^ (t >>> 14)) >>> 0) / 4294967296; + }; +} + +function rngBytes(rng: () => number, n: number): Uint8Array { + const out = new Uint8Array(n); + for (let i = 0; i < n; i++) out[i] = Math.floor(rng() * 256); + return out; +} + +function rngString(rng: () => number, max: number = 32): string { + const len = Math.floor(rng() * max); + let s = ''; + // Mix ascii + occasional unicode + symbols + whitespace. + const alphabet = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789 -_@!#%^&*()[]\\\'"\n\t'; + for (let i = 0; i < len; i++) { + s += alphabet[Math.floor(rng() * alphabet.length)]; + } + return s; +} + +interface Harness { + alice: AgentIdentity; + bob: AgentIdentity; + groupId: Uint8Array; + audit: PolicyAuditLog; + gate: PolicyGate; + validId: () => string; +} + +function makeFakeAgent(identity: AgentIdentity): Agent { + return { identity, inboundQueue: new InboundEventQueue() } as unknown as Agent; +} + +function buildHarness(): Harness { + const alice = makeIdentity('alice'); + const bob = makeIdentity('bob'); + const groupId = new Uint8Array(32).fill(0xfe); + const audit = new PolicyAuditLog({ max: 10000 }); + const policy = new AgentPolicy({ + agent: makeFakeAgent(alice), + config: { mentionPrefixLen: 8, requireMention: false, interests: ['coffee'] }, + }); + const members = new Set([ + Buffer.from(alice.edPublicKey).toString('hex'), + Buffer.from(bob.edPublicKey).toString('hex'), + ]); + const gate = new PolicyGate({ + policy, + audit, + isMember: (gid, pk) => + Buffer.from(gid).equals(Buffer.from(groupId)) && + members.has(Buffer.from(pk).toString('hex')), + }); + let n = 0; + return { alice, bob, groupId, audit, gate, validId: () => `valid-${++n}` }; +} + +// Generate a random "almost-event" payload with assorted malformations. +// Each iteration picks a different combination of broken/missing fields. +function randomMalformedPayload(rng: () => number): unknown { + const bucket = Math.floor(rng() * 8); + switch (bucket) { + case 0: + return null; + case 1: + return undefined; + case 2: + return rng(); + case 3: + return rngString(rng, 16); + case 4: + return []; + case 5: + return { kind: rngString(rng, 8) }; // bad kind + case 6: + return { + kind: 'group', + messageId: rngString(rng, 8), + plaintext: 'i am not a Uint8Array', + }; + case 7: + default: + return { + kind: rng() < 0.5 ? 'group' : 'dm', + // randomly pick which required field to omit + messageId: rng() < 0.5 ? '' : undefined, + senderFingerprint: rng() < 0.3 ? '' : 'fp', + senderPublicKey: rng() < 0.3 ? null : new Uint8Array(32), + plaintext: rng() < 0.3 ? null : new Uint8Array(8), + timestamp: rng() < 0.3 ? NaN : 1, + receivedAt: 1, + }; + } +} + +describe('Property: malformed payloads never throw and never proceed', () => { + // Single deterministic seed for CI reproducibility. Bump if surface + // grows enough that this seed misses obvious bugs. + const SEED = 0x5e1ed5a4; + const N = 500; + + it(`fuzz N=${N} seed=${SEED.toString(16)}: gate returns structured outcome, never throws`, () => { + const h = buildHarness(); + const rng = mulberry32(SEED); + for (let i = 0; i < N; i++) { + const payload = randomMalformedPayload(rng); + let outcome: unknown; + expect(() => { + outcome = h.gate.evaluate(payload); + }, `iter=${i} seed=${SEED.toString(16)} payload=${safeStringify(payload)}`).not.toThrow(); + // Whatever it returned, it MUST be a deny (no allowed:true on + // malformed input by default). + expect( + (outcome as { allowed: boolean }).allowed, + `iter=${i}: malformed payload was allowed: ${safeStringify(payload)}`, + ).toBe(false); + } + // Every iteration produced an audit entry. + expect(h.audit.size()).toBe(N); + }); +}); + +describe('Property: random plaintext never leaks into audit/log output', () => { + const SEED = 0xbeefcafe; + const N = 500; + const TOKEN = 'PLAINTEXT-CANARY-TOKEN-ZZ'; + + it(`fuzz N=${N} seed=${SEED.toString(16)}: audit never contains the canary token regardless of payload`, () => { + const h = buildHarness(); + const rng = mulberry32(SEED); + // Capture any console output during the loop. The gate has no + // logging today; this catches future regressions. + const orig = { + log: console.log, + info: console.info, + warn: console.warn, + error: console.error, + debug: console.debug, + stdoutWrite: process.stdout.write.bind(process.stdout), + stderrWrite: process.stderr.write.bind(process.stderr), + }; + const captured: string[] = []; + const sink = (...args: unknown[]) => + captured.push(args.map((a) => (typeof a === 'string' ? a : safeStringify(a))).join(' ')); + console.log = sink; + console.info = sink; + console.warn = sink; + console.error = sink; + console.debug = sink; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + process.stdout.write = ((c: any) => { captured.push(typeof c === 'string' ? c : c.toString('utf-8')); return true; }) as typeof process.stdout.write; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + process.stderr.write = ((c: any) => { captured.push(typeof c === 'string' ? c : c.toString('utf-8')); return true; }) as typeof process.stderr.write; + + try { + for (let i = 0; i < N; i++) { + // Build a valid event whose plaintext embeds the canary token at + // a random position and contains a fuzz-amount of random + // surrounding bytes (sometimes UTF-8, sometimes binary). + const before = rngString(rng, 16); + const after = rngString(rng, 16); + const ascii = before + TOKEN + after; + const useBinary = rng() < 0.3; + const plaintext = useBinary + ? concatBytes(new TextEncoder().encode(before), rngBytes(rng, 4), new TextEncoder().encode(TOKEN + after)) + : new TextEncoder().encode(ascii); + const ev: PrivateInboundMessageEvent = { + kind: 'group', + messageId: h.validId(), + groupId: h.groupId, + senderPublicKey: h.bob.edPublicKey, + senderFingerprint: h.bob.fingerprint, + plaintext, + timestamp: 1, + receivedAt: 2, + }; + h.gate.evaluate(ev); + } + } finally { + console.log = orig.log; + console.info = orig.info; + console.warn = orig.warn; + console.error = orig.error; + console.debug = orig.debug; + process.stdout.write = orig.stdoutWrite; + process.stderr.write = orig.stderrWrite; + } + + // No console output at all in this PR's policy code path. + for (const line of captured) { + expect(line).not.toContain(TOKEN); + } + // Audit entries are metadata-only — none should ever contain the + // plaintext canary even though every event included it. + const allEntries: PolicyAuditEntry[] = h.audit.recent(); + expect(allEntries.length).toBe(N); + for (const entry of allEntries) { + expect(JSON.stringify(entry)).not.toContain(TOKEN); + } + }); +}); + +describe('Property: byteLength matches but plaintext bytes never appear', () => { + it('100 random valid events: every audit byteLength == plaintext.byteLength; no shape drift', () => { + const h = buildHarness(); + const rng = mulberry32(0x12345678); + for (let i = 0; i < 100; i++) { + const len = Math.floor(rng() * 256); + const plaintext = rngBytes(rng, len); + const ev: PrivateInboundMessageEvent = { + kind: 'group', + messageId: `b-${i}`, + groupId: h.groupId, + senderPublicKey: h.bob.edPublicKey, + senderFingerprint: h.bob.fingerprint, + plaintext, + timestamp: 1, + receivedAt: 2, + }; + h.gate.evaluate(ev); + const last = h.audit.recent(1)[0]; + expect(last.byteLength).toBe(len); + // The exact set of keys on every audit entry is the privacy + // contract. If a new content-bearing field gets added, this fails. + expect(Object.keys(last).sort()).toEqual([ + 'action', + 'addressedToMe', + 'auditId', + 'byteLength', + 'eventKind', + 'gateRejected', + 'groupIdHex', + 'matchedInterests', + 'messageId', + 'reason', + 'receivedAt', + 'senderFingerprint', + 'senderTrusted', + ]); + } + }); +}); + +function concatBytes(...parts: Uint8Array[]): Uint8Array { + const total = parts.reduce((n, p) => n + p.length, 0); + const out = new Uint8Array(total); + let off = 0; + for (const p of parts) { + out.set(p, off); + off += p.length; + } + return out; +} + +function safeStringify(v: unknown): string { + try { + return JSON.stringify(v, (_k, val) => + val instanceof Uint8Array ? `Uint8Array(${val.length})` : val, + ); + } catch { + return String(v); + } +} From 64159e5b040f0ca046306536dc0b48adb3abc244 Mon Sep 17 00:00:00 2001 From: Ray Svitla <130174948+raysvitla@users.noreply.github.com> Date: Sat, 25 Apr 2026 09:37:21 +0100 Subject: [PATCH 6/8] =?UTF-8?q?feat(mcp):=20add=20get=5Fpolicy=5Faudit=5Fr?= =?UTF-8?q?ecent=20=E2=80=94=20read-only=20metadata=20audit=20tool?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Owner-private, local-only MCP tool for inspecting recent policy gate decisions. Tool description explicitly warns against forwarding results to public dashboards/census/shared logs. toPolicyAuditDTO is an explicit projection — it enumerates every field copied from PolicyAuditEntry rather than spread-rest. If a future PR adds a field to the audit entry (intentionally or otherwise) it will NOT auto-propagate through the MCP surface; defence in depth. 6 leak tests cover: - byte-for-byte preservation of privacy-safe fields - pollution rejection: plaintext/decryptedBody/toolArgs canaries stuffed onto an entry never appear in the DTO's JSON - caller cannot mutate the audit log via the returned DTO (matchedInterests is sliced on the way out) - optional fields produce no "key": undefined leakage in JSON - byteLength survives without any byte content keys - the published DTO key set is locked to the metadata vocabulary Total mcp-package tests: 14 (was 8). Cumulative tests: 186. Co-Authored-By: Claude Opus 4.7 --- .../src/__tests__/policy-audit-dto.test.ts | 96 +++++++++++++++++++ packages/mcp/src/server.ts | 2 + packages/mcp/src/tools/policy.ts | 61 ++++++++++++ 3 files changed, 159 insertions(+) create mode 100644 packages/mcp/src/__tests__/policy-audit-dto.test.ts create mode 100644 packages/mcp/src/tools/policy.ts diff --git a/packages/mcp/src/__tests__/policy-audit-dto.test.ts b/packages/mcp/src/__tests__/policy-audit-dto.test.ts new file mode 100644 index 0000000..afdbb44 --- /dev/null +++ b/packages/mcp/src/__tests__/policy-audit-dto.test.ts @@ -0,0 +1,96 @@ +import { describe, it, expect } from 'vitest'; +import type { PolicyAuditEntry } from '@networkselfmd/node'; +import { toPolicyAuditDTO } from '../tools/policy.js'; + +const baseEntry: PolicyAuditEntry = { + auditId: 'a1', + receivedAt: 100, + eventKind: 'group', + messageId: 'm1', + groupIdHex: 'dead', + senderFingerprint: 'fp1', + byteLength: 32, + action: 'ask', + reason: 'addressed-unknown-sender', + addressedToMe: true, + senderTrusted: false, + matchedInterests: ['coffee'], + gateRejected: false, +}; + +describe('toPolicyAuditDTO — metadata-only projection', () => { + it('preserves the privacy-safe fields verbatim', () => { + const dto = toPolicyAuditDTO(baseEntry); + expect(dto).toEqual(baseEntry); + }); + + it('drops any unexpected/extra field on the entry (defence-in-depth)', () => { + // Even if a future PR or attacker stuffs a content-bearing field into + // PolicyAuditEntry at runtime, the DTO must not propagate it. + const polluted = { + ...baseEntry, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + plaintext: new TextEncoder().encode('LEAK-CANARY-AUDIT'), + // eslint-disable-next-line @typescript-eslint/no-explicit-any + decryptedBody: 'LEAK-CANARY-BODY', + // eslint-disable-next-line @typescript-eslint/no-explicit-any + toolArgs: { secret: 'LEAK-CANARY-TOOL' }, + } as unknown as PolicyAuditEntry; + const dto = toPolicyAuditDTO(polluted); + const json = JSON.stringify(dto); + expect(json).not.toContain('LEAK-CANARY-AUDIT'); + expect(json).not.toContain('LEAK-CANARY-BODY'); + expect(json).not.toContain('LEAK-CANARY-TOOL'); + expect(dto).not.toHaveProperty('plaintext'); + expect(dto).not.toHaveProperty('decryptedBody'); + expect(dto).not.toHaveProperty('toolArgs'); + }); + + it('returns a copy of matchedInterests (caller cannot mutate audit log via DTO)', () => { + const dto = toPolicyAuditDTO(baseEntry); + dto.matchedInterests.push('mutated'); + expect(baseEntry.matchedInterests).toEqual(['coffee']); + }); + + it('omits optional fields when absent (no JSON undefined leakage)', () => { + const minimal: PolicyAuditEntry = { + ...baseEntry, + messageId: undefined, + groupIdHex: undefined, + senderFingerprint: undefined, + }; + const dto = toPolicyAuditDTO(minimal); + const json = JSON.stringify(dto); + expect(json).not.toContain('"messageId":'); + expect(json).not.toContain('"groupIdHex":'); + expect(json).not.toContain('"senderFingerprint":'); + }); + + it('byteLength survives the DTO without exposing any byte', () => { + const big = toPolicyAuditDTO({ ...baseEntry, byteLength: 8192 }); + expect(big.byteLength).toBe(8192); + expect(JSON.stringify(big)).not.toMatch(/"plaintext"|"content"|"body"/); + }); +}); + +describe('Locked DTO surface', () => { + it('has the exact set of keys we publish over MCP', () => { + const dto = toPolicyAuditDTO(baseEntry); + const keys = Object.keys(dto).sort(); + expect(keys).toEqual([ + 'action', + 'addressedToMe', + 'auditId', + 'byteLength', + 'eventKind', + 'gateRejected', + 'groupIdHex', + 'matchedInterests', + 'messageId', + 'reason', + 'receivedAt', + 'senderFingerprint', + 'senderTrusted', + ]); + }); +}); diff --git a/packages/mcp/src/server.ts b/packages/mcp/src/server.ts index c4422aa..b1913c2 100644 --- a/packages/mcp/src/server.ts +++ b/packages/mcp/src/server.ts @@ -5,6 +5,7 @@ import { registerGroupTools } from './tools/groups.js'; import { registerMessagingTools } from './tools/messaging.js'; import { registerTTYATools } from './tools/ttya.js'; import { registerPeerTools } from './tools/peers.js'; +import { registerPolicyTools } from './tools/policy.js'; import { registerResources } from './resources.js'; export function createServer(agent: Agent): McpServer { @@ -18,6 +19,7 @@ export function createServer(agent: Agent): McpServer { registerMessagingTools(server, agent); registerTTYATools(server, agent); registerPeerTools(server, agent); + registerPolicyTools(server, agent); registerResources(server, agent); return server; diff --git a/packages/mcp/src/tools/policy.ts b/packages/mcp/src/tools/policy.ts new file mode 100644 index 0000000..fe0af78 --- /dev/null +++ b/packages/mcp/src/tools/policy.ts @@ -0,0 +1,61 @@ +import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import { z } from 'zod'; +import type { Agent, PolicyAuditEntry } from '@networkselfmd/node'; + +// MCP-facing audit DTO. Metadata-only by construction. Every field on +// PolicyAuditEntry is already privacy-safe (see core/policy/audit.ts), +// but we explicitly enumerate the projection so a future field added to +// PolicyAuditEntry does NOT auto-propagate through the MCP surface +// without a deliberate code change here. Defence in depth. +export interface PolicyAuditDTO { + auditId: string; + receivedAt: number; + eventKind: 'group' | 'dm' | 'unknown'; + messageId?: string; + groupIdHex?: string; + senderFingerprint?: string; + byteLength: number; + action: 'act' | 'ask' | 'ignore'; + reason: string; + addressedToMe: boolean; + senderTrusted: boolean; + matchedInterests: string[]; + gateRejected: boolean; +} + +export function toPolicyAuditDTO(entry: PolicyAuditEntry): PolicyAuditDTO { + return { + auditId: entry.auditId, + receivedAt: entry.receivedAt, + eventKind: entry.eventKind, + messageId: entry.messageId, + groupIdHex: entry.groupIdHex, + senderFingerprint: entry.senderFingerprint, + byteLength: entry.byteLength, + action: entry.action, + reason: entry.reason, + addressedToMe: entry.addressedToMe, + senderTrusted: entry.senderTrusted, + matchedInterests: entry.matchedInterests.slice(), + gateRejected: entry.gateRejected, + }; +} + +export function registerPolicyTools(server: McpServer, agent: Agent): void { + server.tool( + 'get_policy_audit_recent', + 'Owner-private, local-only, read-only, metadata-only. Returns recent policy gate decisions for debugging — never includes plaintext, ciphertext, decrypted body, tool args, raw event payloads, or private key material. Safe to inspect; do NOT forward results to public dashboards, census, or shared logs.', + { + limit: z.number().int().positive().optional().describe('Maximum number of audit entries to return (default 50, newest last)'), + }, + async ({ limit }) => { + const entries = agent.policyAudit.recent(limit ?? 50).map(toPolicyAuditDTO); + return { + content: [{ + type: 'text' as const, + text: JSON.stringify({ entries }), + }], + }; + }, + ); +} From 377a61799463515ad83e28ce078a2584eb2b0564 Mon Sep 17 00:00:00 2001 From: Ray Svitla <130174948+raysvitla@users.noreply.github.com> Date: Sat, 25 Apr 2026 09:38:58 +0100 Subject: [PATCH 7/8] docs: add POLICY.md and cross-link from ARCHITECTURE.md MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit POLICY.md covers: - the full inbound event lifecycle (network frame → GroupManager → PolicyGate → queue / emission), with explicit ASCII diagram - where policy is enforced (and what's NOT under the gate's privacy invariant — namely the owner-private message store) - the act/ask/ignore decision table and addressedToMe semantics - the stable POLICY_REASONS vocabulary, split into decision reasons and fail-closed reasons - the privacy invariant: explicit list of what is and is not allowed in audit / decision / MCP surfaces, and the three test layers that enforce it (adversarial, fuzz, MCP DTO) - the dedup retry-poison invariant and the three poison scenarios that are tested - the future tool-execution extension point — explicitly NOT implemented in this PR; consumers subscribe to 'policy:decision' for act-actions ARCHITECTURE.md gains a short "Policy gate" subsection that links to POLICY.md and summarizes where the gate sits in the pipeline. Co-Authored-By: Claude Opus 4.7 --- docs/ARCHITECTURE.md | 15 ++++ docs/POLICY.md | 192 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 207 insertions(+) create mode 100644 docs/POLICY.md diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index f47dc80..80b17de 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -141,6 +141,21 @@ 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`. +### Policy gate + +Every event reaching `Agent.inboundQueue` or firing `inbound:message` on +the Agent has passed through `PolicyGate.evaluate(...)`, which validates +the event structurally, deduplicates by `messageId`, rechecks group +membership, runs the pure `AgentPolicy.decide`, and writes a +metadata-only `PolicyAuditEntry` before any side effect. Fail-closed +reasons (`malformed-event`, `unknown-event-kind`, `duplicate-event`, +`not-a-member`) prevent the queue push and the public re-emit. + +See [POLICY.md](POLICY.md) for the full lifecycle, decision table, +reason vocabulary, and privacy invariant. The MCP tool +`get_policy_audit_recent` exposes a read-only, metadata-only view of the +audit log for owner-side debugging. + ### TTYA Visitor Chat ``` diff --git a/docs/POLICY.md b/docs/POLICY.md new file mode 100644 index 0000000..2e5e0fd --- /dev/null +++ b/docs/POLICY.md @@ -0,0 +1,192 @@ +# Inbound Policy Gate + +The inbound policy gate is the **single chokepoint** between authenticated +network reception and any agent-runtime side effect. Every event that +reaches the agent runtime — meaning anything that lands in +`Agent.inboundQueue` or fires `inbound:message` on the Agent — has passed +through the gate. Events that fail any gate check do not produce +side effects. + +## Inbound event lifecycle + +``` +[ network frame ] + │ + ▼ +GroupManager.handleGroupMessage (packages/node/src/groups/group-manager.ts) + ├─ verifyMessageSignature + ├─ buffersEqual(senderPublicKey, transport peer key) + ├─ isMember(groupId, senderPublicKey) ← protocol-layer auth + ├─ SenderKeys.decrypt + ├─ messageRepo.insert (owner-private store, plaintext at rest by design) + ├─ emit('group:message', legacy) ← unchanged for back-compat + ├─ emit('inbound:message', PrivateInboundMessageEvent) ── consumed by gate + └─ emit('activity:message', PublicActivityEvent) ── metadata-only + │ + ▼ +PolicyGate.evaluate(ev) (packages/node/src/policy/policy-gate.ts) + 1. validateInboundEvent(ev) → fail-closed: malformed-event / unknown-event-kind + 2. dedup check → fail-closed: duplicate-event + 3. for kind='group': isMember recheck → fail-closed: not-a-member (defence-in-depth) + 4. AgentPolicy.decide(ev) → pure: act | ask | ignore + 5. PolicyAuditLog.record(entry) → metadata-only PolicyAuditEntry + 6. mark messageId in dedup ← only here, post-audit + 7. emit('decision', decision) + │ + ┌────┴────────────────────┐ + ▼ ▼ + allowed: true allowed: false + │ │ + ▼ ▼ + inboundQueue.push (no side effect) + emit('inbound:message') + emit('policy:audit', entry) + emit('policy:decision', decision) +``` + +## Where policy is enforced + +- **Authenticity / decryption / membership** are enforced at the protocol + layer in `GroupManager.handleGroupMessage` *before* the gate ever sees + an event. The gate trusts that any event it receives has already been + authenticated by the network layer. +- **Structural validity, deduplication, defence-in-depth membership, and + the act/ask/ignore decision** are enforced at the gate. +- **No agent-runtime side effect happens before the gate.** That includes + the `Agent.inboundQueue.push`, the public `inbound:message` event, and + any future tool/handler invocation. The gate is the only writer. + +`messageRepo.insert` runs *before* the gate but is the protocol layer's +write to the owner-private message store. The privacy invariant below +explicitly does not constrain that store. + +## Decision table + +`AgentPolicy.decide` is a pure function over (config, self-identity, +event). It produces a `PolicyDecision { action, reason, ... }`. + +| addressedToMe | senderTrusted | matched interest | action | reason | +|---|---|---|---|---| +| ✓ | ✓ | * | `act` | `addressed-and-trusted` | +| ✓ | ✗ | ✓ | `ask` | `addressed-matches-interest` | +| ✓ | ✗ | ✗ | `ask` | `addressed-unknown-sender` | +| ✗ | ✓ | ✓ | `ask` | `trusted-interest-hit` | +| ✗ | ✗ | ✓ | `ask` | `interest-hit` | +| ✗ | ✓ | ✗ | `ignore` | `trusted-no-signal` | +| ✗ | ✗ | ✗ | `ignore` | `not-addressed` | + +`addressedToMe` is true when the plaintext (strict UTF-8 decode) contains +`@` or `@` token-bounded, OR +when `requireMention: false` is set on the config. + +## Reason codes (stable vocabulary) + +The full list is exported as `POLICY_REASONS` from `@networkselfmd/core` +and locked by tests. Two groups: + +**Decision reasons** (`AgentPolicy.decide` output): + +- `not-addressed` +- `addressed-and-trusted` +- `addressed-unknown-sender` +- `addressed-matches-interest` +- `trusted-interest-hit` +- `interest-hit` +- `trusted-no-signal` + +**Fail-closed reasons** (gate-level rejection — `decide` is not invoked): + +- `malformed-event` — structural validation failed (missing/wrong-typed + fields, empty messageId/fingerprint, etc). +- `unknown-event-kind` — `kind` is neither `'group'` nor `'dm'`. +- `duplicate-event` — `messageId` was already evaluated successfully. +- `not-a-member` — gate-level membership recheck failed for a group + event. The protocol layer enforces this too; the gate adds an + independent check so direct in-process injections (tests, future + transports) cannot bypass authorization. + +Audit entries with `gateRejected: true` carry one of the fail-closed +reasons. Entries with `gateRejected: false` carry one of the decision +reasons. Both are first-class debug surfaces. + +## Privacy invariant + +**No plaintext, ciphertext, decrypted body, tool args, raw event payload, +or private key material ever appears in:** + +- `PolicyDecision` +- `PolicyAuditEntry` +- `PolicyAuditLog` ring buffer +- `'policy:decision'` / `'policy:audit'` events on Agent +- the MCP `get_policy_audit_recent` tool output (`PolicyAuditDTO`) +- console / stdout / stderr writes from the policy gate code path + +What *is* allowed in audit/decision/MCP surfaces: + +- event kind (`group | dm | unknown`) +- `messageId`, `groupIdHex`, `senderFingerprint` (all already-public + identifiers) +- `byteLength` of the plaintext (size only, no content) +- decision booleans and the kebab-case reason token +- the audit's own `auditId` and `receivedAt` timestamp + +Three independent test layers enforce this: + +1. Adversarial integration tests + (`packages/node/src/__tests__/policy-gate-adversarial.test.ts`) embed + a canary in plaintext and assert it never appears in serialized audit + or decision payloads. +2. Fuzz tests (`policy-gate-fuzz.test.ts`) run 500 iterations with random + plaintext containing a fixed canary, intercepting all console + stdout + + stderr writes for the duration. Zero captured lines may contain the + canary; zero audit serializations either. +3. The MCP DTO test (`packages/mcp/src/__tests__/policy-audit-dto.test.ts`) + pollutes a `PolicyAuditEntry` with `plaintext` / `decryptedBody` / + `toolArgs` fields and verifies `toPolicyAuditDTO` strips them — the + DTO is an explicit projection, not a spread copy, so future + `PolicyAuditEntry` additions do not auto-leak. + +`messageRepo.messages.content` (SQLite, owner-private) is **outside** the +gate's privacy surface. That column stores the plaintext at rest for the +owner's own `getMessages` read API, by design and from the original +schema. The hardening in this PR does not change the message store and +does not extend the privacy invariant to it. + +## Dedup retry-poison invariant + +The dedup set is updated **only** in step 6 of `PolicyGate.evaluate`, +after validation, membership recheck, the pure decision, and the audit +write all succeeded. If any earlier step fails, or if `audit.record` +throws, the `messageId` is **not** added — a legitimate retry with the +same `messageId` will be re-evaluated rather than silently denied as a +duplicate. Tests in +`packages/node/src/__tests__/policy-gate-unit.test.ts` cover the three +poison scenarios (validation fail / membership fail / audit throw) and +the eviction-at-capacity behavior. + +## Future extension point — tool execution (NOT IMPLEMENTED) + +When a `PolicyDecision { action: 'act' }` is produced, this PR does +nothing beyond emitting the decision. The deliberate extension point is: + +```ts +agent.on('policy:decision', (decision) => { + if (decision.action !== 'act') return; + // (future PR) lookup handler/tool, validate args, invoke out-of-band +}); +``` + +`AgentPolicy.decide` must remain pure. Tool execution / side effects +belong **outside** the gate, on a separate consumer that subscribes to +`'policy:decision'`. Anything that requires I/O, network, or filesystem +access does not belong inside `decide`. + +This PR explicitly does not implement: + +- per-interest or per-action handler invocation +- tool calls / agent action execution +- payments or token economics +- server-trust assumptions +- protocol-level "addressed" metadata (mentions stay in plaintext) +- DM events (the DM receive path is fail-closed in `Agent.handleDirectMessage` + until DM signing / Double Ratchet lands) From fa1909cc74429789a9962b75bade668398d92a5e Mon Sep 17 00:00:00 2001 From: Ray Svitla <130174948+raysvitla@users.noreply.github.com> Date: Sat, 25 Apr 2026 09:56:24 +0100 Subject: [PATCH 8/8] fix: harden policy gate review feedback (immutable audit, defensive callbacks, readonly fields) Address concrete issues found in self-review of PR #4: 1. PolicyAuditLog stored entries by reference. Callers reading via recent() could mutate the audit trail (entries[0].action = 'act'; entries[0].matchedInterests.push(...)). record() now structuredClones the entry and Object.freezes both the entry and its matchedInterests array. Audit integrity no longer depends on caller discipline. 5 new tests in policy-gate-immutability cover input-mutation-after-record, frozen-entry-throws-on-write, matchedInterests-is-frozen, record-returns-frozen-copy, and recent()-stability. 2. PolicyGate.evaluate invoked the isMember callback unguarded. A db hiccup in groupRepo.getMembers would throw out of the EventEmitter listener registered by Agent.setupGroupManagerEvents and record no audit row. The callback is now wrapped in try/catch; failure collapses to fail-closed reason 'not-a-member' with an audit row. Dedup is not poisoned, so a retry after the predicate recovers is re-evaluated. 3. PolicyGate.evaluate emitted 'decision' synchronously inside the evaluate() body. A buggy listener could throw, propagate out of evaluate, abort the gate's return path, and desynchronize audit/ dedup from the queue/emit downstream. Wrapped in try/catch with queueMicrotask rethrow so listener bugs surface on the next tick without breaking gate flow. Same pattern as InboundEventQueue. 4. agent.policy / policyGate / policyAudit are now readonly. Matches the existing convention on agent.inboundQueue and prevents callers swapping the gate out from under setupGroupManagerEvents wiring. One-time assignment in start() uses a typed cast so the seam is internal. 5. MCP get_policy_audit_recent.limit gains a .max(1000) clamp on top of the existing .int().positive(). Prevents oversized JSON responses; the audit log itself caps at policyAuditMax (default 1000) so larger limits were dead weight anyway. Test covers the accept/reject grid (1, 50, 1000, 1001, MAX_SAFE_INTEGER, 0, -1, NaN, '50', undefined). 6. POLICY.md gains an explicit "Legacy event compatibility" section noting that 'group:message' bypasses the gate (carries plaintext as .content for backward-compat consumers). The privacy invariant applies to the gate / audit / decision / MCP audit surfaces only; legacy listeners are the consumer's responsibility. New code should listen on 'inbound:message'. 8 new tests; total 194 (was 186). All previous tests unchanged. Co-Authored-By: Claude Opus 4.7 --- docs/POLICY.md | 24 +++ .../src/__tests__/policy-audit-dto.test.ts | 22 ++ packages/mcp/src/tools/policy.ts | 2 +- .../policy-gate-immutability.test.ts | 194 ++++++++++++++++++ packages/node/src/agent.ts | 28 ++- packages/node/src/policy/audit-log.ts | 31 ++- packages/node/src/policy/policy-gate.ts | 27 ++- 7 files changed, 310 insertions(+), 18 deletions(-) create mode 100644 packages/node/src/__tests__/policy-gate-immutability.test.ts diff --git a/docs/POLICY.md b/docs/POLICY.md index 2e5e0fd..115c475 100644 --- a/docs/POLICY.md +++ b/docs/POLICY.md @@ -152,6 +152,30 @@ owner's own `getMessages` read API, by design and from the original schema. The hardening in this PR does not change the message store and does not extend the privacy invariant to it. +### Legacy event compatibility — `'group:message'` is NOT gated + +`GroupManager.emit('group:message', { ... content })` is preserved +unchanged for backward compatibility with consumers that pre-date the +inbound bridge. Its payload includes the decoded plaintext as `content`. + +This event: + +- **bypasses** `PolicyGate.evaluate`, +- is **not** mediated by `AgentPolicy.decide`, +- does **not** appear in the audit log, +- does **not** participate in dedup. + +A consumer that listens on `'group:message'` and logs the payload — or +forwards it to a public surface — would leak plaintext past the gate. +The privacy invariant above applies to gate / audit / decision / MCP +audit surfaces only; it does **not** automatically apply to legacy +consumers. + +New code should listen on `'inbound:message'` (post-gate) and inspect +`PolicyDecision` / `PolicyAuditEntry` for context. The legacy event will +be re-evaluated for deprecation once external consumers have migrated; +removing it is out of scope for the hardening PR. + ## Dedup retry-poison invariant The dedup set is updated **only** in step 6 of `PolicyGate.evaluate`, diff --git a/packages/mcp/src/__tests__/policy-audit-dto.test.ts b/packages/mcp/src/__tests__/policy-audit-dto.test.ts index afdbb44..739c697 100644 --- a/packages/mcp/src/__tests__/policy-audit-dto.test.ts +++ b/packages/mcp/src/__tests__/policy-audit-dto.test.ts @@ -73,6 +73,28 @@ describe('toPolicyAuditDTO — metadata-only projection', () => { }); }); +describe('get_policy_audit_recent — limit clamp', () => { + // Re-derive the schema the tool uses so we can assert clamping + // behavior without a full MCP transport. Keep this in lockstep with + // tools/policy.ts; if the limit shape changes there, this assertion + // catches the drift. + it('rejects limit > 1000 (DoS / oversized response defense)', async () => { + const { z } = await import('zod'); + const limit = z.number().int().positive().max(1000).optional(); + expect(limit.safeParse(1001).success).toBe(false); + expect(limit.safeParse(10000).success).toBe(false); + expect(limit.safeParse(Number.MAX_SAFE_INTEGER).success).toBe(false); + expect(limit.safeParse(1000).success).toBe(true); + expect(limit.safeParse(50).success).toBe(true); + expect(limit.safeParse(1).success).toBe(true); + expect(limit.safeParse(undefined).success).toBe(true); + expect(limit.safeParse(0).success).toBe(false); + expect(limit.safeParse(-1).success).toBe(false); + expect(limit.safeParse(NaN).success).toBe(false); + expect(limit.safeParse('50').success).toBe(false); + }); +}); + describe('Locked DTO surface', () => { it('has the exact set of keys we publish over MCP', () => { const dto = toPolicyAuditDTO(baseEntry); diff --git a/packages/mcp/src/tools/policy.ts b/packages/mcp/src/tools/policy.ts index fe0af78..694cc1f 100644 --- a/packages/mcp/src/tools/policy.ts +++ b/packages/mcp/src/tools/policy.ts @@ -46,7 +46,7 @@ export function registerPolicyTools(server: McpServer, agent: Agent): void { 'get_policy_audit_recent', 'Owner-private, local-only, read-only, metadata-only. Returns recent policy gate decisions for debugging — never includes plaintext, ciphertext, decrypted body, tool args, raw event payloads, or private key material. Safe to inspect; do NOT forward results to public dashboards, census, or shared logs.', { - limit: z.number().int().positive().optional().describe('Maximum number of audit entries to return (default 50, newest last)'), + limit: z.number().int().positive().max(1000).optional().describe('Maximum number of audit entries to return (default 50, newest last; capped at 1000)'), }, async ({ limit }) => { const entries = agent.policyAudit.recent(limit ?? 50).map(toPolicyAuditDTO); diff --git a/packages/node/src/__tests__/policy-gate-immutability.test.ts b/packages/node/src/__tests__/policy-gate-immutability.test.ts new file mode 100644 index 0000000..f188a7d --- /dev/null +++ b/packages/node/src/__tests__/policy-gate-immutability.test.ts @@ -0,0 +1,194 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import type { AgentIdentity, PolicyAuditEntry, PrivateInboundMessageEvent } from '@networkselfmd/core'; +import { PolicyAuditLog } from '../policy/audit-log.js'; +import { PolicyGate } from '../policy/policy-gate.js'; +import { AgentPolicy } from '../policy/agent-policy.js'; +import { InboundEventQueue } from '../events/inbound-queue.js'; +import type { Agent } from '../agent.js'; +import { makeIdentity } from './test-utils/group-harness.js'; + +function fakeAgent(id: AgentIdentity): Agent { + return { identity: id, inboundQueue: new InboundEventQueue() } as unknown as Agent; +} + +const SAMPLE_ENTRY: PolicyAuditEntry = { + auditId: 'a1', + receivedAt: 1, + eventKind: 'group', + messageId: 'm1', + groupIdHex: 'aa', + senderFingerprint: 'fp', + byteLength: 8, + action: 'ask', + reason: 'addressed-unknown-sender', + addressedToMe: true, + senderTrusted: false, + matchedInterests: ['coffee'], + gateRejected: false, +}; + +describe('PolicyAuditLog — stored entries are deeply immutable', () => { + it('mutating the input AFTER record() does not corrupt the stored entry', () => { + const log = new PolicyAuditLog(); + const input: PolicyAuditEntry = { ...SAMPLE_ENTRY, matchedInterests: ['coffee'] }; + log.record(input); + // Mutate every field on the input. + input.action = 'act'; + input.reason = 'addressed-and-trusted'; + input.matchedInterests.push('hijack'); + input.senderFingerprint = 'attacker'; + const stored = log.recent()[0]; + expect(stored.action).toBe('ask'); + expect(stored.reason).toBe('addressed-unknown-sender'); + expect(stored.matchedInterests).toEqual(['coffee']); + expect(stored.senderFingerprint).toBe('fp'); + }); + + it('returned entry is frozen — direct field mutation throws in strict mode', () => { + const log = new PolicyAuditLog(); + log.record(SAMPLE_ENTRY); + const e = log.recent()[0]; + expect(() => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (e as any).action = 'act'; + }).toThrow(); + expect(() => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (e as any).reason = 'addressed-and-trusted'; + }).toThrow(); + }); + + it('matchedInterests array on the stored entry is also frozen', () => { + const log = new PolicyAuditLog(); + log.record(SAMPLE_ENTRY); + const e = log.recent()[0]; + expect(() => e.matchedInterests.push('hijack')).toThrow(); + expect(() => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (e.matchedInterests as any)[0] = 'replaced'; + }).toThrow(); + }); + + it('record() returns the frozen stored copy, not the input', () => { + const log = new PolicyAuditLog(); + const input = { ...SAMPLE_ENTRY }; + const returned = log.record(input); + expect(returned).not.toBe(input); + expect(Object.isFrozen(returned)).toBe(true); + expect(Object.isFrozen(returned.matchedInterests)).toBe(true); + }); + + it('two recent() calls return entries that are equal (no clone-on-read churn)', () => { + const log = new PolicyAuditLog(); + log.record(SAMPLE_ENTRY); + const a = log.recent()[0]; + const b = log.recent()[0]; + // Stored once, frozen, returned by reference — same object instance. + expect(a).toBe(b); + }); +}); + +interface GateHarness { + alice: AgentIdentity; + bob: AgentIdentity; + groupId: Uint8Array; + audit: PolicyAuditLog; + gate: PolicyGate; + isMember: { value: (g: Uint8Array, k: Uint8Array) => boolean }; +} + +function buildGate(): GateHarness { + const alice = makeIdentity('Alice'); + const bob = makeIdentity('Bob'); + const groupId = new Uint8Array(32).fill(0xab); + const audit = new PolicyAuditLog({ max: 32 }); + const policy = new AgentPolicy({ + agent: fakeAgent(alice), + config: { mentionPrefixLen: 8, requireMention: false }, + }); + const isMember = { + value: (g: Uint8Array, k: Uint8Array): boolean => + Buffer.from(g).equals(Buffer.from(groupId)) && + Buffer.from(k).equals(Buffer.from(bob.edPublicKey)), + }; + const gate = new PolicyGate({ + policy, + audit, + isMember: (g, k) => isMember.value(g, k), + }); + return { alice, bob, groupId, audit, gate, isMember }; +} + +function buildEvent(h: GateHarness, opts: { id?: string; sender?: AgentIdentity } = {}): PrivateInboundMessageEvent { + return { + kind: 'group', + messageId: opts.id ?? 'm-' + Math.random().toString(36).slice(2), + groupId: h.groupId, + senderPublicKey: (opts.sender ?? h.bob).edPublicKey, + senderFingerprint: (opts.sender ?? h.bob).fingerprint, + plaintext: new TextEncoder().encode('hi'), + timestamp: 1, + receivedAt: 2, + }; +} + +describe('PolicyGate — defensive wrapping around external callbacks', () => { + let h: GateHarness; + beforeEach(() => { + h = buildGate(); + }); + + it('isMember throwing → fail-closed as not-a-member, no crash, audit recorded', () => { + h.isMember.value = () => { + throw new Error('db boom'); + }; + const ev = buildEvent(h, { id: 'thrown-1' }); + const out = h.gate.evaluate(ev); + expect(out.allowed).toBe(false); + if (out.allowed) throw new Error('unreachable'); + expect(out.reason).toBe('not-a-member'); + expect(out.entry.gateRejected).toBe(true); + expect(h.audit.recent()).toHaveLength(1); + // Dedup must NOT be poisoned: a retry after the predicate recovers + // is still allowed to be re-evaluated (and pass). + expect(h.gate.isDuplicate('thrown-1')).toBe(false); + h.isMember.value = () => true; + const retry = h.gate.evaluate(buildEvent(h, { id: 'thrown-1' })); + expect(retry.allowed).toBe(true); + }); + + it("'decision' listener throwing does not abort the gate's return path", async () => { + h.gate.on('decision', () => { + throw new Error('listener-buggy-canary'); + }); + + // The gate rethrows listener errors on a microtask so the calling + // event loop sees the bug; vitest's default uncaughtException + // handler would fail the test. Detach handlers for the duration of + // this test, capture the rethrow ourselves, and restore. + const previous = process.listeners('uncaughtException'); + process.removeAllListeners('uncaughtException'); + const surfaced: unknown[] = []; + const capture = (err: unknown) => surfaced.push(err); + process.on('uncaughtException', capture); + try { + const out = h.gate.evaluate(buildEvent(h, { id: 'lis-1' })); + // The listener threw, but evaluate returned the normal allowed + // outcome — this is the contract: gate state and downstream side + // effects don't depend on listener health. + expect(out.allowed).toBe(true); + expect(h.gate.isDuplicate('lis-1')).toBe(true); + expect(h.audit.recent()).toHaveLength(1); + // Drain the microtask queue so the rethrow surfaces. + await new Promise((resolve) => setImmediate(resolve)); + expect(surfaced).toHaveLength(1); + const err = surfaced[0] as Error; + expect(err.message).toMatch(/listener-buggy-canary/); + } finally { + process.off('uncaughtException', capture); + for (const l of previous) { + process.on('uncaughtException', l as (err: Error) => void); + } + } + }); +}); diff --git a/packages/node/src/agent.ts b/packages/node/src/agent.ts index 1fcb052..654e18f 100644 --- a/packages/node/src/agent.ts +++ b/packages/node/src/agent.ts @@ -82,10 +82,12 @@ export class Agent extends EventEmitter { // identity are ready. The gate is the single chokepoint between // GroupManager's authenticated `inbound:message` events and any // agent-runtime side effect (queue push, public re-emit). See - // docs/POLICY.md. - policy!: AgentPolicy; - policyAudit!: PolicyAuditLog; - policyGate!: PolicyGate; + // docs/POLICY.md. Marked readonly to match `inboundQueue` and prevent + // callers from swapping the gate or audit log out from under the + // wiring set up in start(). + readonly policy!: AgentPolicy; + readonly policyAudit!: PolicyAuditLog; + readonly policyGate!: PolicyGate; private options: AgentOptions; private database!: AgentDatabase; @@ -137,14 +139,20 @@ export class Agent extends EventEmitter { // Init policy machinery. The gate sits between GroupManager events // and any agent-runtime side effect; see setupGroupManagerEvents. - this.policyAudit = new PolicyAuditLog({ max: this.options.policyAuditMax }); - this.policy = new AgentPolicy({ + // The readonly modifier on policy/policyAudit/policyGate above + // documents post-start immutability; we use a typed cast here for + // the one-time assignment so callers don't see the seam. + const mut = this as { + -readonly [K in 'policy' | 'policyAudit' | 'policyGate']: Agent[K]; + }; + mut.policyAudit = new PolicyAuditLog({ max: this.options.policyAuditMax }); + mut.policy = new AgentPolicy({ agent: this, config: this.options.policyConfig ?? {}, }); - this.policyGate = new PolicyGate({ - policy: this.policy, - audit: this.policyAudit, + mut.policyGate = new PolicyGate({ + policy: mut.policy, + audit: mut.policyAudit, isMember: (groupId, publicKey) => { const members = this.groupRepo.getMembers(groupId); for (const m of members) { @@ -153,7 +161,7 @@ export class Agent extends EventEmitter { return false; }, }); - this.policyGate.on('decision', (decision: PolicyDecision) => { + mut.policyGate.on('decision', (decision: PolicyDecision) => { this.emit('policy:decision', decision); }); diff --git a/packages/node/src/policy/audit-log.ts b/packages/node/src/policy/audit-log.ts index aca4ac5..701e55a 100644 --- a/packages/node/src/policy/audit-log.ts +++ b/packages/node/src/policy/audit-log.ts @@ -22,15 +22,17 @@ export class PolicyAuditLog { this.max = Math.max(1, options.max ?? 1000); } - // Append an entry. Returns the entry as recorded (callers can use the - // returned reference but must NOT mutate it; the log holds a reference - // to the same object). + // Append an entry. The log stores an independent, deeply-frozen copy: + // mutating the input after record() does not corrupt the stored row, + // and callers that read entries back via recent() cannot mutate them. + // Audit integrity must not depend on caller discipline. record(entry: PolicyAuditEntry): PolicyAuditEntry { - this.buf.push(entry); + const stored = freezeAuditEntry(cloneAuditEntry(entry)); + this.buf.push(stored); if (this.buf.length > this.max) { this.buf.splice(0, this.buf.length - this.max); } - return entry; + return stored; } // Most recent N entries, newest last. Returns a copy. @@ -47,3 +49,22 @@ export class PolicyAuditLog { this.buf = []; } } + +// Deep clone the whole entry. structuredClone is available in Node ≥17; +// the fields on PolicyAuditEntry are all structured-clone safe (strings, +// numbers, booleans, optional strings, string[]). New non-cloneable +// field types added later should fail at record() time, which is the +// loud-failure we want. +function cloneAuditEntry(entry: PolicyAuditEntry): PolicyAuditEntry { + return structuredClone(entry); +} + +// Freeze the entry and any contained array reference so callers cannot +// mutate the audit trail through a returned reference. Strict mode +// (which TypeScript-emitted ESM runs in) throws on assignment to a +// frozen property, surfacing accidental writes loudly during +// development. +function freezeAuditEntry(entry: PolicyAuditEntry): PolicyAuditEntry { + Object.freeze(entry.matchedInterests); + return Object.freeze(entry); +} diff --git a/packages/node/src/policy/policy-gate.ts b/packages/node/src/policy/policy-gate.ts index 693c788..f85be02 100644 --- a/packages/node/src/policy/policy-gate.ts +++ b/packages/node/src/policy/policy-gate.ts @@ -108,7 +108,19 @@ export class PolicyGate extends EventEmitter { const entry = this.recordRejectFromEvent(ev, receivedAt, 'malformed-event'); return { allowed: false, reason: 'malformed-event', entry }; } - if (!this.isMember(ev.groupId, ev.senderPublicKey)) { + // Predicate may be backed by a database call. If it throws (db + // hiccup, schema mismatch, etc.) we MUST fail closed: the gate + // cannot decide membership, so the event is treated as a + // non-member rejection. We still record an audit row so operators + // see the failure pattern. + let isMember: boolean; + try { + isMember = this.isMember(ev.groupId, ev.senderPublicKey); + } catch { + const entry = this.recordRejectFromEvent(ev, receivedAt, 'not-a-member'); + return { allowed: false, reason: 'not-a-member', entry }; + } + if (!isMember) { const entry = this.recordRejectFromEvent(ev, receivedAt, 'not-a-member'); return { allowed: false, reason: 'not-a-member', entry }; } @@ -139,7 +151,18 @@ export class PolicyGate extends EventEmitter { this.markDedup(ev.messageId); // --- Step 7: emit and return --- - this.emit('decision', decision); + // Listener errors must not desynchronize the gate from its + // downstream side effects. A buggy listener on 'decision' would + // otherwise abort evaluate() before returning, leaving the audit + // recorded but the queue untouched. Surface the bug on the next + // microtask without breaking gate flow. + try { + this.emit('decision', decision); + } catch (err) { + queueMicrotask(() => { + throw err; + }); + } return decision.action === 'ignore' ? { allowed: false, reason: decision.reason, entry }