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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. package/dist/cli/agent-dir-scanner.d.ts +32 -0
  2. package/dist/cli/agent-dir-scanner.d.ts.map +1 -1
  3. package/dist/cli/agent-dir-scanner.js +52 -0
  4. package/dist/cli/agent-dir-scanner.js.map +1 -1
  5. package/dist/cli/governance-md-renderer.d.ts +50 -0
  6. package/dist/cli/governance-md-renderer.d.ts.map +1 -0
  7. package/dist/cli/governance-md-renderer.js +185 -0
  8. package/dist/cli/governance-md-renderer.js.map +1 -0
  9. package/dist/cli/governance-projection-writer.d.ts +88 -0
  10. package/dist/cli/governance-projection-writer.d.ts.map +1 -0
  11. package/dist/cli/governance-projection-writer.js +291 -0
  12. package/dist/cli/governance-projection-writer.js.map +1 -0
  13. package/dist/cli/index.d.ts +85 -0
  14. package/dist/cli/index.d.ts.map +1 -1
  15. package/dist/cli/index.js +343 -2
  16. package/dist/cli/index.js.map +1 -1
  17. package/dist/cli/per-folder-identity.d.ts +79 -0
  18. package/dist/cli/per-folder-identity.d.ts.map +1 -0
  19. package/dist/cli/per-folder-identity.js +321 -0
  20. package/dist/cli/per-folder-identity.js.map +1 -0
  21. package/dist/cli/sync.d.ts +193 -0
  22. package/dist/cli/sync.d.ts.map +1 -0
  23. package/dist/cli/sync.js +1094 -0
  24. package/dist/cli/sync.js.map +1 -0
  25. package/dist/cli/wrap-shim-generator.d.ts +33 -0
  26. package/dist/cli/wrap-shim-generator.d.ts.map +1 -1
  27. package/dist/cli/wrap-shim-generator.js +93 -8
  28. package/dist/cli/wrap-shim-generator.js.map +1 -1
  29. package/dist/esm/cli/agent-dir-scanner.js +52 -0
  30. package/dist/esm/cli/agent-dir-scanner.js.map +1 -1
  31. package/dist/esm/cli/governance-md-renderer.js +182 -0
  32. package/dist/esm/cli/governance-md-renderer.js.map +1 -0
  33. package/dist/esm/cli/governance-projection-writer.js +253 -0
  34. package/dist/esm/cli/governance-projection-writer.js.map +1 -0
  35. package/dist/esm/cli/index.js +343 -3
  36. package/dist/esm/cli/index.js.map +1 -1
  37. package/dist/esm/cli/per-folder-identity.js +283 -0
  38. package/dist/esm/cli/per-folder-identity.js.map +1 -0
  39. package/dist/esm/cli/sync.js +1054 -0
  40. package/dist/esm/cli/sync.js.map +1 -0
  41. package/dist/esm/cli/wrap-shim-generator.js +92 -8
  42. package/dist/esm/cli/wrap-shim-generator.js.map +1 -1
  43. package/dist/esm/governance/governance-projection-canonical.js +101 -0
  44. package/dist/esm/governance/governance-projection-canonical.js.map +1 -0
  45. package/dist/governance/governance-projection-canonical.d.ts +104 -0
  46. package/dist/governance/governance-projection-canonical.d.ts.map +1 -0
  47. package/dist/governance/governance-projection-canonical.js +141 -0
  48. package/dist/governance/governance-projection-canonical.js.map +1 -0
  49. package/dist/hooks/audit-logger.sh +108 -10
  50. package/package.json +1 -1
  51. package/src/hooks/audit-logger.sh +108 -10
@@ -22,7 +22,9 @@ import { JwtValidator } from '../license/index.js';
22
22
  import { BUILT_IN_PACKS } from '../governed-agent.js';
23
23
  import { scanAgentDirectory } from './agent-dir-scanner.js';
24
24
  import { normalizeAgentDir } from './normalize-agent-dir.js';
