fullcourtdefense-cli 1.34.1 → 1.34.4

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.
@@ -42,6 +42,8 @@ export type ActionPolicyApprovalRemember = 'none' | 'session' | '1h';
42
42
  /** What happens when nobody answers within the approval window. */
43
43
  export type ActionPolicyApprovalOnTimeout = 'block' | 'org';
44
44
  export interface ActionPolicyApproval {
45
+ /** Explicit admin exception: replace approval from these policies for this constrained target only. Hard blocks still win. */
46
+ replacesPolicyIds?: string[];
45
47
  scope?: ActionPolicyApprovalScope;
46
48
  remember?: ActionPolicyApprovalRemember;
47
49
  onTimeout?: ActionPolicyApprovalOnTimeout;
@@ -59,6 +61,8 @@ export interface ActionPolicyRule {
59
61
  /** Approval routing for `require_approval` rules. Absent = org admin approval, no memory, block on timeout. */
60
62
  approval?: ActionPolicyApproval;
61
63
  }
64
+ /** Reject ambiguous/broad approval exceptions at authoring time; runtime also fails closed on them. */
65
+ export declare function approvalExceptionError(rule: ActionPolicyRule): string | undefined;
62
66
  /**
63
67
  * The three kinds of machine a fleet policy can be written for:
64
68
  * - `workstation` — laptops / desktops a person uses (default for any machine that does not say otherwise)
@@ -19,6 +19,7 @@
19
19
  */
20
20
  Object.defineProperty(exports, "__esModule", { value: true });
21
21
  exports.DEFAULT_ACTION_POLICY_APPROVAL = exports.CONTENT_FACT_FIELDS = exports.WORKSTATION_ASK_APPROVAL = exports.MACHINE_KINDS = void 0;
22
+ exports.approvalExceptionError = approvalExceptionError;
22
23
  exports.normalizeMachineKind = normalizeMachineKind;
23
24
  exports.policyTargetsMachineKind = policyTargetsMachineKind;
24
25
  exports.ruleNeedsContentFact = ruleNeedsContentFact;
@@ -31,6 +32,25 @@ exports.detectSecretKind = detectSecretKind;
31
32
  exports.evaluateActionPolicies = evaluateActionPolicies;
32
33
  exports.inferToolContext = inferToolContext;
33
34
  exports.inventoryToolMatchesActionPolicy = inventoryToolMatchesActionPolicy;
35
+ /** Reject ambiguous/broad approval exceptions at authoring time; runtime also fails closed on them. */
36
+ function approvalExceptionError(rule) {
37
+ const ids = rule.approval?.replacesPolicyIds;
38
+ if (ids === undefined)
39
+ return undefined;
40
+ if (!Array.isArray(ids) || ids.length > 50 || ids.some(id => typeof id !== 'string' || !id.trim())) {
41
+ return 'Approval exceptions must name at most 50 existing policy IDs.';
42
+ }
43
+ if (ids.length === 0)
44
+ return undefined;
45
+ if (rule.verdict !== 'require_approval' || rule.approval?.scope !== 'developer') {
46
+ return 'Only an Ask developer rule can replace organization approval.';
47
+ }
48
+ if (!['developerName', 'agentName', 'toolName'].every(field => rule.constraints?.some(c => c.field === field && (c.operator === 'equals' || (field === 'agentName' && c.operator === 'contains'))
49
+ && typeof c.value === 'string' && c.value.trim().length > 0))) {
50
+ return 'A developer-only exception must identify a developer, an agent and an exact tool.';
51
+ }
52
+ return undefined;
53
+ }
34
54
  exports.MACHINE_KINDS = ['workstation', 'ci', 'workload'];
35
55
  /** Normalize a context value into a MachineKind; anything unknown is a workstation. */
36
56
  function normalizeMachineKind(value) {
@@ -1355,6 +1375,29 @@ function evaluateActionPolicies(policies, toolName, operation, context = {}, dec
1355
1375
  let requiredApproval;
1356
1376
  let requiredApprovalResult;
1357
1377
  const approvalObligations = [];
1378
+ const policyApplies = (policy) => policy.enabled !== false
1379
+ && !(policy.scope === 'organization' && !context.developerName) && policyTargetsCall(policy, context);
1380
+ const ruleMatches = (rule) => candidateOperations.some(candidate => operationMatchesRule(candidate, rule.operations, matchText, toolCaps))
1381
+ && (!rule.constraints?.length || rule.constraints.every(c => evaluateConstraint(c, context)));
1382
+ // Exceptions are explicit, name the displaced policies, and require an
1383
+ // attributed developer + concrete agent + exact tool. They never lower a
1384
+ // hard block or a newly added organization approval policy not named here.
1385
+ const replacedApprovalPolicies = new Set();
1386
+ const appliedApprovalExceptions = new Set();
1387
+ for (const policy of policies) {
1388
+ if (!policyApplies(policy) || policy.stage === 'monitor')
1389
+ continue;
1390
+ for (const rule of policy.rules) {
1391
+ if (rule.verdict !== 'require_approval' || rule.approval?.scope !== 'developer' || !ruleMatches(rule))
1392
+ continue;
1393
+ if (approvalExceptionError(rule) || !Array.isArray(rule.approval.replacesPolicyIds))
1394
+ continue;
1395
+ for (const id of rule.approval.replacesPolicyIds) {
1396
+ if (typeof id === 'string' && id !== policy.id)
1397
+ replacedApprovalPolicies.add(id);
1398
+ }
1399
+ }
1400
+ }
1358
1401
  for (const policy of policies) {
1359
1402
  // Org-scoped policies bind to attributed RUNTIME USERS — anonymous/system calls (no
1360
1403
  // developerName) are deliberately out of scope. Surfaced via skippedOrgPolicies so
@@ -1369,12 +1412,12 @@ function evaluateActionPolicies(policies, toolName, operation, context = {}, dec
1369
1412
  continue;
1370
1413
  const isMonitorStage = policy.stage === 'monitor';
1371
1414
  for (const rule of policy.rules) {
1372
- if (!candidateOperations.some(candidate => operationMatchesRule(candidate, rule.operations, matchText, toolCaps)))
1415
+ if (!ruleMatches(rule))
1416
+ continue;
1417
+ if (!isMonitorStage && rule.verdict === 'require_approval' && rule.approval?.scope !== 'developer'
1418
+ && policy.enforcement !== 'hard' && replacedApprovalPolicies.has(policy.id)) {
1419
+ appliedApprovalExceptions.add(`${policy.name} (${policy.id})`);
1373
1420
  continue;
1374
- if (rule.constraints && rule.constraints.length > 0) {
1375
- const allMatch = rule.constraints.every(c => evaluateConstraint(c, context));
1376
- if (!allMatch)
1377
- continue;
1378
1421
  }
1379
1422
  const constraintDesc = rule.constraints?.map(c => `${c.field} ${c.operator} ${c.value}`).join(', ');
1380
1423
  const matchedRule = `${rule.operations.join('/')} → ${rule.verdict}${constraintDesc ? ` (${constraintDesc})` : ''}`;
@@ -1426,7 +1469,7 @@ function evaluateActionPolicies(policies, toolName, operation, context = {}, dec
1426
1469
  else if (rule.verdict === 'require_approval' && worstResult.verdict === 'require_approval') {
1427
1470
  // A second approval rule matched: keep the first rule's attribution but tighten the
1428
1471
  // approval routing to the most restrictive of both (a developer-scoped rule can never
1429
- // relax an org-scoped one that also fires).
1472
+ // relax an org-scoped one unless explicitly replaced above).
1430
1473
  worstResult = {
1431
1474
  ...worstResult,
1432
1475
  approval: mergeApprovalOptions(worstResult.approval || exports.DEFAULT_ACTION_POLICY_APPROVAL, resolveApprovalOptions(rule.approval)),
@@ -1463,6 +1506,9 @@ function evaluateActionPolicies(policies, toolName, operation, context = {}, dec
1463
1506
  if (worstResult.verdict === 'require_approval' && approvalObligations.length > 1) {
1464
1507
  worstResult = { ...worstResult, matchedRule: [...approvalObligations].sort().join('; ') };
1465
1508
  }
1509
+ if (appliedApprovalExceptions.size > 0) {
1510
+ worstResult = { ...worstResult, reason: `${worstResult.reason || worstResult.verdict}; explicit developer approval exception replaces: ${[...appliedApprovalExceptions].sort().join(', ')}` };
1511
+ }
1466
1512
  if (monitorMatches.length > 0) {
1467
1513
  worstResult = { ...worstResult, monitorMatches };
1468
1514
  }
@@ -0,0 +1,12 @@
1
+ /**
2
+ * Name dictionary, not an effective configuration or an authorization source.
3
+ * Include user-owned profiles conservatively: hook payloads do not identify the
4
+ * selected profile. Aliases shared by different names are denied, never guessed.
5
+ * Never consult project files unless the user configuration trusts their root.
6
+ */
7
+ export declare function codexMcpServerNames(home?: string, cwd?: string): string[];
8
+ /** Codex exposes sanitized tool namespaces. Resolve only unique configured identities. */
9
+ export declare function resolveCodexMcpTool(tool: string, serverNames: string[]): {
10
+ server: string;
11
+ tool: string;
12
+ } | undefined;
@@ -0,0 +1,119 @@
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.codexMcpServerNames = codexMcpServerNames;
37
+ exports.resolveCodexMcpTool = resolveCodexMcpTool;
38
+ const fs = __importStar(require("fs"));
39
+ const os = __importStar(require("os"));
40
+ const path = __importStar(require("path"));
41
+ const toml_1 = require("@iarna/toml");
42
+ /** Parse config without executing commands or exposing its contents in errors. */
43
+ function readConfig(file) {
44
+ try {
45
+ return (0, toml_1.parse)(fs.readFileSync(file, 'utf8').replace(/^\uFEFF/, ''));
46
+ }
47
+ catch (err) {
48
+ if (err.code === 'ENOENT')
49
+ return {};
50
+ throw new Error('Cannot parse Codex configuration to resolve MCP server identity.');
51
+ }
52
+ }
53
+ /**
54
+ * Name dictionary, not an effective configuration or an authorization source.
55
+ * Include user-owned profiles conservatively: hook payloads do not identify the
56
+ * selected profile. Aliases shared by different names are denied, never guessed.
57
+ * Never consult project files unless the user configuration trusts their root.
58
+ */
59
+ function codexMcpServerNames(home = process.env.CODEX_HOME || path.join(os.homedir(), '.codex'), cwd = process.cwd()) {
60
+ const base = readConfig(path.join(home, 'config.toml'));
61
+ const configs = [base];
62
+ const system = process.platform === 'win32'
63
+ ? path.join(process.env.ProgramData || 'C:\\ProgramData', 'OpenAI', 'Codex', 'config.toml')
64
+ : '/etc/codex/config.toml';
65
+ configs.push(readConfig(system));
66
+ let profiles = [];
67
+ try {
68
+ profiles = fs.readdirSync(home).filter(name => /^[A-Za-z0-9_-]+\.config\.toml$/.test(name));
69
+ }
70
+ catch (err) {
71
+ if (err.code !== 'ENOENT')
72
+ throw err;
73
+ }
74
+ for (const file of profiles) {
75
+ // An invalid, unselected profile is not active in Codex and cannot supply names.
76
+ // Selecting an invalid profile fails Codex startup itself.
77
+ try {
78
+ configs.push(readConfig(path.join(home, file)));
79
+ }
80
+ catch { /* inactive invalid profile */ }
81
+ }
82
+ const within = (root, dir) => {
83
+ const rel = path.relative(root, dir);
84
+ return rel === '' || (rel !== '..' && !rel.startsWith('..' + path.sep) && !path.isAbsolute(rel));
85
+ };
86
+ const roots = Object.keys(base.projects || {}).filter(root => path.isAbsolute(root) && within(root, path.resolve(cwd)))
87
+ .sort((a, b) => b.length - a.length);
88
+ const trustedRoot = roots[0];
89
+ if (trustedRoot && base.projects[trustedRoot]?.trust_level === 'trusted') {
90
+ for (let dir = path.resolve(cwd); within(trustedRoot, dir); dir = path.dirname(dir)) {
91
+ configs.push(readConfig(path.join(dir, '.codex', 'config.toml')));
92
+ if (path.dirname(dir) === dir)
93
+ break;
94
+ }
95
+ }
96
+ const names = new Set();
97
+ for (const config of configs) {
98
+ if (config.mcp_servers && typeof config.mcp_servers === 'object' && !Array.isArray(config.mcp_servers)) {
99
+ for (const name of Object.keys(config.mcp_servers))
100
+ names.add(name);
101
+ }
102
+ }
103
+ return [...names];
104
+ }
105
+ /** Codex exposes sanitized tool namespaces. Resolve only unique configured identities. */
106
+ function resolveCodexMcpTool(tool, serverNames) {
107
+ if (!tool.startsWith('mcp__'))
108
+ return undefined;
109
+ const matches = [...new Set(serverNames)].flatMap(server => {
110
+ const aliases = new Set([server, server.replace(/[^A-Za-z0-9_]/g, '_')]);
111
+ return [...aliases].filter(alias => tool.startsWith(`mcp__${alias}__`))
112
+ .map(alias => ({ server, tool: tool.slice(`mcp__${alias}__`.length) }));
113
+ });
114
+ if (matches.length > 1)
115
+ throw new Error('Ambiguous Codex MCP server identity; use distinct server names.');
116
+ if (matches.length && !matches[0].tool)
117
+ throw new Error('Missing Codex MCP tool name.');
118
+ return matches[0];
119
+ }
@@ -1220,6 +1220,7 @@ async function runDaemon(args, config) {
1220
1220
  machineId: identity.machineId,
1221
1221
  force: true,
1222
1222
  timeoutMs: BUNDLE_FETCH_TIMEOUT_MS,
1223
+ requireServerValidation: true,
1223
1224
  });
1224
1225
  if (typeof bundle.pollIntervalMs === 'number' && Number.isFinite(bundle.pollIntervalMs)) {
1225
1226
  bundlePollBaseMs = Math.min(Math.max(bundle.pollIntervalMs, 15_000), 60 * 60_000);
@@ -1318,7 +1319,12 @@ async function runDaemon(args, config) {
1318
1319
  }
1319
1320
  catch { /* hooks fall back to their own bounded fetch */ }
1320
1321
  }
1321
- catch { /* offline — cached stance applies */ }
1322
+ catch (error) {
1323
+ // Local enforcement can keep using its cache, but this delivery check
1324
+ // failed. Let the scheduler retry promptly instead of treating it as a
1325
+ // successful poll and waiting the full (server-configurable) cadence.
1326
+ throw error;
1327
+ }
1322
1328
  };
1323
1329
  const heartbeat = async () => {
1324
1330
  (0, daemonForensics_1.touchDaemonAlive)();
@@ -1419,7 +1425,11 @@ async function runDaemon(args, config) {
1419
1425
  // First: if this boot IS the post-upgrade relaunch, confirm the pending
1420
1426
  // upgrade_cli action before anything else touches the control plane.
1421
1427
  await verifyPendingUpgrade();
1422
- await pollBundle();
1428
+ await (0, pollLoop_1.runWithDeadline)({
1429
+ run: pollBundle,
1430
+ deadlineMs: BUNDLE_POLL_DEADLINE_MS,
1431
+ onError: error => log(`Initial bundle check failed: ${error instanceof Error ? error.message : String(error)} — background polling will retry.`),
1432
+ });
1423
1433
  // One protective pass at startup so a machine that drifted while the daemon
1424
1434
  // was down converges immediately. Runs BEFORE the first heartbeat: upgrades
1425
1435
  // legitimately leave stale hook/wrap paths behind, and heartbeating first
@@ -1564,7 +1574,7 @@ async function runDaemon(args, config) {
1564
1574
  intervalMs: () => bundlePollBaseMs,
1565
1575
  deadlineMs: BUNDLE_POLL_DEADLINE_MS,
1566
1576
  onStalled: deadlineMs => {
1567
- log(`Bundle poll did not finish within ${Math.round(deadlineMs / 1000)}s — abandoned; remote actions and policy updates were delayed. Next poll continues normally.`);
1577
+ log(`Bundle check did not finish within ${Math.round(deadlineMs / 1000)}s — stopped waiting and scheduled a retry. This timeout does not confirm its cause or recovery.`);
1568
1578
  (0, distress_1.reportDistress)('bundle', distress_1.DISTRESS.POLL_STALLED, `bundle poll exceeded ${deadlineMs}ms`);
1569
1579
  },
1570
1580
  onError: error => log(`Bundle poll error: ${error instanceof Error ? error.message : String(error)}`),
@@ -161,8 +161,11 @@ function mapClaudeToolPayload(payload) {
161
161
  // MCP tools arrive as `mcp__<server>__<tool>` (same in Claude Code and VS Code).
162
162
  if (toolName.startsWith('mcp__')) {
163
163
  const parts = toolName.split('__');
164
- const server = parts[1] || 'mcp';
165
- const shortTool = parts.slice(2).join('__') || toolName;
164
+ // A verified explicit server preserves names containing the separator itself.
165
+ const explicitServer = str(payload.mcp_server_name);
166
+ const server = explicitServer && toolName.startsWith(`mcp__${explicitServer}__`)
167
+ ? explicitServer : parts[1] || 'mcp';
168
+ const shortTool = toolName.slice(`mcp__${server}__`.length) || toolName;
166
169
  return {
167
170
  event: 'mcp',
168
171
  payload: { ...payload, tool_name: shortTool, mcp_server_name: server, tool_input: input },
@@ -789,7 +792,9 @@ function tryLocalPolicyEnforcement(ctx, call, detail, opts = {}) {
789
792
  }
790
793
  const { operation, context } = (0, actionPolicyEngine_1.inferToolContext)(call.toolName, call.toolArgs);
791
794
  context.toolName = call.toolName;
792
- (0, machineIdentity_1.stampMachineIdentityContext)(context, developerId());
795
+ (0, machineIdentity_1.stampRuntimeIdentityContext)(context, {
796
+ developerName: developerId(), agentName: agentNameForCall(ctx.event, ctx.payload, ctx.client), agentClient: ctx.client,
797
+ });
793
798
  const declaredOperations = (0, actionPolicyEngine_1.deriveToolOperations)(call.toolName);
794
799
  const local = (0, actionPolicyEngine_1.evaluateActionPolicies)(policies, call.toolName, operation, context, declaredOperations);
795
800
  (0, hookIo_1.dbg)({ phase: 'policy_local_verdict', event: ctx.event, tool: call.toolName, operation, verdict: local.verdict, policyName: local.policyName, detail });
@@ -1800,7 +1805,9 @@ async function enforceActionPolicy(ctx) {
1800
1805
  if (ctx.localPolicies && (ctx.localPolicies.length > 0 || localAskPending)) {
1801
1806
  const { operation: localOp, context: localCtx } = (0, actionPolicyEngine_1.inferToolContext)(call.toolName, call.toolArgs);
1802
1807
  localCtx.toolName = call.toolName;
1803
- (0, machineIdentity_1.stampMachineIdentityContext)(localCtx, developerId());
1808
+ (0, machineIdentity_1.stampRuntimeIdentityContext)(localCtx, {
1809
+ developerName: developerId(), agentName: agentNameForCall(event, payload, ctx.client), agentClient: ctx.client,
1810
+ });
1804
1811
  const localVerdict = (0, actionPolicyEngine_1.evaluateActionPolicies)(ctx.localPolicies, call.toolName, localOp, localCtx, (0, actionPolicyEngine_1.deriveToolOperations)(call.toolName));
1805
1812
  (0, hookIo_1.dbg)({ phase: 'policy_local_first', event, tool: call.toolName, operation: localOp, verdict: localVerdict.verdict, policyName: localVerdict.policyName });
1806
1813
  if (localVerdict.verdict === 'allow') {
@@ -475,6 +475,7 @@ class StdioMcpClient {
475
475
  buffer = '';
476
476
  nextId = 1;
477
477
  pending = new Map();
478
+ closed = false;
478
479
  constructor(command, args, onClosed) {
479
480
  this.onClosed = onClosed;
480
481
  if (!command)
@@ -482,9 +483,12 @@ class StdioMcpClient {
482
483
  this.child = spawnDownstream(command, args);
483
484
  this.child.stdout.setEncoding('utf8');
484
485
  this.child.stdout.on('data', chunk => this.onData(chunk));
486
+ this.child.stdout.on('end', () => this.fail(new Error('Downstream MCP response stream closed.')));
487
+ this.child.stdout.on('error', error => this.fail(error));
488
+ this.child.stdin.on('error', error => this.fail(error));
485
489
  this.child.stderr.on('data', chunk => process.stderr.write(String(chunk)));
486
- this.child.on('error', error => { this.rejectAll(error); this.onClosed?.(); });
487
- this.child.on('exit', code => { this.rejectAll(new Error(`Downstream MCP server exited (${code ?? 'signal'}).`)); this.onClosed?.(); });
490
+ this.child.on('error', error => this.fail(error));
491
+ this.child.on('exit', code => this.fail(new Error(`Downstream MCP server exited (${code ?? 'signal'}).`)));
488
492
  }
489
493
  async initialize() {
490
494
  await this.request('initialize', {
@@ -513,31 +517,48 @@ class StdioMcpClient {
513
517
  return this.request('tools/call', { name, arguments: args });
514
518
  }
515
519
  close() {
516
- this.child.kill();
520
+ this.fail(new Error('Downstream MCP connection closed.'));
521
+ }
522
+ fail(error) {
523
+ if (this.closed)
524
+ return;
525
+ this.closed = true;
526
+ this.rejectAll(error);
527
+ this.onClosed?.();
528
+ // A process can stay alive after its transport closes. Retire it so it
529
+ // cannot orphan itself; only a new client request may start a replacement.
530
+ try {
531
+ this.child.kill();
532
+ }
533
+ catch { /* already gone */ }
517
534
  }
518
535
  request(method, params) {
536
+ if (this.closed)
537
+ return Promise.reject(new Error('Downstream MCP connection is closed. Retry with a new request.'));
519
538
  const id = this.nextId++;
520
- this.write({ jsonrpc: '2.0', id, method, params });
521
539
  return new Promise((resolve, reject) => {
522
540
  const timeout = setTimeout(() => {
523
- this.pending.delete(String(id));
524
541
  // A downstream that ignores a request for a full minute is hung, not
525
542
  // slow — kill it so the exit handler rejects everything in flight and
526
543
  // the gateway respawns a fresh downstream on the next call.
527
- try {
528
- this.child.kill();
529
- }
530
- catch { /* already gone */ }
531
- reject(new Error(`MCP request timed out: ${method} — downstream server killed; it restarts on the next call.`));
544
+ this.fail(new Error(`MCP request timed out: ${method} — downstream server killed; it restarts on the next call.`));
532
545
  }, 60000);
533
546
  this.pending.set(String(id), { resolve, reject, timeout });
547
+ this.write({ jsonrpc: '2.0', id, method, params });
534
548
  });
535
549
  }
536
550
  notify(method, params) {
537
551
  this.write({ jsonrpc: '2.0', method, params });
538
552
  }
539
553
  write(message) {
540
- this.child.stdin.write(`${JSON.stringify(message)}\n`);
554
+ if (this.closed)
555
+ return;
556
+ try {
557
+ this.child.stdin.write(`${JSON.stringify(message)}\n`);
558
+ }
559
+ catch (error) {
560
+ this.fail(error instanceof Error ? error : new Error(String(error)));
561
+ }
541
562
  }
542
563
  onData(chunk) {
543
564
  this.buffer += chunk;
@@ -1001,6 +1022,12 @@ class McpGatewayServer {
1001
1022
  return;
1002
1023
  }
1003
1024
  try {
1025
+ // Heartbeats belong to this transport: they must stay responsive even
1026
+ // while the downstream is starting or a developer is deciding a call.
1027
+ if (message.method === 'ping') {
1028
+ this.respond(message.id, {});
1029
+ return;
1030
+ }
1004
1031
  if (message.method === 'initialize') {
1005
1032
  const caps = message.params?.capabilities;
1006
1033
  this.clientSupportsElicitation = Boolean(caps && typeof caps === 'object' && caps.elicitation !== undefined && caps.elicitation !== null);
@@ -1324,7 +1351,7 @@ class McpGatewayServer {
1324
1351
  if (cachedPolicies && cachedPolicies.length > 0) {
1325
1352
  const context = inferred.context;
1326
1353
  context.toolName = toolName;
1327
- (0, machineIdentity_1.stampMachineIdentityContext)(context, this.gatewayConfig.developerName);
1354
+ (0, machineIdentity_1.stampRuntimeIdentityContext)(context, this.gatewayConfig);
1328
1355
  const local = (0, actionPolicyEngine_1.evaluateActionPolicies)(cachedPolicies, toolName, localOperation, context, (0, actionPolicyEngine_1.deriveToolOperations)(toolName));
1329
1356
  if (local.verdict === 'block' || local.verdict === 'require_approval') {
1330
1357
  const wouldWhat = local.verdict === 'require_approval' ? 'require approval for' : 'block';
@@ -1354,7 +1381,7 @@ class McpGatewayServer {
1354
1381
  const localOperation = operation || inferred.operation;
1355
1382
  const context = inferred.context;
1356
1383
  context.toolName = toolName;
1357
- (0, machineIdentity_1.stampMachineIdentityContext)(context, this.gatewayConfig.developerName);
1384
+ (0, machineIdentity_1.stampRuntimeIdentityContext)(context, this.gatewayConfig);
1358
1385
  const local = (0, actionPolicyEngine_1.evaluateActionPolicies)(cachedPolicies, toolName, localOperation, context, (0, actionPolicyEngine_1.deriveToolOperations)(toolName));
1359
1386
  if (local.verdict === 'block') {
1360
1387
  const reason = local.reason || `${toolName}: blocked by org Action Policy`;
@@ -1364,14 +1391,13 @@ class McpGatewayServer {
1364
1391
  throw blockError(explainGatewayPolicy('block', toolName, toolArgs, local, cachedPolicies));
1365
1392
  }
1366
1393
  if (local.verdict === 'require_approval'
1367
- && (0, actionPolicyEngine_1.effectiveApprovalScope)(local.approval, { humanPresent: (0, devConfirm_1.isHumanPresent)() }) === 'developer'
1368
- && this.clientSupportsElicitation) {
1394
+ && (0, actionPolicyEngine_1.effectiveApprovalScope)(local.approval, { humanPresent: (0, devConfirm_1.isHumanPresent)() }) === 'developer') {
1369
1395
  // Developer-scoped approval (an explicit developer rule, or an org
1370
1396
  // `block` softened by the workstation ceiling): ask the human through
1371
1397
  // the MCP client — same ledger and session memory as the IDE hook.
1372
1398
  // Accept → allow (recorded as the developer's decision); decline /
1373
- // timeout → block. A client without elicitation falls through to the
1374
- // org approval queue below — never a silent allow.
1399
+ // timeout → block. Missing client confirmation support must not
1400
+ // silently change a developer-only decision to organization approval.
1375
1401
  const answered = await this.askDeveloperForPolicy(toolName, toolArgs, localOperation, local, cachedPolicies);
1376
1402
  if (answered)
1377
1403
  preflight = { allowed: true, decision: 'allow', operation: localOperation };
@@ -1445,7 +1471,7 @@ class McpGatewayServer {
1445
1471
  const localOperation = operation || inferred.operation;
1446
1472
  const context = inferred.context;
1447
1473
  context.toolName = toolName;
1448
- (0, machineIdentity_1.stampMachineIdentityContext)(context, this.gatewayConfig.developerName);
1474
+ (0, machineIdentity_1.stampRuntimeIdentityContext)(context, this.gatewayConfig);
1449
1475
  const local = (0, actionPolicyEngine_1.evaluateActionPolicies)(cachedPolicies, toolName, localOperation, context, (0, actionPolicyEngine_1.deriveToolOperations)(toolName));
1450
1476
  const blocking = local.verdict === 'block' || local.verdict === 'require_approval';
1451
1477
  if (blocking && !reportOnlyMode) {
@@ -1810,6 +1836,12 @@ class McpGatewayServer {
1810
1836
  * throws the block error when the developer declines or does not answer.
1811
1837
  */
1812
1838
  async askDeveloperForPolicy(toolName, toolArgs, operation, verdict, policies) {
1839
+ if (!this.clientSupportsElicitation) {
1840
+ const reason = 'This rule requires developer confirmation inside the IDE, but this MCP client does not support confirmation prompts. The action was not executed. Update or reconnect the IDE integration.';
1841
+ (0, telemetry_1.spoolEvent)({ decision: 'block', toolName, operation, reason, approvalScope: 'developer', ruleId: verdict.policyId });
1842
+ (0, telemetry_1.triggerFlush)(true);
1843
+ throw blockError(reason);
1844
+ }
1813
1845
  const rememberScope = { toolName, operation, policyId: verdict.policyId, matchedRule: verdict.matchedRule };
1814
1846
  const sessionId = runtimeSessionId() || this.gatewaySessionId;
1815
1847
  const policyLabel = verdict.policyName ? ` (${verdict.policyName})` : '';
@@ -1872,7 +1904,7 @@ class McpGatewayServer {
1872
1904
  const started = Date.now();
1873
1905
  const evidence = input.evidence ? `\n\nEvidence: ${input.evidence.slice(0, 240)}` : '';
1874
1906
  const response = await this.requestClient('elicitation/create', {
1875
- message: `FullCourtDefense — your organization asks you to confirm this action.\n\nTool: ${input.toolName}\nRule: ${input.ruleLabel}\nWhy: ${input.reason}${evidence}\n\nAllow this tool call? Your answer is reported to your security dashboard as your decision.`,
1907
+ message: `FullCourtDefense — your organization asks you to confirm this action.\n\nAgent: ${this.gatewayConfig.agentName}\nIDE: ${this.gatewayConfig.agentClient}\nDeveloper: ${this.gatewayConfig.developerName || 'Unknown'}\nTool: ${input.toolName}\nRule: ${input.ruleLabel}\nWhy: ${input.reason}${evidence}\n\nAllow this tool call? Your answer is reported to your security dashboard as your decision.`,
1876
1908
  requestedSchema: {
1877
1909
  type: 'object',
1878
1910
  properties: {
@@ -45,5 +45,11 @@ export declare function resetLocalMachineKindCache(): void;
45
45
  * workload). `developerName` is only set when the caller resolved one.
46
46
  */
47
47
  export declare function stampMachineIdentityContext<T extends Record<string, string>>(context: T, developerName?: string): T;
48
+ /** Stamp trusted runtime identity after argument inference, for every local policy path. */
49
+ export declare function stampRuntimeIdentityContext<T extends Record<string, string>>(context: T, identity: {
50
+ developerName: string;
51
+ agentName: string;
52
+ agentClient: string;
53
+ }): T;
48
54
  /** Suggested per-machine Shield name, e.g. `boazl@boaz-pc · Windows 11`. */
49
55
  export declare function suggestedShieldName(identity?: MachineIdentity): string;
@@ -38,6 +38,7 @@ exports.getMachineIdentity = getMachineIdentity;
38
38
  exports.localMachineKind = localMachineKind;
39
39
  exports.resetLocalMachineKindCache = resetLocalMachineKindCache;
40
40
  exports.stampMachineIdentityContext = stampMachineIdentityContext;
41
+ exports.stampRuntimeIdentityContext = stampRuntimeIdentityContext;
41
42
  exports.suggestedShieldName = suggestedShieldName;
42
43
  const child_process_1 = require("child_process");
43
44
  const crypto = __importStar(require("crypto"));
@@ -296,6 +297,13 @@ function stampMachineIdentityContext(context, developerName) {
296
297
  context.humanPresent = (0, devConfirm_1.isHumanPresent)() ? 'true' : 'false';
297
298
  return context;
298
299
  }
300
+ /** Stamp trusted runtime identity after argument inference, for every local policy path. */
301
+ function stampRuntimeIdentityContext(context, identity) {
302
+ stampMachineIdentityContext(context, identity.developerName);
303
+ context.agentName = identity.agentName;
304
+ context.agentClient = identity.agentClient;
305
+ return context;
306
+ }
299
307
  /** Suggested per-machine Shield name, e.g. `boazl@boaz-pc · Windows 11`. */
300
308
  function suggestedShieldName(identity = getMachineIdentity()) {
301
309
  return `${identity.developerName} · ${identity.osFriendly}`;
@@ -4,7 +4,7 @@ export type NativeClient = 'codex' | 'gemini_cli' | 'windsurf' | 'kiro';
4
4
  type Payload = Record<string, any>;
5
5
  export declare const NATIVE_CLIENTS: NativeClient[];
6
6
  export declare function patchToolCalls(raw: Payload): Payload[];
7
- export declare function normalizeNativeInput(client: NativeClient, raw: Payload): Payload;
7
+ export declare function normalizeNativeInput(client: NativeClient, raw: Payload, codexServers?: string[]): Payload;
8
8
  export declare function nativeVerdict(client: NativeClient, denied: boolean, reason?: string): {
9
9
  stdout: string;
10
10
  stderr: string;
@@ -15,6 +15,7 @@ const runtimeConfig_1 = require("./runtimeConfig");
15
15
  const hookIo_1 = require("./hookIo");
16
16
  const devConfirm_1 = require("./devConfirm");
17
17
  const telemetry_1 = require("./telemetry");
18
+ const codexMcpIdentity_1 = require("./codexMcpIdentity");
18
19
  exports.NATIVE_CLIENTS = ['codex', 'gemini_cli', 'windsurf', 'kiro'];
19
20
  function patchToolCalls(raw) {
20
21
  const patch = typeof raw.tool_input === 'string' ? raw.tool_input : String(raw.tool_input?.command || raw.tool_input?.patch || '');
@@ -37,10 +38,17 @@ function patchToolCalls(raw) {
37
38
  throw new Error('Patch contains no recognized file operations');
38
39
  return calls;
39
40
  }
40
- function normalizeNativeInput(client, raw) {
41
+ function normalizeNativeInput(client, raw, codexServers = []) {
41
42
  let name = String(raw.hook_event_name || '');
42
43
  let tool = String(raw.tool_name || '');
43
44
  let input = raw.tool_input || {};
45
+ if (client === 'codex') {
46
+ const identity = (0, codexMcpIdentity_1.resolveCodexMcpTool)(tool, codexServers);
47
+ if (identity) {
48
+ tool = `mcp__${identity.server}__${identity.tool}`;
49
+ raw = { ...raw, mcp_server_name: identity.server };
50
+ }
51
+ }
44
52
  if (client === 'windsurf') {
45
53
  const event = String(raw.agent_action_name || '');
46
54
  const info = raw.tool_info || {};
@@ -158,7 +166,7 @@ async function evaluateNativeHook(client, raw, args, config, confirm = confirmNa
158
166
  }
159
167
  let payload;
160
168
  try {
161
- payload = normalizeNativeInput(client, raw);
169
+ payload = normalizeNativeInput(client, raw, client === 'codex' && String(raw.tool_name || '').startsWith('mcp__') ? (0, codexMcpIdentity_1.codexMcpServerNames)() : []);
162
170
  }
163
171
  catch (error) {
164
172
  return nativeErrorVerdict(client, `FullCourtDefense could not parse the hook: ${String(error)}`);
@@ -99,6 +99,8 @@ export interface FetchBundleInput {
99
99
  * 6 health checkpoints false-alarmed on p95 latency spikes.
100
100
  */
101
101
  timeoutMs?: number;
102
+ /** Background delivery checks must surface failure so their scheduler retries. */
103
+ requireServerValidation?: boolean;
102
104
  /**
103
105
  * Hot-path caller (hook / MCP gateway): when the cached mode is monitor or
104
106
  * shadow, accept a STALE cache without a synchronous refresh — the resident
@@ -115,7 +117,7 @@ export interface EffectiveBundle extends RuntimeBundle {
115
117
  }
116
118
  /**
117
119
  * Resolve the effective runtime bundle for a Shield, using the local cache when
118
- * fresh and refreshing (briefly) when stale. Never throws — falls back to cache
120
+ * fresh and refreshing (briefly) when stale. By default, falls back to cache
119
121
  * or a 'default' marker so the caller can apply its local fallback.
120
122
  */
121
123
  export declare function getRuntimeBundle(input: FetchBundleInput): Promise<EffectiveBundle>;
@@ -212,7 +212,7 @@ function sanitizeBundlePolicies(value) {
212
212
  }
213
213
  /**
214
214
  * Resolve the effective runtime bundle for a Shield, using the local cache when
215
- * fresh and refreshing (briefly) when stale. Never throws — falls back to cache
215
+ * fresh and refreshing (briefly) when stale. By default, falls back to cache
216
216
  * or a 'default' marker so the caller can apply its local fallback.
217
217
  */
218
218
  async function getRuntimeBundle(input) {
@@ -220,7 +220,7 @@ async function getRuntimeBundle(input) {
220
220
  const cache = readCacheFile();
221
221
  const cached = cache[input.shieldId];
222
222
  const fresh = cached && Date.now() - cached.fetchedAt < ttl;
223
- if (cached && fresh && !input.force) {
223
+ if (cached && fresh && !input.force && !input.requireServerValidation) {
224
224
  return cachedToEffective(cached);
225
225
  }
226
226
  // MONITOR CONTRACT: hot-path callers never wait on a refresh for a
@@ -231,7 +231,7 @@ async function getRuntimeBundle(input) {
231
231
  // up a kill-switch flip or a monitor->block promotion. Past the bound, ONE
232
232
  // hook per revalidate interval falls through to the bounded (≤1.5s) refresh
233
233
  // below; concurrent/subsequent hooks keep taking the stale cache.
234
- if (cached && !input.force && input.hotPath && cached.mode !== 'block') {
234
+ if (cached && !input.force && !input.requireServerValidation && input.hotPath && cached.mode !== 'block') {
235
235
  const age = Date.now() - cached.fetchedAt;
236
236
  const sinceLastAttempt = Date.now() - (cached.revalidateAttemptAt || 0);
237
237
  if (age < HOT_PATH_MAX_STALE_MS || sinceLastAttempt < HOT_PATH_REVALIDATE_INTERVAL_MS) {
@@ -266,11 +266,12 @@ async function getRuntimeBundle(input) {
266
266
  // suspended field — a hand-edited `suspended: false` cache would otherwise
267
267
  // launder the edit through the 304. Clearing requires a full server body.
268
268
  if (resp.status === 304 && cached) {
269
- cache[input.shieldId] = { ...cached, fetchedAt: Date.now() };
269
+ const validated = { ...cached, fetchedAt: Date.now() };
270
+ cache[input.shieldId] = validated;
270
271
  writeCacheFile(cache);
271
272
  if (cached.suspended === true)
272
273
  assertSuspendMarker(input.shieldId);
273
- return cachedToEffective(cached);
274
+ return cachedToEffective(validated);
274
275
  }
275
276
  if (resp.status === 401 || resp.status === 403) {
276
277
  // The backend REACHED us and rejected the key — a credential problem,
@@ -327,6 +328,9 @@ async function getRuntimeBundle(input) {
327
328
  (0, distress_1.reportDistress)('bundle', distress_1.DISTRESS.NETWORK_DOWN, error instanceof Error ? error.message : String(error));
328
329
  /* fall through to cache / default */
329
330
  }
331
+ if (input.requireServerValidation) {
332
+ throw new Error('Background bundle check did not receive a valid server response; cached policies remain available to local callers');
333
+ }
330
334
  if (cached) {
331
335
  return cachedToEffective(cached);
332
336
  }
package/dist/version.json CHANGED
@@ -1,3 +1,3 @@
1
1
  {
2
- "version": "1.34.1"
2
+ "version": "1.34.4"
3
3
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fullcourtdefense-cli",
3
- "version": "1.34.1",
3
+ "version": "1.34.4",
4
4
  "description": "Full Court Defense CLI — security scanning for AI agents from your terminal",
5
5
  "main": "dist/index.js",
6
6
  "bin": {
@@ -58,7 +58,7 @@
58
58
  "test:scan-root-compaction": "npm run build && node scripts/test-scan-root-compaction.js",
59
59
  "test:dpapi-config": "npm run build && node scripts/test-dpapi-config.js",
60
60
  "test:distress-ledger": "npm run build && node scripts/test-distress-ledger.js",
61
- "test:poll-loop": "npm run build && node scripts/test-poll-loop-resilience.js",
61
+ "test:poll-loop": "npm run build && node scripts/test-poll-loop-resilience.js && node scripts/test-background-bundle.js",
62
62
  "test:slim-hook": "npm run build && node scripts/test-slim-hook.js",
63
63
  "test:killswitch": "npm run build && node scripts/test-killswitch-integrity.js",
64
64
  "test:restart-guarantees": "npm run build && node scripts/test-daemon-restart-guarantees.js",
@@ -118,6 +118,7 @@
118
118
  "node": ">=18.0.0"
119
119
  },
120
120
  "dependencies": {
121
+ "@iarna/toml": "^2.2.5",
121
122
  "@napi-rs/keyring": "^1.3.0",
122
123
  "yaml": "^2.8.4"
123
124
  }