fullcourtdefense-cli 1.15.2 → 1.15.4

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.
@@ -42,6 +42,7 @@ const child_process_1 = require("child_process");
42
42
  const config_1 = require("../config");
43
43
  const mcpGateway_1 = require("./mcpGateway");
44
44
  const runtimeConfig_1 = require("../runtimeConfig");
45
+ const localSafetySnapshot_1 = require("../localSafetySnapshot");
45
46
  const telemetry_1 = require("../telemetry");
46
47
  const notify_1 = require("../notify");
47
48
  const integrity_1 = require("../integrity");
@@ -76,6 +77,55 @@ function daemonDir() {
76
77
  function pidFile() {
77
78
  return path.join(daemonDir(), 'daemon.pid');
78
79
  }
80
+ /** Sidecar next to the pid lock recording WHICH build owns the daemon. */
81
+ function metaFile() {
82
+ return path.join(daemonDir(), 'daemon.meta.json');
83
+ }
84
+ function readDaemonMeta() {
85
+ try {
86
+ const meta = JSON.parse(fs.readFileSync(metaFile(), 'utf8'));
87
+ return meta && typeof meta.pid === 'number' ? meta : undefined;
88
+ }
89
+ catch {
90
+ return undefined;
91
+ }
92
+ }
93
+ function writeDaemonMeta() {
94
+ try {
95
+ const meta = {
96
+ pid: process.pid,
97
+ version: cliVersion(),
98
+ entry: cliEntry(),
99
+ startedAt: new Date().toISOString(),
100
+ };
101
+ fs.writeFileSync(metaFile(), JSON.stringify(meta, null, 2), 'utf8');
102
+ }
103
+ catch { /* best-effort */ }
104
+ }
105
+ /** Kill a process and wait (up to ~5s) for it to actually exit. */
106
+ function stopPid(pid) {
107
+ try {
108
+ process.kill(pid);
109
+ }
110
+ catch { /* may already be gone */ }
111
+ const deadline = Date.now() + 5_000;
112
+ const sleeper = new Int32Array(new SharedArrayBuffer(4));
113
+ while (Date.now() < deadline) {
114
+ if (!isPidAlive(pid))
115
+ return true;
116
+ Atomics.wait(sleeper, 0, 0, 250);
117
+ }
118
+ if (process.platform === 'win32') {
119
+ (0, child_process_1.spawnSync)('taskkill', ['/PID', String(pid), '/F'], { stdio: 'ignore', windowsHide: true, timeout: 5_000 });
120
+ }
121
+ else {
122
+ try {
123
+ process.kill(pid, 'SIGKILL');
124
+ }
125
+ catch { /* ignore */ }
126
+ }
127
+ return !isPidAlive(pid);
128
+ }
79
129
  function logFile() {
80
130
  return path.join(daemonDir(), 'daemon.log');
81
131
  }
@@ -116,24 +166,56 @@ function isPidAlive(pid) {
116
166
  return error.code === 'EPERM';
117
167
  }
118
168
  }
119
- /** Take the single-instance lock. Returns false when another daemon is already running. */
169
+ /** Lexicographic-free semver compare; missing/unparseable sorts oldest. */
170
+ function compareVersions(a, b) {
171
+ const parse = (value) => String(value || '0').split('.').map(part => parseInt(part, 10) || 0);
172
+ const [pa, pb] = [parse(a), parse(b)];
173
+ for (let i = 0; i < Math.max(pa.length, pb.length); i++) {
174
+ const diff = (pa[i] || 0) - (pb[i] || 0);
175
+ if (diff !== 0)
176
+ return diff;
177
+ }
178
+ return 0;
179
+ }
180
+ /**
181
+ * Take the single-instance lock. A NEWER build supersedes a running older
182
+ * daemon (e.g. the MSI-bundled copy kept running after an npm update): the old
183
+ * process is stopped and this one takes over, so the fleet never keeps running
184
+ * stale enforcement code silently. Same-or-newer versions keep the lock.
185
+ */
120
186
  function acquirePidLock() {
121
187
  fs.mkdirSync(daemonDir(), { recursive: true });
122
188
  try {
123
189
  const existing = Number(fs.readFileSync(pidFile(), 'utf8').trim());
124
190
  if (Number.isFinite(existing) && existing > 0 && existing !== process.pid && isPidAlive(existing)) {
125
- return false;
191
+ const meta = readDaemonMeta();
192
+ const runningVersion = meta && meta.pid === existing ? meta.version : undefined;
193
+ // No meta = pre-1.15.4 build (never wrote one) → treated as older.
194
+ if (compareVersions(cliVersion(), runningVersion) > 0) {
195
+ log(`Superseding older daemon (pid ${existing}, version ${runningVersion || 'unknown'}) with ${cliVersion() || 'this build'}.`);
196
+ if (!stopPid(existing))
197
+ return false; // could not stop it — yield rather than double-run
198
+ }
199
+ else {
200
+ return false;
201
+ }
126
202
  }
127
203
  }
128
204
  catch { /* no pidfile — free to start */ }
129
205
  fs.writeFileSync(pidFile(), String(process.pid), 'utf8');
206
+ writeDaemonMeta();
130
207
  return true;
131
208
  }
