fullcourtdefense-cli 1.13.0 → 1.14.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -60,12 +60,33 @@ const AUTORUN_BAT_PATH = path.join(os.homedir(), '.fullcourtdefense-cmd-autorun.
60
60
  const CMD_AUTORUN_KEY = 'HKCU\\Software\\Microsoft\\Command Processor';
61
61
  const AUTORUN_VALUE = 'AutoRun';
62
62
  const AUTORUN_MARKER = 'fullcourtdefense-cmd-autorun.bat';
63
- /** cmd builtins / exes we intercept via doskey (first token of the typed line). */
63
+ /**
64
+ * cmd builtins / exes we intercept via doskey (first token of the typed line).
65
+ *
66
+ * cmd.exe has NO general pre-execution hook (unlike PowerShell PSReadLine or the
67
+ * zsh/bash whole-line hooks), so coverage here is by enumeration: every command
68
+ * name that anchors a dangerous rule must be listed or its rule can never fire in
69
+ * interactive cmd. This is the known residual gap — a command whose leading name
70
+ * is not below runs unchecked. Keep in sync with the builtin rule command anchors
71
+ * in shellGuard.ts. Only leading-token names matter (doskey is first-token only),
72
+ * so pipes/`&&` chains where the dangerous command is not first are NOT caught by
73
+ * cmd (they ARE under PowerShell/zsh/bash and `shell-guard-check`).
74
+ */
64
75
  const INTERCEPTED_COMMANDS = [
65
- 'del', 'erase', 'rd', 'rmdir', 'format',
66
- 'curl', 'wget', 'powershell', 'pwsh',
67
- 'terraform', 'kubectl', 'aws', 'gcloud', 'az',
68
- 'vssadmin', 'certutil', 'bitsadmin', 'reg', 'net', 'sc',
76
+ // Destructive filesystem / disk
77
+ 'del', 'erase', 'rd', 'rmdir', 'format', 'cipher', 'diskpart', 'fsutil',
78
+ 'robocopy', 'xcopy', 'takeown', 'icacls', 'attrib', 'compact',
79
+ // Backup / recovery / shadow-copy tampering (ransomware patterns)
80
+ 'vssadmin', 'wbadmin', 'bcdedit', 'wevtutil',
81
+ // Interpreters / download-and-exec vectors
82
+ 'powershell', 'pwsh', 'curl', 'wget', 'certutil', 'bitsadmin', 'mshta',
83
+ 'rundll32', 'regsvr32', 'wscript', 'cscript', 'wmic', 'wsl',
84
+ // Service / firewall / registry / accounts
85
+ 'reg', 'net', 'net1', 'sc', 'netsh', 'schtasks', 'taskkill',
86
+ // Infra / cloud CLIs
87
+ 'terraform', 'pulumi', 'kubectl', 'aws', 'gcloud', 'az', 'docker',
88
+ // VCS + database clients
89
+ 'git', 'psql', 'mysql', 'mongo', 'mongosh', 'sqlcmd',
69
90
  ];
70
91
  function regString(key, value) {
71
92
  try {
@@ -456,7 +456,7 @@ async function hookCommand(args, config) {
456
456
  const failClosed = args.failClosed === 'true';
457
457
  const timeoutMs = Number(args.timeout) > 0 ? Number(args.timeout) : 8000;
458
458
  const approvalMode = args.approvalMode === 'block' ? 'block' : 'wait';
459
- const approvalTimeoutMs = Number(args.approvalTimeoutMs) > 0 ? Number(args.approvalTimeoutMs) : 300000;
459
+ const approvalTimeoutMs = Number(args.approvalTimeoutMs) > 0 ? Number(args.approvalTimeoutMs) : 900000;
460
460
  const approvalPollMs = Math.max(1000, Number(args.approvalPollMs) > 0 ? Number(args.approvalPollMs) : 3000);
461
461
  const creds = (0, config_1.resolveCliCredentials)(config, {
462
462
  shieldId: args.shieldId,
@@ -191,7 +191,7 @@ function resolveGatewayConfig(args, config, defaults = {}) {
191
191
  userObjective: args.userObjective || process.env.FCD_USER_OBJECTIVE || defaults.userObjective || 'Developer requested this action from Cursor.',
192
192
  authority: args.authority || process.env.FCD_AUTHORITY || 'developer',
193
193
  approvalMode: args.approvalMode === 'block' ? 'block' : 'wait',
194
- approvalTimeoutMs: Number(args.approvalTimeoutMs) > 0 ? Number(args.approvalTimeoutMs) : 300000,
194
+ approvalTimeoutMs: Number(args.approvalTimeoutMs) > 0 ? Number(args.approvalTimeoutMs) : 900000,
195
195
  approvalPollMs: Math.max(1000, Number(args.approvalPollMs) > 0 ? Number(args.approvalPollMs) : 3000),
196
196
  scanResponse: args.scanResponse === 'true',
197
197
  failClosed: args.failClosed !== 'false',
@@ -52,18 +52,23 @@ const shellGuard_1 = require("./shellGuard");
52
52
  * everywhere.
53
53
  *
54
54
  * Two hook mechanisms, one shared Node checker (~/.fullcourtdefense-posix-guard.js).
55
- * Each shell gets its OWN sourced file (never cross-parsed zsh-only expansions
56
- * would otherwise break bash at source time):
55
+ * Both are COMMAND-AGNOSTIC they inspect the WHOLE typed line against every
56
+ * rule, so any command in any position is covered (not a fixed watchlist of
57
+ * names). Each shell gets its OWN sourced file (never cross-parsed — zsh-only
58
+ * expansions would otherwise break bash at source time):
57
59
  * - zsh (macOS default), ~/.fullcourtdefense-shell-guard.zsh: a ZLE
58
- * `accept-line` widget inspects the WHOLE typed line before it runs — the
59
- * strongest form (catches pipes, e.g. `curl … | sh`). Block mode → the line
60
- * is not accepted (never executes); monitor → warning + the line runs.
60
+ * `accept-line` widget inspects the whole typed line before it runs (catches
61
+ * pipes, e.g. `curl … | sh`, and chained `foo && rm -rf /`). Block mode → the
62
+ * line is not accepted (never executes); monitor → warning + the line runs.
61
63
  * Analogous to the PowerShell PSReadLine hook.
62
- * - bash, ~/.fullcourtdefense-shell-guard.bash: function wrappers for a
63
- * watchlist of high-risk command names (rm, dd, chmod, sudo, curl, kubectl…) —
64
- * the same model as the cmd.exe doskey guard. Per-command (does not see the
65
- * full pipeline), so pipe-to-shell is only fully covered under zsh; the
66
- * flagship cases (rm -rf /, sudo rm, dd, infra destroy…) are covered under both.
64
+ * - bash, ~/.fullcourtdefense-shell-guard.bash: a `DEBUG`-trap preexec hook
65
+ * (bash-preexec style) reads the full command line from history right before
66
+ * it runs and, with `extdebug`, skips it on an explicit block (checker exit
67
+ * 97). Whole-line, command-agnostic the bash analogue of zsh's accept-line.
68
+ *
69
+ * Cost of whole-line coverage: the Node checker runs once per typed command. Off
70
+ * with `export FCD_SHELL_GUARD=off`. Benign lines exit the checker in ~1ms after
71
+ * regex matching; the only real overhead is Node startup per command.
67
72
  *
68
73
  * Safety: every layer fails OPEN. If the checker is missing or errors, the command
69
74
  * runs — a guard bug must never brick a developer's shell. Only an explicit block
@@ -82,19 +87,6 @@ const SPOOL_PATH = path.join(os.homedir(), '.fullcourtdefense-spool.jsonl');
82
87
  const MARKER_START = '# >>> fullcourtdefense shell guard >>>';
83
88
  const MARKER_END = '# <<< fullcourtdefense shell guard <<<';
84
89
  const BLOCK_CODE = 97;
85
- /**
86
- * Leading command names that trigger the checker. Everything else runs with zero
87
- * overhead. Each name maps to at least one builtin rule anchor. (No `node`/`git` —
88
- * too common, no destructive builtin rule anchors on them.)
89
- */
90
- const WATCHLIST = [
91
- 'rm', 'rmdir', 'dd', 'mkfs', 'chmod', 'chown', 'sudo',
92
- 'bash', 'sh', 'zsh', 'nc', 'ncat', 'netcat', 'socat',
93
- 'curl', 'wget', 'python', 'python2', 'python3', 'perl', 'php', 'ruby',
94
- 'terraform', 'pulumi', 'kubectl', 'gcloud', 'aws', 'az', 'docker',
95
- 'diskutil', 'csrutil', 'spctl', 'iptables', 'ufw', 'systemctl', 'service',
96
- 'launchctl', 'chattr', 'scp', 'psql', 'mysql', 'mongo', 'mongosh', 'crontab',
97
- ];
98
90
  function isPosixPlatform() {
99
91
  return process.platform === 'darwin' || process.platform === 'linux';
100
92
  }
@@ -129,7 +121,6 @@ function buildGuardJs(nodePath, cliEntry) {
129
121
  }
130
122
  /** zsh guard — sourced ONLY from .zshrc (contains zsh-specific ZLE syntax). */
131
123
  function buildGuardZsh(nodePath) {
132
- const watch = ` ${WATCHLIST.join(' ')} `;
133
124
  return [
134
125
  `# FullCourtDefense interactive shell guard for zsh (installed by fullcourtdefense-cli).`,
135
126
  `# Disable for one session: export FCD_SHELL_GUARD=off`,
@@ -139,27 +130,21 @@ function buildGuardZsh(nodePath) {
139
130
  ``,
140
131
  `FCD_GUARD_NODE=${shSingleQuote(nodePath)}`,
141
132
  `FCD_GUARD_JS=${shSingleQuote(GUARD_JS_PATH)}`,
142
- `__fcd_watch=${shSingleQuote(watch)}`,
143
133
  ``,
144
- `# Inspect the WHOLE typed line before accepting it (catches pipes too).`,
134
+ `# Inspect the WHOLE typed line against every rule before accepting it`,
135
+ `# (command-agnostic: catches any command, pipes, and chained segments).`,
145
136
  `__fcd_zle_accept_line() {`,
146
137
  ` emulate -L zsh`,
147
138
  ` local __fcd_line=$BUFFER`,
148
- ` if [[ -n "$__fcd_line" && "\${FCD_SHELL_GUARD:-}" != "off" ]]; then`,
149
- ` local __fcd_first="\${\${(z)__fcd_line}[1]:t}"`,
150
- ` if [[ "$__fcd_first" == "sudo" ]]; then`,
151
- ` __fcd_first="\${\${(z)__fcd_line}[2]:t}"`,
152
- ` fi`,
153
- ` if [[ -x "$FCD_GUARD_NODE" && -f "$FCD_GUARD_JS" && "$__fcd_watch" == *" $__fcd_first "* ]]; then`,
154
- ` local __fcd_out`,
155
- ` __fcd_out="$("$FCD_GUARD_NODE" "$FCD_GUARD_JS" -- "$__fcd_line" 2>&1)"`,
156
- ` local __fcd_rc=$?`,
157
- ` if [[ $__fcd_rc -eq 97 ]]; then`,
158
- ` zle -M -- "$__fcd_out"`,
159
- ` return`,
160
- ` fi`,
161
- ` [[ -n "$__fcd_out" ]] && print -u2 -r -- "$__fcd_out"`,
139
+ ` if [[ -n "$__fcd_line" && "\${FCD_SHELL_GUARD:-}" != "off" && -x "$FCD_GUARD_NODE" && -f "$FCD_GUARD_JS" ]]; then`,
140
+ ` local __fcd_out`,
141
+ ` __fcd_out="$("$FCD_GUARD_NODE" "$FCD_GUARD_JS" -- "$__fcd_line" 2>&1)"`,
142
+ ` local __fcd_rc=$?`,
143
+ ` if [[ $__fcd_rc -eq 97 ]]; then`,
144
+ ` zle -M -- "$__fcd_out"`,
145
+ ` return`,
162
146
  ` fi`,
147
+ ` [[ -n "$__fcd_out" ]] && print -u2 -r -- "$__fcd_out"`,
163
148
  ` fi`,
164
149
  ` zle .accept-line`,
165
150
  `}`,
@@ -169,9 +154,19 @@ function buildGuardZsh(nodePath) {
169
154
  ``,
170
155
  ].join('\n');
171
156
  }
172
- /** bash guard — sourced ONLY from .bashrc/.bash_profile (pure bash/POSIX syntax). */
157
+ /**
158
+ * bash guard — sourced ONLY from .bashrc/.bash_profile (pure bash syntax).
159
+ *
160
+ * Command-agnostic whole-line hook via a DEBUG trap (bash-preexec style). The
161
+ * trap fires before each command; it reads the FULL interactive line the user
162
+ * typed from `history` (so pipelines like `curl … | sh` and chained lines like
163
+ * `foo && rm -rf /` are evaluated as one string, exactly like zsh's accept-line
164
+ * BUFFER — not per-simple-command). It de-duplicates so the checker runs once
165
+ * per typed line. With `extdebug` enabled, returning 1 from the DEBUG trap
166
+ * SKIPS the command — so an explicit block (checker exit 97) prevents execution.
167
+ * A missing/erroring checker returns 0 → the command runs (fail open).
168
+ */
173
169
  function buildGuardBash(nodePath) {
174
- const watch = ` ${WATCHLIST.join(' ')} `;
175
170
  return [
176
171
  `# FullCourtDefense interactive shell guard for bash (installed by fullcourtdefense-cli).`,
177
172
  `# Disable for one session: export FCD_SHELL_GUARD=off`,
@@ -181,23 +176,41 @@ function buildGuardBash(nodePath) {
181
176
  ``,
182
177
  `FCD_GUARD_NODE=${shSingleQuote(nodePath)}`,
183
178
  `FCD_GUARD_JS=${shSingleQuote(GUARD_JS_PATH)}`,
184
- `__fcd_watch=${shSingleQuote(watch)}`,
179
+ `__fcd_last_line=""`,
185
180
  ``,
186
- `# Wrap a high-risk command. Blocks only on an explicit checker verdict (97);`,
187
- `# a missing/erroring checker runs the command (fail open).`,
188
- `__fcd_wrap() {`,
189
- ` local __fcd_cmd="$1"; shift`,
190
- ` if [ "\${FCD_SHELL_GUARD:-}" = "off" ] || [ ! -x "$FCD_GUARD_NODE" ] || [ ! -f "$FCD_GUARD_JS" ]; then`,
191
- ` command "$__fcd_cmd" "$@"; return $?`,
181
+ `# Run the shared checker on one full line. Returns 1 to cancel (extdebug).`,
182
+ `__fcd_check_line() {`,
183
+ ` local __fcd_line="$1"`,
184
+ ` [ -z "$__fcd_line" ] && return 0`,
185
+ ` [ "\${FCD_SHELL_GUARD:-}" = "off" ] && return 0`,
186
+ ` if [ ! -x "$FCD_GUARD_NODE" ] || [ ! -f "$FCD_GUARD_JS" ]; then return 0; fi`,
187
+ ` local __fcd_out __fcd_rc`,
188
+ ` __fcd_out="$("$FCD_GUARD_NODE" "$FCD_GUARD_JS" -- "$__fcd_line" 2>&1)"`,
189
+ ` __fcd_rc=$?`,
190
+ ` [ -n "$__fcd_out" ] && printf '%s\\n' "$__fcd_out" >&2`,
191
+ ` [ "$__fcd_rc" -eq 97 ] && return 1`,
192
+ ` return 0`,
193
+ `}`,
194
+ ``,
195
+ `# DEBUG trap: skip completions and the guard functions, then read the whole`,
196
+ `# typed line from history (number stripped) and check it once per line.`,
197
+ `__fcd_debug_trap() {`,
198
+ ` [ -n "\${COMP_LINE:-}" ] && return 0`,
199
+ ` case "$BASH_COMMAND" in __fcd_*|__fcd_debug_trap) return 0 ;; esac`,
200
+ ` local __fcd_hist __fcd_num __fcd_line HISTTIMEFORMAT=`,
201
+ ` __fcd_hist=$(builtin history 1 2>/dev/null)`,
202
+ ` read -r __fcd_num __fcd_line <<< "$__fcd_hist"`,
203
+ ` [ -z "$__fcd_line" ] && return 0`,
204
+ ` if [ "$__fcd_line" != "$__fcd_last_line" ]; then`,
205
+ ` __fcd_last_line="$__fcd_line"`,
206
+ ` __fcd_check_line "$__fcd_line" || return 1`,
192
207
  ` fi`,
193
- ` "$FCD_GUARD_NODE" "$FCD_GUARD_JS" -- "$__fcd_cmd $*"`,
194
- ` if [ "$?" -eq 97 ]; then return 1; fi`,
195
- ` command "$__fcd_cmd" "$@"`,
208
+ ` return 0`,
196
209
  `}`,
197
- `for __fcd_c in $__fcd_watch; do`,
198
- ` eval "\${__fcd_c}() { __fcd_wrap \${__fcd_c} \\"\\$@\\"; }"`,
199
- `done`,
200
- `unset __fcd_c`,
210
+ `if [ "\${FCD_SHELL_GUARD:-}" != "off" ]; then`,
211
+ ` shopt -s extdebug 2>/dev/null || true`,
212
+ ` trap '__fcd_debug_trap' DEBUG`,
213
+ `fi`,
201
214
  ``,
202
215
  ].join('\n');
203
216
  }
@@ -16,6 +16,23 @@ interface ShellGuardRulesFile {
16
16
  }
17
17
  /** Write (or rewrite) the ruleset the profile guard loads at shell startup. */
18
18
  export declare function writeShellGuardRules(): ShellGuardRulesFile;
19
+ /** The active ruleset in memory (builtins minus dashboard-disabled + org custom). */
20
+ export declare function activeShellGuardRules(): ShellGuardRule[];
21
+ export interface ShellGuardMatch {
22
+ rule: ShellGuardRule;
23
+ /** The command segment that matched (chained commands are split on ; & |). */
24
+ segment: string;
25
+ }
26
+ /**
27
+ * Evaluate a typed command line WITHOUT executing it — the dry-run classifier
28
+ * behind `shell-guard-check`. Uses the exact same rules + JS regex engine as the
29
+ * live cmd/posix checkers (equivalent to .NET for these patterns). Also splits
30
+ * chained commands (`a && del /s c:\`) and tests each segment, so combinations
31
+ * are caught even when the dangerous part is not first.
32
+ *
33
+ * Returns ALL matches (one per matched segment/rule), first-match-per-segment.
34
+ */
35
+ export declare function evaluateShellCommand(line: string, rules?: ShellGuardRule[]): ShellGuardMatch[];
19
36
  /**
20
37
  * Opportunistic rules/mode refresh — called from the telemetry flush cycle so a
21
38
  * console mode flip or new custom rule reaches interactive shells within ~30s.
@@ -34,6 +34,8 @@ var __importStar = (this && this.__importStar) || (function () {
34
34
  })();
35
35
  Object.defineProperty(exports, "__esModule", { value: true });
36
36
  exports.writeShellGuardRules = writeShellGuardRules;
37
+ exports.activeShellGuardRules = activeShellGuardRules;
38
+ exports.evaluateShellCommand = evaluateShellCommand;
37
39
  exports.refreshShellGuardRules = refreshShellGuardRules;
38
40
  exports.getShellGuardStatus = getShellGuardStatus;
39
41
  exports.isShellGuardInstalled = isShellGuardInstalled;
@@ -134,6 +136,12 @@ const BUILTIN_RULES = [
134
136
  pattern: String.raw `:\s*\(\)\s*\{\s*:\s*\|\s*:\s*&\s*\}\s*;\s*:` },
135
137
  { id: 'vssadmin_delete_shadows', category: 'destructive_command', severity: 'critical', reason: 'shadow copy deletion (ransomware pattern)', source: 'builtin',
136
138
  pattern: String.raw `\bvssadmin\b(?=.*\bdelete\b)(?=.*\bshadows\b)` },
139
+ { id: 'wbadmin_delete_backup', category: 'destructive_command', severity: 'critical', reason: 'Windows backup catalog deletion (ransomware pattern)', source: 'builtin',
140
+ pattern: String.raw `\bwbadmin\b(?=.*\bdelete\b)(?=.*\b(?:catalog|systemstatebackup|backup)\b)` },
141
+ { id: 'bcdedit_recovery_disable', category: 'security_tampering', severity: 'critical', reason: 'disabling Windows recovery/boot repair (ransomware pattern)', source: 'builtin',
142
+ pattern: String.raw `\bbcdedit\b(?=.*(?:\brecoveryenabled\b\s+no\b|\bbootstatuspolicy\b\s+ignoreallfailures\b))` },
143
+ { id: 'cipher_wipe_disk', category: 'destructive_command', severity: 'high', reason: 'wiping deleted data from a drive (cipher /w)', source: 'builtin',
144
+ pattern: String.raw `\bcipher(?:\.exe)?\b(?=.*\s/w(?::|\b))` },
137
145
  { id: 'defender_disable', category: 'security_tampering', severity: 'high', reason: 'disabling Windows Defender real-time protection', source: 'builtin',
138
146
  pattern: String.raw `\bset-mppreference\b(?=.*-disablerealtimemonitoring)(?=.*(?:\$true|\s1\b))` },
139
147
  // Reverse shells / droppers
@@ -330,6 +338,52 @@ function writeShellGuardRules() {
330
338
  fs.writeFileSync(GUARD_RULES_PATH, JSON.stringify(data, null, 2), { encoding: 'utf8', mode: 0o600 });
331
339
  return data;
332
340
  }
341
+ /** The active ruleset in memory (builtins minus dashboard-disabled + org custom). */
342
+ function activeShellGuardRules() {
343
+ const disabled = cachedDisabledItemIds();
344
+ return [...BUILTIN_RULES.filter(rule => !disabled.has(rule.id)), ...cachedCustomRules()];
345
+ }
346
+ /**
347
+ * Evaluate a typed command line WITHOUT executing it — the dry-run classifier
348
+ * behind `shell-guard-check`. Uses the exact same rules + JS regex engine as the
349
+ * live cmd/posix checkers (equivalent to .NET for these patterns). Also splits
350
+ * chained commands (`a && del /s c:\`) and tests each segment, so combinations
351
+ * are caught even when the dangerous part is not first.
352
+ *
353
+ * Returns ALL matches (one per matched segment/rule), first-match-per-segment.
354
+ */
355
+ function evaluateShellCommand(line, rules = activeShellGuardRules()) {
356
+ const compiled = rules.map(r => {
357
+ try {
358
+ return { rule: r, re: new RegExp(r.pattern, 'i') };
359
+ }
360
+ catch {
361
+ return null;
362
+ }
363
+ }).filter((x) => !!x);
364
+ // Whole line first (some rules intentionally span a pipe, e.g. curl | sh),
365
+ // then each &&/||/;/newline segment (catches "cleanup && del /s c:\").
366
+ const candidates = [line, ...line.split(/&&|\|\||[;&\n]|\|/).map(s => s.trim()).filter(Boolean)];
367
+ const seen = new Set();
368
+ const matches = [];
369
+ for (const candidate of candidates) {
370
+ const norm = candidate.replace(/\s+/g, ' ').trim();
371
+ for (const { rule, re } of compiled) {
372
+ try {
373
+ if (re.test(norm) || re.test(candidate)) {
374
+ const key = `${rule.id}|${candidate}`;
375
+ if (seen.has(key))
376
+ continue;
377
+ seen.add(key);
378
+ matches.push({ rule, segment: candidate });
379
+ break; // first rule per segment is enough
380
+ }
381
+ }
382
+ catch { /* skip bad regex */ }
383
+ }
384
+ }
385
+ return matches;
386
+ }
333
387
  /**
334
388
  * Opportunistic rules/mode refresh — called from the telemetry flush cycle so a
335
389
  * console mode flip or new custom rule reaches interactive shells within ~30s.
package/dist/index.js CHANGED
@@ -69,6 +69,7 @@ function readCliVersion() {
69
69
  const VERSION = readCliVersion();
70
70
  function parseArgs(argv) {
71
71
  const flags = {};
72
+ const positional = [];
72
73
  let command = '';
73
74
  for (let i = 0; i < argv.length; i++) {
74
75
  const arg = argv[i];
@@ -76,6 +77,10 @@ function parseArgs(argv) {
76
77
  command = arg;
77
78
  continue;
78
79
  }
80
+ if (command && !arg.startsWith('-')) {
81
+ positional.push(arg);
82
+ continue;
83
+ }
79
84
  if (arg.startsWith('--')) {
80
85
  const key = arg.slice(2);
81
86
  const eqIdx = key.indexOf('=');
@@ -105,7 +110,7 @@ function parseArgs(argv) {
105
110
  }
106
111
  }
107
112
  }
108
- return { command, flags };
113
+ return { command, flags, positional };
109
114
  }
110
115
  function printHelp() {
111
116
  console.log(`
@@ -134,6 +139,9 @@ function printHelp() {
134
139
  uninstall-shell-guard removes it; shell-guard-status shows coverage.
135
140
  install-cmd-guard Block dangerous commands typed in cmd.exe (Command Prompt).
136
141
  uninstall-cmd-guard removes it; cmd-guard-status shows coverage.
142
+ shell-guard-check Dry-run: test whether a command (or a file/stdin list of
143
+ commands) would be blocked — nothing is executed. Splits chained
144
+ commands (a && del /s c:\). --file <path>, --json; exits 1 if any block.
137
145
  scan Runs the scan. Use --local for inside-organization scans.
138
146
  discover Finds MCP servers, local secrets, agent rules/skills, and machine
139
147
  blast radius on this laptop. Use --surface all --upload for AI Fleet.
@@ -350,7 +358,7 @@ function printHelp() {
350
358
  `);
351
359
  }
352
360
  async function main() {
353
- const { command, flags } = parseArgs(process.argv.slice(2));
361
+ const { command, flags, positional } = parseArgs(process.argv.slice(2));
354
362
  const buildInstallArgs = () => ({
355
363
  clients: flags.clients,
356
364
  cursorProject: flags['cursor-project'],
@@ -656,6 +664,62 @@ async function main() {
656
664
  console.log('');
657
665
  break;
658
666
  }
667
+ case 'shell-guard-check': {
668
+ // Dry-run classifier — evaluate command(s) against the guard rules WITHOUT
669
+ // executing anything. Positional args are one command; --file reads one
670
+ // command per line; no args reads stdin. Exit 1 if ANY command is blocked
671
+ // (so it can gate CI). --json for machine output.
672
+ const asJson = flags.json === 'true' || flags.json === '';
673
+ const lines = [];
674
+ if (flags.file) {
675
+ try {
676
+ lines.push(...fs.readFileSync(flags.file, 'utf8').split(/\r?\n/));
677
+ }
678
+ catch (e) {
679
+ console.error(`Cannot read --file: ${e instanceof Error ? e.message : String(e)}`);
680
+ process.exit(2);
681
+ }
682
+ }
683
+ else if (positional.length > 0) {
684
+ lines.push(positional.join(' '));
685
+ }
686
+ else if (!process.stdin.isTTY) {
687
+ lines.push(...fs.readFileSync(0, 'utf8').split(/\r?\n/));
688
+ }
689
+ else {
690
+ console.log('Usage: fullcourtdefense shell-guard-check "<command>" | --file cmds.txt | echo "<cmd>" | fullcourtdefense shell-guard-check');
691
+ break;
692
+ }
693
+ const cmds = lines.map(l => l.trim()).filter(Boolean);
694
+ const rules = (0, shellGuard_1.activeShellGuardRules)();
695
+ let blocked = 0;
696
+ const results = cmds.map(cmd => {
697
+ const matches = (0, shellGuard_1.evaluateShellCommand)(cmd, rules);
698
+ if (matches.length)
699
+ blocked++;
700
+ return { command: cmd, blocked: matches.length > 0, matches: matches.map(m => ({ ruleId: m.rule.id, severity: m.rule.severity, category: m.rule.category, reason: m.rule.reason, segment: m.segment })) };
701
+ });
702
+ if (asJson) {
703
+ console.log(JSON.stringify({ total: cmds.length, blocked, ruleCount: rules.length, results }, null, 2));
704
+ }
705
+ else {
706
+ console.log(`\nEvaluated ${cmds.length} command(s) against ${rules.length} rules (dry-run — nothing executed):\n`);
707
+ for (const r of results) {
708
+ if (r.blocked) {
709
+ const m = r.matches[0];
710
+ console.log(` \x1b[31mBLOCK\x1b[0m [\x1b[1m${m.severity}\x1b[0m] ${r.command}`);
711
+ console.log(` \x1b[2m${m.reason} (rule ${m.ruleId})${r.matches.length > 1 ? ` +${r.matches.length - 1} more` : ''}\x1b[0m`);
712
+ }
713
+ else {
714
+ console.log(` \x1b[32mALLOW\x1b[0m ${r.command}`);
715
+ }
716
+ }
717
+ console.log(`\n${blocked} blocked, ${cmds.length - blocked} allowed.`);
718
+ }
719
+ if (blocked > 0)
720
+ process.exit(1);
721
+ break;
722
+ }
659
723
  case 'install-cmd-guard': {
660
724
  await (0, cmdGuard_1.installCmdGuardCommand)();
661
725
  break;
package/dist/telemetry.js CHANGED
@@ -155,8 +155,12 @@ async function flushSpool(input) {
155
155
  const body = await resp.json().catch(() => ({}));
156
156
  if (!body.success)
157
157
  return null;
158
- // Successfully delivered → drop the flushed events, keep any overflow.
159
- rewriteSpool(overflow);
158
+ // Successfully delivered → drop ONLY the events we actually sent. The spool
159
+ // may have gained new events while the POST was in flight (e.g. a terminal
160
+ // guard block landing mid-flush) — rewriting with the pre-flush snapshot
161
+ // (`overflow`) would silently discard them.
162
+ const flushedIds = new Set(batch.map(e => e.eventId));
163
+ rewriteSpool(readSpool().filter(e => !flushedIds.has(e.eventId)));
160
164
  return body.data || { accepted: batch.length, deduped: 0 };
161
165
  }
162
166
  catch {
package/dist/version.json CHANGED
@@ -1,3 +1,3 @@
1
1
  {
2
- "version": "1.13.0"
2
+ "version": "1.14.1"
3
3
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fullcourtdefense-cli",
3
- "version": "1.13.0",
3
+ "version": "1.14.1",
4
4
  "description": "Full Court Defense CLI — security scanning for AI agents from your terminal",
5
5
  "main": "dist/index.js",
6
6
  "bin": {