修复工具调用卡片五类凭据脱敏覆盖不足(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:
@@ -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, "脱敏必须幂等");
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user