132
209
  function releasePidLock() {
133
210
  try {
134
211
  const recorded = Number(fs.readFileSync(pidFile(), 'utf8').trim());
135
- if (recorded === process.pid)
212
+ if (recorded === process.pid) {
136
213
  fs.unlinkSync(pidFile());
214
+ try {
215
+ fs.unlinkSync(metaFile());
216
+ }
217
+ catch { /* best-effort */ }
218
+ }
137
219
  }
138
220
  catch { /* best-effort */ }
139
221
  }
@@ -288,7 +370,28 @@ async function runDaemon(args, config) {
288
370
  throw new Error(resultSummary);
289
371
  }
290
372
  else if (action.type === 'policy_refresh') {
291
- resultSummary = 'Policy bundle refreshed from the control plane.';
373
+ if (!creds.shieldId)
374
+ throw new Error('Shield not configured on this machine.');
375
+ const shieldId = creds.shieldId;
376
+ const identity = (0, machineIdentity_1.getMachineIdentity)();
377
+ const bundle = await (0, runtimeConfig_1.getRuntimeBundle)({
378
+ apiUrl: creds.apiUrl,
379
+ shieldId,
380
+ shieldKey: creds.shieldKey,
381
+ developerName: identity.developerName,
382
+ machineName: identity.hostname,
383
+ force: true,
384
+ ttlMs: 0,
385
+ });
386
+ (0, localSafetySnapshot_1.clearLocalSafetySnapshotCache)({
387
+ apiUrl: creds.apiUrl,
388
+ shieldId,
389
+ developerName: identity.developerName,
390
+ machineName: identity.hostname,
391
+ });
392
+ resultSummary = bundle.policyHash
393
+ ? `Policy bundle refreshed (hash ${bundle.policyHash.slice(0, 12)}…).`
394
+ : 'Policy bundle refreshed from the control plane.';
292
395
  }
