fullcourtdefense-cli 1.21.6 → 1.21.8
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.
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.scanDeterministicClipboard = scanDeterministicClipboard;
|
|
4
|
+
const deterministicGuard_1 = require("./deterministicGuard");
|
|
5
|
+
/**
|
|
6
|
+
* Clipboard scan for advisory paste protection (e.g. Claude Desktop, where the
|
|
7
|
+
* native chat has no hook and the MCP gateway can't see typed prompts). A
|
|
8
|
+
* recognizable credential sitting on the clipboard is a finding on its own —
|
|
9
|
+
* the very next action is a paste the guard cannot observe.
|
|
10
|
+
*
|
|
11
|
+
* This composes the SAME deterministic engine used everywhere else via its
|
|
12
|
+
* public API, so detection patterns and per-rule Local Safety toggles are
|
|
13
|
+
* identical. No new detection logic and no keyword list lives here.
|
|
14
|
+
*
|
|
15
|
+
* - Prefixed secrets (sk-, ghp_, AKIA…) and keyword-adjacent high-entropy
|
|
16
|
+
* strings are caught directly by scanDeterministicTextResponse.
|
|
17
|
+
* - A clipboard that is ONE bare credential-shaped token has, by definition,
|
|
18
|
+
* no surrounding keyword. We re-run the same contextual detector with a
|
|
19
|
+
* synthetic keyword so its entropy + shape gate still decides — a lone hex
|
|
20
|
+
* app secret (the exact WhatsApp-secret scenario) is caught, while a hash
|
|
21
|
+
* embedded in a sentence or diff stays silent because it is never a lone
|
|
22
|
+
* token.
|
|
23
|
+
*/
|
|
24
|
+
const LONE_TOKEN = /^[A-Za-z0-9_\-+/=]{24,256}$/;
|
|
25
|
+
function scanDeterministicClipboard(text, options) {
|
|
26
|
+
const trimmed = (text || '').trim();
|
|
27
|
+
if (!trimmed)
|
|
28
|
+
return undefined;
|
|
29
|
+
const direct = (0, deterministicGuard_1.scanDeterministicTextResponse)(trimmed, options);
|
|
30
|
+
if (direct)
|
|
31
|
+
return direct;
|
|
32
|
+
if (LONE_TOKEN.test(trimmed)) {
|
|
33
|
+
// Inject a canonical keyword so the contextual detector's proximity check
|
|
34
|
+
// passes; its high-entropy gate still does the real work. The synthetic
|
|
35
|
+
// prefix is never surfaced — the finding's evidence comes from the token.
|
|
36
|
+
return (0, deterministicGuard_1.scanDeterministicTextResponse)(`api key ${trimmed}`, options);
|
|
37
|
+
}
|
|
38
|
+
return undefined;
|
|
39
|
+
}
|
|
@@ -12,14 +12,24 @@ export declare function desktopChatGuardSupported(): boolean;
|
|
|
12
12
|
export declare function desktopChatGuardHealthy(withinMs?: number): boolean;
|
|
13
13
|
/**
|
|
14
14
|
* The PowerShell watcher. Compiles a tiny MSAA reader (C# via Add-Type using
|
|
15
|
-
* the .NET Framework compiler built into Windows), then —
|
|
16
|
-
*
|
|
17
|
-
* `FCD:<base64 utf8>`
|
|
18
|
-
*
|
|
15
|
+
* the .NET Framework compiler built into Windows), then — while the "Claude"
|
|
16
|
+
* process is running — reads the composer's editable text (emitting
|
|
17
|
+
* `FCD:<base64 utf8>`) and, only while Claude Desktop is the FOREGROUND window,
|
|
18
|
+
* the clipboard contents (emitting `FCDCLIP:<base64 utf8>`) when either
|
|
19
|
+
* changes. Everything is wrapped in try/catch — it must never crash the host,
|
|
20
|
+
* and it never writes to the clipboard or synthesizes input.
|
|
19
21
|
*/
|
|
20
22
|
export declare function desktopChatWatcherScript(): string;
|
|
21
|
-
|
|
22
|
-
|
|
23
|
+
export type WatcherSource = 'composer' | 'clipboard';
|
|
24
|
+
/**
|
|
25
|
+
* Decode one watcher line into { source, text } (undefined if not ours).
|
|
26
|
+
* `FCD:` lines are composer reads; `FCDCLIP:` lines are clipboard reads while
|
|
27
|
+
* Claude Desktop is focused.
|
|
28
|
+
*/
|
|
29
|
+
export declare function decodeWatcherLine(line: string): {
|
|
30
|
+
source: WatcherSource;
|
|
31
|
+
text: string;
|
|
32
|
+
} | undefined;
|
|
23
33
|
export interface DesktopChatGuardHandle {
|
|
24
34
|
stop(): void;
|
|
25
35
|
}
|
|
@@ -47,6 +47,7 @@ const readline = __importStar(require("readline"));
|
|
|
47
47
|
const config_1 = require("../config");
|
|
48
48
|
const localSafetySnapshot_1 = require("../localSafetySnapshot");
|
|
49
49
|
const deterministicGuard_1 = require("./deterministicGuard");
|
|
50
|
+
const clipboardScan_1 = require("./clipboardScan");
|
|
50
51
|
const telemetry_1 = require("../telemetry");
|
|
51
52
|
const notify_1 = require("../notify");
|
|
52
53
|
const machineIdentity_1 = require("../machineIdentity");
|
|
@@ -60,43 +61,45 @@ const discoverPaths_1 = require("./discoverPaths");
|
|
|
60
61
|
* closes that blind spot with the SAME on-device deterministic engine used
|
|
61
62
|
* everywhere else (scanDeterministicPrompt).
|
|
62
63
|
*
|
|
63
|
-
* Reading the composer — MSAA
|
|
64
|
+
* Reading the composer — MSAA, passive best-effort:
|
|
64
65
|
* Claude Desktop is Electron/Chromium. Chromium keeps web-content
|
|
65
66
|
* accessibility OFF until it detects assistive tech, and on current Claude
|
|
66
|
-
* builds
|
|
67
|
+
* builds no passive wake signal turns it on from outside the process
|
|
67
68
|
* (verified against Claude 1.20186: WM_GETOBJECT for OBJID_CLIENT and for the
|
|
68
69
|
* custom screen-reader object id 1, SPI_SETSCREENREADER, a UIA FindAll walk,
|
|
69
70
|
* and an NVDA-style IServiceProvider->IAccessible2 handshake all leave the
|
|
70
|
-
* web area empty).
|
|
71
|
-
*
|
|
72
|
-
*
|
|
73
|
-
*
|
|
74
|
-
*
|
|
75
|
-
*
|
|
76
|
-
*
|
|
71
|
+
* web area empty). The only reliable switch is launching Claude with
|
|
72
|
+
* --force-renderer-accessibility — but force-restarting the user's app is
|
|
73
|
+
* too disruptive for a fleet product, so we deliberately DON'T. The guard
|
|
74
|
+
* reads via MSAA (oleacc's AccessibleObjectFromWindow on the main
|
|
75
|
+
* Chrome_WidgetWin_1 window, accValue of ROLE_SYSTEM_TEXT / 0x2A nodes) and
|
|
76
|
+
* simply works when accessibility happens to be active (screen-reader users,
|
|
77
|
+
* older builds, or the app launched with the flag) and stays silent when not.
|
|
77
78
|
*
|
|
78
|
-
*
|
|
79
|
-
*
|
|
80
|
-
* "Claude" process — never any other application), scans it in-process, and —
|
|
81
|
-
* on a finding — shows a native toast and reports a monitor event to the fleet.
|
|
82
|
-
* Text is never uploaded; only finding metadata (item id + masked value) is
|
|
83
|
-
* spooled. It cannot break typing and presents no keylogger surface.
|
|
79
|
+
* The SUPPORTED protection surface for Claude Desktop is its MCP tool calls
|
|
80
|
+
* through the FCD gateway — that is where enforcement happens.
|
|
84
81
|
*
|
|
85
|
-
*
|
|
86
|
-
* the
|
|
87
|
-
*
|
|
88
|
-
*
|
|
82
|
+
* Paste protection (clipboard) — the reliable surface:
|
|
83
|
+
* Because reading the composer is best-effort, the guard ALSO watches the
|
|
84
|
+
* Windows clipboard, but ONLY while Claude Desktop is the foreground window.
|
|
85
|
+
* When a developer pastes a secret into Claude (the exact scenario we keep
|
|
86
|
+
* testing), the value is on the clipboard at that moment — the guard runs it
|
|
87
|
+
* through the SAME deterministic engine and warns before it reaches the model.
|
|
88
|
+
* Scoping to the Claude foreground window means a secret copied for any other
|
|
89
|
+
* app is never inspected. This is app-agnostic detection reused only here.
|
|
90
|
+
*
|
|
91
|
+
* Advisory ONLY: no keyboard hook, no input mutation, no keystroke capture, and
|
|
92
|
+
* it never writes to the clipboard. It reads the composer text and (only when
|
|
93
|
+
* Claude is focused) the clipboard, scans in-process, and — on a finding —
|
|
94
|
+
* shows a native warning window and reports a monitor event to the fleet. Text
|
|
95
|
+
* is never uploaded; only finding metadata (item id + masked value) is spooled.
|
|
96
|
+
* It cannot break typing and presents no keylogger surface.
|
|
89
97
|
*/
|
|
90
98
|
const WATCHER_PS1_PATH = path.join(os.homedir(), '.fullcourtdefense-claude-desktop-watcher.ps1');
|
|
91
99
|
const STATUS_PATH = path.join(os.homedir(), '.fullcourtdefense', 'claude-desktop-guard.json');
|
|
92
|
-
/** Throttle marker for the one-time --force-renderer-accessibility relaunch. */
|
|
93
|
-
const A11Y_RELAUNCH_MARKER = path.join(os.homedir(), '.fullcourtdefense', 'claude-a11y-relaunch.json');
|
|
94
|
-
/** Never relaunch Claude more than once per this window (a paste-time restart
|
|
95
|
-
* is disruptive; once the flag sticks it survives until Claude next updates). */
|
|
96
|
-
const A11Y_RELAUNCH_THROTTLE_MS = 6 * 60 * 60_000;
|
|
97
|
-
/** How often to re-check that Claude is still running with the a11y flag. */
|
|
98
|
-
const A11Y_CHECK_INTERVAL_MS = 5 * 60_000;
|
|
99
100
|
const STDOUT_PREFIX = 'FCD:';
|
|
101
|
+
/** Prefix for clipboard payloads (distinct from composer reads). */
|
|
102
|
+
const CLIP_PREFIX = 'FCDCLIP:';
|
|
100
103
|
/** Poll cadence for the composer read (ms). 800ms lost the race against a
|
|
101
104
|
* fast paste+Enter (the composer clears before the next read); 250ms is still
|
|
102
105
|
* negligible CPU for one MSAA tree walk. */
|
|
@@ -139,10 +142,12 @@ function desktopChatGuardHealthy(withinMs = 15 * 60_000) {
|
|
|
139
142
|
}
|
|
140
143
|
/**
|
|
141
144
|
* The PowerShell watcher. Compiles a tiny MSAA reader (C# via Add-Type using
|
|
142
|
-
* the .NET Framework compiler built into Windows), then —
|
|
143
|
-
*
|
|
144
|
-
* `FCD:<base64 utf8>`
|
|
145
|
-
*
|
|
145
|
+
* the .NET Framework compiler built into Windows), then — while the "Claude"
|
|
146
|
+
* process is running — reads the composer's editable text (emitting
|
|
147
|
+
* `FCD:<base64 utf8>`) and, only while Claude Desktop is the FOREGROUND window,
|
|
148
|
+
* the clipboard contents (emitting `FCDCLIP:<base64 utf8>`) when either
|
|
149
|
+
* changes. Everything is wrapped in try/catch — it must never crash the host,
|
|
150
|
+
* and it never writes to the clipboard or synthesizes input.
|
|
146
151
|
*/
|
|
147
152
|
function desktopChatWatcherScript() {
|
|
148
153
|
return [
|
|
@@ -167,8 +172,15 @@ function desktopChatWatcherScript() {
|
|
|
167
172
|
' [DllImport("user32.dll")] static extern int GetWindowText(IntPtr h, StringBuilder sb, int max);',
|
|
168
173
|
' [DllImport("user32.dll")] static extern uint GetWindowThreadProcessId(IntPtr h, out uint pid);',
|
|
169
174
|
' [DllImport("user32.dll")] static extern IntPtr SendMessage(IntPtr h, uint msg, IntPtr wParam, IntPtr lParam);',
|
|
175
|
+
' [DllImport("user32.dll")] static extern IntPtr GetForegroundWindow();',
|
|
170
176
|
' const uint WM_GETOBJECT = 0x003D;',
|
|
171
177
|
' public delegate bool EnumProc(IntPtr h, IntPtr p);',
|
|
178
|
+
' public static bool ClaudeIsForeground(HashSet<uint> pids) {',
|
|
179
|
+
' IntPtr fg = GetForegroundWindow();',
|
|
180
|
+
' if (fg == IntPtr.Zero) return false;',
|
|
181
|
+
' uint pid; GetWindowThreadProcessId(fg, out pid);',
|
|
182
|
+
' return pids.Contains(pid);',
|
|
183
|
+
' }',
|
|
172
184
|
' public static IntPtr FindMain(HashSet<uint> pids) {',
|
|
173
185
|
' IntPtr result = IntPtr.Zero;',
|
|
174
186
|
' EnumWindows((h, p) => {',
|
|
@@ -231,6 +243,7 @@ function desktopChatWatcherScript() {
|
|
|
231
243
|
'} catch { }',
|
|
232
244
|
'',
|
|
233
245
|
'$last = ""',
|
|
246
|
+
'$lastClip = ""',
|
|
234
247
|
'$lastWake = [DateTime]::MinValue',
|
|
235
248
|
'while ($true) {',
|
|
236
249
|
' Start-Sleep -Milliseconds ' + POLL_MS,
|
|
@@ -238,6 +251,19 @@ function desktopChatWatcherScript() {
|
|
|
238
251
|
' $pids = New-Object \'System.Collections.Generic.HashSet[uint32]\'',
|
|
239
252
|
" Get-Process -Name 'Claude' -ErrorAction SilentlyContinue | ForEach-Object { [void]$pids.Add([uint32]$_.Id) }",
|
|
240
253
|
' if ($pids.Count -eq 0) { continue }',
|
|
254
|
+
// Clipboard paste guard: only while Claude Desktop is the foreground app, so
|
|
255
|
+
// a copy staged for any OTHER application is never inspected or reported.
|
|
256
|
+
// The deterministic engine (parent process) decides if the text is a secret.
|
|
257
|
+
' if ([FcdMsaa]::ClaudeIsForeground($pids)) {',
|
|
258
|
+
' $clip = ""',
|
|
259
|
+
' try { $clip = Get-Clipboard -Raw -ErrorAction SilentlyContinue } catch { }',
|
|
260
|
+
' if (-not [string]::IsNullOrEmpty($clip) -and $clip.Length -le 8000 -and $clip -ne $lastClip) {',
|
|
261
|
+
' $lastClip = $clip',
|
|
262
|
+
' $cb = [System.Text.Encoding]::UTF8.GetBytes($clip)',
|
|
263
|
+
" [Console]::Out.WriteLine('" + CLIP_PREFIX + "' + [Convert]::ToBase64String($cb))",
|
|
264
|
+
' [Console]::Out.Flush()',
|
|
265
|
+
' }',
|
|
266
|
+
' }',
|
|
241
267
|
' $main = [FcdMsaa]::FindMain($pids)',
|
|
242
268
|
' if ($main -eq [IntPtr]::Zero) { continue }',
|
|
243
269
|
' if (([DateTime]::UtcNow - $lastWake).TotalSeconds -ge 30) { [FcdMsaa]::Wake($main); $lastWake = [DateTime]::UtcNow }',
|
|
@@ -264,12 +290,21 @@ function desktopChatWatcherScript() {
|
|
|
264
290
|
'',
|
|
265
291
|
].join('\n');
|
|
266
292
|
}
|
|
267
|
-
/**
|
|
293
|
+
/**
|
|
294
|
+
* Decode one watcher line into { source, text } (undefined if not ours).
|
|
295
|
+
* `FCD:` lines are composer reads; `FCDCLIP:` lines are clipboard reads while
|
|
296
|
+
* Claude Desktop is focused.
|
|
297
|
+
*/
|
|
268
298
|
function decodeWatcherLine(line) {
|
|
269
|
-
|
|
299
|
+
const prefix = line.startsWith(CLIP_PREFIX)
|
|
300
|
+
? { p: CLIP_PREFIX, source: 'clipboard' }
|
|
301
|
+
: line.startsWith(STDOUT_PREFIX)
|
|
302
|
+
? { p: STDOUT_PREFIX, source: 'composer' }
|
|
303
|
+
: undefined;
|
|
304
|
+
if (!prefix)
|
|
270
305
|
return undefined;
|
|
271
306
|
try {
|
|
272
|
-
return Buffer.from(line.slice(
|
|
307
|
+
return { source: prefix.source, text: Buffer.from(line.slice(prefix.p.length), 'base64').toString('utf8') };
|
|
273
308
|
}
|
|
274
309
|
catch {
|
|
275
310
|
return undefined;
|
|
@@ -279,137 +314,6 @@ function ensureWatcherScript() {
|
|
|
279
314
|
fs.writeFileSync(WATCHER_PS1_PATH, desktopChatWatcherScript(), { encoding: 'utf8' });
|
|
280
315
|
return WATCHER_PS1_PATH;
|
|
281
316
|
}
|
|
282
|
-
function relaunchThrottleOk(currentClaudeCreated) {
|
|
283
|
-
try {
|
|
284
|
-
const marker = JSON.parse(fs.readFileSync(A11Y_RELAUNCH_MARKER, 'utf8'));
|
|
285
|
-
const last = marker.lastAttemptAt || 0;
|
|
286
|
-
const sinceLast = Date.now() - last;
|
|
287
|
-
// Hard floor: never two relaunches within 15 minutes regardless of
|
|
288
|
-
// instance tracking — bounds the worst case if a relaunched Claude drops
|
|
289
|
-
// our flag (e.g. MSIX self-restart) so we can't kill it in a loop.
|
|
290
|
-
if (sinceLast < 15 * 60_000)
|
|
291
|
-
return false;
|
|
292
|
-
// Claude was restarted (update, crash, user quit/reopen) since our last
|
|
293
|
-
// relaunch attempt — the old throttle must not leave the composer blind.
|
|
294
|
-
// Legacy markers without claudeCreated also take this path once, then
|
|
295
|
-
// start tracking the instance.
|
|
296
|
-
if (currentClaudeCreated && marker.claudeCreated !== currentClaudeCreated) {
|
|
297
|
-
return true;
|
|
298
|
-
}
|
|
299
|
-
return sinceLast > A11Y_RELAUNCH_THROTTLE_MS;
|
|
300
|
-
}
|
|
301
|
-
catch {
|
|
302
|
-
return true;
|
|
303
|
-
}
|
|
304
|
-
}
|
|
305
|
-
function stampRelaunch(claudeCreated) {
|
|
306
|
-
try {
|
|
307
|
-
fs.mkdirSync(path.dirname(A11Y_RELAUNCH_MARKER), { recursive: true });
|
|
308
|
-
fs.writeFileSync(A11Y_RELAUNCH_MARKER, JSON.stringify({
|
|
309
|
-
lastAttemptAt: Date.now(),
|
|
310
|
-
...(claudeCreated ? { claudeCreated } : {}),
|
|
311
|
-
}), { encoding: 'utf8', mode: 0o600 });
|
|
312
|
-
}
|
|
313
|
-
catch { /* best-effort */ }
|
|
314
|
-
}
|
|
315
|
-
/**
|
|
316
|
-
* PowerShell that inspects the running Claude Desktop browser process and, when
|
|
317
|
-
* allowed, relaunches it with --force-renderer-accessibility. Chromium exposes
|
|
318
|
-
* web-content (the chat composer) to MSAA only when that switch is present, and
|
|
319
|
-
* no external/passive signal turns it on for current Claude builds. Emits a
|
|
320
|
-
* single `STATE:<...>` line so the caller can log/throttle honestly.
|
|
321
|
-
*
|
|
322
|
-
* STATE:not-running — nothing to do
|
|
323
|
-
* STATE:has-flag — already accessible, no action
|
|
324
|
-
* STATE:relaunched — was missing the flag; we restarted it with the flag
|
|
325
|
-
* STATE:needs-flag — missing the flag but relaunch not allowed (throttled)
|
|
326
|
-
* STATE:error:<msg>
|
|
327
|
-
*
|
|
328
|
-
* Only ever targets processes named exactly 'claude.exe' from their own install
|
|
329
|
-
* root, and only restarts a process the user already had open.
|
|
330
|
-
*/
|
|
331
|
-
function ensureAccessibilityScript(allowRelaunch) {
|
|
332
|
-
return [
|
|
333
|
-
"$ErrorActionPreference = 'SilentlyContinue'",
|
|
334
|
-
"$procs = Get-CimInstance Win32_Process -Filter \"Name = 'claude.exe'\"",
|
|
335
|
-
'if (-not $procs) { Write-Output \'STATE:not-running\'; exit 0 }',
|
|
336
|
-
// The browser (main) process is the claude.exe with no --type= child switch.
|
|
337
|
-
"$main = $procs | Where-Object { $_.CommandLine -and ($_.CommandLine -notmatch '--type=') } | Select-Object -First 1",
|
|
338
|
-
"if (-not $main) { Write-Output 'STATE:not-running'; exit 0 }",
|
|
339
|
-
"$created = $main.CreationDate",
|
|
340
|
-
"if ($main.CommandLine -match '--force-renderer-accessibility') { Write-Output ('STATE:has-flag:' + $created); exit 0 }",
|
|
341
|
-
allowRelaunch ? '' : "Write-Output ('STATE:needs-flag:' + $created); exit 0",
|
|
342
|
-
"$exe = $main.ExecutablePath",
|
|
343
|
-
"if (-not $exe -or -not (Test-Path $exe)) { Write-Output 'STATE:error:no-exe'; exit 0 }",
|
|
344
|
-
'try {',
|
|
345
|
-
" $procs | ForEach-Object { Stop-Process -Id $_.ProcessId -Force -ErrorAction SilentlyContinue }",
|
|
346
|
-
' Start-Sleep -Milliseconds 1500',
|
|
347
|
-
" Start-Process -FilePath $exe -ArgumentList '--force-renderer-accessibility'",
|
|
348
|
-
" Write-Output ('STATE:relaunched:' + $created)",
|
|
349
|
-
"} catch { Write-Output ('STATE:error:' + $_.Exception.Message) }",
|
|
350
|
-
].filter(Boolean).join('\n');
|
|
351
|
-
}
|
|
352
|
-
function parseAccessibilityState(out) {
|
|
353
|
-
const match = out.match(/STATE:([^:\r\n]+)(?::(.+))?/);
|
|
354
|
-
if (!match)
|
|
355
|
-
return { state: '' };
|
|
356
|
-
return { state: match[1].trim(), claudeCreated: match[2]?.trim() };
|
|
357
|
-
}
|
|
358
|
-
/**
|
|
359
|
-
* Ensure Claude Desktop is running with renderer accessibility so the composer
|
|
360
|
-
* is readable. Non-blocking best-effort; throttled to at most one relaunch per
|
|
361
|
-
* A11Y_RELAUNCH_THROTTLE_MS for the SAME Claude process instance. A fresh
|
|
362
|
-
* Claude restart (update, MSI, user reopen) bypasses the throttle.
|
|
363
|
-
*/
|
|
364
|
-
function ensureClaudeForceAccessibility(log) {
|
|
365
|
-
if (process.platform !== 'win32')
|
|
366
|
-
return;
|
|
367
|
-
// Probe first without committing to relaunch so we can compare process age
|
|
368
|
-
// against the throttle marker before deciding.
|
|
369
|
-
const probe = (0, child_process_1.spawn)('powershell', [
|
|
370
|
-
'-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass', '-WindowStyle', 'Hidden',
|
|
371
|
-
'-Command', ensureAccessibilityScript(false),
|
|
372
|
-
], { windowsHide: true, stdio: ['ignore', 'pipe', 'ignore'] });
|
|
373
|
-
let probeOut = '';
|
|
374
|
-
probe.stdout?.on('data', (d) => { probeOut += d.toString(); });
|
|
375
|
-
probe.on('error', () => { });
|
|
376
|
-
probe.on('exit', () => {
|
|
377
|
-
const { state, claudeCreated } = parseAccessibilityState(probeOut);
|
|
378
|
-
if (!state || state === 'not-running' || state.startsWith('has-flag'))
|
|
379
|
-
return;
|
|
380
|
-
if (!state.startsWith('needs-flag')) {
|
|
381
|
-
if (state.startsWith('error'))
|
|
382
|
-
log(`Claude Desktop guard: accessibility probe failed (${state}).`);
|
|
383
|
-
return;
|
|
384
|
-
}
|
|
385
|
-
const allow = relaunchThrottleOk(claudeCreated);
|
|
386
|
-
if (!allow) {
|
|
387
|
-
log('Claude Desktop guard: Claude is running without accessibility; relaunch throttled — composer stays unreadable until the next allowed relaunch.');
|
|
388
|
-
return;
|
|
389
|
-
}
|
|
390
|
-
const child = (0, child_process_1.spawn)('powershell', [
|
|
391
|
-
'-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass', '-WindowStyle', 'Hidden',
|
|
392
|
-
'-Command', ensureAccessibilityScript(true),
|
|
393
|
-
], { windowsHide: true, stdio: ['ignore', 'pipe', 'ignore'] });
|
|
394
|
-
let out = '';
|
|
395
|
-
child.stdout?.on('data', (d) => { out += d.toString(); });
|
|
396
|
-
child.on('error', () => { });
|
|
397
|
-
child.on('exit', () => {
|
|
398
|
-
const result = parseAccessibilityState(out);
|
|
399
|
-
if (result.state === 'relaunched') {
|
|
400
|
-
stampRelaunch(result.claudeCreated || claudeCreated);
|
|
401
|
-
log('Claude Desktop guard: relaunched Claude with --force-renderer-accessibility so the chat composer is readable.');
|
|
402
|
-
}
|
|
403
|
-
else if (result.state.startsWith('error')) {
|
|
404
|
-
// An error can surface AFTER we already force-stopped Claude (Start-Process
|
|
405
|
-
// threw). Stamp the throttle regardless so a half-failed restart can never
|
|
406
|
-
// loop the kill every 5 minutes — wait the full window before retrying.
|
|
407
|
-
stampRelaunch(result.claudeCreated || claudeCreated);
|
|
408
|
-
log(`Claude Desktop guard: could not enable accessibility (${result.state}); backing off before retry.`);
|
|
409
|
-
}
|
|
410
|
-
});
|
|
411
|
-
});
|
|
412
|
-
}
|
|
413
317
|
/**
|
|
414
318
|
* Start the advisory guard. Returns a handle whose stop() tears down the
|
|
415
319
|
* watcher and timers. Safe no-op (returns undefined) on unsupported platforms.
|
|
@@ -447,10 +351,12 @@ function startDesktopChatGuard(runtime) {
|
|
|
447
351
|
startedAt,
|
|
448
352
|
heartbeatAt: new Date().toISOString(),
|
|
449
353
|
});
|
|
450
|
-
const handleText = (text) => {
|
|
354
|
+
const handleText = (text, source) => {
|
|
451
355
|
if (!text.trim())
|
|
452
356
|
return;
|
|
453
|
-
const finding =
|
|
357
|
+
const finding = source === 'clipboard'
|
|
358
|
+
? (0, clipboardScan_1.scanDeterministicClipboard)(text, scanOptions)
|
|
359
|
+
: (0, deterministicGuard_1.scanDeterministicPrompt)(text, scanOptions);
|
|
454
360
|
if (!finding)
|
|
455
361
|
return;
|
|
456
362
|
findings += 1;
|
|
@@ -462,7 +368,7 @@ function startDesktopChatGuard(runtime) {
|
|
|
462
368
|
(0, telemetry_1.spoolEvent)({
|
|
463
369
|
decision: 'allow',
|
|
464
370
|
toolName: 'claude_desktop_chat',
|
|
465
|
-
operation: 'prompt',
|
|
371
|
+
operation: source === 'clipboard' ? 'clipboard_paste' : 'prompt',
|
|
466
372
|
reason: `would block: ${finding.reason}`,
|
|
467
373
|
ruleId: finding.ruleId,
|
|
468
374
|
category: finding.category,
|
|
@@ -474,21 +380,24 @@ function startDesktopChatGuard(runtime) {
|
|
|
474
380
|
policyHash: finding.policyHash,
|
|
475
381
|
});
|
|
476
382
|
(0, telemetry_1.triggerFlush)();
|
|
477
|
-
const key = `${finding.itemId}:${masked}`;
|
|
383
|
+
const key = `${source}:${finding.itemId}:${masked}`;
|
|
478
384
|
const now = Date.now();
|
|
479
385
|
const last = lastToastAt.get(key) || 0;
|
|
480
386
|
if (!runtime.quiet && now - last > TOAST_DEBOUNCE_MS) {
|
|
481
387
|
lastToastAt.set(key, now);
|
|
388
|
+
const message = source === 'clipboard'
|
|
389
|
+
? `${finding.reason} You just copied this — do NOT paste it into Claude Desktop; its chat is not protected.`
|
|
390
|
+
: `${finding.reason}. Remove it before sending — this text has not been protected.`;
|
|
482
391
|
(0, notify_1.notifyOs)({
|
|
483
392
|
title: 'FullCourtDefense: secret in Claude Desktop',
|
|
484
|
-
message
|
|
393
|
+
message,
|
|
485
394
|
// Security warning: use a top-most window, not a toast. Toasts are
|
|
486
395
|
// silently swallowed by Focus Assist / Do-Not-Disturb and per-app
|
|
487
396
|
// banner settings — verified in the field — which would hide the alert.
|
|
488
397
|
forceWindow: true,
|
|
489
398
|
});
|
|
490
399
|
}
|
|
491
|
-
runtime.log(`Claude Desktop
|
|
400
|
+
runtime.log(`Claude Desktop ${source} finding: ${finding.itemId} (${finding.reason}).`);
|
|
492
401
|
};
|
|
493
402
|
const spawnWatcher = () => {
|
|
494
403
|
if (stopped)
|
|
@@ -511,11 +420,11 @@ function startDesktopChatGuard(runtime) {
|
|
|
511
420
|
}
|
|
512
421
|
const rl = readline.createInterface({ input: child.stdout });
|
|
513
422
|
rl.on('line', (line) => {
|
|
514
|
-
const
|
|
515
|
-
if (
|
|
423
|
+
const decoded = decodeWatcherLine(line);
|
|
424
|
+
if (decoded === undefined)
|
|
516
425
|
return;
|
|
517
426
|
restartDelay = RESTART_MIN_MS; // healthy output resets backoff
|
|
518
|
-
handleText(text);
|
|
427
|
+
handleText(decoded.text, decoded.source);
|
|
519
428
|
});
|
|
520
429
|
child.on('exit', () => { rl.close(); if (!stopped)
|
|
521
430
|
scheduleRestart(); });
|
|
@@ -534,10 +443,8 @@ function startDesktopChatGuard(runtime) {
|
|
|
534
443
|
restartDelay = Math.min(RESTART_MAX_MS, restartDelay * 2);
|
|
535
444
|
};
|
|
536
445
|
void refreshSnapshot();
|
|
537
|
-
ensureClaudeForceAccessibility(runtime.log);
|
|
538
446
|
const snapshotTimer = setInterval(() => { void refreshSnapshot(); }, SNAPSHOT_REFRESH_MS);
|
|
539
447
|
const heartbeatTimer = setInterval(heartbeat, 60_000);
|
|
540
|
-
const a11yTimer = setInterval(() => ensureClaudeForceAccessibility(runtime.log), A11Y_CHECK_INTERVAL_MS);
|
|
541
448
|
spawnWatcher();
|
|
542
449
|
return {
|
|
543
450
|
stop() {
|
|
@@ -546,7 +453,6 @@ function startDesktopChatGuard(runtime) {
|
|
|
546
453
|
stopped = true;
|
|
547
454
|
clearInterval(snapshotTimer);
|
|
548
455
|
clearInterval(heartbeatTimer);
|
|
549
|
-
clearInterval(a11yTimer);
|
|
550
456
|
if (restartTimer) {
|
|
551
457
|
clearTimeout(restartTimer);
|
|
552
458
|
restartTimer = undefined;
|
package/dist/version.json
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "fullcourtdefense-cli",
|
|
3
|
-
"version": "1.21.
|
|
3
|
+
"version": "1.21.8",
|
|
4
4
|
"description": "Full Court Defense CLI — security scanning for AI agents from your terminal",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"bin": {
|
|
@@ -35,6 +35,7 @@
|
|
|
35
35
|
"test:posture-overhaul": "npm run build && node scripts/test-posture-overhaul.js",
|
|
36
36
|
"test:dpapi-config": "npm run build && node scripts/test-dpapi-config.js",
|
|
37
37
|
"test:desktop-chat-guard": "npm run build && node scripts/test-desktop-chat-guard.js",
|
|
38
|
+
"test:clipboard-scan": "npm run build && node scripts/test-clipboard-scan.js",
|
|
38
39
|
"build:msi": "powershell -NoProfile -ExecutionPolicy Bypass -File installer/windows/Build-Msi.ps1",
|
|
39
40
|
"prepublishOnly": "npm run build"
|
|
40
41
|
},
|