@connexum/ai-governance 1.0.0-beta.21 → 1.0.0-beta.22

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.
@@ -0,0 +1,927 @@
1
+ /**
2
+ * ai-governance sync — pull the server's signed governance bundle and
3
+ * re-materialize the local .governance.json to match.
4
+ *
5
+ * Design (Thomas, locked):
6
+ * - SAFE BY DEFAULT: no arguments = dry-run. Shows diff, writes nothing.
7
+ * - --apply (or interactive confirm) writes the new governance to disk.
8
+ * - NOT at runtime, NOT at init — a deliberate standalone verb.
9
+ * - Client PULLS from server. Server never calls out to the client (Invariant 10).
10
+ * - Server unreachable → graceful message, local config unchanged (Invariant 2/3).
11
+ * - Unsigned / tampered bundle → REJECTED, nothing written.
12
+ *
13
+ * Usage:
14
+ * ai-governance sync Dry-run: show diff, no writes
15
+ * ai-governance sync --apply Apply the bundle to .governance.json
16
+ * ai-governance sync --agent <id> Sync a specific agent only
17
+ * ai-governance sync --gov-server-url Override gov server URL
18
+ *
19
+ * Source of per-agent identities: .governance.json agents[] written by TS-002
20
+ * (register-fleet + writePerAgentIdentities). Each entry has:
21
+ * { localId, agentId, serviceToken, passportId, filePath }
22
+ * Plus top-level runtime block:
23
+ * { govServerUrl, orgId, agentId, serviceToken }
24
+ *
25
+ * Follow-up slices (historic seams — see feat/effective-governance-projection-2026-06-08):
26
+ * F1: serviceToken TTL reduced to 30d — DONE
27
+ * F2: JTI added to minted tokens, persisted on metadata — DONE
28
+ * F3: per-agent audit-logger.sh push rewiring (CXNI_AGENT_FILE resolver) — DONE
29
+ * F4: serviceToken moved off curl argv via tmpfile — DONE (this file + audit-logger.sh)
30
+ * F5: narrower AGENT_SELF role on server for tighter RBAC — DEFERRED (architectural)
31
+ *
32
+ * @connexum/ai-governance TS-010 sync verb
33
+ */
34
+ import * as fs from 'fs';
35
+ import * as path from 'path';
36
+ import * as crypto from 'crypto';
37
+ import { spawnSync } from 'child_process';
38
+ // ---------------------------------------------------------------------------
39
+ // Verification helpers
40
+ // ---------------------------------------------------------------------------
41
+ /**
42
+ * Verify an Ed25519 signature using Node.js built-in crypto.
43
+ * Signature is base64url; message is the raw 32-byte SHA-256 digest;
44
+ * publicKey is the 32-byte raw Ed25519 key as base64url.
45
+ *
46
+ * Returns true iff the signature is valid.
47
+ * NEVER throws — returns false on any error (Invariant 2).
48
+ */
49
+ function verifyEd25519(signatureB64Url, messageBytes, publicKeyB64Url) {
50
+ try {
51
+ const sigBytes = Buffer.from(signatureB64Url, 'base64url');
52
+ const pubKeyBytes = Buffer.from(publicKeyB64Url, 'base64url');
53
+ if (sigBytes.length !== 64 || pubKeyBytes.length !== 32)
54
+ return false;
55
+ // Import the raw 32-byte public key as an Ed25519 KeyObject.
56
+ // Node.js crypto.createPublicKey only accepts structured formats
57
+ // (SPKI/JWK). The raw 32 bytes need to be wrapped in an SPKI header.
58
+ // Ed25519 SPKI prefix (12 bytes): 30 2a 30 05 06 03 2b 65 70 03 21 00
59
+ const SPKI_PREFIX = Buffer.from('302a300506032b6570032100', 'hex');
60
+ const spkiDer = Buffer.concat([SPKI_PREFIX, pubKeyBytes]);
61
+ const publicKeyObj = crypto.createPublicKey({
62
+ key: spkiDer,
63
+ format: 'der',
64
+ type: 'spki',
65
+ });
66
+ return crypto.verify(null, Buffer.from(messageBytes), publicKeyObj, sigBytes);
67
+ }
68
+ catch {
69
+ return false;
70
+ }
71
+ }
72
+ /**
73
+ * RFC 8785 JSON Canonicalization Scheme — inline minimal implementation.
74
+ * Used only for verification (matching what the server produced). This avoids
75
+ * importing from the trust-api package, keeping the CLI self-contained.
76
+ *
77
+ * NOTE: for the CLI's verification use-case this is equivalent to the server's
78
+ * jcsCanonicalize. The test suite verifies byte-identity between the server-side
79
+ * contentHash and the client-side re-derivation.
80
+ */
81
+ function jcsCanonicalizeMinimal(value) {
82
+ if (value === null)
83
+ return 'null';
84
+ if (typeof value === 'boolean')
85
+ return value ? 'true' : 'false';
86
+ if (typeof value === 'number') {
87
+ if (!Number.isFinite(value))
88
+ throw new Error('jcs: non-finite number');
89
+ if (value === 0)
90
+ return '0';
91
+ return String(value);
92
+ }
93
+ if (typeof value === 'string') {
94
+ // RFC 8785 §3.2.2.2: minimal escaping
95
+ let out = '"';
96
+ for (let i = 0; i < value.length; i++) {
97
+ const c = value.charCodeAt(i);
98
+ if (c === 0x08) {
99
+ out += '\\b';
100
+ continue;
101
+ }
102
+ if (c === 0x09) {
103
+ out += '\\t';
104
+ continue;
105
+ }
106
+ if (c === 0x0a) {
107
+ out += '\\n';
108
+ continue;
109
+ }
110
+ if (c === 0x0c) {
111
+ out += '\\f';
112
+ continue;
113
+ }
114
+ if (c === 0x0d) {
115
+ out += '\\r';
116
+ continue;
117
+ }
118
+ if (c === 0x22) {
119
+ out += '\\"';
120
+ continue;
121
+ }
122
+ if (c === 0x5c) {
123
+ out += '\\\\';
124
+ continue;
125
+ }
126
+ if (c < 0x20) {
127
+ out += '\\u' + c.toString(16).padStart(4, '0');
128
+ continue;
129
+ }
130
+ out += value[i];
131
+ }
132
+ return out + '"';
133
+ }
134
+ if (Array.isArray(value)) {
135
+ return '[' + value.map(jcsCanonicalizeMinimal).join(',') + ']';
136
+ }
137
+ if (typeof value === 'object') {
138
+ const obj = value;
139
+ const keys = Object.keys(obj).sort((a, b) => (a < b ? -1 : a > b ? 1 : 0));
140
+ const parts = [];
141
+ for (const k of keys) {
142
+ const v = obj[k];
143
+ if (v === undefined)
144
+ continue;
145
+ parts.push(jcsCanonicalizeMinimal(k) + ':' + jcsCanonicalizeMinimal(v));
146
+ }
147
+ return '{' + parts.join(',') + '}';
148
+ }
149
+ throw new Error(`jcs: unsupported type ${typeof value}`);
150
+ }
151
+ // ---------------------------------------------------------------------------
152
+ // Bundle fetch (HTTPS-only via curl — same pattern as rotate-token)
153
+ // ---------------------------------------------------------------------------
154
+ /**
155
+ * Fetch the governance bundle from the server using curl.
156
+ * Returns the parsed bundle, or null with a human-readable error.
157
+ *
158
+ * Invariant 2/3: server unreachable → returns null gracefully; local config
159
+ * is NEVER modified on a fetch failure.
160
+ *
161
+ * F4 (SECURITY 2026-06-09): serviceToken is passed via a 0600 temp file
162
+ * (curl -H @<tmpfile>) so it never appears on the process argv (visible in
163
+ * `ps aux`). The tmpfile is removed immediately after spawnSync returns.
164
+ * Falls back gracefully when mkdtemp/tmp is unavailable (non-blocking per
165
+ * Invariant 2) — but logs a warning since the fallback IS less secure.
166
+ */
167
+ function fetchBundle(govServerUrl, agentId, serviceToken, timeoutSec = 10) {
168
+ const url = `${govServerUrl.replace(/\/$/, '')}/api/v1/agents/${encodeURIComponent(agentId)}/governance-bundle`;
169
+ // F4 (SECURITY 2026-06-09): write the Authorization header to a 0600 temp
170
+ // file so the serviceToken never appears on the process argv. Falls back
171
+ // gracefully to inline argv header if the tmpfile can't be created (Invariant 2).
172
+ let authTmpFile = null;
173
+ try {
174
+ const tmpDir = process.env['TMPDIR'] || process.env['TMP'] || process.env['TEMP'] || '/tmp';
175
+ const tmpPath = path.join(tmpDir, `gov-sync-auth-${crypto.randomBytes(8).toString('hex')}.hdr`);
176
+ fs.writeFileSync(tmpPath, `Authorization: Bearer ${serviceToken}\n`, { mode: 0o600 });
177
+ try {
178
+ fs.chmodSync(tmpPath, 0o600);
179
+ }
180
+ catch { /* best-effort on Windows */ }
181
+ authTmpFile = tmpPath;
182
+ }
183
+ catch {
184
+ // CONDITION-2 (Shield 2026-06-09): emit an operator-visible warning so
185
+ // the degraded path is not silent. The token-on-argv path is functional
186
+ // but less secure (token visible in `ps aux` to local users).
187
+ // Non-fatal per Invariant 2 — sync continues.
188
+ console.warn('[gov-sync] WARNING: auth tmpfile write failed; falling back to token-on-argv (less secure). Check TMPDIR permissions.');
189
+ authTmpFile = null;
190
+ }
191
+ // Build curl args: prefer @tmpfile for Authorization, fall back to inline header.
192
+ const authArgs = authTmpFile
193
+ ? ['-H', `@${authTmpFile}`]
194
+ : ['-H', `Authorization: Bearer ${serviceToken}`];
195
+ // F4: wrap in try/finally so the tmpfile is always cleaned up, even on
196
+ // early returns. The token must not persist on disk after the request.
197
+ try {
198
+ const result = spawnSync('curl', [
199
+ '--silent',
200
+ '--show-error',
201
+ '--max-time', String(timeoutSec),
202
+ '--connect-timeout', '5',
203
+ '--fail-with-body',
204
+ ...authArgs,
205
+ '-H', 'Accept: application/json',
206
+ url,
207
+ ], { encoding: 'utf8', timeout: (timeoutSec + 5) * 1000 });
208
+ if (result.status !== 0) {
209
+ const detail = result.stderr?.trim() || `curl exit ${result.status}`;
210
+ return { bundle: null, error: `Server request failed: ${detail}` };
211
+ }
212
+ if (!result.stdout) {
213
+ return { bundle: null, error: 'Empty response from server.' };
214
+ }
215
+ let parsed;
216
+ try {
217
+ parsed = JSON.parse(result.stdout);
218
+ }
219
+ catch {
220
+ return { bundle: null, error: 'Could not parse server response as JSON.' };
221
+ }
222
+ if (typeof parsed !== 'object' || parsed === null) {
223
+ return { bundle: null, error: 'Server response is not a JSON object.' };
224
+ }
225
+ const obj = parsed;
226
+ if (typeof obj['error'] === 'string') {
227
+ return { bundle: null, error: `Server error: ${obj['error']}` };
228
+ }
229
+ return { bundle: obj };
230
+ }
231
+ catch (err) {
232
+ return { bundle: null, error: `Request failed: ${err.message}` };
233
+ }
234
+ finally {
235
+ // F4: always clean up the auth tmpfile (contains the serviceToken).
236
+ if (authTmpFile) {
237
+ try {
238
+ fs.unlinkSync(authTmpFile);
239
+ }
240
+ catch { /* best-effort */ }
241
+ }
242
+ }
243
+ }
244
+ /**
245
+ * Fetch the live per-agent serviceTokens for the org's ACTIVE agents.
246
+ *
247
+ * register-fleet DEFERS per-agent token issuance (3c HIGH-2); activate-fleet mints
248
+ * the tokens after the customer Confirms + pays. This calls the server with the
249
+ * INSTALL/org serviceToken (the runtime block's token, ORG_SERVICE/org-scoped) to
250
+ * retrieve the now-minted tokens. SCANNED (pre-payment) agents are not returned —
251
+ * runtime push for them stays unavailable until Confirm.
252
+ *
253
+ * Same security posture as fetchBundle: HTTPS via curl, Authorization in a 0600
254
+ * tmpfile (never on argv), and NEVER throws (Invariant 2/3 — server unreachable
255
+ * leaves local config unchanged).
256
+ */
257
+ function fetchAgentTokens(govServerUrl, installToken, timeoutSec = 10) {
258
+ const url = `${govServerUrl.replace(/\/$/, '')}/api/v1/cli/agent-tokens`;
259
+ let authTmpFile = null;
260
+ try {
261
+ const tmpDir = process.env['TMPDIR'] || process.env['TMP'] || process.env['TEMP'] || '/tmp';
262
+ const tmpPath = path.join(tmpDir, `gov-tok-auth-${crypto.randomBytes(8).toString('hex')}.hdr`);
263
+ fs.writeFileSync(tmpPath, `Authorization: Bearer ${installToken}\n`, { mode: 0o600 });
264
+ try {
265
+ fs.chmodSync(tmpPath, 0o600);
266
+ }
267
+ catch { /* best-effort on Windows */ }
268
+ authTmpFile = tmpPath;
269
+ }
270
+ catch {
271
+ console.warn('[gov-sync] WARNING: auth tmpfile write failed; falling back to token-on-argv (less secure). Check TMPDIR permissions.');
272
+ authTmpFile = null;
273
+ }
274
+ const authArgs = authTmpFile
275
+ ? ['-H', `@${authTmpFile}`]
276
+ : ['-H', `Authorization: Bearer ${installToken}`];
277
+ try {
278
+ const result = spawnSync('curl', [
279
+ '--silent', '--show-error',
280
+ '--max-time', String(timeoutSec),
281
+ '--connect-timeout', '5',
282
+ '--fail-with-body',
283
+ '-X', 'POST',
284
+ ...authArgs,
285
+ '-H', 'Content-Type: application/json',
286
+ '-H', 'Accept: application/json',
287
+ '--data', '{}',
288
+ url,
289
+ ], { encoding: 'utf8', timeout: (timeoutSec + 5) * 1000 });
290
+ if (result.status !== 0) {
291
+ const detail = result.stderr?.trim() || `curl exit ${result.status}`;
292
+ return { tokens: null, error: `Token fetch failed: ${detail}` };
293
+ }
294
+ if (!result.stdout)
295
+ return { tokens: null, error: 'Empty response from server.' };
296
+ let parsed;
297
+ try {
298
+ parsed = JSON.parse(result.stdout);
299
+ }
300
+ catch {
301
+ return { tokens: null, error: 'Could not parse token response as JSON.' };
302
+ }
303
+ const obj = parsed;
304
+ if (typeof obj?.['error'] === 'string')
305
+ return { tokens: null, error: `Server error: ${obj['error']}` };
306
+ if (!Array.isArray(obj?.['tokens']))
307
+ return { tokens: null, error: 'Server response missing tokens[].' };
308
+ const tokens = [];
309
+ for (const t of obj['tokens']) {
310
+ const e = t;
311
+ if (typeof e?.['agentId'] === 'string' && typeof e?.['serviceToken'] === 'string') {
312
+ tokens.push({
313
+ agentId: e['agentId'],
314
+ serviceToken: e['serviceToken'],
315
+ passportId: typeof e['passportId'] === 'string' ? e['passportId'] : null,
316
+ });
317
+ }
318
+ }
319
+ return { tokens };
320
+ }
321
+ catch (err) {
322
+ return { tokens: null, error: `Request failed: ${err.message}` };
323
+ }
324
+ finally {
325
+ if (authTmpFile) {
326
+ try {
327
+ fs.unlinkSync(authTmpFile);
328
+ }
329
+ catch { /* best-effort */ }
330
+ }
331
+ }
332
+ }
333
+ // ---------------------------------------------------------------------------
334
+ // Org public key pinning (trusted init-time fetch)
335
+ // ---------------------------------------------------------------------------
336
+ /**
337
+ * Fetch the org's Ed25519 public key from the governance server and write it
338
+ * into .governance.json under `orgPublicKey`.
339
+ *
340
+ * SECURITY: this must be called at `ai-governance init` time, BEFORE the first
341
+ * sync. The key is fetched over authenticated TLS from the same gov-server that
342
+ * issued the serviceToken. Subsequent syncs verify bundle signatures against
343
+ * this pinned key — they NEVER trust the key embedded in the bundle itself.
344
+ *
345
+ * The endpoint `GET /api/v1/orgs/:orgId/public-key` is unauthenticated (public
346
+ * keys are public, per Locked Invariant 4). We call it over the same
347
+ * govServerUrl + orgId that the exchange just returned, giving us a trusted
348
+ * source: the same TLS channel that authenticated the exchange.
349
+ *
350
+ * Returns the pinned key (base64url) on success, or null on failure.
351
+ * NEVER throws — on failure the caller logs the advisory and continues;
352
+ * the subsequent sync will fail closed (no pinned key = bundle rejected).
353
+ *
354
+ * DEPENDENCY NOTE (2026-06-08 prod incident): the org signing key is currently
355
+ * in-memory and regenerates on every gov-server deploy. With key-pinning, every
356
+ * post-deploy sync hits the rotation path until the key is made durable. That
357
+ * durability fix is the separate incident runbook — this function is still
358
+ * correct and required (rotation-as-explicit-event is the safe behavior).
359
+ */
360
+ export function fetchAndPinOrgPublicKey(govServerUrl, orgId, configPath, timeoutSec = 10) {
361
+ const url = `${govServerUrl.replace(/\/$/, '')}/api/v1/orgs/${encodeURIComponent(orgId)}/public-key`;
362
+ try {
363
+ const result = spawnSync('curl', [
364
+ '--silent',
365
+ '--show-error',
366
+ '--max-time', String(timeoutSec),
367
+ '--connect-timeout', '5',
368
+ '--fail-with-body',
369
+ '-H', 'Accept: application/json',
370
+ url,
371
+ ], { encoding: 'utf8', timeout: (timeoutSec + 5) * 1000 });
372
+ if (result.status !== 0) {
373
+ return null;
374
+ }
375
+ if (!result.stdout) {
376
+ return null;
377
+ }
378
+ let parsed;
379
+ try {
380
+ parsed = JSON.parse(result.stdout);
381
+ }
382
+ catch {
383
+ return null;
384
+ }
385
+ const obj = parsed;
386
+ // The endpoint returns `publicKeyPem` (base64url, 43 chars for 32-byte Ed25519 key).
387
+ const publicKeyB64 = typeof obj['publicKeyPem'] === 'string' ? obj['publicKeyPem'] : null;
388
+ if (!publicKeyB64 || publicKeyB64.length === 0) {
389
+ return null;
390
+ }
391
+ // Sanity check: must decode to 32 bytes (Ed25519 raw public key).
392
+ const raw = Buffer.from(publicKeyB64, 'base64url');
393
+ if (raw.length !== 32) {
394
+ return null;
395
+ }
396
+ // Write the pinned key into .governance.json with mode 0o600.
397
+ let config = {};
398
+ if (fs.existsSync(configPath)) {
399
+ try {
400
+ config = JSON.parse(fs.readFileSync(configPath, 'utf-8'));
401
+ }
402
+ catch { /* fresh */ }
403
+ }
404
+ config['orgPublicKey'] = publicKeyB64;
405
+ fs.writeFileSync(configPath, JSON.stringify(config, null, 2), { mode: 0o600 });
406
+ try {
407
+ fs.chmodSync(configPath, 0o600);
408
+ }
409
+ catch { /* best-effort on Windows */ }
410
+ return publicKeyB64;
411
+ }
412
+ catch {
413
+ return null;
414
+ }
415
+ }
416
+ // ---------------------------------------------------------------------------
417
+ // Bundle verification
418
+ // ---------------------------------------------------------------------------
419
+ /**
420
+ * Verify a governance bundle's Ed25519 signature.
421
+ *
422
+ * The signed payload is the JCS-canonical serialization of:
423
+ * { agentId, bundleVersion, governance, issuedAt, orgId }
424
+ * The contentHash is SHA-256(canonical_bytes), hex-encoded.
425
+ * The signature is Ed25519(raw_sha256_digest_bytes) by the org signing key.
426
+ *
427
+ * KEY-TRUST SECURITY (Shield TS-010 remediation):
428
+ * `pinnedPublicKeyB64` is the PINNED org public key read from the LOCAL
429
+ * .governance.json (written at trusted init time). It is the SOLE key used
430
+ * for signature verification. bundle['publicKeyB64'] is NEVER used as the
431
+ * verification key — doing so would allow a MITM/rogue server to substitute
432
+ * their own key alongside a tampered bundle and have it pass verification.
433
+ *
434
+ * When `pinnedPublicKeyB64` is undefined (no pinned key on disk), the bundle
435
+ * is REJECTED (fail closed). Re-running `ai-governance init` pins the key.
436
+ *
437
+ * KEY ROTATION: if bundle['publicKeyB64'] differs from the pinned key, the
438
+ * bundle is REJECTED with a rotation message. Key rotation is an EXPLICIT
439
+ * operator event — re-run `ai-governance init` to accept the new key over an
440
+ * authenticated TLS channel and re-pin it.
441
+ *
442
+ * Returns the verified SyncedGovernance on success, or an error string.
443
+ *
444
+ * SAFE BY DEFAULT: any failure (missing fields, bad sig, tampered, missing/
445
+ * mismatched pinned key) returns an error string; the caller MUST NOT apply.
446
+ */
447
+ export function verifyBundle(bundle, pinnedPublicKeyB64) {
448
+ // KEY-TRUST: fail closed when no pinned key is available.
449
+ if (!pinnedPublicKeyB64) {
450
+ return {
451
+ ok: false,
452
+ error: 'No pinned org public key found in .governance.json. ' +
453
+ 'Cannot verify bundle authenticity. ' +
454
+ 'Re-run `ai-governance init` to pin the org public key from a trusted server exchange.',
455
+ };
456
+ }
457
+ // Structural checks
458
+ const requiredFields = [
459
+ 'agentId', 'orgId', 'bundleVersion', 'issuedAt',
460
+ 'governance', 'contentHash', 'signature', 'publicKeyB64',
461
+ ];
462
+ for (const f of requiredFields) {
463
+ if (bundle[f] === undefined) {
464
+ return { ok: false, error: `Bundle missing required field: ${f}` };
465
+ }
466
+ }
467
+ const agentId = bundle['agentId'];
468
+ const orgId = bundle['orgId'];
469
+ const bundleVersion = bundle['bundleVersion'];
470
+ const issuedAt = bundle['issuedAt'];
471
+ const governance = bundle['governance'];
472
+ const contentHash = bundle['contentHash'];
473
+ const signature = bundle['signature'];
474
+ // bundle['publicKeyB64'] is informational (audit record only).
475
+ // NEVER used as the verification key — see security note above.
476
+ const bundlePublicKeyB64 = bundle['publicKeyB64'];
477
+ if (typeof agentId !== 'string' ||
478
+ typeof orgId !== 'string' ||
479
+ typeof bundleVersion !== 'string' ||
480
+ typeof issuedAt !== 'string' ||
481
+ typeof contentHash !== 'string' || contentHash.length !== 64 ||
482
+ typeof signature !== 'string' ||
483
+ typeof bundlePublicKeyB64 !== 'string' ||
484
+ typeof governance !== 'object' || governance === null) {
485
+ return { ok: false, error: 'Bundle has invalid field types.' };
486
+ }
487
+ // KEY ROTATION GUARD: if the bundle embeds a key different from the pinned
488
+ // key, reject — this is either a MITM attack or a genuine server-side key
489
+ // rotation. Either way, automatic acceptance is unsafe. The operator must
490
+ // re-run `ai-governance init` to explicitly accept the new key over TLS.
491
+ if (bundlePublicKeyB64 !== pinnedPublicKeyB64) {
492
+ return {
493
+ ok: false,
494
+ error: 'Bundle public key does not match the pinned org public key in .governance.json. ' +
495
+ 'This may indicate a key rotation or a MITM attack. ' +
496
+ 'Re-run `ai-governance init` to accept the new key from a trusted server exchange, ' +
497
+ 'then re-run `ai-governance sync`.',
498
+ };
499
+ }
500
+ // Re-derive the canonical payload (must match server's buildGovernanceBundle).
501
+ // JCS sorts object keys — same order as the server's jcsCanonicalize.
502
+ const payload = {
503
+ agentId,
504
+ bundleVersion,
505
+ governance,
506
+ issuedAt,
507
+ orgId,
508
+ };
509
+ let canonicalBytes;
510
+ try {
511
+ canonicalBytes = Buffer.from(jcsCanonicalizeMinimal(payload), 'utf8');
512
+ }
513
+ catch (e) {
514
+ return { ok: false, error: `Cannot canonicalize bundle payload: ${e.message}` };
515
+ }
516
+ const hashBytes = crypto.createHash('sha256').update(canonicalBytes).digest();
517
+ const expectedHash = hashBytes.toString('hex');
518
+ if (contentHash !== expectedHash) {
519
+ return {
520
+ ok: false,
521
+ error: `Bundle contentHash mismatch. Expected ${expectedHash}, got ${contentHash}. Bundle may be tampered.`,
522
+ };
523
+ }
524
+ // Verify Ed25519 signature against the PINNED key (not the bundle-embedded key).
525
+ const sigValid = verifyEd25519(signature, new Uint8Array(hashBytes), pinnedPublicKeyB64);
526
+ if (!sigValid) {
527
+ return { ok: false, error: 'Bundle signature is invalid. Rejecting tampered bundle.' };
528
+ }
529
+ // Extract governance fields
530
+ const gov = governance;
531
+ const syncedGovernance = {
532
+ packs: Array.isArray(gov['boundPacks'])
533
+ ? gov['boundPacks'].filter((p) => typeof p === 'string')
534
+ : [],
535
+ ruleOverrides: Array.isArray(gov['ruleOverrides']) ? [...gov['ruleOverrides']] : [],
536
+ declaredApprovalGates: Array.isArray(gov['declaredApprovalGates']) ? [...gov['declaredApprovalGates']] : [],
537
+ declaredForbiddenCapabilities: Array.isArray(gov['declaredForbiddenCapabilities'])
538
+ ? gov['declaredForbiddenCapabilities'].filter((c) => typeof c === 'string')
539
+ : [],
540
+ bundleIssuedAt: issuedAt,
541
+ contentHash: contentHash,
542
+ // signerPublicKeyB64 records the PINNED key used for verification (audit trail).
543
+ signerPublicKeyB64: pinnedPublicKeyB64,
544
+ };
545
+ return { ok: true, governance: syncedGovernance };
546
+ }
547
+ // ---------------------------------------------------------------------------
548
+ // Diff computation
549
+ // ---------------------------------------------------------------------------
550
+ /** Produce a human-readable unified-style diff between two SyncedGovernance objects. */
551
+ export function computeGovernanceDiff(previous, next) {
552
+ const lines = [];
553
+ const prevPacks = previous?.packs ?? [];
554
+ const nextPacks = next.packs;
555
+ const addedPacks = nextPacks.filter((p) => !prevPacks.includes(p));
556
+ const removedPacks = prevPacks.filter((p) => !nextPacks.includes(p));
557
+ if (addedPacks.length > 0) {
558
+ for (const p of addedPacks)
559
+ lines.push(`+ packs: ${p}`);
560
+ }
561
+ if (removedPacks.length > 0) {
562
+ for (const p of removedPacks)
563
+ lines.push(`- packs: ${p}`);
564
+ }
565
+ const prevOverrides = (previous?.ruleOverrides ?? []);
566
+ const nextOverrides = next.ruleOverrides;
567
+ if (JSON.stringify(prevOverrides) !== JSON.stringify(nextOverrides)) {
568
+ if (prevOverrides.length === 0 && nextOverrides.length > 0) {
569
+ lines.push(`+ ruleOverrides: ${nextOverrides.length} override(s) added`);
570
+ }
571
+ else if (nextOverrides.length === 0 && prevOverrides.length > 0) {
572
+ lines.push(`- ruleOverrides: ${prevOverrides.length} override(s) removed (now 0)`);
573
+ }
574
+ else {
575
+ lines.push(`~ ruleOverrides: ${prevOverrides.length} → ${nextOverrides.length} override(s)`);
576
+ }
577
+ }
578
+ const prevGates = (previous?.declaredApprovalGates ?? []);
579
+ const nextGates = next.declaredApprovalGates;
580
+ if (JSON.stringify(prevGates) !== JSON.stringify(nextGates)) {
581
+ lines.push(`~ declaredApprovalGates: ${prevGates.length} → ${nextGates.length}`);
582
+ }
583
+ const prevForbidden = previous?.declaredForbiddenCapabilities ?? [];
584
+ const nextForbidden = next.declaredForbiddenCapabilities;
585
+ const addedForbidden = nextForbidden.filter((c) => !prevForbidden.includes(c));
586
+ const removedForbidden = prevForbidden.filter((c) => !nextForbidden.includes(c));
587
+ for (const c of addedForbidden)
588
+ lines.push(`+ declaredForbiddenCapabilities: ${c}`);
589
+ for (const c of removedForbidden)
590
+ lines.push(`- declaredForbiddenCapabilities: ${c}`);
591
+ const prevBundleAt = previous?.bundleIssuedAt ?? '(none)';
592
+ if (prevBundleAt !== next.bundleIssuedAt) {
593
+ lines.push(`~ bundleIssuedAt: ${prevBundleAt} → ${next.bundleIssuedAt}`);
594
+ }
595
+ if (lines.length === 0) {
596
+ lines.push(' (no governance changes)');
597
+ }
598
+ return lines;
599
+ }
600
+ // ---------------------------------------------------------------------------
601
+ // .governance.json re-materialisation
602
+ // ---------------------------------------------------------------------------
603
+ /**
604
+ * Apply the synced governance to the local .governance.json.
605
+ *
606
+ * Rules:
607
+ * 1. NEVER silently clobber a hand-edited file. If the local `packs` field
608
+ * diverges from what `lastSync[agentId].governance.packs` recorded (i.e.
609
+ * the user hand-edited it since the last sync), surface that in the diff
610
+ * output. The --apply write still proceeds but the divergence is logged.
611
+ * 2. Write mode 0o600 — serviceTokens are present in the same file.
612
+ * 3. The `packs` top-level field is updated only when it is the first sync
613
+ * or when --apply is passed. It is the union of all agents' packs.
614
+ * 4. `lastSync[agentId]` is updated unconditionally on --apply.
615
+ *
616
+ * Returns a list of warnings (non-fatal) about hand-edit divergences.
617
+ */
618
+ export function applyGovernanceToLocal(configPath, agentId, newGovernance) {
619
+ const warnings = [];
620
+ let config = {};
621
+ if (fs.existsSync(configPath)) {
622
+ try {
623
+ config = JSON.parse(fs.readFileSync(configPath, 'utf-8'));
624
+ }
625
+ catch { /* fresh */ }
626
+ }
627
+ // Detect divergence: was the local `packs` hand-edited since the last sync?
628
+ const lastSyncMap = config['lastSync'] ?? {};
629
+ const prevSync = lastSyncMap[agentId];
630
+ const lastSyncedPacks = prevSync?.governance?.packs ?? null;
631
+ const localPacks = Array.isArray(config['packs'])
632
+ ? config['packs'].filter((p) => typeof p === 'string')
633
+ : [];
634
+ if (lastSyncedPacks !== null) {
635
+ // Check if local packs diverged from what we last synced
636
+ const localSet = new Set(localPacks);
637
+ const lastSyncedSet = new Set(lastSyncedPacks);
638
+ const handAdded = localPacks.filter((p) => !lastSyncedSet.has(p));
639
+ const handRemoved = lastSyncedPacks.filter((p) => !localSet.has(p));
640
+ if (handAdded.length > 0 || handRemoved.length > 0) {
641
+ const detail = [
642
+ ...(handAdded.map((p) => `+ ${p}`)),
643
+ ...(handRemoved.map((p) => `- ${p}`)),
644
+ ].join(', ');
645
+ warnings.push(`[sync] Divergence detected for agent ${agentId}: ` +
646
+ `local packs were hand-edited since last sync (${detail}). ` +
647
+ `Applying server governance will overwrite local edits.`);
648
+ }
649
+ }
650
+ // Update packs: use server's packs for this agent. For multi-agent projects,
651
+ // merge with other agents' already-synced packs (union, deduplicated).
652
+ const serverPacks = new Set(newGovernance.packs);
653
+ // Preserve packs from other agents' last-sync snapshots
654
+ for (const [otherId, otherSync] of Object.entries(lastSyncMap)) {
655
+ if (otherId === agentId)
656
+ continue;
657
+ const other = otherSync;
658
+ if (Array.isArray(other?.governance?.packs)) {
659
+ for (const p of other.governance.packs)
660
+ serverPacks.add(p);
661
+ }
662
+ }
663
+ config['packs'] = [...serverPacks].sort();
664
+ // Update lastSync snapshot
665
+ const updatedLastSync = {
666
+ agentId,
667
+ syncedAt: new Date().toISOString(),
668
+ governance: newGovernance,
669
+ };
670
+ config['lastSync'] = {
671
+ ...lastSyncMap,
672
+ [agentId]: updatedLastSync,
673
+ };
674
+ // Write atomically with mode 0o600 (serviceTokens present)
675
+ fs.writeFileSync(configPath, JSON.stringify(config, null, 2), { mode: 0o600 });
676
+ try {
677
+ fs.chmodSync(configPath, 0o600);
678
+ }
679
+ catch { /* best-effort on Windows */ }
680
+ return { warnings };
681
+ }
682
+ // ---------------------------------------------------------------------------
683
+ // Main sync command
684
+ // ---------------------------------------------------------------------------
685
+ /**
686
+ * Parse the args array and run the sync command.
687
+ *
688
+ * @param args Slice of process.argv after 'sync'
689
+ * @param projectDir Absolute path to the project directory (default: cwd)
690
+ * @param options.fetchBundleFn Override the fetch function for testing
691
+ * @returns exit code: 0 = success (or dry-run with no errors), 1 = some errors
692
+ */
693
+ export async function runSyncCommand(args, projectDir = process.cwd(), options = {}) {
694
+ const applyFlag = args.includes('--apply');
695
+ const agentFilter = (() => {
696
+ const idx = args.indexOf('--agent');
697
+ return idx >= 0 ? args[idx + 1] : undefined;
698
+ })();
699
+ const govServerUrlOverride = (() => {
700
+ const idx = args.indexOf('--gov-server-url');
701
+ return idx >= 0 ? args[idx + 1] : undefined;
702
+ })();
703
+ const log = options.silent ? () => { } : (s) => process.stdout.write(s + '\n');
704
+ const err = options.silent ? () => { } : (s) => process.stderr.write(s + '\n');
705
+ // Read .governance.json
706
+ const configPath = path.join(projectDir, '.governance.json');
707
+ if (!fs.existsSync(configPath)) {
708
+ err('No .governance.json found. Run `ai-governance init` first.');
709
+ return { dryRun: !applyFlag, diffs: [], applied: 0, errors: 1 };
710
+ }
711
+ let config;
712
+ try {
713
+ config = JSON.parse(fs.readFileSync(configPath, 'utf-8'));
714
+ }
715
+ catch (e) {
716
+ err(`Failed to parse .governance.json: ${e.message}`);
717
+ return { dryRun: !applyFlag, diffs: [], applied: 0, errors: 1 };
718
+ }
719
+ // Resolve gov server URL: cli flag > .governance.json runtime block > default
720
+ const runtime = config['runtime'] ?? {};
721
+ const govServerUrl = (govServerUrlOverride ??
722
+ (typeof runtime['govServerUrl'] === 'string' ? runtime['govServerUrl'] : null) ??
723
+ 'https://api.my-cc.io');
724
+ // Collect agents to sync from agents[] (per-agent identity, TS-002)
725
+ const agentsArr = Array.isArray(config['agents'])
726
+ ? config['agents']
727
+ : [];
728
+ // Fall back to the single-agent runtime block when agents[] is empty
729
+ // (pre-TS-002 installs that only have runtime.agentId)
730
+ const singleAgentFallback = agentsArr.length === 0 &&
731
+ typeof runtime['agentId'] === 'string' &&
732
+ typeof runtime['serviceToken'] === 'string'
733
+ ? {
734
+ localId: String(runtime['agentId']),
735
+ agentId: String(runtime['agentId']),
736
+ serviceToken: String(runtime['serviceToken']),
737
+ passportId: null,
738
+ filePath: undefined,
739
+ }
740
+ : null;
741
+ const allAgents = agentsArr.length > 0 ? agentsArr : singleAgentFallback ? [singleAgentFallback] : [];
742
+ if (allAgents.length === 0) {
743
+ err('No agents found in .governance.json. ' +
744
+ 'Run `ai-governance init --agent-dir <path>` to register agents first.');
745
+ return { dryRun: !applyFlag, diffs: [], applied: 0, errors: 1 };
746
+ }
747
+ // Filter to requested agent if --agent was passed
748
+ const toSync = agentFilter
749
+ ? allAgents.filter((a) => a.agentId === agentFilter || a.localId === agentFilter)
750
+ : allAgents;
751
+ if (toSync.length === 0) {
752
+ err(`No agent matching --agent ${agentFilter} found in .governance.json.`);
753
+ return { dryRun: !applyFlag, diffs: [], applied: 0, errors: 1 };
754
+ }
755
+ if (!applyFlag) {
756
+ log(`[sync] Dry-run mode (default). Use --apply to write changes.`);
757
+ }
758
+ log(`[sync] Server: ${govServerUrl}`);
759
+ log(`[sync] Agents: ${toSync.length}`);
760
+ const fetchFn = options.fetchBundleFn ?? fetchBundle;
761
+ const fetchTokensFn = options.fetchAgentTokensFn ?? fetchAgentTokens;
762
+ // Slice 3d: per-agent tokens are DEFERRED to payment activation (3c). Any agent
763
+ // still missing a serviceToken was detected pre-payment; once the customer
764
+ // Confirms + pays, activate-fleet mints the tokens. Fetch them here using the
765
+ // install/org token from the runtime block and merge them into agents[]. Agents
766
+ // that are still scanned (not returned) keep a null token → "push unavailable
767
+ // until Confirm" below. Never throws (Invariant 2/3): a failed fetch leaves the
768
+ // tokens null and the loop reports them honestly.
769
+ const installToken = typeof runtime['serviceToken'] === 'string' ? runtime['serviceToken'] : null;
770
+ if (toSync.some((a) => !a.serviceToken) && installToken) {
771
+ log('[sync] Fetching activated per-agent tokens...');
772
+ const { tokens, error: tokenErr } = fetchTokensFn(govServerUrl, installToken);
773
+ if (tokens && tokens.length > 0) {
774
+ const byId = new Map(tokens.map((t) => [t.agentId, t]));
775
+ let merged = 0;
776
+ // Merge into the persisted agents[] (so the tokens survive across runs) and
777
+ // into the in-memory toSync entries (so this run uses them immediately).
778
+ const persistedAgents = Array.isArray(config['agents']) ? config['agents'] : [];
779
+ for (const a of persistedAgents) {
780
+ const t = byId.get(a.agentId);
781
+ if (t && !a.serviceToken) {
782
+ a.serviceToken = t.serviceToken;
783
+ if (t.passportId)
784
+ a.passportId = t.passportId;
785
+ merged++;
786
+ }
787
+ }
788
+ for (const a of toSync) {
789
+ const t = byId.get(a.agentId);
790
+ if (t && !a.serviceToken) {
791
+ a.serviceToken = t.serviceToken;
792
+ if (t.passportId)
793
+ a.passportId = t.passportId;
794
+ }
795
+ }
796
+ // Persist the tokens only on --apply (dry-run writes nothing). The in-memory
797
+ // merge above still lets a dry-run fetch bundles + show the diff this run.
798
+ if (merged > 0 && applyFlag) {
799
+ config['agents'] = persistedAgents;
800
+ try {
801
+ fs.writeFileSync(configPath, JSON.stringify(config, null, 2), { mode: 0o600 });
802
+ try {
803
+ fs.chmodSync(configPath, 0o600);
804
+ }
805
+ catch { /* best-effort on Windows */ }
806
+ }
807
+ catch { /* advisory: in-memory tokens still used this run */ }
808
+ }
809
+ log(`[sync] Activated ${merged} agent token(s)${applyFlag ? '' : ' (dry-run — not written; re-run with --apply)'}.`);
810
+ }
811
+ else if (tokenErr) {
812
+ // Non-fatal: the agents without tokens fall through to the message below.
813
+ err(`[sync] Could not fetch activated tokens: ${tokenErr}`);
814
+ }
815
+ }
816
+ const diffs = [];
817
+ let applied = 0;
818
+ let errors = 0;
819
+ for (const agent of toSync) {
820
+ const { agentId, serviceToken, filePath } = agent;
821
+ if (!serviceToken) {
822
+ // Slice 3d (Decision 4): a token-less agent is still SCANNED — the customer
823
+ // has not Confirmed + paid, so it has no runnable identity yet. This is the
824
+ // fail-safe, not an error to fix by re-running init.
825
+ const d = {
826
+ agentId,
827
+ filePath,
828
+ bundle: null,
829
+ newGovernance: null,
830
+ previousGovernance: null,
831
+ diffLines: [],
832
+ error: 'Runtime push unavailable until Confirm — this agent is detected but not yet activated. ' +
833
+ 'Confirm + pay in the dashboard, then re-run `ai-governance sync`.',
834
+ };
835
+ diffs.push(d);
836
+ err(`[sync] agent ${agentId}: ${d.error}`);
837
+ errors++;
838
+ continue;
839
+ }
840
+ // Fetch bundle (Invariant 2/3: server unreachable → graceful no-op)
841
+ log(`[sync] Fetching bundle for agent ${agentId}...`);
842
+ const { bundle, error: fetchError } = fetchFn(govServerUrl, agentId, serviceToken);
843
+ if (!bundle || fetchError) {
844
+ const d = {
845
+ agentId,
846
+ filePath,
847
+ bundle: null,
848
+ newGovernance: null,
849
+ previousGovernance: null,
850
+ diffLines: [],
851
+ error: fetchError ?? 'Could not fetch bundle.',
852
+ };
853
+ diffs.push(d);
854
+ err(`[sync] agent ${agentId}: ${d.error}`);
855
+ err(`[sync] Server unreachable or request failed — local config unchanged.`);
856
+ errors++;
857
+ continue;
858
+ }
859
+ // KEY-TRUST: read the PINNED org public key from the local config.
860
+ // This was written at `ai-governance init` time over authenticated TLS.
861
+ // Do NOT use bundle['publicKeyB64'] — that is the attack vector.
862
+ const pinnedPublicKeyB64 = typeof config['orgPublicKey'] === 'string'
863
+ ? config['orgPublicKey']
864
+ : undefined;
865
+ // Verify signature against the PINNED key — REJECT if no pinned key or
866
+ // if the bundle key differs (rotation guard, see verifyBundle doc).
867
+ const verifyResult = verifyBundle(bundle, pinnedPublicKeyB64);
868
+ if (!verifyResult.ok) {
869
+ const d = {
870
+ agentId,
871
+ filePath,
872
+ bundle,
873
+ newGovernance: null,
874
+ previousGovernance: null,
875
+ diffLines: [],
876
+ error: verifyResult.error,
877
+ };
878
+ diffs.push(d);
879
+ err(`[sync] agent ${agentId}: REJECTED — ${verifyResult.error}`);
880
+ err(`[sync] Local config unchanged.`);
881
+ errors++;
882
+ continue;
883
+ }
884
+ const newGovernance = verifyResult.governance;
885
+ // Read previous sync snapshot for diff
886
+ const lastSyncMap = config['lastSync'] ?? {};
887
+ const prevSync = lastSyncMap[agentId];
888
+ const previousGovernance = prevSync?.governance ?? null;
889
+ // Compute diff
890
+ const diffLines = computeGovernanceDiff(previousGovernance, newGovernance);
891
+ const d = {
892
+ agentId,
893
+ filePath,
894
+ bundle,
895
+ newGovernance,
896
+ previousGovernance,
897
+ diffLines,
898
+ };
899
+ diffs.push(d);
900
+ // Print diff
901
+ log(`\n[sync] agent ${agentId}${filePath ? ` (${filePath})` : ''}:`);
902
+ for (const line of diffLines) {
903
+ log(` ${line}`);
904
+ }
905
+ if (applyFlag) {
906
+ const { warnings } = applyGovernanceToLocal(configPath, agentId, newGovernance);
907
+ // Re-read config after write so subsequent agents see the updated state
908
+ try {
909
+ config = JSON.parse(fs.readFileSync(configPath, 'utf-8'));
910
+ }
911
+ catch { /* advisory */ }
912
+ for (const w of warnings) {
913
+ err(w);
914
+ }
915
+ log(`[sync] agent ${agentId}: applied.`);
916
+ applied++;
917
+ }
918
+ }
919
+ if (!applyFlag && diffs.some((d) => d.newGovernance !== null)) {
920
+ log(`\n[sync] Dry-run complete. Re-run with --apply to write changes.`);
921
+ }
922
+ else if (applyFlag) {
923
+ log(`\n[sync] Applied ${applied}/${toSync.length} agent(s). Errors: ${errors}.`);
924
+ }
925
+ return { dryRun: !applyFlag, diffs, applied, errors };
926
+ }
927
+ //# sourceMappingURL=sync.js.map