293
396
  else if (action.type === 'repair_protection') {
294
397
  if (suspended)
@@ -301,15 +404,18 @@ async function runDaemon(args, config) {
301
404
  resultSummary = 'AgentGuard hooks, gateways, and protection configuration were repaired and verified.';
302
405
  }
303
406
  else if (action.type === 'discovery_scan') {
304
- const result = (0, child_process_1.spawnSync)(process.execPath, [cliEntry(), 'discover', '--upload'], {
407
+ // Full surface sweep (MCP + secrets + agent-files + posture) so a remote
408
+ // "Run discovery" refreshes BOTH the discovery and the posture scan
409
+ // timestamps in the dashboard — not just the MCP inventory.
410
+ const result = (0, child_process_1.spawnSync)(process.execPath, [cliEntry(), 'discover', '--upload', '--surface', 'all', '--silent'], {
305
411
  encoding: 'utf8',
306
412
  windowsHide: true,
307
- timeout: 180_000,
413
+ timeout: 300_000,
308
414
  });
309
415
  if (result.status !== 0) {
310
416
  throw new Error((result.stderr || result.stdout || 'Discovery command failed').trim().slice(0, 500));
311
417
  }
312
- resultSummary = 'Discovery scan completed and uploaded.';
418
+ resultSummary = 'Discovery + posture scan completed and uploaded.';
313
419
  }
314
420
  await reportMachineAction(action.id, 'succeeded', { resultSummary });
315
421
  log(`Remote action succeeded: ${action.type}.`);
@@ -323,11 +429,14 @@ async function runDaemon(args, config) {
323
429
  const pollBundle = async () => {
324
430
  if (!creds.shieldId)
325
431
  return;
432
+ const identity = (0, machineIdentity_1.getMachineIdentity)();
326
433
  try {
327
434
  const bundle = await (0, runtimeConfig_1.getRuntimeBundle)({
328
435
  apiUrl: creds.apiUrl,
329
436
  shieldId: creds.shieldId,
330
437
  shieldKey: creds.shieldKey,
438
+ developerName: identity.developerName,
439
+ machineName: identity.hostname,
331
440
  force: true,
332
441
  });
333
442
  if (bundle.suspended && !suspended) {
@@ -439,8 +548,15 @@ function isWindowsRunKeyInstalled() {
439
548
  function startDaemonNowWindows(vbs) {
440
549
  try {
441
550
  const existing = Number(fs.readFileSync(pidFile(), 'utf8').trim());
442
- if (Number.isFinite(existing) && existing > 0 && isPidAlive(existing))
443
- return;
551
+ if (Number.isFinite(existing) && existing > 0 && isPidAlive(existing)) {
552
+ // Skip only when the running daemon is the same version or newer; an
553
+ // older one (e.g. stale MSI copy) gets superseded by the spawned daemon,
554
+ // whose acquirePidLock() stops it and takes over.
555
+ const meta = readDaemonMeta();
556
+ const runningVersion = meta && meta.pid === existing ? meta.version : undefined;
557
+ if (compareVersions(cliVersion(), runningVersion) <= 0)
558
+ return;
559
+ }
444
560
  }
445
561
  catch { /* not running */ }
446
562
  const escaped = vbs.replace(/'/g, "''");
@@ -593,9 +709,20 @@ function statusCommand() {
593
709
  try {
594
710
  const pid = Number(fs.readFileSync(pidFile(), 'utf8').trim());
595
711
  running = Number.isFinite(pid) && pid > 0 && isPidAlive(pid);
596
- console.log(running
597
- ? `${COLOR.green}Running${COLOR.reset} (pid ${pid})`
598
- : `${COLOR.yellow}Not running${COLOR.reset} (stale pid file: ${pidFile()})`);
712
+ if (running) {
713
+ const meta = readDaemonMeta();
714
+ const runningVersion = meta && meta.pid === pid ? meta.version : undefined;
715
+ const mine = cliVersion();
716
+ console.log(`${COLOR.green}Running${COLOR.reset} (pid ${pid}, version ${runningVersion || 'unknown — pre-1.15.4 build'})`);
717
+ if (meta?.entry)
718
+ console.log(`${COLOR.gray}Binary:${COLOR.reset} ${meta.entry}`);
719
+ if (mine && compareVersions(mine, runningVersion) > 0) {
720
+ console.log(`${COLOR.yellow}Outdated:${COLOR.reset} this CLI is ${mine} — run ${COLOR.bold}fullcourtdefense daemon${COLOR.reset} to supersede the old daemon.`);
721
+ }
722
+ }
723
+ else {
724
+ console.log(`${COLOR.yellow}Not running${COLOR.reset} (stale pid file: ${pidFile()})`);
725
+ }
599
726
  }
600
727
  catch {
601
728
  console.log(`${COLOR.yellow}Not running${COLOR.reset} (no pid file)`);
@@ -603,7 +730,16 @@ function statusCommand() {
603
730
  console.log(`${COLOR.gray}Log:${COLOR.reset} ${logFile()}`);
604
731
  console.log(`${COLOR.gray}Autostart:${COLOR.reset}`);
605
732
  if (process.platform === 'win32') {
606
- (0, child_process_1.spawnSync)('schtasks', ['/Query', '/TN', TASK_NAME], { stdio: 'inherit' });
733
+ const task = (0, child_process_1.spawnSync)('schtasks', ['/Query', '/TN', TASK_NAME], { stdio: 'ignore', windowsHide: true, timeout: 5_000 });
734
+ if (task.status === 0) {
735
+ console.log(` Scheduled Task "${TASK_NAME}" installed`);
736
+ }
737
+ else if (isWindowsRunKeyInstalled()) {
738
+ console.log(` Run key installed (HKCU\\...\\Run\\${WINDOWS_RUN_VALUE}) — starts at logon`);
739
+ }
740
+ else {
741
+ console.log(' not installed — run: fullcourtdefense daemon --install true');
742
+ }
607
743
  }
608
744
  else if (process.platform === 'darwin') {
609
745
  console.log(fs.existsSync(launchdPlistPath()) ? ` launchd agent installed (${launchdPlistPath()})` : ' not installed');
package/dist/config.js CHANGED
@@ -157,23 +157,29 @@ function readConfigFile(filePath) {
157
157
  function powershellDpapi(script, value) {
158
158
  if (process.platform !== 'win32' || !value)
159
159
  return undefined;
160
- try {
161
- return (0, child_process_1.execFileSync)('powershell.exe', [
162
- '-NoProfile',
163
- '-NonInteractive',
164
- '-ExecutionPolicy', 'Bypass',
165
- '-Command', script,
166
- ], {
167
- encoding: 'utf8',
168
- stdio: ['ignore', 'pipe', 'ignore'],
169
- env: { ...process.env, FCD_DPAPI_VALUE: value },
170
- timeout: 5_000,
171
- windowsHide: true,
172
- }).trim() || undefined;
173
- }
174
- catch {
175
- return undefined;
160
+ // Windows PowerShell 5.1 first, then PowerShell 7 — both use DPAPI for
161
+ // SecureString on Windows. Some locked-down/service contexts (e.g. CI
162
+ // runners) break one shell but not the other.
163
+ for (const shell of ['powershell.exe', 'pwsh.exe']) {
164
+ try {
165
+ const output = (0, child_process_1.execFileSync)(shell, [
166
+ '-NoProfile',
167
+ '-NonInteractive',
168
+ '-ExecutionPolicy', 'Bypass',
169
+ '-Command', script,
170
+ ], {
171
+ encoding: 'utf8',
172
+ stdio: ['ignore', 'pipe', 'ignore'],
173
+ env: { ...process.env, FCD_DPAPI_VALUE: value },
174
+ timeout: 15_000,
175
+ windowsHide: true,
176
+ }).trim();
177
+ if (output)
178
+ return output;
179
+ }
180
+ catch { /* try the next shell */ }
176
181
  }
182
+ return undefined;
177
183
  }
178
184
  function protectShieldKeyForCurrentWindowsUser(value) {
179
185
  return powershellDpapi('$secure=ConvertTo-SecureString -String $env:FCD_DPAPI_VALUE -AsPlainText -Force; ConvertFrom-SecureString -SecureString $secure', value);
@@ -14,5 +14,6 @@ export interface LocalSafetySnapshotInput {
14
14
  expectedPolicyHash?: string;
15
15
  timeoutMs?: number;
16
16
  }
17
+ export declare function clearLocalSafetySnapshotCache(input: Pick<LocalSafetySnapshotInput, 'apiUrl' | 'shieldId' | 'developerName' | 'machineName'>): void;
17
18
  export declare function loadLocalSafetySnapshot(input: LocalSafetySnapshotInput): Promise<LocalSafetySnapshot | undefined>;
18
19
  export declare function snapshotToScanOptions(snapshot: LocalSafetySnapshot | undefined, extra?: Pick<LocalSafetyScanOptions, 'cwd' | 'inspectScripts'>): LocalSafetyScanOptions;
@@ -33,6 +33,7 @@ var __importStar = (this && this.__importStar) || (function () {
33
33
  };
34
34
  })();
35
35
  Object.defineProperty(exports, "__esModule", { value: true });
36
+ exports.clearLocalSafetySnapshotCache = clearLocalSafetySnapshotCache;
36
37
  exports.loadLocalSafetySnapshot = loadLocalSafetySnapshot;
37
38
  exports.snapshotToScanOptions = snapshotToScanOptions;
38
39
  const crypto = __importStar(require("crypto"));
@@ -86,6 +87,12 @@ function toSnapshot(data) {
86
87
  updatedAt: typeof data.updatedAt === 'string' ? data.updatedAt : undefined,
87
88
  };
88
89
  }
90
+ function clearLocalSafetySnapshotCache(input) {
91
+ try {
92
+ fs.unlinkSync(cachePath(input));
93
+ }
94
+ catch { /* cache may not exist */ }
95
+ }
89
96
  async function loadLocalSafetySnapshot(input) {
90
97
  const file = cachePath(input);
91
98
  const cached = readCache(file);
package/dist/version.json CHANGED
@@ -1,3 +1,3 @@
1
1
  {
2
- "version": "1.15.2"
2
+ "version": "1.15.4"
3
3
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fullcourtdefense-cli",
3
- "version": "1.15.2",
3
+ "version": "1.15.4",
4
4
  "description": "Full Court Defense CLI — security scanning for AI agents from your terminal",
5
5
  "main": "dist/index.js",
6
6
  "bin": {