fullcourtdefense-cli 1.22.7 → 1.22.8

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.
@@ -0,0 +1,293 @@
1
+ "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
15
+ }) : function(o, v) {
16
+ o["default"] = v;
17
+ });
18
+ var __importStar = (this && this.__importStar) || (function () {
19
+ var ownKeys = function(o) {
20
+ ownKeys = Object.getOwnPropertyNames || function (o) {
21
+ var ar = [];
22
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
23
+ return ar;
24
+ };
25
+ return ownKeys(o);
26
+ };
27
+ return function (mod) {
28
+ if (mod && mod.__esModule) return mod;
29
+ var result = {};
30
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
31
+ __setModuleDefault(result, mod);
32
+ return result;
33
+ };
34
+ })();
35
+ Object.defineProperty(exports, "__esModule", { value: true });
36
+ exports.collectRemnantChecks = collectRemnantChecks;
37
+ exports.verifyRemovedCommand = verifyRemovedCommand;
38
+ const fs = __importStar(require("fs"));
39
+ const os = __importStar(require("os"));
40
+ const path = __importStar(require("path"));
41
+ const child_process_1 = require("child_process");
42
+ const uninstallAll_1 = require("./uninstallAll");
43
+ const FCD_TASK_NAMES = [
44
+ 'FullCourtDefense Updater',
45
+ 'FullCourtDefense Daemon',
46
+ 'FullCourtDefense Daemon Watchdog',
47
+ 'FullCourtDefense Auto-Protect',
48
+ 'FullCourtDefenseDesktopDiscover',
49
+ ];
50
+ function taskExists(taskName) {
51
+ const query = (0, child_process_1.spawnSync)('schtasks', ['/Query', '/TN', taskName], {
52
+ stdio: 'ignore', windowsHide: true, timeout: 10_000,
53
+ });
54
+ return query.status === 0;
55
+ }
56
+ function regQuery(keyPath, valueName) {
57
+ const queryArgs = valueName ? ['query', keyPath, '/v', valueName] : ['query', keyPath];
58
+ const query = (0, child_process_1.spawnSync)('reg', queryArgs, { encoding: 'utf8', windowsHide: true, timeout: 10_000 });
59
+ return query.status === 0 ? query.stdout : undefined;
60
+ }
61
+ /** Well-known GLOBAL client config files that FCD wraps or hooks into. */
62
+ function knownClientConfigFiles() {
63
+ const home = os.homedir();
64
+ const files = [
65
+ path.join(home, '.cursor', 'mcp.json'),
66
+ path.join(home, '.cursor', 'hooks.json'),
67
+ path.join(home, '.claude', 'settings.json'),
68
+ path.join(home, '.claude.json'),
69
+ path.join(home, '.codex', 'config.toml'),
70
+ path.join(home, '.gemini', 'settings.json'),
71
+ path.join(home, '.codeium', 'windsurf', 'mcp_config.json'),
72
+ ];
73
+ if (process.platform === 'win32' && process.env.APPDATA) {
74
+ files.push(path.join(process.env.APPDATA, 'Claude', 'claude_desktop_config.json'));
75
+ files.push(path.join(process.env.APPDATA, 'Code', 'User', 'settings.json'));
76
+ }
77
+ else if (process.platform === 'darwin') {
78
+ files.push(path.join(home, 'Library', 'Application Support', 'Claude', 'claude_desktop_config.json'));
79
+ files.push(path.join(home, 'Library', 'Application Support', 'Code', 'User', 'settings.json'));
80
+ }
81
+ else {
82
+ files.push(path.join(home, '.config', 'Claude', 'claude_desktop_config.json'));
83
+ files.push(path.join(home, '.config', 'Code', 'User', 'settings.json'));
84
+ }
85
+ return files;
86
+ }
87
+ function fileMentionsFcd(file) {
88
+ try {
89
+ return /fullcourtdefense|fcd-mcp-gateway/i.test(fs.readFileSync(file, 'utf8'));
90
+ }
91
+ catch {
92
+ return false;
93
+ }
94
+ }
95
+ function collectWindowsChecks() {
96
+ const checks = [];
97
+ const home = os.homedir();
98
+ for (const taskName of FCD_TASK_NAMES) {
99
+ checks.push({
100
+ id: `task:${taskName}`,
101
+ label: `Scheduled task "${taskName}"`,
102
+ present: taskExists(taskName),
103
+ fix: taskName === 'FullCourtDefense Updater'
104
+ ? 'removed by the MSI uninstall (Settings → Apps → FullCourtDefense → Uninstall); it runs as SYSTEM, so an elevated `schtasks /Delete /TN "FullCourtDefense Updater" /F` also works'
105
+ : `schtasks /Delete /TN "${taskName}" /F`,
106
+ });
107
+ }
108
+ // HKLM machine-wide entries. PowerShell audit policies are attributed to
109
+ // FCD via the transcript OutputDirectory — an org GPO pointing elsewhere is
110
+ // NOT flagged (it is not ours to remove).
111
+ const fcdRoot = regQuery('HKLM\\SOFTWARE\\FullCourtDefense');
112
+ checks.push({
113
+ id: 'reg:hklm-fcd',
114
+ label: 'HKLM\\SOFTWARE\\FullCourtDefense registry key',
115
+ present: fcdRoot !== undefined,
116
+ fix: 'removed by the MSI uninstall',
117
+ });
118
+ const transcription = regQuery('HKLM\\SOFTWARE\\Policies\\Microsoft\\Windows\\PowerShell\\Transcription', 'OutputDirectory');
119
+ const fcdTranscription = transcription !== undefined && /FullCourtDefense/i.test(transcription);
120
+ checks.push({
121
+ id: 'reg:transcription',
122
+ label: 'HKLM PowerShell Transcription policy (FCD-enabled)',
123
+ present: fcdTranscription,
124
+ detail: fcdTranscription ? 'OutputDirectory points at a FullCourtDefense folder' : undefined,
125
+ fix: 'fullcourtdefense windows-audit --disable true (or the MSI uninstall)',
126
+ });
127
+ if (fcdTranscription) {
128
+ const sbl = regQuery('HKLM\\SOFTWARE\\Policies\\Microsoft\\Windows\\PowerShell\\ScriptBlockLogging', 'EnableScriptBlockLogging');
129
+ checks.push({
130
+ id: 'reg:scriptblocklogging',
131
+ label: 'HKLM PowerShell ScriptBlockLogging policy (FCD-enabled)',
132
+ present: sbl !== undefined,
133
+ fix: 'fullcourtdefense windows-audit --disable true (or the MSI uninstall)',
134
+ });
135
+ }
136
+ const runKey = regQuery('HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Run', 'FullCourtDefenseDaemon');
137
+ checks.push({
138
+ id: 'reg:run-key',
139
+ label: 'HKCU Run key "FullCourtDefenseDaemon"',
140
+ present: runKey !== undefined,
141
+ fix: 'reg delete "HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Run" /v FullCourtDefenseDaemon /f',
142
+ });
143
+ const autorun = regQuery('HKCU\\Software\\Microsoft\\Command Processor', 'AutoRun');
144
+ const fcdAutorun = autorun !== undefined && /fullcourtdefense/i.test(autorun);
145
+ checks.push({
146
+ id: 'reg:cmd-autorun',
147
+ label: 'cmd.exe AutoRun hook (cmd shell guard)',
148
+ present: fcdAutorun,
149
+ fix: 'fullcourtdefense uninstall-cmd-guard',
150
+ });
151
+ const machinePath = regQuery('HKLM\\SYSTEM\\CurrentControlSet\\Control\\Session Manager\\Environment', 'Path');
152
+ checks.push({
153
+ id: 'reg:system-path',
154
+ label: 'System PATH entry (FullCourtDefense install dir)',
155
+ present: machinePath !== undefined && /FullCourtDefense/i.test(machinePath),
156
+ fix: 'removed by the MSI uninstall',
157
+ });
158
+ const dirs = [
159
+ ['dir:program-files', process.env.ProgramFiles, 'removed by the MSI uninstall'],
160
+ ['dir:program-files-x86', process.env['ProgramFiles(x86)'], 'removed by the MSI uninstall'],
161
+ ['dir:programdata', process.env.ProgramData, 'removed by the MSI uninstall (transcripts + machine state)'],
162
+ ['dir:localappdata', process.env.LOCALAPPDATA, 'fullcourtdefense uninstall (purges local caches)'],
163
+ ];
164
+ for (const [id, base, fix] of dirs) {
165
+ if (!base)
166
+ continue;
167
+ const dir = path.join(base, 'FullCourtDefense');
168
+ checks.push({ id, label: dir, present: fs.existsSync(dir), fix });
169
+ }
170
+ const profileFiles = [
171
+ '.fullcourtdefense.yml',
172
+ '.fullcourtdefense-spool.jsonl',
173
+ '.fullcourtdefense-runtime.json',
174
+ '.fullcourtdefense-hook.log',
175
+ '.fullcourtdefense',
176
+ '.fullcourtdefense-shell-guard.json',
177
+ '.fullcourtdefense-shell-guard.ps1',
178
+ '.fullcourtdefense-cmd-guard.js',
179
+ '.fullcourtdefense-cmd-autorun.bat',
180
+ ];
181
+ for (const name of profileFiles) {
182
+ const file = path.join(home, name);
183
+ checks.push({
184
+ id: `file:${name}`,
185
+ label: file,
186
+ present: fs.existsSync(file),
187
+ fix: 'fullcourtdefense uninstall (purges profile state)',
188
+ });
189
+ }
190
+ return checks;
191
+ }
192
+ function collectPosixChecks() {
193
+ const checks = [];
194
+ const home = os.homedir();
195
+ if (process.platform === 'darwin') {
196
+ const plist = path.join(home, 'Library', 'LaunchAgents', 'ai.fullcourtdefense.daemon.plist');
197
+ checks.push({ id: 'launchd', label: plist, present: fs.existsSync(plist), fix: 'fullcourtdefense daemon --uninstall true' });
198
+ }
199
+ else {
200
+ const unit = path.join(process.env.XDG_CONFIG_HOME || path.join(home, '.config'), 'systemd', 'user', 'fullcourtdefense-daemon.service');
201
+ checks.push({ id: 'systemd', label: unit, present: fs.existsSync(unit), fix: 'fullcourtdefense daemon --uninstall true' });
202
+ const crontab = (0, child_process_1.spawnSync)('crontab', ['-l'], { encoding: 'utf8', timeout: 10_000 });
203
+ checks.push({
204
+ id: 'crontab',
205
+ label: 'crontab entries (# FCD_DAEMON)',
206
+ present: crontab.status === 0 && /FCD_DAEMON|fullcourtdefense/i.test(crontab.stdout || ''),
207
+ fix: 'fullcourtdefense daemon --uninstall true',
208
+ });
209
+ }
210
+ for (const rc of ['.zshrc', '.bashrc', '.bash_profile', '.profile']) {
211
+ const file = path.join(home, rc);
212
+ let mentions = false;
213
+ try {
214
+ mentions = /fullcourtdefense/i.test(fs.readFileSync(file, 'utf8'));
215
+ }
216
+ catch { /* absent */ }
217
+ checks.push({ id: `rc:${rc}`, label: `${file} (shell guard hook)`, present: mentions, fix: 'fullcourtdefense uninstall (removes shell guard wiring)' });
218
+ }
219
+ const profileFiles = [
220
+ '.fullcourtdefense.yml',
221
+ '.fullcourtdefense-spool.jsonl',
222
+ '.fullcourtdefense-runtime.json',
223
+ '.fullcourtdefense-hook.log',
224
+ '.fullcourtdefense',
225
+ '.fullcourtdefense-shell-guard.json',
226
+ '.fullcourtdefense-shell-guard.zsh',
227
+ '.fullcourtdefense-shell-guard.bash',
228
+ ];
229
+ for (const name of profileFiles) {
230
+ const file = path.join(home, name);
231
+ checks.push({ id: `file:${name}`, label: file, present: fs.existsSync(file), fix: 'fullcourtdefense uninstall (purges profile state)' });
232
+ }
233
+ return checks;
234
+ }
235
+ /** Shared (all platforms): config backups + still-wrapped client configs. */
236
+ function collectSharedChecks() {
237
+ const checks = [];
238
+ const backups = (0, uninstallAll_1.listFcdConfigBackups)();
239
+ checks.push({
240
+ id: 'config-backups',
241
+ label: 'Config backups (*.fcd-backup-*) — may contain credentials',
242
+ present: backups.length > 0,
243
+ detail: backups.length ? backups.join(', ') : undefined,
244
+ fix: 'fullcourtdefense uninstall (deletes them), or delete the listed files',
245
+ });
246
+ for (const file of knownClientConfigFiles()) {
247
+ if (!fs.existsSync(file))
248
+ continue;
249
+ if (!fileMentionsFcd(file))
250
+ continue;
251
+ checks.push({
252
+ id: `wrapped:${file}`,
253
+ label: `${file} still references FullCourtDefense`,
254
+ present: true,
255
+ fix: 'fullcourtdefense uninstall (restores original client configs)',
256
+ });
257
+ }
258
+ return checks;
259
+ }
260
+ /** All known-artifact checks for this platform — reused by `uninstall` for its inline self-verification. */
261
+ function collectRemnantChecks() {
262
+ return [
263
+ ...(process.platform === 'win32' ? collectWindowsChecks() : collectPosixChecks()),
264
+ ...collectSharedChecks(),
265
+ ];
266
+ }
267
+ async function verifyRemovedCommand(args) {
268
+ const checks = collectRemnantChecks();
269
+ const remnants = checks.filter(check => check.present);
270
+ if (args.json === 'true') {
271
+ console.log(JSON.stringify({ clean: remnants.length === 0, checked: checks.length, remnants }, null, 2));
272
+ }
273
+ else {
274
+ console.log('\n\x1b[1m\x1b[36mFullCourtDefense — verify removal\x1b[0m');
275
+ console.log(`\x1b[2m${checks.length} known artifact locations checked on this machine.\x1b[0m\n`);
276
+ if (remnants.length === 0) {
277
+ console.log('\x1b[32m\x1b[1m✓ Clean — no FullCourtDefense artifacts found. This machine is back to its pre-FCD state.\x1b[0m');
278
+ console.log('\x1b[2mNote: the fleet console record is server-side and separate — an admin deprovisions it there.\x1b[0m');
279
+ }
280
+ else {
281
+ console.log(`\x1b[33m\x1b[1m${remnants.length} artifact(s) still present:\x1b[0m\n`);
282
+ for (const remnant of remnants) {
283
+ console.log(` \x1b[33m✗\x1b[0m ${remnant.label}`);
284
+ if (remnant.detail)
285
+ console.log(` \x1b[2m${remnant.detail}\x1b[0m`);
286
+ if (remnant.fix)
287
+ console.log(` \x1b[2mfix: ${remnant.fix}\x1b[0m`);
288
+ }
289
+ }
290
+ }
291
+ if (remnants.length > 0)
292
+ process.exitCode = 1;
293
+ }
@@ -63,19 +63,24 @@ export declare function disableWindowsAudit(): EnableWindowsAuditResult;
63
63
  */
