fullcourtdefense-cli 1.21.3 → 1.21.5

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.
@@ -279,19 +279,28 @@ function ensureWatcherScript() {
279
279
  fs.writeFileSync(WATCHER_PS1_PATH, desktopChatWatcherScript(), { encoding: 'utf8' });
280
280
  return WATCHER_PS1_PATH;
281
281
  }
282
- function relaunchThrottleOk() {
282
+ function relaunchThrottleOk(currentClaudeCreated) {
283
283
  try {
284
- const last = JSON.parse(fs.readFileSync(A11Y_RELAUNCH_MARKER, 'utf8')).lastAttemptAt || 0;
284
+ const marker = JSON.parse(fs.readFileSync(A11Y_RELAUNCH_MARKER, 'utf8'));
285
+ const last = marker.lastAttemptAt || 0;
286
+ // Claude was restarted (update, crash, user quit/reopen) since our last
287
+ // relaunch attempt — the old throttle must not leave the composer blind.
288
+ if (currentClaudeCreated && marker.claudeCreated && currentClaudeCreated !== marker.claudeCreated) {
289
+ return true;
290
+ }
285
291
  return Date.now() - last > A11Y_RELAUNCH_THROTTLE_MS;
286
292
  }
287
293
  catch {
288
294
  return true;
289
295
  }
290
296
  }
291
- function stampRelaunch() {
297
+ function stampRelaunch(claudeCreated) {
292
298
  try {
293
299
  fs.mkdirSync(path.dirname(A11Y_RELAUNCH_MARKER), { recursive: true });
294
- fs.writeFileSync(A11Y_RELAUNCH_MARKER, JSON.stringify({ lastAttemptAt: Date.now() }), { encoding: 'utf8', mode: 0o600 });
300
+ fs.writeFileSync(A11Y_RELAUNCH_MARKER, JSON.stringify({
301
+ lastAttemptAt: Date.now(),
302
+ ...(claudeCreated ? { claudeCreated } : {}),
303
+ }), { encoding: 'utf8', mode: 0o600 });
295
304
  }
296
305
  catch { /* best-effort */ }
297
306
  }
@@ -319,50 +328,78 @@ function ensureAccessibilityScript(allowRelaunch) {
319
328
  // The browser (main) process is the claude.exe with no --type= child switch.
320
329
  "$main = $procs | Where-Object { $_.CommandLine -and ($_.CommandLine -notmatch '--type=') } | Select-Object -First 1",
321
330
  "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",
331
+ "$created = $main.CreationDate",
332
+ "if ($main.CommandLine -match '--force-renderer-accessibility') { Write-Output ('STATE:has-flag:' + $created); exit 0 }",
333
+ allowRelaunch ? '' : "Write-Output ('STATE:needs-flag:' + $created); exit 0",
324
334
  "$exe = $main.ExecutablePath",
325
335
  "if (-not $exe -or -not (Test-Path $exe)) { Write-Output 'STATE:error:no-exe'; exit 0 }",
326
336
  'try {',
327
337
  " $procs | ForEach-Object { Stop-Process -Id $_.ProcessId -Force -ErrorAction SilentlyContinue }",
328
338
  ' Start-Sleep -Milliseconds 1500',
329
339
  " Start-Process -FilePath $exe -ArgumentList '--force-renderer-accessibility'",
330
- " Write-Output 'STATE:relaunched'",
340
+ " Write-Output ('STATE:relaunched:' + $created)",
331
341
  "} catch { Write-Output ('STATE:error:' + $_.Exception.Message) }",
332
342
  ].filter(Boolean).join('\n');
333
343
  }
344
+ function parseAccessibilityState(out) {
345
+ const match = out.match(/STATE:([^:\r\n]+)(?::(.+))?/);
346
+ if (!match)
347
+ return { state: '' };
348
+ return { state: match[1].trim(), claudeCreated: match[2]?.trim() };
349
+ }
334
350
  /**
335
351
  * Ensure Claude Desktop is running with renderer accessibility so the composer
336
352
  * 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.
353
+ * A11Y_RELAUNCH_THROTTLE_MS for the SAME Claude process instance. A fresh
354
+ * Claude restart (update, MSI, user reopen) bypasses the throttle.
338
355
  */
339
356
  function ensureClaudeForceAccessibility(log) {
340
357
  if (process.platform !== 'win32')
341
358
  return;
342
- const allow = relaunchThrottleOk();
343
- const child = (0, child_process_1.spawn)('powershell', [
359
+ // Probe first without committing to relaunch so we can compare process age
360
+ // against the throttle marker before deciding.
361
+ const probe = (0, child_process_1.spawn)('powershell', [
344
362
  '-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass', '-WindowStyle', 'Hidden',
345
- '-Command', ensureAccessibilityScript(allow),
363
+ '-Command', ensureAccessibilityScript(false),
346
364
  ], { 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.');
365
+ let probeOut = '';
366
+ probe.stdout?.on('data', (d) => { probeOut += d.toString(); });
367
+ probe.on('error', () => { });
368
+ probe.on('exit', () => {
369
+ const { state, claudeCreated } = parseAccessibilityState(probeOut);
370
+ if (!state || state === 'not-running' || state.startsWith('has-flag'))
371
+ return;
372
+ if (!state.startsWith('needs-flag')) {
373
+ if (state.startsWith('error'))
374
+ log(`Claude Desktop guard: accessibility probe failed (${state}).`);
375
+ return;
355
376
  }
356
- else if (state === 'needs-flag') {
377
+ const allow = relaunchThrottleOk(claudeCreated);
378
+ if (!allow) {
357
379
  log('Claude Desktop guard: Claude is running without accessibility; relaunch throttled — composer stays unreadable until the next allowed relaunch.');
380
+ return;
358
381
  }
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
- }
382
+ const child = (0, child_process_1.spawn)('powershell', [
383
+ '-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass', '-WindowStyle', 'Hidden',
384
+ '-Command', ensureAccessibilityScript(true),
385
+ ], { windowsHide: true, stdio: ['ignore', 'pipe', 'ignore'] });
386
+ let out = '';
387
+ child.stdout?.on('data', (d) => { out += d.toString(); });
388
+ child.on('error', () => { });
389
+ child.on('exit', () => {
390
+ const result = parseAccessibilityState(out);
391
+ if (result.state === 'relaunched') {
392
+ stampRelaunch(result.claudeCreated || claudeCreated);
393
+ log('Claude Desktop guard: relaunched Claude with --force-renderer-accessibility so the chat composer is readable.');
394
+ }
395
+ else if (result.state.startsWith('error')) {
396
+ // An error can surface AFTER we already force-stopped Claude (Start-Process
397
+ // threw). Stamp the throttle regardless so a half-failed restart can never
398
+ // loop the kill every 5 minutes — wait the full window before retrying.
399
+ stampRelaunch(result.claudeCreated || claudeCreated);
400
+ log(`Claude Desktop guard: could not enable accessibility (${result.state}); backing off before retry.`);
401
+ }
402
+ });
366
403
  });
367
404
  }
368
405
  /**
@@ -437,6 +474,10 @@ function startDesktopChatGuard(runtime) {
437
474
  (0, notify_1.notifyOs)({
438
475
  title: 'FullCourtDefense: secret in Claude Desktop',
439
476
  message: `${finding.reason}. Remove it before sending — this text has not been protected.`,
477
+ // Security warning: use a top-most window, not a toast. Toasts are
478
+ // silently swallowed by Focus Assist / Do-Not-Disturb and per-app
479
+ // banner settings — verified in the field — which would hide the alert.
480
+ forceWindow: true,
440
481
  });
441
482
  }
442
483
  runtime.log(`Claude Desktop chat finding: ${finding.itemId} (${finding.reason}).`);
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, { detached: true, stdio: 'ignore', windowsHide: true });
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
@@ -1,3 +1,3 @@
1
1
  {
2
- "version": "1.21.3"
2
+ "version": "1.21.5"
3
3
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fullcourtdefense-cli",
3
- "version": "1.21.3",
3
+ "version": "1.21.5",
4
4
  "description": "Full Court Defense CLI — security scanning for AI agents from your terminal",
5
5
  "main": "dist/index.js",
6
6
  "bin": {