fullcourtdefense-cli 1.20.0 → 1.21.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.
package/dist/commands/daemon.js
CHANGED
|
@@ -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 (
|
|
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
|
|
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.
|
|
58
|
-
*
|
|
59
|
-
*
|
|
60
|
-
*
|
|
61
|
-
*
|
|
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(
|
|
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
|
|
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
|
|
85
|
-
* until interrupted.
|
|
86
|
-
*
|
|
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
|
|
61
|
-
const POLL_MS =
|
|
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
|
|
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,149 +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
|
|
112
|
-
*
|
|
113
|
-
* the
|
|
114
|
-
*
|
|
115
|
-
*
|
|
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
|
|
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
|
|
122
|
-
'
|
|
123
|
-
'
|
|
124
|
-
'
|
|
125
|
-
'
|
|
126
|
-
'
|
|
127
|
-
'
|
|
128
|
-
'
|
|
129
|
-
'
|
|
130
|
-
' [
|
|
131
|
-
' [
|
|
132
|
-
'
|
|
133
|
-
'
|
|
134
|
-
'
|
|
135
|
-
'
|
|
136
|
-
'
|
|
137
|
-
'
|
|
138
|
-
'
|
|
139
|
-
'
|
|
140
|
-
'
|
|
141
|
-
'
|
|
142
|
-
'
|
|
143
|
-
'
|
|
144
|
-
'
|
|
145
|
-
'
|
|
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
|
-
'
|
|
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
|
-
'
|
|
160
|
-
'
|
|
161
|
-
'
|
|
162
|
-
'
|
|
163
|
-
'
|
|
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
|
-
'
|
|
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
|
|
176
|
-
'
|
|
177
|
-
'
|
|
178
|
-
'
|
|
179
|
-
'
|
|
180
|
-
'
|
|
181
|
-
'
|
|
182
|
-
'
|
|
183
|
-
'
|
|
184
|
-
'
|
|
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
|
-
|
|
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
|
-
'
|
|
215
|
-
'
|
|
216
|
-
'
|
|
217
|
-
'
|
|
218
|
-
'
|
|
219
|
-
'
|
|
220
|
-
|
|
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
|
-
|
|
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');
|
|
240
|
+
'',
|
|
241
|
+
].join('\n');
|
|
249
242
|
}
|
|
250
243
|
/** Decode one `FCD:<base64>` watcher line into UTF-8 text (undefined if not ours). */
|
|
251
244
|
function decodeWatcherLine(line) {
|
|
@@ -258,27 +251,13 @@ function decodeWatcherLine(line) {
|
|
|
258
251
|
return undefined;
|
|
259
252
|
}
|
|
260
253
|
}
|
|
261
|
-
function ensureWatcherScript(
|
|
262
|
-
fs.writeFileSync(WATCHER_PS1_PATH, desktopChatWatcherScript(
|
|
254
|
+
function ensureWatcherScript() {
|
|
255
|
+
fs.writeFileSync(WATCHER_PS1_PATH, desktopChatWatcherScript(), { encoding: 'utf8' });
|
|
263
256
|
return WATCHER_PS1_PATH;
|
|
264
257
|
}
|
|
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
258
|
/**
|
|
280
|
-
* Start the guard. Returns a handle whose stop() tears down the
|
|
281
|
-
* 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.
|
|
282
261
|
*/
|
|
283
262
|
function startDesktopChatGuard(runtime) {
|
|
284
263
|
if (!desktopChatGuardSupported())
|
|
@@ -289,12 +268,9 @@ function startDesktopChatGuard(runtime) {
|
|
|
289
268
|
let restartTimer;
|
|
290
269
|
let restartDelay = RESTART_MIN_MS;
|
|
291
270
|
let findings = 0;
|
|
292
|
-
let blocksArmed = 0;
|
|
293
271
|
const lastToastAt = new Map();
|
|
294
272
|
let scanOptions = (0, localSafetySnapshot_1.snapshotToScanOptions)(undefined);
|
|
295
|
-
let mode = resolveDesktopChatMode(runtime.mode);
|
|
296
273
|
const identity = (0, machineIdentity_1.getMachineIdentity)();
|
|
297
|
-
clearArm(); // never start with a stale armed window
|
|
298
274
|
const refreshSnapshot = async () => {
|
|
299
275
|
if (!runtime.shieldId)
|
|
300
276
|
return;
|
|
@@ -307,17 +283,12 @@ function startDesktopChatGuard(runtime) {
|
|
|
307
283
|
machineName: identity.hostname,
|
|
308
284
|
});
|
|
309
285
|
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
286
|
}
|
|
313
|
-
catch { /* offline — keep last options
|
|
287
|
+
catch { /* offline — keep last options */ }
|
|
314
288
|
};
|
|
315
|
-
// heartbeatAt lets the daemon/console tell "guard alive" from "stale".
|
|
316
289
|
const heartbeat = () => writeStatus({
|
|
317
290
|
running: !stopped,
|
|
318
|
-
mode,
|
|
319
291
|
findings,
|
|
320
|
-
blocksArmed,
|
|
321
292
|
startedAt,
|
|
322
293
|
heartbeatAt: new Date().toISOString(),
|
|
323
294
|
});
|
|
@@ -325,26 +296,19 @@ function startDesktopChatGuard(runtime) {
|
|
|
325
296
|
if (!text.trim())
|
|
326
297
|
return;
|
|
327
298
|
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();
|
|
299
|
+
if (!finding)
|
|
332
300
|
return;
|
|
333
|
-
}
|
|
334
301
|
findings += 1;
|
|
335
302
|
const masked = mask(finding.evidence || '');
|
|
336
|
-
const blocking = mode === 'block';
|
|
337
|
-
if (blocking) {
|
|
338
|
-
armBlock();
|
|
339
|
-
blocksArmed += 1;
|
|
340
|
-
}
|
|
341
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.
|
|
342
307
|
(0, telemetry_1.spoolEvent)({
|
|
343
|
-
|
|
344
|
-
decision: blocking ? 'block' : 'allow',
|
|
308
|
+
decision: 'allow',
|
|
345
309
|
toolName: 'claude_desktop_chat',
|
|
346
310
|
operation: 'prompt',
|
|
347
|
-
reason:
|
|
311
|
+
reason: `would block: ${finding.reason}`,
|
|
348
312
|
ruleId: finding.ruleId,
|
|
349
313
|
category: finding.category,
|
|
350
314
|
categoryId: finding.categoryId,
|
|
@@ -361,20 +325,18 @@ function startDesktopChatGuard(runtime) {
|
|
|
361
325
|
if (!runtime.quiet && now - last > TOAST_DEBOUNCE_MS) {
|
|
362
326
|
lastToastAt.set(key, now);
|
|
363
327
|
(0, notify_1.notifyOs)({
|
|
364
|
-
title:
|
|
365
|
-
message:
|
|
366
|
-
? `${finding.reason}. Sending is blocked — remove it to continue.`
|
|
367
|
-
: `${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.`,
|
|
368
330
|
});
|
|
369
331
|
}
|
|
370
|
-
runtime.log(`Claude Desktop chat
|
|
332
|
+
runtime.log(`Claude Desktop chat finding: ${finding.itemId} (${finding.reason}).`);
|
|
371
333
|
};
|
|
372
334
|
const spawnWatcher = () => {
|
|
373
335
|
if (stopped)
|
|
374
336
|
return;
|
|
375
337
|
let scriptPath;
|
|
376
338
|
try {
|
|
377
|
-
scriptPath = ensureWatcherScript(
|
|
339
|
+
scriptPath = ensureWatcherScript();
|
|
378
340
|
}
|
|
379
341
|
catch (error) {
|
|
380
342
|
runtime.log(`Claude Desktop guard: cannot write watcher script: ${error.message}`);
|
|
@@ -396,12 +358,11 @@ function startDesktopChatGuard(runtime) {
|
|
|
396
358
|
restartDelay = RESTART_MIN_MS; // healthy output resets backoff
|
|
397
359
|
handleText(text);
|
|
398
360
|
});
|
|
399
|
-
child.on('exit', () => { rl.close(); if (
|
|
400
|
-
clearArm(); if (!stopped)
|
|
361
|
+
child.on('exit', () => { rl.close(); if (!stopped)
|
|
401
362
|
scheduleRestart(); });
|
|
402
363
|
child.on('error', () => { if (!stopped)
|
|
403
364
|
scheduleRestart(); });
|
|
404
|
-
runtime.log(
|
|
365
|
+
runtime.log('Claude Desktop chat guard: watcher started (advisory).');
|
|
405
366
|
heartbeat();
|
|
406
367
|
};
|
|
407
368
|
const scheduleRestart = () => {
|
|
@@ -428,19 +389,18 @@ function startDesktopChatGuard(runtime) {
|
|
|
428
389
|
clearTimeout(restartTimer);
|
|
429
390
|
restartTimer = undefined;
|
|
430
391
|
}
|
|
431
|
-
clearArm(); // release any armed Enter block immediately
|
|
432
392
|
try {
|
|
433
393
|
child?.kill();
|
|
434
394
|
}
|
|
435
395
|
catch { /* ignore */ }
|
|
436
|
-
writeStatus({ running: false,
|
|
396
|
+
writeStatus({ running: false, findings, startedAt });
|
|
437
397
|
},
|
|
438
398
|
};
|
|
439
399
|
}
|
|
440
400
|
/**
|
|
441
|
-
* Foreground `desktop-chat-guard` command — runs the guard in this
|
|
442
|
-
* until interrupted.
|
|
443
|
-
*
|
|
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).
|
|
444
404
|
*/
|
|
445
405
|
async function desktopChatGuardCommand(args, config) {
|
|
446
406
|
if (process.platform !== 'win32') {
|
|
@@ -460,7 +420,6 @@ async function desktopChatGuardCommand(args, config) {
|
|
|
460
420
|
apiUrl: creds.apiUrl,
|
|
461
421
|
shieldId: creds.shieldId,
|
|
462
422
|
shieldKey: creds.shieldKey,
|
|
463
|
-
mode: args.mode,
|
|
464
423
|
quiet: args.quiet === 'true',
|
|
465
424
|
log: (msg) => console.log(msg),
|
|
466
425
|
});
|
|
@@ -468,10 +427,7 @@ async function desktopChatGuardCommand(args, config) {
|
|
|
468
427
|
console.error('Claude Desktop chat guard is not supported on this machine.');
|
|
469
428
|
process.exit(1);
|
|
470
429
|
}
|
|
471
|
-
|
|
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.');
|
|
430
|
+
console.log('FullCourtDefense Claude Desktop chat guard running (advisory). Stop with Ctrl+C.');
|
|
475
431
|
const shutdown = () => { handle.stop(); process.exit(0); };
|
|
476
432
|
process.on('SIGINT', shutdown);
|
|
477
433
|
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 (
|
|
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
package/dist/version.json
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "fullcourtdefense-cli",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.21.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,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
|
},
|