fullcourtdefense-cli 1.20.1 → 1.21.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.
@@ -758,14 +758,11 @@ async function runDaemon(args, config) {
758
758
  apiUrl: creds.apiUrl,
759
759
  shieldId: creds.shieldId,
760
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
761
  quiet,
765
762
  log,
766
763
  });
767
764
  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).');
765
+ log('Claude Desktop chat guard: supervising (advisory warns on secrets typed into Claude Desktop).');
769
766
  }
770
767
  // Fresh machines have never uploaded an inventory (MSI/onboard defers the
771
768
  // initial discovery to keep setup fast), so the dashboard shows "Never" for
@@ -1,67 +1,23 @@
1
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
2
  export interface DesktopChatGuardArgs {
42
3
  apiUrl?: string;
43
4
  shieldId?: string;
44
5
  shieldKey?: string;
45
- /** 'warn' (default) or 'block'. */
46
- mode?: string;
47
6
  /** Suppress OS toasts (still spools findings). */
48
7
  quiet?: string;
49
8
  }
50
- /** Only meaningful where Claude Desktop runs and UI Automation is available. */
9
+ /** Only meaningful where Claude Desktop runs and MSAA is available. */
51
10
  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
11
  /** True when the guard reported itself healthy within the last `withinMs`. */
55
12
  export declare function desktopChatGuardHealthy(withinMs?: number): boolean;
56
13
  /**
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.
14
+ * The PowerShell watcher. Compiles a tiny MSAA reader (C# via Add-Type using
15
+ * the .NET Framework compiler built into Windows), then only while Claude is
16
+ * the foreground process reads the composer's editable text and emits
17
+ * `FCD:<base64 utf8>` on stdout when it changes. Everything is wrapped in
18
+ * try/catch it must never crash the host.
63
19
  */
64
- export declare function desktopChatWatcherScript(mode?: DesktopChatMode): string;
20
+ export declare function desktopChatWatcherScript(): string;
65
21
  /** Decode one `FCD:<base64>` watcher line into UTF-8 text (undefined if not ours). */
66
22
  export declare function decodeWatcherLine(line: string): string | undefined;
