Skill: jameseball/jameseball-clawdio (version 1.0.0)

--- name: clawdio version: 1.0.0 description: Secure P2P communication for AI agents. Noise XX handshake, XChaCha20-Poly1305 encryption, connection consent, human verification. Zero central servers. --- # Clawdio Minimal secure peer-to-peer communication for AI agents. Two agents exchange a connection string, perform a Noise XX handshake, then communicate over encrypted channels. No central server required. ## When to Use - Agent-to-agent communication across machines or networks - Secure task delegation between sub-agents on different hosts - Any scenario requiring encrypted, authenticated P2P messaging ## Setup The Clawdio project lives at `projects/clawdio/`. Install dependencies and build: ```bash cd projects/clawdio && npm install && npx tsc ``` ## Quick Start ```javascript const { Clawdio } = require('./projects/clawdio/dist/index.js'); // Create two nodes const alice = await Clawdio.create({ port: 9090, autoAccept: true }); const bob = await Clawdio.create({ port: 9091, autoAccept: true }); // Connect (Noise XX handshake) const aliceId = await bob.exchangeKeys(alice.getConnectionString()); // Send messages await bob.send(aliceId, { task: "What's the weather?" }); alice.onMessage((msg, from) => console.log(msg.task)); ``` ## Connection Consent (Recommended) By default, unknown inbound peers require explicit consent: ```javascript const node = await Clawdio.create({ port: 9090 }); // autoAccept defaults to false node.on('connectionRequest', (req) => { console.log(`Connection from ${req.id}`); console.log(`Fingerprint: ${req.fingerprint}`); // Accept or reject node.acceptPeer(req.id); // or node.rejectPeer(req.id) }); ``` Outbound connections (you calling `exchangeKeys`) are auto-accepted. Already-trusted peers auto-reconnect. ## Human Verification For high-trust scenarios, verify peers in person: ```javascript node.setOwner('Alice'); const code = node.getVerificationCode(peerId); // "torch lemon onyx prism jade index" // Both humans compare codes in person, then: node.verifyPeer(peerId); // trust: 'accepted' → 'human-verified' node.getPeerTrust(peerId); // 'human-verified' ``` ## Trust Levels - `pending` — connection request received, not yet accepted - `accepted` — peer accepted, encrypted communication active - `human-verified` — verified via in-person code exchange ## Persistent Identity Pass `identityPath` to persist keys and trusted peers across restarts: ```javascript const node = await Clawdio.create({ port: 9090, identityPath: '.clawdio-identity.json' }); ``` ## Sub-Agent Pattern Spawn a sub-agent to handle Clawdio communication: ``` 1. Main agent spawns sub-agent with task 2. Sub-agent creates Clawdio node, connects to remote peer 3. Sub-agent exchanges messages, collects results 4. Sub-agent reports back to main agent ``` ## Security Properties - Forward secrecy (ephemeral X25519 keys) - Mutual authentication (Noise XX) - Replay protection (monotonic counters) - XChaCha20-Poly1305 AEAD encryption - Connection consent for inbound peers ## API Reference | Method | Description | |--------|-------------| | `Clawdio.create(opts)` | Create and start a node | | `node.exchangeKeys(connStr)` | Connect to peer | | `node.send(peerId, msg)` | Send encrypted message | | `node.onMessage(handler)` | Listen for messages | | `node.acceptPeer(id)` | Accept pending connection | | `node.rejectPeer(id)` | Reject pending connection | | `node.setOwner(name)` | Set human owner name | | `node.getVerificationCode(id)` | Get 6-word verification code | | `node.verifyPeer(id)` | Mark peer as human-verified | | `node.getPeerTrust(id)` | Get trust level | | `node.getFingerprint(id)` | Emoji fingerprint | | `node.getPeerStatus(id)` | alive/stale/down | | `node.stop()` | Shutdown |

path: package.json
content: {
  "name": "clawdio",
  "version": "0.1.0",
  "description": "Minimal secure P2P communication for AI agents",
  "main": "dist/index.js",
  "types": "dist/index.d.ts",
  "bin": {
    "clawdio": "dist/cli.js"
  },
  "scripts": {
    "build": "tsc",
    "dev": "tsc --watch",
    "test": "node --test dist/__tests__/*.js"
  },
  "files": [
    "dist"
  ],
  "keywords": ["p2p", "agent", "communication", "encrypted", "websocket"],
  "license": "MIT",
  "dependencies": {
    "commander": "^12.1.0",
    "libsodium-wrappers": "^0.7.15",
    "ws": "^8.18.0"
  },
  "devDependencies": {
    "@types/libsodium-wrappers": "^0.7.14",
    "@types/ws": "^8.5.13",
    "typescript": "^5.7.0"
  }
}


path: src/__tests__/basic.test.ts
content: import { describe, it } from 'node:test';
import assert from 'node:assert';
import { Clawdio } from '../index.js';

