gabriel / musehub public
wire.py python
189 lines 6.7 KB
8b8f8144 fix: MEDIUM security patch (M1–M7) — dev → main (#27) Gabriel Cardona <cgcardona@gmail.com> 2d ago
1 """Wire protocol Pydantic models — Muse CLI native format.
2
3 These models match the Muse CLI ``HttpTransport`` wire format exactly.
4 All fields are snake_case to match Muse's internal CommitDict/SnapshotDict/
5 ObjectPayload TypedDicts.
6
7 The wire protocol is intentionally separate from the REST API's CamelModel:
8 Wire protocol /wire/repos/{repo_id}/ ← Muse CLI speaks here
9 REST API /api/repos/{id}/ ← agents and integrations speak here
10 MCP /mcp ← agents speak here too
11
12 Denial-of-Service limits
13 ------------------------
14 All list fields that arrive over the network are capped so a single large
15 request cannot exhaust memory or DB connections:
16
17 MAX_COMMITS_PER_PUSH = 1 000 — one push should carry at most 1k commits
18 MAX_OBJECTS_PER_PUSH = 1 000 — ditto for binary blobs
19 MAX_SNAPSHOTS_PER_PUSH = 1 000 — ditto for snapshot manifests
20 MAX_WANT_PER_FETCH = 1 000 — fetch want/have lists
21 MAX_B64_SIZE = 52_000_000 — ~38 MB decoded (base64 overhead ≈1.37×)
22 single-object cap; 100 MB objects must
23 use direct S3/R2 upload instead.
24 """
25 from __future__ import annotations
26
27 from typing import Any
28
29 from pydantic import BaseModel, Field, field_validator
30
31 # ── Per-request DoS limits (enforced via Pydantic max_length) ───────────────
32 MAX_COMMITS_PER_PUSH: int = 1_000
33 MAX_OBJECTS_PER_PUSH: int = 1_000
34 MAX_SNAPSHOTS_PER_PUSH: int = 1_000
35 MAX_WANT_PER_FETCH: int = 1_000
36 # Base64 string length limit — base64 expands by ~1.37×, so 52 MB b64 ≈ 38 MB raw.
37 MAX_B64_SIZE: int = 52_000_000
38
39
40 class WireCommit(BaseModel):
41 """Muse native commit record — mirrors CommitDict from muse.core.store."""
42
43 commit_id: str
44 repo_id: str = ""
45 branch: str = ""
46 snapshot_id: str | None = None
47 message: str = ""
48 committed_at: str = "" # ISO-8601 UTC string
49 parent_commit_id: str | None = None # first parent (linear history)
50 parent2_commit_id: str | None = None # second parent (merge commits)
51 author: str = ""
52 metadata: dict[str, str] = Field(default_factory=dict)
53 structured_delta: Any = None # domain-specific delta blob
54 sem_ver_bump: str = "none" # "none" | "patch" | "minor" | "major"
55 breaking_changes: list[str] = Field(default_factory=list)
56 agent_id: str = ""
57 model_id: str = ""
58 toolchain_id: str = ""
59 prompt_hash: str = ""
60 signature: str = ""
61 signer_key_id: str = ""
62 format_version: int = 1
63 reviewed_by: list[str] = Field(default_factory=list)
64 test_runs: int = 0
65
66 model_config = {"extra": "ignore"} # tolerate future Muse fields gracefully
67
68
69 class WireSnapshot(BaseModel):
70 """Muse native snapshot — mirrors SnapshotDict from muse.core.store.
71
72 The manifest maps file paths to content-addressed object IDs,
73 e.g. ``{"muse/core/pack.py": "sha256:abc123..."}``.
74 """
75
76 snapshot_id: str
77 # max_length caps the number of manifest entries — a 10 000-file snapshot
78 # would already be pathologically large; prevent unbounded dict parsing.
79 manifest: dict[str, str] = Field(default_factory=dict, max_length=10_000)
80 created_at: str = ""
81
82 model_config = {"extra": "ignore"}
83
84
85 class WireObject(BaseModel):
86 """Content-addressed object payload — mirrors ObjectPayload from muse.core.pack."""
87
88 object_id: str
89 content_b64: str = Field(max_length=MAX_B64_SIZE)
90 # path is not in the Muse CLI ObjectPayload TypedDict but we accept it when present
91 path: str = Field(default="", max_length=4096)
92
93 model_config = {"extra": "ignore"}
94
95 @field_validator("content_b64")
96 @classmethod
97 def _check_b64_size(cls, v: str) -> str:
98 """Reject oversized objects before attempting base64 decode.
99
100 Checking ``len(v)`` is O(1) on CPython (str stores its length) and
101 prevents the memory spike that would occur when decoding a multi-GB
102 string into bytes.
103 """
104 if len(v) > MAX_B64_SIZE:
105 raise ValueError(
106 f"content_b64 exceeds maximum size ({MAX_B64_SIZE} chars). "
107 "Upload large objects directly to S3/R2 instead."
108 )
109 return v
110
111
112 class WireBundle(BaseModel):
113 """A pack bundle sent in a push request.
114
115 Mirrors PackBundle from muse.core.pack. All fields are optional because
116 a minimal push may only contain commits (no new objects).
117
118 List lengths are capped to prevent DoS via an oversized single request.
119 See the module-level ``MAX_*`` constants for the exact limits.
120 """
121
122 commits: list[WireCommit] = Field(default_factory=list, max_length=MAX_COMMITS_PER_PUSH)
123 snapshots: list[WireSnapshot] = Field(default_factory=list, max_length=MAX_SNAPSHOTS_PER_PUSH)
124 objects: list[WireObject] = Field(default_factory=list, max_length=MAX_OBJECTS_PER_PUSH)
125 branch_heads: dict[str, str] = Field(default_factory=dict)
126
127
128 class WirePushRequest(BaseModel):
129 """Body for ``POST /wire/repos/{repo_id}/push``.
130
131 Matches the payload built by HttpTransport.push_pack():
132 ``{"bundle": {...}, "branch": "main", "force": false}``
133 """
134
135 bundle: WireBundle
136 branch: str
137 force: bool = False
138
139
140 class WireFetchRequest(BaseModel):
141 """Body for ``POST /wire/repos/{repo_id}/fetch``.
142
143 Matches HttpTransport.fetch_pack() payload:
144 ``{"want": [...sha...], "have": [...sha...]}``
145
146 ``want`` — commit SHAs the client wants.
147 ``have`` — commit SHAs the client already has (exclusion list).
148 """
149
150 want: list[str] = Field(default_factory=list, max_length=MAX_WANT_PER_FETCH)
151 have: list[str] = Field(default_factory=list, max_length=MAX_WANT_PER_FETCH)
152
153
154 class WireRefsResponse(BaseModel):
155 """Response for ``GET /wire/repos/{repo_id}/refs``.
156
157 Parsed by HttpTransport._parse_remote_info() into RemoteInfo.
158 """
159
160 repo_id: str
161 domain: str
162 default_branch: str
163 branch_heads: dict[str, str]
164
165
166 class WirePushResponse(BaseModel):
167 """Response for ``POST /wire/repos/{repo_id}/push``.
168
169 Parsed by HttpTransport._parse_push_result() into PushResult.
170 ``branch_heads`` is what the Muse CLI reads; ``remote_head`` is bonus
171 information for MCP consumers.
172 """
173
174 ok: bool
175 message: str
176 branch_heads: dict[str, str]
177 remote_head: str = ""
178
179
180 class WireFetchResponse(BaseModel):
181 """Response for ``POST /wire/repos/{repo_id}/fetch``.
182
183 Parsed by HttpTransport._parse_bundle() into PackBundle.
184 """
185
186 commits: list[WireCommit] = Field(default_factory=list)
187 snapshots: list[WireSnapshot] = Field(default_factory=list)
188 objects: list[WireObject] = Field(default_factory=list)
189 branch_heads: dict[str, str] = Field(default_factory=dict)