fullcourtdefense-cli 1.34.21 → 1.34.25

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.
Files changed (91) hide show
  1. package/README.md +1 -0
  2. package/dist/actionIdentity.d.ts +1 -0
  3. package/dist/actionIdentity.js +2 -0
  4. package/dist/actionPolicyEngine.d.ts +5 -0
  5. package/dist/actionPolicyEngine.js +273 -30
  6. package/dist/agentTerminal.d.ts +1 -3
  7. package/dist/agentTerminal.js +14 -29
  8. package/dist/approvalPrompt.d.ts +7 -0
  9. package/dist/approvalPrompt.js +46 -0
  10. package/dist/askDialog.d.ts +87 -0
  11. package/dist/askDialog.js +209 -0
  12. package/dist/blockExplanation.js +18 -6
  13. package/dist/boundaryVerification.d.ts +20 -0
  14. package/dist/boundaryVerification.js +147 -0
  15. package/dist/commands/daemon.js +198 -70
  16. package/dist/commands/deterministicGuard.d.ts +2 -0
  17. package/dist/commands/deterministicGuard.js +161 -9
  18. package/dist/commands/hook.js +81 -29
  19. package/dist/commands/installClaudeHook.js +11 -5
  20. package/dist/commands/installCursorHook.js +5 -4
  21. package/dist/commands/mcpGateway.d.ts +2 -0
  22. package/dist/commands/mcpGateway.js +163 -56
  23. package/dist/commands/onboard.js +6 -5
  24. package/dist/commands/onboardingSummary.d.ts +5 -0
  25. package/dist/commands/onboardingSummary.js +11 -0
  26. package/dist/commands/protectedRun.d.ts +14 -0
  27. package/dist/commands/protectedRun.js +207 -0
  28. package/dist/containment-runtime/index.mjs +30118 -0
  29. package/dist/containment-runtime/licenses/_anthropic-ai_sandbox-runtime.txt +201 -0
  30. package/dist/containment-runtime/licenses/_pondwader_socks5-server.txt +20 -0
  31. package/dist/containment-runtime/licenses/commander.txt +22 -0
  32. package/dist/containment-runtime/licenses/node-forge.txt +331 -0
  33. package/dist/containment-runtime/licenses/zod.txt +21 -0
  34. package/dist/containment-runtime/runtime-version.json +1 -0
  35. package/dist/containment-runtime/vendor/java-proxy-agent/build.ts +95 -0
  36. package/dist/containment-runtime/vendor/java-proxy-agent/srt-proxy-agent.jar +0 -0
  37. package/dist/containment-runtime/vendor/seccomp/arm64/apply-seccomp +0 -0
  38. package/dist/containment-runtime/vendor/seccomp/build.ts +69 -0
  39. package/dist/containment-runtime/vendor/seccomp/x64/apply-seccomp +0 -0
  40. package/dist/containment-runtime/vendor/srt-win/arm64/srt-win.exe +0 -0
  41. package/dist/containment-runtime/vendor/srt-win/build.ts +21 -0
  42. package/dist/containment-runtime/vendor/srt-win/x64/srt-win.exe +0 -0
  43. package/dist/containmentLease.d.ts +5 -0
  44. package/dist/containmentLease.js +72 -0
  45. package/dist/detectionUpdates.d.ts +23 -3
  46. package/dist/detectionUpdates.js +45 -14
  47. package/dist/detectorArtifact.d.ts +17 -0
  48. package/dist/detectorArtifact.js +35 -0
  49. package/dist/detectorBaseline.d.ts +2 -0
  50. package/dist/detectorBaseline.js +5 -0
  51. package/dist/detectorModule.d.ts +9 -0
  52. package/dist/detectorModule.js +84 -0
  53. package/dist/detectorRuntime.d.ts +21 -0
  54. package/dist/detectorRuntime.js +49 -0
  55. package/dist/detectorUpdates.d.ts +21 -0
  56. package/dist/detectorUpdates.js +86 -0
  57. package/dist/devConfirm.d.ts +97 -15
  58. package/dist/devConfirm.js +447 -117
  59. package/dist/distress.d.ts +2 -0
  60. package/dist/distress.js +2 -0
  61. package/dist/frictionRollout.js +103 -0
  62. package/dist/gatewayRuntime.d.ts +19 -0
  63. package/dist/gatewayRuntime.js +93 -0
  64. package/dist/hookSlim.js +17 -18
  65. package/dist/index.js +35 -0
  66. package/dist/localDetectionUpdates.d.ts +3 -1
  67. package/dist/localDetectionUpdates.js +8 -1
  68. package/dist/localSafetySnapshot.d.ts +9 -5
  69. package/dist/localSafetySnapshot.js +42 -15
  70. package/dist/mcpToolReview.d.ts +17 -0
  71. package/dist/mcpToolReview.js +127 -0
  72. package/dist/mcpToolTrust.d.ts +22 -0
  73. package/dist/mcpToolTrust.js +66 -0
  74. package/dist/nativeHook.d.ts +4 -0
  75. package/dist/nativeHook.js +31 -17
  76. package/dist/policyRefresh.d.ts +3 -0
  77. package/dist/policyRefresh.js +27 -0
  78. package/dist/protectionMaintenance.d.ts +10 -0
  79. package/dist/protectionMaintenance.js +64 -0
  80. package/dist/protectionProfile.d.ts +26 -0
  81. package/dist/protectionProfile.js +63 -0
  82. package/dist/runtimeConfig.d.ts +2 -0
  83. package/dist/runtimeConfig.js +65 -26
  84. package/dist/selfTest.d.ts +2 -0
  85. package/dist/selfTest.js +9 -1
  86. package/dist/sessionLimits.d.ts +2 -0
  87. package/dist/sessionLimits.js +24 -0
  88. package/dist/telemetry.d.ts +5 -0
  89. package/dist/telemetry.js +8 -0
  90. package/dist/version.json +1 -1
  91. package/package.json +10 -6
@@ -168,7 +168,11 @@ const METADATA_ENDPOINTS = [
168
168
  { itemId: 'alibaba_metadata_ip', value: '100.100.100.200', label: 'Alibaba cloud metadata endpoint' },
169
169
  ];
170
170
  const OUTBOUND_TOOL_HINT = /(?:http|fetch|request|curl|wget|webhook|upload|send|email|mail|slack|discord|teams|post|put|publish|notify)/i;
