fullcourtdefense-cli 1.22.0 → 1.22.1

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.
@@ -39,6 +39,7 @@ const os = __importStar(require("os"));
39
39
  const path = __importStar(require("path"));
40
40
  const child_process_1 = require("child_process");
41
41
  const mcpGateway_1 = require("./mcpGateway");
42
+ const daemon_1 = require("./daemon");
42
43
  const COLOR = {
43
44
  reset: '\x1b[0m', bold: '\x1b[1m', dim: '\x1b[2m',
44
45
  red: '\x1b[31m', yellow: '\x1b[33m', green: '\x1b[32m', cyan: '\x1b[36m', gray: '\x1b[90m',
@@ -54,38 +55,40 @@ function launcherDir() {
54
55
  : path.join(os.homedir(), '.fullcourtdefense');
55
56
  return path.join(base, 'FullCourtDefense');
56
57
  }
57
- /** On Windows, write a hidden VBS launcher so the scheduled run doesn't flash a console window. */
58
- function ensureWindowsLauncher() {
59
- const dir = launcherDir();
60
- fs.mkdirSync(dir, { recursive: true });
61
- const vbs = path.join(dir, 'auto-protect.vbs');
62
- const node = process.execPath;
63
- const cli = cliEntry();
64
- // WScript.Shell.Run with window style 0 = hidden, wait = false. Run node
65
- // directly — `cmd /c` breaks quote parsing under paths with spaces.
66
- const inner = `"${node}" "${cli}" protect-all`;
67
- const content = `CreateObject("WScript.Shell").Run "${inner.replace(/"/g, '""')}", 0, False\n`;
68
- fs.writeFileSync(vbs, content, 'utf8');
69
- return vbs;
70
- }
58
+ // No VBS/wscript launcher here Windows Defender's Commando.A!ml heuristic
59
+ // flags "scheduled task -> wscript runs a user-writable script on an interval"
60
+ // as script persistence. The task runs node.exe directly, hidden via task XML
61
+ // (shared helpers in daemon.ts).
71
62
  function installWindows(intervalMinutes, onLogon) {
72
- const vbs = ensureWindowsLauncher();
73
- const tr = `wscript.exe "${vbs}"`;
74
- const baseArgs = ['/Create', '/TN', TASK_NAME, '/TR', tr, '/F', '/RL', 'LIMITED'];
63
+ const trigger = onLogon
64
+ ? ` <LogonTrigger>
65
+ <Enabled>true</Enabled>
66
+ <UserId>${(0, daemon_1.xmlEscape)((0, daemon_1.taskUserId)())}</UserId>
67
+ </LogonTrigger>`
68
+ : ` <TimeTrigger>
69
+ <Enabled>true</Enabled>
70
+ <StartBoundary>${(0, daemon_1.taskLocalTimestamp)(new Date(Date.now() - 60_000))}</StartBoundary>
71
+ <Repetition>
72
+ <Interval>PT${Math.max(1, intervalMinutes)}M</Interval>
73
+ <StopAtDurationEnd>false</StopAtDurationEnd>
74
+ </Repetition>
75
+ </TimeTrigger>`;
75
76
  const schedArgs = onLogon
76
- ? ['/SC', 'ONLOGON']
77
- : ['/SC', 'MINUTE', '/MO', String(intervalMinutes)];
78
- const result = (0, child_process_1.spawnSync)('schtasks', [...baseArgs, ...schedArgs], { stdio: 'inherit' });
79
- return result.status === 0;
77
+ ? ['/SC', 'ONLOGON', '/RL', 'LIMITED']
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);
80
80
  }
