fullcourtdefense-cli 1.34.6 → 1.34.7

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.
@@ -41,6 +41,7 @@ const fs = __importStar(require("fs"));
41
41
  const path = __importStar(require("path"));
42
42
  const config_1 = require("../config");
43
43
  const discoverPaths_1 = require("./discoverPaths");
44
+ const discoverProxy_1 = require("./discoverProxy");
44
45
  const discoverAgentFiles_1 = require("./discoverAgentFiles");
45
46
  const discoverSecrets_1 = require("./discoverSecrets");
46
47
  const discover_1 = require("./discover");
@@ -88,19 +89,6 @@ function parseTomlMcpServers(content) {
88
89
  }
89
90
  return servers;
90
91
  }
91
- function extractServerMap(parsed) {
92
- if (!parsed || typeof parsed !== 'object')
93
- return {};
94
- if (parsed.mcpServers && typeof parsed.mcpServers === 'object')
95
- return parsed.mcpServers;
96
- if (parsed.servers && typeof parsed.servers === 'object')
97
- return parsed.servers;
98
- if (parsed.mcp?.servers && typeof parsed.mcp.servers === 'object')
99
- return parsed.mcp.servers;
100
- if (parsed['mcp.servers'] && typeof parsed['mcp.servers'] === 'object')
101
- return parsed['mcp.servers'];
102
- return {};
103
- }
104
92
  function riskSignalsForServer(name, server) {
105
93
  const text = [
106
94
  name,
@@ -129,7 +117,7 @@ function discoverMcpServers(cwd, extraPath) {
129
117
  const parsed = isToml
130
118
  ? parseTomlMcpServers(fs.readFileSync(candidate.path, 'utf8'))
131
119
  : parseJsonFile(candidate.path);
132
- const map = extractServerMap(parsed);
120
+ const map = (0, discoverProxy_1.extractServerMap)(parsed);
133
121
  for (const [name, server] of Object.entries(map)) {
134
122
  const cfg = server;
135
123
  const command = typeof cfg.command === 'string' ? cfg.command : undefined;
@@ -153,19 +153,6 @@ function deriveProjectRoots(candidates) {
153
153
  return [...roots];
154
154
  }
155
155
  /** MCP servers can be declared under several keys across clients. */
156
- function extractServerMap(parsed) {
157
- if (!parsed || typeof parsed !== 'object')
158
- return {};
159
- if (parsed.mcpServers && typeof parsed.mcpServers === 'object')
160
- return parsed.mcpServers;
161
- if (parsed.servers && typeof parsed.servers === 'object')
162
- return parsed.servers;
163
- if (parsed.mcp?.servers && typeof parsed.mcp.servers === 'object')
164
- return parsed.mcp.servers;
165
- if (parsed['mcp.servers'] && typeof parsed['mcp.servers'] === 'object')
166
- return parsed['mcp.servers'];
167
- return {};
168
- }
169
156
  function parseTomlString(value) {
170
157
  const trimmed = value.trim().replace(/,$/, '');
171
158
  if (!trimmed)
@@ -347,7 +334,7 @@ function parseConfigFile(filePath, source) {
347
334
  return [];
348
335
  }
349
336
  const out = [];
350
- for (const [name, cfgRaw] of Object.entries(extractServerMap(parsed))) {
337
+ for (const [name, cfgRaw] of Object.entries((0, discoverProxy_1.extractServerMap)(parsed))) {
351
338
  pushDiscoveredServer(out, name, cfgRaw, source, filePath);
352
339
  }
353
340
  // Claude Code stores MCP per-project inside ~/.claude.json
@@ -25,6 +25,29 @@ export interface ProxyClassifiableServer {
25
25
  configPath: string;
26
26
  }
27
27
  export declare function isFcdGatewayServer(server: ProxyClassifiableServer): boolean;
28
+ /**
29
+ * The MCP server map of a parsed client config, whatever the client calls it:
30
+ * `mcpServers` (Cursor, Claude, Windsurf, Gemini), `servers` / `mcp.servers`
31
+ * (VS Code), `mcp_servers` (Codex TOML). Shared by discovery, agent-ci and the
32
+ * hook so every surface reads the same servers.
33
+ */
34
+ export declare function extractServerMap(parsed: any): Record<string, any>;
35
+ /**
36
+ * Every entry named `serverName` across this machine's AI-client MCP configs
37
+ * (Cursor, Claude Code incl. per-project maps in ~/.claude.json, Codex TOML,
38
+ * VS Code, Windsurf, Gemini, ...). Matches the exact name or the sanitized
39
+ * alias clients such as Codex expose (`patria-bank` -> `patria_bank`).
40
+ * Unreadable / unparsable files are skipped, never thrown.
41
+ */
42
+ export declare function findClientMcpServers(serverName: string, cwd?: string, clientKey?: string): ProxyClassifiableServer[];
43
+ /**
44
+ * True when every config entry for `serverName` — in the given client's configs
45
+ * when `clientKey` is set (`claude_code`, `vscode`, ...), else on the whole
46
+ * machine — runs through the FullCourtDefense MCP gateway. Unknown name, or the
47
+ * same name wrapped in one config and direct in another, is `false` — the caller
48
+ * keeps its own guard, so an action is at worst confirmed twice, never zero times.
49
+ */
50
+ export declare function isServerBehindFcdGateway(serverName: string, cwd?: string, clientKey?: string): boolean;
28
51
  export declare function extractGatewayDownstream(server: ProxyClassifiableServer): {
29
52
  command?: string;
30
53
  args?: string[];
@@ -36,6 +36,9 @@ Object.defineProperty(exports, "__esModule", { value: true });
36
36
  exports.FCD_HOOK_TAG = exports.FCD_HOOK_MARKER = exports.FCD_GATEWAY_SERVER_NAMES = void 0;
37
37
  exports.isReferenceConfigSource = isReferenceConfigSource;
38
38
  exports.isFcdGatewayServer = isFcdGatewayServer;
39
+ exports.extractServerMap = extractServerMap;
40
+ exports.findClientMcpServers = findClientMcpServers;
41
+ exports.isServerBehindFcdGateway = isServerBehindFcdGateway;
39
42
  exports.extractGatewayDownstream = extractGatewayDownstream;
40
43
  exports.buildGatewayWrapIndex = buildGatewayWrapIndex;
41
44
  exports.classifyProxyStatus = classifyProxyStatus;
@@ -46,6 +49,7 @@ const fs = __importStar(require("fs"));
46
49
  const os = __importStar(require("os"));
47
50
  const path = __importStar(require("path"));
48
51
  const discoverPaths_1 = require("./discoverPaths");
52
+ const codexMcpIdentity_1 = require("../codexMcpIdentity");
49
53
  /**
50
54
  * Config sources that no AI client actually loads. A plain `mcp.json` at a
51
55
  * repo root is documentation/sample material — Cursor, Claude Code, VS Code,
@@ -76,6 +80,96 @@ function isFcdGatewayServer(server) {
76
80
  return blob.includes('mcp-gateway')
77
81
  && (blob.includes('fullcourtdefense') || blob.includes('agentguard') || blob.includes('botguard') || blob.includes('fcd-managed'));
78
82
  }
83
+ /**
84
+ * The MCP server map of a parsed client config, whatever the client calls it:
85
+ * `mcpServers` (Cursor, Claude, Windsurf, Gemini), `servers` / `mcp.servers`
86
+ * (VS Code), `mcp_servers` (Codex TOML). Shared by discovery, agent-ci and the
87
+ * hook so every surface reads the same servers.
88
+ */
89
+ function extractServerMap(parsed) {
90
+ if (!parsed || typeof parsed !== 'object')
91
+ return {};
92
+ if (parsed.mcpServers && typeof parsed.mcpServers === 'object')
93
+ return parsed.mcpServers;
94
+ if (parsed.mcp_servers && typeof parsed.mcp_servers === 'object')
95
+ return parsed.mcp_servers;
96
+ if (parsed.servers && typeof parsed.servers === 'object')
97
+ return parsed.servers;
98
+ if (parsed.mcp?.servers && typeof parsed.mcp.servers === 'object')
99
+ return parsed.mcp.servers;
100
+ if (parsed['mcp.servers'] && typeof parsed['mcp.servers'] === 'object')
101
+ return parsed['mcp.servers'];
102
+ return {};
103
+ }
104
+ function sanitizedServerAlias(name) {
105
+ return name.toLowerCase().replace(/[^a-z0-9_]/g, '_');
106
+ }
107
+ /**
108
+ * Every entry named `serverName` across this machine's AI-client MCP configs
109
+ * (Cursor, Claude Code incl. per-project maps in ~/.claude.json, Codex TOML,
110
+ * VS Code, Windsurf, Gemini, ...). Matches the exact name or the sanitized
111
+ * alias clients such as Codex expose (`patria-bank` -> `patria_bank`).
112
+ * Unreadable / unparsable files are skipped, never thrown.
113
+ */
114
+ function findClientMcpServers(serverName, cwd = process.cwd(), clientKey) {
115
+ const wanted = serverName.toLowerCase();
116
+ const wantedAlias = sanitizedServerAlias(serverName);
117
+ const out = [];
118
+ for (const candidate of (0, discoverPaths_1.discoverScanTargets)(cwd)) {
119
+ if (clientKey && candidate.clientKey !== clientKey)
120
+ continue;
121
+ let parsed;
122
+ try {
123
+ const raw = fs.readFileSync(candidate.path, 'utf8').replace(/^\uFEFF/, '');
124
+ if (candidate.path.toLowerCase().endsWith('.toml')) {
125
+ const toml = (0, codexMcpIdentity_1.loadTomlParser)();
126
+ parsed = toml ? toml(raw) : null;
127
+ }
128
+ else {
129
+ parsed = JSON.parse(raw);
130
+ }
131
+ }
132
+ catch {
133
+ continue;
134
+ }
135
+ if (!parsed)
136
+ continue;
137
+ const maps = [extractServerMap(parsed)];
138
+ if (parsed.projects && typeof parsed.projects === 'object') {
139
+ for (const project of Object.values(parsed.projects))
140
+ maps.push(extractServerMap(project));
141
+ }
142
+ for (const map of maps) {
143
+ for (const [name, cfg] of Object.entries(map)) {
144
+ if (name.toLowerCase() !== wanted && sanitizedServerAlias(name) !== wantedAlias)
145
+ continue;
146
+ if (!cfg || typeof cfg !== 'object')
147
+ continue;
148
+ const entry = cfg;
149
+ out.push({
150
+ serverName: name,
151
+ command: typeof entry.command === 'string' ? entry.command : undefined,
152
+ args: Array.isArray(entry.args) ? entry.args.map(String) : undefined,
153
+ url: typeof entry.url === 'string' ? entry.url : typeof entry.endpoint === 'string' ? entry.endpoint : undefined,
154
+ source: candidate.source,
155
+ configPath: candidate.path,
156
+ });
157
+ }
158
+ }
159
+ }
160
+ return out;
161
+ }
162
+ /**
163
+ * True when every config entry for `serverName` — in the given client's configs
164
+ * when `clientKey` is set (`claude_code`, `vscode`, ...), else on the whole
165
+ * machine — runs through the FullCourtDefense MCP gateway. Unknown name, or the
166
+ * same name wrapped in one config and direct in another, is `false` — the caller
167
+ * keeps its own guard, so an action is at worst confirmed twice, never zero times.
168
+ */
169
+ function isServerBehindFcdGateway(serverName, cwd = process.cwd(), clientKey) {
170
+ const matches = findClientMcpServers(serverName, cwd, clientKey);
171
+ return matches.length > 0 && matches.every(isFcdGatewayServer);
172
+ }
79
173
  function extractGatewayDownstream(server) {
80
174
  const args = server.args || [];
81
175
  const urlIdx = args.indexOf('--mcp-url');
@@ -89,6 +89,41 @@ export declare function canonicalizeCursorPayload(argEvent: string | undefined,
89
89
  payload: Record<string, unknown>;
90
90
  ignore: boolean;
91
91
  };
92
+ /**
93
+ * Cursor 3.19+ also fires GENERIC tool events — `preToolUse` / `postToolUse`
94
+ * (lower-camel `hook_event_name`, MCP tools named `MCP:<tool>`, built-ins by
95
+ * their tool name) — for the same action its dedicated hooks already report
96
+ * (`beforeMCPExecution` / `afterMCPExecution` / `beforeShellExecution` / ...).
97
+ * Evaluated as-is, one MCP call was judged three times: the generic pre-event
98
+ * (whose `MCP:` name matches no per-tool rule, so it fell through to broad
99
+ * company rules and raised a phantom manager approval), the dedicated
100
+ * pre-event, and the generic POST-event — judged as if the call were about to
101
+ * run, after it had already run.
102
+ *
103
+ * preToolUse -> ignore for MCP (beforeMCPExecution is the authoritative surface,
104
+ * same rule as Shell in normalizeCursorBuiltinTool); built-ins go
105
+ * through the shared taxonomy.
106
+ * postToolUse -> a post-execution correlation signal, never enforced.
107
+ * Returns null for every other payload.
108
+ */
109
+ export declare function canonicalizeCursorGenericToolEvent(payload: Record<string, unknown>): {
110
+ event: HookEvent;
111
+ payload: Record<string, unknown>;
112
+ ignore: boolean;
113
+ } | null;
114
+ /**
115
+ * The FullCourtDefense MCP gateway already sits in front of this server.
116
+ * - Cursor reports the server's command line on `beforeMCPExecution`; ours is
117
+ * `<node> <cli> mcp-gateway --mcp-command|--mcp-url ... --agent-name ...`
118
+ * (Docker's MCP toolkit is `docker mcp gateway run` — no hyphen, no such flags).
119
+ * When a command line is present it is authoritative.
120
+ * - Claude Code / VS Code name the server (`mcp__<server>__<tool>`) but not its
121
+ * command: the server is looked up in THAT client's MCP configs (`client` is
122
+ * the hook's `--agent-client`, e.g. `claude-code` -> Claude Code's configs).
123
+ * Anything unknown is `false`: the hook then keeps its own guard, so an action
124
+ * is at worst confirmed twice, never zero times.
125
+ */
126
+ export declare function isGatewayWrappedMcpServer(payload: Record<string, unknown>, client?: string, cwd?: string): boolean;
92
127
  /**
93
128
  * Map a Cursor execution-hook payload onto an Action Policy tool call.
94
129
  * Returns null when there is nothing actionable to evaluate.
@@ -38,6 +38,8 @@ exports.canonicalBuiltinToolName = canonicalBuiltinToolName;
38
38
  exports.mapBuiltinToolPayload = mapBuiltinToolPayload;
39
39
  exports.normalizeCursorBuiltinTool = normalizeCursorBuiltinTool;
40
40
  exports.canonicalizeCursorPayload = canonicalizeCursorPayload;
41
+ exports.canonicalizeCursorGenericToolEvent = canonicalizeCursorGenericToolEvent;
42
+ exports.isGatewayWrappedMcpServer = isGatewayWrappedMcpServer;
41
43
  exports.buildToolCall = buildToolCall;
42
44
  exports.classifyGateHttpFailure = classifyGateHttpFailure;
43
45
  exports.gateHttpFailureDetail = gateHttpFailureDetail;
@@ -64,6 +66,7 @@ const devConfirm_1 = require("../devConfirm");
64
66
  // same rotated hook.log and read stdin identically.
65
67
  const hookIo_1 = require("../hookIo");
66
68
  const fileWriteCanon_1 = require("../fileWriteCanon");
69
+ const discoverProxy_1 = require("./discoverProxy");
67
70
  function inferEvent(explicit, payload) {
68
71
  const e = (explicit || '').toLowerCase();
69
72
  if (e) {
@@ -293,6 +296,9 @@ function normalizeCursorBuiltinTool(payload) {
293
296
  * IDE false-positive corpus (Layer A) evaluates exactly what the hook evaluates.
294
297
  */
295
298
  function canonicalizeCursorPayload(argEvent, payload) {
299
+ const generic = canonicalizeCursorGenericToolEvent(payload);
300
+ if (generic)
301
+ return generic;
296
302
  const event = inferEvent(argEvent, payload);
297
303
  if (event !== 'mcp')
298
304
  return { event, payload, ignore: false };
@@ -303,6 +309,76 @@ function canonicalizeCursorPayload(argEvent, payload) {
303
309
  return { event: 'unknown', payload, ignore: true };
304
310
  return { event: builtin.event, payload: builtin.payload, ignore: false };
305
311
  }
312
+ /** Cursor names MCP tools `MCP:<tool>` in its generic tool events. */
313
+ const CURSOR_GENERIC_MCP_PREFIX = 'MCP:';
314
+ /**
315
+ * Cursor 3.19+ also fires GENERIC tool events — `preToolUse` / `postToolUse`
316
+ * (lower-camel `hook_event_name`, MCP tools named `MCP:<tool>`, built-ins by
317
+ * their tool name) — for the same action its dedicated hooks already report
318
+ * (`beforeMCPExecution` / `afterMCPExecution` / `beforeShellExecution` / ...).
319
+ * Evaluated as-is, one MCP call was judged three times: the generic pre-event
320
+ * (whose `MCP:` name matches no per-tool rule, so it fell through to broad
321
+ * company rules and raised a phantom manager approval), the dedicated
322
+ * pre-event, and the generic POST-event — judged as if the call were about to
323
+ * run, after it had already run.
324
+ *
325
+ * preToolUse -> ignore for MCP (beforeMCPExecution is the authoritative surface,
326
+ * same rule as Shell in normalizeCursorBuiltinTool); built-ins go
327
+ * through the shared taxonomy.
328
+ * postToolUse -> a post-execution correlation signal, never enforced.
329
+ * Returns null for every other payload.
330
+ */
331
+ function canonicalizeCursorGenericToolEvent(payload) {
332
+ const hookEventName = str(payload.hook_event_name);
333
+ if (hookEventName !== 'preToolUse' && hookEventName !== 'postToolUse')
334
+ return null;
335
+ const toolName = str(payload.tool_name);
336
+ const isMcp = toolName.startsWith(CURSOR_GENERIC_MCP_PREFIX);
337
+ if (hookEventName === 'postToolUse') {
338
+ if (isMcp) {
339
+ return { event: 'post', payload: { ...payload, tool_name: toolName.slice(CURSOR_GENERIC_MCP_PREFIX.length), fcd_post_of: 'mcp' }, ignore: false };
340
+ }
341
+ const builtin = normalizeCursorBuiltinTool(payload);
342
+ if (!builtin || builtin.event === 'ignore')
343
+ return { event: 'unknown', payload, ignore: true };
344
+ return { event: 'post', payload: { ...builtin.payload, fcd_post_of: builtin.event }, ignore: false };
345
+ }
346
+ if (isMcp)
347
+ return { event: 'unknown', payload, ignore: true };
348
+ const builtin = normalizeCursorBuiltinTool(payload);
349
+ if (!builtin)
350
+ return null;
351
+ if (builtin.event === 'ignore')
352
+ return { event: 'unknown', payload, ignore: true };
353
+ return { event: builtin.event, payload: builtin.payload, ignore: false };
354
+ }
355
+ /**
356
+ * The FullCourtDefense MCP gateway already sits in front of this server.
357
+ * - Cursor reports the server's command line on `beforeMCPExecution`; ours is
358
+ * `<node> <cli> mcp-gateway --mcp-command|--mcp-url ... --agent-name ...`
359
+ * (Docker's MCP toolkit is `docker mcp gateway run` — no hyphen, no such flags).
360
+ * When a command line is present it is authoritative.
361
+ * - Claude Code / VS Code name the server (`mcp__<server>__<tool>`) but not its
362
+ * command: the server is looked up in THAT client's MCP configs (`client` is
363
+ * the hook's `--agent-client`, e.g. `claude-code` -> Claude Code's configs).
364
+ * Anything unknown is `false`: the hook then keeps its own guard, so an action
365
+ * is at worst confirmed twice, never zero times.
366
+ */
367
+ function isGatewayWrappedMcpServer(payload, client, cwd = process.cwd()) {
368
+ const command = str(payload.command);
369
+ if (command)
370
+ return /\bmcp-gateway\b/.test(command) && /--(?:mcp-command|mcp-url|mcp-args|agent-name)\b/.test(command);
371
+ const server = mcpServerName(payload);
372
+ if (!server)
373
+ return false;
374
+ const clientKey = client ? client.toLowerCase().replace(/-/g, '_') : undefined;
375
+ try {
376
+ return (0, discoverProxy_1.isServerBehindFcdGateway)(server, cwd, clientKey);
377
+ }
378
+ catch {
379
+ return false;
380
+ }
381
+ }
306
382
  /**
307
383
  * Which AI client invoked a Claude-format hook. Claude Code sets CLAUDECODE=1;
308
384
  * VS Code Copilot runs hooks inside the VS Code process environment.
@@ -1746,6 +1822,17 @@ async function enforceActionPolicy(ctx) {
1746
1822
  }
1747
1823
  // Record untrusted ingress for this event (never blocks).
1748
1824
  (0, taintLedger_1.noteIngress)(sessId, event, call.toolName, call.toolArgs);
1825
+ // ONE enforcement point per MCP call. A server the FullCourtDefense MCP gateway
1826
+ // wraps is checked by the gateway in-line — same vendored engine, same cached
1827
+ // policies, one developer dialog, one org approval, one audit record. Judging
1828
+ // it here as well gave one action two prompts (IDE ask + gateway dialog) and
1829
+ // two approval requests. Local Safety, taint and the self-protection guards
1830
+ // above still ran; only the Action Policy step is the gateway's.
1831
+ if (event === 'mcp' && isGatewayWrappedMcpServer(payload, ctx.client)) {
1832
+ (0, hookIo_1.dbg)({ phase: 'policy_deferred_to_gateway', event, tool: call.toolName, server: mcpServerName(payload) });
1833
+ respond(false);
1834
+ return;
1835
+ }
1749
1836
  const headers = { 'Content-Type': 'application/json' };
1750
1837
  if (shieldKey)
1751
1838
  headers['x-shield-key'] = shieldKey;
@@ -969,6 +969,12 @@ function describeDeveloperNo(answer) {
969
969
  explanation: 'You did not answer the confirmation prompt in time',
970
970
  };
971
971
  }
972
+ if (answer.answer === 'unavailable') {
973
+ return {
974
+ reason: 'developer confirmation not possible (no dialog can be shown on this machine and the MCP client offers no prompt)',
975
+ explanation: 'This rule needs your confirmation, but no confirmation prompt can be shown here (no display and the client cannot ask)',
976
+ };
977
+ }
972
978
  return {
973
979
  reason: `developer declined in ${via}${autoNote}`,
974
980
  explanation: 'You declined the confirmation prompt',
@@ -983,8 +989,8 @@ class McpGatewayServer {
983
989
  api;
984
990
  /**
985
991
  * The MCP client declared `capabilities.elicitation` on initialize — it can
986
- * show the user a server-initiated question. That is the only channel a stdio
987
- * proxy has to "Ask the developer"; without it an ask rule fails strict.
992
+ * show the user a server-initiated question. Used only when no OS dialog can be
993
+ * shown (headless) or when `FCD_CONFIRM_CHANNEL=mcp-client` — see confirmWithDeveloper.
988
994
  */
989
995
  clientSupportsElicitation = false;
990
996
  /** Server -> client requests (elicitation) awaiting the client's response, by id. */
@@ -1892,7 +1898,7 @@ class McpGatewayServer {
1892
1898
  });
1893
1899
  (0, telemetry_1.spoolEvent)({
1894
1900
  decision: 'approval', toolName, operation,
1895
- reason: `developer confirmation requested via ${this.clientSupportsElicitation ? 'MCP client prompt' : 'FullCourtDefense dialog'}${policyLabel}${verdict.reason ? `: ${verdict.reason}` : ''}`,
1901
+ reason: `developer confirmation requested via ${(0, devConfirm_1.nativeDialogAvailable)() ? 'FullCourtDefense dialog' : this.clientSupportsElicitation ? 'MCP client prompt' : 'FullCourtDefense dialog'}${policyLabel}${verdict.reason ? `: ${verdict.reason}` : ''}`,
1896
1902
  ruleId, offlineEnforced: true, approvalScope: 'developer',
1897
1903
  });
1898
1904
  (0, telemetry_1.triggerFlush)(false);
@@ -1929,41 +1935,60 @@ class McpGatewayServer {
1929
1935
  throw blockError(explainGatewayPolicy('block', toolName, toolArgs, verdict, policies, `${no.explanation}, so this tool call was not executed.`));
1930
1936
  }
1931
1937
  /**
1932
- * "Ask the developer" that works in every MCP client. Order:
1933
- * 1. no human on this machine => unavailable (caller blocks)
1934
- * 2. client advertises elicitation => in-client prompt
1935
- * accept / human-speed decline / timeout => that IS the developer's answer
1936
- * decline faster than a human can read (the client answered for the
1937
- * user — Codex Desktop "Full access" declines every elicitation without
1938
- * showing it) or an error response => fall through to 3
1939
- * 3. OS-owned dialog (PowerShell popup / osascript / zenity)
1940
- * Every path that is not an explicit "yes" from a human ends in a block.
1938
+ * "Ask the developer" — ONE dialog, the same in every MCP client.
1939
+ *
1940
+ * The FullCourtDefense OS dialog (PowerShell popup / osascript / zenity) is the
1941
+ * channel. It is owned by this process, not by the IDE, so the agent cannot
1942
+ * answer it and the client cannot answer it for the user. In-client prompts
1943
+ * (MCP elicitation) turned out to be unreliable exactly where it matters:
1944
+ * Codex Desktop "Full access" declines every elicitation in milliseconds,
1945
+ * Cursor 3.19 declines it after ~20s without rendering it — in both cases the
1946
+ * developer saw nothing and the audit said "declined". Order:
1947
+ * 1. no human on this machine => unavailable (caller blocks)
1948
+ * 2. the IDE hook's dialog already confirmed => accept (same call, not asked twice)
1949
+ * THIS exact call moments ago
1950
+ * 3. OS dialog => the developer's answer
1951
+ * 4. no dialog possible (headless, no display, => in-client prompt if the client
1952
+ * binary missing) supports it; a client that answers
1953
+ * for the user is still a "no"
1954
+ * `FCD_CONFIRM_CHANNEL=mcp-client` swaps 3 and 4 for clients whose in-chat prompt
1955
+ * an org has verified. Every path that is not an explicit "yes" from a human
1956
+ * ends in a block.
1941
1957
  */
1942
1958
  async confirmWithDeveloper(input) {
1943
1959
  if (!(0, devConfirm_1.isHumanPresent)())
1944
1960
  return { answer: 'unavailable', remember: false, latencyMs: 0, clientAutoAnswered: false };
1945
- // 0. The IDE hook already showed the OS dialog for THIS call (same tool, same
1946
- // args) and the developer clicked "Allow once" moments ago.
1947
1961
  const handoff = input.argsDigest ? (0, devConfirm_1.consumeConfirmedAction)(input.toolName, input.argsDigest) : undefined;
1948
1962
  if (handoff) {
1949
1963
  return { answer: 'accept', remember: false, latencyMs: Math.max(0, Date.now() - Date.parse(handoff.confirmedAt)), channel: 'hook_dialog', clientAutoAnswered: false };
1950
1964
  }
1965
+ const preferClient = (process.env.FCD_CONFIRM_CHANNEL || '').toLowerCase() === 'mcp-client' && this.clientSupportsElicitation;
1951
1966
  let clientAutoAnswered = false;
1952
- if (this.clientSupportsElicitation) {
1967
+ if (preferClient) {
1953
1968
  const viaClient = await this.elicitDeveloperConfirmation(input);
1954
1969
  const automated = viaClient.clientError || (0, devConfirm_1.looksLikeClientAutoAnswer)(viaClient.answer, viaClient.latencyMs);
1955
1970
  if (!automated)
1956
1971
  return { ...viaClient, channel: 'mcp_client', clientAutoAnswered: false };
1957
1972
  clientAutoAnswered = true;
1958
- process.stderr.write(`FullCourtDefense: the MCP client answered the confirmation prompt for ${input.toolName} by itself in ${viaClient.latencyMs}ms (no dialog reached the developer — e.g. Codex "Full access"); asking in the FullCourtDefense dialog instead.\n`);
1973
+ process.stderr.write(`FullCourtDefense: the MCP client answered the confirmation prompt for ${input.toolName} by itself in ${viaClient.latencyMs}ms (no prompt reached the developer); asking in the FullCourtDefense dialog instead.\n`);
1959
1974
  }
1960
- const started = Date.now();
1975
+ const dialog = await this.askInOsDialog(input, clientAutoAnswered);
1976
+ if (dialog.answer !== 'unavailable')
1977
+ return dialog;
1978
+ if (!preferClient && this.clientSupportsElicitation) {
1979
+ process.stderr.write(`FullCourtDefense: no dialog can be shown on this machine for ${input.toolName} (headless / no display); asking through the MCP client prompt instead.\n`);
1980
+ const viaClient = await this.elicitDeveloperConfirmation(input);
1981
+ const automated = viaClient.clientError || (0, devConfirm_1.looksLikeClientAutoAnswer)(viaClient.answer, viaClient.latencyMs);
1982
+ return { ...viaClient, answer: automated ? 'decline' : viaClient.answer, channel: 'mcp_client', clientAutoAnswered: automated };
1983
+ }
1984
+ return dialog;
1985
+ }
1986
+ async askInOsDialog(input, clientAutoAnswered) {
1961
1987
  const evidence = input.evidence ? `\nEvidence: ${input.evidence.slice(0, 240)}` : '';
1962
- const allowed = await (0, devConfirm_1.confirmNativeAction)(`Agent: ${this.gatewayConfig.agentName}\nIDE: ${this.gatewayConfig.agentClient}\nDeveloper: ${this.gatewayConfig.developerName || 'Unknown'}\nTool: ${input.toolName}\nRule: ${input.ruleLabel}\nWhy: ${input.reason}${evidence}\n\nYour answer is reported to your security dashboard as your decision.`, OS_DIALOG_TIMEOUT_MS);
1963
- const latencyMs = Date.now() - started;
1964
- // The dialog cannot tell "Deny" from "closed / timed out" — both are a no.
1965
- const timedOut = !allowed && latencyMs >= OS_DIALOG_TIMEOUT_MS - 500;
1966
- return { answer: allowed ? 'accept' : timedOut ? 'timeout' : 'decline', remember: false, latencyMs, channel: 'os_dialog', clientAutoAnswered };
1988
+ const dialog = await (0, devConfirm_1.confirmNativeActionDetailed)(`Agent: ${this.gatewayConfig.agentName}\nIDE: ${this.gatewayConfig.agentClient}\nDeveloper: ${this.gatewayConfig.developerName || 'Unknown'}\nTool: ${input.toolName}\nRule: ${input.ruleLabel}\nWhy: ${input.reason}${evidence}\n\nYour answer is reported to your security dashboard as your decision.`, OS_DIALOG_TIMEOUT_MS);
1989
+ process.stderr.write(`FullCourtDefense: dialog for ${input.toolName} ended with "${dialog.outcome}" after ${dialog.latencyMs}ms.\n`);
1990
+ const answer = dialog.outcome === 'allow' ? 'accept' : dialog.outcome === 'deny' ? 'decline' : dialog.outcome;
1991
+ return { answer, remember: false, latencyMs: dialog.latencyMs, channel: 'os_dialog', clientAutoAnswered };
1967
1992
  }
1968
1993
  async elicitDeveloperConfirmation(input) {
1969
1994
  const started = Date.now();
@@ -1990,6 +2015,9 @@ class McpGatewayServer {
1990
2015
  },
1991
2016
  }, ELICITATION_TIMEOUT_MS);
1992
2017
  const latencyMs = Date.now() - started;
2018
+ // One diagnostic line per answer — this is how "the developer saw no prompt but
2019
+ // the call was blocked" gets diagnosed from the IDE's MCP log alone.
2020
+ process.stderr.write(`FullCourtDefense: MCP client answered the confirmation prompt for ${input.toolName} in ${latencyMs}ms: ${response ? JSON.stringify(response.error ? { error: response.error } : response.result).slice(0, 300) : 'no answer (timeout)'}\n`);
1993
2021
  if (!response)
1994
2022
  return { answer: 'timeout', remember: false, latencyMs };
1995
2023
  // A JSON-RPC error ("method not found", "not supported in this mode") means no
@@ -19,6 +19,18 @@ export declare function looksLikeClientAutoAnswer(answer: 'accept' | 'decline' |
19
19
  * timeout, a missing dialog binary or no human present all resolve `false`.
20
20
  */
21
21
  export declare function confirmNativeAction(reason: string, timeoutMs?: number): Promise<boolean>;
22
+ /**
23
+ * How the OS dialog ended. `unavailable` = no dialog could be shown at all (no human,
24
+ * no display, dialog binary missing / failed to start) — the caller may try another
25
+ * channel. `deny` covers Deny, closing the window and the dialog's own give-up.
26
+ */
27
+ export type NativeDialogOutcome = 'allow' | 'deny' | 'timeout' | 'unavailable';
28
+ /** True when this process can put a dialog in front of a person on this machine. */
29
+ export declare function nativeDialogAvailable(): boolean;
30
+ export declare function confirmNativeActionDetailed(reason: string, timeoutMs?: number): Promise<{
31
+ outcome: NativeDialogOutcome;
32
+ latencyMs: number;
33
+ }>;
22
34
  export interface PendingConfirmation {
23
35
  /** Stable key: sha256(event|toolName|argsDigest). */
24
36
  key: string;
@@ -36,6 +36,8 @@ Object.defineProperty(exports, "__esModule", { value: true });
36
36
  exports.CONFIRMED_ACTION_TTL_MS = exports.CONFIRM_PENDING_TTL_MS = exports.MIN_HUMAN_ANSWER_MS = void 0;
37
37
  exports.looksLikeClientAutoAnswer = looksLikeClientAutoAnswer;
38
38
  exports.confirmNativeAction = confirmNativeAction;
39
+ exports.nativeDialogAvailable = nativeDialogAvailable;
40
+ exports.confirmNativeActionDetailed = confirmNativeActionDetailed;
39
41
  exports.isCiEnvironment = isCiEnvironment;
40
42
  exports.isHumanPresent = isHumanPresent;
41
43
  exports.actionDigest = actionDigest;
@@ -98,8 +100,20 @@ function looksLikeClientAutoAnswer(answer, latencyMs) {
98
100
  * timeout, a missing dialog binary or no human present all resolve `false`.
99
101
  */
100
102
  async function confirmNativeAction(reason, timeoutMs = 60_000) {
103
+ return (await confirmNativeActionDetailed(reason, timeoutMs)).outcome === 'allow';
104
+ }
105
+ /** True when this process can put a dialog in front of a person on this machine. */
106
+ function nativeDialogAvailable() {
101
107
  if (!isHumanPresent())
102
108
  return false;
109
+ if (process.platform === 'win32' || process.platform === 'darwin')
110
+ return true;
111
+ return Boolean(process.env.DISPLAY || process.env.WAYLAND_DISPLAY);
112
+ }
113
+ async function confirmNativeActionDetailed(reason, timeoutMs = 60_000) {
114
+ if (!nativeDialogAvailable())
115
+ return { outcome: 'unavailable', latencyMs: 0 };
116
+ const started = Date.now();
103
117
  let executable;
104
118
  let args;
105
119
  const message = `FullCourtDefense — developer approval\n\n${reason.slice(0, 1800)}\n\nAllow this action once?`;
@@ -123,18 +137,35 @@ async function confirmNativeAction(reason, timeoutMs = 60_000) {
123
137
  }
124
138
  return new Promise(resolve => {
125
139
  let finished = false;
126
- const child = (0, child_process_1.spawn)(executable, args, { stdio: 'ignore', windowsHide: true });
127
- const cancelChild = () => child.kill();
128
- process.once('exit', cancelChild);
129
- const done = (allowed) => { if (!finished) {
140
+ let child;
141
+ const done = (outcome) => {
142
+ if (finished)
143
+ return;
130
144
  finished = true;
131
145
  clearTimeout(timer);
132
146
  process.removeListener('exit', cancelChild);
133
- resolve(allowed);
134
- } };
135
- const timer = setTimeout(() => { child.kill(); done(false); }, timeoutMs + 1000);
136
- child.on('error', () => done(false));
137
- child.on('exit', code => done(code === 0));
147
+ resolve({ outcome, latencyMs: Date.now() - started });
148
+ };
149
+ const cancelChild = () => child?.kill();
150
+ const timer = setTimeout(() => { child?.kill(); done('timeout'); }, timeoutMs + 1000);
151
+ try {
152
+ child = (0, child_process_1.spawn)(executable, args, { stdio: 'ignore', windowsHide: true });
153
+ }
154
+ catch {
155
+ done('unavailable');
156
+ return;
157
+ }
158
+ process.once('exit', cancelChild);
159
+ // Spawn failure (binary missing, no permission) = nothing was shown. A child
160
+ // that ran and exited non-zero = the person (or the dialog's give-up) said no;
161
+ // the dialog cannot tell Deny from close from give-up, so a "no" that arrives
162
+ // at the deadline is reported as a timeout.
163
+ child.on('error', () => done('unavailable'));
164
+ child.on('exit', code => {
165
+ if (code === 0)
166
+ return done('allow');
167
+ done(Date.now() - started >= timeoutMs - 500 ? 'timeout' : 'deny');
168
+ });
138
169
  });
139
170
  }
140
171
  /** A raised confirmation that received no post-execution signal in time counts as not approved. */
package/dist/version.json CHANGED
@@ -1,3 +1,3 @@
1
1
  {
2
- "version": "1.34.6"
2
+ "version": "1.34.7"
3
3
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fullcourtdefense-cli",
3
- "version": "1.34.6",
3
+ "version": "1.34.7",
4
4
  "description": "Full Court Defense CLI — security scanning for AI agents from your terminal",
5
5
  "main": "dist/index.js",
6
6
  "bin": {