@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.
- package/dist/cli/agent-dir-scanner.d.ts +32 -0
- package/dist/cli/agent-dir-scanner.d.ts.map +1 -1
- package/dist/cli/agent-dir-scanner.js +47 -0
- package/dist/cli/agent-dir-scanner.js.map +1 -1
- package/dist/cli/index.d.ts +75 -0
- package/dist/cli/index.d.ts.map +1 -1
- package/dist/cli/index.js +268 -2
- package/dist/cli/index.js.map +1 -1
- package/dist/cli/sync.d.ts +189 -0
- package/dist/cli/sync.d.ts.map +1 -0
- package/dist/cli/sync.js +967 -0
- package/dist/cli/sync.js.map +1 -0
- package/dist/esm/cli/agent-dir-scanner.js +47 -0
- package/dist/esm/cli/agent-dir-scanner.js.map +1 -1
- package/dist/esm/cli/index.js +267 -2
- package/dist/esm/cli/index.js.map +1 -1
- package/dist/esm/cli/sync.js +927 -0
- package/dist/esm/cli/sync.js.map +1 -0
- package/dist/hooks/audit-logger.sh +108 -10
- package/package.json +1 -1
- package/src/hooks/audit-logger.sh +108 -10
|
@@ -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
|