67
23
  export interface DesktopChatGuardHandle {
@@ -71,19 +27,18 @@ interface GuardRuntime {
71
27
  apiUrl: string;
72
28
  shieldId?: string;
73
29
  shieldKey?: string;
74
- mode?: string;
75
30
  quiet: boolean;
76
31
  log: (msg: string) => void;
77
32
  }
78
33
  /**
79
- * Start the guard. Returns a handle whose stop() tears down the watcher and
80
- * timers. Safe no-op (returns undefined) on unsupported platforms.
34
+ * Start the advisory guard. Returns a handle whose stop() tears down the
35
+ * watcher and timers. Safe no-op (returns undefined) on unsupported platforms.
81
36
  */
82
37
  export declare function startDesktopChatGuard(runtime: GuardRuntime): DesktopChatGuardHandle | undefined;
83
38
  /**
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.
39
+ * Foreground `desktop-chat-guard` command — runs the advisory guard in this
40
+ * process until interrupted. Mostly for manual testing; in production the
41
+ * daemon supervises the guard in-process (startDesktopChatGuard).
87
42
  */
88
43
  export declare function desktopChatGuardCommand(args: DesktopChatGuardArgs, config: BotGuardConfig): Promise<void>;
89
44
  export {};
@@ -34,7 +34,6 @@ var __importStar = (this && this.__importStar) || (function () {
34
34
  })();
35
35
  Object.defineProperty(exports, "__esModule", { value: true });
36
36
  exports.desktopChatGuardSupported = desktopChatGuardSupported;
37
- exports.resolveDesktopChatMode = resolveDesktopChatMode;
38
37
  exports.desktopChatGuardHealthy = desktopChatGuardHealthy;
39
38
  exports.desktopChatWatcherScript = desktopChatWatcherScript;
40
39
  exports.decodeWatcherLine = decodeWatcherLine;
@@ -52,15 +51,44 @@ const telemetry_1 = require("../telemetry");
52
51
  const notify_1 = require("../notify");
53
52
  const machineIdentity_1 = require("../machineIdentity");
54
53
  const discoverPaths_1 = require("./discoverPaths");
54
+ /**
55
+ * Claude Desktop chat guard (Windows, advisory).
56
+ *
57
+ * Claude Desktop's regular chat sends prompts straight to Anthropic's API — it
58
+ * exposes no hook and never touches an MCP server, so neither the Claude-format
59
+ * hooks nor the MCP gateway can see what a developer types there. This guard
60
+ * closes that blind spot with the SAME on-device deterministic engine used
61
+ * everywhere else (scanDeterministicPrompt).
62
+ *
63
+ * Reading the composer — why MSAA, not UI Automation:
64
+ * Claude Desktop is Electron/Chromium. Chromium's UI Automation support is
65
+ * "very limited" (their own docs) — the ProseMirror composer's text is never
66
+ * exposed through UIA Value/Text patterns (verified: a typed marker never
67
+ * appears in any UIA node). Chromium DOES fully implement MSAA/IAccessible
68
+ * (the API screen readers use). So the watcher reads the composer via
69
+ * oleacc's AccessibleObjectFromWindow on the Chrome_RenderWidgetHostHWND
70
+ * child window, walks the accessible tree, and reads accValue of the editable
71
+ * text node (ROLE_SYSTEM_TEXT). The AccessibleObjectFromWindow call itself
72
+ * wakes Chromium's accessibility tree, so no --force-renderer-accessibility
73
+ * or WM_GETOBJECT dance is needed.
74
+ *
75
+ * Advisory ONLY: no keyboard hook, no input mutation, no keystroke capture. It
76
+ * reads only the composer text of Claude Desktop's own window (scoped to the
77
+ * "Claude" process — never any other application), scans it in-process, and —
78
+ * on a finding — shows a native toast and reports a monitor event to the fleet.
79
+ * Text is never uploaded; only finding metadata (item id + masked value) is
80
+ * spooled. It cannot break typing and presents no keylogger surface.
81
+ *
82
+ * Deliberately NOT gated on the foreground window: a foreground check goes blind
83
+ * the instant the user alt-tabs after typing, and MSAA reads Claude's composer
84
+ * whether or not Claude is focused. Scoping to the Claude process (not the
85
+ * active window) is what keeps this from ever reading other apps.
86
+ */
55
87
  const WATCHER_PS1_PATH = path.join(os.homedir(), '.fullcourtdefense-claude-desktop-watcher.ps1');
56
88
  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
89
  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;
90
+ /** Poll cadence for the composer read (ms). */
91
+ const POLL_MS = 800;
64
92
  /** Don't re-toast the same finding value more often than this. */
65
93
  const TOAST_DEBOUNCE_MS = 60_000;
66
94
  /** Refresh the cached Local Safety snapshot on this cadence. */
@@ -68,16 +96,10 @@ const SNAPSHOT_REFRESH_MS = 5 * 60_000;
68
96
  /** Watcher restart backoff bounds. */
69
97
  const RESTART_MIN_MS = 2_000;
70
98
  const RESTART_MAX_MS = 30_000;
71
- /** Only meaningful where Claude Desktop runs and UI Automation is available. */
99
+ /** Only meaningful where Claude Desktop runs and MSAA is available. */
72
100
  function desktopChatGuardSupported() {
73
101
  return process.platform === 'win32' && (0, discoverPaths_1.claudeDesktopLikelyInstalled)();
74
102
  }
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
103
  function mask(value) {
82
104
  const v = String(value || '');
83
105
  if (v.length <= 8)
@@ -103,158 +125,120 @@ function desktopChatGuardHealthy(withinMs = 15 * 60_000) {
103
125
  return false;
104
126
  }
105
127
  }
106
- /** Escape a JS string for embedding in a PowerShell single-quoted literal. */
107
- function psSingle(value) {
108
- return value.replace(/'/g, "''");
109
- }
110
128
  /**
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.
129
+ * The PowerShell watcher. Compiles a tiny MSAA reader (C# via Add-Type using
130
+ * the .NET Framework compiler built into Windows), then only while Claude is
131
+ * the foreground process reads the composer's editable text and emits
132
+ * `FCD:<base64 utf8>` on stdout when it changes. Everything is wrapped in
133
+ * try/catch it must never crash the host.
116
134
  */
117
- function keyboardHookCSharp() {
135
+ function desktopChatWatcherScript() {
118
136
  return [
137
+ "$ErrorActionPreference = 'SilentlyContinue'",
138
+ '[void][System.Reflection.Assembly]::LoadWithPartialName(\'Accessibility\')',
139
+ 'try {',
140
+ " Add-Type -ReferencedAssemblies 'Accessibility' -TypeDefinition @'",
119
141
  'using System;',
142
+ 'using System.Text;',
143
+ 'using System.Collections.Generic;',
120
144
  '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; }',
145
+ 'using Accessibility;',
146
+ 'public static class FcdMsaa {',
147
+ ' const uint OBJID_CLIENT = 0xFFFFFFFC;',
148
+ ' const int ROLE_TEXT = 0x2A;',
149
+ ' static Guid IID_IAccessible = new Guid("618736e0-3c3d-11cf-810c-00aa00389b71");',
150
+ ' [DllImport("oleacc.dll")] static extern int AccessibleObjectFromWindow(IntPtr hwnd, uint id, ref Guid iid, [MarshalAs(UnmanagedType.Interface)] out object ppv);',
151
+ ' [DllImport("oleacc.dll")] static extern int AccessibleChildren(IAccessible acc, int start, int count, [Out] object[] kids, out int got);',
152
+ ' [DllImport("user32.dll")] static extern bool EnumWindows(EnumProc cb, IntPtr p);',
153
+ ' [DllImport("user32.dll")] static extern bool EnumChildWindows(IntPtr parent, EnumProc cb, IntPtr p);',
154
+ ' [DllImport("user32.dll")] static extern int GetClassName(IntPtr h, StringBuilder sb, int max);',
155
+ ' [DllImport("user32.dll")] static extern int GetWindowText(IntPtr h, StringBuilder sb, int max);',
156
+ ' [DllImport("user32.dll")] static extern uint GetWindowThreadProcessId(IntPtr h, out uint pid);',
157
+ ' public delegate bool EnumProc(IntPtr h, IntPtr p);',
158
+ ' public static IntPtr FindMain(HashSet<uint> pids) {',
159
+ ' IntPtr result = IntPtr.Zero;',
160
+ ' EnumWindows((h, p) => {',
161
+ ' uint pid; GetWindowThreadProcessId(h, out pid);',
162
+ ' if (pids.Contains(pid)) {',
163
+ ' var cn = new StringBuilder(256); GetClassName(h, cn, 256);',
164
+ ' var tt = new StringBuilder(256); GetWindowText(h, tt, 256);',
165
+ ' if (cn.ToString().StartsWith("Chrome_WidgetWin_1") && tt.ToString().Trim().Length > 0) { result = h; return false; }',
166
+ ' }',
167
+ ' return true;',
168
+ ' }, IntPtr.Zero);',
169
+ ' return result;',
156
170
  ' }',
157
- ' private static IntPtr Callback(int nCode, IntPtr wParam, IntPtr lParam) {',
171
+ ' static List<IntPtr> Widgets(IntPtr parent) {',
172
+ ' var l = new List<IntPtr>();',
173
+ ' EnumChildWindows(parent, (h, p) => {',
174
+ ' var cn = new StringBuilder(256); GetClassName(h, cn, 256);',
175
+ ' if (cn.ToString().StartsWith("Chrome_RenderWidgetHostHWND")) l.Add(h);',
176
+ ' return true;',
177
+ ' }, IntPtr.Zero);',
178
+ ' return l;',
179
+ ' }',
180
+ ' static void Walk(IAccessible acc, List<string> outp, int depth, int[] budget) {',
181
+ ' if (acc == null || depth > 40 || budget[0] <= 0) return; budget[0]--;',
158
182
  ' 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
- ' }',
183
+ ' object self = 0; int role = 0;',
184
+ ' try { role = Convert.ToInt32(acc.get_accRole(self)); } catch {}',
185
+ ' if (role == ROLE_TEXT) {',
186
+ ' string v = null; try { v = acc.get_accValue(self); } catch {}',
187
+ ' if (!string.IsNullOrEmpty(v)) outp.Add(v);',
171
188
  ' }',
172
- ' } catch { }',
173
- ' return CallNextHookEx(_hook, nCode, wParam, lParam);',
189
+ ' } catch {}',
190
+ ' int count = 0; try { count = acc.accChildCount; } catch { return; }',
191
+ ' if (count <= 0) return;',
192
+ ' var kids = new object[count]; int got = 0;',
193
+ ' try { AccessibleChildren(acc, 0, count, kids, out got); } catch { return; }',
194
+ ' for (int i = 0; i < got; i++) { var ka = kids[i] as IAccessible; if (ka != null) Walk(ka, outp, depth + 1, budget); }',
174
195
  ' }',
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();',
196
+ ' public static string[] ReadComposer(IntPtr main) {',
197
+ ' var outp = new List<string>();',
198
+ ' foreach (var w in Widgets(main)) {',
199
+ ' object o; int hr = AccessibleObjectFromWindow(w, OBJID_CLIENT, ref IID_IAccessible, out o);',
200
+ ' if (hr != 0 || o == null) continue;',
201
+ ' var acc = o as IAccessible; if (acc == null) continue;',
202
+ ' var budget = new int[] { 5000 };',
203
+ ' Walk(acc, outp, 0, budget);',
204
+ ' }',
205
+ ' return outp.ToArray();',
194
206
  ' }',
195
- ' public static void Stop() { _running = false; }',
196
207
  '}',
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',
208
+ "'@",
213
209
  '} 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);',
210
+ '',
211
+ '$last = ""',
212
+ 'while ($true) {',
213
+ ' Start-Sleep -Milliseconds ' + POLL_MS,
214
+ ' try {',
215
+ ' $pids = New-Object \'System.Collections.Generic.HashSet[uint32]\'',
216
+ " Get-Process -Name 'Claude' -ErrorAction SilentlyContinue | ForEach-Object { [void]$pids.Add([uint32]$_.Id) }",
217
+ ' if ($pids.Count -eq 0) { continue }',
218
+ ' $main = [FcdMsaa]::FindMain($pids)',
219
+ ' if ($main -eq [IntPtr]::Zero) { continue }',
220
+ ' $vals = [FcdMsaa]::ReadComposer($main)',
221
+ ' if ($null -eq $vals -or $vals.Count -eq 0) { continue }',
222
+ ' $parts = @()',
223
+ ' foreach ($v in $vals) {',
224
+ " if ([string]::IsNullOrEmpty($v)) { continue }",
225
+ " if ($v -eq 'Write a message.') { continue }",
226
+ ' if ($v.Length -gt 8000) { continue }',
227
+ ' $parts += $v',
228
+ ' }',
229
+ ' if ($parts.Count -eq 0) { continue }',
230
+ " $joined = [string]::Join(\"`n\", $parts)",
231
+ ' if ([string]::IsNullOrEmpty($joined)) { continue }',
232
+ ' if ($joined -eq $last) { continue }',
233
+ ' $last = $joined',
234
+ ' $bytes = [System.Text.Encoding]::UTF8.GetBytes($joined)',
235
+ ' $b64 = [Convert]::ToBase64String($bytes)',
236
+ " [Console]::Out.WriteLine('" + STDOUT_PREFIX + "' + $b64)",
237
+ ' [Console]::Out.Flush()',
238
+ ' } catch { }',
221
239
  '}',
222
- '"@',
223
- '} catch { }',
224
- ];
225
- if (mode === 'block') {
226
- // The keyboard-hook helper is compiled at runtime by Add-Type using the C#
227
- // compiler built into the .NET Framework (a core Windows component — no SDK
228
- // or dev tools needed). If that ever fails on a locked-down machine, the
229
- // catch leaves $global:FcdBlockReady false and the guard silently degrades
230
- // to advisory warn mode: it still reads text, detects, toasts, and reports.
231
- lines.push('$global:FcdBlockReady = $false');
232
- lines.push(`$global:FcdArmPath = '${armPathLiteral}'`);
233
- lines.push('try {');
234
- lines.push(" Add-Type -TypeDefinition @'");
235
- for (const l of keyboardHookCSharp().split('\n'))
236
- lines.push(l);
237
- lines.push("'@");
238
- lines.push(' [FcdKbGuard]::Start()');
239
- lines.push(' $global:FcdBlockReady = $true');
240
- lines.push('} catch { $global:FcdBlockReady = $false }');
241
- }
242
- 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);
243
- if (mode === 'block') {
244
- // Arm/expire the Enter block from the file Node maintains. The C# side
245
- // auto-expires too, so a missed cycle just releases the block early.
246
- lines.push(' if ($global:FcdBlockReady) {');
247
- lines.push(' try {');
248
- lines.push(' if (Test-Path $global:FcdArmPath) {');
249
- lines.push(' $armUntil = [long](Get-Content -Raw -Path $global:FcdArmPath)');
250
- lines.push(' $nowMs = [long]([DateTimeOffset]::UtcNow.ToUnixTimeMilliseconds())');
251
- lines.push(' if ($armUntil -gt $nowMs) { [FcdKbGuard]::ArmFor($armUntil - $nowMs) } else { [FcdKbGuard]::Clear() }');
252
- lines.push(' } else { [FcdKbGuard]::Clear() }');
253
- lines.push(' } catch { }');
254
- lines.push(' }');
255
- }
256
- 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 { }', '}', '');
257
- return lines.join('\n');
240
+ '',
241
+ ].join('\n');
258
242
  }
