fullcourtdefense-cli 1.30.0 → 1.32.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.
@@ -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,137 @@ 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. Both then drop the
236
+ // credential-shaped words the file on disk already contains — re-stating a
237
+ // value that is already in the file is not putting a secret into it.
238
+ const delta = str(input.new_string) || str(input.new_str) || str(input.new_text)
186
239
  || (Array.isArray(input.edits)
187
- ? input.edits.map((e) => str(e?.new_string) || str(e?.content)).filter(Boolean).join('\n')
240
+ ? input.edits.map((e) => str(e?.new_string) || str(e?.new_text) || str(e?.newText) || str(e?.content)).filter(Boolean).join('\n')
188
241
  : '');
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(', ') : '');
242
+ const whole = str(input.content) || str(input.contents) || str(input.new_source) || str(input.code);
243
+ const content = (0, fileWriteCanon_1.newContentOnly)(filePath || undefined, delta || whole);
191
244
  return { event: 'file', payload: { ...payload, file_path: filePath, content } };
192
245
  }
193
- // Glob/Grep/Task/WebFetch/etc. — no enforcement surface; let the client's own
194
- // permission flow handle them.
195
- return { event: 'ignore', payload };
246
+ if (BUILTIN_PASSIVE_TOOLS.has(canon))
247
+ return { event: 'ignore', payload };
248
+ return null;
249
+ }
250
+ /**
251
+ * Cursor delivers its OWN built-in tools (Shell / Read / Write / Grep / …)
252
+ * through the same execution hook as MCP servers, with `tool_use_id` + no
253
+ * server identity (`mcp_server_name` / `url` / `command` are MCP-only). Route
254
+ * them through the shared taxonomy so an IDE edit is a FILE action, a command
255
+ * is a SHELL action, and neither is ever judged as an unknown MCP tool.
256
+ *
257
+ * Shell is dropped here on purpose: Cursor also fires `beforeShellExecution`
258
+ * for the same command, which is the authoritative shell surface — evaluating
259
+ * it twice produced two (sometimes different) verdicts for one action.
260
+ */
261
+ function normalizeCursorBuiltinTool(payload) {
262
+ const toolName = str(payload.tool_name);
263
+ if (!toolName)
264
+ return null;
265
+ if (str(payload.mcp_server_name) || str(payload.server_name) || str(payload.mcp_server_url) || str(payload.url) || str(payload.command))
266
+ return null;
267
+ if (toolName.startsWith('mcp__') || toolName.startsWith('MCP:'))
268
+ return null;
269
+ let toolInput = payload.tool_input;
270
+ if (typeof toolInput === 'string') {
271
+ const trimmed = toolInput.trim();
272
+ if (trimmed.startsWith('{') || trimmed.startsWith('[')) {
273
+ try {
274
+ toolInput = JSON.parse(trimmed);
275
+ }
276
+ catch { /* keep raw */ }
277
+ }
278
+ }
279
+ const input = (toolInput && typeof toolInput === 'object' ? toolInput : {});
280
+ const mapped = mapBuiltinToolPayload(toolName, input, payload);
281
+ if (!mapped)
282
+ return null;
283
+ if (mapped.event === 'shell')
284
+ return { event: 'ignore', payload };
285
+ return mapped;
286
+ }
287
+ /**
288
+ * Cursor-format entry: infer the event from the `--event` flag / payload shape,
289
+ * then route Cursor built-in tools through the shared taxonomy. Exported so the
290
+ * IDE false-positive corpus (Layer A) evaluates exactly what the hook evaluates.
291
+ */
292
+ function canonicalizeCursorPayload(argEvent, payload) {
293
+ const event = inferEvent(argEvent, payload);
294
+ if (event !== 'mcp')
295
+ return { event, payload, ignore: false };
296
+ const builtin = normalizeCursorBuiltinTool(payload);
297
+ if (!builtin)
298
+ return { event, payload, ignore: false };
299
+ if (builtin.event === 'ignore')
300
+ return { event: 'unknown', payload, ignore: true };
301
+ return { event: builtin.event, payload: builtin.payload, ignore: false };
196
302
  }
