fullcourtdefense-cli 1.18.12 → 1.20.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.
@@ -51,6 +51,7 @@ const integrity_1 = require("../integrity");
51
51
  const machineIdentity_1 = require("../machineIdentity");
52
52
  const discoveryMarker_1 = require("../discoveryMarker");
53
53
  const selfUpdate_1 = require("../selfUpdate");
54
+ const desktopChatGuard_1 = require("./desktopChatGuard");
54
55
  const COLOR = {
55
56
  reset: '\x1b[0m', bold: '\x1b[1m', dim: '\x1b[2m',
56
57
  red: '\x1b[31m', yellow: '\x1b[33m', green: '\x1b[32m', cyan: '\x1b[36m', gray: '\x1b[90m',
@@ -720,6 +721,7 @@ async function runDaemon(args, config) {
720
721
  integrityOk: integrity.ok,
721
722
  integrityReasons: integrity.reasons,
722
723
  integrityCheckedAt: integrity.checkedAt,
724
+ desktopChatGuard: (0, desktopChatGuard_1.desktopChatGuardSupported)() && (0, desktopChatGuard_1.desktopChatGuardHealthy)(),
723
725
  });
724
726
  if (result && result.accepted > 0)
725
727
  log(`Heartbeat: flushed ${result.accepted} spooled event(s).`);
