sqai 0.1.0__tar.gz

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.
sqai-0.1.0/.gitignore ADDED
@@ -0,0 +1,15 @@
1
+ node_modules/
2
+ dist/
3
+ .turbo/
4
+ coverage/
5
+ *.log
6
+ .env
7
+ .env.*
8
+ !.env.example
9
+ __pycache__/
10
+ *.pyc
11
+ .pytest_cache/
12
+ .venv/
13
+ build/
14
+ *.egg-info/
15
+ .DS_Store
sqai-0.1.0/PKG-INFO ADDED
@@ -0,0 +1,52 @@
1
+ Metadata-Version: 2.4
2
+ Name: sqai
3
+ Version: 0.1.0
4
+ Summary: SQAI — the deterministic, read-only structured-data SDK for AI agents, powered by Algenta execution and provenance.
5
+ Project-URL: Homepage, https://sqai.com
6
+ Project-URL: Repository, https://github.com/thyn-ai/sqai
7
+ Author-email: Thyn <eng@thyn.ai>
8
+ License-Expression: Apache-2.0
9
+ Keywords: agents,ai,algenta,deterministic,read-only,sqai,structured-data
10
+ Classifier: Development Status :: 3 - Alpha
11
+ Classifier: Intended Audience :: Developers
12
+ Classifier: Programming Language :: Python :: 3
13
+ Classifier: Programming Language :: Python :: 3.10
14
+ Classifier: Programming Language :: Python :: 3.11
15
+ Classifier: Programming Language :: Python :: 3.12
16
+ Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
17
+ Requires-Python: >=3.10
18
+ Requires-Dist: algenta-core==1.0.5
19
+ Description-Content-Type: text/markdown
20
+
21
+ # sqai
22
+
23
+ SQAI — the deterministic, read-only structured-data SDK for AI agents, powered by
24
+ Algenta execution and provenance. Python twin of [`@thyn-ai/sqai`](https://github.com/thyn-ai/sqai):
25
+ same vocabulary, same error codes, same golden numbers, cross-language-identical hashes.
26
+
27
+ ```python
28
+ from sqai import create_sqai
29
+
30
+ sq = create_sqai() # zero-config: SQAI_ENGINE_URL -> self_hosted, SQAI_API_KEY -> api, else local
31
+ sq.connect(orders, name="orders")
32
+
33
+ outcome = sq.ask({"metric": "revenue", "aggregation": "sum", "group_by": "region",
34
+ "source_name": "orders"})
35
+
36
+ result = sq.compute(module="stats", function="median", args=[[1.0, 2.0, 3.0], 3])
37
+ print(result["value"], result["invocation_hash"], result["computation_hash"])
38
+ ```
39
+
40
+ - **Query plane** — `connect` / `resolve` / `query` / `verify` / `ask`, in-process via Algenta.
41
+ - **Computation plane** — validated against the embedded capability contract, delegated to
42
+ the managed runtime; results pass through verbatim with a two-hash determinism envelope
43
+ (`invocation_hash` before execution, `computation_hash` after).
44
+ - **Policy** — allow-lists for sources, fields, and functions; the default surface is
45
+ read-only deterministic capabilities, and policy can only narrow it.
46
+ - **Errors** — Algenta codes pass through verbatim (`source="algenta"`); SQAI codes share the
47
+ same snake_case vocabulary (`source="sqai"`). Messages never leak absolute paths.
48
+
49
+ The unsafe escape hatch (`from sqai.unsafe import get_unsafe_algenta_runtime`) bypasses every
50
+ guarantee above and is deliberately not exported from the package root.
51
+
52
+ Apache-2.0.
sqai-0.1.0/README.md ADDED
@@ -0,0 +1,32 @@
1
+ # sqai
2
+
3
+ SQAI — the deterministic, read-only structured-data SDK for AI agents, powered by
4
+ Algenta execution and provenance. Python twin of [`@thyn-ai/sqai`](https://github.com/thyn-ai/sqai):
5
+ same vocabulary, same error codes, same golden numbers, cross-language-identical hashes.
6
+
7
+ ```python
8
+ from sqai import create_sqai
9
+
10
+ sq = create_sqai() # zero-config: SQAI_ENGINE_URL -> self_hosted, SQAI_API_KEY -> api, else local
11
+ sq.connect(orders, name="orders")
12
+
13
+ outcome = sq.ask({"metric": "revenue", "aggregation": "sum", "group_by": "region",
14
+ "source_name": "orders"})
15
+
16
+ result = sq.compute(module="stats", function="median", args=[[1.0, 2.0, 3.0], 3])
17
+ print(result["value"], result["invocation_hash"], result["computation_hash"])
18
+ ```
19
+
20
+ - **Query plane** — `connect` / `resolve` / `query` / `verify` / `ask`, in-process via Algenta.
21
+ - **Computation plane** — validated against the embedded capability contract, delegated to
22
+ the managed runtime; results pass through verbatim with a two-hash determinism envelope
23
+ (`invocation_hash` before execution, `computation_hash` after).
24
+ - **Policy** — allow-lists for sources, fields, and functions; the default surface is
25
+ read-only deterministic capabilities, and policy can only narrow it.
26
+ - **Errors** — Algenta codes pass through verbatim (`source="algenta"`); SQAI codes share the
27
+ same snake_case vocabulary (`source="sqai"`). Messages never leak absolute paths.
28
+
29
+ The unsafe escape hatch (`from sqai.unsafe import get_unsafe_algenta_runtime`) bypasses every
30
+ guarantee above and is deliberately not exported from the package root.
31
+
32
+ Apache-2.0.
@@ -0,0 +1,44 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "sqai"
7
+ version = "0.1.0"
8
+ description = "SQAI — the deterministic, read-only structured-data SDK for AI agents, powered by Algenta execution and provenance."
9
+ readme = "README.md"
10
+ requires-python = ">=3.10"
11
+ license = "Apache-2.0"
12
+ authors = [{ name = "Thyn", email = "eng@thyn.ai" }]
13
+ keywords = [
14
+ "ai",
15
+ "agents",
16
+ "structured-data",
17
+ "deterministic",
18
+ "read-only",
19
+ "algenta",
20
+ "sqai",
21
+ ]
22
+ classifiers = [
23
+ "Development Status :: 3 - Alpha",
24
+ "Intended Audience :: Developers",
25
+ "Programming Language :: Python :: 3",
26
+ "Programming Language :: Python :: 3.10",
27
+ "Programming Language :: Python :: 3.11",
28
+ "Programming Language :: Python :: 3.12",
29
+ "Topic :: Scientific/Engineering :: Artificial Intelligence",
30
+ ]
31
+ dependencies = ["algenta-core==1.0.5"]
32
+
33
+ [project.scripts]
34
+ sqai = "sqai.cli:main"
35
+
36
+ [project.urls]
37
+ Homepage = "https://sqai.com"
38
+ Repository = "https://github.com/thyn-ai/sqai"
39
+
40
+ [tool.hatch.build.targets.wheel]
41
+ packages = ["sqai"]
42
+
43
+ [tool.hatch.build.targets.wheel.force-include]
44
+ "sqai/_contract_data.json" = "sqai/_contract_data.json"
@@ -0,0 +1,52 @@
1
+ """sqai — the deterministic, read-only structured-data SDK for AI agents.
2
+
3
+ Python twin of ``@sqai/sdk``: same vocabulary, same error codes, same golden
4
+ numbers, cross-language-identical hashes. Powered by the algenta-core
5
+ substrate; SQAI validates and delegates, it never calculates.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from sqai._contract import ContractIndex, is_read_only_eligible
11
+ from sqai._env import infer_mode, read_env
12
+ from sqai._hashes import computation_hash, invocation_hash
13
+ from sqai._policy import check_field_allowed, check_function_allowed, check_source_allowed
14
+ from sqai._provision import RuntimeProvisioner, current_platform_key, runtime_cache_dir
15
+ from sqai._results import ResultStore
16
+ from sqai._validate import (
17
+ ResolvedBindingValue,
18
+ ValidatedComputation,
19
+ lookup_capability,
20
+ validate_computation,
21
+ )
22
+ from sqai.client import SQAI, create_sqai
23
+ from sqai.errors import SQAI_ERROR_CODES, SqaiError, from_algenta_error, sanitize_message
24
+
25
+ __version__ = "0.1.0"
26
+
27
+ __all__ = [
28
+ "SQAI",
29
+ "create_sqai",
30
+ "SqaiError",
31
+ "SQAI_ERROR_CODES",
32
+ "from_algenta_error",
33
+ "sanitize_message",
34
+ "ContractIndex",
35
+ "is_read_only_eligible",
36
+ "invocation_hash",
37
+ "computation_hash",
38
+ "ResultStore",
39
+ "RuntimeProvisioner",
40
+ "current_platform_key",
41
+ "runtime_cache_dir",
42
+ "validate_computation",
43
+ "lookup_capability",
44
+ "ResolvedBindingValue",
45
+ "ValidatedComputation",
46
+ "check_source_allowed",
47
+ "check_field_allowed",
48
+ "check_function_allowed",
49
+ "read_env",
50
+ "infer_mode",
51
+ "__version__",
52
+ ]
@@ -0,0 +1,468 @@
1
+ """Signed managed-runtime bundle verification + installation primitives.
2
+
3
+ Mirrors the upstream reference verifier (decision-engine
4
+ ``scripts/verify_runtime_bundle.py``) — verification fails closed at every
5
+ step:
6
+
7
+ 1. read manifest.json + manifest.sig bytes FROM the archive (hard size caps
8
+ BEFORE any allocation — the bundle is attacker-controlled until verified)
9
+ 2. verify the RS256 signature over those EXACT manifest bytes against the
10
+ embedded trust root (never re-serialize before verifying)
11
+ 3. schema-check the manifest (format version, artifact entries, path safety:
12
+ reject absolute paths, "..", empty segments) + rollback floor
13
+ 4. stream-hash every artifact member against the signed sha256 + size
14
+ (regular files only — symlinks / hardlinks / devices rejected)
15
+ 5. reject any archive member the signed manifest does not name
16
+
17
+ Verification reads the archive with :mod:`tarfile` (the reference verifier's
18
+ tool); the final extraction of the fully verified archive uses the system
19
+ ``tar`` binary. Parity-locked with the TypeScript SDK's ``runtime/bundle.ts``.
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ import hashlib
25
+ import hmac
26
+ import json
27
+ import os
28
+ import subprocess
29
+ import tarfile
30
+ from pathlib import Path
31
+ from typing import Any
32
+
33
+ from sqai._trust import (
34
+ TrustRoot,
35
+ TrustVerificationError,
36
+ compare_runtime_versions,
37
+ verify_manifest_signature_against_trust_root,
38
+ )
39
+
40
+ MANIFEST_NAME = "manifest.json"
41
+ MANIFEST_SIG_NAME = "manifest.sig"
42
+ BUNDLE_FORMAT_VERSION = 1
43
+
44
+ # Hard caps enforced BEFORE the signature is checked (anti-OOM: a tiny gzip
45
+ # member can declare a multi-GB size). Artifacts are additionally bounded by
46
+ # the SIGNED manifest size once the signature verifies.
47
+ MAX_MANIFEST_BYTES = 8 * 1024 * 1024 # 8 MB
48
+ MAX_SIG_BYTES = 64 * 1024 # 64 KB
49
+ MAX_ARTIFACT_BYTES = 4 * 1024 * 1024 * 1024 # 4 GB per artifact
50
+
51
+
52
+ class BundleVerificationError(Exception):
53
+ """Any signature / integrity / path-safety check failed. Fail closed."""
54
+
55
+ def __init__(self, reason: str, message: str) -> None:
56
+ super().__init__(message)
57
+ self.reason = reason
58
+
59
+
60
+ # ── Path safety ───────────────────────────────────────────────────────────────
61
+
62
+
63
+ def is_safe_relative_path(path: str) -> bool:
64
+ """True only for a normalized, relative, traversal-free archive path."""
65
+ if not path or path.startswith("/") or path.startswith("\\"):
66
+ return False
67
+ if "\\" in path or "\x00" in path:
68
+ return False
69
+ # Stricter than the reference: control characters are rejected so archive
70
+ # names, listings, and on-disk paths can never diverge across tools.
71
+ if any(ord(char) < 0x20 or ord(char) == 0x7F for char in path):
72
+ return False
73
+ return all(part not in ("", ".", "..") for part in path.split("/"))
74
+
75
+
76
+ def _require_safe_member(info: tarfile.TarInfo) -> None:
77
+ if not is_safe_relative_path(info.name):
78
+ raise BundleVerificationError(
79
+ "unsafe_member", f"unsafe path in bundle member: {info.name!r}"
80
+ )
81
+ if info.issym() or info.islnk():
82
+ raise BundleVerificationError("unsafe_member", f"link member rejected: {info.name!r}")
83
+ if not (info.isfile() or info.isdir()):
84
+ raise BundleVerificationError(
85
+ "unsafe_member", f"non-regular member rejected: {info.name!r}"
86
+ )
87
+
88
+
89
+ # ── Capped member reads ───────────────────────────────────────────────────────
90
+
91
+
92
+ def _read_member_bytes(tar: tarfile.TarFile, name: str, max_bytes: int) -> bytes:
93
+ try:
94
+ info = tar.getmember(name)
95
+ except KeyError:
96
+ raise BundleVerificationError(
97
+ "member_unreadable", f"bundle is missing member {name!r}"
98
+ ) from None
99
+ _require_safe_member(info)
100
+ if not info.isfile():
101
+ raise BundleVerificationError(
102
+ "member_unreadable", f"bundle member {name!r} is not a regular file"
103
+ )
104
+ if info.size > max_bytes:
105
+ raise BundleVerificationError(
106
+ "member_too_large",
107
+ f"bundle member {name!r} too large ({info.size} > {max_bytes} cap)",
108
+ )
109
+ handle = tar.extractfile(info)
110
+ if handle is None:
111
+ raise BundleVerificationError(
112
+ "member_unreadable", f"could not read bundle member {name!r}"
113
+ )
114
+ data = handle.read(max_bytes + 1)
115
+ if len(data) > max_bytes:
116
+ raise BundleVerificationError(
117
+ "member_too_large", f"bundle member {name!r} exceeds {max_bytes}-byte cap"
118
+ )
119
+ if len(data) != info.size:
120
+ raise BundleVerificationError(
121
+ "member_unreadable",
122
+ f"bundle member {name!r} size mismatch (header {info.size}, read {len(data)})",
123
+ )
124
+ return data
125
+
126
+
127
+ # ── Hashing helpers ───────────────────────────────────────────────────────────
128
+
129
+
130
+ def sha256_file(path: str | Path) -> str:
131
+ digest = hashlib.sha256()
132
+ with Path(path).open("rb") as handle:
133
+ for chunk in iter(lambda: handle.read(1 << 20), b""):
134
+ digest.update(chunk)
135
+ return digest.hexdigest()
136
+
137
+
138
+ def digests_equal(actual_hex: str, expected_hex: str) -> bool:
139
+ """Constant-time hex digest comparison (mirrors hmac.compare_digest)."""
140
+ return hmac.compare_digest(actual_hex.lower(), expected_hex.lower())
141
+
142
+
143
+ # ── Manifest schema ───────────────────────────────────────────────────────────
144
+
145
+
146
+ def parse_verified_manifest(manifest_bytes: bytes) -> dict[str, Any]:
147
+ """json.loads AFTER signature verification, then schema + path-safety checks."""
148
+ try:
149
+ manifest = json.loads(manifest_bytes)
150
+ except Exception as exc: # noqa: BLE001
151
+ raise BundleVerificationError(
152
+ "manifest_schema_invalid", f"manifest verified but is not valid JSON: {exc}"
153
+ ) from exc
154
+ if not isinstance(manifest, dict):
155
+ raise BundleVerificationError("manifest_schema_invalid", "manifest is not a JSON object")
156
+ if manifest.get("bundle_format_version") != BUNDLE_FORMAT_VERSION:
157
+ raise BundleVerificationError(
158
+ "manifest_schema_invalid",
159
+ f"unsupported bundle_format_version: {manifest.get('bundle_format_version')!r}",
160
+ )
161
+ for field in ("runtime_bundle_version", "platform", "created_at"):
162
+ if not isinstance(manifest.get(field), str) or not manifest[field]:
163
+ raise BundleVerificationError(
164
+ "manifest_schema_invalid", f"manifest missing string field {field!r}"
165
+ )
166
+ if not isinstance(manifest.get("engine_stub"), bool):
167
+ raise BundleVerificationError(
168
+ "manifest_schema_invalid", "manifest missing boolean field 'engine_stub'"
169
+ )
170
+ artifacts = manifest.get("artifacts")
171
+ if not isinstance(artifacts, list) or not artifacts:
172
+ raise BundleVerificationError(
173
+ "manifest_schema_invalid", "manifest missing a non-empty 'artifacts' list"
174
+ )
175
+ seen: set[str] = set()
176
+ for entry in artifacts:
177
+ if not (
178
+ isinstance(entry, dict)
179
+ and isinstance(entry.get("path"), str)
180
+ and isinstance(entry.get("sha256"), str)
181
+ and len(entry["sha256"]) == 64
182
+ and isinstance(entry.get("size"), int)
183
+ and not isinstance(entry.get("size"), bool)
184
+ and entry["size"] >= 0
185
+ ):
186
+ raise BundleVerificationError(
187
+ "manifest_schema_invalid", f"manifest artifact entry malformed: {entry!r}"
188
+ )
189
+ path = entry["path"]
190
+ if not is_safe_relative_path(path) or path in (MANIFEST_NAME, MANIFEST_SIG_NAME):
191
+ raise BundleVerificationError(
192
+ "manifest_schema_invalid", f"unsafe artifact path in manifest: {path!r}"
193
+ )
194
+ if path in seen:
195
+ raise BundleVerificationError(
196
+ "manifest_schema_invalid", f"duplicate artifact path in manifest: {path!r}"
197
+ )
198
+ seen.add(path)
199
+ return manifest
200
+
201
+
202
+ # ── Cross-cutting manifest policy (trust root, rollback, pins) ────────────────
203
+
204
+
205
+ def _verify_manifest_policy(
206
+ manifest: dict[str, Any],
207
+ *,
208
+ trust_root: TrustRoot,
209
+ expected_version: str | None,
210
+ expected_platform: str | None,
211
+ ) -> None:
212
+ version = manifest["runtime_bundle_version"]
213
+ if compare_runtime_versions(version, trust_root.min_accepted_runtime_version) < 0:
214
+ raise BundleVerificationError(
215
+ "rollback_protected",
216
+ f"bundle version {version} is below the minimum accepted runtime version "
217
+ f"{trust_root.min_accepted_runtime_version} (rollback protection)",
218
+ )
219
+ if expected_version is not None and version != expected_version:
220
+ raise BundleVerificationError(
221
+ "version_mismatch",
222
+ f"bundle manifest carries version {version}, expected {expected_version}",
223
+ )
224
+ if expected_platform is not None and manifest["platform"] != expected_platform:
225
+ raise BundleVerificationError(
226
+ "bundle_platform_mismatch",
227
+ f"bundle targets platform '{manifest['platform']}', expected '{expected_platform}'",
228
+ )
229
+
230
+
231
+ def _verify_signature_bytes(
232
+ manifest_bytes: bytes, signature: bytes, trust_root: TrustRoot
233
+ ) -> str:
234
+ try:
235
+ return verify_manifest_signature_against_trust_root(manifest_bytes, signature, trust_root)
236
+ except TrustVerificationError as exc:
237
+ raise BundleVerificationError(exc.reason, str(exc)) from exc
238
+
239
+
240
+ # ── Full tarball verification (reference-verifier mirror) ─────────────────────
241
+
242
+
243
+ def _stream_sha256(tar: tarfile.TarFile, info: tarfile.TarInfo, cap: int) -> tuple[str, int]:
244
+ handle = tar.extractfile(info)
245
+ if handle is None:
246
+ raise BundleVerificationError("artifact_missing", f"could not read artifact {info.name!r}")
247
+ digest = hashlib.sha256()
248
+ total = 0
249
+ while True:
250
+ chunk = handle.read(1 << 20)
251
+ if not chunk:
252
+ break
253
+ total += len(chunk)
254
+ if total > cap:
255
+ raise BundleVerificationError(
256
+ "artifact_size_mismatch", f"artifact {info.name!r} exceeds {cap}-byte cap"
257
+ )
258
+ digest.update(chunk)
259
+ return digest.hexdigest(), total
260
+
261
+
262
+ def verify_bundle_tarball(
263
+ tarball: str | Path,
264
+ *,
265
+ trust_root: TrustRoot,
266
+ expected_version: str | None = None,
267
+ expected_platform: str | None = None,
268
+ ) -> dict[str, Any]:
269
+ """Verify signature, per-artifact integrity, and path safety of a bundle
270
+ tarball WITHOUT extracting anything. Returns the verified manifest."""
271
+ tarball = Path(tarball)
272
+ if not tarball.is_file():
273
+ raise BundleVerificationError("archive_unreadable", f"bundle not found: {tarball.name}")
274
+
275
+ try:
276
+ tar = tarfile.open(tarball, "r:*")
277
+ except Exception as exc: # noqa: BLE001 — corrupt archives fail closed
278
+ raise BundleVerificationError(
279
+ "archive_unreadable", f"the bundle archive could not be opened: {exc}"
280
+ ) from exc
281
+ with tar:
282
+ # 1 + 2: signature over the exact manifest bytes, before trusting anything.
283
+ manifest_bytes = _read_member_bytes(tar, MANIFEST_NAME, MAX_MANIFEST_BYTES)
284
+ signature = _read_member_bytes(tar, MANIFEST_SIG_NAME, MAX_SIG_BYTES)
285
+ _verify_signature_bytes(manifest_bytes, signature, trust_root)
286
+
287
+ # 3: schema + path safety of the now-trusted manifest, plus rollback
288
+ # floor and contract pins.
289
+ manifest = parse_verified_manifest(manifest_bytes)
290
+ _verify_manifest_policy(
291
+ manifest,
292
+ trust_root=trust_root,
293
+ expected_version=expected_version,
294
+ expected_platform=expected_platform,
295
+ )
296
+ by_path = {entry["path"]: entry for entry in manifest["artifacts"]}
297
+
298
+ # 4 + 5: walk EVERY archive member — safety-check all, hash the listed,
299
+ # reject unlisted regular files.
300
+ verified: set[str] = set()
301
+ for info in tar.getmembers():
302
+ _require_safe_member(info)
303
+ if info.isdir():
304
+ continue
305
+ if info.name in (MANIFEST_NAME, MANIFEST_SIG_NAME):
306
+ continue
307
+ entry = by_path.get(info.name)
308
+ if entry is None:
309
+ raise BundleVerificationError(
310
+ "unlisted_member",
311
+ f"archive member not listed in signed manifest: {info.name!r}",
312
+ )
313
+ cap = min(int(entry["size"]), MAX_ARTIFACT_BYTES)
314
+ actual_sha, actual_size = _stream_sha256(tar, info, cap)
315
+ if actual_size != entry["size"]:
316
+ raise BundleVerificationError(
317
+ "artifact_size_mismatch",
318
+ f"artifact {info.name!r} size mismatch "
319
+ f"(manifest {entry['size']}, actual {actual_size})",
320
+ )
321
+ if not digests_equal(actual_sha, entry["sha256"]):
322
+ raise BundleVerificationError(
323
+ "artifact_hash_mismatch",
324
+ f"artifact integrity check FAILED for {info.name!r}: "
325
+ f"expected {entry['sha256'][:12]}…, got {actual_sha[:12]}…",
326
+ )
327
+ verified.add(info.name)
328
+
329
+ missing = sorted(set(by_path) - verified)
330
+ if missing:
331
+ raise BundleVerificationError(
332
+ "artifact_missing",
333
+ f"manifest artifacts missing from archive: {', '.join(missing[:5])}",
334
+ )
335
+ return manifest
336
+
337
+
338
+ # ── Extraction (system tar, only after full verification) ────────────────────
339
+
340
+
341
+ def extract_tarball(tarball: str | Path, destination_dir: str | Path) -> None:
342
+ """Extract the (already fully verified) archive with the system tar."""
343
+ destination = Path(destination_dir)
344
+ destination.mkdir(parents=True, exist_ok=True)
345
+ result = subprocess.run( # noqa: S603 — fixed argv, no shell
346
+ ["tar", "-xf", str(tarball), "-C", str(destination)],
347
+ stdin=subprocess.DEVNULL,
348
+ capture_output=True,
349
+ text=True,
350
+ check=False,
351
+ )
352
+ if result.returncode != 0:
353
+ raise BundleVerificationError(
354
+ "extract_failed",
355
+ f"tar extraction failed (exit {result.returncode}): {result.stderr[:300]}",
356
+ )
357
+
358
+
359
+ # ── Installed-directory verification (same contract, on-disk) ────────────────
360
+
361
+
362
+ def _read_installed_bytes(path: Path, max_bytes: int) -> bytes:
363
+ try:
364
+ if path.is_symlink() or not path.is_file() or path.stat().st_size > max_bytes:
365
+ raise BundleVerificationError(
366
+ "member_unreadable",
367
+ "installed bundle manifest/signature is not a capped regular file",
368
+ )
369
+ return path.read_bytes()
370
+ except BundleVerificationError:
371
+ raise
372
+ except OSError as exc:
373
+ raise BundleVerificationError(
374
+ "member_unreadable", "installed bundle is missing manifest.json / manifest.sig"
375
+ ) from exc
376
+
377
+
378
+ def verify_installed_bundle_dir(
379
+ install_dir: str | Path,
380
+ *,
381
+ trust_root: TrustRoot,
382
+ expected_version: str | None = None,
383
+ expected_platform: str | None = None,
384
+ ) -> dict[str, Any]:
385
+ """Re-verify an installed bundle directory against the trust root: manifest
386
+ signature, schema, rollback floor, and every artifact's sha256 + size; any
387
+ file on disk the signed manifest does not name is rejected."""
388
+ install_dir = Path(install_dir)
389
+ manifest_bytes = _read_installed_bytes(install_dir / MANIFEST_NAME, MAX_MANIFEST_BYTES)
390
+ signature = _read_installed_bytes(install_dir / MANIFEST_SIG_NAME, MAX_SIG_BYTES)
391
+ _verify_signature_bytes(manifest_bytes, signature, trust_root)
392
+ manifest = parse_verified_manifest(manifest_bytes)
393
+ _verify_manifest_policy(
394
+ manifest,
395
+ trust_root=trust_root,
396
+ expected_version=expected_version,
397
+ expected_platform=expected_platform,
398
+ )
399
+ by_path = {entry["path"]: entry for entry in manifest["artifacts"]}
400
+
401
+ verified: set[str] = set()
402
+ for root, _dirs, files in os.walk(install_dir, followlinks=False):
403
+ for name in files:
404
+ absolute = Path(root) / name
405
+ rel_path = absolute.relative_to(install_dir).as_posix()
406
+ if absolute.is_symlink():
407
+ raise BundleVerificationError(
408
+ "unsafe_member", f"symlink in installed bundle rejected: {rel_path!r}"
409
+ )
410
+ if rel_path in (MANIFEST_NAME, MANIFEST_SIG_NAME):
411
+ continue
412
+ entry = by_path.get(rel_path)
413
+ if entry is None:
414
+ raise BundleVerificationError(
415
+ "unlisted_member",
416
+ f"installed file not listed in signed manifest: {rel_path!r}",
417
+ )
418
+ stat = absolute.lstat()
419
+ if stat.st_nlink > 1:
420
+ raise BundleVerificationError(
421
+ "unsafe_member", f"hardlinked file in installed bundle rejected: {rel_path!r}"
422
+ )
423
+ if stat.st_size != entry["size"]:
424
+ raise BundleVerificationError(
425
+ "artifact_size_mismatch",
426
+ f"installed artifact {rel_path!r} size mismatch "
427
+ f"(manifest {entry['size']}, actual {stat.st_size})",
428
+ )
429
+ if not digests_equal(sha256_file(absolute), entry["sha256"]):
430
+ raise BundleVerificationError(
431
+ "artifact_hash_mismatch",
432
+ f"installed artifact integrity check FAILED for {rel_path!r}",
433
+ )
434
+ verified.add(rel_path)
435
+
436
+ missing = sorted(set(by_path) - verified)
437
+ if missing:
438
+ raise BundleVerificationError(
439
+ "artifact_missing",
440
+ f"manifest artifacts missing from installed bundle: {', '.join(missing[:5])}",
441
+ )
442
+ return manifest
443
+
444
+
445
+ # ── Daemon entry derivation ───────────────────────────────────────────────────
446
+
447
+
448
+ def daemon_entry_from_manifest(manifest: dict[str, Any]) -> str:
449
+ """The daemon entry executable, derived from the signed manifest: the
450
+ single regular file directly under ``daemon/`` (PyInstaller onedir keeps
451
+ everything else in ``daemon/_internal/``). Returns a bundle-relative path."""
452
+ candidates = [
453
+ entry["path"]
454
+ for entry in manifest["artifacts"]
455
+ if len(entry["path"].split("/")) == 2 and entry["path"].split("/")[0] == "daemon"
456
+ ]
457
+ if not candidates:
458
+ raise BundleVerificationError(
459
+ "daemon_entry_not_found",
460
+ "the signed manifest lists no daemon entry executable under daemon/",
461
+ )
462
+ if len(candidates) > 1:
463
+ raise BundleVerificationError(
464
+ "daemon_entry_ambiguous",
465
+ f"the signed manifest lists multiple candidate daemon entries: "
466
+ f"{', '.join(candidates)}",
467
+ )
468
+ return candidates[0]