describe('Clawdio P2P', () => {
  it('should perform Noise XX handshake and send encrypted messages', async () => {
    const alice = await Clawdio.create({ port: 19090 });
    const bob = await Clawdio.create({ port: 19091 });

    // Alice auto-accepts bob via connectionRequest handler
    alice.on('connectionRequest', (req: any) => alice.acceptPeer(req.id));

    const connStr = alice.getConnectionString('127.0.0.1');
    const aliceId = await bob.exchangeKeys(connStr);

    await new Promise(r => setTimeout(r, 200));

    // Fingerprints should match on both sides
    const fpBob = bob.getFingerprint(aliceId);
    const fpAlice = alice.getFingerprint(bob.publicKey);
    assert.strictEqual(fpBob, fpAlice, 'Fingerprints should match');
    assert.ok(fpBob.length > 0, 'Fingerprint should not be empty');

    // Safety numbers should also match
    const snBob = bob.getSafetyNumber(aliceId);
    const snAlice = alice.getSafetyNumber(bob.publicKey);
    assert.strictEqual(snBob, snAlice, 'Safety numbers should match');

    // Send message bob → alice
    const received = new Promise<{ msg: any; from: string }>((resolve) => {
      alice.onMessage((msg, from) => resolve({ msg, from }));
    });
    await bob.send(aliceId, { task: 'ping', status: 'ok' });
    const { msg, from } = await received;
    assert.strictEqual(msg.task, 'ping');
    assert.strictEqual(msg.status, 'ok');
    assert.strictEqual(from, bob.publicKey);

    // Reverse direction alice → bob
    const received2 = new Promise<{ msg: any; from: string }>((resolve) => {
      bob.onMessage((msg, from) => resolve({ msg, from }));
    });
    await alice.send(bob.publicKey, { result: 'pong', status: 'ok' });
    const r2 = await received2;
    assert.strictEqual(r2.msg.result, 'pong');

    await alice.stop();
    await bob.stop();
  });
});


path: src/cli.ts
content: #!/usr/bin/env node
import { Command } from 'commander';
import { Clawdio } from './index.js';

const program = new Command();

program
  .name('clawdio')
  .description('Minimal secure P2P agent communication')
  .version('0.1.0');

program
  .command('start')
  .description('Start a Clawdio node and print connection string')
  .option('-p, --port <port>', 'Port to listen on', '9090')
  .option('-h, --host <host>', 'External host/IP for connection string', '127.0.0.1')
  .action(async (opts) => {
    const node = await Clawdio.create({ port: parseInt(opts.port) });
    const connStr = node.getConnectionString(opts.host);
    console.log(`\n🔑 Your connection string:\n${connStr}\n`);
    console.log('Waiting for connections... (Ctrl+C to stop)\n');
    node.on('peer', (id: string) => {
      console.log(`✅ Peer connected: ${id.slice(0, 16)}...`);
      console.log(`🔒 Fingerprint: ${node.getFingerprint(id)}`);
    });
    node.onMessage((msg, from) => {
      console.log(`📨 [${from.slice(0, 8)}...]: ${JSON.stringify(msg)}`);
    });
  });

program
  .command('connect <connectionString>')
  .description('Connect to a peer')
  .option('-p, --port <port>', 'Local port to listen on', '9091')
  .action(async (connStr, opts) => {
    const node = await Clawdio.create({ port: parseInt(opts.port) });
    console.log(`\n🔑 Your ID: ${node.publicKey.slice(0, 16)}...`);
    const peerId = await node.exchangeKeys(connStr);
    console.log(`✅ Connected to: ${peerId.slice(0, 16)}...`);
    console.log(`🔒 Fingerprint: ${node.getFingerprint(peerId)}`);
    node.onMessage((msg, from) => {
      console.log(`📨 [${from.slice(0, 8)}...]: ${JSON.stringify(msg)}`);
    });
    process.stdin.setEncoding('utf-8');
    process.stdout.write('\n> ');
    process.stdin.on('data', async (data) => {
      const text = data.toString().trim();
      if (text) { await node.send(peerId, { task: text }); console.log('Sent ✓'); }
      process.stdout.write('> ');
    });
  });

program.parse();


path: src/crypto.ts
content: import sodium from 'libsodium-wrappers';

let ready = false;
export async function init(): Promise<void> {
  if (!ready) { await sodium.ready; ready = true; }
}

export interface KeyPair { publicKey: Uint8Array; secretKey: Uint8Array; }

export function generateKeyPair(): KeyPair {
  const kp = sodium.crypto_box_keypair();
  return { publicKey: kp.publicKey, secretKey: kp.privateKey };
}

/** HKDF-like extract-and-expand using BLAKE2b */
function hkdf(ck: Uint8Array, ikm: Uint8Array): [Uint8Array, Uint8Array] {
  const tempKey = sodium.crypto_generichash(64, ikm, ck);
  const out1 = sodium.crypto_generichash(32, new Uint8Array([0x01]), tempKey);
  const buf = new Uint8Array(33); buf.set(out1); buf[32] = 0x02;
  const out2 = sodium.crypto_generichash(32, buf, tempKey);
  return [out1, out2];
}