259
243
  /** Decode one `FCD:<base64>` watcher line into UTF-8 text (undefined if not ours). */
260
244
  function decodeWatcherLine(line) {
@@ -267,27 +251,13 @@ function decodeWatcherLine(line) {
267
251
  return undefined;
268
252
  }
269
253
  }
270
- function ensureWatcherScript(mode) {
271
- fs.writeFileSync(WATCHER_PS1_PATH, desktopChatWatcherScript(mode), { encoding: 'utf8' });
254
+ function ensureWatcherScript() {
255
+ fs.writeFileSync(WATCHER_PS1_PATH, desktopChatWatcherScript(), { encoding: 'utf8' });
272
256
  return WATCHER_PS1_PATH;
273
257
  }
274
- /** Arm the Enter block for the standard window (block mode only). */
275
- function armBlock() {
276
- try {
277
- fs.mkdirSync(path.dirname(ARM_PATH), { recursive: true });
278
- fs.writeFileSync(ARM_PATH, String(Date.now() + ARM_WINDOW_MS), { encoding: 'utf8', mode: 0o600 });
279
- }
280
- catch { /* best-effort — the C# side still auto-expires */ }
281
- }
282
- function clearArm() {
283
- try {
284
- fs.unlinkSync(ARM_PATH);
285
- }
286
- catch { /* not armed */ }
287
- }
288
258
  /**
289
- * Start the guard. Returns a handle whose stop() tears down the watcher and
290
- * timers. Safe no-op (returns undefined) on unsupported platforms.
259
+ * Start the advisory guard. Returns a handle whose stop() tears down the
260
+ * watcher and timers. Safe no-op (returns undefined) on unsupported platforms.
291
261
  */
292
262
  function startDesktopChatGuard(runtime) {
293
263
  if (!desktopChatGuardSupported())
@@ -298,12 +268,9 @@ function startDesktopChatGuard(runtime) {
298
268
  let restartTimer;
299
269
  let restartDelay = RESTART_MIN_MS;
300
270
  let findings = 0;
301
- let blocksArmed = 0;
302
271
  const lastToastAt = new Map();
303
272
  let scanOptions = (0, localSafetySnapshot_1.snapshotToScanOptions)(undefined);
304
- let mode = resolveDesktopChatMode(runtime.mode);
305
273
  const identity = (0, machineIdentity_1.getMachineIdentity)();
306
- clearArm(); // never start with a stale armed window
307
274
  const refreshSnapshot = async () => {
308
275
  if (!runtime.shieldId)
309
276
  return;
@@ -316,17 +283,12 @@ function startDesktopChatGuard(runtime) {
316
283
  machineName: identity.hostname,
317
284
  });
318
285
  scanOptions = (0, localSafetySnapshot_1.snapshotToScanOptions)(snapshot);
319
- // Mode can be flipped centrally (snapshot) unless pinned by arg/env.
320
- mode = resolveDesktopChatMode(runtime.mode, snapshot);
321
286
  }
322
- catch { /* offline — keep last options/mode */ }
287
+ catch { /* offline — keep last options */ }
323
288
  };
324
- // heartbeatAt lets the daemon/console tell "guard alive" from "stale".
325
289
  const heartbeat = () => writeStatus({
326
290
  running: !stopped,
327
- mode,
328
291
  findings,
329
- blocksArmed,
330
292
  startedAt,
331
293
  heartbeatAt: new Date().toISOString(),
332
294
  });
@@ -334,26 +296,19 @@ function startDesktopChatGuard(runtime) {
334
296
  if (!text.trim())
335
297
  return;
336
298
  const finding = (0, deterministicGuard_1.scanDeterministicPrompt)(text, scanOptions);
337
- if (!finding) {
338
- // No secret in the box → make sure the Enter block is not armed.
339
- if (mode === 'block')
340
- clearArm();
299
+ if (!finding)
341
300
  return;
342
- }
343
301
  findings += 1;
344
302
  const masked = mask(finding.evidence || '');
345
- const blocking = mode === 'block';
346
- if (blocking) {
347
- armBlock();
348
- blocksArmed += 1;
349
- }
350
303
  heartbeat();
304
+ // Monitor semantics: advisory phase never blocks, so the decision is
305
+ // 'allow' with a "would block" evidence note (same shape shell-guard uses
306
+ // in monitor mode), which the Activity log renders as a monitor finding.
351
307
  (0, telemetry_1.spoolEvent)({
352
- // block mode enforces (Enter is swallowed); warn mode is monitor-only.
353
- decision: blocking ? 'block' : 'allow',
308
+ decision: 'allow',
354
309
  toolName: 'claude_desktop_chat',
355
310
  operation: 'prompt',
356
- reason: blocking ? finding.reason : `would block: ${finding.reason}`,
311
+ reason: `would block: ${finding.reason}`,
357
312
  ruleId: finding.ruleId,
358
313
  category: finding.category,
359
314
  categoryId: finding.categoryId,
@@ -370,20 +325,18 @@ function startDesktopChatGuard(runtime) {
370
325
  if (!runtime.quiet && now - last > TOAST_DEBOUNCE_MS) {
371
326
  lastToastAt.set(key, now);
372
327
  (0, notify_1.notifyOs)({
373
- title: blocking ? 'FullCourtDefense: blocked in Claude Desktop' : 'FullCourtDefense: secret in Claude Desktop',
374
- message: blocking
375
- ? `${finding.reason}. Sending is blocked — remove it to continue.`
376
- : `${finding.reason}. Remove it before sending — this text has not been protected.`,
328
+ title: 'FullCourtDefense: secret in Claude Desktop',
329
+ message: `${finding.reason}. Remove it before sending — this text has not been protected.`,
377
330
  });
378
331
  }
379
- runtime.log(`Claude Desktop chat ${blocking ? 'block' : 'finding'}: ${finding.itemId} (${finding.reason}).`);
332
+ runtime.log(`Claude Desktop chat finding: ${finding.itemId} (${finding.reason}).`);
380
333
  };
381
334
  const spawnWatcher = () => {
382
335
  if (stopped)
383
336
  return;
384
337
  let scriptPath;
385
338
  try {
386
- scriptPath = ensureWatcherScript(mode);
339
+ scriptPath = ensureWatcherScript();
387
340
  }
388
341
  catch (error) {
389
342
  runtime.log(`Claude Desktop guard: cannot write watcher script: ${error.message}`);
@@ -405,12 +358,11 @@ function startDesktopChatGuard(runtime) {
405
358
  restartDelay = RESTART_MIN_MS; // healthy output resets backoff
406
359
  handleText(text);
407
360
  });
408
- child.on('exit', () => { rl.close(); if (mode === 'block')
409
- clearArm(); if (!stopped)
361
+ child.on('exit', () => { rl.close(); if (!stopped)
410
362
  scheduleRestart(); });
411
363
  child.on('error', () => { if (!stopped)
412
364
  scheduleRestart(); });
413
- runtime.log(`Claude Desktop chat guard: watcher started (${mode}).`);
365
+ runtime.log('Claude Desktop chat guard: watcher started (advisory).');
414
366
  heartbeat();
415
367
  };
416
368
  const scheduleRestart = () => {
@@ -437,19 +389,18 @@ function startDesktopChatGuard(runtime) {
437
389
  clearTimeout(restartTimer);
438
390
  restartTimer = undefined;
439
391
  }
440
- clearArm(); // release any armed Enter block immediately
441
392
  try {
442
393
  child?.kill();
443
394
  }
444
395
  catch { /* ignore */ }
445
- writeStatus({ running: false, mode, findings, blocksArmed, startedAt });
396
+ writeStatus({ running: false, findings, startedAt });
446
397
  },
447
398
  };
448
399
  }
449
400
  /**
450
- * Foreground `desktop-chat-guard` command — runs the guard in this process
451
- * until interrupted. Useful for manual testing (e.g. `--mode block`); in
452
- * production the daemon supervises the guard in-process.
401
+ * Foreground `desktop-chat-guard` command — runs the advisory guard in this
402
+ * process until interrupted. Mostly for manual testing; in production the
403
+ * daemon supervises the guard in-process (startDesktopChatGuard).
453
404
  */
454
405
  async function desktopChatGuardCommand(args, config) {
455
406
  if (process.platform !== 'win32') {
@@ -469,7 +420,6 @@ async function desktopChatGuardCommand(args, config) {
469
420
  apiUrl: creds.apiUrl,
470
421
  shieldId: creds.shieldId,
471
422
  shieldKey: creds.shieldKey,
472
- mode: args.mode,
473
423
  quiet: args.quiet === 'true',
474
424
  log: (msg) => console.log(msg),
475
425
  });
@@ -477,10 +427,7 @@ async function desktopChatGuardCommand(args, config) {
477
427
  console.error('Claude Desktop chat guard is not supported on this machine.');
478
428
  process.exit(1);
479
429
  }
480
- const mode = resolveDesktopChatMode(args.mode);
481
- console.log(`FullCourtDefense Claude Desktop chat guard running (${mode}). Stop with Ctrl+C.`);
482
- if (mode === 'block')
483
- console.log('Block mode: pressing Enter to send a message that contains a secret will be prevented while Claude Desktop is focused.');
430
+ console.log('FullCourtDefense Claude Desktop chat guard running (advisory). Stop with Ctrl+C.');
484
431
  const shutdown = () => { handle.stop(); process.exit(0); };
485
432
  process.on('SIGINT', shutdown);
486
433
  process.on('SIGTERM', shutdown);
@@ -593,10 +593,18 @@ async function hookCommand(args, config) {
593
593
  decision.agent_message = agentMsg;
594
594
  }
595
595
  dbg({ phase: 'verdict', event, format: hookFormat, blocked, decision });
596
+ if (hookFormat === 'claude' && event === 'prompt' && blocked) {
597
+ // Claude Desktop/Code variants do not all honor the structured
598
+ // UserPromptSubmit decision consistently. Exit 2 is the documented hard
599
+ // block contract: stderr is shown as the rejection reason and the prompt
600
+ // is not sent to the model.
601
+ process.stderr.write(userMsg || agentMsg || 'Blocked by FullCourtDefense.');
602
+ process.exit(2);
603
+ }
596
604
  process.stdout.write(JSON.stringify(decision));
597
- // ALWAYS exit 0 so the client uses our JSON verdict. Exit 2 is interpreted as
598
- // `permission: "deny"`, which beforeSubmitPrompt ignores (it uses `continue`),
599
- // so exiting 2 would silently let blocked prompts through.
605
+ // Cursor must exit 0 so it consumes the JSON verdict. In particular,
606
+ // beforeSubmitPrompt ignores an exit-2 permission denial and uses
607
+ // `{ continue: false }`.
600
608
  process.exit(0);
601
609
  };
602
610
  // Claude-format lifecycle events with no pre-execution surface (PostToolUse,
@@ -253,7 +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
+ 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 (advisory).`);
257
257
  const parts = [];
258
258
  if (wantTools)
259
259
  parts.push('tool calls (shell / MCP / file writes / reads) checked against org Action Policies + Local Safety rules');
package/dist/index.js CHANGED
@@ -851,7 +851,6 @@ async function main() {
851
851
  apiUrl: flags['api-url'],
852
852
  shieldId: flags['shield-id'],
853
853
  shieldKey: flags['shield-key'],
854
- mode: flags.mode,
855
854
  quiet: flags.quiet,
856
855
  };
857
856
  await (0, desktopChatGuard_1.desktopChatGuardCommand)(args, config);
package/dist/version.json CHANGED
@@ -1,3 +1,3 @@
1
1
  {
2
- "version": "1.20.1"
2
+ "version": "1.21.1"
3
3
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fullcourtdefense-cli",
3
- "version": "1.20.1",
3
+ "version": "1.21.1",
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,7 +15,6 @@
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",
19
18
  "test:taint-ledger": "npm run build && node scripts/test-taint-ledger.js",
20
19
  "test:shell-audit": "npm run build && node scripts/test-shell-audit.js",
21
20
  "test:shell-guard": "npm run build && node scripts/test-shell-guard.js",
@@ -35,6 +34,7 @@
35
34
  "test:integrity": "npm run build && node scripts/test-integrity.js",
36
35
  "test:posture-overhaul": "npm run build && node scripts/test-posture-overhaul.js",
37
36
  "test:dpapi-config": "npm run build && node scripts/test-dpapi-config.js",
37
+ "test:desktop-chat-guard": "npm run build && node scripts/test-desktop-chat-guard.js",
38
38
  "build:msi": "powershell -NoProfile -ExecutionPolicy Bypass -File installer/windows/Build-Msi.ps1",
39
39
  "prepublishOnly": "npm run build"
40
40
  },