fullcourtdefense-cli 1.26.11 → 1.26.13

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.
@@ -5,6 +5,12 @@ export interface CmdGuardStatus {
5
5
  rulesPresent: boolean;
6
6
  ruleCount?: number;
7
7
  mode?: string;
8
+ /**
9
+ * Whether the doskey macro file exists. Installed-but-missing means the bat
10
+ * fails open and NO command is intercepted, so this must be visible rather
11
+ * than hidden behind `installed: true`.
12
+ */
13
+ macrosPresent?: boolean;
8
14
  }
9
15
  export declare function isCmdGuardInstalled(): boolean;
10
16
  export declare function getCmdGuardStatus(): CmdGuardStatus;
@@ -58,6 +58,7 @@ const shellGuard_1 = require("./shellGuard");
58
58
  const GUARD_RULES_PATH = path.join(os.homedir(), '.fullcourtdefense-shell-guard.json');
59
59
  const GUARD_JS_PATH = path.join(os.homedir(), '.fullcourtdefense-cmd-guard.js');
60
60
  const AUTORUN_BAT_PATH = path.join(os.homedir(), '.fullcourtdefense-cmd-autorun.bat');
61
+ const MACRO_FILE_PATH = path.join(os.homedir(), '.fullcourtdefense-cmd-macros.txt');
61
62
  const CMD_AUTORUN_KEY = 'HKCU\\Software\\Microsoft\\Command Processor';
62
63
  const AUTORUN_VALUE = 'AutoRun';
63
64
  const AUTORUN_MARKER = 'fullcourtdefense-cmd-autorun.bat';
@@ -151,10 +152,22 @@ function buildGuardJs(nodePath, cliEntry) {
151
152
  ``,
152
153
  ].join('\n');
153
154
  }
155
+ /**
156
+ * The doskey macro definitions, as a file for `doskey /macrofile=`.
157
+ *
158
+ * Deliberately contains NOTHING but macro lines: every line in a macrofile is a
159
+ * definition, so a comment or build stamp here would register a junk macro (a
160
+ * `REM` macro would shadow REM for anything the user types). The build stamp
161
+ * lives in the bat, which is what the staleness check reads.
162
+ */
163
+ function buildMacroFile(nodePath) {
164
+ const guardJs = batQuote(GUARD_JS_PATH);
165
+ const node = batQuote(nodePath);
166
+ return `${INTERCEPTED_COMMANDS.map(cmd => `${cmd}=${node} ${guardJs} ${cmd} $*`).join('\r\n')}\r\n`;
167
+ }
154
168
  function buildAutorunBat(nodePath) {
155
169
  const guardJs = batQuote(GUARD_JS_PATH);
156
170
  const node = batQuote(nodePath);
157
- const doskeyLines = INTERCEPTED_COMMANDS.map(cmd => `doskey ${cmd}=${node} ${guardJs} ${cmd} $*`);
158
171
  return [
159
172
  '@echo off',
160
173
  'REM FullCourtDefense cmd.exe guard (installed by fullcourtdefense-cli).',
@@ -168,7 +181,13 @@ function buildAutorunBat(nodePath) {
168
181
  // must keep working natively. Never break the customer's tools.
169
182
  `if not exist ${node} goto :eof`,
170
183
  `if not exist ${guardJs} goto :eof`,
171
- ...doskeyLines,
184
+ `if not exist ${batQuote(MACRO_FILE_PATH)} goto :eof`,
185
+ // ONE doskey call, not one per command. AutoRun runs on every cmd.exe start,
186
+ // and each separate `doskey` statement costs ~8ms, so the previous
187
+ // one-line-per-command form added ~320ms to every cmd.exe launch — paid by
188
+ // build scripts, CI and agents on every single `cmd /c`. Loading the same
189
+ // macros from a file is ~18ms for the whole set.
190
+ `doskey /macrofile=${batQuote(MACRO_FILE_PATH)}`,
172
191
  '',
173
192
  ].join('\r\n');
174
193
  }
@@ -229,6 +248,7 @@ function getCmdGuardStatus() {
229
248
  rulesPresent,
230
249
  ruleCount,
231
250
  mode,
251
+ macrosPresent: fs.existsSync(MACRO_FILE_PATH),
232
252
  };
233
253
  }