81
81
  function uninstallWindows() {
82
82
  const result = (0, child_process_1.spawnSync)('schtasks', ['/Delete', '/TN', TASK_NAME, '/F'], { stdio: 'inherit' });
83
- const vbs = path.join(launcherDir(), 'auto-protect.vbs');
84
- try {
85
- if (fs.existsSync(vbs))
86
- fs.unlinkSync(vbs);
83
+ // Legacy VBS launcher from pre-1.22.1 installs + the current task XML.
84
+ for (const name of ['auto-protect.vbs', `${TASK_NAME.replace(/[^A-Za-z0-9]+/g, '_')}.task.xml`]) {
85
+ const file = path.join(launcherDir(), name);
86
+ try {
87
+ if (fs.existsSync(file))
88
+ fs.unlinkSync(file);
89
+ }
90
+ catch { /* ignore */ }
87
91
  }
88
- catch { /* ignore */ }
89
92
  return result.status === 0;
90
93
  }
91
94
  function statusWindows() {
@@ -32,6 +32,33 @@ export declare function discoverSweepCredentialEnv(credentials?: {
32
32
  shieldKey?: string;
33
33
  apiUrl?: string;
34
34
  }): Record<string, string>;
35
+ /** Fully-qualified current user (DOMAIN\\user) for the task principal. */
36
+ export declare function taskUserId(): string;
37
+ /** Local wall-clock timestamp (no ms, no tz) as Task Scheduler expects. */
38
+ export declare function taskLocalTimestamp(date: Date): string;
39
+ /**
40
+ * Task Scheduler XML that runs `node.exe <cliEntry> <command>` hidden.
41
+ *
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.
45
+ */
46
+ export declare function buildTaskXml(command: string, triggers: string): string;
47
+ /**
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.
52
+ *
53
+ * @param taskName Scheduled-task name.
54
+ * @param xml Full Task Scheduler XML (from buildDaemon/Watchdog).
55
+ * @param command CLI subcommand for the /TR fallback ('daemon'|'watchdog').
56
+ * @param scheduleArgs schtasks schedule flags for the fallback (e.g. ['/SC','MINUTE','/MO','5']).
57
+ */
58
+ export declare function registerHiddenTask(taskName: string, xml: string, command: string, scheduleArgs: string[]): boolean;
59
+ /** True when an existing scheduled task's action still runs wscript/cscript
60
+ * (the pre-1.22.1 VBS launcher) — the migration trigger. */
61
+ export declare function windowsTaskReferencesScriptHost(taskName: string): boolean;
35
62
  /** Snapshot of the resident daemon for out-of-process callers (watchdog/status). */
36
63
  export interface DaemonRuntimeState {
37
64
  alive: boolean;
@@ -45,6 +72,7 @@ export declare function spawnDetachedDaemon(): boolean;
45
72
  /** Pid-alive check shared with the watchdog (EPERM still means alive). */
46
73
  export declare function pidIsAlive(pid: number): boolean;
47
74
  export declare function macosLaunchdPath(currentPath?: string, execPath?: string): string;
75
+ export declare function xmlEscape(value: string): string;
48
76
  /** Whether the daemon has been registered to start automatically. */
49
77
  /** Is the 5-minute watchdog scheduled task registered? (Windows-only feature.) */
50
78
  export declare function isWatchdogTaskInstalled(): boolean;
@@ -36,10 +36,16 @@ Object.defineProperty(exports, "__esModule", { value: true });
36
36
  exports.daemonDiscoverSweepArgs = daemonDiscoverSweepArgs;
37
37
  exports.summarizeDiscoverStderr = summarizeDiscoverStderr;
38
38
  exports.discoverSweepCredentialEnv = discoverSweepCredentialEnv;
39
+ exports.taskUserId = taskUserId;
40
+ exports.taskLocalTimestamp = taskLocalTimestamp;
41
+ exports.buildTaskXml = buildTaskXml;
42
+ exports.registerHiddenTask = registerHiddenTask;
43
+ exports.windowsTaskReferencesScriptHost = windowsTaskReferencesScriptHost;
39
44
  exports.daemonRuntimeState = daemonRuntimeState;
40
45
  exports.spawnDetachedDaemon = spawnDetachedDaemon;
41
46
  exports.pidIsAlive = pidIsAlive;
42
47
  exports.macosLaunchdPath = macosLaunchdPath;
48
+ exports.xmlEscape = xmlEscape;
43
49
  exports.isWatchdogTaskInstalled = isWatchdogTaskInstalled;
44
50
  exports.isDaemonAutostartInstalled = isDaemonAutostartInstalled;
45
51
  exports.daemonCommand = daemonCommand;
@@ -1184,29 +1190,198 @@ async function runDaemon(args, config) {
1184
1190
  // ---------------------------------------------------------------------------
1185
1191
  // Autostart install / uninstall / status
1186
1192
  // ---------------------------------------------------------------------------
1187
- /** Hidden VBS launcher so the logon task doesn't flash a console window. */
1188
- function writeHiddenLauncher(fileName, cliCommand) {
1189
- const base = process.env.LOCALAPPDATA || path.join(os.homedir(), 'AppData', 'Local');
1190
- const dir = path.join(base, 'FullCourtDefense');
1191
- fs.mkdirSync(dir, { recursive: true });
1192
- const vbs = path.join(dir, fileName);
1193
- // Run node directly routing through `cmd /c` breaks cmd's quote parsing
1194
- // when node/CLI live under a path with spaces (e.g. Program Files).
1195
- const inner = `"${process.execPath}" "${cliEntry()}" ${cliCommand}`;
1196
- const content = `CreateObject("WScript.Shell").Run "${inner.replace(/"/g, '""')}", 0, False\n`;
1197
- fs.writeFileSync(vbs, content, 'utf8');
1198
- return vbs;
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
+ /** Fully-qualified current user (DOMAIN\\user) for the task principal. */
1208
+ function taskUserId() {
1209
+ const domain = process.env.USERDOMAIN;
1210
+ let user = process.env.USERNAME;
1211
+ if (!user) {
1212
+ try {
1213
+ user = os.userInfo().username;
1214
+ }
1215
+ catch {
1216
+ user = undefined;
1217
+ }
1218
+ }
1219
+ if (user && domain)
1220
+ return `${domain}\\${user}`;
1221
+ if (user)
1222
+ return user;
1223
+ return os.hostname();
1224
+ }
1225
+ /** Local wall-clock timestamp (no ms, no tz) as Task Scheduler expects. */
1226
+ function taskLocalTimestamp(date) {
1227
+ const pad = (n) => String(n).padStart(2, '0');
1228
+ return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}` +
1229
+ `T${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`;
1230
+ }
1231
+ /**
1232
+ * Task Scheduler XML that runs `node.exe <cliEntry> <command>` hidden.
1233
+ *
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.
1237
+ */
1238
+ function buildTaskXml(command, triggers) {
1239
+ const userId = xmlEscape(taskUserId());
1240
+ const nodeExe = xmlEscape(process.execPath);
1241
+ const args = xmlEscape(`"${cliEntry()}" ${command}`);
1242
+ return `<?xml version="1.0" encoding="UTF-16"?>
1243
+ <Task version="1.2" xmlns="http://schemas.microsoft.com/windows/2004/02/mit/task">
1244
+ <RegistrationInfo>
1245
+ <Description>FullCourtDefense ${command} — runs node directly, hidden, no script host.</Description>
1246
+ </RegistrationInfo>
1247
+ <Triggers>
1248
+ ${triggers}
1249
+ </Triggers>
1250
+ <Principals>
1251
+ <Principal id="Author">
1252
+ <UserId>${userId}</UserId>
1253
+ <LogonType>InteractiveToken</LogonType>
1254
+ <RunLevel>LeastPrivilege</RunLevel>
1255
+ </Principal>
1256
+ </Principals>
1257
+ <Settings>
1258
+ <Hidden>true</Hidden>
1259
+ <MultipleInstancesPolicy>IgnoreNew</MultipleInstancesPolicy>
1260
+ <DisallowStartIfOnBatteries>false</DisallowStartIfOnBatteries>
1261
+ <StopIfGoingOnBatteries>false</StopIfGoingOnBatteries>
1262
+ <StartWhenAvailable>true</StartWhenAvailable>
1263
+ <RunOnlyIfNetworkAvailable>false</RunOnlyIfNetworkAvailable>
1264
+ <AllowHardTerminate>true</AllowHardTerminate>
1265
+ <AllowStartOnDemand>true</AllowStartOnDemand>
1266
+ <Enabled>true</Enabled>
1267
+ <ExecutionTimeLimit>PT0S</ExecutionTimeLimit>
1268
+ <IdleSettings>
1269
+ <StopOnIdleEnd>false</StopOnIdleEnd>
1270
+ <RestartOnIdle>false</RestartOnIdle>
1271
+ </IdleSettings>
1272
+ </Settings>
1273
+ <Actions Context="Author">
1274
+ <Exec>
1275
+ <Command>${nodeExe}</Command>
1276
+ <Arguments>${args}</Arguments>
1277
+ </Exec>
1278
+ </Actions>
1279
+ </Task>
1280
+ `;
1199
1281
  }
1200
- function ensureWindowsLauncher() {
1201
- return writeHiddenLauncher('daemon.vbs', 'daemon');
1282
+ /** Daemon task: start at logon (node run hidden, no repetition). */
1283
+ function buildDaemonTaskXml() {
1284
+ const trigger = ` <LogonTrigger>
1285
+ <Enabled>true</Enabled>
1286
+ <UserId>${xmlEscape(taskUserId())}</UserId>
1287
+ </LogonTrigger>`;
1288
+ return buildTaskXml('daemon', trigger);
1202
1289
  }
1203
1290
  /**
1204
- * Watchdog launcher: runs the `watchdog` command (liveness beacon + daemon
1205
- * revival + crash post-mortem) instead of blindly starting the daemon the
1206
- * beacon is what lets the console tell "daemon dead" apart from "machine off".
1291
+ * Watchdog task: run the `watchdog` command every 5 minutes (liveness beacon +
1292
+ * daemon revival + crash post-mortem) the beacon is what lets the console
1293
+ * tell "daemon dead" apart from "machine off". A repeating time trigger with a
1294
+ * past start boundary fires promptly and keeps repeating across reboots.
1207
1295
  */
1208
- function ensureWindowsWatchdogLauncher() {
1209
- return writeHiddenLauncher('watchdog.vbs', 'watchdog');
1296
+ function buildWatchdogTaskXml() {
1297
+ const start = taskLocalTimestamp(new Date(Date.now() - 60_000));
1298
+ const trigger = ` <TimeTrigger>
1299
+ <Enabled>true</Enabled>
1300
+ <StartBoundary>${start}</StartBoundary>
1301
+ <Repetition>
1302
+ <Interval>PT5M</Interval>
1303
+ <StopAtDurationEnd>false</StopAtDurationEnd>
1304
+ </Repetition>
1305
+ </TimeTrigger>`;
1306
+ return buildTaskXml('watchdog', trigger);
1307
+ }
1308
+ /**
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.
1313
+ *
1314
+ * @param taskName Scheduled-task name.
1315
+ * @param xml Full Task Scheduler XML (from buildDaemon/Watchdog).
1316
+ * @param command CLI subcommand for the /TR fallback ('daemon'|'watchdog').
1317
+ * @param scheduleArgs schtasks schedule flags for the fallback (e.g. ['/SC','MINUTE','/MO','5']).
1318
+ */
1319
+ function registerHiddenTask(taskName, xml, command, scheduleArgs) {
1320
+ const base = process.env.LOCALAPPDATA || path.join(os.homedir(), 'AppData', 'Local');
1321
+ const dir = path.join(base, 'FullCourtDefense');
1322
+ try {
1323
+ fs.mkdirSync(dir, { recursive: true });
1324
+ }
1325
+ catch { /* ignore */ }
1326
+ 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;
1336
+ }
1337
+ catch { /* fall through to /TR */ }
1338
+ // Fallback: node directly via /TR (no wscript, no VBS). node/CLI may live
1339
+ // under a path with spaces (Program Files), so quote each and let schtasks
1340
+ // pass the whole /TR string through.
1341
+ const tr = `"${process.execPath}" "${cliEntry()}" ${command}`;
1342
+ const fallback = (0, child_process_1.spawnSync)('schtasks', [
1343
+ '/Create', '/TN', taskName, '/TR', tr, ...scheduleArgs, '/F',
1344
+ ], { stdio: 'ignore', windowsHide: true, timeout: 20_000 });
1345
+ return fallback.status === 0;
1346
+ }
1347
+ /** Remove legacy VBS launchers left by pre-1.22.1 installs. */
1348
+ function removeLegacyWindowsLaunchers() {
1349
+ const launcherDir = path.join(process.env.LOCALAPPDATA || path.join(os.homedir(), 'AppData', 'Local'), 'FullCourtDefense');
1350
+ for (const launcher of ['daemon.vbs', 'watchdog.vbs']) {
1351
+ try {
1352
+ fs.unlinkSync(path.join(launcherDir, launcher));
1353
+ }
1354
+ catch { /* already gone */ }
1355
+ }
1356
+ }
1357
+ /** True when an existing scheduled task's action still runs wscript/cscript
1358
+ * (the pre-1.22.1 VBS launcher) — the migration trigger. */
1359
+ function windowsTaskReferencesScriptHost(taskName) {
1360
+ try {
1361
+ const query = (0, child_process_1.spawnSync)('schtasks', ['/Query', '/TN', taskName, '/XML'], {
1362
+ encoding: 'utf8', windowsHide: true, timeout: 10_000,
1363
+ });
1364
+ if (query.status !== 0 || typeof query.stdout !== 'string')
1365
+ return false;
1366
+ return /wscript|cscript|\.vbs/i.test(query.stdout);
1367
+ }
1368
+ catch {
1369
+ return false;
1370
+ }
1371
+ }
1372
+ /** True when the per-user Run-key fallback still names wscript/a .vbs. */
1373
+ function windowsRunKeyReferencesScriptHost() {
1374
+ try {
1375
+ const query = (0, child_process_1.spawnSync)('reg', ['query', WINDOWS_RUN_KEY, '/v', WINDOWS_RUN_VALUE], {
1376
+ encoding: 'utf8', windowsHide: true, timeout: 5_000,
1377
+ });
1378
+ if (query.status !== 0 || typeof query.stdout !== 'string')
1379
+ return false;
1380
+ return /wscript|cscript|\.vbs/i.test(query.stdout);
1381
+ }
1382
+ catch {
1383
+ return false;
1384
+ }
1210
1385
  }
1211
1386
  const WINDOWS_RUN_KEY = 'HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Run';
1212
1387
  const WINDOWS_RUN_VALUE = 'FullCourtDefenseDaemon';
@@ -1219,7 +1394,7 @@ function isWindowsRunKeyInstalled() {
1219
1394
  /** Launch the daemon right now, outside our own process tree. WMI process
1220
1395
  * creation escapes the Windows Installer job object, which would otherwise
1221
1396
  * kill the daemon the moment an MSI custom action finishes. */
1222
- function startDaemonNowWindows(vbs, viaTask = false) {
1397
+ function startDaemonNowWindows(viaTask = false) {
1223
1398
  try {
1224
1399
  const existing = Number(fs.readFileSync(pidFile(), 'utf8').trim());
1225
1400
  if (Number.isFinite(existing) && existing > 0 && isPidAlive(existing)) {
@@ -1242,42 +1417,41 @@ function startDaemonNowWindows(vbs, viaTask = false) {
1242
1417
  if (run.status === 0)
1243
1418
  return;
1244
1419
  }
1245
- const escaped = vbs.replace(/'/g, "''");
1420
+ // WMI process creation escapes the MSI job object. Runs node.exe DIRECTLY —
1421
+ // no wscript/VBS (Defender's Commando.A!ml heuristic) — with a hidden window.
1422
+ const nodeEsc = process.execPath.replace(/'/g, "''");
1423
+ const entryEsc = cliEntry().replace(/'/g, "''");
1246
1424
  (0, child_process_1.spawnSync)('powershell', [
1247
1425
  '-NoProfile', '-NonInteractive', '-Command',
1248
- `Invoke-CimMethod -ClassName Win32_Process -MethodName Create -Arguments @{ CommandLine = ('wscript.exe \"' + '${escaped}' + '\"') } | Out-Null`,
1426
+ `Invoke-CimMethod -ClassName Win32_Process -MethodName Create -Arguments @{ CommandLine = ('\"' + '${nodeEsc}' + '\" \"' + '${entryEsc}' + '\" daemon') } | Out-Null`,
1249
1427
  ], { stdio: 'ignore', windowsHide: true, timeout: 20_000 });
1250
1428
  }
1251
1429
  function installWindows() {
1252
- const vbs = ensureWindowsLauncher();
1253
- // Preferred: Scheduled Task at logon. Creating a logon-trigger task requires
1254
- // admin rights, so this fails for standard users and unelevated installs.
1255
- const task = (0, child_process_1.spawnSync)('schtasks', [
1256
- '/Create', '/TN', TASK_NAME, '/TR', `wscript.exe "${vbs}"`,
1257
- '/SC', 'ONLOGON', '/F', '/RL', 'LIMITED',
1258
- ], { stdio: 'ignore', windowsHide: true });
1259
- const taskOk = task.status === 0;
1430
+ removeLegacyWindowsLaunchers();
1431
+ // Preferred: Scheduled Task at logon, running node.exe directly and hidden
1432
+ // via task XML. Creating a logon-trigger task can require rights, so this may
1433
+ // fail for some standard-user/unelevated installs.
1434
+ const taskOk = registerHiddenTask(TASK_NAME, buildDaemonTaskXml(), 'daemon', ['/SC', 'ONLOGON', '/RL', 'LIMITED']);
1260
1435
  let ok = taskOk;
1261
1436
  if (!ok) {
1262
1437
  // Fallback: per-user Run key — no elevation needed, runs at every logon.
1438
+ // node.exe directly (no wscript). May flash briefly at logon in this rare
1439
+ // fallback, but carries no script-host persistence fingerprint.
1263
1440
  const reg = (0, child_process_1.spawnSync)('reg', [
1264
1441
  'add', WINDOWS_RUN_KEY, '/v', WINDOWS_RUN_VALUE, '/t', 'REG_SZ',
1265
- '/d', `wscript.exe "${vbs}"`, '/f',
1442
+ '/d', `"${process.execPath}" "${cliEntry()}" daemon`, '/f',
1266
1443
  ], { stdio: 'ignore', windowsHide: true });
1267
1444
  ok = reg.status === 0;
1268
1445
  }
1269
1446
  // Watchdog: logon triggers only START the daemon — nothing restarts it if it
1270
- // is killed or crashes mid-session. A time-based per-user task (no elevation
1271
- // needed, unlike ONLOGON) runs the `watchdog` command every 5 minutes: it
1272
- // beacons daemon liveness to the console (so "daemon dead" is visible even
1273
- // when the daemon can't say so itself) and revives a dead daemon within one
1274
- // tick. Best-effort: a missing watchdog never fails the install.
1275
- (0, child_process_1.spawnSync)('schtasks', [
1276
- '/Create', '/TN', WATCHDOG_TASK_NAME, '/TR', `wscript.exe "${ensureWindowsWatchdogLauncher()}"`,
1277
- '/SC', 'MINUTE', '/MO', '5', '/F',
1278
- ], { stdio: 'ignore', windowsHide: true });
1447
+ // is killed or crashes mid-session. A time-based task runs the `watchdog`
1448
+ // command every 5 minutes: it beacons daemon liveness to the console (so
1449
+ // "daemon dead" is visible even when the daemon can't say so itself) and
1450
+ // revives a dead daemon within one tick. Best-effort: a missing watchdog
1451
+ // never fails the install.
1452
+ registerHiddenTask(WATCHDOG_TASK_NAME, buildWatchdogTaskXml(), 'watchdog', ['/SC', 'MINUTE', '/MO', '5']);
1279
1453
  if (ok)
1280
- startDaemonNowWindows(vbs, taskOk);
1454
+ startDaemonNowWindows(taskOk);
1281
1455
  return ok;
1282
1456
  }
1283
1457
  /**
@@ -1296,29 +1470,43 @@ function ensureWindowsAutostartHealthy(logFn) {
1296
1470
  if (process.env.FCD_DAEMON_NO_TASK_SELF_HEAL === '1')
1297
1471
  return;
1298
1472
  try {
1299
- const vbs = ensureWindowsLauncher();
1300
- const watchdogVbs = ensureWindowsWatchdogLauncher();
1473
+ // Migration: pre-1.22.1 installs registered wscript/VBS-based tasks that
1474
+ // Defender flags as Trojan:Win32/Commando.A!ml. If the existing task still
1475
+ // references a script host, force-replace it with the node-direct XML task.
1476
+ const legacyDaemon = windowsTaskReferencesScriptHost(TASK_NAME);
1477
+ const legacyWatchdog = windowsTaskReferencesScriptHost(WATCHDOG_TASK_NAME);
1478
+ if (legacyDaemon || legacyWatchdog) {
1479
+ logFn('Self-heal: replacing legacy wscript-based autostart task(s) with node-direct hidden tasks (Defender false-positive fix).');
1480
+ }
1301
1481
  const daemonTask = (0, child_process_1.spawnSync)('schtasks', ['/Query', '/TN', TASK_NAME], { stdio: 'ignore', windowsHide: true, timeout: 10_000 });
1302
- if (daemonTask.status !== 0 && !isWindowsRunKeyInstalled()) {
1303
- const created = (0, child_process_1.spawnSync)('schtasks', [
1304
- '/Create', '/TN', TASK_NAME, '/TR', `wscript.exe "${vbs}"`,
1305
- '/SC', 'ONLOGON', '/F', '/RL', 'LIMITED',
1306
- ], { stdio: 'ignore', windowsHide: true, timeout: 20_000 });
1307
- if (created.status !== 0) {
1482
+ if (legacyDaemon || (daemonTask.status !== 0 && !isWindowsRunKeyInstalled())) {
1483
+ const created = registerHiddenTask(TASK_NAME, buildDaemonTaskXml(), 'daemon', ['/SC', 'ONLOGON', '/RL', 'LIMITED']);
1484
+ if (!created) {
1308
1485
  (0, child_process_1.spawnSync)('reg', [
1309
1486
  'add', WINDOWS_RUN_KEY, '/v', WINDOWS_RUN_VALUE, '/t', 'REG_SZ',
1310
- '/d', `wscript.exe "${vbs}"`, '/f',
1487
+ '/d', `"${process.execPath}" "${cliEntry()}" daemon`, '/f',
1311
1488
  ], { stdio: 'ignore', windowsHide: true, timeout: 10_000 });
1312
1489
  }
1313
- logFn('Self-heal: daemon autostart entry was missing — recreated.');
1490
+ if (daemonTask.status !== 0)
1491
+ logFn('Self-heal: daemon autostart entry was missing — recreated.');
1492
+ }
1493
+ // Rewrite the watchdog whenever it's missing OR still script-host based.
1494
+ const watchdogQuery = (0, child_process_1.spawnSync)('schtasks', ['/Query', '/TN', WATCHDOG_TASK_NAME], { stdio: 'ignore', windowsHide: true, timeout: 10_000 });
1495
+ if (legacyWatchdog || watchdogQuery.status !== 0) {
1496
+ const ok = registerHiddenTask(WATCHDOG_TASK_NAME, buildWatchdogTaskXml(), 'watchdog', ['/SC', 'MINUTE', '/MO', '5']);
1497
+ if (!ok) {
1498
+ logFn('Self-heal: could not (re)create the watchdog task — daemon revival relies on logon autostart only.');
1499
+ }
1314
1500
  }
1315
- const watchdog = (0, child_process_1.spawnSync)('schtasks', [
1316
- '/Create', '/TN', WATCHDOG_TASK_NAME, '/TR', `wscript.exe "${watchdogVbs}"`,
1317
- '/SC', 'MINUTE', '/MO', '5', '/F',
1318
- ], { stdio: 'ignore', windowsHide: true, timeout: 20_000 });
1319
- if (watchdog.status !== 0) {
1320
- logFn('Self-heal: could not (re)create the watchdog task daemon revival relies on logon autostart only.');
1501
+ // A stale Run-key fallback from an older install may still name wscript.
1502
+ if (windowsRunKeyReferencesScriptHost()) {
1503
+ (0, child_process_1.spawnSync)('reg', [
1504
+ 'add', WINDOWS_RUN_KEY, '/v', WINDOWS_RUN_VALUE, '/t', 'REG_SZ',
1505
+ '/d', `"${process.execPath}" "${cliEntry()}" daemon`, '/f',
1506
+ ], { stdio: 'ignore', windowsHide: true, timeout: 10_000 });
1507
+ logFn('Self-heal: rewrote legacy wscript Run-key entry to run node directly.');
1321
1508
  }
1509
+ removeLegacyWindowsLaunchers();
1322
1510
  }
1323
1511
  catch { /* self-heal is best-effort — never blocks daemon boot */ }
1324
1512
  }
@@ -1357,10 +1545,12 @@ function uninstallWindows() {
1357
1545
  const task = (0, child_process_1.spawnSync)('schtasks', ['/Delete', '/TN', TASK_NAME, '/F'], { stdio: 'ignore', windowsHide: true });
1358
1546
  (0, child_process_1.spawnSync)('schtasks', ['/Delete', '/TN', WATCHDOG_TASK_NAME, '/F'], { stdio: 'ignore', windowsHide: true });
1359
1547
  const reg = (0, child_process_1.spawnSync)('reg', ['delete', WINDOWS_RUN_KEY, '/v', WINDOWS_RUN_VALUE, '/f'], { stdio: 'ignore', windowsHide: true });
1548
+ removeLegacyWindowsLaunchers();
1549
+ // Clean up the node-direct task XML we now drop next to the launchers.
1360
1550
  const launcherDir = path.join(process.env.LOCALAPPDATA || path.join(os.homedir(), 'AppData', 'Local'), 'FullCourtDefense');
1361
- for (const launcher of ['daemon.vbs', 'watchdog.vbs']) {
1551
+ for (const name of [TASK_NAME, WATCHDOG_TASK_NAME]) {
1362
1552
  try {
1363
- fs.unlinkSync(path.join(launcherDir, launcher));
1553
+ fs.unlinkSync(path.join(launcherDir, `${name.replace(/[^A-Za-z0-9]+/g, '_')}.task.xml`));
1364
1554
  }
1365
1555
  catch { /* ignore */ }
1366
1556
  }
package/dist/version.json CHANGED
@@ -1,3 +1,3 @@
1
1
  {
2
- "version": "1.22.0"
2
+ "version": "1.22.1"
3
3
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fullcourtdefense-cli",
3
- "version": "1.22.0",
3
+ "version": "1.22.1",
4
4
  "description": "Full Court Defense CLI — security scanning for AI agents from your terminal",
5
5
  "main": "dist/index.js",
6
6
  "bin": {
@@ -60,6 +60,7 @@
60
60
  "test:real-scenario": "npm run build && node scripts/test-real-scenario-drill.js",
61
61
  "test:blocking-approval": "npm run build && node scripts/test-blocking-approval-drill.js",
62
62
  "test:clipboard-scan": "npm run build && node scripts/test-clipboard-scan.js",
63
+ "test:no-script-host": "npm run build && node scripts/test-no-script-host.js",
63
64
  "build:msi": "powershell -NoProfile -ExecutionPolicy Bypass -File installer/windows/Build-Msi.ps1",
64
65
  "prepublishOnly": "npm run build"
65
66
  },