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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion docs/MCP.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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.
468 changes: 468 additions & 0 deletions docs/POLICY.md

Large diffs are not rendered by default.

28 changes: 28 additions & 0 deletions docs/releases/2026-09-07-policy-port.md
Original file line number Diff line number Diff line change
@@ -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.
12 changes: 12 additions & 0 deletions packages/cli/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <fingerprint>
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.
182 changes: 182 additions & 0 deletions packages/cli/src/__tests__/policy-audit.test.ts
Original file line number Diff line number Diff line change
@@ -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\(\)/);
});
});
23 changes: 23 additions & 0 deletions packages/cli/src/__tests__/policy-cli.test.ts
Original file line number Diff line number Diff line change
@@ -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 }); }
});
Loading
Loading