From bc89bf45c37a3a69fcdf4fe4bd3d6def489f803a Mon Sep 17 00:00:00 2001 From: Suzumiya Date: Tue, 15 Sep 2026 18:19:33 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E5=B7=A5=E5=85=B7=E8=B0=83?= =?UTF-8?q?=E7=94=A8=E9=A1=B9=E7=9B=AE=E5=86=85=E8=B7=AF=E5=BE=84=E5=BD=A2?= =?UTF-8?q?=E7=8A=B6=E4=B8=8E=E6=AD=BB=E4=BB=A3=E7=A0=81=EF=BC=88P2?= =?UTF-8?q?=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - direct_tool_calls.rs:新增 project_relative_path_segment 与 relativize_project_root_paths, 在 redact_absolute_path_tokens 之前把项目根目录前缀换成项目相对路径(game/src/x.ts), Windows 的 \ 与 / 两种写法都匹配、大小写不敏感、分隔符统一成 / - 项目外绝对路径继续落成 占位;只写了项目根目录本身时也按占位处理 - 删掉永远匹配不到的死代码 without_secret.replace(root, "") (redact_absolute_path_tokens 已经把项目根目录换成 ,契约里的"项目相对路径"在绝对路径场景不成立) - 新增单测:tool_call_paths_become_project_relative(项目内相对路径 + 项目外占位 + 落盘不残留项目根目录) --- .../src-tauri/src/agent/direct_tool_calls.rs | 170 +++++++++++++++++- 1 file changed, 166 insertions(+), 4 deletions(-) diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tool_calls.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tool_calls.rs index 7be534b10..5f3cc5dee 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tool_calls.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tool_calls.rs @@ -86,10 +86,118 @@ fn tool_calls_path(root: &Path) -> PathBuf { root.join(".agent/conversations/tool-calls.jsonl") } -/// 脱敏:先抹绝对路径、再抹密钥前缀,最后走既有的错误上下文脱敏组合。 +/// 项目根目录之后的路径 token:分隔符统一成 `/`,返回 `(消费到的下标, 项目相对路径)`。 +fn project_relative_path_segment(value: &str, start: usize) -> (usize, String) { + let mut index = start; + let mut relative = String::new(); + while index < value.len() { + let character = value[index..].chars().next().unwrap_or_default(); + if matches!(character, '/' | '\\') { + if !relative.is_empty() { + relative.push('/'); + } + index += character.len_utf8(); + continue; + } + if character.is_whitespace() + || matches!( + character, + '\'' | '"' + | '`' + | ',' + | ';' + | '|' + | '&' + | '(' + | ')' + | '[' + | ']' + | '{' + | '}' + | '<' + | '>' + | ':' + ) + { + break; + } + relative.push(character); + index += character.len_utf8(); + } + while relative.ends_with('/') { + relative.pop(); + } + (index, relative) +} + +/// 把项目根目录前缀换成**项目相对路径**(`/game/src/x.ts` → `game/src/x.ts`)。 +/// +/// 必须排在 `redact_absolute_path_tokens` 之前:后者会把整个绝对路径抹成 +/// ``,之后就再也认不出哪些路径在项目内了。 +/// Windows 上同时匹配 `\` 与 `/` 两种分隔符写法,并按大小写不敏感比较(盘符大小写会变)。 +fn relativize_project_root_paths(root: &Path, value: &str) -> String { + let root_text = root.to_string_lossy(); + let root_text = root_text.trim_end_matches(['/', '\\']); + if root_text.is_empty() { + return value.to_string(); + } + let mut needles = [ + root_text.to_string(), + root_text.replace('\\', "/"), + root_text.replace('/', "\\"), + ] + .into_iter() + .map(|needle| needle.to_ascii_lowercase()) + .filter(|needle| !needle.is_empty()) + .collect::>(); + needles.sort(); + needles.dedup(); + let lower = value.to_ascii_lowercase(); + + let mut output = String::with_capacity(value.len()); + let mut cursor = 0usize; + while cursor < value.len() { + let mut hit: Option<(usize, usize)> = None; + for needle in &needles { + let mut search = cursor; + while let Some(relative) = lower[search..].find(needle.as_str()) { + let start = search + relative; + let end = start + needle.len(); + let left_is_boundary = start == 0 + || lower[..start].chars().next_back().is_some_and(|character| { + !character.is_alphanumeric() && character != '_' && character != '-' + }); + if left_is_boundary && value[end..].starts_with(['/', '\\']) { + if hit.is_none_or(|(best_start, _)| start < best_start) { + hit = Some((start, end)); + } + break; + } + search = end; + } + } + let Some((start, end)) = hit else { + break; + }; + output.push_str(&value[cursor..start]); + let (consumed, relative) = project_relative_path_segment(value, end); + if relative.is_empty() { + // 只写了项目根目录本身(没有后续路径段):按占位形状处理。 + output.push_str(""); + } else { + output.push_str(&relative); + } + cursor = consumed; + } + output.push_str(&value[cursor..]); + output +} + +/// 脱敏:项目内绝对路径先归一化成项目相对路径,再依次做绝对路径、密钥前缀与 +/// 错误上下文脱敏。 /// /// 顺序不能反:先抹密钥会把 `sk-…` 之类的 token 换成占位符,但绝对路径里的用户名目录 -/// 仍然会留下;这里先处理绝对路径,再处理密钥。 +/// 仍然会留下;这里先归一化路径 token,再处理密钥。 /// /// 复用既有 `agent/generation/prompt_context.rs` 的脱敏组合:`sanitize_error_context` /// 就是 `redact_secret_tokens` + `redact_error_sensitive_assignments` + @@ -97,11 +205,13 @@ fn tool_calls_path(root: &Path) -> PathBuf { /// `Authorization: Bearer …`、`Cookie: …`、`api_key=…`、`client_secret=…` 这类键值凭据; /// 含 `--password` / `--token` / `--secret` 这类敏感 CLI 标志的行按既有 fail-closed /// 约定整行替换成 `[redacted sensitive context]`(与 `sanitize_agent_runtime_text` 一致)。 -fn sanitize_detail_text(_root: &Path, value: &str) -> String { - let without_absolute = redact_absolute_path_tokens(value); +fn sanitize_detail_text(root: &Path, value: &str) -> String { + let without_project_root = relativize_project_root_paths(root, value); + let without_absolute = redact_absolute_path_tokens(&without_project_root); let without_secret = redact_secret_tokens(&without_absolute); sanitize_error_context(&without_secret) } + /// 按字符数截断(不切坏 UTF-8),并在真正截断时补省略号。 fn bounded_chars(value: &str, max_chars: usize) -> String { if value.chars().count() <= max_chars { @@ -1002,4 +1112,56 @@ mod tests { assert_eq!(calls[0].started_at, 1000); } + /// 判据:项目内绝对路径落成项目相对路径,项目外绝对路径保持既有占位形状。 + #[test] + fn tool_call_paths_become_project_relative() { + let root = init_tool_call_project("tool-call-path-shape"); + let root_display = root.path().to_string_lossy().to_string(); + let inside = root + .path() + .join("game/src/x.ts") + .to_string_lossy() + .to_string(); + let outside = if cfg!(windows) { + r"C:\Windows\Temp\canary-outside.ts".to_string() + } else { + "/opt/canary/outside.ts".to_string() + }; + let call = direct_tool_call_from_item( + root.path(), + &json!({ + "id": "item-paths", + "type": "fileChange", + "changes": [ + {"path": inside, "kind": "update"}, + {"path": outside, "kind": "add"}, + ], + "startedAtMs": 1000, + }), + "turn-1", + false, + 1000, + ) + .expect("path tool call"); + let paths = call + .detail + .changes + .iter() + .map(|change| change.path.as_str()) + .collect::>(); + assert_eq!( + paths[0], "game/src/x.ts", + "项目内绝对路径必须落成项目相对路径(不能是占位符)" + ); + assert_eq!(paths[1], "", "项目外绝对路径保持占位形状"); + assert_eq!(call.summary, "game/src/x.ts", "摘要取首个变更路径"); + + persist_direct_tool_call_at(root.path(), &call).expect("persist path call"); + let raw = std::fs::read_to_string(tool_calls_path(root.path())).expect("read raw file"); + assert!( + !raw.contains(&root_display), + "落盘不得残留项目根目录:{raw}" + ); + } + }