fullcourtdefense-cli 1.25.7 → 1.26.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.
@@ -74,21 +74,29 @@ const AUTORUN_MARKER = 'fullcourtdefense-cmd-autorun.bat';
74
74
  * cmd (they ARE under PowerShell/zsh/bash and `shell-guard-check`).
75
75
  */
76
76
  const INTERCEPTED_COMMANDS = [
77
- // Destructive filesystem / disk
77
+ // Destructive filesystem / disk / permission tampering
78
78
  'del', 'erase', 'rd', 'rmdir', 'format', 'cipher', 'diskpart', 'fsutil',
79
- 'robocopy', 'xcopy', 'takeown', 'icacls', 'attrib', 'compact',
79
+ 'takeown', 'icacls', 'attrib', 'compact',
80
80
  // Backup / recovery / shadow-copy tampering (ransomware patterns)
81
81
  'vssadmin', 'wbadmin', 'bcdedit', 'wevtutil',
82
- // Interpreters / download-and-exec vectors
83
- 'powershell', 'pwsh', 'curl', 'wget', 'certutil', 'bitsadmin', 'mshta',
84
- 'rundll32', 'regsvr32', 'wscript', 'cscript', 'wmic', 'wsl',
85
- // Service / firewall / registry / accounts
86
- 'reg', 'net', 'net1', 'sc', 'netsh', 'schtasks', 'taskkill',
87
- // Infra / cloud CLIs
88
- 'terraform', 'pulumi', 'kubectl', 'aws', 'gcloud', 'az', 'docker',
89
- // VCS + database clients
90
- 'git', 'psql', 'mysql', 'mongo', 'mongosh', 'sqlcmd',
82
+ // Script hosts / LOLBin download-and-exec vectors (rare in dev workflows)
83
+ 'powershell', 'pwsh', 'certutil', 'bitsadmin', 'mshta',
84
+ 'rundll32', 'regsvr32', 'wscript', 'cscript', 'wmic',
85
+ // Service / firewall / registry / accounts / scheduled-task tampering
86
+ 'reg', 'net', 'net1', 'sc', 'netsh', 'schtasks',
91
87
  ];
