fullcourtdefense-cli 1.8.0 → 1.10.0

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.
@@ -1,4 +1,5 @@
1
1
  import { BotGuardConfig } from '../config';
2
+ import { type ShellAuditReport } from './windowsAudit';
2
3
  export type DiscoverSurface = 'mcp' | 'secrets' | 'agent-files' | 'posture';
3
4
  export interface DiscoverArgs {
4
5
  type?: string;
@@ -57,6 +58,8 @@ export interface DesktopDiscoveryHost {
57
58
  user: string;
58
59
  scannedAt: string;
59
60
  probeMode: 'config' | 'deep';
61
+ /** Windows-only: PowerShell audit coverage (ScriptBlock Logging + Transcription). */
62
+ shellAudit?: ShellAuditReport;
60
63
  }
61
64
  export declare function discoverCommand(args: DiscoverArgs, config: BotGuardConfig): Promise<void>;
62
65
  /** Upload-only discover — used after install-mcp-gateway --upload. */
@@ -47,6 +47,7 @@ const knownMcpServers_1 = require("./knownMcpServers");
47
47
  const discoverAgentFiles_1 = require("./discoverAgentFiles");
48
48
  const discoverBlastRadius_1 = require("./discoverBlastRadius");
49
49
  const discoverSecrets_1 = require("./discoverSecrets");
50
+ const windowsAudit_1 = require("./windowsAudit");
50
51
  const DEFAULT_API_URL = 'https://api.fullcourtdefense.ai';
51
52
  function parseSurfaces(args) {
52
53
  const raw = (args.surface || args.type || 'mcp').toLowerCase().trim();
@@ -101,6 +102,7 @@ function buildHostMetadata(userEmail, probeMode = 'config') {
101
102
  user: userEmail || info.username,
102
103
  scannedAt: new Date().toISOString(),
103
104
  probeMode,
105
+ shellAudit: (0, windowsAudit_1.getShellAuditReport)(),
104
106
  };
105
107
  }
106
108
  /** Where MCP client configs live — see discoverPaths.ts for the canonical list. */
@@ -10,6 +10,8 @@ export interface InstallAllArgs extends ProtectAllArgs {
10
10
  scheduleHour?: string;
11
11
  autoProtect?: string;
12
12
  intervalMinutes?: string;
13
+ windowsAudit?: string;
14
+ shellGuard?: string;
13
15
  }
14
16
  /**
15
17
  * One-click install: discover/wrap every existing MCP server, install Cursor
@@ -9,6 +9,8 @@ const discoverSchedule_1 = require("./discoverSchedule");
9
9
  const mcpGateway_1 = require("./mcpGateway");
10
10
  const autoProtect_1 = require("./autoProtect");
11
11
  const restartNotice_1 = require("./restartNotice");
12
+ const windowsAudit_1 = require("./windowsAudit");
13
+ const shellGuard_1 = require("./shellGuard");
12
14
  /**
13
15
  * One-click install: discover/wrap every existing MCP server, install Cursor
14
16
  * hooks for built-in Cursor actions, optional self-heal and fleet upload.
@@ -65,6 +67,45 @@ async function installAllCommand(args, config) {
65
67
  console.log(`Claude-format hooks skipped: ${error instanceof Error ? error.message : String(error)}`);
66
68
  }
67
69
  }
70
+ // Windows-wide command auditing — PowerShell ScriptBlock Logging + Transcription.
71
+ // Covers commands typed in ANY PowerShell (inside or outside the IDE), not just
72
+ // agent-driven actions. Requires ONE admin (UAC) approval; failure is a warning,
73
+ // never an install blocker — missing coverage shows on the fleet dashboard.
74
+ if (process.platform === 'win32' && args.windowsAudit !== 'false') {
75
+ console.log('\n\x1b[1mEnabling Windows PowerShell audit logging (ScriptBlock Logging + Transcription)…\x1b[0m');
76
+ try {
77
+ const current = (0, windowsAudit_1.getWindowsAuditStatus)();
78
+ if (current.scriptBlockLogging && current.transcription) {
79
+ console.log('\x1b[32m✓ Already enabled — every PowerShell command on this machine is logged.\x1b[0m');
80
+ }
81
+ else {
82
+ console.log('\x1b[2mWindows will show an admin approval prompt (UAC). Approve it to enable machine-wide command auditing.\x1b[0m');
83
+ const result = (0, windowsAudit_1.enableWindowsAudit)();
84
+ if (result.ok) {
85
+ console.log(`\x1b[32m✓ ${result.message}\x1b[0m`);
86
+ }
87
+ else {
88
+ console.log(`\x1b[33m⚠ Warning: ${result.message}\x1b[0m`);
89
+ console.log('\x1b[33m Installation continues — this machine will show as "audit logging missing" in the fleet dashboard.\x1b[0m');
90
+ }
91
+ }
92
+ }
93
+ catch (error) {
94
+ console.log(`\x1b[33m⚠ Warning: PowerShell audit logging step failed (${error instanceof Error ? error.message : String(error)}). Installation continues.\x1b[0m`);
95
+ }
96
+ }
97
+ // Interactive PowerShell guard — blocks dangerous commands TYPED in a
98
+ // terminal (inside or outside the IDE) using the same rule families the IDE
99
+ // hooks enforce, from the local cache (no admin needed, no network hot path).
100
+ if (process.platform === 'win32' && args.shellGuard !== 'false') {
101
+ console.log('\n\x1b[1mInstalling interactive PowerShell shell guard…\x1b[0m');
102
+ try {
103
+ await (0, shellGuard_1.installShellGuardCommand)();
104
+ }
105
+ catch (error) {
106
+ console.log(`\x1b[33m⚠ Warning: shell guard install failed (${error instanceof Error ? error.message : String(error)}). Installation continues.\x1b[0m`);
107
+ }
108
+ }
68
109
  if (args.autoProtect === 'true') {
69
110
  console.log('\n\x1b[1mScheduling self-healing MCP protection…\x1b[0m');
70
111
  await (0, autoProtect_1.autoProtectCommand)({
@@ -0,0 +1,46 @@
1
+ export interface ShellGuardRule {
2
+ id: string;
3
+ category: string;
4
+ /** .NET-compatible regex, matched with IgnoreCase against the typed line. */
5
+ pattern: string;
6
+ reason: string;
7
+ source: 'builtin' | 'custom';
8
+ }
9
+ interface ShellGuardRulesFile {
10
+ updatedAt: string;
11
+ mode: 'block' | 'monitor';
12
+ rules: ShellGuardRule[];
13
+ }
14
+ /** Write (or rewrite) the ruleset the profile guard loads at shell startup. */
15
+ export declare function writeShellGuardRules(): ShellGuardRulesFile;
16
+ /**
17
+ * Opportunistic rules/mode refresh — called from the telemetry flush cycle so a
18
+ * console mode flip or new custom rule reaches interactive shells within ~30s.
19
+ * No-op unless the guard is installed. Never throws.
20
+ */
21
+ export declare function refreshShellGuardRules(): void;
22
+ export interface ShellGuardStatus {
23
+ supported: boolean;
24
+ installed: boolean;
25
+ profiles: Array<{
26
+ engine: string;
27
+ profilePath: string;
28
+ installed: boolean;
29
+ }>;
30
+ rulesPresent: boolean;
31
+ ruleCount?: number;
32
+ mode?: string;
33
+ }
34
+ /** Probe whether the profile guard is installed. Read-only, never throws. */
35
+ export declare function getShellGuardStatus(): ShellGuardStatus;
36
+ /** Lightweight boolean for the telemetry heartbeat / discovery report. */
37
+ export declare function isShellGuardInstalled(): boolean;
38
+ export interface InstallShellGuardArgs {
39
+ /** 'false' to skip writing profile entries (rules + script only). */
40
+ profiles?: string;
41
+ }
42
+ /** `fullcourtdefense install-shell-guard` — enable the interactive PowerShell guard. */
43
+ export declare function installShellGuardCommand(args?: InstallShellGuardArgs): Promise<void>;
44
+ /** `fullcourtdefense uninstall-shell-guard` — remove the guard from all profiles. */
45
+ export declare function uninstallShellGuardCommand(): Promise<void>;
46
+ export {};
@@ -0,0 +1,552 @@
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.writeShellGuardRules = writeShellGuardRules;
37
+ exports.refreshShellGuardRules = refreshShellGuardRules;
38
+ exports.getShellGuardStatus = getShellGuardStatus;
39
+ exports.isShellGuardInstalled = isShellGuardInstalled;
40
+ exports.installShellGuardCommand = installShellGuardCommand;
41
+ exports.uninstallShellGuardCommand = uninstallShellGuardCommand;
42
+ const child_process_1 = require("child_process");
43
+ const fs = __importStar(require("fs"));
44
+ const os = __importStar(require("os"));
45
+ const path = __importStar(require("path"));
46
+ /**
47
+ * Interactive PowerShell guard — real-time blocking of dangerous commands
48
+ * TYPED by a human in a terminal (inside or outside the IDE).
49
+ *
50
+ * How it works:
51
+ * - The CLI writes a ruleset JSON (~/.fullcourtdefense-shell-guard.json):
52
+ * the same dangerous-command families the deterministic guard enforces on
53
+ * agent tool calls (destructive filesystem, reverse shells, infra destroy,
54
+ * credential exfiltration, …) plus the org's custom block patterns from the
55
+ * cached local-safety snapshot, plus the Shield mode (block vs monitor).
56
+ * - A profile script (~/.fullcourtdefense-shell-guard.ps1) is dot-sourced from
57
+ * the user's PowerShell profiles (both Windows PowerShell 5.1 and pwsh 7).
58
+ * It compiles the rules ONCE at shell startup and hooks the PSReadLine
59
+ * Enter key: every submitted line is checked in-process (sub-millisecond —
60
+ * no node/network on the hot path).
61
+ * - Match in block mode → the line is cleared and never executes; monitor
62
+ * mode → yellow warning, the command still runs. Either way the event is
63
+ * appended to the telemetry spool and a detached flush reports it to the
64
+ * fleet dashboard.
65
+ * - Rules/mode refresh opportunistically on every telemetry flush (offline
66
+ * caches only — never blocks a shell).
67
+ *
68
+ * Scope honesty: this covers INTERACTIVE PowerShell (PSReadLine hosts, incl.
69
+ * IDE-integrated terminals). Non-interactive scripts are covered by ScriptBlock
70
+ * Logging (windowsAudit.ts); native cmd.exe has no enforcement path yet.
71
+ */
72
+ const GUARD_RULES_PATH = path.join(os.homedir(), '.fullcourtdefense-shell-guard.json');
73
+ const GUARD_PS1_PATH = path.join(os.homedir(), '.fullcourtdefense-shell-guard.ps1');
74
+ const SPOOL_PATH = path.join(os.homedir(), '.fullcourtdefense-spool.jsonl');
75
+ const RUNTIME_CACHE_PATH = path.join(os.homedir(), '.fullcourtdefense-runtime.json');
76
+ const SNAPSHOT_CACHE_DIR = path.join(os.homedir(), '.fullcourtdefense');
77
+ const MARKER_START = '# >>> fullcourtdefense shell guard >>>';
78
+ const MARKER_END = '# <<< fullcourtdefense shell guard <<<';
79
+ /**
80
+ * Built-in dangerous-command rules — the shell-relevant subset of
81
+ * deterministicGuard.ts, expressed as .NET-compatible regexes (PowerShell
82
+ * compiles them with IgnoreCase). Item ids match the deterministic guard so
83
+ * dashboard events line up across IDE hooks and terminal blocks.
84
+ */
85
+ const BUILTIN_RULES = [
86
+ // Destructive filesystem
87
+ { id: 'rm_rf_root', category: 'destructive_command', reason: 'recursive force delete of filesystem root', source: 'builtin',
88
+ pattern: String.raw `\b(?:sudo\s+)?rm\s+-[a-z]*r[a-z]*f[a-z]*\s+(?:["']?/["']?|\*)(?:\s|$|[;&|])` },
89
+ { id: 'windows_drive_delete', category: 'destructive_command', reason: 'recursive Windows drive delete', source: 'builtin',
90
+ pattern: String.raw `\b(?:del|erase)\s+/[a-z]*s[a-z]*\s+/[a-z]*q[a-z]*\s+[a-z]:\\` },
91
+ { id: 'windows_drive_delete', category: 'destructive_command', reason: 'recursive Remove-Item on a drive root', source: 'builtin',
92
+ pattern: String.raw `\bremove-item\b(?=.*-recurse)(?=.*-force)(?=.*\s[a-z]:[\\/]?(?:\s|$))` },
93
+ { id: 'format_drive', category: 'destructive_command', reason: 'drive format command', source: 'builtin',
94
+ pattern: String.raw `(?<![-\w])(?:format(?:\.com)?\s+[a-z]:(?:\s|$)|format-volume\b(?=.*-driveletter))` },
95
+ { id: 'fork_bomb', category: 'destructive_command', reason: 'fork bomb', source: 'builtin',
96
+ pattern: String.raw `:\s*\(\)\s*\{\s*:\s*\|\s*:\s*&\s*\}\s*;\s*:` },
97
+ { id: 'mkfs_device', category: 'destructive_command', reason: 'filesystem format command', source: 'builtin',
98
+ pattern: String.raw `\bmkfs(?:\.[a-z0-9]+)?\s+/dev/` },
99
+ { id: 'dd_disk_overwrite', category: 'destructive_command', reason: 'raw disk overwrite command', source: 'builtin',
100
+ pattern: String.raw `\bdd\b(?=.*\bof=/dev/(?:sd|xvd|hd|nvme|disk))` },
101
+ { id: 'chmod_root_777', category: 'destructive_command', reason: 'recursive permission change on root', source: 'builtin',
102
+ pattern: String.raw `\bchmod\s+-r\s+777\s+/(?:\s|$)` },
103
+ { id: 'vssadmin_delete_shadows', category: 'destructive_command', reason: 'shadow copy deletion (ransomware pattern)', source: 'builtin',
104
+ pattern: String.raw `\bvssadmin\b(?=.*\bdelete\b)(?=.*\bshadows\b)` },
105
+ { id: 'defender_disable', category: 'destructive_command', reason: 'disabling Windows Defender real-time protection', source: 'builtin',
106
+ pattern: String.raw `\bset-mppreference\b(?=.*-disablerealtimemonitoring)(?=.*(?:\$true|\s1\b))` },
107
+ // Reverse shells / droppers
108
+ { id: 'bash_dev_tcp', category: 'reverse_shell', reason: 'bash reverse shell', source: 'builtin',
109
+ pattern: String.raw `\bbash\s+-i\b.*(?:/dev/tcp/)` },
110
+ { id: 'netcat_exec', category: 'reverse_shell', reason: 'netcat reverse shell', source: 'builtin',
111
+ pattern: String.raw `\b(?:nc|ncat|netcat)\b.*\s-e\s+(?:/bin/)?(?:sh|bash|cmd(?:\.exe)?|powershell(?:\.exe)?)\b` },
112
+ { id: 'socat_exec', category: 'reverse_shell', reason: 'socat reverse shell', source: 'builtin',
113
+ pattern: String.raw `\bsocat\b.*\bexec:(?:/bin/)?(?:sh|bash)\b` },
114
+ { id: 'curl_pipe_shell', category: 'reverse_shell', reason: 'remote script piped to shell', source: 'builtin',
115
+ pattern: String.raw `\b(?:curl|wget)\b[^|;&]{0,300}\|\s*(?:sudo\s+)?(?:sh|bash)\b` },
116
+ { id: 'powershell_download_exec', category: 'reverse_shell', reason: 'download-and-execute (IEX + web download)', source: 'builtin',
117
+ pattern: String.raw `(?=.*\b(?:iex\b|invoke-expression\b))(?=.*(?:downloadstring|net\.webclient|invoke-webrequest\b|\biwr\b|invoke-restmethod\b|\birm\b))` },
118
+ { id: 'python_socket_subprocess', category: 'reverse_shell', reason: 'python reverse shell', source: 'builtin',
119
+ pattern: String.raw `\bpython(?:3)?\s+-c\b(?=.*socket)(?=.*subprocess)` },
120
+ // Cloud metadata / credential exfiltration
121
+ { id: 'aws_gcp_metadata_ip', category: 'metadata_ssrf', reason: 'request to cloud metadata endpoint', source: 'builtin',
122
+ pattern: String.raw `(?=.*\b(?:curl|wget|iwr|invoke-webrequest|invoke-restmethod|irm)\b)(?=.*(?:169\.254\.169\.254|metadata\.google\.internal|metadata\.azure\.com|100\.100\.100\.200))` },
123
+ { id: 'credential_exfiltration', category: 'secret_exfiltration', reason: 'credential file sent to the network', source: 'builtin',
124
+ pattern: String.raw `(?=.*\b(?:curl|wget|iwr|invoke-webrequest|invoke-restmethod|scp|nc|ncat)\b)(?=.*(?:\.ssh[\\/]id_(?:rsa|ed25519|ecdsa|dsa)|\.aws[\\/]credentials|application_default_credentials\.json|\.npmrc|\.netrc|\.kube[\\/]config))` },
125
+ // Infra destroy
126
+ { id: 'terraform_destroy_auto', category: 'infra_destroy', reason: 'terraform destroy with auto-approve', source: 'builtin',
127
+ pattern: String.raw `\bterraform\s+destroy\b(?=.*--auto-approve)` },
128
+ { id: 'pulumi_destroy_yes', category: 'infra_destroy', reason: 'pulumi destroy with auto-approve', source: 'builtin',
129
+ pattern: String.raw `\bpulumi\s+destroy\b(?=.*(?:--yes|\s-y\b))` },
130
+ { id: 'kubectl_delete_namespace', category: 'infra_destroy', reason: 'destructive kubectl namespace delete', source: 'builtin',
131
+ pattern: String.raw `\bkubectl\s+delete\s+namespace\b` },
132
+ { id: 'kubectl_delete_all_all', category: 'infra_destroy', reason: 'broad kubectl delete all', source: 'builtin',
133
+ pattern: String.raw `\bkubectl\s+delete\s+all\b(?=.*--all)` },
134
+ { id: 'gcloud_project_delete', category: 'infra_destroy', reason: 'GCP project deletion', source: 'builtin',
135
+ pattern: String.raw `\bgcloud\s+projects\s+delete\b` },
136
+ { id: 'gcloud_sql_delete', category: 'infra_destroy', reason: 'GCP SQL instance deletion', source: 'builtin',
137
+ pattern: String.raw `\bgcloud\s+sql\s+instances\s+delete\b` },
138
+ { id: 'aws_s3_recursive_rm', category: 'infra_destroy', reason: 'recursive S3 deletion', source: 'builtin',
139
+ pattern: String.raw `\baws\s+s3\s+rm\s+s3://\S+\s+--recursive\b` },
140
+ { id: 'aws_cloudformation_delete_stack', category: 'infra_destroy', reason: 'CloudFormation stack deletion', source: 'builtin',
141
+ pattern: String.raw `\baws\s+cloudformation\s+delete-stack\b` },
142
+ { id: 'aws_iam_admin_policy', category: 'infra_destroy', reason: 'IAM administrator privilege grant', source: 'builtin',
143
+ pattern: String.raw `\baws\s+iam\s+attach-user-policy\b(?=.*AdministratorAccess)` },
144
+ { id: 'aws_iam_access_key', category: 'infra_destroy', reason: 'IAM access key creation', source: 'builtin',
145
+ pattern: String.raw `\baws\s+iam\s+create-access-key\b` },
146
+ { id: 'az_group_delete_yes', category: 'infra_destroy', reason: 'Azure resource group deletion', source: 'builtin',
147
+ pattern: String.raw `\baz\s+group\s+delete\b(?=.*(?:--yes|\s-y\b))` },
148
+ // Destructive SQL typed straight into a terminal client
149
+ { id: 'drop_database', category: 'destructive_sql', reason: 'DROP DATABASE', source: 'builtin',
150
+ pattern: String.raw `\bdrop\s+database\b` },
151
+ { id: 'drop_schema', category: 'destructive_sql', reason: 'DROP SCHEMA', source: 'builtin',
152
+ pattern: String.raw `\bdrop\s+schema\b` },
153
+ ];
154
+ function escapeDotNetRegex(literal) {
155
+ return literal.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
156
+ }
157
+ /** Cached Shield mode from the runtime bundle cache (offline read, never network). */
158
+ function cachedMode() {
159
+ try {
160
+ const parsed = JSON.parse(fs.readFileSync(RUNTIME_CACHE_PATH, 'utf8'));
161
+ const entries = Object.values(parsed).filter(e => e && typeof e === 'object');
162
+ entries.sort((a, b) => (b.fetchedAt || 0) - (a.fetchedAt || 0));
163
+ const mode = entries[0]?.mode;
164
+ return mode === 'monitor' || mode === 'shadow' ? 'monitor' : 'block';
165
+ }
166
+ catch {
167
+ return 'block';
168
+ }
169
+ }
170
+ /**
171
+ * Built-in rule ids toggled OFF in the dashboard (local-safety snapshot cache).
172
+ * Rule ids match the deterministic guard's item ids, so one console toggle
173
+ * disables the rule in IDE hooks AND interactive terminals.
174
+ */
175
+ function cachedDisabledItemIds() {
176
+ const disabled = new Set();
177
+ try {
178
+ for (const file of fs.readdirSync(SNAPSHOT_CACHE_DIR)) {
179
+ if (!file.startsWith('local-safety-') || !file.endsWith('.json'))
180
+ continue;
181
+ try {
182
+ const parsed = JSON.parse(fs.readFileSync(path.join(SNAPSHOT_CACHE_DIR, file), 'utf8'));
183
+ const ids = parsed?.snapshot?.disabledBuiltInItemIds;
184
+ if (Array.isArray(ids)) {
185
+ for (const id of ids) {
186
+ if (typeof id === 'string' && id)
187
+ disabled.add(id);
188
+ }
189
+ }
190
+ }
191
+ catch { /* skip malformed snapshot */ }
192
+ }
193
+ }
194
+ catch { /* no snapshot cache yet */ }
195
+ return disabled;
196
+ }
197
+ /** Org custom block patterns from the cached local-safety snapshots (offline read). */
198
+ function cachedCustomRules() {
199
+ const rules = [];
200
+ try {
201
+ for (const file of fs.readdirSync(SNAPSHOT_CACHE_DIR)) {
202
+ if (!file.startsWith('local-safety-') || !file.endsWith('.json'))
203
+ continue;
204
+ try {
205
+ const parsed = JSON.parse(fs.readFileSync(path.join(SNAPSHOT_CACHE_DIR, file), 'utf8'));
206
+ const blocks = parsed?.snapshot?.customBlocks;
207
+ if (!Array.isArray(blocks))
208
+ continue;
209
+ for (const block of blocks.slice(0, 100)) {
210
+ if (!block || typeof block.pattern !== 'string' || !block.pattern.trim())
211
+ continue;
212
+ rules.push({
213
+ id: String(block.id || block.pattern).slice(0, 120),
214
+ category: String(block.categoryId || 'custom'),
215
+ // Snapshot custom blocks are substring matches — escape into a literal regex.
216
+ pattern: escapeDotNetRegex(block.pattern.trim()),
217
+ reason: typeof block.explanation === 'string' && block.explanation ? block.explanation : `matched custom safety pattern "${block.pattern.trim()}"`,
218
+ source: 'custom',
219
+ });
220
+ }
221
+ }
222
+ catch { /* skip malformed snapshot */ }
223
+ }
224
+ }
225
+ catch { /* no snapshot cache yet */ }
226
+ // Dedupe by id+pattern.
227
+ const seen = new Set();
228
+ return rules.filter(rule => {
229
+ const key = `${rule.id}|${rule.pattern}`;
230
+ if (seen.has(key))
231
+ return false;
232
+ seen.add(key);
233
+ return true;
234
+ });
235
+ }
236
+ /** Write (or rewrite) the ruleset the profile guard loads at shell startup. */
237
+ function writeShellGuardRules() {
238
+ const disabled = cachedDisabledItemIds();
239
+ const data = {
240
+ updatedAt: new Date().toISOString(),
241
+ mode: cachedMode(),
242
+ rules: [...BUILTIN_RULES.filter(rule => !disabled.has(rule.id)), ...cachedCustomRules()],
243
+ };
244
+ fs.writeFileSync(GUARD_RULES_PATH, JSON.stringify(data, null, 2), { encoding: 'utf8', mode: 0o600 });
245
+ return data;
246
+ }
247
+ /**
248
+ * Opportunistic rules/mode refresh — called from the telemetry flush cycle so a
249
+ * console mode flip or new custom rule reaches interactive shells within ~30s.
250
+ * No-op unless the guard is installed. Never throws.
251
+ */
252
+ function refreshShellGuardRules() {
253
+ try {
254
+ if (!fs.existsSync(GUARD_PS1_PATH))
255
+ return;
256
+ writeShellGuardRules();
257
+ }
258
+ catch { /* best-effort */ }
259
+ }
260
+ function psQuote(value) {
261
+ return `'${value.replace(/'/g, "''")}'`;
262
+ }
263
+ /** The profile guard script. Placeholders are substituted at install time. */
264
+ function buildGuardPs1(nodePath, cliEntry) {
265
+ const lines = [
266
+ `# FullCourtDefense interactive shell guard (installed by fullcourtdefense-cli).`,
267
+ `# Blocks dangerous commands typed in PowerShell using the org's cached rules.`,
268
+ `# Disable for one session: $env:FCD_SHELL_GUARD = 'off'`,
269
+ `# Remove permanently: fullcourtdefense uninstall-shell-guard`,
270
+ ``,
271
+ `$global:FcdRulesPath = ${psQuote(GUARD_RULES_PATH)}`,
272
+ `$global:FcdSpoolPath = ${psQuote(SPOOL_PATH)}`,
273
+ `$global:FcdNodePath = ${psQuote(nodePath)}`,
274
+ `$global:FcdCliEntry = ${psQuote(cliEntry)}`,
275
+ `$global:FcdGuardMode = 'block'`,
276
+ `$global:FcdGuardRules = @()`,
277
+ ``,
278
+ `try {`,
279
+ ` if (Test-Path $global:FcdRulesPath) {`,
280
+ ` $fcdRaw = Get-Content -Raw -Path $global:FcdRulesPath | ConvertFrom-Json`,
281
+ ` if ($fcdRaw.mode -eq 'monitor') { $global:FcdGuardMode = 'monitor' }`,
282
+ ` $global:FcdGuardRules = @($fcdRaw.rules | ForEach-Object {`,
283
+ ` try {`,
284
+ ` [pscustomobject]@{`,
285
+ ` Id = [string]$_.id`,
286
+ ` Category = [string]$_.category`,
287
+ ` Reason = [string]$_.reason`,
288
+ ` Source = [string]$_.source`,
289
+ ` Regex = [regex]::new([string]$_.pattern, 'IgnoreCase')`,
290
+ ` }`,
291
+ ` } catch { $null }`,
292
+ ` } | Where-Object { $null -ne $_ })`,
293
+ ` }`,
294
+ `} catch { $global:FcdGuardRules = @() }`,
295
+ ``,
296
+ `function global:Test-FcdShellGuard {`,
297
+ ` param([string]$CommandLine)`,
298
+ ` if ([string]::IsNullOrWhiteSpace($CommandLine)) { return $null }`,
299
+ ` $normalized = ($CommandLine -replace '\\s+', ' ').Trim()`,
300
+ ` foreach ($rule in $global:FcdGuardRules) {`,
301
+ ` try {`,
302
+ ` if ($rule.Regex.IsMatch($normalized) -or $rule.Regex.IsMatch($CommandLine)) { return $rule }`,
303
+ ` } catch { }`,
304
+ ` }`,
305
+ ` return $null`,
306
+ `}`,
307
+ ``,
308
+ `function global:Write-FcdGuardEvent {`,
309
+ ` param($Rule, [string]$CommandLine, [string]$Decision)`,
310
+ ` try {`,
311
+ ` $evidence = $CommandLine.Trim()`,
312
+ ` if ($evidence.Length -gt 180) { $evidence = $evidence.Substring(0, 180) + '...' }`,
313
+ ` $event = [pscustomobject]@{`,
314
+ ` eventId = [guid]::NewGuid().ToString()`,
315
+ ` type = 'verdict'`,
316
+ ` decision = $Decision`,
317
+ ` toolName = 'powershell_terminal'`,
318
+ ` operation = 'shell_command'`,
319
+ ` reason = ('Shell guard: ' + $Rule.Reason)`,
320
+ ` ruleId = $Rule.Id`,
321
+ ` category = $Rule.Category`,
322
+ ` source = $Rule.Source`,
323
+ ` evidence = $evidence`,
324
+ ` occurredAt = (Get-Date).ToUniversalTime().ToString('o')`,
325
+ ` }`,
326
+ ` # BOM-free UTF8 append — PS5's Add-Content -Encoding utf8 writes a BOM that breaks JSONL parsing.`,
327
+ ` [System.IO.File]::AppendAllText($global:FcdSpoolPath, (($event | ConvertTo-Json -Compress) + "\`n"), (New-Object System.Text.UTF8Encoding($false)))`,
328
+ ` if ((Test-Path $global:FcdNodePath) -and (Test-Path $global:FcdCliEntry)) {`,
329
+ ` Start-Process -FilePath $global:FcdNodePath -ArgumentList @($global:FcdCliEntry, 'flush-spool', '--heartbeat', 'true') -WindowStyle Hidden -ErrorAction SilentlyContinue | Out-Null`,
330
+ ` }`,
331
+ ` } catch { }`,
332
+ `}`,
333
+ ``,
334
+ `# Hook the Enter key only in interactive PSReadLine hosts (consoles, IDE terminals).`,
335
+ `# try/catch: in non-interactive hosts PSReadLine can be present but uninitialized.`,
336
+ `try {`,
337
+ `if ($env:FCD_SHELL_GUARD -ne 'off' -and (Get-Command Set-PSReadLineKeyHandler -ErrorAction SilentlyContinue)) {`,
338
+ ` Set-PSReadLineKeyHandler -Key Enter -BriefDescription 'FCDShellGuard' -Description 'FullCourtDefense dangerous-command guard' -ScriptBlock {`,
339
+ ` param($key, $arg)`,
340
+ ` $line = $null`,
341
+ ` $cursor = $null`,
342
+ ` try { [Microsoft.PowerShell.PSConsoleReadLine]::GetBufferState([ref]$line, [ref]$cursor) } catch { }`,
343
+ ` $hit = $null`,
344
+ ` if ($line -and $env:FCD_SHELL_GUARD -ne 'off') {`,
345
+ ` try { $hit = Test-FcdShellGuard -CommandLine $line } catch { $hit = $null }`,
346
+ ` }`,
347
+ ` if ($null -ne $hit) {`,
348
+ ` if ($global:FcdGuardMode -eq 'monitor') {`,
349
+ ` Write-Host ''`,
350
+ ` Write-Host ('[FullCourtDefense] monitor: would block - ' + $hit.Reason + ' (rule ' + $hit.Id + ')') -ForegroundColor Yellow`,
351
+ ` Write-FcdGuardEvent -Rule $hit -CommandLine $line -Decision 'allow'`,
352
+ ` [Microsoft.PowerShell.PSConsoleReadLine]::AcceptLine()`,
353
+ ` return`,
354
+ ` }`,
355
+ ` [Microsoft.PowerShell.PSConsoleReadLine]::RevertLine()`,
356
+ ` Write-Host ''`,
357
+ ` Write-Host ('[FullCourtDefense] BLOCKED: ' + $hit.Reason + ' (rule ' + $hit.Id + ')') -ForegroundColor Red`,
358
+ ` Write-Host 'This command was not executed. Reported to your security dashboard.' -ForegroundColor DarkGray`,
359
+ ` Write-FcdGuardEvent -Rule $hit -CommandLine $line -Decision 'block'`,
360
+ ` [Microsoft.PowerShell.PSConsoleReadLine]::AcceptLine()`,
361
+ ` return`,
362
+ ` }`,
363
+ ` [Microsoft.PowerShell.PSConsoleReadLine]::AcceptLine()`,
364
+ ` }`,
365
+ `}`,
366
+ `} catch { }`,
367
+ ``,
368
+ ];
369
+ return lines.join('\r\n');
370
+ }
371
+ /** Resolve each PowerShell engine's CurrentUserAllHosts profile path. */
372
+ function resolveProfilePaths() {
373
+ const out = [];
374
+ for (const engine of ['powershell.exe', 'pwsh.exe']) {
375
+ try {
376
+ const result = (0, child_process_1.spawnSync)(engine, ['-NoProfile', '-NonInteractive', '-Command', 'Write-Output $PROFILE.CurrentUserAllHosts'], {
377
+ encoding: 'utf8', windowsHide: true, timeout: 15000,
378
+ });
379
+ const profilePath = (result.stdout || '').trim().split(/\r?\n/).pop()?.trim();
380
+ if (result.status === 0 && profilePath && /\.ps1$/i.test(profilePath)) {
381
+ out.push({ engine: engine.replace(/\.exe$/i, ''), profilePath });
382
+ }
383
+ }
384
+ catch { /* engine not installed */ }
385
+ }
386
+ // Dedupe (both engines can share a profile only if paths match — normally they differ).
387
+ const seen = new Set();
388
+ return out.filter(entry => {
389
+ const key = entry.profilePath.toLowerCase();
390
+ if (seen.has(key))
391
+ return false;
392
+ seen.add(key);
393
+ return true;
394
+ });
395
+ }
396
+ function profileSnippet() {
397
+ return [
398
+ MARKER_START,
399
+ `# Loads the FullCourtDefense dangerous-command guard for interactive sessions.`,
400
+ `if (Test-Path ${psQuote(GUARD_PS1_PATH)}) { . ${psQuote(GUARD_PS1_PATH)} }`,
401
+ MARKER_END,
402
+ ].join('\r\n');
403
+ }
404
+ function addSnippetToProfile(profilePath) {
405
+ fs.mkdirSync(path.dirname(profilePath), { recursive: true });
406
+ let content = '';
407
+ try {
408
+ content = fs.readFileSync(profilePath, 'utf8');
409
+ }
410
+ catch { /* new profile */ }
411
+ if (content.includes(MARKER_START))
412
+ return 'already';
413
+ const sep = content && !content.endsWith('\n') ? '\r\n\r\n' : content ? '\r\n' : '';
414
+ fs.writeFileSync(profilePath, content + sep + profileSnippet() + '\r\n', 'utf8');
415
+ return 'installed';
416
+ }
417
+ function removeSnippetFromProfile(profilePath) {
418
+ try {
419
+ const content = fs.readFileSync(profilePath, 'utf8');
420
+ const start = content.indexOf(MARKER_START);
421
+ const end = content.indexOf(MARKER_END);
422
+ if (start === -1 || end === -1)
423
+ return false;
424
+ const cleaned = (content.slice(0, start) + content.slice(end + MARKER_END.length)).replace(/(\r?\n){3,}/g, '\r\n\r\n');
425
+ fs.writeFileSync(profilePath, cleaned, 'utf8');
426
+ return true;
427
+ }
428
+ catch {
429
+ return false;
430
+ }
431
+ }
432
+ /** Probe whether the profile guard is installed. Read-only, never throws. */
433
+ function getShellGuardStatus() {
434
+ if (process.platform !== 'win32') {
435
+ return { supported: false, installed: false, profiles: [], rulesPresent: false };
436
+ }
437
+ try {
438
+ const profiles = resolveProfilePaths().map(entry => {
439
+ let installed = false;
440
+ try {
441
+ installed = fs.readFileSync(entry.profilePath, 'utf8').includes(MARKER_START);
442
+ }
443
+ catch { /* no profile */ }
444
+ return { ...entry, installed };
445
+ });
446
+ let ruleCount;
447
+ let mode;
448
+ let rulesPresent = false;
449
+ try {
450
+ const parsed = JSON.parse(fs.readFileSync(GUARD_RULES_PATH, 'utf8'));
451
+ rulesPresent = Array.isArray(parsed?.rules) && parsed.rules.length > 0;
452
+ ruleCount = Array.isArray(parsed?.rules) ? parsed.rules.length : undefined;
453
+ mode = typeof parsed?.mode === 'string' ? parsed.mode : undefined;
454
+ }
455
+ catch { /* not written yet */ }
456
+ return {
457
+ supported: true,
458
+ installed: profiles.some(p => p.installed) && fs.existsSync(GUARD_PS1_PATH),
459
+ profiles,
460
+ rulesPresent,
461
+ ruleCount,
462
+ mode,
463
+ };
464
+ }
465
+ catch {
466
+ return { supported: true, installed: false, profiles: [], rulesPresent: false };
467
+ }
468
+ }
469
+ /** Lightweight boolean for the telemetry heartbeat / discovery report. */
470
+ function isShellGuardInstalled() {
471
+ try {
472
+ if (process.platform !== 'win32')
473
+ return false;
474
+ if (!fs.existsSync(GUARD_PS1_PATH))
475
+ return false;
476
+ // Cheap check: at least one known profile contains the marker. Avoid
477
+ // spawning PowerShell on the heartbeat path — check well-known locations.
478
+ const candidates = [
479
+ path.join(os.homedir(), 'Documents', 'WindowsPowerShell', 'profile.ps1'),
480
+ path.join(os.homedir(), 'Documents', 'PowerShell', 'profile.ps1'),
481
+ path.join(os.homedir(), 'OneDrive', 'Documents', 'WindowsPowerShell', 'profile.ps1'),
482
+ path.join(os.homedir(), 'OneDrive', 'Documents', 'PowerShell', 'profile.ps1'),
483
+ ];
484
+ for (const candidate of candidates) {
485
+ try {
486
+ if (fs.readFileSync(candidate, 'utf8').includes(MARKER_START))
487
+ return true;
488
+ }
489
+ catch { /* keep looking */ }
490
+ }
491
+ return false;
492
+ }
493
+ catch {
494
+ return false;
495
+ }
496
+ }
497
+ /** `fullcourtdefense install-shell-guard` — enable the interactive PowerShell guard. */
498
+ async function installShellGuardCommand(args = {}) {
499
+ if (process.platform !== 'win32') {
500
+ console.log('The interactive shell guard currently supports Windows PowerShell. macOS/Linux (bash/zsh preexec) is planned.');
501
+ return;
502
+ }
503
+ const rules = writeShellGuardRules();
504
+ const nodePath = process.execPath;
505
+ const cliEntry = process.argv[1] || '';
506
+ fs.writeFileSync(GUARD_PS1_PATH, buildGuardPs1(nodePath, cliEntry), 'utf8');
507
+ console.log(`Guard rules written: ${rules.rules.length} rule(s), mode "${rules.mode}".`);
508
+ if (args.profiles === 'false') {
509
+ console.log('Skipped profile wiring (--profiles false): guard script + rules written only.');
510
+ return;
511
+ }
512
+ const profiles = resolveProfilePaths();
513
+ if (profiles.length === 0) {
514
+ console.log('\x1b[33m⚠ Could not resolve any PowerShell profile path — guard script written but not wired into a profile.\x1b[0m');
515
+ return;
516
+ }
517
+ for (const profile of profiles) {
518
+ try {
519
+ const outcome = addSnippetToProfile(profile.profilePath);
520
+ console.log(outcome === 'installed'
521
+ ? `\x1b[32m✓ ${profile.engine}: guard added to profile\x1b[0m \x1b[2m(${profile.profilePath})\x1b[0m`
522
+ : `\x1b[32m✓ ${profile.engine}: guard already in profile\x1b[0m \x1b[2m(${profile.profilePath})\x1b[0m`);
523
+ }
524
+ catch (error) {
525
+ console.log(`\x1b[33m⚠ ${profile.engine}: could not update profile (${error instanceof Error ? error.message : String(error)})\x1b[0m`);
526
+ }
527
+ }
528
+ console.log('\x1b[2mTakes effect in NEW PowerShell windows. Dangerous typed commands are blocked (or warned in monitor mode) and reported to the fleet dashboard.\x1b[0m');
529
+ }
530
+ /** `fullcourtdefense uninstall-shell-guard` — remove the guard from all profiles. */
531
+ async function uninstallShellGuardCommand() {
532
+ if (process.platform !== 'win32') {
533
+ console.log('Nothing to remove on this OS.');
534
+ return;
535
+ }
536
+ let removed = 0;
537
+ for (const profile of resolveProfilePaths()) {
538
+ if (removeSnippetFromProfile(profile.profilePath)) {
539
+ removed++;
540
+ console.log(`Removed guard from ${profile.engine} profile (${profile.profilePath}).`);
541
+ }
542
+ }
543
+ try {
544
+ fs.unlinkSync(GUARD_PS1_PATH);
545
+ }
546
+ catch { /* not present */ }
547
+ try {
548
+ fs.unlinkSync(GUARD_RULES_PATH);
549
+ }
550
+ catch { /* not present */ }
551
+ console.log(removed > 0 ? 'Shell guard uninstalled. Open shells keep the guard until closed.' : 'Shell guard was not installed in any profile.');
552
+ }
@@ -0,0 +1,49 @@
1
+ export interface WindowsAuditStatus {
2
+ /** False on non-Windows platforms (audit logging is a Windows feature). */
3
+ supported: boolean;
4
+ scriptBlockLogging: boolean;
5
+ transcription: boolean;
6
+ transcriptionPath?: string;
7
+ checkedAt: string;
8
+ }
9
+ /** Shape reported to the backend (discovery host payload + heartbeat). */
10
+ export interface ShellAuditReport {
11
+ platform: string;
12
+ scriptBlockLogging: boolean;
13
+ transcription: boolean;
14
+ transcriptionPath?: string;
15
+ /** Interactive PowerShell profile guard installed (real-time blocking of typed commands). */
16
+ profileGuard?: boolean;
17
+ checkedAt: string;
18
+ }
19
+ /** Probe current audit coverage. Read-only, no elevation needed, never throws. */
20
+ export declare function getWindowsAuditStatus(): WindowsAuditStatus;
21
+ /**
22
+ * Compact status for the discovery host payload and telemetry heartbeat.
23
+ * Returns undefined on non-Windows so the field is simply omitted.
24
+ * Never throws — telemetry/discovery must not break on a probe failure.
25
+ */
26
+ export declare function getShellAuditReport(): ShellAuditReport | undefined;
27
+ export interface EnableWindowsAuditResult {
28
+ ok: boolean;
29
+ status: WindowsAuditStatus;
30
+ /** Human-readable outcome, printable as-is. */
31
+ message: string;
32
+ /** True when the user saw (and answered) a UAC elevation prompt. */
33
+ promptShown: boolean;
34
+ }
35
+ /**
36
+ * Enable ScriptBlock Logging + Transcription via ONE UAC elevation prompt.
37
+ * Skips silently when already fully enabled. Never throws; on any failure
38
+ * (UAC declined, no admin, GPO conflict) returns ok=false with a warning
39
+ * message — callers must treat that as non-blocking.
40
+ */
41
+ export declare function enableWindowsAudit(): EnableWindowsAuditResult;
42
+ export interface WindowsAuditArgs {
43
+ enable?: string;
44
+ }
45
+ /**
46
+ * `fullcourtdefense windows-audit` — show (default) or enable (--enable)
47
+ * Windows PowerShell audit coverage on this machine.
48
+ */
49
+ export declare function windowsAuditCommand(args: WindowsAuditArgs): Promise<void>;
@@ -0,0 +1,247 @@
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.getWindowsAuditStatus = getWindowsAuditStatus;
37
+ exports.getShellAuditReport = getShellAuditReport;
38
+ exports.enableWindowsAudit = enableWindowsAudit;
39
+ exports.windowsAuditCommand = windowsAuditCommand;
40
+ const child_process_1 = require("child_process");
41
+ const fs = __importStar(require("fs"));
42
+ const os = __importStar(require("os"));
43
+ const path = __importStar(require("path"));
44
+ const shellGuard_1 = require("./shellGuard");
45
+ /**
46
+ * Windows PowerShell audit coverage — ScriptBlock Logging + Transcription.
47
+ *
48
+ * Both are built-in Windows engine features (no agent, no driver): once the
49
+ * HKLM policy keys are set, PowerShell itself records every script block it
50
+ * executes (event 4104 — including decoded/obfuscated code, scripts run as
51
+ * files, and PowerShell spawned by other processes) and writes full session
52
+ * transcripts. That closes the biggest endpoint blind spot: dangerous commands
53
+ * that never pass through an IDE hook or interactive prompt.
54
+ *
55
+ * The keys live under HKLM\SOFTWARE\Policies so writing them requires
56
+ * elevation — `enableWindowsAudit()` triggers ONE UAC prompt via
57
+ * `Start-Process -Verb RunAs`. Reading them needs no elevation, so coverage
58
+ * status is probed freely by `install-all`, `discover --upload`, and every
59
+ * telemetry heartbeat (which is how the fleet dashboard knows which machines
60
+ * are covered).
61
+ *
62
+ * Fail-open by design: enable failures (UAC declined, no admin rights,
63
+ * GPO-managed) NEVER block an install — they surface as a warning locally and
64
+ * as missing coverage in the Devices & MCP fleet view.
65
+ */
66
+ const SBL_KEY = 'HKLM\\SOFTWARE\\Policies\\Microsoft\\Windows\\PowerShell\\ScriptBlockLogging';
67
+ const TRANSCRIPTION_KEY = 'HKLM\\SOFTWARE\\Policies\\Microsoft\\Windows\\PowerShell\\Transcription';
68
+ /** Read a REG_DWORD value; returns undefined when the key/value is absent. */
69
+ function regDword(key, value) {
70
+ try {
71
+ const out = (0, child_process_1.execFileSync)('reg', ['query', key, '/v', value], {
72
+ encoding: 'utf8', windowsHide: true, timeout: 5000, stdio: ['ignore', 'pipe', 'ignore'],
73
+ });
74
+ const match = out.match(new RegExp(`${value}\\s+REG_DWORD\\s+0x([0-9a-fA-F]+)`));
75
+ return match ? parseInt(match[1], 16) : undefined;
76
+ }
77
+ catch {
78
+ return undefined; // key or value missing → not enabled
79
+ }
80
+ }
81
+ /** Read a REG_SZ value; returns undefined when the key/value is absent. */
82
+ function regString(key, value) {
83
+ try {
84
+ const out = (0, child_process_1.execFileSync)('reg', ['query', key, '/v', value], {
85
+ encoding: 'utf8', windowsHide: true, timeout: 5000, stdio: ['ignore', 'pipe', 'ignore'],
86
+ });
87
+ const match = out.match(new RegExp(`${value}\\s+REG_(?:EXPAND_)?SZ\\s+(.+)`));
88
+ return match ? match[1].trim() : undefined;
89
+ }
90
+ catch {
91
+ return undefined;
92
+ }
93
+ }
94
+ /** Probe current audit coverage. Read-only, no elevation needed, never throws. */
95
+ function getWindowsAuditStatus() {
96
+ const checkedAt = new Date().toISOString();
97
+ if (process.platform !== 'win32') {
98
+ return { supported: false, scriptBlockLogging: false, transcription: false, checkedAt };
99
+ }
100
+ return {
101
+ supported: true,
102
+ scriptBlockLogging: regDword(SBL_KEY, 'EnableScriptBlockLogging') === 1,
103
+ transcription: regDword(TRANSCRIPTION_KEY, 'EnableTranscripting') === 1,
104
+ transcriptionPath: regString(TRANSCRIPTION_KEY, 'OutputDirectory'),
105
+ checkedAt,
106
+ };
107
+ }
108
+ /**
109
+ * Compact status for the discovery host payload and telemetry heartbeat.
110
+ * Returns undefined on non-Windows so the field is simply omitted.
111
+ * Never throws — telemetry/discovery must not break on a probe failure.
112
+ */
113
+ function getShellAuditReport() {
114
+ try {
115
+ if (process.platform !== 'win32')
116
+ return undefined;
117
+ const status = getWindowsAuditStatus();
118
+ return {
119
+ platform: 'win32',
120
+ scriptBlockLogging: status.scriptBlockLogging,
121
+ transcription: status.transcription,
122
+ transcriptionPath: status.transcriptionPath,
123
+ profileGuard: (0, shellGuard_1.isShellGuardInstalled)(),
124
+ checkedAt: status.checkedAt,
125
+ };
126
+ }
127
+ catch {
128
+ return undefined;
129
+ }
130
+ }
131
+ function defaultTranscriptDir() {
132
+ const programData = process.env.ProgramData || 'C:\\ProgramData';
133
+ return path.join(programData, 'FullCourtDefense', 'Transcripts');
134
+ }
135
+ /** The elevated payload: sets both policy keys + creates the transcript dir. */
136
+ function buildEnableScript(transcriptDir) {
137
+ return [
138
+ `$ErrorActionPreference = 'Stop'`,
139
+ `$sbl = 'HKLM:\\SOFTWARE\\Policies\\Microsoft\\Windows\\PowerShell\\ScriptBlockLogging'`,
140
+ `New-Item -Path $sbl -Force | Out-Null`,
141
+ `New-ItemProperty -Path $sbl -Name EnableScriptBlockLogging -Value 1 -PropertyType DWord -Force | Out-Null`,
142
+ `$tr = 'HKLM:\\SOFTWARE\\Policies\\Microsoft\\Windows\\PowerShell\\Transcription'`,
143
+ `New-Item -Path $tr -Force | Out-Null`,
144
+ `New-ItemProperty -Path $tr -Name EnableTranscripting -Value 1 -PropertyType DWord -Force | Out-Null`,
145
+ `New-ItemProperty -Path $tr -Name EnableInvocationHeader -Value 1 -PropertyType DWord -Force | Out-Null`,
146
+ `$dir = '${transcriptDir.replace(/'/g, "''")}'`,
147
+ `New-Item -ItemType Directory -Path $dir -Force | Out-Null`,
148
+ `New-ItemProperty -Path $tr -Name OutputDirectory -Value $dir -PropertyType String -Force | Out-Null`,
149
+ ].join('\r\n') + '\r\n';
150
+ }
151
+ /**
152
+ * Enable ScriptBlock Logging + Transcription via ONE UAC elevation prompt.
153
+ * Skips silently when already fully enabled. Never throws; on any failure
154
+ * (UAC declined, no admin, GPO conflict) returns ok=false with a warning
155
+ * message — callers must treat that as non-blocking.
156
+ */
157
+ function enableWindowsAudit() {
158
+ const before = getWindowsAuditStatus();
159
+ if (!before.supported) {
160
+ return { ok: false, status: before, promptShown: false, message: 'PowerShell audit logging is a Windows feature — skipped on this OS.' };
161
+ }
162
+ if (before.scriptBlockLogging && before.transcription) {
163
+ return { ok: true, status: before, promptShown: false, message: 'PowerShell audit logging already enabled (ScriptBlock Logging + Transcription).' };
164
+ }
165
+ const transcriptDir = before.transcriptionPath || defaultTranscriptDir();
166
+ const scriptPath = path.join(os.tmpdir(), `fcd-enable-audit-${process.pid}.ps1`);
167
+ const elevatePath = path.join(os.tmpdir(), `fcd-elevate-audit-${process.pid}.ps1`);
168
+ let promptShown = false;
169
+ try {
170
+ fs.writeFileSync(scriptPath, buildEnableScript(transcriptDir), 'utf8');
171
+ // Outer (unelevated) launcher script asks Windows to run the payload
172
+ // elevated. Using -File for BOTH scripts avoids inline -Command quoting
173
+ // pitfalls (paths with spaces, nested quotes). -Wait blocks until the
174
+ // elevated child finishes so the recheck below is accurate; if the user
175
+ // clicks "No" on UAC, Start-Process throws and the launcher exits 1.
176
+ const quotedPayload = `"${scriptPath}"`.replace(/'/g, "''");
177
+ fs.writeFileSync(elevatePath, [
178
+ `$ErrorActionPreference = 'Stop'`,
179
+ `try {`,
180
+ ` $p = Start-Process -FilePath 'powershell.exe' -Verb RunAs -Wait -PassThru -WindowStyle Hidden -ArgumentList @('-NoProfile','-ExecutionPolicy','Bypass','-File','${quotedPayload}')`,
181
+ ` exit $p.ExitCode`,
182
+ `} catch {`,
183
+ ` exit 1`,
184
+ `}`,
185
+ ].join('\r\n') + '\r\n', 'utf8');
186
+ promptShown = true;
187
+ const result = (0, child_process_1.spawnSync)('powershell.exe', [
188
+ '-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass', '-File', elevatePath,
189
+ ], { encoding: 'utf8', windowsHide: true, timeout: 120000 });
190
+ const after = getWindowsAuditStatus();
191
+ if (after.scriptBlockLogging && after.transcription) {
192
+ return {
193
+ ok: true, status: after, promptShown,
194
+ message: `PowerShell audit logging enabled — ScriptBlock Logging + Transcription (transcripts: ${after.transcriptionPath || transcriptDir}).`,
195
+ };
196
+ }
197
+ const declined = result.status !== 0;
198
+ return {
199
+ ok: false, status: after, promptShown,
200
+ message: declined
201
+ ? 'PowerShell audit logging NOT enabled — the admin prompt was declined or elevation failed. Re-run `fullcourtdefense install-all` (or `fullcourtdefense windows-audit --enable`) and approve the prompt, or ask IT to enable it via GPO/Intune.'
202
+ : 'PowerShell audit logging could not be verified after the elevated write — the keys may be managed by Group Policy. Ask IT to enable ScriptBlock Logging + Transcription via GPO/Intune.',
203
+ };
204
+ }
205
+ catch (error) {
206
+ return {
207
+ ok: false, status: getWindowsAuditStatus(), promptShown,
208
+ message: `PowerShell audit logging NOT enabled (${error instanceof Error ? error.message : String(error)}). Re-run elevated or ask IT to enable via GPO/Intune.`,
209
+ };
210
+ }
211
+ finally {
212
+ try {
213
+ fs.unlinkSync(scriptPath);
214
+ }
215
+ catch { /* best effort */ }
216
+ try {
217
+ fs.unlinkSync(elevatePath);
218
+ }
219
+ catch { /* best effort */ }
220
+ }
221
+ }
222
+ /**
223
+ * `fullcourtdefense windows-audit` — show (default) or enable (--enable)
224
+ * Windows PowerShell audit coverage on this machine.
225
+ */
226
+ async function windowsAuditCommand(args) {
227
+ if (process.platform !== 'win32') {
228
+ console.log('PowerShell audit logging is a Windows feature — nothing to do on this OS.');
229
+ return;
230
+ }
231
+ if (args.enable === 'true') {
232
+ console.log('Enabling PowerShell audit logging (ScriptBlock Logging + Transcription)…');
233
+ console.log('\x1b[2mWindows will show an admin approval prompt (UAC). Approve it to enable machine-wide command auditing.\x1b[0m');
234
+ const result = enableWindowsAudit();
235
+ console.log(result.ok ? `\x1b[32m✓ ${result.message}\x1b[0m` : `\x1b[33m⚠ ${result.message}\x1b[0m`);
236
+ return;
237
+ }
238
+ const status = getWindowsAuditStatus();
239
+ const mark = (on) => (on ? '\x1b[32m✓ enabled\x1b[0m' : '\x1b[31m✗ disabled\x1b[0m');
240
+ console.log('\n\x1b[1mWindows PowerShell audit coverage\x1b[0m');
241
+ console.log(` ScriptBlock Logging (event 4104): ${mark(status.scriptBlockLogging)}`);
242
+ console.log(` Transcription (session recording): ${mark(status.transcription)}${status.transcriptionPath ? ` \x1b[2m→ ${status.transcriptionPath}\x1b[0m` : ''}`);
243
+ if (!status.scriptBlockLogging || !status.transcription) {
244
+ console.log('\n Enable with: \x1b[1mfullcourtdefense windows-audit --enable\x1b[0m (requires one admin approval)');
245
+ }
246
+ console.log('');
247
+ }
package/dist/index.js CHANGED
@@ -50,6 +50,8 @@ const installClaudeHook_1 = require("./commands/installClaudeHook");
50
50
  const mcpGateway_1 = require("./commands/mcpGateway");
51
51
  const installAll_1 = require("./commands/installAll");
52
52
  const autoProtect_1 = require("./commands/autoProtect");
53
+ const windowsAudit_1 = require("./commands/windowsAudit");
54
+ const shellGuard_1 = require("./commands/shellGuard");
53
55
  const fs = __importStar(require("fs"));
54
56
  const path = __importStar(require("path"));
55
57
  function readCliVersion() {
@@ -123,6 +125,11 @@ function printHelp() {
123
125
  install available runtime hooks for built-in actions. No folder path
124
126
  or --mcp-command needed. Use --hooks false to skip runtime hooks.
125
127
  install Alias for install-all.
128
+ windows-audit Show Windows PowerShell audit coverage (ScriptBlock Logging +
129
+ Transcription). Use --enable to turn it on (one admin prompt).
130
+ install-shell-guard Block dangerous commands typed in PowerShell terminals
131
+ (real-time, offline rule cache). uninstall-shell-guard removes it;
132
+ shell-guard-status shows coverage.
126
133
  scan Runs the scan. Use --local for inside-organization scans.
127
134
  discover Finds MCP servers, local secrets, agent rules/skills, and machine
128
135
  blast radius on this laptop. Use --surface all --upload for AI Fleet.
@@ -592,10 +599,39 @@ async function main() {
592
599
  discover: flags.discover,
593
600
  autoProtect: flags['auto-protect'],
594
601
  intervalMinutes: flags['interval-minutes'],
602
+ windowsAudit: flags['windows-audit'],
595
603
  };
596
604
  await (0, installAll_1.installAllCommand)(args, config);
597
605
  break;
598
606
  }
607
+ case 'windows-audit': {
608
+ const args = { enable: flags.enable };
609
+ await (0, windowsAudit_1.windowsAuditCommand)(args);
610
+ break;
611
+ }
612
+ case 'install-shell-guard': {
613
+ await (0, shellGuard_1.installShellGuardCommand)({ profiles: flags.profiles });
614
+ break;
615
+ }
616
+ case 'uninstall-shell-guard': {
617
+ await (0, shellGuard_1.uninstallShellGuardCommand)();
618
+ break;
619
+ }
620
+ case 'shell-guard-status': {
621
+ const status = (0, shellGuard_1.getShellGuardStatus)();
622
+ if (!status.supported) {
623
+ console.log('Shell guard supports Windows PowerShell (bash/zsh planned).');
624
+ break;
625
+ }
626
+ console.log(`\nInteractive PowerShell shell guard: ${status.installed ? '\x1b[32minstalled\x1b[0m' : '\x1b[31mnot installed\x1b[0m'}`);
627
+ for (const profile of status.profiles) {
628
+ console.log(` ${profile.engine}: ${profile.installed ? '\x1b[32m✓\x1b[0m' : '\x1b[31m✗\x1b[0m'} \x1b[2m${profile.profilePath}\x1b[0m`);
629
+ }
630
+ if (status.rulesPresent)
631
+ console.log(` Rules: ${status.ruleCount} (mode: ${status.mode || 'block'})`);
632
+ console.log('');
633
+ break;
634
+ }
599
635
  case 'install-mcp-gateway': {
600
636
  await (0, mcpGateway_1.installMcpGatewayCommand)(buildInstallArgs(), config);
601
637
  break;
package/dist/telemetry.js CHANGED
@@ -42,6 +42,8 @@ const fs = __importStar(require("fs"));
42
42
  const os = __importStar(require("os"));
43
43
  const path = __importStar(require("path"));
44
44
  const machineIdentity_1 = require("./machineIdentity");
45
+ const windowsAudit_1 = require("./commands/windowsAudit");
46
+ const shellGuard_1 = require("./commands/shellGuard");
45
47
  /**
46
48
  * Local-first telemetry: every enforcement decision is appended to an on-disk
47
49
  * spool (instant, offline-safe) and flushed to the backend in batches. Critical
@@ -108,6 +110,9 @@ function rewriteSpool(remaining) {
108
110
  }
109
111
  /** Drain the spool to the backend in one batch (+ optional heartbeat). Returns accepted count. */
110
112
  async function flushSpool(input) {
113
+ // Piggyback on the flush cycle to re-sync the interactive shell guard's rule
114
+ // cache (mode flips + new custom rules reach open-terminal machines in ~30s).
115
+ (0, shellGuard_1.refreshShellGuardRules)();
111
116
  const all = readSpool();
112
117
  const batch = all.slice(0, MAX_BATCH);
113
118
  const overflow = all.slice(MAX_BATCH);
@@ -125,7 +130,16 @@ async function flushSpool(input) {
125
130
  shieldId: input.shieldId,
126
131
  machineId: identity.machineId,
127
132
  heartbeat: input.heartbeat
128
- ? { agentVersion: input.agentVersion, integrityOk: input.integrityOk, coverage: 'hooks' }
133
+ ? {
134
+ agentVersion: input.agentVersion,
135
+ integrityOk: input.integrityOk,
136
+ coverage: 'hooks',
137
+ hostname: identity.hostname,
138
+ // Windows-only: current PowerShell audit coverage (ScriptBlock
139
+ // Logging + Transcription). Keeps the fleet dashboard's coverage
140
+ // fresh on every ping — undefined (omitted) on other platforms.
141
+ shellAudit: (0, windowsAudit_1.getShellAuditReport)(),
142
+ }
129
143
  : undefined,
130
144
  events: batch,
131
145
  }),
package/dist/version.json CHANGED
@@ -1,3 +1,3 @@
1
1
  {
2
- "version": "1.8.0"
2
+ "version": "1.10.0"
3
3
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fullcourtdefense-cli",
3
- "version": "1.8.0",
3
+ "version": "1.10.0",
4
4
  "description": "Full Court Defense CLI — security scanning for AI agents from your terminal",
5
5
  "main": "dist/index.js",
6
6
  "bin": {
@@ -16,6 +16,9 @@
16
16
  "build": "tsc && node scripts/copy-attack-corpus.js",
17
17
  "test:deterministic-guard": "npm run build && node scripts/test-deterministic-guard.js",
18
18
  "test:taint-ledger": "npm run build && node scripts/test-taint-ledger.js",
19
+ "test:shell-audit": "npm run build && node scripts/test-shell-audit.js",
20
+ "test:shell-guard": "npm run build && node scripts/test-shell-guard.js",
21
+ "test:ping-e2e": "npm run build && node scripts/test-ping-e2e.js",
19
22
  "prepublishOnly": "npm run build"
20
23
  },
21
24
  "keywords": [