852 lines
34 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.

#!/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
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",
"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"})
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")
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 _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("一次调用只能使用一种授权认证")
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:
"""递归校验 usage 中的数值均有限且非负。"""
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 _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_number_tree(envelope.get("usage"), "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",
]