fullcourtdefense-cli 1.19.0 → 1.20.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,11 +758,14 @@ 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,
761
764
  quiet,
762
765
  log,
763
766
  });
764
767
  if (desktopChatGuard)
765
- log('Claude Desktop chat guard: supervising (advisory warns on secrets typed into Claude Desktop).');
768
+ log('Claude Desktop chat guard: supervising (warns, or blocks the Enter send if block mode is enabled, on secrets typed into Claude Desktop).');
766
769
  }
767
770
  // Fresh machines have never uploaded an inventory (MSI/onboard defers the
768
771
  // initial discovery to keep setup fast), so the dashboard shows "Never" for
@@ -1,22 +1,67 @@
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';
2
41
  export interface DesktopChatGuardArgs {
3
42
  apiUrl?: string;
4
43
  shieldId?: string;
5
44
  shieldKey?: string;
45
+ /** 'warn' (default) or 'block'. */
46
+ mode?: string;
6
47
  /** Suppress OS toasts (still spools findings). */
7
48
  quiet?: string;
8
49
  }
9
50
  /** Only meaningful where Claude Desktop runs and UI Automation is available. */
10
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;
11
54
  /** True when the guard reported itself healthy within the last `withinMs`. */
12
55
  export declare function desktopChatGuardHealthy(withinMs?: number): boolean;
13
56
  /**
14
57
  * The PowerShell watcher. Reads ONLY the focused element of the foreground
15
58
  * Claude process (never the transcript), so it is low-noise and resilient to
16
59
  * Claude UI changes. Emits `FCD:<base64 utf8>` lines on stdout when the text
17
- * changes. Everything is wrapped in try/catch it must never crash the host.
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.
18
63
  */
19
- export declare function desktopChatWatcherScript(): string;
64
+ export declare function desktopChatWatcherScript(mode?: DesktopChatMode): string;
20
65
  /** Decode one `FCD:<base64>` watcher line into UTF-8 text (undefined if not ours). */
21
66
  export declare function decodeWatcherLine(line: string): string | undefined;
