0. 先给审计结论:input_required 是请求账本,不是一个布尔开关
很多系统第一次接入 MCP Tasks 时,会把 input_required 当成"前端暂时停一下"的展示状态:服务端返回一段提示,客户端让用户输入,再把输入拼回下一次请求。这个理解少了最关键的一层。按照当前 Tasks 扩展草案,任务在 input_required 状态下携带的是一组由服务器发出的、等待客户端兑现的 inputRequests;客户端随后通过 tasks/update 的 inputResponses 回答这些请求。每一个 key 都必须能够指回任务生命周期内唯一的一次服务器请求,不能被另一个请求复用,也不能因为轮询、重试或客户端重启而失去边界。
因此,input_required 不是一个没有历史的布尔字段,而是一个带有请求账本的中间状态。账本至少要回答六个问题:这组输入由哪个任务产生;由哪个主体、租户和客户端持有;请求 key 对应哪一次服务端请求;客户端提交的回答是否符合当时的 schema;该回答是否已经被消费;回答被接受之后,工具、队列、通知和外部写入是否只发生了一次。
本节后续的复现全部运行在作者自建的纯本地双租户夹具中,不监听端口、不连接第三方系统,主体、租户、任务指纹和金丝雀均为虚构值。还要特别区分两类字段:inputRequests、inputResponses、任务状态和通知语义来自 Tasks 扩展草案;schema_digest、owner binding、CAS、outbox、效果计数和审计事件是本文提出的应用层控制与证据字段,不应被误读为协议新增字段。34/34 与 10/10 表示夹具层不变量通过,不等价于对任意生产实现的安全背书。
本文把安全结论写成三个约束:
text
InputVisible(task, caller) =
protocol_ok
∧ capability_ok
∧ route_tenant == token_tenant
∧ owner_policy(task, caller)
InputAccepted(task, response) =
InputVisible(task, caller)
∧ task.status == input_required
∧ key_is_currently_outstanding
∧ schema_digest_matches
∧ ttl_and_version_valid
EffectOnce(response) =
InputAccepted(task, response)
∧ atomic_claim_won
∧ idempotency_record_absent
第一条约束解决"谁能看到等待输入的内容";第二条解决"什么回答可以推进状态";第三条解决"一个回答是否只会产生一次实际效果"。只校验 JSON 结构,不能替代归属授权;只返回空的成功响应,不能证明状态已经完成;只看到一个 200,不能证明没有重复工具调用。
| 审计对象 | 本文要证明的安全性质 | 失败时最危险的结果 |
|---|---|---|
inputRequests |
key 唯一、请求来源可追溯、schema 可验证 | 把旧请求当成新请求,或把敏感请求展示给错误主体 |
inputResponses |
当前主体有权提交,key 当前有效,回答只消费一次 | 重放、越权推进、重复工具调用 |
| 任务状态 | working、input_required、终态迁移受版本和并发约束 |
错误回答推进任务,取消后仍执行,状态回退 |
| 轮询与通知 | 只返回调用者有权看到的完整任务快照 | 轮询安全但通知泄露,或通知与查询结果不一致 |
| 副作用账本 | 拒绝发生在工具、队列、取消和通知之前 | 表面返回错误,实际已经执行高价值动作 |
修复是否成立,只看四个可核验问题
把"已经修复"写成一句结论很容易,把它写成可复核证据则必须落到字段和时间顺序上。评审时可以用下面四个问题快速压缩范围:
| 问题 | 最小证据 | 合格条件 |
|---|---|---|
| 当前调用者能否看见这组请求? | issuer、audience、路由租户、owner_match、可见字段 |
归属不匹配时在加载对象前结束,外部响应不泄露存在性细节 |
| 当前调用者能否回答这个 key? | request_key_hash、schema_digest、status、version、TTL |
key 仍为当前 outstanding,schema 和版本匹配,且动作策略允许提交 |
| 回答能否只兑现一次? | CAS/唯一约束结果、claimed_at、outbox/效果记录、effects_delta |
只有一个竞争者取得消费权,重试不会再创建工具、队列或外部写入效果 |
| 重启或通知重放后是否仍安全? | 重新授权结果、订阅状态、旧 key 的 consumed/superseded 状态 |
恢复只恢复定位信息,不恢复旧权限;通知和轮询都重新做对象授权 |
这张表也是本文后续截图和回归的阅读索引:每一条攻击路径都同时观察响应、状态和效果,而不是只截取一个看起来成功的 HTTP 或 JSON-RPC 返回。
1. 先把规范语义讲清楚:任务、能力和输入机制不是一回事
1.1 服务端决定是否创建任务,客户端不能把能力声明当成授权
当前 Tasks 扩展的标识是 io.modelcontextprotocol/tasks。客户端通过每请求 _meta 中的能力对象声明支持扩展,服务器可以按请求决定返回普通结果还是 resultType: "task" 的任务结果。这里有两个容易被混淆的事实:第一,客户端声明能力只说明它能理解任务响应,不说明它可以读取、更新或取消所有任务;第二,服务器不是一看到能力声明就必须创建任务,而是按自己的策略决定是否把一次 tools/call 物化为持久任务。
这直接影响输入生命周期的审计入口。能力协商门禁负责回答"双方是否能使用这套语义";对象授权负责回答"当前主体是否能看到这个任务及其中的输入请求";状态门禁负责回答"此时能否提交这组回答"。三者不能合并成一个 tasks.manage 布尔值。
服务端还不能在任务尚未持久化到可查询状态前就把 taskId 返回给客户端。否则客户端拿到任务句柄后立即轮询,可能遭遇短暂的"任务不存在";更严重的是,网关、队列和缓存可能在不同时间看到不同版本的归属信息。对安全审计来说,"返回句柄时任务已经可以被 tasks/get 解析"不仅是可用性要求,也是归属、TTL 和初始状态必须已经落盘的证据。
1.2 五种状态不是五种权限
Tasks 草案定义了 working、input_required、completed、cancelled 和 failed 等状态。状态是任务当前的操作事实,不是调用者的权限集合。一个主体可以有权查询某个已完成任务,却没有权更新或取消它;另一个主体可能有产品明确声明的共享读取能力,但不能提交 inputResponses。因此审计矩阵不能写成"状态为 input_required,所以 update 允许",而应写成:
text
can_update =
authenticated
∧ extension_supported
∧ task_visible_to_caller
∧ action_scope_allows_update
∧ state == input_required
∧ response_keys_valid
其中任何一项失败,都必须在副作用之前结束。状态机只回答"现在允许哪一类迁移",不回答"谁有资格请求迁移"。
1.3 两种输入机制必须分开:任务内输入与创建前多轮往返
在 MCP 生态中,输入可能出现在两个时间点。第一种是任务创建前的多轮往返:服务器在原始请求返回前需要用户确认或补充资料,客户端继续原来的请求流程;第二种是任务已经创建之后,执行过程中进入 input_required,服务器把 inputRequests 放进 tasks/get 或任务通知,客户端用 tasks/update 提交 inputResponses。
两种机制的安全边界不同。创建前的输入通常决定"是否创建任务、是否继续原操作";任务内的输入则决定一个已经持久化的对象如何继续执行。若兼容层把两者都转换成一个内部 resume(payload) 函数,却不携带 phase、task_id、request_key 和授权上下文,就会出现把创建前确认当成任务内回答、把旧 key 当成新 key 的混淆。
1.4 空响应是确认,不是最终状态
tasks/update 和 tasks/cancel 成功时可以返回空结果确认。这个确认只表示服务端接受了请求,不表示客户端立刻通过 tasks/get 看到最终状态,也不保证取消一定已经完成。输入更新可能先写入事件表,再由工作器消费;取消可能只是记录协作式取消意图,实际工作在另一个线程或队列中继续一小段时间。
客户端和测试代码必须把三件事分开断言:请求响应是否符合契约;任务可观察状态是否按预期变化;工具调用、队列、通知和外部写入的效果计数是否符合预期。只断言第一项,最容易把"接口返回 200 但重复执行"误判为成功。
1.5 用脱敏消息流固定"请求、回答、确认"三层边界
下面的缩写示例按当前 Draft 的字段形状整理,taskId、key、提示文本和回答均为虚构值。它不是可以直接发送到生产服务的攻击请求,而是帮助评审把协议字段和应用层审计字段对齐:
jsonc
// tasks/get 返回的 input_required 快照
{
"resultType": "complete",
"taskId": "task:local-7f21",
"status": "input_required",
"inputRequests": {
"confirm-release-01": {
"method": "elicitation/create",
"params": {
"mode": "form",
"message": "Confirm the local test action.",
"requestedSchema": {
"type": "object",
"properties": { "approved": { "type": "boolean" } },
"required": ["approved"]
}
}
}
}
}
// 客户端通过 tasks/update 提交一个当前 outstanding key
{
"method": "tasks/update",
"params": {
"taskId": "task:local-7f21",
"inputResponses": {
"confirm-release-01": {
"action": "accept",
"content": { "approved": true }
}
}
}
}
// 成功确认:不等于任务已经完成
{ "resultType": "complete" }
审计时,第一段要落成输入账本,第二段要经过主体、租户、key、schema、TTL 和版本校验,第三段只能作为 ack 记录。随后必须继续通过 tasks/get 或 notifications/tasks 观察状态和效果。schema_digest、owner_match、CAS 结果和 effects_delta 不属于上述线协议字段,而是服务端为了证明授权与副作用顺序而补充的内部证据。

