fullcourtdefense-cli 1.27.4 → 1.29.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.
@@ -33,6 +33,9 @@ var __importStar = (this && this.__importStar) || (function () {
33
33
  };
34
34
  })();
35
35
  Object.defineProperty(exports, "__esModule", { value: true });
36
+ exports.ASK_CODE = void 0;
37
+ exports.detectGitBash = detectGitBash;
38
+ exports.posixGuardSupported = posixGuardSupported;
36
39
  exports.getPosixShellGuardStatus = getPosixShellGuardStatus;
37
40
  exports.isPosixShellGuardInstalled = isPosixShellGuardInstalled;
38
41
  exports.installPosixShellGuardCommand = installPosixShellGuardCommand;
@@ -75,6 +78,18 @@ const shellGuard_1 = require("./shellGuard");
75
78
  * (checker exit code 97) stops a command. Disable for a session with
76
79
  * `export FCD_SHELL_GUARD=off`.
77
80
  *
81
+ * "Ask the developer" rules (checker exit code 98): the checker prints one
82
+ * machine-readable `FCD_ASK\t<rule>\t<severity>\t<reason>` line and the SHELL
83
+ * (which owns the tty) shows `Run anyway? [y] / [s] remember for this shell /
84
+ * [N]` with a timeout. y/s run the line, anything else drops it; every answer
85
+ * is reported through the checker's `--record` mode as a developer decision.
86
+ * An ask that cannot be answered (no tty, timeout) is a block — the one place
87
+ * the guard fails STRICT, because the org explicitly asked for a human.
88
+ *
89
+ * Git Bash on Windows (MSYS2 runtime) is supported: same bash guard, wired into
90
+ * %USERPROFILE%\.bashrc, node invoked with MSYS path conversion disabled so
91
+ * typed lines like `/dev/tcp/...` reach the checker untouched.
92
+ *
78
93
  * Rules/mode refresh opportunistically on every telemetry flush (offline caches
79
94
  * only — never blocks a shell). Blocks are spooled and reported to the fleet
80
95
  * dashboard as `shell_terminal` events.
@@ -87,9 +102,49 @@ const SPOOL_PATH = path.join(os.homedir(), '.fullcourtdefense-spool.jsonl');
87
102
  const MARKER_START = '# >>> fullcourtdefense shell guard >>>';
88
103
  const MARKER_END = '# <<< fullcourtdefense shell guard <<<';
89
104
  const BLOCK_CODE = 97;
105
+ /** Checker exit code: the rule asks the developer — the shell must prompt. */
106
+ exports.ASK_CODE = 98;
90
107
  function isPosixPlatform() {
91
108
  return process.platform === 'darwin' || process.platform === 'linux';
92
109
  }