171
- const COMMAND_TOOL_HINT = /(?:shell|bash|command|cmd|powershell|terminal|exec|subprocess|run|script)/i;
171
+ const WEB_LOOKUP_TOOL_HINT = /(?:^|__)web(?:[_.-]*run)$/i;
172
+ // Match execution words, not substrings: webrun and describe_subscription do
173
+ // not execute their text. The web lookup contract also distinguishes web.run
174
+ // from generic run tools. Explicit command fields remain actionable everywhere.
175
+ const COMMAND_TOOL_HINT = /\b(?:shell|bash|command|cmd|powershell|pwsh|terminal|exec|execute|subprocess|run|script|repl)\b/i;
172
176
  const SQL_TOOL_HINT = /(?:sql|query|database|postgres|mysql|sqlite|snowflake|bigquery|db)/i;
173
177
  const COMMAND_KEYS = new Set([
174
178
  'command',
@@ -802,7 +806,9 @@ function isLocalFileEditor(toolName, candidates) {
802
806
  });
803
807
  }
804
808
  function isOutboundContext(toolName, candidates) {
805
- if (OUTBOUND_TOOL_HINT.test(toolName))
809
+ // Web lookup arguments leave the machine, but query text is neither a shell
810
+ // command nor the destination URL. Keep secret egress separate from execution.
811
+ if (OUTBOUND_TOOL_HINT.test(toolName) || WEB_LOOKUP_TOOL_HINT.test(toolName))
806
812
  return true;
807
813
  if (isLocalFileEditor(toolName, candidates))
808
814
  return false;
@@ -810,12 +816,17 @@ function isOutboundContext(toolName, candidates) {
810
816
  }
811
817
  function isCommandContext(toolName, candidate) {
812
818
  const lastKey = candidate.keyPath.split('.').pop() || '';
813
- return COMMAND_TOOL_HINT.test(toolName) || COMMAND_KEYS.has(lastKey) || COMMAND_KEYS.has(candidate.keyPath);
819
+ const nameWords = toolName.replace(/([a-z0-9])([A-Z])/g, '$1 $2').replace(/[_\-.]+/g, ' ');
820
+ return (!WEB_LOOKUP_TOOL_HINT.test(toolName) && COMMAND_TOOL_HINT.test(nameWords))
821
+ || COMMAND_KEYS.has(lastKey) || COMMAND_KEYS.has(candidate.keyPath);
814
822
  }
815
823
  function isSqlContext(toolName, candidate) {
816
824
  const lastKey = candidate.keyPath.split('.').pop() || '';
817
825
  return SQL_TOOL_HINT.test(toolName) || SQL_KEYS.has(lastKey) || SQL_KEYS.has(candidate.keyPath);
818
826
  }
827
+ function isWebLookupTarget(toolName, candidate) {
828
+ return WEB_LOOKUP_TOOL_HINT.test(toolName) && candidate.keyPath.split('.').pop() === 'ref_id';
829
+ }
819
830
  /**
820
831
  * Metadata SSRF only matters where the string can actually BECOME a request: a shell
821
832
  * command, an outbound/HTTP tool, or a URL-typed argument. Source code or file contents
@@ -825,6 +836,8 @@ function isSqlContext(toolName, candidate) {
825
836
  function isMetadataRequestContext(toolName, candidate) {
826
837
  if (isCommandContext(toolName, candidate))
827
838
  return true;
839
+ if (isWebLookupTarget(toolName, candidate))
840
+ return true;
828
841
  if (OUTBOUND_TOOL_HINT.test(toolName))
829
842
  return true;
830
843
  const lastKey = candidate.keyPath.split('.').pop() || '';
@@ -838,6 +851,8 @@ function isMetadataRequestContext(toolName, candidate) {
838
851
  function isSensitivePathContext(toolName, candidate) {
839
852
  if (isCommandContext(toolName, candidate))
840
853
  return true;
854
+ if (isWebLookupTarget(toolName, candidate))
855
+ return true;
841
856
  if (/(?:read|open|cat|copy|download|upload|access|stat|list|glob|search|find|grep)/i.test(toolName))
842
857
  return true;
843
858
  const lastKey = candidate.keyPath.split('.').pop() || '';
@@ -873,7 +888,8 @@ const DATA_TO_INTERPRETER = /\b(?:iex|invoke-expression|invoke-command)\b|\beval
873
888
  // a shell command line. `node -e "console.log('rm -rf /')"` prints a string;
874
889
  // `python -c "print('DROP TABLE')"` prints a string. That code only reaches the
875
890
  // shell through a process-spawning API — then (and only then) the whole line is
876
- // kept, exactly like `bash -c`. Structural: API names, not vocabulary.
891
+ // kept, exactly like `bash -c`. Direct file arguments have a separate sensitive
892
+ // path view below, so a reader does not turn its other string data into shell code.
877
893
  const LANG_INTERPRETER_CODE_BEFORE = /\b(?:node|nodejs|deno|bun|python[0-9.]*|py|perl|ruby|php|lua|tsx|ts-node)(?:\.exe)?\s+(?:-c|-e|-r|eval)[= ]\s*$/i;
878
894
  const CODE_SPAWNS_PROCESS = /\b(?:child_process|execSync|execFileSync|spawnSync|spawn|execFile|exec|fork|os\.system|os\.popen|os\.exec[lvpe]*|subprocess|Popen|system|passthru|shell_exec|proc_open|popen|Process\.Start|Deno\.run|Deno\.Command|Bun\.spawn)\b/;
879
895
  const BACKTICK_SHELL_INTERPRETER = /\b(?:perl|ruby)(?:\.exe)?\s+(?:-c|-e)[= ]\s*$/i;
@@ -882,6 +898,38 @@ function interpreterCodeSpawnsProcess(before, code) {
882
898
  return true;
883
899
  return BACKTICK_SHELL_INTERPRETER.test(before) && /`[^`]*`|%x[\[{(]/.test(code);
884
900
  }
901
+ /** First path argument of direct file APIs in inline language code, not printed examples. */
902
+ function interpreterFileArguments(text) {
903
+ if (text.length > 32_000)
904
+ return '';
905
+ const paths = [];
906
+ let calls = 0;
907
+ text.replace(/"((?:[^"\\]|\\.)*)"|'((?:[^'\\]|\\.)*)'/g, (whole, dq, sq, offset) => {
908
+ if (!LANG_INTERPRETER_CODE_BEFORE.test(text.slice(Math.max(0, offset - 80), offset)))
909
+ return whole;
910
+ const code = dq !== undefined ? dq : (sq || '');
911
+ // Preserve offsets while masking string contents: a call printed inside a
912
+ // string is not an invocation; a sensitive path in another argument is data.
913
+ const instructions = code.replace(/"(?:[^"\\]|\\.)*"|'(?:[^'\\]|\\.)*'/g, literal => ' '.repeat(literal.length));
914
+ for (const call of instructions.matchAll(/\b(?:open|fopen|readFile(?:Sync)?|createReadStream|ReadAllText|ReadAllBytes)\s*\(/g)) {
915
+ if (++calls > 48)
916
+ break;
917
+ const start = (call.index || 0) + call[0].length;
918
+ let depth = 1;
919
+ for (let end = start; end < instructions.length; end++) {
920
+ const ch = instructions[end];
921
+ if ((ch === ')' && --depth === 0) || (ch === ',' && depth === 1)) {
922
+ paths.push(code.slice(start, end));
923
+ break;
924
+ }
925
+ if (ch === '(')
926
+ depth++;
927
+ }
928
+ }
929
+ return whole;
930
+ });
931
+ return paths.join('\n');
932
+ }
885
933
  // $( … ) always executes; backtick pairs only with ≥2 chars of content
