修复工具调用项目内路径形状与死代码(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:
@@ -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}"
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user