修复陶泥儿错误诊断脱敏与资源参数提示
分离普通 Prompt 与错误诊断脱敏,保留安全 HTTP 字段并隐藏敏感值 补充 assetName 必填说明与 schema 回归测试 增加前端错误标记过滤及 HTTP 诊断回归测试 同步更新 AI 游戏创作智能体实施方案
This commit is contained in:
@@ -133,7 +133,8 @@ fn direct_tools_mcp_specs() -> Value {
|
||||
"assetName": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": DIRECT_TOOLS_MCP_MAX_RESOURCE_NAME_CHARS
|
||||
"maxLength": DIRECT_TOOLS_MCP_MAX_RESOURCE_NAME_CHARS,
|
||||
"description": "必填的资源显示名称,用于项目清单和恢复匹配;只填写人类可读名称,不能传项目路径、URL、objectKey、Token 或其它凭据"
|
||||
}
|
||||
},
|
||||
"required": ["kind", "mode", "prompt", "assetName"],
|
||||
@@ -707,6 +708,19 @@ mod tests {
|
||||
assert!(art_tool["description"].as_str().is_some_and(
|
||||
|description| description.contains("模型参数和 MCP 自动批准本身不构成替换授权")
|
||||
));
|
||||
let resource_tool = specs["tools"]
|
||||
.as_array()
|
||||
.expect("tool array")
|
||||
.iter()
|
||||
.find(|tool| tool["name"] == "agc_create_or_derive_resource")
|
||||
.expect("resource tool");
|
||||
assert_eq!(
|
||||
resource_tool["inputSchema"]["properties"]["assetName"]["description"],
|
||||
"必填的资源显示名称,用于项目清单和恢复匹配;只填写人类可读名称,不能传项目路径、URL、objectKey、Token 或其它凭据"
|
||||
);
|
||||
assert!(resource_tool["inputSchema"]["required"]
|
||||
.as_array()
|
||||
.is_some_and(|required| required.iter().any(|field| field == "assetName")));
|
||||
assert_eq!(
|
||||
tool_art_preparation_mode(&json!({})).expect("safe default"),
|
||||
"reuse-or-create"
|
||||
|
||||
@@ -102,8 +102,8 @@ pub(crate) use prompt_context::{
|
||||
game_creator_planner_system_prompt, game_creator_planner_user_prompt,
|
||||
game_creator_system_prompt, read_optional_text, redact_secret_tokens,
|
||||
render_local_asset_prompt_context, render_local_conversation_prompt_context,
|
||||
render_local_conversation_prompt_context_for_session, sanitize_prompt_context,
|
||||
truncate_prompt_context, truncate_prompt_context_preserving_tail,
|
||||
render_local_conversation_prompt_context_for_session, sanitize_error_context,
|
||||
sanitize_prompt_context, truncate_prompt_context, truncate_prompt_context_preserving_tail,
|
||||
};
|
||||
#[allow(unused_imports)]
|
||||
pub(crate) use role_briefs::{
|
||||
|
||||
@@ -411,6 +411,497 @@ pub(crate) fn sanitize_prompt_context(value: &str) -> String {
|
||||
sanitized.join("\n")
|
||||
}
|
||||
|
||||
// Prompt context is deliberately fail-closed: a line which looks like a
|
||||
// credential assignment is dropped in full before it can reach a model. An
|
||||
// error, however, is an operator-facing diagnostic. Dropping the whole line
|
||||
// there also drops the HTTP status and the provider's actionable validation
|
||||
// fields, which turns a recoverable 401/403 into an opaque error. Keep this
|
||||
// separate sanitizer narrow and only replace sensitive values.
|
||||
const ERROR_SENSITIVE_ASSIGNMENT_KEYS: &[&str] = &[
|
||||
"proxy-authorization",
|
||||
"authorization",
|
||||
"set-cookie",
|
||||
"cookie",
|
||||
"access-token",
|
||||
"access_token",
|
||||
"access token",
|
||||
"accesstoken",
|
||||
"refresh-token",
|
||||
"refresh_token",
|
||||
"refresh token",
|
||||
"refreshtoken",
|
||||
"oauth-token",
|
||||
"oauth_token",
|
||||
"oauth token",
|
||||
"id-token",
|
||||
"id_token",
|
||||
"id token",
|
||||
"auth-token",
|
||||
"auth_token",
|
||||
"auth token",
|
||||
"authtoken",
|
||||
"client-secret",
|
||||
"client_secret",
|
||||
"client secret",
|
||||
"clientsecret",
|
||||
"private-key",
|
||||
"private_key",
|
||||
"private key",
|
||||
"privatekey",
|
||||
"secret-key",
|
||||
"secret_key",
|
||||
"secret key",
|
||||
"secretkey",
|
||||
"x-api-key",
|
||||
"x_api_key",
|
||||
"api-key",
|
||||
"api_key",
|
||||
"api key",
|
||||
"apikey",
|
||||
"credentials",
|
||||
"credential",
|
||||
"password",
|
||||
"token",
|
||||
"secret",
|
||||
"bearer",
|
||||
];
|
||||
|
||||
const ERROR_REDACTED_VALUE: &str = "[redacted-secret]";
|
||||
const ERROR_REDACTED_KEY: &str = "[redacted-sensitive-field]";
|
||||
|
||||
/// Redact an error for display in Runtime state, diagnostics, and tool
|
||||
/// results. This intentionally does not call `sanitize_prompt_context`: the
|
||||
/// latter is stricter by design and would erase useful HTTP status/code/field
|
||||
/// information whenever a safe error line mentions a credential field.
|
||||
pub(crate) fn sanitize_error_context(value: &str) -> String {
|
||||
let mut sanitized = Vec::new();
|
||||
let mut inside_private_key = false;
|
||||
for line in value.lines() {
|
||||
let lower = line.to_ascii_lowercase();
|
||||
if inside_private_key {
|
||||
if lower.contains("-----end") && lower.contains("private key") {
|
||||
inside_private_key = false;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if lower.contains("-----begin") && lower.contains("private key") {
|
||||
sanitized.push("[redacted sensitive context]".to_string());
|
||||
inside_private_key = !(lower.contains("-----end") && lower.contains("private key"));
|
||||
continue;
|
||||
}
|
||||
|
||||
let line = redact_secret_tokens(line);
|
||||
let line = redact_error_sensitive_assignments(&line);
|
||||
let line = redact_error_bearer_values(&line);
|
||||
let line = redact_error_config_names(&line);
|
||||
sanitized.push(redact_secret_tokens(&line));
|
||||
}
|
||||
sanitized.join("\n")
|
||||
}
|
||||
|
||||
fn error_key_boundary(lower: &str, start: usize, end: usize) -> bool {
|
||||
let left_is_boundary = start == 0
|
||||
|| lower[..start]
|
||||
.chars()
|
||||
.next_back()
|
||||
.is_some_and(|character| !character.is_ascii_alphanumeric());
|
||||
let right_is_boundary = end == lower.len()
|
||||
|| lower[end..]
|
||||
.chars()
|
||||
.next()
|
||||
.is_some_and(|character| !character.is_ascii_alphanumeric());
|
||||
left_is_boundary && right_is_boundary
|
||||
}
|
||||
|
||||
fn error_assignment_value_start(lower: &str, key_end: usize) -> Option<usize> {
|
||||
let mut index = key_end;
|
||||
if lower.as_bytes().get(index) == Some(&b'\\')
|
||||
&& lower
|
||||
.as_bytes()
|
||||
.get(index + 1)
|
||||
.is_some_and(|byte| matches!(byte, b'\'' | b'"'))
|
||||
{
|
||||
index += 2;
|
||||
} else if matches!(lower.as_bytes().get(index), Some(b'\'' | b'"' | b'`')) {
|
||||
index += 1;
|
||||
}
|
||||
while lower[index..]
|
||||
.chars()
|
||||
.next()
|
||||
.is_some_and(char::is_whitespace)
|
||||
{
|
||||
index += lower[index..].chars().next()?.len_utf8();
|
||||
}
|
||||
let delimiter = lower[index..].chars().next()?;
|
||||
if !matches!(delimiter, ':' | '=') {
|
||||
return None;
|
||||
}
|
||||
index += delimiter.len_utf8();
|
||||
if delimiter == '=' && lower.as_bytes().get(index) == Some(&b'>') {
|
||||
index += 1;
|
||||
}
|
||||
while lower[index..]
|
||||
.chars()
|
||||
.next()
|
||||
.is_some_and(char::is_whitespace)
|
||||
{
|
||||
index += lower[index..].chars().next()?.len_utf8();
|
||||
}
|
||||
Some(index)
|
||||
}
|
||||
|
||||
fn find_error_sensitive_assignment(
|
||||
line: &str,
|
||||
search_from: usize,
|
||||
) -> Option<(usize, usize, usize)> {
|
||||
let lower = line.to_ascii_lowercase();
|
||||
let mut best: Option<(usize, usize, usize)> = None;
|
||||
for key in ERROR_SENSITIVE_ASSIGNMENT_KEYS {
|
||||
let mut search = search_from;
|
||||
while let Some(relative) = lower[search..].find(key) {
|
||||
let start = search + relative;
|
||||
let end = start + key.len();
|
||||
if error_key_boundary(&lower, start, end) {
|
||||
if let Some(value_start) = error_assignment_value_start(&lower, end) {
|
||||
let replace = best.is_none_or(|(best_start, best_len, _)| {
|
||||
start < best_start || (start == best_start && key.len() > best_len)
|
||||
});
|
||||
if replace {
|
||||
best = Some((start, key.len(), value_start));
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
search = end;
|
||||
}
|
||||
}
|
||||
best
|
||||
}
|
||||
|
||||
fn error_sensitive_unquoted_value_end(line: &str, mut index: usize) -> usize {
|
||||
while index < line.len() {
|
||||
let character = line[index..].chars().next().unwrap_or_default();
|
||||
if matches!(
|
||||
character,
|
||||
',' | ',' | ';' | ';' | '&' | ']' | '}' | ')' | '<' | '>'
|
||||
) {
|
||||
break;
|
||||
}
|
||||
index += character.len_utf8();
|
||||
}
|
||||
index
|
||||
}
|
||||
|
||||
fn error_redaction_marker_at(line: &str, value_start: usize) -> Option<(usize, String)> {
|
||||
[
|
||||
"[redacted-secret]",
|
||||
"<redacted-secret>",
|
||||
"<redacted-url>",
|
||||
"<absolute-path>",
|
||||
"[redacted-config]",
|
||||
"[redacted sensitive context]",
|
||||
]
|
||||
.into_iter()
|
||||
.find_map(|marker| {
|
||||
line[value_start..]
|
||||
.starts_with(marker)
|
||||
.then(|| (value_start + marker.len(), marker.to_string()))
|
||||
})
|
||||
}
|
||||
|
||||
fn error_normal_quoted_value_end(line: &str, content_start: usize, quote: char) -> usize {
|
||||
let mut escaped = false;
|
||||
for (offset, character) in line[content_start..].char_indices() {
|
||||
if escaped {
|
||||
escaped = false;
|
||||
continue;
|
||||
}
|
||||
if character == '\\' {
|
||||
escaped = true;
|
||||
continue;
|
||||
}
|
||||
if character == quote {
|
||||
return content_start + offset + quote.len_utf8();
|
||||
}
|
||||
}
|
||||
line.len()
|
||||
}
|
||||
|
||||
fn error_escaped_quoted_value_end(line: &str, content_start: usize, quote: char) -> usize {
|
||||
let mut index = content_start;
|
||||
while index < line.len() {
|
||||
let character = line[index..].chars().next().unwrap_or_default();
|
||||
if character == '\\' {
|
||||
let next_index = index + character.len_utf8();
|
||||
if let Some(next) = line[next_index..].chars().next() {
|
||||
if next == quote {
|
||||
let after_quote = next_index + next.len_utf8();
|
||||
let follows_json_boundary =
|
||||
line[after_quote..].chars().next().is_none_or(|character| {
|
||||
character.is_whitespace()
|
||||
|| matches!(
|
||||
character,
|
||||
',' | ',' | ';' | ';' | ':' | ':' | '}' | ']' | ')' | '&'
|
||||
)
|
||||
});
|
||||
if follows_json_boundary {
|
||||
return after_quote;
|
||||
}
|
||||
}
|
||||
index = next_index + next.len_utf8();
|
||||
continue;
|
||||
}
|
||||
}
|
||||
index += character.len_utf8();
|
||||
}
|
||||
line.len()
|
||||
}
|
||||
|
||||
fn error_sensitive_value_replacement(line: &str, value_start: usize) -> Option<(usize, String)> {
|
||||
if value_start >= line.len() {
|
||||
return Some((value_start, String::new()));
|
||||
}
|
||||
if let Some(marker) = error_redaction_marker_at(line, value_start) {
|
||||
return Some(marker);
|
||||
}
|
||||
let first = line[value_start..].chars().next().unwrap_or_default();
|
||||
|
||||
// JSON escaped strings are common in serialized provider errors, e.g.
|
||||
// {\"token\":\"raw-secret\"}. Treat the escaped quote pair as the
|
||||
// wrapper so the value after it cannot remain in the diagnostic.
|
||||
if first == '\\'
|
||||
&& line[value_start + first.len_utf8()..]
|
||||
.chars()
|
||||
.next()
|
||||
.is_some_and(|character| matches!(character, '\'' | '"'))
|
||||
{
|
||||
let quote = line[value_start + first.len_utf8()..]
|
||||
.chars()
|
||||
.next()
|
||||
.unwrap_or('"');
|
||||
let content_start = value_start + first.len_utf8() + quote.len_utf8();
|
||||
let end = error_escaped_quoted_value_end(line, content_start, quote);
|
||||
return Some((end, format!("\\{quote}{ERROR_REDACTED_VALUE}\\{quote}")));
|
||||
}
|
||||
|
||||
if matches!(first, '\'' | '"') {
|
||||
let quote = first;
|
||||
let content_start = value_start + quote.len_utf8();
|
||||
let end = error_normal_quoted_value_end(line, content_start, quote);
|
||||
let replacement = if end > content_start && line[..end].ends_with(quote) {
|
||||
format!("{quote}{ERROR_REDACTED_VALUE}{quote}")
|
||||
} else {
|
||||
format!("{quote}{ERROR_REDACTED_VALUE}")
|
||||
};
|
||||
return Some((end, replacement));
|
||||
}
|
||||
|
||||
if first == '<' {
|
||||
let end = line[value_start + first.len_utf8()..]
|
||||
.find('>')
|
||||
.map(|offset| value_start + first.len_utf8() + offset + 1)
|
||||
.unwrap_or_else(|| line.len());
|
||||
return Some((end, "<redacted-secret>".to_string()));
|
||||
}
|
||||
|
||||
if first == '[' {
|
||||
let end = line[value_start + first.len_utf8()..]
|
||||
.find(']')
|
||||
.map(|offset| value_start + first.len_utf8() + offset + 1)
|
||||
.unwrap_or_else(|| line.len());
|
||||
return Some((end, ERROR_REDACTED_VALUE.to_string()));
|
||||
}
|
||||
|
||||
let lower = line[value_start..].to_ascii_lowercase();
|
||||
if lower.starts_with("bearer")
|
||||
&& lower["bearer".len()..]
|
||||
.chars()
|
||||
.next()
|
||||
.is_some_and(char::is_whitespace)
|
||||
{
|
||||
let mut token_start = value_start + "bearer".len();
|
||||
while line[token_start..]
|
||||
.chars()
|
||||
.next()
|
||||
.is_some_and(char::is_whitespace)
|
||||
{
|
||||
token_start += line[token_start..].chars().next().unwrap().len_utf8();
|
||||
}
|
||||
let token_end = error_sensitive_unquoted_value_end(line, token_start);
|
||||
if token_end > token_start {
|
||||
let bearer = &line[value_start..value_start + "bearer".len()];
|
||||
return Some((token_end, format!("{bearer} {ERROR_REDACTED_VALUE}")));
|
||||
}
|
||||
}
|
||||
|
||||
// Assignment values may contain spaces (for example
|
||||
// `Authorization=Basic <token>`). Consume the whole delimited value;
|
||||
// stopping at the first space would leak the remainder of a credential.
|
||||
let end = error_sensitive_unquoted_value_end(line, value_start);
|
||||
if end == value_start {
|
||||
Some((end, String::new()))
|
||||
} else {
|
||||
Some((end, ERROR_REDACTED_VALUE.to_string()))
|
||||
}
|
||||
}
|
||||
|
||||
fn error_assignment_value_replacement(line: &str, value_start: usize) -> (usize, String) {
|
||||
if value_start >= line.len() {
|
||||
return (value_start, String::new());
|
||||
}
|
||||
|
||||
let lower = line[value_start..].to_ascii_lowercase();
|
||||
if lower.starts_with("bearer")
|
||||
&& lower["bearer".len()..]
|
||||
.chars()
|
||||
.next()
|
||||
.is_some_and(char::is_whitespace)
|
||||
{
|
||||
let mut token_start = value_start + "bearer".len();
|
||||
while line[token_start..]
|
||||
.chars()
|
||||
.next()
|
||||
.is_some_and(char::is_whitespace)
|
||||
{
|
||||
token_start += line[token_start..].chars().next().unwrap().len_utf8();
|
||||
}
|
||||
if let Some((token_end, _)) = error_sensitive_value_replacement(line, token_start) {
|
||||
if token_end > token_start {
|
||||
let bearer = &line[value_start..value_start + "bearer".len()];
|
||||
return (token_end, format!("{bearer} {ERROR_REDACTED_VALUE}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
error_sensitive_value_replacement(line, value_start)
|
||||
.unwrap_or_else(|| (value_start, String::new()))
|
||||
}
|
||||
|
||||
fn redact_error_sensitive_assignments(line: &str) -> String {
|
||||
let mut output = String::with_capacity(line.len());
|
||||
let mut cursor = 0usize;
|
||||
while let Some((key_start, key_len, value_start)) =
|
||||
find_error_sensitive_assignment(line, cursor)
|
||||
{
|
||||
if value_start < cursor {
|
||||
break;
|
||||
}
|
||||
// Do not retain the sensitive field name itself. A warning may be
|
||||
// serialized to the Agent/UI, and names such as `api_key` or
|
||||
// `Authorization` are sensitive context even after their values have
|
||||
// been replaced. Preserve surrounding quotes, separators, and
|
||||
// whitespace so JSON-ish diagnostics remain readable.
|
||||
let key_end = key_start + key_len;
|
||||
output.push_str(&line[cursor..key_start]);
|
||||
output.push_str(ERROR_REDACTED_KEY);
|
||||
output.push_str(&line[key_end..value_start]);
|
||||
let (value_end, replacement) = error_assignment_value_replacement(line, value_start);
|
||||
if value_end <= value_start {
|
||||
cursor = value_start;
|
||||
} else {
|
||||
output.push_str(&replacement);
|
||||
cursor = value_end;
|
||||
}
|
||||
}
|
||||
output.push_str(&line[cursor..]);
|
||||
output
|
||||
}
|
||||
|
||||
fn redact_error_bearer_values(line: &str) -> String {
|
||||
let lower = line.to_ascii_lowercase();
|
||||
let mut output = String::with_capacity(line.len());
|
||||
let mut cursor = 0usize;
|
||||
while let Some(relative) = lower[cursor..].find("bearer") {
|
||||
let start = cursor + relative;
|
||||
let end = start + "bearer".len();
|
||||
let boundary_before = start == 0
|
||||
|| lower[..start]
|
||||
.chars()
|
||||
.next_back()
|
||||
.is_some_and(|character| !character.is_ascii_alphanumeric());
|
||||
let boundary_after = end == lower.len()
|
||||
|| lower[end..]
|
||||
.chars()
|
||||
.next()
|
||||
.is_some_and(|character| !character.is_ascii_alphanumeric());
|
||||
if !boundary_before || !boundary_after {
|
||||
cursor = end;
|
||||
continue;
|
||||
}
|
||||
let mut token_start = end;
|
||||
while line[token_start..]
|
||||
.chars()
|
||||
.next()
|
||||
.is_some_and(char::is_whitespace)
|
||||
{
|
||||
token_start += line[token_start..].chars().next().unwrap().len_utf8();
|
||||
}
|
||||
let Some((token_end, replacement)) = error_sensitive_value_replacement(line, token_start)
|
||||
else {
|
||||
cursor = end;
|
||||
continue;
|
||||
};
|
||||
if token_end <= token_start {
|
||||
cursor = end;
|
||||
continue;
|
||||
}
|
||||
output.push_str(&line[cursor..token_start]);
|
||||
output.push_str(&replacement);
|
||||
cursor = token_end;
|
||||
}
|
||||
output.push_str(&line[cursor..]);
|
||||
output
|
||||
}
|
||||
|
||||
fn redact_error_named_token(value: &str, needle: &str) -> String {
|
||||
let lower = value.to_ascii_lowercase();
|
||||
let needle_lower = needle.to_ascii_lowercase();
|
||||
let mut output = String::with_capacity(value.len());
|
||||
let mut cursor = 0usize;
|
||||
while let Some(relative) = lower[cursor..].find(&needle_lower) {
|
||||
let start = cursor + relative;
|
||||
let mut end = start + needle.len();
|
||||
while end < value.len() {
|
||||
let character = value[end..].chars().next().unwrap_or_default();
|
||||
if character.is_whitespace()
|
||||
|| matches!(
|
||||
character,
|
||||
'\'' | '"'
|
||||
| '`'
|
||||
| ','
|
||||
| ','
|
||||
| ';'
|
||||
| ';'
|
||||
| ':'
|
||||
| ':'
|
||||
| '&'
|
||||
| ']'
|
||||
| '}'
|
||||
| ')'
|
||||
| '<'
|
||||
| '>'
|
||||
)
|
||||
{
|
||||
break;
|
||||
}
|
||||
end += character.len_utf8();
|
||||
}
|
||||
output.push_str(&value[cursor..start]);
|
||||
output.push_str("[redacted-config]");
|
||||
cursor = end;
|
||||
}
|
||||
output.push_str(&value[cursor..]);
|
||||
output
|
||||
}
|
||||
|
||||
fn redact_error_config_names(line: &str) -> String {
|
||||
[".env", "game-creator.config"]
|
||||
.into_iter()
|
||||
.fold(line.to_string(), |value, needle| {
|
||||
redact_error_named_token(&value, needle)
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn redact_secret_tokens(line: &str) -> String {
|
||||
let mut spans = [
|
||||
("tnr_sk_", 8usize),
|
||||
|
||||
@@ -1727,6 +1727,88 @@ mod planning_state_tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod runtime_error_redaction_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn runtime_error_redaction_keeps_http_diagnostics_while_redacting_values() {
|
||||
let value = concat!(
|
||||
"陶泥儿请求失败:HTTP 401;code=invalid-token;field=authorization;",
|
||||
"message=token=token-value-123;authorization: Bearer bearer-value-456;",
|
||||
"payload={\"token\":\"json-token-value-789\",\"api_key\":\"api-value-012\"}"
|
||||
);
|
||||
let redacted = redact_agent_runtime_error(Path::new("."), value, 1_000);
|
||||
|
||||
assert!(redacted.contains("HTTP 401"), "{redacted}");
|
||||
assert!(redacted.contains("code=invalid-token"), "{redacted}");
|
||||
assert!(redacted.contains("field=authorization"), "{redacted}");
|
||||
assert!(redacted.contains("message="), "{redacted}");
|
||||
for secret in [
|
||||
"token-value-123",
|
||||
"bearer-value-456",
|
||||
"json-token-value-789",
|
||||
"api-value-012",
|
||||
] {
|
||||
assert!(!redacted.contains(secret), "{secret} leaked in {redacted}");
|
||||
}
|
||||
assert!(
|
||||
!redacted.contains("[redacted sensitive context]"),
|
||||
"{redacted}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn runtime_error_redaction_hides_private_key_and_config_names_without_losing_status() {
|
||||
let value = concat!(
|
||||
"平台返回 HTTP 403;reason=forbidden;detail=读取 .env.production 失败\n",
|
||||
"-----BEGIN PRIVATE KEY-----\n",
|
||||
"PRIVATE-KEY-VALUE-123\n",
|
||||
"-----END PRIVATE KEY-----\n",
|
||||
"message=拒绝访问"
|
||||
);
|
||||
let redacted = redact_agent_runtime_error(Path::new("."), value, 1_000);
|
||||
|
||||
assert!(redacted.contains("HTTP 403"), "{redacted}");
|
||||
assert!(redacted.contains("reason=forbidden"), "{redacted}");
|
||||
assert!(redacted.contains("message=拒绝访问"), "{redacted}");
|
||||
assert!(!redacted.contains(".env.production"), "{redacted}");
|
||||
assert!(!redacted.contains("PRIVATE-KEY-VALUE-123"), "{redacted}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn runtime_error_redaction_handles_escaped_json_wrapped_bearer_and_camel_case_keys() {
|
||||
let value = concat!(
|
||||
r#"HTTP 401;code=invalid-token;payload={\"token\":\"json-token-value-789\",\"clientsecret\":\"client-secret-value-123\"}"#,
|
||||
r#";escaped={\"token\":\"raw\"tail-secret-value\"}"#,
|
||||
r#";authorization: Bearer \"quoted-bearer-value-456\";"#,
|
||||
"authorization: Bearer <angle-bearer-value-567>;",
|
||||
"privatekey=private-key-value-890;secretkey=secret-key-value-901;",
|
||||
"authtoken=auth-token-value-012;bearer=bare-bearer-value-345;",
|
||||
"authorization => arrow-authorization-value-678"
|
||||
);
|
||||
let redacted = redact_agent_runtime_error(Path::new("."), value, 2_000);
|
||||
|
||||
assert!(redacted.contains("HTTP 401"), "{redacted}");
|
||||
assert!(redacted.contains("code=invalid-token"), "{redacted}");
|
||||
for secret in [
|
||||
"json-token-value-789",
|
||||
"client-secret-value-123",
|
||||
"tail-secret-value",
|
||||
"quoted-bearer-value-456",
|
||||
"angle-bearer-value-567",
|
||||
"private-key-value-890",
|
||||
"secret-key-value-901",
|
||||
"auth-token-value-012",
|
||||
"bare-bearer-value-345",
|
||||
"arrow-authorization-value-678",
|
||||
] {
|
||||
assert!(!redacted.contains(secret), "{secret} leaked in {redacted}");
|
||||
}
|
||||
assert!(redacted.contains("[redacted-secret]"), "{redacted}");
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn normalize_game_creator_agent_runtime_state(
|
||||
state: &mut AgentRuntimeState,
|
||||
agent_id: &str,
|
||||
@@ -4210,7 +4292,7 @@ pub(crate) fn redact_agent_runtime_error(root: &Path, value: &str, max_chars: us
|
||||
let redacted = redact_agent_runtime_project_paths_raw(root, &redacted);
|
||||
let redacted = redact_absolute_path_tokens(&redacted);
|
||||
let redacted = redact_secret_tokens(&redacted);
|
||||
let mut sanitized = sanitize_prompt_context(&redacted);
|
||||
let mut sanitized = sanitize_error_context(&redacted);
|
||||
let preserved_markers = [
|
||||
"<redacted-url>",
|
||||
"$PROJECT_ROOT",
|
||||
|
||||
@@ -1724,6 +1724,68 @@ export function isMudPointInsufficientRuntimeError(message: string) {
|
||||
);
|
||||
}
|
||||
|
||||
const DIRECT_FAILURE_SENSITIVE_ASSIGNMENT_PATTERN =
|
||||
/(?:^|[^\w])(?:proxy-authorization|authorization|set-cookie|cookie|access[_ -]?token|accesstoken|refresh[_ -]?token|refreshtoken|oauth[_ -]?token|id[_ -]?token|auth[_ -]?token|authtoken|client[_ -]?secret|clientsecret|private[_ -]?key|privatekey|secret[_ -]?key|secretkey|x-api-key|x_api_key|api[_ -]?key|apikey|credentials?|password|token|secret|bearer)\s*["']?\s*[:=]>?\s*("(?:\\.|[^"\\])*"|'(?:\\.|[^'\\])*'|(?:Bearer\s+(?:\[[^\]]+\]|<[^>]+>|[^\s,;,;&}\]]+)|\[[^\]]+\]|<[^>]+>|[^\s,;,;&}\]]+))/gi;
|
||||
|
||||
const DIRECT_FAILURE_BEARER_PATTERN =
|
||||
/(?:^|[^\w])Bearer\s+(\[[^\]]+\]|<[^>]+>|[^\s,;,;&}\]]+)/gi;
|
||||
|
||||
function isRedactedDirectFailureValue(value: string) {
|
||||
const normalized = value.trim();
|
||||
return (
|
||||
normalized.length === 0 ||
|
||||
/^(?:Bearer\s+)?(?:\[redacted-secret\]|\[redacted-sensitive-field\]|\[已隐藏凭据\]|\[已隐藏敏感字段\]|\[已隐藏链接\]|\[已隐藏路径\]|\[已隐藏配置\]|\[已隐藏敏感信息\]|<redacted-secret>|<redacted-url>|<absolute-path>|\[redacted-config\]|\[redacted sensitive context\])$/i.test(
|
||||
normalized,
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function containsUnredactedDirectFailureSecret(value: string) {
|
||||
// Provider payloads are sometimes embedded as escaped JSON in a single
|
||||
// diagnostic line (`{\"token\":\"...\"}`). Normalize only the quote
|
||||
// escapes for the detector; the displayed value still goes through the
|
||||
// backend's redaction and marker conversion unchanged.
|
||||
const normalizedValue = value.replace(/\\(["'])/g, '$1');
|
||||
DIRECT_FAILURE_SENSITIVE_ASSIGNMENT_PATTERN.lastIndex = 0;
|
||||
let match: RegExpExecArray | null;
|
||||
while (
|
||||
(match =
|
||||
DIRECT_FAILURE_SENSITIVE_ASSIGNMENT_PATTERN.exec(normalizedValue)) !==
|
||||
null
|
||||
) {
|
||||
const rawCandidate = (match[1] ?? '').trim();
|
||||
const candidate =
|
||||
rawCandidate.length >= 2 &&
|
||||
((rawCandidate.startsWith('"') && rawCandidate.endsWith('"')) ||
|
||||
(rawCandidate.startsWith("'") && rawCandidate.endsWith("'")))
|
||||
? rawCandidate.slice(1, -1).trim()
|
||||
: rawCandidate;
|
||||
if (!isRedactedDirectFailureValue(candidate)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
DIRECT_FAILURE_BEARER_PATTERN.lastIndex = 0;
|
||||
while (
|
||||
(match = DIRECT_FAILURE_BEARER_PATTERN.exec(normalizedValue)) !== null
|
||||
) {
|
||||
if (!isRedactedDirectFailureValue(match[1] ?? '')) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function redactDirectFailureMarkers(value: string) {
|
||||
return value
|
||||
.replace(/<redacted-url>/gi, '[已隐藏链接]')
|
||||
.replace(/\$PROJECT_ROOT|<absolute-path>/g, '[已隐藏路径]')
|
||||
.replace(/\[redacted-secret\]/gi, '[已隐藏凭据]')
|
||||
.replace(/\[redacted-sensitive-field\]/gi, '[已隐藏敏感字段]')
|
||||
.replace(/\[redacted-config\]/gi, '[已隐藏配置]')
|
||||
.replace(/\[redacted sensitive context\]/gi, '[已隐藏敏感信息]');
|
||||
}
|
||||
|
||||
function directCodexDiagnosticFailureDetail(message: string) {
|
||||
const trimmed = message.trim();
|
||||
const match =
|
||||
@@ -1737,22 +1799,18 @@ function directCodexDiagnosticFailureDetail(message: string) {
|
||||
if (!stage || !retryable || !rawSummary || !rawHint) {
|
||||
return null;
|
||||
}
|
||||
const redactMarkerForDisplay = (value: string) =>
|
||||
value
|
||||
.replace(/<redacted-url>/gi, '[已隐藏链接]')
|
||||
.replace(/\$PROJECT_ROOT|<absolute-path>/g, '[已隐藏路径]')
|
||||
.replace(/\[redacted-secret\]/gi, '[已隐藏凭据]');
|
||||
const summary = redactMarkerForDisplay(rawSummary)
|
||||
const summary = redactDirectFailureMarkers(rawSummary)
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim()
|
||||
.slice(0, 320);
|
||||
const hint = redactMarkerForDisplay(rawHint)
|
||||
const hint = redactDirectFailureMarkers(rawHint)
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim()
|
||||
.slice(0, 180);
|
||||
const combined = `${summary} ${hint}`;
|
||||
if (
|
||||
/(?:authorization|bearer|api[_ -]?key|token|secret|cookie|set-cookie|https?:\/\/|(?:^|[\s::])\/?(?:users|home|var|tmp|private)\/|[A-Z]:\\Users\\)/i.test(
|
||||
containsUnredactedDirectFailureSecret(combined) ||
|
||||
/https?:\/\/|(?:^|[\s::])\/?(?:users|home|var|tmp|private)\/|[A-Z]:\\Users\\/i.test(
|
||||
combined,
|
||||
)
|
||||
) {
|
||||
@@ -1794,13 +1852,14 @@ function directPlatformFailureDetail(message: string) {
|
||||
return null;
|
||||
}
|
||||
if (
|
||||
/(?:authorization|bearer|api[_ -]?key|token|secret|cookie|set-cookie|https?:\/\/|(?:^|[\\s::])\/?(?:users|home|var|tmp|private)\/|[A-Z]:\\Users\\)/i.test(
|
||||
containsUnredactedDirectFailureSecret(trimmed) ||
|
||||
/https?:\/\/|(?:^|[\\s::])\/?(?:users|home|var|tmp|private)\/|[A-Z]:\\Users\\/i.test(
|
||||
trimmed,
|
||||
)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
const safe = trimmed
|
||||
const safe = redactDirectFailureMarkers(trimmed)
|
||||
.replace(/(?:;|;|\s)operationId\s*=\s*[^;;\s]+/gi, '')
|
||||
.replace(/(?:;|;)\s*externalGenerationJobId\s*=\s*[^;;\s]+/gi, '')
|
||||
.replace(/\s+/g, ' ')
|
||||
|
||||
@@ -718,6 +718,63 @@ describe('Agent Runtime Provider 状态投影', () => {
|
||||
).toBe(
|
||||
'陶泥儿智能创作:平台资源准备失败:读取陶泥儿画布资源失败:[已隐藏链接] [已隐藏路径] [已隐藏凭据]。平台资源暂时无法完成准备,请稍后重试;如持续失败请检查项目诊断(可直接重试)',
|
||||
);
|
||||
expect(
|
||||
projectRuntimeVisibleError(
|
||||
'direct-codex-failure:v1 stage=art-preparation retryable=true summary=请求失败 authorization=<redacted-url> [redacted-config] token=[redacted-sensitive-field];建议:请检查已隐藏配置并稍后重试;已保存脱敏项目诊断',
|
||||
'陶泥儿智能创作',
|
||||
true,
|
||||
),
|
||||
).toBe(
|
||||
'陶泥儿智能创作:平台资源准备失败:请求失败 authorization=[已隐藏链接] [已隐藏配置] token=[已隐藏敏感字段]。请检查已隐藏配置并稍后重试(可直接重试)',
|
||||
);
|
||||
});
|
||||
|
||||
test('直连平台错误保留 HTTP 诊断字段但只显示已脱敏的敏感值', () => {
|
||||
const safe = projectRuntimeVisibleError(
|
||||
'陶泥儿美术包生成失败(规范图):请求平台图片生成失败:HTTP 401;code=invalid-token;field=authorization;message=token=[redacted-secret];detail=登录态已失效',
|
||||
'陶泥儿智能创作',
|
||||
true,
|
||||
);
|
||||
expect(safe).toContain('HTTP 401');
|
||||
expect(safe).toContain('code=invalid-token');
|
||||
expect(safe).toContain('field=authorization');
|
||||
expect(safe).toContain('登录态已失效');
|
||||
expect(safe).toContain('[已隐藏凭据]');
|
||||
expect(safe).not.toContain('[redacted sensitive context]');
|
||||
|
||||
const unsafe = projectRuntimeVisibleError(
|
||||
'陶泥儿美术包生成失败(规范图):请求平台图片生成失败:HTTP 401;code=invalid-token;field=authorization;message=token=raw-secret-value',
|
||||
'陶泥儿智能创作',
|
||||
true,
|
||||
);
|
||||
expect(unsafe).not.toContain('raw-secret-value');
|
||||
expect(unsafe).toBe('陶泥儿智能创作 鉴权失败,请检查运行时配置');
|
||||
const unsafeBearer = projectRuntimeVisibleError(
|
||||
'陶泥儿美术包生成失败(规范图):请求平台图片生成失败:HTTP 401;bearer=raw-bearer-value',
|
||||
'陶泥儿智能创作',
|
||||
true,
|
||||
);
|
||||
expect(unsafeBearer).not.toContain('raw-bearer-value');
|
||||
expect(unsafeBearer).toBe('陶泥儿智能创作 鉴权失败,请检查运行时配置');
|
||||
const unsafeEscapedJson = projectRuntimeVisibleError(
|
||||
String.raw`陶泥儿美术包生成失败(规范图):请求平台图片生成失败:HTTP 401;payload={\"token\":\"raw-escaped-json-value\"}`,
|
||||
'陶泥儿智能创作',
|
||||
true,
|
||||
);
|
||||
expect(unsafeEscapedJson).not.toContain('raw-escaped-json-value');
|
||||
expect(unsafeEscapedJson).toBe('陶泥儿智能创作 鉴权失败,请检查运行时配置');
|
||||
|
||||
const markerSafe = projectRuntimeVisibleError(
|
||||
'陶泥儿美术包生成失败(规范图):请求平台图片生成失败:HTTP 403;detail=authorization=[redacted-config];field=[redacted-sensitive-field];path=<absolute-path>',
|
||||
'陶泥儿智能创作',
|
||||
true,
|
||||
);
|
||||
expect(markerSafe).toContain('HTTP 403');
|
||||
expect(markerSafe).toContain('authorization=[已隐藏配置]');
|
||||
expect(markerSafe).toContain('field=[已隐藏敏感字段]');
|
||||
expect(markerSafe).toContain('path=[已隐藏路径]');
|
||||
expect(markerSafe).not.toContain('[redacted-sensitive-field]');
|
||||
expect(markerSafe).not.toBe('陶泥儿智能创作 鉴权失败,请检查运行时配置');
|
||||
});
|
||||
|
||||
test('直连 Codex 失败保留安全原因并隐藏链接与路径', () => {
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
# AI 游戏创作智能体 App 实施计划
|
||||
|
||||
## 2026-08-25 账户 / 项目画布 / 本地素材导入
|
||||
|
||||
- 普通 Prompt 上下文与错误诊断必须使用分离的脱敏边界:Prompt 继续对疑似凭据行整体隐藏;错误诊断保留 HTTP 状态以及 `code / field / message / reason / detail` 等安全字段,仅替换 Token、Cookie、私钥、配置名、URL 和宿主路径等敏感值。`agc_create_or_derive_resource.assetName` 是必填的人类可读资源显示名称,不接受项目路径、URL、objectKey、Token 或其它凭据。
|
||||
|
||||
## 2026-08-24 Direct Codex 已登记资源查询与媒体生成语义工具
|
||||
|
||||
- `agc_tools` 新增 `agc_list_registered_assets` 与 `agc_create_or_derive_resource`。前者按 `kind / assetId / offset / limit` 有界查询客户端权威 manifest,并可显式返回角色动画正式序列帧的稳定 objectKey、assetObjectId 和尺寸;结果不包含完整 manifest、prompt、model、provider route、签名 URL、宿主路径或凭据。后者只接受 `kind / mode / sourceLocalAssetId / prompt / assetName`,`create` 仅允许无源视频、音效和背景音乐,`derive` 必须引用当前项目已登记的 localAssetId,角色动画固定为 derive。
|
||||
|
||||
Reference in New Issue
Block a user