fullcourtdefense-cli 1.21.34 → 1.21.35

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.
@@ -1029,6 +1029,9 @@ async function runDaemon(args, config) {
1029
1029
  // Self-heal autostart + watchdog tasks: machines installed by older versions
1030
1030
  // (or where task creation failed once) must converge without a reinstall.
1031
1031
  ensureWindowsAutostartHealthy(log);
1032
+ // Converge pre-Node-updater machines onto the PowerShell-free updater task
1033
+ // (best-effort; needs an elevated daemon to rewrite a SYSTEM task).
1034
+ (0, selfUpdate_1.modernizeUpdaterTask)(log);
1032
1035
  const watched = refreshWatchTargets();
1033
1036
  log(`Watching ${watched} config file(s) across ${watchers.size} director${watchers.size === 1 ? 'y' : 'ies'}.`);
1034
1037
  // First: if this boot IS the post-upgrade relaunch, confirm the pending
@@ -39,6 +39,13 @@ export declare function evaluateShellCommand(line: string, rules?: ShellGuardRul
39
39
  * No-op unless the guard is installed. Never throws.
40
40
  */
41
41
  export declare function refreshShellGuardRules(): void;
42
+ /**
43
+ * The user's Documents folder from the shell-folder registry entry — the one
44
+ * place that knows about OneDrive/folder redirection. reg.exe is a plain
45
+ * system utility (not a scripting engine), so EDRs that block powershell.exe
46
+ * do not block this.
47
+ */
48
+ export declare function resolveDocumentsFolder(): string;
42
49
  export interface ShellGuardStatus {
43
50
  supported: boolean;
44
51
  installed: boolean;
@@ -37,6 +37,7 @@ exports.writeShellGuardRules = writeShellGuardRules;
37
37
  exports.activeShellGuardRules = activeShellGuardRules;
38
38
  exports.evaluateShellCommand = evaluateShellCommand;
39
39
  exports.refreshShellGuardRules = refreshShellGuardRules;
40
+ exports.resolveDocumentsFolder = resolveDocumentsFolder;
40
41
  exports.getShellGuardStatus = getShellGuardStatus;
41
42
  exports.isShellGuardInstalled = isShellGuardInstalled;
42
43
  exports.installShellGuardCommand = installShellGuardCommand;
@@ -510,30 +511,57 @@ function buildGuardPs1(nodePath, cliEntry) {
510
511
  ];
511
512
  return lines.join('\r\n');
512
513
  }
513
- /** Resolve each PowerShell engine's CurrentUserAllHosts profile path. */
514
- function resolveProfilePaths() {
515
- const out = [];
516
- for (const engine of ['powershell.exe', 'pwsh.exe']) {
517
- try {
518
- const result = (0, child_process_1.spawnSync)(engine, ['-NoProfile', '-NonInteractive', '-Command', 'Write-Output $PROFILE.CurrentUserAllHosts'], {
519
- encoding: 'utf8', windowsHide: true, timeout: 15000,
520
- });
521
- const profilePath = (result.stdout || '').trim().split(/\r?\n/).pop()?.trim();
522
- if (result.status === 0 && profilePath && /\.ps1$/i.test(profilePath)) {
523
- out.push({ engine: engine.replace(/\.exe$/i, ''), profilePath });
524
- }
514
+ /**
515
+ * The user's Documents folder from the shell-folder registry entry — the one
516
+ * place that knows about OneDrive/folder redirection. reg.exe is a plain
517
+ * system utility (not a scripting engine), so EDRs that block powershell.exe
518
+ * do not block this.
519
+ */
520
+ function resolveDocumentsFolder() {
521
+ try {
522
+ const run = (0, child_process_1.spawnSync)('reg', [
523
+ 'query', 'HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Explorer\\User Shell Folders', '/v', 'Personal',
524
+ ], { encoding: 'utf8', windowsHide: true, timeout: 10_000 });
525
+ const raw = ((run.stdout || '').match(/Personal\s+REG_(?:EXPAND_)?SZ\s+(.+)/) || [])[1]?.trim();
526
+ if (run.status === 0 && raw) {
527
+ const expanded = raw.replace(/%([^%]+)%/g, (_, name) => process.env[name] ?? `%${name}%`);
528
+ if (!expanded.includes('%'))
529
+ return expanded;
525
530
  }
526
- catch { /* engine not installed */ }
527
531
  }
528
- // Dedupe (both engines can share a profile only if paths match — normally they differ).
529
- const seen = new Set();
530
- return out.filter(entry => {
531
- const key = entry.profilePath.toLowerCase();
532
- if (seen.has(key))
533
- return false;
534
- seen.add(key);
532
+ catch { /* registry unreadable — fall through */ }
533
+ return path.join(os.homedir(), 'Documents');
534
+ }
535
+ function pwshInstalled() {
536
+ const programFiles = process.env.ProgramFiles || 'C:\\Program Files';
537
+ if (fs.existsSync(path.join(programFiles, 'PowerShell', '7', 'pwsh.exe'))
538
+ || fs.existsSync(path.join(programFiles, 'PowerShell', '7-preview', 'pwsh.exe')))
535
539
  return true;
536
- });
540
+ for (const dir of (process.env.PATH || '').split(path.delimiter)) {
541
+ try {
542
+ if (dir && fs.existsSync(path.join(dir, 'pwsh.exe')))
543
+ return true;
544
+ }
545
+ catch { /* keep looking */ }
546
+ }
547
+ return false;
548
+ }
549
+ /**
550
+ * Resolve each PowerShell engine's CurrentUserAllHosts profile path WITHOUT
551
+ * spawning PowerShell. $PROFILE.CurrentUserAllHosts is deterministic:
552
+ * <Documents>\WindowsPowerShell\profile.ps1 (Windows PowerShell, always
553
+ * present on Windows) and <Documents>\PowerShell\profile.ps1 (pwsh, when
554
+ * installed). Asking the engines themselves was both an EDR flag risk and
555
+ * wrong on machines where EDR blocks PowerShell — the guard then silently
556
+ * skipped profile installation on exactly the fleets that want it most.
557
+ */
558
+ function resolveProfilePaths() {
559
+ const documents = resolveDocumentsFolder();
560
+ const out = [{ engine: 'powershell', profilePath: path.join(documents, 'WindowsPowerShell', 'profile.ps1') }];
561
+ if (pwshInstalled()) {
562
+ out.push({ engine: 'pwsh', profilePath: path.join(documents, 'PowerShell', 'profile.ps1') });
563
+ }
564
+ return out;
537
565
  }
538
566
  function profileSnippet() {
539
567
  return [
@@ -26,6 +26,28 @@ export interface SelfUpdateResult {
26
26
  detail: string;
27
27
  }
28
28
  export declare function resolveNpmCommand(platform?: NodeJS.Platform, execPath?: string, exists?: typeof fs.existsSync): string;
29
+ /**
30
+ * The updater task command for an install root. Prefers the Node updater
31
+ * (runs on the MSI's own bundled runtime — no powershell.exe spawn, which
32
+ * EDR/AppLocker block on hardened fleets); the PowerShell script remains the
33
+ * command only for old payloads that predate the Node updater. Escaped for
34
+ * `schtasks /TR` (embedded quotes as \").
35
+ */
36
+ export declare function buildUpdaterTaskCommand(root: string, exists?: (p: string) => boolean): string | undefined;
37
+ /**
38
+ * True when a registered task command still points at the PowerShell updater
39
+ * while this install carries the Node updater — i.e. the task predates the
40
+ * PowerShell-free updater and should be re-registered.
41
+ */
42
+ export declare function updaterTaskNeedsModernization(taskToRun: string, root: string, exists?: (p: string) => boolean): boolean;
43
+ /**
44
+ * Upgrade an EXISTING updater task from the PowerShell command to the Node
45
+ * command. Machines that upgraded from a pre-Node-updater MSI keep their old
46
+ * task definition (the MSI re-registers it, but registration can be denied);
47
+ * this converges them whenever a daemon runs elevated. Best-effort: needs an
48
+ * elevated token to re-register a SYSTEM task, silent no-op otherwise.
49
+ */
50
+ export declare function modernizeUpdaterTask(log: (message: string) => void): void;
29
51
  /**
30
52
  * Updater script log (%ProgramData%\FullCourtDefense\updater.log) — the
31
53
  * elevated task's own words. Read by the diagnostics bundle and the update-
@@ -37,6 +37,9 @@ exports.MSI_UPDATER_TASK_NAME = void 0;
37
37
  exports.detectInstallKind = detectInstallKind;
38
38
  exports.compareCliVersions = compareCliVersions;
39
39
  exports.resolveNpmCommand = resolveNpmCommand;
40
+ exports.buildUpdaterTaskCommand = buildUpdaterTaskCommand;
41
+ exports.updaterTaskNeedsModernization = updaterTaskNeedsModernization;
42
+ exports.modernizeUpdaterTask = modernizeUpdaterTask;
40
43
  exports.readUpdaterLogTail = readUpdaterLogTail;
41
44
  exports.maybeSelfUpdate = maybeSelfUpdate;
42
45
  exports.installedMsiVersion = installedMsiVersion;
@@ -133,7 +136,38 @@ function msiInstallRoot() {
133
136
  // dist\index.js → install root is the parent of dist.
134
137
  const dist = path.dirname(entry);
135
138
  const root = path.dirname(dist);
136
- return fs.existsSync(path.join(root, 'Update-FullCourtDefense.ps1')) ? root : undefined;
139
+ const hasUpdater = fs.existsSync(path.join(root, 'Update-FullCourtDefense.js'))
140
+ || fs.existsSync(path.join(root, 'Update-FullCourtDefense.ps1'));
141
+ return hasUpdater ? root : undefined;
142
+ }
143
+ /**
144
+ * The updater task command for an install root. Prefers the Node updater
145
+ * (runs on the MSI's own bundled runtime — no powershell.exe spawn, which
146
+ * EDR/AppLocker block on hardened fleets); the PowerShell script remains the
147
+ * command only for old payloads that predate the Node updater. Escaped for
148
+ * `schtasks /TR` (embedded quotes as \").
149
+ */
150
+ function buildUpdaterTaskCommand(root, exists = fs.existsSync) {
151
+ const nodeExe = path.join(root, 'runtime', 'node.exe');
152
+ const jsUpdater = path.join(root, 'Update-FullCourtDefense.js');
153
+ if (exists(nodeExe) && exists(jsUpdater)) {
154
+ return `\\"${nodeExe}\\" \\"${jsUpdater}\\"`;
155
+ }
156
+ const ps1Updater = path.join(root, 'Update-FullCourtDefense.ps1');
157
+ if (exists(ps1Updater)) {
158
+ return `powershell.exe -NoProfile -ExecutionPolicy Bypass -File \\"${ps1Updater}\\"`;
159
+ }
160
+ return undefined;
161
+ }
162
+ /**
163
+ * True when a registered task command still points at the PowerShell updater
164
+ * while this install carries the Node updater — i.e. the task predates the
165
+ * PowerShell-free updater and should be re-registered.
166
+ */
167
+ function updaterTaskNeedsModernization(taskToRun, root, exists = fs.existsSync) {
168
+ if (!/powershell/i.test(taskToRun))
169
+ return false;
170
+ return exists(path.join(root, 'Update-FullCourtDefense.js')) && exists(path.join(root, 'runtime', 'node.exe'));
137
171
  }
138
172
  /**
139
173
  * Self-heal a missing updater task. Machines installed from an older MSI (or
@@ -146,8 +180,9 @@ function tryRegisterMsiUpdaterTask(log) {
146
180
  const root = msiInstallRoot();
147
181
  if (!root)
148
182
  return false;
149
- const script = path.join(root, 'Update-FullCourtDefense.ps1');
150
- const taskCommand = `powershell.exe -NoProfile -ExecutionPolicy Bypass -File \\"${script}\\"`;
183
+ const taskCommand = buildUpdaterTaskCommand(root);
184
+ if (!taskCommand)
185
+ return false;
151
186
  const create = (0, child_process_1.spawnSync)('schtasks', [
152
187
  '/Create', '/TN', exports.MSI_UPDATER_TASK_NAME, '/TR', taskCommand,
153
188
  '/SC', 'DAILY', '/ST', '03:07', '/RU', 'SYSTEM', '/RL', 'HIGHEST', '/F',
@@ -158,6 +193,34 @@ function tryRegisterMsiUpdaterTask(log) {
158
193
  }
159
194
  return false;
160
195
  }
196
+ /**
197
+ * Upgrade an EXISTING updater task from the PowerShell command to the Node
198
+ * command. Machines that upgraded from a pre-Node-updater MSI keep their old
199
+ * task definition (the MSI re-registers it, but registration can be denied);
200
+ * this converges them whenever a daemon runs elevated. Best-effort: needs an
201
+ * elevated token to re-register a SYSTEM task, silent no-op otherwise.
202
+ */
203
+ function modernizeUpdaterTask(log) {
204
+ if (process.platform !== 'win32')
205
+ return;
206
+ try {
207
+ const root = msiInstallRoot();
208
+ if (!root)
209
+ return;
210
+ const query = (0, child_process_1.spawnSync)('schtasks', ['/Query', '/TN', exports.MSI_UPDATER_TASK_NAME, '/V', '/FO', 'LIST'], {
211
+ encoding: 'utf8', windowsHide: true, timeout: 15_000,
212
+ });
213
+ if (query.status !== 0 || !query.stdout)
214
+ return;
215
+ const taskToRun = (query.stdout.match(/^Task To Run:\s*(.*)$/m) || [])[1] || '';
216
+ if (!updaterTaskNeedsModernization(taskToRun, root))
217
+ return;
218
+ if (tryRegisterMsiUpdaterTask(log)) {
219
+ log('Self-update: updater task modernized to the PowerShell-free Node updater.');
220
+ }
221
+ }
222
+ catch { /* diagnostics-only convergence — never disturb the daemon */ }
223
+ }
161
224
  function startMsiSelfUpdate(targetVersion, log) {
162
225
  const query = (0, child_process_1.spawnSync)('schtasks', ['/Query', '/TN', exports.MSI_UPDATER_TASK_NAME], { stdio: 'ignore', windowsHide: true, timeout: 10_000 });
163
226
  if (query.status !== 0 && !tryRegisterMsiUpdaterTask(log)) {
package/dist/version.json CHANGED
@@ -1,3 +1,3 @@
1
1
  {
2
- "version": "1.21.34"
2
+ "version": "1.21.35"
3
3
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fullcourtdefense-cli",
3
- "version": "1.21.34",
3
+ "version": "1.21.35",
4
4
  "description": "Full Court Defense CLI — security scanning for AI agents from your terminal",
5
5
  "main": "dist/index.js",
6
6
  "bin": {
@@ -49,6 +49,7 @@
49
49
  "test:bricked-rescue": "npm run build && node scripts/test-bricked-machine-rescue.js",
50
50
  "test:native-credstore": "npm run build && node scripts/test-native-credential-store.js",
51
51
  "test:real-life": "npm run build && node scripts/test-real-life-scenarios.js",
52
+ "test:node-updater": "npm run build && node scripts/test-node-updater.js",
52
53
  "test:desktop-chat-guard": "npm run build && node scripts/test-desktop-chat-guard.js",
53
54
  "test:clipboard-scan": "npm run build && node scripts/test-clipboard-scan.js",
54
55
  "build:msi": "powershell -NoProfile -ExecutionPolicy Bypass -File installer/windows/Build-Msi.ps1",