@wix/pathgrade 0.32.0 → 0.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.
Files changed (38) hide show
  1. package/dist/agents/claude/denied-mcp-event-store.d.ts +8 -0
  2. package/dist/agents/claude/denied-mcp-event-store.js +17 -0
  3. package/dist/agents/claude/mcp-tool-name.d.ts +12 -0
  4. package/dist/agents/claude/mcp-tool-name.js +22 -0
  5. package/dist/agents/claude/sdk-message-projector.d.ts +5 -0
  6. package/dist/agents/claude/sdk-message-projector.js +31 -3
  7. package/dist/agents/claude/sdk-options.d.ts +4 -2
  8. package/dist/agents/claude/sdk-options.js +2 -0
  9. package/dist/agents/claude/tool-permission-bridge.d.ts +18 -0
  10. package/dist/agents/claude/tool-permission-bridge.js +86 -0
  11. package/dist/agents/claude.d.ts +1 -1
  12. package/dist/agents/claude.js +43 -5
  13. package/dist/agents/codex-app-server/agent.js +162 -3
  14. package/dist/agents/codex.js +4 -0
  15. package/dist/agents/cursor.js +12 -13
  16. package/dist/providers/mcp-config.d.ts +24 -16
  17. package/dist/providers/mcp-config.js +128 -20
  18. package/dist/providers/mcp-runtime-mounting.d.ts +46 -0
  19. package/dist/providers/mcp-runtime-mounting.js +155 -0
  20. package/dist/providers/sandbox.d.ts +1 -1
  21. package/dist/providers/sandbox.js +2 -1
  22. package/dist/providers/workspace.d.ts +1 -1
  23. package/dist/providers/workspace.js +2 -2
  24. package/dist/sdk/agent.js +7 -4
  25. package/dist/sdk/index.d.ts +6 -1
  26. package/dist/sdk/index.js +2 -0
  27. package/dist/sdk/managed-session.d.ts +3 -0
  28. package/dist/sdk/managed-session.js +2 -0
  29. package/dist/sdk/mcp-evidence.d.ts +29 -0
  30. package/dist/sdk/mcp-evidence.js +96 -0
  31. package/dist/sdk/mcp-safety.d.ts +30 -0
  32. package/dist/sdk/mcp-safety.js +61 -0
  33. package/dist/sdk/types.d.ts +12 -2
  34. package/dist/sdk/types.js +2 -2
  35. package/dist/tool-events.d.ts +1 -1
  36. package/dist/types.d.ts +6 -0
  37. package/dist/utils/timeout.js +11 -10
  38. package/package.json +3 -2
@@ -0,0 +1,8 @@
1
+ import type { ToolEvent } from '../../tool-events.js';
2
+ export interface ClaudeDeniedMcpEventStore {
3
+ record(toolUseId: string, event: ToolEvent): void;
4
+ get(toolUseId: string | undefined): ToolEvent | undefined;
5
+ all(): ToolEvent[];
6
+ clear(): void;
7
+ }
8
+ export declare function createClaudeDeniedMcpEventStore(): ClaudeDeniedMcpEventStore;
@@ -0,0 +1,17 @@
1
+ export function createClaudeDeniedMcpEventStore() {
2
+ const byToolUseId = new Map();
3
+ return {
4
+ record(toolUseId, event) {
5
+ byToolUseId.set(toolUseId, event);
6
+ },
7
+ get(toolUseId) {
8
+ return toolUseId ? byToolUseId.get(toolUseId) : undefined;
9
+ },
10
+ all() {
11
+ return [...byToolUseId.values()];
12
+ },
13
+ clear() {
14
+ byToolUseId.clear();
15
+ },
16
+ };
17
+ }
@@ -0,0 +1,12 @@
1
+ import type { McpPolicyDenialReason } from '../../sdk/mcp-safety.js';
2
+ export type ClaudeSdkMcpToolNameParseResult = {
3
+ kind: 'non_mcp';
4
+ } | {
5
+ kind: 'mcp';
6
+ server: string;
7
+ tool: string;
8
+ } | {
9
+ kind: 'unrecognized';
10
+ reason: Extract<McpPolicyDenialReason, 'unrecognized_mcp_tool_name'>;
11
+ };
12
+ export declare function parseClaudeSdkMcpToolName(providerToolName: string, configuredServerNames: readonly string[]): ClaudeSdkMcpToolNameParseResult;
@@ -0,0 +1,22 @@
1
+ const MCP_TOOL_PREFIX = 'mcp__';
2
+ export function parseClaudeSdkMcpToolName(providerToolName, configuredServerNames) {
3
+ if (!providerToolName.startsWith(MCP_TOOL_PREFIX))
4
+ return { kind: 'non_mcp' };
5
+ const body = providerToolName.slice(MCP_TOOL_PREFIX.length);
6
+ const matches = configuredServerNames
7
+ .filter((serverName) => {
8
+ if (serverName.length === 0)
9
+ return false;
10
+ const toolStart = `${serverName}__`;
11
+ return body.startsWith(toolStart) && body.length > toolStart.length;
12
+ })
13
+ .sort((a, b) => b.length - a.length);
14
+ if (matches.length !== 1) {
15
+ return { kind: 'unrecognized', reason: 'unrecognized_mcp_tool_name' };
16
+ }
17
+ const server = matches[0];
18
+ const tool = body.slice(server.length + 2);
19
+ if (!tool)
20
+ return { kind: 'unrecognized', reason: 'unrecognized_mcp_tool_name' };
21
+ return { kind: 'mcp', server, tool };
22
+ }
@@ -18,6 +18,7 @@
18
18
  import type { SDKMessage } from '@anthropic-ai/claude-agent-sdk';
