Files
MoviePilot/tests/test_agent_tool_result_policy.py
2026-08-12 10:48:14 +08:00

1782 lines
56 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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