function dh([REDACTED_SECRET], pub: Uint8Array): Uint8Array {
  return sodium.crypto_scalarmult(secret, pub);
}

/** Encrypt with XChaCha20-Poly1305 using a zero nonce (single use per key in Noise) */
function aead_encrypt(key: Uint8Array, nonce_counter: number, ad: Uint8Array, plaintext: Uint8Array): Uint8Array {
  const nonce = new Uint8Array(24);
  new DataView(nonce.buffer).setUint32(16, nonce_counter, true); // put counter in last 8 bytes area
  return sodium.crypto_aead_xchacha20poly1305_ietf_encrypt(plaintext, ad, null, nonce, key);
}

function aead_decrypt(key: Uint8Array, nonce_counter: number, ad: Uint8Array, ciphertext: Uint8Array): Uint8Array {
  const nonce = new Uint8Array(24);
  new DataView(nonce.buffer).setUint32(16, nonce_counter, true);
  return sodium.crypto_aead_xchacha20poly1305_ietf_decrypt(null, ciphertext, ad, nonce, key);
}

/**
 * Noise_XX handshake: -> e | <- e, ee, s, es | -> s, se
 * Forward secrecy + mutual authentication.
 */
export class NoiseXXHandshake {
  private ck: Uint8Array;     // chaining key
  private h: Uint8Array;      // handshake hash
  private k: Uint8Array | null = null; // cipher key (null = no encryption yet)
  private n = 0;              // nonce counter

  private localStatic: KeyPair;
  private localEphemeral!: KeyPair;
  private remoteStaticPub: Uint8Array | null = null;
  private remoteEphemeralPub: Uint8Array | null = null;

  constructor(staticKeyPair: KeyPair) {
    this.localStatic = staticKeyPair;
    const protocolName = sodium.from_string([REDACTED_SECRET]);
    this.h = sodium.crypto_generichash(32, protocolName);
    this.ck = new Uint8Array(this.h);
  }

  private mixHash(data: Uint8Array): void {
    const buf = new Uint8Array(this.h.length + data.length);
    buf.set(this.h); buf.set(data, this.h.length);
    this.h = sodium.crypto_generichash(32, buf);
  }

  private mixKey(ikm: Uint8Array): void {
    const [ck, k] = hkdf(this.ck, ikm);
    this.ck = ck;
    this.k = k;
    this.n = 0;
  }

  private encryptAndHash(plaintext: Uint8Array): Uint8Array {
    if (!this.k) { this.mixHash(plaintext); return plaintext; }
    const ct = aead_encrypt(this.k, this.n++, this.h, plaintext);
    this.mixHash(ct);
    return ct;
  }

  private decryptAndHash(ciphertext: Uint8Array): Uint8Array {
    if (!this.k) { this.mixHash(ciphertext); return ciphertext; }
    const pt = aead_decrypt(this.k, this.n++, this.h, ciphertext);
    this.mixHash(ciphertext);
    return pt;
  }

  // --- Initiator ---

  /** Msg 1: -> e */
  writeMessage1(): Uint8Array {
    this.localEphemeral = generateKeyPair();
    this.mixHash(this.localEphemeral.publicKey);
    return this.localEphemeral.publicKey; // 32 bytes
  }

  /** Msg 2 received: <- e, ee, s, es */
  readMessage2(msg: Uint8Array): void {
    // e
    this.remoteEphemeralPub = msg.slice(0, 32);
    this.mixHash(this.remoteEphemeralPub);
    // ee
    this.mixKey(dh(this.localEphemeral.secretKey, this.remoteEphemeralPub));
    // s (encrypted)
    const encS = msg.slice(32);
    this.remoteStaticPub = this.decryptAndHash(encS);
    // es
    this.mixKey(dh(this.localEphemeral.secretKey, this.remoteStaticPub));
  }

  /** Msg 3: -> s, se */
  writeMessage3(): Uint8Array {
    const encS = this.encryptAndHash(this.localStatic.publicKey);
    // se
    this.mixKey(dh(this.localStatic.secretKey, this.remoteEphemeralPub!));
    return encS;
  }

  // --- Responder ---

  /** Msg 1 received: -> e */
  readMessage1(msg: Uint8Array): void {
    this.remoteEphemeralPub = msg.slice(0, 32);
    this.mixHash(this.remoteEphemeralPub);
  }

  /** Msg 2: <- e, ee, s, es */
  writeMessage2(): Uint8Array {
    // e
    this.localEphemeral = generateKeyPair();
    this.mixHash(this.localEphemeral.publicKey);
    // ee
    this.mixKey(dh(this.localEphemeral.secretKey, this.remoteEphemeralPub!));
    // s
    const encS = this.encryptAndHash(this.localStatic.publicKey);
    // es
    this.mixKey(dh(this.localStatic.secretKey, this.remoteEphemeralPub!));
    // output: e_pub || encrypted_s
    const out = new Uint8Array(32 + encS.length);
    out.set(this.localEphemeral.publicKey);
    out.set(encS, 32);
    return out;
  }

