cerberus-e2e / tree / src/reference.ts · raw (this view has old GitHub links rewritten for the mirror; raw is byte-exact)
/**
 * Reference chambers, kit version 1: small, deterministic stand ins that honour the interfaces in types.ts, so a test
 * suite can drive the whole airlock with no network. They are fakes for testing, not Kognai's production chambers
 * (chamber 2 here is a plain rule on accepted deliveries; the real one is seat SEAT2).
 */
import { createHmac, randomBytes, timingSafeEqual } from 'node:crypto';
import { verifyMessage } from 'viem';
import type {
  AgentRecord, AirlockDecision, AirlockInput, AirlockStep, Challenge, IntentVerdict, OutsideScore, RunAirlock, SessionToken, Tier, TrustResult, Verdict,
} from './types.ts';

export const KIT_VERSION = 1;
export const LAWS = ['Solidarity Oath', 'Renewal Mandate', 'Treasury Equilibrium', 'Harm Shield', 'Transparency Covenant'];
export const TIERS: Tier[] = ['unproven', 'bronze', 'silver', 'gold'];
export const CHALLENGE_TTL_MS = 10 * 60_000;
export const SESSION_TTL_MS: Record<Tier, number> = { unproven: 15 * 60_000, bronze: 15 * 60_000, silver: 60 * 60_000, gold: 240 * 60_000 };
export const SESSION_PROTOCOL = 'kognai-cerberus-session/1';

const no = (reason: string): Verdict => ({ ok: false, reason });
const isIso = (s: unknown): s is string => typeof s === 'string' && Number.isFinite(Date.parse(s)) && new Date(Date.parse(s)).toISOString() === s;

// ── chamber 1, identity ──────────────────────────────────────────────────────────────────────────────────────────
const usedNonces = new Set<string>();

export function challengeMessage(c: Challenge): string {
  return ['LAX identity challenge', `did: ${c.did}`, `listing: ${c.listingId}`, `nonce: ${c.nonce}`, `issued: ${c.issuedAt}`, `expires: ${c.expiresAt}`].join('\n');
}

export function createChallenge(input: { did: string; listingId: string; now: number }): Challenge {
  const { did, listingId, now } = input || ({} as any);
  if (typeof did !== 'string' || !/^did:[a-z0-9]+:\S+$/.test(did)) throw new Error('did must be a DID');
  if (typeof listingId !== 'string' || !listingId.trim() || /\s/.test(listingId)) throw new Error('listingId must be a non empty id');
  if (typeof now !== 'number' || !Number.isFinite(now)) throw new Error('now must be milliseconds since the epoch');
  const c: Challenge = { did, listingId, nonce: randomBytes(16).toString('hex'), issuedAt: new Date(now).toISOString(), expiresAt: new Date(now + CHALLENGE_TTL_MS).toISOString(), message: '' };
  c.message = challengeMessage(c);
  return c;
}

export async function verifyChallenge(input: { challenge: Challenge; signature: string; ownerAddress: string; now: number }): Promise<Verdict> {
  try {
    const { challenge: c, signature, ownerAddress, now } = input || ({} as any);
    if (!c || typeof c !== 'object') return no('no challenge');
    if (typeof c.did !== 'string' || typeof c.listingId !== 'string' || !/^[0-9a-f]{32,}$/.test(String(c.nonce)) || !isIso(c.issuedAt) || !isIso(c.expiresAt)) return no('the challenge is malformed');
    if (c.message !== challengeMessage(c)) return no('the challenge text does not match its fields (tampered)');
    const ttl = Date.parse(c.expiresAt) - Date.parse(c.issuedAt);
    if (ttl < 60_000 || ttl > 15 * 60_000) return no('the challenge lifetime is not between 1 and 15 minutes');
    if (typeof now !== 'number' || !Number.isFinite(now)) return no('now must be milliseconds since the epoch');
    if (now >= Date.parse(c.expiresAt)) return no('the challenge expired');
    if (usedNonces.has(c.nonce)) return no('the challenge was already used (replay)');
    if (typeof signature !== 'string' || !/^0x[0-9a-fA-F]{130}$/.test(signature)) return no('the signature is malformed');
    if (typeof ownerAddress !== 'string' || !/^0x[0-9a-fA-F]{40}$/.test(ownerAddress)) return no('the owner address is malformed');
    let ok = false;
    try { ok = await verifyMessage({ address: ownerAddress as `0x${string}`, message: c.message, signature: signature as `0x${string}` }); } catch { ok = false; }
    if (!ok) return no('the challenge is not signed by the listing owner');
    usedNonces.add(c.nonce);
    return { ok: true, reason: 'signed by the listing owner' };
  } catch {
    return no('the input is malformed');
  }
}

