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