图 1 输入生命周期审计模型:能力、身份、对象、状态和副作用共同决定一次回答能否被兑现
2. 威胁模型:输入回答从哪里来,谁可以让任务继续
2.1 参与者与资产
本文使用两个虚构租户和三个虚构主体:Alice 属于 tenant-alpha,Bob 属于 tenant-beta,Carol 是同租户的另一名普通主体。任务由 Alice 创建,执行一个需要人工确认的虚拟工具;工具不会连接网络,只会把一个固定金丝雀写入内存效果账本。Bob 持有自己的合法令牌,但通过模拟共享日志、追踪字段或模型上下文获得 Alice 的任务指纹。Carol 用于验证"同租户"不能被默认解释成"同一所有者"。
资产不只有最终结果,还包括:等待用户输入的原始请求、请求 schema、任务状态、工具参数摘要、外部资源 URI、通知订阅、队列消息、取消意图、审计事件和效果计数。某些输入请求本身就可能包含高价值信息,例如"是否批准发布""请提供生产数据库凭据""请选择要删除的对象"。即使最终结果经过脱敏,错误主体看见这些待回答问题也可能造成业务和隐私风险。
2.2 攻击者不需要伪造令牌
威胁模型故意给 Bob 一枚正确发行、未过期、受众正确且带有 tasks.manage 的合法令牌。这样可以排除最容易的认证问题,观察对象授权和状态授权是否独立存在。Bob 的能力包括:提交任意自己可构造的 taskId、轮询任务、尝试订阅通知、重试 tasks/update、改变 inputResponses 中的 key 顺序和提交时机。Bob 不能修改服务端签发的主体、租户或令牌,也不能直接访问 Alice 的内存表。
一个真正危险的兼容层,可能已经做对了令牌发行方、受众、有效期、scope 和路由租户检查,却仍然执行如下逻辑:
python
def update_task(request, context):
task = store.get(request.params["taskId"])
validate_input_responses(task, request.params["inputResponses"])
enqueue_resume(task, request.params["inputResponses"])
return {"resultType": "complete"}
如果 store.get 是全局按 ID 查询,validate_input_responses 只检查 key 和 schema,攻击者就能用自己的合法身份推动别人的任务。这里没有令牌伪造,没有猜 ID,也没有管理员权限;问题在于输入回答被当成普通业务数据,而不是带有主体和对象边界的控制请求。
2.3 四条主要攻击路径
第一条是请求重放:客户端因网络超时没有收到空确认,于是再次提交同一个 key;服务端每次都把回答送入工作器,产生两次工具调用。第二条是未知或过期 key:服务端为了"兼容旧客户端"接受任意 key,并把它当成当前等待的回答,导致攻击者可以跳过原始请求的确认流程。第三条是部分更新误完成:客户端只回答一个 key,服务端却把所有 outstanding key 标记为已消费,任务在缺少必要条件时继续执行。第四条是跨租户更新:服务端对 tasks/get 做了归属判断,却让通知订阅或 tasks/update 进入不带 owner 的内部恢复函数。
这些路径看起来分散,根因却相同:缺少一个可持久化的输入账本,或者账本存在但没有被所有入口共同使用。
2.4 现场审计应先问什么
对一个实现进行黑盒或灰盒审计时,建议按以下顺序提问:
- 客户端没有声明 Tasks 能力时,服务端是否仍返回
resultType: "task",或是否明确返回缺少能力错误? - 任务创建响应返回前,任务的 owner、租户、工具、参数摘要、TTL 和初始状态是否已经可查询?
tasks/get、tasks/update、tasks/cancel和subscriptions/listen是否走同一个对象授权内核?inputRequests的 key 是否在整个任务生命周期内唯一,并且是否能在审计事件中追溯到原始请求?inputResponses对未知、已消费、被替换和部分提交的 key 分别如何处理?- 空确认返回后,客户端是否仍能观察到正确的状态和效果计数?
- 取消意图、更新回答和工作器执行发生竞争时,谁拥有唯一状态迁移权?

