fullcourtdefense-cli 1.30.0 → 1.31.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/actionPolicyEngine.d.ts +1 -1
- package/dist/actionPolicyEngine.js +11 -0
- package/dist/commands/agentCi.d.ts +2 -0
- package/dist/commands/agentCi.js +14 -0
- package/dist/commands/discover.js +7 -2
- package/dist/commands/hook.d.ts +35 -0
- package/dist/commands/hook.js +131 -23
- package/dist/commands/mcpGateway.js +9 -3
- package/dist/fileWriteCanon.d.ts +27 -0
- package/dist/fileWriteCanon.js +107 -0
- package/dist/version.json +1 -1
- package/package.json +3 -1
|
@@ -157,7 +157,7 @@ export declare function toolCapabilities(value: string): Set<string>;
|
|
|
157
157
|
* - known token formats (GitHub, AWS, Google, OpenAI/Anthropic, Slack, JWT)
|
|
158
158
|
* - private-key PEM blocks
|
|
159
159
|
* - an ASSIGNMENT of a secret-named field to a REAL value:
|
|
160
|
-
* `password
|
|
160
|
+
* `password=<real value>`, `"api_key": "sk-…"`, `--token abc…` — where the
|
|
161
161
|
* value is not a placeholder (`<your-key>`, `xxxx`, `changeme`, `${VAR}`,
|
|
162
162
|
* `process.env.X`), not a code expression (`req.body.password`,
|
|
163
163
|
* `config.get('token')`) and has real-secret shape (>= 8 chars, >= 2
|
|
@@ -511,6 +511,17 @@ function isHighEntropySecretToken(token) {
|
|
|
511
511
|
// the password inside its userinfo can be a secret — judged as a value.
|
|
512
512
|
if (/^[a-z][a-z0-9+.-]*:\/\//i.test(token))
|
|
513
513
|
return looksLikeSecretValue(token);
|
|
514
|
+
// A generic token is TOKEN-SHAPED: base64 / base64url / hex / a prefixed id.
|
|
515
|
+
// Source code that happens to sit after `=` or `:` — call expressions
|
|
516
|
+
// (`parseInput(args.files`), member chains (`process.env.GITHUB_REPOSITORY`),
|
|
517
|
+
// regex literals, array indexing — carries parens, brackets and operators
|
|
518
|
+
// that no credential format uses. Structural, so it holds for every language.
|
|
519
|
+
if (!/^[A-Za-z0-9._~+/=-]+$/.test(token))
|
|
520
|
+
return false;
|
|
521
|
+
// A dotted chain of identifiers (`config.auth.clientSecret`, `DEFAULT_POLICY.scope`)
|
|
522
|
+
// is a reference to a value, not the value.
|
|
523
|
+
if (/^[A-Za-z_$][\w$]*(?:\.[A-Za-z_$][\w$]*)+$/.test(token))
|
|
524
|
+
return false;
|
|
514
525
|
if (/^[0-9a-f]+$/i.test(token))
|
|
515
526
|
return false; // hex digest / git sha
|
|
516
527
|
if (/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(token))
|
|
@@ -12,4 +12,6 @@ export interface AgentCiArgs {
|
|
|
12
12
|
apiKey?: string;
|
|
13
13
|
apiUrl?: string;
|
|
14
14
|
}
|
|
15
|
+
/** True when the path lives under the repository being gated (not the runner's home / system dirs). */
|
|
16
|
+
export declare function isInsideRepo(cwd: string, filePath?: string): boolean;
|
|
15
17
|
export declare function agentCiCommand(args: AgentCiArgs, config: BotGuardConfig): Promise<void>;
|
package/dist/commands/agentCi.js
CHANGED
|
@@ -33,6 +33,7 @@ var __importStar = (this && this.__importStar) || (function () {
|
|
|
33
33
|
};
|
|
34
34
|
})();
|
|
35
35
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
36
|
+
exports.isInsideRepo = isInsideRepo;
|
|
36
37
|
exports.agentCiCommand = agentCiCommand;
|
|
37
38
|
const crypto = __importStar(require("crypto"));
|
|
38
39
|
const child_process_1 = require("child_process");
|
|
@@ -201,6 +202,13 @@ function resolveChangedFiles(cwd, args) {
|
|
|
201
202
|
return null;
|
|
202
203
|
return new Set(files.map(file => normalizeRelativePath(cwd, file)));
|
|
203
204
|
}
|
|
205
|
+
/** True when the path lives under the repository being gated (not the runner's home / system dirs). */
|
|
206
|
+
function isInsideRepo(cwd, filePath) {
|
|
207
|
+
if (!filePath)
|
|
208
|
+
return false;
|
|
209
|
+
const rel = path.relative(path.resolve(cwd), path.resolve(cwd, filePath));
|
|
210
|
+
return rel !== '' && !rel.startsWith('..') && !path.isAbsolute(rel);
|
|
211
|
+
}
|
|
204
212
|
function isChangedFile(cwd, changedFiles, filePath) {
|
|
205
213
|
if (!changedFiles)
|
|
206
214
|
return true;
|
|
@@ -234,7 +242,13 @@ function evaluateAgentGate(args) {
|
|
|
234
242
|
const agentFiles = (0, discoverAgentFiles_1.scanAgentFiles)({ cwd })
|
|
235
243
|
.filter(file => isChangedFile(cwd, changedFiles, file.filePath));
|
|
236
244
|
const secrets = (0, discoverSecrets_1.scanSecrets)({ cwd, maxEnvDepth: 3 });
|
|
245
|
+
// agent-ci is a gate on the REPOSITORY. The secrets scanner also inspects the
|
|
246
|
+
// host's credential stores (~/.docker/config.json, ~/.kube/config, ~/.aws, …);
|
|
247
|
+
// on a CI runner those belong to the runner image, not to the code under
|
|
248
|
+
// review, and the developer cannot fix them from the PR. Host posture is the
|
|
249
|
+
// `discover` command's job — here only findings inside the repo count.
|
|
237
250
|
const secretFindings = secrets.findings
|
|
251
|
+
.filter(item => isInsideRepo(cwd, item.filePath))
|
|
238
252
|
.filter(item => isChangedFile(cwd, changedFiles, item.filePath));
|
|
239
253
|
const requireGateway = args.requireGateway !== 'false';
|
|
240
254
|
const requiredControls = parseRequiredControls(args.requireApprovalFor);
|
|
@@ -668,7 +668,7 @@ function describeMissingUploadCreds(config, creds) {
|
|
|
668
668
|
parts.push('no org API key');
|
|
669
669
|
return parts.join('; ') || 'unknown';
|
|
670
670
|
}
|
|
671
|
-
async function upload(servers, host, clientCoverage, apiUrl, auth, connectorName, extras) {
|
|
671
|
+
async function upload(servers, host, clientCoverage, apiUrl, auth, connectorName, extras, silent = false) {
|
|
672
672
|
const payload = {
|
|
673
673
|
connectorName: connectorName || `Desktop MCP · ${host.hostname}`,
|
|
674
674
|
host,
|
|
@@ -736,6 +736,11 @@ async function upload(servers, host, clientCoverage, apiUrl, auth, connectorName
|
|
|
736
736
|
// Any successful inventory upload satisfies the daemon's "initial discovery"
|
|
737
737
|
// requirement — onboard-time, manual, scheduled, or remote-action sweeps all count.
|
|
738
738
|
(0, discoveryMarker_1.writeDiscoveryUploadMarker)('discover_upload');
|
|
739
|
+
// Silent callers (agent-ci --format sarif|json, scheduled sweeps) own stdout:
|
|
740
|
+
// a human line here would land INSIDE the machine-readable document they are
|
|
741
|
+
// emitting and break the consumer (GitHub code scanning rejects the SARIF).
|
|
742
|
+
if (silent)
|
|
743
|
+
return;
|
|
739
744
|
const postureNote = extras?.posture ? ` · machine score ${extras.posture.score}/100 (${extras.posture.grade})` : '';
|
|
740
745
|
console.log(`${COLOR.green}Uploaded ${ingested} MCP server(s) from ${host.hostname} to your AI Inventory${postureNote}.${COLOR.reset}`);
|
|
741
746
|
}
|
|
@@ -1017,7 +1022,7 @@ async function discoverCommand(args, config) {
|
|
|
1017
1022
|
console.error(`discover --upload: missing credentials (${describeMissingUploadCreds(config, creds)}). Run \`fullcourtdefense login --token <fleet-enrollment-token>\`.`);
|
|
1018
1023
|
process.exit(1);
|
|
1019
1024
|
}
|
|
1020
|
-
await upload(found, host, clientCoverage, creds.apiUrl, { apiKey: creds.apiKey, shieldId: creds.shieldId, shieldKey: creds.shieldKey, preferApiKey: !!args.apiKey }, args.connectorName, uploadExtras);
|
|
1025
|
+
await upload(found, host, clientCoverage, creds.apiUrl, { apiKey: creds.apiKey, shieldId: creds.shieldId, shieldKey: creds.shieldKey, preferApiKey: !!args.apiKey }, args.connectorName, uploadExtras, silent || args.json === 'true');
|
|
1021
1026
|
}
|
|
1022
1027
|
if (args.json === 'true') {
|
|
1023
1028
|
await maybeUpload();
|
package/dist/commands/hook.d.ts
CHANGED
|
@@ -53,6 +53,41 @@ export declare function normalizeClaudePayload(payload: Record<string, unknown>)
|
|
|
53
53
|
event: HookEvent | 'ignore';
|
|
54
54
|
payload: Record<string, unknown>;
|
|
55
55
|
} | null;
|
|
56
|
+
export declare function canonicalBuiltinToolName(toolName: string): string;
|
|
57
|
+
/**
|
|
58
|
+
* Map a built-in IDE tool (any client) onto our event taxonomy. Returns null
|
|
59
|
+
* for a tool name this layer does not recognize so the caller keeps its own
|
|
60
|
+
* default (Claude: ignore; Cursor: treat as an MCP tool).
|
|
61
|
+
*/
|
|
62
|
+
export declare function mapBuiltinToolPayload(toolName: string, input: Record<string, unknown>, payload: Record<string, unknown>): {
|
|
63
|
+
event: HookEvent | 'ignore';
|
|
64
|
+
payload: Record<string, unknown>;
|
|
65
|
+
} | null;
|
|
66
|
+
/**
|
|
67
|
+
* Cursor delivers its OWN built-in tools (Shell / Read / Write / Grep / …)
|
|
68
|
+
* through the same execution hook as MCP servers, with `tool_use_id` + no
|
|
69
|
+
* server identity (`mcp_server_name` / `url` / `command` are MCP-only). Route
|
|
70
|
+
* them through the shared taxonomy so an IDE edit is a FILE action, a command
|
|
71
|
+
* is a SHELL action, and neither is ever judged as an unknown MCP tool.
|
|
72
|
+
*
|
|
73
|
+
* Shell is dropped here on purpose: Cursor also fires `beforeShellExecution`
|
|
74
|
+
* for the same command, which is the authoritative shell surface — evaluating
|
|
75
|
+
* it twice produced two (sometimes different) verdicts for one action.
|
|
76
|
+
*/
|
|
77
|
+
export declare function normalizeCursorBuiltinTool(payload: Record<string, unknown>): {
|
|
78
|
+
event: HookEvent | 'ignore';
|
|
79
|
+
payload: Record<string, unknown>;
|
|
80
|
+
} | null;
|
|
81
|
+
/**
|
|
82
|
+
* Cursor-format entry: infer the event from the `--event` flag / payload shape,
|
|
83
|
+
* then route Cursor built-in tools through the shared taxonomy. Exported so the
|
|
84
|
+
* IDE false-positive corpus (Layer A) evaluates exactly what the hook evaluates.
|
|
85
|
+
*/
|
|
86
|
+
export declare function canonicalizeCursorPayload(argEvent: string | undefined, payload: Record<string, unknown>): {
|
|
87
|
+
event: HookEvent;
|
|
88
|
+
payload: Record<string, unknown>;
|
|
89
|
+
ignore: boolean;
|
|
90
|
+
};
|
|
56
91
|
/**
|
|
57
92
|
* Map a Cursor execution-hook payload onto an Action Policy tool call.
|
|
58
93
|
* Returns null when there is nothing actionable to evaluate.
|
package/dist/commands/hook.js
CHANGED
|
@@ -34,6 +34,10 @@ var __importStar = (this && this.__importStar) || (function () {
|
|
|
34
34
|
})();
|
|
35
35
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
36
36
|
exports.normalizeClaudePayload = normalizeClaudePayload;
|
|
37
|
+
exports.canonicalBuiltinToolName = canonicalBuiltinToolName;
|
|
38
|
+
exports.mapBuiltinToolPayload = mapBuiltinToolPayload;
|
|
39
|
+
exports.normalizeCursorBuiltinTool = normalizeCursorBuiltinTool;
|
|
40
|
+
exports.canonicalizeCursorPayload = canonicalizeCursorPayload;
|
|
37
41
|
exports.buildToolCall = buildToolCall;
|
|
38
42
|
exports.classifyGateHttpFailure = classifyGateHttpFailure;
|
|
39
43
|
exports.gateHttpFailureDetail = gateHttpFailureDetail;
|
|
@@ -59,6 +63,7 @@ const devConfirm_1 = require("../devConfirm");
|
|
|
59
63
|
// Shared with the slim hook entry (hookSlim.ts) so both entries log to the
|
|
60
64
|
// same rotated hook.log and read stdin identically.
|
|
61
65
|
const hookIo_1 = require("../hookIo");
|
|
66
|
+
const fileWriteCanon_1 = require("../fileWriteCanon");
|
|
62
67
|
function inferEvent(explicit, payload) {
|
|
63
68
|
const e = (explicit || '').toLowerCase();
|
|
64
69
|
if (e) {
|
|
@@ -163,36 +168,135 @@ function mapClaudeToolPayload(payload) {
|
|
|
163
168
|
payload: { ...payload, tool_name: shortTool, mcp_server_name: server, tool_input: input },
|
|
164
169
|
};
|
|
165
170
|
}
|
|
166
|
-
//
|
|
167
|
-
//
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
171
|
+
// Glob/Grep/Task/WebFetch/etc. — no enforcement surface; let the client's own
|
|
172
|
+
// permission flow handle them.
|
|
173
|
+
return mapBuiltinToolPayload(toolName, input, payload) ?? { event: 'ignore', payload };
|
|
174
|
+
}
|
|
175
|
+
// ---------------------------------------------------------------------------
|
|
176
|
+
// Built-in IDE tools — ONE taxonomy for every client.
|
|
177
|
+
//
|
|
178
|
+
// Cursor, Claude Code, VS Code Copilot and Copilot CLI each name the same three
|
|
179
|
+
// primitive actions differently (Cursor `Write`, Claude `Edit`, VS Code
|
|
180
|
+
// `replaceStringInFile`; Cursor `Shell`, Claude `Bash`, VS Code `runInTerminal`).
|
|
181
|
+
// They also ship different payload shapes for a file edit: Claude sends the
|
|
182
|
+
// old/new strings, Cursor's `Write` and MCP `write_file` send the WHOLE resulting
|
|
183
|
+
// file. If those shapes reach the policy engine as-is, the same developer action
|
|
184
|
+
// gets a different verdict per client — and a whole-file write is judged as if
|
|
185
|
+
// the agent were sending the entire file somewhere, so a credential-shaped
|
|
186
|
+
// literal that has sat in a test fixture for years blocks every edit of that file.
|
|
187
|
+
//
|
|
188
|
+
// This layer canonicalizes BEFORE the engine ("capture -> canonicalize ->
|
|
189
|
+
// decide -> enforce"): a file edit is `edit_file { path, content }` where
|
|
190
|
+
// `content` is only what the edit ADDS; a command is `shell { command, cwd }`;
|
|
191
|
+
// a file read is `read_file { path }`. Built-ins with no enforcement surface
|
|
192
|
+
// (search, task, web) are ignored. Nothing here decides anything.
|
|
193
|
+
// ---------------------------------------------------------------------------
|
|
194
|
+
const BUILTIN_SHELL_TOOLS = new Set(['bash', 'shell', 'runterminalcommand', 'runinterminal', 'powershell', 'terminal']);
|
|
195
|
+
const BUILTIN_READ_TOOLS = new Set(['read', 'readfile', 'view']);
|
|
196
|
+
const BUILTIN_WRITE_TOOLS = new Set([
|
|
197
|
+
'write', 'edit', 'multiedit', 'notebookedit', 'createfile', 'editfiles',
|
|
198
|
+
'replacestringinfile', 'editfile', 'applypatch', 'strreplaceeditor', 'insertedittofile', 'strreplace', 'editnotebook',
|
|
199
|
+
]);
|
|
200
|
+
/**
|
|
201
|
+
* Built-ins that never touch a file, a command, a server or the outside world —
|
|
202
|
+
* nothing to enforce. Web fetch / web search are deliberately NOT here: they
|
|
203
|
+
* are INGRESS for the taint ledger (untrusted content entering the session)
|
|
204
|
+
* and keep flowing through the MCP path exactly as before.
|
|
205
|
+
*/
|
|
206
|
+
const BUILTIN_PASSIVE_TOOLS = new Set([
|
|
207
|
+
'grep', 'glob', 'ls', 'listdir', 'codebasesearch', 'semanticsearch',
|
|
208
|
+
'task', 'todowrite', 'readlints', 'switchmode', 'askquestion', 'sendtouser', 'awaitshell',
|
|
209
|
+
'getdynamictools',
|
|
210
|
+
]);
|
|
211
|
+
function canonicalBuiltinToolName(toolName) {
|
|
212
|
+
return (0, fileWriteCanon_1.canonicalToolName)(toolName);
|
|
213
|
+
}
|
|
214
|
+
/**
|
|
215
|
+
* Map a built-in IDE tool (any client) onto our event taxonomy. Returns null
|
|
216
|
+
* for a tool name this layer does not recognize so the caller keeps its own
|
|
217
|
+
* default (Claude: ignore; Cursor: treat as an MCP tool).
|
|
218
|
+
*/
|
|
219
|
+
function mapBuiltinToolPayload(toolName, input, payload) {
|
|
220
|
+
const canon = canonicalBuiltinToolName(toolName);
|
|
221
|
+
if (BUILTIN_SHELL_TOOLS.has(canon)) {
|
|
176
222
|
return {
|
|
177
223
|
event: 'shell',
|
|
178
|
-
payload: { ...payload, command: str(input.command), cwd: str(input.cwd) || str(payload.cwd) || undefined },
|
|
224
|
+
payload: { ...payload, command: str(input.command), cwd: str(input.cwd) || str(input.working_directory) || str(payload.cwd) || undefined },
|
|
179
225
|
};
|
|
180
226
|
}
|
|
181
|
-
if (
|
|
182
|
-
return { event: 'read', payload: { ...payload, file_path: str(input.file_path) || str(input.path) } };
|
|
227
|
+
if (BUILTIN_READ_TOOLS.has(canon)) {
|
|
228
|
+
return { event: 'read', payload: { ...payload, file_path: str(input.file_path) || str(input.path) || str(input.target_file) } };
|
|
183
229
|
}
|
|
184
|
-
if (
|
|
185
|
-
const
|
|
230
|
+
if (BUILTIN_WRITE_TOOLS.has(canon)) {
|
|
231
|
+
const filePath = str(input.file_path) || str(input.notebook_path) || str(input.path) || str(input.filePath) || str(input.target_file)
|
|
232
|
+
|| (Array.isArray(input.files) ? input.files.map((f) => str(f)).filter(Boolean).join(', ') : '');
|
|
233
|
+
// Delta shapes first (old/new strings, edit lists): they already carry only
|
|
234
|
+
// what changes. Whole-file shapes (`content`, `contents`, `code`,
|
|
235
|
+
// `new_source`) are reduced to the lines they add.
|
|
236
|
+
const delta = str(input.new_string) || str(input.new_str) || str(input.new_text)
|
|
186
237
|
|| (Array.isArray(input.edits)
|
|
187
|
-
? input.edits.map((e) => str(e?.new_string) || str(e?.content)).filter(Boolean).join('\n')
|
|
238
|
+
? input.edits.map((e) => str(e?.new_string) || str(e?.new_text) || str(e?.newText) || str(e?.content)).filter(Boolean).join('\n')
|
|
188
239
|
: '');
|
|
189
|
-
const
|
|
190
|
-
|
|
240
|
+
const whole = str(input.content) || str(input.contents) || str(input.new_source) || str(input.code);
|
|
241
|
+
const content = delta || (0, fileWriteCanon_1.newContentOnly)(filePath || undefined, whole);
|
|
191
242
|
return { event: 'file', payload: { ...payload, file_path: filePath, content } };
|
|
192
243
|
}
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
return
|
|
244
|
+
if (BUILTIN_PASSIVE_TOOLS.has(canon))
|
|
245
|
+
return { event: 'ignore', payload };
|
|
246
|
+
return null;
|
|
247
|
+
}
|
|
248
|
+
/**
|
|
249
|
+
* Cursor delivers its OWN built-in tools (Shell / Read / Write / Grep / …)
|
|
250
|
+
* through the same execution hook as MCP servers, with `tool_use_id` + no
|
|
251
|
+
* server identity (`mcp_server_name` / `url` / `command` are MCP-only). Route
|
|
252
|
+
* them through the shared taxonomy so an IDE edit is a FILE action, a command
|
|
253
|
+
* is a SHELL action, and neither is ever judged as an unknown MCP tool.
|
|
254
|
+
*
|
|
255
|
+
* Shell is dropped here on purpose: Cursor also fires `beforeShellExecution`
|
|
256
|
+
* for the same command, which is the authoritative shell surface — evaluating
|
|
257
|
+
* it twice produced two (sometimes different) verdicts for one action.
|
|
258
|
+
*/
|
|
259
|
+
function normalizeCursorBuiltinTool(payload) {
|
|
260
|
+
const toolName = str(payload.tool_name);
|
|
261
|
+
if (!toolName)
|
|
262
|
+
return null;
|
|
263
|
+
if (str(payload.mcp_server_name) || str(payload.server_name) || str(payload.mcp_server_url) || str(payload.url) || str(payload.command))
|
|
264
|
+
return null;
|
|
265
|
+
if (toolName.startsWith('mcp__') || toolName.startsWith('MCP:'))
|
|
266
|
+
return null;
|
|
267
|
+
let toolInput = payload.tool_input;
|
|
268
|
+
if (typeof toolInput === 'string') {
|
|
269
|
+
const trimmed = toolInput.trim();
|
|
270
|
+
if (trimmed.startsWith('{') || trimmed.startsWith('[')) {
|
|
271
|
+
try {
|
|
272
|
+
toolInput = JSON.parse(trimmed);
|
|
273
|
+
}
|
|
274
|
+
catch { /* keep raw */ }
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
const input = (toolInput && typeof toolInput === 'object' ? toolInput : {});
|
|
278
|
+
const mapped = mapBuiltinToolPayload(toolName, input, payload);
|
|
279
|
+
if (!mapped)
|
|
280
|
+
return null;
|
|
281
|
+
if (mapped.event === 'shell')
|
|
282
|
+
return { event: 'ignore', payload };
|
|
283
|
+
return mapped;
|
|
284
|
+
}
|
|
285
|
+
/**
|
|
286
|
+
* Cursor-format entry: infer the event from the `--event` flag / payload shape,
|
|
287
|
+
* then route Cursor built-in tools through the shared taxonomy. Exported so the
|
|
288
|
+
* IDE false-positive corpus (Layer A) evaluates exactly what the hook evaluates.
|
|
289
|
+
*/
|
|
290
|
+
function canonicalizeCursorPayload(argEvent, payload) {
|
|
291
|
+
const event = inferEvent(argEvent, payload);
|
|
292
|
+
if (event !== 'mcp')
|
|
293
|
+
return { event, payload, ignore: false };
|
|
294
|
+
const builtin = normalizeCursorBuiltinTool(payload);
|
|
295
|
+
if (!builtin)
|
|
296
|
+
return { event, payload, ignore: false };
|
|
297
|
+
if (builtin.event === 'ignore')
|
|
298
|
+
return { event: 'unknown', payload, ignore: true };
|
|
299
|
+
return { event: builtin.event, payload: builtin.payload, ignore: false };
|
|
196
300
|
}
|
|
197
301
|
/**
|
|
198
302
|
* Which AI client invoked a Claude-format hook. Claude Code sets CLAUDECODE=1;
|
|
@@ -298,7 +402,8 @@ function buildToolCall(event, p) {
|
|
|
298
402
|
}
|
|
299
403
|
}
|
|
300
404
|
const toolArgs = rawInput && typeof rawInput === 'object' ? rawInput : { value: rawInput };
|
|
301
|
-
|
|
405
|
+
// MCP filesystem servers write whole files; judge only what the write adds.
|
|
406
|
+
return { toolName, toolArgs: (0, fileWriteCanon_1.canonicalizeFileWriteArgs)(toolName, toolArgs) };
|
|
302
407
|
}
|
|
303
408
|
case 'file': {
|
|
304
409
|
const filePath = str(p.file_path) || str(p.path);
|
|
@@ -1229,7 +1334,10 @@ async function hookCommandInner(args, config, io) {
|
|
|
1229
1334
|
}
|
|
1230
1335
|
}
|
|
1231
1336
|
else {
|
|
1232
|
-
|
|
1337
|
+
const cursor = canonicalizeCursorPayload(args.event, payload);
|
|
1338
|
+
event = cursor.event;
|
|
1339
|
+
payload = cursor.payload;
|
|
1340
|
+
ignoreEvent = cursor.ignore;
|
|
1233
1341
|
}
|
|
1234
1342
|
(0, hookIo_1.dbg)({ phase: 'invoke', event, argEvent: args.event, format: hookFormat, client, pid: process.pid,
|
|
1235
1343
|
isTTY, rawLen: raw.length, ...(hookIo_1.HOOK_DEBUG_VERBOSE ? { rawPreview: raw.slice(0, 300) } : {}), stdinErr, parseErr,
|
|
@@ -62,6 +62,7 @@ const runtimeConfig_1 = require("../runtimeConfig");
|
|
|
62
62
|
const sessionLimits_1 = require("../sessionLimits");
|
|
63
63
|
const telemetry_1 = require("../telemetry");
|
|
64
64
|
const notify_1 = require("../notify");
|
|
65
|
+
const fileWriteCanon_1 = require("../fileWriteCanon");
|
|
65
66
|
const blockExplanation_1 = require("../blockExplanation");
|
|
66
67
|
const distress_1 = require("../distress");
|
|
67
68
|
const policyGateHealth_1 = require("../policyGateHealth");
|
|
@@ -1092,7 +1093,12 @@ class McpGatewayServer {
|
|
|
1092
1093
|
const toolName = String(params.name || '');
|
|
1093
1094
|
if (!toolName)
|
|
1094
1095
|
throw new Error('Missing MCP tool name.');
|
|
1095
|
-
|
|
1096
|
+
// What the server receives is untouched (`forwardArgs`). What the policy
|
|
1097
|
+
// decision sees is the canonical action: a whole-file write is reduced to
|
|
1098
|
+
// the lines it adds so a secret already sitting in the file never blocks
|
|
1099
|
+
// editing it (see fileWriteCanon.ts).
|
|
1100
|
+
const forwardArgs = params.arguments && typeof params.arguments === 'object' ? params.arguments : {};
|
|
1101
|
+
const toolArgs = (0, fileWriteCanon_1.canonicalizeFileWriteArgs)(toolName, forwardArgs);
|
|
1096
1102
|
(0, telemetry_1.setVerdictToolArgs)(toolArgs);
|
|
1097
1103
|
let approvalActionId;
|
|
1098
1104
|
let operation = typeof params.operation === 'string' && params.operation.trim() ? params.operation.trim() : undefined;
|
|
@@ -1528,7 +1534,7 @@ class McpGatewayServer {
|
|
|
1528
1534
|
}
|
|
1529
1535
|
}
|
|
1530
1536
|
downstreamAttempted = true;
|
|
1531
|
-
let rawResult = await this.downstream.callTool(toolName,
|
|
1537
|
+
let rawResult = await this.downstream.callTool(toolName, forwardArgs);
|
|
1532
1538
|
downstreamRan = true;
|
|
1533
1539
|
downstreamResult = rawResult;
|
|
1534
1540
|
// Response-side events must measure our scanning overhead, not the
|
|
@@ -1707,7 +1713,7 @@ class McpGatewayServer {
|
|
|
1707
1713
|
if (!isBlockError(error) && !downstreamAttempted) {
|
|
1708
1714
|
// Pre-call bug: forward the tool call now (exactly once).
|
|
1709
1715
|
try {
|
|
1710
|
-
const recovered = await this.downstream.callTool(toolName,
|
|
1716
|
+
const recovered = await this.downstream.callTool(toolName, forwardArgs);
|
|
1711
1717
|
try {
|
|
1712
1718
|
(0, telemetry_1.spoolEvent)({ decision: 'allow', toolName, operation, reason: `gateway error failed open (pre-call): ${message.slice(0, 200)}`, offlineEnforced: true });
|
|
1713
1719
|
(0, telemetry_1.triggerFlush)(false);
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Canonicalization of FILE WRITES before any policy decision.
|
|
3
|
+
*
|
|
4
|
+
* Clients ship a file edit in two shapes: a delta (Claude `Edit` old/new
|
|
5
|
+
* strings, Cursor `afterFileEdit` edits) or the WHOLE resulting file (Cursor's
|
|
6
|
+
* `Write`, MCP filesystem `write_file`, VS Code `createFile`). Judging the whole
|
|
7
|
+
* file as "tool arguments" means a credential-shaped literal that has sat in a
|
|
8
|
+
* test fixture for years blocks every edit of that file, in every policy that
|
|
9
|
+
* looks at `toolArgs.containsSecret` — and the verdict differs per client for
|
|
10
|
+
* the same developer action.
|
|
11
|
+
*
|
|
12
|
+
* `newContentOnly` reduces a whole-file write to the lines it ADDS relative to
|
|
13
|
+
* the file on disk, which is exactly what the delta shapes already carry. Every
|
|
14
|
+
* client then yields the identical canonical action `edit_file { path, content }`.
|
|
15
|
+
* Deterministic, reads only the target path, decides nothing.
|
|
16
|
+
*/
|
|
17
|
+
/** Largest on-disk file we diff against (bigger = treat all content as new). */
|
|
18
|
+
export declare const DELTA_MAX_FILE_BYTES: number;
|
|
19
|
+
export declare function canonicalToolName(toolName: string): string;
|
|
20
|
+
export declare function newContentOnly(filePath: string | undefined, content: string): string;
|
|
21
|
+
/**
|
|
22
|
+
* For a whole-file write tool (MCP `write_file`, …) with `{ path, content }`
|
|
23
|
+
* arguments, replace `content` with only the added lines. Any other tool or
|
|
24
|
+
* shape is returned untouched (same object identity), so callers can apply it
|
|
25
|
+
* unconditionally.
|
|
26
|
+
*/
|
|
27
|
+
export declare function canonicalizeFileWriteArgs(toolName: string, args: Record<string, unknown>): Record<string, unknown>;
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
3
|
+
if (k2 === undefined) k2 = k;
|
|
4
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
5
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
6
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
7
|
+
}
|
|
8
|
+
Object.defineProperty(o, k2, desc);
|
|
9
|
+
}) : (function(o, m, k, k2) {
|
|
10
|
+
if (k2 === undefined) k2 = k;
|
|
11
|
+
o[k2] = m[k];
|
|
12
|
+
}));
|
|
13
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
14
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
15
|
+
}) : function(o, v) {
|
|
16
|
+
o["default"] = v;
|
|
17
|
+
});
|
|
18
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
19
|
+
var ownKeys = function(o) {
|
|
20
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
21
|
+
var ar = [];
|
|
22
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
23
|
+
return ar;
|
|
24
|
+
};
|
|
25
|
+
return ownKeys(o);
|
|
26
|
+
};
|
|
27
|
+
return function (mod) {
|
|
28
|
+
if (mod && mod.__esModule) return mod;
|
|
29
|
+
var result = {};
|
|
30
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
31
|
+
__setModuleDefault(result, mod);
|
|
32
|
+
return result;
|
|
33
|
+
};
|
|
34
|
+
})();
|
|
35
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
36
|
+
exports.DELTA_MAX_FILE_BYTES = void 0;
|
|
37
|
+
exports.canonicalToolName = canonicalToolName;
|
|
38
|
+
exports.newContentOnly = newContentOnly;
|
|
39
|
+
exports.canonicalizeFileWriteArgs = canonicalizeFileWriteArgs;
|
|
40
|
+
const fs = __importStar(require("fs"));
|
|
41
|
+
/**
|
|
42
|
+
* Canonicalization of FILE WRITES before any policy decision.
|
|
43
|
+
*
|
|
44
|
+
* Clients ship a file edit in two shapes: a delta (Claude `Edit` old/new
|
|
45
|
+
* strings, Cursor `afterFileEdit` edits) or the WHOLE resulting file (Cursor's
|
|
46
|
+
* `Write`, MCP filesystem `write_file`, VS Code `createFile`). Judging the whole
|
|
47
|
+
* file as "tool arguments" means a credential-shaped literal that has sat in a
|
|
48
|
+
* test fixture for years blocks every edit of that file, in every policy that
|
|
49
|
+
* looks at `toolArgs.containsSecret` — and the verdict differs per client for
|
|
50
|
+
* the same developer action.
|
|
51
|
+
*
|
|
52
|
+
* `newContentOnly` reduces a whole-file write to the lines it ADDS relative to
|
|
53
|
+
* the file on disk, which is exactly what the delta shapes already carry. Every
|
|
54
|
+
* client then yields the identical canonical action `edit_file { path, content }`.
|
|
55
|
+
* Deterministic, reads only the target path, decides nothing.
|
|
56
|
+
*/
|
|
57
|
+
/** Largest on-disk file we diff against (bigger = treat all content as new). */
|
|
58
|
+
exports.DELTA_MAX_FILE_BYTES = 8 * 1024 * 1024;
|
|
59
|
+
/** MCP / IDE tool names (case- and separator-insensitive) that write a whole file. */
|
|
60
|
+
const WHOLE_FILE_WRITE_TOOLS = new Set(['writefile', 'createfile', 'write', 'putfile', 'savefile', 'createorupdatefile']);
|
|
61
|
+
function canonicalToolName(toolName) {
|
|
62
|
+
return toolName.toLowerCase().replace(/[_-]/g, '');
|
|
63
|
+
}
|
|
64
|
+
function newContentOnly(filePath, content) {
|
|
65
|
+
if (!filePath || !content)
|
|
66
|
+
return content;
|
|
67
|
+
let existing;
|
|
68
|
+
try {
|
|
69
|
+
const stat = fs.statSync(filePath);
|
|
70
|
+
if (!stat.isFile() || stat.size > exports.DELTA_MAX_FILE_BYTES)
|
|
71
|
+
return content;
|
|
72
|
+
existing = fs.readFileSync(filePath, 'utf8');
|
|
73
|
+
}
|
|
74
|
+
catch {
|
|
75
|
+
return content; // new file (or unreadable) — everything is new
|
|
76
|
+
}
|
|
77
|
+
if (existing === content)
|
|
78
|
+
return '';
|
|
79
|
+
const known = new Set(existing.split(/\r?\n/).map(line => line.trim()).filter(Boolean));
|
|
80
|
+
const added = content.split(/\r?\n/).filter(line => {
|
|
81
|
+
const t = line.trim();
|
|
82
|
+
return t && !known.has(t);
|
|
83
|
+
});
|
|
84
|
+
return added.join('\n');
|
|
85
|
+
}
|
|
86
|
+
/**
|
|
87
|
+
* For a whole-file write tool (MCP `write_file`, …) with `{ path, content }`
|
|
88
|
+
* arguments, replace `content` with only the added lines. Any other tool or
|
|
89
|
+
* shape is returned untouched (same object identity), so callers can apply it
|
|
90
|
+
* unconditionally.
|
|
91
|
+
*/
|
|
92
|
+
function canonicalizeFileWriteArgs(toolName, args) {
|
|
93
|
+
if (!WHOLE_FILE_WRITE_TOOLS.has(canonicalToolName(toolName)))
|
|
94
|
+
return args;
|
|
95
|
+
const filePath = typeof args.path === 'string' ? args.path
|
|
96
|
+
: typeof args.file_path === 'string' ? args.file_path
|
|
97
|
+
: typeof args.filePath === 'string' ? args.filePath
|
|
98
|
+
: undefined;
|
|
99
|
+
const contentKey = typeof args.content === 'string' ? 'content' : typeof args.contents === 'string' ? 'contents' : undefined;
|
|
100
|
+
if (!filePath || !contentKey)
|
|
101
|
+
return args;
|
|
102
|
+
const whole = args[contentKey];
|
|
103
|
+
const delta = newContentOnly(filePath, whole);
|
|
104
|
+
if (delta === whole)
|
|
105
|
+
return args;
|
|
106
|
+
return { ...args, [contentKey]: delta };
|
|
107
|
+
}
|
package/dist/version.json
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "fullcourtdefense-cli",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.31.0",
|
|
4
4
|
"description": "Full Court Defense CLI — security scanning for AI agents from your terminal",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"bin": {
|
|
@@ -87,6 +87,8 @@
|
|
|
87
87
|
"test:no-script-host": "npm run build && node scripts/test-no-script-host.js",
|
|
88
88
|
"test:s4u-migration": "npm run build && node scripts/test-s4u-task-migration.js",
|
|
89
89
|
"test:block-explanation": "npm run build && node scripts/test-block-explanation.js",
|
|
90
|
+
"test:file-write-canon": "npm run build && node scripts/test-file-write-canon.js",
|
|
91
|
+
"test:agent-ci-gate": "npm run build && node scripts/test-agent-ci-gate.js",
|
|
90
92
|
"test:ide-fp-corpus": "npm run build && node scripts/test-ide-fp-corpus.js",
|
|
91
93
|
"test:ide-fp-corpus:hook": "npm run build && node scripts/test-ide-fp-corpus.js --hook",
|
|
92
94
|
"build:msi": "powershell -NoProfile -ExecutionPolicy Bypass -File installer/windows/Build-Msi.ps1",
|