diff --git a/docs/MCP.md b/docs/MCP.md index 7980f3a..75312bd 100644 --- a/docs/MCP.md +++ b/docs/MCP.md @@ -26,7 +26,7 @@ Replace the secret-file path with an existing owner-readable passphrase file. Re ## Tools -The server exposes 20 tools across 5 categories. State IDs and public keys are hexadecimal strings in tool inputs, tool responses and resources. +The server exposes 28 tools across 6 categories. State IDs and public keys are hexadecimal strings in tool inputs, tool responses and resources. ### Identity @@ -199,3 +199,9 @@ User: Send "gm builders" to the group ← { accepted: true, messageId: "..." } ``` + +## Inbound policy controls + +Eight additional owner-local tools are available: `get_pending_inbound_events`, `get_policy_audit_recent`, `get_policy_config`, `set_policy_config`, `add_policy_trusted_fingerprint`, `remove_policy_trusted_fingerprint`, `add_policy_interest`, and `remove_policy_interest`. + +Pending-event responses contain private decrypted message content and drain a bounded process-local queue. They must not be exposed to public observers. Audit responses contain only metadata and survive restart. Both DM and group reception use the policy gate; `act`/`ask` decisions do not execute tools or implement an approval UI. See `docs/POLICY.md` in the repository for migration and runtime semantics. diff --git a/docs/POLICY.md b/docs/POLICY.md new file mode 100644 index 0000000..f8eed94 --- /dev/null +++ b/docs/POLICY.md @@ -0,0 +1,468 @@ +# 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 + +The current implementation covers authenticated group messages and direct messages on handshake v3. Schema 10 adds policy configuration and metadata-only audit tables to the modern runtime. Historical policy schemas 2/3 are reconciled without discarding their configuration or audit. + +1. The transport verifies the sender and recipient, decrypts the message, and checks group membership where applicable. +2. Inside one SQLite transaction, it stores the message, advances the ratchet/replay state, records reliable-delivery deduplication, and evaluates the policy gate. Audit persistence participates in that same transaction. +3. If any write fails, all writes roll back. No in-memory audit, policy dedup mark, decision, queue entry, or receipt is published. The sender can retry. +4. After commit, the runtime updates its audit ring and policy dedup set, emits the decision and metadata-only audit/activity events, then queues and emits allowed private inbound events. Existing `group:message` and `dm:message` consumers retain their payloads. +5. The delivery receipt acknowledges durable message storage, including when policy chooses `ignore`; it does not acknowledge execution or approval. + +`PolicyGate.evaluate(event, afterCommit)` accepts an internal scheduling callback for transactional reception. Standalone calls without that callback apply their effects immediately. Do not schedule effects from a transaction that subsequently rolls back. + +The inbound queue is bounded and process-local. Its contents are not restored after process restart. The durable audit preserves decisions, not an executable job queue. `act` and `ask` are decisions only: no tools execute and no approval UI is implied. + +Raw `GroupManager` events and legacy message events are owner-private SDK surfaces, not public API endpoints. Only Agent-level `inbound:message` and `inboundQueue` are policy-filtered. The runtime isolates failures in its policy event observers so a faulty subscriber cannot suppress later subscribers or a delivery receipt. + +## 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 always true for an authenticated DM to this agent. For group messages it requires a token-bounded `@` or `@` mention, unless `requireMention: false` is configured. Unknown DM senders produce `ask`, not automatic execution. + +## 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. + +### 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`, +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. + +## Configuring the policy gate + +Configuration is owner-local. There are three equivalent surfaces: + +1. **Programmatic** — pass `policyConfig` to `new Agent({ ... })`. +2. **MCP tools** (owner-private, local-only): `get_policy_config`, + `set_policy_config`, `add_policy_trusted_fingerprint`, + `remove_policy_trusted_fingerprint`, `add_policy_interest`, + `remove_policy_interest`. +3. **CLI** (under the `policy` parent command): + + ``` + networkselfmd policy get + networkselfmd policy set --interests=coffee,deploy --require-mention=true + networkselfmd policy set --reset + networkselfmd policy trust add abcd1234 + networkselfmd policy trust remove abcd1234 + networkselfmd policy interest add ship-it + networkselfmd policy interest remove ship-it + ``` + +### Precedence + +On `Agent.start()`: + +- If a row exists in the local `policy_config` table, **the persisted + config wins** and `AgentOptions.policyConfig` is ignored. Once an + operator has tightened/loosened policy via CLI, MCP, or + `agent.setPolicyConfig`, that setting survives restart. +- Otherwise the `AgentOptions.policyConfig` value is used as a + first-time default. **It is NOT auto-persisted.** Programmatic + defaults stay programmatic until the operator opts in via + `setPolicyConfig`. +- If neither is present, the runtime default is `{}` — strict, + ignore-everything. + +`agent.resetPolicyConfig()` clears the persisted row and reverts the +runtime to `AgentOptions.policyConfig` (or `{}`). + +> **CLI `policy set --reset`** clears the persisted row but reverts to +> the empty default `{}` — the CLI has no programmatic AgentOptions +> context to fall back on. Operators who want different defaults +> should pass `AgentOptions.policyConfig` to their long-running agent +> process; the next agent restart will then pick those up because the +> persisted row is empty after reset. + +> **`AgentOptions.policyConfig` is validated at `Agent.start()`.** +> A JS caller that passes an off-spec config (e.g. via JSON.parse, or +> the TypeScript escape hatch `as never`) will see +> `PolicyConfigValidationError` thrown synchronously from `start()`. +> Persisted configs that already exist on disk are not re-validated on +> load — they were validated at the time they were written, and a +> corrupt JSON list is degraded to an empty array rather than throwing. + +### Validation and bounds + +`Agent.setPolicyConfig` validates input with the pure +`validatePolicyConfig` helper before persisting. Bounds (frozen as +`POLICY_LIMITS`): + +- `trustedFingerprints`: ≤256 entries; each 4–64 chars matching + `/^[a-z0-9]+$/`. Mixed-case input is trim+lowercased, not rejected. +- `interests`: ≤256 entries; each non-empty after trim, ≤64 chars. +- `requireMention`: boolean. +- `mentionPrefixLen`: integer in `[1, 64]`. + +Lists are deduplicated while preserving order. Unknown keys on the +input (e.g. an attacker-supplied `plaintext` field) are stripped from +the sanitized output and never reach persistence. + +On bad input `setPolicyConfig` throws `PolicyConfigValidationError` +with a structured `errors` array; the persisted row and live +configuration are untouched. + +### Examples + +Trust a peer and react to "deploy" mentions, requiring an explicit +@-mention: + +```ts +agent.setPolicyConfig({ + trustedFingerprints: ['abcd1234'], + interests: ['deploy'], + requireMention: true, + mentionPrefixLen: 8, +}); +``` + +CLI equivalent: + +``` +networkselfmd policy set \ + --trusted=abcd1234 \ + --interests=deploy \ + --require-mention=true \ + --mention-prefix-len=8 +``` + +Open the gate to all group messages (no @-mention required), useful +for a chatbot scenario where every message wants attention: + +``` +networkselfmd policy set --require-mention=false +``` + +### Privacy boundaries — operator config surface + +The privacy invariant on the gate / audit / decision / MCP audit +surfaces extends here, with one expected exception: operator-supplied +configuration values **are persisted** because that is the entire +purpose. What's persisted: + +- trusted fingerprints (already public identifiers) +- interest keywords (operator-chosen literal strings) +- the two flags (`requireMention`, `mentionPrefixLen`) + +What's **never** persisted, even by accident: + +- message plaintext / decoded content +- ciphertext / nonces / chain keys +- private keys or any encrypted-at-rest material +- raw event payloads +- tool args (no tool execution) + +The schema enforces this — the `policy_config` table has columns only +for the four configurable fields plus an `updated_at` timestamp. A +test asserts the exact column set so future drift fails loudly. The +existing owner-private message store (`messages.content`) is unrelated +to the policy surface and unchanged in this PR. + +### Local-only warning for MCP/CLI tools + +The MCP policy tools and the CLI subcommands are operator controls. +They modify local state only. They MUST NOT be: + +- exposed through a public web UI +- proxied to remote callers +- forwarded to dashboards, census, or shared logs +- used to drive automated decisions on behalf of other accounts + +There is **no tool execution** in this PR. `act` decisions still +require the consumer to wire `agent.on('policy:decision', ...)` +themselves and dispatch out-of-band — and that wiring is an +owner-side concern, not a policy-tool concern. + +## Durable audit trail + +PR #6 turns the in-memory `PolicyAuditLog` into a durable, owner-local, +metadata-only audit trail that survives restart. The in-memory log +stays as a runtime convenience; the operator-truth surface for "recent +decisions across restart" is the persisted SQLite table. + +### Storage + +Schema 10 adds a `policy_audit` table with exactly these columns: + +``` +audit_id TEXT PRIMARY KEY +received_at INTEGER NOT NULL +inserted_at INTEGER NOT NULL +event_kind TEXT NOT NULL -- 'group' | 'dm' | 'unknown' +message_id TEXT -- nullable +group_id_hex TEXT -- nullable +sender_fingerprint TEXT -- nullable +byte_length INTEGER NOT NULL +action TEXT NOT NULL -- 'act' | 'ask' | 'ignore' +reason TEXT NOT NULL +addressed_to_me INTEGER NOT NULL +sender_trusted INTEGER NOT NULL +matched_interests TEXT NOT NULL -- JSON array +gate_rejected INTEGER NOT NULL +allowed INTEGER NOT NULL -- derived: action != 'ignore' && !gate_rejected +``` + +This is the entire surface. There is **no column** for plaintext, +content, body, payload, tool args, private-key bytes, ciphertext, +secrets, or passwords — by intent, and pinned by a column-set test +that fails loudly on any future drift. + +`allowed` is a **DB-only derived convenience** for analytics queries +(e.g. `SELECT COUNT(*) FROM policy_audit WHERE allowed = 1`). It is +intentionally NOT part of `PolicyAuditEntry`, the MCP DTO +(`toPolicyAuditDTO`), or CLI output — the public vocabulary stays +`action` + `gateRejected`, which together are sufficient to derive +allowed-ness without expanding the operator-facing surface. + +### Reads fail closed on corrupt rows + +`PolicyAuditRepository.recent()` validates every enum / boolean / +numeric field per row. A row whose `event_kind`, `action`, `reason`, +`addressed_to_me`, `sender_trusted`, `gate_rejected`, or `byte_length` +is out of spec is mapped to a fail-closed metadata-only stub: +`eventKind="unknown"`, `action="ignore"`, `reason="malformed-event"`, +`gateRejected=true`. The original `auditId` and `receivedAt` are +preserved (best-effort) so operators can correlate, but no other +field of a corrupt row ever surfaces as if it were a normal decision. +`matched_interests` JSON corruption already degrades to `[]` via the +shared `parseStringArray` helper. + +### Privacy boundary + +What persists: + +- per-decision metadata listed above +- operator identifiers already public in protocol (`group_id_hex`, + `sender_fingerprint`) +- the size of the (decrypted) plaintext as `byte_length`, content-free +- operator-supplied `matched_interests` config tokens (these came from + `PolicyConfig`, not from the message) + +What never persists, even by accident: + +- decoded message plaintext / content / body +- ciphertext / nonces / chain keys +- private keys or any encrypted-at-rest material +- raw event payloads +- tool args (no tool execution) + +Three independent test layers enforce this end-to-end: + +1. Repository tests — schema column-set, plaintext canary on insert, + polluted-entry projection. +2. Persistence integration tests — feed canary plaintext through the + real gate; raw SQL read of every column never contains the canary; + 100-iteration intercepted console / stdout / stderr canary. +3. Cross-restart MCP test — write through the gate, restart the + Agent, read via the durable repo + `toPolicyAuditDTO` + + `JSON.stringify`; the canary must not appear at any step. + +### Retention + +- `POLICY_AUDIT_LIMITS.defaultMaxEntries = 5000` rows on disk + (overridable via `AgentOptions.policyAuditDbMax`). +- `POLICY_AUDIT_LIMITS.maxRecentLimit = 1000` per `recent()` / + MCP / CLI fetch — clamped at the repo, the MCP zod schema, and the + CLI parser. +- Prune-on-insert: each `insert()` evicts the oldest rows when the + count exceeds the configured cap. `Agent.start()` runs one + `prune()` to enforce retention after a downgrade. +- `prune({ olderThanMs })` deletes by age for operator-driven trim. + +### Retry-poison invariant + +`PolicyAuditLog.record()` runs `persist(entry)` **before** pushing to +the in-memory ring. A throw from persist propagates with the in-memory +log unchanged. The gate's `evaluate()` then throws too, and +`markDedup()` never runs — exactly the contract preserved since PR #4 +polish. A legitimate retry of the same `messageId` is re-evaluated +rather than silently denied as a duplicate. + +### MCP + +`get_policy_audit_recent` reads from the durable repo when present +(production after `Agent.start()`), and falls back to the in-memory +log only for Agents constructed without `start()` (test ergonomics). +Both paths project through the same `toPolicyAuditDTO`, so the privacy +invariant is identical. + +### CLI + +``` +networkselfmd policy audit recent [--limit N] +networkselfmd policy audit prune --max-entries N +networkselfmd policy audit prune --older-than-ms N +networkselfmd policy audit clear +``` + +These commands are owner-private and **local-only**. They do not +instantiate `Agent` or call `agent.start()` — they go straight to +`AgentDatabase` + `PolicyAuditRepository` over the local SQLite file. +A source-level guard test fails if a future change reintroduces an +`Agent` import or `agent.start()` call into `commands/policy-audit.ts`. + +CLI output is metadata-only (the columns of `PolicyAuditEntry`); group +IDs and fingerprints are truncated for screen layout. There is no +flag or command in this PR that prints raw event payloads, plaintext, +or any content-bearing field. + +### Out of scope (still) + +- Tool execution / `act` handlers. +- Payments / token economics. +- Audit fan-out to remote sinks (durable here means owner-local SQLite + only, no relays). +- Touching `messages.content` (the existing owner-private message + store is unchanged). + +## 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) + +## Runtime configuration and owner access + +The local CLI reads/writes SQLite without starting an Agent or opening network listeners. `L2S_DATA_DIR` expands `~` consistently with the SDK. CLI changes are read at the next Agent startup; use the owner's connected MCP controls to change the running Agent immediately. + +MCP adds `get_pending_inbound_events` (drain 1–1000 events, default 50), `get_policy_audit_recent`, `get_policy_config`, `set_policy_config`, and trust/interest add/remove controls. Pending inbound events contain private plaintext encoded as base64 and optional UTF-8. Never publish them through the dashboard feed. Audit/config responses project explicit fields and omit injected properties. diff --git a/docs/releases/2026-09-07-policy-port.md b/docs/releases/2026-09-07-policy-port.md new file mode 100644 index 0000000..39a322f --- /dev/null +++ b/docs/releases/2026-09-07-policy-port.md @@ -0,0 +1,28 @@ +# Policy forward port + +Base: integration PR [#10](https://github.com/selfmd/network.self.md/pull/10), commit `7268e38`. +Branch: `codex/policy-forward-port-2026-09-07`. + +## Changes + +Port the functional layers of historical selfmd PRs #2 → #3 → #4 → #5 → #6 onto the current runtime. Keep the current signed group epochs, encrypted sender-key exchange, handshake v3 and reliable DM/group delivery. The old runtime in PR #1 is not reintroduced. + +- Typed private inbound events and bounded local queue, plus metadata-only activity events on Agent. +- Pure act/ask/ignore decisions, validation, membership rechecks and bounded deduplication. +- Persistent configuration with validated SDK/MCP controls and network-free CLI commands. +- Metadata-only SQLite audit with retention and explicit read projections. +- DM gating: authenticated direct messages count as addressed; unknown senders produce ask. Group mention rules remain configurable. +- Message, ratchet, delivery inbox and policy audit commit in one transaction. In-memory audit, dedup and dispatch effects run after commit. Audit failure permits a clean retry. +- Schema 10 adds the policy tables and reconciles historical policy schema 2/3 without losing their config/audit. Schema 9 upgrades additively. + +## Verification + +The workspace suite and focused CLI subprocess test pass: core 128, node 313, web 42, MCP 44, dashboard 116, CLI 36 — 679 tests total. Full build and workspace/client TypeScript checks pass. The transactional tests inject failure after the audit write and after gate preparation for both DM and group messages, then verify successful retries without duplicate history, audit or inbound events. Legacy policy schema 2/3 and runtime schema 9 fixtures preserve data across migration. + +All five npm tarballs passed consumer checks using an isolated snapshot of the unstaged source (the local archive adapter substitutes that snapshot for HEAD; the pinned docs submodule is exported normally). The Linux arm64/Node 22 Docker image built and passed the live publication/authentication smoke test. Package README additions after those checks are documentation-only. No policy changes have been staged or committed. + +## Rollout and limitations + +Merge/release the integration prerequisite first; policy uses the same handshake v3 and does not add another wire-version change. Back up the data directory before migrating to schema 10. For rollback, restore the previous data snapshot with its previous application revision. + +The inbound queue is process-local; the audit is durable. A delivered receipt means stored, not executed. Tool execution and ask/approval UI are still outside this change. Public dashboards receive none of the private inbound or audit APIs. CLI edits require Agent restart; MCP controls affect the connected running Agent. diff --git a/packages/cli/README.md b/packages/cli/README.md index b86160b..656112e 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -441,3 +441,15 @@ PRs welcome! See the main repository for contribution guidelines. ## License MIT + +## Local policy controls + +```bash +networkselfmd policy get +networkselfmd policy set --interests coffee --require-mention true +networkselfmd policy trust add +networkselfmd policy interest add coffee +networkselfmd policy audit recent --limit 50 +``` + +These commands open the owner's local SQLite database without starting a network Agent. `L2S_DATA_DIR` supports `~`. CLI configuration changes apply when the Agent next starts; connected MCP policy tools update the running Agent immediately. Audit contains decision metadata, not message bodies. `act` and `ask` do not execute tools or provide an approval UI. Schema 10 preserves historical policy configuration/audit when upgrading policy schema 2/3 or current runtime schema 9. diff --git a/packages/cli/src/__tests__/policy-audit.test.ts b/packages/cli/src/__tests__/policy-audit.test.ts new file mode 100644 index 0000000..080c816 --- /dev/null +++ b/packages/cli/src/__tests__/policy-audit.test.ts @@ -0,0 +1,182 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { mkdtempSync, rmSync, readFileSync } from 'node:fs'; +import { join, resolve } from 'node:path'; +import { tmpdir } from 'node:os'; +import { + AgentDatabase, + PolicyAuditRepository, + POLICY_AUDIT_LIMITS, +} from '@networkselfmd/node'; +import type { PolicyAuditEntry } from '@networkselfmd/node'; + +import { + policyAuditRecent, + policyAuditPrune, + policyAuditClear, +} from '../commands/policy-audit.js'; + +const SAMPLE: PolicyAuditEntry = { + auditId: 'a-cli-1', + receivedAt: 1000, + eventKind: 'group', + messageId: 'm-cli', + groupIdHex: 'feed', + senderFingerprint: 'fp-cli', + byteLength: 12, + action: 'ask', + reason: 'addressed-unknown-sender', + addressedToMe: true, + senderTrusted: false, + matchedInterests: ['coffee'], + gateRejected: false, +}; + +let dataDir: string; +let priorEnv: string | undefined; + +function seed(entry: PolicyAuditEntry): void { + const db = new AgentDatabase(dataDir); + db.migrate(); + try { + new PolicyAuditRepository(db.getDb()).insert(entry); + } finally { + db.close(); + } +} + +function readAll(): PolicyAuditEntry[] { + const db = new AgentDatabase(dataDir); + db.migrate(); + try { + return new PolicyAuditRepository(db.getDb()).recent({ limit: POLICY_AUDIT_LIMITS.maxRecentLimit }); + } finally { + db.close(); + } +} + +beforeEach(() => { + dataDir = mkdtempSync(join(tmpdir(), 'cli-policy-audit-')); + priorEnv = process.env.L2S_DATA_DIR; + process.env.L2S_DATA_DIR = dataDir; +}); + +afterEach(() => { + if (priorEnv === undefined) delete process.env.L2S_DATA_DIR; + else process.env.L2S_DATA_DIR = priorEnv; + rmSync(dataDir, { recursive: true, force: true }); +}); + +describe('CLI policy audit commands — happy paths', () => { + it('audit recent on empty DB prints "(none)" and persists nothing', async () => { + const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + await policyAuditRecent(); + const captured = logSpy.mock.calls.flat().join(' '); + expect(captured).toContain('(none)'); + expect(readAll()).toEqual([]); + logSpy.mockRestore(); + }); + + it('audit recent reads persisted entries newest-first', async () => { + const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + seed({ ...SAMPLE, auditId: 'a-1', messageId: 'm-1' }); + await new Promise((r) => setTimeout(r, 4)); + seed({ ...SAMPLE, auditId: 'a-2', messageId: 'm-2' }); + await policyAuditRecent({ limit: '10' }); + const captured = logSpy.mock.calls.flat().join('\n'); + const idxA1 = captured.indexOf('m-1'); + const idxA2 = captured.indexOf('m-2'); + expect(idxA1).toBeGreaterThan(-1); + expect(idxA2).toBeGreaterThan(-1); + // Newest (m-2) prints before oldest (m-1). + expect(idxA2).toBeLessThan(idxA1); + logSpy.mockRestore(); + }); + + it('audit prune --max-entries reduces the table', async () => { + for (let i = 0; i < 5; i++) { + seed({ ...SAMPLE, auditId: `a-${i}`, messageId: `m-${i}` }); + await new Promise((r) => setTimeout(r, 2)); + } + const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + await policyAuditPrune({ maxEntries: '2' }); + expect(readAll()).toHaveLength(2); + logSpy.mockRestore(); + }); + + it('audit clear removes everything', async () => { + seed({ ...SAMPLE, auditId: 'a-only' }); + expect(readAll()).toHaveLength(1); + const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + await policyAuditClear(); + expect(readAll()).toHaveLength(0); + logSpy.mockRestore(); + }); + + it('audit recent --limit clamps to maxRecentLimit (no DoS via huge JSON)', async () => { + for (let i = 0; i < 50; i++) seed({ ...SAMPLE, auditId: `a-${i}` }); + const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + await policyAuditRecent({ limit: String(POLICY_AUDIT_LIMITS.maxRecentLimit + 999) }); + // Should not throw; clamping happens before DB query, so the read + // returns at most maxRecentLimit (or all rows if fewer). + const captured = logSpy.mock.calls.flat().join(' '); + expect(captured).toContain('Policy audit'); + logSpy.mockRestore(); + }); +}); + +describe('CLI policy audit commands — privacy: no plaintext canary in CLI output', () => { + it('audit recent never echoes a plaintext canary because the DB never stores it', async () => { + // Simulate what an upstream Agent run would have written: pollute + // the entry with content-bearing fields. The repository's explicit + // projection drops them, so the CLI cannot leak what isn't there. + const polluted = { + ...SAMPLE, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + plaintext: 'CLI-AUDIT-CANARY-PLAINTEXT' as any, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + decryptedBody: 'CLI-AUDIT-CANARY-BODY' as any, + }; + seed(polluted as PolicyAuditEntry); + const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + await policyAuditRecent({ limit: '10' }); + const captured = logSpy.mock.calls.flat().join(' '); + expect(captured).not.toContain('CLI-AUDIT-CANARY-PLAINTEXT'); + expect(captured).not.toContain('CLI-AUDIT-CANARY-BODY'); + logSpy.mockRestore(); + }); +}); + +describe('CLI policy audit commands — fail-closed on bad input', () => { + beforeEach(() => { + vi.spyOn(process, 'exit').mockImplementation(((code?: string | number | null) => { + throw new Error(`EXIT_${code}`); + }) as never); + vi.spyOn(console, 'error').mockImplementation(() => {}); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('audit prune with no flags exits with code 1', async () => { + await expect(policyAuditPrune({})).rejects.toThrow('EXIT_1'); + }); + + it('audit prune with non-integer max-entries exits with code 1', async () => { + await expect(policyAuditPrune({ maxEntries: '7.5' })).rejects.toThrow('EXIT_1'); + }); + + it('audit recent with non-integer limit exits with code 1', async () => { + await expect(policyAuditRecent({ limit: 'soon' })).rejects.toThrow('EXIT_1'); + }); +}); + +describe('CLI policy audit commands are truly local — no Agent.start, no swarm', () => { + it('source file does not import Agent or SwarmManager and does not call agent.start()', () => { + const file = resolve(import.meta.dirname, '..', 'commands', 'policy-audit.ts'); + const src = readFileSync(file, 'utf-8'); + expect(src).not.toMatch(/\bimport[^;]*\bAgent\b/); + expect(src).not.toMatch(/\bimport[^;]*\bSwarmManager\b/); + expect(src).not.toMatch(/\bagent\.start\(\)/); + }); +}); diff --git a/packages/cli/src/__tests__/policy-cli.test.ts b/packages/cli/src/__tests__/policy-cli.test.ts new file mode 100644 index 0000000..d558014 --- /dev/null +++ b/packages/cli/src/__tests__/policy-cli.test.ts @@ -0,0 +1,23 @@ +import { execFileSync } from 'node:child_process'; +import { mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { expect, it } from 'vitest'; +import { AgentDatabase } from '@networkselfmd/node'; + +it('registers real policy CLI commands without creating an identity or starting a node', () => { + const directory = mkdtempSync(join(tmpdir(), 'nsmd-policy-cli-')); + const run = (...args: string[]) => execFileSync(process.execPath, + [fileURLToPath(new URL('../../dist/bin.js', import.meta.url)), 'policy', ...args], + { encoding: 'utf8', env: { ...process.env, L2S_DATA_DIR: directory }, timeout: 10000 }); + try { + expect(run('--help')).toContain('audit'); + expect(run('set', '--interests', 'coffee')).toContain('coffee'); + expect(run('get')).toContain('coffee'); + expect(run('audit', 'recent')).toMatch(/no|empty/i); + const database = new AgentDatabase(directory); + try { expect(database.getDb().prepare('SELECT COUNT(*) AS n FROM identity').get()).toEqual({ n: 0 }); } + finally { database.close(); } + } finally { rmSync(directory, { recursive: true, force: true }); } +}); diff --git a/packages/cli/src/__tests__/policy.test.ts b/packages/cli/src/__tests__/policy.test.ts new file mode 100644 index 0000000..c61e8da --- /dev/null +++ b/packages/cli/src/__tests__/policy.test.ts @@ -0,0 +1,165 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { mkdtempSync, rmSync, readFileSync } from 'node:fs'; +import { join, resolve } from 'node:path'; +import { tmpdir } from 'node:os'; +import { AgentDatabase, PolicyConfigRepository } from '@networkselfmd/node'; +import type { PolicyConfig } from '@networkselfmd/node'; + +import { + policyGet, + policySet, + policyTrustAdd, + policyTrustRemove, + policyInterestAdd, + policyInterestRemove, +} from '../commands/policy.js'; + +// Read the CLI policy config back through the same repository the +// commands use, in a fresh DB handle. This is what an operator running +// a separate `policy get` would observe. +function readPersisted(dataDir: string): PolicyConfig | undefined { + const db = new AgentDatabase(dataDir); + db.migrate(); + try { + return new PolicyConfigRepository(db.getDb()).load(); + } finally { + db.close(); + } +} + +let dataDir: string; +let priorEnv: string | undefined; + +beforeEach(() => { + dataDir = mkdtempSync(join(tmpdir(), 'cli-policy-')); + priorEnv = process.env.L2S_DATA_DIR; + process.env.L2S_DATA_DIR = dataDir; +}); + +afterEach(() => { + if (priorEnv === undefined) delete process.env.L2S_DATA_DIR; + else process.env.L2S_DATA_DIR = priorEnv; + rmSync(dataDir, { recursive: true, force: true }); +}); + +describe('CLI policy commands — happy paths', () => { + it('policy get on an empty data dir prints defaults and persists nothing', async () => { + await policyGet(); + expect(readPersisted(dataDir)).toBeUndefined(); + }); + + it('policy set --interests / --trusted writes a validated, persisted config', async () => { + await policySet({ interests: 'coffee, deploy', trusted: 'abcd1234, ef567890' }); + expect(readPersisted(dataDir)).toEqual({ + trustedFingerprints: ['abcd1234', 'ef567890'], + interests: ['coffee', 'deploy'], + }); + }); + + it('policy set is partial-merge, not replacement', async () => { + await policySet({ interests: 'a' }); + await policySet({ trusted: 'fp1234ab' }); + expect(readPersisted(dataDir)).toEqual({ + interests: ['a'], + trustedFingerprints: ['fp1234ab'], + }); + }); + + it('policy set --require-mention parses true/false/yes/no/1/0', async () => { + for (const truthy of ['true', 'yes', '1']) { + await policySet({ requireMention: truthy }); + expect(readPersisted(dataDir)?.requireMention).toBe(true); + } + for (const falsy of ['false', 'no', '0']) { + await policySet({ requireMention: falsy }); + expect(readPersisted(dataDir)?.requireMention).toBe(false); + } + }); + + it('policy set --mention-prefix-len writes an integer', async () => { + await policySet({ mentionPrefixLen: '12' }); + expect(readPersisted(dataDir)?.mentionPrefixLen).toBe(12); + }); + + it('policy set --reset clears the persisted row', async () => { + await policySet({ interests: 'x' }); + expect(readPersisted(dataDir)).toEqual({ interests: ['x'] }); + await policySet({ reset: true }); + expect(readPersisted(dataDir)).toBeUndefined(); + }); + + it('policy trust add/remove maintain a deduped, lower-cased list', async () => { + await policyTrustAdd('abcd1234'); + await policyTrustAdd('abcd1234'); // dup → no error, no double entry + await policyTrustAdd('ef567890'); + expect(readPersisted(dataDir)).toEqual({ trustedFingerprints: ['abcd1234', 'ef567890'] }); + await policyTrustRemove('abcd1234'); + expect(readPersisted(dataDir)).toEqual({ trustedFingerprints: ['ef567890'] }); + }); + + it('policy interest add/remove maintain a deduped list', async () => { + await policyInterestAdd('coffee'); + await policyInterestAdd('coffee'); + await policyInterestAdd('deploy'); + expect(readPersisted(dataDir)).toEqual({ interests: ['coffee', 'deploy'] }); + await policyInterestRemove('coffee'); + expect(readPersisted(dataDir)).toEqual({ interests: ['deploy'] }); + }); +}); + +describe('CLI policy commands — fail-closed on bad input (process.exit + no mutation)', () => { + beforeEach(() => { + // Mock process.exit to throw so we can assert and so the test + // process doesn't actually exit. Cast to never to satisfy the + // process.exit return-type contract. + vi.spyOn(process, 'exit').mockImplementation(((code?: string | number | null) => { + throw new Error(`EXIT_${code}`); + }) as never); + vi.spyOn(console, 'error').mockImplementation(() => {}); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('rejects unparseable --require-mention and does not persist', async () => { + await expect(policySet({ requireMention: 'sometimes' })).rejects.toThrow('EXIT_1'); + expect(readPersisted(dataDir)).toBeUndefined(); + }); + + it('rejects non-integer --mention-prefix-len and does not persist', async () => { + await expect(policySet({ mentionPrefixLen: '8.5' })).rejects.toThrow('EXIT_1'); + expect(readPersisted(dataDir)).toBeUndefined(); + }); + + it('rejects out-of-range --mention-prefix-len and does not mutate previous config', async () => { + await policySet({ interests: 'kept' }); + await expect(policySet({ mentionPrefixLen: '9999' })).rejects.toThrow('EXIT_1'); + // Previous interest still there; bad attempt did not corrupt the row. + expect(readPersisted(dataDir)).toEqual({ interests: ['kept'] }); + }); + + it('rejects malformed fingerprint shape on trust add and does not persist', async () => { + await expect(policyTrustAdd('has space')).rejects.toThrow('EXIT_1'); + expect(readPersisted(dataDir)).toBeUndefined(); + }); + + it('errors when policy set is called with no flags', async () => { + await expect(policySet({})).rejects.toThrow('EXIT_1'); + expect(readPersisted(dataDir)).toBeUndefined(); + }); +}); + +describe('CLI policy commands are truly local — they do not start the network', () => { + // Static check: the policy command source must NOT import Agent / + // SwarmManager / any network surface. The CLI's local-only promise is + // partly enforced here at the source level — if a future change adds + // such an import, this test fails before runtime side-effects can. + it('source file does not import Agent or SwarmManager', () => { + const file = resolve(import.meta.dirname, '..', 'commands', 'policy.ts'); + const src = readFileSync(file, 'utf-8'); + expect(src).not.toMatch(/\bimport[^;]*\bAgent\b/); + expect(src).not.toMatch(/\bimport[^;]*\bSwarmManager\b/); + expect(src).not.toMatch(/\bagent\.start\(\)/); + }); +}); diff --git a/packages/cli/src/bin.ts b/packages/cli/src/bin.ts index 54b0e5c..ad74bb3 100644 --- a/packages/cli/src/bin.ts +++ b/packages/cli/src/bin.ts @@ -1,4 +1,17 @@ #!/usr/bin/env node +import { + policyGet, + policySet, + policyTrustAdd, + policyTrustRemove, + policyInterestAdd, + policyInterestRemove, +} from './commands/policy.js'; +import { + policyAuditRecent, + policyAuditPrune, + policyAuditClear, +} from './commands/policy-audit.js'; import { Command } from 'commander'; import { initAgent } from './commands/init.js'; @@ -123,4 +136,88 @@ program await showStatus(await secrets(command)); }); +// ---- Policy operator controls ---- +// +// Owner-private, local-only controls for the inbound policy gate. +// See docs/POLICY.md for semantics. None of these tools execute agent +// actions or send anything over the network — they only read/update +// the local policy_config table and the live AgentPolicy. +const policy = program.command('policy').description('Inspect or update the inbound policy gate config (local only)'); + +policy + .command('get') + .description('Print the current policy configuration') + .action(async () => { + await policyGet(); + }); + +policy + .command('set') + .description('Update one or more policy fields (partial merge)') + .option('--interests ', 'Comma-separated interest keywords') + .option('--trusted ', 'Comma-separated trusted peer fingerprints') + .option('--require-mention ', 'true/false: require @-mention to count as addressed') + .option('--mention-prefix-len ', 'Integer: chars of fingerprint that count as a mention prefix') + .option('--reset', 'Wipe persisted config and revert to AgentOptions / defaults') + .action(async (opts) => { + await policySet(opts); + }); + +const policyTrust = policy + .command('trust') + .description('Manage the trusted-fingerprint list'); +policyTrust + .command('add ') + .description('Add a fingerprint to the trusted list') + .action(async (fp: string) => { + await policyTrustAdd(fp); + }); +policyTrust + .command('remove ') + .description('Remove a fingerprint from the trusted list') + .action(async (fp: string) => { + await policyTrustRemove(fp); + }); + +const policyInterest = policy + .command('interest') + .description('Manage interest keywords'); +policyInterest + .command('add ') + .description('Add an interest keyword') + .action(async (kw: string) => { + await policyInterestAdd(kw); + }); +policyInterest + .command('remove ') + .description('Remove an interest keyword') + .action(async (kw: string) => { + await policyInterestRemove(kw); + }); + +const policyAudit = policy + .command('audit') + .description('Inspect and manage the durable policy audit trail (local only)'); +policyAudit + .command('recent') + .description('Print recent policy gate decisions (newest first)') + .option('--limit ', 'Maximum entries to return (default 50, capped at 1000)') + .action(async (opts) => { + await policyAuditRecent(opts); + }); +policyAudit + .command('prune') + .description('Trim the audit trail by max-entries or by age') + .option('--max-entries ', 'Keep at most this many newest rows') + .option('--older-than-ms ', 'Delete rows older than this many milliseconds') + .action(async (opts) => { + await policyAuditPrune(opts); + }); +policyAudit + .command('clear') + .description('Remove all rows from policy_audit') + .action(async () => { + await policyAuditClear(); + }); + await program.parseAsync(); diff --git a/packages/cli/src/commands/policy-audit.ts b/packages/cli/src/commands/policy-audit.ts new file mode 100644 index 0000000..c4354ca --- /dev/null +++ b/packages/cli/src/commands/policy-audit.ts @@ -0,0 +1,126 @@ +import os from 'node:os'; +import path from 'node:path'; +import chalk from 'chalk'; +import { + AgentDatabase, + resolveDataDir, + PolicyAuditRepository, + POLICY_AUDIT_LIMITS, +} from '@networkselfmd/node'; +import type { PolicyAuditEntry } from '@networkselfmd/node'; + +// --------------------------------------------------------------------------- +// CLI policy-audit commands are owner-private, local-only operator +// controls. They MUST NOT instantiate Agent / start the swarm / rejoin +// groups / join the TTYA topic. The promise to the operator is "this +// just reads or trims a row in your local SQLite", and that is exactly +// what these handlers do — they go straight to AgentDatabase + +// PolicyAuditRepository with no network in sight. +// +// Output is metadata-only: only the columns of PolicyAuditEntry are +// printed. There is no plaintext / ciphertext / decrypted body / raw +// event payload exposed by these commands. +// --------------------------------------------------------------------------- + +function getDataDir(): string { + return resolveDataDir(process.env.L2S_DATA_DIR || path.join(os.homedir(), '.networkselfmd')); +} + +function withRepo(fn: (repo: PolicyAuditRepository) => T): T { + const db = new AgentDatabase(getDataDir()); + try { + db.migrate(); + return fn(new PolicyAuditRepository(db.getDb())); + } finally { + db.close(); + } +} + +function fail(msg: string): never { + console.error(chalk.red(msg)); + process.exit(1); +} + +function parseInteger(raw: string | undefined, field: string): number | undefined { + if (raw === undefined) return undefined; + const n = Number(raw); + if (!Number.isInteger(n)) fail(`--${field} must be an integer (got "${raw}")`); + return n; +} + +function clampLimit(raw: number | undefined): number { + if (raw === undefined) return 50; + if (!Number.isFinite(raw) || raw <= 0) return 1; + return Math.min(Math.floor(raw), POLICY_AUDIT_LIMITS.maxRecentLimit); +} + +function formatTime(ms: number): string { + return new Date(ms).toISOString(); +} + +function actionStyle(action: string): string { + if (action === 'act') return chalk.green(action); + if (action === 'ask') return chalk.yellow(action); + return chalk.dim(action); +} + +function printEntries(entries: PolicyAuditEntry[]): void { + console.log(chalk.bold('\nPolicy audit (newest first)\n')); + if (entries.length === 0) { + console.log(chalk.dim(' (none)\n')); + return; + } + for (const e of entries) { + const blocked = e.gateRejected ? chalk.red('blocked') : chalk.dim('passed'); + console.log( + ` ${chalk.dim(formatTime(e.receivedAt))} ${actionStyle(e.action)} ` + + `${chalk.cyan(e.reason)} ${blocked}`, + ); + const meta: string[] = []; + meta.push(`kind=${e.eventKind}`); + if (e.messageId) meta.push(`messageId=${e.messageId}`); + if (e.groupIdHex) meta.push(`groupId=${e.groupIdHex.slice(0, 12)}…`); + if (e.senderFingerprint) meta.push(`from=${e.senderFingerprint.slice(0, 12)}…`); + meta.push(`bytes=${e.byteLength}`); + if (e.matchedInterests.length > 0) meta.push(`matched=${e.matchedInterests.join(',')}`); + console.log(` ${chalk.dim(meta.join(' '))}`); + } + console.log(); +} + +export interface PolicyAuditRecentOpts { + limit?: string; +} + +export async function policyAuditRecent(opts: PolicyAuditRecentOpts = {}): Promise { + const rawLimit = parseInteger(opts.limit, 'limit'); + const limit = clampLimit(rawLimit); + const entries = withRepo((repo) => repo.recent({ limit })); + printEntries(entries); +} + +export interface PolicyAuditPruneOpts { + maxEntries?: string; + olderThanMs?: string; +} + +export async function policyAuditPrune(opts: PolicyAuditPruneOpts = {}): Promise { + const maxEntries = parseInteger(opts.maxEntries, 'max-entries'); + const olderThanMs = parseInteger(opts.olderThanMs, 'older-than-ms'); + if (maxEntries === undefined && olderThanMs === undefined) { + fail('Pass at least one of --max-entries or --older-than-ms .'); + } + if (maxEntries !== undefined && (maxEntries < 1 || maxEntries > POLICY_AUDIT_LIMITS.defaultMaxEntries * 100)) { + fail(`--max-entries out of range`); + } + const removed = withRepo((repo) => repo.prune({ + maxEntries: maxEntries ?? undefined, + olderThanMs: olderThanMs ?? undefined, + })); + console.log(chalk.green(`\nPruned ${removed} row(s) from policy_audit.\n`)); +} + +export async function policyAuditClear(): Promise { + withRepo((repo) => repo.clear()); + console.log(chalk.yellow('\nCleared policy_audit (all rows removed).\n')); +} diff --git a/packages/cli/src/commands/policy.ts b/packages/cli/src/commands/policy.ts new file mode 100644 index 0000000..79d5a14 --- /dev/null +++ b/packages/cli/src/commands/policy.ts @@ -0,0 +1,206 @@ +import os from 'node:os'; +import path from 'node:path'; +import chalk from 'chalk'; +import { + AgentDatabase, + resolveDataDir, + PolicyConfigRepository, + validatePolicyConfig, + PolicyConfigValidationError, + formatValidationErrors, +} from '@networkselfmd/node'; +import type { PolicyConfig } from '@networkselfmd/node'; + +// --------------------------------------------------------------------------- +// CLI policy commands are owner-private, local-only operator controls. +// +// They MUST NOT instantiate Agent / start the swarm / rejoin groups / join the +// TTYA topic. The promise to the operator is "this just edits a row in your +// local SQLite", and that's what these commands do — they go straight to +// AgentDatabase + PolicyConfigRepository + validatePolicyConfig with no +// network in sight. +// --------------------------------------------------------------------------- + +function getDataDir(): string { + return resolveDataDir(process.env.L2S_DATA_DIR || path.join(os.homedir(), '.networkselfmd')); +} + +// Open the local DB, run a synchronous transform with a PolicyConfigRepository, +// then close. Migrations are idempotent so this is safe to call repeatedly. +function withRepo(fn: (repo: PolicyConfigRepository) => T): T { + const db = new AgentDatabase(getDataDir()); + try { + db.migrate(); + return fn(new PolicyConfigRepository(db.getDb())); + } finally { + db.close(); + } +} + +function describeError(err: unknown): string { + if (err instanceof PolicyConfigValidationError) return err.message; + if (err instanceof Error) return err.message; + return 'unknown error'; +} + +function fail(msg: string): never { + console.error(chalk.red(msg)); + process.exit(1); +} + +function printConfig(c: PolicyConfig): void { + console.log(chalk.bold('\nPolicy configuration\n')); + const trusted = c.trustedFingerprints ?? []; + const interests = c.interests ?? []; + console.log(` ${chalk.dim('Trusted fingerprints:')} ${trusted.length === 0 ? chalk.dim('(none)') : ''}`); + for (const fp of trusted) console.log(` - ${fp}`); + console.log(` ${chalk.dim('Interests:')} ${interests.length === 0 ? chalk.dim('(none)') : ''}`); + for (const kw of interests) console.log(` - ${kw}`); + console.log( + ` ${chalk.dim('requireMention:')} ${c.requireMention === undefined ? chalk.dim('(default: true)') : c.requireMention}`, + ); + console.log( + ` ${chalk.dim('mentionPrefixLen:')} ${c.mentionPrefixLen === undefined ? chalk.dim('(default: 8)') : c.mentionPrefixLen}`, + ); + console.log(); +} + +// Validate + persist in a single repo-open. Throws on bad input — caller +// turns that into a CLI failure. Used by every mutation command below so +// validation is defined once. +function persistConfig(merged: PolicyConfig): PolicyConfig { + return withRepo((repo) => { + const result = validatePolicyConfig(merged); + if (!result.ok) throw new PolicyConfigValidationError(result.errors); + repo.save(result.config); + return result.config; + }); +} + +export async function policyGet(): Promise { + const config = withRepo((repo) => repo.load() ?? {}); + printConfig(config); +} + +export interface PolicySetOpts { + interests?: string; + trusted?: string; + requireMention?: string; + mentionPrefixLen?: string; + reset?: boolean; +} + +function parseList(raw: string | undefined): string[] | undefined { + if (raw === undefined) return undefined; + return raw + .split(',') + .map((s) => s.trim()) + .filter((s) => s.length > 0); +} + +function parseBool(raw: string | undefined, field: string): boolean | undefined { + if (raw === undefined) return undefined; + const lower = raw.toLowerCase(); + if (lower === 'true' || lower === 'yes' || lower === '1') return true; + if (lower === 'false' || lower === 'no' || lower === '0') return false; + fail(`--${field} must be true/false (got "${raw}")`); +} + +function parseInteger(raw: string | undefined, field: string): number | undefined { + if (raw === undefined) return undefined; + const n = Number(raw); + if (!Number.isInteger(n)) fail(`--${field} must be an integer (got "${raw}")`); + return n; +} + +export async function policySet(opts: PolicySetOpts): Promise { + // CLI `reset` clears the persisted row. The CLI has no programmatic + // AgentOptions context, so reset reverts to the empty default {} — not + // to a previously-passed AgentOptions.policyConfig. Operators that + // want different defaults should restart their agent with a desired + // AgentOptions.policyConfig and then `policy set --reset` from the + // CLI will defer to that on the next agent start. + if (opts.reset) { + withRepo((repo) => repo.clear()); + console.log(chalk.green('\nPolicy reset (persisted row cleared).\n')); + printConfig({}); + return; + } + + const partial: Partial = {}; + const trusted = parseList(opts.trusted); + const interests = parseList(opts.interests); + const requireMention = parseBool(opts.requireMention, 'require-mention'); + const mentionPrefixLen = parseInteger(opts.mentionPrefixLen, 'mention-prefix-len'); + if (trusted !== undefined) partial.trustedFingerprints = trusted; + if (interests !== undefined) partial.interests = interests; + if (requireMention !== undefined) partial.requireMention = requireMention; + if (mentionPrefixLen !== undefined) partial.mentionPrefixLen = mentionPrefixLen; + if (Object.keys(partial).length === 0) { + fail('Nothing to set. Pass at least one of --interests, --trusted, --require-mention, --mention-prefix-len, or --reset.'); + } + + let updated: PolicyConfig; + try { + const merged = withRepo((repo) => ({ ...(repo.load() ?? {}), ...partial })); + updated = persistConfig(merged); + } catch (err) { + fail(describeError(err)); + } + console.log(chalk.green('\nPolicy updated.\n')); + printConfig(updated); +} + +function mutateList( + field: 'trustedFingerprints' | 'interests', + transform: (current: string[]) => string[], +): PolicyConfig { + const merged = withRepo((repo) => { + const cur = repo.load() ?? {}; + const list = cur[field] ?? []; + const next = transform(list); + return { ...cur, [field]: next }; + }); + return persistConfig(merged); +} + +export async function policyTrustAdd(fingerprint: string): Promise { + try { + mutateList('trustedFingerprints', (list) => Array.from(new Set([...list, fingerprint]))); + } catch (err) { + fail(describeError(err)); + } + console.log(chalk.green(`\nTrusted: ${fingerprint}\n`)); +} + +export async function policyTrustRemove(fingerprint: string): Promise { + const target = fingerprint.toLowerCase(); + try { + mutateList('trustedFingerprints', (list) => list.filter((fp) => fp !== target)); + } catch (err) { + fail(describeError(err)); + } + console.log(chalk.green(`\nUntrusted: ${fingerprint}\n`)); +} + +export async function policyInterestAdd(keyword: string): Promise { + try { + mutateList('interests', (list) => Array.from(new Set([...list, keyword]))); + } catch (err) { + fail(describeError(err)); + } + console.log(chalk.green(`\nInterest added: ${keyword}\n`)); +} + +export async function policyInterestRemove(keyword: string): Promise { + try { + mutateList('interests', (list) => list.filter((k) => k !== keyword)); + } catch (err) { + fail(describeError(err)); + } + console.log(chalk.green(`\nInterest removed: ${keyword}\n`)); +} + +// Re-exported for unit tests so they can verify validator messages +// without re-importing from the node package. +export { formatValidationErrors }; diff --git a/packages/core/src/events/inbound.ts b/packages/core/src/events/inbound.ts new file mode 100644 index 0000000..6b4148c --- /dev/null +++ b/packages/core/src/events/inbound.ts @@ -0,0 +1,24 @@ +export type InboundMessageKind = 'group' | 'dm'; + +// Local-only. Carries decrypted plaintext. Never serialized to public +// surfaces (census, heartbeat, logs). Consumers: owner's agent runtime. +export interface PrivateInboundMessageEvent { + kind: InboundMessageKind; + messageId: string; + groupId?: Uint8Array; + senderPublicKey: Uint8Array; + senderFingerprint: string; + plaintext: Uint8Array; + timestamp: number; + receivedAt: number; +} + +// Metadata-only. Safe for public logs, census, future heartbeat/dashboard. +// Never contains plaintext or ciphertext bytes. +export interface PublicActivityEvent { + kind: InboundMessageKind; + groupIdHex?: string; + senderFingerprint: string; + timestamp: number; + byteLength: number; +} diff --git a/packages/core/src/events/index.ts b/packages/core/src/events/index.ts new file mode 100644 index 0000000..dc58b68 --- /dev/null +++ b/packages/core/src/events/index.ts @@ -0,0 +1,5 @@ +export type { + InboundMessageKind, + PrivateInboundMessageEvent, + PublicActivityEvent, +} from './inbound.js'; diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index ce5854b..ecb8907 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -7,3 +7,6 @@ export { } from './identity.js'; export * from './crypto/index.js'; export * from './protocol/index.js'; + +export * from './events/index.js'; +export * from './policy/index.js'; 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 new file mode 100644 index 0000000..9d823da --- /dev/null +++ b/packages/core/src/policy/index.ts @@ -0,0 +1,11 @@ +export type { + PolicyAction, + PolicyReason, + PolicyDecisionReason, + PolicyFailClosedReason, + PolicyDecision, + 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/core/src/policy/types.ts b/packages/core/src/policy/types.ts new file mode 100644 index 0000000..1813db1 --- /dev/null +++ b/packages/core/src/policy/types.ts @@ -0,0 +1,65 @@ +export type PolicyAction = 'act' | 'ask' | 'ignore'; + +// Fixed, metadata-safe reason tokens. Never include plaintext content. +// +// "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' + | 'addressed-matches-interest' + | 'trusted-interest-hit' + | '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; + action: PolicyAction; + reason: PolicyReason; + matchedInterests: string[]; + addressedToMe: boolean; + senderTrusted: boolean; +} + +export interface PolicyConfig { + // Peer fingerprints considered trusted (full fingerprint string as emitted + // by `fingerprintFromPublicKey`). + trustedFingerprints?: string[]; + // Interest keywords — cheap case-insensitive substring match over the + // UTF-8 decode of the inbound plaintext. Empty/undefined = no interests. + interests?: string[]; + // If true (default), "addressed to me" requires an explicit + // `@` or `@` mention. If false, every + // group message we receive counts as addressed. + requireMention?: boolean; + // Characters of the leading fingerprint that count as a valid mention + // (e.g. 8 → `@alice7x2a`). Default: 8. + mentionPrefixLen?: number; +} diff --git a/packages/mcp/README.md b/packages/mcp/README.md index 98e13fb..d7b98e1 100644 --- a/packages/mcp/README.md +++ b/packages/mcp/README.md @@ -58,7 +58,7 @@ Restart Claude Code. The `networkselfmd` server will now be available. ## Tools -This server exposes 20 tools across 5 categories. State IDs and public keys use hexadecimal strings, including identity tool and resource responses. +This server exposes 28 tools across 6 categories. State IDs and public keys use hexadecimal strings, including identity tool and resource responses. ### Identity (2 tools) @@ -84,7 +84,7 @@ Manage encrypted group membership. | `state_join` | `stateId` | Accept a group invitation | | `state_leave` | `stateId` | Leave a group | -### Messaging (4 tools) +### Messaging (5 tools) Outbound messages use a local persistent queue. Acceptance returns a message ID, not proof of delivery. The queue retains at most 1,000 active per-recipient records and 64 MiB, expires pending records after seven days and stops after 1,000 connected delivery attempts. Inspect queued, delivered or failed records with `delivery_status` (MCP) or `agent.listDeliveries(messageId?)` (SDK). Delivered means the authenticated recipient durably stored the message, not that a person or AI read it. Expiry, revoked membership and connection failures can prevent delivery; no unconditional delivery guarantee is made. @@ -254,3 +254,9 @@ Identity keys are encrypted on disk when a passphrase is configured. Without one ## License MIT + +## Inbound policy controls + +Eight additional owner-local tools are available: `get_pending_inbound_events`, `get_policy_audit_recent`, `get_policy_config`, `set_policy_config`, `add_policy_trusted_fingerprint`, `remove_policy_trusted_fingerprint`, `add_policy_interest`, and `remove_policy_interest`. + +Pending-event responses contain private decrypted message content and drain a bounded process-local queue. They must not be exposed to public observers. Audit responses contain only metadata and survive restart. Both DM and group reception use the policy gate; `act`/`ask` decisions do not execute tools or implement an approval UI. See `docs/POLICY.md` in the repository for migration and runtime semantics. diff --git a/packages/mcp/src/__tests__/messaging.test.ts b/packages/mcp/src/__tests__/messaging.test.ts index 2e3ca9d..2cdb2d8 100644 --- a/packages/mcp/src/__tests__/messaging.test.ts +++ b/packages/mcp/src/__tests__/messaging.test.ts @@ -3,6 +3,7 @@ import { Client } from '@modelcontextprotocol/sdk/client/index.js'; import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js'; import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; import type { Agent } from '@networkselfmd/node'; +import { InboundEventQueue } from '@networkselfmd/node'; import { createServer } from '../server.js'; const stateId = 'ab'.repeat(32); @@ -18,6 +19,7 @@ describe('MCP message reads over the protocol', () => { getMessages.mockClear(); agent = { getMessages, + inboundQueue: new InboundEventQueue(), createGroup: vi.fn(async () => ({ groupId: Buffer.from(stateId, 'hex') })), updateGroupManifest: vi.fn(), listGroupInvitations: vi.fn(() => [{ @@ -56,11 +58,29 @@ describe('MCP message reads over the protocol', () => { it('advertises only implemented tools and does not expose deferred TTYA tools', async () => { const { tools } = await client.listTools(); - expect(tools).toHaveLength(20); + expect(tools).toHaveLength(28); expect(tools.some((tool) => tool.name.startsWith('ttya_'))).toBe(false); expect(tools.map((tool) => tool.name)).toContain('send_direct_message'); }); + it('validates bounded private event drains before consuming the queue', async () => { + agent.inboundQueue.push({ kind: 'dm', messageId: 'private-message', + senderPublicKey: Buffer.from(peerPublicKey, 'hex'), senderFingerprint: 'peer', + plaintext: new TextEncoder().encode('owner-only content'), timestamp: 1, receivedAt: 2 }); + for (const limit of [0, -1, 1001, 1.5]) { + const result = await client.callTool({ name: 'get_pending_inbound_events', arguments: { limit } }); + expect(result.isError).toBe(true); + expect(agent.inboundQueue.size()).toBe(1); + } + const result = await client.callTool({ name: 'get_pending_inbound_events', arguments: { limit: 1 } }); + const content = result.content as Array<{ type: string; text: string }>; + expect(JSON.parse(content[0].text).events[0]).toMatchObject({ + messageId: 'private-message', senderPublicKeyHex: peerPublicKey, plaintextUtf8: 'owner-only content', + plaintextBase64: Buffer.from('owner-only content').toString('base64'), + }); + expect(agent.inboundQueue.size()).toBe(0); + }); + it('exposes actionable incoming invitations with hexadecimal state IDs', async () => { const result = await client.callTool({ name: 'state_invites', arguments: {} }); const content = result.content as Array<{ type: string; text: string }>; diff --git a/packages/mcp/src/__tests__/policy-audit-cross-restart.test.ts b/packages/mcp/src/__tests__/policy-audit-cross-restart.test.ts new file mode 100644 index 0000000..6d9becf --- /dev/null +++ b/packages/mcp/src/__tests__/policy-audit-cross-restart.test.ts @@ -0,0 +1,162 @@ +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 { PolicyAuditEntry, PrivateInboundMessageEvent } from '@networkselfmd/node'; + +vi.mock('hyperswarm', () => ({ + default: class { + on() {} + join() { return { flushed: () => Promise.resolve() }; } + leave() { return Promise.resolve(); } + destroy() { return Promise.resolve(); } + }, +})); +vi.mock('hyperdht', () => ({ default: class {} })); + +import { Agent } from '@networkselfmd/node'; +import { toPolicyAuditDTO } from '../tools/policy.js'; + +// Adversarial test: a polluted PolicyAuditEntry survives an Agent +// restart on the same dataDir, and the MCP DTO projection STILL drops +// the canary fields. Exercises the full stack: gate → in-memory log → +// persist callback → SQLite policy_audit → reload in a fresh Agent → +// repo.recent() → toPolicyAuditDTO → JSON.stringify. +// +// This is the cross-restart privacy guarantee that PR #6 promises: +// operators can read recent decisions across restart, but never +// content. + +let dataDir: string; + +beforeEach(() => { + dataDir = mkdtempSync(join(tmpdir(), 'mcp-cross-restart-')); +}); + +afterEach(() => { + rmSync(dataDir, { recursive: true, force: true }); +}); + +function buildEvent(pub: Uint8Array, fp: string, gid: Uint8Array, plaintext: string, mid: string): PrivateInboundMessageEvent { + return { + kind: 'group', + messageId: mid, + groupId: gid, + senderPublicKey: pub, + senderFingerprint: fp, + plaintext: new TextEncoder().encode(plaintext), + timestamp: 1, + receivedAt: 2, + }; +} + +describe('Durable audit cross-restart MCP DTO projection', () => { + it('plaintext canary written via gate is invisible through MCP DTO after restart', { timeout: 20000 }, async () => { + const canary = 'XRESTART-MCP-PLAINTEXT-CANARY-7q'; + + // ---- run 1: persist a real event with a plaintext canary ---- + const a1 = new Agent({ + dataDir, + displayName: 'Alice', + policyConfig: { requireMention: false, mentionPrefixLen: 8 }, + }); + await a1.start(); + + // Seed a group + a member (Bob) so the gate accepts the event. + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const groupRepo = (a1 as any).groupRepo; + const groupId = new Uint8Array(32).fill(0xab); + groupRepo.create(groupId, 'xrestart', 'admin'); + groupRepo.addMember(groupId, a1.identity.edPublicKey, 'admin'); + + // Build a fake "Bob" identity by reading from the agent's own + // fingerprint-from-key helper (already exercised in earlier tests). + const bobPub = new Uint8Array(32).fill(0x42); + const bobFp = 'b'.repeat(20); + groupRepo.addMember(groupId, bobPub, 'member'); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (a1 as any).prepareInbound( + buildEvent(bobPub, bobFp, groupId, `head ${canary} tail`, 'm-canary'), + )(); + expect(a1.policyAuditRepo.count()).toBe(1); + await a1.stop(); + + // ---- run 2: fresh Agent on the same dataDir ---- + const a2 = new Agent({ + dataDir, + displayName: 'Alice', + policyConfig: { requireMention: false, mentionPrefixLen: 8 }, + }); + await a2.start(); + + // Read through the durable repo, then project through the MCP DTO + // exactly as the MCP tool does, and serialize. + const dtos = a2.policyAuditRepo.recent({ limit: 10 }).map(toPolicyAuditDTO); + expect(dtos).toHaveLength(1); + expect(dtos[0].messageId).toBe('m-canary'); + expect(dtos[0].byteLength).toBe(`head ${canary} tail`.length); + + const serialized = JSON.stringify({ entries: dtos }); + expect(serialized).not.toContain(canary); + expect(serialized).not.toContain('head '); + expect(serialized).not.toContain(' tail'); + + // Pollution canaries on a PolicyAuditEntry mocked at the DTO layer + // are also dropped — we re-verify by reading the raw row, then + // round-tripping it as if it had attacker fields. + const entries = a2.policyAuditRepo.recent({ limit: 1 }); + const pollutedEntry = { + ...entries[0], + // eslint-disable-next-line @typescript-eslint/no-explicit-any + plaintext: 'POLLUTED-PLAINTEXT' as any, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + privateKey: 'POLLUTED-KEY' as any, + } as PolicyAuditEntry; + const pollutedDto = toPolicyAuditDTO(pollutedEntry); + expect(JSON.stringify(pollutedDto)).not.toContain('POLLUTED-PLAINTEXT'); + expect(JSON.stringify(pollutedDto)).not.toContain('POLLUTED-KEY'); + + await a2.stop(); + }); + + it('polluted entry returns from repo without the pollution fields', { timeout: 20000 }, async () => { + const a = new Agent({ + dataDir, + displayName: 'Alice', + policyConfig: { requireMention: false }, + }); + await a.start(); + + // Insert a polluted entry directly via the repo. This skips the + // gate; the assertion is that the repo's projection plus + // toPolicyAuditDTO still drop the bad fields. + a.policyAuditRepo.insert({ + auditId: 'pollute-1', + receivedAt: 1, + eventKind: 'group', + messageId: 'mp', + groupIdHex: 'aa', + senderFingerprint: 'fp', + byteLength: 8, + action: 'ask', + reason: 'addressed-unknown-sender', + addressedToMe: true, + senderTrusted: false, + matchedInterests: [], + gateRejected: false, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + plaintext: 'INSERT-POLLUTE-PLAINTEXT' as any, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + decryptedBody: 'INSERT-POLLUTE-BODY' as any, + } as PolicyAuditEntry); + + const dtos = a.policyAuditRepo.recent({ limit: 5 }).map(toPolicyAuditDTO); + const json = JSON.stringify(dtos); + expect(json).not.toContain('INSERT-POLLUTE-PLAINTEXT'); + expect(json).not.toContain('INSERT-POLLUTE-BODY'); + expect(dtos[0]).not.toHaveProperty('plaintext'); + expect(dtos[0]).not.toHaveProperty('decryptedBody'); + await a.stop(); + }); +}); 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..739c697 --- /dev/null +++ b/packages/mcp/src/__tests__/policy-audit-dto.test.ts @@ -0,0 +1,118 @@ +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('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); + 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/__tests__/policy-config-dto.test.ts b/packages/mcp/src/__tests__/policy-config-dto.test.ts new file mode 100644 index 0000000..214ccd7 --- /dev/null +++ b/packages/mcp/src/__tests__/policy-config-dto.test.ts @@ -0,0 +1,68 @@ +import { describe, it, expect } from 'vitest'; +import type { PolicyConfig } from '@networkselfmd/node'; +import { toPolicyConfigDTO } from '../tools/policy.js'; + +describe('toPolicyConfigDTO — explicit projection', () => { + it('preserves all four configurable fields verbatim', () => { + const c: PolicyConfig = { + trustedFingerprints: ['fp1abc', 'fp2def'], + interests: ['coffee'], + requireMention: true, + mentionPrefixLen: 8, + }; + expect(toPolicyConfigDTO(c)).toEqual(c); + }); + + it('omits unset (undefined) fields rather than emitting key:undefined', () => { + const dto = toPolicyConfigDTO({ interests: ['only'] }); + const json = JSON.stringify(dto); + expect(json).not.toContain('"trustedFingerprints":'); + expect(json).not.toContain('"requireMention":'); + expect(json).not.toContain('"mentionPrefixLen":'); + expect(json).toContain('"interests":'); + }); + + it('drops attacker-injected extra fields (defence-in-depth)', () => { + const polluted = { + interests: ['x'], + // eslint-disable-next-line @typescript-eslint/no-explicit-any + plaintext: 'LEAK-CFG-PLAINTEXT', + // eslint-disable-next-line @typescript-eslint/no-explicit-any + privateKey: 'LEAK-CFG-KEY', + // eslint-disable-next-line @typescript-eslint/no-explicit-any + messageContent: 'LEAK-CFG-CONTENT', + } as unknown as PolicyConfig; + const dto = toPolicyConfigDTO(polluted); + const json = JSON.stringify(dto); + expect(json).not.toContain('LEAK-CFG-PLAINTEXT'); + expect(json).not.toContain('LEAK-CFG-KEY'); + expect(json).not.toContain('LEAK-CFG-CONTENT'); + expect(dto).not.toHaveProperty('plaintext'); + expect(dto).not.toHaveProperty('privateKey'); + expect(dto).not.toHaveProperty('messageContent'); + }); + + it('returned arrays are independent of the input', () => { + const c: PolicyConfig = { interests: ['a'], trustedFingerprints: ['fp1abc'] }; + const dto = toPolicyConfigDTO(c); + dto.interests?.push('mutated'); + dto.trustedFingerprints?.push('hijack'); + expect(c.interests).toEqual(['a']); + expect(c.trustedFingerprints).toEqual(['fp1abc']); + }); + + it('locked DTO key set — adding a field requires a deliberate change here', () => { + const dto = toPolicyConfigDTO({ + trustedFingerprints: [], + interests: [], + requireMention: false, + mentionPrefixLen: 8, + }); + expect(Object.keys(dto).sort()).toEqual([ + 'interests', + 'mentionPrefixLen', + 'requireMention', + 'trustedFingerprints', + ]); + }); +}); diff --git a/packages/mcp/src/server.ts b/packages/mcp/src/server.ts index ec720af..8b4fe65 100644 --- a/packages/mcp/src/server.ts +++ b/packages/mcp/src/server.ts @@ -1,3 +1,4 @@ +import { registerPolicyTools } from './tools/policy.js'; import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; import type { Agent } from '@networkselfmd/node'; import { registerIdentityTools } from './tools/identity.js'; @@ -18,6 +19,7 @@ export function createServer(agent: Agent): McpServer { registerMessagingTools(server, agent); registerPeerTools(server, agent); registerDiscoveryTools(server, agent); + registerPolicyTools(server, agent); registerResources(server, agent); return server; diff --git a/packages/mcp/src/tools/messaging.ts b/packages/mcp/src/tools/messaging.ts index d05e08f..1c49fd1 100644 --- a/packages/mcp/src/tools/messaging.ts +++ b/packages/mcp/src/tools/messaging.ts @@ -1,7 +1,41 @@ +import type { PrivateInboundMessageEvent } from '@networkselfmd/node'; import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; import { z } from 'zod'; import type { Agent } from '@networkselfmd/node'; +export interface InboundEventDTO { + kind: 'group' | 'dm'; + messageId: string; + groupIdHex?: string; + senderPublicKeyHex: string; + senderFingerprint: string; + plaintextUtf8?: string; + plaintextBase64: string; + timestamp: number; + receivedAt: number; +} + +export function toInboundEventDTO(ev: PrivateInboundMessageEvent): InboundEventDTO { + const dto: InboundEventDTO = { + kind: ev.kind, + messageId: ev.messageId, + groupIdHex: ev.groupId ? Buffer.from(ev.groupId).toString('hex') : undefined, + senderPublicKeyHex: Buffer.from(ev.senderPublicKey).toString('hex'), + senderFingerprint: ev.senderFingerprint, + plaintextBase64: Buffer.from(ev.plaintext).toString('base64'), + timestamp: ev.timestamp, + receivedAt: ev.receivedAt, + }; + // Strict UTF-8 decode. If plaintext isn't valid UTF-8, omit the field — + // consumers fall back to plaintextBase64. + try { + dto.plaintextUtf8 = new TextDecoder('utf-8', { fatal: true }).decode(ev.plaintext); + } catch { + // non-UTF-8 payload — plaintextUtf8 stays undefined + } + return dto; +} + export function registerMessagingTools(server: McpServer, agent: Agent): void { server.tool( 'send_state_message', @@ -50,6 +84,23 @@ Offline peers are retried for up to seven days. Get the public key from peer_lis }), ); + server.tool( + 'get_pending_inbound_events', + 'Owner-private, local-only. Drains pending inbound (authenticated, decrypted) message events for the owner\'s agent runtime so it can decide act | ask | ignore. Results may contain plaintext — do NOT forward them to public dashboards, census, heartbeat, shared logs, or any non-owner surface.', + { + limit: z.number().int().min(1).max(1000).optional().describe('Maximum number of events to drain (default 50)'), + }, + async ({ limit }) => { + const events = agent.inboundQueue.drain(limit ?? 50); + return { + content: [{ + type: 'text' as const, + text: JSON.stringify({ events: events.map(toInboundEventDTO) }), + }], + }; + }, + ); + server.tool( 'read_messages', `Read recent messages. Provide EITHER stateId (for state messages) or peerPublicKey (for direct messages). diff --git a/packages/mcp/src/tools/policy.ts b/packages/mcp/src/tools/policy.ts new file mode 100644 index 0000000..1d8a8be --- /dev/null +++ b/packages/mcp/src/tools/policy.ts @@ -0,0 +1,293 @@ +import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import { z } from 'zod'; +import type { Agent, PolicyAuditEntry, PolicyConfig } from '@networkselfmd/node'; +import { + PolicyConfigValidationError, + POLICY_LIMITS, + POLICY_AUDIT_LIMITS, +} 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, + }; +} + +// MCP-facing config DTO. PolicyConfig is already metadata-only by +// design (operator-supplied trusted fingerprints, interest keywords, +// boolean flags, integer prefix length). The DTO mirrors it +// explicitly — like toPolicyAuditDTO above, this is a defence-in-depth +// projection so future PolicyConfig additions don't auto-propagate +// over MCP without a deliberate code change. +export interface PolicyConfigDTO { + trustedFingerprints?: string[]; + interests?: string[]; + requireMention?: boolean; + mentionPrefixLen?: number; +} + +export function toPolicyConfigDTO(c: PolicyConfig): PolicyConfigDTO { + const out: PolicyConfigDTO = {}; + if (c.trustedFingerprints !== undefined) { + out.trustedFingerprints = c.trustedFingerprints.slice(); + } + if (c.interests !== undefined) out.interests = c.interests.slice(); + if (c.requireMention !== undefined) out.requireMention = c.requireMention; + if (c.mentionPrefixLen !== undefined) out.mentionPrefixLen = c.mentionPrefixLen; + return out; +} + +function describeValidationError(err: unknown): string { + if (err instanceof PolicyConfigValidationError) { + return err.message; + } + if (err instanceof Error) return err.message; + return 'unknown error'; +} + +// Common operator-control disclaimer attached to every policy mutation +// tool description. Keeps the privacy/scope reminders consistent and +// in plain sight of MCP clients. +const OPERATOR_DISCLAIMER = + ' Owner-private, local-only operator control. No tool execution, no plaintext storage, no payments. Do NOT forward results to public dashboards, census, or shared logs.'; + +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 across restarts. Reads from the durable policy_audit table (PR #6); 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() + .max(POLICY_AUDIT_LIMITS.maxRecentLimit) + .optional() + .describe( + `Maximum number of audit entries to return (default 50, newest first; capped at ${POLICY_AUDIT_LIMITS.maxRecentLimit})`, + ), + }, + async ({ limit }) => { + const cap = Math.min(limit ?? 50, POLICY_AUDIT_LIMITS.maxRecentLimit); + // Prefer the durable repo (cross-restart visibility). Fall back + // to the in-memory log only for Agent instances constructed + // without start() — primarily a test ergonomics concern. The + // privacy invariant holds either way: both surfaces project + // through toPolicyAuditDTO. + const sourceEntries = agent.policyAuditRepo + ? agent.policyAuditRepo.recent({ limit: cap }) + : agent.policyAudit.recent(cap); + const entries = sourceEntries.map(toPolicyAuditDTO); + return { + content: [{ + type: 'text' as const, + text: JSON.stringify({ entries }), + }], + }; + }, + ); + + server.tool( + 'get_policy_config', + 'Read the current policy gate configuration (trusted fingerprints, interest keywords, mention behavior).' + OPERATOR_DISCLAIMER, + {}, + async () => ({ + content: [{ + type: 'text' as const, + text: JSON.stringify({ config: toPolicyConfigDTO(agent.getPolicyConfig()) }), + }], + }), + ); + + // Bounded zod schemas mirror the runtime validator in the node + // package. Defence-in-depth — even if a buggy MCP client gets past + // the zod layer, validatePolicyConfig at the agent boundary will + // catch it. + const fingerprintList = z + .array(z.string().min(POLICY_LIMITS.minFingerprintLength).max(POLICY_LIMITS.maxFingerprintLength)) + .max(POLICY_LIMITS.maxTrustedFingerprints); + const interestList = z + .array(z.string().min(1).max(POLICY_LIMITS.maxInterestLength)) + .max(POLICY_LIMITS.maxInterests); + + server.tool( + 'set_policy_config', + 'Replace the policy gate configuration. Validates input; on bad shape returns an error and does NOT modify state.' + OPERATOR_DISCLAIMER, + { + trustedFingerprints: fingerprintList.optional(), + interests: interestList.optional(), + requireMention: z.boolean().optional(), + mentionPrefixLen: z + .number() + .int() + .min(POLICY_LIMITS.minMentionPrefixLen) + .max(POLICY_LIMITS.maxMentionPrefixLen) + .optional(), + }, + async (args) => { + try { + agent.setPolicyConfig(args); + } catch (err) { + return { + content: [{ + type: 'text' as const, + text: JSON.stringify({ ok: false, error: describeValidationError(err) }), + }], + isError: true, + }; + } + return { + content: [{ + type: 'text' as const, + text: JSON.stringify({ ok: true, config: toPolicyConfigDTO(agent.getPolicyConfig()) }), + }], + }; + }, + ); + + // Convenience: add a single trusted fingerprint without re-supplying + // the rest of the config. Idempotent (no error if already present). + server.tool( + 'add_policy_trusted_fingerprint', + 'Add a peer fingerprint to the trusted list. Idempotent.' + OPERATOR_DISCLAIMER, + { + fingerprint: z + .string() + .min(POLICY_LIMITS.minFingerprintLength) + .max(POLICY_LIMITS.maxFingerprintLength), + }, + async ({ fingerprint }) => { + try { + const cur = agent.getPolicyConfig(); + const set = new Set(cur.trustedFingerprints ?? []); + set.add(fingerprint); + agent.updatePolicyConfig({ trustedFingerprints: Array.from(set) }); + } catch (err) { + return { + content: [{ type: 'text' as const, text: JSON.stringify({ ok: false, error: describeValidationError(err) }) }], + isError: true, + }; + } + return { + content: [{ + type: 'text' as const, + text: JSON.stringify({ ok: true, trustedFingerprints: agent.getPolicyConfig().trustedFingerprints ?? [] }), + }], + }; + }, + ); + + server.tool( + 'remove_policy_trusted_fingerprint', + 'Remove a peer fingerprint from the trusted list. Idempotent.' + OPERATOR_DISCLAIMER, + { + fingerprint: z + .string() + .min(POLICY_LIMITS.minFingerprintLength) + .max(POLICY_LIMITS.maxFingerprintLength), + }, + async ({ fingerprint }) => { + try { + const cur = agent.getPolicyConfig(); + const next = (cur.trustedFingerprints ?? []).filter((fp) => fp !== fingerprint.toLowerCase()); + agent.updatePolicyConfig({ trustedFingerprints: next }); + } catch (err) { + return { + content: [{ type: 'text' as const, text: JSON.stringify({ ok: false, error: describeValidationError(err) }) }], + isError: true, + }; + } + return { + content: [{ + type: 'text' as const, + text: JSON.stringify({ ok: true, trustedFingerprints: agent.getPolicyConfig().trustedFingerprints ?? [] }), + }], + }; + }, + ); + + server.tool( + 'add_policy_interest', + 'Add an interest keyword to the policy. Idempotent.' + OPERATOR_DISCLAIMER, + { + keyword: z.string().min(1).max(POLICY_LIMITS.maxInterestLength), + }, + async ({ keyword }) => { + try { + const cur = agent.getPolicyConfig(); + const set = new Set(cur.interests ?? []); + set.add(keyword); + agent.updatePolicyConfig({ interests: Array.from(set) }); + } catch (err) { + return { + content: [{ type: 'text' as const, text: JSON.stringify({ ok: false, error: describeValidationError(err) }) }], + isError: true, + }; + } + return { + content: [{ + type: 'text' as const, + text: JSON.stringify({ ok: true, interests: agent.getPolicyConfig().interests ?? [] }), + }], + }; + }, + ); + + server.tool( + 'remove_policy_interest', + 'Remove an interest keyword from the policy. Idempotent.' + OPERATOR_DISCLAIMER, + { + keyword: z.string().min(1).max(POLICY_LIMITS.maxInterestLength), + }, + async ({ keyword }) => { + try { + const cur = agent.getPolicyConfig(); + const next = (cur.interests ?? []).filter((k) => k !== keyword); + agent.updatePolicyConfig({ interests: next }); + } catch (err) { + return { + content: [{ type: 'text' as const, text: JSON.stringify({ ok: false, error: describeValidationError(err) }) }], + isError: true, + }; + } + return { + content: [{ + type: 'text' as const, + text: JSON.stringify({ ok: true, interests: agent.getPolicyConfig().interests ?? [] }), + }], + }; + }, + ); +} diff --git a/packages/node/README.md b/packages/node/README.md index 1fa198d..c47ef02 100644 --- a/packages/node/README.md +++ b/packages/node/README.md @@ -377,3 +377,9 @@ pnpm test ## License MIT + +## Inbound policy + +Authenticated DM and group reception evaluates an owner-local policy before dispatching `inbound:message` or adding to `agent.inboundQueue`. Configure with `AgentOptions.policyConfig` or the validated `getPolicyConfig`, `setPolicyConfig`, `updatePolicyConfig`, and `resetPolicyConfig` methods. An authenticated DM is addressed to this agent; group messages use mentions or `requireMention: false`. Decisions are `act`, `ask`, or `ignore`, with no automatic tool execution. + +`agent.policyAuditRepo.recent()` reads metadata-only decisions across restart. Audit and message storage commit together; an audit failure rolls back reception so reliable delivery can retry. The private inbound queue is process-local and contains decrypted content. Never attach it to a public feed. See `docs/POLICY.md` in the repository for schema-10 migration, retention and owner-access details. diff --git a/packages/node/src/__tests__/agent-policy.test.ts b/packages/node/src/__tests__/agent-policy.test.ts new file mode 100644 index 0000000..566ea79 --- /dev/null +++ b/packages/node/src/__tests__/agent-policy.test.ts @@ -0,0 +1,307 @@ +import { describe, it, expect } from 'vitest'; +import type { + AgentIdentity, + PolicyConfig, + PolicyDecision, + PrivateInboundMessageEvent, +} from '@networkselfmd/core'; +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'; + +// Minimal Agent stand-in: only the fields AgentPolicy touches. +function makeFakeAgent(identity: AgentIdentity): Agent { + const inboundQueue = new InboundEventQueue(); + return { identity, inboundQueue } as unknown as Agent; +} + +function makeEvent(params: { + plaintext: string | Uint8Array; + senderFingerprint: string; + senderPublicKey?: Uint8Array; + messageId?: string; + kind?: 'group' | 'dm'; + groupId?: Uint8Array; +}): PrivateInboundMessageEvent { + const plaintext = + typeof params.plaintext === 'string' + ? new TextEncoder().encode(params.plaintext) + : params.plaintext; + return { + kind: params.kind ?? 'group', + messageId: params.messageId ?? 'm1', + groupId: params.groupId ?? new Uint8Array([0xaa]), + senderPublicKey: params.senderPublicKey ?? new Uint8Array(32), + senderFingerprint: params.senderFingerprint, + plaintext, + timestamp: 1, + receivedAt: 2, + }; +} + +describe('AgentPolicy.decide — pure decision logic', () => { + let alice: AgentIdentity; + let bob: AgentIdentity; + + it('unaddressed + untrusted + no interest hit → ignore / not-addressed', () => { + alice = makeIdentity('Alice'); + bob = makeIdentity('Bob'); + const policy = new AgentPolicy({ + agent: makeFakeAgent(alice), + config: {}, + }); + const d = policy.decide( + makeEvent({ plaintext: 'random chatter', senderFingerprint: bob.fingerprint }), + ); + expect(d.action).toBe('ignore'); + expect(d.reason).toBe('not-addressed'); + expect(d.addressedToMe).toBe(false); + expect(d.senderTrusted).toBe(false); + expect(d.matchedInterests).toEqual([]); + }); + + it('addressed by fingerprint prefix, untrusted, no interest → ask / addressed-unknown-sender', () => { + alice = makeIdentity('Alice'); + bob = makeIdentity('Bob'); + const policy = new AgentPolicy({ + agent: makeFakeAgent(alice), + config: { mentionPrefixLen: 8 }, + }); + const prefix = alice.fingerprint.slice(0, 8); + const d = policy.decide( + makeEvent({ + plaintext: `hey @${prefix} check this`, + senderFingerprint: bob.fingerprint, + }), + ); + expect(d.addressedToMe).toBe(true); + expect(d.action).toBe('ask'); + expect(d.reason).toBe('addressed-unknown-sender'); + }); + + it('addressed by displayName → addressedToMe=true', () => { + alice = makeIdentity('alice7'); + bob = makeIdentity('Bob'); + const policy = new AgentPolicy({ + agent: makeFakeAgent(alice), + config: {}, + }); + const d = policy.decide( + makeEvent({ plaintext: 'hi @alice7 here', senderFingerprint: bob.fingerprint }), + ); + expect(d.addressedToMe).toBe(true); + }); + + it('addressed + trusted → act / addressed-and-trusted', () => { + alice = makeIdentity('Alice'); + bob = makeIdentity('Bob'); + const policy = new AgentPolicy({ + agent: makeFakeAgent(alice), + config: { + trustedFingerprints: [bob.fingerprint], + mentionPrefixLen: 8, + }, + }); + const prefix = alice.fingerprint.slice(0, 8); + const d = policy.decide( + makeEvent({ plaintext: `@${prefix} ping`, senderFingerprint: bob.fingerprint }), + ); + expect(d.action).toBe('act'); + expect(d.reason).toBe('addressed-and-trusted'); + expect(d.addressedToMe).toBe(true); + expect(d.senderTrusted).toBe(true); + }); + + it('trusted but unaddressed, no interest → ignore / trusted-no-signal', () => { + alice = makeIdentity('Alice'); + bob = makeIdentity('Bob'); + const policy = new AgentPolicy({ + agent: makeFakeAgent(alice), + config: { trustedFingerprints: [bob.fingerprint] }, + }); + const d = policy.decide( + makeEvent({ plaintext: 'just chatter', senderFingerprint: bob.fingerprint }), + ); + expect(d.action).toBe('ignore'); + expect(d.reason).toBe('trusted-no-signal'); + expect(d.senderTrusted).toBe(true); + }); + + it('interest keyword hit (untrusted, unaddressed) → ask / interest-hit', () => { + alice = makeIdentity('Alice'); + bob = makeIdentity('Bob'); + const policy = new AgentPolicy({ + agent: makeFakeAgent(alice), + config: { interests: ['coffee', 'meeting'] }, + }); + const d = policy.decide( + makeEvent({ + plaintext: 'want to grab COFFEE later?', + senderFingerprint: bob.fingerprint, + }), + ); + expect(d.action).toBe('ask'); + expect(d.reason).toBe('interest-hit'); + expect(d.matchedInterests).toEqual(['coffee']); + }); + + it('invalid UTF-8 plaintext → addressedToMe=false, matchedInterests=[], ignore', () => { + alice = makeIdentity('Alice'); + bob = makeIdentity('Bob'); + const policy = new AgentPolicy({ + agent: makeFakeAgent(alice), + config: { interests: ['coffee'] }, + }); + const d = policy.decide( + makeEvent({ + plaintext: new Uint8Array([0xc3, 0x28, 0xff]), // invalid UTF-8 + senderFingerprint: bob.fingerprint, + }), + ); + expect(d.addressedToMe).toBe(false); + expect(d.matchedInterests).toEqual([]); + expect(d.action).toBe('ignore'); + expect(d.reason).toBe('not-addressed'); + }); + + it('mention is token-bounded — trailing identifier chars do not match', () => { + alice = makeIdentity('Alice'); + bob = makeIdentity('Bob'); + const policy = new AgentPolicy({ + agent: makeFakeAgent(alice), + config: { mentionPrefixLen: 8 }, + }); + const prefix = alice.fingerprint.slice(0, 8); + // Valid mention at end of string. + const d1 = policy.decide( + makeEvent({ plaintext: `hi @${prefix}`, senderFingerprint: bob.fingerprint }), + ); + expect(d1.addressedToMe).toBe(true); + + // Valid mention followed by punctuation/whitespace. + const d2 = policy.decide( + makeEvent({ plaintext: `@${prefix}, yo`, senderFingerprint: bob.fingerprint }), + ); + expect(d2.addressedToMe).toBe(true); + + // Invalid: followed by more identifier chars — must NOT match. + const d3 = policy.decide( + makeEvent({ plaintext: `hello @${prefix}abc`, senderFingerprint: bob.fingerprint }), + ); + expect(d3.addressedToMe).toBe(false); + }); + + it('requireMention=false treats every group message as addressed', () => { + alice = makeIdentity('Alice'); + bob = makeIdentity('Bob'); + const policy = new AgentPolicy({ + agent: makeFakeAgent(alice), + config: { requireMention: false }, + }); + const d = policy.decide( + makeEvent({ + plaintext: 'no mention at all', + senderFingerprint: bob.fingerprint, + kind: 'group', + }), + ); + expect(d.addressedToMe).toBe(true); + expect(d.action).toBe('ask'); + expect(d.reason).toBe('addressed-unknown-sender'); + }); + + it('trusted sender with interest hit but unaddressed → ask / trusted-interest-hit', () => { + alice = makeIdentity('Alice'); + bob = makeIdentity('Bob'); + const policy = new AgentPolicy({ + agent: makeFakeAgent(alice), + config: { + interests: ['deploy'], + trustedFingerprints: [bob.fingerprint], + }, + }); + const d = policy.decide( + makeEvent({ plaintext: 'deploy in 5m', senderFingerprint: bob.fingerprint }), + ); + expect(d.action).toBe('ask'); + expect(d.reason).toBe('trusted-interest-hit'); + expect(d.matchedInterests).toEqual(['deploy']); + }); + + it('addressed + interest hit, untrusted → ask / addressed-matches-interest', () => { + alice = makeIdentity('Alice'); + bob = makeIdentity('Bob'); + const policy = new AgentPolicy({ + agent: makeFakeAgent(alice), + config: { interests: ['ship'], mentionPrefixLen: 8 }, + }); + const prefix = alice.fingerprint.slice(0, 8); + const d = policy.decide( + makeEvent({ + plaintext: `@${prefix} let's ship today`, + senderFingerprint: bob.fingerprint, + }), + ); + expect(d.action).toBe('ask'); + expect(d.reason).toBe('addressed-matches-interest'); + }); +}); + +describe('AgentPolicy.start — queue subscription', () => { + it('fires decision events for each pushed inbound event', () => { + const alice = makeIdentity('Alice'); + const bob = makeIdentity('Bob'); + const agent = makeFakeAgent(alice); + const config: PolicyConfig = { mentionPrefixLen: 8 }; + const policy = new AgentPolicy({ agent, config }); + + const decisions: PolicyDecision[] = []; + policy.on('decision', (d: PolicyDecision) => decisions.push(d)); + policy.start(); + + const prefix = alice.fingerprint.slice(0, 8); + agent.inboundQueue.push( + makeEvent({ plaintext: `@${prefix} hi`, messageId: 'm1', senderFingerprint: bob.fingerprint }), + ); + agent.inboundQueue.push( + makeEvent({ plaintext: 'nothing', messageId: 'm2', senderFingerprint: bob.fingerprint }), + ); + + expect(decisions).toHaveLength(2); + expect(decisions[0].messageId).toBe('m1'); + expect(decisions[0].addressedToMe).toBe(true); + expect(decisions[1].messageId).toBe('m2'); + expect(decisions[1].addressedToMe).toBe(false); + + policy.stop(); + agent.inboundQueue.push( + makeEvent({ plaintext: 'after stop', messageId: 'm3', senderFingerprint: bob.fingerprint }), + ); + expect(decisions).toHaveLength(2); + }); +}); + +describe('PolicyDecision — no plaintext leakage', () => { + it('JSON.stringify of a decision never contains plaintext bytes', () => { + const alice = makeIdentity('Alice'); + const bob = makeIdentity('Bob'); + const policy = new AgentPolicy({ + agent: makeFakeAgent(alice), + config: { interests: ['coffee'], mentionPrefixLen: 8 }, + }); + const canary = 'policy-canary-plaintext-42'; + const prefix = alice.fingerprint.slice(0, 8); + const d = policy.decide( + makeEvent({ + plaintext: `@${prefix} talk about coffee ${canary}`, + senderFingerprint: bob.fingerprint, + }), + ); + const serialized = JSON.stringify(d); + expect(serialized).not.toContain(canary); + expect(serialized).not.toContain('talk about coffee'); + // matched interest keyword itself is config-supplied, not plaintext, so it may appear. + expect(serialized).toContain('coffee'); + }); +}); diff --git a/packages/node/src/__tests__/delivery.test.ts b/packages/node/src/__tests__/delivery.test.ts index 47bd763..d0aff58 100644 --- a/packages/node/src/__tests__/delivery.test.ts +++ b/packages/node/src/__tests__/delivery.test.ts @@ -273,3 +273,65 @@ describe('durable authenticated delivery', () => { expect(b.getMessages({ peerPublicKey: pk(a) })).toHaveLength(0); }); }); + + +describe('policy and reliable delivery commit together', () => { + for (const kind of ['dm', 'group'] as const) { + for (const fault of ['audit-write', 'after-prepare'] as const) { + it(`${kind}: rolls back ${fault} and retries without losing or duplicating the inbound event`, async () => { + const a = await start(); const b = await start(); + b.setPolicyConfig({ requireMention: false }); + const gid = kind === 'group' ? await group(a, b) : undefined; + if (!gid) connect(a, b); + const observed: unknown[] = []; + b.on('inbound:message', event => observed.push(event)); + if (fault === 'audit-write') { + const original = b.policyAuditRepo.insert.bind(b.policyAuditRepo); + vi.spyOn(b.policyAuditRepo, 'insert').mockImplementationOnce(entry => { + original(entry); + throw new Error('simulated audit write failure'); + }); + } else { + const original = internals(b).prepareInbound.bind(b); + vi.spyOn(internals(b), 'prepareInbound').mockImplementationOnce((event: unknown) => { + original(event); + throw new Error('simulated failure before commit'); + }); + } + const content = 'private policy transaction canary'; + const id = gid ? await a.sendGroupMessage(gid, content) : await a.sendDirectMessage(pk(b), content); + await internals(a).deliveryManager.flush(); await drain(); + const selector = gid ? { groupId: gid } : { peerPublicKey: pk(a) }; + expect(b.getMessages(selector)).toHaveLength(0); + expect(b.policyAuditRepo.count()).toBe(0); + expect(b.policyAudit.size()).toBe(0); + expect(b.policyGate.dedupCount()).toBe(0); + expect(b.inboundQueue.size()).toBe(0); + expect(observed).toHaveLength(0); + expect(a.listDeliveries(id)[0].status).toBe('queued'); + internals(a).deliveryRepo.reconnect(b.identity.edPublicKey); + await pump(a, b); + expect(a.listDeliveries(id)[0].status).toBe('delivered'); + expect(b.getMessages(selector).map(message => message.content)).toEqual([content]); + expect(b.policyAuditRepo.count()).toBe(1); + expect(b.policyAudit.size()).toBe(1); + expect(b.inboundQueue.size()).toBe(1); + expect(observed).toHaveLength(1); + expect(JSON.stringify(b.policyAuditRepo.recent())).not.toContain(content); + expect(b.inboundQueue.peek()[0].messageId).toBe(b.getMessages(selector)[0].id); + }); + } + } + + it('a broken policy observer cannot suppress later observers or delivery receipts', async () => { + const a = await start(); const b = await start(); connect(a, b); + const observed = vi.fn(); + b.on('policy:audit', () => { throw new Error('private observer error'); }); + b.on('policy:audit', observed); + const id = await a.sendDirectMessage(pk(b), 'observer isolation'); + await pump(a, b); + expect(a.listDeliveries(id)[0].status).toBe('delivered'); + expect(observed).toHaveBeenCalledTimes(1); + expect(b.inboundQueue.size()).toBe(1); + }); +}); diff --git a/packages/node/src/__tests__/direct-message-collision.test.ts b/packages/node/src/__tests__/direct-message-collision.test.ts index 8655bc9..17ccc9c 100644 --- a/packages/node/src/__tests__/direct-message-collision.test.ts +++ b/packages/node/src/__tests__/direct-message-collision.test.ts @@ -6,7 +6,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; import { DoubleRatchet, DELIVERY_TTL_MS, computeSharedSecret, encrypt, generateIdentity, signAuthenticatedMessage } from '@networkselfmd/core'; import type { AgentIdentity, DirectEncryptedMessage } from '@networkselfmd/core'; import { Agent } from '../agent.js'; -import { AgentDatabase, IdentityRepository, MessageRepository, ProtocolReplayRepository, RatchetStateRepository } from '../storage/index.js'; +import { AgentDatabase, PolicyConfigRepository, IdentityRepository, MessageRepository, ProtocolReplayRepository, RatchetStateRepository } from '../storage/index.js'; import { DM_BOOTSTRAP_WINDOW_MS } from '../network/direct-ratchet.js'; const directories: string[] = []; @@ -41,6 +41,8 @@ function runtime(identity: AgentIdentity, peer: AgentIdentity, existingDir?: str messageRepo: new MessageRepository(db), swarm: { getSession: () => ({ peerXPublicKey: peer.xPublicKey, send: (message: DirectEncryptedMessage) => outgoing.push(message) }) }, }); + Object.assign(agent, { policyConfigRepo: new PolicyConfigRepository(db) }); + (agent as any).initializePolicy(); agent.removeAllListeners('error'); agent.on('error', errors); const session = { state: 'ready', peerFingerprint: peer.fingerprint, peerPublicKey: peer.edPublicKey, peerXPublicKey: peer.xPublicKey }; diff --git a/packages/node/src/__tests__/fixtures/legacy-policy-schema.ts b/packages/node/src/__tests__/fixtures/legacy-policy-schema.ts new file mode 100644 index 0000000..ab21906 --- /dev/null +++ b/packages/node/src/__tests__/fixtures/legacy-policy-schema.ts @@ -0,0 +1,6 @@ +// Exact SQL migrations from the historical policy branch. +export const legacyPolicyMigrations: string[] = [ + "\n CREATE TABLE IF NOT EXISTS identity (\n id INTEGER PRIMARY KEY,\n ed_private_key BLOB NOT NULL,\n ed_public_key BLOB NOT NULL,\n display_name TEXT,\n created_at INTEGER NOT NULL\n );\n\n CREATE TABLE IF NOT EXISTS peers (\n public_key BLOB PRIMARY KEY,\n fingerprint TEXT NOT NULL,\n display_name TEXT,\n trusted INTEGER DEFAULT 0,\n last_seen INTEGER\n );\n\n CREATE TABLE IF NOT EXISTS groups (\n group_id BLOB PRIMARY KEY,\n name TEXT NOT NULL,\n role TEXT NOT NULL DEFAULT 'member',\n created_at INTEGER NOT NULL,\n joined_at INTEGER\n );\n\n CREATE TABLE IF NOT EXISTS group_members (\n group_id BLOB NOT NULL,\n public_key BLOB NOT NULL,\n role TEXT NOT NULL DEFAULT 'member',\n PRIMARY KEY (group_id, public_key)\n );\n\n CREATE TABLE IF NOT EXISTS messages (\n id TEXT PRIMARY KEY,\n group_id BLOB,\n sender_public_key BLOB,\n peer_public_key BLOB,\n content TEXT NOT NULL,\n timestamp INTEGER NOT NULL,\n type TEXT NOT NULL DEFAULT 'group'\n );\n\n CREATE TABLE IF NOT EXISTS sender_keys (\n group_id BLOB NOT NULL,\n public_key BLOB NOT NULL,\n chain_key BLOB NOT NULL,\n chain_index INTEGER NOT NULL DEFAULT 0,\n PRIMARY KEY (group_id, public_key)\n );\n\n CREATE TABLE IF NOT EXISTS key_storage (\n id INTEGER PRIMARY KEY,\n salt BLOB NOT NULL,\n nonce BLOB NOT NULL,\n ciphertext BLOB NOT NULL\n );\n\n CREATE TABLE IF NOT EXISTS schema_version (\n version INTEGER NOT NULL\n );\n\n INSERT INTO schema_version (version) VALUES (1);\n ", + "\n CREATE TABLE IF NOT EXISTS policy_config (\n id INTEGER PRIMARY KEY,\n trusted_fingerprints TEXT,\n interests TEXT,\n require_mention INTEGER,\n mention_prefix_len INTEGER,\n updated_at INTEGER NOT NULL\n );\n\n UPDATE schema_version SET version = 2;\n ", + "\n CREATE TABLE IF NOT EXISTS policy_audit (\n audit_id TEXT PRIMARY KEY,\n received_at INTEGER NOT NULL,\n inserted_at INTEGER NOT NULL,\n event_kind TEXT NOT NULL,\n message_id TEXT,\n group_id_hex TEXT,\n sender_fingerprint TEXT,\n byte_length INTEGER NOT NULL,\n action TEXT NOT NULL,\n reason TEXT NOT NULL,\n addressed_to_me INTEGER NOT NULL,\n sender_trusted INTEGER NOT NULL,\n matched_interests TEXT NOT NULL,\n gate_rejected INTEGER NOT NULL,\n allowed INTEGER NOT NULL\n );\n CREATE INDEX IF NOT EXISTS idx_policy_audit_inserted_at\n ON policy_audit(inserted_at DESC);\n\n UPDATE schema_version SET version = 3;\n " +]; diff --git a/packages/node/src/__tests__/migration-v4.test.ts b/packages/node/src/__tests__/migration-v4.test.ts index ebf32e8..1604538 100644 --- a/packages/node/src/__tests__/migration-v4.test.ts +++ b/packages/node/src/__tests__/migration-v4.test.ts @@ -72,7 +72,7 @@ describe('schema v4 migration fixture', () => { version: number; } ).version, - ).toBe(9); + ).toBe(10); const replayColumns = db .prepare('PRAGMA table_info(protocol_replay)') .all() as Array<{ name: string }>; @@ -159,7 +159,7 @@ describe('schema v5 migration variants', () => { const db = migrated.getDb(); expect( db.prepare('SELECT version FROM schema_version').get(), - ).toEqual({ version: 9 }); + ).toEqual({ version: 10 }); expect( db.prepare(` SELECT sender_fingerprint, message_type, state, received_at, expires_at diff --git a/packages/node/src/__tests__/policy-audit-persistence.test.ts b/packages/node/src/__tests__/policy-audit-persistence.test.ts new file mode 100644 index 0000000..c4e01ac --- /dev/null +++ b/packages/node/src/__tests__/policy-audit-persistence.test.ts @@ -0,0 +1,227 @@ +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 { PrivateInboundMessageEvent } from '@networkselfmd/core'; + +vi.mock('hyperswarm', () => ({ + default: class MockHyperswarm { + on() {} + join() { return { flushed: () => Promise.resolve() }; } + leave() { return Promise.resolve(); } + destroy() { return Promise.resolve(); } + }, +})); +vi.mock('hyperdht', () => ({ default: class MockHyperDHT {} })); + +import { Agent } from '../agent.js'; +import { makeIdentity } from './test-utils/group-harness.js'; + +interface Harness { + agent: Agent; + dataDir: string; + bobFp: string; + bobPub: Uint8Array; + groupId: Uint8Array; + cleanup: () => Promise; +} + +async function startAgent(opts?: { policyAuditDbMax?: number }): Promise { + const dataDir = mkdtempSync(join(tmpdir(), 'nsmd-audit-persist-')); + const agent = new Agent({ + dataDir, + displayName: 'Alice', + policyConfig: { requireMention: false, mentionPrefixLen: 8 }, + policyAuditDbMax: opts?.policyAuditDbMax, + }); + await agent.start(); + + const bob = makeIdentity('Bob'); + const groupId = new Uint8Array(32).fill(0xdc); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const groupRepo = (agent as any).groupRepo; + groupRepo.create(groupId, 'audit-test', 'admin'); + groupRepo.addMember(groupId, agent.identity.edPublicKey, 'admin'); + groupRepo.addMember(groupId, bob.edPublicKey, 'member'); + + return { + agent, + dataDir, + bobFp: bob.fingerprint, + bobPub: bob.edPublicKey, + groupId, + cleanup: async () => { + await agent.stop(); + rmSync(dataDir, { recursive: true, force: true }); + }, + }; +} + +function buildEvent(h: Harness, opts: { plaintext?: string; messageId?: string } = {}): PrivateInboundMessageEvent { + return { + kind: 'group', + messageId: opts.messageId ?? 'mid-' + Math.random().toString(36).slice(2), + groupId: h.groupId, + senderPublicKey: h.bobPub, + senderFingerprint: h.bobFp, + plaintext: new TextEncoder().encode(opts.plaintext ?? 'hello'), + timestamp: 1, + receivedAt: 2, + }; +} + +function inject(agent: Agent, ev: PrivateInboundMessageEvent | unknown): void { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (agent as any).prepareInbound(ev)(); +} + +describe('Persisted policy audit — Agent integration', () => { + let h: Harness; + beforeEach(async () => { h = await startAgent(); }); + afterEach(async () => h.cleanup()); + + it('an allowed event is persisted to policy_audit', () => { + const ev = buildEvent(h, { messageId: 'p1' }); + inject(h.agent, ev); + const rows = h.agent.policyAuditRepo.recent({ limit: 5 }); + expect(rows).toHaveLength(1); + expect(rows[0].messageId).toBe('p1'); + expect(rows[0].gateRejected).toBe(false); + expect(rows[0].action).toBe('ask'); + }); + + it('persists across Agent restart on the same dataDir', async () => { + inject(h.agent, buildEvent(h, { messageId: 'restart-1' })); + await h.agent.stop(); + + const a2 = new Agent({ + dataDir: h.dataDir, + displayName: 'Alice', + policyConfig: { requireMention: false, mentionPrefixLen: 8 }, + }); + await a2.start(); + const rows = a2.policyAuditRepo.recent({ limit: 5 }); + expect(rows.map((r) => r.messageId)).toContain('restart-1'); + await a2.stop(); + }); + + it('persists rejected events with gate_rejected=1 (malformed-event path)', () => { + inject(h.agent, null); // malformed + const rows = h.agent.policyAuditRepo.recent({ limit: 5 }); + expect(rows).toHaveLength(1); + expect(rows[0].gateRejected).toBe(true); + expect(rows[0].reason).toBe('malformed-event'); + }); + + it('persistence failure preserves dedup retry-poison invariant', () => { + // Stub the repo's insert to throw on the FIRST call only. The gate's + // PolicyAuditLog.record runs persist() before the in-memory push, + // so a throw here must propagate out of evaluate(), prevent the + // queue push and emit, AND leave the messageId unmarked in dedup + // so a legitimate retry can succeed. + const real = h.agent.policyAuditRepo.insert.bind(h.agent.policyAuditRepo); + let allow = false; + const spy = vi.spyOn(h.agent.policyAuditRepo, 'insert').mockImplementation((entry) => { + if (!allow) throw new Error('disk full'); + real(entry); + }); + + const ev = buildEvent(h, { messageId: 'crash-1' }); + // The Agent's listener for inbound:message rethrows from evaluate; + // EventEmitter.emit forwards the throw. We catch it here and verify + // post-conditions. + expect(() => inject(h.agent, ev)).toThrow(/disk full/); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + expect((h.agent.policyGate as any).isDuplicate('crash-1')).toBe(false); + expect(h.agent.inboundQueue.size()).toBe(0); + + allow = true; + inject(h.agent, ev); + expect(h.agent.inboundQueue.size()).toBe(1); + spy.mockRestore(); + }); + + it('plaintext canary feeds through the gate but never lands in any policy_audit column', () => { + const canary = 'AUDIT-DB-CANARY-XYZ'; + inject(h.agent, buildEvent(h, { plaintext: canary, messageId: 'canary-1' })); + // Read the row directly from raw SQLite. None of the columns must + // contain the canary string. + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const db = (h.agent as any).database.getDb(); + const rows = db.prepare('SELECT * FROM policy_audit').all() as Record[]; + expect(rows).toHaveLength(1); + expect(JSON.stringify(rows[0])).not.toContain(canary); + // policyAuditRepo.recent() must also be canary-free. + expect(JSON.stringify(h.agent.policyAuditRepo.recent({ limit: 5 }))).not.toContain(canary); + }); + + it('in-memory and durable surfaces agree on metadata for the same event', () => { + inject(h.agent, buildEvent(h, { messageId: 'mirror-1' })); + const mem = h.agent.policyAudit.recent(); + const disk = h.agent.policyAuditRepo.recent({ limit: 10 }); + expect(mem).toHaveLength(1); + expect(disk).toHaveLength(1); + // auditId / messageId / reason / action / addressedToMe must match. + // (received_at on disk is whatever evaluate captured; in-memory + // entry has the same value because both come from the same record.) + expect(disk[0].auditId).toBe(mem[0].auditId); + expect(disk[0].messageId).toBe(mem[0].messageId); + expect(disk[0].action).toBe(mem[0].action); + expect(disk[0].reason).toBe(mem[0].reason); + expect(disk[0].addressedToMe).toBe(mem[0].addressedToMe); + }); +}); + +describe('Persisted policy audit — retention', () => { + let h: Harness; + afterEach(async () => h?.cleanup()); + + it('policyAuditDbMax: 3 + 5 events ⇒ DB keeps newest 3, newest-first', async () => { + h = await startAgent({ policyAuditDbMax: 3 }); + for (let i = 0; i < 5; i++) { + inject(h.agent, buildEvent(h, { messageId: `r-${i}` })); + // Slow successive inserts so the inserted_at timestamps are + // monotonically distinct enough for the prune to keep the right + // ones. + await new Promise((r) => setTimeout(r, 4)); + } + const ids = h.agent.policyAuditRepo.recent({ limit: 10 }).map((e) => e.messageId); + expect(ids).toEqual(['r-4', 'r-3', 'r-2']); + expect(h.agent.policyAuditRepo.count()).toBe(3); + }); +}); + +describe('Privacy invariant — no plaintext on console/stdout/stderr while persisting', () => { + let h: Harness; + beforeEach(async () => { h = await startAgent(); }); + afterEach(async () => h.cleanup()); + + it('100 valid events with embedded canary → no captured line contains it', () => { + const orig = { + log: console.log, info: console.info, warn: console.warn, + error: console.error, debug: console.debug, + stdout: process.stdout.write.bind(process.stdout), + stderr: process.stderr.write.bind(process.stderr), + }; + const captured: string[] = []; + const sink = (...args: unknown[]) => + captured.push(args.map((a) => (typeof a === 'string' ? a : JSON.stringify(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 { + const canary = 'PERSIST-CANARY-PLAINTEXT-9f'; + for (let i = 0; i < 100; i++) { + inject(h.agent, buildEvent(h, { plaintext: `start ${canary} end ${i}`, messageId: `pc-${i}` })); + } + for (const line of captured) expect(line).not.toContain(canary); + } 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.stdout; process.stderr.write = orig.stderr; + } + }); +}); diff --git a/packages/node/src/__tests__/policy-audit-repo.test.ts b/packages/node/src/__tests__/policy-audit-repo.test.ts new file mode 100644 index 0000000..d7b1ab4 --- /dev/null +++ b/packages/node/src/__tests__/policy-audit-repo.test.ts @@ -0,0 +1,463 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { mkdtempSync, rmSync } from 'node:fs'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; +import { + AgentDatabase, + PolicyAuditRepository, + POLICY_AUDIT_LIMITS, +} from '../storage/index.js'; +import type { PolicyAuditEntry } from '@networkselfmd/core'; + +const SAMPLE: PolicyAuditEntry = { + auditId: 'a-001', + receivedAt: 100, + eventKind: 'group', + messageId: 'm-1', + groupIdHex: 'dead', + senderFingerprint: 'fp-1', + byteLength: 32, + action: 'ask', + reason: 'addressed-unknown-sender', + addressedToMe: true, + senderTrusted: false, + matchedInterests: ['coffee'], + gateRejected: false, +}; + +let dir: string; +let db: AgentDatabase; +let repo: PolicyAuditRepository; + +beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), 'nsmd-audit-repo-')); + db = new AgentDatabase(dir); + db.migrate(); + repo = new PolicyAuditRepository(db.getDb()); +}); + +afterEach(() => { + db.close(); + rmSync(dir, { recursive: true, force: true }); +}); + +describe('Migration v3 — policy_audit schema', () => { + it('schema_version is 10', () => { + const row = db + .getDb() + .prepare('SELECT version FROM schema_version LIMIT 1') + .get() as { version: number }; + expect(row.version).toBe(10); + }); + + it('creates policy_audit table and inserted_at index', () => { + const tables = db + .getDb() + .prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='policy_audit'") + .all() as Array<{ name: string }>; + expect(tables).toHaveLength(1); + const idx = db + .getDb() + .prepare("SELECT name FROM sqlite_master WHERE type='index' AND name='idx_policy_audit_inserted_at'") + .all() as Array<{ name: string }>; + expect(idx).toHaveLength(1); + }); + + it('column set is exactly the 15 metadata-only fields (privacy invariant)', () => { + const cols = db + .getDb() + .prepare("PRAGMA table_info(policy_audit)") + .all() as Array<{ name: string }>; + const names = cols.map((c) => c.name).sort(); + expect(names).toEqual([ + 'action', + 'addressed_to_me', + 'allowed', + 'audit_id', + 'byte_length', + 'event_kind', + 'gate_rejected', + 'group_id_hex', + 'inserted_at', + 'matched_interests', + 'message_id', + 'reason', + 'received_at', + 'sender_fingerprint', + 'sender_trusted', + ]); + // No column may carry plaintext / content / body / payload / tool args / + // private-key bytes. Schema-enforced privacy. + for (const name of names) { + expect(name).not.toMatch( + /plaintext|content|body|payload|tool_args|private_key|secret|password|cipher/i, + ); + } + }); +}); + +describe('PolicyAuditRepository.insert/recent — round-trip + projection', () => { + it('round-trips every metadata field', () => { + repo.insert(SAMPLE); + const back = repo.recent({ limit: 10 }); + expect(back).toHaveLength(1); + expect(back[0]).toEqual(SAMPLE); + }); + + it('round-trips an entry with all-null optional fields and gateRejected=true', () => { + const minimal: PolicyAuditEntry = { + auditId: 'a-min', + receivedAt: 1, + eventKind: 'unknown', + byteLength: 0, + action: 'ignore', + reason: 'malformed-event', + addressedToMe: false, + senderTrusted: false, + matchedInterests: [], + gateRejected: true, + }; + repo.insert(minimal); + const back = repo.recent({ limit: 10 }); + expect(back).toHaveLength(1); + expect(back[0]).toEqual(minimal); + }); + + it('explicit projection — pollution canaries on the entry are NOT persisted', () => { + const polluted = { + ...SAMPLE, + auditId: 'a-pollute', + // eslint-disable-next-line @typescript-eslint/no-explicit-any + plaintext: 'AUDIT-CANARY-PLAINTEXT' as any, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + decryptedBody: 'AUDIT-CANARY-BODY' as any, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + toolArgs: { secret: 'AUDIT-CANARY-TOOL' } as any, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + privateKey: 'AUDIT-CANARY-KEY' as any, + } as PolicyAuditEntry; + repo.insert(polluted); + // Read every column with raw SQLite — none must contain the canaries. + const rawRow = db + .getDb() + .prepare('SELECT * FROM policy_audit WHERE audit_id = ?') + .get('a-pollute') as Record; + const serialized = JSON.stringify(rawRow); + expect(serialized).not.toContain('AUDIT-CANARY-PLAINTEXT'); + expect(serialized).not.toContain('AUDIT-CANARY-BODY'); + expect(serialized).not.toContain('AUDIT-CANARY-TOOL'); + expect(serialized).not.toContain('AUDIT-CANARY-KEY'); + // The recent() DTO must also omit these. + const back = repo.recent({ limit: 1 }); + const backJson = JSON.stringify(back); + expect(backJson).not.toContain('AUDIT-CANARY-PLAINTEXT'); + expect(backJson).not.toContain('AUDIT-CANARY-BODY'); + expect(backJson).not.toContain('AUDIT-CANARY-TOOL'); + expect(backJson).not.toContain('AUDIT-CANARY-KEY'); + expect(back[0]).not.toHaveProperty('plaintext'); + expect(back[0]).not.toHaveProperty('decryptedBody'); + expect(back[0]).not.toHaveProperty('toolArgs'); + expect(back[0]).not.toHaveProperty('privateKey'); + }); + + it('writes a derived allowed=1 column when action != ignore && !gateRejected', () => { + repo.insert(SAMPLE); + const row = db + .getDb() + .prepare('SELECT allowed FROM policy_audit WHERE audit_id = ?') + .get(SAMPLE.auditId) as { allowed: number }; + expect(row.allowed).toBe(1); + + repo.insert({ ...SAMPLE, auditId: 'a-block', gateRejected: true }); + const row2 = db + .getDb() + .prepare('SELECT allowed FROM policy_audit WHERE audit_id = ?') + .get('a-block') as { allowed: number }; + expect(row2.allowed).toBe(0); + + repo.insert({ ...SAMPLE, auditId: 'a-ignore', action: 'ignore' }); + const row3 = db + .getDb() + .prepare('SELECT allowed FROM policy_audit WHERE audit_id = ?') + .get('a-ignore') as { allowed: number }; + expect(row3.allowed).toBe(0); + }); +}); + +describe('PolicyAuditRepository.recent — ordering, paging, clamping', () => { + it('returns newest first', async () => { + repo.insert({ ...SAMPLE, auditId: 'a-0' }); + await new Promise((r) => setTimeout(r, 5)); + repo.insert({ ...SAMPLE, auditId: 'a-1' }); + await new Promise((r) => setTimeout(r, 5)); + repo.insert({ ...SAMPLE, auditId: 'a-2' }); + expect(repo.recent({ limit: 10 }).map((e) => e.auditId)).toEqual(['a-2', 'a-1', 'a-0']); + }); + + it('clamps limit at POLICY_AUDIT_LIMITS.maxRecentLimit', () => { + for (let i = 0; i < 20; i++) repo.insert({ ...SAMPLE, auditId: `a-${i}` }); + const r = repo.recent({ limit: POLICY_AUDIT_LIMITS.maxRecentLimit + 999 }); + expect(r.length).toBeLessThanOrEqual(POLICY_AUDIT_LIMITS.maxRecentLimit); + // 20 < cap, so we still get 20. + expect(r.length).toBe(20); + }); + + it('rejects bogus limit values gracefully (clamps to >=1)', () => { + repo.insert(SAMPLE); + expect(repo.recent({ limit: 0 }).length).toBe(1); + expect(repo.recent({ limit: -5 }).length).toBe(1); + expect(repo.recent({ limit: NaN }).length).toBe(1); + }); + + it('paginates backward with `before` (inserted_at exclusive)', async () => { + const ids: string[] = []; + const insertedAts: number[] = []; + for (let i = 0; i < 5; i++) { + const id = `a-${i}`; + repo.insert({ ...SAMPLE, auditId: id }); + ids.push(id); + const row = db + .getDb() + .prepare('SELECT inserted_at FROM policy_audit WHERE audit_id = ?') + .get(id) as { inserted_at: number }; + insertedAts.push(row.inserted_at); + await new Promise((r) => setTimeout(r, 5)); + } + // Page back from the third row. + const earlier = repo.recent({ before: insertedAts[2], limit: 10 }); + expect(earlier.map((e) => e.auditId)).toEqual(['a-1', 'a-0']); + }); +}); + +describe('PolicyAuditRepository — retention and prune', () => { + it('prune-on-insert evicts oldest beyond max', async () => { + const small = new PolicyAuditRepository(db.getDb(), { maxEntries: 3 }); + for (let i = 0; i < 5; i++) { + small.insert({ ...SAMPLE, auditId: `a-${i}` }); + await new Promise((r) => setTimeout(r, 2)); + } + const ids = small.recent({ limit: 10 }).map((e) => e.auditId); + expect(ids).toEqual(['a-4', 'a-3', 'a-2']); + expect(small.count()).toBe(3); + }); + + it('prune({ olderThanMs }) deletes by age', async () => { + repo.insert({ ...SAMPLE, auditId: 'old-1' }); + await new Promise((r) => setTimeout(r, 30)); + const cutoff = Date.now(); + await new Promise((r) => setTimeout(r, 5)); + repo.insert({ ...SAMPLE, auditId: 'new-1' }); + const removed = repo.prune({ olderThanMs: Date.now() - cutoff }); + expect(removed).toBeGreaterThanOrEqual(1); + const ids = repo.recent({ limit: 10 }).map((e) => e.auditId); + expect(ids).toContain('new-1'); + expect(ids).not.toContain('old-1'); + }); + + it('clear() removes all rows', () => { + for (let i = 0; i < 4; i++) repo.insert({ ...SAMPLE, auditId: `a-${i}` }); + expect(repo.count()).toBe(4); + repo.clear(); + expect(repo.count()).toBe(0); + expect(repo.recent()).toEqual([]); + // After clear, inserts still work. + repo.insert({ ...SAMPLE, auditId: 'after-clear' }); + expect(repo.count()).toBe(1); + }); + + it('corrupt matched_interests JSON degrades to [] on read; never throws', () => { + db.getDb() + .prepare( + `INSERT INTO policy_audit ( + audit_id, received_at, inserted_at, event_kind, message_id, + group_id_hex, sender_fingerprint, byte_length, action, reason, + addressed_to_me, sender_trusted, matched_interests, gate_rejected, allowed + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + ) + .run( + 'corrupt-1', + 1, + Date.now(), + 'group', + 'm', + 'aa', + 'fp', + 4, + 'ask', + 'interest-hit', + 0, + 0, + '{not valid json', + 0, + 0, + ); + expect(() => repo.recent({ limit: 5 })).not.toThrow(); + const back = repo.recent({ limit: 5 }); + expect(back.find((e) => e.auditId === 'corrupt-1')?.matchedInterests).toEqual([]); + }); +}); + +describe('PolicyAuditRepository — fail-closed reads on corrupt rows', () => { + // Helper: insert a row with arbitrary raw values, bypassing the + // typed insert() method. Used to simulate on-disk corruption (or a + // malicious caller that wrote directly to SQLite). + function insertRaw(values: { + audit_id: string; + received_at: number; + inserted_at: number; + event_kind: string; + message_id: string | null; + group_id_hex: string | null; + sender_fingerprint: string | null; + byte_length: number; + action: string; + reason: string; + addressed_to_me: number; + sender_trusted: number; + matched_interests: string; + gate_rejected: number; + allowed: number; + }): void { + db.getDb() + .prepare( + `INSERT INTO policy_audit ( + audit_id, received_at, inserted_at, event_kind, message_id, + group_id_hex, sender_fingerprint, byte_length, action, reason, + addressed_to_me, sender_trusted, matched_interests, gate_rejected, allowed + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + ) + .run( + values.audit_id, + values.received_at, + values.inserted_at, + values.event_kind, + values.message_id, + values.group_id_hex, + values.sender_fingerprint, + values.byte_length, + values.action, + values.reason, + values.addressed_to_me, + values.sender_trusted, + values.matched_interests, + values.gate_rejected, + values.allowed, + ); + } + + const baseRaw = { + audit_id: 'a-corrupt', + received_at: 100, + inserted_at: Date.now(), + event_kind: 'group', + message_id: 'm', + group_id_hex: 'aa', + sender_fingerprint: 'fp', + byte_length: 4, + action: 'ask', + reason: 'addressed-unknown-sender', + addressed_to_me: 1, + sender_trusted: 0, + matched_interests: '[]', + gate_rejected: 0, + allowed: 1, + }; + + function expectStub(entry: PolicyAuditEntry, originalAuditId: string): void { + // Public fields are forced to the safe fail-closed stub. auditId + // and receivedAt are preserved so operators can correlate. + expect(entry.auditId).toBe(originalAuditId); + expect(entry.eventKind).toBe('unknown'); + expect(entry.action).toBe('ignore'); + expect(entry.reason).toBe('malformed-event'); + expect(entry.gateRejected).toBe(true); + expect(entry.addressedToMe).toBe(false); + expect(entry.senderTrusted).toBe(false); + expect(entry.matchedInterests).toEqual([]); + expect(entry.byteLength).toBe(0); + expect(entry.messageId).toBeUndefined(); + expect(entry.groupIdHex).toBeUndefined(); + expect(entry.senderFingerprint).toBeUndefined(); + } + + it('corrupt event_kind ⇒ fail-closed stub', () => { + insertRaw({ ...baseRaw, audit_id: 'kind-bad', event_kind: 'rumor' }); + const back = repo.recent({ limit: 5 }); + expect(back).toHaveLength(1); + expectStub(back[0], 'kind-bad'); + }); + + it('corrupt action ⇒ fail-closed stub', () => { + insertRaw({ ...baseRaw, audit_id: 'action-bad', action: 'execute' }); + expectStub(repo.recent({ limit: 5 })[0], 'action-bad'); + }); + + it('corrupt reason ⇒ fail-closed stub (not in POLICY_REASONS)', () => { + insertRaw({ ...baseRaw, audit_id: 'reason-bad', reason: 'cosmic-rays' }); + expectStub(repo.recent({ limit: 5 })[0], 'reason-bad'); + }); + + it('corrupt boolean (addressed_to_me=2) ⇒ fail-closed stub', () => { + insertRaw({ ...baseRaw, audit_id: 'bool-bad', addressed_to_me: 2 }); + expectStub(repo.recent({ limit: 5 })[0], 'bool-bad'); + }); + + it('corrupt boolean (gate_rejected=-1) ⇒ fail-closed stub', () => { + insertRaw({ ...baseRaw, audit_id: 'gr-bad', gate_rejected: -1 }); + expectStub(repo.recent({ limit: 5 })[0], 'gr-bad'); + }); + + it('negative byte_length ⇒ fail-closed stub', () => { + insertRaw({ ...baseRaw, audit_id: 'bytes-bad', byte_length: -42 }); + expectStub(repo.recent({ limit: 5 })[0], 'bytes-bad'); + }); + + it('valid rows alongside corrupt rows are returned normally', () => { + insertRaw({ ...baseRaw, audit_id: 'valid-1' }); + insertRaw({ ...baseRaw, audit_id: 'corrupt-1', action: 'execute' }); + const back = repo.recent({ limit: 5 }); + const byId = new Map(back.map((e) => [e.auditId, e])); + expect(byId.get('valid-1')?.action).toBe('ask'); + expect(byId.get('valid-1')?.reason).toBe('addressed-unknown-sender'); + expect(byId.get('corrupt-1')?.action).toBe('ignore'); + expect(byId.get('corrupt-1')?.reason).toBe('malformed-event'); + }); + + it('stub never carries plaintext / content / payload / private fields', () => { + insertRaw({ ...baseRaw, audit_id: 'shape', event_kind: 'rumor' }); + const stub = repo.recent({ limit: 1 })[0]; + expect(JSON.stringify(stub)).not.toMatch( + /plaintext|content|body|payload|tool_args|private_key|cipher/i, + ); + // The exact field set is the privacy contract: anything outside this + // list would be a leak. + expect(Object.keys(stub).sort()).toEqual([ + 'action', + 'addressedToMe', + 'auditId', + 'byteLength', + 'eventKind', + 'gateRejected', + 'groupIdHex', + 'matchedInterests', + 'messageId', + 'reason', + 'receivedAt', + 'senderFingerprint', + 'senderTrusted', + ]); + }); +}); + +describe('PolicyAuditRepository — durability', () => { + it('survives close/reopen on the same dataDir', () => { + repo.insert({ ...SAMPLE, auditId: 'durable-1' }); + db.close(); + db = new AgentDatabase(dir); + db.migrate(); + const r = new PolicyAuditRepository(db.getDb()); + const back = r.recent({ limit: 5 }); + expect(back).toHaveLength(1); + expect(back[0].auditId).toBe('durable-1'); + }); +}); diff --git a/packages/node/src/__tests__/policy-config-repo.test.ts b/packages/node/src/__tests__/policy-config-repo.test.ts new file mode 100644 index 0000000..8b3651c --- /dev/null +++ b/packages/node/src/__tests__/policy-config-repo.test.ts @@ -0,0 +1,137 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { mkdtempSync, rmSync } from 'node:fs'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; +import { AgentDatabase, PolicyConfigRepository } from '../storage/index.js'; +import type { PolicyConfig } from '@networkselfmd/core'; + +describe('PolicyConfigRepository — persistence + migration v2', () => { + let dir: string; + let db: AgentDatabase; + let repo: PolicyConfigRepository; + + beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), 'nsmd-policy-cfg-')); + db = new AgentDatabase(dir); + db.migrate(); + repo = new PolicyConfigRepository(db.getDb()); + }); + + afterEach(() => { + db.close(); + rmSync(dir, { recursive: true, force: true }); + }); + + it('starts empty: load() is undefined and exists() is false', () => { + expect(repo.load()).toBeUndefined(); + expect(repo.exists()).toBe(false); + }); + + it('round-trips a full config', () => { + const cfg: PolicyConfig = { + trustedFingerprints: ['fp1xyz', 'fp2abc'], + interests: ['coffee', 'deploy'], + requireMention: true, + mentionPrefixLen: 8, + }; + repo.save(cfg); + expect(repo.exists()).toBe(true); + const loaded = repo.load(); + expect(loaded).toEqual(cfg); + }); + + it('round-trips a partial config (NULL columns → undefined fields)', () => { + const cfg: PolicyConfig = { interests: ['only-this'] }; + repo.save(cfg); + const loaded = repo.load(); + expect(loaded).toEqual({ interests: ['only-this'] }); + expect(loaded).not.toHaveProperty('trustedFingerprints'); + expect(loaded).not.toHaveProperty('requireMention'); + expect(loaded).not.toHaveProperty('mentionPrefixLen'); + }); + + it('round-trips requireMention false (distinguishes from unset)', () => { + repo.save({ requireMention: false }); + expect(repo.load()).toEqual({ requireMention: false }); + }); + + it('overwrites on subsequent save (single-row table)', () => { + repo.save({ trustedFingerprints: ['a'] }); + repo.save({ interests: ['b'] }); + // Second save replaces the row entirely — trusted fp goes back to + // undefined because the new config didn't include it. Callers that + // want partial updates should compose at the Agent layer. + expect(repo.load()).toEqual({ interests: ['b'] }); + }); + + it('clear() removes the row', () => { + repo.save({ interests: ['x'] }); + expect(repo.exists()).toBe(true); + repo.clear(); + expect(repo.exists()).toBe(false); + expect(repo.load()).toBeUndefined(); + }); + + it('survives database close/reopen (real SQLite roundtrip)', () => { + repo.save({ trustedFingerprints: ['durable-1'], requireMention: true }); + db.close(); + db = new AgentDatabase(dir); + db.migrate(); + repo = new PolicyConfigRepository(db.getDb()); + expect(repo.load()).toEqual({ + trustedFingerprints: ['durable-1'], + requireMention: true, + }); + }); + + it('migration v2 created the policy_config table', () => { + const tables = db + .getDb() + .prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='policy_config'") + .all() as Array<{ name: string }>; + expect(tables).toHaveLength(1); + }); + + it('schema_version is at the current head (>= 2; PR #6 bumps to 3)', () => { + const row = db + .getDb() + .prepare('SELECT version FROM schema_version LIMIT 1') + .get() as { version: number }; + expect(row.version).toBeGreaterThanOrEqual(2); + }); + + it('handles a corrupt JSON row gracefully (returns empty array, never throws)', () => { + db.getDb() + .prepare( + `INSERT OR REPLACE INTO policy_config + (id, trusted_fingerprints, interests, require_mention, mention_prefix_len, updated_at) + VALUES (1, ?, ?, NULL, NULL, ?)`, + ) + .run('not-json-at-all', '[broken', Date.now()); + expect(() => repo.load()).not.toThrow(); + const loaded = repo.load(); + expect(loaded).toEqual({ trustedFingerprints: [], interests: [] }); + }); + + it('does NOT persist message plaintext / private-key fields (schema check)', () => { + // Columns of the policy_config table — the privacy invariant is + // schema-enforced. If anyone later adds a 'plaintext' column, this + // test fails loudly. + const cols = db + .getDb() + .prepare("PRAGMA table_info(policy_config)") + .all() as Array<{ name: string }>; + const names = cols.map((c) => c.name).sort(); + expect(names).toEqual([ + 'id', + 'interests', + 'mention_prefix_len', + 'require_mention', + 'trusted_fingerprints', + 'updated_at', + ]); + for (const n of names) { + expect(n).not.toMatch(/plaintext|content|body|private|secret|password/i); + } + }); +}); 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..33ca862 --- /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 publish = (agent as any).database.getDb().transaction(() => (agent as any).prepareInbound(ev))(); + publish(); +} + +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'); + }); +}); 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); + } +} 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/__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/__tests__/policy-migration.test.ts b/packages/node/src/__tests__/policy-migration.test.ts new file mode 100644 index 0000000..d9e9009 --- /dev/null +++ b/packages/node/src/__tests__/policy-migration.test.ts @@ -0,0 +1,38 @@ +import { mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { describe, it, expect } from 'vitest'; +import { AgentDatabase, PolicyConfigRepository, PolicyAuditRepository } from '../storage/index.js'; +import { legacyPolicyMigrations } from './fixtures/legacy-policy-schema.js'; + +describe('policy migration to the current transport schema', () => { + for (const version of [2, 3, 9]) { + it(`upgrades schema ${version} without losing policy config or audit`, () => { + const dir = mkdtempSync(join(tmpdir(), 'nsmd-policy-migration-')); + const database = new AgentDatabase(dir); + try { + const db = database.getDb(); + if (version < 9) { + for (const migration of legacyPolicyMigrations.slice(0, version)) db.exec(migration); + new PolicyConfigRepository(db).save({ interests: ['keep-me'], requireMention: false }); + if (version === 3) new PolicyAuditRepository(db).insert({ auditId: 'retained', receivedAt: 1, + eventKind: 'dm', messageId: 'old', senderFingerprint: 'abc', byteLength: 3, + action: 'ask', reason: 'addressed-unknown-sender', addressedToMe: true, + senderTrusted: false, matchedInterests: [], gateRejected: false }); + } else { + database.migrate(); + db.exec('DROP TABLE policy_config; DROP TABLE policy_audit; UPDATE schema_version SET version = 9'); + } + db.prepare("INSERT INTO messages (id, content, timestamp, type) VALUES ('preserved', 'owner-private history', 1, 'direct')").run(); + database.migrate(); database.migrate(); + expect(db.prepare('SELECT version FROM schema_version').get()).toEqual({ version: 10 }); + if (version < 9) expect(new PolicyConfigRepository(db).load()).toEqual({ interests: ['keep-me'], requireMention: false }); + expect(new PolicyAuditRepository(db).count()).toBe(version === 3 ? 1 : 0); + expect(db.prepare("SELECT content FROM messages WHERE id = 'preserved'").get()).toEqual({ content: 'owner-private history' }); + for (const table of ['dm_ratchet_states', 'discovered_groups', 'delivery_inbox', 'policy_config', 'policy_audit']) { + expect(db.prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?").get(table)).toEqual({ name: table }); + } + } finally { database.close(); rmSync(dir, { recursive: true, force: true }); } + }); + } +}); diff --git a/packages/node/src/__tests__/policy-operator-controls.test.ts b/packages/node/src/__tests__/policy-operator-controls.test.ts new file mode 100644 index 0000000..3619d35 --- /dev/null +++ b/packages/node/src/__tests__/policy-operator-controls.test.ts @@ -0,0 +1,270 @@ +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 { PrivateInboundMessageEvent } from '@networkselfmd/core'; + +// Mock the network layer so Agent.start() works without DHT/sockets. +vi.mock('hyperswarm', () => ({ + default: class MockHyperswarm { + on() {} + join() { return { flushed: () => Promise.resolve() }; } + leave() { return Promise.resolve(); } + destroy() { return Promise.resolve(); } + }, +})); +vi.mock('hyperdht', () => ({ default: class MockHyperDHT {} })); + +import { Agent } from '../agent.js'; +import { PolicyConfigValidationError } from '../policy/validate-config.js'; +import { makeIdentity } from './test-utils/group-harness.js'; + +interface Harness { + agent: Agent; + dataDir: string; + cleanup: () => Promise; +} + +async function startAgent(opts?: { policyConfig?: unknown }): Promise { + const dataDir = mkdtempSync(join(tmpdir(), 'nsmd-policy-ops-')); + const agent = new Agent({ + dataDir, + displayName: 'Alice', + policyConfig: (opts?.policyConfig as never) ?? undefined, + }); + await agent.start(); + return { + agent, + dataDir, + cleanup: async () => { + await agent.stop(); + rmSync(dataDir, { recursive: true, force: true }); + }, + }; +} + +describe('Agent policy operator controls — precedence', () => { + let h: Harness; + afterEach(async () => h?.cleanup()); + + it('uses AgentOptions.policyConfig when nothing is persisted; does NOT auto-persist', async () => { + h = await startAgent({ + policyConfig: { interests: ['from-options'] }, + }); + expect(h.agent.getPolicyConfig()).toEqual({ interests: ['from-options'] }); + // Restart: same dataDir, NO policyConfig passed. The first run did + // not persist, so we expect the default `{}` (NOT 'from-options'). + await h.agent.stop(); + const a2 = new Agent({ dataDir: h.dataDir }); + await a2.start(); + expect(a2.getPolicyConfig()).toEqual({}); + await a2.stop(); + }); + + it('persisted config wins on restart even if AgentOptions provides a different one', async () => { + h = await startAgent({ policyConfig: { interests: ['ignored-on-restart'] } }); + h.agent.setPolicyConfig({ interests: ['persisted'] }); + await h.agent.stop(); + + const a2 = new Agent({ + dataDir: h.dataDir, + // This should be IGNORED because a row exists. + policyConfig: { interests: ['ignored-on-restart'] }, + }); + await a2.start(); + expect(a2.getPolicyConfig()).toEqual({ interests: ['persisted'] }); + await a2.stop(); + }); + + it('defaults to {} when neither persisted nor AgentOptions config is present', async () => { + h = await startAgent(); + expect(h.agent.getPolicyConfig()).toEqual({}); + }); +}); + +describe('Agent.setPolicyConfig — validation + persistence + live update', () => { + let h: Harness; + beforeEach(async () => { h = await startAgent(); }); + afterEach(async () => h.cleanup()); + + it('validates input; throws PolicyConfigValidationError on bad shape; does not persist or update on failure', () => { + expect(() => h.agent.setPolicyConfig({ requireMention: 'no' as unknown as boolean })).toThrow( + PolicyConfigValidationError, + ); + expect(h.agent.getPolicyConfig()).toEqual({}); + }); + + it('saves to SQLite and updates the live policy on success', async () => { + h.agent.setPolicyConfig({ trustedFingerprints: ['abcd1234'], interests: ['coffee'] }); + expect(h.agent.getPolicyConfig()).toEqual({ + trustedFingerprints: ['abcd1234'], + interests: ['coffee'], + }); + // Restart and confirm persistence. + await h.agent.stop(); + const a2 = new Agent({ dataDir: h.dataDir }); + await a2.start(); + expect(a2.getPolicyConfig()).toEqual({ + trustedFingerprints: ['abcd1234'], + interests: ['coffee'], + }); + await a2.stop(); + }); + + it('strips unknown extra fields in input (defence-in-depth at the boundary)', () => { + h.agent.setPolicyConfig({ + interests: ['x'], + // eslint-disable-next-line @typescript-eslint/no-explicit-any + attackerKey: 'PWNED' as any, + }); + const cfg = h.agent.getPolicyConfig(); + expect(cfg.interests).toEqual(['x']); + expect(cfg).not.toHaveProperty('attackerKey'); + }); + + it('config update affects AgentPolicy.decide on the very next event (no restart needed)', () => { + const bob = makeIdentity('Bob'); + h.agent.setPolicyConfig({ requireMention: false }); + const ev: PrivateInboundMessageEvent = { + kind: 'group', + messageId: 'm1', + groupId: new Uint8Array([0xaa]), + senderPublicKey: bob.edPublicKey, + senderFingerprint: bob.fingerprint, + plaintext: new TextEncoder().encode('hi'), + timestamp: 1, + receivedAt: 2, + }; + const decisionA = h.agent.policy.decide(ev); + expect(decisionA.addressedToMe).toBe(true); // requireMention:false → all addressed + + h.agent.setPolicyConfig({ requireMention: true }); + const decisionB = h.agent.policy.decide(ev); + expect(decisionB.addressedToMe).toBe(false); // strict mode, no @-mention + }); +}); + +describe('Agent.updatePolicyConfig — partial merge', () => { + let h: Harness; + beforeEach(async () => { h = await startAgent(); }); + afterEach(async () => h.cleanup()); + + it('merges partial updates without clearing other fields', () => { + h.agent.setPolicyConfig({ trustedFingerprints: ['fp1abc'], interests: ['coffee'] }); + h.agent.updatePolicyConfig({ requireMention: true }); + expect(h.agent.getPolicyConfig()).toEqual({ + trustedFingerprints: ['fp1abc'], + interests: ['coffee'], + requireMention: true, + }); + }); + + it('rejects non-object partial input', () => { + expect(() => h.agent.updatePolicyConfig(42)).toThrow(PolicyConfigValidationError); + expect(() => h.agent.updatePolicyConfig(null)).toThrow(PolicyConfigValidationError); + expect(() => h.agent.updatePolicyConfig([1])).toThrow(PolicyConfigValidationError); + }); + + it('rejects malformed merged result', () => { + h.agent.setPolicyConfig({ interests: ['x'] }); + expect(() => h.agent.updatePolicyConfig({ requireMention: 'sometimes' })).toThrow( + PolicyConfigValidationError, + ); + // Original config preserved on failure. + expect(h.agent.getPolicyConfig()).toEqual({ interests: ['x'] }); + }); +}); + +describe('Agent.getPolicyConfig — defensive copy', () => { + let h: Harness; + beforeEach(async () => { h = await startAgent(); }); + afterEach(async () => h.cleanup()); + + it('returned arrays are independent of the live config', () => { + h.agent.setPolicyConfig({ interests: ['a', 'b'] }); + const a = h.agent.getPolicyConfig(); + a.interests?.push('hijack'); + expect(h.agent.getPolicyConfig().interests).toEqual(['a', 'b']); + }); +}); + +describe('Agent.resetPolicyConfig', () => { + let h: Harness; + beforeEach(async () => { h = await startAgent({ policyConfig: { interests: ['default'] } }); }); + afterEach(async () => h.cleanup()); + + it('clears persisted config and reverts to AgentOptions.policyConfig', async () => { + h.agent.setPolicyConfig({ interests: ['persisted'] }); + expect(h.agent.getPolicyConfig()).toEqual({ interests: ['persisted'] }); + h.agent.resetPolicyConfig(); + expect(h.agent.getPolicyConfig()).toEqual({ interests: ['default'] }); + // Restart: nothing persisted, falls back to AgentOptions again. + await h.agent.stop(); + const a2 = new Agent({ + dataDir: h.dataDir, + policyConfig: { interests: ['default'] }, + }); + await a2.start(); + expect(a2.getPolicyConfig()).toEqual({ interests: ['default'] }); + await a2.stop(); + }); +}); + +describe('Agent.start — validates AgentOptions.policyConfig', () => { + it('rejects malformed AgentOptions.policyConfig with PolicyConfigValidationError', async () => { + const dir = mkdtempSync(join(tmpdir(), 'nsmd-bad-options-')); + const agent = new Agent({ + dataDir: dir, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + policyConfig: { requireMention: 'no' as any }, + }); + await expect(agent.start()).rejects.toBeInstanceOf(PolicyConfigValidationError); + rmSync(dir, { recursive: true, force: true }); + }); + + it('persisted bypasses AgentOptions validation (persisted wins, even if AgentOptions is malformed)', async () => { + // First run: write a valid persisted config. + const dir = mkdtempSync(join(tmpdir(), 'nsmd-persisted-wins-')); + const a1 = new Agent({ dataDir: dir, policyConfig: { interests: ['ok'] } }); + await a1.start(); + a1.setPolicyConfig({ interests: ['persisted'] }); + await a1.stop(); + + // Second run: malformed AgentOptions.policyConfig. Because persisted + // exists, AgentOptions is ignored — start() must NOT throw. + const a2 = new Agent({ + dataDir: dir, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + policyConfig: { requireMention: 'no' as any }, + }); + await expect(a2.start()).resolves.toBeUndefined(); + expect(a2.getPolicyConfig()).toEqual({ interests: ['persisted'] }); + await a2.stop(); + rmSync(dir, { recursive: true, force: true }); + }); +}); + +describe('Privacy invariant — operator config never carries plaintext', () => { + let h: Harness; + beforeEach(async () => { h = await startAgent(); }); + afterEach(async () => h.cleanup()); + + it('the persisted row contains zero columns that could hold message content', async () => { + h.agent.setPolicyConfig({ interests: ['canary-keyword'] }); + // Read the raw row directly to confirm shape. + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const db = (h.agent as any).database.getDb(); + const cols = db + .prepare("PRAGMA table_info(policy_config)") + .all() as Array<{ name: string }>; + const names = cols.map((c) => c.name); + for (const n of names) { + expect(n).not.toMatch(/plaintext|content|body|private|secret|password/i); + } + // The interest keyword IS owner-supplied config and IS persisted — + // that's the whole point. Verify it's the operator-supplied string, + // not an exfiltration of message bytes. + const row = db.prepare('SELECT interests FROM policy_config WHERE id=1').get() as { interests: string }; + expect(JSON.parse(row.interests)).toEqual(['canary-keyword']); + }); +}); diff --git a/packages/node/src/__tests__/policy-validate-config.test.ts b/packages/node/src/__tests__/policy-validate-config.test.ts new file mode 100644 index 0000000..9abed60 --- /dev/null +++ b/packages/node/src/__tests__/policy-validate-config.test.ts @@ -0,0 +1,157 @@ +import { describe, it, expect } from 'vitest'; +import { + validatePolicyConfig, + POLICY_LIMITS, + formatValidationErrors, + PolicyConfigValidationError, +} from '../policy/validate-config.js'; + +describe('validatePolicyConfig — happy path', () => { + it('accepts an empty object as a valid (no-op) config', () => { + const r = validatePolicyConfig({}); + expect(r.ok).toBe(true); + if (r.ok) expect(r.config).toEqual({}); + }); + + it('accepts a fully-populated config and trims/lowercases fingerprints', () => { + const r = validatePolicyConfig({ + trustedFingerprints: [' ABC1234 ', 'def5678 '], + interests: [' Coffee ', ' meeting'], + requireMention: true, + mentionPrefixLen: 8, + }); + expect(r.ok).toBe(true); + if (r.ok) { + expect(r.config).toEqual({ + trustedFingerprints: ['abc1234', 'def5678'], + interests: ['Coffee', 'meeting'], + requireMention: true, + mentionPrefixLen: 8, + }); + } + }); + + it('deduplicates fingerprints and interests preserving order', () => { + const r = validatePolicyConfig({ + trustedFingerprints: ['abcd', 'abcd', 'efgh'], + interests: ['k', 'k', 'k', 'q'], + }); + expect(r.ok).toBe(true); + if (r.ok) { + expect(r.config.trustedFingerprints).toEqual(['abcd', 'efgh']); + expect(r.config.interests).toEqual(['k', 'q']); + } + }); + + it('strips unknown extra keys from the input', () => { + const r = validatePolicyConfig({ + interests: ['x'], + // eslint-disable-next-line @typescript-eslint/no-explicit-any + attackerKey: 'PWNED', + } as any); + expect(r.ok).toBe(true); + if (r.ok) { + expect(r.config).toEqual({ interests: ['x'] }); + expect(r.config).not.toHaveProperty('attackerKey'); + } + }); +}); + +describe('validatePolicyConfig — fail-closed paths', () => { + it('rejects null/undefined/non-objects', () => { + expect(validatePolicyConfig(null).ok).toBe(false); + expect(validatePolicyConfig(undefined).ok).toBe(false); + expect(validatePolicyConfig(42).ok).toBe(false); + expect(validatePolicyConfig('hi').ok).toBe(false); + expect(validatePolicyConfig([]).ok).toBe(false); + }); + + it('rejects non-array trustedFingerprints', () => { + const r = validatePolicyConfig({ trustedFingerprints: 'fp1' }); + expect(r.ok).toBe(false); + }); + + it('rejects fingerprints with bad shape (symbols, whitespace, too short, too long)', () => { + // Mixed case is normalized to lowercase, not rejected — operators + // pasting from UIs commonly uppercase. + const upper = validatePolicyConfig({ trustedFingerprints: ['BadCaps'] }); + expect(upper.ok).toBe(true); + if (upper.ok) expect(upper.config.trustedFingerprints).toEqual(['badcaps']); + + expect(validatePolicyConfig({ trustedFingerprints: ['has space'] }).ok).toBe(false); + expect(validatePolicyConfig({ trustedFingerprints: ['hyphen-no'] }).ok).toBe(false); + expect(validatePolicyConfig({ trustedFingerprints: ['x'] }).ok).toBe(false); // < min + expect( + validatePolicyConfig({ trustedFingerprints: ['a'.repeat(POLICY_LIMITS.maxFingerprintLength + 1)] }).ok, + ).toBe(false); + }); + + it('rejects too many fingerprints', () => { + const fps = Array.from({ length: POLICY_LIMITS.maxTrustedFingerprints + 1 }, (_, i) => + `fp${String(i).padStart(4, '0')}`, + ); + const r = validatePolicyConfig({ trustedFingerprints: fps }); + expect(r.ok).toBe(false); + }); + + it('rejects empty / oversized interests', () => { + expect(validatePolicyConfig({ interests: [''] }).ok).toBe(false); + expect( + validatePolicyConfig({ interests: ['a'.repeat(POLICY_LIMITS.maxInterestLength + 1)] }).ok, + ).toBe(false); + const many = Array.from({ length: POLICY_LIMITS.maxInterests + 1 }, () => 'k'); + expect(validatePolicyConfig({ interests: many }).ok).toBe(false); + }); + + it('rejects non-boolean requireMention', () => { + expect(validatePolicyConfig({ requireMention: 'yes' }).ok).toBe(false); + expect(validatePolicyConfig({ requireMention: 1 }).ok).toBe(false); + }); + + it('rejects out-of-range / non-integer mentionPrefixLen', () => { + expect(validatePolicyConfig({ mentionPrefixLen: 0 }).ok).toBe(false); + expect(validatePolicyConfig({ mentionPrefixLen: -1 }).ok).toBe(false); + expect(validatePolicyConfig({ mentionPrefixLen: 1.5 }).ok).toBe(false); + expect(validatePolicyConfig({ mentionPrefixLen: 65 }).ok).toBe(false); + expect(validatePolicyConfig({ mentionPrefixLen: 'eight' }).ok).toBe(false); + }); + + it('aggregates multiple errors instead of stopping at the first', () => { + const r = validatePolicyConfig({ + trustedFingerprints: ['has space'], + interests: [''], + requireMention: 'no', + mentionPrefixLen: 9999, + }); + expect(r.ok).toBe(false); + if (!r.ok) { + const fields = r.errors.map((e) => e.field); + expect(fields).toContain('trustedFingerprints'); + expect(fields).toContain('interests'); + expect(fields).toContain('requireMention'); + expect(fields).toContain('mentionPrefixLen'); + } + }); +}); + +describe('formatValidationErrors / PolicyConfigValidationError', () => { + it('renders human-readable errors with the offending field', () => { + const r = validatePolicyConfig({ trustedFingerprints: ['has space'] }); + expect(r.ok).toBe(false); + if (!r.ok) { + const msg = formatValidationErrors(r.errors); + expect(msg).toContain('invalid PolicyConfig'); + expect(msg).toContain('trustedFingerprints'); + } + }); + + it('PolicyConfigValidationError carries the structured errors and a message', () => { + const err = new PolicyConfigValidationError([ + { field: 'requireMention', message: 'must be a boolean' }, + ]); + expect(err).toBeInstanceOf(Error); + expect(err.name).toBe('PolicyConfigValidationError'); + expect(err.errors).toHaveLength(1); + expect(err.message).toMatch(/requireMention/); + }); +}); 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/__tests__/storage.test.ts b/packages/node/src/__tests__/storage.test.ts index fed1915..600b7c3 100644 --- a/packages/node/src/__tests__/storage.test.ts +++ b/packages/node/src/__tests__/storage.test.ts @@ -68,7 +68,7 @@ describe('AgentDatabase', () => { const row = db .prepare('SELECT version FROM schema_version') .get() as { version: number }; - expect(row.version).toBe(9); + expect(row.version).toBe(10); }); it('should migrate a populated v4 identity to the nullable encrypted-only schema', () => { diff --git a/packages/node/src/__tests__/test-utils/group-harness.ts b/packages/node/src/__tests__/test-utils/group-harness.ts new file mode 100644 index 0000000..472f102 --- /dev/null +++ b/packages/node/src/__tests__/test-utils/group-harness.ts @@ -0,0 +1 @@ +export { generateIdentity as makeIdentity } from '@networkselfmd/core'; diff --git a/packages/node/src/agent.ts b/packages/node/src/agent.ts index a24a5e6..c504455 100644 --- a/packages/node/src/agent.ts +++ b/packages/node/src/agent.ts @@ -1,3 +1,10 @@ +import type { PolicyConfig, PolicyDecision, PrivateInboundMessageEvent } from '@networkselfmd/core'; +import { InboundEventQueue } from './events/inbound-queue.js'; +import { AgentPolicy } from './policy/agent-policy.js'; +import { PolicyGate } from './policy/policy-gate.js'; +import { PolicyAuditLog } from './policy/audit-log.js'; +import { validatePolicyConfig, PolicyConfigValidationError } from './policy/validate-config.js'; +import { PolicyConfigRepository, PolicyAuditRepository } from './storage/policy.js'; import { EventEmitter } from 'node:events'; import { existsSync } from 'node:fs'; import { join } from 'node:path'; @@ -73,6 +80,10 @@ import { } from './network/protocol-security.js'; export interface AgentOptions { + policyConfig?: PolicyConfig; + policyAuditMax?: number; + policyAuditDbMax?: number; + inboundQueueMax?: number; dataDir: string; passphrase?: string; secretProvider?: SecretProvider; @@ -125,6 +136,12 @@ export class Agent extends EventEmitter { groups: Map = new Map(); isRunning = false; + readonly inboundQueue: InboundEventQueue; + policy!: AgentPolicy; + policyAudit!: PolicyAuditLog; + policyAuditRepo!: PolicyAuditRepository; + policyGate!: PolicyGate; + private policyConfigRepo!: PolicyConfigRepository; private options: AgentOptions; private database!: AgentDatabase; private identityRepo!: IdentityRepository; @@ -147,6 +164,7 @@ export class Agent extends EventEmitter { constructor(options: AgentOptions) { super(); + this.inboundQueue = new InboundEventQueue({ max: options.inboundQueueMax }); this.options = { ...options, dataDir: resolveDataDir(options.dataDir), @@ -179,6 +197,7 @@ export class Agent extends EventEmitter { this.database.migrate(); const db = this.database.getDb(); + this.policyConfigRepo = new PolicyConfigRepository(db); this.identityRepo = new IdentityRepository(db); this.peerRepo = new PeerRepository(db); this.groupRepo = new GroupRepository(db); @@ -197,6 +216,8 @@ export class Agent extends EventEmitter { // on an unlock failure so callers can retry with a fresh Agent instance. await this.loadOrGenerateIdentity(passphrase); + this.initializePolicy(); + // Init swarm this.swarm = new SwarmManager({ identity: this.identity, @@ -223,6 +244,7 @@ export class Agent extends EventEmitter { invites: this.groupInviteRepo, replay: this.protocolReplayRepo, bootstraps: this.groupBootstrapRepo, + prepareInbound: (event) => this.prepareInbound(event), enqueueDelivery: (groupId, content) => this.enqueueGroupDelivery(groupId, content), }); @@ -1142,6 +1164,108 @@ export class Agent extends EventEmitter { }); } + // ---- Policy ---- + + // Returns a defensive copy of the current runtime policy configuration. + // Lists are sliced so callers cannot mutate the live config; mutating + // the returned arrays does not affect AgentPolicy.decide. + getPolicyConfig(): PolicyConfig { + const c = this.policy.getConfig(); + const out: PolicyConfig = {}; + if (c.trustedFingerprints !== undefined) { + out.trustedFingerprints = c.trustedFingerprints.slice(); + } + if (c.interests !== undefined) out.interests = c.interests.slice(); + if (c.requireMention !== undefined) out.requireMention = c.requireMention; + if (c.mentionPrefixLen !== undefined) out.mentionPrefixLen = c.mentionPrefixLen; + return out; + } + + // Replace the policy configuration. Validates first; on bad input + // throws PolicyConfigValidationError without mutating anything. On + // success, persists to SQLite (so the change survives restart) and + // updates the live AgentPolicy.config in place. Decisions are pure + // over (config, identity, event), so the new config takes effect on + // the very next inbound event. + setPolicyConfig(config: unknown): void { + const result = validatePolicyConfig(config); + if (!result.ok) { + throw new PolicyConfigValidationError(result.errors); + } + this.policyConfigRepo.save(result.config); + this.policy.setConfig(result.config); + } + + // Merge `partial` over the current configuration. Use this for + // single-field updates (e.g. flipping requireMention) without + // re-supplying the whole config. Validation runs on the merged + // result; persistence and live update follow the same rules as + // setPolicyConfig. + updatePolicyConfig(partial: unknown): void { + if (partial === null || typeof partial !== 'object' || Array.isArray(partial)) { + throw new PolicyConfigValidationError([ + { field: 'config', message: 'must be an object' }, + ]); + } + const merged: PolicyConfig = { + ...this.getPolicyConfig(), + ...(partial as Partial), + }; + this.setPolicyConfig(merged); + } + + // Wipe persisted config and reset the runtime to AgentOptions. + // policyConfig (or {} if none was passed). Useful for tests and for + // operators that want to start over without restarting the process. + resetPolicyConfig(): void { + const result = validatePolicyConfig(this.options.policyConfig ?? {}); + if (!result.ok) throw new PolicyConfigValidationError(result.errors); + this.policyConfigRepo.clear(); + this.policy.setConfig(result.config); + } + + private initializePolicy(): void { + const initial = validatePolicyConfig(this.policyConfigRepo.load() ?? this.options.policyConfig ?? {}); + if (!initial.ok) throw new PolicyConfigValidationError(initial.errors); + this.policyAuditRepo = new PolicyAuditRepository(this.database.getDb(), { maxEntries: this.options.policyAuditDbMax }); + this.policyAuditRepo.prune(); + this.policyAudit = new PolicyAuditLog({ max: this.options.policyAuditMax, persist: entry => this.policyAuditRepo.insert(entry) }); + this.policy = new AgentPolicy({ agent: this, config: initial.config }); + this.policyGate = new PolicyGate({ policy: this.policy, audit: this.policyAudit, + isMember: (groupId, publicKey) => this.groupRepo.getMembers(groupId).some(member => Buffer.from(member.public_key).equals(Buffer.from(publicKey))), + }); + this.policyGate.on('decision', (decision: PolicyDecision) => this.emitPolicyEvent('policy:decision', decision)); + } + + // Called inside the message/ratchet/inbox transaction. Effects run only + // after SQLite commits; audit failure rolls the entire reception back. + private emitPolicyEvent(name: string, value: unknown): void { + for (const listener of this.rawListeners(name)) { + try { listener.call(this, value); } + catch { + // A failed observer must not suppress later observers, queueing or + // receipts. Do not log its potentially content-bearing exception. + try { this.emit('error', new Error('Policy observer failed')); } catch {} + } + } + } + + private prepareInbound(event: PrivateInboundMessageEvent): () => void { + const effects: Array<() => void> = []; + const outcome = this.policyGate.evaluate(event, effect => effects.push(effect)); + return () => { + for (const effect of effects) effect(); + this.emitPolicyEvent('policy:audit', outcome.entry); + if (!outcome.entry.gateRejected) this.emitPolicyEvent('activity:message', { kind: event.kind, + groupIdHex: event.groupId ? Buffer.from(event.groupId).toString('hex') : undefined, + senderFingerprint: event.senderFingerprint, timestamp: event.timestamp, byteLength: event.plaintext.byteLength }); + if (outcome.allowed) { + this.inboundQueue.push(outcome.ev); + this.emitPolicyEvent('inbound:message', outcome.ev); + } + }; + } + private setupGroupManagerEvents(): void { this.groupManager.on('group:message', (data) => { this.emit('group:message', data); @@ -1189,6 +1313,7 @@ export class Agent extends EventEmitter { return false; } + let publishInbound: (() => void) | undefined; try { const content = this.protocolReplayRepo.accept(reservation, () => { const senderFingerprint = session.peerFingerprint!; @@ -1222,17 +1347,22 @@ export class Agent extends EventEmitter { const decoded = new TextDecoder().decode(decrypted.plaintext); delivery?.accept(decoded); this.ratchetStateRepo.saveSession(senderFingerprint, decrypted.session); + const messageId = delivery?.messageId ?? createId(); this.messageRepo.insert({ - id: delivery?.messageId ?? createId(), + id: messageId, senderPublicKey: session.peerPublicKey!, peerPublicKey: session.peerPublicKey!, content: decoded, timestamp: delivery?.timestamp ?? message.timestamp, type: 'direct', }); + publishInbound = this.prepareInbound({ kind: 'dm', messageId, + senderPublicKey: session.peerPublicKey!, senderFingerprint, + plaintext: decrypted.plaintext, timestamp: delivery?.timestamp ?? message.timestamp, receivedAt: Date.now() }); return decoded; }); + publishInbound?.(); this.emit('dm:message', { senderPublicKey: session.peerPublicKey!, senderFingerprint: session.peerFingerprint!, diff --git a/packages/node/src/events/inbound-queue.ts b/packages/node/src/events/inbound-queue.ts new file mode 100644 index 0000000..c3d3fd5 --- /dev/null +++ b/packages/node/src/events/inbound-queue.ts @@ -0,0 +1,64 @@ +import type { PrivateInboundMessageEvent } from '@networkselfmd/core'; + +export interface InboundEventQueueOptions { + max?: number; +} + +export type InboundEventHandler = (ev: PrivateInboundMessageEvent) => void; + +// Bounded FIFO buffer for PrivateInboundMessageEvents so poll-based +// consumers (MCP, CLI) don't drop events between polls. Owner-local only — +// never queues PublicActivityEvent. +export class InboundEventQueue { + private buf: PrivateInboundMessageEvent[] = []; + private handlers: Set = new Set(); + private max: number; + + constructor(options: InboundEventQueueOptions = {}) { + this.max = options.max ?? 1000; + } + + push(ev: PrivateInboundMessageEvent): void { + this.buf.push(ev); + if (this.buf.length > this.max) { + this.buf.splice(0, this.buf.length - this.max); + } + for (const h of this.handlers) { + try { + h(ev); + } catch (err) { + // Handler errors must not stop delivery to other handlers, but must + // not be silently swallowed either. Rethrow on a microtask so the + // process 'uncaughtException' / test runner sees it. + queueMicrotask(() => { + throw err; + }); + } + } + } + + drain(limit?: number): PrivateInboundMessageEvent[] { + if (limit === undefined || limit >= this.buf.length) { + const out = this.buf.slice(); + this.buf = []; + return out; + } + return this.buf.splice(0, limit); + } + + peek(limit?: number): PrivateInboundMessageEvent[] { + if (limit === undefined) return this.buf.slice(); + return this.buf.slice(0, limit); + } + + size(): number { + return this.buf.length; + } + + on(handler: InboundEventHandler): () => void { + this.handlers.add(handler); + return () => { + this.handlers.delete(handler); + }; + } +} diff --git a/packages/node/src/groups/group-manager.ts b/packages/node/src/groups/group-manager.ts index d89eab0..961326d 100644 --- a/packages/node/src/groups/group-manager.ts +++ b/packages/node/src/groups/group-manager.ts @@ -1,3 +1,4 @@ +import type { PrivateInboundMessageEvent } from '@networkselfmd/core'; import { EventEmitter } from 'node:events'; import { createId } from '@paralleldrive/cuid2'; import { @@ -54,6 +55,7 @@ const KEY_ROTATION_INTERVAL = 100; export const GROUP_KEY_MAX_AGE_MS = 24 * 60 * 60 * 1000; export interface GroupManagerOptions { + prepareInbound?: (event: PrivateInboundMessageEvent) => () => void; identity: AgentIdentity; swarm: SwarmManager; groups: GroupRepository; @@ -81,10 +83,12 @@ export class GroupManager extends EventEmitter { private messageCounters = new Map(); private rotationTimer?: ReturnType; private enqueueDelivery?: GroupManagerOptions['enqueueDelivery']; + private prepareInbound?: GroupManagerOptions['prepareInbound']; constructor(options: GroupManagerOptions) { super(); this.identity = options.identity; + this.prepareInbound = options.prepareInbound; this.swarm = options.swarm; this.groupRepo = options.groups; this.messageRepo = options.messages; @@ -685,6 +689,7 @@ export class GroupManager extends EventEmitter { this.identity.fingerprint, ); const senderPublicKey = session.peerPublicKey!; + let publishInbound: (() => void) | undefined; const content = this.replayRepo.accept(reservation, () => { const group = this.groupRepo.find(message.groupId); const latest = this.epochRepo.getLatestEpoch( @@ -756,17 +761,27 @@ export class GroupManager extends EventEmitter { const decoded = new TextDecoder().decode(plaintext); delivery?.accept(decoded); + const messageId = delivery?.messageId ?? createId(); this.messageRepo.insert({ - id: delivery?.messageId ?? createId(), + id: messageId, groupId: message.groupId, senderPublicKey, content: decoded, timestamp: delivery?.timestamp ?? message.timestamp, type: 'group', }); + const event: PrivateInboundMessageEvent = { kind: 'group', messageId, + groupId: message.groupId, senderPublicKey, senderFingerprint: session.peerFingerprint!, + plaintext, timestamp: delivery?.timestamp ?? message.timestamp, receivedAt: Date.now() }; + const dispatch = this.prepareInbound?.(event); + publishInbound = () => { + dispatch?.(); + this.emit('inbound:message', event); + }; return decoded; }); + publishInbound?.(); this.emit('group:message', { groupId: message.groupId, senderPublicKey, diff --git a/packages/node/src/index.ts b/packages/node/src/index.ts index 19cf7f6..405564e 100644 --- a/packages/node/src/index.ts +++ b/packages/node/src/index.ts @@ -32,3 +32,14 @@ export type { TTYAResponse, TTYAVisitor, } from './ttya/ttya-manager.js'; + +export * from './policy/agent-policy.js'; +export * from './policy/policy-gate.js'; +export * from './policy/audit-log.js'; +export * from './policy/validate-config.js'; +export * from './events/inbound-queue.js'; +export * from './storage/policy.js'; + +export type { PolicyConfig, PolicyDecision, PolicyAuditEntry, PrivateInboundMessageEvent, PublicActivityEvent } from '@networkselfmd/core'; + +export { resolveDataDir } from './data-dir.js'; diff --git a/packages/node/src/policy/agent-policy.ts b/packages/node/src/policy/agent-policy.ts new file mode 100644 index 0000000..cf6fa44 --- /dev/null +++ b/packages/node/src/policy/agent-policy.ts @@ -0,0 +1,205 @@ +import { EventEmitter } from 'node:events'; +import type { + PolicyAction, + PolicyConfig, + PolicyDecision, + PolicyReason, + PrivateInboundMessageEvent, +} from '@networkselfmd/core'; +import type { Agent } from '../agent.js'; + +export interface AgentPolicyOptions { + agent: Agent; + config: PolicyConfig; +} + +// Minimal policy runner: consume InboundEventQueue → produce act | ask | +// ignore decisions via cheap filters. No tool execution yet; the caller +// wires `.on('decision', ...)` to whatever handler layer they choose. +// +// `decide()` is a pure function over (self identity, config, event). No +// I/O, no Date.now(). Tests hit it directly. +export class AgentPolicy extends EventEmitter { + private agent: Agent; + private config: PolicyConfig; + private unsubscribe?: () => void; + + constructor(options: AgentPolicyOptions) { + super(); + this.agent = options.agent; + this.config = options.config; + } + + start(): void { + if (this.unsubscribe) return; + this.unsubscribe = this.agent.inboundQueue.on((ev) => { + const decision = this.decide(ev); + this.emit('decision', decision); + }); + } + + stop(): void { + this.unsubscribe?.(); + 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; + } + + // Read access for callers that want to introspect the live config. + // Note: returns the internal reference. Agent.getPolicyConfig clones + // arrays before exposing this externally; internal callers like the + // gate read it directly for cost. + getConfig(): PolicyConfig { + return this.config; + } + + decide(ev: PrivateInboundMessageEvent): PolicyDecision { + const text = tryDecodeUtf8(ev.plaintext); + const fingerprint = this.agent.identity?.fingerprint; + const displayName = this.agent.identity?.displayName; + const prefixLen = this.config.mentionPrefixLen ?? 8; + const requireMention = this.config.requireMention ?? true; + + const addressedToMe = computeAddressedToMe({ + text, + kind: ev.kind, + selfFingerprint: fingerprint, + selfDisplayName: displayName, + requireMention, + prefixLen, + }); + + const senderTrusted = + this.config.trustedFingerprints?.includes(ev.senderFingerprint) ?? false; + + const matchedInterests = computeMatchedInterests(text, this.config.interests); + const hasInterestHit = matchedInterests.length > 0; + + const { action, reason } = resolveDecision({ + addressedToMe, + senderTrusted, + hasInterestHit, + }); + + return { + messageId: ev.messageId, + action, + reason, + matchedInterests, + addressedToMe, + senderTrusted, + }; + } +} + +function tryDecodeUtf8(bytes: Uint8Array): string | undefined { + try { + return new TextDecoder('utf-8', { fatal: true }).decode(bytes); + } catch { + return undefined; + } +} + +interface AddressedToMeInputs { + text: string | undefined; + kind: 'group' | 'dm'; + selfFingerprint: string | undefined; + selfDisplayName: string | undefined; + requireMention: boolean; + prefixLen: number; +} + +function computeAddressedToMe(i: AddressedToMeInputs): boolean { + // DM recipient authentication is enforced by the transport before the gate. + if (i.kind === 'dm') return true; + if (!i.requireMention && i.kind === 'group') return true; + if (i.text === undefined) return false; + if (!i.selfFingerprint) return false; + + const prefix = i.selfFingerprint.slice(0, Math.max(1, i.prefixLen)); + if (containsMention(i.text, prefix)) return true; + if (i.selfDisplayName && containsMention(i.text, i.selfDisplayName)) return true; + return false; +} + +// Token-bounded match for `@`. The character immediately after the +// match must not be part of an identifier (letter/digit/underscore/hyphen) +// so `@alice7x2a` inside `@alice7x2abc` does not count. +function containsMention(haystack: string, needle: string): boolean { + if (!needle) return false; + const lowerHay = haystack.toLowerCase(); + const lowerNeedle = needle.toLowerCase(); + let from = 0; + while (from < lowerHay.length) { + const at = lowerHay.indexOf('@' + lowerNeedle, from); + if (at === -1) return false; + const next = lowerHay.charCodeAt(at + 1 + lowerNeedle.length); + if (isNaN(next) || !isIdentifierChar(next)) return true; + from = at + 1; + } + return false; +} + +function isIdentifierChar(code: number): boolean { + // a-z, 0-9, '_', '-' + return ( + (code >= 97 && code <= 122) || + (code >= 48 && code <= 57) || + code === 95 || + code === 45 + ); +} + +function computeMatchedInterests( + text: string | undefined, + interests: string[] | undefined, +): string[] { + if (!text || !interests || interests.length === 0) return []; + const lower = text.toLowerCase(); + const hits: string[] = []; + for (const kw of interests) { + if (!kw) continue; + if (lower.includes(kw.toLowerCase())) hits.push(kw); + } + return hits; +} + +interface DecisionInputs { + addressedToMe: boolean; + senderTrusted: boolean; + hasInterestHit: boolean; +} + +function resolveDecision( + i: DecisionInputs, +): { action: PolicyAction; reason: PolicyReason } { + if (i.addressedToMe && i.senderTrusted) { + return { action: 'act', reason: 'addressed-and-trusted' }; + } + if (i.addressedToMe && i.hasInterestHit) { + return { action: 'ask', reason: 'addressed-matches-interest' }; + } + if (i.addressedToMe) { + return { action: 'ask', reason: 'addressed-unknown-sender' }; + } + if (i.senderTrusted && i.hasInterestHit) { + return { action: 'ask', reason: 'trusted-interest-hit' }; + } + if (i.hasInterestHit) { + return { action: 'ask', reason: 'interest-hit' }; + } + if (i.senderTrusted) { + return { action: 'ignore', reason: 'trusted-no-signal' }; + } + return { action: 'ignore', reason: 'not-addressed' }; +} + +// TODO(next-PR): wire per-interest / per-action handlers here. Keep +// `decide()` a pure function; execute side effects out-of-band from an +// on('decision', ...) consumer. diff --git a/packages/node/src/policy/audit-log.ts b/packages/node/src/policy/audit-log.ts new file mode 100644 index 0000000..ee90e99 --- /dev/null +++ b/packages/node/src/policy/audit-log.ts @@ -0,0 +1,87 @@ +import type { PolicyAuditEntry } from '@networkselfmd/core'; + +export interface PolicyAuditLogOptions { + // Maximum number of entries to retain. Older entries are dropped FIFO. + // Default: 1000. + max?: number; + // Optional synchronous persistence callback. Called inside record() + // BEFORE the in-memory ring is mutated. If it throws, record() throws + // and the in-memory log is unchanged — that propagation is the + // mechanism that preserves the gate's retry-poison invariant: the + // gate's evaluate() will throw, markDedup() never runs, and a + // legitimate retry of the same messageId is re-evaluated. + persist?: (entry: PolicyAuditEntry) => void; +} + +// 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. +// +// Persistence: the optional `persist` callback (wired in PR #6 to +// PolicyAuditRepository.insert) makes this log durable — a throw from +// persist propagates with the in-memory ring unchanged so the gate's +// retry-poison invariant holds. Operators that want cross-restart +// visibility read from the durable repo directly (MCP / CLI); the +// in-memory log stays as a runtime convenience for fast same-process +// reads and event emission. +export class PolicyAuditLog { + private buf: PolicyAuditEntry[] = []; + private max: number; + private persist?: (entry: PolicyAuditEntry) => void; + + constructor(options: PolicyAuditLogOptions = {}) { + this.max = Math.max(1, options.max ?? 1000); + this.persist = options.persist; + } + + // 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. + // + // Order of operations: persist FIRST, then push to the in-memory ring. + // A throw from persist propagates with the in-memory log unchanged — + // exactly the contract the gate's retry-poison invariant relies on. + record(entry: PolicyAuditEntry, afterCommit: (effect: () => void) => void = effect => effect()): PolicyAuditEntry { + const stored = freezeAuditEntry(cloneAuditEntry(entry)); + if (this.persist) this.persist(stored); + afterCommit(() => { + this.buf.push(stored); + if (this.buf.length > this.max) this.buf.splice(0, this.buf.length - this.max); + }); + return stored; + } + + // 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 = []; + } +} + +// 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 new file mode 100644 index 0000000..11bd5b1 --- /dev/null +++ b/packages/node/src/policy/policy-gate.ts @@ -0,0 +1,264 @@ +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, afterCommit: (effect: () => void) => void = effect => effect()): GateOutcome { + const receivedAt = this.now(); + + // --- Step 1: structural validation --- + const validation = validateInboundEvent(raw); + if (!validation.ok) { + const entry = this.recordReject(raw, receivedAt, validation.reason, afterCommit); + 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', afterCommit); + 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', afterCommit); + return { allowed: false, reason: 'malformed-event', entry }; + } + // 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', afterCommit); + return { allowed: false, reason: 'not-a-member', entry }; + } + if (!isMember) { + const entry = this.recordRejectFromEvent(ev, receivedAt, 'not-a-member', afterCommit); + 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, afterCommit); + + // --- Step 6: mark dedup ONLY now (post-audit) --- + afterCommit(() => this.markDedup(ev.messageId)); + + // --- Step 7: emit and return --- + // 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. + afterCommit(() => { + try { + this.emit('decision', decision); + } catch (err) { + queueMicrotask(() => { throw err; }); + } + }); + + 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, + afterCommit: (effect: () => void) => void, + ): 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, afterCommit); + 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, + afterCommit: (effect: () => void) => void, + ): 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, afterCommit); + return entry; + } +} diff --git a/packages/node/src/policy/validate-config.ts b/packages/node/src/policy/validate-config.ts new file mode 100644 index 0000000..95e7a72 --- /dev/null +++ b/packages/node/src/policy/validate-config.ts @@ -0,0 +1,180 @@ +import type { PolicyConfig } from '@networkselfmd/core'; + +// Hard caps on operator-supplied configuration. Generous but bounded so +// a buggy CLI / MCP client cannot bloat the runtime config or the +// persisted row. +export const POLICY_LIMITS = Object.freeze({ + // Max number of trusted fingerprint entries. + maxTrustedFingerprints: 256, + // Max chars in a single fingerprint string. + maxFingerprintLength: 64, + // Min chars in a single fingerprint string. The protocol's fingerprint + // (z-base32 of 32 bytes) is ~52 chars; we accept any non-empty, + // reasonably-shaped string ≥4 to keep tests lean. + minFingerprintLength: 4, + // Max number of interest keywords. + maxInterests: 256, + // Max chars in a single interest keyword. + maxInterestLength: 64, + // Allowed range for mentionPrefixLen. + minMentionPrefixLen: 1, + maxMentionPrefixLen: 64, +} as const); + +// Lowercase z-base32-friendly alphanumeric characters. Strict here +// because the matching code in agent-policy.ts already lowercases for +// case-insensitive compares; permitting uppercase or symbols would +// silently change matching behavior. +const FINGERPRINT_RE = /^[a-z0-9]+$/; + +export type ValidationError = { + field: keyof PolicyConfig | 'config'; + message: string; +}; + +export type ValidatedConfig = + | { ok: true; config: PolicyConfig } + | { ok: false; errors: ValidationError[] }; + +// Pure structural / range validator for PolicyConfig. Accepts unknown +// because operators feed configs in via JSON / CLI flags / MCP — the +// type system can't be trusted at this seam. +// +// Returns a sanitized PolicyConfig with any unknown extra keys +// stripped, OR a list of human-readable errors. Never mutates the +// input; safe to call from anywhere. +export function validatePolicyConfig(raw: unknown): ValidatedConfig { + const errors: ValidationError[] = []; + if (raw === null || typeof raw !== 'object' || Array.isArray(raw)) { + return { ok: false, errors: [{ field: 'config', message: 'must be an object' }] }; + } + const obj = raw as Record; + const sanitized: PolicyConfig = {}; + + if (obj.trustedFingerprints !== undefined) { + const fps = obj.trustedFingerprints; + if (!Array.isArray(fps)) { + errors.push({ field: 'trustedFingerprints', message: 'must be an array of strings' }); + } else if (fps.length > POLICY_LIMITS.maxTrustedFingerprints) { + errors.push({ + field: 'trustedFingerprints', + message: `too many entries (max ${POLICY_LIMITS.maxTrustedFingerprints})`, + }); + } else { + const cleaned: string[] = []; + let bad = false; + for (const entry of fps) { + if (typeof entry !== 'string') { + errors.push({ field: 'trustedFingerprints', message: 'entries must be strings' }); + bad = true; + break; + } + const trimmed = entry.trim().toLowerCase(); + if ( + trimmed.length < POLICY_LIMITS.minFingerprintLength || + trimmed.length > POLICY_LIMITS.maxFingerprintLength || + !FINGERPRINT_RE.test(trimmed) + ) { + errors.push({ + field: 'trustedFingerprints', + message: `invalid fingerprint shape: ${truncate(trimmed, 20)}`, + }); + bad = true; + break; + } + cleaned.push(trimmed); + } + if (!bad) { + // Dedup while preserving order. + sanitized.trustedFingerprints = Array.from(new Set(cleaned)); + } + } + } + + if (obj.interests !== undefined) { + const items = obj.interests; + if (!Array.isArray(items)) { + errors.push({ field: 'interests', message: 'must be an array of strings' }); + } else if (items.length > POLICY_LIMITS.maxInterests) { + errors.push({ + field: 'interests', + message: `too many entries (max ${POLICY_LIMITS.maxInterests})`, + }); + } else { + const cleaned: string[] = []; + let bad = false; + for (const entry of items) { + if (typeof entry !== 'string') { + errors.push({ field: 'interests', message: 'entries must be strings' }); + bad = true; + break; + } + const trimmed = entry.trim(); + if (trimmed.length === 0 || trimmed.length > POLICY_LIMITS.maxInterestLength) { + errors.push({ + field: 'interests', + message: `invalid interest length: ${truncate(trimmed, 20)}`, + }); + bad = true; + break; + } + cleaned.push(trimmed); + } + if (!bad) { + sanitized.interests = Array.from(new Set(cleaned)); + } + } + } + + if (obj.requireMention !== undefined) { + if (typeof obj.requireMention !== 'boolean') { + errors.push({ field: 'requireMention', message: 'must be a boolean' }); + } else { + sanitized.requireMention = obj.requireMention; + } + } + + if (obj.mentionPrefixLen !== undefined) { + const n = obj.mentionPrefixLen; + if ( + typeof n !== 'number' || + !Number.isInteger(n) || + n < POLICY_LIMITS.minMentionPrefixLen || + n > POLICY_LIMITS.maxMentionPrefixLen + ) { + errors.push({ + field: 'mentionPrefixLen', + message: `must be an integer in [${POLICY_LIMITS.minMentionPrefixLen}, ${POLICY_LIMITS.maxMentionPrefixLen}]`, + }); + } else { + sanitized.mentionPrefixLen = n; + } + } + + if (errors.length > 0) { + return { ok: false, errors }; + } + return { ok: true, config: sanitized }; +} + +// Render a list of errors into a single readable string for throw / CLI +// output. Stable, no plaintext, no contextual data beyond field/message. +export function formatValidationErrors(errors: ValidationError[]): string { + return ( + 'invalid PolicyConfig:\n' + + errors.map((e) => ` - ${e.field}: ${e.message}`).join('\n') + ); +} + +export class PolicyConfigValidationError extends Error { + readonly errors: ValidationError[]; + constructor(errors: ValidationError[]) { + super(formatValidationErrors(errors)); + this.name = 'PolicyConfigValidationError'; + this.errors = errors; + } +} + +function truncate(s: string, n: number): string { + return s.length <= n ? s : s.slice(0, n) + '…'; +} 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; +} diff --git a/packages/node/src/storage/database.ts b/packages/node/src/storage/database.ts index 057be54..1da82f5 100644 --- a/packages/node/src/storage/database.ts +++ b/packages/node/src/storage/database.ts @@ -3,7 +3,7 @@ import { join } from 'node:path'; import { mkdirSync, existsSync, chmodSync, readFileSync, statSync } from 'node:fs'; import { deserializeEpoch } from '@networkselfmd/core'; -const SCHEMA_VERSION = 9; +const SCHEMA_VERSION = 10; const REPLAY_TTL_MS = 10 * 60 * 1000; const MIGRATIONS: string[] = [ @@ -252,6 +252,38 @@ const MIGRATIONS: string[] = [ ); UPDATE schema_version SET version = 9; `, + ` + CREATE TABLE IF NOT EXISTS policy_config ( + id INTEGER PRIMARY KEY, + trusted_fingerprints TEXT, + interests TEXT, + require_mention INTEGER, + mention_prefix_len INTEGER, + updated_at INTEGER NOT NULL + ); + + CREATE TABLE IF NOT EXISTS policy_audit ( + audit_id TEXT PRIMARY KEY, + received_at INTEGER NOT NULL, + inserted_at INTEGER NOT NULL, + event_kind TEXT NOT NULL, + message_id TEXT, + group_id_hex TEXT, + sender_fingerprint TEXT, + byte_length INTEGER NOT NULL, + action TEXT NOT NULL, + reason TEXT NOT NULL, + addressed_to_me INTEGER NOT NULL, + sender_trusted INTEGER NOT NULL, + matched_interests TEXT NOT NULL, + gate_rejected INTEGER NOT NULL, + allowed INTEGER NOT NULL + ); + CREATE INDEX IF NOT EXISTS idx_policy_audit_inserted_at + ON policy_audit(inserted_at DESC); + + UPDATE schema_version SET version = 10; + `, ]; export class AgentDatabase { @@ -288,7 +320,17 @@ export class AgentDatabase { // every known v5 shape converge before applying the shared v6 schema. if (currentVersion === 5) this.reconcileSchemaV5(); - for (let i = currentVersion; i < SCHEMA_VERSION; i++) { + let migrationStart = currentVersion; + // The historical policy branch used versions 2/3 for policy tables, + // independently of the discovery/ratchet migrations on main. + if ((currentVersion === 2 || currentVersion === 3) && + this.db.prepare("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'policy_config'").get()) { + const columns = this.db.prepare('PRAGMA table_info(groups)').all() as Array<{ name: string }>; + if (!columns.some(column => column.name === 'is_public')) this.db.exec(MIGRATIONS[1]); + this.db.exec(MIGRATIONS[2]); + migrationStart = 3; + } + for (let i = migrationStart; i < SCHEMA_VERSION; i++) { if (i === 5) { this.applyMigrationV6(); } else if (i === 6) { diff --git a/packages/node/src/storage/index.ts b/packages/node/src/storage/index.ts index 199abd2..92f6c84 100644 --- a/packages/node/src/storage/index.ts +++ b/packages/node/src/storage/index.ts @@ -28,3 +28,5 @@ export type { MessageQueryOptions, StoredGroupInvite, } from './repositories.js'; + +export * from './policy.js'; diff --git a/packages/node/src/storage/policy.ts b/packages/node/src/storage/policy.ts new file mode 100644 index 0000000..29b3015 --- /dev/null +++ b/packages/node/src/storage/policy.ts @@ -0,0 +1,366 @@ +import type Database from 'better-sqlite3'; +import type { + PolicyConfig, + PolicyAuditEntry, + PolicyAction, + PolicyReason, + InboundMessageKind, +} from '@networkselfmd/core'; +import { POLICY_REASONS } from '@networkselfmd/core'; + +export interface StoredPolicyConfig { + id: number; + trusted_fingerprints: string | null; + interests: string | null; + require_mention: number | null; + mention_prefix_len: number | null; + updated_at: number; +} + +// Owner-local policy configuration. Single-row table (id=1). NULL +// columns mean "field unset" — load() returns undefined for those so +// AgentPolicy's existing defaults apply unchanged. +// +// Privacy: this repo persists ONLY operator-supplied policy +// configuration (trusted fingerprints, interest keywords, mention +// behavior). It does NOT store message plaintext, decrypted bodies, +// raw event payloads, or any private key material — by design and by +// schema (no such columns exist on policy_config). +export class PolicyConfigRepository { + constructor(private db: Database.Database) {} + + load(): PolicyConfig | undefined { + const row = this.db + .prepare('SELECT * FROM policy_config WHERE id = 1') + .get() as StoredPolicyConfig | undefined; + if (!row) return undefined; + const config: PolicyConfig = {}; + if (row.trusted_fingerprints !== null) { + config.trustedFingerprints = parseStringArray(row.trusted_fingerprints); + } + if (row.interests !== null) { + config.interests = parseStringArray(row.interests); + } + if (row.require_mention !== null) { + config.requireMention = row.require_mention === 1; + } + if (row.mention_prefix_len !== null) { + config.mentionPrefixLen = row.mention_prefix_len; + } + return config; + } + + save(config: PolicyConfig): void { + const stmt = this.db.prepare( + `INSERT OR REPLACE INTO policy_config + (id, trusted_fingerprints, interests, require_mention, mention_prefix_len, updated_at) + VALUES (1, ?, ?, ?, ?, ?)`, + ); + stmt.run( + config.trustedFingerprints !== undefined + ? JSON.stringify(config.trustedFingerprints) + : null, + config.interests !== undefined ? JSON.stringify(config.interests) : null, + config.requireMention === undefined ? null : config.requireMention ? 1 : 0, + config.mentionPrefixLen ?? null, + Date.now(), + ); + } + + exists(): boolean { + const row = this.db + .prepare('SELECT 1 AS x FROM policy_config WHERE id = 1') + .get() as { x: number } | undefined; + return row !== undefined; + } + + clear(): void { + this.db.prepare('DELETE FROM policy_config WHERE id = 1').run(); + } +} + +function parseStringArray(json: string): string[] { + try { + const parsed: unknown = JSON.parse(json); + if (!Array.isArray(parsed)) return []; + return parsed.filter((v): v is string => typeof v === 'string'); + } catch { + // Corrupt row — return empty rather than throwing during load. + // setPolicyConfig will overwrite on next operator action. + return []; + } +} + +export interface StoredPolicyAuditRow { + audit_id: string; + received_at: number; + inserted_at: number; + event_kind: string; + message_id: string | null; + group_id_hex: string | null; + sender_fingerprint: string | null; + byte_length: number; + action: string; + reason: string; + addressed_to_me: number; + sender_trusted: number; + matched_interests: string; + gate_rejected: number; + allowed: number; +} + +// Bounds + clamps for the durable audit. Frozen so the constants cannot +// be mutated at runtime by buggy or hostile code. +export const POLICY_AUDIT_LIMITS = Object.freeze({ + // Default ring size for the persisted log. Larger than the in-memory + // bound (1000) because durability is the whole point — operators want + // to see decisions across restarts and a few hours of activity. + defaultMaxEntries: 5000, + // Hard cap on a single recent() / MCP / CLI fetch. Matches the existing + // MCP audit cap from PR #4 polish. + maxRecentLimit: 1000, +}); + +export interface PolicyAuditRepositoryOptions { + // Maximum number of rows retained on disk. Defaults to + // POLICY_AUDIT_LIMITS.defaultMaxEntries. Prune-on-insert evicts the + // oldest rows when a new insert would push the table over the cap. + maxEntries?: number; +} + +export interface PolicyAuditRecentOptions { + // Page size; clamped to POLICY_AUDIT_LIMITS.maxRecentLimit. + limit?: number; + // Page backward by inserted_at strictly less than this timestamp. + before?: number; +} + +export interface PolicyAuditPruneOptions { + // Override the per-call retention cap. Defaults to the repository's + // configured maxEntries. + maxEntries?: number; + // If set, also delete rows older than (now - olderThanMs). + olderThanMs?: number; +} + +// Owner-local, metadata-only audit log persisted in SQLite. +// +// Privacy: writes via explicit field projection (no Object.assign / +// spread of arbitrary entry data). The schema has no column for +// plaintext, content, body, payload, tool_args, or private_key +// material — a column-set test pins this. Reads reconstruct the typed +// PolicyAuditEntry the same way, so a corrupt matched_interests JSON +// degrades to [] rather than throwing through to public surfaces. +// +// The repository is the durable source of truth for operator reads +// (MCP, CLI, future dashboards). PolicyAuditLog (the in-memory ring) +// stays as a runtime convenience and event-emission conduit; it is +// not the cross-restart surface. +export class PolicyAuditRepository { + private maxEntries: number; + private insertStmt: Database.Statement; + private deleteOldestStmt: Database.Statement; + + constructor(private db: Database.Database, opts: PolicyAuditRepositoryOptions = {}) { + this.maxEntries = Math.max(1, opts.maxEntries ?? POLICY_AUDIT_LIMITS.defaultMaxEntries); + // Prepare hot-path statements once. INSERT OR REPLACE is defensive + // against an auditId collision (which createId makes vanishingly + // unlikely, but the row PK is intentional). + this.insertStmt = this.db.prepare( + `INSERT OR REPLACE INTO policy_audit ( + audit_id, received_at, inserted_at, event_kind, message_id, + group_id_hex, sender_fingerprint, byte_length, action, reason, + addressed_to_me, sender_trusted, matched_interests, gate_rejected, allowed + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + ); + this.deleteOldestStmt = this.db.prepare( + `DELETE FROM policy_audit WHERE audit_id IN ( + SELECT audit_id FROM policy_audit ORDER BY inserted_at ASC, audit_id ASC LIMIT ? + )`, + ); + } + + insert(entry: PolicyAuditEntry): void { + // Explicit projection — never spread the entry. New fields on + // PolicyAuditEntry will not auto-flow into the schema; adding a + // column requires a deliberate migration + change here. + // + // `allowed` is a DB-only derived column for analytics queries + // (e.g. `SELECT COUNT(*) FROM policy_audit WHERE allowed = 1`). + // It is intentionally NOT part of PolicyAuditEntry, the MCP DTO, + // or CLI output — the public vocabulary stays `action` + + // `gateRejected`, which together are sufficient to derive + // allowed-ness without expanding the operator-facing surface. + const insertedAt = Date.now(); + const allowed = entry.action !== 'ignore' && !entry.gateRejected ? 1 : 0; + this.insertStmt.run( + entry.auditId, + entry.receivedAt, + insertedAt, + entry.eventKind, + entry.messageId ?? null, + entry.groupIdHex ?? null, + entry.senderFingerprint ?? null, + entry.byteLength, + entry.action, + entry.reason, + entry.addressedToMe ? 1 : 0, + entry.senderTrusted ? 1 : 0, + JSON.stringify(entry.matchedInterests ?? []), + entry.gateRejected ? 1 : 0, + allowed, + ); + // Prune-on-insert. Cheap (one COUNT + at most one DELETE). + const surplus = this.count() - this.maxEntries; + if (surplus > 0) this.deleteOldestStmt.run(surplus); + } + + recent(opts: PolicyAuditRecentOptions = {}): PolicyAuditEntry[] { + const limit = clampLimit(opts.limit ?? 50); + let rows: StoredPolicyAuditRow[]; + if (opts.before !== undefined) { + rows = this.db + .prepare( + `SELECT * FROM policy_audit + WHERE inserted_at < ? + ORDER BY inserted_at DESC, audit_id DESC + LIMIT ?`, + ) + .all(opts.before, limit) as StoredPolicyAuditRow[]; + } else { + rows = this.db + .prepare( + `SELECT * FROM policy_audit + ORDER BY inserted_at DESC, audit_id DESC + LIMIT ?`, + ) + .all(limit) as StoredPolicyAuditRow[]; + } + return rows.map(rowToEntry); + } + + prune(opts: PolicyAuditPruneOptions = {}): number { + let deleted = 0; + if (opts.olderThanMs !== undefined) { + const cutoff = Date.now() - Math.max(0, opts.olderThanMs); + const result = this.db + .prepare(`DELETE FROM policy_audit WHERE inserted_at < ?`) + .run(cutoff); + deleted += result.changes; + } + const cap = Math.max(1, opts.maxEntries ?? this.maxEntries); + const surplus = this.count() - cap; + if (surplus > 0) { + const result = this.deleteOldestStmt.run(surplus); + deleted += result.changes; + } + return deleted; + } + + count(): number { + const row = this.db + .prepare(`SELECT COUNT(*) AS n FROM policy_audit`) + .get() as { n: number }; + return row.n; + } + + clear(): void { + this.db.prepare(`DELETE FROM policy_audit`).run(); + } +} + +function clampLimit(n: number): number { + if (!Number.isFinite(n) || n <= 0) return 1; + return Math.min(Math.floor(n), POLICY_AUDIT_LIMITS.maxRecentLimit); +} + +const VALID_KINDS: ReadonlySet = new Set([ + 'group', + 'dm', + 'unknown', +]); +const VALID_ACTIONS: ReadonlySet = new Set(['act', 'ask', 'ignore']); +const VALID_REASONS: ReadonlySet = new Set(POLICY_REASONS); + +// Fail-closed stub returned for any row whose enum / boolean / numeric +// fields are out of spec. Preserves auditId + receivedAt so operators +// can correlate with the underlying corrupt row, and forces every +// other public field into a safe metadata-only state. The reason token +// `malformed-event` is the same one the gate uses for structurally +// invalid inbound events; reusing it here keeps the public vocabulary +// stable. +function corruptStub(row: StoredPolicyAuditRow): PolicyAuditEntry { + return { + auditId: + typeof row.audit_id === 'string' && row.audit_id.length > 0 + ? row.audit_id + : 'corrupt', + receivedAt: Number.isFinite(row.received_at) ? row.received_at : 0, + eventKind: 'unknown', + messageId: undefined, + groupIdHex: undefined, + senderFingerprint: undefined, + byteLength: 0, + action: 'ignore', + reason: 'malformed-event', + addressedToMe: false, + senderTrusted: false, + matchedInterests: [], + gateRejected: true, + }; +} + +function isValidBoolInt(n: unknown): boolean { + return n === 0 || n === 1; +} + +function isNonNegativeFiniteInt(n: unknown): n is number { + return typeof n === 'number' && Number.isFinite(n) && Number.isInteger(n) && n >= 0; +} + +// Reconstruct a typed entry via explicit field-by-field projection AND +// validate every enum / boolean / numeric field. Corrupt rows are +// mapped to a fail-closed metadata-only stub (eventKind=unknown, +// action=ignore, reason=malformed-event, gateRejected=true) — a +// corrupt row never surfaces as if it were a normal allowed/blocked +// decision. matched_interests JSON is already defensively parsed by +// parseStringArray; corrupt JSON degrades to [] without throwing. +function rowToEntry(row: StoredPolicyAuditRow): PolicyAuditEntry { + const eventKind = VALID_KINDS.has(row.event_kind as InboundMessageKind | 'unknown') + ? (row.event_kind as PolicyAuditEntry['eventKind']) + : null; + const action = VALID_ACTIONS.has(row.action as PolicyAction) + ? (row.action as PolicyAction) + : null; + const reason = VALID_REASONS.has(row.reason as PolicyReason) + ? (row.reason as PolicyReason) + : null; + + if ( + eventKind === null || + action === null || + reason === null || + !isValidBoolInt(row.addressed_to_me) || + !isValidBoolInt(row.sender_trusted) || + !isValidBoolInt(row.gate_rejected) || + !isNonNegativeFiniteInt(row.byte_length) + ) { + return corruptStub(row); + } + + return { + auditId: row.audit_id, + receivedAt: row.received_at, + eventKind, + messageId: row.message_id ?? undefined, + groupIdHex: row.group_id_hex ?? undefined, + senderFingerprint: row.sender_fingerprint ?? undefined, + byteLength: row.byte_length, + action, + reason, + addressedToMe: row.addressed_to_me === 1, + senderTrusted: row.sender_trusted === 1, + matchedInterests: parseStringArray(row.matched_interests), + gateRejected: row.gate_rejected === 1, + }; +}