alter-runtime 0.3.0__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 (92) hide show
  1. alter_runtime/__init__.py +11 -0
  2. alter_runtime/adapters/__init__.py +19 -0
  3. alter_runtime/adapters/claude_jsonl_watcher.py +545 -0
  4. alter_runtime/adapters/git_watcher.py +457 -0
  5. alter_runtime/adapters/household/__init__.py +29 -0
  6. alter_runtime/adapters/household/_base.py +138 -0
  7. alter_runtime/adapters/household/compost/__init__.py +17 -0
  8. alter_runtime/adapters/household/compost/adapter.py +81 -0
  9. alter_runtime/adapters/household/compost/storage.py +75 -0
  10. alter_runtime/adapters/household/compost/tests/__init__.py +0 -0
  11. alter_runtime/adapters/household/compost/tests/test_adapter.py +62 -0
  12. alter_runtime/adapters/household/compost/tests/test_storage.py +23 -0
  13. alter_runtime/adapters/household/compost/tests/test_traits.py +38 -0
  14. alter_runtime/adapters/household/compost/traits.py +79 -0
  15. alter_runtime/adapters/household/self_hoster/__init__.py +30 -0
  16. alter_runtime/adapters/household/self_hoster/adapter.py +248 -0
  17. alter_runtime/adapters/household/self_hoster/storage.py +83 -0
  18. alter_runtime/adapters/household/self_hoster/tests/__init__.py +0 -0
  19. alter_runtime/adapters/household/self_hoster/tests/test_adapter.py +216 -0
  20. alter_runtime/adapters/household/self_hoster/tests/test_storage.py +25 -0
  21. alter_runtime/adapters/household/self_hoster/tests/test_traits.py +55 -0
  22. alter_runtime/adapters/household/self_hoster/traits.py +105 -0
  23. alter_runtime/adapters/household/tapo_ecosystem/__init__.py +22 -0
  24. alter_runtime/adapters/household/tapo_ecosystem/adapter.py +98 -0
  25. alter_runtime/adapters/household/tapo_ecosystem/storage.py +95 -0
  26. alter_runtime/adapters/household/tapo_ecosystem/tests/__init__.py +0 -0
  27. alter_runtime/adapters/household/tapo_ecosystem/tests/test_adapter.py +55 -0
  28. alter_runtime/adapters/household/tapo_ecosystem/tests/test_storage.py +28 -0
  29. alter_runtime/adapters/household/tapo_ecosystem/tests/test_traits.py +45 -0
  30. alter_runtime/adapters/household/tapo_ecosystem/traits.py +97 -0
  31. alter_runtime/adapters/household/workshop_tools/__init__.py +25 -0
  32. alter_runtime/adapters/household/workshop_tools/adapter.py +77 -0
  33. alter_runtime/adapters/household/workshop_tools/storage.py +92 -0
  34. alter_runtime/adapters/household/workshop_tools/tests/__init__.py +0 -0
  35. alter_runtime/adapters/household/workshop_tools/tests/test_adapter.py +48 -0
  36. alter_runtime/adapters/household/workshop_tools/tests/test_storage.py +26 -0
  37. alter_runtime/adapters/household/workshop_tools/tests/test_traits.py +45 -0
  38. alter_runtime/adapters/household/workshop_tools/traits.py +95 -0
  39. alter_runtime/adapters/worktree_watcher.py +378 -0
  40. alter_runtime/atlas/__init__.py +48 -0
  41. alter_runtime/atlas/base.py +102 -0
  42. alter_runtime/atlas/ledger.py +196 -0
  43. alter_runtime/atlas/observations.py +136 -0
  44. alter_runtime/atlas/schema.py +106 -0
  45. alter_runtime/cap_cache.py +392 -0
  46. alter_runtime/cli.py +517 -0
  47. alter_runtime/clients/__init__.py +0 -0
  48. alter_runtime/clients/token_usage_client.py +273 -0
  49. alter_runtime/config.py +648 -0
  50. alter_runtime/consent.py +425 -0
  51. alter_runtime/daemon.py +518 -0
  52. alter_runtime/floor_loop.py +335 -0
  53. alter_runtime/floor_preflight.py +734 -0
  54. alter_runtime/http_auth.py +173 -0
  55. alter_runtime/notifiers/__init__.py +18 -0
  56. alter_runtime/notifiers/desktop.py +321 -0
  57. alter_runtime/sdk/__init__.py +12 -0
  58. alter_runtime/sdk/client.py +399 -0
  59. alter_runtime/service_install.py +616 -0
  60. alter_runtime/services/__init__.py +59 -0
  61. alter_runtime/services/launchd/com.alter.runtime.plist.in +90 -0
  62. alter_runtime/services/systemd/alter-runtime.service.in +74 -0
  63. alter_runtime/services/systemd/cf-access-env.conf.in +29 -0
  64. alter_runtime/sockets/__init__.py +20 -0
  65. alter_runtime/sockets/dbus.py +272 -0
  66. alter_runtime/sockets/unix.py +702 -0
  67. alter_runtime/subscribers/__init__.py +58 -0
  68. alter_runtime/subscribers/active_sessions_cron_emitter.py +313 -0
  69. alter_runtime/subscribers/active_sessions_do_publisher.py +1159 -0
  70. alter_runtime/subscribers/active_sessions_gc.py +432 -0
  71. alter_runtime/subscribers/active_sessions_writer.py +446 -0
  72. alter_runtime/subscribers/adapters_writer.py +415 -0
  73. alter_runtime/subscribers/agent_frames.py +461 -0
  74. alter_runtime/subscribers/bus.py +188 -0
  75. alter_runtime/subscribers/cache_writer.py +347 -0
  76. alter_runtime/subscribers/ceremony_echo.py +290 -0
  77. alter_runtime/subscribers/do_sse.py +864 -0
  78. alter_runtime/subscribers/ebpf.py +506 -0
  79. alter_runtime/subscribers/inbox_writer.py +469 -0
  80. alter_runtime/subscribers/mcp_fallback.py +391 -0
  81. alter_runtime/subscribers/presence_writer.py +426 -0
  82. alter_runtime/subscribers/session_presence.py +467 -0
  83. alter_runtime/subscribers/sse.py +125 -0
  84. alter_runtime/subscribers/weave_intent_writer.py +608 -0
  85. alter_runtime/update_loop.py +519 -0
  86. alter_runtime/weave/__init__.py +21 -0
  87. alter_runtime/weave/resolver.py +544 -0
  88. alter_runtime-0.3.0.dist-info/METADATA +289 -0
  89. alter_runtime-0.3.0.dist-info/RECORD +92 -0
  90. alter_runtime-0.3.0.dist-info/WHEEL +4 -0
  91. alter_runtime-0.3.0.dist-info/entry_points.txt +2 -0
  92. alter_runtime-0.3.0.dist-info/licenses/LICENSE +190 -0