  /** Msg 3 received: -> s, se */
  readMessage3(msg: Uint8Array): void {
    this.remoteStaticPub = this.decryptAndHash(msg);
    // se
    this.mixKey(dh(this.localEphemeral.secretKey, this.remoteStaticPub));
  }

  /** Split into transport keys. Call after handshake completes. */
  getTransportKeys(isInitiator: boolean): { sendKey: Uint8Array; recvKey: Uint8Array } {
    const [k1, k2] = hkdf(this.ck, new Uint8Array(0));
    return isInitiator ? { sendKey: k1, recvKey: k2 } : { sendKey: k2, recvKey: k1 };
  }

  getRemoteStaticKey(): Uint8Array {
    if (!this.remoteStaticPub) throw new Error('Handshake not complete');
    return this.remoteStaticPub;
  }
}

// --- Fingerprint verification ---

export function fingerprint(key1: Uint8Array, key2: Uint8Array): string {
  const sorted = Buffer.compare(Buffer.from(key1), Buffer.from(key2)) < 0
    ? new Uint8Array([...key1, ...key2]) : new Uint8Array([...key2, ...key1]);
  const hash = sodium.crypto_generichash(8, sorted);
  const emojis = ['🔑','🛡','🔒','🌟','⚡','🎯','🚀','🔥','💎','🌈','🎪','🎭','🦊','🐉','🌙','☀️','🌊','🏔','🎸','🎹','🎲','🏆','🌸','🍀','🔔','🎨','🧭','🦋','🐝','🌻','🍎','🎵'];
  return Array.from(hash).map(b => emojis[b % emojis.length]).join('');
}

export function safetyNumber(key1: Uint8Array, key2: Uint8Array): string {
  const sorted = Buffer.compare(Buffer.from(key1), Buffer.from(key2)) < 0
    ? new Uint8Array([...key1, ...key2]) : new Uint8Array([...key2, ...key1]);
  const hash = sodium.crypto_generichash(16, sorted);
  return sodium.to_hex(hash).match(/.{4}/g)!.join('-');
}

// --- Transport encryption with replay protection ---

export class TransportCipher {
  private sendCounter = 0n;
  private recvCounter = -1n;

  constructor(private sendKey: Uint8Array, private recvKey: Uint8Array) {}

  encrypt(plaintext: string): { ciphertext: Uint8Array; nonce: Uint8Array; counter: string } {
    const counter = this.sendCounter++;
    const ad = new Uint8Array(8);
    new DataView(ad.buffer).setBigUint64(0, counter);
    const nonce = sodium.randombytes_buf(sodium.crypto_aead_xchacha20poly1305_ietf_NPUBBYTES);
    const ciphertext = sodium.crypto_aead_xchacha20poly1305_ietf_encrypt(
      sodium.from_string(plaintext), ad, null, nonce, this.sendKey
    );
    return { ciphertext, nonce, counter: counter.toString() };
  }

  decrypt(ciphertext: Uint8Array, nonce: Uint8Array, counterStr: string): string {
    const counter = BigInt(counterStr);
    if (counter <= this.recvCounter) throw new Error(`Replay: counter ${counter} <= ${this.recvCounter}`);
    const ad = new Uint8Array(8);
    new DataView(ad.buffer).setBigUint64(0, counter);
    const pt = sodium.crypto_aead_xchacha20poly1305_ietf_decrypt(null, ciphertext, ad, nonce, this.recvKey);
    this.recvCounter = counter;
    return sodium.to_string(pt);
  }
}

// --- Encoding helpers ---
export function toHex(buf: Uint8Array): string { return sodium.to_hex(buf); }
export function fromHex(hex: string): Uint8Array { return sodium.from_hex(hex); }
export function toBase64(buf: Uint8Array): string { return sodium.to_base64(buf, sodium.base64_variants.ORIGINAL); }
export function fromBase64(b64: string): Uint8Array { return sodium.from_base64(b64, sodium.base64_variants.ORIGINAL); }


path: src/index.ts
content: import { EventEmitter } from 'events';
import * as fs from 'fs';
import * as path from 'path';
import { Transport } from './transport.js';
import { NoiseXXHandshake, TransportCipher, KeyPair, fingerprint, safetyNumber,
         toHex, fromHex, toBase64, fromBase64, init, generateKeyPair } from './crypto.js';
import { encodeWire, decodeWire, parseConnectionString, buildConnectionString } from './protocol.js';
import { ClawdioOptions, AgentMessage, Peer, MessageHandler, PeerStatus, ConnectionRequest,
         TrustLevel, TrustedPeerRecord, IdentityData } from './types.js';
import type WebSocket from 'ws';

export { AgentMessage, ClawdioOptions, PeerStatus, ConnectionRequest, TrustLevel, TrustedPeerRecord, IdentityData } from './types.js';