234
254
  catch {
@@ -245,6 +265,10 @@ async function installCmdGuardCommand(_args = {}) {
245
265
  const nodePath = process.execPath;
246
266
  const cliEntry = process.argv[1] || '';
247
267
  fs.writeFileSync(GUARD_JS_PATH, buildGuardJs(nodePath, cliEntry), 'utf8');
268
+ // Macro file BEFORE the bat: the bat refuses to register macros without it,
269
+ // so this order can never leave a window where cmd.exe sources a bat whose
270
+ // macrofile does not exist yet.
271
+ (0, shellGuard_1.writeGuardArtifact)(MACRO_FILE_PATH, buildMacroFile(nodePath));
248
272
  fs.writeFileSync(AUTORUN_BAT_PATH, buildAutorunBat(nodePath), 'utf8');
249
273
  console.log(`Guard rules written: ${rules.rules.length} rule(s), mode "${rules.mode}".`);
250
274
  const current = readCurrentAutorun();
@@ -280,6 +304,10 @@ async function uninstallCmdGuardCommand() {
280
304
  fs.unlinkSync(AUTORUN_BAT_PATH);
281
305
  }
282
306
  catch { /* not present */ }
307
+ try {
308
+ fs.unlinkSync(MACRO_FILE_PATH);
309
+ }
310
+ catch { /* not present */ }
283
311
  console.log('cmd.exe guard uninstalled. Open cmd windows keep doskey until closed.');
284
312
  }
285
313
  /** Refresh shared rules JSON when cmd guard is installed. */
@@ -290,21 +318,34 @@ function refreshCmdGuardRules() {
290
318
  (0, shellGuard_1.writeShellGuardRules)();
291
319
  // Self-heal after CLI updates: the planted checker/autorun carry the
292
320
  // detection logic of the build that wrote them — rewrite when stale.
293
- if ((0, shellGuard_1.guardArtifactStale)(GUARD_JS_PATH)) {
321
+ //
322
+ // A MISSING macro file is also healed here, and matters more than staleness:
323
+ // the bat fails open without it, so the guard would look installed while
324
+ // registering no macros at all. This is the only path that repairs that.
325
+ if ((0, shellGuard_1.guardArtifactStale)(GUARD_JS_PATH) || !fs.existsSync(MACRO_FILE_PATH)) {
294
326
  const nodePath = process.execPath;
295
327
  (0, shellGuard_1.writeGuardArtifact)(GUARD_JS_PATH, buildGuardJs(nodePath, process.argv[1] || ''));
328
+ (0, shellGuard_1.writeGuardArtifact)(MACRO_FILE_PATH, buildMacroFile(nodePath));
296
329
  (0, shellGuard_1.writeGuardArtifact)(AUTORUN_BAT_PATH, buildAutorunBat(nodePath));
297
330
  }
298
331
  }
299
332
  catch { /* best-effort */ }
300
333
  }
301
334
  /**
302
- * The node.exe path baked into an installed autorun bat's doskey macros.
303
- * Format written by buildAutorunBat: `doskey <cmd>="<node>" "<guardJs>" <cmd> $*`.
335
+ * The node.exe path baked into an installed autorun bat.
336
+ *
337
+ * Reads the `if not exist "<node>" goto :eof` fail-open line, which is present
338
+ * in every build. The doskey fallback below covers bats written by builds that
339
+ * predate the macrofile change (`doskey <cmd>="<node>" "<guardJs>" <cmd> $*`):
340
+ * those machines are exactly the ones that still need repairing, so dropping
341
+ * the old shape would strand them.
304
342
  */
305
343
  function autorunBatNodePath(batContent) {
306
- const match = batContent.match(/^doskey\s+\S+="([^"]+)"\s/m);
307
- return match ? match[1] : undefined;
344
+ const existsCheck = batContent.match(/^if not exist\s+"([^"]+node[^"]*)"\s+goto/im);
345
+ if (existsCheck)
346
+ return existsCheck[1];
347
+ const legacyDoskey = batContent.match(/^doskey\s+\S+="([^"]+)"\s/m);
348
+ return legacyDoskey ? legacyDoskey[1] : undefined;
308
349
  }
