/** * G2: MCP Proxy for live-session workers. * * When the parent process has MCP servers configured, live-session workers * can reuse those connections instead of establishing their own. This module * discovers MCP tools available in the parent environment and creates proxy * tool definitions that forward calls through the parent's connections. * * Strategy: * 1. If the Pi SDK session has MCP tools after bindExtensions → use them directly * 2. If not → create proxy custom tools that wrap MCP calls * 3. If sharing is not permitted for the role → disable MCP in the session * * G2 enforcement note (pi ≥0.99): MCP is now a BUILT-IN EXTENSION * (`builtin:mcp`, reading `mcp.json`) and the pre-0.99 `enableMcp:false` * `createAgentSession` option no longer exists. The runtime enforcement for * a non-permitted role therefore drops the MCP extension(s) from the child's * resource loader (`extensionsOverride` → `stripMcpExtensions` below). * `McpProxyConfig.enableMcp` remains the module's semantic contract: callers * can branch on it while E1 (real parent→child proxying) is unbuilt. */ import type { ToolDefinition } from "@earendil-works/pi-coding-agent"; import type { TSchema } from "@sinclair/typebox"; import { permissionForRole } from "./role-permission.ts"; export interface McpProxyConfig { /** Whether to enable MCP in the child session. */ enableMcp: boolean; /** Proxy tools to inject via customTools (replaces MCP connection). */ proxyTools: Array>; /** Names of MCP tools available (for metadata/tracking). */ proxyToolNames: string[]; } /** * Build MCP proxy configuration for a live-session worker. * * @param options.parentMcpTools — MCP tool names from the parent session (if available) * @param options.shareMcp — Whether to share MCP connections. MUST be explicit: * `true` enables the discovery/proxy paths below; `false` or `undefined` * denies (security-review follow-up, SDD-2 remediation — the old * undefined-default fell through to the permissive enable paths). */ export function buildMcpProxyConfig(options: { parentMcpTools?: string[]; shareMcp?: boolean }): McpProxyConfig { if (options.shareMcp !== true) { // G2 (SDD-2 W-B) + remediation hardening: least-privilege contract. // shareMcp must be explicitly opted in — anything else (false OR // undefined) means the child must not discover the parent's MCP // servers AT ALL. The pre-flip behavior returned enableMcp:true for // shareMcp:false (flag inversion), and the pre-hardening default let // an OMITTED flag fall through to the permissive paths — either way // even read-only roles self-discovered every parent MCP server (with // credentials). These helpers currently have no production call // sites; the hardened default exists so a future caller cannot // re-open the gap by forgetting the flag. return { enableMcp: false, proxyTools: [], proxyToolNames: [] }; } const parentTools = options.parentMcpTools ?? []; if (parentTools.length === 0) { // No MCP tools in parent — let session discover on its own return { enableMcp: true, proxyTools: [], proxyToolNames: [] }; } // MCP tools exist in parent — try to create proxy tools. // If proxy tools are not available (stub), keep enableMcp: true // so the child session can self-discover MCP instead of losing all access. const proxyTools = createMcpProxyTools(parentTools); if (proxyTools.length === 0) { // No proxy tools available — let child discover MCP on its own return { enableMcp: true, proxyTools: [], proxyToolNames: parentTools }; } return { enableMcp: false, proxyTools, proxyToolNames: parentTools, }; } /** * Create lightweight proxy tools that represent MCP tools from the parent. * * These tools tell the model that the MCP tools are available, but actual * execution is forwarded through the parent's MCP connections. Since we * can't directly access the parent's MCP manager from a child session, * the tools return a message indicating the model should use them normally. * * In a future iteration, these can be wired to the actual MCP connections * via an inter-process bridge. */ function createMcpProxyTools(toolNames: string[]): Array> { // For now, we don't create individual proxy tools because we can't // forward MCP calls without the parent's MCP manager reference. // // Instead, we let the child session discover MCP on its own (enableMcp: true) // or share the parent's MCP config directory. // // This will be enhanced when we add inter-process MCP call forwarding. return []; } /** * Discover MCP tool names from a live session's active tools. * MCP tools typically have names containing "__" (e.g., "mcp__filesystem__read_file"). */ export function discoverMcpToolNames(activeToolNames: string[]): string[] { return activeToolNames.filter( (name) => name.startsWith("mcp__") || name.startsWith("mcp-") || (name.includes("__") && !name.startsWith("submit_result")), ); } /** * Build MCP proxy config from a real Pi SDK session's active tools. * This is the preferred way — inspect what the parent session has available. */ export function buildMcpProxyFromSession(activeToolNames: string[], options?: { shareMcp?: boolean }): McpProxyConfig { const mcpTools = discoverMcpToolNames(activeToolNames); return buildMcpProxyConfig({ parentMcpTools: mcpTools, shareMcp: options?.shareMcp, }); } /** * G2 (SDD-2 W-B): the role-permission signal that decides `shareMcp` at the * spawn sites. MCP servers carry the parent's credentials and can mutate * state far outside the workspace, so only WRITE-capable roles may discover * them. Read-only roles (explorer/reviewer/security-reviewer/analyst/ * critic/planner) are denied; unknown and undefined roles resolve to * read-only via `permissionForRole`'s FIND-12 default-deny and are denied * too. AgentConfig has no `mcp` field — this permission classification is * the existing explicit signal (same source `filterActiveTools` uses). */ export function mcpPermittedForRole(role: string | undefined): boolean { return permissionForRole(role ?? "") !== "read_only"; } /** * Identify the extensions that provide MCP connectivity in pi ≥0.99: * - `builtin:mcp` — the built-in MCP extension (reads `mcp.json`); * - third-party extensions that REPLACE it by registering `/mcp` — pi's own * docs name `pi-mcp-adapter` as the canonical replacer (docs/mcp.md: * "replaces the built-in MCP support"). Both must be dropped to actually * deny MCP; a path-segment match keeps npm-installed and file-path forms. * * KNOWN LIMITATION (name-denylist gap — CLOSED at the assert layer, * security-audit run-1 N1): this remains a NAME-based denylist, so a * third-party MCP adapter that registers `/mcp` under a different name * (e.g. `mcp-bridge`) is NOT stripped here and would re-enable discovery * for non-permitted roles. The root allowlist fix is now layered on top: * after the child session is created on the legacy `DefaultResourceLoader` * route, the live-session runtime runs the same-shaped post-creation assert * the shared-services fast path runs (`foreignMcpExtensionsAgainstAllowlist` * below, fed by `allowedLiveExtensionPaths`) and FAILS the task for a * non-MCP-permitted role when ANY extension survived the strip — a * differently-named adapter now hard-fails (`McpForeignExtensionError`) * instead of silently loading and connecting its servers. Permitted roles * are unaffected (the strip is not armed for them); the no-loader * degradation path already fails closed via `g2EnforcementDegradationReason` * + `shouldFailClosedMcpEnforcement`. */ export function isMcpExtensionPath(extensionPath: string): boolean { if (extensionPath === "builtin:mcp") return true; return extensionPath.includes("pi-mcp-adapter"); } /** * G2 enforcement-degradation detector (security-review follow-up): the * live-session runtime enforces the role MCP policy by stripping MCP * extensions through `DefaultResourceLoader`'s `extensionsOverride` hook. * If the SDK stops exporting `DefaultResourceLoader` (version drift) or the * agent dir cannot be resolved, that strip silently degrades to fail-open * and a non-permitted role would re-inherit every parent MCP server with * no diagnostic. Callers should emit a warning event when this returns a * reason instead of `undefined`. */ export function g2EnforcementDegradationReason(input: { mcpPermitted: boolean; resourceLoaderAvailable: boolean; }): "sdk_loader_missing" | undefined { if (input.mcpPermitted) return undefined; return input.resourceLoaderAvailable ? undefined : "sdk_loader_missing"; } /** Structural stand-in for the SDK's `LoadExtensionsResult` (loose typing — * the live-session runtime holds the SDK behind `Record`). */ export interface LoadedExtensionsShape { extensions: Array<{ path: string; [key: string]: unknown }>; errors?: unknown; warnings?: Array<{ path: string; warning: string }>; runtime?: unknown; [key: string]: unknown; } /** * `DefaultResourceLoader` `extensionsOverride` hook (G2 enforcement on * pi ≥0.99): drop every MCP-providing extension from the child's extension * list so a non-permitted role neither loads the MCP code nor connects to * any server. Stripped paths are recorded as warnings for diagnostics. */ export function stripMcpExtensions(base: LoadedExtensionsShape): LoadedExtensionsShape { const kept = base.extensions.filter((extension) => !isMcpExtensionPath(extension.path)); if (kept.length === base.extensions.length) return base; const stripped = base.extensions.filter((extension) => isMcpExtensionPath(extension.path)); return { ...base, extensions: kept, warnings: [ ...(base.warnings ?? []), ...stripped.map((extension) => ({ path: extension.path, warning: "stripped by pi-crew G2 role MCP policy (role is not MCP-permitted)", })), ], }; } /** * R1-M2 part 2 (Wave-3): pure fail-closed decision — when G2 MCP enforcement * could not be armed (SDK loader missing / degraded), a NON-PERMITTED role * must ABORT the live-session spawn instead of warn+proceed (fail-open); * permitted roles keep the warn+proceed behavior (their access is intended). */ export function shouldFailClosedMcpEnforcement(mcpPermitted: boolean, degradationReason: string | undefined): boolean { return degradationReason !== undefined && !mcpPermitted; } /** * N1 (security-audit run-1): the allowlist-shaped control the KNOWN * LIMITATION block above called the root fix — given the extension paths a * child session ACTUALLY loaded and the sanctioned allowlist, return the * foreign/offending entries. * * Semantics are MIRRORED EXACTLY from the shared-services fast path's * post-creation assert in live-session-runtime.ts (`allowedLiveExtensionPaths` * + `collectForeignExtensions`): an extension is foreign iff its path is not * a member of the allowlist — no name heuristics, no substring matching. For * a non-MCP-permitted role the allowlist is empty, so ANY loaded extension * (including a differently-named MCP adapter that survives * `stripMcpExtensions`) is foreign; for a permitted role the allowlist is * exactly `builtin:mcp`. `collectForeignExtensions` delegates here so both * live-session routes share ONE implementation of these semantics. */ export function foreignMcpExtensionsAgainstAllowlist( loadedExtensionPaths: readonly string[], allowedExtensionPaths: readonly string[], ): string[] { const allowed = new Set(allowedExtensionPaths); return loadedExtensionPaths.filter((extensionPath) => !allowed.has(extensionPath)); } /** * N1 (security-audit run-1, fail-closed): thrown by the live-session * runtime's post-creation assert on the LEGACY `DefaultResourceLoader` route * when the child session loaded extension(s) not sanctioned for a * non-MCP-permitted role (e.g. a differently-named MCP adapter that survived * `stripMcpExtensions`). Mirrors `LiveSessionEmbeddedEnforcementError`'s * pattern: plain fields assigned in the constructor body (strip-types * rejects TS parameter properties), a stamped `name`, and a `[]`- * prefixed message consistent with the G2 fail-closed style. */ export class McpForeignExtensionError extends Error { readonly foreignExtensions: string[]; readonly allowedExtensions: string[]; constructor(input: { foreignExtensions: readonly string[]; allowedExtensions: readonly string[]; role: string | undefined }) { super( `[mcp-allowlist-fail-closed] Live-session spawn refused for role "${input.role ?? "unknown"}": child session loaded extension(s) not sanctioned for a non-MCP-permitted role: ${input.foreignExtensions.join( ", ", )} (allowed: ${input.allowedExtensions.length === 0 ? "none" : input.allowedExtensions.join(", ")}) — failing closed instead of silently running with them.`, ); this.foreignExtensions = [...input.foreignExtensions]; this.allowedExtensions = [...input.allowedExtensions]; this.name = "McpForeignExtensionError"; } }