64
64
  export declare function enableWindowsAudit(): EnableWindowsAuditResult;
65
65
  export interface TranscriptPruneResult {
66
- /** Files deleted in this run. */
66
+ /** Files deleted in this run (age window). */
67
67
  pruned: number;
68
+ /** Files deleted because the folder exceeded the size budget. */
69
+ prunedBySize?: number;
68
70
  /** Set when pruning was skipped entirely (not enabled / foreign dir / non-Windows). */
69
71
  skipped?: string;
70
72
  }
71
73
  /** Retention window in days — env override for fleets that want tighter/looser. */
72
74
  export declare function transcriptRetentionDays(): number;
75
+ /** Size budget in MB — env override (FCD_TRANSCRIPT_MAX_MB, floor 50MB). */
76
+ export declare function transcriptMaxMb(): number;
73
77
  /**
74
- * Delete transcript files older than the retention window. Never throws;
75
- * locked/in-use files (an open PowerShell session's live transcript) are
76
- * skipped and picked up on a later run. Empty date folders are removed.
78
+ * Delete transcript files older than the retention window, then enforce the
79
+ * size budget (oldest-first) on whatever survived. Never throws; locked/
80
+ * in-use files (an open PowerShell session's live transcript) are skipped and
81
+ * picked up on a later run. Empty date folders are removed.
77
82
  */
