修复工具调用卡片五类凭据脱敏覆盖不足(P0)

- direct_tool_calls.rs:detail.command / detail.output / detail.changes[].path 不再只走
  redact_absolute_path_tokens + redact_secret_tokens(后者只认 sk-/tnr_sk_/ghp_/AKIA/eyJ 固定前缀),
  改为追加既有 sanitize_error_context(= redact_secret_tokens + redact_error_sensitive_assignments
  + redact_error_bearer_values + redact_error_config_names 的既有组合用法),
  覆盖 Authorization: Bearer <token>、Cookie: session=<v>、api_key=<v>、client_secret=<v> 四类键值凭据
- 含 --password / --token / --secret / --api-key 这类敏感 CLI 标志的行沿用既有 fail-closed 约定,
  整行替换成 [redacted sensitive context](与 sanitize_agent_runtime_text 行为一致)
- summary 的 tool 兜底来源同样先脱敏(同一条落盘链路上的残留未脱敏文本)
- 新增单测:tool_call_redacts_extended_credential_shapes(五类凭据在投影与落盘行都不含原始值)、
  tool_call_redaction_keeps_normal_text_and_is_idempotent(裸 password 词不误伤、脱敏幂等、sk- 前缀不回退)
This commit is contained in:
2026-09-15 18:14:15 +08:00
parent a60c04b53d
commit b8ff3cfb56
@@ -9,6 +9,7 @@
//! 文本条目,而且会被注入 Codex 上下文。往里面塞新形状既装不下,又有污染模型上下文的风险。
use crate::agent::redact_secret_tokens;
use crate::agent::sanitize_error_context;
use crate::config::{prepare_game_creator_private_path_for_read, write_game_creator_private_file};
use crate::project::{enforce_project_permission_policy, project_append_lock_for};
use crate::redact_absolute_path_tokens;
@@ -85,20 +86,22 @@ fn tool_calls_path(root: &Path) -> PathBuf {
root.join(".agent/conversations/tool-calls.jsonl")
}
/// 脱敏:项目内相对路径保留,其余按「先抹绝对路径、再抹密钥」处理
/// 脱敏:先抹绝对路径、再抹密钥前缀,最后走既有的错误上下文脱敏组合
///
/// 顺序不能反:先抹密钥会把 `sk-…` 之类的 TOKEN 换成占位符,但绝对路径里的
/// 用户名目录仍然会留下;这里先归一化路径 token,再处理密钥。
fn sanitize_detail_text(root: &Path, value: &str) -> String {
/// 顺序不能反:先抹密钥会把 `sk-…` 之类的 token 换成占位符,但绝对路径里的用户名目录
/// 仍然会留下;这里先处理绝对路径,再处理密钥。
///
/// 复用既有 `agent/generation/prompt_context.rs` 的脱敏组合:`sanitize_error_context`
/// 就是 `redact_secret_tokens` + `redact_error_sensitive_assignments` +
/// `redact_error_bearer_values` + `redact_error_config_names` 的既有组合用法,覆盖
/// `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);
let without_secret = redact_secret_tokens(&without_absolute);
let root = root.to_string_lossy();
if root.is_empty() {
return without_secret;
}
without_secret.replace(root.as_ref(), "<project-root>")
sanitize_error_context(&without_secret)
}
/// 按字符数截断(不切坏 UTF-8),并在真正截断时补省略号。
fn bounded_chars(value: &str, max_chars: usize) -> String {
if value.chars().count() <= max_chars {
@@ -266,15 +269,17 @@ pub(crate) fn direct_tool_call_from_item(
})
.collect::<Vec<_>>();
let tool = item
.get("tool")
.and_then(Value::as_str)
.map(str::trim)
.filter(|tool| !tool.is_empty())
.map(|tool| sanitize_detail_text(root, tool));
// `summary` 会落到卡片与落盘文件,它的兜底来源同样必须脱敏。
let summary_source = command
.as_deref()
.or_else(|| changes.first().map(|change| change.path.as_str()))
.or_else(|| {
item.get("tool")
.and_then(Value::as_str)
.map(str::trim)
.filter(|tool| !tool.is_empty())
})
.or(tool.as_deref())
.unwrap_or_default();
let summary = first_line_bounded(summary_source, DIRECT_TOOL_CALL_SUMMARY_MAX_CHARS);
@@ -474,8 +479,8 @@ pub(crate) fn direct_tool_call_now_ms() -> u64 {
mod tests {
use super::{
direct_tool_call_from_item, direct_tool_call_now_ms, persist_direct_tool_call_at,
persist_direct_tool_calls_at, read_direct_tool_calls_at, tool_calls_path,
DIRECT_TOOL_CALL_LIMIT,
persist_direct_tool_calls_at, read_direct_tool_calls_at, sanitize_detail_text,
tool_calls_path, DIRECT_TOOL_CALL_LIMIT,
};
use serde_json::json;
@@ -750,4 +755,124 @@ mod tests {
);
}
}
/// 五类必须脱敏的凭据形状(审查报告实测泄漏的那五类)。
const CREDENTIAL_CANARIES: [&str; 5] = [
"canary-bearer-value",
"canary-cookie-value",
"canary-api-key-value",
"canary-client-secret-value",
"canary-password-value",
];
/// 判据:`Authorization: Bearer` / `Cookie: session=` / `api_key=` / `client_secret=` /
/// `--password <值>` 五类凭据在投影结果与落盘行里都不得出现原始值。
#[test]
fn tool_call_redacts_extended_credential_shapes() {
let root = init_tool_call_project("tool-call-credential-shapes");
let command = [
"curl -H 'Authorization: Bearer canary-bearer-value' https://example.com",
"curl -b 'Cookie: session=canary-cookie-value' https://example.com",
"curl -d api_key=canary-api-key-value https://example.com",
"curl -d client_secret=canary-client-secret-value https://example.com",
"vault login --password canary-password-value --env prod",
]
.join("\n");
let output = [
"Authorization: Bearer canary-output-bearer-value",
"Cookie: session=canary-output-cookie-value",
]
.join("\n");
let call = direct_tool_call_from_item(
root.path(),
&json!({
"id": "item-credentials",
"type": "commandExecution",
"command": command,
"aggregatedOutput": output,
"startedAtMs": 1000,
}),
"turn-1",
false,
1000,
)
.expect("credential tool call");
let projected_command = call.detail.command.as_deref().expect("command");
let projected_output = call.detail.output.as_deref().expect("output");
for canary in CREDENTIAL_CANARIES {
assert!(
!projected_command.contains(canary),
"命令投影里不得出现原始凭据 {canary}{projected_command}"
);
}
for canary in ["canary-output-bearer-value", "canary-output-cookie-value"] {
assert!(
!projected_output.contains(canary),
"输出投影里不得出现原始凭据 {canary}{projected_output}"
);
}
assert!(
!call.summary.contains("canary-bearer-value"),
"摘要取自命令首行,同样不得带原始凭据:{}",
call.summary
);
persist_direct_tool_call_at(root.path(), &call).expect("persist credential call");
let raw = std::fs::read_to_string(tool_calls_path(root.path())).expect("read raw file");
for canary in CREDENTIAL_CANARIES {
assert!(
!raw.contains(canary),
"落盘行里不得出现原始凭据 {canary}{raw}"
);
}
for canary in ["canary-output-bearer-value", "canary-output-cookie-value"] {
assert!(
!raw.contains(canary),
"落盘行里不得出现原始凭据 {canary}{raw}"
);
}
}
/// 判据:脱敏不误伤正常内容、既有前缀脱敏不回退、且幂等(连跑两次结果一致)。
#[test]
fn tool_call_redaction_keeps_normal_text_and_is_idempotent() {
let root = init_tool_call_project("tool-call-redaction-idempotent");
let sanitize = |value: &str| sanitize_detail_text(root.path(), value);
// 出现 `password` 单词但没有赋值 → 属于正常内容,不得脱敏。
let plain = "grep -n password game/src/config.ts";
let once = sanitize(plain);
assert_eq!(once, plain, "没有赋值的 password 单词不得被脱敏");
assert_eq!(sanitize(&once), once, "脱敏必须幂等");
// 既有前缀脱敏(sk-…)不得回退。
let prefixed = "curl -H 'X-Api-Key: sk-canary-prefix-key' https://example.com";
let once = sanitize(prefixed);
assert!(
!once.contains("sk-canary-prefix-key"),
"既有前缀脱敏不得回退:{once}"
);
assert_eq!(sanitize(&once), once, "脱敏必须幂等");
// `--password <值>`:沿用既有 fail-closed 约定(含敏感 CLI 标志的行整行替换),
// 原始值随之消失,且再次脱敏结果不变。
let with_secret = "vault login --password canary-password-value --env prod";
let once = sanitize(with_secret);
assert!(
!once.contains("canary-password-value"),
"`--password <值>` 不得落盘明文:{once}"
);
assert_eq!(sanitize(&once), once, "脱敏必须幂等");
// `--password $ENV`:占位符不是密钥,但既有 `contains_sensitive_cli_flag` 按标志
// fail-closed 整行替换(与 sanitize_error_context 一致),本次属契约内行为。
let placeholder = "vault login --password $DEPLOY_PASSWORD --env prod";
let once = sanitize(placeholder);
assert_eq!(
once, "[redacted sensitive context]",
"含敏感 CLI 标志的行按既有约定整行替换"
);
assert_eq!(sanitize(&once), once, "脱敏必须幂等");
}
}