992 lines
39 KiB
Python
992 lines
39 KiB
Python
#!/usr/bin/env python3
|
||
"""离线评测统一 Claude CLI 运行时。
|
||
|
||
本模块只接受显式冻结的执行 profile。它负责 fresh process、sandbox、最小环境、
|
||
硬 deadline、structured_output 唯一业务出口和联合执行回执;任何不完整证据都
|
||
失败关闭,且错误对象永不携带 stderr 原文。
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import hashlib
|
||
import json
|
||
import math
|
||
import os
|
||
import pathlib
|
||
import re
|
||
import shutil
|
||
import subprocess
|
||
import tempfile
|
||
import time
|
||
from dataclasses import dataclass
|
||
from decimal import Decimal, InvalidOperation, ROUND_HALF_UP
|
||
from typing import Any, Callable, Mapping, Sequence
|
||
from urllib.parse import urlsplit
|
||
|
||
|
||
SANDBOX_EXECUTABLE = "/usr/bin/sandbox-exec"
|
||
PRIVATE_TMP = pathlib.Path("/private/tmp")
|
||
SUPPORTED_ROLES = frozenset({"writer", "semantic_detector", "blind_judge"})
|
||
ENVIRONMENT_ALLOWLIST = frozenset(
|
||
{
|
||
"ANTHROPIC_API_KEY",
|
||
"CLAUDE_CODE_OAUTH_TOKEN",
|
||
"ANTHROPIC_AUTH_TOKEN",
|
||
"ANTHROPIC_BASE_URL",
|
||
"HTTPS_PROXY",
|
||
"HTTP_PROXY",
|
||
"NO_PROXY",
|
||
"https_proxy",
|
||
"http_proxy",
|
||
"no_proxy",
|
||
"LANG",
|
||
"LC_ALL",
|
||
"SSL_CERT_FILE",
|
||
"SSL_CERT_DIR",
|
||
"NODE_EXTRA_CA_CERTS",
|
||
}
|
||
)
|
||
AUTHENTICATION_FIELDS = frozenset(
|
||
{"ANTHROPIC_API_KEY", "CLAUDE_CODE_OAUTH_TOKEN", "ANTHROPIC_AUTH_TOKEN"}
|
||
)
|
||
HASH_PATTERN = re.compile(r"^(?:sha256:)?[0-9a-f]{64}$")
|
||
MODEL_ID_PATTERN = re.compile(
|
||
r"^(?=.{6,128}$)[A-Za-z0-9][A-Za-z0-9._:-]{5,127}(?:\[[A-Za-z0-9._:-]+\])?$"
|
||
)
|
||
MONEY_QUANTUM = Decimal("0.000001")
|
||
USAGE_REQUIRED_COUNT_FIELDS = frozenset({"input_tokens", "output_tokens"})
|
||
USAGE_OPTIONAL_COUNT_FIELDS = frozenset(
|
||
{"cache_creation_input_tokens", "cache_read_input_tokens"}
|
||
)
|
||
USAGE_COUNT_MAP_FIELDS = {
|
||
"server_tool_use": frozenset({"web_search_requests", "web_fetch_requests"}),
|
||
"cache_creation": frozenset(
|
||
{"ephemeral_5m_input_tokens", "ephemeral_1h_input_tokens"}
|
||
),
|
||
}
|
||
USAGE_STRING_FIELDS = frozenset({"service_tier", "speed", "inference_geo"})
|
||
|
||
|
||
class ClaudeRuntimeError(RuntimeError):
|
||
"""携带稳定主码、受控原因和可选失败回执的运行时错误。"""
|
||
|
||
def __init__(
|
||
self,
|
||
primary_code: str,
|
||
message: str,
|
||
*,
|
||
causes: Sequence[str] = (),
|
||
details: Mapping[str, Any] | None = None,
|
||
receipt: "ExecutionReceipt | None" = None,
|
||
) -> None:
|
||
super().__init__(message)
|
||
self.primary_code = primary_code
|
||
self.code = primary_code
|
||
self.causes = tuple(cause for cause in causes if cause != primary_code)
|
||
self.details = dict(details or {})
|
||
self.receipt = receipt
|
||
self.acceptance_eligible = False
|
||
|
||
|
||
def _json_value(value: Any) -> Any:
|
||
"""把 Decimal 等运行时值转成可复现、可 JSON 序列化的安全值。"""
|
||
|
||
if isinstance(value, Decimal):
|
||
return format(value, "f")
|
||
if value is None or isinstance(value, (str, bool, int)):
|
||
return value
|
||
if isinstance(value, float):
|
||
if not math.isfinite(value):
|
||
raise ValueError("JSON 不允许 NaN 或 Infinity")
|
||
return value
|
||
if isinstance(value, Mapping):
|
||
return {str(key): _json_value(item) for key, item in value.items()}
|
||
if isinstance(value, (list, tuple)):
|
||
return [_json_value(item) for item in value]
|
||
raise TypeError(f"值不是受支持的 JSON 类型: {type(value).__name__}")
|
||
|
||
|
||
def canonical_json(value: Any) -> str:
|
||
"""生成 UTF-8、排序键、无多余空白的规范 JSON。"""
|
||
|
||
return json.dumps(
|
||
_json_value(value),
|
||
ensure_ascii=False,
|
||
sort_keys=True,
|
||
separators=(",", ":"),
|
||
allow_nan=False,
|
||
)
|
||
|
||
|
||
def sha256_text(value: str) -> str:
|
||
"""返回带算法前缀的 UTF-8 文本 SHA-256。"""
|
||
|
||
if not isinstance(value, str):
|
||
raise TypeError("待哈希文本必须是字符串")
|
||
return "sha256:" + hashlib.sha256(value.encode("utf-8")).hexdigest()
|
||
|
||
|
||
def sha256_json(value: Any) -> str:
|
||
"""返回规范 JSON 的带前缀 SHA-256。"""
|
||
|
||
return sha256_text(canonical_json(value))
|
||
|
||
|
||
def _plain_hash(value: str, field: str) -> str:
|
||
"""校验 SHA-256 字段并统一为不带前缀的十六进制。"""
|
||
|
||
if not isinstance(value, str) or not HASH_PATTERN.fullmatch(value):
|
||
raise ValueError(f"{field} 必须是 64 位小写 SHA-256")
|
||
return value.removeprefix("sha256:")
|
||
|
||
|
||
def _money(value: Any, field: str) -> Decimal:
|
||
"""按六位小数半入规则归一化非负美元值。"""
|
||
|
||
if isinstance(value, bool) or value is None:
|
||
raise ValueError(f"{field} 必须是非负十进制数")
|
||
try:
|
||
amount = Decimal(str(value))
|
||
except (InvalidOperation, ValueError) as exc:
|
||
raise ValueError(f"{field} 必须是非负十进制数") from exc
|
||
if not amount.is_finite() or amount < 0:
|
||
raise ValueError(f"{field} 必须是非负十进制数")
|
||
return amount.quantize(MONEY_QUANTUM, rounding=ROUND_HALF_UP)
|
||
|
||
|
||
@dataclass(frozen=True)
|
||
class ExecutionProfile:
|
||
"""一次角色调用的完整冻结配置。"""
|
||
|
||
profile_version: str
|
||
adapter_role: str
|
||
claude_executable_path: str
|
||
claude_executable_sha256: str
|
||
claude_cli_version: str
|
||
model_alias: str
|
||
resolved_model_id: str
|
||
effort: str
|
||
max_budget_usd_per_call: Decimal
|
||
timeout_seconds: float
|
||
max_context_chars: int
|
||
json_schema_id: str
|
||
json_schema: Mapping[str, Any]
|
||
json_schema_sha256: str
|
||
system_prompt_id: str
|
||
system_prompt: str
|
||
system_prompt_sha256: str
|
||
normal_terminal_reasons: tuple[str, ...]
|
||
reproducibility_claim: str = "not_claimed"
|
||
temperature: str = "unsupported"
|
||
top_p: str = "unsupported"
|
||
seed: str = "unsupported"
|
||
|
||
def __post_init__(self) -> None:
|
||
"""在 profile 构造时拒绝别名模型、相对路径和 hash 漂移。"""
|
||
|
||
if self.adapter_role not in SUPPORTED_ROLES:
|
||
raise ValueError("adapter_role 不受支持")
|
||
executable = pathlib.Path(self.claude_executable_path)
|
||
if not executable.is_absolute():
|
||
raise ValueError("claude_executable_path 必须是绝对路径")
|
||
_plain_hash(self.claude_executable_sha256, "claude_executable_sha256")
|
||
if not self.claude_cli_version.strip():
|
||
raise ValueError("claude_cli_version 不能为空")
|
||
if (
|
||
not MODEL_ID_PATTERN.fullmatch(self.resolved_model_id)
|
||
or self.resolved_model_id == self.model_alias
|
||
):
|
||
raise ValueError("resolved_model_id 必须是完整模型 ID,不能使用别名")
|
||
if not self.effort.strip():
|
||
raise ValueError("effort 不能为空")
|
||
object.__setattr__(
|
||
self,
|
||
"max_budget_usd_per_call",
|
||
_money(self.max_budget_usd_per_call, "max_budget_usd_per_call"),
|
||
)
|
||
if isinstance(self.timeout_seconds, bool) or self.timeout_seconds <= 0:
|
||
raise ValueError("timeout_seconds 必须大于 0")
|
||
if isinstance(self.max_context_chars, bool) or self.max_context_chars <= 0:
|
||
raise ValueError("max_context_chars 必须是正整数")
|
||
if not self.json_schema_id.strip() or not isinstance(self.json_schema, Mapping):
|
||
raise ValueError("JSON schema 身份或内容非法")
|
||
if sha256_json(self.json_schema) != self.json_schema_sha256:
|
||
raise ValueError("json_schema_sha256 与 schema 内容不一致")
|
||
if not self.system_prompt_id.strip() or not self.system_prompt:
|
||
raise ValueError("system prompt 身份或内容非法")
|
||
if sha256_text(self.system_prompt) != self.system_prompt_sha256:
|
||
raise ValueError("system_prompt_sha256 与提示词内容不一致")
|
||
if not self.normal_terminal_reasons or any(
|
||
not isinstance(reason, str) or not reason for reason in self.normal_terminal_reasons
|
||
):
|
||
raise ValueError("normal_terminal_reasons 不能为空")
|
||
if (
|
||
self.reproducibility_claim != "not_claimed"
|
||
or {self.temperature, self.top_p, self.seed} != {"unsupported"}
|
||
):
|
||
raise ValueError("不支持声明 temperature/topP/seed 可复现")
|
||
|
||
@property
|
||
def role_prefix(self) -> str:
|
||
"""把角色名转换为稳定错误码前缀。"""
|
||
|
||
return {
|
||
"writer": "WRITER",
|
||
"semantic_detector": "SEMANTIC_DETECTOR",
|
||
"blind_judge": "BLIND_JUDGE",
|
||
}[self.adapter_role]
|
||
|
||
@property
|
||
def execution_profile_sha256(self) -> str:
|
||
"""计算排除大段 schema/prompt 原文后的规范 profile 身份。"""
|
||
|
||
return sha256_json(
|
||
{
|
||
"profileVersion": self.profile_version,
|
||
"adapterRole": self.adapter_role,
|
||
"claudeExecutablePath": self.claude_executable_path,
|
||
"claudeExecutableSha256": self.claude_executable_sha256,
|
||
"claudeCliVersion": self.claude_cli_version,
|
||
"modelAlias": self.model_alias,
|
||
"resolvedModelId": self.resolved_model_id,
|
||
"effort": self.effort,
|
||
"maxBudgetUsdPerCall": format(self.max_budget_usd_per_call, "f"),
|
||
"timeoutSeconds": self.timeout_seconds,
|
||
"maxContextChars": self.max_context_chars,
|
||
"jsonSchemaId": self.json_schema_id,
|
||
"jsonSchemaSha256": self.json_schema_sha256,
|
||
"systemPromptId": self.system_prompt_id,
|
||
"systemPromptSha256": self.system_prompt_sha256,
|
||
"normalTerminalReasons": list(self.normal_terminal_reasons),
|
||
"temperature": self.temperature,
|
||
"topP": self.top_p,
|
||
"seed": self.seed,
|
||
"reproducibilityClaim": self.reproducibility_claim,
|
||
}
|
||
)
|
||
|
||
|
||
@dataclass(frozen=True)
|
||
class ExecutionReceipt:
|
||
"""不含业务正文、prompt、stderr 和 raw path 的模型调用回执。"""
|
||
|
||
adapter_role: str
|
||
invocation_id: str
|
||
execution_profile_sha256: str
|
||
requested_model_id: str
|
||
actual_model_id: str | None
|
||
model_match: bool
|
||
effort: str
|
||
max_budget_usd_per_call: str
|
||
total_cost_usd: str | None
|
||
usage: Mapping[str, Any] | None
|
||
model_usage: Mapping[str, Any] | None
|
||
stop_reason: str | None
|
||
terminal_reason: str | None
|
||
is_error: bool | None
|
||
api_error_status: int | str | None
|
||
exit_code: int | None
|
||
duration_ms: int
|
||
input_sha256: str
|
||
structured_output_sha256: str | None
|
||
json_schema_sha256: str
|
||
|
||
def as_dict(self) -> dict[str, Any]:
|
||
"""按 SoT 的 camelCase 字段输出可持久化回执。"""
|
||
|
||
return {
|
||
"adapterRole": self.adapter_role,
|
||
"invocationId": self.invocation_id,
|
||
"executionProfileSha256": self.execution_profile_sha256,
|
||
"requestedModelId": self.requested_model_id,
|
||
"actualModelId": self.actual_model_id,
|
||
"modelMatch": self.model_match,
|
||
"effort": self.effort,
|
||
"maxBudgetUsdPerCall": self.max_budget_usd_per_call,
|
||
"totalCostUsd": self.total_cost_usd,
|
||
"usage": _json_value(self.usage),
|
||
"modelUsage": _json_value(self.model_usage),
|
||
"stopReason": self.stop_reason,
|
||
"terminalReason": self.terminal_reason,
|
||
"isError": self.is_error,
|
||
"apiErrorStatus": self.api_error_status,
|
||
"exitCode": self.exit_code,
|
||
"durationMs": self.duration_ms,
|
||
"inputSha256": self.input_sha256,
|
||
"structuredOutputSha256": self.structured_output_sha256,
|
||
"jsonSchemaSha256": self.json_schema_sha256,
|
||
}
|
||
|
||
|
||
@dataclass(frozen=True)
|
||
class ClaudeInvocationResult:
|
||
"""成功调用的业务对象和安全回执。"""
|
||
|
||
structured_output: Mapping[str, Any]
|
||
receipt: ExecutionReceipt
|
||
|
||
|
||
def _safe_file_sha256(path: pathlib.Path) -> str:
|
||
"""分块计算绑定可执行文件的 SHA-256。"""
|
||
|
||
digest = hashlib.sha256()
|
||
with path.open("rb") as handle:
|
||
for chunk in iter(lambda: handle.read(1024 * 1024), b""):
|
||
digest.update(chunk)
|
||
return digest.hexdigest()
|
||
|
||
|
||
def verify_execution_profile(
|
||
profile: ExecutionProfile,
|
||
*,
|
||
version_runner: Callable[..., subprocess.CompletedProcess[str]] = subprocess.run,
|
||
) -> None:
|
||
"""在业务调用前机械核对绝对二进制内容和 Claude CLI 版本。"""
|
||
|
||
code = f"{profile.role_prefix}_RECEIPT_INVALID"
|
||
executable = pathlib.Path(profile.claude_executable_path)
|
||
try:
|
||
if not executable.exists() or not executable.is_file():
|
||
raise OSError("可执行文件不存在")
|
||
actual_hash = _safe_file_sha256(executable)
|
||
except OSError as exc:
|
||
raise ClaudeRuntimeError(code, "冻结的 Claude CLI 不可读取") from exc
|
||
if actual_hash != _plain_hash(profile.claude_executable_sha256, "claude_executable_sha256"):
|
||
raise ClaudeRuntimeError(code, "Claude CLI 文件 hash 与冻结 profile 不一致")
|
||
|
||
# 版本探测仍使用绝对路径和最小环境,不继承仓库 secret 或 PATH。
|
||
environment = {
|
||
key: value
|
||
for key, value in os.environ.items()
|
||
if key in {"LANG", "LC_ALL", "SSL_CERT_FILE", "SSL_CERT_DIR", "NODE_EXTRA_CA_CERTS"}
|
||
}
|
||
try:
|
||
completed = version_runner(
|
||
[profile.claude_executable_path, "--version"],
|
||
text=True,
|
||
capture_output=True,
|
||
check=False,
|
||
timeout=min(10.0, profile.timeout_seconds),
|
||
env=environment,
|
||
)
|
||
except (OSError, subprocess.TimeoutExpired) as exc:
|
||
raise ClaudeRuntimeError(code, "Claude CLI 版本探测失败") from exc
|
||
if completed.returncode != 0 or profile.claude_cli_version not in (completed.stdout or ""):
|
||
raise ClaudeRuntimeError(code, "Claude CLI 版本与冻结 profile 不一致")
|
||
|
||
|
||
def _sandbox_literal(value: str) -> str:
|
||
"""把本机路径转义成 sandbox profile 字符串字面量。"""
|
||
|
||
return '"' + value.replace("\\", "\\\\").replace('"', '\\"') + '"'
|
||
|
||
|
||
def build_sandbox_profile(profile: ExecutionProfile, isolation_directory: pathlib.Path) -> str:
|
||
"""生成只允许隔离目录写入及模型调用网络的 sandbox-exec profile。"""
|
||
|
||
isolation = _sandbox_literal(str(isolation_directory))
|
||
executable = _sandbox_literal(profile.claude_executable_path)
|
||
# NVM 的 claude 入口通常是绝对软链接;sandbox 的 exec 检查作用于最终 Mach-O 路径,
|
||
# 因而同时允许该已由同一 SHA-256 绑定的物理目标,不能退回 PATH 查找。
|
||
resolved_executable = _sandbox_literal(
|
||
str(pathlib.Path(profile.claude_executable_path).resolve(strict=True))
|
||
)
|
||
# macOS 动态装载、证书和 DNS 需要系统路径只读;随后显式拒绝用户目录、共享卷
|
||
# 以及本次隔离目录之外的临时数据,确保仓库、数据库凭据及其他业务文件不可见。
|
||
return "\n".join(
|
||
(
|
||
"(version 1)",
|
||
"(deny default)",
|
||
f"(allow process-exec (literal {executable}) (literal {resolved_executable}))",
|
||
"(allow process-fork)",
|
||
"(allow process-info*)",
|
||
"(allow signal)",
|
||
"(allow sysctl-read)",
|
||
"(allow mach-lookup)",
|
||
"(allow file-read*)",
|
||
# 绝对软链接解析需要父目录 metadata;只放行 metadata,用户文件内容仍统一拒绝。
|
||
f"(deny file-read-data (require-all (subpath \"/Users\") "
|
||
f"(require-not (literal {executable})) "
|
||
f"(require-not (literal {resolved_executable}))))",
|
||
"(deny file-read-data (subpath \"/Volumes\"))",
|
||
f"(deny file-read-data (require-all (subpath \"/private/tmp\") "
|
||
f"(require-not (subpath {isolation}))))",
|
||
f"(deny file-write* (require-not (subpath {isolation})))",
|
||
f"(allow file-write* (subpath {isolation}))",
|
||
"(allow network-outbound)",
|
||
)
|
||
)
|
||
|
||
|
||
def build_sandbox_command(
|
||
profile: ExecutionProfile, isolation_directory: pathlib.Path
|
||
) -> list[str]:
|
||
"""构造参数完整且业务输入只走 stdin 的固定 sandbox 命令。"""
|
||
|
||
return [
|
||
SANDBOX_EXECUTABLE,
|
||
"-p",
|
||
build_sandbox_profile(profile, isolation_directory),
|
||
profile.claude_executable_path,
|
||
"--print",
|
||
"--bare",
|
||
"--model",
|
||
profile.resolved_model_id,
|
||
"--effort",
|
||
profile.effort,
|
||
"--max-budget-usd",
|
||
format(profile.max_budget_usd_per_call, "f"),
|
||
"--output-format",
|
||
"json",
|
||
"--json-schema",
|
||
canonical_json(profile.json_schema),
|
||
"--tools",
|
||
"",
|
||
"--no-session-persistence",
|
||
"--disable-slash-commands",
|
||
"--strict-mcp-config",
|
||
"--mcp-config",
|
||
'{"mcpServers":{}}',
|
||
"--system-prompt",
|
||
profile.system_prompt,
|
||
]
|
||
|
||
|
||
def _validate_base_url(value: str) -> None:
|
||
"""拒绝会把认证信息送往非 HTTP(S) 或带隐藏凭据的地址。"""
|
||
|
||
invalid_message = "ANTHROPIC_BASE_URL 必须是合法且不含凭据、查询或片段的 HTTP(S) 地址"
|
||
if (
|
||
not isinstance(value, str)
|
||
or not value
|
||
or value != value.strip()
|
||
or any(char.isspace() or ord(char) < 0x20 or ord(char) == 0x7F for char in value)
|
||
or "?" in value
|
||
or "#" in value
|
||
):
|
||
raise ValueError(invalid_message)
|
||
try:
|
||
parsed = urlsplit(value)
|
||
hostname = parsed.hostname
|
||
port = parsed.port
|
||
username = parsed.username
|
||
password = parsed.password
|
||
except ValueError:
|
||
# 解析器的底层异常可能携带原始 URL,不能把它继续传播到安全错误边界。
|
||
raise ValueError(invalid_message) from None
|
||
if (
|
||
parsed.scheme not in {"http", "https"}
|
||
or not parsed.netloc
|
||
or not hostname
|
||
or username is not None
|
||
or password is not None
|
||
or (parsed.netloc.endswith(":") and port is None)
|
||
):
|
||
raise ValueError(invalid_message)
|
||
|
||
|
||
def _minimal_environment(
|
||
source: Mapping[str, str], isolation_directory: pathlib.Path, *, require_authentication: bool
|
||
) -> dict[str, str]:
|
||
"""仅复制 SoT 白名单字段,并覆盖 HOME/TMPDIR 到本次隔离目录。"""
|
||
|
||
environment = {
|
||
key: value
|
||
for key, value in source.items()
|
||
if key in ENVIRONMENT_ALLOWLIST and isinstance(value, str) and value
|
||
}
|
||
authentication = AUTHENTICATION_FIELDS.intersection(environment)
|
||
if len(authentication) > 1:
|
||
raise ValueError("一次调用只能使用一种授权认证")
|
||
auth_token = environment.get("ANTHROPIC_AUTH_TOKEN")
|
||
base_url = environment.get("ANTHROPIC_BASE_URL")
|
||
if auth_token is not None and not auth_token.strip():
|
||
raise ValueError("ANTHROPIC_AUTH_TOKEN 不能为空")
|
||
if auth_token is not None and (base_url is None or not base_url.strip()):
|
||
raise ValueError("使用 ANTHROPIC_AUTH_TOKEN 时必须同时配置 ANTHROPIC_BASE_URL")
|
||
if base_url is not None:
|
||
_validate_base_url(base_url)
|
||
if require_authentication and not authentication:
|
||
raise ValueError("真实调用缺少经授权的 Claude 认证")
|
||
environment["HOME"] = str(isolation_directory)
|
||
environment["TMPDIR"] = str(isolation_directory)
|
||
return environment
|
||
|
||
|
||
def _validate_number_tree(value: Any, path: str) -> None:
|
||
"""递归校验严格数字树,用于 modelUsage 的计数核账。"""
|
||
|
||
if isinstance(value, Mapping):
|
||
if not value:
|
||
raise ValueError(f"{path} 不能为空")
|
||
for key, item in value.items():
|
||
if not isinstance(key, str) or not key:
|
||
raise ValueError(f"{path} 键非法")
|
||
_validate_number_tree(item, f"{path}.{key}")
|
||
return
|
||
if isinstance(value, bool) or not isinstance(value, (int, float, Decimal)):
|
||
raise ValueError(f"{path} 必须只包含数值")
|
||
decimal_value = Decimal(str(value))
|
||
if not decimal_value.is_finite() or decimal_value < 0:
|
||
raise ValueError(f"{path} 数值非法")
|
||
|
||
|
||
def _validate_non_negative_finite_number(value: Any, path: str) -> None:
|
||
"""校验计数字段是非负有限数,不接受 bool、NaN 或 Infinity。"""
|
||
|
||
if isinstance(value, bool) or not isinstance(value, (int, float, Decimal)):
|
||
raise ValueError(f"{path} 必须是非负有限数")
|
||
try:
|
||
decimal_value = Decimal(str(value))
|
||
except (InvalidOperation, ValueError) as exc:
|
||
raise ValueError(f"{path} 必须是非负有限数") from exc
|
||
if not decimal_value.is_finite() or decimal_value < 0:
|
||
raise ValueError(f"{path} 必须是非负有限数")
|
||
|
||
|
||
def _validate_safe_json(value: Any, path: str) -> None:
|
||
"""递归校验前向兼容字段仍然只包含安全 JSON 值。"""
|
||
|
||
if value is None or isinstance(value, (str, bool, int)):
|
||
return
|
||
if isinstance(value, (float, Decimal)):
|
||
try:
|
||
decimal_value = Decimal(str(value))
|
||
except (InvalidOperation, ValueError) as exc:
|
||
raise ValueError(f"{path} 不是安全 JSON") from exc
|
||
if not decimal_value.is_finite():
|
||
raise ValueError(f"{path} 不是安全 JSON")
|
||
return
|
||
if isinstance(value, list):
|
||
for index, item in enumerate(value):
|
||
_validate_safe_json(item, f"{path}[{index}]")
|
||
return
|
||
if isinstance(value, Mapping):
|
||
for key, item in value.items():
|
||
if not isinstance(key, str):
|
||
raise ValueError(f"{path} 对象键不是字符串")
|
||
_validate_safe_json(item, f"{path}.{key}")
|
||
return
|
||
raise ValueError(f"{path} 不是安全 JSON")
|
||
|
||
|
||
def _validate_usage_count_mapping(
|
||
value: Any, path: str, known_count_fields: frozenset[str]
|
||
) -> None:
|
||
"""校验已知计数映射,并允许未来字段继续使用安全 JSON。"""
|
||
|
||
if not isinstance(value, Mapping):
|
||
raise ValueError(f"{path} 必须是对象")
|
||
for key, item in value.items():
|
||
if not isinstance(key, str):
|
||
raise ValueError(f"{path} 对象键不是字符串")
|
||
if key in known_count_fields:
|
||
_validate_non_negative_finite_number(item, f"{path}.{key}")
|
||
else:
|
||
_validate_safe_json(item, f"{path}.{key}")
|
||
|
||
|
||
def _validate_usage(value: Any, path: str = "usage") -> None:
|
||
"""精确校验 usage 的权威计数,同时兼容安全的未来 metadata。"""
|
||
|
||
if not isinstance(value, Mapping) or not value:
|
||
raise ValueError(f"{path} 必须是非空对象")
|
||
missing_fields = USAGE_REQUIRED_COUNT_FIELDS - set(value)
|
||
if missing_fields:
|
||
raise ValueError(f"{path} 缺少必需计数字段")
|
||
|
||
for key, item in value.items():
|
||
if not isinstance(key, str):
|
||
raise ValueError(f"{path} 对象键不是字符串")
|
||
field_path = f"{path}.{key}"
|
||
if key in USAGE_REQUIRED_COUNT_FIELDS or key in USAGE_OPTIONAL_COUNT_FIELDS:
|
||
_validate_non_negative_finite_number(item, field_path)
|
||
elif key in USAGE_COUNT_MAP_FIELDS:
|
||
_validate_usage_count_mapping(item, field_path, USAGE_COUNT_MAP_FIELDS[key])
|
||
elif key in USAGE_STRING_FIELDS:
|
||
if not isinstance(item, str) or (key != "inference_geo" and not item):
|
||
raise ValueError(f"{field_path} 必须是字符串")
|
||
elif key == "iterations":
|
||
if not isinstance(item, list):
|
||
raise ValueError(f"{field_path} 必须是数组")
|
||
_validate_safe_json(item, field_path)
|
||
else:
|
||
_validate_safe_json(item, field_path)
|
||
|
||
|
||
def _schema_type_matches(value: Any, expected: str) -> bool:
|
||
"""按 JSON 类型语义判断 Python 值,显式排除 bool 伪装整数。"""
|
||
|
||
return {
|
||
"object": isinstance(value, Mapping),
|
||
"array": isinstance(value, list),
|
||
"string": isinstance(value, str),
|
||
"integer": isinstance(value, int) and not isinstance(value, bool),
|
||
"number": isinstance(value, (int, float, Decimal)) and not isinstance(value, bool),
|
||
"boolean": isinstance(value, bool),
|
||
"null": value is None,
|
||
}.get(expected, False)
|
||
|
||
|
||
def validate_json_schema(value: Any, schema: Mapping[str, Any], path: str = "$") -> None:
|
||
"""校验本切片使用的严格 JSON Schema 子集。
|
||
|
||
CLI 负责结构化生成,本地仍必须独立校验。支持对象、数组、基础类型、required、
|
||
additionalProperties、enum/const、pattern、长度、数值边界以及 anyOf/oneOf。
|
||
"""
|
||
|
||
if not isinstance(schema, Mapping):
|
||
raise ValueError(f"{path} schema 必须是对象")
|
||
if "const" in schema and value != schema["const"]:
|
||
raise ValueError(f"{path} 不符合 const")
|
||
if "enum" in schema and value not in schema["enum"]:
|
||
raise ValueError(f"{path} 不符合 enum")
|
||
for combinator in ("anyOf", "oneOf"):
|
||
if combinator in schema:
|
||
matches = 0
|
||
for branch in schema[combinator]:
|
||
try:
|
||
validate_json_schema(value, branch, path)
|
||
except ValueError:
|
||
continue
|
||
matches += 1
|
||
if matches == 0 or (combinator == "oneOf" and matches != 1):
|
||
raise ValueError(f"{path} 不符合 {combinator}")
|
||
|
||
expected_type = schema.get("type")
|
||
if expected_type is not None:
|
||
expected_types = [expected_type] if isinstance(expected_type, str) else list(expected_type)
|
||
if not any(_schema_type_matches(value, item) for item in expected_types):
|
||
raise ValueError(f"{path} 类型非法")
|
||
|
||
if isinstance(value, Mapping):
|
||
properties = schema.get("properties", {})
|
||
required = schema.get("required", [])
|
||
missing = [field for field in required if field not in value]
|
||
if missing:
|
||
raise ValueError(f"{path} 缺少字段")
|
||
if schema.get("additionalProperties") is False:
|
||
unknown = set(value) - set(properties)
|
||
if unknown:
|
||
raise ValueError(f"{path} 包含未知字段")
|
||
for key, item in value.items():
|
||
if key in properties:
|
||
validate_json_schema(item, properties[key], f"{path}.{key}")
|
||
if isinstance(value, list):
|
||
if "minItems" in schema and len(value) < schema["minItems"]:
|
||
raise ValueError(f"{path} 数组过短")
|
||
if "maxItems" in schema and len(value) > schema["maxItems"]:
|
||
raise ValueError(f"{path} 数组过长")
|
||
if schema.get("uniqueItems") and len({canonical_json(item) for item in value}) != len(value):
|
||
raise ValueError(f"{path} 数组项重复")
|
||
item_schema = schema.get("items")
|
||
if isinstance(item_schema, Mapping):
|
||
for index, item in enumerate(value):
|
||
validate_json_schema(item, item_schema, f"{path}[{index}]")
|
||
if isinstance(value, str):
|
||
if "minLength" in schema and len(value) < schema["minLength"]:
|
||
raise ValueError(f"{path} 字符串过短")
|
||
if "maxLength" in schema and len(value) > schema["maxLength"]:
|
||
raise ValueError(f"{path} 字符串过长")
|
||
if "pattern" in schema and re.search(schema["pattern"], value) is None:
|
||
raise ValueError(f"{path} 格式非法")
|
||
if isinstance(value, (int, float, Decimal)) and not isinstance(value, bool):
|
||
numeric = Decimal(str(value))
|
||
if "minimum" in schema and numeric < Decimal(str(schema["minimum"])):
|
||
raise ValueError(f"{path} 小于最小值")
|
||
if "maximum" in schema and numeric > Decimal(str(schema["maximum"])):
|
||
raise ValueError(f"{path} 大于最大值")
|
||
|
||
|
||
def _receipt(
|
||
profile: ExecutionProfile,
|
||
*,
|
||
invocation_id: str,
|
||
started_at: float,
|
||
input_sha256: str,
|
||
exit_code: int | None,
|
||
envelope: Mapping[str, Any] | None,
|
||
actual_model_id: str | None,
|
||
total_cost: Decimal | None,
|
||
structured_output_sha256: str | None,
|
||
) -> ExecutionReceipt:
|
||
"""从已归一化字段构造成功或失败回执。"""
|
||
|
||
return ExecutionReceipt(
|
||
adapter_role=profile.adapter_role,
|
||
invocation_id=invocation_id,
|
||
execution_profile_sha256=profile.execution_profile_sha256,
|
||
requested_model_id=profile.resolved_model_id,
|
||
actual_model_id=actual_model_id,
|
||
model_match=actual_model_id == profile.resolved_model_id,
|
||
effort=profile.effort,
|
||
max_budget_usd_per_call=format(profile.max_budget_usd_per_call, "f"),
|
||
total_cost_usd=format(total_cost, "f") if total_cost is not None else None,
|
||
usage=envelope.get("usage") if envelope else None,
|
||
model_usage=envelope.get("modelUsage") if envelope else None,
|
||
stop_reason=envelope.get("stop_reason") if envelope else None,
|
||
terminal_reason=envelope.get("terminal_reason") if envelope else None,
|
||
is_error=envelope.get("is_error") if envelope else None,
|
||
api_error_status=envelope.get("api_error_status") if envelope else None,
|
||
exit_code=exit_code,
|
||
duration_ms=max(0, int((time.monotonic() - started_at) * 1000)),
|
||
input_sha256=input_sha256,
|
||
structured_output_sha256=structured_output_sha256,
|
||
json_schema_sha256=profile.json_schema_sha256,
|
||
)
|
||
|
||
|
||
def run_claude(
|
||
profile: ExecutionProfile,
|
||
business_input: Mapping[str, Any],
|
||
*,
|
||
runner: Callable[..., subprocess.CompletedProcess[str]] = subprocess.run,
|
||
binding_verifier: Callable[[ExecutionProfile], None] = verify_execution_profile,
|
||
business_validator: Callable[[Any], Mapping[str, Any]] | None = None,
|
||
source_environment: Mapping[str, str] | None = None,
|
||
) -> ClaudeInvocationResult:
|
||
"""以 fresh sandbox 进程执行一次 Claude 调用并联合校验回执。"""
|
||
|
||
prefix = profile.role_prefix
|
||
input_text = canonical_json(business_input)
|
||
input_sha256 = sha256_text(input_text)
|
||
invocation_id = hashlib.sha256(
|
||
f"{profile.execution_profile_sha256}:{input_sha256}:{time.time_ns()}".encode("utf-8")
|
||
).hexdigest()[:32]
|
||
if len(input_text) > profile.max_context_chars:
|
||
raise ClaudeRuntimeError(f"{prefix}_BUDGET_EXCEEDED", "业务输入超过冻结上下文上限")
|
||
binding_verifier(profile)
|
||
|
||
started_at = time.monotonic()
|
||
isolation_path: pathlib.Path | None = None
|
||
try:
|
||
isolation_path = pathlib.Path(
|
||
tempfile.mkdtemp(prefix="muse-claude-runtime-", dir=PRIVATE_TMP)
|
||
)
|
||
os.chmod(isolation_path, 0o700)
|
||
try:
|
||
environment = _minimal_environment(
|
||
source_environment or os.environ,
|
||
isolation_path,
|
||
require_authentication=runner is subprocess.run,
|
||
)
|
||
except ValueError as exc:
|
||
raise ClaudeRuntimeError(f"{prefix}_RECEIPT_INVALID", str(exc)) from exc
|
||
command = build_sandbox_command(profile, isolation_path)
|
||
try:
|
||
completed = runner(
|
||
command,
|
||
input=input_text,
|
||
text=True,
|
||
capture_output=True,
|
||
timeout=profile.timeout_seconds,
|
||
check=False,
|
||
cwd=str(isolation_path),
|
||
env=environment,
|
||
start_new_session=True,
|
||
)
|
||
except subprocess.TimeoutExpired as exc:
|
||
timeout_receipt = _receipt(
|
||
profile,
|
||
invocation_id=invocation_id,
|
||
started_at=started_at,
|
||
input_sha256=input_sha256,
|
||
exit_code=None,
|
||
envelope=None,
|
||
actual_model_id=None,
|
||
total_cost=None,
|
||
structured_output_sha256=None,
|
||
)
|
||
raise ClaudeRuntimeError(
|
||
f"{prefix}_TIMEOUT",
|
||
"Claude CLI 调用超过冻结 deadline",
|
||
details={"timeoutSeconds": profile.timeout_seconds},
|
||
receipt=timeout_receipt,
|
||
) from exc
|
||
except OSError as exc:
|
||
start_receipt = _receipt(
|
||
profile,
|
||
invocation_id=invocation_id,
|
||
started_at=started_at,
|
||
input_sha256=input_sha256,
|
||
exit_code=None,
|
||
envelope=None,
|
||
actual_model_id=None,
|
||
total_cost=None,
|
||
structured_output_sha256=None,
|
||
)
|
||
raise ClaudeRuntimeError(
|
||
f"{prefix}_RECEIPT_INVALID",
|
||
"Claude CLI fresh process 启动失败",
|
||
receipt=start_receipt,
|
||
) from exc
|
||
|
||
stderr_bytes = (completed.stderr or "").encode("utf-8", errors="replace")
|
||
safe_details = {
|
||
"stderrLength": len(stderr_bytes),
|
||
"stderrSha256": "sha256:" + hashlib.sha256(stderr_bytes).hexdigest(),
|
||
}
|
||
envelope: Mapping[str, Any] | None = None
|
||
issues: set[str] = set()
|
||
try:
|
||
parsed = json.loads(completed.stdout or "", parse_float=Decimal)
|
||
if not isinstance(parsed, Mapping):
|
||
raise ValueError("envelope 必须是对象")
|
||
envelope = parsed
|
||
except (json.JSONDecodeError, ValueError):
|
||
issues.add(f"{prefix}_RECEIPT_INVALID")
|
||
|
||
actual_model_id: str | None = None
|
||
total_cost: Decimal | None = None
|
||
structured_output_sha256: str | None = None
|
||
structured_output: Any = None
|
||
if envelope is not None:
|
||
required_receipt_fields = {
|
||
"type",
|
||
"is_error",
|
||
"terminal_reason",
|
||
"stop_reason",
|
||
"api_error_status",
|
||
"total_cost_usd",
|
||
"usage",
|
||
"modelUsage",
|
||
}
|
||
if required_receipt_fields - set(envelope):
|
||
issues.add(f"{prefix}_RECEIPT_INVALID")
|
||
if envelope.get("type") != "result" or not isinstance(envelope.get("is_error"), bool):
|
||
issues.add(f"{prefix}_RECEIPT_INVALID")
|
||
terminal_reason = envelope.get("terminal_reason")
|
||
api_status = envelope.get("api_error_status")
|
||
if envelope.get("is_error") is True or terminal_reason == "api_error" or api_status is not None:
|
||
issues.add(f"{prefix}_API_ERROR")
|
||
if terminal_reason not in profile.normal_terminal_reasons and terminal_reason not in {
|
||
"api_error",
|
||
"budget_exceeded",
|
||
"max_budget_exceeded",
|
||
}:
|
||
issues.add(f"{prefix}_RECEIPT_INVALID")
|
||
if terminal_reason in {"budget_exceeded", "max_budget_exceeded"}:
|
||
issues.add(f"{prefix}_BUDGET_EXCEEDED")
|
||
|
||
try:
|
||
_validate_usage(envelope.get("usage"))
|
||
except (ValueError, InvalidOperation):
|
||
issues.add(f"{prefix}_RECEIPT_INVALID")
|
||
|
||
model_usage = envelope.get("modelUsage")
|
||
if not isinstance(model_usage, Mapping) or not model_usage:
|
||
issues.update({f"{prefix}_RECEIPT_INVALID", f"{prefix}_MODEL_MISMATCH"})
|
||
else:
|
||
model_ids = [key for key in model_usage if isinstance(key, str) and key]
|
||
if len(model_ids) == 1:
|
||
actual_model_id = model_ids[0]
|
||
if set(model_ids) != {profile.resolved_model_id}:
|
||
issues.add(f"{prefix}_MODEL_MISMATCH")
|
||
try:
|
||
model_cost = sum(
|
||
(
|
||
_money(model_usage[model_id]["costUSD"], f"modelUsage.{model_id}.costUSD")
|
||
for model_id in model_ids
|
||
if isinstance(model_usage[model_id], Mapping)
|
||
),
|
||
Decimal("0.000000"),
|
||
)
|
||
if len(model_ids) != len(model_usage) or any(
|
||
not isinstance(model_usage[model_id], Mapping)
|
||
or "costUSD" not in model_usage[model_id]
|
||
for model_id in model_ids
|
||
):
|
||
raise ValueError("modelUsage 结构非法")
|
||
for model_id in model_ids:
|
||
_validate_number_tree(
|
||
{
|
||
key: item
|
||
for key, item in model_usage[model_id].items()
|
||
if key != "costUSD"
|
||
},
|
||
f"modelUsage.{model_id}",
|
||
)
|
||
except (ValueError, KeyError, InvalidOperation):
|
||
model_cost = None
|
||
issues.add(f"{prefix}_RECEIPT_INVALID")
|
||
|
||
try:
|
||
total_cost = _money(envelope.get("total_cost_usd"), "total_cost_usd")
|
||
except ValueError:
|
||
issues.add(f"{prefix}_RECEIPT_INVALID")
|
||
if total_cost is not None and model_cost is not None and total_cost != model_cost:
|
||
issues.update({f"{prefix}_RECEIPT_INVALID", f"{prefix}_BUDGET_EXCEEDED"})
|
||
if total_cost is not None and total_cost > profile.max_budget_usd_per_call:
|
||
issues.add(f"{prefix}_BUDGET_EXCEEDED")
|
||
|
||
if "structured_output" not in envelope:
|
||
issues.add(f"{prefix}_SCHEMA_INVALID")
|
||
else:
|
||
structured_output = envelope["structured_output"]
|
||
try:
|
||
validate_json_schema(structured_output, profile.json_schema)
|
||
if business_validator is not None:
|
||
structured_output = business_validator(structured_output)
|
||
if not isinstance(structured_output, Mapping):
|
||
raise ValueError("业务校验器必须返回对象")
|
||
structured_output_sha256 = sha256_json(structured_output)
|
||
except Exception: # noqa: BLE001 - 所有 schema/业务合同异常都必须统一失败关闭。
|
||
issues.add(f"{prefix}_SCHEMA_INVALID")
|
||
structured_output = None
|
||
|
||
if completed.returncode != 0:
|
||
issues.add(f"{prefix}_NONZERO_EXIT")
|
||
|
||
receipt = _receipt(
|
||
profile,
|
||
invocation_id=invocation_id,
|
||
started_at=started_at,
|
||
input_sha256=input_sha256,
|
||
exit_code=completed.returncode,
|
||
envelope=envelope,
|
||
actual_model_id=actual_model_id,
|
||
total_cost=total_cost,
|
||
structured_output_sha256=structured_output_sha256,
|
||
)
|
||
priority = (
|
||
f"{prefix}_TIMEOUT",
|
||
f"{prefix}_API_ERROR",
|
||
f"{prefix}_NONZERO_EXIT",
|
||
f"{prefix}_RECEIPT_INVALID",
|
||
f"{prefix}_BUDGET_EXCEEDED",
|
||
f"{prefix}_MODEL_MISMATCH",
|
||
f"{prefix}_SCHEMA_INVALID",
|
||
)
|
||
primary = next((code for code in priority if code in issues), None)
|
||
if primary is not None:
|
||
causes = [code for code in priority if code in issues and code != primary]
|
||
raise ClaudeRuntimeError(
|
||
primary,
|
||
"Claude CLI 联合成功条件未满足",
|
||
causes=causes,
|
||
details=safe_details,
|
||
receipt=receipt,
|
||
)
|
||
assert isinstance(structured_output, Mapping)
|
||
return ClaudeInvocationResult(dict(structured_output), receipt)
|
||
finally:
|
||
if isolation_path is not None:
|
||
# 运行隔离目录不承担 raw 保留职责;调用结束始终立即删除。
|
||
shutil.rmtree(isolation_path, ignore_errors=True)
|
||
|
||
|
||
__all__ = [
|
||
"ClaudeInvocationResult",
|
||
"ClaudeRuntimeError",
|
||
"ExecutionProfile",
|
||
"ExecutionReceipt",
|
||
"build_sandbox_command",
|
||
"build_sandbox_profile",
|
||
"canonical_json",
|
||
"run_claude",
|
||
"sha256_json",
|
||
"sha256_text",
|
||
"validate_json_schema",
|
||
"verify_execution_profile",
|
||
]
|