fullcourtdefense-cli 1.25.2 → 1.25.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.
@@ -118,7 +118,10 @@ function buildGuardJs(nodePath, cliEntry) {
118
118
  `const NODE_PATH=${JSON.stringify(nodePath)};`,
119
119
  `const CLI_ENTRY=${JSON.stringify(cliEntry)};`,
120
120
  `function loadRules(){try{const raw=JSON.parse(fs.readFileSync(RULES_PATH,'utf8'));const rules=(raw.rules||[]).map(r=>{try{return{id:r.id,category:r.category,severity:r.severity,reason:r.reason,source:r.source,action:r.action==='warn'?'warn':'block',re:new RegExp(r.pattern,'i')};}catch{return null;}}).filter(Boolean);return{mode:raw.mode==='block'?'block':'monitor',rules};}catch{return{mode:'monitor',rules:[]};}}`,
121
- `function matchLine(line,rules){const n=line.replace(/\\s+/g,' ').trim();let warnHit=null;for(const r of rules){try{if(r.re.test(n)||r.re.test(line)){if(r.action!=='warn')return r;if(!warnHit)warnHit=r;}}catch{}}return warnHit;}`,
121
+ `// Execution view (aligned with the CLI's stripInertDataSegments): commit messages, issue/PR bodies, and`,
122
+ `// here-strings/heredocs are DATA — inert unless the line can feed an interpreter or the region interpolates.`,
123
+ `function execView(line){if(/\\b(?:iex|invoke-expression|invoke-command)\\b|\\beval\\b|\\bxargs\\b|\\bbase64\\b[^\\n]*(?:-d\\b|--decode\\b)|frombase64string|-encodedcommand\\b|\\s-enc\\s|\\|\\s*&?\\s*(?:sh|bash|zsh|dash|ksh|csh|pwsh|powershell(?:\\.exe)?|cmd(?:\\.exe)?|node|python[0-9.]*|perl|ruby)\\b/i.test(line))return line;const K=/\\$\\(|\`[^\`]*\`/;let o=line;o=o.replace(/@'[\\s\\S]*?'@/g,' fcd_inert_data ');o=o.replace(/@"[\\s\\S]*?"@/g,m=>m.includes('$(')?m:' fcd_inert_data ');o=o.replace(/<<-?\\s*'(\\w+)'[\\s\\S]*?(?:\\n\\1\\b|$)/g,' fcd_inert_data ');o=o.replace(/<<-?\\s*"?(\\w+)"?[\\s\\S]*?(?:\\n\\1\\b|$)/g,m=>K.test(m)?m:' fcd_inert_data ');o=o.replace(/((?:^|\\s)(?:-m|-b|--message|--body|--title|--description|--notes?|--comment|--caption|--summary|--subject|-value|-message)[= ]\\s*)("(?:[^"\\\\]|\\\\.)*"|'(?:[^'\\\\]|\\\\.)*')/gi,(w,f,q)=>q[0]==="'"?f+' fcd_inert_data ':K.test(q)?w:f+' fcd_inert_data ');return o;}`,
124
+ `function matchLine(line,rules){const n=line.replace(/\\s+/g,' ').trim();const x=execView(line);const xn=x.replace(/\\s+/g,' ').trim();let warnHit=null;for(const r of rules){try{const t=r.source==='custom'?line:x;const tn=r.source==='custom'?n:xn;if(r.re.test(tn)||r.re.test(t)){if(r.action!=='warn')return r;if(!warnHit)warnHit=r;}}catch{}}return warnHit;}`,
122
125
  `function spoolEvent(rule,line,decision){try{const ev=line.trim();const evidence=ev.length>180?ev.slice(0,180)+'...':ev;const event={eventId:crypto.randomUUID(),type:'verdict',decision,toolName:'cmd_terminal',operation:'shell_command',reason:'Shell guard: '+rule.reason,ruleId:rule.id,category:rule.category,severity:rule.severity,source:rule.source,evidence,occurredAt:new Date().toISOString()};fs.appendFileSync(SPOOL_PATH,JSON.stringify(event)+'\\n',{encoding:'utf8',mode:0o600});if(fs.existsSync(NODE_PATH)&&fs.existsSync(CLI_ENTRY)){spawnSync(NODE_PATH,[CLI_ENTRY,'flush-spool','--heartbeat','true'],{stdio:'ignore',windowsHide:true});}}catch{}}`,
123
126
  `function delegate(args){const r=spawnSync(process.env.ComSpec||'cmd.exe',['/d','/c',...args],{stdio:'inherit',windowsHide:true});process.exit(typeof r.status==='number'?r.status:1);}`,
124
127
  `const args=process.argv.slice(2);if(!args.length)process.exit(0);if(process.env.FCD_CMD_GUARD==='off')delegate(args);`,
@@ -38,14 +38,27 @@ export type TaskLogonType = 'S4U' | 'InteractiveToken';
38
38
  export declare function taskUserId(): string;
39
39
  /** Local wall-clock timestamp (no ms, no tz) as Task Scheduler expects. */
40
40
  export declare function taskLocalTimestamp(date: Date): string;
41
+ /** Full path to conhost.exe — the signed Windows binary whose `--headless`
42
+ * flag hosts a console child on a hidden pseudoconsole (no window, ever). */
43
+ export declare function conhostPath(): string;
41
44
  /**
42
45
  * Task Scheduler XML that runs `node.exe <cliEntry> <command>` hidden.
43
46
  *
44
47
  * @param command CLI subcommand + args ('daemon' | 'watchdog' | 'discover …').
45
48
  * @param triggers Inner <Triggers> XML — logon for the daemon, a repeating
46
49
  * time trigger for the 5-minute watchdog.
47
- * @param logonType S4U (windowless, needs elevation to register) or
48
- * InteractiveToken (registers unelevated, may flash).
50
+ * @param logonType S4U (windowless by session, needs elevation to register) or
51
+ * InteractiveToken (registers unelevated the action is
52
+ * wrapped in `conhost --headless` so it is ALSO windowless).
53
+ *
54
+ * Why the conhost wrapper: an InteractiveToken task runs console apps on the
55
+ * interactive desktop — a visible window on EVERY trigger (the "console flash
56
+ * every 5 minutes" complaint). Current Windows builds deny S4U registration to
57
+ * everyone except the task's own user running elevated (even SYSTEM gets
58
+ * Access denied — verified empirically; the updater's SYSTEM repair pass can
59
+ * no longer fix it). `conhost.exe --headless` sidesteps the whole fight: the
60
+ * child gets a hidden pseudoconsole, no window ever exists, registration
61
+ * stays unelevated, and no script host is involved (Defender-safe).
49
62
  */
50
63
  export declare function buildTaskXml(command: string, triggers: string, logonType?: TaskLogonType): string;
51
64
  /**
@@ -74,6 +87,11 @@ export declare function windowsTaskReferencesScriptHost(taskName: string): boole
74
87
  * the 1.22.1 window-flash bug (console window on every trigger). Migration
75
88
  * trigger for the windowless S4U principal. */
76
89
  export declare function windowsTaskRunsInteractive(taskName: string): boolean;
90
+ /** True when triggering the task would open a VISIBLE console window:
91
+ * InteractiveToken principal AND a bare console action (not wrapped in
92
+ * `conhost --headless`). This — not the principal alone — is the flash
93
+ * condition; a conhost-wrapped InteractiveToken task is fully windowless. */
94
+ export declare function windowsTaskRunsVisibly(taskName: string): boolean;
77
95
  /** Snapshot of the resident daemon for out-of-process callers (watchdog/status). */
78
96
  export interface DaemonRuntimeState {
79
97
  alive: boolean;
@@ -39,11 +39,13 @@ exports.discoverSweepCredentialEnv = discoverSweepCredentialEnv;
39
39
  exports.cliVersion = cliVersion;
40
40
  exports.taskUserId = taskUserId;
41
41
  exports.taskLocalTimestamp = taskLocalTimestamp;
42
+ exports.conhostPath = conhostPath;
42
43
  exports.buildTaskXml = buildTaskXml;
43
44
  exports.buildTaskXmlVariants = buildTaskXmlVariants;
44
45
  exports.registerHiddenTask = registerHiddenTask;
45
46
  exports.windowsTaskReferencesScriptHost = windowsTaskReferencesScriptHost;
46
47
  exports.windowsTaskRunsInteractive = windowsTaskRunsInteractive;
48
+ exports.windowsTaskRunsVisibly = windowsTaskRunsVisibly;
47
49
  exports.daemonRuntimeState = daemonRuntimeState;
48
50
  exports.spawnDetachedDaemon = spawnDetachedDaemon;
49
51
  exports.pidIsAlive = pidIsAlive;
@@ -1261,23 +1263,41 @@ function taskLocalTimestamp(date) {
1261
1263
  return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}` +
1262
1264
  `T${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`;
1263
1265
  }
1266
+ /** Full path to conhost.exe — the signed Windows binary whose `--headless`
1267
+ * flag hosts a console child on a hidden pseudoconsole (no window, ever). */
1268
+ function conhostPath() {
1269
+ return path.join(process.env.SystemRoot || 'C:\\Windows', 'System32', 'conhost.exe');
1270
+ }
1264
1271
  /**
1265
1272
  * Task Scheduler XML that runs `node.exe <cliEntry> <command>` hidden.
1266
1273
  *
1267
1274
  * @param command CLI subcommand + args ('daemon' | 'watchdog' | 'discover …').
1268
1275
  * @param triggers Inner <Triggers> XML — logon for the daemon, a repeating
1269
1276
  * time trigger for the 5-minute watchdog.
1270
- * @param logonType S4U (windowless, needs elevation to register) or
1271
- * InteractiveToken (registers unelevated, may flash).
1277
+ * @param logonType S4U (windowless by session, needs elevation to register) or
1278
+ * InteractiveToken (registers unelevated the action is
1279
+ * wrapped in `conhost --headless` so it is ALSO windowless).
1280
+ *
1281
+ * Why the conhost wrapper: an InteractiveToken task runs console apps on the
1282
+ * interactive desktop — a visible window on EVERY trigger (the "console flash
1283
+ * every 5 minutes" complaint). Current Windows builds deny S4U registration to
1284
+ * everyone except the task's own user running elevated (even SYSTEM gets
1285
+ * Access denied — verified empirically; the updater's SYSTEM repair pass can
1286
+ * no longer fix it). `conhost.exe --headless` sidesteps the whole fight: the
1287
+ * child gets a hidden pseudoconsole, no window ever exists, registration
1288
+ * stays unelevated, and no script host is involved (Defender-safe).
1272
1289
  */
1273
1290
  function buildTaskXml(command, triggers, logonType = 'S4U') {
1274
1291
  const userId = xmlEscape(taskUserId());
1275
- const nodeExe = xmlEscape(process.execPath);
1276
- const args = xmlEscape(`"${cliEntry()}" ${command}`);
1292
+ const headless = logonType === 'InteractiveToken';
1293
+ const execCommand = headless ? xmlEscape(conhostPath()) : xmlEscape(process.execPath);
1294
+ const args = headless
1295
+ ? xmlEscape(`--headless "${process.execPath}" "${cliEntry()}" ${command}`)
1296
+ : xmlEscape(`"${cliEntry()}" ${command}`);
1277
1297
  return `<?xml version="1.0" encoding="UTF-16"?>
1278
1298
  <Task version="1.2" xmlns="http://schemas.microsoft.com/windows/2004/02/mit/task">
1279
1299
  <RegistrationInfo>
1280
- <Description>FullCourtDefense ${xmlEscape(command)} — runs node directly, windowless, no script host.</Description>
1300
+ <Description>FullCourtDefense ${xmlEscape(command)} — runs node ${headless ? 'under conhost --headless' : 'directly'}, windowless, no script host.</Description>
1281
1301
  </RegistrationInfo>
1282
1302
  <Triggers>
1283
1303
  ${triggers}
@@ -1307,7 +1327,7 @@ ${triggers}
1307
1327
  </Settings>
1308
1328
  <Actions Context="Author">
1309
1329
  <Exec>
1310
- <Command>${nodeExe}</Command>
1330
+ <Command>${execCommand}</Command>
1311
1331
  <Arguments>${args}</Arguments>
1312
1332
  </Exec>
1313
1333
  </Actions>
@@ -1383,10 +1403,10 @@ function registerHiddenTask(taskName, xml, command, scheduleArgs) {
1383
1403
  }
1384
1404
  catch { /* try next variant, then /TR */ }
1385
1405
  }
1386
- // Fallback: node directly via /TR (no wscript, no VBS). node/CLI may live
1387
- // under a path with spaces (Program Files), so quote each and let schtasks
1388
- // pass the whole /TR string through.
1389
- const tr = `"${process.execPath}" "${cliEntry()}" ${command}`;
1406
+ // Fallback: node via /TR under conhost --headless (no wscript, no VBS, no
1407
+ // window). node/CLI may live under a path with spaces (Program Files), so
1408
+ // quote each and let schtasks pass the whole /TR string through.
1409
+ const tr = `${conhostPath()} --headless "${process.execPath}" "${cliEntry()}" ${command}`;
1390
1410
  const fallback = (0, child_process_1.spawnSync)('schtasks', [
1391
1411
  '/Create', '/TN', taskName, '/TR', tr, ...scheduleArgs, '/F',
1392
1412
  ], { stdio: 'ignore', windowsHide: true, timeout: 20_000 });
@@ -1421,16 +1441,30 @@ function windowsTaskReferencesScriptHost(taskName) {
1421
1441
  * the 1.22.1 window-flash bug (console window on every trigger). Migration
1422
1442
  * trigger for the windowless S4U principal. */
1423
1443
  function windowsTaskRunsInteractive(taskName) {
1444
+ return windowsTaskXmlState(taskName).interactive;
1445
+ }
1446
+ /** True when triggering the task would open a VISIBLE console window:
1447
+ * InteractiveToken principal AND a bare console action (not wrapped in
1448
+ * `conhost --headless`). This — not the principal alone — is the flash
1449
+ * condition; a conhost-wrapped InteractiveToken task is fully windowless. */
1450
+ function windowsTaskRunsVisibly(taskName) {
1451
+ const state = windowsTaskXmlState(taskName);
1452
+ return state.interactive && !state.headless;
1453
+ }
1454
+ function windowsTaskXmlState(taskName) {
1424
1455
  try {
1425
1456
  const query = (0, child_process_1.spawnSync)('schtasks', ['/Query', '/TN', taskName, '/XML'], {
1426
1457
  encoding: 'utf8', windowsHide: true, timeout: 10_000,
1427
1458
  });
1428
1459
  if (query.status !== 0 || typeof query.stdout !== 'string')
1429
- return false;
1430
- return /<LogonType>\s*InteractiveToken\s*<\/LogonType>/i.test(query.stdout);
1460
+ return { interactive: false, headless: false };
1461
+ return {
1462
+ interactive: /<LogonType>\s*InteractiveToken\s*<\/LogonType>/i.test(query.stdout),
1463
+ headless: /conhost(\.exe)?<\/Command>[\s\S]*?--headless/i.test(query.stdout),
1464
+ };
1431
1465
  }
1432
1466
  catch {
1433
- return false;
1467
+ return { interactive: false, headless: false };
1434
1468
  }
1435
1469
  }
1436
1470
  /**
@@ -1456,7 +1490,38 @@ function upgradeTaskWindowless(taskName, s4uXml, logFn) {
1456
1490
  logFn(`Self-heal: "${taskName}" migrated to the windowless S4U principal (no more console-window flash).`);
1457
1491
  return true;
1458
1492
  }
1459
- logFn(`Self-heal: could not migrate "${taskName}" to S4U (needs elevation) — asking the elevated updater task to do it.`);
1493
+ logFn(`Self-heal: could not migrate "${taskName}" to S4U (needs same-user elevation) — re-registering it headless instead.`);
1494
+ return false;
1495
+ }
1496
+ catch {
1497
+ return false; /* keep the existing working task */
1498
+ }
1499
+ }
1500
+ /**
1501
+ * Re-register an InteractiveToken task with its action wrapped in
1502
+ * `conhost --headless` — windowless without touching the principal, so the
1503
+ * SAME user can do it unelevated (unlike S4U, which current Windows only
1504
+ * grants to the task's own user running elevated — even SYSTEM is denied).
1505
+ * Returns true when the task no longer opens a window on trigger.
1506
+ */
1507
+ function rewrapTaskHeadless(taskName, interactiveHeadlessXml, logFn) {
1508
+ const base = process.env.LOCALAPPDATA || path.join(os.homedir(), 'AppData', 'Local');
1509
+ const dir = path.join(base, 'FullCourtDefense');
1510
+ try {
1511
+ fs.mkdirSync(dir, { recursive: true });
1512
+ }
1513
+ catch { /* ignore */ }
1514
+ const xmlPath = path.join(dir, `${taskName.replace(/[^A-Za-z0-9]+/g, '_')}.task.xml`);
1515
+ try {
1516
+ fs.writeFileSync(xmlPath, `\ufeff${interactiveHeadlessXml}`, 'utf16le');
1517
+ const created = (0, child_process_1.spawnSync)('schtasks', ['/Create', '/TN', taskName, '/XML', xmlPath, '/F'], {
1518
+ stdio: 'ignore', windowsHide: true, timeout: 20_000,
1519
+ });
1520
+ if (created.status === 0) {
1521
+ logFn(`Self-heal: "${taskName}" re-registered with a conhost --headless action (windowless from its next trigger).`);
1522
+ return true;
1523
+ }
1524
+ logFn(`Self-heal: could not re-register "${taskName}" headless — asking the elevated updater task to migrate it.`);
1460
1525
  return false;
1461
1526
  }
1462
1527
  catch {
@@ -1478,14 +1543,20 @@ let updaterMigrationKicked = false;
1478
1543
  function kickUpdaterTaskForMigration(logFn) {
1479
1544
  if (updaterMigrationKicked)
1480
1545
  return;
1481
- updaterMigrationKicked = true;
1482
1546
  try {
1483
1547
  const run = (0, child_process_1.spawnSync)('schtasks', ['/Run', '/TN', selfUpdate_1.MSI_UPDATER_TASK_NAME], {
1484
1548
  stdio: 'ignore', windowsHide: true, timeout: 20_000,
1485
1549
  });
1486
- logFn(run.status === 0
1487
- ? `Self-heal: elevated updater task triggeredit migrates the tasks to S4U now (windowless within a minute).`
1488
- : `Self-heal: updater task unavailable for the S4U migration its next daily run (03:07) will migrate instead.`);
1550
+ // Latch ONLY on a successful kick. On a fresh MSI install the daemon can
1551
+ // boot before the SYSTEM updater task existsburning the one-shot latch
1552
+ // on that failure left the machine flashing until the 03:07 daily run.
1553
+ if (run.status === 0) {
1554
+ updaterMigrationKicked = true;
1555
+ logFn(`Self-heal: elevated updater task triggered — its repair pass migrates the tasks now.`);
1556
+ }
1557
+ else {
1558
+ logFn(`Self-heal: updater task unavailable for the task migration — will retry on a later self-heal; the daily run (03:07) is the backstop.`);
1559
+ }
1489
1560
  }
1490
1561
  catch { /* best-effort — the daily schedule remains the backstop */ }
1491
1562
  }
@@ -1528,21 +1599,20 @@ function startDaemonNowWindows(viaTask = false) {
1528
1599
  }
1529
1600
  }
1530
1601
  catch { /* not running */ }
1531
- if (viaTask && !windowsTaskRunsInteractive(TASK_NAME)) {
1602
+ if (viaTask && !windowsTaskRunsVisibly(TASK_NAME)) {
1532
1603
  // Start through the scheduled task so the daemon runs with the task's
1533
1604
  // LIMITED (non-elevated) token. Launching directly from an elevated MSI
1534
1605
  // custom action would leave an elevated daemon that a normal-user CLI can
1535
1606
  // never stop or supersede.
1536
1607
  //
1537
- // ONLY when the task is windowless (S4U): starting an InteractiveToken
1538
- // task runs node.exe on the interactive desktop, and since the daemon
1539
- // never exits, that console window stays OPEN on the user's screen — the
1540
- // "open console at the end of installation" customer complaint. Fresh
1541
- // unelevated installs register InteractiveToken (S4U needs elevation), so
1542
- // those take the hidden WMI path below instead; the MSI's SYSTEM-context
1543
- // migration flips the task to S4U moments later, and every subsequent
1544
- // task start is windowless. Elevation is not a concern here: when the
1545
- // caller IS elevated, S4U registration succeeded and this branch is taken.
1608
+ // ONLY when the task cannot open a window (S4U principal, or an
1609
+ // InteractiveToken action wrapped in conhost --headless): starting a
1610
+ // bare-console InteractiveToken task runs node.exe on the interactive
1611
+ // desktop, and since the daemon never exits, that console window stays
1612
+ // OPEN on the user's screen the "open console at the end of
1613
+ // installation" customer complaint. A visibly-registered task takes the
1614
+ // hidden WMI path below instead; self-heal re-registers it headless on
1615
+ // the next daemon boot.
1546
1616
  const run = (0, child_process_1.spawnSync)('schtasks', ['/Run', '/TN', TASK_NAME], { stdio: 'ignore', windowsHide: true, timeout: 20_000 });
1547
1617
  if (run.status === 0)
1548
1618
  return;
@@ -1567,11 +1637,11 @@ function installWindows() {
1567
1637
  let ok = taskOk;
1568
1638
  if (!ok) {
1569
1639
  // Fallback: per-user Run key — no elevation needed, runs at every logon.
1570
- // node.exe directly (no wscript). May flash briefly at logon in this rare
1571
- // fallback, but carries no script-host persistence fingerprint.
1640
+ // conhost --headless keeps the logon launch windowless (no wscript, no
1641
+ // script-host persistence fingerprint).
1572
1642
  const reg = (0, child_process_1.spawnSync)('reg', [
1573
1643
  'add', WINDOWS_RUN_KEY, '/v', WINDOWS_RUN_VALUE, '/t', 'REG_SZ',
1574
- '/d', `"${process.execPath}" "${cliEntry()}" daemon`, '/f',
1644
+ '/d', `"${conhostPath()}" --headless "${process.execPath}" "${cliEntry()}" daemon`, '/f',
1575
1645
  ], { stdio: 'ignore', windowsHide: true });
1576
1646
  ok = reg.status === 0;
1577
1647
  }
@@ -1616,7 +1686,7 @@ function ensureWindowsAutostartHealthy(logFn) {
1616
1686
  if (!created) {
1617
1687
  (0, child_process_1.spawnSync)('reg', [
1618
1688
  'add', WINDOWS_RUN_KEY, '/v', WINDOWS_RUN_VALUE, '/t', 'REG_SZ',
1619
- '/d', `"${process.execPath}" "${cliEntry()}" daemon`, '/f',
1689
+ '/d', `"${conhostPath()}" --headless "${process.execPath}" "${cliEntry()}" daemon`, '/f',
1620
1690
  ], { stdio: 'ignore', windowsHide: true, timeout: 10_000 });
1621
1691
  }
1622
1692
  if (daemonTask.status !== 0)
@@ -1630,17 +1700,24 @@ function ensureWindowsAutostartHealthy(logFn) {
1630
1700
  logFn('Self-heal: could not (re)create the watchdog task — daemon revival relies on logon autostart only.');
1631
1701
  }
1632
1702
  }
1633
- // Migration (1.22.1 -> 1.22.2): InteractiveToken tasks flash a console
1634
- // window on every trigger. Upgrade healthy existing tasks to S4U; S4U-only
1635
- // attempt so a denied re-registration never breaks a working task. When
1636
- // unelevated self-heal cannot flip them, kick the SYSTEM updater task
1637
- // once its repair pass migrates with real elevation (1.22.7).
1703
+ // Migration: an InteractiveToken task with a bare console action flashes a
1704
+ // window on every trigger. Try the S4U principal first (works when this
1705
+ // daemon happens to be elevated current Windows denies S4U registration
1706
+ // to everyone else, INCLUDING SYSTEM, so the updater usually cannot help).
1707
+ // When S4U is denied, re-register the task as InteractiveToken with the
1708
+ // action wrapped in `conhost --headless` — same-user unelevated
1709
+ // registration always works and is just as windowless. The updater kick
1710
+ // stays as the final backstop for machines where even that failed.
1638
1711
  let migrationDenied = false;
1639
- if (daemonTask.status === 0 && !legacyDaemon && windowsTaskRunsInteractive(TASK_NAME)) {
1640
- migrationDenied = !upgradeTaskWindowless(TASK_NAME, buildDaemonTaskXml()[0], logFn) || migrationDenied;
1712
+ if (daemonTask.status === 0 && !legacyDaemon && windowsTaskRunsVisibly(TASK_NAME)) {
1713
+ const daemonXml = buildDaemonTaskXml();
1714
+ migrationDenied = !(upgradeTaskWindowless(TASK_NAME, daemonXml[0], logFn)
1715
+ || rewrapTaskHeadless(TASK_NAME, daemonXml[1], logFn)) || migrationDenied;
1641
1716
  }
1642
- if (watchdogQuery.status === 0 && !legacyWatchdog && windowsTaskRunsInteractive(WATCHDOG_TASK_NAME)) {
1643
- migrationDenied = !upgradeTaskWindowless(WATCHDOG_TASK_NAME, buildWatchdogTaskXml()[0], logFn) || migrationDenied;
1717
+ if (watchdogQuery.status === 0 && !legacyWatchdog && windowsTaskRunsVisibly(WATCHDOG_TASK_NAME)) {
1718
+ const watchdogXml = buildWatchdogTaskXml();
1719
+ migrationDenied = !(upgradeTaskWindowless(WATCHDOG_TASK_NAME, watchdogXml[0], logFn)
1720
+ || rewrapTaskHeadless(WATCHDOG_TASK_NAME, watchdogXml[1], logFn)) || migrationDenied;
1644
1721
  }
1645
1722
  if (migrationDenied)
1646
1723
  kickUpdaterTaskForMigration(logFn);
@@ -1648,9 +1725,9 @@ function ensureWindowsAutostartHealthy(logFn) {
1648
1725
  if (windowsRunKeyReferencesScriptHost()) {
1649
1726
  (0, child_process_1.spawnSync)('reg', [
1650
1727
  'add', WINDOWS_RUN_KEY, '/v', WINDOWS_RUN_VALUE, '/t', 'REG_SZ',
1651
- '/d', `"${process.execPath}" "${cliEntry()}" daemon`, '/f',
1728
+ '/d', `"${conhostPath()}" --headless "${process.execPath}" "${cliEntry()}" daemon`, '/f',
1652
1729
  ], { stdio: 'ignore', windowsHide: true, timeout: 10_000 });
1653
- logFn('Self-heal: rewrote legacy wscript Run-key entry to run node directly.');
1730
+ logFn('Self-heal: rewrote legacy wscript Run-key entry to run node headless.');
1654
1731
  }
1655
1732
  removeLegacyWindowsLaunchers();
1656
1733
  }
@@ -55,6 +55,7 @@ export interface LocalSafetyScanOptions {
55
55
  */
56
56
  trustedScriptPaths?: string[];
57
57
  }
58
+ export declare function stripInertDataSegments(text: string): string;
58
59
  export declare function scanDeterministicToolCall(toolName: string, toolArgs: Record<string, unknown>, options?: LocalSafetyScanOptions): DeterministicFinding | undefined;
59
60
  export declare function scanDeterministicTextResponse(text: string, options?: LocalSafetyScanOptions): DeterministicFinding | undefined;
60
61
  export declare function scanDeterministicPrompt(text: string, options?: LocalSafetyScanOptions): DeterministicFinding | undefined;
@@ -33,6 +33,7 @@ var __importStar = (this && this.__importStar) || (function () {
33
33
  };
34
34
  })();
35
35
  Object.defineProperty(exports, "__esModule", { value: true });
36
+ exports.stripInertDataSegments = stripInertDataSegments;
36
37
  exports.scanDeterministicToolCall = scanDeterministicToolCall;
37
38
  exports.scanDeterministicTextResponse = scanDeterministicTextResponse;
38
39
  exports.scanDeterministicPrompt = scanDeterministicPrompt;
@@ -615,10 +616,12 @@ function credentialCommandReason(value) {
615
616
  function destructiveCommandReason(value) {
616
617
  return matchCommand(value, (text, lower) => {
617
618
  // Flag cluster is order-independent (`-rf`, `-fr`, `-rfv`), and the target
618
- // may be the root `/`, the root wildcard `/*`, or a bare `*`.
619
- if (/\brm\s+-(?=[a-z]*r)(?=[a-z]*f)[a-z]+\s+(?:["']?\/\*?["']?|\*)(?:\s|$|[;&|])/.test(lower))
619
+ // may be the root `/`, the root wildcard `/*`, or a bare `*`. Terminators
620
+ // include `)` so command substitution — `$(rm -rf /)` runs BEFORE the
621
+ // outer command — is caught too.
622
+ if (/\brm\s+-(?=[a-z]*r)(?=[a-z]*f)[a-z]+\s+(?:["']?\/\*?["']?|\*)(?:\s|$|[;&|)])/.test(lower))
620
623
  return { itemId: 'rm_rf_root', reason: 'recursive force delete of filesystem root' };
621
- if (/\bsudo\s+rm\s+-(?=[a-z]*r)(?=[a-z]*f)[a-z]+\s+(?:["']?\/\*?["']?|\*)(?:\s|$|[;&|])/.test(lower))
624
+ if (/\bsudo\s+rm\s+-(?=[a-z]*r)(?=[a-z]*f)[a-z]+\s+(?:["']?\/\*?["']?|\*)(?:\s|$|[;&|)])/.test(lower))
622
625
  return { itemId: 'rm_rf_root', reason: 'recursive force delete of filesystem root' };
623
626
  // Windows recursive quiet drive delete via del/erase/rd/rmdir. Flag order
624
627
  // is independent (`/s /q` and `/q /s` both wipe), and the dequoted variant
@@ -779,13 +782,55 @@ function isSensitivePathContext(toolName, candidate) {
779
782
  const lastKey = candidate.keyPath.split('.').pop() || '';
780
783
  return /(?:path|file|filename|dir|directory|target|source|src|dest|location|glob|pattern|uri|url)/i.test(lastKey);
781
784
  }
782
- function scanTextValue(toolName, value, options) {
783
- const honeypot = honeypotTouch(value, options);
785
+ // ---- execution view: message/document DATA inside a command is inert ---------
786
+ // Kills the "wrote ABOUT a dangerous command" FP class: commit messages,
787
+ // issue/PR bodies, here-strings, doc heredocs quoting `terraform destroy` or
788
+ // `rm -rf /` are data, not execution. Safety invariants:
789
+ // 1. NOTHING is stripped when the text can feed an interpreter (| sh, iex,
790
+ // eval, xargs, base64 -d, -EncodedCommand, FromBase64String) — data that
791
+ // flows into an interpreter IS execution.
792
+ // 2. A region containing $( ) or `…` pairs is kept — interpolation executes
793
+ // BEFORE the outer command.
794
+ // 3. Secret-literal rules and honeypot/custom rules never use this view —
795
+ // a token pasted into a commit message still leaks.
796
+ const DATA_TO_INTERPRETER = /\b(?:iex|invoke-expression|invoke-command)\b|\beval\b|\bxargs\b|\bbase64\b[^\n]*(?:-d\b|--decode\b)|frombase64string|-encodedcommand\b|\s-enc\s|\|\s*&?\s*(?:sh|bash|zsh|dash|ksh|csh|pwsh|powershell(?:\.exe)?|cmd(?:\.exe)?|node|python[0-9.]*|perl|ruby)\b/i;
797
+ const INTERPOLATES = /\$\(|`[^`]*`/;
798
+ const INERT_DATA = ' fcd_inert_data ';
799
+ const TEXT_SINK_FLAG_ARG = /((?:^|\s)(?:-m|-b|--message|--body|--title|--description|--notes?|--comment|--caption|--summary|--subject|-value|-message)[= ]\s*)("(?:[^"\\]|\\.)*"|'(?:[^'\\]|\\.)*')/gi;
800
+ function stripInertDataSegments(text) {
801
+ if (DATA_TO_INTERPRETER.test(text))
802
+ return text;
803
+ let out = text;
804
+ // PowerShell here-strings: @'…'@ is fully literal; @"…"@ interpolates $( ).
805
+ out = out.replace(/@'[\s\S]*?'@/g, INERT_DATA);
806
+ out = out.replace(/@"[\s\S]*?"@/g, m => (m.includes('$(') ? m : INERT_DATA));
807
+ // POSIX heredocs: quoted delimiter (<<'EOF') is literal; unquoted interpolates.
808
+ out = out.replace(/<<-?\s*'(\w+)'[\s\S]*?(?:\n\1\b|$)/g, INERT_DATA);
809
+ out = out.replace(/<<-?\s*"?(\w+)"?[\s\S]*?(?:\n\1\b|$)/g, m => (INTERPOLATES.test(m) ? m : INERT_DATA));
810
+ // Quoted message/text-flag arguments (commit messages, issue/PR bodies…).
811
+ out = out.replace(TEXT_SINK_FLAG_ARG, (whole, flag, quoted) => {
812
+ if (quoted.startsWith("'"))
813
+ return flag + INERT_DATA;
814
+ return INTERPOLATES.test(quoted) ? whole : flag + INERT_DATA;
815
+ });
816
+ return out;
817
+ }
818
+ function scanTextValue(toolName, rawValue, options) {
819
+ // Honeypot decoys and org custom patterns scan the RAW value: a decoy path
820
+ // even inside message data is exfiltration staging, and custom rules define
821
+ // their own scope.
822
+ const honeypot = honeypotTouch(rawValue, options);
784
823
  if (honeypot)
785
824
  return honeypot;
786
- const custom = customBlock(value, options);
825
+ const custom = customBlock(rawValue, options);
787
826
  if (custom)
788
827
  return custom;
828
+ // Every rule below asks "can this text ACT?" — so it sees the execution
829
+ // view: quoted message/document data (commit -m, issue --body, here-strings,
830
+ // heredocs) is inert unless it can flow into an interpreter (see
831
+ // stripInertDataSegments invariants). This is the generic FP guard for
832
+ // "wrote ABOUT a dangerous command" vs "ran one".
833
+ const value = stripInertDataSegments(rawValue);
789
834
  // NOTE: a disabled item must FALL THROUGH to the later rules, not end the
790
835
  // scan — otherwise turning off (or gating) a path rule would mask a real
791
836
  // reverse shell / destructive command in the same value.
@@ -104,7 +104,10 @@ function buildGuardJs(nodePath, cliEntry) {
104
104
  `const CLI_ENTRY=${JSON.stringify(cliEntry)};`,
105
105
  `const BLOCK_CODE=${BLOCK_CODE};`,
106
106
  `function loadRules(){try{const raw=JSON.parse(fs.readFileSync(RULES_PATH,'utf8'));const rules=(raw.rules||[]).map(r=>{try{return{id:r.id,category:r.category,severity:r.severity,reason:r.reason,source:r.source,action:r.action==='warn'?'warn':'block',re:new RegExp(r.pattern,'i')};}catch{return null;}}).filter(Boolean);return{mode:raw.mode==='block'?'block':'monitor',rules};}catch{return{mode:'monitor',rules:[]};}}`,
107
- `function matchLine(line,rules){const n=line.replace(/\\s+/g,' ').trim();let warnHit=null;for(const r of rules){try{if(r.re.test(n)||r.re.test(line)){if(r.action!=='warn')return r;if(!warnHit)warnHit=r;}}catch{}}return warnHit;}`,
107
+ `// Execution view (aligned with the CLI's stripInertDataSegments): commit messages, issue/PR bodies, and`,
108
+ `// here-strings/heredocs are DATA — inert unless the line can feed an interpreter or the region interpolates.`,
109
+ `function execView(line){if(/\\b(?:iex|invoke-expression|invoke-command)\\b|\\beval\\b|\\bxargs\\b|\\bbase64\\b[^\\n]*(?:-d\\b|--decode\\b)|frombase64string|-encodedcommand\\b|\\s-enc\\s|\\|\\s*&?\\s*(?:sh|bash|zsh|dash|ksh|csh|pwsh|powershell(?:\\.exe)?|cmd(?:\\.exe)?|node|python[0-9.]*|perl|ruby)\\b/i.test(line))return line;const K=/\\$\\(|\`[^\`]*\`/;let o=line;o=o.replace(/@'[\\s\\S]*?'@/g,' fcd_inert_data ');o=o.replace(/@"[\\s\\S]*?"@/g,m=>m.includes('$(')?m:' fcd_inert_data ');o=o.replace(/<<-?\\s*'(\\w+)'[\\s\\S]*?(?:\\n\\1\\b|$)/g,' fcd_inert_data ');o=o.replace(/<<-?\\s*"?(\\w+)"?[\\s\\S]*?(?:\\n\\1\\b|$)/g,m=>K.test(m)?m:' fcd_inert_data ');o=o.replace(/((?:^|\\s)(?:-m|-b|--message|--body|--title|--description|--notes?|--comment|--caption|--summary|--subject|-value|-message)[= ]\\s*)("(?:[^"\\\\]|\\\\.)*"|'(?:[^'\\\\]|\\\\.)*')/gi,(w,f,q)=>q[0]==="'"?f+' fcd_inert_data ':K.test(q)?w:f+' fcd_inert_data ');return o;}`,
110
+ `function matchLine(line,rules){const n=line.replace(/\\s+/g,' ').trim();const x=execView(line);const xn=x.replace(/\\s+/g,' ').trim();let warnHit=null;for(const r of rules){try{const t=r.source==='custom'?line:x;const tn=r.source==='custom'?n:xn;if(r.re.test(tn)||r.re.test(t)){if(r.action!=='warn')return r;if(!warnHit)warnHit=r;}}catch{}}return warnHit;}`,
108
111
  `function triggerFlush(){try{if(fs.existsSync(NODE_PATH)&&fs.existsSync(CLI_ENTRY)){const c=spawn(NODE_PATH,[CLI_ENTRY,'flush-spool','--heartbeat','true'],{detached:true,stdio:'ignore'});c.unref();}}catch{}}`,
109
112
  `function spoolEvent(rule,line,decision){try{const ev=line.trim();const evidence=ev.length>180?ev.slice(0,180)+'...':ev;const event={eventId:crypto.randomUUID(),type:'verdict',decision,toolName:'shell_terminal',operation:'shell_command',reason:'Shell guard: '+rule.reason,ruleId:rule.id,category:rule.category,severity:rule.severity,source:rule.source,evidence,occurredAt:new Date().toISOString()};fs.appendFileSync(SPOOL_PATH,JSON.stringify(event)+'\\n',{encoding:'utf8',mode:0o600});triggerFlush();}catch{}}`,
110
113
  `let argv=process.argv.slice(2);if(argv[0]==='--')argv=argv.slice(1);const line=argv.join(' ');`,
@@ -485,14 +485,42 @@ function buildGuardPs1(nodePath, cliEntry) {
485
485
  ` }`,
486
486
  `} catch { $global:FcdGuardRules = @() }`,
487
487
  ``,
488
+ `# Execution view (aligned with the CLI's stripInertDataSegments): message/`,
489
+ `# document DATA inside a command is inert — commit messages, issue/PR bodies,`,
490
+ `# here-strings. Never strips when the line can feed an interpreter (| sh,`,
491
+ `# iex, eval, xargs, base64 -d, -EncodedCommand) and keeps any region that`,
492
+ `# interpolates $( ) or backtick pairs — those execute BEFORE the command.`,
493
+ `function global:Get-FcdExecView {`,
494
+ ` param([string]$CommandLine)`,
495
+ String.raw ` if ($CommandLine -match '(?i)\b(?:iex|invoke-expression|invoke-command)\b|\beval\b|\bxargs\b|\bbase64\b[^\n]*(?:-d\b|--decode\b)|frombase64string|-encodedcommand\b|\s-enc\s|\|\s*&?\s*(?:sh|bash|zsh|dash|ksh|csh|pwsh|powershell(?:\.exe)?|cmd(?:\.exe)?|node|python[0-9.]*|perl|ruby)\b') { return $CommandLine }`,
496
+ ` $out = $CommandLine`,
497
+ " $keepIfInterpolates = [System.Text.RegularExpressions.MatchEvaluator]{ param($m) if ($m.Value -match '\\$\\(|" + '`[^`]*`' + "') { $m.Value } else { ' fcd_inert_data ' } }",
498
+ String.raw ` $out = [regex]::Replace($out, "@'[\s\S]*?'@", ' fcd_inert_data ')`,
499
+ ` $out = [regex]::Replace($out, '@"[\\s\\S]*?"@', $keepIfInterpolates)`,
500
+ String.raw ` $out = [regex]::Replace($out, "<<-?\s*'(\w+)'[\s\S]*?(?:\n\1\b|$)", ' fcd_inert_data ')`,
501
+ String.raw ` $out = [regex]::Replace($out, '<<-?\s*"?(\w+)"?[\s\S]*?(?:\n\1\b|$)', $keepIfInterpolates)`,
502
+ String.raw ` $flagArg = '((?:^|\s)(?:-m|-b|--message|--body|--title|--description|--notes?|--comment|--caption|--summary|--subject|-value|-message)[= ]\s*)("(?:[^"\\]|\\.)*"|''(?:[^''\\]|\\.)*'')'`,
503
+ " $out = [regex]::Replace($out, $flagArg, [System.Text.RegularExpressions.MatchEvaluator]{ param($m) if ($m.Groups[2].Value.StartsWith(\"'\")) { $m.Groups[1].Value + ' fcd_inert_data ' } elseif ($m.Groups[2].Value -match '\\$\\(|" + '`[^`]*`' + "') { $m.Value } else { $m.Groups[1].Value + ' fcd_inert_data ' } }, 'IgnoreCase')",
504
+ ` return $out`,
505
+ `}`,
506
+ ``,
488
507
  `function global:Test-FcdShellGuard {`,
489
508
  ` param([string]$CommandLine)`,
490
509
  ` if ([string]::IsNullOrWhiteSpace($CommandLine)) { return $null }`,
491
510
  ` $normalized = ($CommandLine -replace '\\s+', ' ').Trim()`,
511
+ ` $execView = $CommandLine`,
512
+ ` try { $execView = Get-FcdExecView -CommandLine $CommandLine } catch { }`,
513
+ ` $execNormalized = ($execView -replace '\\s+', ' ').Trim()`,
492
514
  ` $warnHit = $null`,
493
515
  ` foreach ($rule in $global:FcdGuardRules) {`,
494
516
  ` try {`,
495
- ` if ($rule.Regex.IsMatch($normalized) -or $rule.Regex.IsMatch($CommandLine)) {`,
517
+ ` # Built-in rules ask "can this text ACT?" — they see the execution`,
518
+ ` # view, so a commit message quoting a dangerous command is inert.`,
519
+ ` # Console custom rules define their own scope — they see the raw line.`,
520
+ ` $isCustom = ([string]$rule.Source -eq 'custom')`,
521
+ ` $target = if ($isCustom) { $CommandLine } else { $execView }`,
522
+ ` $targetNorm = if ($isCustom) { $normalized } else { $execNormalized }`,
523
+ ` if ($rule.Regex.IsMatch($targetNorm) -or $rule.Regex.IsMatch($target)) {`,
496
524
  ` # A block rule always outranks a warn rule matching the same line.`,
497
525
  ` if ($rule.Action -ne 'warn') { return $rule }`,
498
526
  ` if ($null -eq $warnHit) { $warnHit = $rule }`,
package/dist/version.json CHANGED
@@ -1,3 +1,3 @@
1
1
  {
2
- "version": "1.25.2"
2
+ "version": "1.25.4"
3
3
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fullcourtdefense-cli",
3
- "version": "1.25.2",
3
+ "version": "1.25.4",
4
4
  "description": "Full Court Defense CLI — security scanning for AI agents from your terminal",
5
5
  "main": "dist/index.js",
6
6
  "bin": {