interface PendingHandshake {
  noise: NoiseXXHandshake;
  isInitiator: boolean;
}

interface PendingConsent {
  peer: Peer;
  ws: WebSocket;
  messageQueue: Array<{ payload: string; nonce: string; counter: string }>;
}

// Word list for verification codes (BIP39 subset)
const VERIFY_WORDS = [
  'apple','brave','coral','delta','eagle','flame','grape','haven','ivory','jewel',
  'karma','lemon','maple','night','ocean','pearl','quilt','river','storm','tiger',
  'ultra','vivid','waltz','xenon','yield','zephyr','amber','blaze','cedar','drift',
  'ember','frost','globe','haste','index','joker','kneel','lunar','marsh','noble',
  'orbit','prism','quest','reign','sonic','torch','umbra','vigor','wheat','xenial',
  'yacht','azure','birch','crane','dusk','elfin','flora','glyph','heron','isle',
  'jade','kite','lotus','mirth','nexus','onyx',
];

export class Clawdio extends EventEmitter {
  private transport = new Transport();
  private keyPair!: KeyPair;
  private peers = new Map<string, Peer>();
  private pending = new Map<WebSocket, PendingHandshake>();
  private pendingConsent = new Map<string, PendingConsent>();
  private trustedPeers = new Map<string, TrustedPeerRecord>();
  private owner?: string;
  private port: number;
  private host: string;
  private identityPath?: string;
  private autoAccept: boolean;
  private peerLastSeen = new Map<string, number>();
  private heartbeatInterval: ReturnType<typeof setInterval> | null = null;
  private heartbeatMs: number;
  private heartbeatTimeout: number;

  constructor(private opts: ClawdioOptions = {}) {
    super();
    this.port = opts.port ?? 9090;
    this.host = opts.host ?? '0.0.0.0';
    this.heartbeatMs = opts.heartbeatMs ?? 5000;
    this.heartbeatTimeout = opts.heartbeatTimeout ?? 15000;
    this.identityPath = opts.identityPath;
    this.autoAccept = opts.autoAccept ?? false;
  }

  static async create(opts?: ClawdioOptions): Promise<Clawdio> {
    const node = new Clawdio(opts);
    await node.start();
    return node;
  }

  get publicKey(): string { return toHex(this.keyPair.publicKey); }
  get peerId(): string { return this.publicKey; }

  async start(): Promise<void> {
    await init();
    if (this.identityPath && fs.existsSync(this.identityPath)) {
      const data: IdentityData = JSON.parse(fs.readFileSync(this.identityPath, 'utf8'));
      this.keyPair = { publicKey: fromHex(data.publicKey), secretKey: fromHex(data.secretKey) };
      this.owner = data.owner;
      for (const tp of (data.trustedPeers ?? [])) {
        this.trustedPeers.set(tp.id, tp);
      }
    } else {
      this.keyPair = generateKeyPair();
    }
    await this.transport.listen(this.port, this.host);
    this.transport.on('data', (raw: string, ws: WebSocket) => this.handleData(raw, ws));
    this.startHeartbeat();
    if (this.identityPath) this.saveIdentity();
  }

  // --- Owner & Verification ---

  setOwner(name: string): void {
    this.owner = name;
    this.saveIdentity();
  }

  getOwner(): string | undefined { return this.owner; }

  /** Generate a 6-word verification code from both peers' static keys */
  getVerificationCode(peerId: string): string {
    const peer = this.peers.get(peerId);
    if (!peer) throw new Error(`Unknown peer: ${peerId}`);
    const key1 = this.keyPair.publicKey;
    const key2 = peer.staticKey;
    // Deterministic: sort keys, hash, pick 6 words
    const sorted = Buffer.compare(Buffer.from(key1), Buffer.from(key2)) < 0
      ? new Uint8Array([...key1, ...key2]) : new Uint8Array([...key2, ...key1]);
    // Use crypto_generichash via the safetyNumber approach
    const { crypto_generichash } = require('libsodium-wrappers');
    const hash = crypto_generichash(12, sorted); // 12 bytes = 6 words (2 bytes per word index)
    const words: string[] = [];
    for (let i = 0; i < 12; i += 2) {
      const idx = ((hash[i] << 8) | hash[i + 1]) % VERIFY_WORDS.length;
      words.push(VERIFY_WORDS[idx]);
    }
    return words.join(' ');
  }

  /** Mark peer as human-verified after in-person code exchange */
  verifyPeer(peerId: string): void {
    const rec = this.trustedPeers.get(peerId);
    if (!rec) throw new Error(`Peer not trusted: ${peerId}`);
    rec.trust = 'human-verified';
    this.saveIdentity();
  }

  /** Get trust level of a peer */
  getPeerTrust(peerId: string): TrustLevel | null {
    const rec = this.trustedPeers.get(peerId);
    return rec?.trust ?? null;
  }

  // --- Connection Consent ---

