@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
package/dist/cli/index.js CHANGED
@@ -64,6 +64,7 @@ exports.writeCursorGovernanceRules = writeCursorGovernanceRules;
64
64
  exports.writeContinueConfig = writeContinueConfig;
65
65
  exports.writeGovernanceQuickstart = writeGovernanceQuickstart;
66
66
  exports.writeGovernanceMcpSuggest = writeGovernanceMcpSuggest;
67
+ exports.writePerAgentIdentities = writePerAgentIdentities;
67
68
  exports.nonInteractiveInit = nonInteractiveInit;
68
69
  exports.retentionSweep = retentionSweep;
69
70
  exports.verifyChain = verifyChain;
@@ -79,6 +80,8 @@ const governed_agent_1 = require("../governed-agent");
79
80
  const agent_dir_scanner_1 = require("./agent-dir-scanner");
80
81
  const normalize_agent_dir_1 = require("./normalize-agent-dir");
81
82
  const wrap_shim_generator_1 = require("./wrap-shim-generator");
83
+ // Identity RFC B1 (per-folder identity files) — Pass-1 wiring (init --link).
84
+ const per_folder_identity_1 = require("./per-folder-identity");
82
85
  const preflight_1 = require("./preflight");
83
86
  const preflight_report_1 = require("./preflight-report");
