修复工具调用项目内路径形状与死代码(P2)

- direct_tool_calls.rs:新增 project_relative_path_segment 与 relativize_project_root_paths,
  在 redact_absolute_path_tokens 之前把项目根目录前缀换成项目相对路径(game/src/x.ts),
  Windows 的 \ 与 / 两种写法都匹配、大小写不敏感、分隔符统一成 /
- 项目外绝对路径继续落成 <absolute-path> 占位;只写了项目根目录本身时也按占位处理
- 删掉永远匹配不到的死代码 without_secret.replace(root, "<project-root>")
  (redact_absolute_path_tokens 已经把项目根目录换成 <absolute-path>,契约里的"项目相对路径"在绝对路径场景不成立)
- 新增单测:tool_call_paths_become_project_relative(项目内相对路径 + 项目外占位 + 落盘不残留项目根目录)
This commit is contained in:
2026-09-15 18:19:33 +08:00
parent a0efd92192
commit bc89bf45c3
@@ -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)
}
/// 把项目根目录前缀换成**项目相对路径**`<root>/game/src/x.ts` → `game/src/x.ts`)。
///
/// 必须排在 `redact_absolute_path_tokens` 之前:后者会把整个绝对路径抹成
/// `<absolute-path>`,之后就再也认不出哪些路径在项目内了。
/// 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::<Vec<_>>();
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("<absolute-path>");
} 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::<Vec<_>>();
assert_eq!(
paths[0], "game/src/x.ts",
"项目内绝对路径必须落成项目相对路径(不能是占位符)"
);
assert_eq!(paths[1], "<absolute-path>", "项目外绝对路径保持占位形状");
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}"
);
}
}