78
- export declare function pruneTranscripts(maxAgeDays?: number, dirOverride?: string): TranscriptPruneResult;
83
+ export declare function pruneTranscripts(maxAgeDays?: number, dirOverride?: string, maxTotalMb?: number): TranscriptPruneResult;
79
84
  export interface WindowsAuditArgs {
80
85
  enable?: string;
81
86
  disable?: string;
@@ -40,6 +40,7 @@ exports.captureAuditPreInstallSnapshot = captureAuditPreInstallSnapshot;
40
40
  exports.disableWindowsAudit = disableWindowsAudit;
41
41
  exports.enableWindowsAudit = enableWindowsAudit;
42
42
  exports.transcriptRetentionDays = transcriptRetentionDays;
43
+ exports.transcriptMaxMb = transcriptMaxMb;
43
44
  exports.pruneTranscripts = pruneTranscripts;
44
45
  exports.windowsAuditCommand = windowsAuditCommand;
45
46
  const child_process_1 = require("child_process");
@@ -353,17 +354,29 @@ function enableWindowsAudit() {
353
354
  // that FCD itself configured (path contains "FullCourtDefense") — an
354
355
  // org-managed transcript share set via GPO is never cleaned by us.
355
356
  const DEFAULT_TRANSCRIPT_RETENTION_DAYS = 30;
357
+ // Size budget alongside the age window: heavy PowerShell users produce
358
+ // ~200MB/day of transcripts, so 30 days of age-based retention alone can eat
359
+ // multiple GB of disk (a customer churn finding). Oldest files go first once
360
+ // the folder exceeds the budget, regardless of age.
361
+ const DEFAULT_TRANSCRIPT_MAX_MB = 500;
362
+ const MIN_TRANSCRIPT_MAX_MB = 50;
356
363
  /** Retention window in days — env override for fleets that want tighter/looser. */
357
364
  function transcriptRetentionDays() {
358
365
  const raw = Number(process.env.FCD_TRANSCRIPT_RETENTION_DAYS);
359
366
  return Number.isFinite(raw) && raw >= 1 ? Math.floor(raw) : DEFAULT_TRANSCRIPT_RETENTION_DAYS;
360
367
  }
368
+ /** Size budget in MB — env override (FCD_TRANSCRIPT_MAX_MB, floor 50MB). */
369
+ function transcriptMaxMb() {
370
+ const raw = Number(process.env.FCD_TRANSCRIPT_MAX_MB);
371
+ return Number.isFinite(raw) && raw >= MIN_TRANSCRIPT_MAX_MB ? Math.floor(raw) : DEFAULT_TRANSCRIPT_MAX_MB;
372
+ }
361
373
  /**
362
- * Delete transcript files older than the retention window. Never throws;
363
- * locked/in-use files (an open PowerShell session's live transcript) are
364
- * skipped and picked up on a later run. Empty date folders are removed.
374
+ * Delete transcript files older than the retention window, then enforce the
375
+ * size budget (oldest-first) on whatever survived. Never throws; locked/
376
+ * in-use files (an open PowerShell session's live transcript) are skipped and
377
+ * picked up on a later run. Empty date folders are removed.
365
378
  */
366
- function pruneTranscripts(maxAgeDays = transcriptRetentionDays(), dirOverride) {
379
+ function pruneTranscripts(maxAgeDays = transcriptRetentionDays(), dirOverride, maxTotalMb = transcriptMaxMb()) {
367
380
  if (process.platform !== 'win32' && !dirOverride)
368
381
  return { pruned: 0, skipped: 'non-Windows' };
369
382
  const dir = dirOverride ?? getWindowsAuditStatus().transcriptionPath;
@@ -375,6 +388,7 @@ function pruneTranscripts(maxAgeDays = transcriptRetentionDays(), dirOverride) {
375
388
  return { pruned: 0, skipped: 'transcript dir does not exist' };
376
389
  const cutoff = Date.now() - maxAgeDays * 24 * 60 * 60 * 1000;
377
390
  let pruned = 0;
391
+ const survivors = [];
378
392
  const pruneDir = (target, depth) => {
379
393
  let entries;
380
394
  try {
@@ -394,16 +408,41 @@ function pruneTranscripts(maxAgeDays = transcriptRetentionDays(), dirOverride) {
394
408
  }
395
409
  catch { /* not empty — keep */ }
396
410
  }
397
- else if (fs.statSync(full).mtimeMs < cutoff) {
398
- fs.unlinkSync(full);
399
- pruned += 1;
411
+ else {
412
+ const stat = fs.statSync(full);
413
+ if (stat.mtimeMs < cutoff) {
414
+ fs.unlinkSync(full);
415
+ pruned += 1;
416
+ }
417
+ else {
418
+ survivors.push({ path: full, mtimeMs: stat.mtimeMs, size: stat.size });
419
+ }
400
420
  }
401
421
  }
402
422
  catch { /* locked (live session transcript) or race — next run */ }
403
423
  }
404
424
  };
405
425
  pruneDir(dir, 0);
406
- return { pruned };
426
+ // Size budget: age retention alone is unbounded on chatty machines. Delete
427
+ // oldest-first until under budget; the newest transcript always survives so
428
+ // a live session's file is never the one we try to delete.
429
+ let prunedBySize = 0;
430
+ const budgetBytes = maxTotalMb * 1024 * 1024;
431
+ let totalBytes = survivors.reduce((sum, file) => sum + file.size, 0);
432
+ if (totalBytes > budgetBytes) {
433
+ survivors.sort((a, b) => a.mtimeMs - b.mtimeMs);
434
+ for (const file of survivors.slice(0, -1)) {
435
+ if (totalBytes <= budgetBytes)
436
+ break;
437
+ try {
438
+ fs.unlinkSync(file.path);
439
+ totalBytes -= file.size;
440
+ prunedBySize += 1;
441
+ }
442
+ catch { /* locked — skip, next run */ }
443
+ }
444
+ }
445
+ return { pruned, prunedBySize };
407
446
  }
408
447
  /**
409
448
  * `fullcourtdefense windows-audit` — show (default) or enable (--enable)
package/dist/index.js CHANGED
@@ -51,6 +51,7 @@ const mcpGateway_1 = require("./commands/mcpGateway");
51
51
  const installAll_1 = require("./commands/installAll");
52
52
  const onboard_1 = require("./commands/onboard");
53
53
  const uninstallAll_1 = require("./commands/uninstallAll");
54
+ const verifyRemoved_1 = require("./commands/verifyRemoved");
54
55
  const autoProtect_1 = require("./commands/autoProtect");
55
56
  const daemon_1 = require("./commands/daemon");
56
57
  const watchdog_1 = require("./commands/watchdog");
@@ -134,11 +135,21 @@ function printHelp() {
134
135
  Safe to re-run: completed steps are reused automatically
135
136
  (--repair true re-runs them; --dry-run true previews;
136
137
  --json true prints the transaction as JSON).
137
- uninstall ONE command that reverses onboard/install-all on this machine:
138
- daemon, self-heal schedule, MCP gateway wraps, IDE hooks,
139
- terminal guards, and the daily discovery schedule. Keeps
140
- machine credentials for easy re-onboarding; --purge true
141
- deletes all local state too.
138
+ uninstall COMPLETE removal in one command: daemon, self-heal schedule,
139
+ MCP gateway wraps, IDE hooks, terminal guards, discovery
140
+ schedule, credentials, caches, and config backups. On Windows
141
+ MSI installs it then launches the Windows uninstaller (one UAC
142
+ prompt) which removes the SYSTEM updater task, HKLM audit
143
+ policies, and the application itself.
144
+ detach Reverse onboard/install-all protection but KEEP machine
145
+ credentials and the installed app, for easy re-onboarding.
146
+ --purge true deletes local state too.
147
+ verify-removed
148
+ Prove this machine is back to its pre-FCD state: checks every
149
+ known FCD artifact (tasks, registry, files, config backups)
150
+ and lists anything left, with the command that removes it.
151
+ Exits 1 when remnants are found. Works via npx after the CLI
152
+ itself is uninstalled.
142
153
  doctor First step. Checks outbound HTTPS access to FullCourtDefense.
143
154
  login Enrolls this machine with a fleet enrollment token and saves
144
155
  per-machine Shield credentials to ~/.fullcourtdefense.yml.
@@ -150,8 +161,9 @@ function printHelp() {
150
161
  or --mcp-command needed. Use --hooks false to skip runtime hooks.
151
162
  install Alias for install-all.
152
163
  windows-audit Show Windows PowerShell audit coverage (ScriptBlock Logging +
153
- Transcription). Use --enable to turn it on, --disable to remove
154
- both policy keys (one admin prompt either way).
164
+ Transcription). OFF by default (machine-wide policy + on-disk
165
+ transcripts are opt-in). Use --enable to turn it on, --disable
166
+ to remove both policy keys (one admin prompt either way).
155
167
  install-shell-guard Block dangerous commands typed in a terminal (real-time,
156
168
  offline rule cache): PowerShell on Windows, bash + zsh on macOS/Linux.
157
169
  uninstall-shell-guard removes it; shell-guard-status shows coverage.
@@ -233,6 +245,9 @@ function printHelp() {
233
245
  2. Run the one-command onboarding (connectivity + enroll + install + verify):
234
246
  fullcourtdefense onboard --token <fleet-enrollment-token>
235
247
  Or set FCD_ENROLL_TOKEN and run: fullcourtdefense onboard
248
+ Shows a trust disclosure (what gets installed, what data is sent
249
+ to your org's console) and asks for confirmation; automation/MDM
250
+ passes --accept-disclosure true (or FCD_ACCEPT_DISCLOSURE=1).
236
251
  Ends with a green/red checklist per protection surface and exits
237
252
  non-zero on failure (safe for MDM scripts).
238
253
 
@@ -670,10 +685,26 @@ async function main() {
670
685
  clients: flags.clients,
671
686
  config: flags.config,
672
687
  purge: flags.purge,
688
+ localOnly: flags['local-only'],
689
+ reason: flags.reason,
673
690
  };
674
691
  await (0, uninstallAll_1.uninstallAllCommand)(args, config);
675
692
  break;
676
693
  }
694
+ case 'detach': {
695
+ const args = {
696
+ ...buildGatewayArgs(),
697
+ clients: flags.clients,
698
+ config: flags.config,
699
+ purge: flags.purge,
700
+ };
701
+ await (0, uninstallAll_1.detachCommand)(args, config);
702
+ break;
703
+ }
704
+ case 'verify-removed': {
705
+ await (0, verifyRemoved_1.verifyRemovedCommand)({ json: flags.json });
706
+ break;
707
+ }
677
708
  case 'onboard': {
678
709
  const args = {
679
710
  ...buildInstallArgs(),
@@ -694,6 +725,7 @@ async function main() {
694
725
  dryRun: flags['dry-run'],
695
726
  json: flags.json,
696
727
  noDaemon: flags['no-daemon'],
728
+ acceptDisclosure: flags['accept-disclosure'],
697
729
  };
698
730
  await (0, onboard_1.onboardCommand)(args, config);
699
731
  break;
@@ -68,13 +68,16 @@ function writeCache(file, snapshot) {
68
68
  catch { /* best-effort */ }
69
69
  }
70
70
  function toRuleAction(value) {
71
- return value === 'warn' || value === 'mask' ? value : undefined;
71
+ // Explicit 'block' is preserved (not treated as absent): warn-by-default
72
+ // items (e.g. env_workspace) need an org's explicit block override to
73
+ // survive parsing, otherwise the CLI's catalog default would resurrect warn.
74
+ return value === 'warn' || value === 'mask' || value === 'block' ? value : undefined;
72
75
  }
73
76
  function toSnapshot(data) {
74
77
  if (!data || typeof data !== 'object')
75
78
  return undefined;
76
- // Only non-default ('warn'/'mask') entries are kept — anything unrecognized
77
- // falls back to 'block', so a malformed value can only fail strict.
79
+ // Anything unrecognized falls back to 'block' (fail strict); recognized
80
+ // entries — including explicit 'block' — are kept verbatim.
78
81
  let itemActions;
79
82
  if (data.itemActions && typeof data.itemActions === 'object' && !Array.isArray(data.itemActions)) {
80
83
  for (const [itemId, raw] of Object.entries(data.itemActions)) {
package/dist/selfTest.js CHANGED
@@ -145,8 +145,39 @@ async function runDeepSelfTest(input) {
145
145
  add({ id: 'watchdog_task', label: 'Watchdog task registered', ok: false, detail: error instanceof Error ? error.message : String(error) });
146
146
  }
147
147
  }
148
+ // 9. Windowless task principals. FCD tasks must run with the S4U logon type:
149
+ // an InteractiveToken principal running console node.exe flashes a visible
150
+ // console window on every trigger (the "5-minute flicker" complaint class —
151
+ // <Hidden>true</Hidden> does NOT suppress it). Machines where the S4U
152
+ // migration did not converge surface here instead of a user email.
153
+ if (process.platform === 'win32') {
154
+ const interactive = listInteractiveFcdTasks();
155
+ add({
156
+ id: 'windowless_tasks',
157
+ label: 'Scheduled tasks are windowless (S4U)',
158
+ ok: interactive.length === 0,
159
+ detail: interactive.length === 0
160
+ ? 'no FCD task runs with an InteractiveToken principal'
161
+ : `InteractiveToken principal (visible console flash) on: ${interactive.join(', ')} — daemon migration to S4U has not converged`,
162
+ });
163
+ }
148
164
  return { ok: checks.every(check => check.ok), checks, ranAt: new Date().toISOString() };
149
165
  }
166
+ /** FCD per-user tasks whose XML still declares an InteractiveToken principal. */
167
+ const WINDOWED_TASK_CANDIDATES = ['FullCourtDefense Daemon', 'FullCourtDefense Daemon Watchdog', 'FullCourtDefense Auto-Protect', 'FullCourtDefenseDesktopDiscover'];
168
+ function listInteractiveFcdTasks() {
169
+ const offenders = [];
170
+ for (const taskName of WINDOWED_TASK_CANDIDATES) {
171
+ const query = (0, child_process_1.spawnSync)('schtasks', ['/Query', '/TN', taskName, '/XML'], {
172
+ encoding: 'utf8', windowsHide: true, timeout: 10_000,
173
+ });
174
+ if (query.status !== 0 || !query.stdout)
175
+ continue; // absent — nothing to flash
176
+ if (/<LogonType>\s*InteractiveToken\s*<\/LogonType>/i.test(query.stdout))
177
+ offenders.push(taskName);
178
+ }
179
+ return offenders;
180
+ }
150
181
  /** Compact one-line summary for the machine-action result card. */
151
182
  function summarizeSelfTest(report) {
152
183
  const failed = report.checks.filter(check => !check.ok);
package/dist/version.json CHANGED
@@ -1,3 +1,3 @@
1
1
  {
2
- "version": "1.22.7"
2
+ "version": "1.22.8"
3
3
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fullcourtdefense-cli",
3
- "version": "1.22.7",
3
+ "version": "1.22.8",
4
4
  "description": "Full Court Defense CLI — security scanning for AI agents from your terminal",
5
5
  "main": "dist/index.js",
6
6
  "bin": {