@cirvix_ai/agent-control 0.1.3 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (81) hide show
  1. package/README.md +76 -17
  2. package/bin/cirvix.mjs +539 -85
  3. package/bin/escape-benchmark.mjs +67 -0
  4. package/package.json +36 -16
  5. package/src/adapters/base.mjs +150 -0
  6. package/src/adapters/claude-code.mjs +161 -0
  7. package/src/adapters/cline.mjs +107 -0
  8. package/src/adapters/codex.mjs +104 -0
  9. package/src/adapters/cursor.mjs +104 -0
  10. package/src/adapters/frameworks.mjs +110 -0
  11. package/src/adapters/gemini-cli.mjs +104 -0
  12. package/src/adapters/generic-mcp.mjs +101 -0
  13. package/src/adapters/index.mjs +209 -0
  14. package/src/adapters/roo-code.mjs +106 -0
  15. package/src/adapters/vscode.mjs +104 -0
  16. package/src/adapters/windsurf.mjs +107 -0
  17. package/src/commands/console.mjs +58 -0
  18. package/src/commands/demo.mjs +55 -124
  19. package/src/commands/doctor.mjs +235 -0
  20. package/src/commands/init.mjs +292 -30
  21. package/src/commands/interactive.mjs +690 -0
  22. package/src/commands/kill.mjs +74 -0
  23. package/src/commands/login.mjs +227 -0
  24. package/src/commands/onboard.mjs +52 -0
  25. package/src/commands/passport.mjs +149 -0
  26. package/src/commands/policy.mjs +10 -6
  27. package/src/commands/protect.mjs +293 -0
  28. package/src/commands/prove.mjs +209 -0
  29. package/src/commands/redteam.mjs +51 -0
  30. package/src/commands/scan.mjs +11 -9
  31. package/src/commands/shadow.mjs +62 -0
  32. package/src/commands/simulate.mjs +96 -0
  33. package/src/commands/status.mjs +122 -41
  34. package/src/commands/upgrade.mjs +11 -11
  35. package/src/commands/welcome.mjs +105 -0
  36. package/src/core/authority.mjs +909 -0
  37. package/src/core/baseline.mjs +97 -0
  38. package/src/core/config-store.mjs +280 -0
  39. package/src/core/cost.mjs +0 -0
  40. package/src/core/detect.mjs +4 -33
  41. package/src/core/entitlements.mjs +7 -24
  42. package/src/core/escape-benchmark.mjs +597 -0
  43. package/src/core/events.mjs +234 -0
  44. package/src/core/evidence.mjs +212 -0
  45. package/src/core/format.mjs +44 -18
  46. package/src/core/gateway.mjs +15 -211
  47. package/src/core/graph.mjs +270 -0
  48. package/src/core/guard.mjs +118 -4
  49. package/src/core/intent.mjs +166 -0
  50. package/src/core/journal.mjs +131 -40
  51. package/src/core/kill-switch.mjs +122 -0
  52. package/src/core/notices.mjs +22 -2
  53. package/src/core/packs.mjs +193 -0
  54. package/src/core/passport.mjs +555 -0
  55. package/src/core/pipeline.mjs +148 -6
  56. package/src/core/prompts.mjs +51 -0
  57. package/src/core/proof.mjs +440 -0
  58. package/src/core/redteam/index.mjs +185 -0
  59. package/src/core/referral.mjs +187 -0
  60. package/src/core/sandbox.mjs +139 -0
  61. package/src/core/session.mjs +172 -0
  62. package/src/core/shadow.mjs +95 -0
  63. package/src/core/theme.mjs +240 -0
  64. package/src/core/trifecta.mjs +321 -0
  65. package/src/core/ui/controller.mjs +192 -0
  66. package/src/core/ui/decisions.mjs +55 -0
  67. package/src/core/ui/index.mjs +49 -0
  68. package/src/core/ui/intercept.mjs +103 -0
  69. package/src/core/ui/live.mjs +51 -0
  70. package/src/core/ui/primitives.mjs +123 -0
  71. package/src/core/ui/theme.mjs +92 -0
  72. package/src/core/verified.mjs +108 -0
  73. package/src/core/windows.mjs +270 -0
  74. package/src/index.mjs +67 -0
  75. package/src/tui/activity.mjs +71 -0
  76. package/src/tui/app.mjs +292 -0
  77. package/src/tui/cards.mjs +235 -0
  78. package/src/tui/composer.mjs +88 -0
  79. package/src/tui/palette.mjs +48 -0
  80. package/src/tui/status.mjs +42 -0
  81. package/src/core/cinematic.mjs +0 -545
