收窄 Provider handoff 绝对路径校验边界
按已知工具参数语义检查可执行路径字段 允许 plan.submit_gdd 的 GDD 与决定内容包含普通斜杠文本 移除未知字段绝对路径的过宽旧测试断言 补充路径校验边界排障记忆
This commit is contained in:
@@ -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<ToolPlanAbsolutePathFinding>,
|
||||
) {
|
||||
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<ToolPlanAbsolutePathFinding>,
|
||||
) {
|
||||
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<ToolPlanAbsolutePathFinding>,
|
||||
) {
|
||||
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<ToolPlanAbsolutePathFinding>,
|
||||
) {
|
||||
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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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`。
|
||||
|
||||
|
||||
Reference in New Issue
Block a user