From c5a0705eff38cb31a1b65ced1390e3973011f6b2 Mon Sep 17 00:00:00 2001 From: Linghong Date: Thu, 27 Aug 2026 11:17:31 +0000 Subject: [PATCH] =?UTF-8?q?=E6=94=B6=E7=AA=84=20Provider=20handoff=20?= =?UTF-8?q?=E7=BB=9D=E5=AF=B9=E8=B7=AF=E5=BE=84=E6=A0=A1=E9=AA=8C=E8=BE=B9?= =?UTF-8?q?=E7=95=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 按已知工具参数语义检查可执行路径字段 允许 plan.submit_gdd 的 GDD 与决定内容包含普通斜杠文本 移除未知字段绝对路径的过宽旧测试断言 补充路径校验边界排障记忆 --- .../tool_plan_handoff/content_validation.rs | 198 +++++++++++++++++- .../src-tauri/src/tool_plan_handoff/tests.rs | 24 --- docs/project-memory/shared-memory/pitfalls.md | 1 + 3 files changed, 197 insertions(+), 26 deletions(-) diff --git a/apps/ai-game-creator-shell/src-tauri/src/tool_plan_handoff/content_validation.rs b/apps/ai-game-creator-shell/src-tauri/src/tool_plan_handoff/content_validation.rs index ec8727915..1a3c5772a 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/tool_plan_handoff/content_validation.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/tool_plan_handoff/content_validation.rs @@ -295,8 +295,19 @@ fn validate_tool_plan_json_absolute_path_inputs( value: &serde_json::Value, duplicate_safe_json: bool, ) -> Result<(), String> { + if tool_name == crate::agent_native_tools::PLAN_SUBMIT_GDD_FUNCTION_NAME { + // plan.submit_gdd 只承载 GDD/决定文本,没有可执行的文件路径字段。 + // 其中出现的斜杠、示例路径等属于用户内容,不应按工具路径扫描。 + return Ok(()); + } let mut findings = Vec::new(); - collect_tool_plan_absolute_path_findings(root, value, None, "#", &mut findings); + if let Some(runtime_tool) = native_tool_for_handoff_function(tool_name) { + collect_native_tool_absolute_path_findings(root, runtime_tool, value, &mut findings); + } else { + // 未知工具和 legacy tool-plan 没有可信字段 schema,继续保守扫描整个 + // arguments payload,避免在无法解释参数语义时放宽路径边界。 + collect_tool_plan_absolute_path_findings(root, value, None, "#", &mut findings); + } let Some(first) = findings.first() else { return Ok(()); }; @@ -313,6 +324,189 @@ fn validate_tool_plan_json_absolute_path_inputs( )) } +fn native_tool_for_handoff_function(tool_name: &str) -> Option<&'static str> { + crate::agent::agent_runtime_native_executable_tools() + .into_iter() + .find(|tool| { + crate::agent_native_tools::native_runtime_function_name(tool).as_deref() + == Some(tool_name) + }) +} + +fn collect_native_tool_absolute_path_findings( + root: &Path, + tool: &str, + arguments: &serde_json::Value, + findings: &mut Vec, +) { + let (input, input_pointer) = arguments + .get("input") + .map(|input| (input, "#/input")) + .unwrap_or((arguments, "#")); + match tool { + "project.search" | "file.list" => { + collect_native_string_field( + root, + input, + "path", + &format!("{input_pointer}/path"), + findings, + ); + } + "file.read" | "file.write" | "file.patch" | "file.delete" => { + collect_native_string_field( + root, + input, + "path", + &format!("{input_pointer}/path"), + findings, + ); + } + "project.patchset" => { + if let Some(changes) = input.get("changes").and_then(serde_json::Value::as_array) { + for (index, change) in changes.iter().enumerate() { + let pointer = format!("{input_pointer}/changes/{index}/path"); + collect_native_string_field(root, change, "path", &pointer, findings); + } + } + } + "project.git_commit" | "image.inspect" => { + collect_native_string_array_field( + root, + input, + "paths", + &format!("{input_pointer}/paths"), + findings, + ); + } + "command.exec" | "command.start" => { + collect_native_string_field( + root, + input, + "cwd", + &format!("{input_pointer}/cwd"), + findings, + ); + if let Some(args) = input.get("args").and_then(serde_json::Value::as_array) { + for (index, argument) in args.iter().enumerate() { + let pointer = format!("{input_pointer}/args/{index}"); + collect_native_string_value(root, argument, &pointer, findings); + } + } + } + "project.verify" => { + collect_native_string_field( + root, + input, + "expectedCommand", + &format!("{input_pointer}/expectedCommand"), + findings, + ); + } + "canvas.asset_generate" => { + collect_native_string_field( + root, + input, + "outputPath", + &format!("{input_pointer}/outputPath"), + findings, + ); + } + "ui.workflow.run" => { + if let Some(pages) = input.get("pages").and_then(serde_json::Value::as_array) { + for (index, page) in pages.iter().enumerate() { + let pointer = format!("{input_pointer}/pages/{index}/applicationPath"); + collect_native_string_field(root, page, "applicationPath", &pointer, findings); + } + } + } + "task.create" => { + collect_native_string_array_field( + root, + input, + "artifacts", + &format!("{input_pointer}/artifacts"), + findings, + ); + } + "agent.delegate" => { + collect_native_string_array_field( + root, + input, + "expectedArtifacts", + &format!("{input_pointer}/expectedArtifacts"), + findings, + ); + } + "agent.spawn_isolated" => { + if let Some(children) = input.get("children").and_then(serde_json::Value::as_array) { + for (index, child) in children.iter().enumerate() { + let pointer = format!("{input_pointer}/children/{index}/writeScopes"); + collect_native_string_array_field( + root, + child, + "writeScopes", + &pointer, + findings, + ); + } + } + } + // 其它原生工具的输入是文本、ID、枚举或计数,不承载文件路径。 + _ => {} + } +} + +fn collect_native_string_field( + root: &Path, + object: &serde_json::Value, + key: &str, + pointer: &str, + findings: &mut Vec, +) { + if let Some(value) = object.get(key) { + collect_native_string_value(root, value, pointer, findings); + } +} + +fn collect_native_string_array_field( + root: &Path, + object: &serde_json::Value, + key: &str, + pointer: &str, + findings: &mut Vec, +) { + if let Some(values) = object.get(key).and_then(serde_json::Value::as_array) { + for (index, value) in values.iter().enumerate() { + collect_native_string_value(root, value, &format!("{pointer}/{index}"), findings); + } + } +} + +fn collect_native_string_value( + root: &Path, + value: &serde_json::Value, + pointer: &str, + findings: &mut Vec, +) { + let Some(value) = value.as_str() else { + return; + }; + let Some(path_shape) = tool_plan_absolute_path_shape(value) else { + return; + }; + let relation_to_root = if matches!(path_shape, "exact-absolute" | "exact-platform-absolute") { + lexical_absolute_path_relation_to_root(root, value) + } else { + "not-applicable" + }; + findings.push(ToolPlanAbsolutePathFinding { + json_pointer: pointer.to_string(), + path_shape: path_shape.to_string(), + relation_to_root: relation_to_root.to_string(), + }); +} + fn collect_tool_plan_absolute_path_findings( root: &Path, value: &serde_json::Value, @@ -461,7 +655,7 @@ fn tool_plan_absolute_path_shape(value: &str) -> Option<&'static str> { fn tool_plan_function_class(tool_name: &str) -> String { if tool_name == crate::agent::AGENT_RUNTIME_TOOL_PLAN_FUNCTION_NAME { "legacy-tool-plan".to_string() - } else if let Some(tool) = super::ledger::runtime_tool_for_native_handoff_function(tool_name) { + } else if let Some(tool) = native_tool_for_handoff_function(tool_name) { format!("native:{tool}") } else if tool_name.starts_with("mcp_tool_") { "dynamic-mcp".to_string() diff --git a/apps/ai-game-creator-shell/src-tauri/src/tool_plan_handoff/tests.rs b/apps/ai-game-creator-shell/src-tauri/src/tool_plan_handoff/tests.rs index 5ea9ba07e..aee08077d 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/tool_plan_handoff/tests.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/tool_plan_handoff/tests.rs @@ -797,30 +797,6 @@ fn tool_plan_handoff_reports_file_uri_and_flattened_path_shapes() { "exact-absolute" }, ), - ( - serde_json::json!({ - "reason": "修复页面", - "opaqueProviderField": "/tmp/private.html", - }), - "#/field", - if cfg!(windows) { - "exact-platform-absolute" - } else { - "exact-absolute" - }, - ), - ( - serde_json::json!({ - "reason": "修复页面", - "12345678901234567890": "/tmp/private.html", - }), - "#/field", - if cfg!(windows) { - "exact-platform-absolute" - } else { - "exact-absolute" - }, - ), ] .into_iter() .enumerate() diff --git a/docs/project-memory/shared-memory/pitfalls.md b/docs/project-memory/shared-memory/pitfalls.md index 07eb4b5c1..89bad121d 100644 --- a/docs/project-memory/shared-memory/pitfalls.md +++ b/docs/project-memory/shared-memory/pitfalls.md @@ -4196,6 +4196,7 @@ - 现象:Provider 已返回 HTTP 200 并计费,tool-plan lifecycle 却只有 `started`,handoff 账本停在上一 loop,Runtime 进入 `needs-reconciliation`;重启 Runner 或 `/resume` 后仍原样被屏障阻断。 - 原因:在解析 function arguments 之前,对整段 `response.text` 和序列化 arguments 统一执行 `.env`、`game-creator.config` 等字面标记扫描。安全叙述如“无需读取 `.env`”,或 `oldText / newText / content / patch` 中的普通源码字面量,会在真实路径和内容字段尚未区分时被误判。原始响应未成功交接时不会留下正文,因此现场只能结合 loop 边界和最小复现定位,不能把高概率分支冒充已恢复的原响应证据。 - 处理:计划叙述与规范源码内容字段只检查真实密钥 token 形状、凭据头标记和不安全控制字符;结构化敏感 JSON key、非内容字段的配置痕迹和绝对路径、真实 token、容量、thinking、身份、顺序及账本完整性继续失败关闭。成功 handoff 失败时只在 Runtime event/state 和 Agent DB 保存受控 `failureKind`、脱敏错误 SHA-256、字符数与 requestId,禁止保存正文、arguments、密钥和绝对路径。 +- 路径边界补充:绝对路径校验按已知工具的参数语义执行,只检查 `path`、`paths`、`cwd`、`outputPath`、`changes[*].path`、`pages[*].applicationPath`、产物范围以及命令 `args` / `expectedCommand` 等可能影响文件访问或执行的字段;`plan.submit_gdd` 的完整输入属于 GDD/决定内容,不做文件路径扫描。未知工具、动态 MCP 和无法解析的 JSON 继续整体失败关闭,不能用普通内容字段白名单替代可信 schema。 - 验证:必须同时覆盖 narrative 和 `oldText / newText / content / html / patch` 提及 `.env` / `game-creator.config` 可 round-trip,`path=.env.local` 与 `sk-...` 真实 token 仍拒绝,全部 handoff 回归通过;诊断审计必须断言不存在 `error / response / arguments` 原文。修复后的外部 Provider 重试仍需新起独立轮次,不能与故障轮或确定性回归拼接为 PASS。 - 关联:`apps/ai-game-creator-shell/src-tauri/src/tool_plan_handoff/content_validation.rs`、`apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/provider_control.rs`、`apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/real_e2e_checkpoint.rs`。