@@ -0,0 +1,425 @@
1
+ """Install-consent gate, artefact manifest, and deletion-permanence guard.
2
+
3
+ Implements D-SILENT-INSTALL-NEGATION countermeasures:
4
+
5
+ * **C1** - Visible install-consent gate. Before any byte lands, the user
6
+ sees which artefacts will be written, their sizes, SHA-256 digests, and
7
+ sigstore verification status. They must confirm ``[Y/n]`` (or pass
8
+ ``--yes``).
9
+ * **C2** - Queryable post-install manifest. Every successfully installed
10
+ artefact is recorded in ``install-manifest.json`` under the XDG data
11
+ directory with path, SHA-256, sigstore URL, install timestamp, and
12
+ version. The manifest is atomically written (tmp + rename) at mode
13
+ 0o600 and survives subsequent installs as a log.
14
+ * **C3** - Deletion permanence. When the user removes a service unit via
15
+ ``alter-runtime stop`` (or manually), a tombstone entry is written to
16
+ ``deleted-tombstones.json``. Subsequent ``alter-runtime start`` calls
17
+ check the tombstone table before writing the unit file; if the path is
18
+ tombstoned, the install is refused with an actionable message. The user
19
+ can explicitly clear the tombstone with ``--reinstall``.
20
+
21
+ Inspired by the Chrome / Gemini Nano silent-install pattern documented in
22
+ Phone-links Session 47 (2026-05-05). ALTER will not ship the same pattern.
23
+ """
24
+
25
+ from __future__ import annotations
26
+
27
+ import contextlib
28
+ import hashlib
29
+ import json
30
+ import os
31
+ import sys
32
+ from datetime import datetime, timezone
33
+ from pathlib import Path
34
+ from typing import Any
35
+
36
+ from alter_runtime.config import data_dir
37
+
38
+ __all__ = [
39
+ "ConsentDeclined",
40
+ "UserDeletedArtefact",
41
+ "enumerate_artefacts",
42
+ "is_tombstoned",
43
+ "manifest_path",
44
+ "manifest_version_for",
45
+ "print_consent_screen",
46
+ "prompt_consent",
47
+ "remove_tombstone",
48
+ "tombstone_path",
49
+ "write_manifest_entry",
50
+ "write_tombstone",
51
+ ]
52
+
53
+
54
+ # ---------------------------------------------------------------------------
55
+ # Exceptions
56
+ # ---------------------------------------------------------------------------
57
+
58
+
59
+ class ConsentDeclined(Exception):
60
+ """User declined the install consent prompt."""
61
+
62
+
63
+ class UserDeletedArtefact(Exception):
64
+ """Artefact was previously tombstoned by user; refuse silent reinstall."""
65
+
66
+
67
+ # ---------------------------------------------------------------------------
68
+ # SHA-256 helper
69
+ # ---------------------------------------------------------------------------
70
+
71
+
72
+ def _sha256_file(path: Path) -> str:
73
+ """Return the hex SHA-256 digest of a file. Returns empty string if the
74
+ file does not exist (artefact not yet installed - pre-consent phase)."""
75
+ if not path.exists():
76
+ return ""
77
+ h = hashlib.sha256()
78
+ try:
79
+ with path.open("rb") as fh:
80
+ for chunk in iter(lambda: fh.read(65536), b""):
81
+ h.update(chunk)
82
+ except OSError:
83
+ return ""
84
+ return h.hexdigest()
85
+
86
+
87
+ # ---------------------------------------------------------------------------
88
+ # Artefact enumeration
89
+ # ---------------------------------------------------------------------------
90
+
91
+
92
+ def enumerate_artefacts(binary_path: Path | None = None) -> list[dict[str, Any]]:
93
+ """Return a list of artefacts that ``alter-runtime start`` will write.
94
+
95
+ Each entry is a dict::
96
+
97
+ {
98
+ "path": Path,
99
+ "size_bytes": int | None, # None if not yet on disk
100
+ "sha256": str, # empty str if not yet on disk
101
+ "sigstore_bundle_url": str | None,
102
+ }
103
+
104
+ For v1 the set is:
105
+
106
+ * The systemd user unit *or* launchd plist (platform-appropriate).
107
+ * The ``alter-runtime`` binary itself (resolved via
108
+ :func:`~alter_runtime.service_install.resolve_runtime_binary`, with
109
+ a graceful fallback to ``None`` if the binary is not yet installed).
110
+
111
+ ``sigstore_bundle_url`` is ``None`` in v1 - the bundle-URL lookup lands
112
+ post D-STATIC-BINARY-1.
113
+ """
114
+ from alter_runtime.service_install import (
115
+ current_platform,
116
+ launchd_plist_install_path,
117
+ systemd_unit_install_path,
118
+ )
119
+
120
+ artefacts: list[dict[str, Any]] = []
121
+
122
+ # Unit file
123
+ platform = current_platform()
124
+ if platform == "linux":
125
+ unit_path: Path = systemd_unit_install_path()
126
+ elif platform == "darwin":
127
+ unit_path = launchd_plist_install_path()
128
+ else:
129
+ unit_path = Path("/dev/null") # Windows stub - Wave 3
130
+
131
+ unit_size: int | None = unit_path.stat().st_size if unit_path.exists() else None
132
+ artefacts.append(
133
+ {
134
+ "path": unit_path,
135
+ "size_bytes": unit_size,
136
+ "sha256": _sha256_file(unit_path),
137
+ "sigstore_bundle_url": None,
138
+ }
139
+ )
140
+
141
+ # Runtime binary
142
+ resolved_binary: Path | None = binary_path
143
+ if resolved_binary is None:
144
+ try:
145
+ from alter_runtime.service_install import resolve_runtime_binary
146
+
147
+ resolved_binary = resolve_runtime_binary()
148
+ except FileNotFoundError:
149
+ resolved_binary = None
150
+
151
+ if resolved_binary is not None:
152
+ bin_size: int | None = resolved_binary.stat().st_size if resolved_binary.exists() else None
153
+ artefacts.append(
154
+ {
155
+ "path": resolved_binary,
156
+ "size_bytes": bin_size,
157
+ "sha256": _sha256_file(resolved_binary),
158
+ "sigstore_bundle_url": None,
159
+ }
160
+ )
161
+
162
+ return artefacts
163
+
164
+
165
+ # ---------------------------------------------------------------------------
166
+ # Consent screen
167
+ # ---------------------------------------------------------------------------
168
+
169
+
170
+ def _human_size(size_bytes: int | None) -> str:
171
+ """Format bytes as a human-readable string."""
172
+ if size_bytes is None:
173
+ return "?"
174
+ if size_bytes < 1024:
175
+ return f"{size_bytes} B"
176
+ if size_bytes < 1024 * 1024:
177
+ return f"{size_bytes / 1024:.1f} KiB"
178
+ return f"{size_bytes / (1024 * 1024):.1f} MiB"
179
+
180
+
181
+ def print_consent_screen(artefacts: list[dict[str, Any]], uninstall_cmd: str) -> None:
182
+ """Render the pre-install consent screen to stdout.
183
+
184
+ Format::
185
+
186
+ ALTER Runtime - install consent
187
+ ================================
188
+ The following artefacts will be written to your system:
189
+ /path/to/unit (12.3 KiB) sha256:abcd1234 sigstore: unverified
190
+ ...
191
+ Sigstore-keyless verification: UNVERIFIED
192
+ To remove everything: alter-runtime stop
193
+ """
194
+ print("ALTER Runtime - install consent")
195
+ print("================================")
196
+ print("The following artefacts will be written to your system:")
197
+
198
+ any_sigstore = False
199
+ for artefact in artefacts:
200
+ path = artefact["path"]
201
+ size_str = _human_size(artefact.get("size_bytes"))
202
+ sha = artefact.get("sha256") or ""
203
+ short_sha = sha[:12] if sha else "(not yet on disk)"
204
+ bundle_url = artefact.get("sigstore_bundle_url")
205
+ sig_label = "verified" if bundle_url else "unverified"
206
+ if bundle_url:
207
+ any_sigstore = True
208
+ print(f" {path} ({size_str}) sha256:{short_sha} sigstore: {sig_label}")
209
+
210
+ sig_status = "PASSED" if any_sigstore else "UNVERIFIED"
211
+ print(f"Sigstore-keyless verification: {sig_status}")
212
+ print(f"To remove everything: {uninstall_cmd}")
213
+ print()
214
+
215
+
216
+ # ---------------------------------------------------------------------------
217
+ # Consent prompt
218
+ # ---------------------------------------------------------------------------
219
+
220
+
221
+ def prompt_consent(yes: bool = False) -> bool:
222
+ """Prompt the user to confirm installation.
223
+
224
+ Parameters
225
+ ----------
226
+ yes:
227
+ When ``True`` the prompt is skipped and the function returns
228
+ immediately - intended for CI / package-manager post-install hooks
229
+ that cannot be interactive.
230
+
231
+ Returns
232
+ -------
233
+ bool
234
+ Always ``True`` when the user confirms (or ``yes=True``).
235
+
236
+ Raises
237
+ ------
238
+ ConsentDeclined
239
+ When the user answers ``n`` / ``N``, when the process is not
240
+ attached to a TTY and ``yes=False``, or on EOF / KeyboardInterrupt.
241
+ """
242
+ if yes:
243
+ return True
244
+
245
+ if not sys.stdin.isatty():
246
+ raise ConsentDeclined(
247
+ "stdin is not a TTY and --yes was not passed. "
248
+ "Use `alter-runtime start --yes` for non-interactive installs."
249
+ )
250
+
251
+ try:
252
+ answer = input("Proceed with install? [Y/n]: ").strip().lower()
253
+ except (EOFError, KeyboardInterrupt):
254
+ raise ConsentDeclined("install cancelled by user")
255
+
256
+ if answer in ("n", "no"):
257
+ raise ConsentDeclined("install declined by user")
258
+
259
+ # Any other input (empty = Enter, 'y', 'yes', or unrecognised) proceeds.
260
+ return True
261
+
262
+
263
+ # ---------------------------------------------------------------------------
264
+ # Install manifest (C2)
265
+ # ---------------------------------------------------------------------------
266
+
267
+
268
+ def manifest_path() -> Path:
269
+ """Resolve the install-manifest path under ``data_dir()``."""
270
+ return data_dir() / "install-manifest.json"
271
+
272
+
273
+ def _read_manifest() -> dict[str, Any]:
274
+ """Load the manifest or return an empty scaffold on missing/corrupt file."""
275
+ path = manifest_path()
276
+ if not path.exists():
277
+ return {"schema_version": 1, "entries": {}}
278
+ try:
279
+ raw = json.loads(path.read_text(encoding="utf-8"))
280
+ except (json.JSONDecodeError, OSError):
281
+ return {"schema_version": 1, "entries": {}}
282
+ if not isinstance(raw, dict):
283
+ return {"schema_version": 1, "entries": {}}
284
+ if "entries" not in raw:
285
+ raw["entries"] = {}
286
+ return raw
287
+
288
+
289
+ def _atomic_json_write(path: Path, data: Any) -> None:
290
+ """Write ``data`` as JSON to ``path`` atomically (tmp + rename) at mode 0o600."""
291
+ path.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
292
+ tmp_path = path.with_suffix(path.suffix + ".tmp")
293
+
294
+ flags = os.O_WRONLY | os.O_CREAT | os.O_TRUNC
295
+ fd = os.open(tmp_path, flags, 0o600)
296
+ try:
297
+ with contextlib.suppress(OSError):
298
+ os.fchmod(fd, 0o600)
299
+ payload = json.dumps(data, indent=2, default=str).encode("utf-8")
300
+ os.write(fd, payload)
301
+ os.fsync(fd)
302
+ finally:
303
+ os.close(fd)
304
+
305
+ os.replace(tmp_path, path)
306
+ with contextlib.suppress(OSError):
307
+ os.chmod(path, 0o600)
308
+
309
+
310
+ def write_manifest_entry(artefact: dict[str, Any], version: str) -> None:
311
+ """Append / upsert an entry in ``install-manifest.json``, keyed on path.
312
+
313
+ The manifest schema::
314
+
315
+ {
316
+ "schema_version": 1,
317
+ "entries": {
318
+ "/absolute/path/to/unit": {
319
+ "path": "...",
320
+ "sha256": "...",
321
+ "sigstore_bundle_url": null,
322
+ "installed_at": "2026-05-07T00:00:00+00:00",
323
+ "version": "0.1.0"
324
+ },
325
+ ...
326
+ }
327
+ }
328
+
329
+ Writes are atomic (tmp + rename) at mode 0o600.
330
+ """
331
+ manifest = _read_manifest()
332
+ entries: dict[str, Any] = manifest.setdefault("entries", {})
333
+
334
+ path_str = str(artefact["path"])
335
+ entries[path_str] = {
336
+ "path": path_str,
337
+ "sha256": artefact.get("sha256") or "",
338
+ "sigstore_bundle_url": artefact.get("sigstore_bundle_url"),
339
+ "installed_at": datetime.now(tz=timezone.utc).isoformat(),
340
+ "version": version,
341
+ }
342
+ manifest["entries"] = entries
343
+ _atomic_json_write(manifest_path(), manifest)
344
+
345
+
346
+ def manifest_version_for(path: Path) -> str | None:
347
+ """Return the recorded version for *path* in the manifest, or ``None``."""
348
+ manifest = _read_manifest()
349
+ entry = manifest.get("entries", {}).get(str(path))
350
+ if entry is None:
351
+ return None
352
+ return entry.get("version")
353
+
354
+
355
+ # ---------------------------------------------------------------------------
356
+ # Tombstone store (C3)
357
+ # ---------------------------------------------------------------------------
358
+
359
+
360
+ def tombstone_path() -> Path:
361
+ """Resolve the deleted-tombstones path under ``data_dir()``."""
362
+ return data_dir() / "deleted-tombstones.json"
363
+
364
+
365
+ def _read_tombstones() -> dict[str, Any]:
366
+ """Load tombstones or return an empty scaffold."""
367
+ path = tombstone_path()
368
+ if not path.exists():
369
+ return {"schema_version": 1, "tombstones": {}}
370
+ try:
371
+ raw = json.loads(path.read_text(encoding="utf-8"))
372
+ except (json.JSONDecodeError, OSError):
373
+ return {"schema_version": 1, "tombstones": {}}
374
+ if not isinstance(raw, dict):
375
+ return {"schema_version": 1, "tombstones": {}}
376
+ if "tombstones" not in raw:
377
+ raw["tombstones"] = {}
378
+ return raw
379
+
380
+
381
+ def is_tombstoned(path: Path) -> bool:
382
+ """Return ``True`` if *path* has a tombstone entry.
383
+
384
+ Never raises - returns ``False`` on any read error (missing file, corrupt
385
+ JSON, etc.) so the install path is not blocked by a bad tombstone file.
386
+ """
387
+ try:
388
+ tombstones = _read_tombstones()
389
+ return str(path) in tombstones.get("tombstones", {})
390
+ except Exception: # noqa: BLE001 - intentional broad catch for install safety
391
+ return False
392
+
393
+
394
+ def write_tombstone(
395
+ path: Path,
396
+ sha256_at_deletion: str | None = None,
397
+ source: str = "user_explicit",
398
+ ) -> None:
399
+ """Record a tombstone entry for *path*.
400
+
401
+ Parameters
402
+ ----------
403
+ path:
404
+ The artefact path that was deleted.
405
+ sha256_at_deletion:
406
+ The SHA-256 of the file at deletion time, if available.
407
+ source:
408
+ How the deletion happened - ``"user_explicit"`` (via
409
+ ``alter-runtime stop``) or ``"user_manual"`` (detected out-of-band).
410
+ """
411
+ data = _read_tombstones()
412
+ data["tombstones"][str(path)] = {
413
+ "path": str(path),
414
+ "sha256_at_deletion": sha256_at_deletion,
415
+ "deleted_at": datetime.now(tz=timezone.utc).isoformat(),
416
+ "source": source,
417
+ }
418
+ _atomic_json_write(tombstone_path(), data)
419
+
420
+
421
+ def remove_tombstone(path: Path) -> None:
422
+ """Remove the tombstone entry for *path* (used by ``--reinstall`` flow)."""
423
+ data = _read_tombstones()
424
+ data["tombstones"].pop(str(path), None)
425
+ _atomic_json_write(tombstone_path(), data)