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.
Files changed (63) hide show
  1. cgate/__init__.py +26 -0
  2. cgate/__main__.py +8 -0
  3. cgate/_version.py +24 -0
  4. cgate/cli/__init__.py +1 -0
  5. cgate/cli/_console.py +17 -0
  6. cgate/cli/connections.py +212 -0
  7. cgate/cli/history.py +191 -0
  8. cgate/cli/install.py +182 -0
  9. cgate/cli/main.py +115 -0
  10. cgate/cli/mcp.py +197 -0
  11. cgate/cli/uninstall.py +403 -0
  12. cgate/cli/update.py +538 -0
  13. cgate/cli/watch.py +20 -0
  14. cgate/connections/__init__.py +1 -0
  15. cgate/connections/auth.py +93 -0
  16. cgate/connections/detect.py +78 -0
  17. cgate/connections/store.py +88 -0
  18. cgate/core/__init__.py +1 -0
  19. cgate/core/path_env.py +218 -0
  20. cgate/core/paths.py +35 -0
  21. cgate/core/update_log.py +36 -0
  22. cgate/db/__init__.py +1 -0
  23. cgate/db/batches.py +111 -0
  24. cgate/db/commands.py +191 -0
  25. cgate/db/connection.py +104 -0
  26. cgate/db/mode.py +74 -0
  27. cgate/db/rows.py +99 -0
  28. cgate/db/schema.py +54 -0
  29. cgate/db/server_settings.py +105 -0
  30. cgate/db/types.py +77 -0
  31. cgate/executor/__init__.py +7 -0
  32. cgate/executor/base.py +71 -0
  33. cgate/executor/selector.py +61 -0
  34. cgate/executor/ssh.py +157 -0
  35. cgate/executor/winrm.py +129 -0
  36. cgate/helper/__init__.py +10 -0
  37. cgate/helper/__main__.py +112 -0
  38. cgate/helper/waiter.py +123 -0
  39. cgate/mcp_installer.py +161 -0
  40. cgate/mcp_server/__init__.py +6 -0
  41. cgate/mcp_server/__main__.py +6 -0
  42. cgate/mcp_server/auto_resolution.py +80 -0
  43. cgate/mcp_server/server.py +271 -0
  44. cgate/mcp_server/tools.py +351 -0
  45. cgate/risk.py +129 -0
  46. cgate/update.py +713 -0
  47. cgate/watch/__init__.py +7 -0
  48. cgate/watch/app.py +560 -0
  49. cgate/watch/approval.py +237 -0
  50. cgate/watch/command_detail_modal.py +68 -0
  51. cgate/watch/history_modal.py +242 -0
  52. cgate/watch/mode_modal.py +110 -0
  53. cgate/watch/queue.py +106 -0
  54. cgate/watch/render.py +156 -0
  55. cgate/watch/server_settings_modal.py +179 -0
  56. cgate/watch/session.py +40 -0
  57. cgate/watch/theme.py +32 -0
  58. cgate/watch/widgets.py +35 -0
  59. command_gate-0.2.4.dist-info/METADATA +204 -0
  60. command_gate-0.2.4.dist-info/RECORD +63 -0
  61. command_gate-0.2.4.dist-info/WHEEL +4 -0
  62. command_gate-0.2.4.dist-info/entry_points.txt +2 -0
  63. 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