diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 34339dd..5a6ffef 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -23,3 +23,9 @@ jobs: - name: Run tests run: python -m pytest -q + + - name: Validate custody policy + run: python -c "from services.custody_policy.policy import load_policy; load_policy(); print('custody policy: valid')" + + - name: Run custody policy tests + run: python -m pytest -q tests/custody_policy diff --git a/requirements-dev.txt b/requirements-dev.txt index c3ae04d..3a1e7c9 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -1,3 +1,6 @@ pytest>=8.4,<9.0 httpx>=0.28,<1.0 +pyyaml>=6.0,<7.0 +jsonschema>=4.25,<5.0 +web3>=7.13,<8.0 -r services/api/requirements.txt diff --git a/services/custody_policy/README.md b/services/custody_policy/README.md new file mode 100644 index 0000000..a2ec827 --- /dev/null +++ b/services/custody_policy/README.md @@ -0,0 +1,14 @@ +# Custody policy package + +This package provides deterministic, fail-closed custody validation. + +## Components + +- `custody-policy.yaml`: policy configuration. +- `custody-policy.schema.json`: JSON Schema validation. +- `scoring.py`: deterministic signer and transaction risk scoring. +- `collector.py`: read-only Safe state collection over JSON-RPC. +- `policy.py`: policy loading and schema validation. +- `cli.py`: local fixture or live RPC evaluation. + +The collector is read-only and never accepts private keys or signing material. diff --git a/services/custody_policy/__init__.py b/services/custody_policy/__init__.py new file mode 100644 index 0000000..9768a1b --- /dev/null +++ b/services/custody_policy/__init__.py @@ -0,0 +1,4 @@ +from .policy import evaluate, load_policy +from .scoring import evaluate_custody, signer_independence, transaction_risk + +__all__ = ["evaluate", "evaluate_custody", "load_policy", "signer_independence", "transaction_risk"] diff --git a/services/custody_policy/cli.py b/services/custody_policy/cli.py new file mode 100644 index 0000000..28083f7 --- /dev/null +++ b/services/custody_policy/cli.py @@ -0,0 +1,35 @@ +from __future__ import annotations + +import argparse +import json + +from .collector import collect_safe_state +from .policy import evaluate + + +def main() -> int: + parser = argparse.ArgumentParser(description="SentinelAI deterministic custody policy validator") + parser.add_argument("--state", help="JSON state fixture to evaluate") + parser.add_argument("--rpc-url", help="JSON-RPC URL for live Safe collection") + parser.add_argument("--safe", help="Safe address") + parser.add_argument("--chain-id", type=int) + args = parser.parse_args() + + if bool(args.state) == bool(args.rpc_url): + parser.error("provide exactly one of --state or --rpc-url") + + if args.state: + with open(args.state, "r", encoding="utf-8") as handle: + state = json.load(handle) + else: + if not args.safe: + parser.error("--safe is required with --rpc-url") + state = collect_safe_state(args.rpc_url, args.safe, args.chain_id).as_dict() + + result = evaluate(state) + print(json.dumps(result, indent=2, sort_keys=True)) + return 0 if result["decision"] != "BLOCK" else 2 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/services/custody_policy/collector.py b/services/custody_policy/collector.py new file mode 100644 index 0000000..a6e7d31 --- /dev/null +++ b/services/custody_policy/collector.py @@ -0,0 +1,111 @@ +from __future__ import annotations + +from dataclasses import asdict, dataclass +from typing import Any + +from web3 import Web3 + +FALLBACK_HANDLER_STORAGE_SLOT = "0x6c9a6c4a39284e37ed1cf53d337577d14212a4870fb976a4366c693b939918d5" +GUARD_STORAGE_SLOT = "0x4a204f620c8c5ccdca3fd54d003badd85ba500436a431f0cbda4f558c93c34c8" +MODULE_GUARD_STORAGE_SLOT = "0xb104e0b93118902c651344349b610029d694cfdec91c589c91ebafbcd0289947" +SAFE_ABI = [ + {"inputs": [], "name": "getOwners", "outputs": [{"internalType": "address[]", "name": "", "type": "address[]"}], "stateMutability": "view", "type": "function"}, + {"inputs": [], "name": "getThreshold", "outputs": [{"internalType": "uint256", "name": "", "type": "uint256"}], "stateMutability": "view", "type": "function"}, + {"inputs": [{"internalType": "address", "name": "start", "type": "address"}, {"internalType": "uint256", "name": "pageSize", "type": "uint256"}], "name": "getModulesPaginated", "outputs": [{"internalType": "address[]", "name": "array", "type": "address[]"}, {"internalType": "address", "name": "next", "type": "address"}], "stateMutability": "view", "type": "function"}, + {"inputs": [], "name": "VERSION", "outputs": [{"internalType": "string", "name": "", "type": "string"}], "stateMutability": "view", "type": "function"}, +] +ZERO = "0x0000000000000000000000000000000000000000" +SENTINEL = "0x0000000000000000000000000000000000000001" + + +@dataclass(frozen=True) +class SafeState: + chain_id: int + address: str + owners: list[str] + threshold: int + modules: list[dict[str, str]] + guards: list[dict[str, str]] + module_guard: dict[str, str] | None + fallback_handler: dict[str, str] | None + version: str | None + signers: list[dict[str, Any]] + + def as_dict(self) -> dict[str, Any]: + return asdict(self) + + +def _address_from_storage(value: bytes | str) -> str: + raw = value.hex() if isinstance(value, bytes) else value.removeprefix("0x") + return Web3.to_checksum_address("0x" + raw[-40:]) + + +def _code_hash(w3: Web3, address: str) -> str: + code = w3.eth.get_code(address) + if not code: + return ZERO + return Web3.to_hex(Web3.keccak(code)) + + +def _extension(w3: Web3, address: str) -> dict[str, str]: + address = Web3.to_checksum_address(address) + return {"address": address, "code_hash": _code_hash(w3, address)} + + +def _walk_modules(contract: Any, page_size: int = 100) -> list[str]: + modules: list[str] = [] + cursor = SENTINEL + for _ in range(100): + page, next_cursor = contract.functions.getModulesPaginated(cursor, page_size).call() + modules.extend(Web3.to_checksum_address(x) for x in page) + if not page or Web3.to_checksum_address(next_cursor) == Web3.to_checksum_address(SENTINEL): + break + cursor = next_cursor + else: + raise RuntimeError("module pagination exceeded safety limit") + return modules + + +def collect_safe_state(rpc_url: str, safe_address: str, chain_id: int | None = None) -> SafeState: + w3 = Web3(Web3.HTTPProvider(rpc_url, request_kwargs={"timeout": 20})) + if not w3.is_connected(): + raise ConnectionError("unable to connect to JSON-RPC endpoint") + actual_chain_id = int(w3.eth.chain_id) + if chain_id is not None and actual_chain_id != chain_id: + raise ValueError(f"chain ID mismatch: expected {chain_id}, got {actual_chain_id}") + + address = Web3.to_checksum_address(safe_address) + if w3.eth.get_code(address) in (b"", b"\x00"): + raise ValueError("target address has no deployed bytecode") + contract = w3.eth.contract(address=address, abi=SAFE_ABI) + owners = [Web3.to_checksum_address(x) for x in contract.functions.getOwners().call()] + threshold = int(contract.functions.getThreshold().call()) + modules = [_extension(w3, x) for x in _walk_modules(contract)] + + fallback_address = _address_from_storage(w3.eth.get_storage_at(address, FALLBACK_HANDLER_STORAGE_SLOT)) + guard_address = _address_from_storage(w3.eth.get_storage_at(address, GUARD_STORAGE_SLOT)) + module_guard_address = _address_from_storage(w3.eth.get_storage_at(address, MODULE_GUARD_STORAGE_SLOT)) + guards = [] if guard_address == Web3.to_checksum_address(ZERO) else [_extension(w3, guard_address)] + module_guard = None if module_guard_address == Web3.to_checksum_address(ZERO) else _extension(w3, module_guard_address) + fallback = None if fallback_address == Web3.to_checksum_address(ZERO) else _extension(w3, fallback_address) + + try: + version = str(contract.functions.VERSION().call()) + except Exception: + version = None + + # On-chain state cannot establish operational independence. Unknown metadata is + # intentionally false so the policy engine fails closed until an operator enriches it. + signers = [{ + "address": owner, + "key_generation_verified": False, + "hardware_independent": False, + "software_independent": False, + "administrator_independent": False, + "geography_independent": False, + "backup_independent": False, + "communications_independent": False, + "recovery_independent": False, + } for owner in owners] + + return SafeState(actual_chain_id, address, owners, threshold, modules, guards, module_guard, fallback, version, signers) diff --git a/services/custody_policy/custody-policy.schema.json b/services/custody_policy/custody-policy.schema.json new file mode 100644 index 0000000..cddae30 --- /dev/null +++ b/services/custody_policy/custody-policy.schema.json @@ -0,0 +1,53 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://sentinelai.dev/schemas/custody-policy.schema.json", + "title": "SentinelAI Custody Policy", + "type": "object", + "required": ["version", "mode", "custody", "modules", "guards", "allowlists", "transaction", "recovery", "risk", "hard_blocks"], + "properties": { + "version": {"type": "string", "pattern": "^[0-9]+\\.[0-9]+$"}, + "mode": {"const": "FAIL_CLOSED"}, + "custody": { + "type": "object", "required": ["minimum_threshold", "require_threshold_below_signer_count", "minimum_signer_independence", "preferred_signer_independence", "minimum_recovery_score"], + "properties": { + "minimum_threshold": {"type": "integer", "minimum": 2}, + "require_threshold_below_signer_count": {"const": true}, + "minimum_signer_independence": {"type": "integer", "minimum": 0, "maximum": 100}, + "preferred_signer_independence": {"type": "integer", "minimum": 0, "maximum": 100}, + "minimum_recovery_score": {"type": "integer", "minimum": 0, "maximum": 100} + }, "additionalProperties": false + }, + "modules": {"$ref": "#/$defs/extensionPolicy"}, + "guards": {"$ref": "#/$defs/extensionPolicyWithRecovery"}, + "allowlists": { + "type": "object", "additionalProperties": false, + "required": ["modules", "guards", "module_guards", "fallback_handlers"], + "properties": { + "modules": {"$ref": "#/$defs/allowlist"}, + "guards": {"$ref": "#/$defs/allowlist"}, + "module_guards": {"$ref": "#/$defs/allowlist"}, + "fallback_handlers": {"$ref": "#/$defs/allowlist"} + } + }, + "transaction": { + "type": "object", "additionalProperties": false, + "required": ["require_destination_verification", "require_calldata_decoding", "require_simulation_for_high_value", "block_unlimited_approval", "block_unknown_delegatecall"], + "properties": { + "require_destination_verification": {"const": true}, + "require_calldata_decoding": {"const": true}, + "require_simulation_for_high_value": {"const": true}, + "block_unlimited_approval": {"const": true}, + "block_unknown_delegatecall": {"const": true} + } + }, + "recovery": {"type": "object", "additionalProperties": false, "required": ["require_test", "max_age_days"], "properties": {"require_test": {"const": true}, "max_age_days": {"type": "integer", "minimum": 1}}}, + "risk": {"type": "object", "required": ["bands"], "properties": {"bands": {"type": "object", "required": ["low_max", "moderate_max", "high_max", "critical_max"], "properties": {"low_max": {"const": 19}, "moderate_max": {"const": 39}, "high_max": {"const": 59}, "critical_max": {"const": 79}}, "additionalProperties": false}}, "additionalProperties": false}, + "hard_blocks": {"type": "array", "items": {"type": "string"}, "uniqueItems": true} + }, + "$defs": { + "extensionPolicy": {"type": "object", "additionalProperties": false, "required": ["default", "require_source_verified", "require_code_hash", "require_audit_for_material"], "properties": {"default": {"const": "DENY"}, "require_source_verified": {"const": true}, "require_code_hash": {"const": true}, "require_audit_for_material": {"const": true}}}, + "extensionPolicyWithRecovery": {"type": "object", "additionalProperties": false, "required": ["default", "require_source_verified", "require_code_hash", "require_audit_for_material", "require_recovery_test"], "properties": {"default": {"const": "DENY"}, "require_source_verified": {"const": true}, "require_code_hash": {"const": true}, "require_audit_for_material": {"const": true}, "require_recovery_test": {"const": true}}}, + "allowlist": {"type": "array", "items": {"type": "object", "required": ["chain_id", "address", "code_hash"], "properties": {"chain_id": {"type": "integer", "minimum": 1}, "address": {"type": "string", "pattern": "^0x[0-9a-fA-F]{40}$"}, "code_hash": {"type": "string", "pattern": "^0x[0-9a-fA-F]{64}$"}, "audit_status": {"type": "string"}, "max_value": {"type": "string"}}, "additionalProperties": false}, "uniqueItems": true} + }, + "additionalProperties": false +} \ No newline at end of file diff --git a/services/custody_policy/custody-policy.yaml b/services/custody_policy/custody-policy.yaml new file mode 100644 index 0000000..92bd88b --- /dev/null +++ b/services/custody_policy/custody-policy.yaml @@ -0,0 +1,49 @@ +version: "1.0" +mode: FAIL_CLOSED +custody: + minimum_threshold: 2 + require_threshold_below_signer_count: true + minimum_signer_independence: 70 + preferred_signer_independence: 85 + minimum_recovery_score: 80 +modules: + default: DENY + require_source_verified: true + require_code_hash: true + require_audit_for_material: true +guards: + default: DENY + require_source_verified: true + require_code_hash: true + require_audit_for_material: true + require_recovery_test: true +allowlists: + modules: [] + guards: [] + module_guards: [] + fallback_handlers: [] +transaction: + require_destination_verification: true + require_calldata_decoding: true + require_simulation_for_high_value: true + block_unlimited_approval: true + block_unknown_delegatecall: true +recovery: + require_test: true + max_age_days: 365 +risk: + bands: + low_max: 19 + moderate_max: 39 + high_max: 59 + critical_max: 79 +hard_blocks: + - unknown_module + - unknown_guard + - unknown_module_guard + - unknown_fallback + - simulation_mismatch + - unauthorized_owner_change + - unauthorized_threshold_change + - unbounded_delegatecall + - denylisted_destination diff --git a/services/custody_policy/policy.py b/services/custody_policy/policy.py new file mode 100644 index 0000000..a0185f5 --- /dev/null +++ b/services/custody_policy/policy.py @@ -0,0 +1,29 @@ +from __future__ import annotations + +import json +from pathlib import Path +from typing import Any + +import yaml +from jsonschema import Draft202012Validator + +from .scoring import evaluate_custody + +ROOT = Path(__file__).resolve().parent +SCHEMA_PATH = ROOT / "custody-policy.schema.json" +POLICY_PATH = ROOT / "custody-policy.yaml" + + +def load_policy(path: str | Path = POLICY_PATH) -> dict[str, Any]: + with Path(path).open("r", encoding="utf-8") as handle: + policy = yaml.safe_load(handle) + schema = json.loads(SCHEMA_PATH.read_text(encoding="utf-8")) + errors = sorted(Draft202012Validator(schema).iter_errors(policy), key=lambda e: list(e.path)) + if errors: + details = "; ".join(f"{'.'.join(map(str, e.path))}: {e.message}" for e in errors) + raise ValueError(f"invalid custody policy: {details}") + return policy + + +def evaluate(state: dict[str, Any], policy: dict[str, Any] | None = None) -> dict[str, Any]: + return evaluate_custody(state, policy or load_policy()) diff --git a/services/custody_policy/scoring.py b/services/custody_policy/scoring.py new file mode 100644 index 0000000..a9d6d39 --- /dev/null +++ b/services/custody_policy/scoring.py @@ -0,0 +1,116 @@ +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any + +ZERO = "0x0000000000000000000000000000000000000000" + + +@dataclass(frozen=True) +class Score: + signer_independence: float + key_security: float + execution_security: float + transaction_controls: float + recovery_resilience: float + monitoring_response: float + + @property + def total(self) -> int: + value = (0.25 * self.signer_independence + 0.15 * self.key_security + 0.20 * self.execution_security + 0.15 * self.transaction_controls + 0.15 * self.recovery_resilience + 0.10 * self.monitoring_response) + return round(max(0.0, min(100.0, value))) + + +def signer_independence(signer: dict[str, Any]) -> int: + fields = {"key_generation_verified": 20, "hardware_independent": 15, "software_independent": 10, "administrator_independent": 15, "geography_independent": 15, "backup_independent": 10, "communications_independent": 5, "recovery_independent": 10} + return sum(weight for field, weight in fields.items() if signer.get(field) is True) + + +def _is_zero(address: Any) -> bool: + return isinstance(address, str) and address.lower() == ZERO + + +def _allowlisted(item: dict[str, Any] | None, entries: list[dict[str, Any]], chain_id: int) -> bool: + if not item: + return True + address = str(item.get("address", "")).lower() + code_hash = str(item.get("code_hash", "")).lower() + return any(int(e.get("chain_id", -1)) == chain_id and str(e.get("address", "")).lower() == address and str(e.get("code_hash", "")).lower() == code_hash for e in entries) + + +def _audited(item: dict[str, Any] | None, entries: list[dict[str, Any]], chain_id: int) -> bool: + if not item: + return True + return any(int(e.get("chain_id", -1)) == chain_id and str(e.get("address", "")).lower() == str(item.get("address", "")).lower() and str(e.get("code_hash", "")).lower() == str(item.get("code_hash", "")).lower() and e.get("audit_status") in {"audited", "reviewed"} for e in entries) + + +def _decision_for_risk(risk: int) -> str: + if risk <= 19: return "ALLOW" + if risk <= 39: return "ALLOW_WITH_CONTROLS" + if risk <= 79: return "REVIEW" + return "BLOCK" + + +def transaction_risk(tx: dict[str, Any]) -> tuple[int, list[str]]: + score = 0 + rules: list[str] = [] + weighted = (("unknown_destination", 20), ("unverified_contract", 15), ("first_interaction", 10), ("token_approval", 15), ("unlimited_approval", 30), ("ownership_change", 40), ("threshold_change", 40), ("signer_change", 35), ("module_change", 40), ("guard_change", 35), ("fallback_change", 35), ("delegatecall", 40), ("unbounded_external_execution", 35), ("simulation_mismatch", 50), ("simulation_unavailable_high_value", 30), ("policy_violation", 40)) + for field, weight in weighted: + if tx.get(field) is True: + score += weight + rules.append(field) + if tx.get("denylisted_destination") is True: + return 100, rules + ["denylisted_destination"] + if tx.get("calldata_decoded") is False: + rules.append("calldata_undecoded") + score += 30 if tx.get("high_value") else 10 + return min(score, 100), rules + + +def evaluate_custody(state: dict[str, Any], policy: dict[str, Any]) -> dict[str, Any]: + owners = state.get("owners", []) + threshold = int(state.get("threshold", 0)) + chain_id = int(state.get("chain_id", 0)) + hard_blocks: list[str] = [] + + if threshold < int(policy["custody"]["minimum_threshold"]): hard_blocks.append("threshold_below_minimum") + if policy["custody"]["require_threshold_below_signer_count"] and not threshold < len(owners): hard_blocks.append("threshold_not_below_signer_count") + hard_blocks.extend(str(item) for item in state.get("hard_blocks", []) if item) + + signers = state.get("signers", []) + if len(signers) != len(owners): + hard_blocks.append("signer_metadata_mismatch") + signer_scores = [signer_independence(s) for s in signers] + min_signer = min(signer_scores) if signer_scores else 0 + if min_signer < int(policy["custody"]["minimum_signer_independence"]): hard_blocks.append("signer_independence_below_minimum") + + allow = policy["allowlists"] + modules = state.get("modules", []) + guards = state.get("guards", []) + module_guard = state.get("module_guard") + fallback = state.get("fallback_handler") + + if any(not _allowlisted(m, allow["modules"], chain_id) for m in modules): hard_blocks.append("unknown_module") + if any(not _allowlisted(g, allow["guards"], chain_id) for g in guards): hard_blocks.append("unknown_guard") + if module_guard and not _allowlisted(module_guard, allow["module_guards"], chain_id): hard_blocks.append("unknown_module_guard") + if fallback and not _allowlisted(fallback, allow["fallback_handlers"], chain_id): hard_blocks.append("unknown_fallback") + + recovery = float(state.get("recovery_score", 0)) + if recovery < int(policy["custody"]["minimum_recovery_score"]): hard_blocks.append("recovery_score_below_minimum") + + transaction = state.get("transaction") + tx_score, tx_rules = (0, []) if transaction is None else transaction_risk(transaction) + configured_hard_blocks = set(policy.get("hard_blocks", [])) + hard_blocks.extend(r for r in tx_rules if r in configured_hard_blocks) + if "denylisted_destination" in tx_rules: hard_blocks.append("denylisted_destination") + + extension_entries = allow["modules"] + allow["guards"] + allow["module_guards"] + allow["fallback_handlers"] + active_extensions = modules + guards + ([module_guard] if module_guard else []) + ([fallback] if fallback else []) + execution = 100 if all(_audited(x, extension_entries, chain_id) for x in active_extensions) else 0 + + score = Score(min_signer, float(state.get("key_security_score", 100)), execution, max(0, 100 - tx_score), recovery, float(state.get("monitoring_score", 100))) + decision = "BLOCK" if hard_blocks else _decision_for_risk(tx_score) + if not hard_blocks and score.total < 40: decision = "BLOCK" + elif not hard_blocks and score.total < 70 and decision == "ALLOW": decision = "ALLOW_WITH_CONTROLS" + + return {"custody_risk_score": score.total, "transaction_risk_score": tx_score, "transaction_rules": tx_rules, "signer_independence_min": min_signer, "hard_blocks": sorted(set(hard_blocks)), "decision": decision} diff --git a/tests/custody_policy/test_policy.py b/tests/custody_policy/test_policy.py new file mode 100644 index 0000000..0a6641e --- /dev/null +++ b/tests/custody_policy/test_policy.py @@ -0,0 +1,82 @@ +import pytest + +from services.custody_policy.policy import load_policy +from services.custody_policy.scoring import evaluate_custody, signer_independence, transaction_risk + + +@pytest.fixture +def policy(): + return load_policy() + + +def signer(independent=True): + return {name: independent for name in ( + "key_generation_verified", "hardware_independent", "software_independent", + "administrator_independent", "geography_independent", "backup_independent", + "communications_independent", "recovery_independent", + )} + + +def test_signer_independence_is_deterministic(): + assert signer_independence(signer()) == 100 + assert signer_independence(signer(False)) == 0 + + +def test_transaction_risk_is_deterministic(): + score, rules = transaction_risk({"token_approval": True, "unlimited_approval": True}) + assert score == 45 + assert rules == ["token_approval", "unlimited_approval"] + + +def test_denylisted_destination_is_hard_block(): + score, rules = transaction_risk({"denylisted_destination": True}) + assert score == 100 + assert rules == ["denylisted_destination"] + + +def test_healthy_3_of_5_can_pass(policy): + state = { + "owners": ["a", "b", "c", "d", "e"], "threshold": 3, + "signers": [signer() for _ in range(5)], "modules": [], "guards": [], + "fallback_handler": {}, "recovery_score": 95, "key_security_score": 95, + "monitoring_score": 95, "transaction": {"calldata_decoded": True}, + } + result = evaluate_custody(state, policy) + assert result["hard_blocks"] == [] + assert result["decision"] == "ALLOW" + assert result["custody_risk_score"] >= 90 + + +def test_n_of_n_blocks(policy): + state = { + "owners": ["a", "b", "c"], "threshold": 3, + "signers": [signer() for _ in range(3)], "modules": [], "guards": [], + "fallback_handler": {}, "recovery_score": 95, + } + result = evaluate_custody(state, policy) + assert "threshold_not_below_signer_count" in result["hard_blocks"] + assert result["decision"] == "BLOCK" + + +def test_unknown_module_blocks(policy): + state = { + "owners": ["a", "b", "c"], "threshold": 2, + "signers": [signer() for _ in range(3)], + "modules": [{"allowlisted": False, "audited": False}], "guards": [], + "fallback_handler": {}, "recovery_score": 95, + } + result = evaluate_custody(state, policy) + assert "unknown_module" in result["hard_blocks"] + assert result["decision"] == "BLOCK" + + +def test_simulation_mismatch_is_hard_block(policy): + state = { + "owners": ["a", "b", "c"], "threshold": 2, + "signers": [signer() for _ in range(3)], "modules": [], "guards": [], + "fallback_handler": {}, "recovery_score": 95, + "transaction": {"simulation_mismatch": True, "calldata_decoded": True}, + } + result = evaluate_custody(state, policy) + assert "simulation_mismatch" in result["hard_blocks"] + assert result["decision"] == "BLOCK"