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.
- package/dist/actionPolicyEngine.d.ts +61 -1
- package/dist/actionPolicyEngine.js +473 -34
- package/dist/agentTerminal.d.ts +47 -0
- package/dist/agentTerminal.js +187 -0
- package/dist/commands/agentCi.d.ts +2 -0
- package/dist/commands/agentCi.js +14 -0
- package/dist/commands/cmdGuard.js +9 -3
- package/dist/commands/deterministicGuard.d.ts +16 -0
- package/dist/commands/deterministicGuard.js +59 -49
- package/dist/commands/discover.js +7 -2
- package/dist/commands/hook.d.ts +35 -0
- package/dist/commands/hook.js +163 -32
- package/dist/commands/mcpGateway.js +87 -4
- package/dist/commands/posixShellGuard.js +9 -3
- package/dist/commands/shellGuard.js +27 -2
- package/dist/fileWriteCanon.d.ts +46 -0
- package/dist/fileWriteCanon.js +147 -0
- package/dist/machineIdentity.js +5 -0
- package/dist/version.json +1 -1
- package/package.json +3 -1
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,137 @@ 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. 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
|
|
190
|
-
|
|
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
|
-
|
|
194
|
-
|
|
195
|
-
return
|
|
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
|
-
|
|
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
|
|
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 && !
|
|
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
|
-
|
|
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
|
-
//
|
|
1401
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
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 === '
|
|
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,
|
|
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,
|
|
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
|
|
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>;
|