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.
- package/dist/commands/agentCi.js +2 -14
- package/dist/commands/discover.js +1 -14
- package/dist/commands/discoverProxy.d.ts +23 -0
- package/dist/commands/discoverProxy.js +94 -0
- package/dist/commands/hook.d.ts +35 -0
- package/dist/commands/hook.js +87 -0
- package/dist/commands/mcpGateway.js +50 -22
- package/dist/devConfirm.d.ts +12 -0
- package/dist/devConfirm.js +40 -9
- package/dist/version.json +1 -1
- package/package.json +1 -1
package/dist/commands/agentCi.js
CHANGED
|
@@ -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');
|
package/dist/commands/hook.d.ts
CHANGED
|
@@ -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.
|
package/dist/commands/hook.js
CHANGED
|
@@ -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.
|
|
987
|
-
*
|
|
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"
|
|
1933
|
-
*
|
|
1934
|
-
*
|
|
1935
|
-
*
|
|
1936
|
-
*
|
|
1937
|
-
*
|
|
1938
|
-
*
|
|
1939
|
-
*
|
|
1940
|
-
*
|
|
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 (
|
|
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
|
|
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
|
|
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
|
|
1963
|
-
|
|
1964
|
-
|
|
1965
|
-
|
|
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
|
package/dist/devConfirm.d.ts
CHANGED
|
@@ -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;
|
package/dist/devConfirm.js
CHANGED
|
@@ -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
|
-
|
|
127
|
-
const
|
|
128
|
-
|
|
129
|
-
|
|
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(
|
|
134
|
-
}
|
|
135
|
-
const
|
|
136
|
-
|
|
137
|
-
|
|
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