  /** Accept a pending peer connection */
  acceptPeer(peerId: string): void {
    const pc = this.pendingConsent.get(peerId);
    if (!pc) throw new Error(`No pending connection from: ${peerId}`);
    // Register as accepted trusted peer
    this.trustedPeers.set(peerId, {
      id: peerId, trust: 'accepted', addedAt: new Date().toISOString()
    });
    // Finalize the peer
    this.peers.set(peerId, pc.peer);
    this.peerLastSeen.set(peerId, Date.now());
    this.transport.registerSocket(peerId, pc.ws);
    this.pendingConsent.delete(peerId);
    this.saveIdentity();
    this.emit('peer', peerId);
    this.emit('_peerWs', peerId, pc.ws);
    // Drain queued messages
    for (const qm of pc.messageQueue) {
      this.handleEncryptedMessage(pc.peer, qm);
    }
  }

  /** Reject a pending peer connection */
  rejectPeer(peerId: string): void {
    const pc = this.pendingConsent.get(peerId);
    if (!pc) throw new Error(`No pending connection from: ${peerId}`);
    pc.ws.close();
    this.pendingConsent.delete(peerId);
    this.emit('peerRejected', peerId);
  }

  // --- Core ---

  private startHeartbeat(): void {
    this.heartbeatInterval = setInterval(() => {
      const now = Date.now();
      for (const [id, peer] of this.peers) {
        const ws = peer.ws ?? this.transport.getSocket(id);
        if (ws) this.transport.send(ws, encodeWire({ type: 'ping', payload: now.toString() }));
        const last = this.peerLastSeen.get(id);
        if (last && now - last > this.heartbeatTimeout) {
          this.emit('peerDown', id);
        }
      }
    }, this.heartbeatMs);
  }

  getPeerStatus(peerId: string): PeerStatus {
    const last = this.peerLastSeen.get(peerId);
    if (!last) return 'down';
    const elapsed = Date.now() - last;
    if (elapsed <= this.heartbeatMs * 2) return 'alive';
    if (elapsed <= this.heartbeatTimeout) return 'stale';
    return 'down';
  }

  getConnectionString(externalHost?: string): string {
    return buildConnectionString(this.publicKey, externalHost ?? '127.0.0.1', this.port);
  }

  /** Initiate Noise XX handshake */
  async exchangeKeys(connStr: string): Promise<string> {
    const { address } = parseConnectionString(connStr);
    const ws = await this.transport.connect(address);
    const noise = new NoiseXXHandshake(this.keyPair);
    const msg1 = noise.writeMessage1();
    this.pending.set(ws, { noise, isInitiator: true });
    this.transport.send(ws, encodeWire({ type: 'noise1', payload: toBase64(msg1) }));

    return new Promise((resolve, reject) => {
      const timeout = setTimeout(() => reject(new Error('Handshake timeout')), 10000);
      const onPeer = (id: string, peerWs: WebSocket) => {
        if (peerWs === ws) { clearTimeout(timeout); this.removeListener('_peerWs', onPeer); resolve(id); }
      };
      this.on('_peerWs', onPeer);
    });
  }

  async send(peerId: string, message: AgentMessage | string): Promise<void> {
    const peer = this.peers.get(peerId);
    if (!peer) throw new Error(`Unknown peer: ${peerId}`);
    const payload = typeof message === 'string' ? JSON.stringify({ task: message }) : JSON.stringify(message);
    const { ciphertext, nonce, counter } = peer.cipher.encrypt(payload);
    const ws = peer.ws ?? this.transport.getSocket(peerId);
    if (!ws) throw new Error(`No connection to peer: ${peerId}`);
    this.transport.send(ws, encodeWire({
      type: 'message', payload: toBase64(ciphertext), nonce: toBase64(nonce), counter
    }));
  }

  onMessage(handler: MessageHandler): void { this.on('message', handler); }

  getFingerprint(peerId: string): string {
    const peer = this.peers.get(peerId);
    if (!peer) throw new Error(`Unknown peer: ${peerId}`);
    return fingerprint(this.keyPair.publicKey, peer.staticKey);
  }

  getSafetyNumber(peerId: string): string {
    const peer = this.peers.get(peerId);
    if (!peer) throw new Error(`Unknown peer: ${peerId}`);
    return safetyNumber(this.keyPair.publicKey, peer.staticKey);
  }

  private handleData(raw: string, ws: WebSocket): void {
    try {
      const wire = decodeWire(raw);
      if (wire.type === 'ping') {
        this.transport.send(ws, encodeWire({ type: 'pong', payload: wire.payload }));
        this.touchPeer(ws);
      } else if (wire.type === 'pong') {
        this.touchPeer(ws);
      } else if (wire.type === 'noise1') this.handleNoise1(ws, fromBase64(wire.payload));
      else if (wire.type === 'noise2') this.handleNoise2(ws, fromBase64(wire.payload));
      else if (wire.type === 'noise3') this.handleNoise3(ws, fromBase64(wire.payload));
      else if (wire.type === 'message') this.handleMessage(ws, wire);
    } catch (e) { this.emit('error', e); }
  }