84
87
  /**
@@ -1264,6 +1267,25 @@ async function interactiveInit(projectDir, opts = {}) {
1264
1267
  writeVendorCodeToConfig(projectDir, '', true);
1265
1268
  process.stdout.write('Offline mode recorded in .governance.json\n');
1266
1269
  }
1270
+ // TS-010 KEY-TRUST: pin the org public key from the server into
1271
+ // .governance.json immediately after the config is written.
1272
+ // The key is fetched over authenticated TLS from the same gov-server that
1273
+ // issued the serviceToken. Subsequent `ai-governance sync` calls verify
1274
+ // bundle signatures against this pinned key (never the bundle-embedded key).
1275
+ // Non-fatal: advisory if unavailable (Invariant 2), but sync will fail
1276
+ // closed (bundle rejected) until the key is pinned.
1277
+ if (opts.runtime?.govServerUrl && opts.runtime?.orgId) {
1278
+ const { fetchAndPinOrgPublicKey } = require('./sync.js');
1279
+ const pinned = fetchAndPinOrgPublicKey(opts.runtime.govServerUrl, opts.runtime.orgId, configPath);
1280
+ if (pinned) {
1281
+ process.stdout.write(`Org public key pinned for offline bundle verification.\n`);
1282
+ }
1283
+ else {
1284
+ process.stderr.write('WARNING: could not fetch org public key for pinning. ' +
1285
+ '`ai-governance sync` will reject bundles until the key is pinned. ' +
1286
+ 'Re-run `ai-governance init` when the server is reachable.\n');
1287
+ }
1288
+ }
1267
1289
  // Install hooks
1268
1290
  const { installed, errors } = installHooks(projectDir, config);
1269
1291
  process.stdout.write(`Hooks installed: ${installed}\n`);
@@ -1745,6 +1767,109 @@ function writeGovernanceMcpSuggest(projectDir, tenantId) {
1745
1767
  ].join('\n'));
1746
1768
  return path.relative(projectDir, mcpSuggestPath);
1747
1769
  }
1770
+ /**
1771
+ * TS-002 CLI half — consume the registered[] list returned by register-fleet
1772
+ * and write a per-agent identity block into the project's .governance.json.
1773
+ *
1774
+ * Shape written:
1775
+ * ```json
1776
+ * {
1777
+ * "agents": [
1778
+ * { "localId": "auto-...", "agentId": "srv-uuid", "passportId": "pp-...",
1779
+ * "serviceToken": "eyJ...", "filePath": "src/agents/billing.py" }
1780
+ * ],
1781
+ * "runtime": {
1782
+ * "agentId": "<first-registered-agentId>",
1783
+ * "serviceToken": "<first-registered-serviceToken>",
1784
+ * ...rest of existing runtime block
1785
+ * }
1786
+ * }
1787
+ * ```
1788
+ *
1789
+ * Back-compat guarantee: runtime.agentId + runtime.serviceToken are updated to
1790
+ * the first registered agent so the existing audit-logger.sh → /cluster-events
1791
+ * push path (which reads these two flat fields) continues to work for the
1792
+ * single-agent case. The per-agent `agents[]` array is the source of truth for
1793
+ * multi-agent deployments; future per-agent attribution reads from there.
1794
+ *
1795
+ * Null-passport and null-serviceToken entries are written without throwing
1796
+ * (Invariant 2 — advisory, never blocking). A null serviceToken from agent N
1797
+ * does NOT overwrite the runtime.serviceToken that was already set.
1798
+ *
1799
+ * Idempotent: calling twice with the same input upserts by localId — does not
1800
+ * duplicate entries.
1801
+ *
1802
+ * @param projectDir Absolute path to the directory containing .governance.json
1803
+ * @param registered Array from register-fleet response.registered[]
1804
+ * @param localAgents Locally scanned DetectedAgent list (used to resolve filePath by localId)
1805
+ */
1806
+ function writePerAgentIdentities(projectDir, registered, localAgents, opts = {}) {
1807
+ if (registered.length === 0)
1808
+ return;
1809
+ const configPath = path.join(projectDir, '.governance.json');
1810
+ // Read existing config — must preserve all existing keys (packs, hooks, etc.)
1811
+ let config = {};
1812
+ if (fs.existsSync(configPath)) {
1813
+ try {
1814
+ config = JSON.parse(fs.readFileSync(configPath, 'utf-8'));
1815
+ }
1816
+ catch { /* fresh config — start empty */ }
1817
+ }
1818
+ // Build a quick-lookup: localId → filePath from the local scan.
1819
+ // For entries where localId is absent, fall back to agentId as key.
1820
+ const filePathByLocalId = new Map();
1821
+ for (const la of localAgents) {
1822
+ filePathByLocalId.set(la.agentId, la.filePath);
1823
+ }
1824
+ // Build the new per-agent entries.
1825
+ const newEntries = registered.map((r) => {
1826
+ const key = r.localId ?? r.agentId;
1827
+ return {
1828
+ localId: key,
1829
+ agentId: r.agentId,
1830
+ passportId: r.passportId,
1831
+ serviceToken: r.serviceToken,
1832
+ filePath: filePathByLocalId.get(key),
1833
+ };
1834
+ });
1835
+ // Upsert into existing agents[] (idempotent — replace by localId).
1836
+ const existingEntries = Array.isArray(config['agents'])
1837
+ ? config['agents']
1838
+ : [];
1839
+ const merged = [...existingEntries];
1840
+ for (const entry of newEntries) {
1841
+ const idx = merged.findIndex((e) => e.localId === entry.localId);
1842
+ if (idx >= 0) {
1843
+ merged[idx] = entry;
1844
+ }
1845
+ else {
1846
+ merged.push(entry);
1847
+ }
1848
+ }
1849
+ config['agents'] = merged;
1850
+ // Back-compat: update runtime.agentId + runtime.serviceToken to the first
1851
+ // registered agent so the audit-logger.sh single-agent push path keeps working.
1852
+ // Only update runtime.serviceToken if the first agent's token is non-null
1853
+ // (do not overwrite a valid existing token with null).
1854
+ const first = newEntries[0];
1855
+ if (first) {
1856
+ const runtime = config['runtime'] ?? {};
1857
+ runtime['agentId'] = first.agentId;
1858
+ if (first.serviceToken !== null) {
1859
+ runtime['serviceToken'] = first.serviceToken;
1860
+ }
1861
+ if (opts.agentDir) {
1862
+ runtime['agentDir'] = opts.agentDir;
1863
+ }
1864
+ config['runtime'] = runtime;
1865
+ }
1866
+ // Write atomically — mode 0o600 (service tokens are secrets, Invariant 10).
1867
+ fs.writeFileSync(configPath, JSON.stringify(config, null, 2), { mode: 0o600 });
1868
+ try {
1869
+ fs.chmodSync(configPath, 0o600);
1870
+ }
1871
+ catch { /* best-effort on Windows */ }
1872
+ }
1748
1873
  // --- Non-interactive init (CI mode) ---