197
303
  /**
198
304
  * Which AI client invoked a Claude-format hook. Claude Code sets CLAUDECODE=1;
@@ -298,16 +404,20 @@ function buildToolCall(event, p) {
298
404
  }
299
405
  }
300
406
  const toolArgs = rawInput && typeof rawInput === 'object' ? rawInput : { value: rawInput };
301
- return { toolName, toolArgs };
407
+ // MCP filesystem servers write whole files; judge only what the write adds.
408
+ return { toolName, toolArgs: (0, fileWriteCanon_1.canonicalizeFileWriteArgs)(toolName, toolArgs) };
302
409
  }
303
410
  case 'file': {
304
411
  const filePath = str(p.file_path) || str(p.path);
305
412
  const edits = p.edits;
306
- const content = Array.isArray(edits)
413
+ const rawContent = Array.isArray(edits)
307
414
  ? edits.map((e) => str(e.new_text) || str(e.text) || str(e.content)).filter(Boolean).join('\n')
308
415
  : (str(p.content) || str(p.new_text));
309
- if (!filePath && !content)
416
+ if (!filePath && !rawContent)
310
417
  return null;
418
+ // Single choke point for every client's edit shape: judge only what is
419
+ // new for the file on disk (added lines, minus values it already holds).
420
+ const content = (0, fileWriteCanon_1.newContentOnly)(filePath || undefined, rawContent);
311
421
  return { toolName: 'edit_file', toolArgs: { path: filePath || undefined, content: content || undefined } };
312
422
  }
313
423
  case 'read': {
@@ -349,6 +459,12 @@ function spoolCallEvent(call, rest) {
349
459
  function warnByPolicy(call, verdict, event, exitsProcess, source) {
350
460
  const reason = verdict.reason || `${call.toolName}: flagged by org Action Policy`;
351
461
  const named = verdict.policyName ? ` (${verdict.policyName})` : '';
462
+ // Local content ceiling: the rule is set to Block, but it fired on what a
463
+ // file on this machine CONTAINS and nothing left the machine — recorded, not
464
+ // stopped. Say so, so "set to Block" in the console and "warning" here agree.
465
+ const ceiling = verdict.loggedInsteadOfBlocked
466
+ ? ' This rule is set to Block, but it matched only the CONTENT of a local file (a secret-shaped value) — content on your own disk is recorded, not blocked. The same content leaving the machine would be blocked.'
467
+ : '';
352
468
  if (source === 'local-first') {
353
469
  // The gate path is recorded as Warned by the server itself (it saw the
354
470
  // call); only the local verdict has nobody else to write the audit.
@@ -361,8 +477,8 @@ function warnByPolicy(call, verdict, event, exitsProcess, source) {
361
477
  (0, telemetry_1.triggerFlush)(exitsProcess);
362
478
  }
363
479
  return {
364
- userMsg: `FullCourtDefense warning${named} — ${reason}. The ${event} was allowed and recorded as Warned.`,
365
- agentMsg: `FullCourtDefense warning${named}: ${reason}. This ${event} was allowed by a warn-only Action Policy and recorded as Warned — proceed, but mention the warning to the developer.`,
480
+ userMsg: `FullCourtDefense warning${named} — ${reason}. The ${event} was allowed and recorded as Warned.${ceiling}`,
481
+ agentMsg: `FullCourtDefense warning${named}: ${reason}. This ${event} was allowed by a warn-only Action Policy and recorded as Warned — proceed, but mention the warning to the developer.${ceiling}`,
366
482
  };
367
483
  }
368
484
  function safeJson(v) {
@@ -535,6 +651,7 @@ function tryDeveloperConfirmation(ctx, call, verdict, payload, where) {
535
651
  reason: verdict.reason,
536
652
  where,
537
653
  policies: ctx.localPolicies,
654
+ askedInsteadOfBlocked: verdict.askedInsteadOfBlocked === true,
538
655
  });
539
656
  if (prepared.outcome === 'allow')
540
657
  ctx.respond(false, undefined, prepared.agentMsg);
@@ -599,9 +716,12 @@ function prepareDeveloperConfirmation(event, call, payload, source) {
599
716
  const reason = source.reason || `${call.toolName} needs your confirmation`;
600
717
  // Name the rule in the prompt itself: a developer deciding y/n must see WHICH
601
718
  // rule is asking and where to change it — same builder as every block.
719
+ const ceilingNote = source.askedInsteadOfBlocked
720
+ ? 'This policy is set to Block. On a developer workstation it asks you instead of blocking — a wrong Ask costs one click, a wrong block costs your afternoon. An admin can mark the policy "Hard block" in the console to stop this without asking.'
721
+ : undefined;
602
722
  const explained = source.where === 'local-safety' && source.localFinding
603
723
  ? explainLocalSafetyStop('ask', event, call, source.localFinding)
604
- : explainPolicyStop('ask', event, call, source, source.policies);
724
+ : explainPolicyStop('ask', event, call, source, source.policies, ceilingNote);
605
725
  return {
606
726
  outcome: 'ask',
607
727
  userMsg: `${explained.userMessage}\nAllow this ${event}?`,
@@ -1229,7 +1349,10 @@ async function hookCommandInner(args, config, io) {
1229
1349
  }
1230
1350
  }
1231
1351
  else {
1232
- event = inferEvent(args.event, payload);
1352
+ const cursor = canonicalizeCursorPayload(args.event, payload);
1353
+ event = cursor.event;
1354
+ payload = cursor.payload;
1355
+ ignoreEvent = cursor.ignore;
1233
1356
  }
1234
1357
  (0, hookIo_1.dbg)({ phase: 'invoke', event, argEvent: args.event, format: hookFormat, client, pid: process.pid,
1235
1358
  isTTY, rawLen: raw.length, ...(hookIo_1.HOOK_DEBUG_VERBOSE ? { rawPreview: raw.slice(0, 300) } : {}), stdinErr, parseErr,
@@ -1397,9 +1520,13 @@ async function hookCommandInner(args, config, io) {
1397
1520
  });
1398
1521
  const promptOutcome = (0, deterministicGuard_1.resolveDeterministicOutcome)(opts => (0, deterministicGuard_1.scanDeterministicPrompt)(text, opts), (0, localSafetySnapshot_1.snapshotToScanOptions)(snapshot));
1399
1522
  deferLocalWarnings(promptOutcome.warnings, 'prompt', 'prompt');
1400
- // Prompt events have no IDE permission prompt to hand an "ask" to — an
1401
- // ask-action rule fails strict here (block), never silently allows.
1523
+ // The prompt path only judges what is literally IN the text — a real secret
1524
+ // value (or an org custom block). Command / path mentions are decided when
1525
+ // the action happens, where the developer's Ask answer can be collected.
1526
+ // A prompt has no Allow / Deny to offer, so a secret rule set to "ask"
1527
+ // fails strict here (block) and SAYS so — never silently allows.
1402
1528
  const localBlock = promptOutcome.blockingFinding || promptOutcome.askFinding;
1529
+ const askDegraded = !promptOutcome.blockingFinding && Boolean(promptOutcome.askFinding);
1403
1530
  if (localBlock) {
1404
1531
  (0, hookIo_1.dbg)({ phase: 'local_deterministic_prompt_block', event, ruleId: localBlock.ruleId, category: localBlock.category });
1405
1532
  if (shadow) {
@@ -1409,7 +1536,7 @@ async function hookCommandInner(args, config, io) {
1409
1536
  return;
1410
1537
  }
1411
1538
  spoolLocalFinding({ finding: localBlock, toolName: 'prompt', operation: 'prompt' });
1412
- const explained = explainLocalSafetyStop('block', 'prompt', { toolName: 'prompt', toolArgs: {} }, localBlock);
1539
+ const explained = explainLocalSafetyStop('block', 'prompt', { toolName: 'prompt', toolArgs: {} }, localBlock, askDegraded ? 'This rule is set to "Ask the developer", but a chat prompt has no Allow / Deny to offer, so it was stopped. Remove the secret value from the prompt and send it again.' : undefined);
1413
1540
  respond(true, explained.userMessage, `FullCourtDefense blocked this prompt locally (${localBlock.ruleId}). Do not retry. ${explained.agentMessage}`);
1414
1541
  return;
1415
1542
  }
@@ -1838,7 +1965,11 @@ async function enforceActionPolicy(ctx) {
1838
1965
  message: `${call.toolName}: ${outcome.message}`,
1839
1966
  url: outcome.status === 'timeout' ? queueUrl : undefined,
1840
1967
  });
1841
- respond(true, `FullCourtDefense: ${outcome.message} (${queueUrl})`, `This ${event} was not approved (${outcome.status}). Do not retry until a human approves it in the FullCourtDefense console: ${queueUrl}`);
1968
+ // Same contract as every other stop: WHAT was paused, WHICH rule (by name),
1969
+ // WHY, and how to change it — a developer who only reads "still waiting"
1970
+ // cannot tell a wrong rule from a slow approver.
1971
+ const explainedWait = explainPolicyStop('approval', event, call, { policyName, policyId: result.actionPolicy?.policyId, matchedRule: result.actionPolicy?.matchedRule, reason }, ctx.localPolicies, `Status: ${outcome.message} Approve at ${queueUrl}`);
1972
+ respond(true, explainedWait.userMessage, `This ${event} was not approved (${outcome.status}). Do not retry until a human approves it in the FullCourtDefense console: ${queueUrl}. ${explainedWait.agentMessage}`);
1842
1973
  return;
1843
1974
  }
1844
1975
  // block / mask / any other not-allowed decision.
@@ -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;
@@ -1365,7 +1371,20 @@ class McpGatewayServer {
1365
1371
  (0, notify_1.notifyOs)({ title: 'FullCourtDefense — blocked by policy', message: `${toolName} blocked${local.policyName ? ` (${local.policyName})` : ''}.` });
1366
1372
  throw blockError(explainGatewayPolicy('block', toolName, toolArgs, local, cachedPolicies));
1367
1373
  }
1368
- if (local.verdict === 'log') {
1374
+ if (local.verdict === 'require_approval'
1375
+ && (0, actionPolicyEngine_1.effectiveApprovalScope)(local.approval, { humanPresent: (0, devConfirm_1.isHumanPresent)() }) === 'developer'
1376
+ && this.clientSupportsElicitation) {
1377
+ // Developer-scoped approval (an explicit developer rule, or an org
1378
+ // `block` softened by the workstation ceiling): ask the human through
1379
+ // the MCP client — same ledger and session memory as the IDE hook.
1380
+ // Accept → allow (recorded as the developer's decision); decline /
1381
+ // timeout → block. A client without elicitation falls through to the
1382
+ // org approval queue below — never a silent allow.
1383
+ const answered = await this.askDeveloperForPolicy(toolName, toolArgs, localOperation, local, cachedPolicies);
1384
+ if (answered)
1385
+ preflight = { allowed: true, decision: 'allow', operation: localOperation };
1386
+ }
1387
+ else if (local.verdict === 'log') {
1369
1388
  // "Warn only": the call runs, the fleet records Warned (not a silent
1370
1389
  // allow). Final verdict — no gate round trip; the audit rides the spool.
1371
1390
  const reason = local.reason || `${toolName}: flagged by org Action Policy`;
@@ -1528,7 +1547,7 @@ class McpGatewayServer {
1528
1547
  }
1529
1548
  }
1530
1549
  downstreamAttempted = true;
1531
- let rawResult = await this.downstream.callTool(toolName, toolArgs);
1550
+ let rawResult = await this.downstream.callTool(toolName, forwardArgs);
1532
1551
  downstreamRan = true;
1533
1552
  downstreamResult = rawResult;
1534
1553
  // Response-side events must measure our scanning overhead, not the
@@ -1707,7 +1726,7 @@ class McpGatewayServer {
1707
1726
  if (!isBlockError(error) && !downstreamAttempted) {
1708
1727
  // Pre-call bug: forward the tool call now (exactly once).
1709
1728
  try {
1710
- const recovered = await this.downstream.callTool(toolName, toolArgs);
1729
+ const recovered = await this.downstream.callTool(toolName, forwardArgs);
1711
1730
  try {
1712
1731
  (0, telemetry_1.spoolEvent)({ decision: 'allow', toolName, operation, reason: `gateway error failed open (pre-call): ${message.slice(0, 200)}`, offlineEnforced: true });
1713
1732
  (0, telemetry_1.triggerFlush)(false);
@@ -1778,6 +1797,70 @@ class McpGatewayServer {
1778
1797
  * that is not an explicit accept (decline, cancel, timeout, malformed) is a
1779
1798
  * decline. The caller decides what a decline means (block).
1780
1799
  */
