fullcourtdefense-cli 1.32.0 → 1.34.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.
@@ -0,0 +1,145 @@
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.nativeHookCommand = nativeHookCommand;
37
+ exports.installNativeHooks = installNativeHooks;
38
+ const fs = __importStar(require("fs"));
39
+ const os = __importStar(require("os"));
40
+ const path = __importStar(require("path"));
41
+ const marker = '--fcd-native-managed true';
42
+ const managed = (value) => {
43
+ if (typeof value?.command !== 'string')
44
+ return false;
45
+ if (value.command.includes(marker))
46
+ return true;
47
+ const encoded = /-EncodedCommand ([A-Za-z0-9+/=]+)$/.exec(value.command)?.[1];
48
+ return Boolean(encoded && Buffer.from(encoded, 'base64').toString('utf16le').includes(marker));
49
+ };
50
+ const quote = (value) => `'${value.replace(/'/g, `'"'"'`)}'`;
51
+ function nativeHookCommand(client) {
52
+ if (!['codex', 'gemini_cli', 'windsurf', 'kiro'].includes(client))
53
+ throw new Error('Unsupported native hook client');
54
+ const entry = path.resolve(__dirname, '..', 'nativeHook.js');
55
+ const flags = `--agent-client ${client} ${marker} --fcd-managed true --local-only true`;
56
+ if (process.platform !== 'win32')
57
+ return `${quote(process.execPath)} ${quote(entry)} ${flags}`;
58
+ // Windows vendors use different shells. An encoded PowerShell launcher is
59
+ // valid in both cmd and PowerShell, preserves paths with spaces, and forwards
60
+ // the JSON stdin explicitly. Paths are data, never interpolated expressions.
61
+ const psQuote = (value) => `'${value.replace(/'/g, "''")}'`;
62
+ const script = `$ProgressPreference='SilentlyContinue'; $OutputEncoding=[Text.UTF8Encoding]::new($false); [Console]::InputEncoding=$OutputEncoding; [Console]::OutputEncoding=$OutputEncoding; $raw=[Console]::In.ReadToEnd(); $raw | & ${psQuote(process.execPath)} ${psQuote(entry)} ${flags}; exit $LASTEXITCODE`;
63
+ return `powershell.exe -NoProfile -NonInteractive -EncodedCommand ${Buffer.from(script, 'utf16le').toString('base64')}`;
64
+ }
65
+ /** Preserve unrelated settings and hooks; refuse malformed files. */
66
+ function installNativeHooks(options = {}) {
67
+ // Validate library callers too, before any filesystem changes.
68
+ for (const client of options.clients || [])
69
+ nativeHookCommand(client);
70
+ const home = options.home || os.homedir();
71
+ const files = [];
72
+ const errors = [];
73
+ for (const client of options.clients || ['codex', 'gemini_cli', 'windsurf', 'kiro']) {
74
+ const root = client === 'codex' ? path.join(home, '.codex') : client === 'gemini_cli' ? path.join(home, '.gemini')
75
+ : client === 'windsurf' ? path.join(home, '.codeium', 'windsurf') : path.join(options.project || process.cwd(), '.kiro');
76
+ // Install only detected clients. Explicit client selection also supports setup before first launch.
77
+ if (!options.clients && !fs.existsSync(root))
78
+ continue;
79
+ const file = path.join(root, client === 'kiro' ? 'hooks/fullcourtdefense.json' : client === 'gemini_cli' ? 'settings.json' : 'hooks.json');
80
+ if (options.remove && !fs.existsSync(file))
81
+ continue;
82
+ try {
83
+ const data = fs.existsSync(file) ? JSON.parse(fs.readFileSync(file, 'utf8')) : {};
84
+ if (!data || typeof data !== 'object' || Array.isArray(data))
85
+ throw new Error(`Invalid hook settings: ${file}`);
86
+ const before = JSON.stringify(data);
87
+ const command = nativeHookCommand(client);
88
+ if (client === 'kiro') {
89
+ if (data.hooks !== undefined && !Array.isArray(data.hooks))
90
+ throw new Error(`Invalid Kiro hooks: ${file}`);
91
+ const kept = (data.hooks || []).filter((hook) => !managed(hook.action));
92
+ data.version = 'v1';
93
+ data.hooks = options.remove ? kept : [...kept, ...['PreToolUse', 'PostToolUse', 'UserPromptSubmit'].map(trigger => ({
94
+ name: `FullCourtDefense ${trigger}`, trigger, action: { type: 'command', command }, timeout: 130,
95
+ }))];
96
+ }
97
+ else {
98
+ if (data.hooks !== undefined && (!data.hooks || typeof data.hooks !== 'object' || Array.isArray(data.hooks)))
99
+ throw new Error(`Invalid hooks: ${file}`);
100
+ const hooks = data.hooks || {};
101
+ const events = client === 'windsurf'
102
+ ? ['pre_run_command', 'post_run_command', 'pre_read_code', 'pre_write_code', 'post_write_code', 'pre_mcp_tool_use', 'post_mcp_tool_use', 'pre_user_prompt']
103
+ : client === 'gemini_cli' ? ['BeforeTool', 'AfterTool', 'BeforeAgent'] : ['PreToolUse', 'PostToolUse', 'UserPromptSubmit'];
104
+ for (const event of events) {
105
+ if (hooks[event] !== undefined && !Array.isArray(hooks[event]))
106
+ throw new Error(`Invalid ${event} hooks: ${file}`);
107
+ const entries = hooks[event] || [];
108
+ const kept = client === 'windsurf' ? entries.filter((hook) => !managed(hook)) : entries.flatMap((group) => {
109
+ if (!Array.isArray(group?.hooks))
110
+ throw new Error(`Invalid ${event} hook group: ${file}`);
111
+ const rest = group.hooks.filter((hook) => !managed(hook));
112
+ return rest.length ? [{ ...group, hooks: rest }] : [];
113
+ });
114
+ if (!options.remove) {
115
+ if (client === 'windsurf')
116
+ kept.push({ command, powershell: `${command}; exit $LASTEXITCODE`, show_output: true });
117
+ else
118
+ kept.push({ ...(event.includes('Tool') ? { matcher: '.*' } : {}), hooks: [{ type: 'command', command,
119
+ ...(client === 'gemini_cli' ? { name: `fullcourtdefense-${event}`, timeout: 130000 } : { timeout: 130 }) }] });
120
+ }
121
+ if (kept.length)
122
+ hooks[event] = kept;
123
+ else
124
+ delete hooks[event];
125
+ }
126
+ data.hooks = hooks;
127
+ }
128
+ if (JSON.stringify(data) !== before) {
129
+ fs.mkdirSync(path.dirname(file), { recursive: true });
130
+ if (fs.existsSync(file) && !fs.existsSync(`${file}.fcd-backup`))
131
+ fs.copyFileSync(file, `${file}.fcd-backup`);
132
+ const temporary = `${file}.fcd-${process.pid}.tmp`;
133
+ fs.writeFileSync(temporary, JSON.stringify(data, null, 2) + '\n', { mode: 0o600 });
134
+ fs.renameSync(temporary, file);
135
+ }
136
+ files.push(file);
137
+ }
138
+ catch (error) {
139
+ errors.push(`${file}: ${error instanceof Error ? error.message : String(error)}`);
140
+ }
141
+ }
142
+ if (errors.length)
143
+ throw new Error(`Native hook settings need repair: ${errors.join('; ')}`);
144
+ return files;
145
+ }
@@ -1086,7 +1086,7 @@ class McpGatewayServer {
1086
1086
  // waited for OUR decision (path 'gateway'). Pure in-memory context — no
1087
1087
  // probe, no I/O; the clock is restarted after the downstream call so the
1088
1088
  // tool's own runtime is never billed to FullCourtDefense.
1089
- return (0, telemetry_1.markVerdictTiming)({ startedAt: Date.now(), path: 'gateway' }, () => this.handleToolCallInner(params));
1089
+ return (0, telemetry_1.markVerdictTiming)({ startedAt: Date.now(), path: 'gateway', agentClient: this.gatewayConfig.agentClient }, () => this.handleToolCallInner(params));
1090
1090
  }
