refactor(agent): align policy contracts with runtime (#6306)

This commit is contained in:
InfinityPacer
2026-08-13 22:14:43 +08:00
committed by GitHub
parent 57e7f86f38
commit 402543f636
9 changed files with 42 additions and 573 deletions
+1 -2
View File
@@ -114,8 +114,7 @@ class AgentPolicyMiddleware(AgentMiddleware):
handler=lambda: handler(request), handler=lambda: handler(request),
enforce_decision=False, enforce_decision=False,
) )
# 普通 ToolNode 在严格策略接管前保持 shadow 观测语义。 # 普通 ToolNode 保持 shadow 观测;已确认调用使用默认的强制决策语义。
# 已确认的受保护调用会使用默认的强制决策语义。
return result return result
async def execute_tool_call( async def execute_tool_call(
-16
View File
@@ -4,14 +4,9 @@ from app.agent.policy.contracts import (
ActionEffect, ActionEffect,
ActionPolicy, ActionPolicy,
AuthSource, AuthSource,
CanonicalInvocation,
ConversationKind,
DeliveryTarget,
ConfirmationMode, ConfirmationMode,
ExecutionOutcome, ExecutionOutcome,
ExecutionReceipt, ExecutionReceipt,
InboundEnvelope,
InboundProvenance,
MigrationState, MigrationState,
PolicyDecision, PolicyDecision,
PolicyObservation, PolicyObservation,
@@ -19,10 +14,7 @@ from app.agent.policy.contracts import (
PrincipalRole, PrincipalRole,
PrincipalType, PrincipalType,
RecoveryMode, RecoveryMode,
RECEIPT_STATE_TRANSITIONS,
ReceiptState,
ResultSensitivity, ResultSensitivity,
TERMINAL_RECEIPT_STATES,
ToolInvocation, ToolInvocation,
ToolOrigin, ToolOrigin,
ToolPolicyContext, ToolPolicyContext,
@@ -48,16 +40,11 @@ __all__ = [
"ActionPolicy", "ActionPolicy",
"AgentToolPolicyOrchestrator", "AgentToolPolicyOrchestrator",
"AuthSource", "AuthSource",
"CanonicalInvocation",
"ConversationKind",
"DeliveryTarget",
"ConfirmationMode", "ConfirmationMode",
"DEFAULT_TOOL_POLICY_ORCHESTRATOR", "DEFAULT_TOOL_POLICY_ORCHESTRATOR",
"DEFAULT_TOOL_POLICY_REGISTRY", "DEFAULT_TOOL_POLICY_REGISTRY",
"ExecutionOutcome", "ExecutionOutcome",
"ExecutionReceipt", "ExecutionReceipt",
"InboundEnvelope",
"InboundProvenance",
"MigrationState", "MigrationState",
"PolicyDecision", "PolicyDecision",
"PolicyObservation", "PolicyObservation",
@@ -66,10 +53,7 @@ __all__ = [
"PrincipalType", "PrincipalType",
"REDACTED_VALUE", "REDACTED_VALUE",
"RecoveryMode", "RecoveryMode",
"RECEIPT_STATE_TRANSITIONS",
"ReceiptState",
"ResultSensitivity", "ResultSensitivity",
"TERMINAL_RECEIPT_STATES",
"ToolInvocation", "ToolInvocation",
"ToolOrigin", "ToolOrigin",
"ToolPolicyContext", "ToolPolicyContext",
-130
View File
@@ -1,130 +0,0 @@
"""严格工具调用规范化,不读取设置值或执行工具实现。"""
import hashlib
import json
from typing import Any, Mapping
from pydantic import BaseModel, ValidationError
from app.agent.policy.contracts import (
ActionPolicy,
CanonicalInvocation,
ToolRevision,
)
from app.agent.tools.impl._system_setting_utils import (
list_setting_specs,
resolve_setting_spec,
)
class CanonicalizationError(ValueError):
"""当前工具 schema 或静态前提无法产生严格调用时的稳定失败。"""
def _stable_json(value: Any) -> str:
"""生成拒绝 NaN 且保留 Unicode 的稳定紧凑 JSON。"""
try:
return json.dumps(
value,
ensure_ascii=False,
allow_nan=False,
sort_keys=True,
separators=(",", ":"),
)
except (TypeError, ValueError, ValidationError) as error:
raise CanonicalizationError("调用参数无法规范化") from error
def _accepted_argument_names(args_schema: type[BaseModel]) -> set[str]:
"""返回严格入口可接受的字段名与字符串别名。"""
accepted = set(args_schema.model_fields)
for field in args_schema.model_fields.values():
for alias in (field.alias, field.validation_alias):
if isinstance(alias, str):
accepted.add(alias)
return accepted
def canonicalize_invocation(
*,
tool: Any,
arguments: Mapping[str, Any],
policy: ActionPolicy,
tool_revision: ToolRevision,
) -> CanonicalInvocation:
"""使用当前 Pydantic schema 与静态设置定义生成不可变调用摘要。"""
tool_name = str(getattr(tool, "name", "") or "")
args_schema = getattr(tool, "args_schema", None)
if not tool_name or not isinstance(args_schema, type) or not issubclass(
args_schema, BaseModel
):
raise CanonicalizationError("工具缺少严格 Pydantic 参数契约")
raw_arguments = dict(arguments or {})
unknown_arguments = set(raw_arguments) - _accepted_argument_names(args_schema)
if unknown_arguments:
raise CanonicalizationError("工具参数校验失败")
try:
validated = args_schema.model_validate(raw_arguments)
normalized = validated.model_dump(
mode="json",
exclude_unset=False,
exclude_none=False,
)
schema_json = _stable_json(args_schema.model_json_schema())
except (TypeError, ValueError) as error:
raise CanonicalizationError("工具参数校验失败") from error
preconditions: tuple[tuple[str, str], ...] = ()
setting_key = normalized.get("setting_key")
if tool_name == "query_system_settings":
if setting_key:
spec = resolve_setting_spec(setting_key)
if spec is None:
raise CanonicalizationError("系统设置项不存在")
specs = [spec]
else:
try:
specs = list_setting_specs(
group=normalized.get("group"),
keyword=normalized.get("keyword"),
)
except ValueError as error:
raise CanonicalizationError("系统设置选择器无效") from error
if not specs:
raise CanonicalizationError("系统设置选择器没有匹配项")
preconditions = tuple(
(
"setting",
f"{spec.source}:{spec.key}:{spec.group}",
)
for spec in specs
)
payload = {
"canonical_version": "p1-g2a2-canonical-v1",
"action_subtype": policy.effect.value,
"arguments": normalized,
"policy_version": policy.policy_version,
"preconditions": preconditions,
"schema_digest": hashlib.sha256(schema_json.encode("utf-8")).hexdigest(),
"tool_name": tool_name,
"tool_revision": {
"factory": tool_revision.factory,
"implementation": tool_revision.implementation,
"plugin": tool_revision.plugin,
},
}
canonical_json = _stable_json(payload)
return CanonicalInvocation(
tool_name=tool_name,
arguments=normalized,
canonical_json=canonical_json,
digest=hashlib.sha256(canonical_json.encode("utf-8")).hexdigest(),
policy_version=policy.policy_version,
tool_revision=tool_revision,
schema_digest=payload["schema_digest"],
preconditions=preconditions,
)
__all__ = ["CanonicalizationError", "canonicalize_invocation"]
+4 -164
View File
@@ -2,23 +2,9 @@
from dataclasses import dataclass, field from dataclasses import dataclass, field
from enum import Enum from enum import Enum
from types import MappingProxyType
from typing import Any, Mapping, MutableMapping, Optional from typing import Any, Mapping, MutableMapping, Optional
def _freeze_contract_value(value: Any) -> Any:
"""递归冻结确认边界中的容器,避免等待期间被调用方修改。"""
if isinstance(value, Mapping):
return MappingProxyType(
{str(key): _freeze_contract_value(item) for key, item in value.items()}
)
if isinstance(value, (list, tuple)):
return tuple(_freeze_contract_value(item) for item in value)
if isinstance(value, set):
return frozenset(_freeze_contract_value(item) for item in value)
return value
class ToolOrigin(str, Enum): class ToolOrigin(str, Enum):
"""工具调用的宿主可信入口。""" """工具调用的宿主可信入口。"""
@@ -49,24 +35,6 @@ class AuthSource(str, Enum):
AGENT_TOKEN = "agent_token" AGENT_TOKEN = "agent_token"
class InboundProvenance(str, Enum):
"""入站事实经宿主验证后的可信等级。"""
WEB_SESSION = "web_session"
VERIFIED_ADAPTER = "verified_adapter"
ADMIN_INTEGRATION = "admin_integration"
UNTRUSTED = "untrusted"
class ConversationKind(str, Enum):
"""目标会话的隐私边界。"""
WEB = "web"
PRIVATE = "private"
GROUP = "group"
UNKNOWN = "unknown"
class PrincipalRole(str, Enum): class PrincipalRole(str, Enum):
"""策略授权使用的角色层级。""" """策略授权使用的角色层级。"""
@@ -118,92 +86,19 @@ class ResultSensitivity(str, Enum):
class MigrationState(str, Enum): class MigrationState(str, Enum):
"""工具策略从兼容观测迁移到宿主执行的状态""" """工具策略当前采用宿主强制还是兼容观测"""
ENFORCED = "enforced" ENFORCED = "enforced"
LEGACY_SHADOW = "legacy_shadow" LEGACY_SHADOW = "legacy_shadow"
class ExecutionOutcome(str, Enum): class ExecutionOutcome(str, Enum):
"""P1-G1 handler 生命周期终态;成功不代表业务授权或副作用已完成。""" """工具 handler 观测终态;成功不代表业务授权或副作用已完成。"""
SUCCEEDED = "succeeded" SUCCEEDED = "succeeded"
FAILED = "failed" FAILED = "failed"
class ReceiptState(str, Enum):
"""严格执行回执的单向生命周期状态。"""
WAITING_CONFIRMATION = "waiting_confirmation"
VALIDATING = "validating"
EXECUTING = "executing"
DELIVERING = "delivering"
SUCCEEDED = "succeeded"
CANCELLED = "cancelled"
EXPIRED = "expired"
EXPIRED_RESTART = "expired_restart"
EXPIRED_ORPHANED = "expired_orphaned"
PREPARATION_FAILED = "preparation_failed"
PROMPT_DELIVERY_FAILED = "prompt_delivery_failed"
VALIDATION_FAILED = "validation_failed"
VALIDATION_RECORD_FAILED = "validation_record_failed"
EXECUTION_FAILED = "execution_failed"
DELIVERY_FAILED = "delivery_failed"
UNKNOWN_AFTER_RESTART = "unknown_after_restart"
UNKNOWN_ORPHANED = "unknown_orphaned"
TERMINAL_RECEIPT_STATES = frozenset({
ReceiptState.SUCCEEDED,
ReceiptState.CANCELLED,
ReceiptState.EXPIRED,
ReceiptState.EXPIRED_RESTART,
ReceiptState.EXPIRED_ORPHANED,
ReceiptState.PREPARATION_FAILED,
ReceiptState.PROMPT_DELIVERY_FAILED,
ReceiptState.VALIDATION_FAILED,
ReceiptState.VALIDATION_RECORD_FAILED,
ReceiptState.EXECUTION_FAILED,
ReceiptState.DELIVERY_FAILED,
ReceiptState.UNKNOWN_AFTER_RESTART,
ReceiptState.UNKNOWN_ORPHANED,
})
RECEIPT_STATE_TRANSITIONS = {
ReceiptState.WAITING_CONFIRMATION: frozenset({
ReceiptState.VALIDATING,
ReceiptState.CANCELLED,
ReceiptState.EXPIRED,
ReceiptState.EXPIRED_RESTART,
ReceiptState.EXPIRED_ORPHANED,
ReceiptState.PREPARATION_FAILED,
ReceiptState.PROMPT_DELIVERY_FAILED,
}),
ReceiptState.VALIDATING: frozenset({
ReceiptState.EXECUTING,
ReceiptState.CANCELLED,
ReceiptState.EXPIRED,
ReceiptState.EXPIRED_RESTART,
ReceiptState.EXPIRED_ORPHANED,
ReceiptState.VALIDATION_FAILED,
ReceiptState.VALIDATION_RECORD_FAILED,
}),
ReceiptState.EXECUTING: frozenset({
ReceiptState.DELIVERING,
ReceiptState.EXECUTION_FAILED,
ReceiptState.UNKNOWN_AFTER_RESTART,
ReceiptState.UNKNOWN_ORPHANED,
}),
ReceiptState.DELIVERING: frozenset({
ReceiptState.SUCCEEDED,
ReceiptState.DELIVERY_FAILED,
ReceiptState.UNKNOWN_AFTER_RESTART,
ReceiptState.UNKNOWN_ORPHANED,
}),
}
@dataclass(frozen=True) @dataclass(frozen=True)
class PolicyPrincipal: class PolicyPrincipal:
"""由可信入口建立、不可由工具参数覆盖的调用主体。""" """由可信入口建立、不可由工具参数覆盖的调用主体。"""
@@ -229,62 +124,15 @@ class ToolInvocation:
source: Optional[str] = None source: Optional[str] = None
@dataclass(frozen=True)
class DeliveryTarget:
"""受保护结果的精确宿主路由,actor 与 recipient 不得互相替代。"""
channel: str
source_instance_id: str
tenant_or_account_id: str
conversation_kind: ConversationKind
conversation_id: str
recipient_id: str
actor_id: str
server_session_id: str
@dataclass(frozen=True)
class InboundEnvelope:
"""由可信宿主入口创建的不可变入站事实。"""
provenance: InboundProvenance
target: DeliveryTarget
inbound_event_id: str
raw_text: str = field(repr=False)
normalized_text: str = field(repr=False)
has_images: bool = False
has_audio: bool = False
has_files: bool = False
is_callback: bool = False
@dataclass(frozen=True) @dataclass(frozen=True)
class ToolRevision: class ToolRevision:
"""严格调用绑定的工具实现、工厂和插件目录版本。""" """记录目录项绑定的工具实现、工厂和插件目录版本。"""
implementation: str implementation: str
factory: str factory: str
plugin: str plugin: str
@dataclass(frozen=True)
class CanonicalInvocation:
"""经当前工具 schema 校验、可绑定确认与版本前提的调用。"""
tool_name: str
arguments: Mapping[str, Any] = field(repr=False)
canonical_json: str = field(repr=False)
digest: str
policy_version: str
tool_revision: ToolRevision
schema_digest: str
preconditions: tuple[tuple[str, str], ...] = ()
def __post_init__(self) -> None:
"""阻止调用方在确认等待期间修改规范化参数。"""
object.__setattr__(self, "arguments", _freeze_contract_value(self.arguments))
@dataclass(frozen=True) @dataclass(frozen=True)
class ActionPolicy: class ActionPolicy:
"""参数级动作策略及其兼容迁移状态。""" """参数级动作策略及其兼容迁移状态。"""
@@ -325,7 +173,7 @@ class PolicyObservation:
@dataclass(frozen=True) @dataclass(frozen=True)
class ExecutionReceipt: class ExecutionReceipt:
"""P1-G1 的非持久化脱敏回执 envelope""" """工具策略生命周期生成的非持久化脱敏回执"""
invocation_id: str invocation_id: str
tool_name: str tool_name: str
@@ -391,14 +239,9 @@ __all__ = [
"ActionEffect", "ActionEffect",
"ActionPolicy", "ActionPolicy",
"AuthSource", "AuthSource",
"CanonicalInvocation",
"ConfirmationMode", "ConfirmationMode",
"ConversationKind",
"DeliveryTarget",
"ExecutionOutcome", "ExecutionOutcome",
"ExecutionReceipt", "ExecutionReceipt",
"InboundEnvelope",
"InboundProvenance",
"MigrationState", "MigrationState",
"PolicyDecision", "PolicyDecision",
"PolicyObservation", "PolicyObservation",
@@ -406,10 +249,7 @@ __all__ = [
"PrincipalRole", "PrincipalRole",
"PrincipalType", "PrincipalType",
"RecoveryMode", "RecoveryMode",
"RECEIPT_STATE_TRANSITIONS",
"ReceiptState",
"ResultSensitivity", "ResultSensitivity",
"TERMINAL_RECEIPT_STATES",
"ToolInvocation", "ToolInvocation",
"ToolOrigin", "ToolOrigin",
"ToolPolicyContext", "ToolPolicyContext",
+4 -4
View File
@@ -37,7 +37,7 @@ def call_policy_hook(
*args: Any, *args: Any,
**kwargs: Any, **kwargs: Any,
) -> Optional[_HookResult]: ) -> Optional[_HookResult]:
"""以 fail-open 方式调用 P1-G1 观测 hook,故障只记录稳定类型。""" """以 fail-open 方式调用兼容观测 hook,故障只记录稳定类型。"""
try: try:
return hook(*args, **kwargs) return hook(*args, **kwargs)
except Exception as error: except Exception as error:
@@ -76,7 +76,7 @@ class AgentToolPolicyOrchestrator:
"""让 Agent middleware 与 direct manager 复用同一策略生命周期。""" """让 Agent middleware 与 direct manager 复用同一策略生命周期。"""
def __init__(self, registry: ToolPolicyRegistry = DEFAULT_TOOL_POLICY_REGISTRY) -> None: def __init__(self, registry: ToolPolicyRegistry = DEFAULT_TOOL_POLICY_REGISTRY) -> None:
"""绑定固定工具迁移注册表。""" """绑定工具策略解析表。"""
self.registry = registry self.registry = registry
def start( def start(
@@ -103,12 +103,12 @@ class AgentToolPolicyOrchestrator:
reason_code="legacy_shadow_allow", reason_code="legacy_shadow_allow",
) )
elif policy.confirmation is ConfirmationMode.REQUIRED: elif policy.confirmation is ConfirmationMode.REQUIRED:
# 严格运行时接管前保持既有调用能力,但不得把敏感动作记为安全读取 # 通用编排器保持 shadow;支持的 Agent 入口会在 ToolNode 前独立完成确认
decision = PolicyDecision( decision = PolicyDecision(
allowed=True, allowed=True,
confirmation_required=False, confirmation_required=False,
shadow=True, shadow=True,
reason_code="strict_runtime_pending", reason_code="confirmation_policy_shadow_allow",
) )
else: else:
decision = PolicyDecision( decision = PolicyDecision(
+21 -17
View File
@@ -1,4 +1,4 @@
"""固定工具迁移注册表与参数级策略解析。""" """工具策略例外与参数级策略解析。"""
from typing import Any, Mapping from typing import Any, Mapping
@@ -13,7 +13,7 @@ from app.agent.policy.contracts import (
) )
# 这些读取已具备清晰的无副作用语义,用于证明新宿主边界不会改变正常结果 # 这些非管理员读取在运行时解析为强制 SAFE_READ;管理员门禁仍沿用原有授权事实源
SAFE_READ_TOOL_NAMES = frozenset( SAFE_READ_TOOL_NAMES = frozenset(
{ {
"list_slash_commands", "list_slash_commands",
@@ -25,8 +25,8 @@ SAFE_READ_TOOL_NAMES = frozenset(
) )
# 其余固定工具先显式处于兼容观测状态,待领域叶子 Goal 逐个迁移 # 该清单只校验固定工具 inventory;未命中的固定或动态工具同样默认 LEGACY_SHADOW
LEGACY_SHADOW_TOOL_NAMES = frozenset( BUILTIN_LEGACY_SHADOW_INVENTORY = frozenset(
{ {
"add_custom_filter_rule", "add_custom_filter_rule",
"add_download_tasks", "add_download_tasks",
@@ -112,25 +112,29 @@ LEGACY_SHADOW_TOOL_NAMES = frozenset(
class ToolPolicyRegistry: class ToolPolicyRegistry:
"""解析固定和动态工具的 P1-G1 迁移策略。""" """解析固定和动态工具的宿主策略。"""
def __init__( def __init__(
self, self,
*, *,
safe_read_tool_names: frozenset[str] = SAFE_READ_TOOL_NAMES, safe_read_tool_names: frozenset[str] = SAFE_READ_TOOL_NAMES,
legacy_shadow_tool_names: frozenset[str] = LEGACY_SHADOW_TOOL_NAMES, builtin_legacy_shadow_inventory: frozenset[str] = (
BUILTIN_LEGACY_SHADOW_INVENTORY
),
) -> None: ) -> None:
"""建立互斥的固定工具迁移表""" """建立 SAFE_READ 例外与固定工具 inventory"""
overlap = safe_read_tool_names & legacy_shadow_tool_names overlap = safe_read_tool_names & builtin_legacy_shadow_inventory
if overlap: if overlap:
raise ValueError(f"工具策略迁移表存在重复项: {sorted(overlap)}") raise ValueError(f"工具策略 inventory 存在重复项: {sorted(overlap)}")
self._safe_read_tool_names = safe_read_tool_names self._safe_read_tool_names = safe_read_tool_names
self._legacy_shadow_tool_names = legacy_shadow_tool_names self._builtin_legacy_shadow_inventory = builtin_legacy_shadow_inventory
@property @property
def builtin_tool_names(self) -> set[str]: def builtin_tool_inventory(self) -> set[str]:
"""返回注册表覆盖的全部固定工具名""" """返回用于测试校验的固定工具 inventory"""
return set(self._safe_read_tool_names | self._legacy_shadow_tool_names) return set(
self._safe_read_tool_names | self._builtin_legacy_shadow_inventory
)
def resolve( def resolve(
self, self,
@@ -139,7 +143,7 @@ class ToolPolicyRegistry:
arguments: Mapping[str, Any], arguments: Mapping[str, Any],
requires_admin: bool, requires_admin: bool,
) -> ActionPolicy: ) -> ActionPolicy:
"""根据工具名和宿主权限声明解析当前迁移策略。""" """根据工具名和宿主权限声明解析当前参数级策略。"""
required_role = ( required_role = (
PrincipalRole.SYSTEM_ADMIN if requires_admin else PrincipalRole.USER PrincipalRole.SYSTEM_ADMIN if requires_admin else PrincipalRole.USER
) )
@@ -166,7 +170,7 @@ class ToolPolicyRegistry:
confirmation=ConfirmationMode.NONE, confirmation=ConfirmationMode.NONE,
recovery=RecoveryMode.NONE, recovery=RecoveryMode.NONE,
result_sensitivity=ResultSensitivity.NORMAL, result_sensitivity=ResultSensitivity.NORMAL,
# 角色门禁仍可能异步识别渠道管理员;G1 不复制旧授权事实源 # 角色门禁仍由既有授权事实源判断,管理员读取保持兼容观测
migration_state=( migration_state=(
MigrationState.LEGACY_SHADOW MigrationState.LEGACY_SHADOW
if requires_admin if requires_admin
@@ -174,7 +178,7 @@ class ToolPolicyRegistry:
), ),
) )
# 固定未迁移工具和动态工具都保持现有执行能力,但不得被视为安全读取。 # 除明确例外外,固定和动态工具都保持现有能力,但不得被视为安全读取。
return ActionPolicy( return ActionPolicy(
effect=ActionEffect.UNKNOWN, effect=ActionEffect.UNKNOWN,
required_role=required_role, required_role=required_role,
@@ -189,8 +193,8 @@ DEFAULT_TOOL_POLICY_REGISTRY = ToolPolicyRegistry()
__all__ = [ __all__ = [
"BUILTIN_LEGACY_SHADOW_INVENTORY",
"DEFAULT_TOOL_POLICY_REGISTRY", "DEFAULT_TOOL_POLICY_REGISTRY",
"LEGACY_SHADOW_TOOL_NAMES",
"SAFE_READ_TOOL_NAMES", "SAFE_READ_TOOL_NAMES",
"ToolPolicyRegistry", "ToolPolicyRegistry",
] ]
+1 -1
View File
@@ -65,7 +65,7 @@ class ToolCatalogEntry:
@dataclass(frozen=True) @dataclass(frozen=True)
class ToolCatalogSnapshot: class ToolCatalogSnapshot:
"""同一图构造与严格执行共享的本地工具事实源。""" """图构造、缓存签名和身份碰撞审计共享的本地工具事实源。"""
entries: tuple[ToolCatalogEntry, ...] entries: tuple[ToolCatalogEntry, ...]
plugin_revision: int plugin_revision: int
-228
View File
@@ -1,228 +0,0 @@
import math
from dataclasses import FrozenInstanceError
import pytest
from pydantic import BaseModel, ConfigDict, Field
from app.agent.policy.canonical import (
CanonicalizationError,
canonicalize_invocation,
)
from app.agent.policy.contracts import (
ConversationKind,
DeliveryTarget,
InboundEnvelope,
InboundProvenance,
ToolRevision,
)
from app.agent.policy.registry import DEFAULT_TOOL_POLICY_REGISTRY
from app.agent.tools.impl.query_system_settings import QuerySystemSettingsTool
def _secret_policy():
return DEFAULT_TOOL_POLICY_REGISTRY.resolve(
tool_name="query_system_settings",
arguments={"show_secrets": True},
requires_admin=True,
)
def _tool_revision() -> ToolRevision:
return ToolRevision(
implementation="query-system-settings:1",
factory="builtin-factory:1",
plugin="plugin-catalog:1",
)
def test_canonical_invocation_includes_defaults_and_is_stable() -> None:
"""省略的默认值必须进入相同规范化参数与摘要。"""
tool = QuerySystemSettingsTool(session_id="session-1", user_id="admin")
omitted = canonicalize_invocation(
tool=tool,
arguments={"setting_key": "COOKIECLOUD_KEY", "show_secrets": True},
policy=_secret_policy(),
tool_revision=_tool_revision(),
)
explicit = canonicalize_invocation(
tool=tool,
arguments={
"setting_key": "COOKIECLOUD_KEY",
"group": "all",
"keyword": None,
"include_values": None,
"show_secrets": True,
},
policy=_secret_policy(),
tool_revision=_tool_revision(),
)
assert omitted.digest == explicit.digest
assert omitted.arguments == explicit.arguments
assert omitted.preconditions == (
("setting", "settings:COOKIECLOUD_KEY:settings"),
)
def test_canonical_invocation_repr_hides_arguments_and_json() -> None:
"""调用对象的 repr 不得泄露确认参数或完整规范化 JSON。"""
marker = "canonical-private-marker"
class _Input(BaseModel):
model_config = ConfigDict(extra="forbid")
value: str
class _Tool:
name = "private_tool"
args_schema = _Input
invocation = canonicalize_invocation(
tool=_Tool(),
arguments={"value": marker},
policy=_secret_policy(),
tool_revision=_tool_revision(),
)
assert marker not in repr(invocation)
assert "canonical_json" not in repr(invocation)
def test_canonical_arguments_are_recursively_immutable() -> None:
"""确认等待期间不能修改嵌套参数后复用旧 digest。"""
class _Input(BaseModel):
payload: dict[str, list[str]]
class _Tool:
name = "nested_tool"
args_schema = _Input
invocation = canonicalize_invocation(
tool=_Tool(),
arguments={"payload": {"items": ["one"]}},
policy=_secret_policy(),
tool_revision=_tool_revision(),
)
with pytest.raises(TypeError):
invocation.arguments["payload"]["items"] += ("two",)
def test_inbound_contract_hides_raw_text_and_keeps_target_identities_separate() -> None:
"""入站原文不可进入 repractor、recipient 和 conversation 独立绑定。"""
target = DeliveryTarget(
channel="telegram",
source_instance_id="source-1",
tenant_or_account_id="account-1",
conversation_kind=ConversationKind.PRIVATE,
conversation_id="chat-1",
recipient_id="recipient-1",
actor_id="actor-1",
server_session_id="session-1",
)
envelope = InboundEnvelope(
provenance=InboundProvenance.VERIFIED_ADAPTER,
target=target,
inbound_event_id="event-1",
raw_text=" confirm K7P4-M2Q8 ",
normalized_text="confirm K7P4-M2Q8",
)
rendered = repr(envelope)
assert "K7P4-M2Q8" not in rendered
assert target.actor_id != target.recipient_id
assert target.recipient_id != target.conversation_id
with pytest.raises(FrozenInstanceError):
envelope.inbound_event_id = "forged"
def test_canonical_invocation_rejects_unknown_arguments() -> None:
"""严格确认不能把 schema 外参数降级为原始字典。"""
tool = QuerySystemSettingsTool(session_id="session-1", user_id="admin")
with pytest.raises(CanonicalizationError, match="参数校验失败"):
canonicalize_invocation(
tool=tool,
arguments={"show_secrets": True, "forged": "value"},
policy=_secret_policy(),
tool_revision=_tool_revision(),
)
def test_canonical_invocation_rejects_missing_pydantic_schema() -> None:
"""动态 dict schema 不能进入严格确认路径。"""
class _Tool:
name = "dynamic_tool"
args_schema = {"type": "object"}
with pytest.raises(CanonicalizationError, match="Pydantic"):
canonicalize_invocation(
tool=_Tool(),
arguments={},
policy=_secret_policy(),
tool_revision=_tool_revision(),
)
def test_canonical_invocation_preserves_unicode_and_rejects_nan() -> None:
"""稳定 JSON 保留 Unicode,同时禁止非标准 NaN。"""
class _Input(BaseModel):
label: str
value: float = Field(allow_inf_nan=True)
class _Tool:
name = "unicode_tool"
args_schema = _Input
with pytest.raises(CanonicalizationError, match="无法规范化"):
canonicalize_invocation(
tool=_Tool(),
arguments={"label": "中文", "value": math.nan},
policy=_secret_policy(),
tool_revision=_tool_revision(),
)
def test_canonicalization_reads_no_setting_value(monkeypatch) -> None:
"""确认前只能读取静态 SettingSpec,不能访问实际设置值。"""
tool = QuerySystemSettingsTool(session_id="session-1", user_id="admin")
load_value = monkeypatch.setattr(
QuerySystemSettingsTool,
"_load_setting_value",
lambda *_: pytest.fail("canonicalization must not read setting value"),
)
invocation = canonicalize_invocation(
tool=tool,
arguments={"setting_key": "COOKIECLOUD_KEY", "show_secrets": True},
policy=_secret_policy(),
tool_revision=_tool_revision(),
)
assert invocation.digest
assert load_value is None
def test_group_selector_binds_static_setting_set_without_loading_values(
monkeypatch,
) -> None:
"""列表型密钥读取必须绑定匹配的静态设置集合。"""
tool = QuerySystemSettingsTool(session_id="session-1", user_id="admin")
monkeypatch.setattr(
QuerySystemSettingsTool,
"_load_setting_value",
lambda *_: pytest.fail("canonicalization must not read setting value"),
)
invocation = canonicalize_invocation(
tool=tool,
arguments={"group": "ai_agent", "show_secrets": True},
policy=_secret_policy(),
tool_revision=_tool_revision(),
)
assert len(invocation.preconditions) > 1
assert all(kind == "setting" for kind, _identity in invocation.preconditions)
+11 -11
View File
@@ -126,8 +126,8 @@ def _interactive_context(*, is_admin: bool = True) -> ToolPolicyContext:
) )
def test_builtin_policy_registry_covers_every_fixed_tool() -> None: def test_builtin_policy_inventory_covers_every_fixed_tool() -> None:
"""固定内置工具必须全部具有显式 migration registry 条目""" """固定内置工具 inventory 必须随工厂入口同步"""
fixed_tool_names = { fixed_tool_names = {
_tool_class_name(tool_class) _tool_class_name(tool_class)
for tool_class in MoviePilotToolFactory.BUILTIN_TOOL_CLASSES for tool_class in MoviePilotToolFactory.BUILTIN_TOOL_CLASSES
@@ -140,11 +140,11 @@ def test_builtin_policy_registry_covers_every_fixed_tool() -> None:
} }
) )
assert DEFAULT_TOOL_POLICY_REGISTRY.builtin_tool_names == fixed_tool_names assert DEFAULT_TOOL_POLICY_REGISTRY.builtin_tool_inventory == fixed_tool_names
def test_registry_separates_safe_read_from_legacy_shadow() -> None: def test_registry_applies_safe_read_exceptions_and_defaults_to_shadow() -> None:
"""少量安全读取可直接迁移,其余高影响工具保持 shadow allow。""" """SAFE_READ 仅用于明确例外,其他固定和动态工具默认 shadow。"""
safe_policy = DEFAULT_TOOL_POLICY_REGISTRY.resolve( safe_policy = DEFAULT_TOOL_POLICY_REGISTRY.resolve(
tool_name="query_personas", tool_name="query_personas",
arguments={}, arguments={},
@@ -216,7 +216,7 @@ def test_system_settings_secret_read_has_enforced_sensitive_policy() -> None:
def test_legacy_shadow_decision_allows_without_claiming_enforcement() -> None: def test_legacy_shadow_decision_allows_without_claiming_enforcement() -> None:
"""G1 的 shadow 决策只能观测,不能拒绝或要求确认。""" """shadow 决策只能观测,不能拒绝或要求确认。"""
context = _interactive_context() context = _interactive_context()
tool = _EchoTool(session_id="session-1", user_id="user-1") tool = _EchoTool(session_id="session-1", user_id="user-1")
@@ -231,8 +231,8 @@ def test_legacy_shadow_decision_allows_without_claiming_enforcement() -> None:
assert observation.decision.reason_code == "legacy_shadow_allow" assert observation.decision.reason_code == "legacy_shadow_allow"
def test_sensitive_policy_does_not_claim_safe_read_before_strict_runtime() -> None: def test_sensitive_policy_stays_shadow_in_generic_orchestrator() -> None:
"""严格运行时接管前,敏感读取只能以明确的兼容 shadow 语义通过""" """敏感读取在通用编排器中保持明确的兼容 shadow 语义。"""
tool = QuerySystemSettingsTool(session_id="session-1", user_id="admin") tool = QuerySystemSettingsTool(session_id="session-1", user_id="admin")
tool.set_agent_context({"is_admin": True}) tool.set_agent_context({"is_admin": True})
@@ -246,7 +246,7 @@ def test_sensitive_policy_does_not_claim_safe_read_before_strict_runtime() -> No
assert observation.decision.allowed is True assert observation.decision.allowed is True
assert observation.decision.shadow is True assert observation.decision.shadow is True
assert observation.decision.confirmation_required is False assert observation.decision.confirmation_required is False
assert observation.decision.reason_code == "strict_runtime_pending" assert observation.decision.reason_code == "confirmation_policy_shadow_allow"
def test_policy_context_reads_mutable_admin_state_without_model_fields() -> None: def test_policy_context_reads_mutable_admin_state_without_model_fields() -> None:
@@ -439,8 +439,8 @@ def test_middleware_fail_observation_does_not_mask_tool_error() -> None:
assert error_info.value is tool_error assert error_info.value is tool_error
def test_middleware_keeps_shadow_observation_until_strict_runtime_takeover() -> None: def test_middleware_keeps_shadow_observation_without_enforcing_decision() -> None:
"""普通 ToolNode 在严格策略接管前不得因观测决策改变既有行为。""" """普通 ToolNode 不得因 shadow 观测决策改变既有行为。"""
orchestrator = MagicMock() orchestrator = MagicMock()
orchestrator.start.return_value = SimpleNamespace( orchestrator.start.return_value = SimpleNamespace(
decision=SimpleNamespace(allowed=False) decision=SimpleNamespace(allowed=False)