图 2 协议与输入动作矩阵:能力声明、对象授权和状态允许必须分别留下证据
3. 输入账本:把一段回答变成可验证的安全对象
3.1 最小记录不能只有 key 和 value
一个可审计的输入请求记录至少应包含以下字段:
json
{
"task_id_hash": "hmac:8d4b...",
"request_key": "confirm-release-01",
"request_method": "elicitation/create",
"owner_subject": "alice",
"owner_tenant": "tenant-alpha",
"client_id": "client-a",
"schema_digest": "sha256:4f2a...",
"request_digest": "sha256:9a17...",
"status": "outstanding",
"issued_at": "2026-08-23T10:00:00Z",
"consumed_at": null,
"version": 7
}
原始 taskId 可以在外部日志中用 HMAC 指纹替代,但服务端内部仍需保存不可变归属。request_key 是匹配字段,不是授权字段;schema_digest 用于确认回答对应的是当时的请求形状;request_digest 用于发现兼容层或队列在传递过程中被篡改;version 用于 CAS 或等价的原子迁移。
3.2 key 唯一性是状态机不变量
Tasks 草案要求一个任务生命周期内的输入 key 唯一。实现不能在任务重试、工作器迁移、数据库分片或服务重启时重新生成同名 key。若原始请求 key 是 confirm-release,后续再次请求确认必须产生 confirm-release-2 或新的不可碰撞标识,并在服务端记录它与前一请求的关系。否则客户端在连续轮询中无法区分"同一个尚未回答的问题"和"新的、内容相似的问题"。
唯一性还影响去重。客户端可以因为通知重复、轮询重叠或进程恢复多次看到同一个 inputRequests,但这不应让 UI 每次都重新弹窗,也不应让服务端为同一个 key 创建多个待消费记录。客户端的展示去重和服务端的消费幂等是两个不同层次,不能用其中一个代替另一个。
3.3 schema digest 防止回答错配
只检查 inputResponses[key] 存在并不够。服务端应该在签发请求时保存规范化 requestedSchema 的摘要;消费回答时加载同一版本的 schema,验证回答的类型、必填字段、枚举、长度、数量和业务范围,并单独计算规范化回答的 response_digest。对于复杂表单,还要防止客户端把一个旧版本的 schema 送到同一个 key 上。建议将 schema 版本或摘要和 key 一起纳入幂等键:
text
idempotency_key = HMAC(
task_id || request_key || schema_digest || response_digest
)
这不是为了把摘要当成秘密,而是为了让"同一个 key 的同一个回答重试"和"同一个 key 的不同回答篡改"在审计记录中可区分。服务端仍必须以任务归属、当前状态和动作授权为先,不能因为摘要相同就跳过授权。
审计日志可以采用下面这种脱敏的内部事件格式。它不是 Tasks 线协议的新增字段,而是服务端为了证明"拒绝发生在效果之前"而保存的最小证据:
json
{
"event": "input_response_decision",
"decision": "deny",
"reason": "schema_mismatch",
"task_hash": "hmac:8d4b...",
"request_key_hash": "hmac:2c91...",
"owner_match": true,
"state_before": "input_required",
"state_after": "input_required",
"effects": {
"tool_calls_delta": 0,
"queue_delta": 0,
"notifications_delta": 0
},
"correlation_id": "corr:7f21..."
}
其中 reason 应使用稳定的内部枚举,避免把完整输入或资源 URI写入日志;state_after 和效果增量用于发现"接口拒绝但后台已经入队"的时间倒置;correlation_id 则把请求、工作器和通知串成一条可追溯链。对外响应可以统一,内部审计不能因此丢失定位信息。
3.4 输入对象与资源对象要二次授权
回答里可能包含资源 URI、文件路径、工单编号或工具参数。即使提交者拥有任务,也不代表他可以让任务读取回答中指向的任意资源。任务授权和资源授权要分开判断:前者确认谁可以推动任务,后者确认任务在推进过程中能否使用指定资源。把资源 URI 原样放入队列,再由高权限工作器直接读取,是一个常见的"输入已经合法,所以参数也合法"的错误。