309
350
  /**
310
351
  * Self-heal a stale/broken cmd-guard AutoRun. Two failure shapes are covered:
@@ -358,6 +399,10 @@ function repairStaleCmdAutorun() {
358
399
  fs.unlinkSync(GUARD_JS_PATH);
359
400
  }
360
401
  catch { /* already gone */ }
402
+ try {
403
+ fs.unlinkSync(MACRO_FILE_PATH);
404
+ }
405
+ catch { /* already gone */ }
361
406
  return true;
362
407
  }
363
408
  catch {
@@ -76,3 +76,8 @@ export declare function buildFolderCatalog(options?: {
76
76
  export declare function resolveScanRoots(flag: string | undefined): string[];
77
77
  /** Include config paths for apps that exist on disk even when the MCP file is not created yet. */
78
78
  export declare function discoverScanTargets(cwd: string, extra?: string): ConfigPathCandidate[];
79
+ /**
80
+ * True when the AI *app* is on disk — not when a leftover config folder exists.
81
+ * `~/.codex` / `~/.cursor` without Cursor.exe must not count as installed.
82
+ */
83
+ export declare function detectIdeAppInstalled(clientKey: string, env?: NodeJS.ProcessEnv, home?: string, platform?: NodeJS.Platform): boolean;
@@ -49,6 +49,7 @@ exports.scanRootsForProjectConfigs = scanRootsForProjectConfigs;
49
49
  exports.buildFolderCatalog = buildFolderCatalog;
50
50
  exports.resolveScanRoots = resolveScanRoots;
51
51
  exports.discoverScanTargets = discoverScanTargets;
52
+ exports.detectIdeAppInstalled = detectIdeAppInstalled;
52
53
  const fs = __importStar(require("fs"));
53
54
  const os = __importStar(require("os"));
54
55
  const path = __importStar(require("path"));
@@ -461,3 +462,76 @@ function discoverScanTargets(cwd, extra) {
461
462
  }
462
463
  return [...existing, ...implicit];
463
464
  }
465
+ function fileExists(file) {
466
+ try {
467
+ return fs.existsSync(file) && fs.statSync(file).isFile();
468
+ }
469
+ catch {
470
+ return false;
471
+ }
472
+ }
473
+ function dirHasFile(dir, names) {
474
+ return names.some(name => fileExists(path.join(dir, name)));
475
+ }
476
+ function firstExistingCodexBin(localAppData) {
477
+ const binRoot = path.join(localAppData, 'OpenAI', 'Codex', 'bin');
478
+ try {
479
+ if (!fs.statSync(binRoot).isDirectory())
480
+ return false;
481
+ for (const name of fs.readdirSync(binRoot)) {
482
+ if (fileExists(path.join(binRoot, name, 'codex.exe')) || fileExists(path.join(binRoot, name, 'codex')))
483
+ return true;
484
+ }
485
+ }
486
+ catch { /* missing */ }
487
+ return fileExists(path.join(localAppData, 'OpenAI', 'Codex', 'codex.exe'));
488
+ }
489
+ /**
490
+ * True when the AI *app* is on disk — not when a leftover config folder exists.
491
+ * `~/.codex` / `~/.cursor` without Cursor.exe must not count as installed.
492
+ */
493
+ function detectIdeAppInstalled(clientKey, env = process.env, home = os.homedir(), platform = process.platform) {
494
+ const localAppData = env.LOCALAPPDATA || path.join(home, 'AppData', 'Local');
495
+ const programFiles = env.ProgramFiles || 'C:\\Program Files';
496
+ const programFilesX86 = env['ProgramFiles(x86)'] || 'C:\\Program Files (x86)';
497
+ if (clientKey === 'cursor') {
498
+ if (platform === 'darwin' && fs.existsSync('/Applications/Cursor.app'))
499
+ return true;
500
+ return dirHasFile(path.join(localAppData, 'Programs', 'cursor'), ['Cursor.exe'])
501
+ || dirHasFile(path.join(localAppData, 'Programs', 'Cursor'), ['Cursor.exe'])
502
+ || dirHasFile(path.join(programFiles, 'Cursor'), ['Cursor.exe']);
503
+ }
504
+ if (clientKey === 'vscode') {
505
+ if (platform === 'darwin' && fs.existsSync('/Applications/Visual Studio Code.app'))
506
+ return true;
507
+ return dirHasFile(path.join(localAppData, 'Programs', 'Microsoft VS Code'), ['Code.exe'])
508
+ || dirHasFile(path.join(programFiles, 'Microsoft VS Code'), ['Code.exe'])
509
+ || dirHasFile(path.join(programFilesX86, 'Microsoft VS Code'), ['Code.exe']);
510
+ }
511
+ if (clientKey === 'windsurf') {
512
+ if (platform === 'darwin' && fs.existsSync('/Applications/Windsurf.app'))
513
+ return true;
514
+ return dirHasFile(path.join(localAppData, 'Programs', 'Windsurf'), ['Windsurf.exe'])
515
+ || dirHasFile(path.join(localAppData, 'Programs', 'windsurf'), ['Windsurf.exe']);
516
+ }
517
+ if (clientKey === 'claude_desktop') {
518
+ if (platform === 'darwin' && fs.existsSync('/Applications/Claude.app'))
519
+ return true;
520
+ if (platform === 'win32' && claudeDesktopMsixCandidates().length > 0)
521
+ return true;
522
+ if (platform === 'win32' && claudeDesktopWindowsInstallDirs(env, home).some(dir => fs.existsSync(dir)))
523
+ return true;
524
+ return false;
525
+ }
526
+ if (clientKey === 'claude_code') {
527
+ return fileExists(path.join(home, '.local', 'bin', 'claude.exe'))
528
+ || fileExists(path.join(home, '.local', 'bin', 'claude'))
529
+ || dirHasFile(path.join(localAppData, 'Programs', 'claude'), ['claude.exe']);
530
+ }
531
+ if (clientKey === 'codex') {
532
+ if (platform === 'darwin' && (fs.existsSync('/Applications/Codex.app') || fs.existsSync('/Applications/ChatGPT Codex.app')))
533
+ return true;
534
+ return firstExistingCodexBin(localAppData);
535
+ }
536
+ return false;
537
+ }
@@ -13,6 +13,8 @@ export interface ClientCoverageRow {
13
13
  cursorHooksInstalled: boolean;
14
14
  cursorHookEvents: string[];
15
15
  cursorHookShadow: boolean;
16
+ /** False when only leftover config exists — the app binary is not on disk. */
17
+ appInstalled: boolean;
16
18
  }
17
19
  export interface ProxyClassifiableServer {
18
20
  serverName: string;
@@ -45,6 +45,7 @@ exports.buildClientCoverage = buildClientCoverage;
45
45
  const fs = __importStar(require("fs"));
46
46
  const os = __importStar(require("os"));
47
47
  const path = __importStar(require("path"));
48
+ const discoverPaths_1 = require("./discoverPaths");
48
49
  /**
49
50
  * Config sources that no AI client actually loads. A plain `mcp.json` at a
50
51
  * repo root is documentation/sample material — Cursor, Claude Code, VS Code,
@@ -258,6 +259,12 @@ function buildClientCoverage(scanned, servers, cwd) {
258
259
  list.push(server);
259
260
  serversByConfig.set(key, list);
260
261
  }
262
+ const appInstalledByKey = new Map();
263
+ const appInstalledFor = (clientKey) => {
264
+ if (!appInstalledByKey.has(clientKey))
265
+ appInstalledByKey.set(clientKey, (0, discoverPaths_1.detectIdeAppInstalled)(clientKey));
266
+ return appInstalledByKey.get(clientKey) === true;
267
+ };
261
268
  const rows = [];
262
269
  for (const item of scanned) {
263
270
  const list = serversByConfig.get(item.path.toLowerCase()) || [];
@@ -273,6 +280,7 @@ function buildClientCoverage(scanned, servers, cwd) {
273
280
  cursorHooksInstalled: clientKey === 'cursor' ? hooks.installed : false,
274
281
  cursorHookEvents: clientKey === 'cursor' ? hooks.events : [],
275
282
  cursorHookShadow: clientKey === 'cursor' ? hooks.shadow : false,
283
+ appInstalled: appInstalledFor(clientKey),
276
284
  });
277
285
  }
278
286
  // Cursor hooks apply globally even if only project mcp.json was scanned
@@ -287,6 +295,7 @@ function buildClientCoverage(scanned, servers, cwd) {
287
295
  cursorHooksInstalled: true,
288
296
  cursorHookEvents: hooks.events,
289
297
  cursorHookShadow: hooks.shadow,
298
+ appInstalled: appInstalledFor('cursor'),
290
299
  });
291
300
  }
292
301
  return rows.sort((a, b) => a.client.localeCompare(b.client));
@@ -111,7 +111,10 @@ async function perfCheck(apiUrl, config) {
111
111
  console.log(`node spawn floor: ${hp.nodeSpawnFloorMs}ms median — OS+AV cost of any per-event process`);
112
112
  console.log(`hook end-to-end: ${hp.hookMedianMs}ms median, ${hp.hookMinMs}-${hp.hookMaxMs}ms range — per IDE event (shell/MCP/file)`);
113
113
  if (hp.cmdGuardOverheadMs !== undefined) {
114
- console.log(`cmd guard overhead: +${hp.cmdGuardOverheadMs}ms median — per intercepted cmd.exe command`);
114
+ console.log(`cmd guard overhead: +${hp.cmdGuardOverheadMs}ms median — per cmd.exe START (once per terminal window; every time for "cmd /c" callers such as build scripts and CI)`);
115
+ }
116
+ if (hp.cmdGuardCheckerMs !== undefined) {
117
+ console.log(`cmd guard checker: +${hp.cmdGuardCheckerMs}ms median — additional, only when the typed command is one we intercept`);
115
118
  }
116
119
  if (snapshot.processes.length > 0) {
117
120
  console.log(`resident processes: ${snapshot.processes.length} FullCourtDefense process(es), ${snapshot.totalWorkingSetMB}MB working set total`);
@@ -177,6 +177,7 @@ function collectWindowsChecks() {
177
177
  '.fullcourtdefense-shell-guard.ps1',
178
178
  '.fullcourtdefense-cmd-guard.js',
179
179
  '.fullcourtdefense-cmd-autorun.bat',
180
+ '.fullcourtdefense-cmd-macros.txt',
180
181
  ];
181
182
  for (const name of profileFiles) {
182
183
  const file = path.join(home, name);
@@ -40,8 +40,19 @@ export interface PerfHotPathBench {
40
40
  hookMinMs: number;
41
41
  hookMaxMs: number;
42
42
  hookRuns: number;
43
- /** cmd.exe doskey guard overhead vs a direct command (Windows, when installed). */
43
+ /**
44
+ * Cost the cmd guard adds to STARTING a cmd.exe session, measured as a
45
+ * guarded `cmd /c` against the same command with AutoRun suppressed (`/d`).
46
+ *
47
+ * This is what a developer actually pays: AutoRun runs on every cmd.exe
48
+ * launch and registers the doskey macros, whether or not the command being
49
+ * run is one of the intercepted ones. An interactive terminal pays it once
50
+ * when the window opens; anything invoking `cmd /c` per command (build
51
+ * scripts, CI, agents) pays it every time.
52
+ */
44
53
  cmdGuardOverheadMs?: number;
54
+ /** Extra cost when the typed command IS doskey-intercepted and goes through the checker. */
55
+ cmdGuardCheckerMs?: number;
45
56
  }
46
57
  export interface PerfDiskState {
47
58
  hookLogKB?: number;
@@ -112,15 +112,35 @@ async function benchHotPath(entryJs) {
112
112
  hookTimes.push((await runTimed(process.execPath, [entry, 'hook', '--event', 'shell', '--fcd-managed', 'true'], { input: hookEvent, timeoutMs: 60_000 })).elapsedMs);
113
113
  }
114
114
  let cmdGuardOverheadMs;
115
+ let cmdGuardCheckerMs;
115
116
  const guardJs = path.join(os.homedir(), '.fullcourtdefense-cmd-guard.js');
116
117
  if (process.platform === 'win32' && fs.existsSync(guardJs)) {
117
- const direct = [];
118
- const guarded = [];
118
+ const comSpec = process.env.ComSpec || 'cmd.exe';
119
+ // What a real command pays: AutoRun fires (registering macros) vs `/d`,
120
+ // which suppresses it. Measuring `node guardJs echo` instead — as this
121
+ // did previously — reports only the checker's cost for an intercepted
122
+ // command and never observes the AutoRun work every cmd.exe start does,
123
+ // which understated the figure developers actually feel by ~5x.
124
+ const withAutoRun = [];
125
+ const withoutAutoRun = [];
119
126
  for (let i = 0; i < 4; i++) {
120
- direct.push((await runTimed(process.env.ComSpec || 'cmd.exe', ['/d', '/c', 'echo', 'fcd-perf'], { timeoutMs: 30_000 })).elapsedMs);
121
- guarded.push((await runTimed(process.execPath, [guardJs, 'echo', 'fcd-perf'], { timeoutMs: 30_000 })).elapsedMs);
127
+ // Alternate order so neither side systematically absorbs warm-up cost.
128
+ if (i % 2 === 0) {
129
+ withAutoRun.push((await runTimed(comSpec, ['/c', 'echo', 'fcd-perf'], { timeoutMs: 30_000 })).elapsedMs);
130
+ withoutAutoRun.push((await runTimed(comSpec, ['/d', '/c', 'echo', 'fcd-perf'], { timeoutMs: 30_000 })).elapsedMs);
131
+ }
132
+ else {
133
+ withoutAutoRun.push((await runTimed(comSpec, ['/d', '/c', 'echo', 'fcd-perf'], { timeoutMs: 30_000 })).elapsedMs);
134
+ withAutoRun.push((await runTimed(comSpec, ['/c', 'echo', 'fcd-perf'], { timeoutMs: 30_000 })).elapsedMs);
135
+ }
136
+ }
137
+ cmdGuardOverheadMs = Math.max(0, median(withAutoRun) - median(withoutAutoRun));
138
+ // Kept as a separate number: the checker cost for an intercepted command.
139
+ const checker = [];
140
+ for (let i = 0; i < 4; i++) {
141
+ checker.push((await runTimed(process.execPath, [guardJs, 'echo', 'fcd-perf'], { timeoutMs: 30_000 })).elapsedMs);
122
142
  }
123
- cmdGuardOverheadMs = Math.max(0, median(guarded) - median(direct));
143
+ cmdGuardCheckerMs = Math.max(0, median(checker) - median(withoutAutoRun));
124
144
  }
125
145
  return {
126
146
  nodeSpawnFloorMs: median(spawnTimes),
@@ -129,6 +149,7 @@ async function benchHotPath(entryJs) {
129
149
  hookMaxMs: Math.max(...hookTimes),
130
150
  hookRuns: hookTimes.length,
131
151
  cmdGuardOverheadMs,
152
+ cmdGuardCheckerMs,
132
153
  };
133
154
  }
134
155
  /**
@@ -246,7 +267,7 @@ async function collectPerfSnapshot(input = {}) {
246
267
  }
247
268
  /** One-line human summary for machine-action result reporting. */
248
269
  function summarizePerfSnapshot(s) {
249
- const guard = s.hotPath.cmdGuardOverheadMs !== undefined ? `, cmd guard +${s.hotPath.cmdGuardOverheadMs}ms` : '';
270
+ const guard = s.hotPath.cmdGuardOverheadMs !== undefined ? `, cmd guard +${s.hotPath.cmdGuardOverheadMs}ms/cmd.exe start` : '';
250
271
  return `Hook ${s.hotPath.hookMedianMs}ms median (spawn floor ${s.hotPath.nodeSpawnFloorMs}ms${guard}); `
251
272
  + `${s.processes.length} FCD process(es) using ${s.totalWorkingSetMB}MB; `
252
273
  + `spool backlog ${s.disk.spoolBacklogCount ?? 0}, hook.log ${s.disk.hookLogKB ?? 0}KB${s.enforcement.mode ? `; mode ${s.enforcement.mode}` : ''}.`;
package/dist/version.json CHANGED
@@ -1,3 +1,3 @@
1
1
  {
2
- "version": "1.26.11"
2
+ "version": "1.26.13"
3
3
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fullcourtdefense-cli",
3
- "version": "1.26.11",
3
+ "version": "1.26.13",
4
4
  "description": "Full Court Defense CLI — security scanning for AI agents from your terminal",
5
5
  "main": "dist/index.js",
6
6
  "bin": {