fullcourtdefense-cli 1.25.7 → 1.26.0

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);}`,
@@ -76,9 +76,12 @@ const discoveryMarker_1 = require("../discoveryMarker");
76
76
  const selfUpdate_1 = require("../selfUpdate");
77
77
  const cmdGuard_1 = require("./cmdGuard");
78
78
  const machineActionVerify_1 = require("../machineActionVerify");
79
+ const perfSnapshot_1 = require("../perfSnapshot");
79
80
  const desktopChatGuard_1 = require("./desktopChatGuard");
80
81
  const honeypot_1 = require("../honeypot");
81
82
  const windowsAudit_1 = require("./windowsAudit");
83
+ const hook_1 = require("./hook");
84
+ const verdictIpc_1 = require("../verdictIpc");
82
85
  const COLOR = {
83
86
  reset: '\x1b[0m', bold: '\x1b[1m', dim: '\x1b[2m',
84
87
  red: '\x1b[31m', yellow: '\x1b[33m', green: '\x1b[32m', cyan: '\x1b[36m', gray: '\x1b[90m',
@@ -108,6 +111,11 @@ const INITIAL_DISCOVER_DELAY_MS = envMs('FCD_DAEMON_INITIAL_DISCOVER_MS', 2 * 60
108
111
  const DISCOVER_STALE_MS = envMs('FCD_DAEMON_DISCOVER_STALE_MS', 20 * 60 * 60_000);
109
112
  /** How often the daemon re-checks discovery freshness. */
110
113
  const DISCOVER_CHECK_INTERVAL_MS = envMs('FCD_DAEMON_DISCOVER_CHECK_MS', 60 * 60_000);
114
+ /** Boot catch-up sweep delay — well past login so it never competes with the
115
+ * developer's IDE/browser startup (the sweep also runs at idle priority). */
116
+ const DISCOVER_BOOT_CATCHUP_DELAY_MS = envMs('FCD_DAEMON_DISCOVER_BOOT_DELAY_MS', 15 * 60_000);
117
+ /** Random jitter added to the boot catch-up so a fleet doesn't sweep in lockstep. */
118
+ const DISCOVER_BOOT_CATCHUP_JITTER_MS = 5 * 60_000;
111
119
  /** Rotate the daemon log when it grows past this size. */
112
120
  const LOG_MAX_BYTES = 1_000_000;
113
121
  function daemonDir() {
@@ -225,6 +233,14 @@ function runDiscoverSweep(credentials, timeoutMs = 300_000) {
225
233
  // home so the posture scope reports a meaningful folder, not an OS dir.
226
234
  cwd: os.homedir(),
227
235
  });
236
+ // Idle CPU priority (IDLE_PRIORITY_CLASS on Windows, nice 19 on POSIX): a
237
+ // deep sweep must never compete with the developer's build/IDE for CPU.
238
+ // The sweep just takes longer on a busy machine — which is the point.
239
+ try {
240
+ if (child.pid)
241
+ os.setPriority(child.pid, 19);
242
+ }
243
+ catch { /* best-effort */ }
228
244
  let stderrTail = '';
229
245
  child.stderr?.on('data', (chunk) => {
230
246
  stderrTail = (stderrTail + chunk.toString('utf8')).slice(-4096);
@@ -898,6 +914,31 @@ async function runDaemon(args, config) {
898
914
  log('Discovery scan: upload complete — dashboard discovery + posture timestamps will refresh.');
899
915
  resultSummary = 'Discovery + posture scan completed and uploaded.';
900
916
  }
917
+ else if (action.type === 'perf_snapshot') {
918
+ // On-demand performance proof: per-process footprint + hot-path
919
+ // micro-bench + disk state, collected in one shot (no resident
920
+ // profiler, no periodic sampling — the lightness principle applies
921
+ // to the measurement itself). Same collection as `doctor --perf`,
922
+ // so the console table matches what the customer sees locally.
923
+ log('Perf snapshot: measuring hot-path latency, process footprint, and disk state (~10s)…');
924
+ await uploadLogTail();
925
+ let bundleState = {};
926
+ try {
927
+ const identity = (0, machineIdentity_1.getMachineIdentity)();
928
+ const bundle = await (0, runtimeConfig_1.getRuntimeBundle)({
929
+ apiUrl: creds.apiUrl, shieldId: creds.shieldId || '', shieldKey: creds.shieldKey,
930
+ developerName: identity.developerName, machineName: identity.hostname, hotPath: true,
931
+ });
932
+ bundleState = { mode: bundle.mode, source: bundle.source, policyHash: bundle.policyHash };
933
+ }
934
+ catch { /* snapshot still useful without mode context */ }
935
+ const snapshot = (0, perfSnapshot_1.collectPerfSnapshot)({ cliVersion: cliVersion(), ...bundleState });
936
+ const uploaded = await uploadDiagnostics({ perfSnapshot: snapshot });
937
+ if (!uploaded)
938
+ throw new Error('Perf snapshot could not be uploaded (network or backend rejection).');
939
+ resultSummary = (0, perfSnapshot_1.summarizePerfSnapshot)(snapshot);
940
+ log(`Perf snapshot: ${resultSummary}`);
941
+ }
901
942
  await reportMachineAction(action.id, 'succeeded', { resultSummary });
902
943
  log(`Remote action succeeded: ${action.type}.`);
903
944
  }
@@ -938,6 +979,9 @@ async function runDaemon(args, config) {
938
979
  }
939
980
  catch { /* offline — next poll retries */ }
940
981
  };
982
+ // Base cadence for the bundle poll — the server can slow a whole fleet down
983
+ // via pollIntervalMs (bounded: 15s..1h) without shipping a new CLI.
984
+ let bundlePollBaseMs = BUNDLE_POLL_MS;
941
985
  const pollBundle = async () => {
942
986
  if (!creds.shieldId)
943
987
  return;
@@ -957,6 +1001,9 @@ async function runDaemon(args, config) {
957
1001
  machineId: identity.machineId,
958
1002
  force: true,
959
1003
  });
1004
+ if (typeof bundle.pollIntervalMs === 'number' && Number.isFinite(bundle.pollIntervalMs)) {
1005
+ bundlePollBaseMs = Math.min(Math.max(bundle.pollIntervalMs, 15_000), 60 * 60_000);
1006
+ }
960
1007
  if (bundle.suspended && !suspended) {
961
1008
  suspended = true;
962
1009
  log('Machine SUSPENDED by admin — hooks/gateway deny all actions; daemon pauses re-protection.');
@@ -1020,6 +1067,22 @@ async function runDaemon(args, config) {
1020
1067
  log(`Honeypot: removal failed: ${error.message}`);
1021
1068
  }
1022
1069
  }
1070
+ // Keep the IDE hooks' Local Safety snapshot warm. Hooks on monitor
1071
+ // machines read this cache with preferCached and never fetch — the
1072
+ // daemon is the only process paying the network cost, off the hot path.
1073
+ try {
1074
+ const hookIdentity = (0, localSafetySnapshot_1.hookSnapshotIdentity)();
1075
+ await (0, localSafetySnapshot_1.loadLocalSafetySnapshot)({
1076
+ apiUrl: creds.apiUrl,
1077
+ shieldId: creds.shieldId,
1078
+ shieldKey: creds.shieldKey,
1079
+ developerName: hookIdentity.developerName,
1080
+ machineName: hookIdentity.machineName,
1081
+ expectedPolicyHash: bundle.policyHash || bundle.version,
1082
+ timeoutMs: 8_000,
1083
+ });
1084
+ }
1085
+ catch { /* hooks fall back to their own bounded fetch */ }
1023
1086
  }
1024
1087
  catch { /* offline — cached stance applies */ }
1025
1088
  };
@@ -1115,6 +1178,20 @@ async function runDaemon(args, config) {
1115
1178
  if (desktopChatGuard)
1116
1179
  log('Claude Desktop chat guard: supervising (advisory — warns on secrets typed into Claude Desktop).');
1117
1180
  }
1181
+ // Verdict IPC server (resident-agent pattern): per-event hook processes
1182
+ // become thin clients that ask THIS process for the verdict over a named
1183
+ // pipe / Unix socket — everything (config, bundle, snapshot, policy engine)
1184
+ // is warm here, so verdicts resolve in milliseconds instead of repeating
1185
+ // cold-start work per IDE event. Config is re-read per request (small YAML,
1186
+ // cheap) so on-disk edits apply without a daemon restart. Hooks fall back
1187
+ // to full local evaluation whenever this server is down — never a blocker.
1188
+ let verdictServer;
1189
+ try {
1190
+ verdictServer = (0, verdictIpc_1.startVerdictServer)((hookArgs, stdin) => (0, hook_1.evaluateHookRequest)(hookArgs, stdin, (0, config_1.loadConfig)()), log);
1191
+ }
1192
+ catch (error) {
1193
+ log(`Verdict IPC: failed to start (${error.message}) — hooks will evaluate locally.`);
1194
+ }
1118
1195
  // Fresh machines have never uploaded an inventory (MSI/onboard defers the
1119
1196
  // initial discovery to keep setup fast), so the dashboard shows "Never" for
1120
1197
  // discovery + posture until the daily scheduled job fires — up to 24h later.
@@ -1178,16 +1255,51 @@ async function runDaemon(args, config) {
1178
1255
  await uploadLogTail();
1179
1256
  }
1180
1257
  };
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);
1258
+ // Boot deep sweeps were the "my PC is slow every morning" complaint: a
1259
+ // machine off overnight is always >20h stale, so the old 2-minute grace
1260
+ // launched a full deep sweep right while the developer opened their IDE and
1261
+ // browser. Defer the boot catch-up well past login (default 15 min) and add
1262
+ // jitter so a fleet behind one proxy doesn't sweep in lockstep. The hourly
1263
+ // re-check still guarantees same-day freshness.
1264
+ const discoverCatchUpBootTimer = setTimeout(() => { void maybeCatchUpDiscovery('after start'); }, DISCOVER_BOOT_CATCHUP_DELAY_MS + Math.floor(Math.random() * DISCOVER_BOOT_CATCHUP_JITTER_MS));
1184
1265
  const discoverCatchUpTimer = setInterval(() => { void maybeCatchUpDiscovery('hourly check'); }, DISCOVER_CHECK_INTERVAL_MS);
1185
1266
  const rescanTimer = setInterval(() => {
1186
1267
  const count = refreshWatchTargets();
1187
1268
  log(`Rescan: watching ${count} config file(s).`);
1188
1269
  }, RESCAN_INTERVAL_MS);
1189
- const bundleTimer = setInterval(() => { void pollBundle(); }, BUNDLE_POLL_MS);
1190
- const heartbeatTimer = setInterval(() => { void heartbeat(); }, HEARTBEAT_INTERVAL_MS);
1270
+ // Jittered self-rescheduling polls (±20%) instead of fixed setInterval:
1271
+ // a fleet enrolled behind one corporate proxy must not hit the gate in
1272
+ // lockstep, and one machine's timers must not stack into the same tick.
1273
+ // The bundle poll additionally honors the server's pollIntervalMs, and the
1274
+ // next tick is scheduled only AFTER the previous poll finishes — a slow
1275
+ // gate can never pile up overlapping polls.
1276
+ const jittered = (baseMs) => Math.max(5_000, Math.round(baseMs * (0.8 + Math.random() * 0.4)));
1277
+ let bundleTimer;
1278
+ const scheduleBundlePoll = () => {
1279
+ if (stopped)
1280
+ return;
1281
+ bundleTimer = setTimeout(async () => {
1282
+ try {
1283
+ await pollBundle();
1284
+ }
1285
+ catch { /* poll never throws, but never stop the loop */ }
1286
+ scheduleBundlePoll();
1287
+ }, jittered(bundlePollBaseMs));
1288
+ };
1289
+ scheduleBundlePoll();
1290
+ let heartbeatTimer;
1291
+ const scheduleHeartbeat = () => {
1292
+ if (stopped)
1293
+ return;
1294
+ heartbeatTimer = setTimeout(async () => {
1295
+ try {
1296
+ await heartbeat();
1297
+ }
1298
+ catch { /* keep the loop alive */ }
1299
+ scheduleHeartbeat();
1300
+ }, jittered(HEARTBEAT_INTERVAL_MS));
1301
+ };
1302
+ scheduleHeartbeat();
1191
1303
  // PowerShell transcript retention (Windows): the Transcription policy FCD
1192
1304
  // enables writes a file per session forever — prune anything older than the
1193
1305
  // retention window once a day (plus once shortly after boot, so laptops
@@ -1211,8 +1323,10 @@ async function runDaemon(args, config) {
1211
1323
  stopped = true;
1212
1324
  log(`Received ${signal} — shutting down.`);
1213
1325
  clearInterval(rescanTimer);
1214
- clearInterval(bundleTimer);
1215
- clearInterval(heartbeatTimer);
1326
+ if (bundleTimer)
1327
+ clearTimeout(bundleTimer);
1328
+ if (heartbeatTimer)
1329
+ clearTimeout(heartbeatTimer);
1216
1330
  clearTimeout(transcriptPruneBootTimer);
1217
1331
  clearInterval(transcriptPruneTimer);
1218
1332
  clearTimeout(discoverCatchUpBootTimer);
@@ -1223,6 +1337,8 @@ async function runDaemon(args, config) {
1223
1337
  clearTimeout(debounceTimer);
1224
1338
  if (desktopChatGuard)
1225
1339
  desktopChatGuard.stop();
1340
+ if (verdictServer)
1341
+ verdictServer.close();
1226
1342
  for (const watcher of watchers.values())
1227
1343
  watcher.close();
1228
1344
  for (const watcher of rootWatchers.values())
@@ -1815,6 +1931,7 @@ function installMacos() {
1815
1931
  </dict>
1816
1932
  <key>RunAtLoad</key><true/>
1817
1933
  <key>KeepAlive</key><true/>
1934
+ <key>ThrottleInterval</key><integer>30</integer>
1818
1935
  <key>StandardOutPath</key><string>${logFile()}</string>
1819
1936
  <key>StandardErrorPath</key><string>${logFile()}</string>
1820
1937
  </dict>
@@ -1873,7 +1990,11 @@ Description=FullCourtDefense resident daemon (config watch + heartbeat)
1873
1990
  [Service]
1874
1991
  ExecStart=${JSON.stringify(process.execPath)} ${JSON.stringify(cliEntry())} daemon
1875
1992
  Restart=always
1876
- RestartSec=10
1993
+ RestartSec=30
1994
+ # Crash-loop circuit breaker: a daemon that dies 8 times in 10 minutes stops
1995
+ # being restarted (no infinite 10s spawn storm eating CPU on a broken install).
1996
+ StartLimitIntervalSec=600
1997
+ StartLimitBurst=8
1877
1998
 
1878
1999
  [Install]
1879
2000
  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
@@ -1,5 +1,7 @@
1
1
  import { BotGuardConfig } from '../config';
2
2
  export interface DoctorArgs {
3
3
  apiUrl?: string;
4
+ /** `doctor --perf` — measure this machine's real per-event overhead. */
5
+ perf?: string;
4
6
  }
5
7
  export declare function doctorCommand(args: DoctorArgs, config: BotGuardConfig): Promise<void>;