22
67
  export interface DesktopChatGuardHandle {
@@ -26,18 +71,19 @@ interface GuardRuntime {
26
71
  apiUrl: string;
27
72
  shieldId?: string;
28
73
  shieldKey?: string;
74
+ mode?: string;
29
75
  quiet: boolean;
30
76
  log: (msg: string) => void;
31
77
  }
32
78
  /**
33
- * Start the advisory guard. Returns a handle whose stop() tears down the
34
- * watcher and timers. Safe no-op (returns undefined) on unsupported platforms.
79
+ * Start the guard. Returns a handle whose stop() tears down the watcher and
80
+ * timers. Safe no-op (returns undefined) on unsupported platforms.
35
81
  */
36
82
  export declare function startDesktopChatGuard(runtime: GuardRuntime): DesktopChatGuardHandle | undefined;
37
83
  /**
38
- * Foreground `desktop-chat-guard` command — runs the advisory guard in this
39
- * process until interrupted. Mostly for manual testing; in production the
40
- * daemon supervises the guard in-process (startDesktopChatGuard).
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.
41
87
  */
42
88
  export declare function desktopChatGuardCommand(args: DesktopChatGuardArgs, config: BotGuardConfig): Promise<void>;
43
89
  export {};
@@ -34,6 +34,7 @@ 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;
37
38
  exports.desktopChatGuardHealthy = desktopChatGuardHealthy;
38
39
  exports.desktopChatWatcherScript = desktopChatWatcherScript;
39
40
  exports.decodeWatcherLine = decodeWatcherLine;
@@ -51,29 +52,15 @@ const telemetry_1 = require("../telemetry");
51
52
  const notify_1 = require("../notify");
52
53
  const machineIdentity_1 = require("../machineIdentity");
53
54
  const discoverPaths_1 = require("./discoverPaths");
54
- /**
55
- * Claude Desktop chat guard (Windows, phase 1 — 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): a lightweight PowerShell UI
62
- * Automation watcher reads the text of the focused Claude Desktop input element
63
- * and streams it (base64) to this process, which scans it locally and — on a
64
- * finding — shows a native toast and reports a monitor event to the fleet.
65
- *
66
- * Phase 1 is advisory ONLY: no keyboard hook, no input mutation. It cannot
67
- * break typing and presents no keylogger surface (it reads only the focused
68
- * element of the foreground Claude process). Text is scanned in-process and is
69
- * never uploaded — only finding metadata (item id + masked value) is spooled.
70
- * Hard blocking is a later, org-policy-gated phase.
71
- */
72
55
  const WATCHER_PS1_PATH = path.join(os.homedir(), '.fullcourtdefense-claude-desktop-watcher.ps1');
73
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');
74
59
  const STDOUT_PREFIX = 'FCD:';
75
60
  /** Poll cadence for the foreground/focused-element read (ms). */
76
- const POLL_MS = 800;
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;
77
64
  /** Don't re-toast the same finding value more often than this. */
78
65
  const TOAST_DEBOUNCE_MS = 60_000;
79
66
  /** Refresh the cached Local Safety snapshot on this cadence. */
@@ -85,6 +72,12 @@ const RESTART_MAX_MS = 30_000;
85
72
  function desktopChatGuardSupported() {
86
73
  return process.platform === 'win32' && (0, discoverPaths_1.claudeDesktopLikelyInstalled)();
87
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
+ }
88
81
  function mask(value) {
89
82
  const v = String(value || '');
90
83
  if (v.length <= 8)
@@ -110,17 +103,113 @@ function desktopChatGuardHealthy(withinMs = 15 * 60_000) {
110
103
  return false;
111
104
  }
112
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
+ }
113
199
  /**
114
200
  * The PowerShell watcher. Reads ONLY the focused element of the foreground
115
201
  * Claude process (never the transcript), so it is low-noise and resilient to
116
202
  * Claude UI changes. Emits `FCD:<base64 utf8>` lines on stdout when the text
117
- * changes. Everything is wrapped in try/catch it must never crash the host.
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.
118
206
  */
119
- function desktopChatWatcherScript() {
120
- return [
207
+ function desktopChatWatcherScript(mode = 'warn') {
208
+ const armPathLiteral = psSingle(ARM_PATH);
209
+ const lines = [
121
210
  "$ErrorActionPreference = 'SilentlyContinue'",
122
211
  'try {',
123
- " Add-Type -AssemblyName UIAutomationClient,UIAutomationTypes,WindowsBase | Out-Null",
212
+ ' Add-Type -AssemblyName UIAutomationClient,UIAutomationTypes,WindowsBase | Out-Null',
124
213
  '} catch { }',
125
214
  'try {',
126
215
  ' Add-Type -TypeDefinition @"',
@@ -132,53 +221,40 @@ function desktopChatWatcherScript() {
132
221
  '}',
133
222
  '"@',
134
223
  '} catch { }',
135
- '',
136
- 'function Get-FcdFocusedText {',
137
- ' param([int]$OwnerPid)',
138
- ' try {',
139
- ' $focused = [System.Windows.Automation.AutomationElement]::FocusedElement',
140
- ' if ($null -eq $focused) { return $null }',
141
- ' if ($focused.Current.ProcessId -ne $OwnerPid) { return $null }',
142
- ' $text = $null',
143
- ' $vp = $null',
144
- ' if ($focused.TryGetCurrentPattern([System.Windows.Automation.ValuePattern]::Pattern, [ref]$vp)) {',
145
- ' $text = $vp.Current.Value',
146
- ' }',
147
- ' if ([string]::IsNullOrEmpty($text)) {',
148
- ' $tp = $null',
149
- ' if ($focused.TryGetCurrentPattern([System.Windows.Automation.TextPattern]::Pattern, [ref]$tp)) {',
150
- ' $text = $tp.DocumentRange.GetText(20000)',
151
- ' }',
152
- ' }',
153
- ' if ([string]::IsNullOrEmpty($text)) { $text = $focused.Current.Name }',
154
- ' return $text',
155
- ' } catch { return $null }',
156
- '}',
157
- '',
158
- '$last = ""',
159
- 'while ($true) {',
160
- " Start-Sleep -Milliseconds " + POLL_MS,
161
- ' try {',
162
- ' $h = [FcdWin]::GetForegroundWindow()',
163
- ' if ($h -eq [IntPtr]::Zero) { continue }',
164
- ' $procId = 0',
165
- ' [void][FcdWin]::GetWindowThreadProcessId($h, [ref]$procId)',
166
- ' if ($procId -eq 0) { continue }',
167
- ' $proc = Get-Process -Id $procId -ErrorAction SilentlyContinue',
168
- " if ($null -eq $proc -or $proc.ProcessName -ne 'Claude') { continue }",
169
- ' $text = Get-FcdFocusedText -OwnerPid $procId',
170
- ' if ([string]::IsNullOrEmpty($text)) { continue }',
171
- ' if ($text.Length -gt 8000) { continue }',
172
- ' if ($text -eq $last) { continue }',
173
- ' $last = $text',
174
- ' $bytes = [System.Text.Encoding]::UTF8.GetBytes($text)',
175
- ' $b64 = [Convert]::ToBase64String($bytes)',
176
- " [Console]::Out.WriteLine('" + STDOUT_PREFIX + "' + $b64)",
177
- ' [Console]::Out.Flush()',
178
- ' } catch { }',
179
- '}',
180
- '',
181
- ].join('\n');
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');
182
258
  }
183
259
  /** Decode one `FCD:<base64>` watcher line into UTF-8 text (undefined if not ours). */
184
260
  function decodeWatcherLine(line) {
@@ -191,13 +267,27 @@ function decodeWatcherLine(line) {
191
267
  return undefined;
192
268
  }
193
269
  }
194
- function ensureWatcherScript() {
195
- fs.writeFileSync(WATCHER_PS1_PATH, desktopChatWatcherScript(), { encoding: 'utf8' });
270
+ function ensureWatcherScript(mode) {
271
+ fs.writeFileSync(WATCHER_PS1_PATH, desktopChatWatcherScript(mode), { encoding: 'utf8' });
196
272
  return WATCHER_PS1_PATH;
197
273
  }
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
+ }
198
288
  /**
199
- * Start the advisory guard. Returns a handle whose stop() tears down the
200
- * watcher and timers. Safe no-op (returns undefined) on unsupported platforms.
289
+ * Start the guard. Returns a handle whose stop() tears down the watcher and
290
+ * timers. Safe no-op (returns undefined) on unsupported platforms.
201
291
  */
202
292
  function startDesktopChatGuard(runtime) {
203
293
  if (!desktopChatGuardSupported())
@@ -208,9 +298,12 @@ function startDesktopChatGuard(runtime) {
208
298
  let restartTimer;
209
299
  let restartDelay = RESTART_MIN_MS;
210
300
  let findings = 0;
301
+ let blocksArmed = 0;
211
302
  const lastToastAt = new Map();
212
303
  let scanOptions = (0, localSafetySnapshot_1.snapshotToScanOptions)(undefined);
304
+ let mode = resolveDesktopChatMode(runtime.mode);
213
305
  const identity = (0, machineIdentity_1.getMachineIdentity)();
306
+ clearArm(); // never start with a stale armed window
214
307
  const refreshSnapshot = async () => {
215
308
  if (!runtime.shieldId)
216
309
  return;
@@ -223,13 +316,17 @@ function startDesktopChatGuard(runtime) {
223
316
  machineName: identity.hostname,
224
317
  });
225
318
  scanOptions = (0, localSafetySnapshot_1.snapshotToScanOptions)(snapshot);
319
+ // Mode can be flipped centrally (snapshot) unless pinned by arg/env.
320
+ mode = resolveDesktopChatMode(runtime.mode, snapshot);
226
321
  }
227
- catch { /* offline — keep last options */ }
322
+ catch { /* offline — keep last options/mode */ }
228
323
  };
229
324
  // heartbeatAt lets the daemon/console tell "guard alive" from "stale".
230
325
  const heartbeat = () => writeStatus({
231
326
  running: !stopped,
327
+ mode,
232
328
  findings,
329
+ blocksArmed,
233
330
  startedAt,
234
331
  heartbeatAt: new Date().toISOString(),
235
332
  });
@@ -237,19 +334,26 @@ function startDesktopChatGuard(runtime) {
237
334
  if (!text.trim())
238
335
  return;
239
336
  const finding = (0, deterministicGuard_1.scanDeterministicPrompt)(text, scanOptions);
240
- if (!finding)
337
+ if (!finding) {
338
+ // No secret in the box → make sure the Enter block is not armed.
339
+ if (mode === 'block')
340
+ clearArm();
241
341
  return;
342
+ }
242
343
  findings += 1;
243
344
  const masked = mask(finding.evidence || '');
345
+ const blocking = mode === 'block';
346
+ if (blocking) {
347
+ armBlock();
348
+ blocksArmed += 1;
349
+ }
244
350
  heartbeat();
245
- // Monitor semantics: advisory phase never blocks, so the decision is
246
- // 'allow' with a "would block" evidence note (same shape shell-guard uses
247
- // in monitor mode), which the Activity log renders as a monitor finding.
248
351
  (0, telemetry_1.spoolEvent)({
249
- decision: 'allow',
352
+ // block mode enforces (Enter is swallowed); warn mode is monitor-only.
353
+ decision: blocking ? 'block' : 'allow',
250
354
  toolName: 'claude_desktop_chat',
251
355
  operation: 'prompt',
252
- reason: `would block: ${finding.reason}`,
356
+ reason: blocking ? finding.reason : `would block: ${finding.reason}`,
253
357
  ruleId: finding.ruleId,
254
358
  category: finding.category,
255
359
  categoryId: finding.categoryId,
@@ -266,18 +370,20 @@ function startDesktopChatGuard(runtime) {
266
370
  if (!runtime.quiet && now - last > TOAST_DEBOUNCE_MS) {
267
371
  lastToastAt.set(key, now);
268
372
  (0, notify_1.notifyOs)({
269
- title: 'FullCourtDefense: secret in Claude Desktop',
270
- message: `${finding.reason}. Remove it before sending — this text has not been protected.`,
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.`,
271
377
  });
272
378
  }
273
- runtime.log(`Claude Desktop chat finding: ${finding.itemId} (${finding.reason}).`);
379
+ runtime.log(`Claude Desktop chat ${blocking ? 'block' : 'finding'}: ${finding.itemId} (${finding.reason}).`);
274
380
  };
275
381
  const spawnWatcher = () => {
276
382
  if (stopped)
277
383
  return;
278
384
  let scriptPath;
279
385
  try {
280
- scriptPath = ensureWatcherScript();
386
+ scriptPath = ensureWatcherScript(mode);
281
387
  }
282
388
  catch (error) {
283
389
  runtime.log(`Claude Desktop guard: cannot write watcher script: ${error.message}`);
@@ -299,11 +405,12 @@ function startDesktopChatGuard(runtime) {
299
405
  restartDelay = RESTART_MIN_MS; // healthy output resets backoff
300
406
  handleText(text);
301
407
  });
302
- child.on('exit', () => { rl.close(); if (!stopped)
408
+ child.on('exit', () => { rl.close(); if (mode === 'block')
409
+ clearArm(); if (!stopped)
303
410
  scheduleRestart(); });
304
411
  child.on('error', () => { if (!stopped)
305
412
  scheduleRestart(); });
306
- runtime.log('Claude Desktop chat guard: watcher started (advisory).');
413
+ runtime.log(`Claude Desktop chat guard: watcher started (${mode}).`);
307
414
  heartbeat();
308
415
  };
309
416
  const scheduleRestart = () => {
@@ -330,18 +437,19 @@ function startDesktopChatGuard(runtime) {
330
437
  clearTimeout(restartTimer);
331
438
  restartTimer = undefined;
332
439
  }
440
+ clearArm(); // release any armed Enter block immediately
333
441
  try {
334
442
  child?.kill();
335
443
  }
336
444
  catch { /* ignore */ }
337
- writeStatus({ running: false, findings, startedAt });
445
+ writeStatus({ running: false, mode, findings, blocksArmed, startedAt });
338
446
  },
339
447
  };
340
448
  }
341
449
  /**
342
- * Foreground `desktop-chat-guard` command — runs the advisory guard in this
343
- * process until interrupted. Mostly for manual testing; in production the
344
- * daemon supervises the guard in-process (startDesktopChatGuard).
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.
345
453
  */
346
454
  async function desktopChatGuardCommand(args, config) {
347
455
  if (process.platform !== 'win32') {
@@ -361,6 +469,7 @@ async function desktopChatGuardCommand(args, config) {
361
469
  apiUrl: creds.apiUrl,
362
470
  shieldId: creds.shieldId,
363
471
  shieldKey: creds.shieldKey,
472
+ mode: args.mode,
364
473
  quiet: args.quiet === 'true',
365
474
  log: (msg) => console.log(msg),
366
475
  });
@@ -368,7 +477,10 @@ async function desktopChatGuardCommand(args, config) {
368
477
  console.error('Claude Desktop chat guard is not supported on this machine.');
369
478
  process.exit(1);
370
479
  }
371
- console.log('FullCourtDefense Claude Desktop chat guard running (advisory). Stop with Ctrl+C.');
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.');
372
484
  const shutdown = () => { handle.stop(); process.exit(0); };
373
485
  process.on('SIGINT', shutdown);
374
486
  process.on('SIGTERM', shutdown);
@@ -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 (advisory).`);
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).`);
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,6 +851,7 @@ 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,
854
855
  quiet: flags.quiet,
855
856
  };
856
857
  await (0, desktopChatGuard_1.desktopChatGuardCommand)(args, config);
package/dist/version.json CHANGED
@@ -1,3 +1,3 @@
1
1
  {
2
- "version": "1.19.0"
2
+ "version": "1.20.1"
3
3
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fullcourtdefense-cli",
3
- "version": "1.19.0",
3
+ "version": "1.20.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": {