1800
+ /**
1801
+ * Ask the developer (MCP elicitation) to confirm a tool call an Action Policy
1802
+ * routed to them. Returns true when the call may run (remembered or accepted);
1803
+ * throws the block error when the developer declines or does not answer.
1804
+ */
1805
+ async askDeveloperForPolicy(toolName, toolArgs, operation, verdict, policies) {
1806
+ const rememberScope = { toolName, operation, policyId: verdict.policyId, matchedRule: verdict.matchedRule };
1807
+ const sessionId = runtimeSessionId() || this.gatewaySessionId;
1808
+ const policyLabel = verdict.policyName ? ` (${verdict.policyName})` : '';
1809
+ const ruleId = verdict.policyName ? `action-policy:${verdict.policyName}` : undefined;
1810
+ const remembered = (0, devConfirm_1.isConfirmationRemembered)(rememberScope, sessionId);
1811
+ if (remembered) {
1812
+ (0, telemetry_1.spoolEvent)({
1813
+ decision: 'allow', toolName, operation,
1814
+ reason: `developer confirmed earlier (${remembered.remember} memory)${policyLabel}`,
1815
+ ruleId, offlineEnforced: true, approvalScope: 'developer', approvedBy: 'developer',
1816
+ });
1817
+ (0, telemetry_1.triggerFlush)(false);
1818
+ return true;
1819
+ }
1820
+ const digest = (0, devConfirm_1.actionDigest)(toolArgs);
1821
+ const remember = verdict.approval?.remember ?? 'none';
1822
+ const marker = (0, devConfirm_1.raiseDeveloperConfirmation)({
1823
+ event: 'mcp_gateway', toolName, operation, argsDigest: digest,
1824
+ policyId: verdict.policyId, policyName: verdict.policyName, matchedRule: verdict.matchedRule,
1825
+ sessionId, remember,
1826
+ });
1827
+ (0, telemetry_1.spoolEvent)({
1828
+ decision: 'approval', toolName, operation,
1829
+ reason: `developer confirmation requested via MCP client prompt${policyLabel}${verdict.reason ? `: ${verdict.reason}` : ''}`,
1830
+ ruleId, offlineEnforced: true, approvalScope: 'developer',
1831
+ });
1832
+ (0, telemetry_1.triggerFlush)(false);
1833
+ const ceiling = verdict.askedInsteadOfBlocked
1834
+ ? ' This policy is set to Block; on a developer workstation it asks you instead. An admin can mark it "Hard block" in the console to stop this without asking.'
1835
+ : '';
1836
+ process.stderr.write(`FullCourtDefense asking the developer to confirm ${toolName}${policyLabel}: ${verdict.reason || verdict.matchedRule || 'action policy'}\n`);
1837
+ const answer = await this.elicitDeveloperConfirmation({
1838
+ toolName,
1839
+ reason: `${verdict.reason || `${toolName} needs your confirmation`}.${ceiling}`,
1840
+ ruleLabel: verdict.policyName ? `Action Policy "${verdict.policyName}"${verdict.matchedRule ? ` — ${verdict.matchedRule}` : ''}` : 'Action Policy',
1841
+ });
1842
+ if (answer.answer === 'accept') {
1843
+ if (answer.remember && remember === 'none')
1844
+ (0, devConfirm_1.raiseDeveloperConfirmation)({ ...marker, remember: 'session' });
1845
+ (0, devConfirm_1.settleDeveloperConfirmation)('mcp_gateway', toolName, digest);
1846
+ (0, telemetry_1.spoolEvent)({
1847
+ decision: 'allow', toolName, operation,
1848
+ reason: `developer confirmed in MCP client prompt${answer.remember ? ' (remembered for this session)' : ''}${policyLabel}`,
1849
+ ruleId, offlineEnforced: true, approvalScope: 'developer', approvedBy: 'developer', confirmLatencyMs: answer.latencyMs,
1850
+ });
1851
+ (0, telemetry_1.triggerFlush)(false);
1852
+ return true;
1853
+ }
1854
+ (0, devConfirm_1.settleDeveloperConfirmation)('mcp_gateway', toolName, digest);
1855
+ const how = answer.answer === 'timeout' ? 'developer confirmation not given (prompt timed out)' : 'developer declined in MCP client prompt';
1856
+ (0, telemetry_1.spoolEvent)({
1857
+ decision: 'block', toolName, operation,
1858
+ reason: `${how}${policyLabel}`,
1859
+ ruleId, offlineEnforced: true, approvalScope: 'developer', confirmLatencyMs: answer.latencyMs,
1860
+ });
1861
+ (0, telemetry_1.triggerFlush)(true);
1862
+ throw blockError(explainGatewayPolicy('block', toolName, toolArgs, verdict, policies, `${answer.answer === 'timeout' ? 'You did not answer the confirmation prompt in time' : 'You declined the confirmation prompt'}, so this tool call was not executed.`));
1863
+ }
1781
1864
  async elicitDeveloperConfirmation(input) {
1782
1865
  const started = Date.now();
1783
1866
  const evidence = input.evidence ? `\n\nEvidence: ${input.evidence.slice(0, 240)}` : '';
@@ -48,6 +48,7 @@ const os = __importStar(require("os"));
48
48
  const path = __importStar(require("path"));
49
49
  const shellGuard_1 = require("./shellGuard");
50
50
  const notify_1 = require("../notify");
51
+ const agentTerminal_1 = require("../agentTerminal");
51
52
  /**
52
53
  * Interactive macOS/Linux shell guard — real-time blocking of dangerous commands
53
54
  * TYPED by a human into bash or zsh (inside or outside the IDE).
@@ -156,7 +157,7 @@ function buildGuardJs(nodePath, cliEntry) {
156
157
  return [
157
158
  `'use strict';`,
158
159
  `// ${(0, shellGuard_1.guardBuildStamp)()}`,
159
- `const fs=require('fs');const crypto=require('crypto');const{spawn}=require('child_process');`,
160
+ `const fs=require('fs');const os=require('os');const path=require('path');const crypto=require('crypto');const{spawn}=require('child_process');`,
160
161
  `const RULES_PATH=${JSON.stringify(GUARD_RULES_PATH)};`,
161
162
  `const SPOOL_PATH=${JSON.stringify(SPOOL_PATH)};`,
162
163
  `const NODE_PATH=${JSON.stringify(nodePath)};`,
@@ -174,8 +175,9 @@ function buildGuardJs(nodePath, cliEntry) {
174
175
  `// Precedence on one line: block outranks ask, ask outranks warn.`,
175
176
  `function matchLine(line,rules){const n=line.replace(/\\s+/g,' ').trim();const x=execView(line);const xn=x.replace(/\\s+/g,' ').trim();let warnHit=null,askHit=null;for(const r of rules){try{const t=r.source==='custom'?line:x;const tn=r.source==='custom'?n:xn;if(r.re.test(tn)||r.re.test(t)){if(r.action==='block')return r;if(r.action==='ask'){if(!askHit)askHit=r;}else if(!warnHit)warnHit=r;}}catch{}}return askHit||warnHit;}`,
176
177
  `function triggerFlush(){try{if(fs.existsSync(NODE_PATH)&&fs.existsSync(CLI_ENTRY)){const c=spawn(NODE_PATH,[CLI_ENTRY,'flush-spool','--heartbeat','true'],{detached:true,stdio:'ignore'});c.unref();}}catch{}}`,
177
- `// developer: '' | 'confirmed' | 'remembered' | 'declined' | 'timeout' — the terminal "ask" answer, reported with the same facts as an IDE prompt.`,
178
- `function spoolEvent(rule,line,decision,developer){try{const ev=line.trim();const evidence=ev.length>180?ev.slice(0,180)+'...':ev;let reason='Shell guard: '+rule.reason;if(developer==='confirmed')reason='developer confirmed in terminal prompt — '+reason;else if(developer==='remembered')reason='developer confirmed earlier in this shell (session memory) — '+reason;else if(developer==='declined')reason='developer declined in terminal prompt — '+reason;else if(developer==='timeout')reason='developer confirmation not given (terminal prompt timed out) — '+reason;const event={eventId:crypto.randomUUID(),type:'verdict',decision,toolName:'shell_terminal',operation:'shell_command',reason,ruleId:rule.id,category:rule.category,severity:rule.severity,source:rule.source,evidence,occurredAt:new Date().toISOString()};if(developer){event.approvalScope='developer';if(developer==='confirmed'||developer==='remembered')event.approvedBy='developer';}fs.appendFileSync(SPOOL_PATH,JSON.stringify(event)+'\\n',{encoding:'utf8',mode:0o600});triggerFlush();}catch{}}`,
178
+ `// developer: '' | 'confirmed' | 'remembered' | 'declined' | 'timeout' | 'ide' | 'agent-terminal' — the "ask" outcome, reported with the same facts as an IDE prompt.`,
179
+ `function spoolEvent(rule,line,decision,developer){try{const ev=line.trim();const evidence=ev.length>180?ev.slice(0,180)+'...':ev;let reason='Shell guard: '+rule.reason;if(developer==='confirmed')reason='developer confirmed in terminal prompt — '+reason;else if(developer==='remembered')reason='developer confirmed earlier in this shell (session memory) — '+reason;else if(developer==='declined')reason='developer declined in terminal prompt — '+reason;else if(developer==='timeout')reason='developer confirmation not given (terminal prompt timed out) — '+reason;else if(developer==='ide')reason='developer confirmed in the IDE prompt (agent-driven terminal) — '+reason;else if(developer==='agent-terminal')reason='ask cannot be answered in an agent-driven terminal (no IDE confirmation) — '+reason;const event={eventId:crypto.randomUUID(),type:'verdict',decision,toolName:'shell_terminal',operation:'shell_command',reason,ruleId:rule.id,category:rule.category,severity:rule.severity,source:rule.source,evidence,occurredAt:new Date().toISOString()};if(developer){event.approvalScope='developer';if(developer==='confirmed'||developer==='remembered'||developer==='ide')event.approvedBy='developer';}fs.appendFileSync(SPOOL_PATH,JSON.stringify(event)+'\\n',{encoding:'utf8',mode:0o600});triggerFlush();}catch{}}`,
180
+ (0, agentTerminal_1.buildAgentTerminalJs)(),
179
181
  `let argv=process.argv.slice(2);`,
180
182
  `// --record <confirmed|remembered|declined|timeout> <ruleId> -- <line>: the shell answered an ask prompt; report it and exit.`,
181
183
  `if(argv[0]==='--record'){const kind=argv[1],ruleId=argv[2];let rest=argv.slice(3);if(rest[0]==='--')rest=rest.slice(1);const rline=rest.join(' ');try{const{rules}=loadRules();const rule=rules.find(r=>r.id===ruleId)||{id:ruleId,reason:'ask rule '+ruleId,category:'',severity:'',source:'builtin'};spoolEvent(rule,rline,(kind==='confirmed'||kind==='remembered')?'allow':'block',kind);}catch{}process.exit(0);}`,
@@ -187,6 +189,10 @@ function buildGuardJs(nodePath, cliEntry) {
187
189
  `if(mode==='monitor'){console.error('[FullCourtDefense] monitor: '+(hit.action==='ask'?'would ask you to confirm':'would block')+' ['+(hit.severity||'')+'] '+hit.reason+' (rule '+hit.id+')');spoolEvent(hit,line,'warn');process.exit(0);}`,
188
190
  `if(hit.action==='warn'){console.error('[FullCourtDefense] warning ['+(hit.severity||'')+']: '+hit.reason+' (rule '+hit.id+') — allowed by org policy, reported to your security dashboard.');spoolEvent(hit,line,'warn');process.exit(0);}`,
189
191
  `if(hit.action==='ask'){`,
192
+ ` if(agentTerminal()){`,
193
+ ` if(ideConfirmed(line,hit.id)){console.error('[FullCourtDefense] '+AGENT_IDE_CONFIRMED+' (rule '+hit.id+')');spoolEvent(hit,line,'allow','ide');process.exit(0);}`,
194
+ ` console.error('[FullCourtDefense] ASK ['+(hit.severity||'')+']: '+hit.reason+' (rule '+hit.id+') — '+AGENT_ASK_BLOCKED);console.error(unblockHint(hit));spoolEvent(hit,line,'block','agent-terminal');process.exit(BLOCK_CODE);`,
195
+ ` }`,
190
196
  ` // "s = remember for this shell": the shell exports FCD_SHELL_ASK_OK=<ruleId>,<ruleId>... — dies with the shell.`,
191
197
  ` const ok=String(process.env.FCD_SHELL_ASK_OK||'').split(',').map(s=>s.trim()).filter(Boolean);`,
192
198
  ` if(ok.includes(hit.id)){console.error('[FullCourtDefense] allowed — you confirmed rule '+hit.id+' earlier in this shell.');spoolEvent(hit,line,'allow','remembered');process.exit(0);}`,
@@ -58,6 +58,7 @@ const os = __importStar(require("os"));
58
58
  const path = __importStar(require("path"));
59
59
  const deterministicGuard_1 = require("./deterministicGuard");
60
60
  const notify_1 = require("../notify");
61
+ const agentTerminal_1 = require("../agentTerminal");
61
62
  /**
62
63
  * Interactive PowerShell guard — real-time blocking of dangerous commands
63
64
  * TYPED by a human in a terminal (inside or outside the IDE).
@@ -637,6 +638,8 @@ function buildGuardPs1(nodePath, cliEntry) {
637
638
  ` } catch { }`,
638
639
  `}`,
639
640
  ``,
641
+ (0, agentTerminal_1.buildAgentTerminalPs)(),
642
+ ``,
640
643
  `# Rules + mode are (re)loaded whenever the rules file changes on disk, so a`,
641
644
  `# console change (rule -> Block, machine -> Protected) reaches THIS open`,
642
645
  `# window as soon as the daemon syncs it — same as cmd.exe and bash/zsh,`,
@@ -764,13 +767,15 @@ function buildGuardPs1(nodePath, cliEntry) {
764
767
  ` try {`,
765
768
  ` $evidence = $CommandLine.Trim()`,
766
769
  ` if ($evidence.Length -gt 180) { $evidence = $evidence.Substring(0, 180) + '...' }`,
767
- ` # $Developer: '' (none) | 'confirmed' | 'remembered' | 'declined' | 'timeout'`,
770
+ ` # $Developer: '' (none) | 'confirmed' | 'remembered' | 'declined' | 'timeout' | 'ide' | 'agent-terminal'`,
768
771
  ` $reason = 'Shell guard: ' + $Rule.Reason`,
769
772
  ` switch ($Developer) {`,
770
773
  ` 'confirmed' { $reason = 'developer confirmed in terminal prompt — ' + $reason }`,
771
774
  ` 'remembered' { $reason = 'developer confirmed earlier in this shell (session memory) — ' + $reason }`,
772
775
  ` 'declined' { $reason = 'developer declined in terminal prompt — ' + $reason }`,
773
776
  ` 'timeout' { $reason = 'developer confirmation not given (terminal prompt timed out) — ' + $reason }`,
777
+ ` 'ide' { $reason = 'developer confirmed in the IDE prompt (agent-driven terminal) — ' + $reason }`,
778
+ ` 'agent-terminal' { $reason = 'ask cannot be answered in an agent-driven terminal (no IDE confirmation) — ' + $reason }`,
774
779
  ` }`,
775
780
  ` $event = [ordered]@{`,
776
781
  ` eventId = [guid]::NewGuid().ToString()`,
@@ -788,7 +793,7 @@ function buildGuardPs1(nodePath, cliEntry) {
788
793
  ` }`,
789
794
  ` if ($Developer -ne '') {`,
790
795
  ` $event['approvalScope'] = 'developer'`,
791
- ` if ($Developer -eq 'confirmed' -or $Developer -eq 'remembered') { $event['approvedBy'] = 'developer' }`,
796
+ ` if ($Developer -eq 'confirmed' -or $Developer -eq 'remembered' -or $Developer -eq 'ide') { $event['approvedBy'] = 'developer' }`,
792
797
  ` }`,
793
798
  ` $event = [pscustomobject]$event`,
794
799
  ` # BOM-free UTF8 append — PS5's Add-Content -Encoding utf8 writes a BOM that breaks JSONL parsing.`,
@@ -833,6 +838,26 @@ function buildGuardPs1(nodePath, cliEntry) {
833
838
  ` # reported to the fleet as a developer decision (same facts as the IDE`,
834
839
  ` # prompt). No answerable console => block, never a silent allow.`,
835
840
  ` if ($hit.Action -eq 'ask') {`,
841
+ ` if (Test-FcdAgentTerminal) {`,
842
+ ` # An AI agent drives this terminal: it would answer y/N itself, so nobody is asked here.`,
843
+ ` # Honor the developer's answer from the IDE prompt for this exact line; otherwise block.`,
844
+ ` if (Test-FcdIdeConfirmed -Line $line -RuleId ([string]$hit.Id)) {`,
845
+ ` Write-Host ''`,
846
+ ` Write-Host ('[FullCourtDefense] ' + $global:FcdAgentIdeConfirmed + ' (rule ' + $hit.Id + ')') -ForegroundColor DarkGray`,
847
+ ` try { [Microsoft.PowerShell.PSConsoleReadLine]::InvokePrompt() } catch { }`,
848
+ ` Write-FcdGuardEvent -Rule $hit -CommandLine $line -Decision 'allow' -Developer 'ide'`,
849
+ ` [Microsoft.PowerShell.PSConsoleReadLine]::AcceptLine()`,
850
+ ` return`,
851
+ ` }`,
852
+ ` [Microsoft.PowerShell.PSConsoleReadLine]::RevertLine()`,
853
+ ` Write-Host ''`,
854
+ ` Write-Host ('[FullCourtDefense] ASK [' + $hit.Severity + ']: ' + $hit.Reason + ' (rule ' + $hit.Id + ') — ' + $global:FcdAgentAskBlocked) -ForegroundColor Red`,
855
+ ` Write-FcdUnblockHint -Rule $hit`,
856
+ ` try { [Microsoft.PowerShell.PSConsoleReadLine]::InvokePrompt() } catch { }`,
857
+ ` Write-FcdGuardEvent -Rule $hit -CommandLine $line -Decision 'block' -Developer 'agent-terminal'`,
858
+ ` [Microsoft.PowerShell.PSConsoleReadLine]::AcceptLine()`,
859
+ ` return`,
860
+ ` }`,
836
861
  ` if (Test-FcdAskRemembered -RuleId ([string]$hit.Id)) {`,
837
862
  ` Write-Host ''`,
838
863
  ` Write-Host ('[FullCourtDefense] allowed — you confirmed rule ' + $hit.Id + ' earlier in this shell.') -ForegroundColor DarkGray`,
@@ -0,0 +1,46 @@
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
+ /** What a re-stated word is replaced with — not credential-shaped, so no detector can fire on it. */
21
+ export declare const UNCHANGED_TOKEN_MARK = "[unchanged]";
22
+ /**
23
+ * Mask every credential-shaped word in `content` that ALREADY appears verbatim
24
+ * in the file being edited. An edit that re-states a value the file already
25
+ * holds (an existing test fixture, a documented example key, a checksum) adds
26
+ * no information to that file — it cannot be exfiltration into it — so the
27
+ * value must not become a `containsSecret` fact. A word that is NOT in the
28
+ * target file stays exactly as written and is judged as before. Pure.
29
+ */
30
+ export declare function maskTokensAlreadyPresent(existing: string, content: string): string;
31
+ /** Line-level delta: the lines `content` adds relative to `existing`. */
32
+ export declare function addedLinesOnly(existing: string, content: string): string;
33
+ /**
34
+ * Reduce a file write to what is NEW for the file on disk: first the lines it
35
+ * adds, then — inside those lines — mask credential-shaped words the file
36
+ * already contains. Whole-file shapes and delta shapes (old/new strings) both
37
+ * go through this, so every client yields the same canonical `content`.
38
+ */
39
+ export declare function newContentOnly(filePath: string | undefined, content: string): string;
40
+ /**
41
+ * For a whole-file write tool (MCP `write_file`, …) with `{ path, content }`
42
+ * arguments, replace `content` with only the added lines. Any other tool or
43
+ * shape is returned untouched (same object identity), so callers can apply it
44
+ * unconditionally.
45
+ */
46
+ export declare function canonicalizeFileWriteArgs(toolName: string, args: Record<string, unknown>): Record<string, unknown>;