fullcourtdefense-cli 1.22.6 → 1.22.8
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/backupManifest.d.ts +7 -0
- package/dist/backupManifest.js +108 -0
- package/dist/commands/daemon.js +47 -7
- package/dist/commands/deterministicGuard.js +18 -3
- package/dist/commands/discover.js +5 -0
- package/dist/commands/hook.js +10 -0
- package/dist/commands/installAll.js +10 -4
- package/dist/commands/installCursorHook.js +6 -2
- package/dist/commands/mcpGateway.js +13 -0
- package/dist/commands/onboard.d.ts +2 -0
- package/dist/commands/onboard.js +53 -2
- package/dist/commands/uninstallAll.d.ts +58 -9
- package/dist/commands/uninstallAll.js +359 -37
- package/dist/commands/verifyRemoved.d.ts +24 -0
- package/dist/commands/verifyRemoved.js +293 -0
- package/dist/commands/windowsAudit.d.ts +10 -5
- package/dist/commands/windowsAudit.js +47 -8
- package/dist/distress.d.ts +8 -0
- package/dist/distress.js +26 -0
- package/dist/index.js +39 -7
- package/dist/localSafetySnapshot.js +6 -3
- package/dist/selfTest.js +31 -0
- package/dist/selfUpdate.d.ts +10 -0
- package/dist/selfUpdate.js +42 -1
- package/dist/version.json +1 -1
- package/package.json +2 -1
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
export declare function backupManifestPath(): string;
|
|
2
|
+
/** Every recorded backup path (absent/corrupt manifest = empty list). */
|
|
3
|
+
export declare function readBackupManifest(): string[];
|
|
4
|
+
/** Record one backup file path. Deduped, capped, never throws. */
|
|
5
|
+
export declare function recordConfigBackup(backupPath: string): void;
|
|
6
|
+
/** Drop entries whose backup file no longer exists (post-cleanup compaction). */
|
|
7
|
+
export declare function pruneBackupManifest(): void;
|
|
@@ -0,0 +1,108 @@
|
|
|
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.backupManifestPath = backupManifestPath;
|
|
37
|
+
exports.readBackupManifest = readBackupManifest;
|
|
38
|
+
exports.recordConfigBackup = recordConfigBackup;
|
|
39
|
+
exports.pruneBackupManifest = pruneBackupManifest;
|
|
40
|
+
const fs = __importStar(require("fs"));
|
|
41
|
+
const os = __importStar(require("os"));
|
|
42
|
+
const path = __importStar(require("path"));
|
|
43
|
+
/**
|
|
44
|
+
* Ledger of every `*.fcd-backup-*` config safety copy the CLI creates.
|
|
45
|
+
*
|
|
46
|
+
* MCP-config edits back the original file up next to it — including
|
|
47
|
+
* PROJECT-LOCAL configs (a repo's .cursor/mcp.json), which live in places no
|
|
48
|
+
* directory scan can enumerate. Those backups can embed live credentials, so
|
|
49
|
+
* `uninstall` / `verify-removed` must be able to find every one of them: each
|
|
50
|
+
* backup path is recorded here at creation time and the manifest is the
|
|
51
|
+
* authoritative list at cleanup time (union-ed with the known-dir scan for
|
|
52
|
+
* backups created by older CLI versions).
|
|
53
|
+
*
|
|
54
|
+
* Best-effort by design: a manifest write failure must never break a protect
|
|
55
|
+
* operation, and cleanup falls back to the directory scan.
|
|
56
|
+
*/
|
|
57
|
+
const MANIFEST_NAME = 'backup-manifest.json';
|
|
58
|
+
const MAX_ENTRIES = 2000;
|
|
59
|
+
function backupManifestPath() {
|
|
60
|
+
return path.join(os.homedir(), '.fullcourtdefense', MANIFEST_NAME);
|
|
61
|
+
}
|
|
62
|
+
/** Every recorded backup path (absent/corrupt manifest = empty list). */
|
|
63
|
+
function readBackupManifest() {
|
|
64
|
+
try {
|
|
65
|
+
const raw = JSON.parse(fs.readFileSync(backupManifestPath(), 'utf8'));
|
|
66
|
+
const entries = Array.isArray(raw?.backups) ? raw.backups : [];
|
|
67
|
+
return entries.filter((entry) => typeof entry === 'string' && entry.length > 0);
|
|
68
|
+
}
|
|
69
|
+
catch {
|
|
70
|
+
return [];
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
/** Record one backup file path. Deduped, capped, never throws. */
|
|
74
|
+
function recordConfigBackup(backupPath) {
|
|
75
|
+
try {
|
|
76
|
+
const existing = readBackupManifest();
|
|
77
|
+
if (existing.includes(backupPath))
|
|
78
|
+
return;
|
|
79
|
+
const entries = [...existing, backupPath].slice(-MAX_ENTRIES);
|
|
80
|
+
const file = backupManifestPath();
|
|
81
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
82
|
+
fs.writeFileSync(file, `${JSON.stringify({ backups: entries }, null, 2)}\n`, 'utf8');
|
|
83
|
+
}
|
|
84
|
+
catch { /* best effort — cleanup still has the directory scan */ }
|
|
85
|
+
}
|
|
86
|
+
/** Drop entries whose backup file no longer exists (post-cleanup compaction). */
|
|
87
|
+
function pruneBackupManifest() {
|
|
88
|
+
try {
|
|
89
|
+
const existing = readBackupManifest();
|
|
90
|
+
const remaining = existing.filter(entry => {
|
|
91
|
+
try {
|
|
92
|
+
return fs.existsSync(entry);
|
|
93
|
+
}
|
|
94
|
+
catch {
|
|
95
|
+
return true;
|
|
96
|
+
}
|
|
97
|
+
});
|
|
98
|
+
if (remaining.length === existing.length)
|
|
99
|
+
return;
|
|
100
|
+
const file = backupManifestPath();
|
|
101
|
+
if (remaining.length === 0) {
|
|
102
|
+
fs.rmSync(file, { force: true });
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
fs.writeFileSync(file, `${JSON.stringify({ backups: remaining }, null, 2)}\n`, 'utf8');
|
|
106
|
+
}
|
|
107
|
+
catch { /* best effort */ }
|
|
108
|
+
}
|
package/dist/commands/daemon.js
CHANGED
|
@@ -385,6 +385,10 @@ async function runDaemon(args, config) {
|
|
|
385
385
|
if (postmortem) {
|
|
386
386
|
log(`Previous daemon (v${postmortem.version || '?'}) died UNCLEANLY — last alive ${postmortem.lastAliveAt || 'unknown'}, no shutdown recorded. Likely killed externally (EDR/AV, OOM, or forced termination). Post-mortem recorded; reporting upstream.`);
|
|
387
387
|
}
|
|
388
|
+
// A fresh daemon runs right after every successful MSI update: if a
|
|
389
|
+
// previously-reported update loop has been resolved by this version, clear
|
|
390
|
+
// it so heartbeats stop shipping it as live distress.
|
|
391
|
+
(0, selfUpdate_1.clearResolvedUpdateLoop)(cliVersion(), log);
|
|
388
392
|
// --- state ---------------------------------------------------------------
|
|
389
393
|
const watchers = new Map(); // directory -> watcher
|
|
390
394
|
const rootWatchers = new Map(); // admin protection roots -> recursive watcher
|
|
@@ -1183,6 +1187,8 @@ async function runDaemon(args, config) {
|
|
|
1183
1187
|
const result = (0, windowsAudit_1.pruneTranscripts)();
|
|
1184
1188
|
if (result.pruned > 0)
|
|
1185
1189
|
log(`Transcript retention: pruned ${result.pruned} transcript file(s) older than ${(0, windowsAudit_1.transcriptRetentionDays)()} day(s).`);
|
|
1190
|
+
if ((result.prunedBySize || 0) > 0)
|
|
1191
|
+
log(`Transcript retention: pruned ${result.prunedBySize} more transcript file(s) (oldest first) to stay under the ${(0, windowsAudit_1.transcriptMaxMb)()}MB size budget.`);
|
|
1186
1192
|
}
|
|
1187
1193
|
catch { /* never let housekeeping hurt the daemon */ }
|
|
1188
1194
|
};
|
|
@@ -1422,6 +1428,7 @@ function windowsTaskRunsInteractive(taskName) {
|
|
|
1422
1428
|
* Upgrade an EXISTING (working) task to the windowless S4U principal. Tries
|
|
1423
1429
|
* ONLY the S4U XML — on failure (unelevated daemon) the current task is left
|
|
1424
1430
|
* untouched so autostart never regresses from "flashes" to "broken".
|
|
1431
|
+
* Returns true when the task now runs S4U.
|
|
1425
1432
|
*/
|
|
1426
1433
|
function upgradeTaskWindowless(taskName, s4uXml, logFn) {
|
|
1427
1434
|
const base = process.env.LOCALAPPDATA || path.join(os.homedir(), 'AppData', 'Local');
|
|
@@ -1438,12 +1445,40 @@ function upgradeTaskWindowless(taskName, s4uXml, logFn) {
|
|
|
1438
1445
|
});
|
|
1439
1446
|
if (created.status === 0) {
|
|
1440
1447
|
logFn(`Self-heal: "${taskName}" migrated to the windowless S4U principal (no more console-window flash).`);
|
|
1448
|
+
return true;
|
|
1441
1449
|
}
|
|
1442
|
-
|
|
1443
|
-
|
|
1444
|
-
|
|
1450
|
+
logFn(`Self-heal: could not migrate "${taskName}" to S4U (needs elevation) — asking the elevated updater task to do it.`);
|
|
1451
|
+
return false;
|
|
1452
|
+
}
|
|
1453
|
+
catch {
|
|
1454
|
+
return false; /* keep the existing working task */
|
|
1455
|
+
}
|
|
1456
|
+
}
|
|
1457
|
+
/**
|
|
1458
|
+
* The unelevated daemon cannot rewrite task principals — but the SYSTEM
|
|
1459
|
+
* "FullCourtDefense Updater" task can, and its stage-1 repair pass migrates
|
|
1460
|
+
* InteractiveToken tasks BEFORE any network work (so it succeeds even behind
|
|
1461
|
+
* a blocked egress). Kick it ONCE per daemon lifetime when self-heal could
|
|
1462
|
+
* not flip a task itself: same-boot windowless fix instead of waiting for
|
|
1463
|
+
* the 03:07 daily run (lptx1110 flashed CMD windows for a day because the
|
|
1464
|
+
* MSI's PowerShell migration was silently blocked and self-heal had no
|
|
1465
|
+
* elevation). The task grants Authenticated Users read+execute, so an
|
|
1466
|
+
* unelevated /Run works by design (same path as org-pushed upgrades).
|
|
1467
|
+
*/
|
|
1468
|
+
let updaterMigrationKicked = false;
|
|
1469
|
+
function kickUpdaterTaskForMigration(logFn) {
|
|
1470
|
+
if (updaterMigrationKicked)
|
|
1471
|
+
return;
|
|
1472
|
+
updaterMigrationKicked = true;
|
|
1473
|
+
try {
|
|
1474
|
+
const run = (0, child_process_1.spawnSync)('schtasks', ['/Run', '/TN', selfUpdate_1.MSI_UPDATER_TASK_NAME], {
|
|
1475
|
+
stdio: 'ignore', windowsHide: true, timeout: 20_000,
|
|
1476
|
+
});
|
|
1477
|
+
logFn(run.status === 0
|
|
1478
|
+
? `Self-heal: elevated updater task triggered — it migrates the tasks to S4U now (windowless within a minute).`
|
|
1479
|
+
: `Self-heal: updater task unavailable for the S4U migration — its next daily run (03:07) will migrate instead.`);
|
|
1445
1480
|
}
|
|
1446
|
-
catch { /*
|
|
1481
|
+
catch { /* best-effort — the daily schedule remains the backstop */ }
|
|
1447
1482
|
}
|
|
1448
1483
|
/** True when the per-user Run-key fallback still names wscript/a .vbs. */
|
|
1449
1484
|
function windowsRunKeyReferencesScriptHost() {
|
|
@@ -1576,13 +1611,18 @@ function ensureWindowsAutostartHealthy(logFn) {
|
|
|
1576
1611
|
}
|
|
1577
1612
|
// Migration (1.22.1 -> 1.22.2): InteractiveToken tasks flash a console
|
|
1578
1613
|
// window on every trigger. Upgrade healthy existing tasks to S4U; S4U-only
|
|
1579
|
-
// attempt so a denied re-registration never breaks a working task.
|
|
1614
|
+
// attempt so a denied re-registration never breaks a working task. When
|
|
1615
|
+
// unelevated self-heal cannot flip them, kick the SYSTEM updater task
|
|
1616
|
+
// once — its repair pass migrates with real elevation (1.22.7).
|
|
1617
|
+
let migrationDenied = false;
|
|
1580
1618
|
if (daemonTask.status === 0 && !legacyDaemon && windowsTaskRunsInteractive(TASK_NAME)) {
|
|
1581
|
-
upgradeTaskWindowless(TASK_NAME, buildDaemonTaskXml()[0], logFn);
|
|
1619
|
+
migrationDenied = !upgradeTaskWindowless(TASK_NAME, buildDaemonTaskXml()[0], logFn) || migrationDenied;
|
|
1582
1620
|
}
|
|
1583
1621
|
if (watchdogQuery.status === 0 && !legacyWatchdog && windowsTaskRunsInteractive(WATCHDOG_TASK_NAME)) {
|
|
1584
|
-
upgradeTaskWindowless(WATCHDOG_TASK_NAME, buildWatchdogTaskXml()[0], logFn);
|
|
1622
|
+
migrationDenied = !upgradeTaskWindowless(WATCHDOG_TASK_NAME, buildWatchdogTaskXml()[0], logFn) || migrationDenied;
|
|
1585
1623
|
}
|
|
1624
|
+
if (migrationDenied)
|
|
1625
|
+
kickUpdaterTaskForMigration(logFn);
|
|
1586
1626
|
// A stale Run-key fallback from an older install may still name wscript.
|
|
1587
1627
|
if (windowsRunKeyReferencesScriptHost()) {
|
|
1588
1628
|
(0, child_process_1.spawnSync)('reg', [
|
|
@@ -258,9 +258,18 @@ function disabled(options) {
|
|
|
258
258
|
function isEnabled(itemId, options) {
|
|
259
259
|
return !disabled(options).has(itemId);
|
|
260
260
|
}
|
|
261
|
+
/**
|
|
262
|
+
* Items that WARN by default (catalog-level default action). Applies only when
|
|
263
|
+
* the snapshot carries no explicit action for the item — an org override to
|
|
264
|
+
* 'block' ships explicitly in the snapshot and wins. Keeps offline machines
|
|
265
|
+
* and stale snapshots on the warn-first rollout instead of hard-blocking.
|
|
266
|
+
*/
|
|
267
|
+
const DEFAULT_WARN_BUILT_INS = new Set(['env_workspace']);
|
|
261
268
|
function actionFor(itemId, options) {
|
|
262
269
|
const action = options?.itemActions?.[itemId];
|
|
263
|
-
|
|
270
|
+
if (action === 'warn' || action === 'mask' || action === 'block')
|
|
271
|
+
return action;
|
|
272
|
+
return DEFAULT_WARN_BUILT_INS.has(itemId) ? 'warn' : 'block';
|
|
264
273
|
}
|
|
265
274
|
function builtIn(itemId, categoryId, category, ruleId, reason, findingEvidence, explanation, options) {
|
|
266
275
|
if (!isEnabled(itemId, options))
|
|
@@ -396,8 +405,14 @@ function containsSensitiveCredentialPath(value) {
|
|
|
396
405
|
if (check.re.test(text))
|
|
397
406
|
return { itemId: check.itemId, label: check.label };
|
|
398
407
|
}
|
|
399
|
-
if (
|
|
400
|
-
|
|
408
|
+
if (/(?:^|\/)\.env(?:\.[a-z0-9_-]+)?(?:$|[/?#\s'"])/.test(text)) {
|
|
409
|
+
// Traversal to a .env outside the workspace stays a hard block
|
|
410
|
+
// (env_traversal); a plain workspace .env is a separate warn-by-default
|
|
411
|
+
// item (env_workspace) — agents legitimately read it during development,
|
|
412
|
+
// but the fleet should still see every touch.
|
|
413
|
+
return traversal
|
|
414
|
+
? { itemId: 'env_traversal', label: 'environment file via path traversal' }
|
|
415
|
+
: { itemId: 'env_workspace', label: 'workspace environment file' };
|
|
401
416
|
}
|
|
402
417
|
return undefined;
|
|
403
418
|
}
|
|
@@ -1097,6 +1097,11 @@ async function discoverCommand(args, config) {
|
|
|
1097
1097
|
printAgentFilesReport(agentFiles, silent);
|
|
1098
1098
|
if (posture)
|
|
1099
1099
|
printPostureReport(posture, silent);
|
|
1100
|
+
// Explicit scope boundary: a broad security/credential report during an
|
|
1101
|
+
// active incident can reasonably be over-read as endpoint protection.
|
|
1102
|
+
if (!silent) {
|
|
1103
|
+
console.log(`${COLOR.gray}Scope: this discovery inventories AI agents, MCP servers, credentials, and machine posture. It is NOT antivirus/EDR — binaries, running processes, and quarantined files are not scanned. Keep your endpoint protection (e.g. Microsoft Defender) active alongside it.${COLOR.reset}`);
|
|
1104
|
+
}
|
|
1100
1105
|
if (uploadRequested) {
|
|
1101
1106
|
console.log('');
|
|
1102
1107
|
await maybeUpload();
|
package/dist/commands/hook.js
CHANGED
|
@@ -47,6 +47,7 @@ const taintLedger_1 = require("./taintLedger");
|
|
|
47
47
|
const notify_1 = require("../notify");
|
|
48
48
|
const distress_1 = require("../distress");
|
|
49
49
|
const policyGateHealth_1 = require("../policyGateHealth");
|
|
50
|
+
const machineIdentity_1 = require("../machineIdentity");
|
|
50
51
|
const actionPolicyEngine_1 = require("../actionPolicyEngine");
|
|
51
52
|
const DEBUG_LOG = path.join(os.homedir(), '.fullcourtdefense-hook.log');
|
|
52
53
|
/**
|
|
@@ -533,7 +534,16 @@ function machineMetadata(client = 'cursor') {
|
|
|
533
534
|
username = os.userInfo().username;
|
|
534
535
|
}
|
|
535
536
|
catch { /* ignore */ }
|
|
537
|
+
// Stable fleet machine identity: lets the backend attribute this decision to
|
|
538
|
+
// THIS machine exactly (timeline + behavior baseline), instead of matching
|
|
539
|
+
// by hostname. Best-effort — identity must never break the hook.
|
|
540
|
+
let machineId;
|
|
541
|
+
try {
|
|
542
|
+
machineId = (0, machineIdentity_1.getMachineIdentity)().machineId;
|
|
543
|
+
}
|
|
544
|
+
catch { /* ignore */ }
|
|
536
545
|
return {
|
|
546
|
+
...(machineId ? { machineId } : {}),
|
|
537
547
|
developerName: developerId(),
|
|
538
548
|
machineName: os.hostname(),
|
|
539
549
|
os: `${os.platform()} ${os.release()}`,
|
|
@@ -44,7 +44,8 @@ async function installAllCommand(args, config) {
|
|
|
44
44
|
// Prompt secrets are checked locally; prompt text never leaves the machine.
|
|
45
45
|
// No failClosed flag: the offline stance is server-authoritative (from
|
|
46
46
|
// the runtime bundle), unified with the Claude/VS Code hook below.
|
|
47
|
-
|
|
47
|
+
// file + read close the "agent reads ~/.aws/credentials silently" hole.
|
|
48
|
+
events: 'prompt,shell,mcp,file,read',
|
|
48
49
|
};
|
|
49
50
|
try {
|
|
50
51
|
await (0, installCursorHook_1.installCursorHookCommand)(hookArgs, config);
|
|
@@ -72,9 +73,11 @@ async function installAllCommand(args, config) {
|
|
|
72
73
|
}
|
|
73
74
|
// Windows-wide command auditing — PowerShell ScriptBlock Logging + Transcription.
|
|
74
75
|
// Covers commands typed in ANY PowerShell (inside or outside the IDE), not just
|
|
75
|
-
// agent-driven actions.
|
|
76
|
-
//
|
|
77
|
-
|
|
76
|
+
// agent-driven actions. OFF BY DEFAULT: it flips machine-wide HKLM policy and
|
|
77
|
+
// transcription writes sizeable plaintext logs to ProgramData — that footprint
|
|
78
|
+
// is an explicit opt-in (--windows-audit true, or org policy), not a side
|
|
79
|
+
// effect of installing protection. Requires ONE admin (UAC) approval.
|
|
80
|
+
if (process.platform === 'win32' && args.windowsAudit === 'true') {
|
|
78
81
|
console.log('\n\x1b[1mEnabling Windows PowerShell audit logging (ScriptBlock Logging + Transcription)…\x1b[0m');
|
|
79
82
|
try {
|
|
80
83
|
const current = (0, windowsAudit_1.getWindowsAuditStatus)();
|
|
@@ -97,6 +100,9 @@ async function installAllCommand(args, config) {
|
|
|
97
100
|
console.log(`\x1b[33m⚠ Warning: PowerShell audit logging step failed (${error instanceof Error ? error.message : String(error)}). Installation continues.\x1b[0m`);
|
|
98
101
|
}
|
|
99
102
|
}
|
|
103
|
+
else if (process.platform === 'win32' && args.windowsAudit !== 'false') {
|
|
104
|
+
console.log('\n\x1b[2mPowerShell audit logging (ScriptBlock Logging + Transcription) is OFF by default — it changes machine-wide policy and writes command transcripts to disk. Opt in with --windows-audit true, or later via: fullcourtdefense windows-audit --enable true\x1b[0m');
|
|
105
|
+
}
|
|
100
106
|
// Interactive PowerShell guard — blocks dangerous commands TYPED in a
|
|
101
107
|
// terminal (inside or outside the IDE) using the same rule families the IDE
|
|
102
108
|
// hooks enforce, from the local cache (no admin needed, no network hot path).
|
|
@@ -147,7 +147,7 @@ function repairCursorManagedHooks() {
|
|
|
147
147
|
const json = readHooksJson(before.file);
|
|
148
148
|
let changed = false;
|
|
149
149
|
if (before.managedEntries === 0) {
|
|
150
|
-
for (const event of ['prompt', 'shell', 'mcp']) {
|
|
150
|
+
for (const event of ['prompt', 'shell', 'mcp', 'file', 'read']) {
|
|
151
151
|
const { hookKey, flag, timeoutSec } = EVENT_MAP[event];
|
|
152
152
|
const list = Array.isArray(json.hooks[hookKey]) ? json.hooks[hookKey] : [];
|
|
153
153
|
list.push({
|
|
@@ -218,7 +218,11 @@ async function installCursorHookCommand(args, config) {
|
|
|
218
218
|
const projectScope = args.project === 'true';
|
|
219
219
|
const shadow = args.shadow === 'true';
|
|
220
220
|
const file = hooksJsonPath(projectScope);
|
|
221
|
-
|
|
221
|
+
// file + read are in the default set: without them a Cursor agent can read
|
|
222
|
+
// ~/.aws/credentials or write a poisoned config with no hook ever firing —
|
|
223
|
+
// the exact coverage hole customers assume is closed. Opt out with
|
|
224
|
+
// --events prompt,shell,mcp.
|
|
225
|
+
const requested = (args.events || 'prompt,shell,mcp,file,read')
|
|
222
226
|
.split(/[\s,]+/).map((e) => e.trim().toLowerCase()).filter(Boolean);
|
|
223
227
|
const events = requested.filter((e) => EVENT_MAP[e]);
|
|
224
228
|
if (events.length === 0) {
|
|
@@ -63,6 +63,8 @@ const telemetry_1 = require("../telemetry");
|
|
|
63
63
|
const notify_1 = require("../notify");
|
|
64
64
|
const distress_1 = require("../distress");
|
|
65
65
|
const actionPolicyEngine_1 = require("../actionPolicyEngine");
|
|
66
|
+
const machineIdentity_1 = require("../machineIdentity");
|
|
67
|
+
const backupManifest_1 = require("../backupManifest");
|
|
66
68
|
const DEFAULT_API_URL = 'https://api.fullcourtdefense.ai';
|
|
67
69
|
const MANAGED_SERVER_NAME = 'agentguard-gateway';
|
|
68
70
|
const INSTALL_GATEWAY_ARGS = { includeShieldKey: false };
|
|
@@ -230,7 +232,15 @@ function resolveGatewayConfig(args, config, defaults = {}) {
|
|
|
230
232
|
}
|
|
231
233
|
function machineMetadata(developerName, agentClient = 'cursor') {
|
|
232
234
|
const sessionId = runtimeSessionId();
|
|
235
|
+
// Exact fleet machine attribution for the backend's runtime decision mirror
|
|
236
|
+
// (timeline + behavior baseline). Best-effort — never break the gateway.
|
|
237
|
+
let machineId;
|
|
238
|
+
try {
|
|
239
|
+
machineId = (0, machineIdentity_1.getMachineIdentity)().machineId;
|
|
240
|
+
}
|
|
241
|
+
catch { /* ignore */ }
|
|
233
242
|
return {
|
|
243
|
+
...(machineId ? { machineId } : {}),
|
|
234
244
|
developerName: developerName || machineScopedUser(),
|
|
235
245
|
machineName: os.hostname(),
|
|
236
246
|
os: `${os.platform()} ${os.release()}`,
|
|
@@ -1621,6 +1631,9 @@ function backupFile(file) {
|
|
|
1621
1631
|
try {
|
|
1622
1632
|
const bak = `${file}.fcd-backup-${Date.now()}`;
|
|
1623
1633
|
fs.copyFileSync(file, bak);
|
|
1634
|
+
// Backups can embed live credentials and land in project-local dirs no
|
|
1635
|
+
// scan can enumerate — record each one so uninstall can delete them all.
|
|
1636
|
+
(0, backupManifest_1.recordConfigBackup)(bak);
|
|
1624
1637
|
}
|
|
1625
1638
|
catch { /* best effort */ }
|
|
1626
1639
|
}
|
|
@@ -15,6 +15,8 @@ export interface OnboardArgs extends InstallAllArgs {
|
|
|
15
15
|
json?: string;
|
|
16
16
|
/** Do not enable optional self-healing protection. */
|
|
17
17
|
noDaemon?: string;
|
|
18
|
+
/** 'true' => skip the interactive trust disclosure (automation/MDM; the MSI consents via its own dialog). */
|
|
19
|
+
acceptDisclosure?: string;
|
|
18
20
|
}
|
|
19
21
|
/**
|
|
20
22
|
* `fullcourtdefense onboard` — the one-command path from a fresh machine to a
|
package/dist/commands/onboard.js
CHANGED
|
@@ -63,6 +63,50 @@ const YELLOW = '\x1b[33m';
|
|
|
63
63
|
const BOLD = '\x1b[1m';
|
|
64
64
|
const DIM = '\x1b[2m';
|
|
65
65
|
const RESET = '\x1b[0m';
|
|
66
|
+
/**
|
|
67
|
+
* The trust disclosure — the npm/CLI twin of the MSI EnrollmentDlg text
|
|
68
|
+
* (FullCourtDefense.wxs). A user installing from npm must see the same trust
|
|
69
|
+
* boundary the Windows installer shows BEFORE anything touches the machine:
|
|
70
|
+
* what gets installed, what runs in the background, and what data leaves the
|
|
71
|
+
* device. Keep both texts in sync when either changes.
|
|
72
|
+
*
|
|
73
|
+
* Consent model: interactive terminals must confirm; `--accept-disclosure
|
|
74
|
+
* true` / FCD_ACCEPT_DISCLOSURE=1 skip the prompt for automation and MDM;
|
|
75
|
+
* non-interactive runs (MSI custom action — consent was the Install click)
|
|
76
|
+
* print the text and continue, because a hidden prompt would hang the install.
|
|
77
|
+
* Returns false when the user explicitly declined.
|
|
78
|
+
*/
|
|
79
|
+
async function confirmTrustDisclosure(args) {
|
|
80
|
+
console.log(`${BOLD}Before continuing, understand what FullCourtDefense sets up on this machine:${RESET}`);
|
|
81
|
+
console.log(`${DIM} - background protection tasks (resident daemon + watchdog) that start with your session`);
|
|
82
|
+
console.log(' - IDE/terminal hooks, and MCP tool traffic routed through a local security gateway');
|
|
83
|
+
console.log(" - agent/tool activity (commands, tool-call argument summaries) sent to your organization's");
|
|
84
|
+
console.log(' FullCourtDefense console for policy decisions and audit');
|
|
85
|
+
console.log(' - intended for organization-managed devices');
|
|
86
|
+
console.log(` - removed with \`fullcourtdefense uninstall\`${process.platform === 'win32' ? ' (MSI installs: Windows Settings > Apps)' : ''}${RESET}`);
|
|
87
|
+
console.log('');
|
|
88
|
+
const accepted = args.acceptDisclosure === 'true'
|
|
89
|
+
|| process.env.FCD_ACCEPT_DISCLOSURE === '1'
|
|
90
|
+
|| process.env.FCD_ACCEPT_DISCLOSURE === 'true';
|
|
91
|
+
if (accepted) {
|
|
92
|
+
console.log(`${DIM}Disclosure accepted via --accept-disclosure / FCD_ACCEPT_DISCLOSURE.${RESET}\n`);
|
|
93
|
+
return true;
|
|
94
|
+
}
|
|
95
|
+
if (!process.stdin.isTTY || !process.stdout.isTTY) {
|
|
96
|
+
// Non-interactive (MSI custom action, CI, MDM) — consent happened upstream
|
|
97
|
+
// (installer dialog / management policy); prompting here would hang.
|
|
98
|
+
console.log(`${DIM}Non-interactive run — continuing (pass --accept-disclosure true to make consent explicit in automation).${RESET}\n`);
|
|
99
|
+
return true;
|
|
100
|
+
}
|
|
101
|
+
const readline = await Promise.resolve().then(() => __importStar(require('readline')));
|
|
102
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
103
|
+
const answer = await new Promise(resolve => {
|
|
104
|
+
rl.question(`${BOLD}Continue? [y/N] ${RESET}`, resolve);
|
|
105
|
+
});
|
|
106
|
+
rl.close();
|
|
107
|
+
console.log('');
|
|
108
|
+
return /^y(es)?$/i.test(answer.trim());
|
|
109
|
+
}
|
|
66
110
|
function printCheck(check) {
|
|
67
111
|
const mark = check.ok ? `${GREEN}✓${RESET}` : check.optional ? `${YELLOW}○${RESET}` : `${RED}✗${RESET}`;
|
|
68
112
|
const detail = check.detail ? ` ${DIM}${check.detail}${RESET}` : '';
|
|
@@ -193,10 +237,10 @@ function checkTerminalGuards() {
|
|
|
193
237
|
const audit = (0, windowsAudit_1.getWindowsAuditStatus)();
|
|
194
238
|
const auditOk = audit.scriptBlockLogging && audit.transcription;
|
|
195
239
|
checks.push({
|
|
196
|
-
label: 'PowerShell audit logging',
|
|
240
|
+
label: 'PowerShell audit logging (opt-in)',
|
|
197
241
|
ok: auditOk,
|
|
198
242
|
optional: true,
|
|
199
|
-
detail: auditOk ? undefined : '
|
|
243
|
+
detail: auditOk ? undefined : 'off by default — enable with windows-audit --enable true (one UAC approval; writes transcripts to ProgramData)',
|
|
200
244
|
});
|
|
201
245
|
}
|
|
202
246
|
else {
|
|
@@ -234,6 +278,13 @@ async function onboardCommand(args, config) {
|
|
|
234
278
|
};
|
|
235
279
|
console.log(`\n${BOLD}\x1b[36mFullCourtDefense — machine onboarding${RESET}`);
|
|
236
280
|
console.log(`${DIM}Connectivity -> enrollment -> protection -> verification. One command.${RESET}\n`);
|
|
281
|
+
// Trust disclosure first — nothing touches the machine before consent.
|
|
282
|
+
// Dry runs report the plan without changing anything, so no prompt there.
|
|
283
|
+
if (!dryRun && !(await confirmTrustDisclosure(args))) {
|
|
284
|
+
console.log(`${YELLOW}Onboarding cancelled — nothing was installed or changed on this machine.${RESET}`);
|
|
285
|
+
process.exitCode = 1;
|
|
286
|
+
return;
|
|
287
|
+
}
|
|
237
288
|
// Onboarding is a persisted transaction: completed steps are reused on any
|
|
238
289
|
// re-run, so `--resume` is an explicit alias that also surfaces the journal.
|
|
239
290
|
const resume = args.resume === 'true' || args.resume === '';
|
|
@@ -1,18 +1,67 @@
|
|
|
1
1
|
import { BotGuardConfig } from '../config';
|
|
2
2
|
import { ProtectAllArgs } from './mcpGateway';
|
|
3
3
|
export interface UninstallAllArgs extends ProtectAllArgs {
|
|
4
|
-
/**
|
|
4
|
+
/** Kept for backward compatibility — `uninstall` now always purges. */
|
|
5
5
|
purge?: string;
|
|
6
|
+
/** 'true' => never hand off to the Windows (MSI) uninstaller. Advanced/CI. */
|
|
7
|
+
localOnly?: string;
|
|
8
|
+
/** Optional one-line reason recorded with the fleet uninstall report. */
|
|
9
|
+
reason?: string;
|
|
6
10
|
}
|
|
7
11
|
/**
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
+
* Every `*.fcd-backup-*` config safety copy: the known client dirs (scan)
|
|
13
|
+
* union-ed with the backup manifest, which records PROJECT-LOCAL backups
|
|
14
|
+
* (a repo's .cursor/mcp.json.fcd-backup-*) that no directory scan can find.
|
|
15
|
+
* The scan still covers backups created by pre-manifest CLI versions.
|
|
16
|
+
*/
|
|
17
|
+
export declare function listFcdConfigBackups(): string[];
|
|
18
|
+
/** Delete every known `*.fcd-backup-*` config safety copy (scan + manifest). */
|
|
19
|
+
export declare function removeFcdConfigBackups(): {
|
|
20
|
+
removed: string[];
|
|
21
|
+
failures: string[];
|
|
22
|
+
};
|
|
23
|
+
/**
|
|
24
|
+
* Delete all local FullCourtDefense state: config + spool + logs, the
|
|
25
|
+
* ~/.fullcourtdefense dir (machine key file lives inside), the Credential
|
|
26
|
+
* Manager entry, %LOCALAPPDATA%\FullCourtDefense, and config backups.
|
|
27
|
+
*/
|
|
28
|
+
export declare function purgeLocalState(config: BotGuardConfig): {
|
|
29
|
+
failures: string[];
|
|
30
|
+
};
|
|
31
|
+
/**
|
|
32
|
+
* Product code of the installed FullCourtDefense MSI, from the registry
|
|
33
|
+
* uninstall hive (both 64-bit and WOW6432Node views). Undefined on npm
|
|
34
|
+
* installs or when the MSI entry is missing.
|
|
35
|
+
*/
|
|
36
|
+
export declare function parseMsiProductCodeFromRegOutput(output: string): string | undefined;
|
|
37
|
+
export declare function findMsiProductCode(): string | undefined;
|
|
38
|
+
/**
|
|
39
|
+
* `fullcourtdefense detach` — reverse `onboard`/`install-all` protection but
|
|
40
|
+
* KEEP machine credentials and the installed application, so the machine can
|
|
41
|
+
* re-onboard without a new enrollment token. This is the old `uninstall`
|
|
42
|
+
* behavior, renamed to say what it actually does.
|
|
43
|
+
*/
|
|
44
|
+
export declare function detachCommand(args: UninstallAllArgs, config: BotGuardConfig): Promise<void>;
|
|
45
|
+
/**
|
|
46
|
+
* Tell the fleet console this machine is being uninstalled, BEFORE the local
|
|
47
|
+
* credential purge erases the shield key we need to authenticate the call.
|
|
48
|
+
* Best-effort with a short timeout: an offline uninstall must never hang or
|
|
49
|
+
* fail because the control plane is unreachable. On success the console shows
|
|
50
|
+
* the machine as "Uninstalled" instead of silently ghosting until an admin
|
|
51
|
+
* notices the missing heartbeats.
|
|
52
|
+
*/
|
|
53
|
+
export declare function reportUninstallToFleet(config: BotGuardConfig, reason?: string): Promise<boolean>;
|
|
54
|
+
/**
|
|
55
|
+
* `fullcourtdefense uninstall` — COMPLETE removal.
|
|
56
|
+
*
|
|
57
|
+
* On MSI installs (Windows): runs the local teardown + credential purge, then
|
|
58
|
+
* hands off to the Windows uninstaller (`msiexec /x`) so the SYSTEM updater
|
|
59
|
+
* task, HKLM audit policies, and the installed application are all removed in
|
|
60
|
+
* the same flow — one command, one UAC prompt, machine back to its pre-FCD
|
|
61
|
+
* state. On npm installs: local teardown + purge, then tells the user the one
|
|
62
|
+
* remaining step (removing the npm package itself).
|
|
12
63
|
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
* `--purge true` to delete them too. The fleet record in the dashboard is not
|
|
16
|
-
* touched — deprovision it from the console (Machines → machine → Deprovision).
|
|
64
|
+
* To detach protection but keep credentials/the app, use `fullcourtdefense
|
|
65
|
+
* detach` instead.
|
|
17
66
|
*/
|
|
18
67
|
export declare function uninstallAllCommand(args: UninstallAllArgs, config: BotGuardConfig): Promise<void>;
|