  /** Responder: receives → e, sends ← e, ee, s, es */
  private handleNoise1(ws: WebSocket, msg1: Uint8Array): void {
    const noise = new NoiseXXHandshake(this.keyPair);
    noise.readMessage1(msg1);
    const msg2 = noise.writeMessage2();
    this.pending.set(ws, { noise, isInitiator: false });
    this.transport.send(ws, encodeWire({ type: 'noise2', payload: toBase64(msg2) }));
  }

  /** Initiator: receives ← e, ee, s, es; sends → s, se */
  private handleNoise2(ws: WebSocket, msg2: Uint8Array): void {
    const hs = this.pending.get(ws);
    if (!hs) throw new Error('No pending handshake');
    hs.noise.readMessage2(msg2);
    const msg3 = hs.noise.writeMessage3();
    this.transport.send(ws, encodeWire({ type: 'noise3', payload: toBase64(msg3) }));
    this.finishHandshake(ws, hs);
  }

  /** Responder: receives → s, se */
  private handleNoise3(ws: WebSocket, msg3: Uint8Array): void {
    const hs = this.pending.get(ws);
    if (!hs) throw new Error('No pending handshake');
    hs.noise.readMessage3(msg3);
    this.finishHandshake(ws, hs);
  }

  private touchPeer(ws: WebSocket): void {
    const peer = [...this.peers.values()].find(p => p.ws === ws || this.transport.getSocket(p.id) === ws);
    if (peer) this.peerLastSeen.set(peer.id, Date.now());
  }

  private finishHandshake(ws: WebSocket, hs: PendingHandshake): void {
    this.pending.delete(ws);
    const remoteStatic = hs.noise.getRemoteStaticKey();
    const { sendKey, recvKey } = hs.noise.getTransportKeys(hs.isInitiator);
    const id = toHex(remoteStatic);
    const peer: Peer = {
      id, staticKey: remoteStatic,
      cipher: new TransportCipher(sendKey, recvKey),
      address: '', ws
    };

    // Check if this peer is already trusted or if autoAccept is on
    const isTrusted = this.trustedPeers.has(id);
    const isInitiator = hs.isInitiator;

    if (isTrusted || isInitiator || this.autoAccept) {
      // Auto-accept: trusted peers, outbound connections, or autoAccept mode
      this.peers.set(id, peer);
      this.peerLastSeen.set(id, Date.now());
      this.transport.registerSocket(id, ws);
      if (!isTrusted) {
        this.trustedPeers.set(id, {
          id, trust: 'accepted', addedAt: new Date().toISOString()
        });
        this.saveIdentity();
      }
      this.emit('peer', id);
      this.emit('_peerWs', id, ws);
    } else {
      // Unknown inbound peer — require consent
      this.pendingConsent.set(id, { peer, ws, messageQueue: [] });
      const fp = fingerprint(this.keyPair.publicKey, remoteStatic);
      const sn = safetyNumber(this.keyPair.publicKey, remoteStatic);
      const req: ConnectionRequest = { id, fingerprint: fp, safetyNumber: sn, address: '' };
      this.emit('connectionRequest', req);
    }
  }

  private handleMessage(ws: WebSocket, wire: { payload: string; nonce?: string; counter?: string }): void {
    // Check if message is from a peer in pending consent
    const pendingEntry = [...this.pendingConsent.entries()].find(([_, pc]) => pc.ws === ws);
    if (pendingEntry) {
      // Queue the message
      if (wire.nonce && wire.counter) {
        pendingEntry[1].messageQueue.push({ payload: wire.payload, nonce: wire.nonce, counter: wire.counter });
      }
      return;
    }

    const peer = [...this.peers.values()].find(p => p.ws === ws || this.transport.getSocket(p.id) === ws);
    if (!peer) { this.emit('error', new Error('Message from unknown peer')); return; }
    if (!wire.nonce || !wire.counter) { this.emit('error', new Error('Missing nonce/counter')); return; }
    this.handleEncryptedMessage(peer, { payload: wire.payload, nonce: wire.nonce, counter: wire.counter });
  }

  private handleEncryptedMessage(peer: Peer, wire: { payload: string; nonce: string; counter: string }): void {
    const plaintext = peer.cipher.decrypt(fromBase64(wire.payload), fromBase64(wire.nonce), wire.counter);
    const msg: AgentMessage = JSON.parse(plaintext);
    this.emit('message', msg, peer.id);
  }