886
934
  // (PowerShell escapes like `n / `t are single-char and inert).
887
935
  const INTERPOLATES = /\$\(|`[^`]{2,}`/;
@@ -918,7 +966,104 @@ function stripInertDataSegments(text) {
918
966
  });
919
967
  return out;
920
968
  }
921
- function scanTextValue(toolName, rawValue, options) {
969
+ const FILE_METADATA_PROPERTIES = new Set([
970
+ 'name', 'fullname', 'basename', 'extension', 'length', 'attributes', 'exists',
971
+ 'creationtime', 'creationtimeutc', 'lastaccesstime', 'lastaccesstimeutc',
972
+ 'lastwritetime', 'lastwritetimeutc', 'directoryname', 'psiscontainer',
973
+ ]);
974
+ /** Prove a whole pipeline returns only file metadata, never a file reader/handle consumer. */
975
+ function isFileMetadataQuery(statement) {
976
+ // Expressions, substitutions, redirections and script blocks are unproven.
977
+ if (/[@`{}()[\]<>;&\r\n]/.test(statement))
978
+ return false;
979
+ const tokens = statement.trim().match(/"[^"\r\n]*"|'(?:[^'\r\n]|'')*'|[|,]|[^\s"'|,]+/g) || [];
980
+ if (!/^(?:get-item|gi)$/i.test(tokens[0] || ''))
981
+ return false;
982
+ const pipe = tokens.indexOf('|');
983
+ const args = tokens.slice(1, pipe < 0 ? undefined : pipe);
984
+ let paths = 0;
985
+ for (let i = 0; i < args.length; i++) {
986
+ const arg = args[i];
987
+ if (/^-(?:literalpath|path|force)$/i.test(arg) || arg === ',')
988
+ continue;
989
+ if (/^-(?:erroraction|ea)$/i.test(arg)) {
990
+ if (!/^(?:silentlycontinue|stop|continue|ignore)$/i.test(args[++i] || ''))
991
+ return false;
992
+ continue;
993
+ }
994
+ // Output variables can retain FileInfo objects for a later content read.
995
+ if (arg.startsWith('-'))
996
+ return false;
997
+ paths++;
998
+ }
999
+ if (!paths)
1000
+ return false;
1001
+ if (pipe < 0)
1002
+ return true; // Default Get-Item formatting contains metadata only.
1003
+ if (!/^(?:select-object|select)$/i.test(tokens[pipe + 1] || ''))
1004
+ return false;
1005
+ const properties = tokens.slice(pipe + 2);
1006
+ if (/^-property$/i.test(properties[0] || ''))
1007
+ properties.shift();
1008
+ const names = properties.filter(token => token !== ',');
1009
+ return names.length > 0 && names.every(token => FILE_METADATA_PROPERTIES.has(token.replace(/^['"]|['"]$/g, '').toLowerCase()));
1010
+ }
1011
+ /**
1012
+ * Sensitive-path view of a shell command. Omit only proven metadata queries;
1013
+ * preserve all other statements and their consumers. Quotes and nested scopes
1014
+ * keep separators inside data/code from creating fictitious cmdlet invocations.
1015
+ * Unknown execution/rebinding forms retain the original conservative scan.
1016
+ */
1017
+ function credentialAccessCommandText(text) {
1018
+ if (text.length > 32_000)
1019
+ return text;
1020
+ // PowerShell 7 permits a pipeline to continue on a line starting with `|`,
1021
+ // even across comment lines. A trailing comma also continues an argument list.
1022
+ // Keep these forms intact so a later consumer cannot lose its source path.
1023
+ const allowMetadata = !(/(?:^|[\r\n])[ \t]*\||,[ \t]*(?:#[^\r\n]*)?[\r\n]/.test(text)
1024
+ || /`|(?:^|[^&])&(?:[^&]|$)|@['"]|(?:^|[;&|\r\n])\s*\.\s|\b(?:iex|invoke-expression|invoke-command|eval|function|filter|set-alias|new-alias|sal|nal|set-item|new-item|update-typedata|add-type|import-module|ipmo|PSDefaultParameterValues)\b/i.test(text));
1025
+ const statements = [];
1026
+ let current = '', quote = '', depth = 0;
1027
+ for (let i = 0; i < text.length; i++) {
1028
+ const ch = text[i];
1029
+ if (quote) {
1030
+ current += ch;
1031
+ if (ch === quote) {
1032
+ if (quote === "'" && text[i + 1] === "'")
1033
+ current += text[++i];
1034
+ else
1035
+ quote = '';
1036
+ }
1037
+ continue;
1038
+ }
1039
+ if (ch === '"' || ch === "'") {
1040
+ quote = ch;
1041
+ current += ch;
1042
+ continue;
1043
+ }
1044
+ if (ch === '(' || ch === '{' || ch === '[')
1045
+ depth++;
1046
+ if (ch === ')' || ch === '}' || ch === ']') {
1047
+ if (--depth < 0)
1048
+ return text;
1049
+ }
1050
+ const conditional = (ch === '&' || ch === '|') && text[i + 1] === ch;
1051
+ if (depth === 0 && (conditional || ch === ';' || ch === '\r' || ch === '\n')) {
1052
+ if (conditional)
1053
+ i++;
1054
+ statements.push(current);
1055
+ current = '';
1056
+ continue;
1057
+ }
1058
+ current += ch === '|' || ch === '&' || ch === ';' ? ` ${ch} ` : ch;
1059
+ }
1060
+ if (quote || depth)
1061
+ return text;
1062
+ statements.push(current);
1063
+ // A real shell separator is a boundary of the preceding path, even without spaces.
1064
+ return statements.map(statement => allowMetadata && isFileMetadataQuery(statement) ? 'fcd_file_metadata' : statement).join('\n');
1065
+ }
1066
+ function scanTextValue(toolName, rawValue, options, commandContext = false) {
922
1067
  // Honeypot decoys and org custom patterns scan the RAW value: a decoy path
923
1068
  // even inside message data is exfiltration staging, and custom rules define
924
1069
  // their own scope.
@@ -937,7 +1082,8 @@ function scanTextValue(toolName, rawValue, options) {
937
1082
  // NOTE: a disabled item must FALL THROUGH to the later rules, not end the
938
1083
  // scan — otherwise turning off (or gating) a path rule would mask a real
939
1084
  // reverse shell / destructive command in the same value.
940
- const sensitivePath = containsSensitiveCredentialPath(value);
1085
+ const sensitivePath = containsSensitiveCredentialPath(commandContext
1086
+ ? `${credentialAccessCommandText(value)}\n${interpreterFileArguments(rawValue)}` : value);
941
1087
  if (sensitivePath) {
942
1088
  const finding = builtIn(sensitivePath.itemId, 'sensitive_files', 'sensitive_file', 'local-sensitive-credential-path', `Blocked local agent access to ${sensitivePath.label}.`, value, sensitivePath.label, options);
943
1089
  if (finding)
@@ -985,6 +1131,12 @@ function isActionableContext(toolName, candidate) {
985
1131
  || OUTBOUND_TOOL_HINT.test(toolName);
986
1132
  }
987
1133
  function scanDeterministicToolCall(toolName, toolArgs, options) {
1134
+ // MCP adapters keep the server separately from its method. Restore that
1135
+ // identity for the known web lookup contract; keep other method matching
1136
+ // unchanged so a server's name cannot turn its documentation into a command.
1137
+ const qualifiedName = options?.mcpServerName ? `mcp__${options.mcpServerName}__${toolName}` : toolName;
1138
+ if (WEB_LOOKUP_TOOL_HINT.test(qualifiedName))
1139
+ toolName = qualifiedName;
988
1140
  const candidates = collectStrings(toolArgs);
989
1141
  const outbound = isOutboundContext(toolName, candidates);
990
1142
  for (const candidate of candidates) {
@@ -998,7 +1150,7 @@ function scanDeterministicToolCall(toolName, toolArgs, options) {
998
1150
  return custom;
999
1151
  }
1000
1152
  for (const candidate of candidates) {
1001
- const finding = scanTextValue(toolName, candidate.value, options);
1153
+ const finding = scanTextValue(toolName, candidate.value, options, isCommandContext(toolName, candidate));
1002
1154
  if (!finding)
1003
1155
  continue;
1004
1156
  // Context gates: a pattern is only dangerous where it can act — a command, a request
@@ -1210,13 +1362,13 @@ function scanReferencedScript(command, cwd, options) {
1210
1362
  // string mentions and rescan so command-shaped rules (reverse shell, destructive
1211
1363
  // delete...) in the SAME file still surface.
1212
1364
  let scanOptions = options;
1213
- let finding = scanTextValue('script_file', content, scanOptions);
1365
+ let finding = scanTextValue('script_file', content, scanOptions, true);
1214
1366
  while (finding
1215
1367
  && (finding.category === 'sensitive_file' || finding.category === 'metadata_ssrf')
1216
1368
  && !scriptActsOnFinding(content, finding.category)) {
1217
1369
  const baseDisabled = scanOptions?.disabledBuiltInItemIds || Array.from(exports.DEFAULT_DISABLED_BUILT_INS);
1218
1370
  scanOptions = { ...(scanOptions || {}), disabledBuiltInItemIds: [...baseDisabled, finding.itemId] };
1219
- finding = scanTextValue('script_file', content, scanOptions);
1371
+ finding = scanTextValue('script_file', content, scanOptions, true);
1220
1372
  }
1221
1373
  if (!finding)
1222
1374
  continue;
@@ -63,6 +63,7 @@ const verdictIpc_1 = require("../verdictIpc");
63
63
  const machineIdentity_1 = require("../machineIdentity");
64
64
  const actionPolicyEngine_1 = require("../actionPolicyEngine");
65
65
  const devConfirm_1 = require("../devConfirm");
66
+ const askDialog_1 = require("../askDialog");
66
67
  // Shared with the slim hook entry (hookSlim.ts) so both entries log to the
67
68
  // same rotated hook.log and read stdin identically.
68
69
  const hookIo_1 = require("../hookIo");
@@ -480,7 +481,7 @@ function buildToolCall(event, p) {
480
481
  || str(nested?.command) || str(nested?.cmd);
481
482
  if (!command)
482
483
  return null;
483
- return { toolName: 'shell', toolArgs: { command, cwd: str(p.cwd) || str(nested?.cwd) || undefined } };
484
+ return { toolName: 'shell', toolArgs: { command, cwd: str(p.cwd) || str(nested?.cwd) || process.cwd() } };
484
485
  }
485
486
  case 'mcp': {
486
487
  const toolName = str(p.tool_name) || str(p.toolName) || str(p.name) || 'mcp';
@@ -757,6 +758,7 @@ function tryDeveloperConfirmation(ctx, call, verdict, payload, where) {
757
758
  if (ctx.shadow)
758
759
  return false; // monitor machines never prompt — the caller records would-approve
759
760
  const prepared = prepareDeveloperConfirmation(ctx.event, call, payload, {
761
+ policyHash: ctx.effectivePolicyHash,
760
762
  policyId: verdict.policyId,
761
763
  policyName: verdict.policyName,
762
764
  matchedRule: verdict.matchedRule,
@@ -768,7 +770,7 @@ function tryDeveloperConfirmation(ctx, call, verdict, payload, where) {
768
770
  });
769
771
  if (prepared.outcome === 'allow')
770
772
  ctx.respond(false, undefined, prepared.agentMsg);
771
- ctx.respond('ask', prepared.userMsg, prepared.agentMsg);
773
+ ctx.respond('ask', prepared.userMsg, prepared.agentMsg, prepared.envelope);
772
774
  }
773
775
  /**
774
776
  * The developer-confirmation core, independent of WHAT asked for it: an Action
@@ -779,10 +781,11 @@ function tryDeveloperConfirmation(ctx, call, verdict, payload, where) {
779
781
  * Returns what to answer; the caller emits it (respond() ends the process).
780
782
  */
781
783
  function prepareDeveloperConfirmation(event, call, payload, source) {
782
- const { operation } = (0, actionPolicyEngine_1.inferToolContext)(call.toolName, call.toolArgs);
784
+ const { operation, context } = (0, actionPolicyEngine_1.inferToolContext)(call.toolName, call.toolArgs);
783
785
  const sessId = sessionId(payload);
784
786
  const policyLabel = source.policyName ? ` (${source.policyName})` : '';
785
787
  const offlineEnforced = source.where !== 'local-first';
788
+ const policyHash = source.policyHash || source.localFinding?.policyHash;
786
789
  // Sweep first: markers the human never answered are recorded as not approved.
787
790
  for (const expired of (0, devConfirm_1.sweepExpiredConfirmations)()) {
788
791
  (0, telemetry_1.spoolEvent)({
@@ -794,12 +797,14 @@ function prepareDeveloperConfirmation(event, call, payload, source) {
794
797
  offlineEnforced: true,
795
798
  });
796
799
  }
797
- const remembered = source.remember === 'none' ? undefined : (0, devConfirm_1.isConfirmationRemembered)({ toolName: call.toolName, operation, policyId: source.policyId, matchedRule: source.matchedRule }, sessId);
800
+ const remembered = source.remember === 'none' ? undefined : (0, devConfirm_1.isConfirmationRemembered)({ toolName: call.toolName, operation, argsDigest: (0, devConfirm_1.actionDigest)(call.toolArgs), policyId: source.policyId, matchedRule: source.matchedRule, policyHash: source.policyHash || source.localFinding?.policyHash }, sessId);
798
801
  if (remembered) {
799
802
  (0, hookIo_1.dbg)({ phase: 'dev_confirm_remembered', event, tool: call.toolName, operation, remember: remembered.remember, where: source.where });
800
803
  spoolCallEvent(call, {
801
804
  decision: 'allow',
802
805
  reason: `developer confirmed earlier (${remembered.remember} memory)${policyLabel}`,
806
+ ruleId: source.policyId || source.localFinding?.ruleId,
807
+ policyHash: source.policyHash || source.localFinding?.policyHash,
803
808
  approvalScope: 'developer',
804
809
  approvedBy: 'developer',
805
810
  offlineEnforced,
@@ -807,6 +812,25 @@ function prepareDeveloperConfirmation(event, call, payload, source) {
807
812
  (0, telemetry_1.triggerFlush)(false);
808
813
  return { outcome: 'allow', agentMsg: `FullCourtDefense: ${call.toolName} was already confirmed by the developer in this ${remembered.remember === '1h' ? 'hour' : 'session'}.` };
809
814
  }
815
+ // A standing "Always allow" the developer gave in our dialog for this scope under
816
+ // this exact rule (same policy hash). Rules that forbid memory never reach here.
817
+ const grantScope = (0, devConfirm_1.grantScopeFor)(event, call.toolName, call.toolArgs, operation, context);
818
+ const grant = source.remember === 'none' ? undefined : (0, devConfirm_1.findDeveloperGrant)(grantScope, { policyId: source.policyId, matchedRule: source.matchedRule, policyHash });
819
+ if (grant) {
820
+ const covers = (0, devConfirm_1.describeGrantScope)(grant.scope);
821
+ (0, hookIo_1.dbg)({ phase: 'dev_confirm_granted', event, tool: call.toolName, operation, grant: grant.key, where: source.where });
822
+ spoolCallEvent(call, {
823
+ decision: 'allow',
824
+ reason: `developer granted Always allow for ${covers}${policyLabel}`,
825
+ ruleId: source.policyId || source.localFinding?.ruleId,
826
+ policyHash,
827
+ approvalScope: 'developer',
828
+ approvedBy: 'developer',
829
+ offlineEnforced,
830
+ });
831
+ (0, telemetry_1.triggerFlush)(false);
832
+ return { outcome: 'allow', agentMsg: `FullCourtDefense: the developer has a standing "Always allow" for ${covers} under this rule.` };
833
+ }
810
834
  const marker = (0, devConfirm_1.raiseDeveloperConfirmation)({
811
835
  event,
812
836
  toolName: call.toolName,
@@ -814,6 +838,7 @@ function prepareDeveloperConfirmation(event, call, payload, source) {
814
838
  argsDigest: (0, devConfirm_1.actionDigest)(call.toolArgs),
815
839
  policyId: source.policyId,
816
840
  policyName: source.policyName,
841
+ policyHash: source.policyHash || source.localFinding?.policyHash,
817
842
  matchedRule: source.matchedRule,
818
843
  sessionId: sessId,
819
844
  remember: source.remember,
@@ -822,6 +847,8 @@ function prepareDeveloperConfirmation(event, call, payload, source) {
822
847
  (0, hookIo_1.dbg)({ phase: 'dev_confirm_ask', event, tool: call.toolName, operation, key: marker.key, remember: source.remember, where: source.where });
823
848
  spoolCallEvent(call, {
824
849
  decision: 'approval',
850
+ ruleId: source.policyId || source.localFinding?.ruleId,
851
+ policyHash: source.policyHash || source.localFinding?.policyHash,
825
852
  reason: `developer confirmation requested via IDE prompt${policyLabel}${source.reason ? `: ${source.reason}` : ''}`,
826
853
  approvalScope: 'developer',
827
854
  offlineEnforced,
@@ -840,6 +867,24 @@ function prepareDeveloperConfirmation(event, call, payload, source) {
840
867
  outcome: 'ask',
841
868
  userMsg: `${explained.userMessage}\nAllow this ${event}?`,
842
869
  agentMsg: `FullCourtDefense paused this ${event} for the developer's confirmation${policyLabel}: ${reason}. Wait for the developer's answer in the IDE prompt — do not retry or work around it. ${explained.agentMessage}`,
870
+ // For the per-event hook process in the developer's session: everything the
871
+ // three-button dialog needs. Stays on this machine (IPC / stdout of our own
872
+ // process) and is stripped before the IDE reads the verdict; the grant it
873
+ // may create stores only the scope and a digest, never the command text.
874
+ envelope: {
875
+ v: 1,
876
+ event,
877
+ toolName: call.toolName,
878
+ argsDigest: marker.argsDigest,
879
+ operation,
880
+ scope: grantScope,
881
+ policyId: source.policyId,
882
+ policyName: source.policyName,
883
+ policyHash,
884
+ matchedRule: source.matchedRule,
885
+ remember: source.remember,
886
+ reason: explained.userMessage,
887
+ },
843
888
  };
844
889
  }
845
890
  /**
@@ -916,14 +961,14 @@ function tryLocalPolicyEnforcement(ctx, call, detail, opts = {}) {
916
961
  if (ctx.shadow) {
917
962
  // Truth-in-reporting: the action RAN — 'warn' (advisory), never 'allow'
918
963
  // (which hides the would-block from the console) and never 'block'.
919
- spoolCallEvent(call, { decision: 'warn', reason: `[monitor] local policy would ${local.verdict} (offline): ${reason}`, offlineEnforced: true });
964
+ spoolCallEvent(call, { decision: 'warn', ruleId: local.policyId, policyHash: ctx.effectivePolicyHash, reason: `[monitor] local policy would ${local.verdict} (offline): ${reason}`, offlineEnforced: true });
920
965
  (0, telemetry_1.triggerFlush)(false);
921
966
  ctx.respond(false, undefined, `[FullCourtDefense shadow] would ${local.verdict === 'block' ? 'block' : 'require approval for'} ${call.toolName} (offline, locally cached policy): ${reason}`);
922
967
  }
923
968
  const approvalNote = local.verdict === 'require_approval'
924
969
  ? ' This action requires human approval, which is not possible while the policy service is unreachable.'
925
970
  : '';
926
- spoolCallEvent(call, { decision: 'block', reason: `local policy (offline): ${reason}`, offlineEnforced: true });
971
+ spoolCallEvent(call, { decision: 'block', ruleId: local.policyId, policyHash: ctx.effectivePolicyHash, reason: `local policy (offline): ${reason}`, offlineEnforced: true });
927
972
  (0, telemetry_1.triggerFlush)(true);
928
973
  const explained = explainPolicyStop('block', ctx.event, call, local, policies, `Enforced from the locally cached policy while the policy service is unreachable (${detail}).${approvalNote}`);
929
974
  ctx.respond(true, explained.userMessage, `${explained.agentMessage} Do not retry until the connection is restored or the policy allows it.`);
@@ -939,7 +984,7 @@ function tryLocalPolicyEnforcement(ctx, call, detail, opts = {}) {
939
984
  ? warning.agentMsg
940
985
  : `${warning.agentMsg} (policy service unreachable — evaluated from the locally cached org policies).`);
941
986
  }
942
- spoolCallEvent(call, { decision: 'allow', reason: `local policy allow (${monitorPath ? 'monitor' : 'offline'}): ${detail}`, offlineEnforced: true });
987
+ spoolCallEvent(call, { decision: 'allow', ruleId: local.policyId, policyHash: ctx.effectivePolicyHash, reason: `local policy allow (${monitorPath ? 'monitor' : 'offline'}): ${detail}`, offlineEnforced: true });
943
988
  (0, telemetry_1.triggerFlush)(false);
944
989
  ctx.respond(false, undefined, monitorPath
945
990
  ? undefined
@@ -1182,15 +1227,25 @@ function processIo(stdin, stdinErr) {
1182
1227
  * an allow verdict, never an error the thin client would surface.
1183
1228
  */
1184
1229
  async function evaluateHookRequest(args, stdinRaw, config) {
1230
+ return evaluateBuffered(args, stdinRaw, '', config, { isTTY: false, exitsProcess: false });
1231
+ }
1232
+ /**
1233
+ * Run the hook against a buffered io and return the verdict instead of writing
1234
+ * it. `exitsProcess: true` keeps the standalone hook's delivery contract (spool +
1235
+ * detached flusher) while still letting the caller post-process the verdict —
1236
+ * the per-event process needs that to open the developer's dialog on an `ask`.
1237
+ */
1238
+ async function evaluateBuffered(args, stdinRaw, stdinErr, config, mode) {
1185
1239
  let out = '';
1186
1240
  let errOut = '';
1187
1241
  const io = {
1188
1242
  stdin: stdinRaw,
1189
- isTTY: false,
1243
+ stdinErr: stdinErr || undefined,
1244
+ isTTY: mode.isTTY,
1190
1245
  write: (text) => { out += text; },
1191
1246
  writeErr: (text) => { errOut += text; },
1192
1247
  exit: (code) => { throw new HookExitSignal(code); },
1193
- exitsProcess: false,
1248
+ exitsProcess: mode.exitsProcess,
1194
1249
  };
1195
1250
  try {
1196
1251
  await hookCommandInner(args, config, io);
@@ -1246,25 +1301,16 @@ async function hookCommandOuter(args, config, startedAt) {
1246
1301
  const ipc = await (0, verdictIpc_1.requestDaemonVerdict)(args, raw, undefined, startedAt);
1247
1302
  if (ipc.outcome === 'verdict') {
1248
1303
  (0, hookIo_1.dbg)({ phase: 'ipc_verdict', event: args.event, exitCode: ipc.exitCode });
1249
- if (ipc.stderr) {
1250
- try {
1251
- process.stderr.write(ipc.stderr);
1252
- }
1253
- catch { /* stderr gone */ }
1254
- }
1255
- if (ipc.stdout) {
1256
- try {
1257
- process.stdout.write(ipc.stdout);
1258
- }
1259
- catch { /* stdout gone */ }
1260
- }
1261
- process.exit(ipc.exitCode ?? 0);
1304
+ await (0, askDialog_1.emitVerdictForIde)(args, { stdout: ipc.stdout || '', stderr: ipc.stderr || '', exitCode: ipc.exitCode ?? 0 }, 'ipc');
1262
1305
  }
1263
1306
  // Daemon down / busy / refused — evaluate locally as always. Logged so a
1264
1307
  // machine that silently stopped using the fast path is diagnosable.
1265
1308
  (0, hookIo_1.dbg)({ phase: 'ipc_unavailable', event: args.event, detail: ipc.detail });
1266
1309
  }
1267
- await hookCommandInner(args, config, processIo(raw, stdinErr));
1310
+ // Buffered so an `ask` can be put to the developer in OUR dialog before the
1311
+ // IDE reads the verdict; exitsProcess stays true — this process ends right after.
1312
+ const result = await evaluateBuffered(args, raw, stdinErr, config, { isTTY: !!process.stdin.isTTY, exitsProcess: true });
1313
+ await (0, askDialog_1.emitVerdictForIde)(args, result, 'local');
1268
1314
  }
1269
1315
  catch (err) {
1270
1316
  const message = err instanceof Error ? err.message : String(err);
@@ -1517,7 +1563,7 @@ async function hookCommandInner(args, config, io) {
1517
1563
  pendingLocalAsk = pendingLocalAsk || { finding, call };
1518
1564
  };
1519
1565
  const hasPendingLocalAsk = () => Boolean(pendingLocalAsk);
1520
- const respond = (blocked, userMsg, agentMsg) => {
1566
+ const respond = (blocked, userMsg, agentMsg, askEnvelope) => {
1521
1567
  let ask = blocked === 'ask' && event !== 'prompt';
1522
1568
  const denied = blocked === true;
1523
1569
  if (!denied && !ask && pendingLocalAsk && event !== 'prompt') {
@@ -1535,6 +1581,7 @@ async function hookCommandInner(args, config, io) {
1535
1581
  if (prepared.outcome === 'ask') {
1536
1582
  ask = true;
1537
1583
  userMsg = prepared.userMsg;
1584
+ askEnvelope = prepared.envelope;
1538
1585
  }
1539
1586
  agentMsg = [prepared.agentMsg, agentMsg].filter(Boolean).join(' ');
1540
1587
  }
@@ -1575,7 +1622,11 @@ async function hookCommandInner(args, config, io) {
1575
1622
  if (agentMsg)
1576
1623
  decision.agent_message = agentMsg;
1577
1624
  }
1578
- (0, hookIo_1.dbg)({ phase: 'verdict', event, format: hookFormat, blocked, decision });
1625
+ // An ask carries the dialog envelope for the per-event hook process in the
1626
+ // developer's session (askDialog.ts); it is removed before the IDE reads the verdict.
1627
+ if (ask && askEnvelope)
1628
+ decision = (0, askDialog_1.attachAskEnvelope)(decision, askEnvelope);
1629
+ (0, hookIo_1.dbg)({ phase: 'verdict', event, format: hookFormat, blocked, decision: ask && askEnvelope ? { ...decision, [askDialog_1.ASK_ENVELOPE_FIELD]: '(envelope)' } : decision });
1579
1630
  if (hookFormat === 'claude' && event === 'prompt' && denied) {
1580
1631
  // Claude Desktop/Code variants do not all honor the structured
1581
1632
  // UserPromptSubmit decision consistently. Exit 2 is the documented hard
@@ -1783,6 +1834,7 @@ async function enforceActionPolicy(ctx) {
1783
1834
  const toolOutcome = (0, deterministicGuard_1.resolveDeterministicOutcome)(opts => (0, deterministicGuard_1.scanDeterministicToolCall)(call.toolName, call.toolArgs, opts), (0, localSafetySnapshot_1.snapshotToScanOptions)(snapshot, {
1784
1835
  cwd: typeof call.toolArgs.cwd === 'string' ? call.toolArgs.cwd : process.cwd(),
1785
1836
  inspectScripts: event === 'shell',
1837
+ mcpServerName: event === 'mcp' ? mcpServerName(payload) : undefined,
1786
1838
  }));
1787
1839
  deferLocalWarnings(toolOutcome.warnings, call.toolName, event);
1788
1840
  const localBlock = toolOutcome.blockingFinding;
@@ -1947,7 +1999,7 @@ async function enforceActionPolicy(ctx) {
1947
1999
  // read "N blocked of N recent" with every allow missing. Same delivery
1948
2000
  // as a block: spool (sync file append) + detached flusher that outlives
1949
2001
  // this process. The IDE is not kept waiting on the network.
1950
- spoolCallEvent(call, { decision: 'allow', reason: 'local policy allow (block mode, local-first)' });
2002
+ spoolCallEvent(call, { decision: 'allow', policyHash: effectivePolicyHash, ruleId: localVerdict.policyId, reason: 'local policy allow (block mode, local-first)' });
1951
2003
  (0, telemetry_1.triggerFlush)(true);
1952
2004
  respond(false);
1953
2005
  return;
@@ -1958,7 +2010,7 @@ async function enforceActionPolicy(ctx) {
1958
2010
  void fetch(gateUrl, { method: 'POST', headers, body: gateBody, signal: AbortSignal.timeout(4000) })
1959
2011
  .then(() => (0, policyGateHealth_1.recordGateSuccess)())
1960
2012
  .catch(() => {
1961
- spoolCallEvent(call, { decision: 'allow', reason: 'local policy allow (block mode, local-first); gate report failed — spooled', offlineEnforced: true });
2013
+ spoolCallEvent(call, { decision: 'allow', policyHash: effectivePolicyHash, ruleId: localVerdict.policyId, reason: 'local policy allow (block mode, local-first); gate report failed — spooled', offlineEnforced: true });
1962
2014
  (0, telemetry_1.triggerFlush)(false);
1963
2015
  });
1964
2016
  respond(false);
@@ -1966,7 +2018,7 @@ async function enforceActionPolicy(ctx) {
1966
2018
  }
1967
2019
  if (localVerdict.verdict === 'block') {
1968
2020
  const reason = localVerdict.reason || `${call.toolName}: blocked by org Action Policy`;
1969
- spoolCallEvent(call, { decision: 'block', reason: `local policy (block mode, local-first): ${reason}`, offlineEnforced: true });
2021
+ spoolCallEvent(call, { decision: 'block', policyHash: effectivePolicyHash, ruleId: localVerdict.policyId, reason: `local policy (block mode, local-first): ${reason}`, offlineEnforced: true });
1970
2022
  (0, telemetry_1.triggerFlush)(true);
1971
2023
  const explained = explainPolicyStop('block', event, call, localVerdict, ctx.localPolicies);
1972
2024
  respond(true, explained.userMessage, explained.agentMessage);
@@ -1980,7 +2032,7 @@ async function enforceActionPolicy(ctx) {
1980
2032
  return;
1981
2033
  }
1982
2034
  // require_approval, developer scope + human present → IDE-native ask (no org queue).
1983
- if (tryDeveloperConfirmation({ event, respond, shadow, localPolicies: ctx.localPolicies }, call, localVerdict, payload, 'local-first'))
2035
+ if (tryDeveloperConfirmation({ event, respond, shadow, effectivePolicyHash, localPolicies: ctx.localPolicies }, call, localVerdict, payload, 'local-first'))
1984
2036
  return;
1985
2037
  // require_approval (org scope) → synchronous gate below creates the approval action.
1986
2038
  }
@@ -44,6 +44,7 @@ const os = __importStar(require("os"));
44
44
  const path = __importStar(require("path"));
45
45
  const config_1 = require("../config");
46
46
  const hookIo_1 = require("../hookIo");
47
+ const gatewayRuntime_1 = require("../gatewayRuntime");
47
48
  const restartNotice_1 = require("./restartNotice");
48
49
  const COLOR = {
49
50
  reset: '\x1b[0m', bold: '\x1b[1m', dim: '\x1b[2m',
@@ -94,15 +95,16 @@ function hookScriptPath() {
94
95
  || (0, hookIo_1.slimHookEntryPath)(path.join(__dirname, '..', 'index.js'))
95
96
  || cliScript;
96
97
  }
97
- function hookInvocationPrefix() {
98
+ function hookInvocationPrefix(existingCommand) {
98
99
  const q = (s) => (/\s/.test(s) ? `"${s}"` : s);
99
- return `${q(process.execPath)} ${q(hookScriptPath())}`;
100
+ const runtime = (0, gatewayRuntime_1.resolveHookRuntime)({ executable: process.execPath, entry: hookScriptPath() }, existingCommand);
101
+ return `${q(runtime.executable)} ${q(runtime.entry)}`;
100
102
  }
101
103
  function repairManagedCommandPath(command) {
102
104
  const marker = command.indexOf(' hook ');
103
105
  if (marker < 0)
104
106
  return { command, stale: true };
105
- const expected = `${hookInvocationPrefix()}${command.slice(marker)}`;
107
+ const expected = `${hookInvocationPrefix(command)}${command.slice(marker)}`;
106
108
  return { command: expected, stale: expected !== command };
107
109
  }
108
110
  function managedCommands(settings) {
@@ -212,8 +214,12 @@ function syncCopilotToolHooks(command) {
212
214
  for (const event of ['PreToolUse', 'PostToolUse']) {
213
215
  const entries = Array.isArray(hooks[event]) ? hooks[event] : [];
214
216
  const kept = entries.filter(entry => !isManagedCommand(entry));
215
- if (command)
216
- kept.push({ type: 'command', command: `${command} --agent-client copilot`, timeoutSec: event === 'PreToolUse' ? TOOL_HOOK_TIMEOUT_SEC : POST_TOOL_HOOK_TIMEOUT_SEC });
217
+ if (command) {
218
+ const existing = entries.find(isManagedCommand)?.command;
219
+ const marker = command.indexOf(' hook ');
220
+ const ownedCommand = existing && marker >= 0 ? `${hookInvocationPrefix(existing)}${command.slice(marker)}` : command;
221
+ kept.push({ type: 'command', command: `${ownedCommand} --agent-client copilot`, timeoutSec: event === 'PreToolUse' ? TOOL_HOOK_TIMEOUT_SEC : POST_TOOL_HOOK_TIMEOUT_SEC });
222
+ }
217
223
  if (kept.length)
218
224
  hooks[event] = kept;
219
225
  else
@@ -42,6 +42,7 @@ const os = __importStar(require("os"));
42
42
  const path = __importStar(require("path"));
43
43
  const config_1 = require("../config");
44
44
  const hookIo_1 = require("../hookIo");
45
+ const gatewayRuntime_1 = require("../gatewayRuntime");
45
46
  const restartNotice_1 = require("./restartNotice");
46
47
  const COLOR = {
47
48
  reset: '\x1b[0m', bold: '\x1b[1m', dim: '\x1b[2m',
@@ -153,19 +154,19 @@ function isManaged(entry) {
153
154
  return typeof entry?.command === 'string'
154
155
  && (entry.command.includes(MANAGED_MARKER) || entry.command.includes(MANAGED_TAG));
155
156
  }
156
- function hookInvocationPrefix() {
157
- const nodeExe = process.execPath;
157
+ function hookInvocationPrefix(existingCommand) {
158
+ const runtime = (0, gatewayRuntime_1.resolveHookRuntime)({ executable: process.execPath, entry: hookScriptPath() }, existingCommand);
158
159
  const q = (s) => (/\s/.test(s) ? `"${s}"` : s);
159
160
  // Repair rewrites stale managed entries to the slim entry too — this is how
160
161
  // an already-enrolled fleet migrates to the slim hook on upgrade, without a
161
162
  // reinstall (protection repair runs from the daemon after every update).
162
- return `${q(nodeExe)} ${q(hookScriptPath())}`;
163
+ return `${q(runtime.executable)} ${q(runtime.entry)}`;
163
164
  }
164
165
  function repairManagedCommandPath(command) {
165
166
  const marker = command.indexOf(' hook ');
166
167
  if (marker < 0)
167
168
  return { command, stale: true };
168
- const expected = `${hookInvocationPrefix()}${command.slice(marker)}`;
169
+ const expected = `${hookInvocationPrefix(command)}${command.slice(marker)}`;
169
170
  return { command: expected, stale: expected !== command };
170
171
  }
171
172
  /** A managed command mutated to weaken enforcement (`--shadow true` injected). */
@@ -3,6 +3,8 @@ import { BotGuardConfig } from '../config';
3
3
  export type GatewayClient = 'cursor' | 'claude-code' | 'claude-desktop' | 'codex' | 'gemini-cli' | 'windsurf' | 'vscode';
4
4
  export declare const ALL_GATEWAY_CLIENTS: GatewayClient[];
5
5
  export interface McpGatewayArgs {
6
+ /** Original MCP configuration key; independent of the display agent name. */
7
+ mcpServerName?: string;
6
8
  mcpCommand?: string;
7
9
  mcpArgs?: string;
8
10
  /** Remote MCP server URL (Streamable HTTP / SSE) — alternative to --mcp-command. */