1749
1874
  async function nonInteractiveInit(projectDir, opts) {
1750
1875
  const licenseKey = opts.license || 'free';
@@ -2493,6 +2618,10 @@ Usage:
2493
2618
  [--archive-dir <dir>] Archive destination (else delete)
2494
2619
  [--dry-run] Report without acting
2495
2620
  [--json] Machine-readable output
2621
+ ai-governance sync Pull server governance bundle to local .governance.json
2622
+ [--apply] Write changes (default: dry-run, shows diff only)
2623
+ [--agent <id>] Sync a specific agent only
2624
+ [--gov-server-url <url>] Override gov server URL
2496
2625
  ai-governance rotate-token Rotate the runtime service token
2497
2626
  Reads .governance.json runtime.serviceToken,
2498
2627
  requests a fresh token from the gov-server,
@@ -2818,7 +2947,74 @@ if (isDirectRun) {
2818
2947
  if (runtimeConfig?.serviceToken && runtimeConfig?.govServerUrl && detectedAgents.length > 0) {
2819
2948
  try {
2820
2949
  const { spawnSync } = require('node:child_process');
2950
+ // TS-009: capture project identity so the server can link agents to
2951
+ // the customer's actual project for audit roll-up in the portal.
2952
+ //
2953
+ // projectId: deterministic hash of the agent-dir absolute path.
2954
+ // Uses SHA-256(resolvedAgentDir) → first 16 hex chars. Stable across
2955
+ // re-runs on the same machine for the same project. If the customer
2956
+ // moves the repo, a new projectId is generated (a new project entity).
2957
+ //
2958
+ // projectName: basename of resolvedAgentDir (human-readable). If git
2959
+ // is available and resolvedAgentDir is inside a repo, use the repo
2960
+ // name (basename of the top-level). Falls back to dir basename.
2961
+ //
2962
+ // origin: git remote 'origin' URL, read locally by the CLI from the
2963
+ // customer's repo. The client's CLI reads its own git remote — we
2964
+ // NEVER scan or poll their infrastructure (Invariant 10: push-receive).
2965
+ // Only sent when git is available and the remote is set; never fails.
2966
+ //
2967
+ // Advisory (Invariant 2): any failure in this block produces null
2968
+ // values — the fleet payload is always sent and registration never
2969
+ // fails because of a missing project block.
2970
+ const captureProjectIdentity = () => {
2971
+ let origin;
2972
+ let projectName = path.basename(resolvedAgentDir);
2973
+ let gitRootDir;
2974
+ // Attempt to read git remote origin and repo name (client reads
2975
+ // its own git — Invariant 10 respected).
2976
+ try {
2977
+ const gitRemote = spawnSync('git', ['-C', resolvedAgentDir, 'remote', 'get-url', 'origin'], { encoding: 'utf8', timeout: 3000 });
2978
+ if (gitRemote.status === 0 && gitRemote.stdout) {
2979
+ origin = gitRemote.stdout.trim();
2980
+ // Derive projectName from the repo name embedded in the origin URL.
2981
+ // Works for both SSH (git@github.com:org/repo.git) and HTTPS
2982
+ // (https://github.com/org/repo.git). Strip trailing .git.
2983
+ const match = origin.match(/[\/:]([\w.-]+?)(?:\.git)?$/);
2984
+ if (match && match[1]) {
2985
+ projectName = match[1];
2986
+ }
2987
+ }
2988
+ }
2989
+ catch { /* git not available or not a git repo — use dir basename */ }
2990
+ try {
2991
+ // Best-effort: get the git top-level for a more stable projectId
2992
+ const gitRoot = spawnSync('git', ['-C', resolvedAgentDir, 'rev-parse', '--show-toplevel'], { encoding: 'utf8', timeout: 3000 });
2993
+ if (gitRoot.status === 0 && gitRoot.stdout) {
2994
+ gitRootDir = gitRoot.stdout.trim();
2995
+ // Override projectName with repo root basename when git is available
2996
+ const rootBasename = path.basename(gitRootDir);
2997
+ if (rootBasename)
2998
+ projectName = rootBasename;
2999
+ }
3000
+ }
3001
+ catch { /* best-effort */ }
3002
+ // projectId: deterministic hash of the absolute project root dir
3003
+ // (git root when available, otherwise resolvedAgentDir). This gives
3004
+ // all register-fleet runs from the same repo the same projectId.
3005
+ const hashInput = gitRootDir ?? resolvedAgentDir;
3006
+ const projectId = 'proj-' + crypto.createHash('sha256').update(hashInput).digest('hex').slice(0, 16);
3007
+ return { projectId, projectName, ...(origin ? { origin } : {}) };
3008
+ };
3009
+ let projectIdentity;
3010
+ try {
3011
+ projectIdentity = captureProjectIdentity();
3012
+ }
3013
+ catch {
3014
+ // Advisory: project identity capture failure must never block fleet registration.
3015
+ }
2821
3016
  const fleetPayload = {
3017
+ ...(projectIdentity ? { project: projectIdentity } : {}),
2822
3018
  agents: detectedAgents.map((a) => ({
2823
3019
  localId: a.agentId,
2824
3020
  name: a.name ?? undefined,
@@ -2830,6 +3026,17 @@ if (isDirectRun) {
2830
3026
  // so the platform's OWASP/ASI analysis grades real tool surface.
2831
3027
  tools: a.tools && a.tools.length > 0 ? a.tools : undefined,
2832
3028
  mcpServers: a.mcpServers && a.mcpServers.length > 0 ? a.mcpServers : undefined,
3029
+ // TS-008 lossless capture (ticket A2): the scanner already
3030
+ // parses these from manifest.json; sending them closes the
3031
+ // model/version/vertical/allowed_domains/toolDetails/
3032
+ // max_concurrent_actions = null gap on the registered agent
3033
+ // (server validates + persists them — FleetAgentInput).
3034
+ model_identifier: a.model_identifier,
3035
+ version: a.version,
3036
+ vertical: a.vertical,
3037
+ allowed_domains: a.allowed_domains && a.allowed_domains.length > 0 ? a.allowed_domains : undefined,
3038
+ toolDetails: a.toolDetails && a.toolDetails.length > 0 ? a.toolDetails : undefined,
3039
+ max_concurrent_actions: a.max_concurrent_actions,
2833
3040
  })),
2834
3041
  };
2835
3042
  const fleetResult = spawnSync('curl', [
@@ -2848,9 +3055,109 @@ if (isDirectRun) {
2848
3055
  const resp = JSON.parse(fleetResult.stdout);
2849
3056
  if (typeof resp.registeredCount === 'number') {
2850
3057
  fleetRegistered = true;
2851
- process.stdout.write(`\n✅ Registered ${resp.registeredCount} agent(s) on the platform` +
3058
+ process.stdout.write(`\n✅ Detected ${resp.registeredCount} agent(s) on the platform` +
2852
3059
  (resp.skippedCount ? ` (${resp.skippedCount} already registered, skipped)` : '') +
2853
- ` — check your dashboard.\n`);
3060
+ ` — check your dashboard.\n` +
3061
+ // Slice 3c/3d (Decision 4): passports + runtime tokens are issued
3062
+ // at payment activation, not at detection. Tell the customer that
3063
+ // runtime push is unavailable until they Confirm + pay, and how to
3064
+ // activate it afterward.
3065
+ ` ⏳ Runtime governance push is UNAVAILABLE until you Confirm + pay in the dashboard\n` +
3066
+ ` (detected agents are not yet activated). After you Confirm, run\n` +
3067
+ ` \`ai-governance sync --apply\` to activate each agent's runtime push.\n`);
3068
+ // TS-002 CLI half: persist per-agent identities into .governance.json.
3069
+ // Each agent gets its own serviceToken (sub=agent:<agentId>) so future
3070
+ // per-agent audit pushes carry the correct identity. The runtime block
3071
+ // is updated to the first agent for single-agent backward-compat.
3072
+ // Non-fatal (Invariant 2): if this write fails governance still runs.
3073
+ // Ghost-fix (#1717) fail-loud: print WHY any agent failed to
3074
+ // register — a bare count hid the b60d6cbc-class failures.
3075
+ if (Array.isArray(resp.failures) && resp.failures.length > 0) {
3076
+ for (const f of resp.failures) {
3077
+ process.stderr.write(` ❌ ${f.name}: registration failed — ${f.reason}\n`);
3078
+ }
3079
+ process.stderr.write(' Re-run the install command to retry the failed agent(s).\n');
3080
+ }
3081
+ if (typeof resp.healedGhostCount === 'number' && resp.healedGhostCount > 0) {
3082
+ process.stdout.write(` ♻ Healed ${resp.healedGhostCount} previously-stuck agent registration(s).\n`);
3083
+ }
3084
+ if (Array.isArray(resp.registered) && resp.registered.length > 0) {
3085
+ try {
3086
+ // Shield F1 (#1720, MEDIUM): server agentIds are BAKED into
3087
+ // executable shim files via raw template interpolation and
3088
+ // into URL paths — gate them against the canonical format
3089
+ // at the trust boundary before ANY downstream use. A
3090
+ // non-matching id from a compromised/MITM'd response is
3091
+ // dropped with a visible WARN, never interpolated.
3092
+ const SAFE_SERVER_AGENT_ID = /^agent-[0-9a-f]{8}$/;
3093
+ const safeRegistered = resp.registered.filter((r) => {
3094
+ if (SAFE_SERVER_AGENT_ID.test(r.agentId))
3095
+ return true;
3096
+ process.stderr.write(` [WARN] server returned a non-canonical agentId (${JSON.stringify(r.agentId).slice(0, 60)}) — entry dropped.\n`);
3097
+ return false;
3098
+ });
3099
+ const localRefs = detectedAgents.map((a) => ({
3100
+ agentId: a.agentId,
3101
+ filePath: a.filePath,
3102
+ }));
3103
+ // Persist the scan root so sync passes can resolve the
3104
+ // SCAN-ROOT-relative agents[].filePath to real folders.
3105
+ const agentDirRel = path.relative(projectDir, resolvedAgentDir) || '.';
3106
+ writePerAgentIdentities(projectDir, safeRegistered, localRefs, {
3107
+ agentDir: path.isAbsolute(agentDirRel) ? resolvedAgentDir : agentDirRel,
3108
+ });
3109
+ process.stdout.write(`[CLI] per-agent identities written to .governance.json ` +
3110
+ `(${safeRegistered.length} agent(s)).\n`);
3111
+ // Identity RFC B1 (Pass 1): land each agent's identity in
3112
+ // ITS OWN folder — <agent>/.connexum/identity.json. No
3113
+ // secret at this pass (tokens are minted at payment
3114
+ // activation), so this writes metadata regardless of git
3115
+ // state. Folder root = the SCAN root, not projectDir.
3116
+ const folderInputs = (0, per_folder_identity_1.buildPerFolderIdentityInputs)(safeRegistered, localRefs, {
3117
+ installationId: resp.installationId,
3118
+ signingKeyId: resp.signingKeyId,
3119
+ govServerUrl: resp.govServerUrl ?? runtimeConfig.govServerUrl,
3120
+ orgId: resp.orgId ?? runtimeConfig.orgId,
3121
+ }, new Date().toISOString());
3122
+ const folderRes = (0, per_folder_identity_1.writePerFolderIdentities)(resolvedAgentDir, folderInputs, {
3123
+ warn: (m) => process.stderr.write(m + '\n'),
3124
+ });
3125
+ process.stdout.write(`[CLI] per-folder identity files: ${folderRes.written.length} written` +
3126
+ (folderRes.skipped.length > 0 ? `, ${folderRes.skipped.length} skipped` : '') + '.\n');
3127
+ // Identity RFC C1 (Option B): regenerate each shim with
3128
+ // the SERVER agentId. Without this every shim keeps the
3129
+ // baked local auto-* id and all runtime signals collapse
3130
+ // onto ids the platform never issued. Pristine shims are
3131
+ // rebaked; edited shims get a VISIBLE warn (never a
3132
+ // silent skip — Stern BLOCK-2).
3133
+ const serverIdByLocalId = new Map();
3134
+ for (const r of safeRegistered) {
3135
+ if (r.localId)
3136
+ serverIdByLocalId.set(r.localId, r.agentId);
3137
+ }
3138
+ const regenResults = (0, wrap_shim_generator_1.regenerateShimAgentIds)(detectedAgents, serverIdByLocalId, resolvedAgentDir, (msg) => process.stdout.write(msg + '\n'));
3139
+ const rebaked = regenResults.filter((r) => r.status === 'regenerated' || r.status === 'verified').length;
3140
+ const stuck = regenResults.filter((r) => r.status === 'skipped-edited' || r.status === 'failed');
3141
+ process.stdout.write(`[CLI] shim identity rebake: ${rebaked} on server ids` +
3142
+ (stuck.length > 0 ? `, ${stuck.length} NEED ATTENTION (listed above)` : '') + '.\n');
3143
+ if (stuck.length > 0) {
3144
+ // Stern F1 (#1720): the recovery path must be in the
3145
+ // operator-facing summary, not buried in per-file WARNs.
3146
+ process.stderr.write('⚠️ The agent(s) listed above still emit their LOCAL id at runtime — their\n' +
3147
+ ' signals will not attach to the registered platform identity until fixed.\n' +
3148
+ ' Recovery: if you did NOT edit the .governed file yourself, DELETE it and\n' +
3149
+ ' re-run this install command — it will be regenerated with the correct id.\n' +
3150
+ ' If you did edit it, set its AGENT_ID to the server id shown above.\n');
3151
+ }
3152
+ }
3153
+ catch (identityErr) {
3154
+ // Non-fatal — fleet is registered on the platform; local identity
3155
+ // write failure must not undo that success (Invariant 2).
3156
+ process.stderr.write(`[CLI] per-agent identity write failed (non-fatal): ` +
3157
+ `${identityErr.message}. ` +
3158
+ `Agents are registered on the platform; re-run init to retry the write.\n`);
3159
+ }
3160
+ }
2854
3161
  }
2855
3162
  else if (resp.error) {
2856
3163
  process.stderr.write(`[CLI] register-fleet: server error: ${resp.error}\n`);
@@ -2941,6 +3248,27 @@ if (isDirectRun) {
2941
3248
  runtime: runtimeConfig,
2942
3249
  agentDir: agentDirArg,
2943
3250
  });
3251
+ // TS-010 KEY-TRUST: pin the org public key from the server into
3252
+ // .governance.json immediately after init writes the config file.
3253
+ // This is the trusted pinning event — the key is fetched over the same
3254
+ // authenticated TLS channel that issued the runtimeConfig exchange.
3255
+ // Subsequent `ai-governance sync` calls verify bundle signatures against
3256
+ // this pinned key. Non-fatal: advisory if unavailable (Invariant 2), but
3257
+ // sync will fail closed until the key is pinned.
3258
+ if (runtimeConfig?.govServerUrl && runtimeConfig?.orgId) {
3259
+ // fetchAndPinOrgPublicKey is imported from sync.ts
3260
+ const { fetchAndPinOrgPublicKey } = require('./sync.js');
3261
+ const configPath = path.join(projectDir, '.governance.json');
3262
+ const pinned = fetchAndPinOrgPublicKey(runtimeConfig.govServerUrl, runtimeConfig.orgId, configPath);
3263
+ if (pinned) {
3264
+ process.stderr.write(`[CLI] org public key pinned to .governance.json (${pinned.slice(0, 8)}...).\n`);
3265
+ }
3266
+ else {
3267
+ process.stderr.write('[CLI] WARNING: could not fetch org public key for pinning. ' +
3268
+ '`ai-governance sync` will fail closed until the key is pinned. ' +
3269
+ 'Re-run `ai-governance init` when the server is reachable to pin the key.\n');
3270
+ }
3271
+ }
2944
3272
  }
2945
3273
  else if (!agentDirFlag) {
2946
3274
  interactiveInit(projectDir, { vendorCodeFlag, offlineFlag, licenseServerUrl, runtime: runtimeConfig }).catch(err => {
@@ -3043,6 +3371,19 @@ if (isDirectRun) {
3043
3371
  });
3044
3372
  });
3045
3373
  }
3374
+ else if (command === 'sync') {
3375
+ // TS-010: pull server governance bundle and re-materialize local .governance.json.
3376
+ // Safe by default — no --apply = dry-run (shows diff, writes nothing).
3377
+ // See src/cli/sync.ts.
3378
+ Promise.resolve().then(() => __importStar(require('./sync.js'))).then(({ runSyncCommand }) => {
3379
+ runSyncCommand(args.slice(1), projectDir).then((result) => {
3380
+ process.exit(result.errors > 0 ? 1 : 0);
3381
+ }).catch((e) => {
3382
+ process.stderr.write(`Error: ${e.message}\n`);
3383
+ process.exit(2);
3384
+ });
3385
+ });
3386
+ }
3046
3387
  else if (command === 'scan') {
3047
3388
  // A-2 (2026-05-19): customer-side OWASP Agentic Top 10 scan. Replaces
3048
3389
  // the deprecated W-03 server-side Git-clone flow. Source code stays on