图 3 输入账本证据:任务指纹、owner、租户、schema、outstanding key 和状态必须能够互相校验
4. 攻击一:重试变成重复消费,最终效果比响应更危险
4.1 典型脆弱实现
下面的伪代码在单线程演示环境中看起来很自然,但没有任何消费占有或幂等检查:
python
def accept_response(task_id, key, value):
task = tasks[task_id]
if task.status != "input_required":
raise Conflict("wrong_state")
if key not in task.input_requests:
raise BadRequest("unknown_key")
task.input_responses[key] = value
worker_queue.put((task_id, key, value))
return empty_ack()
网络超时发生在 worker_queue.put 之后、空确认返回之前时,客户端会认为请求失败并重试。第二次请求仍然通过所有条件,队列得到第二个相同任务。若工具是发送邮件、发布配置、创建云资源或变更权限,重复效果可能远高于一次结果泄露。
4.2 复现时必须记录四个时间点
一个有价值的复现截图不能只展示两次 200。应至少记录:服务端第一次观察到请求的时间;输入 key 被标记为占有的时间;工具或队列效果发生的时间;客户端收到确认的时间。若效果发生在占有之前,说明存在并发窗口;若效果发生在拒绝之后,说明队列或工作器没有尊重状态迁移;若两次请求都产生效果,说明幂等边界不完整。
本地夹具把 tool_calls_added 作为硬判据。脆弱对照中,同一个合法所有者的重复回答可能产生两次调用;修复实现只允许一个线程取得 key 的消费权,其他线程得到已消费或状态冲突,并且 tool_calls_added 不再增加。截图中的任务主体、租户和请求摘要均为虚构值。

图 4 重放探针:请求响应、key 状态和实际工具效果必须放在同一条时间线上判断
4.3 幂等不是简单缓存响应
把上一次响应缓存起来再返回,不能完全解决重复消费。缓存命中前可能已经再次入队;不同实例可能使用不同缓存;缓存 TTL 过期后同一回答又被执行;而且一个任务可能有多个不同 key,每个 key 的重复语义不同。更稳妥的做法是把"领取消费权"和"产生效果"绑定到同一个事务或可证明的原子协议:
- 在任务和输入账本上加条件:owner、状态、key、schema、version 必须全部匹配。
- 用 CAS、数据库唯一约束、租约或等价机制把 key 从
outstanding变成claimed。 - 只有成功领取的请求可以创建 outbox 事件或投递工作器消息。
- 工作器以幂等键写效果记录,重复消息只能得到已完成结果,不能再次调用外部工具。
- 事务提交后再返回空确认;异步效果通过状态和通知继续观察。
5. 攻击二:空确认与最终状态之间的时间差造成误判
5.1 eventual consistency 不是错误,但必须被测试
Tasks 草案明确允许 tasks/update 在服务端接受回答后先返回空确认,随后 tasks/get 或 notifications/tasks 才反映新状态。这种 eventual consistency 对分布式系统是合理的,但它给客户端和测试带来两个陷阱。第一,客户端可能在看到空确认后立即再次提交同一个 key;第二,测试可能把"空确认成功"当作"任务已经完成",漏掉工作器失败、schema 不完整或资源授权失败。
正确的客户端逻辑应保存一个本地 pending 记录:task_hash、key、response digest、提交时间、服务端 ack 状态。收到空确认后,客户端可以继续轮询或等待通知,但不能把 key 当成可以再次回答;只有观察到 key 被服务端标记为 consumed,或任务进入终态,才可以清理本地记录。若任务最终失败,清理策略也要能区分"回答已消费但工具失败"和"回答根本没有被接受"。
5.2 部分回答不能偷偷完成任务
一个任务可能同时等待 confirm-release 和 select-target 两个 key。客户端先回答其中一个,服务端可以接受部分 inputResponses,但任务仍应保持 input_required,直到必要的剩余请求完成。实现若把收到的回答数量与 outstanding 数量简单比较,或者在一次更新中先清空整个 inputRequests,就会出现部分更新误完成。
可以把这个过程写成一张小型状态账本,避免用"收到过回答"代替"所有必需回答已消费":
| 时点 | outstanding keys | consumed keys | 允许的状态迁移 |
|---|---|---|---|
| 初始等待 | confirm-release、select-target |
无 | 保持 input_required |
| 只回答确认 | select-target |
confirm-release |
仍为 input_required |
| 两项都回答且校验通过 | 无 | 两项均已记录 | 通过 CAS 进入 working 或按业务进入终态 |
| 第二项回答过期或 schema 不符 | 原 key 仍有效或转为 superseded |
只记录合法项 | 保持等待或要求重新确认,不得执行旧回答 |
表中的 key 是虚构示例;真正的必需集合应由服务端任务策略决定,并在审计事件中记录版本。这样既允许草案所述的部分更新,也不会把一个合法回答误当成整组授权。
建议把请求状态逐 key 保存:
text
outstanding = {
confirm_release: pending,
select_target: pending
}
after partial update:
confirm_release: consumed
select_target: pending
task.status: input_required
effects_added: 0
只有满足业务必需 key 集合、schema 校验和资源授权后,状态才可以从 input_required 迁移到 working 或 completed。不要把"所有收到的 key 都合法"误写成"任务已经具备继续执行的全部条件"。
5.3 并发更新需要唯一赢家
两个客户端可能同时回答同一个 key:用户界面的一次点击被重试,模型和人工同时提交,或者一个旧工作器在恢复后继续发送。若两个请求都先读取 outstanding,再分别写入 consumed,最后都进入队列,就会有双重效果。测试应使用双线程或双进程,在同一版本号和同一 key 上同时发起请求,并断言只有一个请求取得成功迁移权。