88
+ /**
89
+ * LIGHTNESS DIET (deliberate non-goals of the cmd guard): high-frequency
90
+ * developer commands are NOT doskey-intercepted. Every macro costs a full
91
+ * Node spawn per typed command — plus a corporate-AV scan of node.exe — so
92
+ * wrapping `docker`/`git` made every docker-heavy terminal feel sticky.
93
+ * Dropped: docker, git, kubectl, aws, gcloud, az, terraform, pulumi, wsl,
94
+ * curl, wget, taskkill, robocopy, xcopy, psql, mysql, mongo, mongosh, sqlcmd.
95
+ * Coverage is NOT lost where it matters: agent-driven commands go through the
96
+ * IDE hooks (full ruleset, any position in the line), and humans typing in
97
+ * PowerShell are covered in-process by the PSReadLine guard. Interactive
98
+ * cmd.exe keeps only the rare, high-signal destructive/tamper commands above.
99
+ */
92
100
  function regString(key, value) {
93
101
  try {
94
102
  const out = (0, child_process_1.execFileSync)('reg', ['query', key, '/v', value], {
@@ -130,7 +138,10 @@ function buildGuardJs(nodePath, cliEntry) {
130
138
  `function spoolEvent(rule,line,decision){try{const ev=line.trim();const evidence=ev.length>180?ev.slice(0,180)+'...':ev;const event={eventId:crypto.randomUUID(),type:'verdict',decision,toolName:'cmd_terminal',operation:'shell_command',reason:'Shell guard: '+rule.reason,ruleId:rule.id,category:rule.category,severity:rule.severity,source:rule.source,evidence,occurredAt:new Date().toISOString()};fs.appendFileSync(SPOOL_PATH,JSON.stringify(event)+'\\n',{encoding:'utf8',mode:0o600});if(fs.existsSync(NODE_PATH)&&fs.existsSync(CLI_ENTRY)){const c=spawn(NODE_PATH,[CLI_ENTRY,'flush-spool','--heartbeat','true'],{detached:true,stdio:'ignore',windowsHide:true});c.unref();}}catch{}}`,
131
139
  `function delegate(args){const r=spawnSync(process.env.ComSpec||'cmd.exe',['/d','/c',...args],{stdio:'inherit',windowsHide:true});process.exit(typeof r.status==='number'?r.status:1);}`,
132
140
  `const args=process.argv.slice(2);if(!args.length)process.exit(0);if(process.env.FCD_CMD_GUARD==='off')delegate(args);`,
133
- `const line=args.join(' ');const{mode,rules}=loadRules();const hit=matchLine(line,rules);`,
141
+ // TOP-LEVEL FAIL-OPEN: any unexpected exception in rule loading/matching
142
+ // must never break the user's typed command — treat it as "no hit" and
143
+ // delegate. A stack trace or nonzero exit here IS an outage for the shell.
144
+ `const line=args.join(' ');let mode='monitor';let hit=null;try{const r=loadRules();mode=r.mode;hit=matchLine(line,r.rules);}catch{hit=null;}`,
134
145
  `if(!hit)delegate(args);`,
135
146
  `if(mode==='monitor'){console.error('[FullCourtDefense] monitor: would block ['+(hit.severity||'')+'] '+hit.reason+' (rule '+hit.id+')');spoolEvent(hit,line,'warn');delegate(args);}`,
136
147
  `if(hit.action==='warn'){console.error('[FullCourtDefense] warning ['+(hit.severity||'')+']: '+hit.reason+' (rule '+hit.id+') — allowed by org policy, reported to your security dashboard.');spoolEvent(hit,line,'warn');delegate(args);}`,
@@ -1,5 +1,6 @@
1
1
  import { BotGuardConfig } from '../config';
2
2
  import { ProtectAllArgs } from './mcpGateway';
3
+ import { windowsTaskRunsInteractive, windowsTaskRunsVisibly } from '../windowsTaskState';
3
4
  export interface DaemonArgs extends ProtectAllArgs {
4
5
  install?: string;
5
6
  uninstall?: string;
@@ -83,15 +84,7 @@ export declare function registerHiddenTask(taskName: string, xml: string | strin
83
84
  /** True when an existing scheduled task's action still runs wscript/cscript
84
85
  * (the pre-1.22.1 VBS launcher) — the migration trigger. */
85
86
  export declare function windowsTaskReferencesScriptHost(taskName: string): boolean;
86
- /** True when an existing task still runs with an InteractiveToken principal —
87
- * the 1.22.1 window-flash bug (console window on every trigger). Migration
88
- * trigger for the windowless S4U principal. */
89
- export declare function windowsTaskRunsInteractive(taskName: string): boolean;
90
- /** True when triggering the task would open a VISIBLE console window:
91
- * InteractiveToken principal AND a bare console action (not wrapped in
92
- * `conhost --headless`). This — not the principal alone — is the flash
93
- * condition; a conhost-wrapped InteractiveToken task is fully windowless. */
94
- export declare function windowsTaskRunsVisibly(taskName: string): boolean;
87
+ export { windowsTaskRunsInteractive, windowsTaskRunsVisibly };
95
88
  /** Snapshot of the resident daemon for out-of-process callers (watchdog/status). */
96
89
  export interface DaemonRuntimeState {
97
90
  alive: boolean;
@@ -33,6 +33,7 @@ var __importStar = (this && this.__importStar) || (function () {
33
33
  };
34
34
  })();
35
35
  Object.defineProperty(exports, "__esModule", { value: true });
36
+ exports.windowsTaskRunsVisibly = exports.windowsTaskRunsInteractive = void 0;
36
37
  exports.daemonDiscoverSweepArgs = daemonDiscoverSweepArgs;
37
38
  exports.summarizeDiscoverStderr = summarizeDiscoverStderr;
38
39
  exports.discoverSweepCredentialEnv = discoverSweepCredentialEnv;
@@ -44,8 +45,6 @@ exports.buildTaskXml = buildTaskXml;
44
45
  exports.buildTaskXmlVariants = buildTaskXmlVariants;
45
46
  exports.registerHiddenTask = registerHiddenTask;
46
47
  exports.windowsTaskReferencesScriptHost = windowsTaskReferencesScriptHost;
47
- exports.windowsTaskRunsInteractive = windowsTaskRunsInteractive;
48
- exports.windowsTaskRunsVisibly = windowsTaskRunsVisibly;
49
48
  exports.daemonRuntimeState = daemonRuntimeState;
50
49
  exports.spawnDetachedDaemon = spawnDetachedDaemon;
51
50
  exports.pidIsAlive = pidIsAlive;
@@ -76,9 +75,15 @@ const discoveryMarker_1 = require("../discoveryMarker");
76
75
  const selfUpdate_1 = require("../selfUpdate");
77
76
  const cmdGuard_1 = require("./cmdGuard");
78
77
  const machineActionVerify_1 = require("../machineActionVerify");
78
+ const perfSnapshot_1 = require("../perfSnapshot");
79
79
  const desktopChatGuard_1 = require("./desktopChatGuard");
80
80
  const honeypot_1 = require("../honeypot");
81
81
  const windowsAudit_1 = require("./windowsAudit");
82
+ const hook_1 = require("./hook");
83
+ const verdictIpc_1 = require("../verdictIpc");
84
+ const windowsTaskState_1 = require("../windowsTaskState");
85
+ Object.defineProperty(exports, "windowsTaskRunsInteractive", { enumerable: true, get: function () { return windowsTaskState_1.windowsTaskRunsInteractive; } });
86
+ Object.defineProperty(exports, "windowsTaskRunsVisibly", { enumerable: true, get: function () { return windowsTaskState_1.windowsTaskRunsVisibly; } });
82
87
  const COLOR = {
83
88
  reset: '\x1b[0m', bold: '\x1b[1m', dim: '\x1b[2m',
84
89
  red: '\x1b[31m', yellow: '\x1b[33m', green: '\x1b[32m', cyan: '\x1b[36m', gray: '\x1b[90m',
@@ -108,6 +113,11 @@ const INITIAL_DISCOVER_DELAY_MS = envMs('FCD_DAEMON_INITIAL_DISCOVER_MS', 2 * 60
108
113
  const DISCOVER_STALE_MS = envMs('FCD_DAEMON_DISCOVER_STALE_MS', 20 * 60 * 60_000);
109
114
  /** How often the daemon re-checks discovery freshness. */
110
115
  const DISCOVER_CHECK_INTERVAL_MS = envMs('FCD_DAEMON_DISCOVER_CHECK_MS', 60 * 60_000);
116
+ /** Boot catch-up sweep delay — well past login so it never competes with the
117
+ * developer's IDE/browser startup (the sweep also runs at idle priority). */
118
+ const DISCOVER_BOOT_CATCHUP_DELAY_MS = envMs('FCD_DAEMON_DISCOVER_BOOT_DELAY_MS', 15 * 60_000);
119
+ /** Random jitter added to the boot catch-up so a fleet doesn't sweep in lockstep. */
120
+ const DISCOVER_BOOT_CATCHUP_JITTER_MS = 5 * 60_000;
111
121
  /** Rotate the daemon log when it grows past this size. */
112
122
  const LOG_MAX_BYTES = 1_000_000;
113
123
  function daemonDir() {
@@ -225,6 +235,14 @@ function runDiscoverSweep(credentials, timeoutMs = 300_000) {
225
235
  // home so the posture scope reports a meaningful folder, not an OS dir.
226
236
  cwd: os.homedir(),
227
237
  });
238
+ // Idle CPU priority (IDLE_PRIORITY_CLASS on Windows, nice 19 on POSIX): a
239
+ // deep sweep must never compete with the developer's build/IDE for CPU.
240
+ // The sweep just takes longer on a busy machine — which is the point.
241
+ try {
242
+ if (child.pid)
243
+ os.setPriority(child.pid, 19);
244
+ }
245
+ catch { /* best-effort */ }
228
246
  let stderrTail = '';
229
247
  child.stderr?.on('data', (chunk) => {
230
248
  stderrTail = (stderrTail + chunk.toString('utf8')).slice(-4096);
@@ -616,10 +634,18 @@ async function runDaemon(args, config) {
616
634
  body: JSON.stringify({ shieldId: creds.shieldId, machineId: identity.machineId, ...payload }),
617
635
  signal: AbortSignal.timeout(15_000),
618
636
  });
637
+ if (!resp.ok) {
638
+ // Log the STATUS and the server's own words. Swallowing them turned a
639
+ // precise backend rejection into "network or backend rejection" in the
640
+ // console, which cost a Cloud Run log dig to diagnose — on the very
641
+ // feature whose job is to explain a machine remotely.
642
+ const body = await resp.text().catch(() => '');
643
+ log(`Diagnostics upload rejected: HTTP ${resp.status}${body ? ` — ${body.slice(0, 300).replace(/\s+/g, ' ')}` : ''}`);
644
+ }
619
645
  return resp.ok;
620
646
  }
621
- catch {
622
- log('Diagnostics upload failed (network) — the bundle stays available locally.');
647
+ catch (error) {
648
+ log(`Diagnostics upload failed (network: ${error.message}) — the bundle stays available locally.`);
623
649
  return false;
624
650
  }
625
651
  };
@@ -898,6 +924,31 @@ async function runDaemon(args, config) {
898
924
  log('Discovery scan: upload complete — dashboard discovery + posture timestamps will refresh.');
899
925
  resultSummary = 'Discovery + posture scan completed and uploaded.';
900
926
  }
927
+ else if (action.type === 'perf_snapshot') {
928
+ // On-demand performance proof: per-process footprint + hot-path
929
+ // micro-bench + disk state, collected in one shot (no resident
930
+ // profiler, no periodic sampling — the lightness principle applies
931
+ // to the measurement itself). Same collection as `doctor --perf`,
932
+ // so the console table matches what the customer sees locally.
933
+ log('Perf snapshot: measuring hot-path latency, process footprint, and disk state (~10s)…');
934
+ await uploadLogTail();
935
+ let bundleState = {};
936
+ try {
937
+ const identity = (0, machineIdentity_1.getMachineIdentity)();
938
+ const bundle = await (0, runtimeConfig_1.getRuntimeBundle)({
939
+ apiUrl: creds.apiUrl, shieldId: creds.shieldId || '', shieldKey: creds.shieldKey,
940
+ developerName: identity.developerName, machineName: identity.hostname, hotPath: true,
941
+ });
942
+ bundleState = { mode: bundle.mode, source: bundle.source, policyHash: bundle.policyHash };
943
+ }
944
+ catch { /* snapshot still useful without mode context */ }
945
+ const snapshot = (0, perfSnapshot_1.collectPerfSnapshot)({ cliVersion: cliVersion(), ...bundleState });
946
+ const uploaded = await uploadDiagnostics({ perfSnapshot: snapshot });
947
+ if (!uploaded)
948
+ throw new Error('Perf snapshot could not be uploaded (network or backend rejection).');
949
+ resultSummary = (0, perfSnapshot_1.summarizePerfSnapshot)(snapshot);
950
+ log(`Perf snapshot: ${resultSummary}`);
951
+ }
901
952
  await reportMachineAction(action.id, 'succeeded', { resultSummary });
902
953
  log(`Remote action succeeded: ${action.type}.`);
903
954
  }
@@ -938,6 +989,9 @@ async function runDaemon(args, config) {
938
989
  }
939
990
  catch { /* offline — next poll retries */ }
940
991
  };
992
+ // Base cadence for the bundle poll — the server can slow a whole fleet down
993
+ // via pollIntervalMs (bounded: 15s..1h) without shipping a new CLI.
994
+ let bundlePollBaseMs = BUNDLE_POLL_MS;
941
995
  const pollBundle = async () => {
942
996
  if (!creds.shieldId)
943
997
  return;
@@ -957,6 +1011,9 @@ async function runDaemon(args, config) {
957
1011
  machineId: identity.machineId,
958
1012
  force: true,
959
1013
  });
1014
+ if (typeof bundle.pollIntervalMs === 'number' && Number.isFinite(bundle.pollIntervalMs)) {
1015
+ bundlePollBaseMs = Math.min(Math.max(bundle.pollIntervalMs, 15_000), 60 * 60_000);
1016
+ }
960
1017
  if (bundle.suspended && !suspended) {
961
1018
  suspended = true;
962
1019
  log('Machine SUSPENDED by admin — hooks/gateway deny all actions; daemon pauses re-protection.');
@@ -1020,6 +1077,22 @@ async function runDaemon(args, config) {
1020
1077
  log(`Honeypot: removal failed: ${error.message}`);
1021
1078
  }
1022
1079
  }
1080
+ // Keep the IDE hooks' Local Safety snapshot warm. Hooks on monitor
1081
+ // machines read this cache with preferCached and never fetch — the
1082
+ // daemon is the only process paying the network cost, off the hot path.
1083
+ try {
1084
+ const hookIdentity = (0, localSafetySnapshot_1.hookSnapshotIdentity)();
1085
+ await (0, localSafetySnapshot_1.loadLocalSafetySnapshot)({
1086
+ apiUrl: creds.apiUrl,
1087
+ shieldId: creds.shieldId,
1088
+ shieldKey: creds.shieldKey,
1089
+ developerName: hookIdentity.developerName,
1090
+ machineName: hookIdentity.machineName,
1091
+ expectedPolicyHash: bundle.policyHash || bundle.version,
1092
+ timeoutMs: 8_000,
1093
+ });
1094
+ }
1095
+ catch { /* hooks fall back to their own bounded fetch */ }
1023
1096
  }
1024
1097
  catch { /* offline — cached stance applies */ }
1025
1098
  };
@@ -1115,6 +1188,20 @@ async function runDaemon(args, config) {
1115
1188
  if (desktopChatGuard)
1116
1189
  log('Claude Desktop chat guard: supervising (advisory — warns on secrets typed into Claude Desktop).');
1117
1190
  }
1191
+ // Verdict IPC server (resident-agent pattern): per-event hook processes
1192
+ // become thin clients that ask THIS process for the verdict over a named
1193
+ // pipe / Unix socket — everything (config, bundle, snapshot, policy engine)
1194
+ // is warm here, so verdicts resolve in milliseconds instead of repeating
1195
+ // cold-start work per IDE event. Config is re-read per request (small YAML,
1196
+ // cheap) so on-disk edits apply without a daemon restart. Hooks fall back
1197
+ // to full local evaluation whenever this server is down — never a blocker.
1198
+ let verdictServer;
1199
+ try {
1200
+ verdictServer = (0, verdictIpc_1.startVerdictServer)((hookArgs, stdin) => (0, hook_1.evaluateHookRequest)(hookArgs, stdin, (0, config_1.loadConfig)()), log);
1201
+ }
1202
+ catch (error) {
1203
+ log(`Verdict IPC: failed to start (${error.message}) — hooks will evaluate locally.`);
1204
+ }
1118
1205
  // Fresh machines have never uploaded an inventory (MSI/onboard defers the
1119
1206
  // initial discovery to keep setup fast), so the dashboard shows "Never" for
1120
1207
  // discovery + posture until the daily scheduled job fires — up to 24h later.
@@ -1178,16 +1265,51 @@ async function runDaemon(args, config) {
1178
1265
  await uploadLogTail();
1179
1266
  }
1180
1267
  };
1181
- // Give the machine a couple of minutes to settle after boot/wake before the
1182
- // first check same grace as the initial sweep.
1183
- const discoverCatchUpBootTimer = setTimeout(() => { void maybeCatchUpDiscovery('after start'); }, INITIAL_DISCOVER_DELAY_MS);
1268
+ // Boot deep sweeps were the "my PC is slow every morning" complaint: a
1269
+ // machine off overnight is always >20h stale, so the old 2-minute grace
1270
+ // launched a full deep sweep right while the developer opened their IDE and
1271
+ // browser. Defer the boot catch-up well past login (default 15 min) and add
1272
+ // jitter so a fleet behind one proxy doesn't sweep in lockstep. The hourly
1273
+ // re-check still guarantees same-day freshness.
1274
+ const discoverCatchUpBootTimer = setTimeout(() => { void maybeCatchUpDiscovery('after start'); }, DISCOVER_BOOT_CATCHUP_DELAY_MS + Math.floor(Math.random() * DISCOVER_BOOT_CATCHUP_JITTER_MS));
1184
1275
  const discoverCatchUpTimer = setInterval(() => { void maybeCatchUpDiscovery('hourly check'); }, DISCOVER_CHECK_INTERVAL_MS);
1185
1276
  const rescanTimer = setInterval(() => {
1186
1277
  const count = refreshWatchTargets();
1187
1278
  log(`Rescan: watching ${count} config file(s).`);
1188
1279
  }, RESCAN_INTERVAL_MS);
1189
- const bundleTimer = setInterval(() => { void pollBundle(); }, BUNDLE_POLL_MS);
1190
- const heartbeatTimer = setInterval(() => { void heartbeat(); }, HEARTBEAT_INTERVAL_MS);
1280
+ // Jittered self-rescheduling polls (±20%) instead of fixed setInterval:
1281
+ // a fleet enrolled behind one corporate proxy must not hit the gate in
1282
+ // lockstep, and one machine's timers must not stack into the same tick.
1283
+ // The bundle poll additionally honors the server's pollIntervalMs, and the
1284
+ // next tick is scheduled only AFTER the previous poll finishes — a slow
1285
+ // gate can never pile up overlapping polls.
1286
+ const jittered = (baseMs) => Math.max(5_000, Math.round(baseMs * (0.8 + Math.random() * 0.4)));
1287
+ let bundleTimer;
1288
+ const scheduleBundlePoll = () => {
1289
+ if (stopped)
1290
+ return;
1291
+ bundleTimer = setTimeout(async () => {
1292
+ try {
1293
+ await pollBundle();
1294
+ }
1295
+ catch { /* poll never throws, but never stop the loop */ }
1296
+ scheduleBundlePoll();
1297
+ }, jittered(bundlePollBaseMs));
1298
+ };
1299
+ scheduleBundlePoll();
1300
+ let heartbeatTimer;
1301
+ const scheduleHeartbeat = () => {
1302
+ if (stopped)
1303
+ return;
1304
+ heartbeatTimer = setTimeout(async () => {
1305
+ try {
1306
+ await heartbeat();
1307
+ }
1308
+ catch { /* keep the loop alive */ }
1309
+ scheduleHeartbeat();
1310
+ }, jittered(HEARTBEAT_INTERVAL_MS));
1311
+ };
1312
+ scheduleHeartbeat();
1191
1313
  // PowerShell transcript retention (Windows): the Transcription policy FCD
1192
1314
  // enables writes a file per session forever — prune anything older than the
1193
1315
  // retention window once a day (plus once shortly after boot, so laptops
@@ -1211,8 +1333,10 @@ async function runDaemon(args, config) {
1211
1333
  stopped = true;
1212
1334
  log(`Received ${signal} — shutting down.`);
1213
1335
  clearInterval(rescanTimer);
1214
- clearInterval(bundleTimer);
1215
- clearInterval(heartbeatTimer);
1336
+ if (bundleTimer)
1337
+ clearTimeout(bundleTimer);
1338
+ if (heartbeatTimer)
1339
+ clearTimeout(heartbeatTimer);
1216
1340
  clearTimeout(transcriptPruneBootTimer);
1217
1341
  clearInterval(transcriptPruneTimer);
1218
1342
  clearTimeout(discoverCatchUpBootTimer);
@@ -1223,6 +1347,8 @@ async function runDaemon(args, config) {
1223
1347
  clearTimeout(debounceTimer);
1224
1348
  if (desktopChatGuard)
1225
1349
  desktopChatGuard.stop();
1350
+ if (verdictServer)
1351
+ verdictServer.close();
1226
1352
  for (const watcher of watchers.values())
1227
1353
  watcher.close();
1228
1354
  for (const watcher of rootWatchers.values())
@@ -1437,36 +1563,6 @@ function windowsTaskReferencesScriptHost(taskName) {
1437
1563
  return false;
1438
1564
  }
1439
1565
  }
1440
- /** True when an existing task still runs with an InteractiveToken principal —
1441
- * the 1.22.1 window-flash bug (console window on every trigger). Migration
1442
- * trigger for the windowless S4U principal. */
1443
- function windowsTaskRunsInteractive(taskName) {
1444
- return windowsTaskXmlState(taskName).interactive;
1445
- }
1446
- /** True when triggering the task would open a VISIBLE console window:
1447
- * InteractiveToken principal AND a bare console action (not wrapped in
1448
- * `conhost --headless`). This — not the principal alone — is the flash
1449
- * condition; a conhost-wrapped InteractiveToken task is fully windowless. */
1450
- function windowsTaskRunsVisibly(taskName) {
1451
- const state = windowsTaskXmlState(taskName);
1452
- return state.interactive && !state.headless;
1453
- }
1454
- function windowsTaskXmlState(taskName) {
1455
- try {
1456
- const query = (0, child_process_1.spawnSync)('schtasks', ['/Query', '/TN', taskName, '/XML'], {
1457
- encoding: 'utf8', windowsHide: true, timeout: 10_000,
1458
- });
1459
- if (query.status !== 0 || typeof query.stdout !== 'string')
1460
- return { interactive: false, headless: false };
1461
- return {
1462
- interactive: /<LogonType>\s*InteractiveToken\s*<\/LogonType>/i.test(query.stdout),
1463
- headless: /conhost(\.exe)?<\/Command>[\s\S]*?--headless/i.test(query.stdout),
1464
- };
1465
- }
1466
- catch {
1467
- return { interactive: false, headless: false };
1468
- }
1469
- }
1470
1566
  /**
1471
1567
  * Upgrade an EXISTING (working) task to the windowless S4U principal. Tries
1472
1568
  * ONLY the S4U XML — on failure (unelevated daemon) the current task is left
@@ -1599,7 +1695,7 @@ function startDaemonNowWindows(viaTask = false) {
1599
1695
  }
1600
1696
  }
1601
1697
  catch { /* not running */ }
1602
- if (viaTask && !windowsTaskRunsVisibly(TASK_NAME)) {
1698
+ if (viaTask && !(0, windowsTaskState_1.windowsTaskRunsVisibly)(TASK_NAME)) {
1603
1699
  // Start through the scheduled task so the daemon runs with the task's
1604
1700
  // LIMITED (non-elevated) token. Launching directly from an elevated MSI
1605
1701
  // custom action would leave an elevated daemon that a normal-user CLI can
@@ -1709,12 +1805,12 @@ function ensureWindowsAutostartHealthy(logFn) {
1709
1805
  // registration always works and is just as windowless. The updater kick
1710
1806
  // stays as the final backstop for machines where even that failed.
1711
1807
  let migrationDenied = false;
1712
- if (daemonTask.status === 0 && !legacyDaemon && windowsTaskRunsVisibly(TASK_NAME)) {
1808
+ if (daemonTask.status === 0 && !legacyDaemon && (0, windowsTaskState_1.windowsTaskRunsVisibly)(TASK_NAME)) {
1713
1809
  const daemonXml = buildDaemonTaskXml();
1714
1810
  migrationDenied = !(upgradeTaskWindowless(TASK_NAME, daemonXml[0], logFn)
1715
1811
  || rewrapTaskHeadless(TASK_NAME, daemonXml[1], logFn)) || migrationDenied;
1716
1812
  }
1717
- if (watchdogQuery.status === 0 && !legacyWatchdog && windowsTaskRunsVisibly(WATCHDOG_TASK_NAME)) {
1813
+ if (watchdogQuery.status === 0 && !legacyWatchdog && (0, windowsTaskState_1.windowsTaskRunsVisibly)(WATCHDOG_TASK_NAME)) {
1718
1814
  const watchdogXml = buildWatchdogTaskXml();
1719
1815
  migrationDenied = !(upgradeTaskWindowless(WATCHDOG_TASK_NAME, watchdogXml[0], logFn)
1720
1816
  || rewrapTaskHeadless(WATCHDOG_TASK_NAME, watchdogXml[1], logFn)) || migrationDenied;
@@ -1815,6 +1911,7 @@ function installMacos() {
1815
1911
  </dict>
1816
1912
  <key>RunAtLoad</key><true/>
1817
1913
  <key>KeepAlive</key><true/>
1914
+ <key>ThrottleInterval</key><integer>30</integer>
1818
1915
  <key>StandardOutPath</key><string>${logFile()}</string>
1819
1916
  <key>StandardErrorPath</key><string>${logFile()}</string>
1820
1917
  </dict>
@@ -1873,7 +1970,11 @@ Description=FullCourtDefense resident daemon (config watch + heartbeat)
1873
1970
  [Service]
1874
1971
  ExecStart=${JSON.stringify(process.execPath)} ${JSON.stringify(cliEntry())} daemon
1875
1972
  Restart=always
1876
- RestartSec=10
1973
+ RestartSec=30
1974
+ # Crash-loop circuit breaker: a daemon that dies 8 times in 10 minutes stops
1975
+ # being restarted (no infinite 10s spawn storm eating CPU on a broken install).
1976
+ StartLimitIntervalSec=600
1977
+ StartLimitBurst=8
1877
1978
 
1878
1979
  [Install]
1879
1980
  WantedBy=default.target
@@ -100,10 +100,24 @@ const STATUS_PATH = path.join(os.homedir(), '.fullcourtdefense', 'claude-desktop
100
100
  const STDOUT_PREFIX = 'FCD:';
101
101
  /** Prefix for clipboard payloads (distinct from composer reads). */
102
102
  const CLIP_PREFIX = 'FCDCLIP:';
103
- /** Poll cadence for the composer read (ms). 800ms lost the race against a
104
- * fast paste+Enter (the composer clears before the next read); 250ms is still
105
- * negligible CPU for one MSAA tree walk. */
103
+ /** FALLBACK poll cadence while Claude Desktop is the FOREGROUND window (ms),
104
+ * used only when the event subscriptions could not initialize. 800ms lost
105
+ * the race against a fast paste+Enter (the composer clears before the next
106
+ * read); 250ms is needed only while the user can actually type. */
106
107
  const POLL_MS = 250;
108
+ /** Foreground safety-tick in EVENT mode (ms): clipboard/focus arrive as
109
+ * events, so this timeout only paces the best-effort MSAA composer read —
110
+ * the paste race is covered by WM_CLIPBOARDUPDATE, not by this cadence. */
111
+ const POLL_FOCUSED_EVT_MS = 1000;
112
+ /** FALLBACK poll cadence while Claude Desktop runs in the BACKGROUND (ms). No
113
+ * typing or pasting can reach the composer without focus, so the expensive
114
+ * MSAA tree walk is skipped — this loop only watches for focus returning.
115
+ * (Event mode blocks on the focus-change event instead.) */
116
+ const POLL_BG_MS = 1500;
117
+ /** Safety timeout while idle/background (ms) — in event mode the loop BLOCKS
118
+ * on the event signal for this long; in fallback mode it is the idle poll
119
+ * cadence (one cheap Get-Process per tick, nothing else). */
120
+ const POLL_IDLE_MS = 4000;
107
121
  /** Don't re-toast the same finding value more often than this. */
108
122
  const TOAST_DEBOUNCE_MS = 60_000;
109
123
  /** Refresh the cached Local Safety snapshot on this cadence. */
@@ -111,6 +125,11 @@ const SNAPSHOT_REFRESH_MS = 5 * 60_000;
111
125
  /** Watcher restart backoff bounds. */
112
126
  const RESTART_MIN_MS = 2_000;
113
127
  const RESTART_MAX_MS = 30_000;
128
+ /** Give up after this many restarts with NO healthy output in between — a
129
+ * watcher that can never start (powershell.exe blocked by EDR/GPO) must not
130
+ * spawn-loop forever; each spawn costs CPU and an EDR scan. The daemon's
131
+ * next full restart (or an admin repair_protection action) tries again. */
132
+ const RESTART_GIVE_UP_AFTER = 10;
114
133
  /** Only meaningful where Claude Desktop runs and MSAA is available. */
115
134
  function desktopChatGuardSupported() {
116
135
  return process.platform === 'win32' && (0, discoverPaths_1.claudeDesktopLikelyInstalled)();
@@ -242,19 +261,114 @@ function desktopChatWatcherScript() {
242
261
  "'@",
243
262
  '} catch { }',
244
263
  '',
264
+ // Event subscriptions (WM_CLIPBOARDUPDATE + EVENT_SYSTEM_FOREGROUND): the
265
+ // loop below BLOCKS on these instead of spinning at 250ms. A hidden
266
+ // message-only window on a background STA thread receives clipboard-change
267
+ // messages and a WinEvent hook fires on every foreground-window change —
268
+ // both set flags and pulse one AutoResetEvent the main loop waits on.
269
+ // If any of this fails to initialize (hardened hosts, odd session types),
270
+ // [FcdEvents]::Ok stays false and the loop falls back to the old polling.
271
+ 'try {',
272
+ " Add-Type -TypeDefinition @'",
273
+ 'using System;',
274
+ 'using System.Runtime.InteropServices;',
275
+ 'using System.Threading;',
276
+ 'public static class FcdEvents {',
277
+ ' const uint WM_CLIPBOARDUPDATE = 0x031D;',
278
+ ' const uint EVENT_SYSTEM_FOREGROUND = 0x0003;',
279
+ ' static readonly IntPtr HWND_MESSAGE = new IntPtr(-3);',
280
+ ' public static bool Ok = false;',
281
+ ' static AutoResetEvent signal = new AutoResetEvent(false);',
282
+ ' static int clipFlag = 0, fgFlag = 0;',
283
+ ' delegate IntPtr WndProc(IntPtr h, uint m, IntPtr w, IntPtr l);',
284
+ ' delegate void WinEventProc(IntPtr hook, uint ev, IntPtr hwnd, int obj, int child, uint tid, uint time);',
285
+ ' static WndProc wndProcRef; static WinEventProc fgProcRef; // keep delegates alive (GC)',
286
+ ' [StructLayout(LayoutKind.Sequential)] struct MSG { public IntPtr hwnd; public uint message; public IntPtr wParam; public IntPtr lParam; public uint time; public int ptX; public int ptY; }',
287
+ ' [StructLayout(LayoutKind.Sequential, CharSet=CharSet.Unicode)] struct WNDCLASS { public uint style; public WndProc lpfnWndProc; public int cbClsExtra; public int cbWndExtra; public IntPtr hInstance; public IntPtr hIcon; public IntPtr hCursor; public IntPtr hbrBackground; [MarshalAs(UnmanagedType.LPWStr)] public string lpszMenuName; [MarshalAs(UnmanagedType.LPWStr)] public string lpszClassName; }',
288
+ ' [DllImport("user32.dll", CharSet=CharSet.Unicode)] static extern ushort RegisterClassW(ref WNDCLASS wc);',
289
+ ' [DllImport("user32.dll", CharSet=CharSet.Unicode)] static extern IntPtr CreateWindowExW(int ex, string cls, string name, int style, int x, int y, int w, int h, IntPtr parent, IntPtr menu, IntPtr inst, IntPtr param);',
290
+ ' [DllImport("user32.dll")] static extern IntPtr DefWindowProcW(IntPtr h, uint m, IntPtr w, IntPtr l);',
291
+ ' [DllImport("user32.dll")] static extern bool AddClipboardFormatListener(IntPtr h);',
292
+ ' [DllImport("user32.dll")] static extern IntPtr SetWinEventHook(uint mn, uint mx, IntPtr mod, WinEventProc cb, uint pid, uint tid, uint flags);',
293
+ ' [DllImport("user32.dll")] static extern int GetMessageW(out MSG m, IntPtr h, uint mn, uint mx);',
294
+ ' [DllImport("user32.dll")] static extern bool TranslateMessage(ref MSG m);',
295
+ ' [DllImport("user32.dll")] static extern IntPtr DispatchMessageW(ref MSG m);',
296
+ ' [DllImport("kernel32.dll", CharSet=CharSet.Unicode)] static extern IntPtr GetModuleHandleW(string n);',
297
+ ' public static void Start() {',
298
+ ' var t = new Thread(Pump); t.IsBackground = true; t.SetApartmentState(ApartmentState.STA); t.Start();',
299
+ ' }',
300
+ ' static void Pump() {',
301
+ ' try {',
302
+ ' wndProcRef = HandleMsg;',
303
+ ' var wc = new WNDCLASS(); wc.lpfnWndProc = wndProcRef; wc.hInstance = GetModuleHandleW(null); wc.lpszClassName = "FcdDesktopGuardEvt";',
304
+ ' RegisterClassW(ref wc);',
305
+ ' IntPtr hwnd = CreateWindowExW(0, "FcdDesktopGuardEvt", "", 0, 0, 0, 0, 0, HWND_MESSAGE, IntPtr.Zero, wc.hInstance, IntPtr.Zero);',
306
+ ' if (hwnd == IntPtr.Zero) return;',
307
+ ' bool clipOk = AddClipboardFormatListener(hwnd);',
308
+ ' fgProcRef = HandleFg;',
309
+ ' IntPtr hook = SetWinEventHook(EVENT_SYSTEM_FOREGROUND, EVENT_SYSTEM_FOREGROUND, IntPtr.Zero, fgProcRef, 0, 0, 0);',
310
+ ' Ok = clipOk && hook != IntPtr.Zero;',
311
+ ' MSG m;',
312
+ ' while (GetMessageW(out m, IntPtr.Zero, 0, 0) > 0) { TranslateMessage(ref m); DispatchMessageW(ref m); }',
313
+ ' } catch { Ok = false; }',
314
+ ' }',
315
+ ' static IntPtr HandleMsg(IntPtr h, uint m, IntPtr w, IntPtr l) {',
316
+ ' if (m == WM_CLIPBOARDUPDATE) { Interlocked.Exchange(ref clipFlag, 1); signal.Set(); return IntPtr.Zero; }',
317
+ ' return DefWindowProcW(h, m, w, l);',
318
+ ' }',
319
+ ' static void HandleFg(IntPtr hook, uint ev, IntPtr hwnd, int obj, int child, uint tid, uint time) {',
320
+ ' Interlocked.Exchange(ref fgFlag, 1); signal.Set();',
321
+ ' }',
322
+ ' public static bool Wait(int ms) { try { return signal.WaitOne(ms); } catch { return false; } }',
323
+ ' public static bool TakeClip() { return Interlocked.Exchange(ref clipFlag, 0) == 1; }',
324
+ ' public static bool TakeFg() { return Interlocked.Exchange(ref fgFlag, 0) == 1; }',
325
+ '}',
326
+ "'@",
327
+ '} catch { }',
328
+ '',
329
+ '$evt = $false',
330
+ 'try { [FcdEvents]::Start(); Start-Sleep -Milliseconds 300; $evt = [FcdEvents]::Ok } catch { $evt = $false }',
331
+ // Observability: doctor/tests can confirm which mode the watcher runs in.
332
+ 'if ($env:FCD_DESKTOP_GUARD_DEBUG) { [Console]::Out.WriteLine("FCDEVT:" + $evt); [Console]::Out.Flush() }',
333
+ '',
245
334
  '$last = ""',
246
335
  '$lastClip = ""',
247
336
  '$lastWake = [DateTime]::MinValue',
337
+ '$lastComposer = [DateTime]::MinValue',
338
+ // EVENT-DRIVEN loop (when FcdEvents initialized): the loop BLOCKS on the
339
+ // clipboard/foreground event signal with a safety timeout — zero wakeups
340
+ // while nothing happens. Clipboard is read only when it actually changed
341
+ // (or focus just moved, covering copy-elsewhere-then-paste-into-Claude);
342
+ // the MSAA composer read relaxes to ~1s because the clipboard EVENT now
343
+ // wins the paste+Enter race the old 250ms poll existed for.
344
+ // FALLBACK (events unavailable): the original adaptive polling — 250ms
345
+ // focused / 1500ms background / 4000ms idle.
346
+ '$sleepMs = ' + POLL_IDLE_MS,
248
347
  'while ($true) {',
249
- ' Start-Sleep -Milliseconds ' + POLL_MS,
348
+ ' $clipEvt = $true',
349
+ ' $fgEvt = $true',
350
+ ' if ($evt) {',
351
+ ' [void][FcdEvents]::Wait($sleepMs)',
352
+ ' $clipEvt = [FcdEvents]::TakeClip()',
353
+ ' $fgEvt = [FcdEvents]::TakeFg()',
354
+ ' } else {',
355
+ ' Start-Sleep -Milliseconds $sleepMs',
356
+ ' }',
250
357
  ' try {',
251
358
  ' $pids = New-Object \'System.Collections.Generic.HashSet[uint32]\'',
252
359
  " Get-Process -Name 'Claude' -ErrorAction SilentlyContinue | ForEach-Object { [void]$pids.Add([uint32]$_.Id) }",
253
- ' if ($pids.Count -eq 0) { continue }',
360
+ ' if ($pids.Count -eq 0) { $sleepMs = ' + POLL_IDLE_MS + '; continue }',
361
+ ' if (-not [FcdMsaa]::ClaudeIsForeground($pids)) {',
362
+ ' if ($evt) { $sleepMs = ' + POLL_IDLE_MS + ' } else { $sleepMs = ' + POLL_BG_MS + ' }',
363
+ ' continue',
364
+ ' }',
365
+ ' if ($evt) { $sleepMs = ' + POLL_FOCUSED_EVT_MS + ' } else { $sleepMs = ' + POLL_MS + ' }',
254
366
  // Clipboard paste guard: only while Claude Desktop is the foreground app, so
255
367
  // a copy staged for any OTHER application is never inspected or reported.
256
368
  // The deterministic engine (parent process) decides if the text is a secret.
257
- ' if ([FcdMsaa]::ClaudeIsForeground($pids)) {',
369
+ // Event mode reads it only when the clipboard changed or focus just landed
370
+ // on Claude — not on every tick.
371
+ ' if ($clipEvt -or $fgEvt) {',
258
372
  ' $clip = ""',
259
373
  ' try { $clip = Get-Clipboard -Raw -ErrorAction SilentlyContinue } catch { }',
260
374
  ' if (-not [string]::IsNullOrEmpty($clip) -and $clip.Length -le 8000 -and $clip -ne $lastClip) {',
@@ -264,6 +378,10 @@ function desktopChatWatcherScript() {
264
378
  ' [Console]::Out.Flush()',
265
379
  ' }',
266
380
  ' }',
381
+ // Composer read throttle: event wakes can arrive in bursts (every copy on
382
+ // the machine); the MSAA tree walk stays on its own ~1s cadence.
383
+ ' if ($evt -and ([DateTime]::UtcNow - $lastComposer).TotalMilliseconds -lt 900) { continue }',
384
+ ' $lastComposer = [DateTime]::UtcNow',
267
385
  ' $main = [FcdMsaa]::FindMain($pids)',
268
386
  ' if ($main -eq [IntPtr]::Zero) { continue }',
269
387
  ' if (([DateTime]::UtcNow - $lastWake).TotalSeconds -ge 30) { [FcdMsaa]::Wake($main); $lastWake = [DateTime]::UtcNow }',
@@ -326,6 +444,7 @@ function startDesktopChatGuard(runtime) {
326
444
  let child;
327
445
  let restartTimer;
328
446
  let restartDelay = RESTART_MIN_MS;
447
+ let consecutiveRestarts = 0;
329
448
  let findings = 0;
330
449
  const lastToastAt = new Map();
331
450
  let scanOptions = (0, localSafetySnapshot_1.snapshotToScanOptions)(undefined);
@@ -424,6 +543,7 @@ function startDesktopChatGuard(runtime) {
424
543
  if (decoded === undefined)
425
544
  return;
426
545
  restartDelay = RESTART_MIN_MS; // healthy output resets backoff
546
+ consecutiveRestarts = 0; // …and the give-up counter
427
547
  handleText(decoded.text, decoded.source);
428
548
  });
429
549
  child.on('exit', () => { rl.close(); if (!stopped)
@@ -436,6 +556,11 @@ function startDesktopChatGuard(runtime) {
436
556
  const scheduleRestart = () => {
437
557
  if (stopped || restartTimer)
438
558
  return;
559
+ consecutiveRestarts += 1;
560
+ if (consecutiveRestarts > RESTART_GIVE_UP_AFTER) {
561
+ runtime.log(`Claude Desktop chat guard: watcher failed ${RESTART_GIVE_UP_AFTER} consecutive starts — giving up until the daemon restarts (powershell.exe may be blocked by EDR/policy).`);
562
+ return;
563
+ }
439
564
  restartTimer = setTimeout(() => {
440
565
  restartTimer = undefined;
441
566
  spawnWatcher();
@@ -613,15 +613,27 @@ function credentialCommandReason(value) {
613
613
  return undefined;
614
614
  });
615
615
  }
616
+ // Container-scoped execution context immediately before an `rm` hit: a
617
+ // docker/podman/nerdctl/kubectl exec|run (incl. `compose exec/run`) earlier in
618
+ // the SAME statement (no ; & | between). Inside that context a bare `rm -rf *`
619
+ // wipes the CONTAINER's workdir — routine build/image cleanup on a
620
+ // docker-heavy fleet — not the developer's machine, so it must never warn or
621
+ // stall. Root targets (`/`, `/*`) stay blocked even inside containers (the
622
+ // Patria case: an agent nuking a running container's filesystem).
623
+ const CONTAINER_EXEC_BEFORE = /\b(?:docker|podman|nerdctl|kubectl)(?:\.exe)?\s+(?:compose\s+)?(?:exec|run)\b[^;&|]*$/i;
616
624
  function destructiveCommandReason(value) {
617
625
  return matchCommand(value, (text, lower) => {
618
- // Flag cluster is order-independent (`-rf`, `-fr`, `-rfv`), and the target
619
- // may be the root `/`, the root wildcard `/*`, or a bare `*`. Terminators
626
+ // Flag cluster is order-independent (`-rf`, `-fr`, `-rfv`). Terminators
620
627
  // include `)` so command substitution — `$(rm -rf /)` runs BEFORE the
621
- // outer command — is caught too.
622
- if (/\brm\s+-(?=[a-z]*r)(?=[a-z]*f)[a-z]+\s+(?:["']?\/\*?["']?|\*)(?:\s|$|[;&|)])/.test(lower))
628
+ // outer command — is caught too. Path-aware: only the root `/`, the root
629
+ // wildcard `/*`, and a bare `*` are treated as destructive — scoped paths
630
+ // (`/tmp/...`, `/var/cache/...`, `./build`) are everyday cleanup.
631
+ if (/\b(?:sudo\s+)?rm\s+-(?=[a-z]*r)(?=[a-z]*f)[a-z]+\s+["']?\/\*?["']?(?:\s|$|[;&|)])/.test(lower))
623
632
  return { itemId: 'rm_rf_root', reason: 'recursive force delete of filesystem root' };
624
- if (/\bsudo\s+rm\s+-(?=[a-z]*r)(?=[a-z]*f)[a-z]+\s+(?:["']?\/\*?["']?|\*)(?:\s|$|[;&|)])/.test(lower))
633
+ // Bare `*` deletes the current directory tree — destructive on the HOST,
634
+ // but inside a container exec/run it is the container workdir (cleanup).
635
+ const bareStar = /\b(?:sudo\s+)?rm\s+-(?=[a-z]*r)(?=[a-z]*f)[a-z]+\s+\*(?:\s|$|[;&|)])/.exec(lower);
636
+ if (bareStar && !CONTAINER_EXEC_BEFORE.test(lower.slice(0, bareStar.index)))
625
637
  return { itemId: 'rm_rf_root', reason: 'recursive force delete of filesystem root' };
626
638
  // Windows recursive quiet drive delete via del/erase/rd/rmdir. Flag order
627
639
  // is independent (`/s /q` and `/q /s` both wipe), and the dequoted variant