fullcourtdefense-cli 1.34.6 → 1.34.8
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/actionPolicyEngine.js +22 -0
- 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 +111 -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
|
@@ -96,6 +96,15 @@ exports.CONTENT_FACT_FIELDS = new Set([
|
|
|
96
96
|
]);
|
|
97
97
|
/** Tool / operation names that mean "a file on this machine": read_file, edit_file, write_file, create_file, append_to_file, str_replace, ... */
|
|
98
98
|
const LOCAL_FILE_ACTION_RE = /(?:^|_)(?:read|write|edit|create|append|save|touch|patch|replace|insert|update)(?:_(?:to|in|into))?_?(?:file|text|lines?|contents?)s?(?:$|_)|^(?:read|write|edit)$|^str_replace$/i;
|
|
99
|
+
/**
|
|
100
|
+
* Tool names that remove a FILE (delete_file, remove_file, unlink, rm_file, the
|
|
101
|
+
* IDE Delete mapped to delete_file). Deliberately narrow: `delete_directory`,
|
|
102
|
+
* `kubectl_delete`, `delete_object`, or any delete-verb tool that merely carries
|
|
103
|
+
* a `path` argument is NOT the `file` kind — those keep matching typed DELETE
|
|
104
|
+
* rules exactly as before, so an argument can never talk an action out of a
|
|
105
|
+
* destructive-scope rule.
|
|
106
|
+
*/
|
|
107
|
+
const FILE_REMOVAL_TOOL_RE = /(?:^|_)(?:delete|remove|rm|unlink)_?(?:file|path)s?(?:$|_)|^unlink$/i;
|
|
99
108
|
/** Does this rule need a content fact to fire? (A rule with no content constraint is about the ACTION, not the content.) */
|
|
100
109
|
function ruleNeedsContentFact(rule) {
|
|
101
110
|
return (rule.constraints || []).some(c => exports.CONTENT_FACT_FIELDS.has(c.field));
|
|
@@ -1668,6 +1677,19 @@ function inferToolContext(toolName, args) {
|
|
|
1668
1677
|
if (args.amount !== undefined || args.price !== undefined || args.total !== undefined) {
|
|
1669
1678
|
context.amount = String(args.amount ?? args.price ?? args.total);
|
|
1670
1679
|
}
|
|
1680
|
+
// WHAT KIND of action this is — the fact a typed rule means. One verb spans
|
|
1681
|
+
// four kinds: DELETE is `rm -rf /` (shell destructive scope), `DELETE FROM`
|
|
1682
|
+
// (SQL), `DELETE /api/x` (HTTP) and `delete_file` (one file). Policies are not
|
|
1683
|
+
// filtered by resourceType (an "ANY + containsSecret" rule filed under
|
|
1684
|
+
// `database` must still catch a token leaving through Slack), so a rule that
|
|
1685
|
+
// means only one kind — "Block destructive SHELL commands" — constrains on
|
|
1686
|
+
// this fact. Without it, cleaning up one temp file in the IDE was paused as a
|
|
1687
|
+
// destructive shell command. Derived from the branches above, never guessed.
|
|
1688
|
+
context['action.kind'] = isCodeExecTool || toolNameLower === 'shell' ? 'shell'
|
|
1689
|
+
: context.query !== undefined && operation !== toolName && /^(SELECT|INSERT|UPDATE|DELETE|DROP|CREATE|ALTER|TRUNCATE|GRANT|REVOKE|SHOW|DESCRIBE|EXPLAIN)$/.test(operation) ? 'database'
|
|
1690
|
+
: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'HEAD', 'OPTIONS'].includes(methodArg) ? 'http'
|
|
1691
|
+
: context.path !== undefined && (operation === 'read' || operation === 'write' || LOCAL_FILE_ACTION_RE.test(toolName) || FILE_REMOVAL_TOOL_RE.test(toolName)) ? 'file'
|
|
1692
|
+
: 'tool';
|
|
1671
1693
|
return { operation, context };
|
|
1672
1694
|
}
|
|
1673
1695
|
function inferInventoryToolOperations(toolName, toolActions = []) {
|
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) {
|
|
@@ -200,6 +203,17 @@ const BUILTIN_WRITE_TOOLS = new Set([
|
|
|
200
203
|
'write', 'edit', 'multiedit', 'notebookedit', 'createfile', 'editfiles', 'writefile', 'fswrite', 'replace',
|
|
201
204
|
'replacestringinfile', 'editfile', 'applypatch', 'strreplaceeditor', 'insertedittofile', 'strreplace', 'editnotebook',
|
|
202
205
|
]);
|
|
206
|
+
/**
|
|
207
|
+
* Built-ins that remove ONE file (Cursor `Delete`, Copilot `deleteFile`, …).
|
|
208
|
+
* Left unmapped they fell through as an unknown MCP tool named "Delete" — no
|
|
209
|
+
* path fact, operation = the tool name — and were judged as if a destructive
|
|
210
|
+
* shell command / SQL DELETE had been issued ("Block destructive shell
|
|
211
|
+
* commands" paused the clean-up of one temp file). Mapped, they are a FILE
|
|
212
|
+
* action: `delete_file {path}` → operation `delete`, `action.kind = file`,
|
|
213
|
+
* which the machine role ("Deletes pause for approval") and filesystem
|
|
214
|
+
* policies govern, and which typed DELETE rules exclude.
|
|
215
|
+
*/
|
|
216
|
+
const BUILTIN_DELETE_TOOLS = new Set(['delete', 'deletefile', 'removefile', 'fsdelete', 'rmfile', 'unlink', 'deletefiles']);
|
|
203
217
|
/**
|
|
204
218
|
* Built-ins that never touch a file, a command, a server or the outside world —
|
|
205
219
|
* nothing to enforce. Web fetch / web search are deliberately NOT here: they
|
|
@@ -246,6 +260,11 @@ function mapBuiltinToolPayload(toolName, input, payload) {
|
|
|
246
260
|
const content = (0, fileWriteCanon_1.newContentOnly)(filePath || undefined, delta || whole);
|
|
247
261
|
return { event: 'file', payload: { ...payload, file_path: filePath, content } };
|
|
248
262
|
}
|
|
263
|
+
if (BUILTIN_DELETE_TOOLS.has(canon)) {
|
|
264
|
+
const filePath = str(input.file_path) || str(input.path) || str(input.filePath) || str(input.target_file) || str(input.absolute_path)
|
|
265
|
+
|| (Array.isArray(input.files) ? input.files.map((f) => str(f)).filter(Boolean).join(', ') : '');
|
|
266
|
+
return { event: 'file', payload: { ...payload, file_path: filePath, fcd_file_op: 'delete' } };
|
|
267
|
+
}
|
|
249
268
|
if (BUILTIN_PASSIVE_TOOLS.has(canon))
|
|
250
269
|
return { event: 'ignore', payload };
|
|
251
270
|
return null;
|
|
@@ -293,6 +312,9 @@ function normalizeCursorBuiltinTool(payload) {
|
|
|
293
312
|
* IDE false-positive corpus (Layer A) evaluates exactly what the hook evaluates.
|
|
294
313
|
*/
|
|
295
314
|
function canonicalizeCursorPayload(argEvent, payload) {
|
|
315
|
+
const generic = canonicalizeCursorGenericToolEvent(payload);
|
|
316
|
+
if (generic)
|
|
317
|
+
return generic;
|
|
296
318
|
const event = inferEvent(argEvent, payload);
|
|
297
319
|
if (event !== 'mcp')
|
|
298
320
|
return { event, payload, ignore: false };
|
|
@@ -303,6 +325,76 @@ function canonicalizeCursorPayload(argEvent, payload) {
|
|
|
303
325
|
return { event: 'unknown', payload, ignore: true };
|
|
304
326
|
return { event: builtin.event, payload: builtin.payload, ignore: false };
|
|
305
327
|
}
|
|
328
|
+
/** Cursor names MCP tools `MCP:<tool>` in its generic tool events. */
|
|
329
|
+
const CURSOR_GENERIC_MCP_PREFIX = 'MCP:';
|
|
330
|
+
/**
|
|
331
|
+
* Cursor 3.19+ also fires GENERIC tool events — `preToolUse` / `postToolUse`
|
|
332
|
+
* (lower-camel `hook_event_name`, MCP tools named `MCP:<tool>`, built-ins by
|
|
333
|
+
* their tool name) — for the same action its dedicated hooks already report
|
|
334
|
+
* (`beforeMCPExecution` / `afterMCPExecution` / `beforeShellExecution` / ...).
|
|
335
|
+
* Evaluated as-is, one MCP call was judged three times: the generic pre-event
|
|
336
|
+
* (whose `MCP:` name matches no per-tool rule, so it fell through to broad
|
|
337
|
+
* company rules and raised a phantom manager approval), the dedicated
|
|
338
|
+
* pre-event, and the generic POST-event — judged as if the call were about to
|
|
339
|
+
* run, after it had already run.
|
|
340
|
+
*
|
|
341
|
+
* preToolUse -> ignore for MCP (beforeMCPExecution is the authoritative surface,
|
|
342
|
+
* same rule as Shell in normalizeCursorBuiltinTool); built-ins go
|
|
343
|
+
* through the shared taxonomy.
|
|
344
|
+
* postToolUse -> a post-execution correlation signal, never enforced.
|
|
345
|
+
* Returns null for every other payload.
|
|
346
|
+
*/
|
|
347
|
+
function canonicalizeCursorGenericToolEvent(payload) {
|
|
348
|
+
const hookEventName = str(payload.hook_event_name);
|
|
349
|
+
if (hookEventName !== 'preToolUse' && hookEventName !== 'postToolUse')
|
|
350
|
+
return null;
|
|
351
|
+
const toolName = str(payload.tool_name);
|
|
352
|
+
const isMcp = toolName.startsWith(CURSOR_GENERIC_MCP_PREFIX);
|
|
353
|
+
if (hookEventName === 'postToolUse') {
|
|
354
|
+
if (isMcp) {
|
|
355
|
+
return { event: 'post', payload: { ...payload, tool_name: toolName.slice(CURSOR_GENERIC_MCP_PREFIX.length), fcd_post_of: 'mcp' }, ignore: false };
|
|
356
|
+
}
|
|
357
|
+
const builtin = normalizeCursorBuiltinTool(payload);
|
|
358
|
+
if (!builtin || builtin.event === 'ignore')
|
|
359
|
+
return { event: 'unknown', payload, ignore: true };
|
|
360
|
+
return { event: 'post', payload: { ...builtin.payload, fcd_post_of: builtin.event }, ignore: false };
|
|
361
|
+
}
|
|
362
|
+
if (isMcp)
|
|
363
|
+
return { event: 'unknown', payload, ignore: true };
|
|
364
|
+
const builtin = normalizeCursorBuiltinTool(payload);
|
|
365
|
+
if (!builtin)
|
|
366
|
+
return null;
|
|
367
|
+
if (builtin.event === 'ignore')
|
|
368
|
+
return { event: 'unknown', payload, ignore: true };
|
|
369
|
+
return { event: builtin.event, payload: builtin.payload, ignore: false };
|
|
370
|
+
}
|
|
371
|
+
/**
|
|
372
|
+
* The FullCourtDefense MCP gateway already sits in front of this server.
|
|
373
|
+
* - Cursor reports the server's command line on `beforeMCPExecution`; ours is
|
|
374
|
+
* `<node> <cli> mcp-gateway --mcp-command|--mcp-url ... --agent-name ...`
|
|
375
|
+
* (Docker's MCP toolkit is `docker mcp gateway run` — no hyphen, no such flags).
|
|
376
|
+
* When a command line is present it is authoritative.
|
|
377
|
+
* - Claude Code / VS Code name the server (`mcp__<server>__<tool>`) but not its
|
|
378
|
+
* command: the server is looked up in THAT client's MCP configs (`client` is
|
|
379
|
+
* the hook's `--agent-client`, e.g. `claude-code` -> Claude Code's configs).
|
|
380
|
+
* Anything unknown is `false`: the hook then keeps its own guard, so an action
|
|
381
|
+
* is at worst confirmed twice, never zero times.
|
|
382
|
+
*/
|
|
383
|
+
function isGatewayWrappedMcpServer(payload, client, cwd = process.cwd()) {
|
|
384
|
+
const command = str(payload.command);
|
|
385
|
+
if (command)
|
|
386
|
+
return /\bmcp-gateway\b/.test(command) && /--(?:mcp-command|mcp-url|mcp-args|agent-name)\b/.test(command);
|
|
387
|
+
const server = mcpServerName(payload);
|
|
388
|
+
if (!server)
|
|
389
|
+
return false;
|
|
390
|
+
const clientKey = client ? client.toLowerCase().replace(/-/g, '_') : undefined;
|
|
391
|
+
try {
|
|
392
|
+
return (0, discoverProxy_1.isServerBehindFcdGateway)(server, cwd, clientKey);
|
|
393
|
+
}
|
|
394
|
+
catch {
|
|
395
|
+
return false;
|
|
396
|
+
}
|
|
397
|
+
}
|
|
306
398
|
/**
|
|
307
399
|
* Which AI client invoked a Claude-format hook. Claude Code sets CLAUDECODE=1;
|
|
308
400
|
* VS Code Copilot runs hooks inside the VS Code process environment.
|
|
@@ -412,6 +504,14 @@ function buildToolCall(event, p) {
|
|
|
412
504
|
}
|
|
413
505
|
case 'file': {
|
|
414
506
|
const filePath = str(p.file_path) || str(p.path);
|
|
507
|
+
// A built-in single-file removal (mapBuiltinToolPayload): no content to
|
|
508
|
+
// judge; the engine sees `delete_file {path}` → operation `delete`,
|
|
509
|
+
// `action.kind = file`.
|
|
510
|
+
if (str(p.fcd_file_op) === 'delete') {
|
|
511
|
+
if (!filePath)
|
|
512
|
+
return null;
|
|
513
|
+
return { toolName: 'delete_file', toolArgs: { path: filePath } };
|
|
514
|
+
}
|
|
415
515
|
const edits = p.edits;
|
|
416
516
|
const rawContent = Array.isArray(edits)
|
|
417
517
|
? edits.map((e) => str(e.new_text) || str(e.text) || str(e.content)).filter(Boolean).join('\n')
|
|
@@ -1746,6 +1846,17 @@ async function enforceActionPolicy(ctx) {
|
|
|
1746
1846
|
}
|
|
1747
1847
|
// Record untrusted ingress for this event (never blocks).
|
|
1748
1848
|
(0, taintLedger_1.noteIngress)(sessId, event, call.toolName, call.toolArgs);
|
|
1849
|
+
// ONE enforcement point per MCP call. A server the FullCourtDefense MCP gateway
|
|
1850
|
+
// wraps is checked by the gateway in-line — same vendored engine, same cached
|
|
1851
|
+
// policies, one developer dialog, one org approval, one audit record. Judging
|
|
1852
|
+
// it here as well gave one action two prompts (IDE ask + gateway dialog) and
|
|
1853
|
+
// two approval requests. Local Safety, taint and the self-protection guards
|
|
1854
|
+
// above still ran; only the Action Policy step is the gateway's.
|
|
1855
|
+
if (event === 'mcp' && isGatewayWrappedMcpServer(payload, ctx.client)) {
|
|
1856
|
+
(0, hookIo_1.dbg)({ phase: 'policy_deferred_to_gateway', event, tool: call.toolName, server: mcpServerName(payload) });
|
|
1857
|
+
respond(false);
|
|
1858
|
+
return;
|
|
1859
|
+
}
|
|
1749
1860
|
const headers = { 'Content-Type': 'application/json' };
|
|
1750
1861
|
if (shieldKey)
|
|
1751
1862
|
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