图 5 状态竞争:空确认、可观察状态和实际效果是三个不同时间点
5.4 客户端恢复不能恢复权限
客户端重启后可以从持久存储恢复 taskId、轮询间隔和未完成的 input key,但不能从本地缓存恢复"我仍然拥有这个任务"的结论。账号切换、令牌刷新、权限撤销和租户切换都要求重新建立授权上下文。尤其不能把旧会话中保存的完整 inputRequests 直接交给新主体展示或回答。
6. 攻击三:未知 key、被替换 key 与 schema 混淆
6.1 忽略未知 key 不等于忽略整个请求
Tasks 草案允许服务端忽略当前不 outstanding 的回答,包括从未发出、已经回答或被 superseded 的 key。这个语义的目的是帮助客户端安全重试和处理状态竞争,但实现仍然需要清楚记录:请求主体是否有权更新任务;请求中是否同时包含合法和未知 key;合法 key 是否已经被消费;忽略未知 key 是否会影响空确认和状态迁移。
一个危险实现是:发现请求中有一个未知 key,就把整个请求交给"兼容旧协议"的宽松路径;另一个危险实现是:为了返回 400,把请求先写入统一输入表,再异步清理未知 key。前者扩大了输入面,后者把拒绝变成了晚拒绝。安全策略可以选择"未知 key 忽略并保持状态"或"统一返回输入冲突",但无论选择哪一种,都必须保证未知 key 不会消费合法 key、推进版本、触发工具或发出通知。
6.2 schema 错误必须在效果之前暴露
schema 校验不应只验证 JSON 类型。需要检查字段是否缺失、额外字段是否被允许、字符串长度、枚举范围、资源 URI、目标租户、时间窗口和业务条件。对于确认类输入,还要避免把任意非空字符串当成同意;对于选择类输入,要验证候选项仍然属于服务器发出的那一版请求;对于资源类输入,要重新走资源授权,而不是信任客户端回传的"已授权"标志。
错误信息也要分层。对外可以使用统一的 invalid_input_response、input_schema_mismatch 或 task_not_found,避免暴露任务归属和内部字段;对内审计要保留 key 指纹、schema digest、版本前后值、决策原因和效果计数。日志不能记录完整回答,尤其不能记录用户输入中的令牌、密码或个人数据。
6.3 被替换的请求不能复用旧回答
如果任务在等待期间更新了业务上下文,例如目标环境从 staging 变为 production,服务端可能需要使旧的输入请求失效并签发新 key。旧回答即使结构完全相同,也不能直接用于新请求。服务端应把旧 key 标成 superseded,保留替换关系,并在消费时检查当前 key 的状态。客户端收到新通知时,应以最新的完整 DetailedTask 为准,不能把旧缓存和新状态做无条件合并。

图 6 输入校验与效果账本:未知 key、schema 冲突和已消费回答都不能穿透到工具层
7. 攻击四:跨租户回答与通知泄露
7.1 tasks/get 安全不代表 tasks/update 安全
对象授权必须在每个入口独立执行。常见的迁移漏洞是:开发者给 tasks/get 增加了 owner 条件,却让 tasks/update 继续调用旧的 resume_task(task_id, responses);或者 HTTP 网关通过 Mcp-Name 把请求路由到正确实例后,内部服务只相信这个路由结果,不再检查当前主体。Mcp-Name == params.taskId 解决的是传输路由一致性,不解决"谁可以回答"。
修复后的查询应把归属条件放进第一次读取,而不是先按 taskId 全局取出对象再判断:
sql
SELECT task_hash, owner_subject, owner_tenant, status, version
FROM task_input_ledger
WHERE task_id_hash = :task_hash
AND owner_subject = :verified_subject
AND owner_tenant = :verified_tenant
AND client_id = :client_policy
AND status = 'outstanding';
外租户、同租户不同主体和同主体不同客户端的拒绝策略可以不同,但必须是产品明确声明的策略,不能由"查到了对象"隐式决定。对外统一不可见能减少存在性 oracle;对内仍需用 owner_mismatch、tenant_mismatch、client_policy_mismatch 等原因分类支持排障。
7.2 通知携带完整任务快照,泄露面更大
当前扩展允许通过 subscriptions/listen 订阅任务状态,notifications/tasks 携带与 tasks/get 同等完整的 DetailedTask。这意味着通知不是"只发一个状态字符串"的低风险通道,而是可能携带 inputRequests、最终 result、error 和资源 URI 的主动推送。订阅建立时检查一次权限还不够,任务归属变化、权限撤销、租户切换、任务进入终态和通知重试都需要被纳入策略。
不要在消息总线里只保存裸 taskId 和订阅者连接。更好的事件载荷包含订阅建立时的主体摘要、租户、客户端策略、任务指纹、策略版本和过期时间;工作器取到事件后重新判断订阅是否仍有效,再决定是否发送完整任务或脱敏版本。通知失败重试也必须具备幂等和撤销语义,不能因为队列重放而把已撤销任务继续推送。
7.3 订阅能力是通信能力,不是对象共享能力
如果一个客户端声明支持 Tasks,它只是能够解析任务通知;这不表示它能订阅任意 taskId。订阅接口应至少检查:能力是否声明;令牌是否有效;路由租户是否一致;任务是否属于主体;通知范围是否包含该动作;当前任务是否允许推送;结果和输入字段是否需要脱敏。对外拒绝时不要返回"该任务属于 Alice",内部事件则保留足够信息定位哪一层失败。

图 7 通知边界:订阅请求、持续授权和完整任务快照必须共享同一对象策略
8. 取消与恢复:协作式语义下的状态竞争
8.1 取消是意图确认,不是强制终止
Tasks 草案把 tasks/cancel 定义为客户端发送的取消意图。服务端可以先返回空确认,任务状态随后仍然短暂保持 working,也可能因为工作已经完成而最终进入 completed,不保证一定进入 cancelled。因此,测试不能把"收到取消响应"直接等同于"工具已经停止"。
安全边界仍然要在收到取消意图时成立:只有有权操作任务的主体才能写入取消意图;外租户请求不能让工作器停止、不能写入补偿队列、不能发取消通知,也不能改变任务的版本。合法取消与工具执行之间的竞争由服务端决定,但必须可观察、可解释并具备幂等语义。
8.2 update 与 cancel 谁先赢
任务处于 input_required 时,所有者可能同时提交回答和取消任务。两个动作都合法,但不能让一个回答在取消成功后继续进入工具,也不能让取消覆盖已经完成的外部效果却不留下记录。建议把二者建模为同一个状态版本上的互斥迁移:
text
working(v7) --cancel claim--> cancelling(v8)
input_required(v7) --update claim--> working(v8)
only one claim may commit for the same (task_id, version)
worker must re-check state before external effect
outbox event includes transition_id and idempotency_key
如果 update 先赢,工作器可以继续,但取消请求要得到"已接受/已完成/已过晚"的明确分类;如果 cancel 先赢,后到的回答只能返回终态冲突,不能重新打开任务。不要通过简单的最后写入覆盖状态解决竞争,这会让状态表看似一致而效果账本重复。
8.3 崩溃恢复不能回到未经授权的旧状态
工作器在领取输入后崩溃,恢复进程可能从队列重放事件。恢复流程应先读取任务当前版本、输入 key 状态、幂等记录和 owner policy,再决定是否继续。若任务 TTL 已过、权限已撤销、key 已被另一实例消费或状态已经终止,恢复应安全退出,并记录不产生外部效果的原因。
对于跨实例系统,可以使用事务 outbox、租约、数据库唯一约束或带 fencing token 的工作器。关键不是选哪一种技术,而是要证明"同一回答最多产生一次外部效果",以及"恢复过程不会因为旧消息携带了曾经有效的身份和状态就自动获得当前权限"。

