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.
- package/dist/commands/daemon.js +149 -13
- package/dist/config.js +22 -16
- package/dist/localSafetySnapshot.d.ts +1 -0
- package/dist/localSafetySnapshot.js +7 -0
- package/dist/version.json +1 -1
- package/package.json +1 -1
package/dist/commands/daemon.js
CHANGED
|
@@ -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
|
-
/**
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
597
|
-
|
|
598
|
-
|
|
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: '
|
|
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
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
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