@@ -746,6 +748,25 @@ async function runDaemon(args, config) {
746
748
  // for a condition the very next line repairs.
747
749
  await reprotect(['startup pass']);
748
750
  await heartbeat();
751
+ // Claude Desktop chat guard (Windows, advisory): Claude Desktop's regular
752
+ // chat has no hook and never hits an MCP server, so it is the one machine
753
+ // surface neither hooks nor the gateway can see. Supervise the advisory guard
754
+ // in-process here (auto-restart is built into the guard itself).
755
+ let desktopChatGuard;
756
+ if (creds.shieldId && (0, desktopChatGuard_1.desktopChatGuardSupported)()) {
757
+ desktopChatGuard = (0, desktopChatGuard_1.startDesktopChatGuard)({
758
+ apiUrl: creds.apiUrl,
759
+ shieldId: creds.shieldId,
760
+ shieldKey: creds.shieldKey,
761
+ // Mode is resolved inside the guard: FCD_DESKTOP_CHAT_MODE env or the
762
+ // Local Safety snapshot's desktopChatMode hint; defaults to advisory warn.
763
+ mode: process.env.FCD_DESKTOP_CHAT_MODE,
764
+ quiet,
765
+ log,
766
+ });
767
+ if (desktopChatGuard)
768
+ log('Claude Desktop chat guard: supervising (warns, or blocks the Enter send if block mode is enabled, on secrets typed into Claude Desktop).');
769
+ }
749
770
  // Fresh machines have never uploaded an inventory (MSI/onboard defers the
750
771
  // initial discovery to keep setup fast), so the dashboard shows "Never" for
751
772
  // discovery + posture until the daily scheduled job fires — up to 24h later.
@@ -833,6 +854,8 @@ async function runDaemon(args, config) {
833
854
  clearTimeout(initialDiscoverTimer);
834
855
  if (debounceTimer)
835
856
  clearTimeout(debounceTimer);
857
+ if (desktopChatGuard)
858
+ desktopChatGuard.stop();
836
859
  for (const watcher of watchers.values())
837
860
  watcher.close();
838
861
  for (const watcher of rootWatchers.values())
@@ -0,0 +1,89 @@
1
+ import { BotGuardConfig } from '../config';
2
+ import { LocalSafetySnapshot } from '../localSafetySnapshot';
3
+ /**
4
+ * Claude Desktop chat guard (Windows).
5
+ *
6
+ * Claude Desktop's regular chat sends prompts straight to Anthropic's API — it
7
+ * exposes no hook and never touches an MCP server, so neither the Claude-format
8
+ * hooks nor the MCP gateway can see what a developer types there. This guard
9
+ * closes that blind spot with the SAME on-device deterministic engine used
10
+ * everywhere else (scanDeterministicPrompt).
11
+ *
12
+ * Two modes:
13
+ * - warn (default): advisory. A PowerShell UI Automation watcher reads the
14
+ * focused Claude input, this process scans it locally, and on a finding it
15
+ * toasts + reports a monitor event. No keyboard hook, no input mutation —
16
+ * cannot break typing, no keylogger surface.
17
+ * - block (org-policy opt-in): everything warn does, PLUS the watcher installs
18
+ * a low-level keyboard hook that swallows the Enter "send" while a secret is
19
+ * in the box, so the prompt cannot be sent to Anthropic.
20
+ *
21
+ * Safety model (why this cannot freeze/crash a machine):
22
+ * 1. ALL native code (the keyboard hook + its message pump) runs in the CHILD
23
+ * PowerShell process. The Node daemon only does a fast regex scan and a
24
+ * tiny file write; it is never in the keystroke hot path.
25
+ * 2. The hook callback does only a volatile-timestamp read + a cheap
26
+ * foreground check, returning in microseconds — far under Windows'
27
+ * LowLevelHooksTimeout. A slow LL hook is silently removed by Windows
28
+ * (keys pass through); it FAILS OPEN, it never freezes the keyboard.
29
+ * 3. The block is armed as a short time WINDOW (auto-expires ~2s after the
30
+ * last detection). If the daemon dies, stops arming, or the child is
31
+ * killed, Enter is released within ~2s — the keyboard can never get stuck.
32
+ * 4. block mode is OFF by default; the hook is only installed when an org
33
+ * opts in, so the default fleet behavior is the risk-free advisory mode.
34
+ * 5. Detection stays 100% in the shared Node deterministicGuard (single
35
+ * source of truth); the child never re-implements detection.
36
+ *
37
+ * Text is scanned in-process and never uploaded — only finding metadata (item
38
+ * id + masked value) is spooled.
39
+ */
40
+ export type DesktopChatMode = 'warn' | 'block';
41
+ export interface DesktopChatGuardArgs {
42
+ apiUrl?: string;
43
+ shieldId?: string;
44
+ shieldKey?: string;
45
+ /** 'warn' (default) or 'block'. */
46
+ mode?: string;
47
+ /** Suppress OS toasts (still spools findings). */
48
+ quiet?: string;
49
+ }
50
+ /** Only meaningful where Claude Desktop runs and UI Automation is available. */
51
+ export declare function desktopChatGuardSupported(): boolean;
52
+ /** Resolve the effective mode: explicit arg > env > snapshot hint > 'warn'. */
53
+ export declare function resolveDesktopChatMode(explicit?: string, snapshot?: LocalSafetySnapshot): DesktopChatMode;
54
+ /** True when the guard reported itself healthy within the last `withinMs`. */
55
+ export declare function desktopChatGuardHealthy(withinMs?: number): boolean;
56
+ /**
57
+ * The PowerShell watcher. Reads ONLY the focused element of the foreground
58
+ * Claude process (never the transcript), so it is low-noise and resilient to
59
+ * Claude UI changes. Emits `FCD:<base64 utf8>` lines on stdout when the text
60
+ * changes. In block mode it also compiles the keyboard-hook helper and, each
61
+ * cycle, reads the arm file Node maintains to arm/expire the Enter block.
62
+ * Everything is wrapped in try/catch — it must never crash the host.
63
+ */
64
+ export declare function desktopChatWatcherScript(mode?: DesktopChatMode): string;
65
+ /** Decode one `FCD:<base64>` watcher line into UTF-8 text (undefined if not ours). */
66
+ export declare function decodeWatcherLine(line: string): string | undefined;
67
+ export interface DesktopChatGuardHandle {
68
+ stop(): void;
69
+ }
70
+ interface GuardRuntime {
71
+ apiUrl: string;
72
+ shieldId?: string;
73
+ shieldKey?: string;
74
+ mode?: string;
75
+ quiet: boolean;
76
+ log: (msg: string) => void;
77
+ }
78
+ /**
79
+ * Start the guard. Returns a handle whose stop() tears down the watcher and
80
+ * timers. Safe no-op (returns undefined) on unsupported platforms.
81
+ */
82
+ export declare function startDesktopChatGuard(runtime: GuardRuntime): DesktopChatGuardHandle | undefined;
83
+ /**
84
+ * Foreground `desktop-chat-guard` command — runs the guard in this process
85
+ * until interrupted. Useful for manual testing (e.g. `--mode block`); in
86
+ * production the daemon supervises the guard in-process.
87
+ */
88
+ export declare function desktopChatGuardCommand(args: DesktopChatGuardArgs, config: BotGuardConfig): Promise<void>;
89
+ export {};
@@ -0,0 +1,479 @@
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.desktopChatGuardSupported = desktopChatGuardSupported;
37
+ exports.resolveDesktopChatMode = resolveDesktopChatMode;
38
+ exports.desktopChatGuardHealthy = desktopChatGuardHealthy;
39
+ exports.desktopChatWatcherScript = desktopChatWatcherScript;
40
+ exports.decodeWatcherLine = decodeWatcherLine;
41
+ exports.startDesktopChatGuard = startDesktopChatGuard;
42
+ exports.desktopChatGuardCommand = desktopChatGuardCommand;
43
+ const child_process_1 = require("child_process");
44
+ const fs = __importStar(require("fs"));
45
+ const os = __importStar(require("os"));
46
+ const path = __importStar(require("path"));
47
+ const readline = __importStar(require("readline"));
48
+ const config_1 = require("../config");
49
+ const localSafetySnapshot_1 = require("../localSafetySnapshot");
50
+ const deterministicGuard_1 = require("./deterministicGuard");
51
+ const telemetry_1 = require("../telemetry");
52
+ const notify_1 = require("../notify");
53
+ const machineIdentity_1 = require("../machineIdentity");
54
+ const discoverPaths_1 = require("./discoverPaths");
55
+ const WATCHER_PS1_PATH = path.join(os.homedir(), '.fullcourtdefense-claude-desktop-watcher.ps1');
56
+ const STATUS_PATH = path.join(os.homedir(), '.fullcourtdefense', 'claude-desktop-guard.json');
57
+ /** Node writes an epoch-ms "block until" here; the watcher reads it each cycle. */
58
+ const ARM_PATH = path.join(os.homedir(), '.fullcourtdefense', 'claude-desktop-arm');
59
+ const STDOUT_PREFIX = 'FCD:';
60
+ /** Poll cadence for the foreground/focused-element read (ms). */
61
+ const POLL_MS = 250;
62
+ /** How long each detection arms the Enter block (auto-expiry safety valve). */
63
+ const ARM_WINDOW_MS = 2_000;
64
+ /** Don't re-toast the same finding value more often than this. */
65
+ const TOAST_DEBOUNCE_MS = 60_000;
66
+ /** Refresh the cached Local Safety snapshot on this cadence. */
67
+ const SNAPSHOT_REFRESH_MS = 5 * 60_000;
68
+ /** Watcher restart backoff bounds. */
69
+ const RESTART_MIN_MS = 2_000;
70
+ const RESTART_MAX_MS = 30_000;
71
+ /** Only meaningful where Claude Desktop runs and UI Automation is available. */
72
+ function desktopChatGuardSupported() {
73
+ return process.platform === 'win32' && (0, discoverPaths_1.claudeDesktopLikelyInstalled)();
74
+ }
75
+ /** Resolve the effective mode: explicit arg > env > snapshot hint > 'warn'. */
76
+ function resolveDesktopChatMode(explicit, snapshot) {
77
+ const raw = (explicit || process.env.FCD_DESKTOP_CHAT_MODE || snapshot?.desktopChatMode || 'warn')
78
+ .toString().toLowerCase();
79
+ return raw === 'block' ? 'block' : 'warn';
80
+ }
81
+ function mask(value) {
82
+ const v = String(value || '');
83
+ if (v.length <= 8)
84
+ return '*'.repeat(v.length);
85
+ return `${v.slice(0, 4)}${'*'.repeat(Math.max(4, v.length - 8))}${v.slice(-4)}`;
86
+ }
87
+ function writeStatus(status) {
88
+ try {
89
+ fs.mkdirSync(path.dirname(STATUS_PATH), { recursive: true });
90
+ fs.writeFileSync(STATUS_PATH, JSON.stringify(status, null, 2), { encoding: 'utf8', mode: 0o600 });
91
+ }
92
+ catch { /* best-effort */ }
93
+ }
94
+ /** True when the guard reported itself healthy within the last `withinMs`. */
95
+ function desktopChatGuardHealthy(withinMs = 15 * 60_000) {
96
+ try {
97
+ const parsed = JSON.parse(fs.readFileSync(STATUS_PATH, 'utf8'));
98
+ if (!parsed?.running || !parsed.heartbeatAt)
99
+ return false;
100
+ return Date.now() - new Date(parsed.heartbeatAt).getTime() < withinMs;
101
+ }
102
+ catch {
103
+ return false;
104
+ }
105
+ }
106
+ /** Escape a JS string for embedding in a PowerShell single-quoted literal. */
107
+ function psSingle(value) {
108
+ return value.replace(/'/g, "''");
109
+ }
110
+ /**
111
+ * The C# helper (compiled by Add-Type in the child) that owns the low-level
112
+ * keyboard hook. Only used in block mode. Design notes are in the file header:
113
+ * the callback is trivial (a volatile timestamp read + foreground check), the
114
+ * block auto-expires, and a periodic reinstall on the pump thread recovers from
115
+ * Windows silently removing the hook under sustained load.
116
+ */
117
+ function keyboardHookCSharp() {
118
+ return [
119
+ 'using System;',
120
+ 'using System.Runtime.InteropServices;',
121
+ 'using System.Threading;',
122
+ 'using System.Diagnostics;',
123
+ 'public static class FcdKbGuard {',
124
+ ' private const int WH_KEYBOARD_LL = 13;',
125
+ ' private const int WM_KEYDOWN = 0x0100;',
126
+ ' private const int WM_SYSKEYDOWN = 0x0104;',
127
+ ' private const uint VK_RETURN = 0x0D;',
128
+ ' private const int VK_SHIFT = 0x10;',
129
+ ' private const uint PM_REMOVE = 0x0001;',
130
+ ' [StructLayout(LayoutKind.Sequential)] private struct KBDLLHOOKSTRUCT { public uint vkCode; public uint scanCode; public uint flags; public uint time; public IntPtr dwExtraInfo; }',
131
+ ' [StructLayout(LayoutKind.Sequential)] private struct MSG { public IntPtr hwnd; public uint message; public IntPtr wParam; public IntPtr lParam; public uint time; public int ptx; public int pty; }',
132
+ ' private delegate IntPtr HookProc(int nCode, IntPtr wParam, IntPtr lParam);',
133
+ ' [DllImport("user32.dll", SetLastError=true)] private static extern IntPtr SetWindowsHookEx(int idHook, HookProc lpfn, IntPtr hMod, uint dwThreadId);',
134
+ ' [DllImport("user32.dll", SetLastError=true)] private static extern bool UnhookWindowsHookEx(IntPtr hhk);',
135
+ ' [DllImport("user32.dll")] private static extern IntPtr CallNextHookEx(IntPtr hhk, int nCode, IntPtr wParam, IntPtr lParam);',
136
+ ' [DllImport("user32.dll")] private static extern short GetKeyState(int nVirtKey);',
137
+ ' [DllImport("user32.dll")] private static extern IntPtr GetForegroundWindow();',
138
+ ' [DllImport("user32.dll")] private static extern uint GetWindowThreadProcessId(IntPtr hWnd, out uint pid);',
139
+ ' [DllImport("kernel32.dll")] private static extern IntPtr GetModuleHandle(string name);',
140
+ ' [DllImport("user32.dll")] private static extern bool PeekMessage(out MSG msg, IntPtr hWnd, uint min, uint max, uint remove);',
141
+ ' private static IntPtr _hook = IntPtr.Zero;',
142
+ ' private static HookProc _proc;',
143
+ ' private static long _blockUntil = 0;',
144
+ ' private static long _blocked = 0;',
145
+ ' private static volatile bool _running = false;',
146
+ ' public static void ArmFor(long ms) { Interlocked.Exchange(ref _blockUntil, DateTime.UtcNow.Ticks + ms * TimeSpan.TicksPerMillisecond); }',
147
+ ' public static void Clear() { Interlocked.Exchange(ref _blockUntil, 0); }',
148
+ ' public static long Blocked() { return Interlocked.Read(ref _blocked); }',
149
+ ' private static bool ForegroundIsClaude() {',
150
+ ' try {',
151
+ ' IntPtr h = GetForegroundWindow(); if (h == IntPtr.Zero) return false;',
152
+ ' uint pid; GetWindowThreadProcessId(h, out pid); if (pid == 0) return false;',
153
+ ' Process p = Process.GetProcessById((int)pid);',
154
+ ' return p != null && string.Equals(p.ProcessName, "Claude", StringComparison.OrdinalIgnoreCase);',
155
+ ' } catch { return false; }',
156
+ ' }',
157
+ ' private static IntPtr Callback(int nCode, IntPtr wParam, IntPtr lParam) {',
158
+ ' try {',
159
+ ' if (nCode >= 0) {',
160
+ ' int m = wParam.ToInt32();',
161
+ ' if (m == WM_KEYDOWN || m == WM_SYSKEYDOWN) {',
162
+ ' KBDLLHOOKSTRUCT k = (KBDLLHOOKSTRUCT)Marshal.PtrToStructure(lParam, typeof(KBDLLHOOKSTRUCT));',
163
+ ' if (k.vkCode == VK_RETURN) {',
164
+ ' bool shift = (GetKeyState(VK_SHIFT) & 0x8000) != 0;',
165
+ ' if (!shift && DateTime.UtcNow.Ticks < Interlocked.Read(ref _blockUntil) && ForegroundIsClaude()) {',
166
+ ' Interlocked.Increment(ref _blocked);',
167
+ ' return (IntPtr)1;',
168
+ ' }',
169
+ ' }',
170
+ ' }',
171
+ ' }',
172
+ ' } catch { }',
173
+ ' return CallNextHookEx(_hook, nCode, wParam, lParam);',
174
+ ' }',
175
+ ' public static void Start() {',
176
+ ' if (_running) return; _running = true; _proc = Callback;',
177
+ ' Thread t = new Thread(delegate() {',
178
+ ' _hook = SetWindowsHookEx(WH_KEYBOARD_LL, _proc, GetModuleHandle(null), 0);',
179
+ ' int lastReinstall = Environment.TickCount;',
180
+ ' MSG msg;',
181
+ ' while (_running) {',
182
+ ' while (PeekMessage(out msg, IntPtr.Zero, 0, 0, PM_REMOVE)) { }',
183
+ ' Thread.Sleep(10);',
184
+ ' if (Environment.TickCount - lastReinstall > 60000) {',
185
+ ' try { if (_hook != IntPtr.Zero) UnhookWindowsHookEx(_hook); } catch { }',
186
+ ' _hook = SetWindowsHookEx(WH_KEYBOARD_LL, _proc, GetModuleHandle(null), 0);',
187
+ ' lastReinstall = Environment.TickCount;',
188
+ ' }',
189
+ ' }',
190
+ ' try { if (_hook != IntPtr.Zero) UnhookWindowsHookEx(_hook); } catch { }',
191
+ ' _hook = IntPtr.Zero;',
192
+ ' });',
193
+ ' t.IsBackground = true; t.Start();',
194
+ ' }',
195
+ ' public static void Stop() { _running = false; }',
196
+ '}',
197
+ ].join('\n');
198
+ }
199
+ /**
200
+ * The PowerShell watcher. Reads ONLY the focused element of the foreground
201
+ * Claude process (never the transcript), so it is low-noise and resilient to
202
+ * Claude UI changes. Emits `FCD:<base64 utf8>` lines on stdout when the text
203
+ * changes. In block mode it also compiles the keyboard-hook helper and, each
204
+ * cycle, reads the arm file Node maintains to arm/expire the Enter block.
205
+ * Everything is wrapped in try/catch — it must never crash the host.
206
+ */
207
+ function desktopChatWatcherScript(mode = 'warn') {
208
+ const armPathLiteral = psSingle(ARM_PATH);
209
+ const lines = [
210
+ "$ErrorActionPreference = 'SilentlyContinue'",
211
+ 'try {',
212
+ ' Add-Type -AssemblyName UIAutomationClient,UIAutomationTypes,WindowsBase | Out-Null',
213
+ '} catch { }',
214
+ 'try {',
215
+ ' Add-Type -TypeDefinition @"',
216
+ 'using System;',
217
+ 'using System.Runtime.InteropServices;',
218
+ 'public static class FcdWin {',
219
+ ' [DllImport("user32.dll")] public static extern IntPtr GetForegroundWindow();',
220
+ ' [DllImport("user32.dll")] public static extern uint GetWindowThreadProcessId(IntPtr hWnd, out uint pid);',
221
+ '}',
222
+ '"@',
223
+ '} catch { }',
224
+ ];
225
+ if (mode === 'block') {
226
+ lines.push('try {');
227
+ lines.push(" Add-Type -TypeDefinition @'");
228
+ for (const l of keyboardHookCSharp().split('\n'))
229
+ lines.push(l);
230
+ lines.push("'@");
231
+ lines.push(' [FcdKbGuard]::Start()');
232
+ lines.push('} catch { }');
233
+ lines.push(`$global:FcdArmPath = '${armPathLiteral}'`);
234
+ }
235
+ lines.push('', 'function Get-FcdFocusedText {', ' param([int]$OwnerPid)', ' try {', ' $focused = [System.Windows.Automation.AutomationElement]::FocusedElement', ' if ($null -eq $focused) { return $null }', ' if ($focused.Current.ProcessId -ne $OwnerPid) { return $null }', ' $text = $null', ' $vp = $null', ' if ($focused.TryGetCurrentPattern([System.Windows.Automation.ValuePattern]::Pattern, [ref]$vp)) {', ' $text = $vp.Current.Value', ' }', ' if ([string]::IsNullOrEmpty($text)) {', ' $tp = $null', ' if ($focused.TryGetCurrentPattern([System.Windows.Automation.TextPattern]::Pattern, [ref]$tp)) {', ' $text = $tp.DocumentRange.GetText(20000)', ' }', ' }', ' if ([string]::IsNullOrEmpty($text)) { $text = $focused.Current.Name }', ' return $text', ' } catch { return $null }', '}', '', '$last = ""', 'while ($true) {', ' Start-Sleep -Milliseconds ' + POLL_MS);
236
+ if (mode === 'block') {
237
+ // Arm/expire the Enter block from the file Node maintains. The C# side
238
+ // auto-expires too, so a missed cycle just releases the block early.
239
+ lines.push(' try {');
240
+ lines.push(' if (Test-Path $global:FcdArmPath) {');
241
+ lines.push(' $armUntil = [long](Get-Content -Raw -Path $global:FcdArmPath)');
242
+ lines.push(' $nowMs = [long]([DateTimeOffset]::UtcNow.ToUnixTimeMilliseconds())');
243
+ lines.push(' if ($armUntil -gt $nowMs) { [FcdKbGuard]::ArmFor($armUntil - $nowMs) } else { [FcdKbGuard]::Clear() }');
244
+ lines.push(' } else { [FcdKbGuard]::Clear() }');
245
+ lines.push(' } catch { }');
246
+ }
247
+ lines.push(' try {', ' $h = [FcdWin]::GetForegroundWindow()', ' if ($h -eq [IntPtr]::Zero) { continue }', ' $procId = 0', ' [void][FcdWin]::GetWindowThreadProcessId($h, [ref]$procId)', ' if ($procId -eq 0) { continue }', ' $proc = Get-Process -Id $procId -ErrorAction SilentlyContinue', " if ($null -eq $proc -or $proc.ProcessName -ne 'Claude') { continue }", ' $text = Get-FcdFocusedText -OwnerPid $procId', ' if ([string]::IsNullOrEmpty($text)) { continue }', ' if ($text.Length -gt 8000) { continue }', ' if ($text -eq $last) { continue }', ' $last = $text', ' $bytes = [System.Text.Encoding]::UTF8.GetBytes($text)', ' $b64 = [Convert]::ToBase64String($bytes)', " [Console]::Out.WriteLine('" + STDOUT_PREFIX + "' + $b64)", ' [Console]::Out.Flush()', ' } catch { }', '}', '');
248
+ return lines.join('\n');
249
+ }
250
+ /** Decode one `FCD:<base64>` watcher line into UTF-8 text (undefined if not ours). */
251
+ function decodeWatcherLine(line) {
252
+ if (!line.startsWith(STDOUT_PREFIX))
253
+ return undefined;
254
+ try {
255
+ return Buffer.from(line.slice(STDOUT_PREFIX.length), 'base64').toString('utf8');
256
+ }
257
+ catch {
258
+ return undefined;
259
+ }
260
+ }
261
+ function ensureWatcherScript(mode) {
262
+ fs.writeFileSync(WATCHER_PS1_PATH, desktopChatWatcherScript(mode), { encoding: 'utf8' });
263
+ return WATCHER_PS1_PATH;
264
+ }
265
+ /** Arm the Enter block for the standard window (block mode only). */
266
+ function armBlock() {
267
+ try {
268
+ fs.mkdirSync(path.dirname(ARM_PATH), { recursive: true });
269
+ fs.writeFileSync(ARM_PATH, String(Date.now() + ARM_WINDOW_MS), { encoding: 'utf8', mode: 0o600 });
270
+ }
271
+ catch { /* best-effort — the C# side still auto-expires */ }
272
+ }
273
+ function clearArm() {
274
+ try {
275
+ fs.unlinkSync(ARM_PATH);
276
+ }
277
+ catch { /* not armed */ }
278
+ }
279
+ /**
280
+ * Start the guard. Returns a handle whose stop() tears down the watcher and
281
+ * timers. Safe no-op (returns undefined) on unsupported platforms.
282
+ */
283
+ function startDesktopChatGuard(runtime) {
284
+ if (!desktopChatGuardSupported())
285
+ return undefined;
286
+ const startedAt = new Date().toISOString();
287
+ let stopped = false;
288
+ let child;
289
+ let restartTimer;
290
+ let restartDelay = RESTART_MIN_MS;
291
+ let findings = 0;
292
+ let blocksArmed = 0;
293
+ const lastToastAt = new Map();
294
+ let scanOptions = (0, localSafetySnapshot_1.snapshotToScanOptions)(undefined);
295
+ let mode = resolveDesktopChatMode(runtime.mode);
296
+ const identity = (0, machineIdentity_1.getMachineIdentity)();
297
+ clearArm(); // never start with a stale armed window
298
+ const refreshSnapshot = async () => {
299
+ if (!runtime.shieldId)
300
+ return;
301
+ try {
302
+ const snapshot = await (0, localSafetySnapshot_1.loadLocalSafetySnapshot)({
303
+ apiUrl: runtime.apiUrl,
304
+ shieldId: runtime.shieldId,
305
+ shieldKey: runtime.shieldKey,
306
+ developerName: identity.developerName,
307
+ machineName: identity.hostname,
308
+ });
309
+ scanOptions = (0, localSafetySnapshot_1.snapshotToScanOptions)(snapshot);
310
+ // Mode can be flipped centrally (snapshot) unless pinned by arg/env.
311
+ mode = resolveDesktopChatMode(runtime.mode, snapshot);
312
+ }
313
+ catch { /* offline — keep last options/mode */ }
314
+ };
315
+ // heartbeatAt lets the daemon/console tell "guard alive" from "stale".
316
+ const heartbeat = () => writeStatus({
317
+ running: !stopped,
318
+ mode,
319
+ findings,
320
+ blocksArmed,
321
+ startedAt,
322
+ heartbeatAt: new Date().toISOString(),
323
+ });
324
+ const handleText = (text) => {
325
+ if (!text.trim())
326
+ return;
327
+ const finding = (0, deterministicGuard_1.scanDeterministicPrompt)(text, scanOptions);
328
+ if (!finding) {
329
+ // No secret in the box → make sure the Enter block is not armed.
330
+ if (mode === 'block')
331
+ clearArm();
332
+ return;
333
+ }
334
+ findings += 1;
335
+ const masked = mask(finding.evidence || '');
336
+ const blocking = mode === 'block';
337
+ if (blocking) {
338
+ armBlock();
339
+ blocksArmed += 1;
340
+ }
341
+ heartbeat();
342
+ (0, telemetry_1.spoolEvent)({
343
+ // block mode enforces (Enter is swallowed); warn mode is monitor-only.
344
+ decision: blocking ? 'block' : 'allow',
345
+ toolName: 'claude_desktop_chat',
346
+ operation: 'prompt',
347
+ reason: blocking ? finding.reason : `would block: ${finding.reason}`,
348
+ ruleId: finding.ruleId,
349
+ category: finding.category,
350
+ categoryId: finding.categoryId,
351
+ itemId: finding.itemId,
352
+ source: finding.source,
353
+ evidence: masked,
354
+ explanation: finding.explanation,
355
+ policyHash: finding.policyHash,
356
+ });
357
+ (0, telemetry_1.triggerFlush)();
358
+ const key = `${finding.itemId}:${masked}`;
359
+ const now = Date.now();
360
+ const last = lastToastAt.get(key) || 0;
361
+ if (!runtime.quiet && now - last > TOAST_DEBOUNCE_MS) {
362
+ lastToastAt.set(key, now);
363
+ (0, notify_1.notifyOs)({
364
+ title: blocking ? 'FullCourtDefense: blocked in Claude Desktop' : 'FullCourtDefense: secret in Claude Desktop',
365
+ message: blocking
366
+ ? `${finding.reason}. Sending is blocked — remove it to continue.`
367
+ : `${finding.reason}. Remove it before sending — this text has not been protected.`,
368
+ });
369
+ }
370
+ runtime.log(`Claude Desktop chat ${blocking ? 'block' : 'finding'}: ${finding.itemId} (${finding.reason}).`);
371
+ };
372
+ const spawnWatcher = () => {
373
+ if (stopped)
374
+ return;
375
+ let scriptPath;
376
+ try {
377
+ scriptPath = ensureWatcherScript(mode);
378
+ }
379
+ catch (error) {
380
+ runtime.log(`Claude Desktop guard: cannot write watcher script: ${error.message}`);
381
+ scheduleRestart();
382
+ return;
383
+ }
384
+ child = (0, child_process_1.spawn)('powershell', [
385
+ '-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass', '-WindowStyle', 'Hidden', '-File', scriptPath,
386
+ ], { windowsHide: true, stdio: ['ignore', 'pipe', 'ignore'] });
387
+ if (!child.stdout) {
388
+ scheduleRestart();
389
+ return;
390
+ }
391
+ const rl = readline.createInterface({ input: child.stdout });
392
+ rl.on('line', (line) => {
393
+ const text = decodeWatcherLine(line);
394
+ if (text === undefined)
395
+ return;
396
+ restartDelay = RESTART_MIN_MS; // healthy output resets backoff
397
+ handleText(text);
398
+ });
399
+ child.on('exit', () => { rl.close(); if (mode === 'block')
400
+ clearArm(); if (!stopped)
401
+ scheduleRestart(); });
402
+ child.on('error', () => { if (!stopped)
403
+ scheduleRestart(); });
404
+ runtime.log(`Claude Desktop chat guard: watcher started (${mode}).`);
405
+ heartbeat();
406
+ };
407
+ const scheduleRestart = () => {
408
+ if (stopped || restartTimer)
409
+ return;
410
+ restartTimer = setTimeout(() => {
411
+ restartTimer = undefined;
412
+ spawnWatcher();
413
+ }, restartDelay);
414
+ restartDelay = Math.min(RESTART_MAX_MS, restartDelay * 2);
415
+ };
416
+ void refreshSnapshot();
417
+ const snapshotTimer = setInterval(() => { void refreshSnapshot(); }, SNAPSHOT_REFRESH_MS);
418
+ const heartbeatTimer = setInterval(heartbeat, 60_000);
419
+ spawnWatcher();
420
+ return {
421
+ stop() {
422
+ if (stopped)
423
+ return;
424
+ stopped = true;
425
+ clearInterval(snapshotTimer);
426
+ clearInterval(heartbeatTimer);
427
+ if (restartTimer) {
428
+ clearTimeout(restartTimer);
429
+ restartTimer = undefined;
430
+ }
431
+ clearArm(); // release any armed Enter block immediately
432
+ try {
433
+ child?.kill();
434
+ }
435
+ catch { /* ignore */ }
436
+ writeStatus({ running: false, mode, findings, blocksArmed, startedAt });
437
+ },
438
+ };
439
+ }
440
+ /**
441
+ * Foreground `desktop-chat-guard` command — runs the guard in this process
442
+ * until interrupted. Useful for manual testing (e.g. `--mode block`); in
443
+ * production the daemon supervises the guard in-process.
444
+ */
445
+ async function desktopChatGuardCommand(args, config) {
446
+ if (process.platform !== 'win32') {
447
+ console.error('The Claude Desktop chat guard is Windows-only.');
448
+ process.exit(1);
449
+ }
450
+ if (!(0, discoverPaths_1.claudeDesktopLikelyInstalled)()) {
451
+ console.error('Claude Desktop was not detected on this machine — nothing to guard.');
452
+ process.exit(1);
453
+ }
454
+ const creds = (0, config_1.resolveCliCredentials)(config, {
455
+ shieldId: args.shieldId,
456
+ shieldKey: args.shieldKey,
457
+ apiUrl: args.apiUrl,
458
+ });
459
+ const handle = startDesktopChatGuard({
460
+ apiUrl: creds.apiUrl,
461
+ shieldId: creds.shieldId,
462
+ shieldKey: creds.shieldKey,
463
+ mode: args.mode,
464
+ quiet: args.quiet === 'true',
465
+ log: (msg) => console.log(msg),
466
+ });
467
+ if (!handle) {
468
+ console.error('Claude Desktop chat guard is not supported on this machine.');
469
+ process.exit(1);
470
+ }
471
+ const mode = resolveDesktopChatMode(args.mode);
472
+ console.log(`FullCourtDefense Claude Desktop chat guard running (${mode}). Stop with Ctrl+C.`);
473
+ if (mode === 'block')
474
+ console.log('Block mode: pressing Enter to send a message that contains a secret will be prevented while Claude Desktop is focused.');
475
+ const shutdown = () => { handle.stop(); process.exit(0); };
476
+ process.on('SIGINT', shutdown);
477
+ process.on('SIGTERM', shutdown);
478
+ await new Promise(() => { });
479
+ }
@@ -253,6 +253,7 @@ async function installClaudeHookCommand(args, config) {
253
253
  console.log(`${COLOR.gray}Scope:${COLOR.reset} ${projectScope ? 'project (.claude/settings.json)' : 'machine-wide (~/.claude/settings.json)'}`);
254
254
  console.log(`${COLOR.gray}File:${COLOR.reset} ${file}`);
255
255
  console.log(`${COLOR.gray}Covers:${COLOR.reset} Claude Code, VS Code Copilot agent mode, GitHub Copilot CLI (all read this file)`);
256
+ console.log(`${COLOR.gray}Note:${COLOR.reset} Claude Desktop's regular chat has no hook — it is protected separately by the Windows chat guard the daemon runs (warns on secrets; can block the send in block mode).`);
256
257
  const parts = [];
257
258
  if (wantTools)
258
259
  parts.push('tool calls (shell / MCP / file writes / reads) checked against org Action Policies + Local Safety rules');
package/dist/index.js CHANGED
@@ -52,6 +52,7 @@ const installAll_1 = require("./commands/installAll");
52
52
  const onboard_1 = require("./commands/onboard");
53
53
  const autoProtect_1 = require("./commands/autoProtect");
54
54
  const daemon_1 = require("./commands/daemon");
55
+ const desktopChatGuard_1 = require("./commands/desktopChatGuard");
55
56
  const windowsAudit_1 = require("./commands/windowsAudit");
56
57
  const shellGuard_1 = require("./commands/shellGuard");
57
58
  const cmdGuard_1 = require("./commands/cmdGuard");
@@ -845,6 +846,17 @@ async function main() {
845
846
  await (0, daemon_1.daemonCommand)(args, config);
846
847
  break;
847
848
  }
849
+ case 'desktop-chat-guard': {
850
+ const args = {
851
+ apiUrl: flags['api-url'],
852
+ shieldId: flags['shield-id'],
853
+ shieldKey: flags['shield-key'],
854
+ mode: flags.mode,
855
+ quiet: flags.quiet,
856
+ };
857
+ await (0, desktopChatGuard_1.desktopChatGuardCommand)(args, config);
858
+ break;
859
+ }
848
860
  case 'install-cursor-mcp-gateway': {
849
861
  const args = {
850
862
  ...buildGatewayArgs(),
@@ -34,6 +34,8 @@ export interface FlushInput {
34
34
  integrityCheckedAt?: string;
35
35
  /** Set only by the resident process; distinguishes daemon liveness from hook flushes. */
36
36
  daemon?: boolean;
37
+ /** Windows Claude Desktop chat guard liveness (advisory prompt protection). */
38
+ desktopChatGuard?: boolean;
37
39
  timeoutMs?: number;
38
40
  }
39
41
  /** Drain the spool to the backend in one batch (+ optional heartbeat). Returns accepted count. */
package/dist/telemetry.js CHANGED
@@ -164,6 +164,7 @@ async function flushSpool(input) {
164
164
  integrityCheckedAt: input.integrityCheckedAt,
165
165
  daemon: input.daemon === true,
166
166
  coverage: 'hooks',
167
+ desktopChatGuard: input.desktopChatGuard === true,
167
168
  hostname: identity.hostname,
168
169
  // Windows-only: current PowerShell audit coverage (ScriptBlock
169
170
  // Logging + Transcription). Keeps the fleet dashboard's coverage
package/dist/version.json CHANGED
@@ -1,3 +1,3 @@
1
1
  {
2
- "version": "1.18.12"
2
+ "version": "1.20.0"
3
3
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fullcourtdefense-cli",
3
- "version": "1.18.12",
3
+ "version": "1.20.0",
4
4
  "description": "Full Court Defense CLI — security scanning for AI agents from your terminal",
5
5
  "main": "dist/index.js",
6
6
  "bin": {
@@ -15,6 +15,7 @@
15
15
  "scripts": {
16
16
  "build": "tsc && node scripts/copy-attack-corpus.js",
17
17
  "test:deterministic-guard": "npm run build && node scripts/test-deterministic-guard.js",
18
+ "test:desktop-chat-guard": "npm run build && node scripts/test-desktop-chat-guard.js",
18
19
  "test:taint-ledger": "npm run build && node scripts/test-taint-ledger.js",
19
20
  "test:shell-audit": "npm run build && node scripts/test-shell-audit.js",
20
21
  "test:shell-guard": "npm run build && node scripts/test-shell-guard.js",