图 8 取消与恢复边界:空确认、最终状态和工作器效果必须分别观测
9. 修复方案:从输入账本到可恢复执行管线
9.1 统一 loader,先建立安全上下文再加载对象
服务端可以把所有任务管理入口收敛到一个 owner-bound loader。它接收已验签的主体、租户、客户端策略、动作和任务句柄;它不接受请求体中自报的 owner 或 tenant;它返回脱敏后的对象视图和当前版本,或者返回统一不可见错误。get、update、cancel、订阅和兼容层都必须调用它,不能为某个入口保留"内部快捷路径"。
loader 的顺序建议如下:
- 解析协议版本、方法、扩展能力和参数形状。
- 验证令牌发行方、受众、有效期、撤销状态、主体和动作 scope。
- 验证 Streamable HTTP 的
Mcp-Name与params.taskId一致,并确认路由租户来自可信上下文。 - 使用主体、租户、客户端策略和 task 指纹作为查询条件读取任务与输入账本。
- 检查任务状态、TTL、版本、key、schema 和请求摘要。
- 只有全部通过后,才创建 outbox、队列、工具调用、取消意图或通知事件。
9.2 输入消费的伪代码
python
def update_task(ctx, task_id, responses):
auth = verify_request(ctx, action="tasks.update")
task = load_visible_task(auth, task_id)
with transaction() as tx:
ledger = tx.lock_input_ledger(task.id, task.version)
if task.status != "input_required":
return conflict("task_not_waiting")
validate_partial_responses(ledger, responses)
claim = tx.claim_outstanding_keys(
task.id,
responses.keys(),
expected_version=task.version,
)
if not claim.won:
return conflict("input_key_consumed_or_version_changed")
transition = tx.record_input_consumption(
task.id, claim.keys, response_digest(responses)
)
tx.enqueue_outbox(transition.idempotency_key, task.id)
tx.commit()
return empty_ack(result_type="complete")
这里的 empty_ack 只表示事务已经提交。工作器消费 outbox 时仍然要再次读取任务状态和输入消费记录;外部工具调用完成后,结果写入也要使用同一幂等键。若结果资源需要独立授权,工作器不能把任务 owner 直接当成资源 owner。
9.3 效果账本要能回答"拒绝之前发生了什么"
每次请求至少保存以下结构化字段:case_id、协议版本、方法、主体摘要、路由租户、任务指纹、输入 key 指纹、schema digest、状态前后值、决策、对外错误类、内部原因类、工具调用增量、取消增量、通知增量、outbox 增量和 correlation id。原始 taskId、令牌和完整回答不应进入普通日志或模型上下文。
效果账本不是为了给测试增加格式,而是为了捕获"拒绝太晚"。例如外部响应是 404,工具调用计数却从 0 变成 1;或者通知没有发出,但队列已经写入了一个可执行消息。没有效果计数,审计只能证明某个接口返回了什么,不能证明系统做了什么。
9.4 修复验收:拒绝与可用必须同时成立
安全修复不能用"所有请求都返回错误"伪装成功。Alice 的合法任务应能正常轮询、回答、恢复和完成;Bob 的外租户请求应在所有入口保持不可见;Carol 的同租户不同主体应按产品策略得到明确允许或拒绝;并发提交应只有一个赢家;旧 key、未知 key、错误 schema 和过期版本不能触发效果。

图 9 修复证据:对象授权、key 占有、schema 校验和效果计数在副作用前形成闭环
10. 回归设计:34 个用例要覆盖行为,而不是只覆盖函数
10.1 研究方法与五个维度
本文采用配对对照方法:同一组虚构主体、租户、任务、输入 key 和效果账本,分别交给脆弱适配器与修复适配器处理;两次运行只改变授权、状态迁移和幂等控制,不改变请求材料。这样可以把"攻击是否发生"和"修复是否仍保持正向可用"放在同一实验基线下比较。
每个场景固定采集三层证据:响应层记录 JSON-RPC/HTTP 结果和内部决策码;状态层记录任务状态、outstanding/consumed key、版本和 TTL;效果层记录工具调用、队列、取消、通知、缓存、资源读取和 outbox 的增量。只有三层同时符合预期才算通过,截图只负责展示关键切片,最终判断以脚本输出和效果账本为准。
五个覆盖维度
第一维是主体与租户:外租户、同租户不同主体、同主体不同客户端、错误发行方、错误受众、过期令牌和撤销权限。第二维是输入:正确 key、未知 key、已消费 key、被替换 key、缺失回答、额外字段、错误 schema、部分回答和重复回答。第三维是状态:working、input_required、completed、cancelled、failed、TTL 到期和版本冲突。第四维是并发:双线程 update、双线程 cancel、update 与 cancel 交叉、工作器重放和客户端恢复。第五维是效果:工具调用、取消、通知、队列、缓存、资源 URI 和审计事件。
每个用例至少有三条断言:响应层断言、状态层断言、效果层断言。例如"外租户提交未知 key"不能只断言 404;还要断言任务仍为 input_required、key 没有被消费、工具调用为 0、通知为 0、队列增量为 0。对于"所有者部分回答",则要断言空响应符合契约、剩余 key 仍 outstanding、任务没有提前进入 working 或 completed。
10.2 本地回归结果
本文复用一个纯本地、无网络依赖的适配器,生成虚构任务和内存效果账本。脆弱对照复现三条路径:外租户读取完成结果、外租户取消运行中任务、外租户提交 inputResponses 推进受害任务。修复对照把任务绑定到发行方、主体、租户、客户端、工具和请求摘要,并在所有管理入口执行同一对象授权。
回归集合包含:能力缺失、未知任务、过期任务、身份异常、路由租户异常、同租户不同主体、输入 key 异常、schema 不匹配、终态取消、所有者正确更新、更新重放、双线程 update、双线程 cancel、拒绝后无工具调用以及结果不包含凭据等 34 个场景。输出中的 effects_added=0 不是装饰性字段,而是每一个修复后应拒绝场景的硬门槛。
评审者可以在隔离目录中用下面的顺序复现同一条证据链;每一步都先生成本地夹具,再读取结果,不需要网络、第三方 MCP 服务或真实令牌:
bash
python mcp_task_audit_lab.py --prepare
python mcp_task_audit_lab.py --vulnerable # 预期复现 3/3 条脆弱路径
python mcp_task_audit_lab.py --fixed # 预期 3/3 条外部操作在效果前被拒绝
python mcp_task_audit_lab.py --tests # 预期 SUMMARY 34/34 PASS
python mcp_task_audit_lab.py --stability # 预期 STABILITY 10/10 PASS
命令输出中的任务指纹和金丝雀只用于本地关联;发布截图时保留状态、原因和计数即可,不应把完整 taskId 或输入值复制到公开材料。
10.3 稳定性复测与证据保存
单次通过只能说明当前进程的一次执行。稳定复测每轮重建任务表和虚拟主体,重复脆弱路径、修复路径、所有者正向流程和并发场景,检查结果、状态和效果计数是否一致。本文完成 10/10 轮稳定复测;这不等价于证明所有真实分布式部署都安全,但可以排除大量依赖随机调度的偶然通过。
发布证据建议保存三层材料:一是人类可读的截图,展示关键状态和计数;二是机器可解析的 JSONL,保留决策和效果字段;三是回归脚本和固定运行命令,允许评审者在隔离环境复现。截图不能替代脚本,脚本也不能替代授权声明和脱敏检查。