19
19
  import type { AgentTurnResult } from '../../types.js';
20
20
  import type { AskUserAnswerStore } from './ask-user-answer-store.js';
21
+ import type { ClaudeDeniedMcpEventStore } from './denied-mcp-event-store.js';
21
22
  export interface ProjectTurnInput {
22
23
  /** Buffered typed-message stream from one `query()` call. */
23
24
  messages: SDKMessage[];
@@ -34,6 +35,10 @@ export interface ProjectTurnInput {
34
35
  * `answerSource: 'unknown'` it stamped before the bridge existed.
35
36
  */
36
37
  answerStore?: AskUserAnswerStore;
38
+ /** Staged MCP server names from the Claude SDK mount boundary. */
39
+ mcpServerNames?: readonly string[];
40
+ /** Policy-denied MCP events recorded by the per-turn permission bridge. */
41
+ deniedMcpEvents?: ClaudeDeniedMcpEventStore;
37
42
  }
38
43
  export interface ProjectedTurn {
39
44
  result: AgentTurnResult;
@@ -22,6 +22,7 @@ const SDK_ERROR_SUBTYPES = [
22
22
  'error_max_structured_output_retries',
23
23
  ];
24
24
  import { TOOL_NAME_MAP, buildSummary, enrichSkillEvents } from '../../tool-events.js';
25
+ import { parseClaudeSdkMcpToolName } from './mcp-tool-name.js';
25
26
  export function projectSdkMessages(input) {
26
27
  let sessionId;
27
28
  let initSkills;
@@ -59,7 +60,9 @@ export function projectSdkMessages(input) {
59
60
  continue;
60
61
  }
61
62
  if (block.type === 'tool_use') {
62
- toolEvents.push(buildToolEvent(block, input.turnNumber, input.answerStore));
63
+ const event = buildToolEvent(block, input.turnNumber, input.answerStore, input.mcpServerNames ?? [], input.deniedMcpEvents);
64
+ if (event)
65
+ toolEvents.push(event);
63
66
  }
64
67
  }
65
68
  break;
@@ -109,7 +112,10 @@ export function projectSdkMessages(input) {
109
112
  const trimmedResult = resultText.trim();
110
113
  const visible = isError ? '' : (trimmedAssistant || trimmedResult);
111
114
  const rawOutput = resultText || assistantText;
112
- const enriched = enrichSkillEvents(toolEvents);
115
+ const enriched = enrichSkillEvents([
116
+ ...toolEvents,
117
+ ...(input.deniedMcpEvents?.all() ?? []),
118
+ ]);
113
119
  const finalToolEvents = prependSlashCommandSkillEvent(enriched, input.firstMessage, initSkills);
114
120
  const traceOutput = input.messages.map((m) => JSON.stringify(m)).join('\n');
115
121
  const result = {
@@ -161,10 +167,32 @@ function prependSlashCommandSkillEvent(events, firstMessage, initSkills) {
161
167
  ...events,
162
168
  ];
163
169
  }
164
- function buildToolEvent(block, turnNumber, answerStore) {
170
+ function buildToolEvent(block, turnNumber, answerStore, mcpServerNames, deniedMcpEvents) {
165
171
  const providerToolName = String(block.name || 'unknown');
166
172
  const rawInput = block.input ?? undefined;
167
173
  const toolUseId = typeof block.id === 'string' ? block.id : undefined;
174
+ const mcpTool = parseClaudeSdkMcpToolName(providerToolName, mcpServerNames);
175
+ if (mcpTool.kind === 'mcp') {
176
+ if (deniedMcpEvents?.get(toolUseId))
177
+ return undefined;
178
+ const normalizedProviderToolName = `${mcpTool.server}.${mcpTool.tool}`;
179
+ const args = {
180
+ ...(rawInput ?? {}),
181
+ server: mcpTool.server,
182
+ tool: mcpTool.tool,
183
+ status: 'completed',
184
+ };
185
+ return {
186
+ action: 'mcp_tool_call',
187
+ provider: 'claude',
188
+ providerToolName: normalizedProviderToolName,
189
+ turnNumber,
190
+ arguments: args,
191
+ summary: `MCP tool ${normalizedProviderToolName} completed`,
192
+ confidence: 'high',
193
+ rawSnippet: JSON.stringify(block).slice(0, 200),
194
+ };
195
+ }
168
196
  const action = TOOL_NAME_MAP[providerToolName] ?? 'unknown';
169
197
  const args = action === 'ask_user'
170
198
  ? buildAskUserArguments(rawInput, answerStore?.get(toolUseId))
@@ -1,5 +1,5 @@
1
1
  import type { CanUseTool, Options, SpawnedProcess, SpawnOptions as SdkSpawnOptions } from '@anthropic-ai/claude-agent-sdk';
2
- import type { McpServersObject } from '../../providers/mcp-config.js';
2
+ import type { McpServersObject } from '../../providers/mcp-runtime-mounting.js';
3
3
  export interface ClaudeSdkOptionsInputs {
4
4
  /** Per-trial workspace; cwd for the SDK so project-staged skills resolve. */
5
5
  workspacePath: string;
@@ -31,8 +31,10 @@ export interface ClaudeSdkOptionsInputs {
31
31
  * `session_id` on every turn after.
32
32
  */
33
33
  resume?: string;
34
- /** MCP servers in the SDK's object form, from `loadMcpServersForSdk`. */
34
+ /** MCP servers in the SDK's object form, from `mountMcpForClaudeSdk`. */
35
35
  mcpServers?: McpServersObject;
36
+ /** Per-turn cancellation controller linked to PathGrade's turn timeout. */
37
+ abortController?: AbortController;
36
38
  }
37
39
  /**
38
40
  * Pick the Claude executable. Precedence:
@@ -58,6 +58,8 @@ export function buildClaudeSdkOptions(inputs) {
58
58
  opts.resume = inputs.resume;
59
59
  if (inputs.mcpServers !== undefined)
60
60
  opts.mcpServers = inputs.mcpServers;
61
+ if (inputs.abortController !== undefined)
62
+ opts.abortController = inputs.abortController;
61
63
  // Env composition ownership: the driver does NOT pluck specific keys.
62
64
  // `prepareWorkspace` curates the runtime env (safe host vars, sandbox
63
65
  // HOME/TMPDIR, resolveCredentials() output, user-supplied
@@ -0,0 +1,18 @@
1
+ import type { CanUseTool } from '@anthropic-ai/claude-agent-sdk';
2
+ import type { AskBus } from '../../sdk/ask-bus/types.js';
3
+ import { type McpSafetyOptions } from '../../sdk/mcp-safety.js';
4
+ import type { AskUserAnswerStore } from './ask-user-answer-store.js';
5
+ import type { ClaudeDeniedMcpEventStore } from './denied-mcp-event-store.js';
6
+ export interface ClaudeToolPermissionBridgeDeps {
7
+ askBus: AskBus;
8
+ getTurnNumber: () => number;
9
+ answerStore: AskUserAnswerStore;
10
+ mcpServerNames: readonly string[];
11
+ mcpSafety?: McpSafetyOptions;
12
+ deniedMcpEvents?: ClaudeDeniedMcpEventStore;
13
+ }
14
+ export type ClaudeToolPermissionBridge = CanUseTool & {
15
+ lastError(): Error | null;
16
+ clearLastError(): void;
17
+ };
18
+ export declare function createClaudeToolPermissionBridge(deps: ClaudeToolPermissionBridgeDeps): ClaudeToolPermissionBridge;
@@ -0,0 +1,86 @@
1
+ import { decideMcpToolCall, redactMcpSecrets, } from '../../sdk/mcp-safety.js';
2
+ import { createAskUserBridge, } from './ask-user-bridge.js';
3
+ import { parseClaudeSdkMcpToolName } from './mcp-tool-name.js';
4
+ export function createClaudeToolPermissionBridge(deps) {
5
+ const askUserBridge = createAskUserBridge({
6
+ askBus: deps.askBus,
7
+ getTurnNumber: deps.getTurnNumber,
8
+ answerStore: deps.answerStore,
9
+ });
10
+ const canUseTool = async (toolName, input, options) => {
11
+ if (toolName === 'AskUserQuestion') {
12
+ return askUserBridge(toolName, input, options);
13
+ }
14
+ const runMode = deps.mcpSafety?.runMode ?? 'mock';
15
+ const parsed = parseClaudeSdkMcpToolName(toolName, deps.mcpServerNames);
16
+ if (parsed.kind === 'non_mcp') {
17
+ return allow(input);
18
+ }
19
+ if (runMode === 'mock') {
20
+ return allow(input);
21
+ }
22
+ if (parsed.kind === 'unrecognized') {
23
+ return {
24
+ behavior: 'deny',
25
+ message: `unrecognized Claude SDK MCP tool name: ${toolName}`,
26
+ };
27
+ }
28
+ const decision = decideMcpToolCall(deps.mcpSafety, {
29
+ serverName: parsed.server,
30
+ toolName: parsed.tool,
31
+ arguments: asRecord(input),
32
+ });
33
+ if (decision.action === 'allow')
34
+ return allow(input);
35
+ recordDeniedMcpEvent({
36
+ store: deps.deniedMcpEvents,
37
+ toolUseId: options.toolUseID,
38
+ serverName: parsed.server,
39
+ toolName: parsed.tool,
40
+ input: asRecord(input),
41
+ decision,
42
+ });
43
+ return { behavior: 'deny', message: decision.message };
44
+ };
45
+ const bridge = canUseTool;
46
+ bridge.lastError = () => askUserBridge.lastError();
47
+ bridge.clearLastError = () => askUserBridge.clearLastError();
48
+ return bridge;
49
+ }
50
+ function allow(input) {
51
+ return { behavior: 'allow', updatedInput: asRecord(input) };
52
+ }
53
+ function asRecord(value) {
54
+ return value && typeof value === 'object' && !Array.isArray(value)
55
+ ? value
56
+ : {};
57
+ }
58
+ function recordDeniedMcpEvent(opts) {
59
+ if (!opts.store)
60
+ return;
61
+ const providerToolName = `${opts.serverName}.${opts.toolName}`;
62
+ const args = redactMcpSecrets(opts.input);
63
+ const event = {
64
+ action: 'mcp_tool_call',
65
+ provider: 'claude',
66
+ providerToolName,
67
+ arguments: {
68
+ ...args,
69
+ server: opts.serverName,
70
+ tool: opts.toolName,
71
+ status: 'policy_denied',
72
+ policyResult: {
73
+ action: 'deny',
74
+ reason: opts.decision.reason,
75
+ message: opts.decision.message,
76
+ },
77
+ },
78
+ summary: `MCP tool ${providerToolName} policy_denied`,
79
+ confidence: 'high',
80
+ rawSnippet: JSON.stringify(redactMcpSecrets({
81
+ toolUseID: opts.toolUseId,
82
+ input: opts.input,
83
+ })).slice(0, 200),
84
+ };
85
+ opts.store.record(opts.toolUseId, event);
86
+ }
@@ -8,7 +8,7 @@
8
8
  * - `sandboxedClaudeSpawn` — `Options.spawnClaudeCodeProcess` adapter
9
9
  * that filters env and (optionally) wraps
10
10
  * argv with macOS sandbox-exec.
11
- * - `loadMcpServersForSdk` — reads pathgrade's MCP config JSON into
11
+ * - `mountMcpForClaudeSdk` — reads pathgrade's MCP config JSON into
12
12
  * the SDK's `Options.mcpServers` shape.
13
13
  * - `buildClaudeSdkOptions` — pure builder for the per-turn `Options`.
14
14
  * - `createAskUserBridge` — live `canUseTool` that auto-allows
@@ -8,7 +8,7 @@
8
8
  * - `sandboxedClaudeSpawn` — `Options.spawnClaudeCodeProcess` adapter
9
9
  * that filters env and (optionally) wraps
10
10
  * argv with macOS sandbox-exec.
11
- * - `loadMcpServersForSdk` — reads pathgrade's MCP config JSON into
11
+ * - `mountMcpForClaudeSdk` — reads pathgrade's MCP config JSON into
12
12
  * the SDK's `Options.mcpServers` shape.
13
13
  * - `buildClaudeSdkOptions` — pure builder for the per-turn `Options`.
14
14
  * - `createAskUserBridge` — live `canUseTool` that auto-allows
@@ -24,12 +24,27 @@
24
24
  import { query as sdkQuery, } from '@anthropic-ai/claude-agent-sdk';
25
25
  import { BaseAgent, getRuntimeEnv, getWorkspacePath, } from '../types.js';
26
26
  import { createSandboxedClaudeSpawn } from '../providers/sandboxed-claude-spawn.js';
27
- import { loadMcpServersForSdk } from '../providers/mcp-config.js';
27
+ import { assertClaudeLiveMcpSafetyPreflight, assertStdioMcpServersStartForClaudeSdk, mountMcpForClaudeSdk, } from '../providers/mcp-runtime-mounting.js';
28
28
  import { buildClaudeSdkOptions, resolveClaudeCodeExecutable, } from './claude/sdk-options.js';
29
29
  import { projectSdkMessages } from './claude/sdk-message-projector.js';
30
- import { createAskUserBridge } from './claude/ask-user-bridge.js';
31
30
  import { createAskUserAnswerStore } from './claude/ask-user-answer-store.js';
31
+ import { createClaudeToolPermissionBridge } from './claude/tool-permission-bridge.js';
32
+ import { createClaudeDeniedMcpEventStore } from './claude/denied-mcp-event-store.js';
32
33
  import { requireAskBusForLiveBatches } from '../sdk/ask-bus/bus.js';
34
+ function createLinkedAbortController(signal) {
35
+ const controller = new AbortController();
36
+ if (!signal)
37
+ return controller;
38
+ if (signal.aborted) {
39
+ controller.abort();
40
+ return controller;
41
+ }
42
+ signal.addEventListener('abort', () => controller.abort(), { once: true });
43
+ return controller;
44
+ }
45
+ function getTurnAbortSignal(sessionOptions) {
46
+ return sessionOptions?.getAbortSignal?.() ?? sessionOptions?.abortSignal;
47
+ }
33
48
  export class ClaudeAgent extends BaseAgent {
34
49
  deps;
35
50
  opts;
@@ -57,7 +72,17 @@ export class ClaudeAgent extends BaseAgent {
57
72
  agentOptionsExecutable: this.opts.claudeCodeExecutable,
58
73
  envExecutable,
59
74
  });
60
- const mcpServers = await loadMcpServersForSdk(workspacePath);
75
+ const mcpMountOptions = {
76
+ workspacePath,
77
+ mcpConfigPath: sessionOptions?.mcpConfigPath,
78
+ runtimeEnv: getRuntimeEnv(runtime),
79
+ };
80
+ await assertClaudeLiveMcpSafetyPreflight({
81
+ ...mcpMountOptions,
82
+ mcpSafety: sessionOptions?.mcpSafety,
83
+ });
84
+ await assertStdioMcpServersStartForClaudeSdk(mcpMountOptions);
85
+ const mcpServers = await mountMcpForClaudeSdk(mcpMountOptions);
61
86
  let priorSessionId;
62
87
  let turnNumber = 0;
63
88
  // The live ask-user bridge resolves AskUserQuestion through the bus
@@ -67,14 +92,24 @@ export class ClaudeAgent extends BaseAgent {
67
92
  // turn 2 cannot read a stale answer from turn 1 even on toolUseID
68
93
  // collisions.
69
94
  let answerStore = createAskUserAnswerStore();
70
- const bridge = createAskUserBridge({
95
+ let deniedMcpEvents = createClaudeDeniedMcpEventStore();
96
+ const bridge = createClaudeToolPermissionBridge({
71
97
  askBus,
72
98
  getTurnNumber: () => turnNumber,
73
99
  answerStore: { record: (id, e) => answerStore.record(id, e), get: (id) => answerStore.get(id) },
100
+ mcpServerNames: mcpServers ? Object.keys(mcpServers) : [],
101
+ mcpSafety: sessionOptions?.mcpSafety,
102
+ deniedMcpEvents: {
103
+ record: (id, event) => deniedMcpEvents.record(id, event),
104
+ get: (id) => deniedMcpEvents.get(id),
105
+ all: () => deniedMcpEvents.all(),
106
+ clear: () => deniedMcpEvents.clear(),
107
+ },
74
108
  });
75
109
  const runTurn = async (message) => {
76
110
  turnNumber += 1;
77
111
  answerStore = createAskUserAnswerStore();
112
+ deniedMcpEvents = createClaudeDeniedMcpEventStore();
78
113
  // Clear any ask-bus rejection captured on a prior turn so a
79
114
  // stale error never causes a spurious result on this turn.
80
115
  bridge.clearLastError();
@@ -87,6 +122,7 @@ export class ClaudeAgent extends BaseAgent {
87
122
  claudeCodeExecutable,
88
123
  resume: priorSessionId,
89
124
  mcpServers,
125
+ abortController: createLinkedAbortController(getTurnAbortSignal(sessionOptions)),
90
126
  });
91
127
  const messages = [];
92
128
  const stream = queryFn({ prompt: message, options: sdkOptions });
@@ -107,6 +143,8 @@ export class ClaudeAgent extends BaseAgent {
107
143
  turnNumber,
108
144
  firstMessage: projectorFirstMessage,
109
145
  answerStore,
146
+ mcpServerNames: mcpServers ? Object.keys(mcpServers) : [],
147
+ deniedMcpEvents,
110
148
  });
111
149
  // Capture the SDK-reported session id BEFORE checking for a bus
112
150
  // rejection so the next turn's `Options.resume` points at this
@@ -1,10 +1,45 @@
1
1
  import { BaseAgent, getRuntimeEnv, getWorkspacePath, } from '../../types.js';
2
+ import { mountMcpForCodexAppServer } from '../../providers/mcp-runtime-mounting.js';
3
+ import { assertMcpSecretReferencesReady } from '../../providers/mcp-config.js';
2
4
  import { requireAskBusForLiveBatches, } from '../../sdk/ask-bus/bus.js';
3
5
  import { toAskUserToolEvent } from '../../sdk/ask-bus/projection.js';
6
+ import { decideMcpToolCall, redactMcpSecrets, } from '../../sdk/mcp-safety.js';
4
7
  import { spawnAppServerTransport, } from './transport.js';
5
8
  import { normalizeUpstreamQuestion, toWireAnswerMap, } from './wire-translators.js';
6
9
  const DEFAULT_MODEL = 'gpt-5.3-codex';
7
10
  const TURN_COMPLETED_METHOD = 'turn/completed';
11
+ function recordFromUnknown(value) {
12
+ if (value && typeof value === 'object' && !Array.isArray(value)) {
13
+ return value;
14
+ }
15
+ return {};
16
+ }
17
+ function isMcpToolCallApprovalRequest(params) {
18
+ const meta = recordFromUnknown(recordFromUnknown(params)._meta);
19
+ return meta.codex_approval_kind === 'mcp_tool_call';
20
+ }
21
+ function extractMcpToolApprovalRequest(params) {
22
+ if (!isMcpToolCallApprovalRequest(params))
23
+ return undefined;
24
+ const record = recordFromUnknown(params);
25
+ const meta = recordFromUnknown(record._meta);
26
+ const serverName = typeof record.serverName === 'string' ? record.serverName : undefined;
27
+ const toolName = typeof meta.toolName === 'string' ? meta.toolName
28
+ : typeof meta.tool_name === 'string' ? meta.tool_name
29
+ : typeof meta.name === 'string' ? meta.name
30
+ : typeof record.message === 'string' ? parseToolNameFromApprovalMessage(record.message)
31
+ : undefined;
32
+ if (!serverName || !toolName)
33
+ return undefined;
34
+ return {
35
+ serverName,
36
+ toolName,
37
+ arguments: recordFromUnknown(meta.tool_params),
38
+ };
39
+ }
40
+ function parseToolNameFromApprovalMessage(message) {
41
+ return message.match(/tool\s+"([^"]+)"/i)?.[1];
42
+ }
8
43
  function projectItemIntoTurn(item, turn) {
9
44
  if (item.type === 'agentMessage') {
10
45
  const msg = item;
@@ -43,6 +78,82 @@ function projectItemIntoTurn(item, turn) {
43
78
  }
44
79
  return;
45
80
  }
81
+ if (item.type === 'mcpToolCall') {
82
+ const call = item;
83
+ const args = recordFromUnknown(call.arguments);
84
+ const providerToolName = `${call.server}.${call.tool}`;
85
+ turn.nonAskToolEvents.push({
86
+ action: 'mcp_tool_call',
87
+ provider: 'codex',
88
+ providerToolName,
89
+ turnNumber: turn.turnNumber,
90
+ arguments: {
91
+ ...args,
92
+ server: call.server,
93
+ tool: call.tool,
94
+ status: call.status ?? 'unknown',
95
+ },
96
+ summary: `MCP tool ${providerToolName} ${call.status ?? 'unknown'}`,
97
+ confidence: 'high',
98
+ rawSnippet: JSON.stringify(call),
99
+ });
100
+ return;
101
+ }
102
+ }
103
+ function projectMcpStartupStatusIntoTurn(params, turn) {
104
+ const name = params.name ?? 'unknown';
105
+ const status = params.status ?? 'unknown';
106
+ const error = typeof params.error === 'string' ? params.error : undefined;
107
+ turn.nonAskToolEvents.push({
108
+ action: 'unknown',
109
+ provider: 'codex',
110
+ providerToolName: 'mcpServer/startupStatus/updated',
111
+ turnNumber: turn.turnNumber,
112
+ arguments: {
113
+ name,
114
+ status,
115
+ ...(error ? { error } : {}),
116
+ },
117
+ summary: `MCP server ${name} startup ${status}`,
118
+ confidence: 'high',
119
+ rawSnippet: JSON.stringify(params),
120
+ });
121
+ if (status === 'failed') {
122
+ const message = `MCP server ${name} failed to start${error ? `: ${error}` : ''}`;
123
+ turn.turnFailed = true;
124
+ turn.failureMessage = message;
125
+ turn.signalFailure?.(message);
126
+ }
127
+ }
128
+ function recordPolicyDeniedMcpToolCall(turn, request, decision, rawParams) {
129
+ if (!turn)
130
+ return;
131
+ const args = redactMcpSecrets(request.arguments);
132
+ const providerToolName = `${request.serverName}.${request.toolName}`;
133
+ turn.nonAskToolEvents.push({
134
+ action: 'mcp_tool_call',
135
+ provider: 'codex',
136
+ providerToolName,
137
+ turnNumber: turn.turnNumber,
138
+ arguments: {
139
+ ...args,
140
+ server: request.serverName,
141
+ tool: request.toolName,
142
+ status: 'policy_denied',
143
+ policyResult: {
144
+ action: 'deny',
145
+ reason: decision.reason,
146
+ message: decision.message,
147
+ },
148
+ },
149
+ summary: `MCP tool ${providerToolName} policy_denied`,
150
+ confidence: 'high',
151
+ rawSnippet: JSON.stringify(redactMcpSecrets(rawParams)),
152
+ });
153
+ }
154
+ function isLiveMcpSafetyMode(options) {
155
+ const runMode = options?.runMode ?? 'mock';
156
+ return runMode === 'live-readonly' || runMode === 'live-sandbox' || runMode === 'live';
46
157
  }
47
158
  export class CodexAppServerAgent extends BaseAgent {
48
159
  deps;
@@ -76,6 +187,7 @@ export class CodexAppServerAgent extends BaseAgent {
76
187
  askBus,
77
188
  activeTurn: () => activeTurn,
78
189
  onPermissionGrant: this.deps.onPermissionGrant,
190
+ mcpSafety: options?.mcpSafety,
79
191
  }));
80
192
  transport.onClose((info) => {
81
193
  closeInfo = info;
@@ -84,6 +196,13 @@ export class CodexAppServerAgent extends BaseAgent {
84
196
  if (process.env.PATHGRADE_CODEX_DEBUG) {
85
197
  console.error(`[codex app-server] notification method=${n.method} params=${JSON.stringify(n.params).slice(0, 300)}`);
86
198
  }
199
+ if (n.method === 'mcpServer/startupStatus/updated') {
200
+ const turn = activeTurn;
201
+ if (turn) {
202
+ projectMcpStartupStatusIntoTurn((n.params ?? {}), turn);
203
+ }
204
+ return;
205
+ }
87
206
  if (n.method !== 'item/completed')
88
207
  return;
89
208
  const turn = activeTurn;
@@ -117,7 +236,20 @@ export class CodexAppServerAgent extends BaseAgent {
117
236
  activeTurn = turn;
118
237
  try {
119
238
  if (threadId === null) {
120
- const resp = await t.sendRequest('thread/start', buildThreadStartParams({ cwd: workspacePath, model, sandboxMode }));
239
+ if (options?.mcpConfigPath && isLiveMcpSafetyMode(options.mcpSafety)) {
240
+ await assertMcpSecretReferencesReady({
241
+ workspacePath,
242
+ mcpConfigPath: options.mcpConfigPath,
243
+ env: runtimeEnv,
244
+ });
245
+ }
246
+ const mcpConfig = options?.mcpConfigPath
247
+ ? await mountMcpForCodexAppServer({
248
+ workspacePath,
249
+ mcpConfigPath: options.mcpConfigPath,
250
+ })
251
+ : undefined;
252
+ const resp = await t.sendRequest('thread/start', buildThreadStartParams({ cwd: workspacePath, model, sandboxMode, mcpConfig }));
121
253
  threadId = resp.thread.id;
122
254
  }
123
255
  // Wait for TurnCompleted OR subprocess crash OR dispatcher failure.
@@ -155,6 +287,9 @@ export class CodexAppServerAgent extends BaseAgent {
155
287
  turn.failureMessage = msg;
156
288
  resolve();
157
289
  };
290
+ if (turn.turnFailed) {
291
+ turn.signalFailure(turn.failureMessage ?? 'turn failed');
292
+ }
158
293
  // If already closed, settle immediately.
159
294
  if (closeInfo) {
160
295
  if (!settled) {
@@ -254,7 +389,7 @@ export class CodexAppServerAgent extends BaseAgent {
254
389
  };
255
390
  }
256
391
  dispatchServerRequest(req, ctx) {
257
- const { transport, askBus, activeTurn, onPermissionGrant } = ctx;
392
+ const { transport, askBus, activeTurn, onPermissionGrant, mcpSafety } = ctx;
258
393
  switch (req.method) {
259
394
  case 'item/tool/requestUserInput':
260
395
  void handleRequestUserInput(req, { transport, askBus, activeTurn });
@@ -288,7 +423,30 @@ export class CodexAppServerAgent extends BaseAgent {
288
423
  transport.sendResponse(req.id, { status: 'declined' });
289
424
  return;
290
425
  case 'mcpServer/elicitation/request':
291
- transport.sendResponse(req.id, { action: 'decline' });
426
+ if (isMcpToolCallApprovalRequest(req.params)) {
427
+ const toolRequest = extractMcpToolApprovalRequest(req.params);
428
+ if (toolRequest) {
429
+ const decision = decideMcpToolCall(mcpSafety, toolRequest);
430
+ if (decision.action === 'deny') {
431
+ recordPolicyDeniedMcpToolCall(activeTurn(), toolRequest, decision, req.params);
432
+ transport.sendResponse(req.id, {
433
+ action: 'decline',
434
+ content: null,
435
+ _meta: {
436
+ pathgrade_policy_denial: {
437
+ reason: decision.reason,
438
+ message: decision.message,
439
+ },
440
+ },
441
+ });
442
+ return;
443
+ }
444
+ }
445
+ transport.sendResponse(req.id, { action: 'accept', content: {}, _meta: null });
446
+ }
447
+ else {
448
+ transport.sendResponse(req.id, { action: 'decline', content: null, _meta: null });
449
+ }
292
450
  return;
293
451
  case 'account/chatgptAuthTokens/refresh': {
294
452
  const message = 'codex app-server requires OPENAI_API_KEY for pathgrade and honors OPENAI_BASE_URL when set; ChatGPT/cached auth unsupported under transport=app-server';
@@ -347,6 +505,7 @@ function buildThreadStartParams(opts) {
347
505
  experimentalRawEvents: false,
348
506
  persistExtendedHistory: false,
349
507
  model: opts.model,
508
+ ...(opts.mcpConfig ? { config: opts.mcpConfig } : {}),
350
509
  };
351
510
  }
352
511
  function assembleTurnResult(args) {
@@ -1,7 +1,11 @@
1
1
  import { TOOL_NAME_MAP, buildSummary, inferCodexExecAction, enrichSkillEvents } from '../tool-events.js';
2
2
  import { TranscriptAgent } from './transcript-agent.js';
3
+ import { assertMcpRuntimeMountingSupportedForCodexExec } from '../providers/mcp-runtime-mounting.js';
3
4
  export class CodexAgent extends TranscriptAgent {
4
5
  async runTurn(instruction, runCommand, options) {
6
+ await assertMcpRuntimeMountingSupportedForCodexExec({
7
+ mcpConfigPath: options?.mcpConfigPath,
8
+ });
5
9
  const promptPath = await this.writePromptFile(instruction, runCommand);
6
10
  const command = buildCodexExecCommand(promptPath, options?.model);
7
11
  const result = await runCommand(command);