110
+ /**
111
+ * Git for Windows' bash.exe, if installed (the MSYS2-based "Git Bash"). WSL's
112
+ * System32\bash.exe is deliberately NOT a match — that is a Linux distro with
113
+ * its own home directory and rc files, not this Windows profile.
114
+ */
115
+ function detectGitBash() {
116
+ if (process.platform !== 'win32')
117
+ return undefined;
118
+ const candidates = [
119
+ path.join(process.env.ProgramFiles || 'C:\\Program Files', 'Git', 'bin', 'bash.exe'),
120
+ path.join(process.env['ProgramFiles(x86)'] || 'C:\\Program Files (x86)', 'Git', 'bin', 'bash.exe'),
121
+ path.join(process.env.LOCALAPPDATA || '', 'Programs', 'Git', 'bin', 'bash.exe'),
122
+ path.join(process.env.ProgramW6432 || '', 'Git', 'bin', 'bash.exe'),
123
+ ];
124
+ for (const candidate of candidates) {
125
+ try {
126
+ if (candidate && fs.existsSync(candidate))
127
+ return candidate;
128
+ }
129
+ catch { /* keep looking */ }
130
+ }
131
+ return undefined;
132
+ }
133
+ /**
134
+ * Where the bash/zsh guard can be planted: real POSIX hosts, or Windows with
135
+ * Git Bash installed (bash only — no zsh there).
136
+ */
137
+ function posixGuardSupported() {
138
+ return isPosixPlatform() || Boolean(detectGitBash());
139
+ }
140
+ /**
141
+ * Path as the planted shell script should spell it. MSYS2 bash resolves
142
+ * `C:/Program Files/nodejs/node.exe` for `[ -x ]` and exec; backslashes would
143
+ * be read as escapes inside the script.
144
+ */
145
+ function shellPath(p) {
146
+ return process.platform === 'win32' ? p.replace(/\\/g, '/') : p;
147
+ }
93
148
  function shSingleQuote(value) {
94
149
  return `'${value.replace(/'/g, `'\\''`)}'`;
95
150
  }
@@ -104,27 +159,107 @@ function buildGuardJs(nodePath, cliEntry) {
104
159
  `const NODE_PATH=${JSON.stringify(nodePath)};`,
105
160
  `const CLI_ENTRY=${JSON.stringify(cliEntry)};`,
106
161
  `const BLOCK_CODE=${BLOCK_CODE};`,
107
- `function loadRules(){try{const raw=JSON.parse(fs.readFileSync(RULES_PATH,'utf8'));const rules=(raw.rules||[]).map(r=>{try{return{id:r.id,category:r.category,severity:r.severity,reason:r.reason,source:r.source,action:r.action==='warn'?'warn':'block',re:new RegExp(r.pattern,'i')};}catch{return null;}}).filter(Boolean);return{mode:raw.mode==='block'?'block':'monitor',rules};}catch{return{mode:'monitor',rules:[]};}}`,
162
+ `const ASK_CODE=${exports.ASK_CODE};`,
163
+ `function loadRules(){try{const raw=JSON.parse(fs.readFileSync(RULES_PATH,'utf8'));const rules=(raw.rules||[]).map(r=>{try{return{id:r.id,category:r.category,severity:r.severity,reason:r.reason,source:r.source,action:r.action==='warn'?'warn':r.action==='ask'?'ask':'block',re:new RegExp(r.pattern,'i')};}catch{return null;}}).filter(Boolean);return{mode:raw.mode==='block'?'block':'monitor',rules};}catch{return{mode:'monitor',rules:[]};}}`,
108
164
  `// Execution view (aligned with the CLI's stripInertDataSegments): quoted spans containing whitespace are`,
109
165
  `// prose (inert) unless in a code position — interpreter -c/-Command flags, remote executors (ssh/wsl/docker`,
110
166
  `// exec), $( ) substitution, or real backtick command substitution (>=2 chars; a 1-char pair is a PS escape).`,
111
167
  `function execView(line){if(/\\b(?:iex|invoke-expression|invoke-command)\\b|\\beval\\b|\\bxargs\\b|\\bbase64\\b[^\\n]*(?:-d\\b|--decode\\b)|frombase64string|-encodedcommand\\b|\\s-enc\\s|\\|\\s*&?\\s*(?:sh|bash|zsh|dash|ksh|csh|pwsh|powershell(?:\\.exe)?|cmd(?:\\.exe)?|node|python[0-9.]*|perl|ruby)\\b|\\b(?:sh|bash|zsh|dash|ksh|csh|pwsh|powershell(?:\\.exe)?|cmd(?:\\.exe)?|node|python[0-9.]*|perl|ruby)\\s+(?:-c|\\/c|-e|-command|-scriptblock)\\b|\\b(?:ssh|wsl(?:\\.exe)?|chroot)\\b|\\b(?:docker|kubectl|podman|nerdctl)(?:\\.exe)?\\s+(?:exec|run)\\b/i.test(line))return line;const K=/\\$\\(|\`[^\`]{2,}\`/;const C=/(?:(?:^|[;&|(]|\\s)(?:-c|\\/c|-e|-command|-scriptblock|-filter)[= ]\\s*|(?:^|[;&|(]\\s*)(?:\\S*[\\\\\\/])?(?:sudo\\s+|doas\\s+)?(?:ssh|wsl(?:\\.exe)?|chroot|su|screen|tmux)(?:\\s[^;&|"']*)?\\s|(?:\\S*[\\\\\\/])?(?:docker|kubectl|podman|nerdctl)(?:\\.exe)?\\s+(?:exec|run)\\b[^;&|"']*\\s)$/i;let o=line;o=o.replace(/@'[\\s\\S]*?'@/g,' fcd_inert_data ');o=o.replace(/@"[\\s\\S]*?"@/g,m=>m.includes('$(')?m:' fcd_inert_data ');o=o.replace(/<<-?\\s*'(\\w+)'[\\s\\S]*?(?:\\n\\1\\b|$)/g,' fcd_inert_data ');o=o.replace(/<<-?\\s*"?(\\w+)"?[\\s\\S]*?(?:\\n\\1\\b|$)/g,m=>K.test(m)?m:' fcd_inert_data ');const s=o;o=s.replace(/"((?:[^"\\\\]|\\\\.)*)"|'((?:[^'\\\\]|\\\\.)*)'/g,(w,dq,sq,i)=>{const b=dq!==undefined?dq:(sq||'');if(!/\\s/.test(b))return w;if(C.test(s.slice(Math.max(0,i-80),i)))return w;if(dq!==undefined&&K.test(b))return w;return ' fcd_inert_data ';});return o;}`,
112
- `function matchLine(line,rules){const n=line.replace(/\\s+/g,' ').trim();const x=execView(line);const xn=x.replace(/\\s+/g,' ').trim();let warnHit=null;for(const r of rules){try{const t=r.source==='custom'?line:x;const tn=r.source==='custom'?n:xn;if(r.re.test(tn)||r.re.test(t)){if(r.action!=='warn')return r;if(!warnHit)warnHit=r;}}catch{}}return warnHit;}`,
168
+ `// Precedence on one line: block outranks ask, ask outranks warn.`,
169
+ `function matchLine(line,rules){const n=line.replace(/\\s+/g,' ').trim();const x=execView(line);const xn=x.replace(/\\s+/g,' ').trim();let warnHit=null,askHit=null;for(const r of rules){try{const t=r.source==='custom'?line:x;const tn=r.source==='custom'?n:xn;if(r.re.test(tn)||r.re.test(t)){if(r.action==='block')return r;if(r.action==='ask'){if(!askHit)askHit=r;}else if(!warnHit)warnHit=r;}}catch{}}return askHit||warnHit;}`,
113
170
  `function triggerFlush(){try{if(fs.existsSync(NODE_PATH)&&fs.existsSync(CLI_ENTRY)){const c=spawn(NODE_PATH,[CLI_ENTRY,'flush-spool','--heartbeat','true'],{detached:true,stdio:'ignore'});c.unref();}}catch{}}`,
114
- `function spoolEvent(rule,line,decision){try{const ev=line.trim();const evidence=ev.length>180?ev.slice(0,180)+'...':ev;const event={eventId:crypto.randomUUID(),type:'verdict',decision,toolName:'shell_terminal',operation:'shell_command',reason:'Shell guard: '+rule.reason,ruleId:rule.id,category:rule.category,severity:rule.severity,source:rule.source,evidence,occurredAt:new Date().toISOString()};fs.appendFileSync(SPOOL_PATH,JSON.stringify(event)+'\\n',{encoding:'utf8',mode:0o600});triggerFlush();}catch{}}`,
115
- `let argv=process.argv.slice(2);if(argv[0]==='--')argv=argv.slice(1);const line=argv.join(' ');`,
171
+ `// developer: '' | 'confirmed' | 'remembered' | 'declined' | 'timeout' — the terminal "ask" answer, reported with the same facts as an IDE prompt.`,
172
+ `function spoolEvent(rule,line,decision,developer){try{const ev=line.trim();const evidence=ev.length>180?ev.slice(0,180)+'...':ev;let reason='Shell guard: '+rule.reason;if(developer==='confirmed')reason='developer confirmed in terminal prompt — '+reason;else if(developer==='remembered')reason='developer confirmed earlier in this shell (session memory) — '+reason;else if(developer==='declined')reason='developer declined in terminal prompt — '+reason;else if(developer==='timeout')reason='developer confirmation not given (terminal prompt timed out) — '+reason;const event={eventId:crypto.randomUUID(),type:'verdict',decision,toolName:'shell_terminal',operation:'shell_command',reason,ruleId:rule.id,category:rule.category,severity:rule.severity,source:rule.source,evidence,occurredAt:new Date().toISOString()};if(developer){event.approvalScope='developer';if(developer==='confirmed'||developer==='remembered')event.approvedBy='developer';}fs.appendFileSync(SPOOL_PATH,JSON.stringify(event)+'\\n',{encoding:'utf8',mode:0o600});triggerFlush();}catch{}}`,
173
+ `let argv=process.argv.slice(2);`,
174
+ `// --record <confirmed|remembered|declined|timeout> <ruleId> -- <line>: the shell answered an ask prompt; report it and exit.`,
175
+ `if(argv[0]==='--record'){const kind=argv[1],ruleId=argv[2];let rest=argv.slice(3);if(rest[0]==='--')rest=rest.slice(1);const rline=rest.join(' ');try{const{rules}=loadRules();const rule=rules.find(r=>r.id===ruleId)||{id:ruleId,reason:'ask rule '+ruleId,category:'',severity:'',source:'builtin'};spoolEvent(rule,rline,(kind==='confirmed'||kind==='remembered')?'allow':'block',kind);}catch{}process.exit(0);}`,
176
+ `if(argv[0]==='--')argv=argv.slice(1);const line=argv.join(' ');`,
116
177
  `if(!line.trim())process.exit(0);`,
117
178
  `if(process.env.FCD_SHELL_GUARD==='off')process.exit(0);`,
118
179
  `const{mode,rules}=loadRules();const hit=matchLine(line,rules);`,
119
180
  `if(!hit)process.exit(0);`,
120
- `if(mode==='monitor'){console.error('[FullCourtDefense] monitor: would block ['+(hit.severity||'')+'] '+hit.reason+' (rule '+hit.id+')');spoolEvent(hit,line,'warn');process.exit(0);}`,
181
+ `if(mode==='monitor'){console.error('[FullCourtDefense] monitor: '+(hit.action==='ask'?'would ask you to confirm':'would block')+' ['+(hit.severity||'')+'] '+hit.reason+' (rule '+hit.id+')');spoolEvent(hit,line,'warn');process.exit(0);}`,
121
182
  `if(hit.action==='warn'){console.error('[FullCourtDefense] warning ['+(hit.severity||'')+']: '+hit.reason+' (rule '+hit.id+') — allowed by org policy, reported to your security dashboard.');spoolEvent(hit,line,'warn');process.exit(0);}`,
183
+ `if(hit.action==='ask'){`,
184
+ ` // "s = remember for this shell": the shell exports FCD_SHELL_ASK_OK=<ruleId>,<ruleId>... — dies with the shell.`,
185
+ ` const ok=String(process.env.FCD_SHELL_ASK_OK||'').split(',').map(s=>s.trim()).filter(Boolean);`,
186
+ ` if(ok.includes(hit.id)){console.error('[FullCourtDefense] allowed — you confirmed rule '+hit.id+' earlier in this shell.');spoolEvent(hit,line,'allow','remembered');process.exit(0);}`,
187
+ ` // Machine-readable ask line on stdout; the sourcing shell owns the prompt (it has the tty).`,
188
+ ` process.stdout.write('FCD_ASK\\t'+hit.id+'\\t'+(hit.severity||'')+'\\t'+String(hit.reason||'').replace(/[\\t\\n\\r]+/g,' ')+'\\n');`,
189
+ ` process.exit(ASK_CODE);`,
190
+ `}`,
122
191
  `console.error('[FullCourtDefense] BLOCKED ['+(hit.severity||'')+']: '+hit.reason+' (rule '+hit.id+')');`,
123
192
  `console.error('This command was not executed. Reported to your security dashboard.');`,
124
193
  `spoolEvent(hit,line,'block');process.exit(BLOCK_CODE);`,
125
194
  ``,
126
195
  ].join('\n');