  private saveIdentity(): void {
    if (!this.identityPath) return;
    const data: IdentityData = {
      publicKey: toHex(this.keyPair.publicKey),
      secretKey: toHex(this.keyPair.secretKey),
      created: new Date().toISOString(),
      owner: this.owner,
      trustedPeers: Array.from(this.trustedPeers.values()),
    };
    const dir = path.dirname(this.identityPath);
    if (dir && !fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
    fs.writeFileSync(this.identityPath, JSON.stringify(data, null, 2));
  }

  async stop(): Promise<void> {
    if (this.heartbeatInterval) { clearInterval(this.heartbeatInterval); this.heartbeatInterval = null; }
    await this.transport.close();
    this.peers.clear();
    this.pending.clear();
    this.pendingConsent.clear();
    this.peerLastSeen.clear();
  }
}

export default Clawdio;


path: src/protocol.ts
content: import { WireMessage } from './types.js';

export function encodeWire(msg: WireMessage): string { return JSON.stringify(msg); }

export function decodeWire(data: string): WireMessage {
  const parsed = JSON.parse(data);
  if (!parsed.type || !parsed.payload) throw new Error('Invalid wire message');
  return parsed as WireMessage;
}

export function parseConnectionString(connStr: string): { publicKey: string; address: string } {
  const match = connStr.match(/^clawdio:\/\/([a-f0-9]+)@(.+)$/i);
  if (!match) throw new Error(`Invalid connection string: ${connStr}`);
  return { publicKey: match[1], address: match[2] };
}

export function buildConnectionString(publicKeyHex: string, host: string, port: number): string {
  return `clawdio://${publicKeyHex}@${host}:${port}`;
}


path: src/transport.ts
content: import WebSocket, { WebSocketServer } from 'ws';
import { EventEmitter } from 'events';

export class Transport extends EventEmitter {
  private server: WebSocketServer | null = null;
  private connections = new Map<string, WebSocket>();

  async listen(port: number, host: string = '0.0.0.0'): Promise<void> {
    return new Promise((resolve, reject) => {
      this.server = new WebSocketServer({ port, host }, () => resolve());
      this.server.on('error', reject);
      this.server.on('connection', (ws) => {
        ws.on('message', (data) => {
          this.emit('data', data.toString(), ws);
        });
      });
    });
  }

  async connect(address: string): Promise<WebSocket> {
    return new Promise((resolve, reject) => {
      const ws = new WebSocket(`ws://${address}`);
      ws.on('open', () => {
        this.connections.set(address, ws);
        ws.on('message', (data) => {
          this.emit('data', data.toString(), ws);
        });
        resolve(ws);
      });
      ws.on('error', reject);
    });
  }

  send(ws: WebSocket, data: string): void {
    if (ws.readyState === WebSocket.OPEN) ws.send(data);
  }

  registerSocket(id: string, ws: WebSocket): void {
    this.connections.set(id, ws);
  }

  getSocket(id: string): WebSocket | undefined {
    return this.connections.get(id);
  }

  async close(): Promise<void> {
    for (const ws of this.connections.values()) ws.close();
    this.connections.clear();
    if (this.server) {
      await new Promise<void>((r) => this.server!.close(() => r()));
      this.server = null;
    }
  }
}


path: src/types.ts
content: import type { TransportCipher } from './crypto.js';

export interface ClawdioOptions {
  port?: number;
  host?: string;
  keyDir?: string;
  identityPath?: string;       // path to persistent identity/trust file
  heartbeatMs?: number;        // ping interval (default 5000)
  heartbeatTimeout?: number;   // peer considered down after this (default 15000)
  autoAccept?: boolean;        // auto-accept all connections (default false, for testing)
}

export interface AgentMessage {
  task?: string;
  result?: any;
  context?: any;
  status?: 'ok' | 'error';
  [key: string]: any;
}

export interface Peer {
  id: string;               // hex-encoded static public key
  staticKey: Uint8Array;
  cipher: TransportCipher;
  address: string;
  ws?: import('ws').WebSocket;
}

export interface WireMessage {
  type: 'noise1' | 'noise2' | 'noise3' | 'message' | 'ping' | 'pong';
  payload: string;          // base64
  nonce?: string;           // base64 nonce for messages
  counter?: string;         // monotonic counter for replay protection
}

export type PeerStatus = 'alive' | 'stale' | 'down';
export type TrustLevel = 'pending' | 'accepted' | 'human-verified';

export interface ConnectionRequest {
  id: string;               // hex public key
  fingerprint: string;      // emoji fingerprint
  safetyNumber: string;
  address: string;
}

export interface TrustedPeerRecord {
  id: string;
  trust: TrustLevel;
  addedAt: string;
  owner?: string;            // human owner name if known
}

export interface IdentityData {
  publicKey: string;
  secretKey: string;
  created: string;
  owner?: string;
  trustedPeers: TrustedPeerRecord[];
}

export type MessageHandler = (message: AgentMessage, fromPeerId: string) => void;


path: tsconfig.json
content: {
  "compilerOptions": {
    "target": "ES2022",
    "module": "commonjs",
    "lib": ["ES2022"],
    "outDir": "dist",
    "rootDir": "src",
    "strict": true,
    "esModuleInterop": true,
    "declaration": true,
    "sourceMap": true,
    "resolveJsonModule": true,
    "skipLibCheck": true
  },
  "include": ["src"]
}


Security scan verdicts — ClawScan: suspicious | static analysis: clean | VirusTotal: clean | SkillSpector: suspicious (HIGH)