25
- import { writeWrapShims } from './wrap-shim-generator.js';
25
+ import { writeWrapShims, regenerateShimAgentIds } from './wrap-shim-generator.js';
26
+ // Identity RFC B1 (per-folder identity files) — Pass-1 wiring (init --link).
27
+ import { writePerFolderIdentities, buildPerFolderIdentityInputs } from './per-folder-identity.js';
26
28
  import { preflightAgents } from './preflight.js';
27
29
  import { formatPreflightReport } from './preflight-report.js';
28
30
  /**
@@ -1208,6 +1210,25 @@ async function interactiveInit(projectDir, opts = {}) {
1208
1210
  writeVendorCodeToConfig(projectDir, '', true);
1209
1211
  process.stdout.write('Offline mode recorded in .governance.json\n');
1210
1212
  }
1213
+ // TS-010 KEY-TRUST: pin the org public key from the server into
1214
+ // .governance.json immediately after the config is written.
1215
+ // The key is fetched over authenticated TLS from the same gov-server that
1216
+ // issued the serviceToken. Subsequent `ai-governance sync` calls verify
1217
+ // bundle signatures against this pinned key (never the bundle-embedded key).
1218
+ // Non-fatal: advisory if unavailable (Invariant 2), but sync will fail
1219
+ // closed (bundle rejected) until the key is pinned.
1220
+ if (opts.runtime?.govServerUrl && opts.runtime?.orgId) {
1221
+ const { fetchAndPinOrgPublicKey } = require('./sync.js');
1222
+ const pinned = fetchAndPinOrgPublicKey(opts.runtime.govServerUrl, opts.runtime.orgId, configPath);
1223
+ if (pinned) {
1224
+ process.stdout.write(`Org public key pinned for offline bundle verification.\n`);
1225
+ }
1226
+ else {
1227
+ process.stderr.write('WARNING: could not fetch org public key for pinning. ' +
1228
+ '`ai-governance sync` will reject bundles until the key is pinned. ' +
1229
+ 'Re-run `ai-governance init` when the server is reachable.\n');
1230
+ }
1231
+ }
1211
1232
  // Install hooks
1212
1233
  const { installed, errors } = installHooks(projectDir, config);
1213
1234
  process.stdout.write(`Hooks installed: ${installed}\n`);
@@ -1689,6 +1710,109 @@ export function writeGovernanceMcpSuggest(projectDir, tenantId) {
1689
1710
  ].join('\n'));
1690
1711
  return path.relative(projectDir, mcpSuggestPath);
1691
1712
  }
1713
+ /**
1714
+ * TS-002 CLI half — consume the registered[] list returned by register-fleet
1715
+ * and write a per-agent identity block into the project's .governance.json.
1716
+ *
1717
+ * Shape written:
1718
+ * ```json
1719
+ * {
1720
+ * "agents": [
1721
+ * { "localId": "auto-...", "agentId": "srv-uuid", "passportId": "pp-...",
1722
+ * "serviceToken": "eyJ...", "filePath": "src/agents/billing.py" }
1723
+ * ],
1724
+ * "runtime": {
1725
+ * "agentId": "<first-registered-agentId>",
1726
+ * "serviceToken": "<first-registered-serviceToken>",
1727
+ * ...rest of existing runtime block
1728
+ * }
1729
+ * }
1730
+ * ```
1731
+ *
1732
+ * Back-compat guarantee: runtime.agentId + runtime.serviceToken are updated to
1733
+ * the first registered agent so the existing audit-logger.sh → /cluster-events
1734
+ * push path (which reads these two flat fields) continues to work for the
1735
+ * single-agent case. The per-agent `agents[]` array is the source of truth for
1736
+ * multi-agent deployments; future per-agent attribution reads from there.
1737
+ *
1738
+ * Null-passport and null-serviceToken entries are written without throwing
1739
+ * (Invariant 2 — advisory, never blocking). A null serviceToken from agent N
1740
+ * does NOT overwrite the runtime.serviceToken that was already set.
1741
+ *
1742
+ * Idempotent: calling twice with the same input upserts by localId — does not
1743
+ * duplicate entries.
1744
+ *
1745
+ * @param projectDir Absolute path to the directory containing .governance.json
1746
+ * @param registered Array from register-fleet response.registered[]
1747
+ * @param localAgents Locally scanned DetectedAgent list (used to resolve filePath by localId)
1748
+ */
1749
+ export function writePerAgentIdentities(projectDir, registered, localAgents, opts = {}) {
1750
+ if (registered.length === 0)
1751
+ return;
1752
+ const configPath = path.join(projectDir, '.governance.json');
1753
+ // Read existing config — must preserve all existing keys (packs, hooks, etc.)
1754
+ let config = {};
1755
+ if (fs.existsSync(configPath)) {
1756
+ try {
1757
+ config = JSON.parse(fs.readFileSync(configPath, 'utf-8'));
1758
+ }
1759
+ catch { /* fresh config — start empty */ }
1760
+ }
1761
+ // Build a quick-lookup: localId → filePath from the local scan.
1762
+ // For entries where localId is absent, fall back to agentId as key.
1763
+ const filePathByLocalId = new Map();
1764
+ for (const la of localAgents) {
1765
+ filePathByLocalId.set(la.agentId, la.filePath);
1766
+ }
1767
+ // Build the new per-agent entries.
1768
+ const newEntries = registered.map((r) => {
1769
+ const key = r.localId ?? r.agentId;
1770
+ return {
1771
+ localId: key,
1772
+ agentId: r.agentId,
1773
+ passportId: r.passportId,
1774
+ serviceToken: r.serviceToken,
1775
+ filePath: filePathByLocalId.get(key),
1776
+ };
1777
+ });
1778
+ // Upsert into existing agents[] (idempotent — replace by localId).
1779
+ const existingEntries = Array.isArray(config['agents'])
1780
+ ? config['agents']
1781
+ : [];
1782
+ const merged = [...existingEntries];
1783
+ for (const entry of newEntries) {
1784
+ const idx = merged.findIndex((e) => e.localId === entry.localId);
1785
+ if (idx >= 0) {
1786
+ merged[idx] = entry;
1787
+ }
1788
+ else {
1789
+ merged.push(entry);
1790
+ }
1791
+ }
1792
+ config['agents'] = merged;
1793
+ // Back-compat: update runtime.agentId + runtime.serviceToken to the first
1794
+ // registered agent so the audit-logger.sh single-agent push path keeps working.
1795
+ // Only update runtime.serviceToken if the first agent's token is non-null
1796
+ // (do not overwrite a valid existing token with null).
1797
+ const first = newEntries[0];
1798
+ if (first) {
1799
+ const runtime = config['runtime'] ?? {};
1800
+ runtime['agentId'] = first.agentId;
1801
+ if (first.serviceToken !== null) {
1802
+ runtime['serviceToken'] = first.serviceToken;
1803
+ }
1804
+ if (opts.agentDir) {
1805
+ runtime['agentDir'] = opts.agentDir;
1806
+ }
1807
+ config['runtime'] = runtime;
1808
+ }
1809
+ // Write atomically — mode 0o600 (service tokens are secrets, Invariant 10).
1810
+ fs.writeFileSync(configPath, JSON.stringify(config, null, 2), { mode: 0o600 });
1811
+ try {
1812
+ fs.chmodSync(configPath, 0o600);
1813
+ }
1814
+ catch { /* best-effort on Windows */ }
1815
+ }
1692
1816
  // --- Non-interactive init (CI mode) ---
