fullcourtdefense-cli 1.21.2 → 1.21.4
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/desktopChatGuard.js +131 -14
- package/dist/notify.d.ts +7 -0
- package/dist/notify.js +62 -2
- package/dist/version.json +1 -1
- package/package.json +1 -1
|
@@ -60,17 +60,20 @@ const discoverPaths_1 = require("./discoverPaths");
|
|
|
60
60
|
* closes that blind spot with the SAME on-device deterministic engine used
|
|
61
61
|
* everywhere else (scanDeterministicPrompt).
|
|
62
62
|
*
|
|
63
|
-
* Reading the composer —
|
|
64
|
-
* Claude Desktop is Electron/Chromium. Chromium
|
|
65
|
-
*
|
|
66
|
-
*
|
|
67
|
-
*
|
|
68
|
-
*
|
|
69
|
-
*
|
|
70
|
-
*
|
|
71
|
-
*
|
|
72
|
-
*
|
|
73
|
-
*
|
|
63
|
+
* Reading the composer — MSAA + forced renderer accessibility:
|
|
64
|
+
* Claude Desktop is Electron/Chromium. Chromium keeps web-content
|
|
65
|
+
* accessibility OFF until it detects assistive tech, and on current Claude
|
|
66
|
+
* builds NONE of the passive wake signals work from outside the process
|
|
67
|
+
* (verified against Claude 1.20186: WM_GETOBJECT for OBJID_CLIENT and for the
|
|
68
|
+
* custom screen-reader object id 1, SPI_SETSCREENREADER, a UIA FindAll walk,
|
|
69
|
+
* and an NVDA-style IServiceProvider->IAccessible2 handshake all leave the
|
|
70
|
+
* web area empty). Chromium documents exactly one reliable switch: launch the
|
|
71
|
+
* app with --force-renderer-accessibility. So the guard ensures Claude runs
|
|
72
|
+
* with that flag (a one-time, throttled relaunch when the flag is absent),
|
|
73
|
+
* after which the composer text is exposed via MSAA: oleacc's
|
|
74
|
+
* AccessibleObjectFromWindow on the main Chrome_WidgetWin_1 window, walk the
|
|
75
|
+
* IAccessible tree, read accValue of the editable text node (ROLE_SYSTEM_TEXT
|
|
76
|
+
* / 0x2A). Once the flag is present the guard never touches Claude again.
|
|
74
77
|
*
|
|
75
78
|
* Advisory ONLY: no keyboard hook, no input mutation, no keystroke capture. It
|
|
76
79
|
* reads only the composer text of Claude Desktop's own window (scoped to the
|
|
@@ -86,9 +89,18 @@ const discoverPaths_1 = require("./discoverPaths");
|
|
|
86
89
|
*/
|
|
87
90
|
const WATCHER_PS1_PATH = path.join(os.homedir(), '.fullcourtdefense-claude-desktop-watcher.ps1');
|
|
88
91
|
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;
|
|
89
99
|
const STDOUT_PREFIX = 'FCD:';
|
|
90
|
-
/** Poll cadence for the composer read (ms).
|
|
91
|
-
|
|
100
|
+
/** Poll cadence for the composer read (ms). 800ms lost the race against a
|
|
101
|
+
* fast paste+Enter (the composer clears before the next read); 250ms is still
|
|
102
|
+
* negligible CPU for one MSAA tree walk. */
|
|
103
|
+
const POLL_MS = 250;
|
|
92
104
|
/** Don't re-toast the same finding value more often than this. */
|
|
93
105
|
const TOAST_DEBOUNCE_MS = 60_000;
|
|
94
106
|
/** Refresh the cached Local Safety snapshot on this cadence. */
|
|
@@ -154,6 +166,8 @@ function desktopChatWatcherScript() {
|
|
|
154
166
|
' [DllImport("user32.dll")] static extern int GetClassName(IntPtr h, StringBuilder sb, int max);',
|
|
155
167
|
' [DllImport("user32.dll")] static extern int GetWindowText(IntPtr h, StringBuilder sb, int max);',
|
|
156
168
|
' [DllImport("user32.dll")] static extern uint GetWindowThreadProcessId(IntPtr h, out uint pid);',
|
|
169
|
+
' [DllImport("user32.dll")] static extern IntPtr SendMessage(IntPtr h, uint msg, IntPtr wParam, IntPtr lParam);',
|
|
170
|
+
' const uint WM_GETOBJECT = 0x003D;',
|
|
157
171
|
' public delegate bool EnumProc(IntPtr h, IntPtr p);',
|
|
158
172
|
' public static IntPtr FindMain(HashSet<uint> pids) {',
|
|
159
173
|
' IntPtr result = IntPtr.Zero;',
|
|
@@ -193,9 +207,17 @@ function desktopChatWatcherScript() {
|
|
|
193
207
|
' try { AccessibleChildren(acc, 0, count, kids, out got); } catch { return; }',
|
|
194
208
|
' for (int i = 0; i < got; i++) { var ka = kids[i] as IAccessible; if (ka != null) Walk(ka, outp, depth + 1, budget); }',
|
|
195
209
|
' }',
|
|
210
|
+
' public static void Wake(IntPtr main) {',
|
|
211
|
+
' // Newer Claude Desktop builds no longer enable renderer accessibility',
|
|
212
|
+
' // from AccessibleObjectFromWindow alone; Chromium turns it on when it',
|
|
213
|
+
' // observes WM_GETOBJECT for OBJID_CLIENT on the render widget.',
|
|
214
|
+
' foreach (var w in Widgets(main)) SendMessage(w, WM_GETOBJECT, IntPtr.Zero, new IntPtr(unchecked((int)OBJID_CLIENT)));',
|
|
215
|
+
' SendMessage(main, WM_GETOBJECT, IntPtr.Zero, new IntPtr(unchecked((int)OBJID_CLIENT)));',
|
|
216
|
+
' }',
|
|
196
217
|
' public static string[] ReadComposer(IntPtr main) {',
|
|
197
218
|
' var outp = new List<string>();',
|
|
198
|
-
'
|
|
219
|
+
' var targets = Widgets(main); targets.Add(main);',
|
|
220
|
+
' foreach (var w in targets) {',
|
|
199
221
|
' object o; int hr = AccessibleObjectFromWindow(w, OBJID_CLIENT, ref IID_IAccessible, out o);',
|
|
200
222
|
' if (hr != 0 || o == null) continue;',
|
|
201
223
|
' var acc = o as IAccessible; if (acc == null) continue;',
|
|
@@ -209,6 +231,7 @@ function desktopChatWatcherScript() {
|
|
|
209
231
|
'} catch { }',
|
|
210
232
|
'',
|
|
211
233
|
'$last = ""',
|
|
234
|
+
'$lastWake = [DateTime]::MinValue',
|
|
212
235
|
'while ($true) {',
|
|
213
236
|
' Start-Sleep -Milliseconds ' + POLL_MS,
|
|
214
237
|
' try {',
|
|
@@ -217,6 +240,7 @@ function desktopChatWatcherScript() {
|
|
|
217
240
|
' if ($pids.Count -eq 0) { continue }',
|
|
218
241
|
' $main = [FcdMsaa]::FindMain($pids)',
|
|
219
242
|
' if ($main -eq [IntPtr]::Zero) { continue }',
|
|
243
|
+
' if (([DateTime]::UtcNow - $lastWake).TotalSeconds -ge 30) { [FcdMsaa]::Wake($main); $lastWake = [DateTime]::UtcNow }',
|
|
220
244
|
' $vals = [FcdMsaa]::ReadComposer($main)',
|
|
221
245
|
' if ($null -eq $vals -or $vals.Count -eq 0) { continue }',
|
|
222
246
|
' $parts = @()',
|
|
@@ -255,6 +279,92 @@ function ensureWatcherScript() {
|
|
|
255
279
|
fs.writeFileSync(WATCHER_PS1_PATH, desktopChatWatcherScript(), { encoding: 'utf8' });
|
|
256
280
|
return WATCHER_PS1_PATH;
|
|
257
281
|
}
|
|
282
|
+
function relaunchThrottleOk() {
|
|
283
|
+
try {
|
|
284
|
+
const last = JSON.parse(fs.readFileSync(A11Y_RELAUNCH_MARKER, 'utf8')).lastAttemptAt || 0;
|
|
285
|
+
return Date.now() - last > A11Y_RELAUNCH_THROTTLE_MS;
|
|
286
|
+
}
|
|
287
|
+
catch {
|
|
288
|
+
return true;
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
function stampRelaunch() {
|
|
292
|
+
try {
|
|
293
|
+
fs.mkdirSync(path.dirname(A11Y_RELAUNCH_MARKER), { recursive: true });
|
|
294
|
+
fs.writeFileSync(A11Y_RELAUNCH_MARKER, JSON.stringify({ lastAttemptAt: Date.now() }), { encoding: 'utf8', mode: 0o600 });
|
|
295
|
+
}
|
|
296
|
+
catch { /* best-effort */ }
|
|
297
|
+
}
|
|
298
|
+
/**
|
|
299
|
+
* PowerShell that inspects the running Claude Desktop browser process and, when
|
|
300
|
+
* allowed, relaunches it with --force-renderer-accessibility. Chromium exposes
|
|
301
|
+
* web-content (the chat composer) to MSAA only when that switch is present, and
|
|
302
|
+
* no external/passive signal turns it on for current Claude builds. Emits a
|
|
303
|
+
* single `STATE:<...>` line so the caller can log/throttle honestly.
|
|
304
|
+
*
|
|
305
|
+
* STATE:not-running — nothing to do
|
|
306
|
+
* STATE:has-flag — already accessible, no action
|
|
307
|
+
* STATE:relaunched — was missing the flag; we restarted it with the flag
|
|
308
|
+
* STATE:needs-flag — missing the flag but relaunch not allowed (throttled)
|
|
309
|
+
* STATE:error:<msg>
|
|
310
|
+
*
|
|
311
|
+
* Only ever targets processes named exactly 'claude.exe' from their own install
|
|
312
|
+
* root, and only restarts a process the user already had open.
|
|
313
|
+
*/
|
|
314
|
+
function ensureAccessibilityScript(allowRelaunch) {
|
|
315
|
+
return [
|
|
316
|
+
"$ErrorActionPreference = 'SilentlyContinue'",
|
|
317
|
+
"$procs = Get-CimInstance Win32_Process -Filter \"Name = 'claude.exe'\"",
|
|
318
|
+
'if (-not $procs) { Write-Output \'STATE:not-running\'; exit 0 }',
|
|
319
|
+
// The browser (main) process is the claude.exe with no --type= child switch.
|
|
320
|
+
"$main = $procs | Where-Object { $_.CommandLine -and ($_.CommandLine -notmatch '--type=') } | Select-Object -First 1",
|
|
321
|
+
"if (-not $main) { Write-Output 'STATE:not-running'; exit 0 }",
|
|
322
|
+
"if ($main.CommandLine -match '--force-renderer-accessibility') { Write-Output 'STATE:has-flag'; exit 0 }",
|
|
323
|
+
allowRelaunch ? '' : "Write-Output 'STATE:needs-flag'; exit 0",
|
|
324
|
+
"$exe = $main.ExecutablePath",
|
|
325
|
+
"if (-not $exe -or -not (Test-Path $exe)) { Write-Output 'STATE:error:no-exe'; exit 0 }",
|
|
326
|
+
'try {',
|
|
327
|
+
" $procs | ForEach-Object { Stop-Process -Id $_.ProcessId -Force -ErrorAction SilentlyContinue }",
|
|
328
|
+
' Start-Sleep -Milliseconds 1500',
|
|
329
|
+
" Start-Process -FilePath $exe -ArgumentList '--force-renderer-accessibility'",
|
|
330
|
+
" Write-Output 'STATE:relaunched'",
|
|
331
|
+
"} catch { Write-Output ('STATE:error:' + $_.Exception.Message) }",
|
|
332
|
+
].filter(Boolean).join('\n');
|
|
333
|
+
}
|
|
334
|
+
/**
|
|
335
|
+
* Ensure Claude Desktop is running with renderer accessibility so the composer
|
|
336
|
+
* is readable. Non-blocking best-effort; throttled to at most one relaunch per
|
|
337
|
+
* A11Y_RELAUNCH_THROTTLE_MS. Safe no-op off-Windows or when Claude is closed.
|
|
338
|
+
*/
|
|
339
|
+
function ensureClaudeForceAccessibility(log) {
|
|
340
|
+
if (process.platform !== 'win32')
|
|
341
|
+
return;
|
|
342
|
+
const allow = relaunchThrottleOk();
|
|
343
|
+
const child = (0, child_process_1.spawn)('powershell', [
|
|
344
|
+
'-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass', '-WindowStyle', 'Hidden',
|
|
345
|
+
'-Command', ensureAccessibilityScript(allow),
|
|
346
|
+
], { windowsHide: true, stdio: ['ignore', 'pipe', 'ignore'] });
|
|
347
|
+
let out = '';
|
|
348
|
+
child.stdout?.on('data', (d) => { out += d.toString(); });
|
|
349
|
+
child.on('error', () => { });
|
|
350
|
+
child.on('exit', () => {
|
|
351
|
+
const state = (out.match(/STATE:(.*)/) || [])[1]?.trim();
|
|
352
|
+
if (state === 'relaunched') {
|
|
353
|
+
stampRelaunch();
|
|
354
|
+
log('Claude Desktop guard: relaunched Claude with --force-renderer-accessibility so the chat composer is readable.');
|
|
355
|
+
}
|
|
356
|
+
else if (state === 'needs-flag') {
|
|
357
|
+
log('Claude Desktop guard: Claude is running without accessibility; relaunch throttled — composer stays unreadable until the next allowed relaunch.');
|
|
358
|
+
}
|
|
359
|
+
else if (state && state.startsWith('error')) {
|
|
360
|
+
// An error can surface AFTER we already force-stopped Claude (Start-Process
|
|
361
|
+
// threw). Stamp the throttle regardless so a half-failed restart can never
|
|
362
|
+
// loop the kill every 5 minutes — wait the full window before retrying.
|
|
363
|
+
stampRelaunch();
|
|
364
|
+
log(`Claude Desktop guard: could not enable accessibility (${state}); backing off before retry.`);
|
|
365
|
+
}
|
|
366
|
+
});
|
|
367
|
+
}
|
|
258
368
|
/**
|
|
259
369
|
* Start the advisory guard. Returns a handle whose stop() tears down the
|
|
260
370
|
* watcher and timers. Safe no-op (returns undefined) on unsupported platforms.
|
|
@@ -327,6 +437,10 @@ function startDesktopChatGuard(runtime) {
|
|
|
327
437
|
(0, notify_1.notifyOs)({
|
|
328
438
|
title: 'FullCourtDefense: secret in Claude Desktop',
|
|
329
439
|
message: `${finding.reason}. Remove it before sending — this text has not been protected.`,
|
|
440
|
+
// Security warning: use a top-most window, not a toast. Toasts are
|
|
441
|
+
// silently swallowed by Focus Assist / Do-Not-Disturb and per-app
|
|
442
|
+
// banner settings — verified in the field — which would hide the alert.
|
|
443
|
+
forceWindow: true,
|
|
330
444
|
});
|
|
331
445
|
}
|
|
332
446
|
runtime.log(`Claude Desktop chat finding: ${finding.itemId} (${finding.reason}).`);
|
|
@@ -375,8 +489,10 @@ function startDesktopChatGuard(runtime) {
|
|
|
375
489
|
restartDelay = Math.min(RESTART_MAX_MS, restartDelay * 2);
|
|
376
490
|
};
|
|
377
491
|
void refreshSnapshot();
|
|
492
|
+
ensureClaudeForceAccessibility(runtime.log);
|
|
378
493
|
const snapshotTimer = setInterval(() => { void refreshSnapshot(); }, SNAPSHOT_REFRESH_MS);
|
|
379
494
|
const heartbeatTimer = setInterval(heartbeat, 60_000);
|
|
495
|
+
const a11yTimer = setInterval(() => ensureClaudeForceAccessibility(runtime.log), A11Y_CHECK_INTERVAL_MS);
|
|
380
496
|
spawnWatcher();
|
|
381
497
|
return {
|
|
382
498
|
stop() {
|
|
@@ -385,6 +501,7 @@ function startDesktopChatGuard(runtime) {
|
|
|
385
501
|
stopped = true;
|
|
386
502
|
clearInterval(snapshotTimer);
|
|
387
503
|
clearInterval(heartbeatTimer);
|
|
504
|
+
clearInterval(a11yTimer);
|
|
388
505
|
if (restartTimer) {
|
|
389
506
|
clearTimeout(restartTimer);
|
|
390
507
|
restartTimer = undefined;
|
package/dist/notify.d.ts
CHANGED
|
@@ -14,6 +14,13 @@ export interface OsNotification {
|
|
|
14
14
|
message: string;
|
|
15
15
|
/** Optional URL. Platforms that support click-through open it; others append it to the body. */
|
|
16
16
|
url?: string;
|
|
17
|
+
/**
|
|
18
|
+
* Windows only: render a top-most alert WINDOW instead of a toast. Toasts are
|
|
19
|
+
* silently suppressed by Focus Assist / Do-Not-Disturb, per-app banner
|
|
20
|
+
* settings, and rapid-fire dedup — unacceptable for a security warning the
|
|
21
|
+
* user MUST see. A plain window is not a notification, so none of that applies.
|
|
22
|
+
*/
|
|
23
|
+
forceWindow?: boolean;
|
|
17
24
|
}
|
|
18
25
|
/** Show a native OS notification. Best-effort, non-blocking, never throws. */
|
|
19
26
|
export declare function notifyOs(n: OsNotification): void;
|
package/dist/notify.js
CHANGED
|
@@ -4,9 +4,17 @@ exports.notifyOs = notifyOs;
|
|
|
4
4
|
exports.consoleUrl = consoleUrl;
|
|
5
5
|
exports.approvalsConsoleUrl = approvalsConsoleUrl;
|
|
6
6
|
const child_process_1 = require("child_process");
|
|
7
|
-
function spawnDetached(command, args) {
|
|
7
|
+
function spawnDetached(command, args, opts) {
|
|
8
8
|
try {
|
|
9
|
-
const child = (0, child_process_1.spawn)(command, args, {
|
|
9
|
+
const child = (0, child_process_1.spawn)(command, args, {
|
|
10
|
+
detached: true,
|
|
11
|
+
stdio: 'ignore',
|
|
12
|
+
// windowsHide sets the process show-state to SW_HIDE, which a GUI child's
|
|
13
|
+
// FIRST top-level window inherits — that silently hid the alert form.
|
|
14
|
+
// showWindow:true (used for the forced alert) must NOT hide it.
|
|
15
|
+
windowsHide: opts?.showWindow ? false : true,
|
|
16
|
+
env: opts?.env ? { ...process.env, ...opts.env } : process.env,
|
|
17
|
+
});
|
|
10
18
|
child.unref();
|
|
11
19
|
}
|
|
12
20
|
catch { /* best-effort */ }
|
|
@@ -19,7 +27,59 @@ function psQuote(value) {
|
|
|
19
27
|
function asQuote(value) {
|
|
20
28
|
return value.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
|
|
21
29
|
}
|
|
30
|
+
/**
|
|
31
|
+
* Top-most WinForms alert window. Unlike a toast, this is an ordinary window,
|
|
32
|
+
* so Focus Assist / Do-Not-Disturb and per-app notification settings cannot
|
|
33
|
+
* hide it. Auto-dismisses after ~12s so it never blocks the machine, and reads
|
|
34
|
+
* its text from env vars (no string interpolation into the script → no quoting
|
|
35
|
+
* or injection issues with arbitrary secret-adjacent message text).
|
|
36
|
+
*/
|
|
37
|
+
function notifyWindowsWindow(n) {
|
|
38
|
+
const body = n.url ? `${n.message}\n${n.url}` : n.message;
|
|
39
|
+
// Node composes the full label text (with real newlines) and hands it to
|
|
40
|
+
// PowerShell via an env var. The script does ZERO string concatenation or
|
|
41
|
+
// escaping — arbitrary secret-adjacent message text can never break parsing
|
|
42
|
+
// or inject code.
|
|
43
|
+
const script = [
|
|
44
|
+
"$ErrorActionPreference='SilentlyContinue';",
|
|
45
|
+
'Add-Type -AssemblyName System.Windows.Forms;',
|
|
46
|
+
'Add-Type -AssemblyName System.Drawing;',
|
|
47
|
+
'$f=New-Object System.Windows.Forms.Form;',
|
|
48
|
+
'$f.Text=$env:FCD_NOTIFY_TITLE;',
|
|
49
|
+
'$f.StartPosition="Manual";',
|
|
50
|
+
'$wa=[System.Windows.Forms.Screen]::PrimaryScreen.WorkingArea;',
|
|
51
|
+
'$f.Size=New-Object System.Drawing.Size(460,160);',
|
|
52
|
+
'$f.Location=New-Object System.Drawing.Point(($wa.Right-475),($wa.Bottom-180));',
|
|
53
|
+
'$f.TopMost=$true;',
|
|
54
|
+
'$f.FormBorderStyle="FixedDialog";',
|
|
55
|
+
'$f.MaximizeBox=$false; $f.MinimizeBox=$false; $f.ShowInTaskbar=$true;',
|
|
56
|
+
'$f.BackColor=[System.Drawing.Color]::FromArgb(180,30,40);',
|
|
57
|
+
'$l=New-Object System.Windows.Forms.Label;',
|
|
58
|
+
'$l.Text=$env:FCD_NOTIFY_TEXT;',
|
|
59
|
+
'$l.ForeColor=[System.Drawing.Color]::White;',
|
|
60
|
+
'$l.Font=New-Object System.Drawing.Font("Segoe UI",10,[System.Drawing.FontStyle]::Bold);',
|
|
61
|
+
'$l.Dock="Fill"; $l.Padding=New-Object System.Windows.Forms.Padding(14);',
|
|
62
|
+
'$f.Controls.Add($l);',
|
|
63
|
+
'$tm=New-Object System.Windows.Forms.Timer;',
|
|
64
|
+
'$tm.Interval=12000;',
|
|
65
|
+
'$tm.Add_Tick({$tm.Stop();$f.Close()});',
|
|
66
|
+
'$tm.Start();',
|
|
67
|
+
'$f.Add_Shown({$f.Activate();$f.BringToFront()});',
|
|
68
|
+
'[System.Windows.Forms.Application]::Run($f);',
|
|
69
|
+
].join(' ');
|
|
70
|
+
spawnDetached('powershell', ['-NoProfile', '-NonInteractive', '-STA', '-Command', script], {
|
|
71
|
+
env: {
|
|
72
|
+
FCD_NOTIFY_TITLE: n.title,
|
|
73
|
+
FCD_NOTIFY_TEXT: `${n.title}\r\n\r\n${body}`,
|
|
74
|
+
},
|
|
75
|
+
showWindow: true,
|
|
76
|
+
});
|
|
77
|
+
}
|
|
22
78
|
function notifyWindows(n) {
|
|
79
|
+
if (n.forceWindow) {
|
|
80
|
+
notifyWindowsWindow(n);
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
23
83
|
const body = n.url ? `${n.message}\n${n.url}` : n.message;
|
|
24
84
|
// WinRT toast via PowerShell — works from a plain console process for the
|
|
25
85
|
// current user, no admin/UAC and no extra dependencies. Uses the stock
|
package/dist/version.json
CHANGED