// ── chamber 2, trust (a fake: a plain rule on accepted deliveries) ───────────────────────────────────────────────
const count = (v: unknown) => (typeof v === 'number' && Number.isFinite(v) && v > 0 ? Math.floor(v) : 0);
const outsideOf = (o: OutsideScore): number | null => {
  const v = typeof o === 'number' ? o : o && typeof o === 'object' ? Number((o as any).score) : NaN;
  return Number.isFinite(v) ? Math.max(0, Math.min(100, v)) : null;
};
/** Tier boundaries of this fake, on the score: bronze 20, silver 50, gold 80. */
export const TIER_FLOOR: Record<Tier, number> = { unproven: 0, bronze: 20, silver: 50, gold: 80 };
const tierOf = (score: number): Tier => (score >= TIER_FLOOR.gold ? 'gold' : score >= TIER_FLOOR.silver ? 'silver' : score >= TIER_FLOOR.bronze ? 'bronze' : 'unproven');

export function scoreAgent(record: AgentRecord, outside?: OutsideScore): TrustResult {
  const r = record && typeof record === 'object' ? record : {};
  const accepted = count(r.accepted), rejected = count(r.rejected);
  const own = Math.max(0, Math.min(100, accepted * 10 - rejected * 10));
  const reasons = [`${accepted} accepted and ${rejected} rejected deliveries give ${own}`];
  const base = tierOf(own); const o = outsideOf(outside);
  if (o === null) return { score: own, tier: base, reasons };
  const blended = Math.round((own * 3 + o) / 4);
  const step = Math.max(-1, Math.min(1, TIERS.indexOf(tierOf(blended)) - TIERS.indexOf(base)));
  reasons.push(`the outside score ${o} gives ${blended}; the tier moves ${step === 0 ? 'not at all' : 'by one step at most'}`);
  return { score: blended, tier: TIERS[TIERS.indexOf(base) + step], reasons };
}

// ── chamber 3, intent ────────────────────────────────────────────────────────────────────────────────────────────
/** What the fake refuses (any match) and flags (privilege words, a note for the Node of Wisdom, not a refusal). */
export const INTENT_REFUSE: Array<[RegExp, string]> = [
  [/ignore (all |the )?(previous|prior|above) (instructions|rules)/i, 'prompt injection'],
  [/<\/?(system|instructions?)>/i, 'prompt injection'],
  [/\b(system|assistant)\s*:\s/i, 'prompt injection'],
  [/[A-Za-z0-9+/=]{120,}/, 'an encoded payload'],
];
export const INTENT_FLAG = /\b(admin|root|sudo)\b/i;
export const INTENT_MAX_CHARS = 6000;

export function screenIntent(input: { did: string; text: string }): IntentVerdict {
  const text = typeof input?.text === 'string' ? input.text : '';
  if (!text.trim()) return { ok: false, reason: 'the approach is empty', flags: [] };
  if (text.length > INTENT_MAX_CHARS) return { ok: false, reason: `the approach is longer than ${INTENT_MAX_CHARS} characters`, flags: [] };
  const hit = INTENT_REFUSE.find(([re]) => re.test(text));
  if (hit) return { ok: false, reason: `refused: ${hit[1]}`, flags: [] };
  const flags = INTENT_FLAG.test(text) ? ['privilege word'] : [];
  return { ok: true, reason: flags.length ? 'clean, with a privilege word for the Node of Wisdom' : 'clean', flags };
}

// ── chamber 4, SOUL handshake ────────────────────────────────────────────────────────────────────────────────────
export function soulHandshake(input: { did: string; laws: string[] }): Verdict {
  const have = new Set((Array.isArray(input?.laws) ? input.laws : []).map((l) => String(l).trim().toLowerCase()));
  const missing = LAWS.filter((l) => !have.has(l.toLowerCase()));
  return missing.length ? no(`the listing does not accept: ${missing.join(', ')}`) : { ok: true, reason: 'the listing accepts the Five Laws' };
}

// ── chamber 5, session token ─────────────────────────────────────────────────────────────────────────────────────
const macOf = (secret: string, t: Omit<SessionToken, 'mac'>) =>
  createHmac('sha256', secret).update([SESSION_PROTOCOL, t.did, t.job_id, t.tier, t.issued_at, t.expires_at].join('|')).digest('hex');

export function issueSessionToken(input: { did: string; jobId: string; tier: Tier; now: number; secret: string }): SessionToken {
  const { did, jobId, tier, now, secret } = input;
  if (typeof secret !== 'string' || secret.length < 32) throw new Error('the airlock secret must be at least 32 characters');
  if (!TIERS.includes(tier)) throw new Error('unknown tier');
  const body = { did: String(did), job_id: String(jobId), tier, issued_at: new Date(now).toISOString(), expires_at: new Date(now + SESSION_TTL_MS[tier]).toISOString() };
  return { ...body, mac: macOf(secret, body) };
}