图 10 回归证据:34/34 用例通过,10/10 轮稳定,输入、状态和效果同时满足验收条件
11. 可直接带进评审会的检查清单
11.1 协议与能力
- 记录 Tasks 扩展 URI、协议版本、支持的方法和
resultType语义。 - 客户端未声明能力时不会收到
resultType: "task";缺少能力时错误映射符合当前版本契约。 - 任务创建响应返回前,
taskId对应的任务已经持久化并可查询。 - Streamable HTTP 管理请求校验
Mcp-Name == params.taskId,但不把它当成对象授权。 - 旧实验版的
tasks/result、tasks/list和兼容入口已逐一盘点,没有隐藏的高权限路径。
11.2 输入账本
- 每个任务的
inputRequestskey 在整个生命周期内唯一,不因重启、重试和分片而复用。 - 输入记录保存 task 指纹、owner、租户、客户端、schema digest、request digest、状态、版本和 TTL。
-
inputResponses只能对应当前 outstanding key,未知、已消费和 superseded key 有明确策略。 - 服务端允许部分回答时,未完成的 key 仍保持 outstanding,任务不会提前完成。
- schema 校验覆盖类型、必填、枚举、长度、资源 URI 和业务范围,且发生在任何外部效果之前。
11.3 身份、对象与通知
-
get、update、cancel、subscriptions/listen和兼容层共享 owner-bound loader。 - 路由租户来自可信令牌或网关上下文,服务端忽略请求体自报的 tenant。
- 外租户、同租户不同主体、同主体不同客户端分别验证允许和拒绝结果。
- 通知携带完整
DetailedTask时,任务归属和结果字段再次通过策略检查。 - 权限撤销、账号切换、令牌刷新和订阅重试不会复用旧安全上下文。
11.4 状态、并发与效果
- update、cancel 和工作器恢复使用事务、CAS、锁、租约或等价原子机制。
- 同一 key、同一版本和同一 outbox 事件只有一个成功效果;重试不会重复调用工具。
- 空确认、可观察状态和最终效果分别断言,测试不把 200 当成完成。
- 取消被作为协作式意图测试,区分"已确认""最终 cancelled"和"工作已先完成"。
- 外租户拒绝时工具、队列、取消、通知、缓存和资源读取增量均为零。
11.5 证据与发布
- 每个用例保留响应、状态前后值、决策原因、输入 key 摘要和效果计数。
- 截图使用虚构主体和脱敏指纹,不包含真实令牌、账号、客户数据或生产 taskId。
- 文章说明实验边界、授权范围、规范版本和局限性,没有把本地夹具结果包装成真实产品漏洞。
- Markdown 上传后的 11 个图片位均已替换为平台图片,预览无 broken image。
- 正文中文字符数超过一万,标题、导读、分类和文章类型分别填写。
12. 迁移审计:旧 Tasks 经验不能直接复制到新输入模型
12.1 从 tasks/result 到 tasks/get
旧实验版和当前扩展在结果获取方法上存在差异。迁移时,开发者可能只把 URL 或方法名改为 tasks/get,却没有把旧结果接口的 owner、资源 URI、脱敏和错误处理一并迁移。输入生命周期审计应确认:完成结果、错误详情和输入请求都通过同一个任务对象授权;兼容接口不能因为"只读"而跳过归属检查。
12.2 tasks/list 移除不等于句柄安全
没有列表接口可以减少批量枚举面,但不能修复一个已经泄露的 taskId。旧前端、SDK、后台管理 API 或监控接口如果仍然提供"按主体列出所有任务",还要确认它们不会把不属于当前主体的 inputRequests 或结果带出来。更不能因为前端不再显示列表,就把后端的全局 ID 查询保留为内部快捷接口。
12.3 兼容层要记录 phase 和 policy version
输入机制、状态枚举和错误语义变化时,兼容层应记录请求来源版本、输入 phase、策略版本和 schema 版本。旧客户端提交的字段如果无法映射到当前 outstanding key,应安全失败或要求重新确认,而不是从请求正文推断"最相近的 key"。策略版本发生重大变化时,可以把旧任务标记为需要重新确认,不能在新权限模型下自动继承旧的隐式授权。
12.4 迁移回归必须包括正向可用性
只测试旧接口被拒绝,会把兼容层变成全量拒绝;只测试新接口返回 200,又可能遗漏旧路径的越权。对每个版本、客户端和服务端组合,记录创建、查询、输入、取消、通知、TTL、重试、失败恢复和资源读取的实际行为,并同时保存所有者成功案例。迁移验收的目标是"边界更清晰、功能仍可用",而不是"所有状态都不可操作"。
13. 严重性判断:输入越权要按效果而不是按接口名称定级
同样是一个 tasks/update 越权,影响可能从低价值的状态探测到高价值的生产发布。评估时建议至少看七个维度:提交者是否是普通账号;输入是否能从日志、模型上下文或通知中获得;回答能否改变工具参数、资源 URI 或审批结论;是否跨租户;任务是否持续时间长、可重复或可批量;通知和队列是否会扩大影响;权限撤销和 TTL 是否能及时止损。
如果攻击者只能看到一个没有敏感字段的等待状态,且没有任何效果,可能是信息暴露或存在性问题;如果攻击者可以回答一个审批 key,让任务以受害主体身份发布配置、读取秘密或改变权限,影响应按跨主体副作用评估;如果一次回答能被批量重放、通知扩散或队列重试放大,严重性还要考虑可扩展性和补偿成本。
报告中应把"响应错误""状态变化""实际效果"分开描述,避免把返回 200 或 403 本身当成影响。对外公开材料不应提供真实目标的 taskId、账号、域名、可复制请求或敏感输入;自建夹具和最小化证据足以帮助防守者理解修复位置。
为了让定级意见可被复核,可以先按效果做一个不依赖接口名称的优先级判断:
| 观察结果 | 建议处理优先级 | 判断依据 |
|---|---|---|
归属不匹配且 effects_delta=0,只返回统一拒绝 |
低:保留回归用例 | 边界已阻断,但仍需防止存在性、通知或错误差异泄露 |
| 能读取不属于当前主体的输入请求、结果或资源 URI | 中到高:优先修复 | 影响取决于字段敏感性、可枚举性和通知扩散范围 |
| 能提交他人任务的回答,但尚未观察到外部效果 | 高:立即阻断 | 已突破对象与动作授权,效果可能被队列或重试延迟放大 |
| 回答可让受害主体执行发布、读密、改权限等外部动作 | 严重:按生产影响响应 | 输入越权已经转化为跨主体副作用,应同时回收句柄、审计并验证补偿 |
| 同一 key 的重试或并发能产生多个外部效果 | 高到严重:按可重复性和规模上调 | 幂等边界失效,单次授权可能被批量放大 |
表中的"中到高""高到严重"不是替代组织内部 CVSS 或事件分级,而是提醒评审先确认实际效果,再决定是否需要紧急下线、撤销任务和清理队列。
14. 结语:回答一个问题,也是在请求继续拥有一段能力
MCP Tasks 把长时间运行的工具调用变成了可观察、可恢复的任务对象。进入 input_required 后,服务器不是简单地等待一段字符串,而是在等待一个有来源、有 key、有 schema、有归属和有生命周期的控制回答。回答一旦被接受,就可能改变工具参数、外部资源、审批流程、队列和通知;因此输入更新必须拥有与结果读取、任务取消同等严格的对象授权和状态审计。
本文的最后判断可以压缩成三句话:
text
inputRequests answer: what did the server ask, and when?
inputResponses answer: who may answer it, under which schema and version?
effect ledger answers: did that answer cause exactly one authorized effect?
只要其中一个问题没有可复核的字段、状态和效果证据,就不应把任务标记为安全。把输入请求做成账本,把消费做成原子迁移,把通知做成持续授权,把空确认与最终状态分开测试,才是异步任务在多租户智能体系统中可恢复、可审计、可上线的基础。
附录 A:最短复现与审计记录模板
A.1 运行环境
- Python 3.10 或更高版本。
- 不需要第三方 MCP 服务、数据库、网络或真实令牌。
- 每次运行前重建虚构主体、租户、任务表和效果账本。
- 输出中的 taskId 只作为本地测试值,不应复制到生产日志。
A.2 运行命令
bash
python mcp_task_audit_lab.py --prepare
python mcp_task_audit_lab.py --show-tasks
python mcp_task_audit_lab.py --vulnerable
python mcp_task_audit_lab.py --fixed
python mcp_task_audit_lab.py --tests
python mcp_task_audit_lab.py --stability
A.3 单条记录字段
text
case_id=
protocol_version=
method=
caller_subject_hash=
route_tenant=
task_hash=
request_key_hash=
schema_digest=
owner_match=
state_before=
state_after=
version_before=
version_after=
decision=
external_code=
internal_reason_class=
tool_calls_delta=
cancelations_delta=
notifications_delta=
outbox_delta=
correlation_id=
一条合格的外租户拒绝样本应证明:owner_match=false,外部不可见,状态不变,合法 key 未被消费,工具、队列、取消和通知效果均为零。一条所有者成功样本应证明:归属匹配、状态只迁移一次、回答摘要可追溯、效果数量与预期一致,并且重试不会产生第二个效果。
附录 B:常见"修复"为什么不够
| 表面修复 | 仍然缺什么 | 评审建议 |
|---|---|---|
| 只给 taskId 加随机数 | 日志、通知和模型上下文仍可能泄露句柄 | 高熵 ID 与 owner binding 同时验证 |
| 只要求用户登录 | 没证明对象属于谁、回答能改变什么 | 每个管理方法做对象授权 |
| 只校验输入是 JSON | key、schema、资源和业务范围可能错配 | outstanding + schema digest + 资源授权 |
| 收到回答后立即执行 | 重试和并发会重复产生效果 | 先原子领取,再 outbox/幂等执行 |
| 空响应后立即标记完成 | eventual consistency 和工作器失败被隐藏 | 分开断言 ack、状态和效果 |
| 订阅能力通过即可推送 | 完整 DetailedTask 可能越过对象边界 | 每个 taskId 重新授权并支持撤销 |
| 取消响应成功就停止一切 | 取消是协作式意图,不保证立即终止 | 检查最终状态、工作器和补偿语义 |