@@ -0,0 +1,555 @@
1
+ /**
2
+ * Agent Passport, and the Trust Score on it.
3
+ *
4
+ * A passport is an identity document for one agent, assembled entirely from
5
+ * what that agent has actually done: the decisions in the audit chain, the
6
+ * tools it reached for, the rules that stopped it. Nothing is declared about
7
+ * an agent here — an agent cannot describe itself into a passport.
8
+ *
9
+ * THE TRUST SCORE IS THE DANGEROUS PART, AND IT IS BUILT ACCORDINGLY.
10
+ *
11
+ * A single number attached to a security decision is the easiest thing in this
12
+ * product to fake and the hardest to argue with. "Trust: 87" reads as a
13
+ * measurement whatever produced it, and a reader has no way to check it. Four
14
+ * constraints, all enforced by tests:
15
+ *
16
+ * 1. EVERY POINT IS TRACEABLE. The score is never returned alone. It ships
17
+ * with the signals that produced it, each one a counted fact with its own
18
+ * weight, and the components sum to the score. A reader can recompute it
19
+ * on paper.
20
+ *
21
+ * 2. NO PRIORS. There is no starting score, no vendor opinion, no adjustment
22
+ * for the framework an agent happens to use. Only counted behaviour.
23
+ *
24
+ * 3. IT REFUSES TO SCORE THIN EVIDENCE. Below the threshold it returns null
25
+ * and says why. A new agent with four decisions gets "insufficient
26
+ * evidence", not a confident 50 — a number that arrives before the
27
+ * evidence does is worse than no number, because it will be acted on.
28
+ *
29
+ * 4. IT IS NOT A SAFETY CERTIFICATE. It summarises observed behaviour. An
30
+ * agent that has never been caught doing anything dangerous is an agent
31
+ * that has never been caught, and `meaning` says so in those words.
32
+ *
33
+ * WHAT IT DELIBERATELY DOES NOT DO. It does not predict. It does not compare
34
+ * one customer's agents to another's. It does not move on its own over time.
35
+ * Each of those would require data this has no honest access to.
36
+ */
37
+
38
+ import { RISK } from "./risk.mjs";
39
+ import { DECISION } from "./decisions.mjs";
40
+
41
+ /** Below this many decisions, behaviour is not yet a pattern. */
42
+ export const MIN_DECISIONS_TO_SCORE = 25;
43
+
44
+ /**
45
+ * The signals, and what each is worth.
46
+ *
47
+ * Weights are stated here rather than buried in the arithmetic so the whole
48
+ * model is one readable table. They are a judgement — that is unavoidable in
49
+ * any score — but the judgement is visible, fixed, and the same for every
50
+ * agent. What is NOT a judgement is the count each one is applied to.
51
+ */
52
+ export const SIGNALS = [
53
+ {
54
+ id: "clean_rate",
55
+ label: "Proportion of calls that needed no intervention",
56
+ weight: 40,
57
+ // The base rate. An agent that mostly does its job without tripping a rule
58
+ // is behaving; one that is refused constantly is either misconfigured or
59
+ // doing something it should not.
60
+ measure: (f) => (f.total ? f.allowed / f.total : 0),
61
+ },
62
+ {
63
+ id: "no_critical",
64
+ label: "Absence of critical-risk attempts",
65
+ weight: 25,
66
+ // Not "few" — a single attempt to read cloud credentials is the signal.
67
+ // Scaled steeply so one costs most of the band.
68
+ measure: (f) => (f.criticalAttempts === 0 ? 1 : Math.max(0, 1 - f.criticalAttempts / 3)),
69
+ },
70
+ {
71
+ id: "no_escalation",
72
+ label: "No repeated attempts after a refusal",
73
+ weight: 20,
74
+ // An agent that is refused and moves on is behaving. One that retries the
75
+ // same refused action is the shape of an agent under injection.
76
+ measure: (f) => (f.repeatedRefusals === 0 ? 1 : Math.max(0, 1 - f.repeatedRefusals / 5)),
77
+ },
78
+ {
79
+ id: "scope_stability",
80
+ label: "Stays within the tools it started with",
81
+ weight: 15,
82
+ // Tool sprawl over time is how an agent's blast radius grows without
83
+ // anyone deciding it should.
84
+ measure: (f) => (f.distinctTools <= 1 ? 1 : Math.max(0, 1 - (f.distinctTools - 1) / 20)),
85
+ },
86
+ ];
87
+
88
+ const TOTAL_WEIGHT = SIGNALS.reduce((n, s) => n + s.weight, 0);
89
+
90
+ /**
91
+ * Counts what an agent did, from audit records alone.
92
+ *
93
+ * Every field is a count of something that happened. No field is an opinion,
94
+ * and nothing here is derived from configuration — an agent that claims to be
95
+ * well-behaved and behaves otherwise gets the second one.
96
+ */
97
+ export function observe(records, agentId) {
98
+ const mine = records.filter((r) => (agentId ? r.agent === agentId : true) && r.decision);
99
+
100
+ const tools = new Set();
101
+ const refusedActions = new Map();
102
+ let allowed = 0;
103
+ let denied = 0;
104
+ let held = 0;
105
+ let criticalAttempts = 0;
106
+ let repeatedRefusals = 0;
107
+ let firstSeen = null;
108
+ let lastSeen = null;
109
+
110
+ for (const r of mine) {
111
+ if (r.tool) tools.add(r.tool);
112
+ if (r.decision === DECISION.ALLOW) allowed += 1;
113
+ else if (r.decision === DECISION.DENY) denied += 1;
114
+ else if (r.decision === DECISION.REQUIRE_APPROVAL) held += 1;
115
+
116
+ if (r.risk === RISK.CRITICAL) criticalAttempts += 1;
117
+
118
+ if (r.decision === DECISION.DENY) {
119
+ const key = `${r.action}|${r.resource}`;
120
+ const seen = (refusedActions.get(key) ?? 0) + 1;
121
+ refusedActions.set(key, seen);
122
+ // The second time an identical refused call appears, that is a retry.
123
+ if (seen > 1) repeatedRefusals += 1;
124
+ }
125
+
126
+ if (r.ts) {
127
+ if (!firstSeen || r.ts < firstSeen) firstSeen = r.ts;
128
+ if (!lastSeen || r.ts > lastSeen) lastSeen = r.ts;
129
+ }
130
+ }
131
+
132
+ return {
133
+ agent: agentId ?? null,
134
+ total: mine.length,
135
+ allowed,
136
+ denied,
137
+ held,
138
+ criticalAttempts,
139
+ repeatedRefusals,
140
+ distinctTools: tools.size,
141
+ tools: [...tools].sort(),
142
+ firstSeen,
143
+ lastSeen,
144
+ };
145
+ }
146
+
147
+ /**
148
+ * The score, with everything needed to check it.
149
+ *
150
+ * Returns `score: null` rather than a number when the evidence is too thin.
151
+ * The caller must render that as "not enough evidence" — every consumer in
152
+ * this repository does, and a test asserts the CLI does not print a number
153
+ * when there is none.
154
+ */
155
+ export function trustScore(facts) {
156
+ if (!facts || facts.total < MIN_DECISIONS_TO_SCORE) {
157
+ return {
158
+ score: null,
159
+ confidence: "insufficient",
160
+ observed: facts?.total ?? 0,
161
+ required: MIN_DECISIONS_TO_SCORE,
162
+ reason:
163
+ `Behaviour is not a pattern yet: ${facts?.total ?? 0} decision${facts?.total === 1 ? "" : "s"} recorded, ` +
164
+ `${MIN_DECISIONS_TO_SCORE} needed. A score before the evidence would be acted on as though it meant something.`,
165
+ components: [],
166
+ meaning: null,
167
+ };
168
+ }
169
+
170
+ const components = SIGNALS.map((s) => {
171
+ const raw = Math.max(0, Math.min(1, s.measure(facts)));
172
+ return {
173
+ id: s.id,
174
+ label: s.label,
175
+ weight: s.weight,
176
+ // The measured proportion, rounded for display but not for arithmetic.
177
+ measured: Math.round(raw * 1000) / 1000,
178
+ points: Math.round(raw * s.weight * 10) / 10,
179
+ };
180
+ });
181
+
182
+ const score = Math.round(components.reduce((n, c) => n + c.points, 0));
183
+
184
+ return {
185
+ score,
186
+ outOf: TOTAL_WEIGHT,
187
+ confidence: facts.total >= MIN_DECISIONS_TO_SCORE * 4 ? "high" : "provisional",
188
+ observed: facts.total,
189
+ components,
190
+ /* Said in full every time the score is produced. A number this compact
191
+ will be quoted out of context, and the sentence that bounds it has to
192
+ travel with it. */
193
+ meaning:
194
+ "A summary of what this agent has been observed doing, not a prediction and not a safety certificate. " +
195
+ "An agent with no dangerous attempts on record is an agent that has not been caught making one. " +
196
+ "Every point above is traceable to a counted decision.",
197
+ };
198
+ }
199
+
200
+ /**
201
+ * The passport: identity, observed behaviour, and the score.
202
+ *
203
+ * Deliberately assembled from records rather than from anything the agent
204
+ * supplies. The only inputs an agent controls are the calls it made, and those
205
+ * are exactly what is being described.
206
+ */
207
+ export function buildPassport({ agentId, records, policy = null, environment = null }) {
208
+ const facts = observe(records, agentId);
209
+ const trust = trustScore(facts);
210
+
211
+ return {
212
+ v: 1,
213
+ agent: agentId ?? null,
214
+ environment,
215
+ issuedAt: new Date().toISOString(),
216
+ identity: {
217
+ firstSeen: facts.firstSeen,
218
+ lastSeen: facts.lastSeen,
219
+ // The tools it has ACTUALLY used, which is the honest description of
220
+ // what it can do — a declared capability list is a wish.
221
+ tools: facts.tools,
222
+ },
223
+ behaviour: {
224
+ decisions: facts.total,
225
+ allowed: facts.allowed,
226
+ denied: facts.denied,
227
+ heldForApproval: facts.held,
228
+ criticalAttempts: facts.criticalAttempts,
229
+ repeatedRefusals: facts.repeatedRefusals,
230
+ },
231
+ policy: policy ? { version: policy.version ?? null, ruleCount: policy.rules?.length ?? null } : null,
232
+ trust,
233
+ };
234
+ }
235
+
236
+ /* ==========================================================================
237
+ SIGNING, AND THE PUBLIC ARTIFACT
238
+ --------------------------------------------------------------------------
239
+ buildPassport() returns a plain object. That is fine inside the process
240
+ that built it and worthless to anyone else: an unsigned passport is a claim
241
+ about an agent that anybody can write, including the agent.
242
+
243
+ The envelope, the canonical serialiser and the curve are all taken from
244
+ proof.mjs rather than reimplemented. A second signing scheme in the same
245
+ product is how a verifier ends up checking one format and trusting another,
246
+ and this repository has already been bitten by split-brain implementations
247
+ once.
248
+
249
+ WHY THE PUBLIC ARTIFACT IS A SUBSET
250
+ -----------------------------------
251
+ A passport records the tools an agent reached for and how often it was
252
+ refused. Inside an organisation that is exactly the useful part. Published,
253
+ it is a map of somebody's internal estate — "this agent touches
254
+ payments.write and gets denied a lot" is reconnaissance.
255
+
256
+ So `publicView()` drops the tool inventory and the raw counts and keeps what
257
+ a third party actually needs: which agent, under which policy hash, with
258
+ what trust score, signed by whom, and when. The signature covers the FULL
259
+ passport, so a holder can always disclose more by handing over the whole
260
+ artifact — but the default disclosure is the minimum that still verifies.
261
+ ========================================================================== */
262
+
263
+ import { createHash as _passportHash } from "node:crypto";
264
+ import { buildProofEnvelope, verifyProofEnvelope } from "./proof.mjs";
265
+
266
+ export const PASSPORT_VERSION = 1;
267
+
268
+ /** A stable id for a passport: the agent plus the content it attests to. */
269
+ export function passportId(passport) {
270
+ const basis = `${passport.agent ?? "unknown"}:${passport.issuedAt ?? ""}`;
271
+ return "psp_" + _passportHash("sha256").update(basis).digest("hex").slice(0, 16);
272
+ }
273
+
274
+ /**
275
+ * Signs a passport into the same envelope shape a proof uses.
276
+ *
277
+ * `policyHash` is required when a policy is present. A passport that names a
278
+ * policy version without binding its hash attests to a moving target: the
279
+ * rules can be edited afterwards and the passport still "verifies".
280
+ */
281
+ export function signPassport({ passport, privateKey, keyId, issuer = "local", policyHash = null }) {
282
+ if (!privateKey) throw new Error("A passport needs a signing key.");
283
+ if (!passport?.agent) throw new Error("A passport needs an agent.");
284
+ if (passport.policy && !policyHash) {
285
+ throw new Error("A passport that names a policy must bind its hash.");
286
+ }
287
+ const payload = {
288
+ v: PASSPORT_VERSION,
289
+ kind: "passport",
290
+ issuer,
291
+ passportId: passportId(passport),
292
+ agent: passport.agent,
293
+ environment: passport.environment ?? null,
294
+ issuedAt: passport.issuedAt,
295
+ policyHash,
296
+ passport,
297
+ };
298
+ return buildProofEnvelope({ payload, privateKey, keyId });
299
+ }
300
+
301
+ /**
302
+ * Verifies a signed passport.
303
+ *
304
+ * Returns the same shape as verifyProof so one renderer can present either,
305
+ * and so a caller cannot accidentally treat "unverified" as "valid" because
306
+ * the two artifacts reported differently.
307
+ */
308
+ export function verifyPassport(publicKeyPem, token) {
309
+ const base = verifyProofEnvelope(publicKeyPem, token);
310
+ if (!base.ok) return base;
311
+
312
+ const p = base.payload;
313
+ for (const field of ["v", "kind", "issuer", "passportId", "agent", "issuedAt", "passport"]) {
314
+ if (p[field] === undefined) {
315
+ return { ok: false, verified: false, failed: "integrity", reason: `The passport is missing "${field}".` };
316
+ }
317
+ }
318
+ if (p.kind !== "passport") {
319
+ return { ok: false, verified: false, failed: "integrity", reason: `This is a "${p.kind}" artifact, not a passport.` };
320
+ }
321
+ if (p.v !== PASSPORT_VERSION) {
322
+ return { ok: false, verified: false, failed: "integrity", reason: `This passport is version ${p.v}; this verifier understands ${PASSPORT_VERSION}.` };
323
+ }
324
+ if (p.passportId !== passportId(p.passport)) {
325
+ return { ok: false, verified: false, failed: "integrity", reason: "The passport id does not match its contents." };
326
+ }
327
+ return {
328
+ ok: true,
329
+ verified: true,
330
+ payload: p,
331
+ checks: [
332
+ { name: "signature", ok: true, detail: "Ed25519 signature verifies against the supplied public key." },
333
+ { name: "canonical", ok: true, detail: "The body re-serialises to exactly the bytes that were signed." },
334
+ { name: "identity", ok: true, detail: "The passport id is derived from the passport contents." },
335
+ { name: "policy-binding", ok: p.policyHash !== null, detail: p.policyHash ? `Bound to policy ${p.policyHash}.` : "No policy is bound to this passport." },
336
+ ],
337
+ };
338
+ }
339
+
340
+ /**
341
+ * The subset safe to publish.
342
+ *
343
+ * Everything here is either about the agent's posture or about the artifact
344
+ * itself. Nothing here describes the estate the agent operates in.
345
+ */
346
+ export function publicView(payload) {
347
+ const p = payload?.passport ?? {};
348
+ return {
349
+ passportId: payload?.passportId ?? null,
350
+ agent: payload?.agent ?? null,
351
+ environment: payload?.environment ?? null,
352
+ issuer: payload?.issuer ?? null,
353
+ issuedAt: payload?.issuedAt ?? null,
354
+ policyHash: payload?.policyHash ?? null,
355
+ trust: p.trust ? { score: p.trust.score ?? null, band: p.trust.band ?? null, reasons: p.trust.reasons ?? [] } : null,
356
+ /* Deliberately absent: identity.tools, behaviour.* — see the note above. */
357
+ };
358
+ }
359
+
360
+ /* ==========================================================================
361
+ THE BADGE
362
+ --------------------------------------------------------------------------
363
+ A README badge is the most-seen artifact a security product produces, and
364
+ it is also the easiest place to accidentally lie. Two rules follow from
365
+ that.
366
+
367
+ It is generated from a SIGNED passport, not from a live score. A badge that
368
+ fetches its own value at render time is a badge whose claim nobody checked;
369
+ this one renders what an artifact already attested to, and carries the
370
+ passport id so a reader can verify the artifact behind it.
371
+
372
+ And it never renders a score the passport did not earn. Below the decision
373
+ floor `observe()` requires, there is no score, and the badge says
374
+ "unscored" rather than picking a flattering number. A grey badge that
375
+ admits it has no data is worth more than a green one that invented some.
376
+ ========================================================================== */
377
+
378
+ const BADGE_TONE = {
379
+ A: "#34D399", B: "#34D399", C: "#F7B750", D: "#F7B750", E: "#FB7185", "—": "#6B7080",
380
+ };
381
+
382
+ /** Letter band for a score. Shared with the public verifier's renderer. */
383
+ export function bandFor(score) {
384
+ if (score === null || score === undefined) return "—";
385
+ if (score >= 90) return "A";
386
+ if (score >= 75) return "B";
387
+ if (score >= 60) return "C";
388
+ if (score >= 40) return "D";
389
+ return "E";
390
+ }
391
+
392
+ const esc = (s) => String(s).replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
393
+
394
+ /**
395
+ * A self-contained SVG badge. No external fetch, no font file, no tracking.
396
+ *
397
+ * Sized by character count rather than measured text: a badge that needs a
398
+ * layout engine to be correct is a badge that renders wrong in the one place
399
+ * it matters, which is a README on somebody else's site.
400
+ */
401
+ export function badgeSvg(passport, { label = "cirvix" } = {}) {
402
+ const score = passport?.trust?.score ?? null;
403
+ const band = bandFor(score);
404
+ const value = score === null ? "unscored" : `${band} ${score}`;
405
+ const tone = BADGE_TONE[band] ?? BADGE_TONE["—"];
406
+
407
+ const lw = 7 * label.length + 20;
408
+ const vw = 7 * value.length + 22;
409
+ const w = lw + vw;
410
+ const title = `${label}: ${value}${passport?.agent ? ` (${passport.agent})` : ""}`;
411
+
412
+ return `<svg xmlns="http://www.w3.org/2000/svg" width="${w}" height="20" role="img" aria-label="${esc(title)}">
413
+ <title>${esc(title)}</title>
414
+ <linearGradient id="s" x2="0" y2="100%"><stop offset="0" stop-color="#fff" stop-opacity=".08"/><stop offset="1" stop-opacity=".08"/></linearGradient>
415
+ <clipPath id="r"><rect width="${w}" height="20" rx="3" fill="#fff"/></clipPath>
416
+ <g clip-path="url(#r)">
417
+ <rect width="${lw}" height="20" fill="#0C0D10"/>
418
+ <rect x="${lw}" width="${vw}" height="20" fill="${tone}"/>
419
+ <rect width="${w}" height="20" fill="url(#s)"/>
420
+ </g>
421
+ <g fill="#fff" text-anchor="middle" font-family="ui-monospace,SFMono-Regular,Consolas,monospace" font-size="11">
422
+ <text x="${lw / 2}" y="14" fill="#F2F3F7">${esc(label)}</text>
423
+ <text x="${lw + vw / 2}" y="14" fill="#08090B" font-weight="600">${esc(value)}</text>
424
+ </g>
425
+ </svg>`;
426
+ }
427
+
428
+ /* ==========================================================================
429
+ CRYPTOGRAPHIC IDENTITY & AGENT PASSPORT (Section 3)
430
+ ========================================================================== */
431
+
432
+ import { generateKeyPairSync, sign as cryptoSign, verify as cryptoVerify, randomUUID } from "node:crypto";
433
+ import { canonicalJson } from "./audit.mjs";
434
+
435
+ /**
436
+ * Generates an Ed25519 keypair for an agent.
437
+ */
438
+ export function generateAgentKeypair() {
439
+ const { publicKey, privateKey } = generateKeyPairSync("ed25519");
440
+ return {
441
+ publicKey: publicKey.export({ type: "spki", format: "pem" }).toString(),
442
+ privateKey: privateKey.export({ type: "pkcs8", format: "pem" }).toString(),
443
+ };
444
+ }
445
+
446
+ /**
447
+ * Issues a cryptographic Agent Passport document.
448
+ */
449
+ export function issueCryptographicPassport({
450
+ id: customId = null,
451
+ name,
452
+ owner = "system",
453
+ sponsor = null,
454
+ organization = "local",
455
+ purpose = "general",
456
+ model = "unknown",
457
+ modelVersion = "1.0",
458
+ runtime = "node",
459
+ version = "1.0.0",
460
+ environment = "production",
461
+ tools = [],
462
+ permissions = [],
463
+ riskScore = 0,
464
+ }, privateKey = null) {
465
+ const agentId = customId ?? `cirvix://agent/${randomUUID()}`;
466
+ const ts = new Date().toISOString();
467
+
468
+ let keys = null;
469
+ let priv = privateKey;
470
+ let pub = null;
471
+
472
+ if (!priv) {
473
+ keys = generateAgentKeypair();
474
+ priv = keys.privateKey;
475
+ pub = keys.publicKey;
476
+ }
477
+
478
+ const payload = {
479
+ id: agentId,
480
+ name: name ?? agentId,
481
+ owner,
482
+ sponsor,
483
+ organization,
484
+ purpose,
485
+ model,
486
+ modelVersion,
487
+ runtime,
488
+ version,
489
+ environment,
490
+ tools,
491
+ permissions,
492
+ riskScore,
493
+ createdAt: ts,
494
+ status: "active",
495
+ publicKey: pub,
496
+ };
497
+
498
+ const signature = cryptoSign(null, Buffer.from(canonicalJson(payload)), priv).toString("base64url");
499
+
500
+ return {
501
+ passport: {
502
+ ...payload,
503
+ signature,
504
+ },
505
+ privateKey: priv,
506
+ };
507
+ }
508
+
509
+ /**
510
+ * Verifies the cryptographic integrity and signature of an Agent Passport.
511
+ */
512
+ export function verifyPassportSignature(passport, publicKeyOverride = null) {
513
+ if (!passport || typeof passport !== "object") return false;
514
+ const { signature, ...payload } = passport;
515
+ if (!signature) return false;
516
+
517
+ const keyToUse = publicKeyOverride ?? payload.publicKey;
518
+ if (!keyToUse) return false;
519
+
520
+ try {
521
+ const data = Buffer.from(canonicalJson(payload));
522
+ const sigBytes = Buffer.from(signature, "base64url");
523
+ return cryptoVerify(null, data, keyToUse, sigBytes);
524
+ } catch {
525
+ return false;
526
+ }
527
+ }
528
+
529
+ /**
530
+ * Rotates an agent's cryptographic keypair while preserving identity lineage.
531
+ */
532
+ export function rotatePassportKeys(currentPassport, oldPrivateKey, newKeypair = null) {
533
+ const keys = newKeypair ?? generateAgentKeypair();
534
+ const ts = new Date().toISOString();
535
+
536
+ const { signature: _oldSig, ...prevPayload } = currentPassport;
537
+
538
+ const rotatedPayload = {
539
+ ...prevPayload,
540
+ previousPublicKey: prevPayload.publicKey,
541
+ publicKey: keys.publicKey,
542
+ rotatedAt: ts,
543
+ keyRotationCount: (prevPayload.keyRotationCount ?? 0) + 1,
544
+ };
545
+
546
+ const newSignature = cryptoSign(null, Buffer.from(canonicalJson(rotatedPayload)), keys.privateKey).toString("base64url");
547
+
548
+ return {
549
+ passport: {
550
+ ...rotatedPayload,
551
+ signature: newSignature,
552
+ },
553
+ privateKey: keys.privateKey,
554
+ };
555
+ }