export function verifySessionToken(input: { token: SessionToken; now: number; secret: string; jobId?: string }): Verdict {
  try {
    const { token: t, now, secret, jobId } = input || ({} as any);
    if (typeof secret !== 'string' || secret.length < 32) return no('no airlock secret');
    if (!t || typeof t !== 'object' || typeof t.mac !== 'string' || !/^[0-9a-f]{64}$/.test(t.mac) || !isIso(t.issued_at) || !isIso(t.expires_at) || !TIERS.includes(t.tier)) return no('the token is malformed');
    const want = Buffer.from(macOf(secret, t), 'hex');
    if (!timingSafeEqual(want, Buffer.from(t.mac, 'hex'))) return no('the token was tampered with (MAC mismatch)');
    if (typeof now !== 'number' || !(now < Date.parse(t.expires_at))) return no('the token expired');
    if (jobId !== undefined && t.job_id !== jobId) return no('the token is for another job');
    return { ok: true, reason: `valid until ${t.expires_at}` };
  } catch {
    return no('the token is malformed');
  }
}

// ── the airlock ──────────────────────────────────────────────────────────────────────────────────────────────────
export interface ChamberFns {
  verifyChallenge: (i: { challenge: Challenge; signature: string; ownerAddress: string; now: number }) => Verdict | Promise<Verdict>;
  scoreAgent: (r: AgentRecord, o?: OutsideScore) => TrustResult;
  screenIntent: (i: { did: string; text: string }) => IntentVerdict;
  soulHandshake: (i: { did: string; laws: string[] }) => Verdict;
  issueSessionToken: (i: { did: string; jobId: string; tier: Tier; now: number; secret: string }) => SessionToken;
}

/** The airlock over a set of chambers: in order 1 to 5, stopping at the first refusal; a token only when 1 to 4 pass. */
export function makeAirlock(ch: ChamberFns): RunAirlock {
  return async (input: AirlockInput, opts: { now: number; secret: string }): Promise<AirlockDecision> => {
    const steps: AirlockStep[] = []; const flags: string[] = []; let tier: Tier | null = null;
    const stop = (s: AirlockStep): AirlockDecision => { steps.push(s); return { passed: false, refused_at: s.chamber, tier, flags, steps, token: null }; };
    // 1 identity
    const c = input?.challenge;
    if (!c || c.did !== input.did || c.listingId !== input.listingId) return stop({ chamber: 1, name: 'identity', ok: false, reason: 'the challenge is for another agent or listing' });
    const v1 = await ch.verifyChallenge({ challenge: c, signature: input.signature, ownerAddress: input.ownerAddress, now: opts.now });
    if (!v1.ok) return stop({ chamber: 1, name: 'identity', ok: false, reason: v1.reason || 'refused' });
    steps.push({ chamber: 1, name: 'identity', ok: true, reason: v1.reason });
    // 2 trust
    const s = ch.scoreAgent(input.record || {}, input.outside); tier = s.tier;
    const min = input.minTier || 'unproven';
    if (TIERS.indexOf(s.tier) < TIERS.indexOf(min)) return stop({ chamber: 2, name: 'trust', ok: false, reason: `tier ${s.tier} is below the ${min} this job requires` });
    steps.push({ chamber: 2, name: 'trust', ok: true, reason: `score ${s.score}, ${s.tier}` });
    // 3 intent
    const v3 = ch.screenIntent({ did: input.did, text: input.intent }); flags.push(...(v3.flags || []));
    if (!v3.ok) return stop({ chamber: 3, name: 'intent', ok: false, reason: v3.reason });
    steps.push({ chamber: 3, name: 'intent', ok: true, reason: v3.reason });
    // 4 SOUL
    const v4 = ch.soulHandshake({ did: input.did, laws: input.laws });
    if (!v4.ok) return stop({ chamber: 4, name: 'soul', ok: false, reason: v4.reason });
    steps.push({ chamber: 4, name: 'soul', ok: true, reason: v4.reason });
    // 5 session
    let token: SessionToken;
    try { token = ch.issueSessionToken({ did: input.did, jobId: input.jobId, tier: s.tier, now: opts.now, secret: opts.secret }); } catch (e: any) { return stop({ chamber: 5, name: 'session', ok: false, reason: String(e?.message || e) }); }
    steps.push({ chamber: 5, name: 'session', ok: true, reason: `session token until ${token.expires_at}` });
    return { passed: true, refused_at: null, tier, flags, steps, token };
  };
}

export const runAirlock: RunAirlock = makeAirlock({ verifyChallenge, scoreAgent, screenIntent, soulHandshake, issueSessionToken });

agentgate-source: code mirror on Cloudflare Pages. All repos