fullcourtdefense-cli 1.15.12 → 1.17.0

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.
@@ -50,6 +50,7 @@ const notify_1 = require("../notify");
50
50
  const integrity_1 = require("../integrity");
51
51
  const machineIdentity_1 = require("../machineIdentity");
52
52
  const discoveryMarker_1 = require("../discoveryMarker");
53
+ const selfUpdate_1 = require("../selfUpdate");
53
54
  const COLOR = {
54
55
  reset: '\x1b[0m', bold: '\x1b[1m', dim: '\x1b[2m',
55
56
  red: '\x1b[31m', yellow: '\x1b[33m', green: '\x1b[32m', cyan: '\x1b[36m', gray: '\x1b[90m',
@@ -293,6 +294,8 @@ async function runDaemon(args, config) {
293
294
  let suspended = false;
294
295
  let stopped = false;
295
296
  const executingActionIds = new Set();
297
+ /** Latest org auto-update policy seen on a bundle poll. */
298
+ let autoUpdatePolicy;
296
299
  const reprotect = async (reasonPaths) => {
297
300
  if (reprotecting || stopped)
298
301
  return;
@@ -525,6 +528,24 @@ async function runDaemon(args, config) {
525
528
  log(`Repair protection: verified (${verification.protectedMcpConfigs}/${verification.discoveredMcpConfigs} MCP configs wrapped).`);
526
529
  resultSummary = 'AgentGuard hooks, gateways, and protection configuration were repaired and verified.';
527
530
  }
531
+ else if (action.type === 'upgrade_cli') {
532
+ // Explicit version in the action reason wins; otherwise the org
533
+ // auto-update target from the bundle (pinned or latest release).
534
+ const target = action.reason.match(/\d+\.\d+\.\d+/)?.[0] || autoUpdatePolicy?.targetVersion;
535
+ if (!target)
536
+ throw new Error('No target version available — the control plane could not resolve the latest release.');
537
+ log(`Upgrade CLI: admin requested an upgrade to ${target}.`);
538
+ const outcome = (0, selfUpdate_1.maybeSelfUpdate)({ currentVersion: cliVersion(), targetVersion: target, enabled: true, log });
539
+ if (!outcome) {
540
+ resultSummary = `CLI ${cliVersion() || 'unknown'} is already at ${target} (or an upgrade is in progress).`;
541
+ }
542
+ else if (outcome.started) {
543
+ resultSummary = outcome.detail;
544
+ }
545
+ else {
546
+ throw new Error(outcome.detail);
547
+ }
548
+ }
528
549
  else if (action.type === 'discovery_scan') {
529
550
  log('Discovery scan: starting full surface sweep (MCP + secrets + agent files + posture)…');
530
551
  await uploadLogTail();
@@ -585,6 +606,27 @@ async function runDaemon(args, config) {
585
606
  if (bundle.machineAction) {
586
607
  void executeMachineAction(bundle.machineAction);
587
608
  }
609
+ // Silent auto-update: upgrade toward the org's target version. Skipped
610
+ // while a remote action runs, while suspended, and rate-limited so a
611
+ // pending upgrade isn't re-kicked every poll.
612
+ autoUpdatePolicy = bundle.autoUpdate;
613
+ // Mirror the org policy for the elevated MSI updater task (it runs as
614
+ // SYSTEM and cannot read the shield-key-authenticated bundle itself).
615
+ if (bundle.autoUpdate) {
616
+ try {
617
+ fs.writeFileSync(path.join(daemonDir(), 'update-policy.json'), JSON.stringify({ ...bundle.autoUpdate, updatedAt: new Date().toISOString() }), 'utf8');
618
+ }
619
+ catch { /* mirror is best-effort; the updater defaults to enabled */ }
620
+ }
621
+ if (!suspended && bundle.autoUpdate?.enabled) {
622
+ (0, selfUpdate_1.maybeSelfUpdate)({
623
+ currentVersion: cliVersion(),
624
+ targetVersion: bundle.autoUpdate.targetVersion,
625
+ enabled: true,
626
+ busy: executingActionIds.size > 0,
627
+ log,
628
+ });
629
+ }
588
630
  }
589
631
  catch { /* offline — cached stance applies */ }
590
632
  };
@@ -42,8 +42,9 @@ async function installAllCommand(args, config) {
42
42
  apiUrl: creds.apiUrl,
43
43
  project: args.cursorProject,
44
44
  // Scope is MCP/tool actions only — prompt text never leaves the machine.
45
+ // No failClosed flag: the offline stance is server-authoritative (from
46
+ // the runtime bundle), unified with the Claude/VS Code hook below.
45
47
  events: 'shell,mcp',
46
- failClosed: 'true',
47
48
  };
48
49
  try {
49
50
  await (0, installCursorHook_1.installCursorHookCommand)(hookArgs, config);
@@ -59,12 +59,16 @@ const MANAGED_TAG = 'fullcourtdefense';
59
59
  // developer would see a silent hook failure instead of a clean
60
60
  // approved/rejected/timeout verdict.
61
61
  const APPROVAL_CAPABLE_TIMEOUT_SEC = 960; // 16 min: 15 min approval window + poll slack
62
+ // Offline stance is deliberately NOT baked into the hook command by default:
63
+ // the runtime `hook` resolves it from the org's runtime bundle (enforcing
64
+ // machines fail closed, monitor machines fail open), identical to the Claude
65
+ // hook. Passing --fail-closed at install time writes an explicit override.
62
66
  const EVENT_MAP = {
63
- prompt: { hookKey: 'beforeSubmitPrompt', flag: 'prompt', failClosedDefault: false, timeoutSec: 10 },
64
- shell: { hookKey: 'beforeShellExecution', flag: 'shell', failClosedDefault: true, timeoutSec: APPROVAL_CAPABLE_TIMEOUT_SEC },
65
- mcp: { hookKey: 'beforeMCPExecution', flag: 'mcp', failClosedDefault: true, timeoutSec: APPROVAL_CAPABLE_TIMEOUT_SEC },
66
- file: { hookKey: 'afterFileEdit', flag: 'file', failClosedDefault: false, timeoutSec: APPROVAL_CAPABLE_TIMEOUT_SEC },
67
- read: { hookKey: 'beforeReadFile', flag: 'read', failClosedDefault: false, timeoutSec: APPROVAL_CAPABLE_TIMEOUT_SEC },
67
+ prompt: { hookKey: 'beforeSubmitPrompt', flag: 'prompt', timeoutSec: 10 },
68
+ shell: { hookKey: 'beforeShellExecution', flag: 'shell', timeoutSec: APPROVAL_CAPABLE_TIMEOUT_SEC },
69
+ mcp: { hookKey: 'beforeMCPExecution', flag: 'mcp', timeoutSec: APPROVAL_CAPABLE_TIMEOUT_SEC },
70
+ file: { hookKey: 'afterFileEdit', flag: 'file', timeoutSec: APPROVAL_CAPABLE_TIMEOUT_SEC },
71
+ read: { hookKey: 'beforeReadFile', flag: 'read', timeoutSec: APPROVAL_CAPABLE_TIMEOUT_SEC },
68
72
  };
69
73
  /** Build the absolute, shell-agnostic command that invokes this CLI's `hook`. */
70
74
  function buildHookCommand(flag, opts) {
@@ -142,12 +146,12 @@ function repairCursorManagedHooks() {
142
146
  let changed = false;
143
147
  if (before.managedEntries === 0) {
144
148
  for (const event of ['shell', 'mcp']) {
145
- const { hookKey, flag, failClosedDefault, timeoutSec } = EVENT_MAP[event];
149
+ const { hookKey, flag, timeoutSec } = EVENT_MAP[event];
146
150
  const list = Array.isArray(json.hooks[hookKey]) ? json.hooks[hookKey] : [];
147
151
  list.push({
148
- command: buildHookCommand(flag, { shadow: false, failClosed: failClosedDefault, waitForApproval: true }),
152
+ // Offline stance comes from the runtime bundle at hook time.
153
+ command: buildHookCommand(flag, { shadow: false, failClosed: false, waitForApproval: true }),
149
154
  timeout: timeoutSec,
150
- ...(failClosedDefault ? { failClosed: true } : {}),
151
155
  });
152
156
  json.hooks[hookKey] = list;
153
157
  }
@@ -213,8 +217,10 @@ async function installCursorHookCommand(args, config) {
213
217
  delete json.hooks[key];
214
218
  }
215
219
  for (const e of events) {
216
- const { hookKey, flag, failClosedDefault, timeoutSec } = EVENT_MAP[e];
217
- const failClosed = e === 'prompt' ? false : (args.failClosed !== undefined ? args.failClosed === 'true' : failClosedDefault);
220
+ const { hookKey, flag, timeoutSec } = EVENT_MAP[e];
221
+ // No explicit flag => the hook runtime applies the server-authoritative
222
+ // stance from the runtime bundle (same behavior as the Claude hook).
223
+ const failClosed = e === 'prompt' ? false : args.failClosed === 'true';
218
224
  const entry = {
219
225
  command: buildHookCommand(flag, { shadow, failClosed, waitForApproval: e !== 'prompt' }),
220
226
  timeout: timeoutSec,
@@ -3,6 +3,8 @@ export interface LoginArgs {
3
3
  /** Reusable, non-expiring fleet enrollment token (or FCD_ENROLL_TOKEN env). */
4
4
  token?: string;
5
5
  apiUrl?: string;
6
+ /** Admin-facing friendly machine name shown next to the hostname in the console. */
7
+ nickname?: string;
6
8
  }
7
9
  /**
8
10
  * `fullcourtdefense login` — zero-paste machine enrollment.
@@ -21,8 +21,11 @@ async function loginCommand(args, config) {
21
21
  }
22
22
  const identity = (0, machineIdentity_1.getMachineIdentity)();
23
23
  const shieldName = (0, machineIdentity_1.suggestedShieldName)(identity);
24
+ const nickname = (args.nickname || process.env.FCD_MACHINE_NICKNAME || '').trim().slice(0, 80) || undefined;
24
25
  console.log('\x1b[1mEnrolling this machine…\x1b[0m');
25
26
  console.log(` Device: ${identity.developerName}`);
27
+ if (nickname)
28
+ console.log(` Name: ${nickname}`);
26
29
  console.log(` OS: ${identity.osFriendly}`);
27
30
  console.log(` Shield: ${shieldName}`);
28
31
  const resp = await fetch(`${apiUrl}/api/cli/enroll`, {
@@ -39,6 +42,7 @@ async function loginCommand(args, config) {
39
42
  osFriendly: identity.osFriendly,
40
43
  platform: identity.platform,
41
44
  shieldName,
45
+ nickname,
42
46
  }),
43
47
  });
44
48
  const data = (await resp.json().catch(() => ({})));
@@ -579,18 +579,34 @@ class AgentGuardApi {
579
579
  }
580
580
  async waitForApproval(actionId) {
581
581
  const deadline = Date.now() + this.config.approvalTimeoutMs;
582
+ // Tolerate transient poll blips, but fail FAST on a dead backend: waiting
583
+ // out the full approval window against a server that answers nothing just
584
+ // hangs the developer's tool call for minutes.
585
+ const MAX_CONSECUTIVE_POLL_ERRORS = 3;
586
+ let consecutiveErrors = 0;
582
587
  while (Date.now() <= deadline) {
583
- const status = await this.get(`/api/agent-security/runtime/approvals/${encodeURIComponent(actionId)}?shieldId=${encodeURIComponent(this.config.shieldId)}`);
584
- const approval = status.data?.approval;
585
- if (approval?.approvalStatus === 'approved') {
586
- if (approval.executionStatus === 'executed')
587
- throw new Error(`Approval ${actionId} was already executed.`);
588
- return;
588
+ try {
589
+ const status = await this.get(`/api/agent-security/runtime/approvals/${encodeURIComponent(actionId)}?shieldId=${encodeURIComponent(this.config.shieldId)}`);
590
+ consecutiveErrors = 0;
591
+ const approval = status.data?.approval;
592
+ if (approval?.approvalStatus === 'approved') {
593
+ if (approval.executionStatus === 'executed')
594
+ throw new Error(`Approval ${actionId} was already executed.`);
595
+ return;
596
+ }
597
+ if (approval?.approvalStatus === 'rejected')
598
+ throw new Error(`Approval rejected for ${approval.toolName}.`);
599
+ if (approval?.approvalStatus === 'expired')
600
+ throw new Error(`Approval expired for ${approval.toolName}.`);
601
+ }
602
+ catch (error) {
603
+ if (error instanceof Error && /rejected|expired|already executed/.test(error.message))
604
+ throw error;
605
+ consecutiveErrors += 1;
606
+ if (consecutiveErrors >= MAX_CONSECUTIVE_POLL_ERRORS) {
607
+ throw new Error(`Approval status could not be checked (${consecutiveErrors} consecutive failures) — the control plane is unreachable. The request stays pending; retry once connectivity is restored.`);
608
+ }
589
609
  }
590
- if (approval?.approvalStatus === 'rejected')
591
- throw new Error(`Approval rejected for ${approval.toolName}.`);
592
- if (approval?.approvalStatus === 'expired')
593
- throw new Error(`Approval expired for ${approval.toolName}.`);
594
610
  await new Promise(resolve => setTimeout(resolve, Math.min(this.config.approvalPollMs, Math.max(0, deadline - Date.now()))));
595
611
  }
596
612
  throw new Error(`Approval timed out. Request ${actionId} is still waiting for review.`);
@@ -609,7 +625,10 @@ class AgentGuardApi {
609
625
  agentName: this.config.agentName,
610
626
  operation: input.operation,
611
627
  toolArgs: input.toolArgs,
612
- });
628
+ // Large tool outputs can legitimately take longer than policy checks
629
+ // (full Shield pipeline incl. AI judge) — but still far below the old
630
+ // 120s, which read as a frozen IDE.
631
+ }, 20_000);
613
632
  if (!response.success && response.error)
614
633
  throw new Error(response.error);
615
634
  const data = response.data;
@@ -620,23 +639,26 @@ class AgentGuardApi {
620
639
  ? { content: [{ type: 'text', text: data.safeResponse }] }
621
640
  : input.result;
622
641
  }
623
- async post(pathValue, body) {
642
+ // Tight timeouts on purpose: the gateway sits INSIDE the IDE's tool-call
643
+ // path, so a slow/hung backend must degrade into the offline stance in
644
+ // seconds — a 2-minute hang looks like a frozen IDE to the developer.
645
+ async post(pathValue, body, timeoutMs = 10_000) {
624
646
  const resp = await fetch(`${this.config.apiUrl}${pathValue}`, {
625
647
  method: 'POST',
626
648
  headers: this.headers(),
627
649
  body: JSON.stringify(body),
628
- signal: AbortSignal.timeout(120000),
650
+ signal: AbortSignal.timeout(timeoutMs),
629
651
  });
630
652
  const data = await resp.json().catch(() => ({}));
631
653
  if (!resp.ok)
632
654
  return { success: false, error: data.error || `AgentGuard API error (${resp.status})` };
633
655
  return data;
634
656
  }
635
- async get(pathValue) {
657
+ async get(pathValue, timeoutMs = 10_000) {
636
658
  const resp = await fetch(`${this.config.apiUrl}${pathValue}`, {
637
659
  method: 'GET',
638
660
  headers: this.headers(),
639
- signal: AbortSignal.timeout(120000),
661
+ signal: AbortSignal.timeout(timeoutMs),
640
662
  });
641
663
  const data = await resp.json().catch(() => ({}));
642
664
  if (!resp.ok)
@@ -753,7 +775,9 @@ class McpGatewayServer {
753
775
  try {
754
776
  // The runtime bundle carries the org's current Local Safety policy hash —
755
777
  // passing it as `expectedPolicyHash` makes a console policy edit take
756
- // effect on the very next tool call instead of waiting out the cache TTL.
778
+ // effect within the 60s cache TTL. NOT forced: a forced network round
779
+ // trip on EVERY tool call would add latency and a hang point to the
780
+ // IDE's hot path; the TTL'd cache refreshes with a 1.5s cap instead.
757
781
  // Offline this resolves from the cached bundle (stale-while-error).
758
782
  let expectedPolicyHash;
759
783
  try {
@@ -763,7 +787,6 @@ class McpGatewayServer {
763
787
  shieldKey: this.gatewayConfig.shieldKey,
764
788
  developerName: this.gatewayConfig.developerName,
765
789
  machineName: os.hostname(),
766
- force: true,
767
790
  });
768
791
  if (bundle.source !== 'default') {
769
792
  expectedPolicyHash = bundle.policyHash || bundle.version;
@@ -3,6 +3,8 @@ import { InstallAllArgs } from './installAll';
3
3
  export interface OnboardArgs extends InstallAllArgs {
4
4
  /** Fleet enrollment token (or FCD_ENROLL_TOKEN env). Optional when already enrolled. */
5
5
  token?: string;
6
+ /** Admin-facing friendly machine name (or FCD_MACHINE_NICKNAME env) shown in the console. */
7
+ nickname?: string;
6
8
  /** Continue a persisted onboarding transaction. */
7
9
  resume?: string;
8
10
  /** Re-run completed install steps to repair changed machine state. */
@@ -50,6 +50,8 @@ const posixShellGuard_1 = require("./posixShellGuard");
50
50
  const notify_1 = require("../notify");
51
51
  const daemon_1 = require("./daemon");
52
52
  const machineIdentity_1 = require("../machineIdentity");
53
+ const integrity_1 = require("../integrity");
54
+ const telemetry_1 = require("../telemetry");
53
55
  const onboardingJournal_1 = require("./onboardingJournal");
54
56
  const GREEN = '\x1b[32m';
55
57
  const RED = '\x1b[31m';
@@ -249,7 +251,7 @@ async function onboardCommand(args, config) {
249
251
  mark('enrollment', 'skipped', alreadyEnrolled ? 'would reuse existing per-machine Shield credentials' : 'would enroll with supplied token');
250
252
  }
251
253
  else if (token) {
252
- await (0, login_1.loginCommand)({ token, apiUrl: args.apiUrl }, config);
254
+ await (0, login_1.loginCommand)({ token, apiUrl: args.apiUrl, nickname: args.nickname }, config);
253
255
  mark('enrollment', 'completed', 'machine enrolled');
254
256
  }
255
257
  else if (alreadyEnrolled) {
@@ -349,6 +351,34 @@ async function onboardCommand(args, config) {
349
351
  console.log(`\n${BOLD}[6/6] Verifying protection surfaces…${RESET}`);
350
352
  mark('verification', 'running');
351
353
  const postCreds = (0, config_1.resolveCliCredentials)((0, config_1.loadConfig)(), { apiUrl });
354
+ // Immediate heartbeat: the machine must appear ONLINE in the fleet console
355
+ // the moment onboarding finishes — not whenever the daemon's first 5-minute
356
+ // tick or the daily discovery job happens to fire. Carries the local
357
+ // integrity verdict so the console shows agent health right away too.
358
+ let firstHeartbeatOk = false;
359
+ if (!dryRun && postCreds.shieldId) {
360
+ try {
361
+ const integrity = (0, integrity_1.getLocalIntegrityReport)();
362
+ let agentVersion;
363
+ try {
364
+ agentVersion = JSON.parse(fs.readFileSync(path.resolve(__dirname, '..', '..', 'package.json'), 'utf8')).version;
365
+ }
366
+ catch { /* version is optional on the heartbeat */ }
367
+ const flush = await (0, telemetry_1.flushSpool)({
368
+ apiUrl,
369
+ shieldId: postCreds.shieldId,
370
+ shieldKey: postCreds.shieldKey,
371
+ heartbeat: true,
372
+ agentVersion,
373
+ integrityOk: integrity.ok,
374
+ integrityReasons: integrity.reasons,
375
+ integrityCheckedAt: integrity.checkedAt,
376
+ timeoutMs: 8_000,
377
+ });
378
+ firstHeartbeatOk = flush !== null;
379
+ }
380
+ catch { /* the daemon heartbeat remains the backstop */ }
381
+ }
352
382
  const checks = [
353
383
  reachable,
354
384
  {
@@ -371,6 +401,16 @@ async function onboardCommand(args, config) {
371
401
  optional: true,
372
402
  detail: (0, discoverSchedule_1.isDiscoverScheduleInstalled)() ? undefined : 'run discover --schedule daily',
373
403
  },
404
+ {
405
+ // Informational: protection is local, so a flaky first ping must never
406
+ // fail onboarding — the daemon heartbeat retries within 5 minutes.
407
+ label: 'First heartbeat delivered',
408
+ ok: firstHeartbeatOk,
409
+ optional: true,
410
+ detail: firstHeartbeatOk
411
+ ? 'this machine now shows ONLINE in the fleet console'
412
+ : dryRun ? 'skipped (dry run)' : 'not delivered yet — the daemon retries within 5 minutes',
413
+ },
374
414
  ];
375
415
  console.log('');
376
416
  for (const check of checks)
@@ -388,6 +428,24 @@ async function onboardCommand(args, config) {
388
428
  else {
389
429
  console.log(`${YELLOW}${BOLD}Onboarding finished with ${requiredFailures.length} unresolved surface(s).${RESET} Fix the ✗ lines above and re-run ${BOLD}fullcourtdefense onboard${RESET}.`);
390
430
  mark('verification', 'failed', undefined, `${requiredFailures.length} critical protection surfaces unresolved`);
431
+ // Surface the partial install in the fleet console, not just locally: spool
432
+ // a failed-install event (flushed with the heartbeat above or by the daemon)
433
+ // so admins see WHICH surfaces are missing without touching the laptop.
434
+ if (!dryRun && postCreds.shieldId) {
435
+ try {
436
+ (0, telemetry_1.spoolEvent)({
437
+ type: 'verdict',
438
+ decision: 'allow',
439
+ source: 'builtin',
440
+ toolName: 'onboarding',
441
+ operation: 'install_verification_failed',
442
+ reason: `Onboarding finished with unresolved surfaces: ${requiredFailures.map(check => check.label).join(', ')}`,
443
+ severity: 'high',
444
+ });
445
+ await (0, telemetry_1.flushSpool)({ apiUrl, shieldId: postCreds.shieldId, shieldKey: postCreds.shieldKey, timeoutMs: 5_000 });
446
+ }
447
+ catch { /* best-effort */ }
448
+ }
391
449
  report('Fix the failed required surfaces, then run fullcourtdefense onboard --resume --repair.');
392
450
  process.exitCode = 1;
393
451
  }
package/dist/index.js CHANGED
@@ -506,6 +506,7 @@ async function main() {
506
506
  const args = {
507
507
  token: flags.token || flags['enroll-token'],
508
508
  apiUrl: flags['api-url'],
509
+ nickname: flags.nickname || flags['machine-name'],
509
510
  };
510
511
  await (0, login_1.loginCommand)(args, config);
511
512
  break;
@@ -646,6 +647,7 @@ async function main() {
646
647
  const args = {
647
648
  ...buildInstallArgs(),
648
649
  token: flags.token || flags['enroll-token'],
650
+ nickname: flags.nickname || flags['machine-name'],
649
651
  hooks: flags.hooks,
650
652
  discover: flags.discover,
651
653
  autoProtect: flags['auto-protect'],
@@ -28,10 +28,15 @@ export interface RuntimeBundle {
28
28
  extraScanRoots?: string[];
29
29
  /** Default scan folders the admin explicitly removed from this machine's posture sweep. */
30
30
  disabledScanRoots?: string[];
31
+ /** Org auto-update policy: silently upgrade the CLI to targetVersion. */
32
+ autoUpdate?: {
33
+ enabled: boolean;
34
+ targetVersion?: string;
35
+ };
31
36
  /** One constrained, auditable action queued for the resident daemon. */
32
37
  machineAction?: {
33
38
  id: string;
34
- type: 'health_check' | 'policy_refresh' | 'discovery_scan' | 'repair_protection';
39
+ type: 'health_check' | 'policy_refresh' | 'discovery_scan' | 'repair_protection' | 'upgrade_cli';
35
40
  reason: string;
36
41
  createdAt: string;
37
42
  expiresAt: string;
@@ -72,7 +72,7 @@ async function getRuntimeBundle(input) {
72
72
  const cached = cache[input.shieldId];
73
73
  const fresh = cached && Date.now() - cached.fetchedAt < ttl;
74
74
  if (cached && fresh && !input.force) {
75
- return { mode: cached.mode, version: cached.version, policyHash: cached.policyHash, policyCount: cached.policyCount, pollIntervalMs: cached.pollIntervalMs, failClosed: cached.failClosed, suspended: cached.suspended, extraScanRoots: cached.extraScanRoots, disabledScanRoots: cached.disabledScanRoots, machineAction: cached.machineAction, source: 'cache' };
75
+ return { mode: cached.mode, version: cached.version, policyHash: cached.policyHash, policyCount: cached.policyCount, pollIntervalMs: cached.pollIntervalMs, failClosed: cached.failClosed, suspended: cached.suspended, extraScanRoots: cached.extraScanRoots, disabledScanRoots: cached.disabledScanRoots, autoUpdate: cached.autoUpdate, machineAction: cached.machineAction, source: 'cache' };
76
76
  }
77
77
  try {
78
78
  const headers = { 'Content-Type': 'application/json' };
@@ -92,7 +92,7 @@ async function getRuntimeBundle(input) {
92
92
  if (resp.status === 304 && cached) {
93
93
  cache[input.shieldId] = { ...cached, fetchedAt: Date.now() };
94
94
  writeCacheFile(cache);
95
- return { mode: cached.mode, version: cached.version, policyHash: cached.policyHash, policyCount: cached.policyCount, pollIntervalMs: cached.pollIntervalMs, failClosed: cached.failClosed, suspended: cached.suspended, extraScanRoots: cached.extraScanRoots, disabledScanRoots: cached.disabledScanRoots, machineAction: cached.machineAction, source: 'cache' };
95
+ return { mode: cached.mode, version: cached.version, policyHash: cached.policyHash, policyCount: cached.policyCount, pollIntervalMs: cached.pollIntervalMs, failClosed: cached.failClosed, suspended: cached.suspended, extraScanRoots: cached.extraScanRoots, disabledScanRoots: cached.disabledScanRoots, autoUpdate: cached.autoUpdate, machineAction: cached.machineAction, source: 'cache' };
96
96
  }
97
97
  if (resp.ok) {
98
98
  const body = await resp.json().catch(() => ({}));
@@ -111,6 +111,12 @@ async function getRuntimeBundle(input) {
111
111
  disabledScanRoots: Array.isArray(body.data.disabledScanRoots)
112
112
  ? body.data.disabledScanRoots.filter((root) => typeof root === 'string').slice(0, 20)
113
113
  : undefined,
114
+ autoUpdate: body.data.autoUpdate && typeof body.data.autoUpdate === 'object'
115
+ ? {
116
+ enabled: body.data.autoUpdate.enabled === true,
117
+ targetVersion: typeof body.data.autoUpdate.targetVersion === 'string' ? body.data.autoUpdate.targetVersion : undefined,
118
+ }
119
+ : undefined,
114
120
  machineAction: body.data.machineAction,
115
121
  fetchedAt: Date.now(),
116
122
  };
@@ -122,7 +128,7 @@ async function getRuntimeBundle(input) {
122
128
  }
123
129
  catch { /* fall through to cache / default */ }
124
130
  if (cached) {
125
- return { mode: cached.mode, version: cached.version, policyHash: cached.policyHash, policyCount: cached.policyCount, pollIntervalMs: cached.pollIntervalMs, failClosed: cached.failClosed, suspended: cached.suspended, extraScanRoots: cached.extraScanRoots, disabledScanRoots: cached.disabledScanRoots, machineAction: cached.machineAction, source: 'cache' };
131
+ return { mode: cached.mode, version: cached.version, policyHash: cached.policyHash, policyCount: cached.policyCount, pollIntervalMs: cached.pollIntervalMs, failClosed: cached.failClosed, suspended: cached.suspended, extraScanRoots: cached.extraScanRoots, disabledScanRoots: cached.disabledScanRoots, autoUpdate: cached.autoUpdate, machineAction: cached.machineAction, source: 'cache' };
126
132
  }
127
133
  return { mode: 'block', version: '', source: 'default' };
128
134
  }
@@ -0,0 +1,41 @@
1
+ /**
2
+ * Silent CLI self-update.
3
+ *
4
+ * Two install kinds, two upgrade paths:
5
+ * - npm — `npm install -g fullcourtdefense-cli@<version>` replaces the
6
+ * global files in place; a freshly spawned daemon then supersedes
7
+ * the running one via the pid-lock version takeover.
8
+ * - msi — the CLI runs from Program Files with a bundled node.exe; a
9
+ * normal-user process cannot rewrite those files, so upgrades go
10
+ * through the elevated "FullCourtDefense Updater" scheduled task
11
+ * registered at MSI install time (downloads + verifies + msiexec).
12
+ *
13
+ * The org-level auto-update policy (on/off/pinned) arrives on the runtime
14
+ * bundle; the daemon calls maybeSelfUpdate() on its poll ticks.
15
+ */
16
+ export declare const MSI_UPDATER_TASK_NAME = "FullCourtDefense Updater";
17
+ export type InstallKind = 'msi' | 'npm';
18
+ /** MSI installs run the bundled runtime\node.exe from the install folder. */
19
+ export declare function detectInstallKind(): InstallKind;
20
+ /** Semver compare; returns >0 when a is newer than b. Unparseable = oldest. */
21
+ export declare function compareCliVersions(a?: string, b?: string): number;
22
+ export interface SelfUpdateResult {
23
+ started: boolean;
24
+ kind: InstallKind;
25
+ detail: string;
26
+ }
27
+ /**
28
+ * Upgrade this machine to targetVersion if it is newer than currentVersion.
29
+ * Cheap no-op when already current, when a kick is still pending, or when the
30
+ * platform path is unavailable. Never throws.
31
+ */
32
+ export declare function maybeSelfUpdate(input: {
33
+ currentVersion?: string;
34
+ targetVersion?: string;
35
+ enabled?: boolean;
36
+ log?: (message: string) => void;
37
+ /** Skip while a remote machine action is executing (never upgrade mid-action). */
38
+ busy?: boolean;
39
+ }): SelfUpdateResult | undefined;
40
+ /** Version the MSI updater script reads from the installed package.json. */
41
+ export declare function installedMsiVersion(installFolder: string): string | undefined;
@@ -0,0 +1,180 @@
1
+ "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
15
+ }) : function(o, v) {
16
+ o["default"] = v;
17
+ });
18
+ var __importStar = (this && this.__importStar) || (function () {
19
+ var ownKeys = function(o) {
20
+ ownKeys = Object.getOwnPropertyNames || function (o) {
21
+ var ar = [];
22
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
23
+ return ar;
24
+ };
25
+ return ownKeys(o);
26
+ };
27
+ return function (mod) {
28
+ if (mod && mod.__esModule) return mod;
29
+ var result = {};
30
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
31
+ __setModuleDefault(result, mod);
32
+ return result;
33
+ };
34
+ })();
35
+ Object.defineProperty(exports, "__esModule", { value: true });
36
+ exports.MSI_UPDATER_TASK_NAME = void 0;
37
+ exports.detectInstallKind = detectInstallKind;
38
+ exports.compareCliVersions = compareCliVersions;
39
+ exports.maybeSelfUpdate = maybeSelfUpdate;
40
+ exports.installedMsiVersion = installedMsiVersion;
41
+ const fs = __importStar(require("fs"));
42
+ const path = __importStar(require("path"));
43
+ const child_process_1 = require("child_process");
44
+ /**
45
+ * Silent CLI self-update.
46
+ *
47
+ * Two install kinds, two upgrade paths:
48
+ * - npm — `npm install -g fullcourtdefense-cli@<version>` replaces the
49
+ * global files in place; a freshly spawned daemon then supersedes
50
+ * the running one via the pid-lock version takeover.
51
+ * - msi — the CLI runs from Program Files with a bundled node.exe; a
52
+ * normal-user process cannot rewrite those files, so upgrades go
53
+ * through the elevated "FullCourtDefense Updater" scheduled task
54
+ * registered at MSI install time (downloads + verifies + msiexec).
55
+ *
56
+ * The org-level auto-update policy (on/off/pinned) arrives on the runtime
57
+ * bundle; the daemon calls maybeSelfUpdate() on its poll ticks.
58
+ */
59
+ exports.MSI_UPDATER_TASK_NAME = 'FullCourtDefense Updater';
60
+ /** MSI installs run the bundled runtime\node.exe from the install folder. */
61
+ function detectInstallKind() {
62
+ const execPath = process.execPath.replace(/\\/g, '/').toLowerCase();
63
+ if (execPath.includes('/fullcourtdefense/runtime/node'))
64
+ return 'msi';
65
+ const entry = (process.argv[1] || '').replace(/\\/g, '/').toLowerCase();
66
+ if (entry.includes('/fullcourtdefense/dist/'))
67
+ return 'msi';
68
+ return 'npm';
69
+ }
70
+ /** Semver compare; returns >0 when a is newer than b. Unparseable = oldest. */
71
+ function compareCliVersions(a, b) {
72
+ const parse = (value) => String(value || '0').replace(/^v/, '').split('.').map(part => parseInt(part, 10) || 0);
73
+ const [pa, pb] = [parse(a), parse(b)];
74
+ for (let i = 0; i < Math.max(pa.length, pb.length); i++) {
75
+ const diff = (pa[i] || 0) - (pb[i] || 0);
76
+ if (diff !== 0)
77
+ return diff;
78
+ }
79
+ return 0;
80
+ }
81
+ function npmCommand() {
82
+ return process.platform === 'win32' ? 'npm.cmd' : 'npm';
83
+ }
84
+ /**
85
+ * Kick off an npm global upgrade in a detached child, then (once npm exits 0)
86
+ * spawn a fresh daemon from the same entry path — the new build reads the
87
+ * updated package.json, wins the version-takeover in acquirePidLock(), and
88
+ * stops the old daemon. The old process never kills itself mid-upgrade, so a
89
+ * failed npm install leaves the machine exactly as it was.
90
+ */
91
+ function startNpmSelfUpdate(targetVersion, log) {
92
+ const entry = path.resolve(process.argv[1] || '');
93
+ const script = [
94
+ `"${npmCommand()}" install -g fullcourtdefense-cli@${targetVersion}`,
95
+ // Relaunch the daemon from the (now replaced) global entry. The pid-lock
96
+ // takeover in the new daemon stops this one.
97
+ `&& "${process.execPath}" "${entry}" daemon`,
98
+ ].join(' ');
99
+ try {
100
+ const child = process.platform === 'win32'
101
+ ? (0, child_process_1.spawn)('cmd.exe', ['/d', '/s', '/c', script], { detached: true, stdio: 'ignore', windowsHide: true })
102
+ : (0, child_process_1.spawn)('/bin/sh', ['-c', script], { detached: true, stdio: 'ignore' });
103
+ child.unref();
104
+ log(`Self-update: npm upgrade to ${targetVersion} started (detached).`);
105
+ return { started: true, kind: 'npm', detail: `npm install -g fullcourtdefense-cli@${targetVersion} started; the new daemon supersedes this one when ready.` };
106
+ }
107
+ catch (error) {
108
+ const message = error.message;
109
+ log(`Self-update: could not start npm upgrade: ${message}`);
110
+ return { started: false, kind: 'npm', detail: `Could not start npm upgrade: ${message}` };
111
+ }
112
+ }
113
+ /**
114
+ * MSI path: run the elevated updater task registered at install time. Running
115
+ * an existing task does not require elevation for the task's own principal,
116
+ * but group policy can deny it — in that case the daily trigger remains the
117
+ * backstop and we report that honestly.
118
+ */
119
+ function startMsiSelfUpdate(targetVersion, log) {
120
+ const query = (0, child_process_1.spawnSync)('schtasks', ['/Query', '/TN', exports.MSI_UPDATER_TASK_NAME], { stdio: 'ignore', windowsHide: true, timeout: 10_000 });
121
+ if (query.status !== 0) {
122
+ log('Self-update: MSI updater task is not registered on this machine — reinstall the MSI to enable silent updates.');
123
+ return {
124
+ started: false,
125
+ kind: 'msi',
126
+ detail: `The "${exports.MSI_UPDATER_TASK_NAME}" scheduled task is missing. Reinstall the latest MSI (or redeploy via MDM) to restore silent updates.`,
127
+ };
128
+ }
129
+ const run = (0, child_process_1.spawnSync)('schtasks', ['/Run', '/TN', exports.MSI_UPDATER_TASK_NAME], { stdio: 'ignore', windowsHide: true, timeout: 20_000 });
130
+ if (run.status === 0) {
131
+ log(`Self-update: MSI updater task triggered (target ${targetVersion}).`);
132
+ return { started: true, kind: 'msi', detail: `Updater task triggered; it downloads, verifies, and installs ${targetVersion} silently.` };
133
+ }
134
+ log('Self-update: could not trigger the MSI updater task now — its daily schedule remains the backstop.');
135
+ return {
136
+ started: false,
137
+ kind: 'msi',
138
+ detail: `Could not trigger the "${exports.MSI_UPDATER_TASK_NAME}" task immediately; it still runs on its daily schedule.`,
139
+ };
140
+ }
141
+ let updateInFlightSince = 0;
142
+ const UPDATE_RETRY_COOLDOWN_MS = 60 * 60_000; // don't re-kick a pending upgrade for an hour
143
+ /**
144
+ * Upgrade this machine to targetVersion if it is newer than currentVersion.
145
+ * Cheap no-op when already current, when a kick is still pending, or when the
146
+ * platform path is unavailable. Never throws.
147
+ */
148
+ function maybeSelfUpdate(input) {
149
+ const log = input.log || (() => { });
150
+ if (!input.enabled || !input.targetVersion)
151
+ return undefined;
152
+ if (input.busy)
153
+ return undefined;
154
+ if (compareCliVersions(input.targetVersion, input.currentVersion) <= 0)
155
+ return undefined;
156
+ if (Date.now() - updateInFlightSince < UPDATE_RETRY_COOLDOWN_MS)
157
+ return undefined;
158
+ updateInFlightSince = Date.now();
159
+ log(`Self-update: CLI ${input.currentVersion || 'unknown'} -> ${input.targetVersion} (org auto-update).`);
160
+ const kind = detectInstallKind();
161
+ const result = kind === 'msi'
162
+ ? startMsiSelfUpdate(input.targetVersion, log)
163
+ : startNpmSelfUpdate(input.targetVersion, log);
164
+ if (!result.started) {
165
+ // Allow another attempt on the next tick after a shorter cooldown when we
166
+ // never managed to start anything.
167
+ updateInFlightSince = Date.now() - UPDATE_RETRY_COOLDOWN_MS + 10 * 60_000;
168
+ }
169
+ return result;
170
+ }
171
+ /** Version the MSI updater script reads from the installed package.json. */
172
+ function installedMsiVersion(installFolder) {
173
+ try {
174
+ const pkg = JSON.parse(fs.readFileSync(path.join(installFolder, 'package.json'), 'utf8'));
175
+ return typeof pkg.version === 'string' ? pkg.version : undefined;
176
+ }
177
+ catch {
178
+ return undefined;
179
+ }
180
+ }
package/dist/telemetry.js CHANGED
@@ -58,6 +58,15 @@ const SPOOL_PATH = path.join(os.homedir(), '.fullcourtdefense-spool.jsonl');
58
58
  const FLUSH_MARKER = path.join(os.homedir(), '.fullcourtdefense-flush.ts');
59
59
  const FLUSH_THROTTLE_MS = 30_000; // don't spawn a flusher more than this often
60
60
  const MAX_BATCH = 200;
61
+ /**
62
+ * Hard cap on spooled events. A machine that stays offline for weeks must not
63
+ * grow an unbounded file on the developer's disk; when full, the OLDEST events
64
+ * are dropped — the newest decisions are the ones an admin needs after a
65
+ * reconnect. ~2k events ≈ single-digit MB worst case.
66
+ */
67
+ const MAX_SPOOL_EVENTS = 2_000;
68
+ /** Cheap pre-check: skip the trim parse entirely until the file is plausibly over cap. */
69
+ const SPOOL_TRIM_BYTES = 2 * 1024 * 1024;
61
70
  /** Append one decision to the spool. Never throws (telemetry must not break the hook). */
62
71
  function spoolEvent(event) {
63
72
  try {
@@ -81,6 +90,19 @@ function spoolEvent(event) {
81
90
  offlineEnforced: event.offlineEnforced,
82
91
  };
83
92
  fs.appendFileSync(SPOOL_PATH, JSON.stringify(full) + '\n', { encoding: 'utf-8', mode: 0o600 });
93
+ enforceSpoolCap();
94
+ }
95
+ catch { /* best-effort */ }
96
+ }
97
+ /** Drop-oldest cap so a long-offline machine never grows an unbounded spool. */
98
+ function enforceSpoolCap() {
99
+ try {
100
+ if (fs.statSync(SPOOL_PATH).size < SPOOL_TRIM_BYTES)
101
+ return;
102
+ const events = readSpool();
103
+ if (events.length <= MAX_SPOOL_EVENTS)
104
+ return;
105
+ rewriteSpool(events.slice(events.length - MAX_SPOOL_EVENTS));
84
106
  }
85
107
  catch { /* best-effort */ }
86
108
  }
package/dist/version.json CHANGED
@@ -1,3 +1,3 @@
1
1
  {
2
- "version": "1.15.12"
2
+ "version": "1.17.0"
3
3
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fullcourtdefense-cli",
3
- "version": "1.15.12",
3
+ "version": "1.17.0",
4
4
  "description": "Full Court Defense CLI — security scanning for AI agents from your terminal",
5
5
  "main": "dist/index.js",
6
6
  "bin": {