Files
MoviePilot/tests/test_agent_tool_result_policy.py
T

1782 lines
56 KiB
Python

import asyncio
import base64
import json
from statistics import median
from time import perf_counter
from typing import Annotated, NamedTuple
from unittest.mock import MagicMock, patch
import pytest
from langgraph.types import Command
from pydantic import (
AliasChoices,
AliasPath,
BaseModel,
Field,
ValidationError,
field_validator,
)
from pydantic_core import PydanticCustomError
from pydantic.dataclasses import dataclass as pydantic_dataclass
import app.agent.policy.sanitizer as sanitizer_module
from app.agent.policy import sanitize_for_host, summarize_error, summarize_input, summarize_result
from app.agent.tools.base import MoviePilotTool, serialize_tool_result_for_agent
from app.agent.tools.impl.query_system_settings import QuerySystemSettingsTool
from app.agent.tools.manager import MoviePilotToolsManager
SECRET_MARKER = "nested-secret-marker-8472"
class _SecretInput(BaseModel):
"""日志脱敏测试工具的输入契约。"""
payload: dict = Field(description="包含嵌套值的测试载荷")
class _InvalidSecretInput(BaseModel):
"""用于验证 Pydantic 输入错误不会回显凭据原值。"""
api_key: int
class _DynamicSecretLocationInput(BaseModel):
"""用于验证动态 Mapping key 不会通过错误位置泄漏。"""
payload: dict[str, int]
class _CustomSecretValidationInput(BaseModel):
"""用于验证自定义错误类型、消息和上下文不会进入宿主摘要。"""
value: str
@field_validator("value")
@classmethod
def reject_value(cls, value: str) -> str:
"""构造携带输入值的第三方自定义校验错误。"""
raise PydanticCustomError(
f"custom_{value}",
f"rejected {value}",
{"rejected_value": value},
)
class _NamedTupleCredential(NamedTuple):
"""模拟插件或第三方 SDK 返回的命名元组。"""
api_key: str
label: str
class _AliasedCredentialResult(BaseModel):
"""模拟外部凭据名与 Python 属性名不同的结构化结果。"""
credential: str = Field(alias="apiKey")
class _ValidationAliasedCredentialResult(BaseModel):
"""模拟仅通过 validation alias 接受凭据的 Pydantic 字段。"""
credential: str = Field(validation_alias="apiToken")
class _ChoiceAliasedCredentialResult(BaseModel):
"""模拟敏感名称位于后续 choice 的 Pydantic 字段。"""
credential: str = Field(
validation_alias=AliasChoices("credentialLabel", "apiKey")
)
class _PathAliasedCredentialResult(BaseModel):
"""模拟通过带整数索引的嵌套路径接收凭据的 Pydantic 字段。"""
credential: str = Field(
validation_alias=AliasPath("payload", 0, "clientSecret")
)
class _ChoicePathAliasedCredentialResult(BaseModel):
"""模拟后续 choice 使用嵌套路径的 Pydantic 凭据字段。"""
credential: str = Field(
validation_alias=AliasChoices(
"credentialLabel",
AliasPath("payload", "refreshToken"),
)
)
class _SerializationAliasedCredentialResult(BaseModel):
"""模拟仅在序列化契约中使用凭据名称的结构化结果。"""
credential: str = Field(serialization_alias="clientSecret")
class _AliasedMetadataResult(BaseModel):
"""模拟外部 metadata 名称与 Python 属性名不同的结构化结果。"""
count: int = Field(alias="tokenCount")
class _DisguisedSecretAlias(str):
"""保存敏感底层值但通过字符串协议伪装成 metadata 名称。"""
def __str__(self) -> str:
return "tokenCount"
class _HostileAliasedCredentialResult(BaseModel):
"""模拟使用 hostile str 子类作为直接别名的 Pydantic 字段。"""
credential: str = Field(alias=_DisguisedSecretAlias("apiKey"))
class _HostilePathAliasedCredentialResult(BaseModel):
"""模拟 AliasPath 中包含 hostile str 子类的 Pydantic 字段。"""
credential: str = Field(
validation_alias=AliasPath(
"payload",
_DisguisedSecretAlias("clientSecret"),
)
)
@pydantic_dataclass
class _AliasedCredentialDataclass:
"""模拟通过赋值形式声明外部凭据名的 Pydantic dataclass。"""
credential: str = Field(alias="apiKey")
@pydantic_dataclass
class _AnnotatedAliasedCredentialDataclass:
"""模拟通过 Annotated 声明外部凭据名的 Pydantic dataclass。"""
credential: Annotated[str, Field(alias="refreshToken")]
class _SecretResultTool(MoviePilotTool):
"""返回嵌套敏感值的测试工具。"""
name: str = "secret_result_tool"
description: str = "Return a nested secret test payload."
args_schema: type[BaseModel] = _SecretInput
async def run(self, payload: dict) -> dict:
"""返回输入载荷,验证 shadow 模式不改变工具结果。"""
return {
"ok": True,
"nested": [payload, {"authorization": f"Bearer {SECRET_MARKER}"}],
}
class _SecretErrorTool(_SecretResultTool):
"""抛出包含敏感值异常的测试工具。"""
name: str = "secret_error_tool"
async def run(self, payload: dict) -> dict:
"""抛出测试异常。"""
raise RuntimeError(f"api_key={SECRET_MARKER}")
def _logged_text(mock_logger: MagicMock) -> str:
"""汇总 mock logger 收到的全部消息文本。"""
calls = []
for method_name in ("debug", "info", "warning", "error"):
method = getattr(mock_logger, method_name)
calls.extend(str(call) for call in method.call_args_list)
return "\n".join(calls)
def test_recursive_sanitizer_redacts_nested_structures_and_json_text() -> None:
"""嵌套 mapping/sequence 与 JSON 字符串都不能保留 secret marker。"""
payload = {
"name": "normal-name",
"items": [
{"api_key": SECRET_MARKER},
{"headers": {"Authorization": f"Bearer {SECRET_MARKER}"}},
'{"cookie":"' + SECRET_MARKER + '","count":2}',
],
"token_count": 12,
}
sanitized = sanitize_for_host(payload)
serialized = str(sanitized)
assert SECRET_MARKER not in serialized
assert "normal-name" in serialized
assert sanitized["token_count"] == 12
assert "***" in serialized
@pytest.mark.parametrize("as_json_text", [False, True])
def test_recursive_sanitizer_redacts_values_identified_by_secret_setting_key(
as_json_text: bool,
) -> None:
"""设置项身份为凭据时,同一结构中的通用值字段也必须脱敏。"""
payload = {
"settings": [
{
"setting_key": "API_TOKEN",
"value": SECRET_MARKER,
"value_preview": SECRET_MARKER,
"metadata": {"value": "visible-nested-value"},
}
],
"value": "visible-outer-value",
}
source = json.dumps(payload, ensure_ascii=False) if as_json_text else payload
sanitized = sanitize_for_host(source)
if as_json_text:
sanitized = json.loads(sanitized)
setting = sanitized["settings"][0]
assert setting["value"] == "***"
assert setting["value_preview"] == "***"
assert setting["metadata"]["value"] == "visible-nested-value"
assert sanitized["value"] == "visible-outer-value"
assert SECRET_MARKER not in json.dumps(sanitized, ensure_ascii=False)
def test_recursive_sanitizer_preserves_values_for_nonsecret_setting_key() -> None:
"""普通设置的 value 字段仍应保留可诊断内容。"""
payload = {
"setting_key": "PROJECT_NAME",
"value": "MoviePilot",
"value_preview": "MoviePilot",
}
sanitized = sanitize_for_host(payload)
assert sanitized == payload
@pytest.mark.parametrize(
"setting_key",
[
"API_TOKEN",
"LLM_API_KEY",
"COOKIECLOUD_KEY",
"COOKIECLOUD_AUTH_HEADER",
"SUPERUSER_PASSWORD",
"DB_POSTGRESQL_PASSWORD",
"GITHUB_TOKEN",
"FEISHU_VERIFICATION_TOKEN",
"SECRET_KEY",
"RESOURCE_SECRET_KEY",
],
)
def test_recursive_sanitizer_redacts_shared_secret_setting_identities(
setting_key: str,
) -> None:
"""宿主回执必须与系统设置工具共享敏感设置身份语义。"""
payload = {
"setting_key": setting_key,
"value": SECRET_MARKER,
"value_preview": SECRET_MARKER,
}
sanitized = sanitize_for_host(payload)
assert sanitized["value"] == "***"
assert sanitized["value_preview"] == "***"
@pytest.mark.parametrize(
"setting_key",
[
"PROJECT_NAME",
"ACCESS_TOKEN_EXPIRE_MINUTES",
"LLM_MAX_CONTEXT_TOKENS",
"COOKIECLOUD_INTERVAL",
],
)
def test_recursive_sanitizer_preserves_shared_nonsecret_setting_identities(
setting_key: str,
) -> None:
"""名称中提及凭据概念的普通设置仍应保留诊断值。"""
payload = {
"setting_key": setting_key,
"value": "visible-value",
"value_preview": "visible-value",
}
assert sanitize_for_host(payload) == payload
def test_recursive_sanitizer_fails_closed_when_setting_identity_is_truncated() -> None:
"""设置身份扫描不完整时,已捕获的通用值字段不能按明文放行。"""
payload = {"value": SECRET_MARKER}
payload.update({f"padding_{index}": index for index in range(100)})
payload["setting_key"] = "API_TOKEN"
sanitized = sanitize_for_host(payload)
assert sanitized["value"] == "***"
assert sanitized["<truncated>"] == "more items"
assert SECRET_MARKER not in json.dumps(sanitized, ensure_ascii=False)
@pytest.mark.parametrize(
"field_name",
[
"auth",
"basicAuth",
"authentication",
"httpAuthentication",
"credential",
"credentials",
"serviceCredentials",
],
)
def test_recursive_sanitizer_redacts_credential_containers(
field_name: str,
) -> None:
"""认证与凭据容器必须在读取内部用户名或密码前整体遮蔽。"""
payload = {
field_name: ("alice", SECRET_MARKER),
"authEnabled": True,
"credentialCount": 1,
}
sanitized = sanitize_for_host(payload)
assert sanitized == {
field_name: "***",
"authEnabled": True,
"credentialCount": 1,
}
assert SECRET_MARKER not in str(sanitized)
@pytest.mark.parametrize("field_name", ["oauth", "OAuth", "oauth2", "OAuth2"])
def test_recursive_sanitizer_redacts_oauth_credential_containers(
field_name: str,
) -> None:
"""OAuth 容器判敏必须与字段大小写及数字分词无关。"""
payload = {
field_name: ("alice", SECRET_MARKER),
"oauthEnabled": True,
"OAuthVersion": 2,
}
sanitized = sanitize_for_host(payload)
assert sanitized == {
field_name: "***",
"oauthEnabled": True,
"OAuthVersion": 2,
}
assert SECRET_MARKER not in str(sanitized)
def test_recursive_sanitizer_redacts_named_tuple_secret_fields() -> None:
"""命名元组必须保留字段语义并按字段名脱敏。"""
payload = _NamedTupleCredential(
api_key=SECRET_MARKER,
label="visible-label",
)
sanitized = sanitize_for_host(payload)
assert sanitized == {"api_key": "***", "label": "visible-label"}
assert SECRET_MARKER not in str(sanitized)
def test_sanitizer_rejects_hostile_named_tuple_metadata_without_protocols() -> None:
"""伪造的 `_fields` 与 tuple 覆盖协议不得参与 named-tuple 分类。"""
calls = []
class _HostileFields(tuple):
def __len__(self) -> int:
calls.append("fields.__len__")
raise AssertionError("hostile fields length executed")
def __getitem__(self, index):
calls.append("fields.__getitem__")
raise AssertionError("hostile fields item executed")
class _TupleLike(tuple):
_fields = _HostileFields(("label",))
def __len__(self) -> int:
calls.append("value.__len__")
raise AssertionError("hostile value length executed")
def __getitem__(self, index):
calls.append("value.__getitem__")
raise AssertionError("hostile value item executed")
sanitized = sanitize_for_host(_TupleLike(("visible",)))
assert calls == []
assert sanitized == ["visible"]
@pytest.mark.parametrize(
"value",
[
_AliasedCredentialResult(apiKey=SECRET_MARKER),
_ValidationAliasedCredentialResult(apiToken=SECRET_MARKER),
_ChoiceAliasedCredentialResult(apiKey=SECRET_MARKER),
_PathAliasedCredentialResult(
payload=[{"clientSecret": SECRET_MARKER}]
),
_ChoicePathAliasedCredentialResult(
payload={"refreshToken": SECRET_MARKER}
),
_SerializationAliasedCredentialResult(credential=SECRET_MARKER),
_HostileAliasedCredentialResult.model_validate(
{"apiKey": SECRET_MARKER}
),
_HostilePathAliasedCredentialResult.model_validate(
{"payload": {"clientSecret": SECRET_MARKER}}
),
],
)
def test_recursive_sanitizer_redacts_pydantic_secret_aliases(
value: BaseModel,
) -> None:
"""Pydantic 字段的输入、路径及输出别名均参与凭据判定。"""
sanitized = sanitize_for_host(value)
assert sanitized == {"credential": "***"}
assert SECRET_MARKER not in str(sanitized)
def test_recursive_sanitizer_preserves_pydantic_metadata_alias() -> None:
"""非敏感 Pydantic 外部别名不应遮蔽 metadata 值。"""
assert sanitize_for_host(_AliasedMetadataResult(tokenCount=12)) == {
"count": 12
}
@pytest.mark.parametrize(
"value",
[
_AliasedCredentialDataclass(apiKey=SECRET_MARKER),
_AnnotatedAliasedCredentialDataclass(refreshToken=SECRET_MARKER),
],
)
def test_recursive_sanitizer_redacts_pydantic_dataclass_secret_aliases(
value: object,
) -> None:
"""Pydantic dataclass 的解析后别名元数据同样参与凭据判定。"""
sanitized = sanitize_for_host(value)
assert sanitized == {"credential": "***"}
assert SECRET_MARKER not in str(sanitized)
def test_pydantic_alias_path_limit_applies_before_iteration() -> None:
"""AliasPath 必须先验证长度边界,再读取或复制任何 path part。"""
class _TrackingPath(list):
"""记录 alias path 实际向 sanitizer 交付的 part 数量。"""
yielded_parts = 0
def __iter__(self):
for part in super().__iter__():
type(self).yielded_parts += 1
yield part
alias = AliasPath("placeholder")
alias.path = _TrackingPath(
["metadata"] * (sanitizer_module._MAX_ITEMS + 1)
)
assert sanitizer_module._pydantic_alias_names(alias) is None
assert _TrackingPath.yielded_parts == 0
def test_pydantic_alias_choices_share_one_part_budget() -> None:
"""AliasPath 与普通 choice 共用额度,耗尽后必须 fail-closed。"""
alias = AliasChoices(
AliasPath(*(["metadata"] * sanitizer_module._MAX_ITEMS)),
"metadataTail",
)
budget = [sanitizer_module._MAX_ITEMS]
assert sanitizer_module._pydantic_alias_names(
alias,
_budget=budget,
) is None
assert budget == [0]
class _OversizedAliasResult(BaseModel):
"""模拟 alias part 总量超过宿主固定额度的第三方模型。"""
credential: str = Field(validation_alias=alias)
sanitized = sanitize_for_host(
_OversizedAliasResult.model_construct(credential=SECRET_MARKER)
)
assert sanitized == {"credential": "***"}
assert SECRET_MARKER not in str(sanitized)
@pytest.mark.parametrize(
"field_name",
[
"accessToken",
"refreshToken",
"apiKey",
"APIKey",
"apikey",
"APIKEY",
"authToken",
"clientSecret",
"appSecret",
"proxyAuthorization",
"dbPwd",
"passKey",
"secretKey",
"SECRETKEY",
"AccessKeySecret",
"awsSecretAccessKey",
],
)
def test_recursive_sanitizer_redacts_camel_case_secret_fields(
field_name: str,
) -> None:
"""结构化第三方载荷的驼峰凭据字段必须脱敏,统计字段保持可见。"""
payload = {
field_name: SECRET_MARKER,
"tokenCount": 12,
}
sanitized = sanitize_for_host(payload)
assert sanitized[field_name] == "***"
assert sanitized["tokenCount"] == 12
assert SECRET_MARKER not in str(sanitized)
@pytest.mark.parametrize(
("source", "secret_parts"),
[
(
'password="quoted-secret-alpha quoted-secret-beta"',
("quoted-secret-alpha", "quoted-secret-beta"),
),
(
"password='single-secret-alpha single-secret-beta'",
("single-secret-alpha", "single-secret-beta"),
),
(
f"DATABASE_PASSWORD={SECRET_MARKER}",
(SECRET_MARKER,),
),
(
f"OPENAI_API_KEY={SECRET_MARKER}",
(SECRET_MARKER,),
),
(
f"MOVIEPILOT_API_TOKEN={SECRET_MARKER}",
(SECRET_MARKER,),
),
(
f"authToken={SECRET_MARKER}",
(SECRET_MARKER,),
),
(
f"dbPassword={SECRET_MARKER}",
(SECRET_MARKER,),
),
(
f"secretKey={SECRET_MARKER}",
(SECRET_MARKER,),
),
(
f"proxyAuthorization={SECRET_MARKER}",
(SECRET_MARKER,),
),
(
f"awsSecretAccessKey={SECRET_MARKER}",
(SECRET_MARKER,),
),
(
f"passKey={SECRET_MARKER}",
(SECRET_MARKER,),
),
(
f"url=https://example.invalid/callback?authToken={SECRET_MARKER}",
(SECRET_MARKER,),
),
(
f"{'x' * 300}AuthToken={SECRET_MARKER}",
(SECRET_MARKER,),
),
(
"password=unquoted-secret-alpha unquoted-secret-beta; status=failed",
("unquoted-secret-alpha", "unquoted-secret-beta"),
),
(
"DATABASE_PASSWORD=correct horse battery staple, retry=off",
("correct", "horse", "battery", "staple"),
),
(
f"DATABASE_PASSWORD={SECRET_MARKER}#password-tail&more, retry=off",
(SECRET_MARKER, "password-tail", "more"),
),
],
)
def test_sanitizer_redacts_secret_assignments(
source: str,
secret_parts: tuple[str, ...],
) -> None:
"""常见字段拼写、业务前缀和多词凭据都必须完整脱敏。"""
sanitized = str(sanitize_for_host(source))
assert "***" in sanitized
for secret_part in secret_parts:
assert secret_part not in sanitized
if "; status=failed" in source:
assert "; status=failed" in sanitized
if ", retry=off" in source:
assert ", retry=off" in sanitized
@pytest.mark.parametrize(
"source",
[
"tokenCount=12",
"tokenType=usage",
"secretVersion=2",
"apiKeyId=public-id",
"accessTokenExpiresAt=2030-01-01T00:00:00Z",
"passwordHash=sha256:diagnostic",
"url=https://example.invalid/callback?apiKeyId=public-id",
],
)
def test_sanitizer_preserves_metadata_assignments(source: str) -> None:
"""带凭据词根但以 metadata 语义结尾的字段保持诊断价值。"""
assert sanitize_for_host(source) == source
@pytest.mark.parametrize(
"field_name",
["api key", "access token", "client secret", "refresh token"],
)
@pytest.mark.parametrize("quote", ['"', "'"])
@pytest.mark.parametrize("escape_layers", range(4))
def test_sanitizer_redacts_escaped_quoted_secret_keys_with_spaces(
field_name: str,
quote: str,
escape_layers: int,
) -> None:
"""转义 JSON 片段中的空格分隔凭据名必须复用结构化判敏语义。"""
wrapper = "\\" * escape_layers + quote
source = (
f"payload={{{wrapper}{field_name}{wrapper}:"
f"{wrapper}{SECRET_MARKER}{wrapper}}}"
)
sanitized = str(sanitize_for_host(source))
assert SECRET_MARKER not in sanitized
assert "***" in sanitized
assert field_name in sanitized
@pytest.mark.parametrize("quote", ['"', "'"])
@pytest.mark.parametrize("escape_layers", range(7))
@pytest.mark.parametrize("leading_whitespace", [" ", "\t", " \t"])
def test_sanitizer_redacts_quoted_secret_keys_with_leading_whitespace(
quote: str,
escape_layers: int,
leading_whitespace: str,
) -> None:
"""quoted key 的前导横向空白不得绕过凭据名识别。"""
wrapper = "\\" * escape_layers + quote
source = (
f"payload={{{wrapper}{leading_whitespace}api key{wrapper}:"
f"{wrapper}{SECRET_MARKER}{wrapper}}}"
)
outputs = (
str(sanitize_for_host(source)),
summarize_input(source),
summarize_result(source),
summarize_error(RuntimeError(source)),
)
assert all(SECRET_MARKER not in output for output in outputs)
assert all("***" in output for output in outputs)
def test_sanitizer_preserves_escaped_quoted_metadata_key_with_spaces() -> None:
"""空格分隔的 metadata key 不应因 quoted-key 支持而被误判。"""
source = r'payload=\"{\\\"token count\\\":12}\"'
assert sanitize_for_host(source) == source
@pytest.mark.parametrize("header", ["Authorization", "Proxy-Authorization"])
def test_sanitizer_redacts_basic_auth_in_builtin_tuple_key(
header: str,
) -> None:
"""内建 tuple key 中的 Basic Auth 与 URI userinfo 都不得进入输出 key。"""
basic_token = "YWxpY2U6c3ludGhldGljLXBhc3N3b3Jk"
payload = {
(
header,
f"Basic {basic_token} https://alice:{SECRET_MARKER}@example.invalid",
): "ok"
}
sanitized = sanitize_for_host(payload)
output_key = next(iter(sanitized))
assert sanitized[output_key] == "ok"
assert basic_token not in output_key
assert SECRET_MARKER not in output_key
assert "Basic ***" in output_key
assert "https://***@example.invalid" in output_key
@pytest.mark.parametrize("scheme", ["Basic", "basic", "BASIC"])
@pytest.mark.parametrize(
"basic_token",
[
"dTpw",
"YWxpY2U6cA==",
"YWxpY2U6c3ludGhldGljLXBhc3N3b3Jk",
],
)
def test_sanitizer_redacts_basic_auth_across_host_summaries(
scheme: str,
basic_token: str,
) -> None:
"""裸 Basic Auth token 在全部宿主摘要入口复用中央文本脱敏。"""
source = f"upstream returned {scheme} {basic_token}. status=failed"
outputs = (
str(sanitize_for_host(source)),
summarize_input(source),
summarize_result(source),
summarize_error(RuntimeError(source)),
)
assert all(basic_token not in output for output in outputs)
assert all(f"{scheme} ***" in output for output in outputs)
assert all("status=failed" in output for output in outputs)
@pytest.mark.parametrize(
"source",
[
"transport uses basic mode",
"scheme=Basic dG9rZW4=",
],
)
def test_sanitizer_preserves_noncredential_basic_metadata(source: str) -> None:
"""普通 basic 文案及不含 user:password 的 Base64 metadata 保持可见。"""
assert sanitize_for_host(source) == source
def test_sanitizer_fails_closed_for_truncated_basic_auth_token() -> None:
"""Basic token 在文本上限内未闭合时遮蔽整个已保留前缀。"""
prefix = "log: Basic "
token = base64.b64encode(
b"alice:" + b"x" * sanitizer_module._MAX_TEXT_CHARS
).decode()
source = prefix + token
sanitized = str(sanitize_for_host(source))
assert sanitized == f"{prefix}***<truncated>"
assert token[:100] not in sanitized
def test_sanitizer_fails_closed_for_truncated_basic_auth_in_tuple_key() -> None:
"""tuple renderer 的内部截断事实必须传给 Basic token 脱敏。"""
token = base64.b64encode(
b"alice:" + b"x" * sanitizer_module._MAX_TEXT_CHARS
).decode()
payload = {("Authorization", f"Basic {token}"): "ok"}
sanitized = sanitize_for_host(payload)
output_key = next(iter(sanitized))
assert sanitized[output_key] == "ok"
assert "Basic ***" in output_key
assert "<truncated>" in output_key
assert token[:100] not in output_key
@pytest.mark.parametrize(
("source", "expected"),
[
(
f"url=https://example.invalid/cb?authToken={SECRET_MARKER}&status=ok#done",
"url=https://example.invalid/cb?authToken=***&status=ok#done",
),
(
f"url=https://example.invalid/cb?authToken={SECRET_MARKER}"
f"&refreshToken={SECRET_MARKER}#done",
"url=https://example.invalid/cb?authToken=***&refreshToken=***#done",
),
(
f'message="authToken={SECRET_MARKER}"; status=failed',
'message="authToken=***"; status=failed',
),
(
'message="authToken="; status=ok',
'message="authToken=***"; status=ok',
),
(
"message='authToken='; status=ok",
"message='authToken=***'; status=ok",
),
(
'message="prefix authToken="; status=ok',
'message="prefix authToken=***"; status=ok',
),
(
'authToken=""',
'authToken=***',
),
(
'url=https://example.invalid/cb?authToken=&status=ok',
'url=https://example.invalid/cb?authToken=***&status=ok',
),
(
'authToken="unterminated',
'authToken=***',
),
],
)
def test_sanitizer_preserves_nested_assignment_boundaries(
source: str,
expected: str,
) -> None:
"""内层凭据脱敏后保留 URL 分段与外层引号结构。"""
assert sanitize_for_host(source) == expected
@pytest.mark.parametrize(
("source", "expected"),
[
(
f"database_url=postgresql://alice:{SECRET_MARKER}"
"@example.invalid/media",
"database_url=postgresql://***@example.invalid/media",
),
(
f"endpoint=https://{SECRET_MARKER}@example.invalid/path",
"endpoint=https://***@example.invalid/path",
),
(
f"dsn=postgresql://alice:{SECRET_MARKER}%40tail"
"@[2001:db8::1]:5432/media?sslmode=require",
"dsn=postgresql://***@[2001:db8::1]:5432/media?sslmode=require",
),
(
f"primary=https://alice:{SECRET_MARKER}@one.invalid/a "
f"secondary=redis://:{SECRET_MARKER}-two@two.invalid/0",
"primary=https://***@one.invalid/a "
"secondary=redis://***@two.invalid/0",
),
(
f"https://alice:{SECRET_MARKER}@one.invalid,"
f"redis://:{SECRET_MARKER}-two@two.invalid/0",
"https://***@one.invalid,redis://***@two.invalid/0",
),
(
f"https://alice:{SECRET_MARKER}@one.invalid;"
f"redis://:{SECRET_MARKER}-two@two.invalid/0",
"https://***@one.invalid;redis://***@two.invalid/0",
),
(
f"https://alice:{SECRET_MARKER}@one.invalid|"
f"redis://:{SECRET_MARKER}-two@two.invalid/0",
"https://***@one.invalid|redis://***@two.invalid/0",
),
],
)
def test_sanitizer_redacts_uri_userinfo(source: str, expected: str) -> None:
"""URI authority 中的 userinfo 不得进入宿主摘要。"""
assert sanitize_for_host(source) == expected
@pytest.mark.parametrize("escape_layers", [1, 2])
def test_sanitizer_redacts_slash_escaped_uri_userinfo(
escape_layers: int,
) -> None:
"""嵌入诊断文本中的 slash-escaped URI 仍须清理 userinfo。"""
separator = ":" + "\\" * escape_layers + "/" + "\\" * escape_layers + "/"
source = (
r'payload={\"dsn\":\"postgresql'
f"{separator}alice:{SECRET_MARKER}@example.invalid/media"
r'\"}'
)
sanitized = str(sanitize_for_host(source))
assert SECRET_MARKER not in sanitized
assert f"postgresql{separator}***@example.invalid/media" in sanitized
def test_sanitizer_preserves_slash_escaped_uri_without_userinfo() -> None:
"""slash-escaped URI 没有 userinfo 时保持原始诊断文本。"""
source = r"url=https:\/\/example.invalid/path?email=user@example.invalid"
assert sanitize_for_host(source) == source
def test_sanitizer_redacts_truncated_uri_with_unresolved_userinfo() -> None:
"""截断点前无法确认 authority 结束时按敏感内容处理。"""
source = (
f"dsn=postgresql://alice:{SECRET_MARKER}"
f"{'x' * (16 * 1024)}@example.invalid/media"
)
summaries = (
summarize_input(source),
summarize_result(source),
summarize_error(RuntimeError(source)),
)
assert all(SECRET_MARKER not in summary for summary in summaries)
assert all("***" in summary for summary in summaries)
assert all("<truncated>" in summary for summary in summaries)
def test_sanitizer_redacts_truncated_uri_after_early_at_sign() -> None:
"""截断 authority 内的早期 `@` 不能证明 userinfo 已完整结束。"""
trailing_secret = "truncated-uri-tail-secret-5931"
prefix = f"https://user:{SECRET_MARKER}@{trailing_secret}"
source = (
prefix
+ "x" * (16 * 1024 - len(prefix))
+ "@example.invalid/media"
)
summaries = (
summarize_input(source),
summarize_result(source),
summarize_error(RuntimeError(source)),
)
assert all(SECRET_MARKER not in summary for summary in summaries)
assert all(trailing_secret not in summary for summary in summaries)
assert all("***" in summary for summary in summaries)
assert all("<truncated>" in summary for summary in summaries)
@pytest.mark.parametrize(
"source",
[
'payload="{\\"apiKey\\":\\"' + SECRET_MARKER + '\\"}"',
rf'payload=\"{{\\\"apiKey\\\":\\\"{SECRET_MARKER}\\\"}}\"',
],
)
def test_sanitizer_redacts_escaped_json_secret_fields(source: str) -> None:
"""普通文本内多层转义的 JSON 凭据字段仍须脱敏。"""
sanitized = str(sanitize_for_host(source))
assert SECRET_MARKER not in sanitized
assert "***" in sanitized
assert "}" in sanitized
def test_sanitizer_preserves_tail_after_escaped_json_secret() -> None:
"""转义 JSON 凭据中的分隔符不应截断脱敏或吞掉后续字段。"""
source = (
'payload="{\\"apiKey\\":\\"'
f"{SECRET_MARKER},still-secret"
'\\",\\"status\\":\\"ok\\"}"'
)
sanitized = str(sanitize_for_host(source))
assert SECRET_MARKER not in sanitized
assert "still-secret" not in sanitized
assert "status" in sanitized
assert "ok" in sanitized
@pytest.mark.parametrize("escape_layers", [0, 1, 2])
def test_sanitizer_handles_trailing_backslashes_before_secret_quote(
escape_layers: int,
) -> None:
"""凭据值末尾的 literal backslash 不得吞掉后续敏感字段。"""
payload = (
'{"authToken":"first-secret\\\\",'
'"refreshToken":"second-secret","status":"ok"}'
)
for _ in range(escape_layers):
escaped_payload = payload.replace("\\", "\\\\").replace('"', '\\"')
payload = f'"{escaped_payload}"'
source = f"payload={payload}"
sanitized = str(sanitize_for_host(source))
assert "first-secret" not in sanitized
assert "second-secret" not in sanitized
assert sanitized.count("***") == 2
assert "status" in sanitized
assert "ok" in sanitized
def test_sanitizer_preserves_escaped_json_metadata_fields() -> None:
"""转义 JSON 中的 metadata 字段保持诊断值。"""
source = 'payload="{\\"apiKeyId\\":\\"public-id\\"}"'
assert sanitize_for_host(source) == source
def test_sanitizer_preserves_uri_without_userinfo() -> None:
"""不含 userinfo 的 URL 及 query 邮箱保持原始诊断信息。"""
source = "url=https://example.invalid/path?email=user@example.invalid"
assert sanitize_for_host(source) == source
@pytest.mark.parametrize(
"unit",
["a=", "a.", "a://host/", "\\", "\\\""],
)
def test_sanitizer_assignment_scan_scales_at_text_limit(unit: str) -> None:
"""赋值链和无头字段链在宿主文本上限内保持近似线性扫描。"""
def median_duration(size: int) -> float:
source = (unit * (size // len(unit) + 1))[:size]
durations = []
for _ in range(3):
started_at = perf_counter()
assert sanitize_for_host(source) == source
durations.append(perf_counter() - started_at)
return median(durations)
small_duration = median_duration(4 * 1024)
max_duration = median_duration(16 * 1024)
# 4x 输入允许 10x 时间与 20ms 调度余量,同时约束同步宿主观测的延迟增长。
assert max_duration <= small_duration * 10 + 0.02
def test_secret_assignment_slash_run_scales_at_text_limit() -> None:
"""凭据值中的连续反斜杠必须单向扫描,不能重复遍历同一后缀。"""
def median_duration(slash_count: int) -> float:
source = "password=" + "\\" * slash_count + "tail"
durations = []
for _ in range(3):
started_at = perf_counter()
sanitized = str(sanitize_for_host(source))
durations.append(perf_counter() - started_at)
assert sanitized.startswith("password=***")
return median(durations)
small_duration = median_duration(4 * 1024)
max_duration = median_duration(sanitizer_module._MAX_TEXT_CHARS)
# 4x 输入允许 10x 时间与 50ms 调度余量,同时排除平方级同步扫描。
assert max_duration <= small_duration * 10 + 0.05
def test_sanitizer_bounds_oversized_mapping_key_normalization() -> None:
"""超长结构化字段只允许固定窗口进入凭据名规范化。"""
class _TrackingPattern:
"""记录正则收到的最大文本长度并复用真实匹配行为。"""
def __init__(self, pattern) -> None:
self.pattern = pattern
self.max_chars = 0
def sub(self, replacement: str, value: str) -> str:
self.max_chars = max(self.max_chars, len(value))
return self.pattern.sub(replacement, value)
padding = "x" * (2 * 1024 * 1024)
secret_pattern = _TrackingPattern(
sanitizer_module._ACRONYM_BOUNDARY_PATTERN
)
camel_pattern = _TrackingPattern(
sanitizer_module._CAMEL_CASE_BOUNDARY_PATTERN
)
payload = {
f"secret-prefix-{padding}AuthToken": SECRET_MARKER,
f"metadata-prefix-{padding}tokenCount": 12,
}
with (
patch.object(
sanitizer_module,
"_ACRONYM_BOUNDARY_PATTERN",
secret_pattern,
),
patch.object(
sanitizer_module,
"_CAMEL_CASE_BOUNDARY_PATTERN",
camel_pattern,
),
):
sanitized = sanitize_for_host(payload)
assert SECRET_MARKER not in str(sanitized)
assert "***" in sanitized.values()
assert 12 in sanitized.values()
assert secret_pattern.max_chars <= 1024
assert camel_pattern.max_chars <= 1024
@pytest.mark.parametrize("value", [SECRET_MARKER, 12])
def test_sanitizer_does_not_stringify_dynamic_mapping_key(
value: object,
) -> None:
"""动态 Mapping key 不执行字符串协议,值按未知字段保守遮蔽。"""
class _StatefulKey:
"""通过字符串协议伪装字段语义的第三方 key。"""
def __init__(self) -> None:
self.calls = 0
def __str__(self) -> str:
self.calls += 1
return "api_key"
key = _StatefulKey()
sanitized = sanitize_for_host({key: value})
assert key.calls == 0
assert sanitized == {"<key:_StatefulKey>": "***"}
assert SECRET_MARKER not in str(sanitized)
def test_sanitizer_redacts_value_for_uninspectable_mapping_key() -> None:
"""无法取得稳定名称的 Mapping key 按敏感字段处理。"""
class _UninspectableKey:
"""模拟字符串协议故障的第三方 key。"""
def __str__(self) -> str:
raise RuntimeError(f"unavailable key {SECRET_MARKER}")
sanitized = sanitize_for_host({_UninspectableKey(): SECRET_MARKER})
assert sanitized == {"<key:_UninspectableKey>": "***"}
assert SECRET_MARKER not in str(sanitized)
def test_sanitizer_bounds_shared_reference_expansion() -> None:
"""整个净化调用共享工作预算,重复引用不能按分支指数展开。"""
class _CountingValue:
"""记录共享叶节点被字符串化的次数。"""
def __init__(self) -> None:
self.calls = 0
def __str__(self) -> str:
self.calls += 1
return "visible-leaf"
leaf = _CountingValue()
payload = leaf
for _ in range(7):
payload = [payload] * 5
sanitized = sanitize_for_host(payload)
assert leaf.calls <= 2000
assert "<work-limit>" in str(sanitized)
def test_sanitizer_budget_bounds_container_item_expansion() -> None:
"""共享 DAG 的容器读取和输出项总量必须受全调用预算约束。"""
class _CountingMapping(dict):
"""统计 Mapping iterator 实际交付给 sanitizer 的项数。"""
yielded_items = 0
def items(self):
for item in super().items():
type(self).yielded_items += 1
yield item
def count_entries(value: object) -> int:
"""统计 sanitizer 结果中实际生成的容器项数。"""
if isinstance(value, dict):
return len(value) + sum(count_entries(item) for item in value.values())
if isinstance(value, list):
return len(value) + sum(count_entries(item) for item in value)
return 0
shared: object = {"label": "visible"}
for level in range(7):
node = _CountingMapping(
{
f"field{level}_{index}ApiKey": SECRET_MARKER
for index in range(97)
}
)
node.update({f"child{index}": shared for index in range(3)})
shared = node
sanitized = sanitize_for_host(shared)
max_emitted_items = sanitizer_module._MAX_WORK_ITEMS + 16
assert _CountingMapping.yielded_items <= max_emitted_items
assert count_entries(sanitized) <= max_emitted_items
assert "<work-limit>" in str(sanitized)
def test_camel_case_secret_assignment_is_redacted_from_host_summaries() -> None:
"""输入、结果和异常摘要共享非结构化凭据赋值的脱敏契约。"""
source = f"authToken={SECRET_MARKER}"
summaries = (
summarize_input(source),
summarize_result(source),
summarize_error(RuntimeError(source)),
)
assert all(SECRET_MARKER not in summary for summary in summaries)
assert all("***" in summary for summary in summaries)
@pytest.mark.parametrize(
"container_value",
[
f"['{SECRET_MARKER}', 'second-list-secret']",
(
"{'primary': '"
f"{SECRET_MARKER}', 'nested': ['second-dict-secret', {{'ok': true}}]}}"
),
f"('{SECRET_MARKER}', ('second-tuple-secret', 2))",
],
)
def test_sanitizer_redacts_complete_unquoted_secret_container_assignment(
container_value: str,
) -> None:
"""未加引号的嵌套容器凭据值必须整体遮蔽并保留后续字段。"""
source = f"password={container_value}, operation=connect"
sanitized = str(sanitize_for_host(source))
assert sanitized == "password=***, operation=connect"
assert SECRET_MARKER not in sanitized
assert "second-" not in sanitized
def test_sanitizer_fails_closed_for_unclosed_secret_container_assignment() -> None:
"""未闭合的凭据容器无法确认边界时遮蔽剩余文本。"""
source = (
f"password=['{SECRET_MARKER}', 'unclosed-container-secret', "
"operation=connect"
)
sanitized = str(sanitize_for_host(source))
assert sanitized == "password=***"
assert SECRET_MARKER not in sanitized
assert "unclosed-container-secret" not in sanitized
def test_sanitizer_redacts_secret_tail_after_closed_assignment_container() -> None:
"""容器闭合符不代表凭据值结束,尾随内容也必须遮蔽。"""
source = (
f"password=['{SECRET_MARKER}']tail-container-secret, "
"operation=connect"
)
sanitized = str(sanitize_for_host(source))
assert sanitized == "password=***, operation=connect"
assert "tail-container-secret" not in sanitized
@pytest.mark.parametrize(
"source",
[
f"password=[first\\], {SECRET_MARKER}], status=ok",
f"password=(first\\), {SECRET_MARKER}), status=ok",
"password={first\\}, " + SECRET_MARKER + "}, status=ok",
],
)
def test_sanitizer_ignores_escaped_assignment_container_closers(
source: str,
) -> None:
"""未引号容器中的转义闭合符不得提前结束凭据扫描。"""
sanitized = str(sanitize_for_host(source))
assert sanitized == "password=***, status=ok"
assert SECRET_MARKER not in sanitized
@pytest.mark.parametrize(
"source",
[
f"password=prefix[{SECRET_MARKER}, second-prefix-secret], status=ok",
f"password=call({SECRET_MARKER}, second-call-secret), status=ok",
(
"password=\\["
f"{SECRET_MARKER}, second-escaped-open-secret], status=ok"
),
],
)
def test_sanitizer_tracks_containers_after_unquoted_value_prefix(
source: str,
) -> None:
"""未引号值任意位置的容器均须屏蔽其内部字段分隔符。"""
sanitized = str(sanitize_for_host(source))
assert sanitized == "password=***, status=ok"
assert SECRET_MARKER not in sanitized
assert "second-" not in sanitized
@pytest.mark.parametrize(
("source", "expected"),
[
(
f'password=prefix"{SECRET_MARKER}, second-double-secret", status=ok',
"password=***, status=ok",
),
(
f"password=prefix'{SECRET_MARKER}, second-single-secret', status=ok",
"password=***, status=ok",
),
(
f'message="password=prefix\'{SECRET_MARKER}, second-inner-secret\'"; '
"status=ok",
'message="password=***"; status=ok',
),
],
)
def test_sanitizer_tracks_quoted_fragments_inside_unquoted_secret_value(
source: str,
expected: str,
) -> None:
"""值中途的 quoted fragment 不得让内部逗号提前结束脱敏。"""
sanitized = str(sanitize_for_host(source))
assert sanitized == expected
assert SECRET_MARKER not in sanitized
assert "second-" not in sanitized
@pytest.mark.parametrize("quote", ['"', "'"])
@pytest.mark.parametrize("escape_layers", [1, 2, 3])
def test_sanitizer_tracks_escaped_quoted_fragments_inside_secret_value(
quote: str,
escape_layers: int,
) -> None:
"""多层 slash-escaped quoted fragment 的内部逗号仍属于凭据值。"""
wrapper = "\\" * escape_layers + quote
source = (
f"password=prefix{wrapper}{SECRET_MARKER}, second-escaped-secret"
f"{wrapper}, status=ok"
)
sanitized = str(sanitize_for_host(source))
assert sanitized == "password=***, status=ok"
assert SECRET_MARKER not in sanitized
assert "second-escaped-secret" not in sanitized
def test_sanitizer_redacts_unquoted_multiword_secret_in_error_summary() -> None:
"""异常中的无引号多词凭据必须净化到可靠分隔符。"""
summary = summarize_error(
RuntimeError("password=alpha beta; operation=connect")
)
assert "alpha" not in summary
assert "beta" not in summary
assert "operation=connect" in summary
def test_pydantic_validation_error_is_safe_across_host_entry_points() -> None:
"""Pydantic 原始输入不得从递归 sanitizer 或任一摘要入口回显。"""
with pytest.raises(ValidationError) as exc_info:
_InvalidSecretInput(api_key=SECRET_MARKER)
error = exc_info.value
sanitized = sanitize_for_host(error)
outputs = (
str(sanitized),
str(sanitize_for_host({"error": error})),
summarize_input(error),
summarize_result({"error": error}),
summarize_error(error),
)
assert all(SECRET_MARKER not in output for output in outputs)
assert sanitized == {"error_count": 1}
assert "ValidationError" in outputs[-1]
def test_pydantic_validation_error_excludes_dynamic_metadata() -> None:
"""动态错误位置、类型、消息和上下文均不得成为宿主诊断文本。"""
with pytest.raises(ValidationError) as location_exc_info:
_DynamicSecretLocationInput(
payload={SECRET_MARKER: "not-an-integer"}
)
with pytest.raises(ValidationError) as custom_exc_info:
_CustomSecretValidationInput(value=SECRET_MARKER)
outputs = []
for error in (location_exc_info.value, custom_exc_info.value):
outputs.extend(
(
str(sanitize_for_host(error)),
str(sanitize_for_host({"error": error})),
summarize_result({"error": error}),
summarize_error(error),
)
)
assert all(SECRET_MARKER not in output for output in outputs)
assert all("error_count" in output for output in outputs)
def test_pydantic_validation_error_count_does_not_expand_details() -> None:
"""校验错误计数不得构造完整 errors 明细。"""
with pytest.raises(ValidationError) as exc_info:
_InvalidSecretInput(api_key=SECRET_MARKER)
with patch.object(
ValidationError,
"errors",
side_effect=AssertionError("validation details must not be expanded"),
) as mock_errors:
sanitized = sanitize_for_host(exc_info.value)
mock_errors.assert_not_called()
assert sanitized == {"error_count": 1}
def test_sanitizer_type_fallback_ignores_hostile_metaclass() -> None:
"""对象协议与类型名读取同时失败时仍应返回稳定占位。"""
secret_marker = "hostile-type-secret-4381"
class _HostileMeta(type):
def __getattribute__(cls, name):
if name == "__name__":
raise RuntimeError(f"DATABASE_PASSWORD={secret_marker}")
return super().__getattribute__(name)
class _HostileValue(metaclass=_HostileMeta):
def __str__(self) -> str:
raise RuntimeError("string conversion failed")
escaped = False
try:
sanitized = sanitize_for_host(_HostileValue())
except BaseException:
escaped = True
sanitized = ""
assert escaped is False
assert str(sanitized).startswith("<unavailable:")
assert secret_marker not in str(sanitized)
def test_sanitizer_does_not_stringify_unsupported_leaf_objects() -> None:
"""未知叶对象只输出固定类型占位,不能执行无界字符串协议。"""
class _UnsupportedLeaf:
"""记录 sanitizer 是否调用第三方字符串协议。"""
calls = 0
class_reads = 0
def __getattribute__(self, name: str):
if name == "__class__":
type(self).class_reads += 1
return object.__getattribute__(self, name)
def __str__(self) -> str:
type(self).calls += 1
return SECRET_MARKER
leaf = _UnsupportedLeaf()
sanitized = sanitize_for_host(leaf)
summary = summarize_result(leaf)
assert _UnsupportedLeaf.calls == 0
assert _UnsupportedLeaf.class_reads == 0
assert sanitized == "<unavailable:_UnsupportedLeaf>"
assert summary == "<unavailable:_UnsupportedLeaf>"
assert SECRET_MARKER not in summary
def test_sanitizer_does_not_query_hostile_metaclass_for_dataclass_marker() -> None:
"""未知叶对象的 dataclass 分派不得执行自定义 metaclass 属性协议。"""
class _HostileMeta(type):
dataclass_reads = 0
def __getattribute__(cls, name: str):
if name == "__dataclass_fields__":
reads = type.__getattribute__(_HostileMeta, "dataclass_reads")
type.__setattr__(_HostileMeta, "dataclass_reads", reads + 1)
raise RuntimeError(SECRET_MARKER)
return type.__getattribute__(cls, name)
class _UnsupportedLeaf(metaclass=_HostileMeta):
pass
sanitized = sanitize_for_host(_UnsupportedLeaf())
assert type.__getattribute__(_HostileMeta, "dataclass_reads") == 0
assert sanitized == "<unavailable:_UnsupportedLeaf>"
assert SECRET_MARKER not in sanitized
def test_sanitizer_reads_exception_args_without_custom_string_protocol() -> None:
"""异常摘要保留安全参数,但不得调用异常子类的自定义字符串协议。"""
class _HostileError(RuntimeError):
"""通过字符串协议回显凭据的第三方异常。"""
calls = 0
class_reads = 0
def __getattribute__(self, name: str):
if name in ("__class__", "args"):
type(self).class_reads += 1
return RuntimeError.__getattribute__(self, name)
def __str__(self) -> str:
type(self).calls += 1
return SECRET_MARKER
error = _HostileError("operation=connect")
summary = summarize_error(error)
assert _HostileError.calls == 0
assert _HostileError.class_reads == 0
assert "operation=connect" in summary
assert SECRET_MARKER not in summary
def test_sanitizer_bounds_json_shaped_text_before_parsing() -> None:
"""超过文本上限的 JSON 外形输入不得触发完整解析。"""
secret_marker = "oversized-json-secret-9056"
source = (
'{"password":"' + secret_marker + '","padding":"' + "x" * 20000 + '"}'
)
with patch(
"app.agent.policy.sanitizer.json.loads",
side_effect=AssertionError("oversized JSON must not be parsed"),
) as mock_loads:
sanitized = str(sanitize_for_host(source))
mock_loads.assert_not_called()
assert secret_marker not in sanitized
assert sanitized.endswith("<truncated>")
assert len(sanitized) < 17000
def test_sanitizer_fails_closed_for_oversized_json_identity_values() -> None:
"""超长 JSON 无法确认对象身份时,窗口内通用值字段必须脱敏。"""
secret_marker = "oversized-setting-secret-marker"
source = json.dumps(
{
"setting_key": "API_TOKEN",
"value_preview": secret_marker,
"value": secret_marker + "x" * sanitizer_module._MAX_TEXT_CHARS,
}
)
with patch(
"app.agent.policy.sanitizer.json.loads",
side_effect=AssertionError("oversized JSON must not be parsed"),
) as mock_loads:
sanitized = str(sanitize_for_host(source))
mock_loads.assert_not_called()
assert secret_marker not in sanitized
assert '"value_preview": ***' in sanitized
assert '"value": ***' in sanitized
assert sanitized.endswith("<truncated>")
def test_sanitizer_handles_cyclic_command_without_raising() -> None:
"""循环 LangGraph Command 必须生成有界摘要而不是破坏工具成功结果。"""
cycle = []
command = Command(update={"state": cycle})
cycle.append(command)
summary = summarize_result(command, max_chars=240)
assert len(summary) <= 240
assert summary
def test_tool_result_json_fallback_warning_is_sanitized() -> None:
"""结果序列化 fallback 不能把第三方异常中的凭据写入 warning。"""
class _FallbackResult:
def __init__(self) -> None:
self.calls = 0
def __str__(self) -> str:
self.calls += 1
if self.calls == 1:
raise RuntimeError(f"DATABASE_PASSWORD={SECRET_MARKER}")
return "fallback-result"
mock_logger = MagicMock()
with patch("app.agent.tools.base.logger", mock_logger):
result = serialize_tool_result_for_agent(_FallbackResult())
assert result == "fallback-result"
logged = _logged_text(mock_logger)
assert SECRET_MARKER not in logged
assert "RuntimeError" in logged
def test_summary_helpers_bound_output_without_losing_normal_context() -> None:
"""输入、结果与异常摘要应有界且保留非敏感诊断上下文。"""
payload = {
"query": "MoviePilot",
"password": SECRET_MARKER,
"body": "x" * 2000,
}
input_summary = summarize_input(payload, max_chars=240)
result_summary = summarize_result(payload, max_chars=240)
error_summary = summarize_error(
RuntimeError(f"Authorization: Bearer {SECRET_MARKER}"),
max_chars=240,
)
for summary in (input_summary, result_summary, error_summary):
assert len(summary) <= 240
assert SECRET_MARKER not in summary
assert "MoviePilot" in input_summary
def test_agent_tool_logs_are_sanitized_but_shadow_result_is_unchanged() -> None:
"""G1 只净化宿主日志,shadow 工具返回值仍保持兼容。"""
tool = _SecretResultTool(session_id="session-1", user_id="user-1")
payload = {"token": SECRET_MARKER, "label": "visible"}
mock_logger = MagicMock()
with patch("app.agent.tools.base.logger", mock_logger):
result = asyncio.run(tool._arun(payload=payload))
assert SECRET_MARKER in result
logged = _logged_text(mock_logger)
assert SECRET_MARKER not in logged
assert "visible" in logged
def test_direct_manager_logs_are_sanitized_but_result_is_unchanged() -> None:
"""HTTP/MCP/CLI manager 与 Agent 路径使用同一 secret-safe 日志语义。"""
tool = _SecretResultTool(session_id="session-1", user_id="user-1")
manager = MoviePilotToolsManager(is_admin=True)
manager.tools = [tool]
payload = {"cookie": SECRET_MARKER, "label": "visible"}
mock_logger = MagicMock()
with (
patch("app.agent.tools.manager.logger", mock_logger),
patch("app.agent.policy.orchestrator.logger", mock_logger),
):
result = asyncio.run(manager.call_tool(tool.name, {"payload": payload}))
assert SECRET_MARKER in result
logged = _logged_text(mock_logger)
assert SECRET_MARKER not in logged
assert "visible" in logged
def test_direct_secret_setting_result_is_returned_without_entering_policy_logs() -> None:
"""管理员显式读取凭据时,原值只返回调用方,不进入宿主策略日志。"""
tool = QuerySystemSettingsTool(session_id="session-1", user_id="admin")
tool.set_agent_context({"is_admin": True})
manager = MoviePilotToolsManager(is_admin=True)
manager.tools = [tool]
mock_logger = MagicMock()
with (
patch.object(
QuerySystemSettingsTool,
"_load_setting_value",
return_value=SECRET_MARKER,
),
patch("app.agent.tools.manager.logger", mock_logger),
patch("app.agent.policy.orchestrator.logger", mock_logger),
):
result = asyncio.run(
manager.call_tool(
tool.name,
{"setting_key": "API_TOKEN", "show_secrets": True},
)
)
assert SECRET_MARKER in result
logged = _logged_text(mock_logger)
assert SECRET_MARKER not in logged
assert '"value": "***"' in logged
assert '"value_preview": "***"' in logged
def test_oversized_direct_secret_setting_result_stays_out_of_policy_logs() -> None:
"""超长管理员读取结果仍只返回调用方,不进入 direct 策略回执。"""
secret_marker = "oversized-direct-secret-marker"
secret_value = secret_marker + "x" * sanitizer_module._MAX_TEXT_CHARS
tool = QuerySystemSettingsTool(session_id="session-1", user_id="admin")
tool.set_agent_context({"is_admin": True})
manager = MoviePilotToolsManager(is_admin=True)
manager.tools = [tool]
mock_logger = MagicMock()
with (
patch.object(
QuerySystemSettingsTool,
"_load_setting_value",
return_value=secret_value,
),
patch("app.agent.tools.manager.logger", mock_logger),
patch("app.agent.policy.orchestrator.logger", mock_logger),
):
result = asyncio.run(
manager.call_tool(
tool.name,
{"setting_key": "API_TOKEN", "show_secrets": True},
)
)
assert secret_marker in result
logged = _logged_text(mock_logger)
assert secret_marker not in logged
assert '"value_preview": ***' in logged
def test_tool_error_does_not_echo_secret_to_logs_or_result() -> None:
"""异常消息中的凭据既不能进日志,也不能回显给模型或 direct 调用方。"""
tool = _SecretErrorTool(session_id="session-1", user_id="user-1")
payload = {"token": SECRET_MARKER}
mock_logger = MagicMock()
with patch("app.agent.tools.base.logger", mock_logger):
result = asyncio.run(tool._arun(payload=payload))
assert SECRET_MARKER not in result
assert SECRET_MARKER not in _logged_text(mock_logger)
assert "***" in result