1693
1817
  export async function nonInteractiveInit(projectDir, opts) {
1694
1818
  const licenseKey = opts.license || 'free';
@@ -2437,6 +2561,10 @@ Usage:
2437
2561
  [--archive-dir <dir>] Archive destination (else delete)
2438
2562
  [--dry-run] Report without acting
2439
2563
  [--json] Machine-readable output
2564
+ ai-governance sync Pull server governance bundle to local .governance.json
2565
+ [--apply] Write changes (default: dry-run, shows diff only)
2566
+ [--agent <id>] Sync a specific agent only
2567
+ [--gov-server-url <url>] Override gov server URL
2440
2568
  ai-governance rotate-token Rotate the runtime service token
2441
2569
  Reads .governance.json runtime.serviceToken,
2442
2570
  requests a fresh token from the gov-server,
@@ -2762,7 +2890,74 @@ if (isDirectRun) {
2762
2890
  if (runtimeConfig?.serviceToken && runtimeConfig?.govServerUrl && detectedAgents.length > 0) {
2763
2891
  try {
2764
2892
  const { spawnSync } = require('node:child_process');
2893
+ // TS-009: capture project identity so the server can link agents to
2894
+ // the customer's actual project for audit roll-up in the portal.
2895
+ //
2896
+ // projectId: deterministic hash of the agent-dir absolute path.
2897
+ // Uses SHA-256(resolvedAgentDir) → first 16 hex chars. Stable across
2898
+ // re-runs on the same machine for the same project. If the customer
2899
+ // moves the repo, a new projectId is generated (a new project entity).
2900
+ //
2901
+ // projectName: basename of resolvedAgentDir (human-readable). If git
2902
+ // is available and resolvedAgentDir is inside a repo, use the repo
2903
+ // name (basename of the top-level). Falls back to dir basename.
2904
+ //
2905
+ // origin: git remote 'origin' URL, read locally by the CLI from the
2906
+ // customer's repo. The client's CLI reads its own git remote — we
2907
+ // NEVER scan or poll their infrastructure (Invariant 10: push-receive).
2908
+ // Only sent when git is available and the remote is set; never fails.
2909
+ //
2910
+ // Advisory (Invariant 2): any failure in this block produces null
2911
+ // values — the fleet payload is always sent and registration never
2912
+ // fails because of a missing project block.
2913
+ const captureProjectIdentity = () => {
2914
+ let origin;
2915
+ let projectName = path.basename(resolvedAgentDir);
2916
+ let gitRootDir;
2917
+ // Attempt to read git remote origin and repo name (client reads
2918
+ // its own git — Invariant 10 respected).
2919
+ try {
2920
+ const gitRemote = spawnSync('git', ['-C', resolvedAgentDir, 'remote', 'get-url', 'origin'], { encoding: 'utf8', timeout: 3000 });
2921
+ if (gitRemote.status === 0 && gitRemote.stdout) {
2922
+ origin = gitRemote.stdout.trim();
2923
+ // Derive projectName from the repo name embedded in the origin URL.
2924
+ // Works for both SSH (git@github.com:org/repo.git) and HTTPS
2925
+ // (https://github.com/org/repo.git). Strip trailing .git.
2926
+ const match = origin.match(/[\/:]([\w.-]+?)(?:\.git)?$/);
2927
+ if (match && match[1]) {
2928
+ projectName = match[1];
2929
+ }
2930
+ }
2931
+ }
2932
+ catch { /* git not available or not a git repo — use dir basename */ }
2933
+ try {
2934
+ // Best-effort: get the git top-level for a more stable projectId
2935
+ const gitRoot = spawnSync('git', ['-C', resolvedAgentDir, 'rev-parse', '--show-toplevel'], { encoding: 'utf8', timeout: 3000 });
2936
+ if (gitRoot.status === 0 && gitRoot.stdout) {
2937
+ gitRootDir = gitRoot.stdout.trim();
2938
+ // Override projectName with repo root basename when git is available
2939
+ const rootBasename = path.basename(gitRootDir);
2940
+ if (rootBasename)
2941
+ projectName = rootBasename;
2942
+ }
2943
+ }
2944
+ catch { /* best-effort */ }
2945
+ // projectId: deterministic hash of the absolute project root dir
2946
+ // (git root when available, otherwise resolvedAgentDir). This gives
2947
+ // all register-fleet runs from the same repo the same projectId.
2948
+ const hashInput = gitRootDir ?? resolvedAgentDir;
2949
+ const projectId = 'proj-' + crypto.createHash('sha256').update(hashInput).digest('hex').slice(0, 16);
2950
+ return { projectId, projectName, ...(origin ? { origin } : {}) };
2951
+ };
2952
+ let projectIdentity;
2953
+ try {
2954
+ projectIdentity = captureProjectIdentity();
2955
+ }
2956
+ catch {
2957
+ // Advisory: project identity capture failure must never block fleet registration.
2958
+ }
2765
2959
  const fleetPayload = {
2960
+ ...(projectIdentity ? { project: projectIdentity } : {}),
2766
2961
  agents: detectedAgents.map((a) => ({
2767
2962
  localId: a.agentId,
2768
2963
  name: a.name ?? undefined,
@@ -2774,6 +2969,17 @@ if (isDirectRun) {
2774
2969
  // so the platform's OWASP/ASI analysis grades real tool surface.
2775
2970
  tools: a.tools && a.tools.length > 0 ? a.tools : undefined,
2776
2971
  mcpServers: a.mcpServers && a.mcpServers.length > 0 ? a.mcpServers : undefined,
2972
+ // TS-008 lossless capture (ticket A2): the scanner already
2973
+ // parses these from manifest.json; sending them closes the
2974
+ // model/version/vertical/allowed_domains/toolDetails/
2975
+ // max_concurrent_actions = null gap on the registered agent
2976
+ // (server validates + persists them — FleetAgentInput).
2977
+ model_identifier: a.model_identifier,
2978
+ version: a.version,
2979
+ vertical: a.vertical,
2980
+ allowed_domains: a.allowed_domains && a.allowed_domains.length > 0 ? a.allowed_domains : undefined,
2981
+ toolDetails: a.toolDetails && a.toolDetails.length > 0 ? a.toolDetails : undefined,
2982
+ max_concurrent_actions: a.max_concurrent_actions,
2777
2983
  })),
2778
2984
  };
2779
2985
  const fleetResult = spawnSync('curl', [
@@ -2792,9 +2998,109 @@ if (isDirectRun) {
2792
2998
  const resp = JSON.parse(fleetResult.stdout);
2793
2999
  if (typeof resp.registeredCount === 'number') {
2794
3000
  fleetRegistered = true;
2795
- process.stdout.write(`\n✅ Registered ${resp.registeredCount} agent(s) on the platform` +
3001
+ process.stdout.write(`\n✅ Detected ${resp.registeredCount} agent(s) on the platform` +
2796
3002
  (resp.skippedCount ? ` (${resp.skippedCount} already registered, skipped)` : '') +
2797
- ` — check your dashboard.\n`);
3003
+ ` — check your dashboard.\n` +
3004
+ // Slice 3c/3d (Decision 4): passports + runtime tokens are issued
3005
+ // at payment activation, not at detection. Tell the customer that
3006
+ // runtime push is unavailable until they Confirm + pay, and how to
3007
+ // activate it afterward.
3008
+ ` ⏳ Runtime governance push is UNAVAILABLE until you Confirm + pay in the dashboard\n` +
3009
+ ` (detected agents are not yet activated). After you Confirm, run\n` +
3010
+ ` \`ai-governance sync --apply\` to activate each agent's runtime push.\n`);
3011
+ // TS-002 CLI half: persist per-agent identities into .governance.json.
3012
+ // Each agent gets its own serviceToken (sub=agent:<agentId>) so future
3013
+ // per-agent audit pushes carry the correct identity. The runtime block
3014
+ // is updated to the first agent for single-agent backward-compat.
3015
+ // Non-fatal (Invariant 2): if this write fails governance still runs.
3016
+ // Ghost-fix (#1717) fail-loud: print WHY any agent failed to
3017
+ // register — a bare count hid the b60d6cbc-class failures.
3018
+ if (Array.isArray(resp.failures) && resp.failures.length > 0) {
3019
+ for (const f of resp.failures) {
3020
+ process.stderr.write(` ❌ ${f.name}: registration failed — ${f.reason}\n`);
3021
+ }
3022
+ process.stderr.write(' Re-run the install command to retry the failed agent(s).\n');
3023
+ }
3024
+ if (typeof resp.healedGhostCount === 'number' && resp.healedGhostCount > 0) {
3025
+ process.stdout.write(` ♻ Healed ${resp.healedGhostCount} previously-stuck agent registration(s).\n`);
3026
+ }
3027
+ if (Array.isArray(resp.registered) && resp.registered.length > 0) {
3028
+ try {
3029
+ // Shield F1 (#1720, MEDIUM): server agentIds are BAKED into
3030
+ // executable shim files via raw template interpolation and
3031
+ // into URL paths — gate them against the canonical format
3032
+ // at the trust boundary before ANY downstream use. A
3033
+ // non-matching id from a compromised/MITM'd response is
3034
+ // dropped with a visible WARN, never interpolated.
3035
+ const SAFE_SERVER_AGENT_ID = /^agent-[0-9a-f]{8}$/;
3036
+ const safeRegistered = resp.registered.filter((r) => {
3037
+ if (SAFE_SERVER_AGENT_ID.test(r.agentId))
3038
+ return true;
3039
+ process.stderr.write(` [WARN] server returned a non-canonical agentId (${JSON.stringify(r.agentId).slice(0, 60)}) — entry dropped.\n`);
3040
+ return false;
3041
+ });
3042
+ const localRefs = detectedAgents.map((a) => ({
3043
+ agentId: a.agentId,
3044
+ filePath: a.filePath,
3045
+ }));
3046
+ // Persist the scan root so sync passes can resolve the
3047
+ // SCAN-ROOT-relative agents[].filePath to real folders.
3048
+ const agentDirRel = path.relative(projectDir, resolvedAgentDir) || '.';
3049
+ writePerAgentIdentities(projectDir, safeRegistered, localRefs, {
3050
+ agentDir: path.isAbsolute(agentDirRel) ? resolvedAgentDir : agentDirRel,
3051
+ });
3052
+ process.stdout.write(`[CLI] per-agent identities written to .governance.json ` +
3053
+ `(${safeRegistered.length} agent(s)).\n`);
3054
+ // Identity RFC B1 (Pass 1): land each agent's identity in
3055
+ // ITS OWN folder — <agent>/.connexum/identity.json. No
3056
+ // secret at this pass (tokens are minted at payment
3057
+ // activation), so this writes metadata regardless of git
3058
+ // state. Folder root = the SCAN root, not projectDir.
3059
+ const folderInputs = buildPerFolderIdentityInputs(safeRegistered, localRefs, {
3060
+ installationId: resp.installationId,
3061
+ signingKeyId: resp.signingKeyId,
3062
+ govServerUrl: resp.govServerUrl ?? runtimeConfig.govServerUrl,
3063
+ orgId: resp.orgId ?? runtimeConfig.orgId,
3064
+ }, new Date().toISOString());
3065
+ const folderRes = writePerFolderIdentities(resolvedAgentDir, folderInputs, {
3066
+ warn: (m) => process.stderr.write(m + '\n'),
3067
+ });
3068
+ process.stdout.write(`[CLI] per-folder identity files: ${folderRes.written.length} written` +
3069
+ (folderRes.skipped.length > 0 ? `, ${folderRes.skipped.length} skipped` : '') + '.\n');
3070
+ // Identity RFC C1 (Option B): regenerate each shim with
3071
+ // the SERVER agentId. Without this every shim keeps the
3072
+ // baked local auto-* id and all runtime signals collapse
3073
+ // onto ids the platform never issued. Pristine shims are
3074
+ // rebaked; edited shims get a VISIBLE warn (never a
3075
+ // silent skip — Stern BLOCK-2).
3076
+ const serverIdByLocalId = new Map();
3077
+ for (const r of safeRegistered) {
3078
+ if (r.localId)
3079
+ serverIdByLocalId.set(r.localId, r.agentId);
3080
+ }
3081
+ const regenResults = regenerateShimAgentIds(detectedAgents, serverIdByLocalId, resolvedAgentDir, (msg) => process.stdout.write(msg + '\n'));
3082
+ const rebaked = regenResults.filter((r) => r.status === 'regenerated' || r.status === 'verified').length;
3083
+ const stuck = regenResults.filter((r) => r.status === 'skipped-edited' || r.status === 'failed');
3084
+ process.stdout.write(`[CLI] shim identity rebake: ${rebaked} on server ids` +
3085
+ (stuck.length > 0 ? `, ${stuck.length} NEED ATTENTION (listed above)` : '') + '.\n');
3086
+ if (stuck.length > 0) {
3087
+ // Stern F1 (#1720): the recovery path must be in the
3088
+ // operator-facing summary, not buried in per-file WARNs.
3089
+ process.stderr.write('⚠️ The agent(s) listed above still emit their LOCAL id at runtime — their\n' +
3090
+ ' signals will not attach to the registered platform identity until fixed.\n' +
3091
+ ' Recovery: if you did NOT edit the .governed file yourself, DELETE it and\n' +
3092
+ ' re-run this install command — it will be regenerated with the correct id.\n' +
3093
+ ' If you did edit it, set its AGENT_ID to the server id shown above.\n');
3094
+ }
3095
+ }
3096
+ catch (identityErr) {
3097
+ // Non-fatal — fleet is registered on the platform; local identity
3098
+ // write failure must not undo that success (Invariant 2).
3099
+ process.stderr.write(`[CLI] per-agent identity write failed (non-fatal): ` +
3100
+ `${identityErr.message}. ` +
3101
+ `Agents are registered on the platform; re-run init to retry the write.\n`);
3102
+ }
3103
+ }
2798
3104
  }
2799
3105
  else if (resp.error) {
2800
3106
  process.stderr.write(`[CLI] register-fleet: server error: ${resp.error}\n`);
@@ -2885,6 +3191,27 @@ if (isDirectRun) {
2885
3191
  runtime: runtimeConfig,
2886
3192
  agentDir: agentDirArg,
2887
3193
  });
3194
+ // TS-010 KEY-TRUST: pin the org public key from the server into
3195
+ // .governance.json immediately after init writes the config file.
3196
+ // This is the trusted pinning event — the key is fetched over the same
3197
+ // authenticated TLS channel that issued the runtimeConfig exchange.
3198
+ // Subsequent `ai-governance sync` calls verify bundle signatures against
3199
+ // this pinned key. Non-fatal: advisory if unavailable (Invariant 2), but
3200
+ // sync will fail closed until the key is pinned.
3201
+ if (runtimeConfig?.govServerUrl && runtimeConfig?.orgId) {
3202
+ // fetchAndPinOrgPublicKey is imported from sync.ts
3203
+ const { fetchAndPinOrgPublicKey } = require('./sync.js');
3204
+ const configPath = path.join(projectDir, '.governance.json');
3205
+ const pinned = fetchAndPinOrgPublicKey(runtimeConfig.govServerUrl, runtimeConfig.orgId, configPath);
3206
+ if (pinned) {
3207
+ process.stderr.write(`[CLI] org public key pinned to .governance.json (${pinned.slice(0, 8)}...).\n`);
3208
+ }
3209
+ else {
3210
+ process.stderr.write('[CLI] WARNING: could not fetch org public key for pinning. ' +
3211
+ '`ai-governance sync` will fail closed until the key is pinned. ' +
3212
+ 'Re-run `ai-governance init` when the server is reachable to pin the key.\n');
3213
+ }
3214
+ }
2888
3215
  }
2889
3216
  else if (!agentDirFlag) {
2890
3217
  interactiveInit(projectDir, { vendorCodeFlag, offlineFlag, licenseServerUrl, runtime: runtimeConfig }).catch(err => {
@@ -2987,6 +3314,19 @@ if (isDirectRun) {
2987
3314
  });
2988
3315
  });
2989
3316
  }
3317
+ else if (command === 'sync') {
3318
+ // TS-010: pull server governance bundle and re-materialize local .governance.json.
3319
+ // Safe by default — no --apply = dry-run (shows diff, writes nothing).
3320
+ // See src/cli/sync.ts.
3321
+ import('./sync.js').then(({ runSyncCommand }) => {
3322
+ runSyncCommand(args.slice(1), projectDir).then((result) => {
3323
+ process.exit(result.errors > 0 ? 1 : 0);
3324
+ }).catch((e) => {
3325
+ process.stderr.write(`Error: ${e.message}\n`);
3326
+ process.exit(2);
3327
+ });
3328
+ });
3329
+ }
2990
3330
  else if (command === 'scan') {
2991
3331
  // A-2 (2026-05-19): customer-side OWASP Agentic Top 10 scan. Replaces
2992
3332
  // the deprecated W-03 server-side Git-clone flow. Source code stays on