fullcourtdefense-cli 1.17.1 → 1.18.1

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.
@@ -76,6 +76,10 @@ const HEARTBEAT_INTERVAL_MS = envMs('FCD_DAEMON_HEARTBEAT_MS', 5 * 60_000);
76
76
  const BUNDLE_POLL_MS = envMs('FCD_DAEMON_BUNDLE_POLL_MS', 60_000);
77
77
  /** Delay before the one-time initial discovery sweep on a fresh machine. */
78
78
  const INITIAL_DISCOVER_DELAY_MS = envMs('FCD_DAEMON_INITIAL_DISCOVER_MS', 2 * 60_000);
79
+ /** A discovery upload older than this is stale — the daemon catches up itself. */
80
+ const DISCOVER_STALE_MS = envMs('FCD_DAEMON_DISCOVER_STALE_MS', 20 * 60 * 60_000);
81
+ /** How often the daemon re-checks discovery freshness. */
82
+ const DISCOVER_CHECK_INTERVAL_MS = envMs('FCD_DAEMON_DISCOVER_CHECK_MS', 60 * 60_000);
79
83
  /** Rotate the daemon log when it grows past this size. */
80
84
  const LOG_MAX_BYTES = 1_000_000;
81
85
  function daemonDir() {
@@ -535,9 +539,12 @@ async function runDaemon(args, config) {
535
539
  if (!target)
536
540
  throw new Error('No target version available — the control plane could not resolve the latest release.');
537
541
  log(`Upgrade CLI: admin requested an upgrade to ${target}.`);
538
- const outcome = (0, selfUpdate_1.maybeSelfUpdate)({ currentVersion: cliVersion(), targetVersion: target, enabled: true, log });
542
+ // force: bypass the auto-update retry cooldown an explicit admin
543
+ // action must actually attempt and report the real outcome, not
544
+ // "in progress" while a broken path silently retries hourly.
545
+ const outcome = (0, selfUpdate_1.maybeSelfUpdate)({ currentVersion: cliVersion(), targetVersion: target, enabled: true, force: true, log });
539
546
  if (!outcome) {
540
- resultSummary = `CLI ${cliVersion() || 'unknown'} is already at ${target} (or an upgrade is in progress).`;
547
+ resultSummary = `CLI ${cliVersion() || 'unknown'} is already at ${target}.`;
541
548
  }
542
549
  else if (outcome.started) {
543
550
  resultSummary = outcome.detail;
@@ -693,6 +700,45 @@ async function runDaemon(args, config) {
693
700
  await uploadLogTail();
694
701
  }, INITIAL_DISCOVER_DELAY_MS);
695
702
  }
703
+ // Staleness catch-up: the daily scheduled job fires at a FIXED hour, so a
704
+ // laptop that was asleep/powered off at that hour misses the whole day —
705
+ // admins then see day-old discovery/posture data even though the machine is
706
+ // online. The daemon closes that gap: whenever the last successful upload is
707
+ // older than ~20h, run a full sweep now. Checked shortly after boot (wake-up
708
+ // catch-up) and hourly thereafter; the marker written by `discover` keeps
709
+ // this idempotent alongside the scheduled task.
710
+ let discoverCatchUpRunning = false;
711
+ const maybeCatchUpDiscovery = async (reason) => {
712
+ if (stopped || discoverCatchUpRunning || !creds.shieldId)
713
+ return;
714
+ if (!(0, discoveryMarker_1.hasDiscoveryUploadMarker)())
715
+ return; // fresh machine — initial sweep owns it
716
+ const last = (0, discoveryMarker_1.lastDiscoveryUploadAt)();
717
+ if (last && Date.now() - last.getTime() < DISCOVER_STALE_MS)
718
+ return;
719
+ discoverCatchUpRunning = true;
720
+ log(`Discovery catch-up (${reason}): last upload ${last ? Math.round((Date.now() - last.getTime()) / 3_600_000) + 'h ago' : 'unknown'} — starting full sweep (MCP + secrets + agent files + posture)…`);
721
+ await uploadLogTail();
722
+ const logPump = setInterval(() => { void uploadLogTail(); }, 15_000);
723
+ try {
724
+ const exitCode = await runDiscoverSweep();
725
+ if (exitCode !== 0)
726
+ throw new Error(`discover exited with code ${exitCode}`);
727
+ log('Discovery catch-up: upload complete — dashboard discovery + posture timestamps are now fresh.');
728
+ }
729
+ catch (error) {
730
+ log(`Discovery catch-up failed (retried hourly): ${error.message}`);
731
+ }
732
+ finally {
733
+ clearInterval(logPump);
734
+ discoverCatchUpRunning = false;
735
+ await uploadLogTail();
736
+ }
737
+ };
738
+ // Give the machine a couple of minutes to settle after boot/wake before the
739
+ // first check — same grace as the initial sweep.
740
+ const discoverCatchUpBootTimer = setTimeout(() => { void maybeCatchUpDiscovery('after start'); }, INITIAL_DISCOVER_DELAY_MS);
741
+ const discoverCatchUpTimer = setInterval(() => { void maybeCatchUpDiscovery('hourly check'); }, DISCOVER_CHECK_INTERVAL_MS);
696
742
  const rescanTimer = setInterval(() => {
697
743
  const count = refreshWatchTargets();
698
744
  log(`Rescan: watching ${count} config file(s).`);
@@ -707,6 +753,8 @@ async function runDaemon(args, config) {
707
753
  clearInterval(rescanTimer);
708
754
  clearInterval(bundleTimer);
709
755
  clearInterval(heartbeatTimer);
756
+ clearTimeout(discoverCatchUpBootTimer);
757
+ clearInterval(discoverCatchUpTimer);
710
758
  if (initialDiscoverTimer)
711
759
  clearTimeout(initialDiscoverTimer);
712
760
  if (debounceTimer)
@@ -35,7 +35,6 @@ var __importStar = (this && this.__importStar) || (function () {
35
35
  Object.defineProperty(exports, "__esModule", { value: true });
36
36
  exports.discoverCommand = discoverCommand;
37
37
  exports.runDiscoverUpload = runDiscoverUpload;
38
- const crypto = __importStar(require("crypto"));
39
38
  const fs = __importStar(require("fs"));
40
39
  const os = __importStar(require("os"));
41
40
  const path = __importStar(require("path"));
@@ -52,6 +51,7 @@ const discoverBlastRadius_1 = require("./discoverBlastRadius");
52
51
  const discoverSecrets_1 = require("./discoverSecrets");
53
52
  const windowsAudit_1 = require("./windowsAudit");
54
53
  const discoveryMarker_1 = require("../discoveryMarker");
54
+ const machineIdentity_1 = require("../machineIdentity");
55
55
  const DEFAULT_API_URL = 'https://api.fullcourtdefense.ai';
56
56
  function parseSurfaces(args) {
57
57
  const raw = (args.surface || args.type || 'mcp').toLowerCase().trim();
@@ -93,9 +93,17 @@ function redactCommandArgs(args) {
93
93
  }
94
94
  return out;
95
95
  }
96
+ /**
97
+ * The SAME stable hardware id used at enrollment (MachineGuid / IOPlatformUUID
98
+ * / machine-id, sha256 → 32 hex chars). Discovery uploads MUST carry this id:
99
+ * the fleet console attaches scan evidence to the enrolled machine strictly by
100
+ * machineId, so a divergent id here makes the machine page show "Never
101
+ * scanned" while the data sits under a ghost host record. (Pre-1.18 releases
102
+ * hashed hostname+username into a 16-char fingerprint — the backend still
103
+ * recognizes those as legacy and matches them by hostname.)
104
+ */
96
105
  function machineFingerprint() {
97
- const seed = [os.hostname(), os.userInfo().username, os.platform(), os.arch()].join('|');
98
- return crypto.createHash('sha256').update(seed).digest('hex').slice(0, 16);
106
+ return (0, machineIdentity_1.getMachineIdentity)().machineId;
99
107
  }
100
108
  /** Detect ephemeral CI runners so they are never listed as user machines in the fleet. */
101
109
  function isCiEnvironment() {
@@ -613,6 +621,8 @@ function proxyStatusLabel(status) {
613
621
  return 'Proxied';
614
622
  if (status === 'direct')
615
623
  return 'Direct — needs gateway';
624
+ if (status === 'reference')
625
+ return 'Reference config — not loaded by any AI client';
616
626
  return 'Unknown';
617
627
  }
618
628
  function sanitizeSecretForUpload(finding) {
@@ -1036,7 +1046,9 @@ async function discoverCommand(args, config) {
1036
1046
  console.log('');
1037
1047
  if (clientCoverage.length > 0) {
1038
1048
  for (const row of clientCoverage) {
1039
- const gateway = row.mcpGatewayInstalled ? `${COLOR.green}gateway ✓${COLOR.reset}` : `${COLOR.red}no gateway${COLOR.reset}`;
1049
+ const gateway = (0, discoverProxy_1.isReferenceConfigSource)(row.client)
1050
+ ? `${COLOR.gray}reference only — not loaded by any AI client${COLOR.reset}`
1051
+ : row.mcpGatewayInstalled ? `${COLOR.green}gateway ✓${COLOR.reset}` : `${COLOR.red}no gateway${COLOR.reset}`;
1040
1052
  console.log(` ${COLOR.dim}•${COLOR.reset} ${row.client} — ${row.mcpServerCount} MCP · ${gateway}`);
1041
1053
  }
1042
1054
  console.log('');
@@ -1,4 +1,5 @@
1
- export type DesktopProxyStatus = 'fcd_gateway' | 'proxied' | 'direct' | 'unknown';
1
+ export type DesktopProxyStatus = 'fcd_gateway' | 'proxied' | 'direct' | 'reference' | 'unknown';
2
+ export declare function isReferenceConfigSource(source: string): boolean;
2
3
  export declare const FCD_GATEWAY_SERVER_NAMES: Set<string>;
3
4
  export declare const FCD_HOOK_MARKER = "--fcd-managed true";
4
5
  export declare const FCD_HOOK_TAG = "fullcourtdefense";
@@ -34,6 +34,7 @@ var __importStar = (this && this.__importStar) || (function () {
34
34
  })();
35
35
  Object.defineProperty(exports, "__esModule", { value: true });
36
36
  exports.FCD_HOOK_TAG = exports.FCD_HOOK_MARKER = exports.FCD_GATEWAY_SERVER_NAMES = void 0;
37
+ exports.isReferenceConfigSource = isReferenceConfigSource;
37
38
  exports.isFcdGatewayServer = isFcdGatewayServer;
38
39
  exports.extractGatewayDownstream = extractGatewayDownstream;
39
40
  exports.buildGatewayWrapIndex = buildGatewayWrapIndex;
@@ -44,6 +45,18 @@ exports.buildClientCoverage = buildClientCoverage;
44
45
  const fs = __importStar(require("fs"));
45
46
  const os = __importStar(require("os"));
46
47
  const path = __importStar(require("path"));
48
+ /**
49
+ * Config sources that no AI client actually loads. A plain `mcp.json` at a
50
+ * repo root is documentation/sample material — Cursor, Claude Code, VS Code,
51
+ * Windsurf, etc. all read their own dot-folder configs, never this file.
52
+ * Servers found there are inventory-worthy (they show what a repo references)
53
+ * but are not live attack surface, so they classify as 'reference' instead of
54
+ * 'direct' and must never count toward the machine's exposed status.
55
+ */
56
+ const REFERENCE_CONFIG_SOURCES = new Set(['Repo (mcp.json)']);
57
+ function isReferenceConfigSource(source) {
58
+ return REFERENCE_CONFIG_SOURCES.has(source);
59
+ }
47
60
  exports.FCD_GATEWAY_SERVER_NAMES = new Set([
48
61
  'agentguard-gateway',
49
62
  'fullcourtdefense-gateway',
@@ -157,6 +170,8 @@ function buildGatewayWrapIndex(servers) {
157
170
  return wrapsByConfig;
158
171
  }
159
172
  function classifyProxyStatus(server, wrapsByConfig) {
173
+ if (isReferenceConfigSource(server.source))
174
+ return 'reference';
160
175
  if (isFcdGatewayServer(server))
161
176
  return 'fcd_gateway';
162
177
  const wrapped = wrapsByConfig.get(server.configPath.toLowerCase());
@@ -1,2 +1,9 @@
1
1
  export declare function hasDiscoveryUploadMarker(): boolean;
2
+ /**
3
+ * When the last successful full discovery upload completed, or undefined if
4
+ * this machine never uploaded one. Drives the daemon's staleness catch-up:
5
+ * laptops that were powered off at the daily task's fixed hour still get a
6
+ * fresh scan shortly after they wake up.
7
+ */
8
+ export declare function lastDiscoveryUploadAt(): Date | undefined;
2
9
  export declare function writeDiscoveryUploadMarker(trigger: string): void;
@@ -34,6 +34,7 @@ var __importStar = (this && this.__importStar) || (function () {
34
34
  })();
35
35
  Object.defineProperty(exports, "__esModule", { value: true });
36
36
  exports.hasDiscoveryUploadMarker = hasDiscoveryUploadMarker;
37
+ exports.lastDiscoveryUploadAt = lastDiscoveryUploadAt;
37
38
  exports.writeDiscoveryUploadMarker = writeDiscoveryUploadMarker;
38
39
  const fs = __importStar(require("fs"));
39
40
  const os = __importStar(require("os"));
@@ -55,6 +56,22 @@ function hasDiscoveryUploadMarker() {
55
56
  return false;
56
57
  }
57
58
  }
59
+ /**
60
+ * When the last successful full discovery upload completed, or undefined if
61
+ * this machine never uploaded one. Drives the daemon's staleness catch-up:
62
+ * laptops that were powered off at the daily task's fixed hour still get a
63
+ * fresh scan shortly after they wake up.
64
+ */
65
+ function lastDiscoveryUploadAt() {
66
+ try {
67
+ const raw = JSON.parse(fs.readFileSync(markerFile(), 'utf8'));
68
+ const date = raw.completedAt ? new Date(raw.completedAt) : undefined;
69
+ return date && !Number.isNaN(date.getTime()) ? date : undefined;
70
+ }
71
+ catch {
72
+ return undefined;
73
+ }
74
+ }
58
75
  function writeDiscoveryUploadMarker(trigger) {
59
76
  try {
60
77
  fs.mkdirSync(path.dirname(markerFile()), { recursive: true });
@@ -36,6 +36,9 @@ export declare function maybeSelfUpdate(input: {
36
36
  log?: (message: string) => void;
37
37
  /** Skip while a remote machine action is executing (never upgrade mid-action). */
38
38
  busy?: boolean;
39
+ /** Bypass the retry cooldown — used by explicit admin upgrade_cli actions so
40
+ * they always attempt and report the REAL outcome instead of "in progress". */
41
+ force?: boolean;
39
42
  }): SelfUpdateResult | undefined;
40
43
  /** Version the MSI updater script reads from the installed package.json. */
41
44
  export declare function installedMsiVersion(installFolder: string): string | undefined;
@@ -116,14 +116,45 @@ function startNpmSelfUpdate(targetVersion, log) {
116
116
  * but group policy can deny it — in that case the daily trigger remains the
117
117
  * backstop and we report that honestly.
118
118
  */
119
+ /** Install folder of an MSI deployment (…\FullCourtDefense), from the running entry. */
120
+ function msiInstallRoot() {
121
+ const entry = path.resolve(process.argv[1] || '');
122
+ // dist\index.js → install root is the parent of dist.
123
+ const dist = path.dirname(entry);
124
+ const root = path.dirname(dist);
125
+ return fs.existsSync(path.join(root, 'Update-FullCourtDefense.ps1')) ? root : undefined;
126
+ }
127
+ /**
128
+ * Self-heal a missing updater task. Machines installed from an older MSI (or
129
+ * where task creation was denied at install time) ship the updater SCRIPT but
130
+ * not the scheduled task — without this, every self-update tick dead-ends with
131
+ * "reinstall the MSI". Registration needs an elevated token, so this succeeds
132
+ * on elevated daemons and stays a silent no-op otherwise.
133
+ */
134
+ function tryRegisterMsiUpdaterTask(log) {
135
+ const root = msiInstallRoot();
136
+ if (!root)
137
+ return false;
138
+ const script = path.join(root, 'Update-FullCourtDefense.ps1');
139
+ const taskCommand = `powershell.exe -NoProfile -ExecutionPolicy Bypass -File \\"${script}\\"`;
140
+ const create = (0, child_process_1.spawnSync)('schtasks', [
141
+ '/Create', '/TN', exports.MSI_UPDATER_TASK_NAME, '/TR', taskCommand,
142
+ '/SC', 'DAILY', '/ST', '03:07', '/RU', 'SYSTEM', '/RL', 'HIGHEST', '/F',
143
+ ], { stdio: 'ignore', windowsHide: true, timeout: 20_000 });
144
+ if (create.status === 0) {
145
+ log('Self-update: registered the missing MSI updater task (self-heal).');
146
+ return true;
147
+ }
148
+ return false;
149
+ }
119
150
  function startMsiSelfUpdate(targetVersion, log) {
120
151
  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) {
152
+ if (query.status !== 0 && !tryRegisterMsiUpdaterTask(log)) {
122
153
  log('Self-update: MSI updater task is not registered on this machine — reinstall the MSI to enable silent updates.');
123
154
  return {
124
155
  started: false,
125
156
  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.`,
157
+ detail: `The "${exports.MSI_UPDATER_TASK_NAME}" scheduled task is missing and could not be self-registered (needs an elevated daemon). Reinstall the latest MSI once (or redeploy via MDM) to restore silent updates.`,
127
158
  };
128
159
  }
129
160
  const run = (0, child_process_1.spawnSync)('schtasks', ['/Run', '/TN', exports.MSI_UPDATER_TASK_NAME], { stdio: 'ignore', windowsHide: true, timeout: 20_000 });
@@ -153,7 +184,7 @@ function maybeSelfUpdate(input) {
153
184
  return undefined;
154
185
  if (compareCliVersions(input.targetVersion, input.currentVersion) <= 0)
155
186
  return undefined;
156
- if (Date.now() - updateInFlightSince < UPDATE_RETRY_COOLDOWN_MS)
187
+ if (!input.force && Date.now() - updateInFlightSince < UPDATE_RETRY_COOLDOWN_MS)
157
188
  return undefined;
158
189
  updateInFlightSince = Date.now();
159
190
  log(`Self-update: CLI ${input.currentVersion || 'unknown'} -> ${input.targetVersion} (org auto-update).`);
package/dist/version.json CHANGED
@@ -1,3 +1,3 @@
1
1
  {
2
- "version": "1.17.1"
2
+ "version": "1.18.1"
3
3
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fullcourtdefense-cli",
3
- "version": "1.17.1",
3
+ "version": "1.18.1",
4
4
  "description": "Full Court Defense CLI — security scanning for AI agents from your terminal",
5
5
  "main": "dist/index.js",
6
6
  "bin": {