1091
1091
  async handleToolCallInner(params) {
1092
1092
  await this.ensureDownstream();
@@ -1109,6 +1109,8 @@ class McpGatewayServer {
1109
1109
  let downstreamResult;
1110
1110
  let downstreamAttempted = false;
1111
1111
  let downstreamRan = false;
1112
+ let reportOnlyMode = true;
1113
+ let policyWarning;
1112
1114
  try {
1113
1115
  // The runtime bundle carries the org's current Local Safety policy hash —
1114
1116
  // passing it as `expectedPolicyHash` makes a console policy edit take
@@ -1123,7 +1125,7 @@ class McpGatewayServer {
1123
1125
  let cachedPolicies;
1124
1126
  // Monitor unless a server/cache bundle explicitly says 'block': a machine
1125
1127
  // with no signal (fresh install + offline, wiped cache) must never block.
1126
- let reportOnlyMode = true;
1128
+ reportOnlyMode = true;
1127
1129
  let machineRole;
1128
1130
  try {
1129
1131
  const bundle = await (0, runtimeConfig_1.getRuntimeBundle)({
@@ -1183,6 +1185,10 @@ class McpGatewayServer {
1183
1185
  expectedPolicyHash,
1184
1186
  preferCached: reportOnlyMode,
1185
1187
  });
1188
+ if (reportOnlyMode && snapshot) {
1189
+ snapshot.itemActions = Object.fromEntries(Object.entries(snapshot.itemActions || {}).map(([id, action]) => [id, action === 'mask' ? 'warn' : action]));
1190
+ snapshot.customBlocks = snapshot.customBlocks.map(rule => rule.action === 'mask' ? { ...rule, action: 'warn' } : rule);
1191
+ }
1186
1192
  const requestOutcome = (0, deterministicGuard_1.resolveDeterministicOutcome)(opts => (0, deterministicGuard_1.scanDeterministicToolCall)(toolName, toolArgs, opts), (0, localSafetySnapshot_1.snapshotToScanOptions)(snapshot));
1187
1193
  for (const warning of requestOutcome.warnings) {
1188
1194
  this.spoolLocalFinding(warning, toolName, operation, 'warn');
@@ -1238,7 +1244,7 @@ class McpGatewayServer {
1238
1244
  localBlock = undefined;
1239
1245
  }
1240
1246
  else {
1241
- (0, devConfirm_1.settleDeveloperConfirmation)('mcp_gateway', toolName, digest);
1247
+ (0, devConfirm_1.rejectDeveloperConfirmation)('mcp_gateway', toolName, digest);
1242
1248
  const how = answer.answer === 'timeout' ? 'developer confirmation not given (prompt timed out)' : 'developer declined in MCP client prompt';
1243
1249
  (0, telemetry_1.spoolEvent)({
1244
1250
  decision: 'block', toolName, operation: rememberScope.operation,
@@ -1311,8 +1317,8 @@ class McpGatewayServer {
1311
1317
  // MONITOR CONTRACT (latency half): a monitor/no-signal machine makes
1312
1318
  // ZERO synchronous network calls. The verdict is always allow; the
1313
1319
  // "would block" signal comes from the locally cached policy bundle,
1314
- // and the console report is fired without awaiting (server records the
1315
- // action and its own verdict; failures spool for the next flush).
1320
+ // and the console report uses the audit spool. checkToolCall is an
1321
+ // enforcing endpoint: even an unawaited request can open approvals.
1316
1322
  const inferred = (0, actionPolicyEngine_1.inferToolContext)(toolName, toolArgs);
1317
1323
  const localOperation = operation || inferred.operation;
1318
1324
  if (cachedPolicies && cachedPolicies.length > 0) {
@@ -1333,22 +1339,8 @@ class McpGatewayServer {
1333
1339
  process.stderr.write(`FullCourtDefense (monitor) would ${wouldWhat} ${toolName}: ${local.reason || local.verdict}\n`);
1334
1340
  }
1335
1341
  }
1336
- // PROXY RESILIENCE: while the gate is in its recently-down window
1337
- // (bank proxy, backend outage) don't even fire the async report —
1338
- // spool straight to disk; the flush child delivers it when the
1339
- // network returns. Zero network churn per call during an outage.
1340
- if ((0, policyGateHealth_1.gateRecentlyDown)()) {
1341
- (0, telemetry_1.spoolEvent)({ decision: 'allow', toolName, operation: localOperation, reason: 'monitor: action recorded locally (gate recently unreachable)', offlineEnforced: true });
1342
- (0, telemetry_1.triggerFlush)(false);
1343
- }
1344
- else {
1345
- void this.api.checkToolCall({ toolName, operation, toolArgs }, 1_500)
1346
- .then(() => (0, policyGateHealth_1.recordGateSuccess)())
1347
- .catch(() => {
1348
- (0, telemetry_1.spoolEvent)({ decision: 'allow', toolName, operation: localOperation, reason: 'monitor: action recorded locally (console unreachable)', offlineEnforced: true });
1349
- (0, telemetry_1.triggerFlush)(false);
1350
- });
1351
- }
1342
+ (0, telemetry_1.spoolEvent)({ decision: 'allow', toolName, operation: localOperation, reason: 'monitor: action allowed and recorded without requesting approval' });
1343
+ (0, telemetry_1.triggerFlush)(false);
1352
1344
  preflight = { allowed: true, decision: 'allow', operation: localOperation };
1353
1345
  }
1354
1346
  else if (cachedPolicies && cachedPolicies.length > 0) {
@@ -1388,6 +1380,8 @@ class McpGatewayServer {
1388
1380
  // "Warn only": the call runs, the fleet records Warned (not a silent
1389
1381
  // allow). Final verdict — no gate round trip; the audit rides the spool.
1390
1382
  const reason = local.reason || `${toolName}: flagged by org Action Policy`;
1383
+ policyWarning = reason;
1384
+ process.stderr.write(`FullCourtDefense warning: ${reason}\n`);
1391
1385
  (0, telemetry_1.spoolEvent)({ decision: 'warn', toolName, operation: localOperation, reason: `policy warn (local-first): ${reason}`, ruleId: local.policyName ? `action-policy:${local.policyName}` : undefined, offlineEnforced: true });
1392
1386
  (0, telemetry_1.triggerFlush)(false);
1393
1387
  preflight = { allowed: true, decision: 'allow', operation: localOperation };
@@ -1470,9 +1464,9 @@ class McpGatewayServer {
1470
1464
  process.stderr.write(`FullCourtDefense offline: policy service unreachable (${detail}); ${toolName} checked against locally cached org policies → ${blocking ? `${local.verdict} (report-only)` : 'allow'}.\n`);
1471
1465
  preflight = { allowed: true, decision: 'allow', operation: localOperation };
1472
1466
  }
1473
- else if (this.gatewayConfig.failClosed && (!reportOnlyMode || this.gatewayConfig.failClosedExplicit)) {
1467
+ else if (this.gatewayConfig.failClosed && !reportOnlyMode) {
1474
1468
  // Fail-closed only bites when the machine is actually in block mode
1475
- // (or the operator passed --fail-closed explicitly at install time).
1469
+ // An explicit --fail-closed flag never overrides Shield Monitor.
1476
1470
  // A monitor machine must never hard-block — not even during an
1477
1471
  // outage with a bundle-supplied failClosed=true left over.
1478
1472
  (0, telemetry_1.spoolEvent)({ decision: 'block', toolName, operation, reason: `fail-closed: ${detail}`, offlineEnforced: true });
@@ -1575,13 +1569,13 @@ class McpGatewayServer {
1575
1569
  }
1576
1570
  }
1577
1571
  if (responseOutcome.maskFindings.length > 0) {
1578
- // mask-action rules: redact the matched secrets in place. Masking is
1579
- // monitor-compatible (a redaction is not a block), so it applies in
1580
- // every mode. If the result shape cannot be rewritten (secret hidden
1572
+ // Enforcing mask-action rules redact matched secrets in place.
1573
+ // Monitor snapshots downgrade mask to warn before evaluation.
1574
+ // If the result shape cannot be rewritten (secret hidden
1581
1575
  // in a non-text payload), fail strict and block — a mask rule must
1582
1576
  // never leak what it was set to hide — except under monitor, where
1583
1577
  // the contract is observe-only: record the would-block and pass the
1584
- // response through with whatever partial masking was possible.
1578
+ // original response through.
1585
1579
  const { masked, fullyMasked } = maskToolResult(rawResult, responseOutcome.maskFindings);
1586
1580
  if (!fullyMasked) {
1587
1581
  const finding = responseOutcome.maskFindings[0];
@@ -1642,10 +1636,11 @@ class McpGatewayServer {
1642
1636
  let finalResult = rawResult;
1643
1637
  if (effectiveScanResponse) {
1644
1638
  try {
1645
- finalResult = await this.api.scanToolResponse({ toolName, operation, toolArgs, result: rawResult });
1639
+ const scannedResult = await this.api.scanToolResponse({ toolName, operation, toolArgs, result: rawResult });
1640
+ finalResult = reportOnlyMode ? rawResult : scannedResult;
1646
1641
  }
1647
1642
  catch (scanErr) {
1648
- if (isBlockError(scanErr))
1643
+ if (isBlockError(scanErr) && !reportOnlyMode)
1649
1644
  throw scanErr; // Shield verdict — a real block
1650
1645
  // Scan API/infra failure. On an ENFORCING machine, returning the raw
1651
1646
  // response would silently skip the org's exfil scan — that is a
@@ -1656,7 +1651,7 @@ class McpGatewayServer {
1656
1651
  (0, telemetry_1.triggerFlush)(true);
1657
1652
  throw blockError(`FullCourtDefense could not scan the ${toolName} response (${detail}) and this machine enforces response scanning — response withheld. Retry once the scan service is reachable.`);
1658
1653
  }
1659
- (0, telemetry_1.spoolEvent)({ decision: 'warn', toolName, operation, reason: `[monitor] response scan unavailable: ${detail.slice(0, 200)}`, offlineEnforced: true });
1654
+ (0, telemetry_1.spoolEvent)({ decision: 'warn', toolName, operation, reason: `[monitor] ${isBlockError(scanErr) ? 'response would be blocked' : 'response scan unavailable'}: ${detail.slice(0, 200)}`, offlineEnforced: true });
1660
1655
  (0, telemetry_1.triggerFlush)(false);
1661
1656
  process.stderr.write(`FullCourtDefense (monitor): response scan unavailable for ${toolName} (${detail}) — response passed through.\n`);
1662
1657
  finalResult = rawResult;
@@ -1678,6 +1673,10 @@ class McpGatewayServer {
1678
1673
  (0, telemetry_1.triggerFlush)(false);
1679
1674
  });
1680
1675
  }
1676
+ if (policyWarning && finalResult && typeof finalResult === 'object') {
1677
+ const result = finalResult;
1678
+ finalResult = { ...result, content: [...(Array.isArray(result.content) ? result.content : []), { type: 'text', text: `FullCourtDefense warning: ${policyWarning}` }] };
1679
+ }
1681
1680
  return finalResult;
1682
1681
  }
1683
1682
  catch (error) {
@@ -1713,6 +1712,14 @@ class McpGatewayServer {
1713
1712
  content: [{ type: 'text', text: 'This machine is suspended by your security team — all AI agent actions are blocked. Contact your admin to resume it.' }],
1714
1713
  };
1715
1714
  }
1715
+ if (!isBlockError(error) && !downstreamAttempted && !reportOnlyMode && this.gatewayConfig.failClosed) {
1716
+ try {
1717
+ (0, telemetry_1.spoolEvent)({ decision: 'block', toolName, operation, reason: `fail-closed: gateway evaluation failed: ${message.slice(0, 200)}`, offlineEnforced: true });
1718
+ (0, telemetry_1.triggerFlush)(true);
1719
+ }
1720
+ catch { /* preserve the configured decision even when telemetry fails */ }
1721
+ return { isError: true, blocked: true, content: [{ type: 'text', text: 'FullCourtDefense could not verify this action. Your administrator enabled fail-closed. Retry after policy checking recovers.' }] };
1722
+ }
1716
1723
  if (!isBlockError(error) && downstreamRan) {
1717
1724
  // Post-call scan bug: the tool already succeeded — return its result.
1718
1725
  try {
@@ -1807,7 +1814,7 @@ class McpGatewayServer {
1807
1814
  const sessionId = runtimeSessionId() || this.gatewaySessionId;
1808
1815
  const policyLabel = verdict.policyName ? ` (${verdict.policyName})` : '';
1809
1816
  const ruleId = verdict.policyName ? `action-policy:${verdict.policyName}` : undefined;
1810
- const remembered = (0, devConfirm_1.isConfirmationRemembered)(rememberScope, sessionId);
1817
+ const remembered = verdict.approval?.remember === 'none' ? undefined : (0, devConfirm_1.isConfirmationRemembered)(rememberScope, sessionId);
1811
1818
  if (remembered) {
1812
1819
  (0, telemetry_1.spoolEvent)({
1813
1820
  decision: 'allow', toolName, operation,
@@ -1837,11 +1844,11 @@ class McpGatewayServer {
1837
1844
  const answer = await this.elicitDeveloperConfirmation({
1838
1845
  toolName,
1839
1846
  reason: `${verdict.reason || `${toolName} needs your confirmation`}.${ceiling}`,
1847
+ allowRemember: false,
1840
1848
  ruleLabel: verdict.policyName ? `Action Policy "${verdict.policyName}"${verdict.matchedRule ? ` — ${verdict.matchedRule}` : ''}` : 'Action Policy',
1841
1849
  });
1842
1850
  if (answer.answer === 'accept') {
1843
- if (answer.remember && remember === 'none')
1844
- (0, devConfirm_1.raiseDeveloperConfirmation)({ ...marker, remember: 'session' });
1851
+ // Policy memory limits are authoritative; a client cannot upgrade 'none'.
1845
1852
  (0, devConfirm_1.settleDeveloperConfirmation)('mcp_gateway', toolName, digest);
1846
1853
  (0, telemetry_1.spoolEvent)({
1847
1854
  decision: 'allow', toolName, operation,
@@ -1851,7 +1858,7 @@ class McpGatewayServer {
1851
1858
  (0, telemetry_1.triggerFlush)(false);
1852
1859
  return true;
1853
1860
  }
1854
- (0, devConfirm_1.settleDeveloperConfirmation)('mcp_gateway', toolName, digest);
1861
+ (0, devConfirm_1.rejectDeveloperConfirmation)('mcp_gateway', toolName, digest);
1855
1862
  const how = answer.answer === 'timeout' ? 'developer confirmation not given (prompt timed out)' : 'developer declined in MCP client prompt';
1856
1863
  (0, telemetry_1.spoolEvent)({
1857
1864
  decision: 'block', toolName, operation,
@@ -1875,12 +1882,12 @@ class McpGatewayServer {
1875
1882
  description: 'Yes runs the tool call once. No (or closing this dialog) does not run it.',
1876
1883
  default: false,
1877
1884
  },
1878
- remember: {
1879
- type: 'boolean',
1880
- title: 'Remember for this session',
1881
- description: 'Do not ask again for this tool and rule until this MCP session ends (max 12 hours).',
1882
- default: false,
1883
- },
1885
+ ...(input.allowRemember === false ? {} : { remember: {
1886
+ type: 'boolean',
1887
+ title: 'Remember for this session',
1888
+ description: 'Do not ask again for this tool and rule until this MCP session ends (max 12 hours).',
1889
+ default: false,
1890
+ } }),
1884
1891
  },
1885
1892
  required: ['confirm'],
1886
1893
  },
@@ -1891,7 +1898,7 @@ class McpGatewayServer {
1891
1898
  const result = (response.result && typeof response.result === 'object') ? response.result : undefined;
1892
1899
  const content = (result?.content && typeof result.content === 'object') ? result.content : undefined;
1893
1900
  if (result?.action === 'accept' && content?.confirm === true) {
1894
- return { answer: 'accept', remember: content.remember === true, latencyMs };
1901
+ return { answer: 'accept', remember: input.allowRemember !== false && content.remember === true, latencyMs };
1895
1902
  }
1896
1903
  return { answer: 'decline', remember: false, latencyMs };
1897
1904
  }
@@ -173,10 +173,10 @@ function buildGuardJs(nodePath, cliEntry) {
173
173
  `// exec), $( ) substitution, or real backtick command substitution (>=2 chars; a 1-char pair is a PS escape).`,
174
174
  `function execView(line){if(/\\b(?:iex|invoke-expression|invoke-command)\\b|\\beval\\b|\\bxargs\\b|\\bbase64\\b[^\\n]*(?:-d\\b|--decode\\b)|frombase64string|-encodedcommand\\b|\\s-enc\\s|\\|\\s*&?\\s*(?:sh|bash|zsh|dash|ksh|csh|pwsh|powershell(?:\\.exe)?|cmd(?:\\.exe)?|node|python[0-9.]*|perl|ruby)\\b|\\b(?:sh|bash|zsh|dash|ksh|csh|pwsh|powershell(?:\\.exe)?|cmd(?:\\.exe)?|node|python[0-9.]*|perl|ruby)\\s+(?:-c|\\/c|-e|-command|-scriptblock)\\b|\\b(?:ssh|wsl(?:\\.exe)?|chroot)\\b|\\b(?:docker|kubectl|podman|nerdctl)(?:\\.exe)?\\s+(?:exec|run)\\b/i.test(line))return line;const K=/\\$\\(|\`[^\`]{2,}\`/;const C=/(?:(?:^|[;&|(]|\\s)(?:-c|\\/c|-e|-command|-scriptblock|-filter)[= ]\\s*|(?:^|[;&|(]\\s*)(?:\\S*[\\\\\\/])?(?:sudo\\s+|doas\\s+)?(?:ssh|wsl(?:\\.exe)?|chroot|su|screen|tmux)(?:\\s[^;&|"']*)?\\s|(?:\\S*[\\\\\\/])?(?:docker|kubectl|podman|nerdctl)(?:\\.exe)?\\s+(?:exec|run)\\b[^;&|"']*\\s)$/i;let o=line;o=o.replace(/@'[\\s\\S]*?'@/g,' fcd_inert_data ');o=o.replace(/@"[\\s\\S]*?"@/g,m=>m.includes('$(')?m:' fcd_inert_data ');o=o.replace(/<<-?\\s*'(\\w+)'[\\s\\S]*?(?:\\n\\1\\b|$)/g,' fcd_inert_data ');o=o.replace(/<<-?\\s*"?(\\w+)"?[\\s\\S]*?(?:\\n\\1\\b|$)/g,m=>K.test(m)?m:' fcd_inert_data ');const s=o;o=s.replace(/"((?:[^"\\\\]|\\\\.)*)"|'((?:[^'\\\\]|\\\\.)*)'/g,(w,dq,sq,i)=>{const b=dq!==undefined?dq:(sq||'');if(!/\\s/.test(b))return w;if(C.test(s.slice(Math.max(0,i-80),i)))return w;if(dq!==undefined&&K.test(b))return w;return ' fcd_inert_data ';});return o;}`,
175
175
  `// Precedence on one line: block outranks ask, ask outranks warn.`,
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
+ `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;const asks=[];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'){asks.push(r);}else if(!warnHit)warnHit=r;}}catch{}}if(asks.length===1)return asks[0];if(asks.length>1){asks.sort((a,b)=>a.id<b.id?-1:a.id>b.id?1:0);return{...asks[0],id:'asks:'+asks.map(r=>encodeURIComponent(r.id)).join('+'),reason:asks.map(r=>r.reason).join('; ')}}return warnHit;}`,
177
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{}}`,
178
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{}}`,
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='delegated to the IDE permission gate (confirmation recorded by post-hook) — '+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')event.approvedBy='developer';}fs.appendFileSync(SPOOL_PATH,JSON.stringify(event)+'\\n',{encoding:'utf8',mode:0o600});triggerFlush();}catch{}}`,
180
180
  (0, agentTerminal_1.buildAgentTerminalJs)(),
181
181
  `let argv=process.argv.slice(2);`,
182
182
  `// --record <confirmed|remembered|declined|timeout> <ruleId> -- <line>: the shell answered an ask prompt; report it and exit.`,
@@ -54,6 +54,9 @@ exports.installShellGuardCommand = installShellGuardCommand;
54
54
  exports.uninstallShellGuardCommand = uninstallShellGuardCommand;
55
55
  const child_process_1 = require("child_process");
56
56
  const fs = __importStar(require("fs"));
57
+ const crypto = __importStar(require("crypto"));
58
+ const config_1 = require("../config");
59
+ const localSafetySnapshot_1 = require("../localSafetySnapshot");
57
60
  const os = __importStar(require("os"));
58
61
  const path = __importStar(require("path"));
59
62
  const deterministicGuard_1 = require("./deterministicGuard");
@@ -379,16 +382,48 @@ function builtinRuleCommandNames() {
379
382
  function escapeDotNetRegex(literal) {
380
383
  return literal.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
381
384
  }
382
- /** Cached Shield mode from the runtime bundle cache (offline read, never network). */
385
+ /** Select the enrolled Shield and its exact identity snapshot, never merge old caches. */
386
+ function shellEnrollment() {
387
+ const config = (0, config_1.loadMachineConfig)();
388
+ return {
389
+ shieldId: config.shieldId || process.env.FCD_SHIELD_ID || process.env.FULLCOURTDEFENSE_SHIELD_ID,
390
+ apiUrl: (config.apiUrl || process.env.FULLCOURTDEFENSE_API_URL || process.env.FCD_API_URL || 'https://api.fullcourtdefense.ai').replace(/\/$/, ''),
391
+ };
392
+ }
393
+ function cachedSnapshotFiles() {
394
+ try {
395
+ const enrollment = shellEnrollment();
396
+ if (enrollment.shieldId) {
397
+ const identity = (0, localSafetySnapshot_1.hookSnapshotIdentity)();
398
+ const key = crypto.createHash('sha1').update([enrollment.apiUrl, enrollment.shieldId, identity.developerName, identity.machineName].join('|')).digest('hex');
399
+ const name = `local-safety-${key}.json`;
400
+ return fs.existsSync(path.join(SNAPSHOT_CACHE_DIR, name)) ? [name] : [];
401
+ }
402
+ // Legacy standalone guards have no enrollment. Pick one coherent snapshot;
403
+ // never combine a disabled list from one version with actions from another.
404
+ return fs.readdirSync(SNAPSHOT_CACHE_DIR)
405
+ .filter(name => name.startsWith('local-safety-') && name.endsWith('.json'))
406
+ .map(name => { try {
407
+ return { name, at: JSON.parse(fs.readFileSync(path.join(SNAPSHOT_CACHE_DIR, name), 'utf8')).loadedAt || 0 };
408
+ }
409
+ catch {
410
+ return { name, at: -1 };
411
+ } })
412
+ .filter(entry => entry.at >= 0).sort((a, b) => b.at - a.at || a.name.localeCompare(b.name))
413
+ .slice(0, 1).map(entry => entry.name);
414
+ }
415
+ catch {
416
+ return [];
417
+ }
418
+ }
419
+ /** Cached Shield mode from the active enrollment (offline, never network). */
383
420
  function cachedMode() {
384
421
  try {
385
422
  const parsed = JSON.parse(fs.readFileSync(RUNTIME_CACHE_PATH, 'utf8'));
423
+ const { shieldId } = shellEnrollment();
386
424
  const entries = Object.values(parsed).filter(e => e && typeof e === 'object');
387
425
  entries.sort((a, b) => (b.fetchedAt || 0) - (a.fetchedAt || 0));
388
- const mode = entries[0]?.mode;
389
- // Enforce only on an explicit cached 'block'; missing/unknown = monitor.
390
- // A machine that has never been told to enforce must not block typed
391
- // commands (fresh install before the first bundle fetch, wiped cache).
426
+ const mode = shieldId ? parsed[shieldId]?.mode : entries[0]?.mode;
392
427
  return mode === 'block' ? 'block' : 'monitor';
393
428
  }
394
429
  catch {
@@ -404,7 +439,7 @@ function cachedDisabledItemIds() {
404
439
  const disabled = new Set();
405
440
  let snapshots = 0;
406
441
  try {
407
- for (const file of fs.readdirSync(SNAPSHOT_CACHE_DIR)) {
442
+ for (const file of cachedSnapshotFiles()) {
408
443
  if (!file.startsWith('local-safety-') || !file.endsWith('.json'))
409
444
  continue;
410
445
  try {
@@ -443,7 +478,7 @@ function cachedItemActions() {
443
478
  const ask = new Set();
444
479
  const blocked = new Set();
445
480
  try {
446
- for (const file of fs.readdirSync(SNAPSHOT_CACHE_DIR)) {
481
+ for (const file of cachedSnapshotFiles()) {
447
482
  if (!file.startsWith('local-safety-') || !file.endsWith('.json'))
448
483
  continue;
449
484
  try {
@@ -481,7 +516,7 @@ function cachedItemActions() {
481
516
  function cachedCustomRules() {
482
517
  const rules = [];
483
518
  try {
484
- for (const file of fs.readdirSync(SNAPSHOT_CACHE_DIR)) {
519
+ for (const file of cachedSnapshotFiles()) {
485
520
  if (!file.startsWith('local-safety-') || !file.endsWith('.json'))
486
521
  continue;
487
522
  try {
@@ -524,11 +559,12 @@ function cachedCustomRules() {
524
559
  function effectiveBuiltinRules() {
525
560
  const disabled = cachedDisabledItemIds();
526
561
  const itemActions = cachedItemActions();
562
+ const hasSnapshot = cachedSnapshotFiles().length > 0;
527
563
  return BUILTIN_RULES
528
564
  .filter(rule => !disabled.has(rule.id))
529
565
  .map(rule => {
530
566
  const action = itemActions.get(rule.id);
531
- return action ? { ...rule, action } : rule;
567
+ return action ? { ...rule, action } : hasSnapshot ? rule : { ...rule, action: 'warn' };
532
568
  });
533
569
  }
534
570
  /** Write (or rewrite) the ruleset the profile guard loads at shell startup. */
@@ -707,7 +743,7 @@ function buildGuardPs1(nodePath, cliEntry) {
707
743
  ` try { $execView = Get-FcdExecView -CommandLine $CommandLine } catch { }`,
708
744
  ` $execNormalized = ($execView -replace '\\s+', ' ').Trim()`,
709
745
  ` $warnHit = $null`,
710
- ` $askHit = $null`,
746
+ ` $askHits = @()`,
711
747
  ` foreach ($rule in $global:FcdGuardRules) {`,
712
748
  ` try {`,
713
749
  ` # Built-in rules ask "can this text ACT?" — they see the execution`,
@@ -719,12 +755,19 @@ function buildGuardPs1(nodePath, cliEntry) {
719
755
  ` if ($rule.Regex.IsMatch($targetNorm) -or $rule.Regex.IsMatch($target)) {`,
720
756
  ` # Precedence on one line: block outranks ask, ask outranks warn.`,
721
757
  ` if ($rule.Action -eq 'block') { return $rule }`,
722
- ` if ($rule.Action -eq 'ask') { if ($null -eq $askHit) { $askHit = $rule } }`,
758
+ ` if ($rule.Action -eq 'ask') { $askHits += $rule }`,
723
759
  ` elseif ($null -eq $warnHit) { $warnHit = $rule }`,
724
760
  ` }`,
725
761
  ` } catch { }`,
726
762
  ` }`,
727
- ` if ($null -ne $askHit) { return $askHit }`,
763
+ ` if ($askHits.Count -eq 1) { return $askHits[0] }`,
764
+ ` if ($askHits.Count -gt 1) {`,
765
+ ` $askHits = @($askHits | Sort-Object -Property Id -CaseSensitive)`,
766
+ ` $combined = $askHits[0].PSObject.Copy()`,
767
+ ` $combined.Id = 'asks:' + (($askHits | ForEach-Object { [Uri]::EscapeDataString($_.Id) }) -join '+')`,
768
+ ` $combined.Reason = ($askHits | ForEach-Object { $_.Reason }) -join '; '`,
769
+ ` return $combined`,
770
+ ` }`,
728
771
  ` return $warnHit`,
729
772
  `}`,
730
773
  ``,
@@ -774,7 +817,7 @@ function buildGuardPs1(nodePath, cliEntry) {
774
817
  ` 'remembered' { $reason = 'developer confirmed earlier in this shell (session memory) — ' + $reason }`,
775
818
  ` 'declined' { $reason = 'developer declined in terminal prompt — ' + $reason }`,
776
819
  ` 'timeout' { $reason = 'developer confirmation not given (terminal prompt timed out) — ' + $reason }`,
777
- ` 'ide' { $reason = 'developer confirmed in the IDE prompt (agent-driven terminal) — ' + $reason }`,
820
+ ` 'ide' { $reason = 'delegated to the IDE permission gate (confirmation recorded by post-hook) — ' + $reason }`,
778
821
  ` 'agent-terminal' { $reason = 'ask cannot be answered in an agent-driven terminal (no IDE confirmation) — ' + $reason }`,
779
822
  ` }`,
780
823
  ` $event = [ordered]@{`,
@@ -793,7 +836,7 @@ function buildGuardPs1(nodePath, cliEntry) {
793
836
  ` }`,
794
837
  ` if ($Developer -ne '') {`,
795
838
  ` $event['approvalScope'] = 'developer'`,
796
- ` if ($Developer -eq 'confirmed' -or $Developer -eq 'remembered' -or $Developer -eq 'ide') { $event['approvedBy'] = 'developer' }`,
839
+ ` if ($Developer -eq 'confirmed' -or $Developer -eq 'remembered') { $event['approvedBy'] = 'developer' }`,
797
840
  ` }`,
798
841
  ` $event = [pscustomobject]$event`,
799
842
  ` # BOM-free UTF8 append — PS5's Add-Content -Encoding utf8 writes a BOM that breaks JSONL parsing.`,
@@ -53,6 +53,7 @@ const mcpGateway_1 = require("./mcpGateway");
53
53
  const discoverSchedule_1 = require("./discoverSchedule");
54
54
  const installCursorHook_1 = require("./installCursorHook");
55
55
  const installClaudeHook_1 = require("./installClaudeHook");
56
+ const installNativeHooks_1 = require("./installNativeHooks");
56
57
  const shellGuard_1 = require("./shellGuard");
57
58
  const posixShellGuard_1 = require("./posixShellGuard");
58
59
  const cmdGuard_1 = require("./cmdGuard");
@@ -246,6 +247,7 @@ async function runLocalDetach(args, config) {
246
247
  { label: 'MCP gateway wraps (restore original server commands)', run: () => (0, mcpGateway_1.unprotectAllCommand)({ ...args, clients: args.clients || 'all' }, config) },
247
248
  { label: 'Cursor runtime hooks', run: () => (0, installCursorHook_1.uninstallCursorHookCommand)({}) },
248
249
  { label: 'Claude Code / VS Code / Copilot CLI hooks', run: () => (0, installClaudeHook_1.uninstallClaudeHookCommand)({}) },
250
+ { label: 'Codex / Gemini / Windsurf / current Kiro project hooks', run: () => { (0, installNativeHooks_1.installNativeHooks)({ remove: true }); } },
249
251
  ...(process.platform === 'win32'
250
252
  ? [
251
253
  { label: 'PowerShell shell guard', run: () => (0, shellGuard_1.uninstallShellGuardCommand)() },
package/dist/config.d.ts CHANGED
@@ -61,6 +61,8 @@ export declare function dpapiRoundtripProbe(): {
61
61
  detail: string;
62
62
  };
63
63
  export declare function getHomeConfigPath(): string;
64
+ /** Machine enrollment only; terminal guards must not inherit a repository's Shield. */
65
+ export declare function loadMachineConfig(): BotGuardConfig;
64
66
  export declare function loadConfig(configPath?: string): BotGuardConfig;
65
67
  export declare function getDefaultConfigPath(): string;
66
68
  export declare function saveShieldConfig(input: {
package/dist/config.js CHANGED
@@ -38,6 +38,7 @@ exports.getPowershellHealth = getPowershellHealth;
38
38
  exports.capturePowershellLanguageMode = capturePowershellLanguageMode;
39
39
  exports.dpapiRoundtripProbe = dpapiRoundtripProbe;
40
40
  exports.getHomeConfigPath = getHomeConfigPath;
41
+ exports.loadMachineConfig = loadMachineConfig;
41
42
  exports.loadConfig = loadConfig;
42
43
  exports.getDefaultConfigPath = getDefaultConfigPath;
43
44
  exports.saveShieldConfig = saveShieldConfig;
@@ -295,6 +296,10 @@ function mergeConfig(base, override) {
295
296
  function getHomeConfigPath() {
296
297
  return path.join(os.homedir(), '.fullcourtdefense.yml');
297
298
  }
299
+ /** Machine enrollment only; terminal guards must not inherit a repository's Shield. */
300
+ function loadMachineConfig() {
301
+ return readConfigFile(getHomeConfigPath());
302
+ }
298
303
  function loadConfig(configPath) {
299
304
  const homeConfig = readConfigFile(getHomeConfigPath());
300
305
  let projectConfig = {};
@@ -13,6 +13,8 @@ export interface PendingConfirmation {
13
13
  sessionId?: string;
14
14
  remember: ConfirmRemember;
15
15
  raisedAt: string;
16
+ /** Exact-command handoff to the IDE permission gate; not proof of consent. */
17
+ permissionHandoff?: boolean;
16
18
  }
17
19
  export interface RememberedConfirmation {
18
20
  /**
@@ -65,6 +67,8 @@ export declare function settleDeveloperConfirmation(event: string, toolName: str
65
67
  marker: PendingConfirmation;
66
68
  confirmLatencyMs: number;
67
69
  } | undefined;
70
+ /** Reject or cancel a pending request without creating approval memory. */
71
+ export declare function rejectDeveloperConfirmation(event: string, toolName: string, argsDigest: string): void;
68
72
  /**
69
73
  * Markers older than the TTL never saw a post-execution hook: the developer denied the IDE
70
74
  * prompt, dismissed it, or the client ignored `ask`. Removed and returned so the caller can