hash_utils.py
python
sha256:34035d72cef530c1ab9d6a6f53be18d803dde705fc3157617d70352a96d0747b
Merge branch 'fix/wire-push-external-parent-manifest' into dev
Human
9 days ago
| 1 | """Deterministic contract hashing for execution lineage verification. |
| 2 | |
| 3 | Rules: |
| 4 | - Structural fields participate in hashes. |
| 5 | - Advisory / meta fields are excluded. |
| 6 | - Serialization is canonical: sorted keys, no whitespace, json.dumps. |
| 7 | - Hash is SHA-256 with ``sha256:`` prefix — full 256-bit collision resistance. |
| 8 | - No MD5, no pickle, no repr(). |
| 9 | |
| 10 | Excluded fields (advisory / meta / visual / runtime): |
| 11 | contract_version, contract_hash, parent_contract_hash, execution_hash, |
| 12 | l2_generate_prompt, region_name, gm_guidance, |
| 13 | assigned_color, existing_track_id. |
| 14 | """ |
| 15 | |
| 16 | import dataclasses |
| 17 | import json |
| 18 | from typing import TYPE_CHECKING |
| 19 | |
| 20 | from muse.core.types import blob_id |
| 21 | from musehub.types.json_types import JSONObject, JSONValue |
| 22 | |
| 23 | if TYPE_CHECKING: |
| 24 | from _typeshed import DataclassInstance |
| 25 | |
| 26 | _HASH_EXCLUDED_FIELDS = frozenset({ |
| 27 | "contract_version", |
| 28 | "contract_hash", |
| 29 | "parent_contract_hash", |
| 30 | "execution_hash", |
| 31 | "l2_generate_prompt", |
| 32 | "region_name", |
| 33 | "gm_guidance", |
| 34 | "assigned_color", |
| 35 | "existing_track_id", |
| 36 | }) |
| 37 | |
| 38 | def _normalize_value(value: DataclassInstance | JSONValue) -> JSONValue: |
| 39 | """Recursively normalize a value for canonical serialization.""" |
| 40 | if dataclasses.is_dataclass(value) and not isinstance(value, type): |
| 41 | return canonical_contract_dict(value) |
| 42 | if isinstance(value, dict): |
| 43 | return {k: _normalize_value(v) for k, v in sorted(value.items())} |
| 44 | if isinstance(value, (list, tuple)): |
| 45 | return [_normalize_value(item) for item in value] |
| 46 | if isinstance(value, (int, float, str, bool, type(None))): |
| 47 | return value |
| 48 | return str(value) |
| 49 | |
| 50 | def canonical_contract_dict(obj: DataclassInstance) -> JSONObject: |
| 51 | """Convert a frozen dataclass to a canonical ordered dict for hashing. |
| 52 | |
| 53 | Excludes advisory/meta fields defined in ``_HASH_EXCLUDED_FIELDS``. |
| 54 | Recursively normalizes nested dataclasses and collections. |
| 55 | Keys are sorted for deterministic serialization. |
| 56 | |
| 57 | Special case: ``CompositionContract.sections`` is serialized as a |
| 58 | sorted list of section contract hashes (not full objects), keeping |
| 59 | the root hash compact and order-independent. |
| 60 | """ |
| 61 | if not dataclasses.is_dataclass(obj): |
| 62 | raise TypeError(f"Expected a dataclass, got {type(obj).__name__}") |
| 63 | |
| 64 | _is_composition = type(obj).__name__ == "CompositionContract" |
| 65 | |
| 66 | result: JSONObject = {} |
| 67 | for f in dataclasses.fields(obj): |
| 68 | if f.name in _HASH_EXCLUDED_FIELDS: |
| 69 | continue |
| 70 | value = getattr(obj, f.name) |
| 71 | if _is_composition and f.name == "sections": |
| 72 | result["sections"] = sorted( |
| 73 | getattr(s, "contract_hash", "") for s in value |
| 74 | ) |
| 75 | else: |
| 76 | result[f.name] = _normalize_value(value) |
| 77 | |
| 78 | return dict(sorted(result.items())) |
| 79 | |
| 80 | def compute_contract_hash(obj: DataclassInstance) -> str: |
| 81 | """Compute a deterministic ``sha256:``-prefixed hash of structural contract fields.""" |
| 82 | canonical = canonical_contract_dict(obj) |
| 83 | serialized = json.dumps(canonical, separators=(",", ":"), sort_keys=True) |
| 84 | return blob_id(serialized.encode("utf-8")) |
| 85 | |
| 86 | def seal_contract(obj: DataclassInstance, parent_hash: str = "") -> None: |
| 87 | """Compute and set ``contract_hash`` on a frozen dataclass. |
| 88 | |
| 89 | Uses ``object.__setattr__`` to bypass frozen enforcement. |
| 90 | Optionally sets ``parent_contract_hash`` if provided. |
| 91 | """ |
| 92 | if parent_hash: |
| 93 | object.__setattr__(obj, "parent_contract_hash", parent_hash) |
| 94 | h = compute_contract_hash(obj) |
| 95 | object.__setattr__(obj, "contract_hash", h) |
| 96 | |
| 97 | def set_parent_hash(obj: DataclassInstance, parent_hash: str) -> None: |
| 98 | """Set ``parent_contract_hash`` on a frozen dataclass without unfreezing it. |
| 99 | |
| 100 | Uses ``object.__setattr__`` to bypass the ``frozen=True`` restriction. |
| 101 | Call this when linking a child contract to its parent before sealing the |
| 102 | child with ``seal_contract``. |
| 103 | """ |
| 104 | object.__setattr__(obj, "parent_contract_hash", parent_hash) |
| 105 | |
| 106 | def verify_contract_hash(obj: DataclassInstance) -> bool: |
| 107 | """Recompute hash and compare to the stored ``contract_hash``. |
| 108 | |
| 109 | Returns ``True`` if the stored hash matches the recomputed hash. |
| 110 | """ |
| 111 | stored = getattr(obj, "contract_hash", "") |
| 112 | if not stored: |
| 113 | return False |
| 114 | return compute_contract_hash(obj) == stored |
| 115 | |
| 116 | def hash_list_canonical(items: list[str]) -> str: |
| 117 | """Collision-proof parent hash from a list of child hashes. |
| 118 | |
| 119 | Sorts lexicographically, JSON-encodes the sorted list, then |
| 120 | SHA-256 hashes the result. Returns a ``sha256:``-prefixed canonical ID. |
| 121 | |
| 122 | This replaces the old ``SHA256("hashA:hashB")`` pattern which |
| 123 | was vulnerable to delimiter collisions. |
| 124 | """ |
| 125 | serialized = json.dumps(sorted(items)) |
| 126 | return blob_id(serialized.encode("utf-8")) |
| 127 | |
| 128 | def compute_execution_hash(contract_hash: str, trace_id: str) -> str: |
| 129 | """Bind an execution to a specific contract + session. |
| 130 | |
| 131 | Prevents replay attacks: same contract in a different session |
| 132 | produces a different execution_hash. Returns a ``sha256:``-prefixed ID. |
| 133 | """ |
| 134 | payload = (contract_hash + trace_id).encode("utf-8") |
| 135 | return blob_id(payload) |
File History
14 commits
sha256:34035d72cef530c1ab9d6a6f53be18d803dde705fc3157617d70352a96d0747b
Merge branch 'fix/wire-push-external-parent-manifest' into dev
Human
9 days ago
sha256:fc04e4cae9e1774d6a21b65c45daeed0e6787eb581d13aa1b03bfe9384a34226
Merge branch 'fix/two-column-scroll-layout' into dev
Human
9 days ago
sha256:408916fc5973ba59c6e4eebaa80ebdcc801c0a63205651e25009d11548f79454
chore: bump version to 0.2.0.dev2 — nightly.2, matching muse
Sonnet 4.6
patch
12 days ago
sha256:d035733f21ccff27735fddebfbbe0ed24565a32a22db8de5885402262671ecd2
chore: bump version to 0.2.0rc15 for musehub#113 fix release
Sonnet 4.6
patch
15 days ago
sha256:0032d6cfa33bc3c8367436ad768e7dd0e339b4332153160247da8266cb5fa352
Merge branch 'task/version-tags-phase3-server' into dev
Human
18 days ago
sha256:4669620efda9ff41c55bdefd1f7bfe1c239d468428744c84ead9957e5a003a53
merge: rescue snapshot-recovery hardening (c00aa21d) into d…
Opus 4.8
minor
⚠
30 days ago
sha256:a59da49c4611b970fc4b6ae48678ce4943261c213a07ddbd73ce9201df869b4a
fix: remove false-positive proposal_comments index drop fro…
Sonnet 4.6
patch
34 days ago
sha256:0a240d6dbff234f07d98a28a4a9a68db702f3f9ff9260196f24219bdb1c0b6f3
feat: render markdown mists as HTML with heading anchor links
Sonnet 4.6
patch
35 days ago
sha256:24a7d47486ebc4ebd1832830580e177ec6f877b48dced8c000e198cdec4ce9d6
Merge 'task/bump-version-rc12' into 'dev' — proposal: Bump …
Human
36 days ago
sha256:b9ff931d147e0114a1f17060f415b89ed551c170a91ff226c70437aa5c85f9ee
Merge 'task/bump-version-rc12' into 'dev' — proposal: Bump …
Human
36 days ago
sha256:d1122d21e73471879b460037b22c0b50fded7c423444a176f248428f75dac39c
Merge 'task/fix-issue-pagination-cursor' into 'dev' — propo…
Human
36 days ago
sha256:01e18975e73d2b3cd5b6db7929c895bef9aa6e0d4391dc5b2adfc548b41318dd
Merge 'feat/adding-debug-logs-to-staging' into 'dev' — prop…
Human
37 days ago
sha256:6b1949fc2797ca4c1936a637a4cbfec828ef56cf52398a2e74ca3c4f494e728f
fix: use wire_bytes not mpack_bytes_raw in compute_object_b…
Sonnet 4.6
patch
49 days ago
sha256:b99f2455dc346966d040133f5203297e6e3ef5803a93728a2c30568d0a0f7583
rename: delta_add → delta_upsert across wire format, models…
Sonnet 4.6
patch
51 days ago