127
196
  }
197
+ /**
198
+ * Shared POSIX-shell prompt for an ask hit (sourced by both the bash and zsh
199
+ * guards — plain POSIX/bash syntax, no ZLE). Args: <meta line> <typed line>.
200
+ * Returns 0 = run the command, 1 = do not. Reads ONE key from /dev/tty with a
201
+ * timeout; y = run once, s = run + export FCD_SHELL_ASK_OK for this shell,
202
+ * anything else / timeout / no tty = not executed. Every answer is reported
203
+ * through the checker's --record mode. Fail-strict: an ask that cannot be
204
+ * answered is a block, never a silent allow.
205
+ */
206
+ function buildAskShellFunction(shell) {
207
+ // One key, with timeout: bash `read -n 1 -t`, zsh `read -k 1 -t`.
208
+ const readKey = shell === 'zsh'
209
+ ? `read -k 1 -t ${shellGuard_1.SHELL_ASK_TIMEOUT_SECONDS} __fcd_ans </dev/tty`
210
+ : `IFS= read -r -n 1 -t ${shellGuard_1.SHELL_ASK_TIMEOUT_SECONDS} __fcd_ans </dev/tty`;
211
+ return [
212
+ `__fcd_ask_prompt() {`,
213
+ ` local __fcd_meta="$1" __fcd_line="$2" __fcd_tag __fcd_id __fcd_sev __fcd_reason __fcd_ans=""`,
214
+ ` local __fcd_oldifs="$IFS"; IFS="$(printf '\\t')"; read -r __fcd_tag __fcd_id __fcd_sev __fcd_reason <<< "$__fcd_meta"; IFS="$__fcd_oldifs"`,
215
+ ` printf '[FullCourtDefense] ASK [%s]: %s (rule %s)\\n' "$__fcd_sev" "$__fcd_reason" "$__fcd_id" >&2`,
216
+ ` # Only a shell with a REAL keyboard may be asked: stdin must be this shell's`,
217
+ ` # terminal. A spawned / piped / embedded shell (stdin not a tty) never opens`,
218
+ ` # /dev/tty — on MSYS that can be some OTHER window's console — it blocks now.`,
219
+ ` if [ -t 0 ] && [ -r /dev/tty ] && [ -w /dev/tty ]; then`,
220
+ ` printf 'Run anyway? [y] yes [s] yes, remember for this shell [N] no (%ss): ' "${shellGuard_1.SHELL_ASK_TIMEOUT_SECONDS}" >/dev/tty`,
221
+ ` if ${readKey}; then :; else __fcd_ans="__timeout__"; fi`,
222
+ ` printf '\\n' >/dev/tty`,
223
+ ` else`,
224
+ ` __fcd_ans="__notty__"`,
225
+ ` printf '[FullCourtDefense] no terminal to ask on — not executed.\\n' >&2`,
226
+ ` fi`,
227
+ ` case "$__fcd_ans" in`,
228
+ ` y|Y)`,
229
+ ` printf '[FullCourtDefense] yes — reported to your security dashboard as allowed by you.\\n' >&2`,
230
+ ` __fcd_run_checker --record confirmed "$__fcd_id" -- "$__fcd_line" >/dev/null 2>&1`,
231
+ ` return 0 ;;`,
232
+ ` s|S)`,
233
+ ` export FCD_SHELL_ASK_OK="\${FCD_SHELL_ASK_OK:+$FCD_SHELL_ASK_OK,}$__fcd_id"`,
234
+ ` printf '[FullCourtDefense] yes — remembered for this shell. Reported to your security dashboard as allowed by you.\\n' >&2`,
235
+ ` __fcd_run_checker --record confirmed "$__fcd_id" -- "$__fcd_line" >/dev/null 2>&1`,
236
+ ` return 0 ;;`,
237
+ ` __timeout__)`,
238
+ ` printf '[FullCourtDefense] no answer — this command was not executed (timeout). Reported to your security dashboard.\\n' >&2`,
239
+ ` __fcd_run_checker --record timeout "$__fcd_id" -- "$__fcd_line" >/dev/null 2>&1`,
240
+ ` return 1 ;;`,
241
+ ` *)`,
242
+ ` printf '[FullCourtDefense] no — this command was not executed (declined). Reported to your security dashboard.\\n' >&2`,
243
+ ` __fcd_run_checker --record declined "$__fcd_id" -- "$__fcd_line" >/dev/null 2>&1`,
244
+ ` return 1 ;;`,
245
+ ` esac`,
246
+ `}`,
247
+ ].join('\n');
248
+ }
249
+ /**
250
+ * How the shell scripts invoke the Node checker. On Git Bash (MSYS2 runtime on
251
+ * Windows) node.exe is a NATIVE program, so the runtime would rewrite any
252
+ * argument that looks like a POSIX path (e.g. the typed line "/usr/bin/rm -rf /"
253
+ * or "/dev/tcp/...") into a Windows path before node sees it — disable that for
254
+ * this one call. Both variables are inert on real macOS/Linux shells.
255
+ */
256
+ function buildRunCheckerFunction() {
257
+ return [
258
+ `__fcd_run_checker() {`,
259
+ ` MSYS_NO_PATHCONV=1 MSYS2_ARG_CONV_EXCL='*' "$FCD_GUARD_NODE" "$FCD_GUARD_JS" "$@"`,
260
+ `}`,
261
+ ].join('\n');
262
+ }
128
263
  /** zsh guard — sourced ONLY from .zshrc (contains zsh-specific ZLE syntax). */
