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 +15 -0
- sqai-0.1.0/PKG-INFO +52 -0
- sqai-0.1.0/README.md +32 -0
- sqai-0.1.0/pyproject.toml +44 -0
- sqai-0.1.0/sqai/__init__.py +52 -0
- sqai-0.1.0/sqai/_bundle.py +468 -0
- sqai-0.1.0/sqai/_contract.py +91 -0
- sqai-0.1.0/sqai/_contract_data.json +1 -0
- sqai-0.1.0/sqai/_dispatch.py +146 -0
- sqai-0.1.0/sqai/_env.py +40 -0
- sqai-0.1.0/sqai/_hashes.py +42 -0
- sqai-0.1.0/sqai/_policy.py +70 -0
- sqai-0.1.0/sqai/_provision.py +543 -0
- sqai-0.1.0/sqai/_results.py +127 -0
- sqai-0.1.0/sqai/_trust.py +226 -0
- sqai-0.1.0/sqai/_validate.py +210 -0
- sqai-0.1.0/sqai/cli.py +53 -0
- sqai-0.1.0/sqai/client.py +455 -0
- sqai-0.1.0/sqai/errors.py +103 -0
- sqai-0.1.0/sqai/unsafe.py +29 -0
- sqai-0.1.0/tests/__init__.py +0 -0
- sqai-0.1.0/tests/conftest.py +29 -0
- sqai-0.1.0/tests/fixtures.py +125 -0
- sqai-0.1.0/tests/test_connect.py +54 -0
- sqai-0.1.0/tests/test_dispatch.py +156 -0
- sqai-0.1.0/tests/test_hashes.py +66 -0
- sqai-0.1.0/tests/test_provision.py +104 -0
- sqai-0.1.0/tests/test_provision_install.py +551 -0
- sqai-0.1.0/tests/test_query_golden.py +100 -0
- sqai-0.1.0/tests/test_results.py +72 -0
- sqai-0.1.0/tests/test_validate.py +142 -0
sqai-0.1.0/.gitignore
ADDED
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]
|