fullcourtdefense-cli 1.22.1 → 1.22.3

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.
@@ -76,7 +76,7 @@ function installWindows(intervalMinutes, onLogon) {
76
76
  const schedArgs = onLogon
77
77
  ? ['/SC', 'ONLOGON', '/RL', 'LIMITED']
78
78
  : ['/SC', 'MINUTE', '/MO', String(Math.max(1, intervalMinutes)), '/RL', 'LIMITED'];
79
- return (0, daemon_1.registerHiddenTask)(TASK_NAME, (0, daemon_1.buildTaskXml)('protect-all', trigger), 'protect-all', schedArgs);
79
+ return (0, daemon_1.registerHiddenTask)(TASK_NAME, (0, daemon_1.buildTaskXmlVariants)('protect-all', trigger), 'protect-all', schedArgs);
80
80
  }
81
81
  function uninstallWindows() {
82
82
  const result = (0, child_process_1.spawnSync)('schtasks', ['/Delete', '/TN', TASK_NAME, '/F'], { stdio: 'inherit' });
@@ -32,6 +32,7 @@ export declare function discoverSweepCredentialEnv(credentials?: {
32
32
  shieldKey?: string;
33
33
  apiUrl?: string;
34
34
  }): Record<string, string>;
35
+ export type TaskLogonType = 'S4U' | 'InteractiveToken';
35
36
  /** Fully-qualified current user (DOMAIN\\user) for the task principal. */
36
37
  export declare function taskUserId(): string;
37
38
  /** Local wall-clock timestamp (no ms, no tz) as Task Scheduler expects. */
@@ -39,26 +40,39 @@ export declare function taskLocalTimestamp(date: Date): string;
39
40
  /**
40
41
  * Task Scheduler XML that runs `node.exe <cliEntry> <command>` hidden.
41
42
  *
42
- * @param command CLI subcommand ('daemon' | 'watchdog').
43
- * @param triggers Inner <Triggers> XML — logon for the daemon, a repeating
44
- * time trigger for the 5-minute watchdog.
43
+ * @param command CLI subcommand + args ('daemon' | 'watchdog' | 'discover …').
44
+ * @param triggers Inner <Triggers> XML — logon for the daemon, a repeating
45
+ * time trigger for the 5-minute watchdog.
46
+ * @param logonType S4U (windowless, needs elevation to register) or
47
+ * InteractiveToken (registers unelevated, may flash).
45
48
  */
46
- export declare function buildTaskXml(command: string, triggers: string): string;
49
+ export declare function buildTaskXml(command: string, triggers: string, logonType?: TaskLogonType): string;
47
50
  /**
48
- * Register a scheduled task from XML (node run directly, window hidden). Falls
49
- * back to a plain `/TR node …` task if XML registration fails — that variant
50
- * may briefly flash a console window but still contains NO script host, so the
51
- * malware fingerprint never returns. Returns true if either path succeeds.
51
+ * Windowless-first XML variants for one task: S4U (no window, needs elevation
52
+ * to register) first, InteractiveToken (unelevated fallback) second.
53
+ * registerHiddenTask() tries them in order.
54
+ */
55
+ export declare function buildTaskXmlVariants(command: string, triggers: string): string[];
56
+ /**
57
+ * Register a scheduled task from XML (node run directly). Tries each XML
58
+ * variant in order — S4U (windowless) first, then InteractiveToken — and
59
+ * finally falls back to a plain `/TR node …` task. The fallbacks may briefly
60
+ * flash a console window but still contain NO script host, so the malware
61
+ * fingerprint never returns. Returns true if any path succeeds.
52
62
  *
53
63
  * @param taskName Scheduled-task name.
54
- * @param xml Full Task Scheduler XML (from buildDaemon/Watchdog).
64
+ * @param xml Task Scheduler XML variant(s), tried in order.
55
65
  * @param command CLI subcommand for the /TR fallback ('daemon'|'watchdog').
56
66
  * @param scheduleArgs schtasks schedule flags for the fallback (e.g. ['/SC','MINUTE','/MO','5']).
57
67
  */
58
- export declare function registerHiddenTask(taskName: string, xml: string, command: string, scheduleArgs: string[]): boolean;
68
+ export declare function registerHiddenTask(taskName: string, xml: string | string[], command: string, scheduleArgs: string[]): boolean;
59
69
  /** True when an existing scheduled task's action still runs wscript/cscript
60
70
  * (the pre-1.22.1 VBS launcher) — the migration trigger. */
61
71
  export declare function windowsTaskReferencesScriptHost(taskName: string): boolean;
72
+ /** True when an existing task still runs with an InteractiveToken principal —
73
+ * the 1.22.1 window-flash bug (console window on every trigger). Migration
74
+ * trigger for the windowless S4U principal. */
75
+ export declare function windowsTaskRunsInteractive(taskName: string): boolean;
62
76
  /** Snapshot of the resident daemon for out-of-process callers (watchdog/status). */
63
77
  export interface DaemonRuntimeState {
64
78
  alive: boolean;
@@ -39,8 +39,10 @@ exports.discoverSweepCredentialEnv = discoverSweepCredentialEnv;
39
39
  exports.taskUserId = taskUserId;
40
40
  exports.taskLocalTimestamp = taskLocalTimestamp;
41
41
  exports.buildTaskXml = buildTaskXml;
42
+ exports.buildTaskXmlVariants = buildTaskXmlVariants;
42
43
  exports.registerHiddenTask = registerHiddenTask;
43
44
  exports.windowsTaskReferencesScriptHost = windowsTaskReferencesScriptHost;
45
+ exports.windowsTaskRunsInteractive = windowsTaskRunsInteractive;
44
46
  exports.daemonRuntimeState = daemonRuntimeState;
45
47
  exports.spawnDetachedDaemon = spawnDetachedDaemon;
46
48
  exports.pidIsAlive = pidIsAlive;
@@ -1187,23 +1189,6 @@ async function runDaemon(args, config) {
1187
1189
  // Keep the process alive forever (timers alone would do it, but be explicit).
1188
1190
  await new Promise(() => { });
1189
1191
  }
1190
- // ---------------------------------------------------------------------------
1191
- // Autostart install / uninstall / status
1192
- // ---------------------------------------------------------------------------
1193
- // ---------------------------------------------------------------------------
1194
- // Windows autostart launchers
1195
- //
1196
- // HISTORY: we used to run `wscript.exe <script>.vbs` from the scheduled tasks
1197
- // to launch node with no console-window flash. Windows Defender's ML heuristic
1198
- // (`Trojan:Win32/Commando.A!ml`) flags "schtasks task -> wscript runs a script
1199
- // from a user-writable dir on a short recurring interval" as script-based
1200
- // persistence — a textbook malware pattern — and quarantined our own watchdog.
1201
- //
1202
- // FIX: no VBS, no wscript, no PowerShell anywhere in the autostart chain. The
1203
- // scheduled tasks run the bundled `node.exe` DIRECTLY, and the console window
1204
- // is suppressed the supported way — Task Scheduler XML `<Hidden>true</Hidden>`
1205
- // with an InteractiveToken principal (needs no password, no elevation).
1206
- // ---------------------------------------------------------------------------
1207
1192
  /** Fully-qualified current user (DOMAIN\\user) for the task principal. */
1208
1193
  function taskUserId() {
1209
1194
  const domain = process.env.USERDOMAIN;
@@ -1231,18 +1216,20 @@ function taskLocalTimestamp(date) {
1231
1216
  /**
1232
1217
  * Task Scheduler XML that runs `node.exe <cliEntry> <command>` hidden.
1233
1218
  *
1234
- * @param command CLI subcommand ('daemon' | 'watchdog').
1235
- * @param triggers Inner <Triggers> XML — logon for the daemon, a repeating
1236
- * time trigger for the 5-minute watchdog.
1219
+ * @param command CLI subcommand + args ('daemon' | 'watchdog' | 'discover …').
1220
+ * @param triggers Inner <Triggers> XML — logon for the daemon, a repeating
1221
+ * time trigger for the 5-minute watchdog.
1222
+ * @param logonType S4U (windowless, needs elevation to register) or
1223
+ * InteractiveToken (registers unelevated, may flash).
1237
1224
  */
1238
- function buildTaskXml(command, triggers) {
1225
+ function buildTaskXml(command, triggers, logonType = 'S4U') {
1239
1226
  const userId = xmlEscape(taskUserId());
1240
1227
  const nodeExe = xmlEscape(process.execPath);
1241
1228
  const args = xmlEscape(`"${cliEntry()}" ${command}`);
1242
1229
  return `<?xml version="1.0" encoding="UTF-16"?>
1243
1230
  <Task version="1.2" xmlns="http://schemas.microsoft.com/windows/2004/02/mit/task">
1244
1231
  <RegistrationInfo>
1245
- <Description>FullCourtDefense ${command} — runs node directly, hidden, no script host.</Description>
1232
+ <Description>FullCourtDefense ${xmlEscape(command)} — runs node directly, windowless, no script host.</Description>
1246
1233
  </RegistrationInfo>
1247
1234
  <Triggers>
1248
1235
  ${triggers}
@@ -1250,7 +1237,7 @@ ${triggers}
1250
1237
  <Principals>
1251
1238
  <Principal id="Author">
1252
1239
  <UserId>${userId}</UserId>
1253
- <LogonType>InteractiveToken</LogonType>
1240
+ <LogonType>${logonType}</LogonType>
1254
1241
  <RunLevel>LeastPrivilege</RunLevel>
1255
1242
  </Principal>
1256
1243
  </Principals>
@@ -1279,13 +1266,23 @@ ${triggers}
1279
1266
  </Task>
1280
1267
  `;
1281
1268
  }
1282
- /** Daemon task: start at logon (node run hidden, no repetition). */
1283
- function buildDaemonTaskXml() {
1284
- const trigger = ` <LogonTrigger>
1269
+ /**
1270
+ * Windowless-first XML variants for one task: S4U (no window, needs elevation
1271
+ * to register) first, InteractiveToken (unelevated fallback) second.
1272
+ * registerHiddenTask() tries them in order.
1273
+ */
1274
+ function buildTaskXmlVariants(command, triggers) {
1275
+ return [buildTaskXml(command, triggers, 'S4U'), buildTaskXml(command, triggers, 'InteractiveToken')];
1276
+ }
1277
+ function daemonTaskTrigger() {
1278
+ return ` <LogonTrigger>
1285
1279
  <Enabled>true</Enabled>
1286
1280
  <UserId>${xmlEscape(taskUserId())}</UserId>
1287
1281
  </LogonTrigger>`;
1288
- return buildTaskXml('daemon', trigger);
1282
+ }
1283
+ /** Daemon task variants: start at logon (node run windowless, no repetition). */
1284
+ function buildDaemonTaskXml() {
1285
+ return buildTaskXmlVariants('daemon', daemonTaskTrigger());
1289
1286
  }
1290
1287
  /**
1291
1288
  * Watchdog task: run the `watchdog` command every 5 minutes (liveness beacon +
@@ -1303,16 +1300,17 @@ function buildWatchdogTaskXml() {
1303
1300
  <StopAtDurationEnd>false</StopAtDurationEnd>
1304
1301
  </Repetition>
1305
1302
  </TimeTrigger>`;
1306
- return buildTaskXml('watchdog', trigger);
1303
+ return buildTaskXmlVariants('watchdog', trigger);
1307
1304
  }
1308
1305
  /**
1309
- * Register a scheduled task from XML (node run directly, window hidden). Falls
1310
- * back to a plain `/TR node …` task if XML registration fails that variant
1311
- * may briefly flash a console window but still contains NO script host, so the
1312
- * malware fingerprint never returns. Returns true if either path succeeds.
1306
+ * Register a scheduled task from XML (node run directly). Tries each XML
1307
+ * variant in order S4U (windowless) first, then InteractiveTokenand
1308
+ * finally falls back to a plain `/TR node …` task. The fallbacks may briefly
1309
+ * flash a console window but still contain NO script host, so the malware
1310
+ * fingerprint never returns. Returns true if any path succeeds.
1313
1311
  *
1314
1312
  * @param taskName Scheduled-task name.
1315
- * @param xml Full Task Scheduler XML (from buildDaemon/Watchdog).
1313
+ * @param xml Task Scheduler XML variant(s), tried in order.
1316
1314
  * @param command CLI subcommand for the /TR fallback ('daemon'|'watchdog').
1317
1315
  * @param scheduleArgs schtasks schedule flags for the fallback (e.g. ['/SC','MINUTE','/MO','5']).
1318
1316
  */
@@ -1324,17 +1322,19 @@ function registerHiddenTask(taskName, xml, command, scheduleArgs) {
1324
1322
  }
1325
1323
  catch { /* ignore */ }
1326
1324
  const xmlPath = path.join(dir, `${taskName.replace(/[^A-Za-z0-9]+/g, '_')}.task.xml`);
1327
- try {
1328
- // schtasks /XML insists the file encoding matches the XML declaration and
1329
- // rejects UTF-8 ("unable to switch the encoding") write UTF-16LE + BOM.
1330
- fs.writeFileSync(xmlPath, `\ufeff${xml}`, 'utf16le');
1331
- const created = (0, child_process_1.spawnSync)('schtasks', ['/Create', '/TN', taskName, '/XML', xmlPath, '/F'], {
1332
- stdio: 'ignore', windowsHide: true, timeout: 20_000,
1333
- });
1334
- if (created.status === 0)
1335
- return true;
1325
+ for (const variant of Array.isArray(xml) ? xml : [xml]) {
1326
+ try {
1327
+ // schtasks /XML insists the file encoding matches the XML declaration and
1328
+ // rejects UTF-8 ("unable to switch the encoding") — write UTF-16LE + BOM.
1329
+ fs.writeFileSync(xmlPath, `\ufeff${variant}`, 'utf16le');
1330
+ const created = (0, child_process_1.spawnSync)('schtasks', ['/Create', '/TN', taskName, '/XML', xmlPath, '/F'], {
1331
+ stdio: 'ignore', windowsHide: true, timeout: 20_000,
1332
+ });
1333
+ if (created.status === 0)
1334
+ return true;
1335
+ }
1336
+ catch { /* try next variant, then /TR */ }
1336
1337
  }
1337
- catch { /* fall through to /TR */ }
1338
1338
  // Fallback: node directly via /TR (no wscript, no VBS). node/CLI may live
1339
1339
  // under a path with spaces (Program Files), so quote each and let schtasks
1340
1340
  // pass the whole /TR string through.
@@ -1369,6 +1369,49 @@ function windowsTaskReferencesScriptHost(taskName) {
1369
1369
  return false;
1370
1370
  }
1371
1371
  }
1372
+ /** True when an existing task still runs with an InteractiveToken principal —
1373
+ * the 1.22.1 window-flash bug (console window on every trigger). Migration
1374
+ * trigger for the windowless S4U principal. */
1375
+ function windowsTaskRunsInteractive(taskName) {
1376
+ try {
1377
+ const query = (0, child_process_1.spawnSync)('schtasks', ['/Query', '/TN', taskName, '/XML'], {
1378
+ encoding: 'utf8', windowsHide: true, timeout: 10_000,
1379
+ });
1380
+ if (query.status !== 0 || typeof query.stdout !== 'string')
1381
+ return false;
1382
+ return /<LogonType>\s*InteractiveToken\s*<\/LogonType>/i.test(query.stdout);
1383
+ }
1384
+ catch {
1385
+ return false;
1386
+ }
1387
+ }
1388
+ /**
1389
+ * Upgrade an EXISTING (working) task to the windowless S4U principal. Tries
1390
+ * ONLY the S4U XML — on failure (unelevated daemon) the current task is left
1391
+ * untouched so autostart never regresses from "flashes" to "broken".
1392
+ */
1393
+ function upgradeTaskWindowless(taskName, s4uXml, logFn) {
1394
+ const base = process.env.LOCALAPPDATA || path.join(os.homedir(), 'AppData', 'Local');
1395
+ const dir = path.join(base, 'FullCourtDefense');
1396
+ try {
1397
+ fs.mkdirSync(dir, { recursive: true });
1398
+ }
1399
+ catch { /* ignore */ }
1400
+ const xmlPath = path.join(dir, `${taskName.replace(/[^A-Za-z0-9]+/g, '_')}.task.xml`);
1401
+ try {
1402
+ fs.writeFileSync(xmlPath, `\ufeff${s4uXml}`, 'utf16le');
1403
+ const created = (0, child_process_1.spawnSync)('schtasks', ['/Create', '/TN', taskName, '/XML', xmlPath, '/F'], {
1404
+ stdio: 'ignore', windowsHide: true, timeout: 20_000,
1405
+ });
1406
+ if (created.status === 0) {
1407
+ logFn(`Self-heal: "${taskName}" migrated to the windowless S4U principal (no more console-window flash).`);
1408
+ }
1409
+ else {
1410
+ logFn(`Self-heal: could not migrate "${taskName}" to S4U (needs elevation) — existing task kept as-is.`);
1411
+ }
1412
+ }
1413
+ catch { /* keep the existing working task */ }
1414
+ }
1372
1415
  /** True when the per-user Run-key fallback still names wscript/a .vbs. */
1373
1416
  function windowsRunKeyReferencesScriptHost() {
1374
1417
  try {
@@ -1498,6 +1541,15 @@ function ensureWindowsAutostartHealthy(logFn) {
1498
1541
  logFn('Self-heal: could not (re)create the watchdog task — daemon revival relies on logon autostart only.');
1499
1542
  }
1500
1543
  }
1544
+ // Migration (1.22.1 -> 1.22.2): InteractiveToken tasks flash a console
1545
+ // window on every trigger. Upgrade healthy existing tasks to S4U; S4U-only
1546
+ // attempt so a denied re-registration never breaks a working task.
1547
+ if (daemonTask.status === 0 && !legacyDaemon && windowsTaskRunsInteractive(TASK_NAME)) {
1548
+ upgradeTaskWindowless(TASK_NAME, buildDaemonTaskXml()[0], logFn);
1549
+ }
1550
+ if (watchdogQuery.status === 0 && !legacyWatchdog && windowsTaskRunsInteractive(WATCHDOG_TASK_NAME)) {
1551
+ upgradeTaskWindowless(WATCHDOG_TASK_NAME, buildWatchdogTaskXml()[0], logFn);
1552
+ }
1501
1553
  // A stale Run-key fallback from an older install may still name wscript.
1502
1554
  if (windowsRunKeyReferencesScriptHost()) {
1503
1555
  (0, child_process_1.spawnSync)('reg', [
@@ -40,17 +40,17 @@ const child_process_1 = require("child_process");
40
40
  const fs = __importStar(require("fs"));
41
41
  const os = __importStar(require("os"));
42
42
  const path = __importStar(require("path"));
43
+ const daemon_1 = require("./daemon");
43
44
  const TASK_NAME = 'FullCourtDefenseDesktopDiscover';
44
45
  const STARTUP_LAUNCHER = 'FullCourtDefenseDesktopDiscover.cmd';
45
46
  function windowsStartupLauncherPath() {
46
47
  const appData = process.env.APPDATA || path.join(os.homedir(), 'AppData', 'Roaming');
47
48
  return path.join(appData, 'Microsoft', 'Windows', 'Start Menu', 'Programs', 'Startup', STARTUP_LAUNCHER);
48
49
  }
49
- function discoverCommandLine(opts = {}) {
50
- const node = process.execPath;
51
- const script = path.resolve(process.argv[1] || path.join(__dirname, '..', 'index.js'));
50
+ /** The `discover …` argument tail (no node/script prefix) for task actions. */
51
+ function discoverCommandTail(opts = {}) {
52
52
  const q = (value) => (/\s/.test(value) ? `"${value}"` : value);
53
- const parts = [q(node), q(script), 'discover', '--upload', '--deep', '--silent'];
53
+ const parts = ['discover', '--upload', '--deep', '--silent'];
54
54
  if (opts.surface)
55
55
  parts.push('--surface', q(opts.surface));
56
56
  if (opts.scanRoot)
@@ -61,26 +61,40 @@ function discoverCommandLine(opts = {}) {
61
61
  parts.push('--user-email', q(opts.userEmail));
62
62
  return parts.join(' ');
63
63
  }
64
+ function discoverCommandLine(opts = {}) {
65
+ const node = process.execPath;
66
+ const script = path.resolve(process.argv[1] || path.join(__dirname, '..', 'index.js'));
67
+ const q = (value) => (/\s/.test(value) ? `"${value}"` : value);
68
+ return [q(node), q(script), discoverCommandTail(opts)].join(' ');
69
+ }
70
+ // Registered through the shared windowless task helpers (daemon.ts): S4U
71
+ // principal first (no console-window flash on the daily run), InteractiveToken
72
+ // then plain /TR as fallbacks — never a script host.
64
73
  function installWindowsSchedule(hour, opts, trigger = 'daily') {
65
- const tr = discoverCommandLine(opts);
66
- try {
67
- (0, child_process_1.execSync)(`schtasks /Delete /TN "${TASK_NAME}" /F`, { stdio: 'ignore' });
68
- }
69
- catch {
70
- // task may not exist
71
- }
72
- const schedule = trigger === 'logon'
73
- ? '/SC ONLOGON'
74
- : `/SC DAILY /ST ${String(hour).padStart(2, '0')}:00`;
75
- try {
76
- (0, child_process_1.execSync)(`schtasks /Create /F /TN "${TASK_NAME}" ${schedule} /RL LIMITED /TR "${tr.replace(/"/g, '\\"')}"`, { stdio: 'inherit' });
77
- }
78
- catch (error) {
74
+ const tail = discoverCommandTail(opts);
75
+ const startBoundary = `${new Date().getFullYear()}-${String(new Date().getMonth() + 1).padStart(2, '0')}-${String(new Date().getDate()).padStart(2, '0')}T${String(hour).padStart(2, '0')}:00:00`;
76
+ const triggerXml = trigger === 'logon'
77
+ ? ` <LogonTrigger>
78
+ <Enabled>true</Enabled>
79
+ <UserId>${(0, daemon_1.xmlEscape)((0, daemon_1.taskUserId)())}</UserId>
80
+ </LogonTrigger>`
81
+ : ` <CalendarTrigger>
82
+ <Enabled>true</Enabled>
83
+ <StartBoundary>${startBoundary}</StartBoundary>
84
+ <ScheduleByDay>
85
+ <DaysInterval>1</DaysInterval>
86
+ </ScheduleByDay>
87
+ </CalendarTrigger>`;
88
+ const schedArgs = trigger === 'logon'
89
+ ? ['/SC', 'ONLOGON', '/RL', 'LIMITED']
90
+ : ['/SC', 'DAILY', '/ST', `${String(hour).padStart(2, '0')}:00`, '/RL', 'LIMITED'];
91
+ const ok = (0, daemon_1.registerHiddenTask)(TASK_NAME, (0, daemon_1.buildTaskXmlVariants)(tail, triggerXml), tail, schedArgs);
92
+ if (!ok) {
79
93
  if (trigger !== 'logon')
80
- throw error;
94
+ throw new Error(`Could not register scheduled task "${TASK_NAME}" (Task Scheduler denied all variants).`);
81
95
  const launcher = windowsStartupLauncherPath();
82
96
  fs.mkdirSync(path.dirname(launcher), { recursive: true });
83
- fs.writeFileSync(launcher, `@echo off\r\n${tr} > "%USERPROFILE%\\.fullcourtdefense-discover.log" 2>&1\r\n`, 'utf8');
97
+ fs.writeFileSync(launcher, `@echo off\r\n${discoverCommandLine(opts)} > "%USERPROFILE%\\.fullcourtdefense-discover.log" 2>&1\r\n`, 'utf8');
84
98
  console.log(`Task Scheduler denied access; installed user logon launcher instead: ${launcher}`);
85
99
  }
86
100
  }
package/dist/version.json CHANGED
@@ -1,3 +1,3 @@
1
1
  {
2
- "version": "1.22.1"
2
+ "version": "1.22.3"
3
3
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fullcourtdefense-cli",
3
- "version": "1.22.1",
3
+ "version": "1.22.3",
4
4
  "description": "Full Court Defense CLI — security scanning for AI agents from your terminal",
5
5
  "main": "dist/index.js",
6
6
  "bin": {