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.
@@ -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=Hunter2!2024`, `"api_key": "sk-…"`, `--token abc…` — where the
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>;
@@ -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();
@@ -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.
@@ -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
- // Claude Code, VS Code Copilot, and Copilot CLI each use their own tool names
167
- // for the same operations — compare case/underscore-insensitively.
168
- const canon = toolName.toLowerCase().replace(/[_-]/g, '');
169
- const SHELL_TOOLS = new Set(['bash', 'shell', 'runterminalcommand', 'runinterminal', 'powershell', 'terminal']);
170
- const READ_TOOLS = new Set(['read', 'readfile', 'view']);
171
- const WRITE_TOOLS = new Set([
172
- 'write', 'edit', 'multiedit', 'notebookedit', 'createfile', 'editfiles',
173
- 'replacestringinfile', 'editfile', 'applypatch', 'strreplaceeditor', 'insertedittofile',
174
- ]);
175
- if (SHELL_TOOLS.has(canon)) {
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 (READ_TOOLS.has(canon)) {
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 (WRITE_TOOLS.has(canon)) {
185
- const content = str(input.content) || str(input.new_string) || str(input.new_source) || str(input.code)
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 filePath = str(input.file_path) || str(input.notebook_path) || str(input.path) || str(input.filePath)
190
- || (Array.isArray(input.files) ? input.files.map((f) => str(f)).filter(Boolean).join(', ') : '');
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
- // Glob/Grep/Task/WebFetch/etc. — no enforcement surface; let the client's own
194
- // permission flow handle them.
195
- return { event: 'ignore', payload };
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
- return { toolName, toolArgs };
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
- event = inferEvent(args.event, payload);
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
- const toolArgs = params.arguments && typeof params.arguments === 'object' ? params.arguments : {};
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, toolArgs);
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, toolArgs);
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
@@ -1,3 +1,3 @@
1
1
  {
2
- "version": "1.30.0"
2
+ "version": "1.31.0"
3
3
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fullcourtdefense-cli",
3
- "version": "1.30.0",
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",