command-gate 0.2.4__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- cgate/__init__.py +26 -0
- cgate/__main__.py +8 -0
- cgate/_version.py +24 -0
- cgate/cli/__init__.py +1 -0
- cgate/cli/_console.py +17 -0
- cgate/cli/connections.py +212 -0
- cgate/cli/history.py +191 -0
- cgate/cli/install.py +182 -0
- cgate/cli/main.py +115 -0
- cgate/cli/mcp.py +197 -0
- cgate/cli/uninstall.py +403 -0
- cgate/cli/update.py +538 -0
- cgate/cli/watch.py +20 -0
- cgate/connections/__init__.py +1 -0
- cgate/connections/auth.py +93 -0
- cgate/connections/detect.py +78 -0
- cgate/connections/store.py +88 -0
- cgate/core/__init__.py +1 -0
- cgate/core/path_env.py +218 -0
- cgate/core/paths.py +35 -0
- cgate/core/update_log.py +36 -0
- cgate/db/__init__.py +1 -0
- cgate/db/batches.py +111 -0
- cgate/db/commands.py +191 -0
- cgate/db/connection.py +104 -0
- cgate/db/mode.py +74 -0
- cgate/db/rows.py +99 -0
- cgate/db/schema.py +54 -0
- cgate/db/server_settings.py +105 -0
- cgate/db/types.py +77 -0
- cgate/executor/__init__.py +7 -0
- cgate/executor/base.py +71 -0
- cgate/executor/selector.py +61 -0
- cgate/executor/ssh.py +157 -0
- cgate/executor/winrm.py +129 -0
- cgate/helper/__init__.py +10 -0
- cgate/helper/__main__.py +112 -0
- cgate/helper/waiter.py +123 -0
- cgate/mcp_installer.py +161 -0
- cgate/mcp_server/__init__.py +6 -0
- cgate/mcp_server/__main__.py +6 -0
- cgate/mcp_server/auto_resolution.py +80 -0
- cgate/mcp_server/server.py +271 -0
- cgate/mcp_server/tools.py +351 -0
- cgate/risk.py +129 -0
- cgate/update.py +713 -0
- cgate/watch/__init__.py +7 -0
- cgate/watch/app.py +560 -0
- cgate/watch/approval.py +237 -0
- cgate/watch/command_detail_modal.py +68 -0
- cgate/watch/history_modal.py +242 -0
- cgate/watch/mode_modal.py +110 -0
- cgate/watch/queue.py +106 -0
- cgate/watch/render.py +156 -0
- cgate/watch/server_settings_modal.py +179 -0
- cgate/watch/session.py +40 -0
- cgate/watch/theme.py +32 -0
- cgate/watch/widgets.py +35 -0
- command_gate-0.2.4.dist-info/METADATA +204 -0
- command_gate-0.2.4.dist-info/RECORD +63 -0
- command_gate-0.2.4.dist-info/WHEEL +4 -0
- command_gate-0.2.4.dist-info/entry_points.txt +2 -0
- command_gate-0.2.4.dist-info/licenses/LICENSE +21 -0
cgate/update.py
ADDED
|
@@ -0,0 +1,713 @@
|
|
|
1
|
+
"""Self-update via GitHub Releases API. Pure logic; the CLI layer wraps it."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import contextlib
|
|
6
|
+
import hashlib
|
|
7
|
+
import json
|
|
8
|
+
import os
|
|
9
|
+
import shutil
|
|
10
|
+
import subprocess
|
|
11
|
+
import sys
|
|
12
|
+
import urllib.error
|
|
13
|
+
import urllib.parse
|
|
14
|
+
import urllib.request
|
|
15
|
+
from dataclasses import dataclass
|
|
16
|
+
from http import HTTPStatus
|
|
17
|
+
from packaging.version import InvalidVersion, Version
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
from typing import TYPE_CHECKING, Final, NotRequired, TypedDict
|
|
20
|
+
|
|
21
|
+
from cgate.core.update_log import append_log
|
|
22
|
+
|
|
23
|
+
if TYPE_CHECKING:
|
|
24
|
+
from sigstore.verify import Verifier
|
|
25
|
+
from sigstore.verify.policy import VerificationPolicy
|
|
26
|
+
|
|
27
|
+
DEFAULT_REPO: Final = "wanderlp/command-gate-for-ai-agents"
|
|
28
|
+
GITHUB_API: Final = "https://api.github.com"
|
|
29
|
+
# Defense in depth (issue #14): the release payload's browser_download_url
|
|
30
|
+
# comes from the same trust boundary as the sha256 digest we check it
|
|
31
|
+
# against (see issue #4), so this doesn't stop a compromised API response
|
|
32
|
+
# on its own -- but it stops a URL field pointed somewhere unexpected
|
|
33
|
+
# without also compromising these hosts.
|
|
34
|
+
_ALLOWED_DOWNLOAD_HOSTS: Final = frozenset(
|
|
35
|
+
{
|
|
36
|
+
"github.com",
|
|
37
|
+
"objects.githubusercontent.com",
|
|
38
|
+
"release-assets.githubusercontent.com",
|
|
39
|
+
}
|
|
40
|
+
)
|
|
41
|
+
# Body read has no separate timeout; this only caps the connect/handshake.
|
|
42
|
+
# A 29 MB download on a 1 Mbps link takes ~230s; rely on TCP keepalive for
|
|
43
|
+
# stalled-transfer detection rather than a per-call wall clock.
|
|
44
|
+
REQUEST_TIMEOUT: Final = 30
|
|
45
|
+
_DOWNLOAD_CHUNK: Final = 65536
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class _AssetPayload(TypedDict):
|
|
49
|
+
name: str
|
|
50
|
+
browser_download_url: str
|
|
51
|
+
size: NotRequired[int]
|
|
52
|
+
digest: NotRequired[str]
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class _ReleasePayload(TypedDict, total=False):
|
|
56
|
+
tag_name: str
|
|
57
|
+
html_url: str
|
|
58
|
+
assets: list[_AssetPayload]
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class _ProcessInfo(TypedDict):
|
|
62
|
+
ProcessId: int
|
|
63
|
+
CommandLine: NotRequired[str | None]
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
@dataclass(frozen=True, slots=True)
|
|
67
|
+
class Asset:
|
|
68
|
+
"""A downloadable asset attached to a GitHub Release."""
|
|
69
|
+
|
|
70
|
+
name: str
|
|
71
|
+
download_url: str
|
|
72
|
+
size: int
|
|
73
|
+
# sha256:<hex>; empty string when the source does not provide one.
|
|
74
|
+
digest: str
|
|
75
|
+
|
|
76
|
+
@property
|
|
77
|
+
def suffix(self) -> str:
|
|
78
|
+
"""Return the platform suffix after the ``cgate-`` prefix."""
|
|
79
|
+
return self.name.removeprefix("cgate-")
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
@dataclass(frozen=True, slots=True)
|
|
83
|
+
class Release:
|
|
84
|
+
"""A GitHub Release with its downloadable assets."""
|
|
85
|
+
|
|
86
|
+
tag: str
|
|
87
|
+
version: str
|
|
88
|
+
html_url: str
|
|
89
|
+
assets: tuple[Asset, ...]
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
class UpdateError(Exception):
|
|
93
|
+
"""Network, parse, or filesystem error during update."""
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _resolve_repo() -> str:
|
|
97
|
+
"""Resolve the GitHub "owner/repo" this build checks for updates against.
|
|
98
|
+
|
|
99
|
+
Priority: ``CGATE_UPDATE_REPO`` env var (explicit override -- e.g. to
|
|
100
|
+
test against a fork) > the repo baked in at build time (``_repo.py``,
|
|
101
|
+
generated by ``scripts/_ensure_repo.py`` from ``GITHUB_REPOSITORY``
|
|
102
|
+
when CI builds the release binary -- i.e. literally the repo it's
|
|
103
|
+
about to be downloaded from) > ``DEFAULT_REPO`` (source/dev runs with
|
|
104
|
+
neither available).
|
|
105
|
+
|
|
106
|
+
A repo rename therefore never needs a source-code edit for the
|
|
107
|
+
distributed binary: only the generated file changes, which CI does on
|
|
108
|
+
every build. Resolved fresh on every call, not cached at import time,
|
|
109
|
+
so a test's ``monkeypatch.setenv`` takes effect immediately.
|
|
110
|
+
"""
|
|
111
|
+
override = os.environ.get("CGATE_UPDATE_REPO")
|
|
112
|
+
if override:
|
|
113
|
+
return override
|
|
114
|
+
try:
|
|
115
|
+
from cgate._repo import REPO # noqa: PLC0415
|
|
116
|
+
except ImportError:
|
|
117
|
+
return DEFAULT_REPO
|
|
118
|
+
return REPO
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _fetch_release(url: str) -> Release:
|
|
122
|
+
request = urllib.request.Request( # noqa: S310 -- URL is fixed to the HTTPS GitHub API.
|
|
123
|
+
url, headers={"Accept": "application/vnd.github+json"}
|
|
124
|
+
)
|
|
125
|
+
try:
|
|
126
|
+
with urllib.request.urlopen( # noqa: S310 -- Request contains an HTTPS URL.
|
|
127
|
+
request, timeout=REQUEST_TIMEOUT
|
|
128
|
+
) as response:
|
|
129
|
+
payload = response.read()
|
|
130
|
+
except (urllib.error.HTTPError, urllib.error.URLError, TimeoutError) as exc:
|
|
131
|
+
msg = f"failed to fetch {url}: {exc}"
|
|
132
|
+
raise UpdateError(msg) from exc
|
|
133
|
+
|
|
134
|
+
try:
|
|
135
|
+
data: _ReleasePayload = json.loads(payload)
|
|
136
|
+
return _parse_release(data)
|
|
137
|
+
except (json.JSONDecodeError, KeyError, TypeError, ValueError) as exc:
|
|
138
|
+
msg = f"malformed release JSON: {exc}"
|
|
139
|
+
raise UpdateError(msg) from exc
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def fetch_latest_release(repo: str | None = None) -> Release:
|
|
143
|
+
"""Fetch the latest published GitHub Release via the public API."""
|
|
144
|
+
repo = repo or _resolve_repo()
|
|
145
|
+
return _fetch_release(f"{GITHUB_API}/repos/{repo}/releases/latest")
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def fetch_release_by_tag(tag: str, repo: str | None = None) -> Release:
|
|
149
|
+
"""Fetch one specific GitHub Release by tag, rather than the latest.
|
|
150
|
+
|
|
151
|
+
Used to pair the helper binary (see ``ensure_helper_binary``) with the
|
|
152
|
+
currently *installed* cgate version when there's no in-flight
|
|
153
|
+
``Release`` object already in hand to reuse -- e.g. ``uninstall
|
|
154
|
+
--binary``, which isn't installing anything and so has no natural
|
|
155
|
+
"the release we're applying" to point at.
|
|
156
|
+
"""
|
|
157
|
+
repo = repo or _resolve_repo()
|
|
158
|
+
return _fetch_release(f"{GITHUB_API}/repos/{repo}/releases/tags/{tag}")
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def _parse_release(data: _ReleasePayload) -> Release:
|
|
162
|
+
tag = data.get("tag_name", "")
|
|
163
|
+
assets = tuple(
|
|
164
|
+
Asset(
|
|
165
|
+
name=asset["name"],
|
|
166
|
+
download_url=asset["browser_download_url"],
|
|
167
|
+
size=int(asset.get("size", 0)),
|
|
168
|
+
digest=asset.get("digest", ""),
|
|
169
|
+
)
|
|
170
|
+
for asset in data.get("assets", [])
|
|
171
|
+
)
|
|
172
|
+
return Release(
|
|
173
|
+
tag=tag,
|
|
174
|
+
version=tag.removeprefix("v"),
|
|
175
|
+
html_url=data.get("html_url", ""),
|
|
176
|
+
assets=assets,
|
|
177
|
+
)
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def select_asset(release: Release) -> Asset | None:
|
|
181
|
+
"""Pick the release asset matching the current OS and architecture."""
|
|
182
|
+
suffix = _platform_suffix()
|
|
183
|
+
return next((asset for asset in release.assets if asset.suffix == suffix), None)
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def _platform_suffix() -> str:
|
|
187
|
+
match sys.platform:
|
|
188
|
+
case "win32":
|
|
189
|
+
return "windows-amd64.exe"
|
|
190
|
+
case "darwin":
|
|
191
|
+
return "macos-arm64"
|
|
192
|
+
case _:
|
|
193
|
+
return "linux-x86_64"
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
HELPER_EXECUTABLE_NAME: Final = "cgate-helper.exe"
|
|
197
|
+
_HELPER_ASSET_NAME: Final = "cgate-helper-windows-amd64.exe"
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def select_helper_asset(release: Release) -> Asset | None:
|
|
201
|
+
"""Pick the Windows helper-binary asset from a release, if it has one.
|
|
202
|
+
|
|
203
|
+
Windows-only concept -- POSIX never self-locks, so no helper ships for
|
|
204
|
+
those platforms. Can't reuse ``select_asset``/``Asset.suffix`` here:
|
|
205
|
+
stripping the ``"cgate-"`` prefix from ``"cgate-helper-windows-amd64.exe"``
|
|
206
|
+
yields ``"helper-windows-amd64.exe"``, which won't match
|
|
207
|
+
``_platform_suffix()``. Returns ``None`` (not an error) for an older
|
|
208
|
+
release published before this feature shipped a helper asset.
|
|
209
|
+
"""
|
|
210
|
+
if sys.platform != "win32":
|
|
211
|
+
return None
|
|
212
|
+
return next((asset for asset in release.assets if asset.name == _HELPER_ASSET_NAME), None)
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def helper_binary_path(binary: Path) -> Path:
|
|
216
|
+
"""Return where the compiled helper should live, next to ``binary``."""
|
|
217
|
+
return binary.with_name(HELPER_EXECUTABLE_NAME)
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def spawn_helper(helper: Path, *args: str) -> bool:
|
|
221
|
+
"""Launch the compiled helper, detached, to run after this process exits.
|
|
222
|
+
|
|
223
|
+
Same detachment flags as the ``cmd.exe`` shell-chain fallback in
|
|
224
|
+
``cli/update.py``/``cli/uninstall.py``: DETACHED_PROCESS |
|
|
225
|
+
CREATE_NO_WINDOW so the helper survives this process exiting and never
|
|
226
|
+
flashes a console window.
|
|
227
|
+
"""
|
|
228
|
+
if sys.platform != "win32":
|
|
229
|
+
return False
|
|
230
|
+
try:
|
|
231
|
+
subprocess.Popen(
|
|
232
|
+
[str(helper), *args],
|
|
233
|
+
creationflags=0x00000008 | 0x08000000,
|
|
234
|
+
stdout=subprocess.DEVNULL,
|
|
235
|
+
stderr=subprocess.DEVNULL,
|
|
236
|
+
close_fds=True,
|
|
237
|
+
)
|
|
238
|
+
except (subprocess.SubprocessError, OSError):
|
|
239
|
+
return False
|
|
240
|
+
return True
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
def ensure_helper_binary(binary: Path, release: Release | None = None) -> Path | None:
|
|
244
|
+
"""Return a working local helper binary, downloading it on demand.
|
|
245
|
+
|
|
246
|
+
Reuses ``release`` -- the exact release ``update apply`` is installing
|
|
247
|
+
-- when given, so the helper is always paired with the cgate version
|
|
248
|
+
it's swapping in. With no release in hand (``uninstall --binary`` isn't
|
|
249
|
+
installing anything), falls back to fetching the release matching the
|
|
250
|
+
*currently installed* version, keeping the same same-tag pairing
|
|
251
|
+
principle rather than silently reaching for "latest".
|
|
252
|
+
|
|
253
|
+
Best-effort and never raises: returns ``None`` on anything short of a
|
|
254
|
+
verified, usable binary (old release with no helper asset, network
|
|
255
|
+
failure, failed attestation) so callers can fall back to the existing
|
|
256
|
+
shell-chain mechanism instead.
|
|
257
|
+
"""
|
|
258
|
+
if sys.platform != "win32":
|
|
259
|
+
return None
|
|
260
|
+
path = helper_binary_path(binary)
|
|
261
|
+
if path.exists():
|
|
262
|
+
return path
|
|
263
|
+
|
|
264
|
+
# Computed before the try so the except below can always clean it up,
|
|
265
|
+
# including on a fetch_release_by_tag/select_helper_asset failure that
|
|
266
|
+
# happens before it would otherwise be assigned.
|
|
267
|
+
staging = path.with_name(path.name + ".new")
|
|
268
|
+
try:
|
|
269
|
+
resolved = release or fetch_release_by_tag(f"v{_cgate_version()}")
|
|
270
|
+
asset = select_helper_asset(resolved)
|
|
271
|
+
if asset is None:
|
|
272
|
+
return None
|
|
273
|
+
download_to(asset, staging)
|
|
274
|
+
verify_attestation(asset, resolved)
|
|
275
|
+
_ = staging.replace(path)
|
|
276
|
+
except (UpdateError, OSError):
|
|
277
|
+
# A verified download and rename is all-or-nothing -- a failure
|
|
278
|
+
# partway (attestation rejected, AV locks the rename) must not
|
|
279
|
+
# leave a multi-MB `cgate-helper.exe.new` behind silently, the
|
|
280
|
+
# same orphan-staging-file class of bug issue #15 fixed for the
|
|
281
|
+
# main binary's own `.new` file.
|
|
282
|
+
with contextlib.suppress(OSError):
|
|
283
|
+
staging.unlink(missing_ok=True)
|
|
284
|
+
return None
|
|
285
|
+
return path
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
def _cgate_version() -> str:
|
|
289
|
+
from cgate import __version__ # noqa: PLC0415 -- avoid a module-load-order cycle
|
|
290
|
+
|
|
291
|
+
return __version__
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
def compare_versions(current: str, latest: str) -> int:
|
|
295
|
+
"""Compare versions per PEP 440.
|
|
296
|
+
|
|
297
|
+
Returns positive when ``current`` is newer than ``latest``, zero when
|
|
298
|
+
equal, negative when ``current`` is older. Dev segments (``0.1.6.dev1``)
|
|
299
|
+
and local segments (``+g<hash>``) are handled correctly, unlike a naive
|
|
300
|
+
split-and-compare which falls back to lexicographic order on those.
|
|
301
|
+
|
|
302
|
+
Falls back to lexicographic comparison when either string is not a
|
|
303
|
+
valid PEP 440 version, so legacy data sources cannot crash the updater.
|
|
304
|
+
"""
|
|
305
|
+
|
|
306
|
+
def _parse(version: str) -> Version | None:
|
|
307
|
+
try:
|
|
308
|
+
return Version(version)
|
|
309
|
+
except InvalidVersion:
|
|
310
|
+
return None
|
|
311
|
+
|
|
312
|
+
parsed_current = _parse(current)
|
|
313
|
+
parsed_latest = _parse(latest)
|
|
314
|
+
if parsed_current is not None and parsed_latest is not None:
|
|
315
|
+
if parsed_current > parsed_latest:
|
|
316
|
+
return 1
|
|
317
|
+
if parsed_current < parsed_latest:
|
|
318
|
+
return -1
|
|
319
|
+
return 0
|
|
320
|
+
if latest != current:
|
|
321
|
+
return (current > latest) - (current < latest)
|
|
322
|
+
return 0
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
def _ensure_allowed_download_host(url: str) -> None:
|
|
326
|
+
"""Refuse to fetch a release asset from an unexpected host (issue #14)."""
|
|
327
|
+
parsed = urllib.parse.urlsplit(url)
|
|
328
|
+
if parsed.scheme != "https" or parsed.hostname not in _ALLOWED_DOWNLOAD_HOSTS:
|
|
329
|
+
msg = f"refusing to download from untrusted host: {url}"
|
|
330
|
+
raise UpdateError(msg)
|
|
331
|
+
|
|
332
|
+
|
|
333
|
+
def download_to(asset: Asset, dest: Path) -> None:
|
|
334
|
+
"""Stream an asset to a file and verify size + sha256 before committing.
|
|
335
|
+
|
|
336
|
+
The download is staged to ``<dest>.part`` and only renamed onto
|
|
337
|
+
``dest`` after both the byte count and the SHA-256 match what the
|
|
338
|
+
GitHub Release payload advertised. A mismatch (truncated transfer,
|
|
339
|
+
mirror mismatch, MITM) leaves the part file unlinked and raises
|
|
340
|
+
``UpdateError`` instead of installing a half-downloaded binary.
|
|
341
|
+
"""
|
|
342
|
+
_ensure_allowed_download_host(asset.download_url)
|
|
343
|
+
dest.parent.mkdir(parents=True, exist_ok=True)
|
|
344
|
+
temporary = dest.with_suffix(dest.suffix + ".part")
|
|
345
|
+
sha256 = hashlib.sha256()
|
|
346
|
+
bytes_downloaded = 0
|
|
347
|
+
committed = False
|
|
348
|
+
try:
|
|
349
|
+
with urllib.request.urlopen( # noqa: S310 -- release assets are HTTPS URLs.
|
|
350
|
+
asset.download_url, timeout=REQUEST_TIMEOUT
|
|
351
|
+
) as response, temporary.open("wb") as output:
|
|
352
|
+
while True:
|
|
353
|
+
chunk = response.read(_DOWNLOAD_CHUNK)
|
|
354
|
+
if not chunk:
|
|
355
|
+
break
|
|
356
|
+
output.write(chunk)
|
|
357
|
+
sha256.update(chunk)
|
|
358
|
+
bytes_downloaded += len(chunk)
|
|
359
|
+
if asset.size and bytes_downloaded != asset.size:
|
|
360
|
+
raise UpdateError(
|
|
361
|
+
f"size mismatch: expected {asset.size} bytes, got {bytes_downloaded}"
|
|
362
|
+
)
|
|
363
|
+
if asset.digest:
|
|
364
|
+
expected = asset.digest.removeprefix("sha256:")
|
|
365
|
+
actual = sha256.hexdigest()
|
|
366
|
+
if actual != expected:
|
|
367
|
+
raise UpdateError(
|
|
368
|
+
f"sha256 mismatch: expected {expected[:16]}..., got {actual[:16]}..."
|
|
369
|
+
)
|
|
370
|
+
_ = temporary.replace(dest)
|
|
371
|
+
committed = True
|
|
372
|
+
except (urllib.error.HTTPError, urllib.error.URLError, OSError, TimeoutError) as exc:
|
|
373
|
+
msg = f"download failed: {exc}"
|
|
374
|
+
raise UpdateError(msg) from exc
|
|
375
|
+
finally:
|
|
376
|
+
if not committed:
|
|
377
|
+
temporary.unlink(missing_ok=True)
|
|
378
|
+
|
|
379
|
+
|
|
380
|
+
GITHUB_OIDC_ISSUER: Final = "https://token.actions.githubusercontent.com"
|
|
381
|
+
_SLSA_PROVENANCE_PREDICATE: Final = "https://slsa.dev/provenance/v1"
|
|
382
|
+
|
|
383
|
+
|
|
384
|
+
def _fetch_attestations(asset: Asset, repo: str) -> list[dict[str, object]]:
|
|
385
|
+
"""Fetch the raw attestation entries GitHub has for ``asset.digest``.
|
|
386
|
+
|
|
387
|
+
Raises ``UpdateError`` if there is no digest to look up, the request
|
|
388
|
+
fails, or GitHub reports no attestation for this digest -- via a bare
|
|
389
|
+
404 (no JSON body) for a digest nothing was ever attested for, or an
|
|
390
|
+
empty ``attestations`` list, which the API does not use today but
|
|
391
|
+
which costs nothing to also treat as "none found".
|
|
392
|
+
"""
|
|
393
|
+
if not asset.digest:
|
|
394
|
+
msg = f"cannot verify authenticity: release did not report a digest for {asset.name}"
|
|
395
|
+
raise UpdateError(msg)
|
|
396
|
+
|
|
397
|
+
url = f"{GITHUB_API}/repos/{repo}/attestations/{asset.digest}"
|
|
398
|
+
request = urllib.request.Request( # noqa: S310 -- URL is fixed to the HTTPS GitHub API.
|
|
399
|
+
url, headers={"Accept": "application/vnd.github+json"}
|
|
400
|
+
)
|
|
401
|
+
not_found_msg = (
|
|
402
|
+
f"no build-provenance attestation found for {asset.name} "
|
|
403
|
+
f"(digest {asset.digest}) -- refusing to install an unverifiable binary"
|
|
404
|
+
)
|
|
405
|
+
try:
|
|
406
|
+
with urllib.request.urlopen( # noqa: S310 -- Request contains an HTTPS URL.
|
|
407
|
+
request, timeout=REQUEST_TIMEOUT
|
|
408
|
+
) as response:
|
|
409
|
+
payload = response.read()
|
|
410
|
+
except urllib.error.HTTPError as exc:
|
|
411
|
+
if exc.code == HTTPStatus.NOT_FOUND:
|
|
412
|
+
raise UpdateError(not_found_msg) from exc
|
|
413
|
+
msg = f"failed to fetch attestation for {asset.name}: {exc}"
|
|
414
|
+
raise UpdateError(msg) from exc
|
|
415
|
+
except (urllib.error.URLError, TimeoutError) as exc:
|
|
416
|
+
msg = f"failed to fetch attestation for {asset.name}: {exc}"
|
|
417
|
+
raise UpdateError(msg) from exc
|
|
418
|
+
|
|
419
|
+
try:
|
|
420
|
+
data = json.loads(payload)
|
|
421
|
+
attestations = data.get("attestations", [])
|
|
422
|
+
except (json.JSONDecodeError, AttributeError) as exc:
|
|
423
|
+
msg = f"malformed attestation response for {asset.name}: {exc}"
|
|
424
|
+
raise UpdateError(msg) from exc
|
|
425
|
+
|
|
426
|
+
if not attestations:
|
|
427
|
+
raise UpdateError(not_found_msg)
|
|
428
|
+
return attestations
|
|
429
|
+
|
|
430
|
+
|
|
431
|
+
def _matches_attested_subject(
|
|
432
|
+
entry: dict[str, object],
|
|
433
|
+
*,
|
|
434
|
+
identity_policy: VerificationPolicy,
|
|
435
|
+
verifier: Verifier,
|
|
436
|
+
expected_digest: str,
|
|
437
|
+
) -> str | None:
|
|
438
|
+
"""Verify one attestation entry against ``identity_policy``.
|
|
439
|
+
|
|
440
|
+
Returns ``None`` on a fully matching, verified entry, or a
|
|
441
|
+
human-readable reason it didn't match otherwise -- never raises, so
|
|
442
|
+
the caller can try every entry and report all the reasons together.
|
|
443
|
+
"""
|
|
444
|
+
from sigstore.errors import VerificationError # noqa: PLC0415
|
|
445
|
+
from sigstore.models import Bundle # noqa: PLC0415
|
|
446
|
+
|
|
447
|
+
try:
|
|
448
|
+
bundle = Bundle.from_json(json.dumps(entry["bundle"]))
|
|
449
|
+
_, raw_statement = verifier.verify_dsse(bundle, identity_policy)
|
|
450
|
+
statement = json.loads(raw_statement)
|
|
451
|
+
except (KeyError, TypeError, ValueError, VerificationError) as exc:
|
|
452
|
+
return str(exc)
|
|
453
|
+
if statement.get("predicateType") != _SLSA_PROVENANCE_PREDICATE:
|
|
454
|
+
return f"unexpected predicateType: {statement.get('predicateType')}"
|
|
455
|
+
subjects = statement.get("subject", [])
|
|
456
|
+
if any(subject.get("digest", {}).get("sha256") == expected_digest for subject in subjects):
|
|
457
|
+
return None
|
|
458
|
+
return "attested subject digest does not match the downloaded asset"
|
|
459
|
+
|
|
460
|
+
|
|
461
|
+
def verify_attestation(asset: Asset, release: Release, *, repo: str | None = None) -> None:
|
|
462
|
+
"""Verify the downloaded asset's GitHub Actions build-provenance attestation.
|
|
463
|
+
|
|
464
|
+
``download_to`` already checks ``asset.digest`` against the downloaded
|
|
465
|
+
bytes, but that digest comes from the same Release API response as the
|
|
466
|
+
download URL itself -- it only proves the bytes were not corrupted or
|
|
467
|
+
substituted in transit, not that they came from our own release
|
|
468
|
+
workflow (issue #4). This additionally requires a Sigstore-signed
|
|
469
|
+
attestation, issued by GitHub's OIDC provider to *this* repo's release
|
|
470
|
+
workflow at *this exact tag* (the ``attest-build-provenance`` step in
|
|
471
|
+
``release.yml``), whose signed subject digest matches ``asset.digest``.
|
|
472
|
+
|
|
473
|
+
Uses the bundled Sigstore trust root (``offline=True``) rather than
|
|
474
|
+
fetching current root metadata via TUF on every update check: this
|
|
475
|
+
avoids adding a second live trust dependency to the update path, at
|
|
476
|
+
the cost of needing a ``sigstore`` package upgrade if Sigstore ever
|
|
477
|
+
rotates its root keys (rare and well-announced).
|
|
478
|
+
|
|
479
|
+
Fail-closed, intentionally with no bypass flag (same posture as the
|
|
480
|
+
download host allow-list, issue #14): raises ``UpdateError`` if the
|
|
481
|
+
digest is missing, no attestation exists, the signature/identity does
|
|
482
|
+
not check out, or the attested subject does not match this asset.
|
|
483
|
+
"""
|
|
484
|
+
repo = repo or _resolve_repo()
|
|
485
|
+
|
|
486
|
+
# Deferred: sigstore pulls in tuf/cryptography and costs ~0.6s to
|
|
487
|
+
# import. cli/update.py is loaded on every `cgate` invocation (it's
|
|
488
|
+
# registered as a sub-app in main()), so a top-level import here would
|
|
489
|
+
# tax every command, not just `update apply`.
|
|
490
|
+
import logging # noqa: PLC0415
|
|
491
|
+
|
|
492
|
+
from sigstore.verify import Verifier # noqa: PLC0415
|
|
493
|
+
from sigstore.verify import policy as verify_policy # noqa: PLC0415
|
|
494
|
+
|
|
495
|
+
attestations = _fetch_attestations(asset, repo)
|
|
496
|
+
|
|
497
|
+
# The warning is expected and permanent given `offline=True` below; it
|
|
498
|
+
# would otherwise print an unstyled line to stderr on every `apply` via
|
|
499
|
+
# Python's handler-less-root lastResort handler.
|
|
500
|
+
logging.getLogger("sigstore").setLevel(logging.ERROR)
|
|
501
|
+
|
|
502
|
+
identity_policy = verify_policy.AllOf(
|
|
503
|
+
[
|
|
504
|
+
verify_policy.OIDCIssuer(GITHUB_OIDC_ISSUER),
|
|
505
|
+
verify_policy.GitHubWorkflowRepository(repo),
|
|
506
|
+
verify_policy.GitHubWorkflowRef(f"refs/tags/{release.tag}"),
|
|
507
|
+
]
|
|
508
|
+
)
|
|
509
|
+
verifier = Verifier.production(offline=True)
|
|
510
|
+
expected_digest = asset.digest.removeprefix("sha256:")
|
|
511
|
+
|
|
512
|
+
errors = [
|
|
513
|
+
reason
|
|
514
|
+
for entry in attestations
|
|
515
|
+
if (
|
|
516
|
+
reason := _matches_attested_subject(
|
|
517
|
+
entry,
|
|
518
|
+
identity_policy=identity_policy,
|
|
519
|
+
verifier=verifier,
|
|
520
|
+
expected_digest=expected_digest,
|
|
521
|
+
)
|
|
522
|
+
)
|
|
523
|
+
is not None
|
|
524
|
+
]
|
|
525
|
+
if len(errors) < len(attestations):
|
|
526
|
+
return # at least one entry matched
|
|
527
|
+
|
|
528
|
+
msg = (
|
|
529
|
+
f"could not verify a build-provenance attestation for {asset.name} against "
|
|
530
|
+
f"{repo}@refs/tags/{release.tag}: {'; '.join(errors) or 'no valid attestation'}"
|
|
531
|
+
)
|
|
532
|
+
raise UpdateError(msg)
|
|
533
|
+
|
|
534
|
+
|
|
535
|
+
def replace_binary(new_path: Path, target: Path) -> str | None:
|
|
536
|
+
"""Replace ``target`` with ``new_path``. Return None on success or an
|
|
537
|
+
error description (suitable for printing) on failure.
|
|
538
|
+
|
|
539
|
+
The string distinguishes a Windows file lock from a generic OSError so
|
|
540
|
+
the CLI can recommend the right recovery (kill running process vs.
|
|
541
|
+
check permissions / antivirus).
|
|
542
|
+
"""
|
|
543
|
+
try:
|
|
544
|
+
_ = new_path.replace(target)
|
|
545
|
+
except PermissionError as exc:
|
|
546
|
+
return f"permission denied: {exc}"
|
|
547
|
+
except OSError as exc:
|
|
548
|
+
return f"{type(exc).__name__}: {exc}"
|
|
549
|
+
return None
|
|
550
|
+
|
|
551
|
+
|
|
552
|
+
def find_blocking_processes(
|
|
553
|
+
binary_path: Path, *, exclude_pid: int | None = None
|
|
554
|
+
) -> list[int]:
|
|
555
|
+
"""Return PIDs of running processes whose image name matches the binary.
|
|
556
|
+
|
|
557
|
+
Uses ``tasklist`` on Windows (the only platform where executables are
|
|
558
|
+
locked while running). Returns an empty list on other platforms or when
|
|
559
|
+
the lookup cannot be performed, so the caller can fall back to a manual
|
|
560
|
+
message without crashing. ``exclude_pid`` lets the caller skip its own
|
|
561
|
+
process so an in-place update does not suicide before reporting success.
|
|
562
|
+
|
|
563
|
+
Also always excludes ``os.getppid()``: PyInstaller's ``--onefile``
|
|
564
|
+
bootloader on Windows runs as a parent/child pair under the *same*
|
|
565
|
+
image name -- the parent extracts to a temp dir, execs a child to run
|
|
566
|
+
the actual entry point, then waits to clean up once the child exits.
|
|
567
|
+
``os.getpid()`` (and thus ``exclude_pid``) only ever sees the child, so
|
|
568
|
+
without this the parent -- the other half of this very invocation, not
|
|
569
|
+
a second cgate instance -- would show up as a "blocking process" the
|
|
570
|
+
user is asked to kill.
|
|
571
|
+
"""
|
|
572
|
+
if sys.platform != "win32":
|
|
573
|
+
return []
|
|
574
|
+
try:
|
|
575
|
+
completed = subprocess.run(
|
|
576
|
+
["tasklist", "/FO", "CSV", "/NH"],
|
|
577
|
+
capture_output=True,
|
|
578
|
+
text=True,
|
|
579
|
+
check=False,
|
|
580
|
+
timeout=10,
|
|
581
|
+
)
|
|
582
|
+
except (subprocess.SubprocessError, OSError):
|
|
583
|
+
return []
|
|
584
|
+
target_name = binary_path.name.lower()
|
|
585
|
+
exclude = {os.getppid()}
|
|
586
|
+
if exclude_pid is not None:
|
|
587
|
+
exclude.add(exclude_pid)
|
|
588
|
+
pids: list[int] = []
|
|
589
|
+
for line in completed.stdout.splitlines():
|
|
590
|
+
parts = [part.strip().strip('"') for part in line.split(",")]
|
|
591
|
+
if len(parts) < 2 or parts[0].lower() != target_name:
|
|
592
|
+
continue
|
|
593
|
+
try:
|
|
594
|
+
pid = int(parts[1])
|
|
595
|
+
except ValueError:
|
|
596
|
+
continue
|
|
597
|
+
if pid in exclude:
|
|
598
|
+
continue
|
|
599
|
+
pids.append(pid)
|
|
600
|
+
return pids
|
|
601
|
+
|
|
602
|
+
|
|
603
|
+
def find_mcp_serving_pids(pids: list[int]) -> list[int]:
|
|
604
|
+
"""Return which of the given PIDs were launched as ``cgate mcp serve``.
|
|
605
|
+
|
|
606
|
+
Distinguishes "another cgate.exe happens to be running" from "a live MCP
|
|
607
|
+
session an IA client is actively depending on", so callers can warn
|
|
608
|
+
accordingly before killing it (see issue #19). Windows only; returns an
|
|
609
|
+
empty list on other platforms, when there is nothing to check, or when
|
|
610
|
+
the command-line lookup itself fails -- callers then fall back to a
|
|
611
|
+
generic warning rather than a hard failure. Uses PowerShell's CIM
|
|
612
|
+
cmdlets rather than the deprecated ``wmic``, which newer Windows builds
|
|
613
|
+
no longer ship.
|
|
614
|
+
"""
|
|
615
|
+
if sys.platform != "win32" or not pids:
|
|
616
|
+
return []
|
|
617
|
+
try:
|
|
618
|
+
completed = subprocess.run(
|
|
619
|
+
[
|
|
620
|
+
"powershell",
|
|
621
|
+
"-NoProfile",
|
|
622
|
+
"-NonInteractive",
|
|
623
|
+
"-Command",
|
|
624
|
+
(
|
|
625
|
+
"Get-CimInstance Win32_Process -Filter \"Name='cgate.exe'\" "
|
|
626
|
+
"| Select-Object ProcessId, CommandLine | ConvertTo-Json -Compress"
|
|
627
|
+
),
|
|
628
|
+
],
|
|
629
|
+
capture_output=True,
|
|
630
|
+
text=True,
|
|
631
|
+
check=False,
|
|
632
|
+
timeout=10,
|
|
633
|
+
)
|
|
634
|
+
except (subprocess.SubprocessError, OSError):
|
|
635
|
+
return []
|
|
636
|
+
if completed.returncode != 0 or not completed.stdout.strip():
|
|
637
|
+
return []
|
|
638
|
+
try:
|
|
639
|
+
payload: _ProcessInfo | list[_ProcessInfo] = json.loads(completed.stdout)
|
|
640
|
+
except json.JSONDecodeError:
|
|
641
|
+
return []
|
|
642
|
+
rows = [payload] if isinstance(payload, dict) else payload
|
|
643
|
+
wanted = set(pids)
|
|
644
|
+
return [
|
|
645
|
+
row["ProcessId"]
|
|
646
|
+
for row in rows
|
|
647
|
+
if row.get("ProcessId") in wanted
|
|
648
|
+
and (row.get("CommandLine") or "").strip().lower().endswith("mcp serve")
|
|
649
|
+
]
|
|
650
|
+
|
|
651
|
+
|
|
652
|
+
def kill_process(pid: int) -> bool:
|
|
653
|
+
"""Force-kill the process with the given PID. Returns True on success."""
|
|
654
|
+
if sys.platform != "win32":
|
|
655
|
+
return False
|
|
656
|
+
try:
|
|
657
|
+
completed = subprocess.run(
|
|
658
|
+
["taskkill", "/F", "/PID", str(pid)],
|
|
659
|
+
capture_output=True,
|
|
660
|
+
text=True,
|
|
661
|
+
check=False,
|
|
662
|
+
timeout=10,
|
|
663
|
+
)
|
|
664
|
+
except (subprocess.SubprocessError, OSError):
|
|
665
|
+
return False
|
|
666
|
+
return completed.returncode == 0
|
|
667
|
+
|
|
668
|
+
|
|
669
|
+
def current_binary_path() -> Path | None:
|
|
670
|
+
"""Return the running cgate binary path, or ``None`` in development mode."""
|
|
671
|
+
executable = Path(sys.executable).resolve()
|
|
672
|
+
return executable if executable.name.startswith("cgate") else None
|
|
673
|
+
|
|
674
|
+
|
|
675
|
+
def maybe_heal_pending_update() -> bool:
|
|
676
|
+
"""Schedule a deferred swap of a staged ``<binary>.new`` left by a failed update.
|
|
677
|
+
|
|
678
|
+
When ``update apply`` hits a live MCP session and bails, the downloaded
|
|
679
|
+
``<binary>.new`` is left on disk and the user is told to recover manually.
|
|
680
|
+
The next time any ``cgate`` invocation starts (CLI command or MCP server
|
|
681
|
+
spawn), we check whether that staged file still exists AND no other
|
|
682
|
+
``cgate.exe`` processes are running. If so, we spawn ``cgate-helper.exe
|
|
683
|
+
heal`` (detached, waiting on our own PID) so the swap completes the
|
|
684
|
+
instant we exit -- no user action required.
|
|
685
|
+
|
|
686
|
+
Returns True when a heal was scheduled, False otherwise. Never raises;
|
|
687
|
+
any failure to find the staging, the helper, or to spawn just logs to
|
|
688
|
+
``update.log`` and is otherwise silent.
|
|
689
|
+
"""
|
|
690
|
+
binary = current_binary_path()
|
|
691
|
+
if binary is None:
|
|
692
|
+
return False
|
|
693
|
+
staging = binary.with_name(binary.name + ".new")
|
|
694
|
+
if not staging.exists():
|
|
695
|
+
return False
|
|
696
|
+
self_pid = os.getpid()
|
|
697
|
+
blockers = find_blocking_processes(binary, exclude_pid=self_pid)
|
|
698
|
+
if blockers:
|
|
699
|
+
return False
|
|
700
|
+
helper = ensure_helper_binary(binary)
|
|
701
|
+
if helper is None:
|
|
702
|
+
return False
|
|
703
|
+
if not spawn_helper(
|
|
704
|
+
helper,
|
|
705
|
+
"heal",
|
|
706
|
+
"--target",
|
|
707
|
+
str(binary),
|
|
708
|
+
"--wait-pid",
|
|
709
|
+
str(self_pid),
|
|
710
|
+
):
|
|
711
|
+
append_log("heal: failed to spawn cgate-helper")
|
|
712
|
+
return False
|
|
713
|
+
return True
|