fullcourtdefense-cli 1.27.4 → 1.28.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,3 +1,4 @@
1
+ export declare function buildGuardJs(nodePath: string, cliEntry: string): string;
1
2
  export interface CmdGuardStatus {
2
3
  supported: boolean;
3
4
  installed: boolean;
@@ -33,6 +33,7 @@ var __importStar = (this && this.__importStar) || (function () {
33
33
  };
34
34
  })();
35
35
  Object.defineProperty(exports, "__esModule", { value: true });
36
+ exports.buildGuardJs = buildGuardJs;
36
37
  exports.isCmdGuardInstalled = isCmdGuardInstalled;
37
38
  exports.getCmdGuardStatus = getCmdGuardStatus;
38
39
  exports.installCmdGuardCommand = installCmdGuardCommand;
@@ -127,16 +128,25 @@ function buildGuardJs(nodePath, cliEntry) {
127
128
  `const SPOOL_PATH=${JSON.stringify(path.join(os.homedir(), '.fullcourtdefense-spool.jsonl'))};`,
128
129
  `const NODE_PATH=${JSON.stringify(nodePath)};`,
129
130
  `const CLI_ENTRY=${JSON.stringify(cliEntry)};`,
130
- `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:[]};}}`,
131
+ `const ASK_TIMEOUT_S=${shellGuard_1.SHELL_ASK_TIMEOUT_SECONDS};const ASK_REMEMBER_MS=${shellGuard_1.SHELL_ASK_REMEMBER_HOURS}*3600*1000;`,
132
+ `// "s = remember for this cmd window": keyed by the parent cmd.exe pid (doskey runs us directly under it); dies with the window, capped in time.`,
133
+ `const ASK_MEMORY_PATH=${JSON.stringify(path.join(os.homedir(), '.fullcourtdefense'))}+'/cmd-ask-'+process.ppid+'.json';`,
134
+ `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:[]};}}`,
131
135
  `// Execution view (aligned with the CLI's stripInertDataSegments): quoted spans containing whitespace are`,
132
136
  `// prose (inert) unless in a code position — interpreter -c/-Command flags, remote executors (ssh/wsl/docker`,
133
137
  `// exec), $( ) substitution, or real backtick command substitution (>=2 chars; a 1-char pair is a PS escape).`,
134
138
  `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;}`,
135
- `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;}`,
139
+ `// Precedence on one line: block outranks ask, ask outranks warn.`,
140
+ `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;}`,
136
141
  `// Flush is DETACHED (spawn+unref, matching the posix guard) — a spawnSync here`,
137
142
  `// made the user's terminal wait for a full CLI process INCLUDING the network`,
138
143
  `// flush after every finding. Monitor mode must never add synchronous latency.`,
139
- `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:'cmd_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});if(fs.existsSync(NODE_PATH)&&fs.existsSync(CLI_ENTRY)){const c=spawn(NODE_PATH,[CLI_ENTRY,'flush-spool','--heartbeat','true'],{detached:true,stdio:'ignore',windowsHide:true});c.unref();}}catch{}}`,
144
+ `// developer: '' | 'confirmed' | 'remembered' | 'declined' | 'timeout' — the terminal "ask" answer, reported with the same facts as an IDE prompt.`,
145
+ `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 cmd window (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:'cmd_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});if(fs.existsSync(NODE_PATH)&&fs.existsSync(CLI_ENTRY)){const c=spawn(NODE_PATH,[CLI_ENTRY,'flush-spool','--heartbeat','true'],{detached:true,stdio:'ignore',windowsHide:true});c.unref();}}catch{}}`,
146
+ `function askRemembered(ruleId){try{const m=JSON.parse(fs.readFileSync(ASK_MEMORY_PATH,'utf8'));const until=m&&m[ruleId];return typeof until==='number'&&until>Date.now();}catch{return false;}}`,
147
+ `function rememberAsk(ruleId){try{let m={};try{m=JSON.parse(fs.readFileSync(ASK_MEMORY_PATH,'utf8'))||{};}catch{}m[ruleId]=Date.now()+ASK_REMEMBER_MS;fs.mkdirSync(path.dirname(ASK_MEMORY_PATH),{recursive:true});fs.writeFileSync(ASK_MEMORY_PATH,JSON.stringify(m),{encoding:'utf8',mode:0o600});}catch{}}`,
148
+ `// One key via choice.exe (ships with Windows): Y run once, S run + remember for this window, N / timeout not executed. FCD_ASK_TEST_ANSWER overrides for the drill.`,
149
+ `function askDeveloper(hit){const forced=process.env.FCD_ASK_TEST_ANSWER;if(forced)return forced;try{const r=spawnSync('choice',['/c','YSN','/n','/t',String(ASK_TIMEOUT_S),'/d','N','/m','Run anyway? [Y] yes [S] yes, remember for this cmd window [N] no ('+ASK_TIMEOUT_S+'s):'],{stdio:'inherit',windowsHide:true});if(r.status===1)return 'y';if(r.status===2)return 's';if(r.status===3)return 'n';}catch{}return 'n';}`,
140
150
  `function delegate(args){const r=spawnSync(process.env.ComSpec||'cmd.exe',['/d','/c',...args],{stdio:'inherit',windowsHide:true});process.exit(typeof r.status==='number'?r.status:1);}`,
141
151
  `const args=process.argv.slice(2);if(!args.length)process.exit(0);if(process.env.FCD_CMD_GUARD==='off')delegate(args);`,
142
152
  // TOP-LEVEL FAIL-OPEN: any unexpected exception in rule loading/matching
@@ -144,8 +154,15 @@ function buildGuardJs(nodePath, cliEntry) {
144
154
  // delegate. A stack trace or nonzero exit here IS an outage for the shell.
145
155
  `const line=args.join(' ');let mode='monitor';let hit=null;try{const r=loadRules();mode=r.mode;hit=matchLine(line,r.rules);}catch{hit=null;}`,
146
156
  `if(!hit)delegate(args);`,
147
- `if(mode==='monitor'){console.error('[FullCourtDefense] monitor: would block ['+(hit.severity||'')+'] '+hit.reason+' (rule '+hit.id+')');spoolEvent(hit,line,'warn');delegate(args);}`,
157
+ `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');delegate(args);}`,
148
158
  `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');delegate(args);}`,
159
+ `if(hit.action==='ask'){`,
160
+ ` if(askRemembered(hit.id)){console.error('[FullCourtDefense] allowed — you confirmed rule '+hit.id+' earlier in this cmd window.');spoolEvent(hit,line,'allow','remembered');delegate(args);}`,
161
+ ` console.error('[FullCourtDefense] ASK ['+(hit.severity||'')+']: '+hit.reason+' (rule '+hit.id+')');`,
162
+ ` const a=askDeveloper(hit);`,
163
+ ` if(a==='y'||a==='s'){if(a==='s')rememberAsk(hit.id);console.error('[FullCourtDefense] yes'+(a==='s'?' — remembered for this cmd window.':'')+' Reported to your security dashboard as allowed by you.');spoolEvent(hit,line,'allow','confirmed');delegate(args);}`,
164
+ ` const outcome=a==='t'?'timeout':'declined';console.error('[FullCourtDefense] '+(outcome==='timeout'?'no answer':'no')+' — this command was not executed ('+outcome+'). Reported to your security dashboard.');spoolEvent(hit,line,'block',outcome);process.exit(1);`,
165
+ `}`,
149
166
  `console.error('[FullCourtDefense] BLOCKED ['+(hit.severity||'')+']: '+hit.reason+' (rule '+hit.id+')');`,
150
167
  `console.error('This command was not executed. Reported to your security dashboard.');`,
151
168
  `spoolEvent(hit,line,'block');process.exit(1);`,
@@ -263,7 +280,7 @@ async function installCmdGuardCommand(_args = {}) {
263
280
  }
264
281
  const rules = (0, shellGuard_1.writeShellGuardRules)();
265
282
  const nodePath = process.execPath;
266
- const cliEntry = process.argv[1] || '';
283
+ const cliEntry = (0, shellGuard_1.guardCliEntry)();
267
284
  fs.writeFileSync(GUARD_JS_PATH, buildGuardJs(nodePath, cliEntry), 'utf8');
268
285
  // Macro file BEFORE the bat: the bat refuses to register macros without it,
269
286
  // so this order can never leave a window where cmd.exe sources a bat whose
@@ -324,7 +341,7 @@ function refreshCmdGuardRules() {
324
341
  // registering no macros at all. This is the only path that repairs that.
325
342
  if ((0, shellGuard_1.guardArtifactStale)(GUARD_JS_PATH) || !fs.existsSync(MACRO_FILE_PATH)) {
326
343
  const nodePath = process.execPath;
327
- (0, shellGuard_1.writeGuardArtifact)(GUARD_JS_PATH, buildGuardJs(nodePath, process.argv[1] || ''));
344
+ (0, shellGuard_1.writeGuardArtifact)(GUARD_JS_PATH, buildGuardJs(nodePath, (0, shellGuard_1.guardCliEntry)()));
328
345
  (0, shellGuard_1.writeGuardArtifact)(MACRO_FILE_PATH, buildMacroFile(nodePath));
329
346
  (0, shellGuard_1.writeGuardArtifact)(AUTORUN_BAT_PATH, buildAutorunBat(nodePath));
330
347
  }
@@ -126,10 +126,12 @@ async function installAllCommand(args, config) {
126
126
  }
127
127
  }
128
128
  // Interactive macOS/Linux guard — same rules, zsh accept-line widget + bash
129
- // function wrappers, from the local cache (no admin, no network hot path).
130
- // Gated to darwin/linux so Windows install-all behavior is unchanged.
131
- if ((process.platform === 'darwin' || process.platform === 'linux') && args.posixGuard !== 'false') {
132
- console.log('\n\x1b[1mInstalling interactive shell guard (bash + zsh)…\x1b[0m');
129
+ // DEBUG-trap hook, from the local cache (no admin, no network hot path). On
130
+ // Windows the same bash guard covers Git Bash (MSYS2) when it is installed.
131
+ if ((0, posixShellGuard_1.posixGuardSupported)() && args.posixGuard !== 'false') {
132
+ console.log(process.platform === 'win32'
133
+ ? '\n\x1b[1mGit Bash detected — installing interactive shell guard (bash)…\x1b[0m'
134
+ : '\n\x1b[1mInstalling interactive shell guard (bash + zsh)…\x1b[0m');
133
135
  try {
134
136
  await (0, posixShellGuard_1.installPosixShellGuardCommand)();
135
137
  }
@@ -67,6 +67,7 @@ const policyGateHealth_1 = require("../policyGateHealth");
67
67
  const actionPolicyEngine_1 = require("../actionPolicyEngine");
68
68
  const machineIdentity_1 = require("../machineIdentity");
69
69
  const backupManifest_1 = require("../backupManifest");
70
+ const devConfirm_1 = require("../devConfirm");
70
71
  const DEFAULT_API_URL = 'https://api.fullcourtdefense.ai';
71
72
  const MANAGED_SERVER_NAME = 'agentguard-gateway';
72
73
  const INSTALL_GATEWAY_ARGS = { includeShieldKey: false };
@@ -876,6 +877,8 @@ function blockError(message) {
876
877
  function isBlockError(err) {
877
878
  return err instanceof Error && err.fcdDeliberateBlock === true;
878
879
  }
880
+ /** How long an MCP elicitation ("Ask the developer" dialog in the client) waits before it counts as declined. */
881
+ const ELICITATION_TIMEOUT_MS = 5 * 60_000;
879
882
  class McpGatewayServer {
880
883
  gatewayConfig;
881
884
  downstreamSpec;
@@ -883,6 +886,20 @@ class McpGatewayServer {
883
886
  downstreamReady;
884
887
  buffer = '';
885
888
  api;
889
+ /**
890
+ * The MCP client declared `capabilities.elicitation` on initialize — it can
891
+ * show the user a server-initiated question. That is the only channel a stdio
892
+ * proxy has to "Ask the developer"; without it an ask rule fails strict.
893
+ */
894
+ clientSupportsElicitation = false;
895
+ /** Server -> client requests (elicitation) awaiting the client's response, by id. */
896
+ clientPending = new Map();
897
+ clientRequestSeq = 0;
898
+ /**
899
+ * "Remember for this session" scope when the client passes no session id: the
900
+ * gateway process lives exactly as long as the client's MCP session does.
901
+ */
902
+ gatewaySessionId = `gateway-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`;
886
903
  constructor(gatewayConfig, downstreamSpec) {
887
904
  this.gatewayConfig = gatewayConfig;
888
905
  this.downstreamSpec = downstreamSpec;
@@ -924,10 +941,29 @@ class McpGatewayServer {
924
941
  // id may legally be 0 or "" — only a genuinely ABSENT id marks a notification.
925
942
  if ((message.id === undefined || message.id === null) && message.method?.startsWith('notifications/'))
926
943
  return;
944
+ // A RESPONSE from the client to one of OUR requests (elicitation): no
945
+ // method, an id we issued. Route it to the waiter instead of the -32601 path.
946
+ if (!message.method && message.id !== undefined && message.id !== null && ('result' in message || 'error' in message)) {
947
+ const waiter = this.clientPending.get(String(message.id));
948
+ if (waiter) {
949
+ clearTimeout(waiter.timer);
950
+ this.clientPending.delete(String(message.id));
951
+ waiter.resolve(message);
952
+ }
953
+ return;
954
+ }
927
955
  try {
928
956
  if (message.method === 'initialize') {
957
+ const caps = message.params?.capabilities;
958
+ this.clientSupportsElicitation = Boolean(caps && typeof caps === 'object' && caps.elicitation !== undefined && caps.elicitation !== null);
959
+ // Echo the client's own protocol version (it necessarily supports it);
960
+ // elicitation is a 2025-06-18+ feature, but the capability flag — not
961
+ // the version string — is what tells us the client can show a dialog.
962
+ const requested = typeof message.params?.protocolVersion === 'string' && /^\d{4}-\d{2}-\d{2}$/.test(message.params.protocolVersion)
963
+ ? message.params.protocolVersion
964
+ : '2024-11-05';
929
965
  this.respond(message.id, {
930
- protocolVersion: '2024-11-05',
966
+ protocolVersion: requested,
931
967
  capabilities: { tools: {} },
932
968
  serverInfo: { name: 'agentguard-mcp-gateway', version: '0.1.0' },
933
969
  });
@@ -1099,12 +1135,77 @@ class McpGatewayServer {
1099
1135
  this.spoolLocalFinding(warning, toolName, operation, 'warn');
1100
1136
  process.stderr.write(`FullCourtDefense warning (${warning.itemId}): ${warning.reason}\n`);
1101
1137
  }
1102
- // The gateway is a stdio proxy with no developer prompt of its own — an
1103
- // "Ask the developer" rule fails strict here (block), never a silent allow.
1104
- const localBlock = requestOutcome.blockingFinding || requestOutcome.askFinding;
1138
+ // "Ask the developer" rule with no hard block on the same call: ask the
1139
+ // human through the MCP client (elicitation) when the client can show a
1140
+ // dialog and a human is present. Session memory reuses the IDE hook's
1141
+ // ledger (same tool + rule => remembered). Decline, cancel, timeout, a
1142
+ // client without elicitation, or no human => block, never a silent allow.
1143
+ let localBlock = requestOutcome.blockingFinding || requestOutcome.askFinding;
1144
+ let askNote = '';
1145
+ if (!requestOutcome.blockingFinding && requestOutcome.askFinding && !reportOnlyMode) {
1146
+ const ask = requestOutcome.askFinding;
1147
+ const origin = ask.source === 'custom' ? 'org custom rule' : 'built-in Local Safety rule';
1148
+ const rememberScope = { toolName, operation: operation || (0, actionPolicyEngine_1.inferToolContext)(toolName, toolArgs).operation, policyId: `local-safety:${ask.itemId}`, matchedRule: ask.ruleId };
1149
+ const sessionId = runtimeSessionId() || this.gatewaySessionId;
1150
+ const remembered = (0, devConfirm_1.isConfirmationRemembered)(rememberScope, sessionId);
1151
+ if (remembered) {
1152
+ (0, telemetry_1.spoolEvent)({
1153
+ decision: 'allow', toolName, operation: rememberScope.operation,
1154
+ reason: `developer confirmed earlier this session (session memory) — ${ask.reason}`,
1155
+ ruleId: ask.ruleId, category: ask.category, categoryId: ask.categoryId, itemId: ask.itemId, source: ask.source,
1156
+ evidence: ask.evidence, policyHash: ask.policyHash, offlineEnforced: true,
1157
+ approvalScope: 'developer', approvedBy: 'developer',
1158
+ });
1159
+ (0, telemetry_1.triggerFlush)(false);
1160
+ localBlock = undefined;
1161
+ }
1162
+ else if (this.clientSupportsElicitation && (0, devConfirm_1.isHumanPresent)()) {
1163
+ const digest = (0, devConfirm_1.actionDigest)(toolArgs);
1164
+ const marker = (0, devConfirm_1.raiseDeveloperConfirmation)({
1165
+ event: 'mcp_gateway', toolName, operation: rememberScope.operation, argsDigest: digest,
1166
+ policyId: rememberScope.policyId, policyName: `Local Safety: ${ask.itemId}`, matchedRule: ask.ruleId,
1167
+ sessionId, remember: 'none',
1168
+ });
1169
+ process.stderr.write(`FullCourtDefense asking the developer to confirm ${toolName}: ${ask.reason} (${origin} "${ask.itemId}", ${ask.ruleId})\n`);
1170
+ const answer = await this.elicitDeveloperConfirmation({ toolName, reason: ask.reason, ruleLabel: `${origin} "${ask.itemId}" (${ask.ruleId})`, evidence: ask.evidence });
1171
+ if (answer.answer === 'accept') {
1172
+ // Settle through the shared ledger so "remember for this session" is
1173
+ // recorded exactly like an IDE approval would be.
1174
+ if (answer.remember)
1175
+ (0, devConfirm_1.raiseDeveloperConfirmation)({ ...marker, remember: 'session' });
1176
+ (0, devConfirm_1.settleDeveloperConfirmation)('mcp_gateway', toolName, digest);
1177
+ (0, telemetry_1.spoolEvent)({
1178
+ decision: 'allow', toolName, operation: rememberScope.operation,
1179
+ reason: `developer confirmed in MCP client prompt${answer.remember ? ' (remembered for this session)' : ''} — ${ask.reason}`,
1180
+ ruleId: ask.ruleId, category: ask.category, categoryId: ask.categoryId, itemId: ask.itemId, source: ask.source,
1181
+ evidence: ask.evidence, policyHash: ask.policyHash, offlineEnforced: true,
1182
+ approvalScope: 'developer', approvedBy: 'developer', confirmLatencyMs: answer.latencyMs,
1183
+ });
1184
+ (0, telemetry_1.triggerFlush)(false);
1185
+ localBlock = undefined;
1186
+ }
1187
+ else {
1188
+ (0, devConfirm_1.settleDeveloperConfirmation)('mcp_gateway', toolName, digest);
1189
+ const how = answer.answer === 'timeout' ? 'developer confirmation not given (prompt timed out)' : 'developer declined in MCP client prompt';
1190
+ (0, telemetry_1.spoolEvent)({
1191
+ decision: 'block', toolName, operation: rememberScope.operation,
1192
+ reason: `${how} — ${ask.reason}`,
1193
+ ruleId: ask.ruleId, category: ask.category, categoryId: ask.categoryId, itemId: ask.itemId, source: ask.source,
1194
+ evidence: ask.evidence, policyHash: ask.policyHash, offlineEnforced: true,
1195
+ approvalScope: 'developer', confirmLatencyMs: answer.latencyMs,
1196
+ });
1197
+ (0, telemetry_1.triggerFlush)(true);
1198
+ throw blockError(`${ask.reason} (${origin} "${ask.itemId}", ${ask.ruleId}: ${ask.evidence}) — ${answer.answer === 'timeout' ? 'you did not answer the confirmation prompt in time' : 'you declined the confirmation prompt'}, so this tool call was not executed. Logged to your org's console. This is a security policy decision, not a transient error — do not retry.`);
1199
+ }
1200
+ }
1201
+ else {
1202
+ askNote = this.clientSupportsElicitation
1203
+ ? ' This rule asks the developer to confirm, but no human is present on this machine (CI / ephemeral workload), so it blocks here.'
1204
+ : ' This rule asks the developer to confirm; this MCP client cannot show a confirmation dialog (no elicitation support), so it blocks here (the IDE hook can ask).';
1205
+ }
1206
+ }
1105
1207
  if (localBlock) {
1106
1208
  const origin = localBlock.source === 'custom' ? 'org custom rule' : 'built-in Local Safety rule';
1107
- const askNote = !requestOutcome.blockingFinding ? ' This rule asks the developer to confirm; the MCP gateway cannot prompt, so it blocks here (the IDE hook can ask).' : '';
1108
1209
  if (reportOnlyMode) {
1109
1210
  // MONITOR CONTRACT: a monitor/shadow machine (or one with no bundle
1110
1211
  // signal at all) must NEVER hard-block. Online, the server already
@@ -1112,8 +1213,9 @@ class McpGatewayServer {
1112
1213
  // the client-side belt for offline/no-cache/stale-cache states,
1113
1214
  // where the scan can still yield block-action findings. Record the
1114
1215
  // would-block and let the call through.
1216
+ const would = requestOutcome.blockingFinding ? 'would block' : 'would ask you to confirm';
1115
1217
  this.spoolLocalFinding({ ...localBlock, reason: `[monitor] ${localBlock.reason}` }, toolName, operation, 'warn');
1116
- process.stderr.write(`FullCourtDefense (monitor) would block ${toolName}: ${localBlock.reason} (${origin} "${localBlock.itemId}", ${localBlock.ruleId})\n`);
1218
+ process.stderr.write(`FullCourtDefense (monitor) ${would} ${toolName}: ${localBlock.reason} (${origin} "${localBlock.itemId}", ${localBlock.ruleId})\n`);
1117
1219
  }
1118
1220
  else {
1119
1221
  this.spoolLocalFinding(localBlock, toolName, operation);
@@ -1614,6 +1716,63 @@ class McpGatewayServer {
1614
1716
  error(id, code, message) {
1615
1717
  process.stdout.write(`${JSON.stringify({ jsonrpc: '2.0', id, error: { code, message } })}\n`);
1616
1718
  }
1719
+ /**
1720
+ * Server -> client JSON-RPC request (MCP lets servers ask the client for
1721
+ * things: elicitation, sampling, roots). Resolves with the client's response
1722
+ * message; a timeout resolves to `undefined`. Never rejects.
1723
+ */
1724
+ requestClient(method, params, timeoutMs) {
1725
+ const id = `fcd-${++this.clientRequestSeq}`;
1726
+ return new Promise(resolve => {
1727
+ const timer = setTimeout(() => {
1728
+ this.clientPending.delete(id);
1729
+ resolve(undefined);
1730
+ }, timeoutMs);
1731
+ this.clientPending.set(id, { resolve, timer });
1732
+ process.stdout.write(`${JSON.stringify({ jsonrpc: '2.0', id, method, params })}\n`);
1733
+ });
1734
+ }
1735
+ /**
1736
+ * "Ask the developer" through the MCP client. Shows the same question the IDE
1737
+ * hook would: what is about to run, which rule asked, yes / no, and an
1738
+ * optional "remember for this session". Returns the human's answer; anything
1739
+ * that is not an explicit accept (decline, cancel, timeout, malformed) is a
1740
+ * decline. The caller decides what a decline means (block).
1741
+ */
1742
+ async elicitDeveloperConfirmation(input) {
1743
+ const started = Date.now();
1744
+ const evidence = input.evidence ? `\n\nEvidence: ${input.evidence.slice(0, 240)}` : '';
1745
+ const response = await this.requestClient('elicitation/create', {
1746
+ message: `FullCourtDefense — your organization asks you to confirm this action.\n\nTool: ${input.toolName}\nRule: ${input.ruleLabel}\nWhy: ${input.reason}${evidence}\n\nAllow this tool call? Your answer is reported to your security dashboard as your decision.`,
1747
+ requestedSchema: {
1748
+ type: 'object',
1749
+ properties: {
1750
+ confirm: {
1751
+ type: 'boolean',
1752
+ title: 'Allow this tool call',
1753
+ description: 'Yes runs the tool call once. No (or closing this dialog) does not run it.',
1754
+ default: false,
1755
+ },
1756
+ remember: {
1757
+ type: 'boolean',
1758
+ title: 'Remember for this session',
1759
+ description: 'Do not ask again for this tool and rule until this MCP session ends (max 12 hours).',
1760
+ default: false,
1761
+ },
1762
+ },
1763
+ required: ['confirm'],
1764
+ },
1765
+ }, ELICITATION_TIMEOUT_MS);
1766
+ const latencyMs = Date.now() - started;
1767
+ if (!response)
1768
+ return { answer: 'timeout', remember: false, latencyMs };
1769
+ const result = (response.result && typeof response.result === 'object') ? response.result : undefined;
1770
+ const content = (result?.content && typeof result.content === 'object') ? result.content : undefined;
1771
+ if (result?.action === 'accept' && content?.confirm === true) {
1772
+ return { answer: 'accept', remember: content.remember === true, latencyMs };
1773
+ }
1774
+ return { answer: 'decline', remember: false, latencyMs };
1775
+ }
1617
1776
  }
1618
1777
  async function mcpGatewayCommand(args, config) {
1619
1778
  // The gateway is a resident proxy in front of the IDE's MCP servers — if it
@@ -1,3 +1,16 @@
1
+ /** Checker exit code: the rule asks the developer — the shell must prompt. */
2
+ export declare const ASK_CODE = 98;
3
+ /**
4
+ * Git for Windows' bash.exe, if installed (the MSYS2-based "Git Bash"). WSL's
5
+ * System32\bash.exe is deliberately NOT a match — that is a Linux distro with
6
+ * its own home directory and rc files, not this Windows profile.
7
+ */
8
+ export declare function detectGitBash(): string | undefined;
9
+ /**
10
+ * Where the bash/zsh guard can be planted: real POSIX hosts, or Windows with
11
+ * Git Bash installed (bash only — no zsh there).
12
+ */
13
+ export declare function posixGuardSupported(): boolean;
1
14
  export interface PosixShellGuardStatus {
2
15
  supported: boolean;
3
16
  installed: boolean;
@@ -18,9 +31,9 @@ export interface InstallPosixShellGuardArgs {
18
31
  /** 'false' to write the guard scripts + rules without wiring rc files. */
19
32
  profiles?: string;
20
33
  }
21
- /** `fullcourtdefense install-shell-guard` on macOS/Linux — enable the bash/zsh guard. */
34
+ /** `fullcourtdefense install-shell-guard` on macOS/Linux (bash/zsh) or Windows with Git Bash (bash). */
22
35
  export declare function installPosixShellGuardCommand(args?: InstallPosixShellGuardArgs): Promise<void>;
23
- /** `fullcourtdefense uninstall-shell-guard` on macOS/Linux — remove the guard. */
36
+ /** `fullcourtdefense uninstall-shell-guard` on macOS/Linux (or Git Bash on Windows) — remove the guard. */
24
37
  export declare function uninstallPosixShellGuardCommand(): Promise<void>;
25
38
  /** Refresh the shared rules JSON when the POSIX guard is installed. Never throws. */
26
39
  export declare function refreshPosixShellGuardRules(): void;
@@ -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,30 @@ 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;
35
54
  interface ShellGuardRulesFile {
36
55
  updatedAt: string;
37
56
  mode: 'block' | 'monitor';
@@ -33,10 +33,12 @@ var __importStar = (this && this.__importStar) || (function () {
33
33
  };
34
34
  })();
35
35
  Object.defineProperty(exports, "__esModule", { value: true });
36
- exports.GUARD_BUILD_MARKER = void 0;
36
+ exports.SHELL_ASK_REMEMBER_HOURS = exports.SHELL_ASK_TIMEOUT_SECONDS = exports.GUARD_BUILD_MARKER = void 0;
37
37
  exports.guardBuildStamp = guardBuildStamp;
38
38
  exports.guardArtifactStale = guardArtifactStale;
39
39
  exports.writeGuardArtifact = writeGuardArtifact;
40
+ exports.guardCliEntry = guardCliEntry;
41
+ exports.toShellGuardAction = toShellGuardAction;
40
42
  exports.writeShellGuardRules = writeShellGuardRules;
41
43
  exports.activeShellGuardRules = activeShellGuardRules;
42
44
  exports.evaluateShellCommand = evaluateShellCommand;
@@ -127,6 +129,36 @@ function writeGuardArtifact(filePath, content, mode) {
127
129
  fs.writeFileSync(tmp, content, mode !== undefined ? { encoding: 'utf8', mode } : 'utf8');
128
130
  fs.renameSync(tmp, filePath);
129
131
  }
132
+ /**
133
+ * The CLI entry a planted guard should spawn for `flush-spool`. Resolved from
134
+ * THIS module's location (dist/commands -> dist/index.js), never from
135
+ * process.argv[1]: when the guard installer runs embedded in another process
136
+ * (daemon, test drill, host script), argv[1] is the HOST — baking it in would
137
+ * make every spooled event re-launch the host, hidden, recursively (a fork
138
+ * bomb that has frozen a developer machine). argv[1] is only a last resort
139
+ * when the packaged entry is not on disk.
140
+ */
141
+ function guardCliEntry() {
142
+ const packaged = path.join(__dirname, '..', 'index.js');
143
+ try {
144
+ if (fs.existsSync(packaged))
145
+ return packaged;
146
+ }
147
+ catch { /* fall through */ }
148
+ return process.argv[1] || '';
149
+ }
150
+ /** Seconds a terminal "ask" prompt waits for an answer before it counts as declined. */
151
+ exports.SHELL_ASK_TIMEOUT_SECONDS = 30;
152
+ /** How long "s = remember for this shell" lasts (capped — a forgotten window must not last forever). */
153
+ exports.SHELL_ASK_REMEMBER_HOURS = 12;
154
+ /** Normalize a console action into what a typed-command guard can enforce. */
155
+ function toShellGuardAction(raw) {
156
+ if (raw === 'warn')
157
+ return 'warn';
158
+ if (raw === 'ask')
159
+ return 'ask';
160
+ return 'block';
161
+ }
130
162
  /** Confine every lookahead to a single command segment (never span `;` `&` `|`). */
131
163
  const SEG = String.raw `[^;&|]*`;
132
164
  function compileSpec(spec) {
@@ -337,13 +369,14 @@ function cachedDisabledItemIds() {
337
369
  return disabled;
338
370
  }
339
371
  /**
340
- * Per-item enforcement actions from the cached local-safety snapshots. Only
341
- * 'warn' survives here: 'mask' cannot rewrite a typed command (degrades to
342
- * block by omission), and cross-snapshot conflicts resolve fail-strict — an
343
- * explicit 'block' anywhere wins over a 'warn' elsewhere.
372
+ * Per-item enforcement actions from the cached local-safety snapshots. 'warn'
373
+ * and 'ask' survive here; 'mask' cannot rewrite a typed command (degrades to
374
+ * block by omission). Cross-snapshot conflicts resolve fail-strict: an explicit
375
+ * 'block' anywhere wins over 'ask', which wins over 'warn'.
344
376
  */
345
377
  function cachedItemActions() {
346
378
  const warn = new Set();
379
+ const ask = new Set();
347
380
  const blocked = new Set();
348
381
  try {
349
382
  for (const file of fs.readdirSync(SNAPSHOT_CACHE_DIR)) {
@@ -359,6 +392,8 @@ function cachedItemActions() {
359
392
  continue;
360
393
  if (action === 'warn')
361
394
  warn.add(itemId);
395
+ else if (action === 'ask')
396
+ ask.add(itemId);
362
397
  else
363
398
  blocked.add(itemId);
364
399
  }
@@ -368,8 +403,12 @@ function cachedItemActions() {
368
403
  }
369
404
  catch { /* no snapshot cache yet */ }
370
405
  const result = new Map();
371
- for (const itemId of warn) {
406
+ for (const itemId of ask) {
372
407
  if (!blocked.has(itemId))
408
+ result.set(itemId, 'ask');
409
+ }
410
+ for (const itemId of warn) {
411
+ if (!blocked.has(itemId) && !ask.has(itemId))
373
412
  result.set(itemId, 'warn');
374
413
  }
375
414
  return result;
@@ -398,8 +437,8 @@ function cachedCustomRules() {
398
437
  pattern: escapeDotNetRegex(block.pattern.trim()),
399
438
  reason: typeof block.explanation === 'string' && block.explanation ? block.explanation : `matched custom safety pattern "${block.pattern.trim()}"`,
400
439
  source: 'custom',
401
- // 'mask' cannot rewrite a typed command — only 'warn' relaxes here.
402
- ...(block.action === 'warn' ? { action: 'warn' } : {}),
440
+ // 'mask' cannot rewrite a typed command — only 'warn' / 'ask' relax here.
441
+ ...(block.action === 'warn' || block.action === 'ask' ? { action: block.action } : {}),
403
442
  });
404
443
  }
405
444
  }
@@ -423,7 +462,10 @@ function effectiveBuiltinRules() {
423
462
  const itemActions = cachedItemActions();
424
463
  return BUILTIN_RULES
425
464
  .filter(rule => !disabled.has(rule.id))
426
- .map(rule => itemActions.get(rule.id) === 'warn' ? { ...rule, action: 'warn' } : rule);
465
+ .map(rule => {
466
+ const action = itemActions.get(rule.id);
467
+ return action ? { ...rule, action } : rule;
468
+ });
427
469
  }
428
470
  /** Write (or rewrite) the ruleset the profile guard loads at shell startup. */
429
471
  function writeShellGuardRules() {
@@ -494,7 +536,7 @@ function refreshShellGuardRules() {
494
536
  // detection logic of the build that WROTE it. Rewrite when stale so new
495
537
  // shells load the current guard (rules refreshes alone never carry logic).
496
538
  if (guardArtifactStale(GUARD_PS1_PATH)) {
497
- writeGuardArtifact(GUARD_PS1_PATH, buildGuardPs1(process.execPath, process.argv[1] || ''));
539
+ writeGuardArtifact(GUARD_PS1_PATH, buildGuardPs1(process.execPath, guardCliEntry()));
498
540
  }
499
541
  }
500
542
  catch { /* best-effort */ }
@@ -517,6 +559,10 @@ function buildGuardPs1(nodePath, cliEntry) {
517
559
  `$global:FcdCliEntry = ${psQuote(cliEntry)}`,
518
560
  `$global:FcdGuardMode = 'monitor'`,
519
561
  `$global:FcdGuardRules = @()`,
562
+ `# "s = remember for this shell" answers: rule id -> expiry. Lives and dies with this PowerShell process.`,
563
+ `if ($null -eq $global:FcdAskRemembered) { $global:FcdAskRemembered = @{} }`,
564
+ `$global:FcdAskTimeoutSeconds = ${exports.SHELL_ASK_TIMEOUT_SECONDS}`,
565
+ `$global:FcdAskRememberHours = ${exports.SHELL_ASK_REMEMBER_HOURS}`,
520
566
  ``,
521
567
  `try {`,
522
568
  ` if (Test-Path $global:FcdRulesPath) {`,
@@ -530,7 +576,7 @@ function buildGuardPs1(nodePath, cliEntry) {
530
576
  ` Severity = [string]$_.severity`,
531
577
  ` Reason = [string]$_.reason`,
532
578
  ` Source = [string]$_.source`,
533
- ` Action = $(if ([string]$_.action -eq 'warn') { 'warn' } else { 'block' })`,
579
+ ` Action = $(switch ([string]$_.action) { 'warn' { 'warn' } 'ask' { 'ask' } default { 'block' } })`,
534
580
  ` Regex = [regex]::new([string]$_.pattern, 'IgnoreCase')`,
535
581
  ` }`,
536
582
  ` } catch { $null }`,
@@ -571,6 +617,7 @@ function buildGuardPs1(nodePath, cliEntry) {
571
617
  ` try { $execView = Get-FcdExecView -CommandLine $CommandLine } catch { }`,
572
618
  ` $execNormalized = ($execView -replace '\\s+', ' ').Trim()`,
573
619
  ` $warnHit = $null`,
620
+ ` $askHit = $null`,
574
621
  ` foreach ($rule in $global:FcdGuardRules) {`,
575
622
  ` try {`,
576
623
  ` # Built-in rules ask "can this text ACT?" — they see the execution`,
@@ -580,27 +627,71 @@ function buildGuardPs1(nodePath, cliEntry) {
580
627
  ` $target = if ($isCustom) { $CommandLine } else { $execView }`,
581
628
  ` $targetNorm = if ($isCustom) { $normalized } else { $execNormalized }`,
582
629
  ` if ($rule.Regex.IsMatch($targetNorm) -or $rule.Regex.IsMatch($target)) {`,
583
- ` # A block rule always outranks a warn rule matching the same line.`,
584
- ` if ($rule.Action -ne 'warn') { return $rule }`,
585
- ` if ($null -eq $warnHit) { $warnHit = $rule }`,
630
+ ` # Precedence on one line: block outranks ask, ask outranks warn.`,
631
+ ` if ($rule.Action -eq 'block') { return $rule }`,
632
+ ` if ($rule.Action -eq 'ask') { if ($null -eq $askHit) { $askHit = $rule } }`,
633
+ ` elseif ($null -eq $warnHit) { $warnHit = $rule }`,
586
634
  ` }`,
587
635
  ` } catch { }`,
588
636
  ` }`,
637
+ ` if ($null -ne $askHit) { return $askHit }`,
589
638
  ` return $warnHit`,
590
639
  `}`,
591
640
  ``,
641
+ `# Read one key for an "ask" prompt without going through PSReadLine (we are`,
642
+ `# inside its Enter handler, so the console input is ours). y / s / anything`,
643
+ `# else / timeout / no console => n. Never throws.`,
644
+ `function global:Read-FcdAskAnswer {`,
645
+ ` param([int]$TimeoutSeconds = 30)`,
646
+ ` try {`,
647
+ ` if ([Console]::IsInputRedirected) { return 'n' }`,
648
+ ` $deadline = (Get-Date).AddSeconds($TimeoutSeconds)`,
649
+ ` while ((Get-Date) -lt $deadline) {`,
650
+ ` if ([Console]::KeyAvailable) {`,
651
+ ` $key = [Console]::ReadKey($true)`,
652
+ ` $char = ([string]$key.KeyChar).ToLowerInvariant()`,
653
+ ` if ($char -eq 'y') { return 'y' }`,
654
+ ` if ($char -eq 's') { return 's' }`,
655
+ ` return 'n'`,
656
+ ` }`,
657
+ ` Start-Sleep -Milliseconds 40`,
658
+ ` }`,
659
+ ` return 't'`,
660
+ ` } catch { }`,
661
+ ` return 'n'`,
662
+ `}`,
663
+ ``,
664
+ `# Is this ask rule already answered "s" in this shell (and not expired)?`,
665
+ `function global:Test-FcdAskRemembered {`,
666
+ ` param([string]$RuleId)`,
667
+ ` try {`,
668
+ ` $until = $global:FcdAskRemembered[$RuleId]`,
669
+ ` if ($null -ne $until -and $until -gt (Get-Date)) { return $true }`,
670
+ ` if ($null -ne $until) { $global:FcdAskRemembered.Remove($RuleId) }`,
671
+ ` } catch { }`,
672
+ ` return $false`,
673
+ `}`,
674
+ ``,
592
675
  `function global:Write-FcdGuardEvent {`,
593
- ` param($Rule, [string]$CommandLine, [string]$Decision)`,
676
+ ` param($Rule, [string]$CommandLine, [string]$Decision, [string]$Developer = '')`,
594
677
  ` try {`,
595
678
  ` $evidence = $CommandLine.Trim()`,
596
679
  ` if ($evidence.Length -gt 180) { $evidence = $evidence.Substring(0, 180) + '...' }`,
597
- ` $event = [pscustomobject]@{`,
680
+ ` # $Developer: '' (none) | 'confirmed' | 'remembered' | 'declined' | 'timeout'`,
681
+ ` $reason = 'Shell guard: ' + $Rule.Reason`,
682
+ ` switch ($Developer) {`,
683
+ ` 'confirmed' { $reason = 'developer confirmed in terminal prompt — ' + $reason }`,
684
+ ` 'remembered' { $reason = 'developer confirmed earlier in this shell (session memory) — ' + $reason }`,
685
+ ` 'declined' { $reason = 'developer declined in terminal prompt — ' + $reason }`,
686
+ ` 'timeout' { $reason = 'developer confirmation not given (terminal prompt timed out) — ' + $reason }`,
687
+ ` }`,
688
+ ` $event = [ordered]@{`,
598
689
  ` eventId = [guid]::NewGuid().ToString()`,
599
690
  ` type = 'verdict'`,
600
691
  ` decision = $Decision`,
601
692
  ` toolName = 'powershell_terminal'`,
602
693
  ` operation = 'shell_command'`,
603
- ` reason = ('Shell guard: ' + $Rule.Reason)`,
694
+ ` reason = $reason`,
604
695
  ` ruleId = $Rule.Id`,
605
696
  ` category = $Rule.Category`,
606
697
  ` severity = $Rule.Severity`,
@@ -608,6 +699,11 @@ function buildGuardPs1(nodePath, cliEntry) {
608
699
  ` evidence = $evidence`,
609
700
  ` occurredAt = (Get-Date).ToUniversalTime().ToString('o')`,
610
701
  ` }`,
702
+ ` if ($Developer -ne '') {`,
703
+ ` $event['approvalScope'] = 'developer'`,
704
+ ` if ($Developer -eq 'confirmed' -or $Developer -eq 'remembered') { $event['approvedBy'] = 'developer' }`,
705
+ ` }`,
706
+ ` $event = [pscustomobject]$event`,
611
707
  ` # BOM-free UTF8 append — PS5's Add-Content -Encoding utf8 writes a BOM that breaks JSONL parsing.`,
612
708
  ` [System.IO.File]::AppendAllText($global:FcdSpoolPath, (($event | ConvertTo-Json -Compress) + "\`n"), (New-Object System.Text.UTF8Encoding($false)))`,
613
709
  ` if ((Test-Path $global:FcdNodePath) -and (Test-Path $global:FcdCliEntry)) {`,
@@ -636,13 +732,53 @@ function buildGuardPs1(nodePath, cliEntry) {
636
732
  ` # Cursor/VS Code terminals). InvokePrompt() re-renders the prompt+buffer`,
637
733
  ` # BELOW the message so the warning stays in the scrollback permanently.`,
638
734
  ` if ($global:FcdGuardMode -eq 'monitor') {`,
735
+ ` $would = if ($hit.Action -eq 'ask') { 'would ask you to confirm' } else { 'would block' }`,
639
736
  ` Write-Host ''`,
640
- ` Write-Host ('[FullCourtDefense] monitor: would block [' + $hit.Severity + '] ' + $hit.Reason + ' (rule ' + $hit.Id + ')') -ForegroundColor Yellow`,
737
+ ` Write-Host ('[FullCourtDefense] monitor: ' + $would + ' [' + $hit.Severity + '] ' + $hit.Reason + ' (rule ' + $hit.Id + ')') -ForegroundColor Yellow`,
641
738
  ` try { [Microsoft.PowerShell.PSConsoleReadLine]::InvokePrompt() } catch { }`,
642
739
  ` Write-FcdGuardEvent -Rule $hit -CommandLine $line -Decision 'warn'`,
643
740
  ` [Microsoft.PowerShell.PSConsoleReadLine]::AcceptLine()`,
644
741
  ` return`,
645
742
  ` }`,
743
+ ` # Per-rule 'ask' action from the console: pause the line and ask the`,
744
+ ` # human at the keyboard. y = run once, s = run + remember for this`,
745
+ ` # shell window, anything else / timeout = not executed. Every answer is`,
746
+ ` # reported to the fleet as a developer decision (same facts as the IDE`,
747
+ ` # prompt). No answerable console => block, never a silent allow.`,
748
+ ` if ($hit.Action -eq 'ask') {`,
749
+ ` if (Test-FcdAskRemembered -RuleId ([string]$hit.Id)) {`,
750
+ ` Write-Host ''`,
751
+ ` Write-Host ('[FullCourtDefense] allowed — you confirmed rule ' + $hit.Id + ' earlier in this shell.') -ForegroundColor DarkGray`,
752
+ ` try { [Microsoft.PowerShell.PSConsoleReadLine]::InvokePrompt() } catch { }`,
753
+ ` Write-FcdGuardEvent -Rule $hit -CommandLine $line -Decision 'allow' -Developer 'remembered'`,
754
+ ` [Microsoft.PowerShell.PSConsoleReadLine]::AcceptLine()`,
755
+ ` return`,
756
+ ` }`,
757
+ ` Write-Host ''`,
758
+ ` Write-Host ('[FullCourtDefense] ASK [' + $hit.Severity + ']: ' + $hit.Reason + ' (rule ' + $hit.Id + ')') -ForegroundColor Yellow`,
759
+ ` Write-Host ('Run anyway? [y] yes [s] yes, remember for this shell [N] no (' + $global:FcdAskTimeoutSeconds + 's): ') -NoNewline`,
760
+ ` $answer = Read-FcdAskAnswer -TimeoutSeconds $global:FcdAskTimeoutSeconds`,
761
+ ` if ($answer -eq 'y' -or $answer -eq 's') {`,
762
+ ` if ($answer -eq 's') {`,
763
+ ` $global:FcdAskRemembered[[string]$hit.Id] = (Get-Date).AddHours($global:FcdAskRememberHours)`,
764
+ ` Write-Host 'yes — remembered for this shell. Reported to your security dashboard as allowed by you.' -ForegroundColor Green`,
765
+ ` } else {`,
766
+ ` Write-Host 'yes — reported to your security dashboard as allowed by you.' -ForegroundColor Green`,
767
+ ` }`,
768
+ ` try { [Microsoft.PowerShell.PSConsoleReadLine]::InvokePrompt() } catch { }`,
769
+ ` Write-FcdGuardEvent -Rule $hit -CommandLine $line -Decision 'allow' -Developer 'confirmed'`,
770
+ ` [Microsoft.PowerShell.PSConsoleReadLine]::AcceptLine()`,
771
+ ` return`,
772
+ ` }`,
773
+ ` [Microsoft.PowerShell.PSConsoleReadLine]::RevertLine()`,
774
+ ` $outcome = if ($answer -eq 't') { 'timeout' } else { 'declined' }`,
775
+ ` Write-Host $(if ($outcome -eq 'timeout') { 'no answer' } else { 'no' }) -ForegroundColor Red`,
776
+ ` Write-Host ('This command was not executed (' + $outcome + '). Reported to your security dashboard.') -ForegroundColor DarkGray`,
777
+ ` try { [Microsoft.PowerShell.PSConsoleReadLine]::InvokePrompt() } catch { }`,
778
+ ` Write-FcdGuardEvent -Rule $hit -CommandLine $line -Decision 'block' -Developer $outcome`,
779
+ ` [Microsoft.PowerShell.PSConsoleReadLine]::AcceptLine()`,
780
+ ` return`,
781
+ ` }`,
646
782
  ` # Per-rule 'warn' action from the console: notice + the command runs.`,
647
783
  ` if ($hit.Action -eq 'warn') {`,
648
784
  ` Write-Host ''`,
@@ -830,7 +966,7 @@ async function installShellGuardCommand(args = {}) {
830
966
  }
831
967
  const rules = writeShellGuardRules();
832
968
  const nodePath = process.execPath;
833
- const cliEntry = process.argv[1] || '';
969
+ const cliEntry = guardCliEntry();
834
970
  fs.writeFileSync(GUARD_PS1_PATH, buildGuardPs1(nodePath, cliEntry), 'utf8');
835
971
  console.log(`Guard rules written: ${rules.rules.length} rule(s), mode "${rules.mode}".`);
836
972
  if (args.profiles === 'false') {
package/dist/index.js CHANGED
@@ -822,6 +822,12 @@ async function main() {
822
822
  if (process.platform === 'win32') {
823
823
  const { installShellGuardCommand } = await Promise.resolve().then(() => __importStar(require('./commands/shellGuard')));
824
824
  await installShellGuardCommand({ profiles: flags.profiles });
825
+ // Git Bash (MSYS2) on Windows gets the bash guard too — same rules file.
826
+ const { installPosixShellGuardCommand, detectGitBash } = await Promise.resolve().then(() => __importStar(require('./commands/posixShellGuard')));
827
+ if (detectGitBash()) {
828
+ console.log('\n\x1b[1mGit Bash detected — installing the bash guard as well…\x1b[0m');
829
+ await installPosixShellGuardCommand({ profiles: flags.profiles });
830
+ }
825
831
  }
826
832
  else {
827
833
  const { installPosixShellGuardCommand } = await Promise.resolve().then(() => __importStar(require('./commands/posixShellGuard')));
@@ -833,6 +839,9 @@ async function main() {
833
839
  if (process.platform === 'win32') {
834
840
  const { uninstallShellGuardCommand } = await Promise.resolve().then(() => __importStar(require('./commands/shellGuard')));
835
841
  await uninstallShellGuardCommand();
842
+ const { uninstallPosixShellGuardCommand, posixGuardSupported } = await Promise.resolve().then(() => __importStar(require('./commands/posixShellGuard')));
843
+ if (posixGuardSupported())
844
+ await uninstallPosixShellGuardCommand();
836
845
  }
837
846
  else {
838
847
  const { uninstallPosixShellGuardCommand } = await Promise.resolve().then(() => __importStar(require('./commands/posixShellGuard')));
@@ -850,6 +859,15 @@ async function main() {
850
859
  }
851
860
  if (status.rulesPresent)
852
861
  console.log(` Rules: ${status.ruleCount} (mode: ${status.mode || 'block'})`);
862
+ const { getPosixShellGuardStatus, detectGitBash } = await Promise.resolve().then(() => __importStar(require('./commands/posixShellGuard')));
863
+ const gitBash = detectGitBash();
864
+ if (gitBash) {
865
+ const posix = getPosixShellGuardStatus();
866
+ console.log(`\nInteractive Git Bash guard: ${posix.installed ? '\x1b[32minstalled\x1b[0m' : '\x1b[31mnot installed\x1b[0m'} \x1b[2m(${gitBash})\x1b[0m`);
867
+ for (const rc of posix.rcFiles) {
868
+ console.log(` ${rc.shell}: ${rc.installed ? '\x1b[32m✓\x1b[0m' : '\x1b[31m✗\x1b[0m'} \x1b[2m${rc.rcPath}\x1b[0m`);
869
+ }
870
+ }
853
871
  console.log('');
854
872
  break;
855
873
  }
@@ -898,7 +916,10 @@ async function main() {
898
916
  const matches = evaluateShellCommand(cmd, rules);
899
917
  if (matches.length)
900
918
  blocked++;
901
- 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 })) };
919
+ // The action the terminal guard would take: block > ask > warn across matches.
920
+ const actions = matches.map(m => m.rule.action || 'block');
921
+ const action = actions.includes('block') ? 'block' : actions.includes('ask') ? 'ask' : actions.length ? 'warn' : undefined;
922
+ return { command: cmd, blocked: matches.length > 0, action, matches: matches.map(m => ({ ruleId: m.rule.id, severity: m.rule.severity, category: m.rule.category, action: m.rule.action || 'block', reason: m.rule.reason, segment: m.segment })) };
902
923
  });
903
924
  if (asJson) {
904
925
  console.log(JSON.stringify({ total: cmds.length, blocked, ruleCount: rules.length, results }, null, 2));
@@ -908,8 +929,9 @@ async function main() {
908
929
  for (const r of results) {
909
930
  if (r.blocked) {
910
931
  const m = r.matches[0];
911
- console.log(` \x1b[31mBLOCK\x1b[0m [\x1b[1m${m.severity}\x1b[0m] ${r.command}`);
912
- console.log(` \x1b[2m${m.reason} (rule ${m.ruleId})${r.matches.length > 1 ? ` +${r.matches.length - 1} more` : ''}\x1b[0m`);
932
+ const label = r.action === 'ask' ? '\x1b[33mASK \x1b[0m' : r.action === 'warn' ? '\x1b[33mWARN \x1b[0m' : '\x1b[31mBLOCK\x1b[0m';
933
+ console.log(` ${label} [\x1b[1m${m.severity}\x1b[0m] ${r.command}`);
934
+ console.log(` \x1b[2m${m.reason} (rule ${m.ruleId})${r.matches.length > 1 ? ` +${r.matches.length - 1} more` : ''}${r.action === 'ask' ? ' — asks you to confirm in the terminal' : r.action === 'warn' ? ' — runs with a warning' : ''}\x1b[0m`);
913
935
  }
914
936
  else {
915
937
  console.log(` \x1b[32mALLOW\x1b[0m ${r.command}`);
package/dist/version.json CHANGED
@@ -1,3 +1,3 @@
1
1
  {
2
- "version": "1.27.4"
2
+ "version": "1.28.0"
3
3
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fullcourtdefense-cli",
3
- "version": "1.27.4",
3
+ "version": "1.28.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": {
@@ -78,6 +78,7 @@
78
78
  "test:offline-policy": "npm run build && node scripts/test-offline-policy-enforcement.js",
79
79
  "test:developer-confirmation": "npm run build && node scripts/test-developer-confirmation.js",
80
80
  "test:local-safety-ask": "npm run build && node scripts/test-local-safety-ask.js",
81
+ "test:terminal-mcp-ask": "npm run build && node scripts/test-terminal-mcp-ask.js",
81
82
  "test:demo-actions": "npm run build && node scripts/test-demo-actions.js",
82
83
  "test:real-scenario": "npm run build && node scripts/test-real-scenario-drill.js",
83
84
  "test:blocking-approval": "npm run build && node scripts/test-blocking-approval-drill.js",