/** * [DESIGN-269 车1 §2.2/§2.3 — A2A client seam] — project a caller's per-request A2A peers * (`TaskRequest.a2aPeers`) into the engine, so a `sema` run can talk to the agents the USER declares, * not only the ones the deployment's config center declares. Point-for-point mirror of `task-mcp.ts` * (the MCP seam), because the trust question is the same question — and deliberately NOT a copy where * the two protocols differ (see 「A2A 比 MCP 窄」 below). * * 🔒 SECURITY — an A2A peer is a remote **AGENT**, not a data source: it acts on its own side. core ruled * (2026-08-03, `A2aServerSpec` doc) that every skill such a peer advertises mounts with `egress: true` + * `effect: "write"`, i.e.接入即引入对外写通道 — it registers the approval gate, is never auto-allowed on a * no-policy deployment, and its replies reach the model inside an untrusted-data fence. The trust boundary * is therefore the SAME one the MCP seam draws, for the same reason: * - SINGLE-USER deployment (`requirePrincipal !== false`… i.e. `!== true`): the requester IS the * super-admin of their own worker, so the peers they name are their own choice (CC-parity), on EVERY * execution lane — an A2A call is a plain outbound HTTPS request made by the worker, so the lane where * the agent's TOOL CALLS run is irrelevant (exactly the fact that decoupled the MCP gate from the lane). * - MULTI-TENANT deployment (`requirePrincipal === true`): a tenant must NOT make the SHARED worker POST * to a body-chosen URL (SSRF) carrying body-chosen headers, nor mount write-capable tools from an agent * the operator never vetted. Gate CLOSED → body peers ignored; the fleet gets peers only through center * config (`config.a2aPeers`). * When the gate is closed a sent `a2aPeers` is IGNORED — and SAID (`a2a_injection_ignored` + the * `capabilities.a2aInjection` advertisement), never silently swallowed. * * ⚠️ **A2A 的轴比 MCP 窄,这不是简化而是协议事实**:A2A has no per-skill annotation vocabulary, so there is * no server-hint leg to fold — `toolAxes` (the CALLER's own judgement) is the ONLY thing that can move an axis * off the fail-closed default. core says as much on `A2aToolAxis`: `irreversibility:"never"` / `egress:false` * 「appears ONLY via an explicit caller override」, enforcement ignores the loosening (tighten-only) and the * ask's risk-axes REPORT face consumes it. So this module carries the caller's overrides through verbatim — * they are a judgement, not silence — while knowing that core will not let them widen enforcement. * * ⚠️ **两票判据的词表在 core 5.36 尚未到货**(分单件 C-1/C-2,core 已认领排 5.37 后首个 A2A 窗): * `LockedKey` 是 `"mcp"|"toolPolicy"|"compliancePosture"|"retentionPolicy"`,`ComplianceCapability` 是 * `"mcp_servers"|"workflows"|"web_fetch"|"org_memory_mount"` —— 两张表里都还没有 A2A 的位。本模块因此按 * **字符串**判(server config 的 `lockedConfigKeys` 本就是 server 自己的声明面,不经 core 闭集型), * 判据一到货就换 core 真源、钉子逐字不用改。 * 🔴 **今天这两票在 env 通道上还打不响**,而这是刻意如实记下的、不是被忽略的缺口:`LOCKED_CONFIG_KEYS` * 经 core 的 `resolveLockedKeys` 校验(未知键拒启)、`COMPLIANCE_ADDITIONAL_DENIES` 经 `COMPLIANCE_CAPABILITIES` * 校验(同拒),所以运维今天**写不进** `a2a` / `a2a_peers`。而且合规那一票还有第二层:即便有人跳过 boot 期 * 校验把 `a2a_peers` 塞进档位,`resolveComplianceDenies` 对闭集外的词是**抛**(亲测 core 5.36),于是这条 * 请求腿的失败方向是**响亮**(500)而不是安静放行 —— 方向正确,故本模块不加任何兜底。两票的代码先在场是 * 纵深:core 词表到货那一拍这条腿自己就活了,不需要有人记得回来补;反过来(等词表到了再写判据)才是 * 「广告了却拦不住」的那一类缺口。钉在 `test/task-a2a.test.ts`(含到货后该怎么改那一格的原地说明)。 */ import { type A2aServerSpec, type CompliancePosture } from "@sema-agent/core"; /** Bound on caller-supplied peers (anti-DoS; a real declaration names a handful of agents). */ export declare const MAX_REQUEST_A2A_PEERS = 32; /** The deployment-facing shape this module reads. `lockedConfigKeys` is deliberately `readonly string[]` * and not core's `LockedKey[]`: the `a2a` member does not exist in core's union yet (see module header), * and a predicate that cannot even NAME the key it guards is a predicate that silently guards nothing. */ export interface A2aGateConfig { requirePrincipal?: boolean; lockedConfigKeys?: readonly string[]; compliancePosture?: CompliancePosture; } /** * Does THIS deployment honor caller-supplied per-request A2A peers? Three vetoes, ALL of them here (this * predicate is the single owner — the capability bit and the request leg both read it, so there is exactly * one place where the answer can be wrong): * ① the deployment locks the `a2a` key — a locked deployment that advertised `a2aInjection:true` would * hand every consumer an affordance whose every use 400s (`config.locked_key`); "says yes ⟺ route * works" breaks on the spot. Same conjunction, same reason, as `mcpInjectionHonored`'s lock veto; * ② the compliance posture denies `a2a_peers` — advertised-but-refused again; * ③ multi-tenant (`requirePrincipal === true`) — a tenant cannot point the shared worker at an agent. * * ⚠️ **`resolveComplianceDenies` is deliberately NOT wrapped in try/catch** (same ruling as the MCP twin): * it is fail-loud on a posture outside the closed set, and the posture has already been validated twice * before reaching here (config parse + boot assembly). If it ever throws here, someone bypassed both doors * and the LOUD direction (route 500) is the correct one — swallowing it into a boolean would let a * deployment with a broken posture keep advertising the capability (仓规:安全轴禁静默兜底). */ export declare function a2aInjectionHonored(config: A2aGateConfig): boolean; /** * Shape-check the raw `body.a2aPeers`. `null` → a non-array (caller error; the route may 400). Otherwise the * valid subset + the names of dropped (malformed/over-cap) entries. */ export declare function validateRequestA2a(raw: unknown): { ok: A2aServerSpec[]; dropped: string[]; } | null; /** * DESIGN-269 §2.4 —— **同步**拒面:部署锁了 `a2a` 而请求仍带 `a2aPeers` ⇒ 400 `config.locked_key` * (逐字沿用 `assertRequestMcpUnlocked` 的裁定,连错误码都同一个 —— 消费端判的是「某个键被行政锁住了」, * 不是「哪个键」;码分裂只会让 SDK 多写一条等价分支)。 * * 判据是「请求**占位**」而不是「请求的值合不合法」:锁是两态的,占了就整拒,不静默丢(静默丢正是把 * 判决变成探针的那一形)。键缺席 / 显式 `null` 都不是占位;**空数组是占位**(调用方确实写了这个键)。 * * ⚠️ **不看车道**:多租户腿本来就会忽略 body peers(`a2aInjectionHonored` 关),但锁在场时「忽略」是 * 错的失败方向 —— operator 声明了「本部署不收任务自带 A2A peer」,那就该说出来。 * * ⚠️ 参数类型是 `ReadonlySet`:core 的 `LockedKey` 尚无 `a2a` 成员(见模块头),而 * `ReadonlySet` 结构上可当 `ReadonlySet` 传入,所以调用点无需任何转换。 */ export declare function assertRequestA2aUnlocked(bodyA2a: unknown, lockedKeys: ReadonlySet): void; /** * Merge GATED per-request peers OVER the deployment baseline (center/config). The baseline WINS on a name * clash — a caller can ADD a peer but can never SHADOW a configured one. This is load-bearing on THIS lane * specifically: the peer name is the tool-namespace segment (`a2a____`), so shadowing would let * a caller keep the tool NAMES the model was told about while swapping the agent behind them. * Returns the baseline unchanged when there is nothing to add. */ export declare function mergeRequestA2a(baseline: A2aServerSpec[] | undefined, gated: A2aServerSpec[]): A2aServerSpec[] | undefined; /** * The single entry point the spec-builder calls: compute the effective `TaskSpec.a2a` = deployment baseline * (`a2aForScenario(config.a2aPeers, scenario)`) + the caller's gated per-request peers. Off the honored lane * (or with no caller peers) returns the baseline unchanged. Logs `a2a_injection_ignored` when a caller SENT * peers a closed gate dropped, so a shell never silently believes its declaration took effect (it also reads * `capabilities.a2aInjection`). */ export declare function resolveRequestA2a(baseline: A2aServerSpec[] | undefined, bodyA2a: unknown, config: A2aGateConfig, logger?: { info?(m: string, meta?: unknown): void; warn(m: string, meta?: unknown): void; }): A2aServerSpec[] | undefined; //# sourceMappingURL=task-a2a.d.ts.map