/** * [TOC MCP seam, R3] — project the TOC client's local * `.mcp.json` (`TaskRequest.mcpServers`) into the engine, so a `sema` run honors the user's own MCP servers * (CC-parity "配置即用" local-first), instead of ONLY the center-config MCP. Mirrors the `cwd` seam (task-cwd.ts). * * 🔒 SECURITY — per-request MCP is the constitutional "untrusted-client tool injection" red line, because an MCP * server is a TOOL SOURCE and an stdio transport is **arbitrary command execution**. The decisive fact (verified): * core materializes stdio MCP with `StdioClientTransport`, which spawns the command as a child of **the WORKER * (core's process), NOT the execution env** — so an stdio MCP runs on the worker host REGARDLESS of the exec lane * (host/e2b/docker). Therefore the trust boundary is NOT "the host execution lane" (that's where the agent's TOOL * CALLS run, irrelevant to MCP) — it is **"is the worker single-user, i.e. is the requester the super-admin?"** = * `requirePrincipal !== true`. core's ruling (R3): the SERVICE is core's caller-trust-root; body→`TaskSpec.mcp` is a * trust decision the service makes, never a passthrough. * - SINGLE-USER deployment (`requirePrincipal===false`): the requester IS the super-admin of their own worker, so * their `.mcp.json` is authoritative — exactly what CC does on the user's machine. This holds across EVERY * execution lane (host / E2B / k8s / local-docker), so a TOC user who switches execution local↔cloud keeps their * MCP honored seamlessly (TOC↔cloud is a spectrum, not a wall — clay 2026-06-29: TOB = single-user + people-mgmt). * The caller being the trust root, `toolAxes` (authoritative effect overrides) + `elicitation` are their own choices. * - MULTI-TENANT deployment (`requirePrincipal===true`): a tenant must NOT make the SHARED worker run an arbitrary * command (unrestricted exec) or connect to a body-chosen URL (SSRF). Gate CLOSED → body MCP ignored; the fleet gets MCP only * through center config refs. Per-principal http-remote injection (a center `allowMcpInjection` entitlement via * `runtimeCapsResolver`) is a DEFERRED v2. * When the gate is closed, a sent `mcpServers` is IGNORED (advertised via `capabilities.mcpInjection`). */ import { type CompliancePosture, type EngineNotice, type LockedKey, type McpInjectionDropReason, type McpServerSpec } from "@sema-agent/core"; /** Bound on caller-supplied servers (anti-DoS; a real local `.mcp.json` has a handful). */ export declare const MAX_REQUEST_MCP_SERVERS = 32; /** * 🔴 **一类丢弃至多点名多少条**(codex 对抗复审 r1 [high],验真后采纳)—— 与挂载上限**同一个数**, * 刻意不新造一个可调旋钮:点名的量级本就该与挂载的量级同阶。 * * 病(本批引入,不是既有形):`MAX_REQUEST_MCP_SERVERS` 管的是「挂几台」,管不住「你发了几台」—— * HTTP 体上限 8 MiB 装得下十万个名字,而 `gate_closed`(整只列表被忽略)与 `over_cap`(上限之后的尾项) * 两臂原本逐条点名,于是一条已认证请求就能造出十万条事实、十万条通告,逐条排进本腿的**串行账本写链** * ——同一条链上后续的正常事件全部排在它们后面。修前的名字数组把超上限折成**一条**聚合串,所以无界的 * 是本批新写的那两个循环。 * * 为什么不改成「数组太长就 400」(codex 的另一条建议,**未采纳**):今天 33 台的请求是**照常受理、挂前 * 32 台**的,加一条新的拒绝面为了一个资源边界去改受理语义,爆炸半径比病本身大(而且 resume 腿重放存量 * body 会当场砖死一批已 park 的任务——本仓 `opts.leg` 三处先例逐字写着「砖死 resume 比降级糟」)。 * 有界点名两头都占:该说的仍然说得出,量级钉死在与挂载同阶。 */ export declare const MAX_REQUEST_MCP_DROP_NAMES = 32; /** * S-481 —— 一条 `mcpServers[]` 条目在 wire 上的**键闭集**,与它两种传输各自的键闭集。 * * ## 属主为什么在这里 * 这个文件就是「请求腿的 MCP 条目形」的家({@link normalizeServer} 逐键读的正是这些)。S-481 的探测口 * ({@link import("./capabilities/mcp-probe.js").readMcpProbeBody})要的是**同一个**词表 —— 它在自己那边 * 再写一份就是一份手抄投影([ref]:任何仓第一排查点),而两份哪天分岔,窄的那一份会把**合法**的键当成 * 拼写错误 400 掉。 * * ## 🔴 两向钉在 core 的 `McpServerSpec` 上(不是手抄) * 下面六道编译期门:型上多一个键而表没跟 ⇒ 红;表里写一个型上没有的词(改名 / 拼错 / 删键后的残条目) * ⇒ 也红。于是这三张表是从 core 的型**派生**出来的数据,而不是誰记得去同步的清单。 * * ## 两个消费点的处置**刻意不同**(同一张表,不同的门) * · 提交腿({@link normalizeServer})对词表外的键**宽容**(读不认识的键就当没有)—— 那条腿要能安全地 * 重放存量 body,一个 4xx 打在 resume 上会永久砖死一条 parked 任务(本仓三处 `opts.leg` 先例); * · 探测口**响亮拒**(400,点名 `mcpServers[i].<键>`)—— 它问的是「这台到底能不能用」,把一个拼错的键 * 悄悄忽略再回一份好看的名单,正是那条面要根除的假答案。 * 两个门,一个词表。表在这里,门在各自的腿上。 */ export declare const MCP_SERVER_ENTRY_KEYS: readonly ["allowTools", "contentOrigin", "elicitation", "name", "source", "toolAxes", "toolFaces", "transport"]; /** `transport.kind:"stdio"` 的键闭集(两向钉在 core 那一支上)。 */ export declare const STDIO_MCP_TRANSPORT_KEYS: readonly ["args", "command", "env", "kind"]; /** `transport.kind:"http"` 的键闭集(同上)。 */ export declare const HTTP_MCP_TRANSPORT_KEYS: readonly ["headers", "kind", "principalHeader", "url"]; /** 条目层:`{}` 与缺席等价的可选 Record 键。 */ export declare const MCP_ENTRY_EMPTY_RECORD_KEYS: readonly ["toolAxes", "toolFaces"]; /** 条目层:`[]` 与缺席等价的可选数组键。 */ export declare const MCP_ENTRY_EMPTY_ARRAY_KEYS: readonly ["allowTools"]; /** 传输层(两种合起来):`{}` / `[]` 与缺席等价的键。 */ export declare const MCP_TRANSPORT_EMPTY_RECORD_KEYS: readonly ["env", "headers"]; export declare const MCP_TRANSPORT_EMPTY_ARRAY_KEYS: readonly ["args"]; /** * Does THIS deployment honor caller-supplied per-request MCP servers? TRUE for a SINGLE-USER deployment * (`requirePrincipal !== true`) — the requester is the super-admin of their own worker, so their `.mcp.json` is * authoritative (CC-parity), on ANY execution lane (the stdio command runs on the worker, not the exec env, so the * lane is irrelevant — see module header). FALSE on a multi-tenant deployment (a tenant can't make the shared worker * run an arbitrary command / connect to an arbitrary URL). */ export declare function mcpInjectionHonored(config: { requirePrincipal?: boolean; lockedConfigKeys?: readonly LockedKey[]; compliancePosture?: CompliancePosture; }): boolean; /** * L-167(core 7.9.1)—— 一条**没有被挂上**的请求腿 MCP 条目的**事实**。宿主报事实,core 铸词: * `reason` 的词表属主是 core(`McpInjectionDropReason` = malformed_entry / name_reserved_by_deployment / * gate_closed / over_cap),本仓一个词都不枚举,通告的码/受众/句子一个字都不拼。 * * `sessionId` **不在**这个形上:它是通告的**路由半场**,在铸点(`mintInjectionDropNotices`)才补 —— * 一条丢弃事实在没有会话语境的调用方(桩、单测、未来的非 HTTP 腿)那里同样成立,而通告不成立。 */ export interface McpInjectionDrop { readonly server: string; readonly reason: McpInjectionDropReason; /** `malformed_entry` 专用:**哪个键**判不出形。结构性给出(读器在拒的那一行就知道),不从文案里 parse。 */ readonly field?: string; } /** L-167:本条腿的用户面通告席。`sessionId` = 路由半场(`auth.sessionId`,与 `spec.sessionId` 同源); * `emit` = 收下 core 铸好的 `EngineNotice`。**本模块不知道**它最后落在 SSE、账本还是日志上 —— 那是 * 投递腿的事,本模块只负责「事实 → core 工厂 → 一条通告」。 */ export interface McpInjectionDropSeat { readonly sessionId: string | undefined; readonly emit: (notice: EngineNotice) => void; } /** * Shape-check the raw `body.mcpServers`. `null` → a non-array (caller error; the route may 400). Otherwise the * valid subset + the names of dropped (malformed/over-cap) entries + [ref]'s `badContentOrigin` * (**降级**掉的内容出处声明,与 `dropped` 是两回事:那条 server **留着**,丢的只是它的声明)。 */ export declare function validateRequestMcp(raw: unknown): ValidatedRequestMcp | null; type ValidatedRequestMcp = { ok: McpServerSpec[]; dropped: McpInjectionDrop[]; droppedTotal: number; badContentOrigin: Array<{ server: string; value: string; }>; }; /** * [ref] 件C §4.3 —— **同步**拒面:部署锁了 `mcp` 而请求仍带 `mcpServers` ⇒ 400 `config.locked_key`。 * * 为什么在这里也拒一次(与 core 的 prepare 门不是「两处裁决」):设计稿把裁决点定在 server 治理层, * core 的 preflight 执行的是**同一条**判据的引擎侧终局形。二者语义等价、位置不同 —— 一个请求根本不该 * 进 core 才发现自己被锁,而消费端拿到一条同步 4xx 比拿到一个跑起来又终态失败的任务可诊断得多。 * * 判据是「请求**占位**」而不是「请求的值合不合法」:锁是两态的,占了就整拒,不静默丢(静默丢正是 * [ref] §2.3 讲的那种把判决变成探针的形)。键缺席 / 显式 `null` 都不是占位。 * * ⚠️ **不看车道**:多租户腿本来就会忽略 body MCP(`mcpInjectionHonored` 关),但锁在场时忽略是错的 * 失败方向 —— operator 声明了「本部署不收任务自带 MCP」,那就该说出来,而不是安静地当没看见。 * * ⚠️ **resume 腿同拒,这是刻意的**(与本仓「4xx 会砖存量 task」的一般姿势有意分歧):锁上之后,一条存量 * 任务的 body 里带着 mcpServers 就再也过不了 core 的 preflight —— 放它进去只会换来一个跑起来又终态失败的 * 任务。同样是失败,同步 400 至少是**立刻可诊断**的那一种,而且拒得出理由(锁在哪、怎么改)。 */ export declare function assertRequestMcpUnlocked(bodyMcp: unknown, lockedKeys: ReadonlySet): void; /** * [ref](core 5.60.0 提货批件②)—— 请求体里 `mcpServers[].contentOrigin` 的**值域门**,腿首同步拒。 * * ## 先把 core 的硬条款抄下来,因为本腿的全部争议都在它上面 * `McpServerSpec.contentOrigin` 顶注逐字:「TRUST SOURCE — a DEPLOYMENT-plane key … any assembly layer * that accepts REQUEST-side MCP entries **must reject or strip this key** from them … A single-tenant * superuser surface (a host reading its own `--mcp-config` file) **IS the deployment plane and needs no * such gate**.」 * * ## 本仓怎么兑现这条义务(**结构性**,不是靠纪律) * 请求腿的条目只有在 {@link mcpInjectionHonored} 为真时才被读进 `TaskSpec.mcp`,而那只谓词的语义正是 * 「本部署是单用户 ⇒ 请求者就是这台 worker 的超管」—— core 点名豁免的那一格。多租户部署 * (`requirePrincipal===true`)整只 `mcpServers` 被忽略,租户**结构上**够不着这个键(钉见 * `test/task-mcp.test.ts` 的多租户格)。同一条腿今天已经透传 `toolAxes`(喂**审批门**,权限比本键**高** * 一档 —— core 明写 contentOrigin「no gate/policy/roster effect」)与 stdio 的**任意命令**(worker 宿主 * 上的真无限制执行);在那两样之后,「我声明我自己这台 server 的内容不算外来」不构成任何新的暴露面。 * 名字撞车时 baseline 恒赢(`mergeRequestMcp`)⇒ 调用方也改写不了一台**已配置** server 的声明。 * * ## 为什么 fresh 腿是 400 而不是静默丢键 * 静默丢键会把一次**信任边界声明**变成探针 —— 调用方以为声明生效了,其实没有。本仓 `opts.leg` 的成文 * 契约(`ServiceDeps.resolveSpec` 顶注)逐字写着 fresh 腿「may fail-loud 400 on combos that would * silently discard caller payload」,这正是那种 combo。 * * ## 🔴 但只在 fresh 腿(与 {@link assertRequestMcpUnlocked} **刻意分歧**) * 锁那条敢在 resume 腿同拒,是因为它有一个本门**没有**的前提:锁在场 ⇒ core 的 preflight 无论如何都会 * 拒这条腿,放进去只换来一个跑起来又终态失败的任务。而坏 `contentOrigin` 在本仓两个平面都被**降级**掉了 * (`normalizeServer` / `resolveMcpServers`),core 根本看不到它,也就永远不会拒。 * 与此同时 `checkpoint_ctx` 存的是**原始** `TaskRequestBody`,耐久 resume 逐字重放它,从本门逃出去的 * `HttpError` 会被 `src/http/server.ts` 折成 `409 resume_blocked_by_policy` ⇒ 一条**升级前** park、body 里 * 带着当时被静默忽略的坏值的任务,升级后**永久**恢复不了(codex 交叉复审 R1-[high],验真后采纳)。 * ⇒ 照本仓成文的三处 `opts.leg` 先例(`appendSystemPrompt` 案 / `@model` 名单 [ref] 终裁 (a) / * `compactionModel` 案,都逐字写着「砖死 resume 比降级糟」):fresh fail-loud,resume 降级 + 留声 * (留声点 = {@link resolveRequestMcp} 的 `mcp_content_class_dropped`)。挂点判据在 `boot/resolve-spec.ts`。 * * ## 位置与射程 * 排在腿首、`assertRequestMcpUnlocked` 旁边 —— **任何副作用之前**(附件绑定 / per-session cwd·env 写入; * 理由逐字见 `boot/resolve-spec.ts` 那两行的注)。判据是**纯值域**、与**租户车道**无关:一个写坏的请求 * 在哪条车道上都是写坏的,而多租户腿本来就要忽略整只数组,告诉调用方「你这个键写错了」只多不少。 * 形不合(非数组 / 条目非对象)**不归本门**——那是 `validateRequestMcp` 的既有宽容形,本门不抢裁决。 */ export declare function assertRequestMcpContentOrigin(bodyMcp: unknown): void; /** * The ONE predicate for "this MCP server name belongs to the deployment": the scenario baseline (what mounted for * this scenario) ∪ every name the deployment DECLARED (`CenterMcp.reservedNames` — mounted or not, any scenario). * A caller can ADD servers but can never SHADOW or REFILL a deployment-declared one: a center entry that was skipped * (disabled / missing env ref / malformed `toolFaces`) still occupies its name, otherwise "skipped" would silently * mean "free for the caller to remount without the operator's `allowTools` / `toolFaces`" (车CF codex r2 [high]). * Same posture as `mergeUserSkills`. */ export declare function deploymentClosedMcpNames(baseline: McpServerSpec[] | undefined, reservedNames: readonly string[]): ReadonlySet; /** * Merge GATED per-request MCP servers OVER the deployment baseline (center/config). Names closed by the deployment * ({@link deploymentClosedMcpNames}) are dropped from `gated`. `reservedNames` has NO default: a security predicate's * input is never implied — a caller with no deployment declaration passes `[]` and says so. Returns the baseline unchanged * when nothing is added. */ export declare function mergeRequestMcp(baseline: McpServerSpec[] | undefined, gated: McpServerSpec[], reservedNames: readonly string[]): McpServerSpec[] | undefined; /** * The single entry point the spec-builder calls: compute the effective `TaskSpec.mcp` = deployment baseline + the * caller's gated per-request servers. Off the honored lane (or with no caller servers) returns the baseline * unchanged. Logs an `mcp_injection_dropped` (reason `gate_closed`) when a caller SENT servers a closed gate dropped (so the shell never * silently believes its `.mcp.json` took effect — it also reads `capabilities.mcpInjection`). */ export declare function resolveRequestMcp(baseline: McpServerSpec[] | undefined, bodyMcp: unknown, config: { remoteExec?: { provider?: string; }; requirePrincipal?: boolean; lockedConfigKeys?: readonly LockedKey[]; /** The deployment's declared-name set rides on the same config object the gate reads (one owner, no second arg). */ mcpServers?: { reservedNames: readonly string[]; }; }, logger?: { info?(m: string, meta?: unknown): void; warn(m: string, meta?: unknown): void; }, /** L-167:本条腿的**用户面**投递口。`sessionId` = 通告的路由半场(缺席 ⇒ 一条都不铸,见 * {@link mintInjectionDropNotices});`emit` 收下 core 铸好的通告,由调用腿决定投到哪(见 `boot/resolve-spec.ts`)。 * 整段缺席(桩、单测、非 HTTP 腿)⇒ 只走日志终点,行为逐字与本件之前相同。 */ notice?: McpInjectionDropSeat): McpServerSpec[] | undefined; export {}; //# sourceMappingURL=task-mcp.d.ts.map