From b8ff3cfb56fd34887321fcaecc7ae96eff3724bb Mon Sep 17 00:00:00 2001 From: Suzumiya Date: Tue, 15 Sep 2026 18:14:15 +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=E5=8D=A1=E7=89=87=E4=BA=94=E7=B1=BB=E5=87=AD=E6=8D=AE?= =?UTF-8?q?=E8=84=B1=E6=95=8F=E8=A6=86=E7=9B=96=E4=B8=8D=E8=B6=B3=EF=BC=88?= =?UTF-8?q?P0=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 、Cookie: session=、api_key=、client_secret= 四类键值凭据 - 含 --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- 前缀不回退) --- .../src-tauri/src/agent/direct_tool_calls.rs | 161 ++++++++++++++++-- 1 file changed, 143 insertions(+), 18 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 cd83485d2..38cca0e22 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 @@ -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(), "") + 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::>(); + 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, "脱敏必须幂等"); + } + }