129
264
  function buildGuardZsh(nodePath) {
130
265
  return [
@@ -135,8 +270,12 @@ function buildGuardZsh(nodePath) {
135
270
  ``,
136
271
  `case $- in *i*) ;; *) return 0 2>/dev/null || true ;; esac`,
137
272
  ``,
138
- `FCD_GUARD_NODE=${shSingleQuote(nodePath)}`,
139
- `FCD_GUARD_JS=${shSingleQuote(GUARD_JS_PATH)}`,
273
+ `FCD_GUARD_NODE=${shSingleQuote(shellPath(nodePath))}`,
274
+ `FCD_GUARD_JS=${shSingleQuote(shellPath(GUARD_JS_PATH))}`,
275
+ ``,
276
+ buildRunCheckerFunction(),
277
+ ``,
278
+ buildAskShellFunction('zsh'),
140
279
  ``,
141
280
  `# Inspect the WHOLE typed line against every rule before accepting it`,
142
281
  `# (command-agnostic: catches any command, pipes, and chained segments).`,
@@ -145,12 +284,23 @@ function buildGuardZsh(nodePath) {
145
284
  ` local __fcd_line=$BUFFER`,
146
285
  ` if [[ -n "$__fcd_line" && "\${FCD_SHELL_GUARD:-}" != "off" && -x "$FCD_GUARD_NODE" && -f "$FCD_GUARD_JS" ]]; then`,
147
286
  ` local __fcd_out`,
148
- ` __fcd_out="$("$FCD_GUARD_NODE" "$FCD_GUARD_JS" -- "$__fcd_line" 2>&1)"`,
287
+ ` __fcd_out="$(__fcd_run_checker -- "$__fcd_line" 2>&1)"`,
149
288
  ` local __fcd_rc=$?`,
150
- ` if [[ $__fcd_rc -eq 97 ]]; then`,
289
+ ` if [[ $__fcd_rc -eq ${BLOCK_CODE} ]]; then`,
151
290
  ` zle -M -- "$__fcd_out"`,
152
291
  ` return`,
153
292
  ` fi`,
293
+ ` if [[ $__fcd_rc -eq ${exports.ASK_CODE} ]]; then`,
294
+ ` # Ask the human: leave ZLE for the prompt, then run or drop the line.`,
295
+ ` zle -I`,
296
+ ` if __fcd_ask_prompt "$__fcd_out" "$__fcd_line"; then`,
297
+ ` zle .accept-line`,
298
+ ` else`,
299
+ ` BUFFER=""`,
300
+ ` zle .accept-line`,
301
+ ` fi`,
302
+ ` return`,
303
+ ` fi`,
154
304
  ` [[ -n "$__fcd_out" ]] && print -u2 -r -- "$__fcd_out"`,
155
305
  ` fi`,
156
306
  ` zle .accept-line`,
@@ -182,10 +332,14 @@ function buildGuardBash(nodePath) {
182
332
  ``,
183
333
  `case $- in *i*) ;; *) return 0 2>/dev/null || true ;; esac`,
184
334
  ``,
185
- `FCD_GUARD_NODE=${shSingleQuote(nodePath)}`,
186
- `FCD_GUARD_JS=${shSingleQuote(GUARD_JS_PATH)}`,
335
+ `FCD_GUARD_NODE=${shSingleQuote(shellPath(nodePath))}`,
336
+ `FCD_GUARD_JS=${shSingleQuote(shellPath(GUARD_JS_PATH))}`,
187
337
  `__fcd_last_line=""`,
188
338
  ``,
339
+ buildRunCheckerFunction(),
340
+ ``,
341
+ buildAskShellFunction('bash'),
342
+ ``,
189
343
  `# Run the shared checker on one full line. Returns 1 to cancel (extdebug).`,
190
344
  `__fcd_check_line() {`,
191
345
  ` local __fcd_line="$1"`,
@@ -193,10 +347,14 @@ function buildGuardBash(nodePath) {
193
347
  ` [ "\${FCD_SHELL_GUARD:-}" = "off" ] && return 0`,
194
348
  ` if [ ! -x "$FCD_GUARD_NODE" ] || [ ! -f "$FCD_GUARD_JS" ]; then return 0; fi`,
195
349
  ` local __fcd_out __fcd_rc`,
196
- ` __fcd_out="$("$FCD_GUARD_NODE" "$FCD_GUARD_JS" -- "$__fcd_line" 2>&1)"`,
350
+ ` __fcd_out="$(__fcd_run_checker -- "$__fcd_line" 2>&1)"`,
197
351
  ` __fcd_rc=$?`,
352
+ ` if [ "$__fcd_rc" -eq ${exports.ASK_CODE} ]; then`,
353
+ ` __fcd_ask_prompt "$__fcd_out" "$__fcd_line" && return 0`,
354
+ ` return 1`,
355
+ ` fi`,
198
356
  ` [ -n "$__fcd_out" ] && printf '%s\\n' "$__fcd_out" >&2`,
199
- ` [ "$__fcd_rc" -eq 97 ] && return 1`,
357
+ ` [ "$__fcd_rc" -eq ${BLOCK_CODE} ] && return 1`,
200
358
  ` return 0`,
201
359
  `}`,
202
360
  ``,
@@ -222,13 +380,18 @@ function buildGuardBash(nodePath) {
222
380
  ``,
223
381
  ].join('\n');
224
382
  }
225
- /** rc files we wire the guard into. .bash_profile only when it already exists. */
383
+ /**
384
+ * rc files we wire the guard into. .bash_profile only when it already exists.
385
+ * Windows (Git Bash) is bash-only: Git for Windows' login shell reads
386
+ * ~/.bash_profile, and auto-creates one that sources ~/.bashrc when only the
387
+ * latter exists — so ~/.bashrc is the right home there too.
388
+ */
226
389
  function resolveRcTargets() {
227
390
  const home = os.homedir();
228
- const out = [
229
- { shell: 'zsh', rcPath: path.join(home, '.zshrc'), guardPath: GUARD_ZSH_PATH },
230
- { shell: 'bash', rcPath: path.join(home, '.bashrc'), guardPath: GUARD_BASH_PATH },
231
- ];
391
+ const out = [];
392
+ if (process.platform !== 'win32')
393
+ out.push({ shell: 'zsh', rcPath: path.join(home, '.zshrc'), guardPath: GUARD_ZSH_PATH });
394
+ out.push({ shell: 'bash', rcPath: path.join(home, '.bashrc'), guardPath: GUARD_BASH_PATH });
232
395
  const bashProfile = path.join(home, '.bash_profile');
233
396
  if (fs.existsSync(bashProfile))
234
397
  out.push({ shell: 'bash', rcPath: bashProfile, guardPath: GUARD_BASH_PATH });
@@ -272,7 +435,7 @@ function removeSnippetFromRc(rcPath) {
272
435
  }
273
436
  /** Read-only probe. Never throws. */
274
437
  function getPosixShellGuardStatus() {
275
- if (!isPosixPlatform()) {
438
+ if (!posixGuardSupported()) {
276
439
  return { supported: false, installed: false, rcFiles: [], rulesPresent: false };
277
440
  }
278
441
  try {
@@ -310,7 +473,7 @@ function getPosixShellGuardStatus() {
310
473
  /** Lightweight boolean for the telemetry heartbeat / discovery report. */
311
474
  function isPosixShellGuardInstalled() {
312
475
  try {
313
- if (!isPosixPlatform())
476
+ if (!posixGuardSupported())
314
477
  return false;
315
478
  if (!fs.existsSync(GUARD_ZSH_PATH) && !fs.existsSync(GUARD_BASH_PATH))
316
479
  return false;
@@ -327,17 +490,18 @@ function isPosixShellGuardInstalled() {
327
490
  return false;
328
491
  }
329
492
  }
330
- /** `fullcourtdefense install-shell-guard` on macOS/Linux — enable the bash/zsh guard. */
493
+ /** `fullcourtdefense install-shell-guard` on macOS/Linux (bash/zsh) or Windows with Git Bash (bash). */
331
494
  async function installPosixShellGuardCommand(args = {}) {
332
- if (!isPosixPlatform()) {
333
- console.log('The POSIX shell guard supports macOS and Linux (bash/zsh).');
495
+ if (!posixGuardSupported()) {
496
+ console.log('The POSIX shell guard supports macOS and Linux (bash/zsh), and Git Bash on Windows.');
334
497
  return;
335
498
  }
336
499
  const rules = (0, shellGuard_1.writeShellGuardRules)();
337
500
  const nodePath = process.execPath;
338
- const cliEntry = process.argv[1] || '';
501
+ const cliEntry = (0, shellGuard_1.guardCliEntry)();
339
502
  fs.writeFileSync(GUARD_JS_PATH, buildGuardJs(nodePath, cliEntry), { encoding: 'utf8', mode: 0o600 });
340
- fs.writeFileSync(GUARD_ZSH_PATH, buildGuardZsh(nodePath), { encoding: 'utf8', mode: 0o600 });
503
+ if (process.platform !== 'win32')
504
+ fs.writeFileSync(GUARD_ZSH_PATH, buildGuardZsh(nodePath), { encoding: 'utf8', mode: 0o600 });
341
505
  fs.writeFileSync(GUARD_BASH_PATH, buildGuardBash(nodePath), { encoding: 'utf8', mode: 0o600 });
342
506
  console.log(`Guard rules written: ${rules.rules.length} rule(s), mode "${rules.mode}".`);
343
507
  if (args.profiles === 'false') {
@@ -358,14 +522,14 @@ async function installPosixShellGuardCommand(args = {}) {
358
522
  console.log(`\x1b[33m⚠ ${target.shell}: could not update ${target.rcPath} (${error instanceof Error ? error.message : String(error)})\x1b[0m`);
359
523
  }
360
524
  }
361
- console.log('\x1b[2mTakes effect in NEW terminals (or run: source ~/.zshrc). Dangerous typed commands are blocked (or warned in monitor mode) and reported to the fleet dashboard.\x1b[0m');
525
+ console.log('\x1b[2mTakes effect in NEW terminals (or run: source ~/.zshrc / ~/.bashrc). Dangerous typed commands are blocked, asked about, or warned (per rule and machine mode) and reported to the fleet dashboard.\x1b[0m');
362
526
  if (wired === 0) {
363
527
  console.log('\x1b[33m⚠ Guard scripts written but no rc file was newly wired — check the entries above.\x1b[0m');
364
528
  }
365
529
  }
366
- /** `fullcourtdefense uninstall-shell-guard` on macOS/Linux — remove the guard. */
530
+ /** `fullcourtdefense uninstall-shell-guard` on macOS/Linux (or Git Bash on Windows) — remove the guard. */
367
531
  async function uninstallPosixShellGuardCommand() {
368
- if (!isPosixPlatform()) {
532
+ if (!posixGuardSupported()) {
369
533
  console.log('Nothing to remove on this OS.');
370
534
  return;
371
535
  }
@@ -400,9 +564,10 @@ function refreshPosixShellGuardRules() {
400
564
  // logic of the build that wrote them — rewrite when the stamp is stale.
401
565
  if ((0, shellGuard_1.guardArtifactStale)(GUARD_JS_PATH)) {
402
566
  const nodePath = process.execPath;
403
- const cliEntry = process.argv[1] || '';
567
+ const cliEntry = (0, shellGuard_1.guardCliEntry)();
404
568
  (0, shellGuard_1.writeGuardArtifact)(GUARD_JS_PATH, buildGuardJs(nodePath, cliEntry), 0o600);
405
- (0, shellGuard_1.writeGuardArtifact)(GUARD_ZSH_PATH, buildGuardZsh(nodePath), 0o600);
569
+ if (process.platform !== 'win32')
570
+ (0, shellGuard_1.writeGuardArtifact)(GUARD_ZSH_PATH, buildGuardZsh(nodePath), 0o600);
406
571
  (0, shellGuard_1.writeGuardArtifact)(GUARD_BASH_PATH, buildGuardBash(nodePath), 0o600);
407
572
  }
408
573
  }
@@ -27,11 +27,40 @@ export interface ShellGuardRule {
27
27
  /**
28
28
  * Per-rule enforcement action from the console (Local Safety). Absent =
29
29
  * 'block'. 'warn' → yellow notice, the command still runs (like monitor
30
- * mode, but for this one rule). 'mask' is meaningless for a typed command
31
- * and degrades to block.
30
+ * mode, but for this one rule). 'ask' → the guard pauses the typed line and
31
+ * asks the human at the keyboard (y / N / s = remember for this shell);
32
+ * declined, timed out, or no answerable terminal → block, never a silent
33
+ * allow. 'mask' is meaningless for a typed command and degrades to block.
32
34
  */
33
- action?: 'block' | 'warn';
35
+ action?: ShellGuardAction;
34
36
  }
37
+ export type ShellGuardAction = 'block' | 'warn' | 'ask';
38
+ /**
39
+ * The CLI entry a planted guard should spawn for `flush-spool`. Resolved from
40
+ * THIS module's location (dist/commands -> dist/index.js), never from
41
+ * process.argv[1]: when the guard installer runs embedded in another process
42
+ * (daemon, test drill, host script), argv[1] is the HOST — baking it in would
43
+ * make every spooled event re-launch the host, hidden, recursively (a fork
44
+ * bomb that has frozen a developer machine). argv[1] is only a last resort
45
+ * when the packaged entry is not on disk.
46
+ */
47
+ export declare function guardCliEntry(): string;
48
+ /** Seconds a terminal "ask" prompt waits for an answer before it counts as declined. */
49
+ export declare const SHELL_ASK_TIMEOUT_SECONDS = 30;
50
+ /** How long "s = remember for this shell" lasts (capped — a forgotten window must not last forever). */
51
+ export declare const SHELL_ASK_REMEMBER_HOURS = 12;
52
+ /** Normalize a console action into what a typed-command guard can enforce. */
53
+ export declare function toShellGuardAction(raw: unknown): ShellGuardAction;
54
+ /**
55
+ * Leading command names that anchor a built-in rule — what a first-token hook
56
+ * (cmd.exe doskey) can key on. Derived from the shared tables so a new rule
57
+ * cannot be added without its command name reaching the cmd guard's index.
58
+ */
59
+ /** Every built-in terminal rule id, ignoring console toggles (parity tests, docs). */
60
+ export declare function builtinRuleIds(): string[];
61
+ /** Every built-in terminal rule, all switched ON with default action (parity tests). */
62
+ export declare function builtinRulesAllOn(): ShellGuardRule[];
63
+ export declare function builtinRuleCommandNames(): string[];
35
64
  interface ShellGuardRulesFile {
36
65
  updatedAt: string;
37
66
  mode: 'block' | 'monitor';