diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index ca5d6e2..4592aaf 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -1,4 +1,4 @@ -# SAPIENS Phase-0 Architecture +# SAPIENS Architecture (Phases 0–1) ## Design goals @@ -13,7 +13,11 @@ ```text src/sapiens/ models.py immutable candidate/evidence/manifest models - adapter.py DomainAdapter protocol and Phase-0 adapter validation + adapter.py DomainAdapter protocol; validation routes to the registry + registry.py trust-tiered adapter registry (Phase 1) + permissions.py owner-permission/licence manifest for third-party code (Phase 1) + isolation.py subprocess + rlimit execution for UNTRUSTED adapters (Phase 1) + checkpoints.py signed ledger checkpoints + external anchor export (Phase 1) ledger.py JSONL hash-chain ledger and L0→L4 transition guard kernel.py domain-neutral candidate registration and next-gate validation bridge.py cross-domain structure transfer with mandatory L0 reset @@ -32,7 +36,9 @@ Adapters may propose candidates and produce evidence, but they cannot set eviden Required methods: -- `manifest`: domain name/version/vocabulary and `synthetic_only=True` for Phase 0. +- `manifest`: domain name/version/vocabulary plus provenance facts + (`synthetic_only`, `code_origin`, `data_sources`, `third_party_source`). + The Phase-1 registry derives a trust tier from these facts. - `propose(seed, limit)`: deterministic candidates. - `validate(candidate, stage, seed, context)`: bounded evidence for `internal`, `replication`, or `review`. - `import_structure(structure, candidate_id)`: target-domain candidate from cross-domain structure. @@ -41,7 +47,7 @@ Required methods: The ledger is newline-delimited canonical JSON. Each event stores the previous event hash and its own hash. Replay validates sequence, hashes, candidate creation, evidence scope, one-step promotion, required evidence kinds, demotion reasons, and the L4 human gate. -Hash chaining detects tampering but does **not** prove authorship, signatures, scientific truth, or external timestamping. +Hash chaining detects tampering but does **not** prove authorship, scientific truth, or external timestamping. Phase-1 `checkpoint` events summarise the chain (event count + head hash) and may carry an HMAC-SHA256 signature (environment-held key, never stored); `sapiens.checkpoints` also exports/verifies external anchor files. HMAC is symmetric: it proves key possession, not third-party authorship. ## Cross-domain bridge @@ -51,4 +57,4 @@ Hash chaining detects tampering but does **not** prove authorship, signatures, s `WorkQueue` gives bounded jobs, serialized payload-size limits, idempotency keys, leases, stale-lease rejection, and retry/dead states. `DiscoveryDaemon.run_bounded` executes only explicitly registered handlers under time/step budgets; it does not dynamically import or shell out from queue payloads. -Phase 0 uses cooperative in-process preemption. Subprocess/cgroup isolation is a roadmap item before untrusted adapters. +Synthetic and CORE (first-party, real-data) adapters run in-process with cooperative preemption. UNTRUSTED (third-party) adapters run only via `sapiens.isolation`: a child process applies POSIX rlimits (CPU, address space, open files) to itself, the parent enforces a wall-clock timeout, and every failure mode is contained fail-closed (no evidence on failure). Third-party adapters additionally require a recorded owner permission (`permissions.json`, empty by default) before the registry will validate them at all. rlimits bound resources; they are not a full security sandbox. diff --git a/PROVENANCE.md b/PROVENANCE.md index 1ecdd46..60aac67 100644 --- a/PROVENANCE.md +++ b/PROVENANCE.md @@ -33,3 +33,16 @@ No third-party source code is included. Therefore no third-party notices are emb ## Known licence gate ASTRA-dev/ASTRA/GEODISC/BIODISC lack explicit licences at inspected refs. Any future kernel extraction from those repositories requires a signed/committed compatible licence or separate written permission. Until then: architecture-only references or clean-room reimplementation only. + +## Phase 1 — permission manifest mechanism + +Phase 1 operationalises the gate: [`permissions.json`](permissions.json) is the +machine-readable owner-permission manifest consumed by `sapiens.permissions` +and enforced by `sapiens.registry`. Any adapter declaring +`code_origin="third-party"` is UNTRUSTED-tier: it cannot validate without a +matching active permission entry (`adapter:` scope for its declared +`third_party_source`), and it executes only inside the resource-limited +subprocess (`sapiens.isolation`). **The shipped manifest is empty** — zero +ASTRA-family permissions — so the clean-room boundary above is enforced by +code, not by convention. Entries may be added only with explicit owner +sign-off (recorded grantor, licence, evidence reference, validity window). diff --git a/README.md b/README.md index ae08089..cb73b00 100644 --- a/README.md +++ b/README.md @@ -8,8 +8,8 @@ SAPIENS is an **experimental platform for traceable cross-domain scientific-discovery workflows** — a `DomainAdapter` boundary, a hash-chained -L0→L4 evidence ledger, and kernel-gated promotions. **Phase 0: synthetic -adapters only, no discoveries claimed.** +L0→L4 evidence ledger, and kernel-gated promotions. **Phases 0–1 shipped: +synthetic adapters only in practice, no discoveries claimed.** It provides the plumbing a discovery system needs before it can be trusted: a domain-neutral adapter boundary, an append-only hash-chained evidence @@ -20,11 +20,13 @@ budgets. > **Read this first — despite the acronym:** SAPIENS is **not** AGI, ASI, or > superintelligence, and does not claim to be. It is an experimental research -> platform. **No scientific discoveries are claimed.** Phase 0 ships with -> deterministic **synthetic adapters only**; the included examples discover +> platform. **No scientific discoveries are claimed.** The shipped adapters +> remain deterministic and **synthetic only**; the included examples discover > nothing about nature. The CLI reports `"scientific_discoveries_claimed": 0` > by construction, and the test suite enforces the honesty and boundary -> invariants described below. +> invariants described below. Phase 1 added the *machinery* for real-domain +> work (trust tiers, isolation, permissions) — but no real-data adapter +> ships yet. ## Why @@ -39,7 +41,11 @@ inherits traceability and bounded confidence instead of retrofitting them. ```text src/sapiens/ models.py immutable candidate / evidence / manifest models - adapter.py DomainAdapter protocol + Phase-0 adapter validation + adapter.py DomainAdapter protocol; validation routes to the registry + registry.py trust-tiered adapter registry (SYNTHETIC / CORE / UNTRUSTED) + permissions.py owner-permission/licence manifest for third-party code + isolation.py subprocess + rlimit execution for UNTRUSTED adapters + checkpoints.py HMAC-signed ledger checkpoints + external anchor export ledger.py JSONL hash-chained evidence ledger, L0→L4 transition guard kernel.py domain-neutral DiscoveryKernel; owns all promotions bridge.py cross-domain structure transfer — ALWAYS resets target to L0 @@ -68,7 +74,7 @@ Key design rules (enforced by tests in [`tests/`](tests/)): - **L2 Replication** — passed held-out / reproducibility checks. - **L3 Review** — passed bounded structured review / adversarial checks. - **L4 External-ready** — requires an explicit **human gate**; autonomous - promotion to L4 is disabled in Phase 0. + promotion to L4 is disabled. - **Kernel-owned promotions** — adapters propose, only the `DiscoveryKernel` promotes, and only through the ledger's transition guard. - **Cross-domain bridge resets to L0** — transfer moves *structure and @@ -156,33 +162,43 @@ astrophysical result**. ## Status & roadmap -**Phase 0 — shipped** (current package version `0.1.0`): clean-room foundation, +**Phase 0 — shipped** (package version `0.1.0`): clean-room foundation, three deterministic synthetic adapters, synthetic-only orchestration, hash-chained ledger, kernel gates, bridge, bounded queue/daemon, and CI on -Python 3.10/3.11/3.12. The test suite currently includes positive and negative -period-detection fixtures, L0 reset/provenance checks, ledger tamper checks, -promotion guards, and bounded queue/daemon behavior. +Python 3.10/3.11/3.12. + +**Phase 1 — shipped** (current package version `0.2.0`): the synthetic-only +gate is replaced by a **trust-tiered adapter registry** (SYNTHETIC / CORE / +UNTRUSTED), an **owner-permission/licence manifest** +([`permissions.json`](permissions.json) — empty by default: no third-party +code may power an adapter without a recorded entry), **subprocess isolation +with OS-level resource limits** for UNTRUSTED adapters (rlimit CPU / +address-space / open-files plus wall-clock timeout, fail-closed), and +**HMAC-signed ledger checkpoints** with external anchor export +(key from the environment only, never stored). No real-data adapter ships in +Phase 1; tiers are exercised by synthetic adapters and test doubles. Next, in order (see [`ROADMAP.md`](ROADMAP.md)): -1. **Phase 1** — legal/licence gate and adapter hardening (trust-tiered - adapter registry, sandboxing, signed ledger checkpoints). +1. ~~**Phase 1** — legal/licence gate and adapter hardening~~ **shipped**. 2. **Phase 2** — validation framework v1 (statistical gates, holdout protocols, leakage controls, seeded-bias fixtures, calibration). 3. **Phase 3** — structured L3 review panels (role-specialized reviewers, multi-round objection tracking, catch-rate scoring). -4. **Phase 4** — **real domain adapters** (ASTRA / GEODISC / BIODISC / SLATE) - — only after licence and owner review. +4. **Phase 4** — **real domain adapters** — first a clean-room Kepler + photometry adapter on public NASA/MAST data; ASTRA / GEODISC / BIODISC / + SLATE adapters only after licence and owner review. 5. **Phase 5** — external-review workflows with human L4 gates and reproduction bundles. ## Provenance & legal boundary -SAPIENS Phase 0 is a **clean-room implementation**: no source code from +SAPIENS is a **clean-room implementation**: no source code from ASTRA-dev, ASTRA, GEODISC, BIODISC, or SLATE was copied into this repository -(those codebases carry unresolved licensing; reuse is explicitly gated to -Phase 1+ with owner permission). See [`PROVENANCE.md`](PROVENANCE.md) for the -documented boundary. +(those codebases carry unresolved licensing; reuse is gated on recorded owner +permission — the Phase-1 permission manifest ships empty, so every +third-party adapter is refused today). See [`PROVENANCE.md`](PROVENANCE.md) +for the documented boundary. ## Credits diff --git a/ROADMAP.md b/ROADMAP.md index 47c5d8a..387e546 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -7,12 +7,28 @@ - Synthetic integrated orchestration. - Tests and Python matrix CI. -## Phase 1 — legal/licence gate and adapter hardening - -- Obtain explicit licences/permissions for any ASTRA-family code reuse before extraction. -- Replace synthetic-only adapter gate with a trust-tiered adapter registry. -- Add subprocess isolation and OS-level resource limits for untrusted adapters. -- Add signed ledger checkpoints or external anchoring. +## Phase 1 — shipped (package version 0.2.0) + +Legal/licence gate and adapter hardening: + +- ~~Obtain explicit licences/permissions for any ASTRA-family code reuse + before extraction.~~ Mechanism shipped: `permissions.json` + + `sapiens.permissions` record owner grants; the manifest is **empty** — no + ASTRA-family (or any third-party) permissions exist, so every third-party + adapter is refused until an owner records one. +- ~~Replace synthetic-only adapter gate with a trust-tiered adapter + registry.~~ Shipped: `sapiens.registry` (SYNTHETIC / CORE / UNTRUSTED). +- ~~Add subprocess isolation and OS-level resource limits for untrusted + adapters.~~ Shipped: `sapiens.isolation` — rlimit CPU / address-space / + open-files plus wall-clock timeout, fail-closed; the kernel runs + UNTRUSTED-tier adapters only through it. +- ~~Add signed ledger checkpoints or external anchoring.~~ Shipped: + `sapiens.checkpoints` — HMAC-SHA256 checkpoint events (key from + environment only) plus external anchor export/verify. + +Honest limits: rlimits bound resource use but are not a security sandbox; +HMAC proves local key possession, not third-party authorship. No real-data +adapter ships in Phase 1. ## Phase 2 — validation framework v1 @@ -28,6 +44,8 @@ ## Phase 4 — real domain adapters +- First: one clean-room Kepler photometry adapter on public NASA/MAST data + (reuses this repository's own Apache-2.0 demo pipeline path). - ASTRA/GEODISC/BIODISC/SLATE adapters only after licence and owner review. - Domain-specific validators remain sandboxed behind adapters. - Cross-domain method transfer enters target domain at L0 every time. diff --git a/permissions.json b/permissions.json new file mode 100644 index 0000000..823128d --- /dev/null +++ b/permissions.json @@ -0,0 +1,5 @@ +{ + "version": 1, + "comment": "Owner permissions for third-party code reuse (Phase 1). EMPTY BY DEFAULT: no ASTRA-family (or any other third-party) permissions are recorded, so every third-party adapter is refused. Add entries only with explicit owner sign-off; see PROVENANCE.md.", + "entries": [] +} diff --git a/pyproject.toml b/pyproject.toml index 56feaa4..eec17b9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "sapiens-discovery" -version = "0.1.0" +version = "0.2.0" description = "Experimental foundation for traceable cross-domain scientific-discovery workflows" readme = "README.md" requires-python = ">=3.10" diff --git a/src/sapiens/__init__.py b/src/sapiens/__init__.py index 0d6a2fc..eff7afe 100644 --- a/src/sapiens/__init__.py +++ b/src/sapiens/__init__.py @@ -5,16 +5,22 @@ from .kernel import DiscoveryKernel from .ledger import EvidenceLedger from .models import AdapterManifest, Candidate, Evidence, EvidenceLevel +from .permissions import PermissionEntry, PermissionManifest +from .registry import AdapterRegistry, TrustTier __all__ = [ "AdapterManifest", + "AdapterRegistry", "Candidate", "DiscoveryKernel", "DomainAdapter", "Evidence", "EvidenceLedger", "EvidenceLevel", + "PermissionEntry", + "PermissionManifest", "TransferEnvelope", + "TrustTier", "transfer", ] -__version__ = "0.1.0" +__version__ = "0.2.0" diff --git a/src/sapiens/adapter.py b/src/sapiens/adapter.py index 9829e4b..6627cb6 100644 --- a/src/sapiens/adapter.py +++ b/src/sapiens/adapter.py @@ -25,8 +25,15 @@ def import_structure(self, structure: dict[str, object], *, candidate_id: str) - def validate_adapter(adapter: DomainAdapter) -> None: - if not isinstance(adapter, DomainAdapter): - raise TypeError("adapter does not implement DomainAdapter") - manifest = adapter.manifest - if not manifest.synthetic_only: - raise ValueError("Phase 0 refuses non-synthetic adapters") + """Phase-1 gate: auto-tiered registry validation. + + Kept for backward compatibility with Phase-0 callers. Synthetic adapters + pass exactly as before; first-party clean-room real-data adapters pass at + CORE tier; third-party adapters require a recorded permission and raise + ``MissingPermissionError`` without one. Callers that need the tier (e.g. + the kernel, to decide on isolation) should use ``AdapterRegistry`` + directly. + """ + from .registry import AdapterRegistry + + AdapterRegistry().validate_adapter(adapter) diff --git a/src/sapiens/checkpoints.py b/src/sapiens/checkpoints.py new file mode 100644 index 0000000..d944b38 --- /dev/null +++ b/src/sapiens/checkpoints.py @@ -0,0 +1,163 @@ +"""Signed ledger checkpoints and external anchor export (Phase 1). + +A checkpoint is a ledger event that summarises the chain so far: the event +count and the current head hash. With an HMAC key (environment-only, never +stored) the checkpoint is *signed*; without one it is still a structural +marker the verifier checks for continuity. + +Honest scope, matching the ledger's own warnings: + +- HMAC is symmetric. A signed checkpoint proves the writer held the local + key; it is **not** a public signature and proves nothing to a third party + who does not hold the key. +- ``export_anchor`` writes the head hash to a separate file so it can be + published or archived elsewhere. Comparing an anchor against a ledger + detects whole-file rewrites by anyone who could not also update the + anchor. Anchoring to a truly external system (timestamping service, + public chain) is a Phase-5 workflow; this module provides the hook. + +The key is read from the environment (``SAPIENS_CHECKPOINT_KEY``) at call +time and is never written to the ledger, the anchor file, logs, or any +artifact. +""" + +from __future__ import annotations + +import hashlib +import hmac +import json +import os +from collections.abc import Mapping +from dataclasses import dataclass +from pathlib import Path +from typing import Any + +from .ledger import GENESIS, EvidenceLedger, LedgerEvent + +CHECKPOINT_KEY_ENV = "SAPIENS_CHECKPOINT_KEY" +SIGNATURE_SCHEME = "hmac-sha256" + + +def _canonical(value: Any) -> bytes: + return json.dumps( + value, sort_keys=True, separators=(",", ":"), allow_nan=False, ensure_ascii=False + ).encode("utf-8") + + +def key_from_env(env: Mapping[str, str] | None = None) -> bytes | None: + """Read the HMAC key from the environment. Never log or persist the result.""" + source = env if env is not None else os.environ + raw = source.get(CHECKPOINT_KEY_ENV) + if not raw: + return None + return raw.encode("utf-8") + + +def signature_payload(*, event_count: int, head_hash: str) -> bytes: + return _canonical({"event_count": event_count, "head_hash": head_hash}) + + +def sign(*, event_count: int, head_hash: str, key: bytes) -> str: + if not key: + raise ValueError("signing key must be non-empty") + return hmac.new( + key, signature_payload(event_count=event_count, head_hash=head_hash), hashlib.sha256 + ).hexdigest() + + +@dataclass(frozen=True) +class CheckpointVerification: + """Outcome of checkpoint signature verification. Data, not vibes.""" + + checkpoints: int + signed: int + signatures_verified: int + signatures_unverifiable: tuple[int, ...] # seq numbers: signed, but no key available + signature_failures: tuple[int, ...] # seq numbers: signature does not match + + +def verify_checkpoints( + events: tuple[LedgerEvent, ...], *, key: bytes | None +) -> CheckpointVerification: + """Check every checkpoint event's signature (when verifiable). + + Structural continuity (event count + head hash) is already enforced by + ``EvidenceLedger.verify``; this layer handles only signatures. + """ + checkpoints = signed = verified = 0 + unverifiable: list[int] = [] + failures: list[int] = [] + for event in events: + if event.kind != "checkpoint": + continue + checkpoints += 1 + signature = event.payload.get("signature") + if not signature: + continue + signed += 1 + if key is None: + unverifiable.append(event.seq) + continue + expected = sign( + event_count=int(event.payload["event_count"]), + head_hash=str(event.payload["head_hash"]), + key=key, + ) + if hmac.compare_digest(expected, str(signature)): + verified += 1 + else: + failures.append(event.seq) + return CheckpointVerification( + checkpoints=checkpoints, + signed=signed, + signatures_verified=verified, + signatures_unverifiable=tuple(unverifiable), + signature_failures=tuple(failures), + ) + + +def record_checkpoint(ledger: EvidenceLedger, *, key: bytes | None = None) -> LedgerEvent: + """Append a checkpoint over the current chain. Key from env when omitted.""" + if key is None: + key = key_from_env() + events = ledger.events() + head = events[-1].event_hash if events else GENESIS + payload: dict[str, Any] = { + "event_count": len(events), + "head_hash": head, + "signed": key is not None, + "signature": None, + "scheme": SIGNATURE_SCHEME if key is not None else None, + } + if key is not None: + payload["signature"] = sign(event_count=len(events), head_hash=head, key=key) + return ledger.append("checkpoint", "__ledger__", payload) + + +def export_anchor(ledger: EvidenceLedger, path: str | Path) -> dict[str, Any]: + """Write the current head hash to a separate anchor file (JSON).""" + events = ledger.events() + head = events[-1].event_hash if events else GENESIS + anchor = { + "kind": "sapiens-ledger-anchor", + "version": 1, + "event_count": len(events), + "head_hash": head, + } + target = Path(path) + target.parent.mkdir(parents=True, exist_ok=True) + target.write_text(json.dumps(anchor, indent=2, sort_keys=True) + "\n", encoding="utf-8") + return anchor + + +def verify_anchor(ledger: EvidenceLedger, path: str | Path) -> bool: + """True iff the anchor file matches the ledger's current head.""" + try: + anchor = json.loads(Path(path).read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError) as exc: + raise ValueError(f"unreadable anchor file: {exc}") from exc + if anchor.get("kind") != "sapiens-ledger-anchor" or anchor.get("version") != 1: + raise ValueError("not a sapiens ledger anchor file") + events = ledger.events() + head = events[-1].event_hash if events else GENESIS + return anchor.get("head_hash") == head and anchor.get("event_count") == len(events) diff --git a/src/sapiens/isolation.py b/src/sapiens/isolation.py new file mode 100644 index 0000000..520cf6f --- /dev/null +++ b/src/sapiens/isolation.py @@ -0,0 +1,229 @@ +"""Subprocess isolation with OS-level resource limits for untrusted adapters (Phase 1). + +UNTRUSTED-tier adapters never execute in the kernel's process. Instead the +kernel serialises the work order (adapter location, candidate, stage, seed, +budget) to a child Python process that: + +1. applies POSIX rlimits to *itself* before importing any adapter code + (CPU seconds, address space, open files — thread-safe, unlike preexec_fn), +2. loads the adapter class from its source file, constructs it with no + arguments, and runs ``validate`` under a fresh ``ExecutionContext``, +3. prints exactly one JSON result line on stdout (adapter stdout is + redirected to stderr so a noisy adapter cannot corrupt the protocol). + +The parent enforces a wall-clock timeout and maps every failure — crash, +limit kill, timeout, malformed output — to :class:`IsolationError`. Isolation +is fail-closed: a contained failure produces no evidence. + +Honest limits: rlimits bound resource use; they are not a security sandbox. +A malicious adapter could still attempt network or filesystem mischief within +those bounds — which is why third-party code additionally requires a recorded +owner permission before it may run at all. +""" + +from __future__ import annotations + +import importlib.util +import inspect +import json +import subprocess +import sys +from dataclasses import dataclass +from pathlib import Path +from typing import Any + +from .budget import ExecutionContext +from .models import Candidate, Evidence + +PROTOCOL_VERSION = 1 + + +class IsolationError(RuntimeError): + """An isolated run failed or its result could not be trusted.""" + + +@dataclass(frozen=True) +class ResourceLimits: + cpu_seconds: int = 30 + address_space_bytes: int = 1 << 30 # 1 GiB + max_open_files: int = 64 + + def __post_init__(self) -> None: + if self.cpu_seconds <= 0 or self.address_space_bytes <= 0 or self.max_open_files <= 0: + raise ValueError("resource limits must be positive") + + +DEFAULT_LIMITS = ResourceLimits() + + +def _apply_limits(limits: ResourceLimits) -> None: + import resource + + resource.setrlimit(resource.RLIMIT_CPU, (limits.cpu_seconds, limits.cpu_seconds)) + resource.setrlimit( + resource.RLIMIT_AS, (limits.address_space_bytes, limits.address_space_bytes) + ) + resource.setrlimit(resource.RLIMIT_NOFILE, (limits.max_open_files, limits.max_open_files)) + + +def _load_adapter(path: str, qualname: str) -> Any: + if "" in qualname: + raise IsolationError("isolated adapters must be module-level classes") + spec = importlib.util.spec_from_file_location("sapiens_isolated_adapter", path) + if spec is None or spec.loader is None: + raise IsolationError(f"cannot load adapter module from {path!r}") + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + obj: Any = module + for part in qualname.split("."): + obj = getattr(obj, part, None) + if obj is None: + raise IsolationError(f"adapter class {qualname!r} not found in {path!r}") + return obj() + + +def _serialize_evidence(item: Evidence) -> dict[str, Any]: + return { + "evidence_id": item.evidence_id, + "candidate_id": item.candidate_id, + "kind": item.kind, + "passed": item.passed, + "protocol": item.protocol, + "dataset": item.dataset, + "seed": item.seed, + "score": item.score, + "details": dict(item.details), + } + + +def _child_main() -> None: + """Entry point: ``python -m sapiens.isolation`` (reads the work order on stdin).""" + try: + spec = json.loads(sys.stdin.read()) + if spec.get("protocol") != PROTOCOL_VERSION: + raise IsolationError("isolation protocol version mismatch") + limits = ResourceLimits(**spec["limits"]) + _apply_limits(limits) + adapter = _load_adapter(spec["adapter_path"], spec["adapter_qualname"]) + raw = spec["candidate"] + candidate = Candidate( + raw["candidate_id"], + raw["domain"], + raw["claim"], + raw.get("parameters") or {}, + raw.get("parent_id"), + raw.get("source_adapter", ""), + ) + budget = spec["budget"] + context = ExecutionContext( + max_steps=int(budget["max_steps"]), max_seconds=float(budget["max_seconds"]) + ) + import contextlib + + with contextlib.redirect_stdout(sys.stderr): + evidence = adapter.validate( + candidate, stage=spec["stage"], seed=int(spec["seed"]), context=context + ) + payload = [_serialize_evidence(item) for item in evidence] + # Fail loudly inside the child if anything is not canonical-JSON safe. + result = json.dumps( + {"ok": True, "evidence": payload}, allow_nan=False, ensure_ascii=False + ) + except BaseException as exc: # contained: report, never traceback-spam the parent + result = json.dumps({"ok": False, "error": f"{type(exc).__name__}: {exc}"}) + sys.stdout.write(result + "\n") + sys.stdout.flush() + + +def adapter_location(adapter: object) -> tuple[str, str]: + """(source file, qualname) for an adapter instance; raises if not locatable.""" + cls = type(adapter) + path = inspect.getsourcefile(cls) + if path is None or not Path(path).is_file(): + raise IsolationError(f"cannot locate source file for adapter class {cls.__name__!r}") + qualname = cls.__qualname__ + if "" in qualname: + raise IsolationError("isolated adapters must be defined at module level") + return path, qualname + + +def run_validate_isolated( + adapter: object, + candidate: Candidate, + *, + stage: str, + seed: int, + context: ExecutionContext, + limits: ResourceLimits = DEFAULT_LIMITS, + timeout_seconds: float = 60.0, +) -> tuple[Evidence, ...]: + """Run ``adapter.validate`` in a resource-limited subprocess. Fail-closed.""" + if sys.platform == "win32": + raise IsolationError("subprocess isolation requires POSIX rlimits") + if timeout_seconds <= 0: + raise ValueError("timeout must be positive") + path, qualname = adapter_location(adapter) + spec = { + "protocol": PROTOCOL_VERSION, + "adapter_path": path, + "adapter_qualname": qualname, + "candidate": { + "candidate_id": candidate.candidate_id, + "domain": candidate.domain, + "claim": candidate.claim, + "parameters": dict(candidate.parameters), + "parent_id": candidate.parent_id, + "source_adapter": candidate.source_adapter, + }, + "stage": stage, + "seed": seed, + "budget": {"max_steps": context.max_steps, "max_seconds": context.max_seconds}, + "limits": { + "cpu_seconds": limits.cpu_seconds, + "address_space_bytes": limits.address_space_bytes, + "max_open_files": limits.max_open_files, + }, + } + try: + order = json.dumps(spec, allow_nan=False) + except (TypeError, ValueError) as exc: + raise IsolationError(f"work order is not JSON-serialisable: {exc}") from exc + try: + proc = subprocess.run( + [sys.executable, "-m", "sapiens.isolation"], + input=order, + capture_output=True, + text=True, + timeout=timeout_seconds, + check=False, + ) + except subprocess.TimeoutExpired as exc: + raise IsolationError( + f"isolated adapter exceeded the {timeout_seconds}s wall-clock timeout" + ) from exc + if proc.returncode != 0: + tail = (proc.stderr or "").strip().splitlines() + detail = tail[-1] if tail else "no stderr" + raise IsolationError( + f"isolated adapter died (exit {proc.returncode}; killed by rlimit or crash): {detail}" + ) + lines = [line for line in proc.stdout.splitlines() if line.strip()] + if len(lines) != 1: + raise IsolationError("isolated adapter produced malformed stdout") + try: + result = json.loads(lines[0]) + except json.JSONDecodeError as exc: + raise IsolationError("isolated adapter produced invalid JSON") from exc + if not result.get("ok"): + raise IsolationError(f"isolated adapter failed: {result.get('error', 'unknown')}") + evidence: list[Evidence] = [] + for raw in result["evidence"]: + try: + evidence.append(Evidence(**raw)) + except (TypeError, ValueError) as exc: + raise IsolationError(f"isolated adapter returned invalid evidence: {exc}") from exc + return tuple(evidence) + + +if __name__ == "__main__": + _child_main() diff --git a/src/sapiens/kernel.py b/src/sapiens/kernel.py index 370489a..fad11cf 100644 --- a/src/sapiens/kernel.py +++ b/src/sapiens/kernel.py @@ -2,10 +2,12 @@ from __future__ import annotations -from .adapter import DomainAdapter, validate_adapter +from .adapter import DomainAdapter from .budget import ExecutionContext +from .isolation import run_validate_isolated from .ledger import EvidenceLedger from .models import Candidate, EvidenceLevel +from .registry import AdapterRegistry, TrustTier _STAGE_BY_LEVEL = { EvidenceLevel.L1: "internal", @@ -15,8 +17,9 @@ class DiscoveryKernel: - def __init__(self, ledger: EvidenceLedger) -> None: + def __init__(self, ledger: EvidenceLedger, registry: AdapterRegistry | None = None) -> None: self.ledger = ledger + self.registry = registry if registry is not None else AdapterRegistry() def register(self, candidate: Candidate, *, transferred_from: str | None = None) -> None: self.ledger.record_candidate(candidate.candidate_id, transferred_from=transferred_from) @@ -29,15 +32,22 @@ def validate_next( seed: int, context: ExecutionContext, ) -> EvidenceLevel: - validate_adapter(adapter) + tier = self.registry.validate_adapter(adapter) if candidate.domain != adapter.manifest.domain: raise ValueError("candidate domain does not match adapter") current = self.ledger.state(candidate.candidate_id).level if current >= EvidenceLevel.L3: - raise ValueError("Phase 0 automated kernel cannot promote beyond L3") + raise ValueError("automated kernel cannot promote beyond L3; L4 stays human-gated") target = EvidenceLevel(current + 1) stage = _STAGE_BY_LEVEL[target] - evidence = adapter.validate(candidate, stage=stage, seed=seed, context=context) + if tier == TrustTier.UNTRUSTED: + # Third-party code never runs in this process. A contained + # isolation failure yields no evidence and no promotion. + evidence = run_validate_isolated( + adapter, candidate, stage=stage, seed=seed, context=context + ) + else: + evidence = adapter.validate(candidate, stage=stage, seed=seed, context=context) refs: list[str] = [] for item in evidence: if item.candidate_id != candidate.candidate_id or item.kind != stage: diff --git a/src/sapiens/ledger.py b/src/sapiens/ledger.py index 58c6ec2..a1ccca6 100644 --- a/src/sapiens/ledger.py +++ b/src/sapiens/ledger.py @@ -2,7 +2,9 @@ Hash chaining detects accidental or after-the-fact modification; it does not prove identity, authorship, scientific validity, or resistance to an actor who can rewrite -an entire file. Signatures and external anchoring are roadmap items. +an entire file. Phase 1 adds ``checkpoint`` events (see ``sapiens.checkpoints``): +HMAC-signed or unsigned markers that summarise the chain, plus external anchor +export. Signatures are symmetric and prove key possession, not authorship. """ from __future__ import annotations @@ -19,7 +21,8 @@ from .models import Evidence, EvidenceLevel GENESIS = "0" * 64 -_ALLOWED_KINDS = {"candidate", "evidence", "promotion", "demotion", "transfer"} +_ALLOWED_KINDS = {"candidate", "evidence", "promotion", "demotion", "transfer", "checkpoint"} +CHECKPOINT_ACTOR = "__ledger__" def _canonical(value: Any) -> bytes: @@ -105,6 +108,15 @@ def _verify(events: Iterable[LedgerEvent]) -> None: bool(event.payload.get("passed")), str(event.payload.get("kind", "")), ) + elif event.kind == "checkpoint": + if event.candidate_id != CHECKPOINT_ACTOR: + raise LedgerIntegrityError("checkpoint must be recorded by the ledger actor") + if int(event.payload.get("event_count", -1)) != event.seq - 1: + raise LedgerIntegrityError("checkpoint event count does not match the chain") + if event.payload.get("head_hash") != previous: + raise LedgerIntegrityError("checkpoint head hash does not match the chain") + if bool(event.payload.get("signed")) != bool(event.payload.get("signature")): + raise LedgerIntegrityError("checkpoint signed flag and signature disagree") else: if current is None: raise LedgerIntegrityError("transition references unknown candidate") @@ -155,8 +167,9 @@ def state(self, candidate_id: str) -> CandidateState: level = EvidenceLevel.L0 elif event.kind == "evidence": evidence_ids.add(str(event.payload["evidence_id"])) - else: + elif event.kind in {"promotion", "demotion"}: level = EvidenceLevel(int(event.payload["to_level"])) + # checkpoint events carry no per-candidate state if level is None: raise KeyError(candidate_id) return CandidateState(level, frozenset(evidence_ids)) diff --git a/src/sapiens/models.py b/src/sapiens/models.py index 333ccc5..8d953e2 100644 --- a/src/sapiens/models.py +++ b/src/sapiens/models.py @@ -58,19 +58,39 @@ def __post_init__(self) -> None: object.__setattr__(self, "details", frozen_mapping(self.details)) +CODE_ORIGINS = ("first-party-clean-room", "third-party") + + @dataclass(frozen=True) class AdapterManifest: + """What an adapter is made of. Trust *decisions* live in the registry (Phase 1). + + ``synthetic_only`` describes the data: True means the adapter touches only + deterministic synthetic data. ``code_origin`` describes the code: + first-party-clean-room or third-party. Third-party code additionally + requires ``third_party_source`` so the permission manifest can be checked. + """ + name: str version: str domain: str vocabulary: tuple[str, ...] synthetic_only: bool = True + code_origin: str = "first-party-clean-room" + data_sources: tuple[str, ...] = () + third_party_source: str | None = None def __post_init__(self) -> None: if not self.name or not self.version or not self.domain: raise ValueError("adapter manifest fields must be non-empty") - if not self.synthetic_only: - raise ValueError("Phase 0 accepts synthetic-only adapters") + if self.code_origin not in CODE_ORIGINS: + raise ValueError(f"code_origin must be one of {CODE_ORIGINS}") + if self.synthetic_only and self.data_sources: + raise ValueError("synthetic-only adapters must not declare real data sources") + if self.code_origin == "third-party" and not self.third_party_source: + raise ValueError("third-party adapters must declare third_party_source") + if self.code_origin == "first-party-clean-room" and self.third_party_source: + raise ValueError("clean-room adapters must not declare a third_party_source") @dataclass(frozen=True) diff --git a/src/sapiens/permissions.py b/src/sapiens/permissions.py new file mode 100644 index 0000000..50746eb --- /dev/null +++ b/src/sapiens/permissions.py @@ -0,0 +1,144 @@ +"""Owner-permission/licence manifest for third-party code reuse (Phase 1). + +No third-party code may power an adapter without a recorded permission entry. +The repository ships with an **empty** manifest: zero ASTRA-family permissions +exist, and the clean-room invariant holds until an owner records otherwise. + +The manifest is data, not code: a JSON document listing each grant with its +source, scope, licence, grantor, evidence reference, and validity window. +Entries are immutable once loaded; expiry is checked against an explicit date +(injected, never a hidden clock) so verification is deterministic. +""" + +from __future__ import annotations + +import json +from dataclasses import dataclass +from datetime import date +from pathlib import Path +from typing import Any + +SCHEMA_VERSION = 1 + + +class MissingPermissionError(PermissionError): + """Raised when third-party code is used without a recorded permission.""" + + +class ManifestFormatError(ValueError): + """Raised when a permission manifest is malformed. Fail loudly, never guess.""" + + +def _parse_date(value: Any, *, field: str) -> date | None: + if value is None: + return None + if not isinstance(value, str): + raise ManifestFormatError(f"{field} must be an ISO date string or null") + try: + return date.fromisoformat(value) + except ValueError as exc: + raise ManifestFormatError(f"{field} is not a valid ISO date: {value!r}") from exc + + +@dataclass(frozen=True) +class PermissionEntry: + """One recorded owner grant for third-party material.""" + + source: str # upstream project, e.g. "ASTRA-dev" + scope: str # what the grant covers, e.g. "adapter:astra-photometry" or "module:swarm/*" + licence: str # licence or permission basis, e.g. "MIT" or "written-permission" + granted_by: str # identity of the owner who granted it + reference: str # evidence: URL, document id, or message reference + granted_on: date + expires_on: date | None = None + + def __post_init__(self) -> None: + for name in ("source", "scope", "licence", "granted_by", "reference"): + if not getattr(self, name): + raise ManifestFormatError(f"permission entry field {name} must be non-empty") + if self.expires_on is not None and self.expires_on < self.granted_on: + raise ManifestFormatError("permission expires before it was granted") + + @classmethod + def from_dict(cls, raw: Any) -> PermissionEntry: + if not isinstance(raw, dict): + raise ManifestFormatError("permission entries must be objects") + known = { + "source", "scope", "licence", "granted_by", "reference", "granted_on", "expires_on", + } + unknown = set(raw) - known + if unknown: + raise ManifestFormatError(f"unknown permission entry fields: {sorted(unknown)}") + granted_on = _parse_date(raw.get("granted_on"), field="granted_on") + if granted_on is None: + raise ManifestFormatError("granted_on is required") + return cls( + source=str(raw.get("source", "")), + scope=str(raw.get("scope", "")), + licence=str(raw.get("licence", "")), + granted_by=str(raw.get("granted_by", "")), + reference=str(raw.get("reference", "")), + granted_on=granted_on, + expires_on=_parse_date(raw.get("expires_on"), field="expires_on"), + ) + + def active(self, *, on: date) -> bool: + return self.granted_on <= on and (self.expires_on is None or on <= self.expires_on) + + def covers(self, *, source: str, scope: str) -> bool: + if self.source != source: + return False + if self.scope == scope: + return True + # Prefix wildcard: "module:swarm/*" covers "module:swarm/pheromone_dynamics". + return self.scope.endswith("/*") and scope.startswith(self.scope[:-1]) + + +@dataclass(frozen=True) +class PermissionManifest: + """Immutable set of recorded owner permissions.""" + + entries: tuple[PermissionEntry, ...] = () + + @classmethod + def empty(cls) -> PermissionManifest: + return cls(()) + + @classmethod + def load(cls, path: str | Path) -> PermissionManifest: + try: + raw = json.loads(Path(path).read_text(encoding="utf-8")) + except json.JSONDecodeError as exc: + raise ManifestFormatError(f"permission manifest is not valid JSON: {exc}") from exc + if not isinstance(raw, dict): + raise ManifestFormatError("permission manifest must be a JSON object") + version = raw.get("version") + if version != SCHEMA_VERSION: + raise ManifestFormatError(f"unsupported manifest version: {version!r}") + entries_raw = raw.get("entries") + if not isinstance(entries_raw, list): + raise ManifestFormatError("manifest entries must be a list") + entries = tuple(PermissionEntry.from_dict(item) for item in entries_raw) + sources = [(e.source, e.scope) for e in entries] + if len(set(sources)) != len(sources): + raise ManifestFormatError("duplicate (source, scope) permission entries") + return cls(entries) + + def permits(self, *, source: str, scope: str, on: date) -> bool: + return any( + entry.covers(source=source, scope=scope) and entry.active(on=on) + for entry in self.entries + ) + + def require(self, *, source: str, scope: str, on: date) -> PermissionEntry: + for entry in self.entries: + if entry.covers(source=source, scope=scope): + if entry.active(on=on): + return entry + raise MissingPermissionError( + f"permission for {source!r} scope {scope!r} is not active on {on}" + ) + raise MissingPermissionError( + f"no recorded owner permission for {source!r} scope {scope!r}; " + "third-party code requires an explicit permission entry" + ) diff --git a/src/sapiens/registry.py b/src/sapiens/registry.py new file mode 100644 index 0000000..840a4fe --- /dev/null +++ b/src/sapiens/registry.py @@ -0,0 +1,98 @@ +"""Trust-tiered adapter registry (Phase 1). + +Replaces the Phase-0 synthetic-only gate. Three tiers: + +- ``SYNTHETIC``: deterministic synthetic data only. Runs in-process. +- ``CORE``: first-party clean-room code; real data allowed. Runs in-process. +- ``UNTRUSTED``: third-party code. Requires a recorded owner permission + (``PermissionManifest``) *and* subprocess isolation for every execution. + +An adapter's tier derives from its manifest, never from adapter-supplied +claims at call time: synthetic data + any first-party code ⇒ SYNTHETIC; +real data + first-party clean-room code ⇒ CORE; third-party code ⇒ UNTRUSTED. +Explicit registration pins the tier so a manifest edit cannot silently +downgrade a check that already happened. +""" + +from __future__ import annotations + +from datetime import date +from enum import IntEnum + +from .adapter import DomainAdapter +from .permissions import PermissionManifest + + +class TrustTier(IntEnum): + SYNTHETIC = 0 + CORE = 1 + UNTRUSTED = 2 + + +def derive_tier(adapter: DomainAdapter) -> TrustTier: + """Tier from manifest facts alone.""" + manifest = adapter.manifest + if manifest.code_origin == "third-party": + return TrustTier.UNTRUSTED + return TrustTier.SYNTHETIC if manifest.synthetic_only else TrustTier.CORE + + +class AdapterRegistry: + """Validates adapters against trust tiers and recorded permissions.""" + + def __init__( + self, + permissions: PermissionManifest | None = None, + *, + today: date | None = None, + ) -> None: + self._permissions = permissions if permissions is not None else PermissionManifest.empty() + self._today = today if today is not None else date.today() + self._tiers: dict[str, TrustTier] = {} + + @property + def permissions(self) -> PermissionManifest: + return self._permissions + + def _check(self, adapter: DomainAdapter, tier: TrustTier) -> None: + manifest = adapter.manifest + if tier == TrustTier.SYNTHETIC and not manifest.synthetic_only: + raise ValueError( + f"adapter {manifest.name!r} registered SYNTHETIC but declares real data sources" + ) + if tier == TrustTier.CORE and manifest.code_origin != "first-party-clean-room": + raise ValueError( + f"adapter {manifest.name!r} registered CORE but is not first-party clean-room code" + ) + if tier == TrustTier.UNTRUSTED: + self._permissions.require( + source=manifest.third_party_source or "", + scope=f"adapter:{manifest.name}", + on=self._today, + ) + + def register(self, adapter: DomainAdapter, tier: TrustTier) -> None: + """Pin an adapter to an explicit tier after validating manifest coherence.""" + if not isinstance(adapter, DomainAdapter): + raise TypeError("adapter does not implement DomainAdapter") + self._check(adapter, tier) + name = adapter.manifest.name + existing = self._tiers.get(name) + if existing is not None and existing != tier: + raise ValueError(f"adapter {name!r} already registered at tier {existing.name}") + self._tiers[name] = tier + + def tier_of(self, adapter: DomainAdapter) -> TrustTier: + registered = self._tiers.get(adapter.manifest.name) + return registered if registered is not None else derive_tier(adapter) + + def validate_adapter(self, adapter: DomainAdapter) -> TrustTier: + """Phase-1 gate: structural check + tier rules + permission enforcement.""" + if not isinstance(adapter, DomainAdapter): + raise TypeError("adapter does not implement DomainAdapter") + tier = self.tier_of(adapter) + self._check(adapter, tier) + return tier + + def requires_isolation(self, adapter: DomainAdapter) -> bool: + return self.validate_adapter(adapter) == TrustTier.UNTRUSTED diff --git a/tests/isolation_doubles.py b/tests/isolation_doubles.py new file mode 100644 index 0000000..4ca6632 --- /dev/null +++ b/tests/isolation_doubles.py @@ -0,0 +1,87 @@ +"""Module-level adapter doubles for isolation tests. + +Isolation loads adapters by source-file path in a child process, so these +must live at module level in an importable file (not inside test functions). +Not a pytest module (no test_ functions); ruff still applies. +""" + +from __future__ import annotations + +import time + +from sapiens.models import AdapterManifest, Candidate, Evidence + + +class WellBehavedAdapter: + manifest = AdapterManifest( + "well-behaved", + "1", + "iso-domain", + ("x",), + synthetic_only=False, + code_origin="third-party", + third_party_source="doubles", + data_sources=("https://example.org/public",), + ) + + def propose(self, *, seed: int, limit: int): + return () + + def validate(self, candidate, *, stage: str, seed: int, context): + context.checkpoint() + return ( + Evidence( + f"ev-{candidate.candidate_id}-{stage}", + candidate.candidate_id, + stage, + True, + "double-protocol", + f"double-{stage}", + seed, + 0.75, + {"isolated": True}, + ), + ) + + def import_structure(self, structure, *, candidate_id: str): + return Candidate(candidate_id, "iso-domain", "claim") + + +class NoisyAdapter(WellBehavedAdapter): + def validate(self, candidate, *, stage: str, seed: int, context): + print("NOISE THAT MUST NOT CORRUPT THE PROTOCOL") # noqa: T201 + return super().validate(candidate, stage=stage, seed=seed, context=context) + + +class CpuHogAdapter(WellBehavedAdapter): + def validate(self, candidate, *, stage: str, seed: int, context): + while True: + pass + + +class MemoryHogAdapter(WellBehavedAdapter): + def validate(self, candidate, *, stage: str, seed: int, context): + blob = bytearray(1 << 31) # 2 GiB, exceeds any test rlimit + return blob # unreachable under limits; keeps linters quiet about unused work + + +class SleeperAdapter(WellBehavedAdapter): + def validate(self, candidate, *, stage: str, seed: int, context): + time.sleep(30) + return () + + +class BadEvidenceAdapter(WellBehavedAdapter): + def validate(self, candidate, *, stage: str, seed: int, context): + return ( + Evidence( + "ev-bad", + candidate.candidate_id, + stage, + True, + "double-protocol", + "double-data", + seed, + 1.5, # out of range: model must reject on the parent side + ), + ) diff --git a/tests/test_boundaries.py b/tests/test_boundaries.py index 269318a..298f513 100644 --- a/tests/test_boundaries.py +++ b/tests/test_boundaries.py @@ -5,6 +5,8 @@ from sapiens.adapter import validate_adapter from sapiens.models import AdapterManifest, Candidate, Evidence +from sapiens.permissions import MissingPermissionError +from sapiens.registry import TrustTier ROOT = Path(__file__).resolve().parents[1] @@ -21,23 +23,69 @@ def test_core_does_not_import_adapters(): ) -def test_phase0_rejects_non_synthetic_adapter(): - class RealAdapter: - @property - def manifest(self): # type: ignore[override] - return AdapterManifest("real", "0", "unsafe", ("x",), synthetic_only=False) +class _RealDataAdapter: + @property + def manifest(self): # type: ignore[override] + return AdapterManifest( + "real", + "0", + "unsafe", + ("x",), + synthetic_only=False, + data_sources=("https://example.org/public-data",), + ) - def propose(self, *, seed: int, limit: int): - return () + def propose(self, *, seed: int, limit: int): + return () - def validate(self, candidate, *, stage: str, seed: int, context): - return () + def validate(self, candidate, *, stage: str, seed: int, context): + return () - def import_structure(self, structure, *, candidate_id: str): - return Candidate(candidate_id, "unsafe", "claim") + def import_structure(self, structure, *, candidate_id: str): + return Candidate(candidate_id, "unsafe", "claim") + +class _ThirdPartyAdapter(_RealDataAdapter): + @property + def manifest(self): # type: ignore[override] + return AdapterManifest( + "third-party-real", + "0", + "unsafe", + ("x",), + synthetic_only=False, + code_origin="third-party", + third_party_source="ASTRA-dev", + data_sources=("https://example.org/public-data",), + ) + + +def test_phase1_accepts_first_party_real_data_adapter_at_core_tier(): + # Phase 1 replaced the synthetic-only gate with trust tiers: first-party + # clean-room code on real data is CORE and runs in-process. + validate_adapter(_RealDataAdapter()) + + +def test_phase1_rejects_third_party_adapter_without_permission(): + with pytest.raises(MissingPermissionError): + validate_adapter(_ThirdPartyAdapter()) + + +def test_phase1_registry_reports_untrusted_tier_for_third_party(): + from sapiens.registry import AdapterRegistry + + registry = AdapterRegistry() + assert registry.tier_of(_ThirdPartyAdapter()) == TrustTier.UNTRUSTED + assert registry.tier_of(_RealDataAdapter()) == TrustTier.CORE + + +def test_manifest_rejects_incoherent_provenance(): + with pytest.raises(ValueError): + AdapterManifest("m", "1", "d", ("x",), code_origin="third-party") # no source + with pytest.raises(ValueError): + AdapterManifest("m", "1", "d", ("x",), code_origin="not-a-real-origin") with pytest.raises(ValueError): - validate_adapter(RealAdapter()) + AdapterManifest("m", "1", "d", ("x",), data_sources=("real-data",)) # synthetic lie def test_evidence_rejects_invalid_confidence_score(): diff --git a/tests/test_checkpoints.py b/tests/test_checkpoints.py new file mode 100644 index 0000000..b28211e --- /dev/null +++ b/tests/test_checkpoints.py @@ -0,0 +1,128 @@ +import json + +import pytest + +from sapiens.checkpoints import ( + export_anchor, + key_from_env, + record_checkpoint, + sign, + verify_anchor, + verify_checkpoints, +) +from sapiens.ledger import EvidenceLedger, LedgerIntegrityError +from sapiens.models import Evidence + +KEY = b"test-key-not-a-secret" +OTHER_KEY = b"different-key" + + +def filled_ledger(tmp_path): + ledger = EvidenceLedger(tmp_path / "evidence.jsonl") + ledger.record_candidate("cand-1") + ledger.record_evidence( + Evidence("ev-1", "cand-1", "internal", True, "proto", "data", 7, 0.9) + ) + ledger.promote("cand-1", 1, ("ev-1",)) + return ledger + + +def test_unsigned_checkpoint_roundtrip(tmp_path): + ledger = filled_ledger(tmp_path) + event = record_checkpoint(ledger, key=None) + assert event.kind == "checkpoint" + assert event.payload["event_count"] == 3 + assert event.payload["signed"] is False + assert event.payload["signature"] is None + assert ledger.verify() + report = verify_checkpoints(ledger.events(), key=None) + assert report.checkpoints == 1 and report.signed == 0 + # More events may follow a checkpoint; the chain stays valid. + ledger.record_candidate("cand-2") + assert ledger.verify() + + +def test_signed_checkpoint_verifies_with_key(tmp_path): + ledger = filled_ledger(tmp_path) + record_checkpoint(ledger, key=KEY) + assert ledger.verify() + events = ledger.events() + report = verify_checkpoints(events, key=KEY) + assert report.signed == 1 and report.signatures_verified == 1 + assert not report.signature_failures + + +def test_signed_checkpoint_reports_unverifiable_without_key(tmp_path): + ledger = filled_ledger(tmp_path) + record_checkpoint(ledger, key=KEY) + report = verify_checkpoints(ledger.events(), key=None) + assert report.signed == 1 + assert report.signatures_verified == 0 + assert report.signatures_unverifiable == (4,) + + +def test_wrong_key_detected(tmp_path): + ledger = filled_ledger(tmp_path) + record_checkpoint(ledger, key=KEY) + report = verify_checkpoints(ledger.events(), key=OTHER_KEY) + assert report.signature_failures == (4,) + + +def test_tampered_history_breaks_checkpoint_continuity(tmp_path): + ledger = filled_ledger(tmp_path) + record_checkpoint(ledger, key=KEY) + path = tmp_path / "evidence.jsonl" + lines = path.read_text().splitlines() + # Rewrite a historical payload without fixing the chain. + first = json.loads(lines[0]) + first["payload"]["level"] = 99 + lines[0] = json.dumps(first) + path.write_text("\n".join(lines) + "\n") + with pytest.raises(LedgerIntegrityError): + ledger.verify() + + +def test_forged_checkpoint_event_rejected(tmp_path): + ledger = filled_ledger(tmp_path) + with pytest.raises(LedgerIntegrityError): + # Wrong head hash: verifier catches it at append time. + ledger.append( + "checkpoint", + "__ledger__", + {"event_count": 3, "head_hash": "0" * 64, "signed": False, "signature": None}, + ) + + +def test_anchor_export_and_verify(tmp_path): + ledger = filled_ledger(tmp_path) + anchor_path = tmp_path / "anchor.json" + anchor = export_anchor(ledger, anchor_path) + assert anchor["event_count"] == 3 + assert verify_anchor(ledger, anchor_path) + # Ledger moves on: the anchor no longer matches the head. + ledger.record_candidate("cand-2") + assert not verify_anchor(ledger, anchor_path) + + +def test_anchor_rejects_foreign_file(tmp_path): + ledger = filled_ledger(tmp_path) + bogus = tmp_path / "bogus.json" + bogus.write_text(json.dumps({"kind": "something-else"})) + with pytest.raises(ValueError): + verify_anchor(ledger, bogus) + + +def test_key_from_env_never_returns_empty(monkeypatch): + monkeypatch.delenv("SAPIENS_CHECKPOINT_KEY", raising=False) + assert key_from_env() is None + monkeypatch.setenv("SAPIENS_CHECKPOINT_KEY", "env-key") + assert key_from_env() == b"env-key" + monkeypatch.setenv("SAPIENS_CHECKPOINT_KEY", "") + assert key_from_env() is None + + +def test_sign_is_deterministic_and_key_dependent(): + a = sign(event_count=3, head_hash="ab" * 32, key=KEY) + assert a == sign(event_count=3, head_hash="ab" * 32, key=KEY) + assert a != sign(event_count=3, head_hash="ab" * 32, key=OTHER_KEY) + assert a != sign(event_count=4, head_hash="ab" * 32, key=KEY) diff --git a/tests/test_isolation.py b/tests/test_isolation.py new file mode 100644 index 0000000..fa50e26 --- /dev/null +++ b/tests/test_isolation.py @@ -0,0 +1,104 @@ +import pytest +from isolation_doubles import ( + BadEvidenceAdapter, + CpuHogAdapter, + MemoryHogAdapter, + NoisyAdapter, + SleeperAdapter, + WellBehavedAdapter, +) + +from sapiens.budget import ExecutionContext +from sapiens.isolation import ( + IsolationError, + ResourceLimits, + run_validate_isolated, +) +from sapiens.models import Candidate + +CANDIDATE = Candidate("cand-1", "iso-domain", "isolated validation works", {"k": 1}) +CONTEXT = ExecutionContext(max_steps=10, max_seconds=5.0) +FAST_LIMITS = ResourceLimits(cpu_seconds=2, address_space_bytes=256 << 20, max_open_files=32) + + +def test_well_behaved_adapter_round_trips_evidence(): + evidence = run_validate_isolated( + WellBehavedAdapter(), CANDIDATE, stage="internal", seed=7, context=CONTEXT + ) + assert len(evidence) == 1 + item = evidence[0] + assert item.candidate_id == "cand-1" + assert item.kind == "internal" + assert item.passed is True + assert item.score == 0.75 + assert item.details["isolated"] is True + + +def test_noisy_adapter_stdout_does_not_corrupt_protocol(): + evidence = run_validate_isolated( + NoisyAdapter(), CANDIDATE, stage="internal", seed=7, context=CONTEXT + ) + assert len(evidence) == 1 and evidence[0].passed + + +def test_cpu_hog_killed_by_rlimit(): + with pytest.raises(IsolationError, match="died"): + run_validate_isolated( + CpuHogAdapter(), + CANDIDATE, + stage="internal", + seed=7, + context=CONTEXT, + limits=FAST_LIMITS, + timeout_seconds=20.0, + ) + + +def test_memory_hog_contained_by_rlimit(): + with pytest.raises(IsolationError): + run_validate_isolated( + MemoryHogAdapter(), + CANDIDATE, + stage="internal", + seed=7, + context=CONTEXT, + limits=FAST_LIMITS, + timeout_seconds=20.0, + ) + + +def test_sleeper_killed_by_wall_clock_timeout(): + with pytest.raises(IsolationError, match="wall-clock"): + run_validate_isolated( + SleeperAdapter(), + CANDIDATE, + stage="internal", + seed=7, + context=CONTEXT, + timeout_seconds=2.0, + ) + + +def test_invalid_adapter_evidence_is_contained(): + with pytest.raises(IsolationError): + run_validate_isolated( + BadEvidenceAdapter(), CANDIDATE, stage="internal", seed=7, context=CONTEXT + ) + + +def test_non_serialisable_candidate_parameters_rejected_before_spawn(): + candidate = Candidate("cand-2", "iso-domain", "bad params", {"fn": object()}) + with pytest.raises(IsolationError, match="JSON"): + run_validate_isolated( + WellBehavedAdapter(), candidate, stage="internal", seed=7, context=CONTEXT + ) + + +def test_locally_defined_adapter_cannot_be_isolated(): + class Local(WellBehavedAdapter): + pass + + with pytest.raises(IsolationError, match="module level"): + run_validate_isolated( + Local(), CANDIDATE, stage="internal", seed=7, context=CONTEXT + ) diff --git a/tests/test_kernel_isolation.py b/tests/test_kernel_isolation.py new file mode 100644 index 0000000..1d08247 --- /dev/null +++ b/tests/test_kernel_isolation.py @@ -0,0 +1,66 @@ +import json +from datetime import date + +import pytest +from isolation_doubles import WellBehavedAdapter + +from sapiens.budget import ExecutionContext +from sapiens.kernel import DiscoveryKernel +from sapiens.ledger import EvidenceLedger +from sapiens.models import Candidate, EvidenceLevel +from sapiens.permissions import MissingPermissionError, PermissionManifest +from sapiens.registry import AdapterRegistry + +PERMISSION = { + "source": "doubles", + "scope": "adapter:well-behaved", + "licence": "MIT", + "granted_by": "owner@example.org", + "reference": "https://example.org/permission/1", + "granted_on": "2026-07-01", + "expires_on": None, +} + + +def registry(tmp_path, entries): + path = tmp_path / "permissions.json" + path.write_text(json.dumps({"version": 1, "entries": entries})) + return AdapterRegistry(PermissionManifest.load(path), today=date(2026, 7, 20)) + + +def candidate(): + return Candidate("iso-cand", "iso-domain", "claim under isolation") + + +def test_kernel_runs_untrusted_adapter_isolated(tmp_path): + kernel = DiscoveryKernel( + EvidenceLedger(tmp_path / "evidence.jsonl"), registry(tmp_path, [PERMISSION]) + ) + cand = candidate() + kernel.register(cand) + reached = kernel.validate_next( + WellBehavedAdapter(), + cand, + seed=7, + context=ExecutionContext(max_steps=10, max_seconds=5.0), + ) + assert reached == EvidenceLevel.L1 + state = kernel.ledger.state("iso-cand") + assert state.level == EvidenceLevel.L1 + + +def test_kernel_refuses_third_party_without_permission(tmp_path): + kernel = DiscoveryKernel( + EvidenceLedger(tmp_path / "evidence.jsonl"), registry(tmp_path, []) + ) + cand = candidate() + kernel.register(cand) + with pytest.raises(MissingPermissionError): + kernel.validate_next( + WellBehavedAdapter(), + cand, + seed=7, + context=ExecutionContext(max_steps=10, max_seconds=5.0), + ) + # No promotion, no evidence: the ledger shows the candidate still at L0. + assert kernel.ledger.state("iso-cand").level == EvidenceLevel.L0 diff --git a/tests/test_permissions.py b/tests/test_permissions.py new file mode 100644 index 0000000..8863570 --- /dev/null +++ b/tests/test_permissions.py @@ -0,0 +1,115 @@ +import json +from datetime import date + +import pytest + +from sapiens.permissions import ( + ManifestFormatError, + MissingPermissionError, + PermissionEntry, + PermissionManifest, +) + +TODAY = date(2026, 7, 20) + +ENTRY = { + "source": "ASTRA-dev", + "scope": "adapter:astra-photometry", + "licence": "MIT", + "granted_by": "owner@example.org", + "reference": "https://example.org/permission/1", + "granted_on": "2026-07-01", + "expires_on": "2026-12-31", +} + + +def make_manifest(tmp_path, entries): + path = tmp_path / "permissions.json" + path.write_text(json.dumps({"version": 1, "entries": entries})) + return PermissionManifest.load(path) + + +def test_repo_manifest_is_empty_and_valid(): + from pathlib import Path + + root = Path(__file__).resolve().parents[1] + manifest = PermissionManifest.load(root / "permissions.json") + assert manifest.entries == () + # The clean-room invariant: zero ASTRA-family permissions recorded. + assert not any("ASTRA" in e.source or "astra" in e.source for e in manifest.entries) + + +def test_empty_manifest_refuses_everything(): + manifest = PermissionManifest.empty() + assert not manifest.permits(source="ASTRA-dev", scope="adapter:x", on=TODAY) + with pytest.raises(MissingPermissionError): + manifest.require(source="ASTRA-dev", scope="adapter:x", on=TODAY) + + +def test_load_roundtrip_and_permit(tmp_path): + manifest = make_manifest(tmp_path, [ENTRY]) + assert manifest.permits( + source="ASTRA-dev", scope="adapter:astra-photometry", on=date(2026, 8, 1) + ) + entry = manifest.require( + source="ASTRA-dev", scope="adapter:astra-photometry", on=date(2026, 8, 1) + ) + assert entry.licence == "MIT" + + +def test_expired_permission_refused(tmp_path): + manifest = make_manifest(tmp_path, [ENTRY]) + assert not manifest.permits( + source="ASTRA-dev", scope="adapter:astra-photometry", on=date(2027, 1, 1) + ) + with pytest.raises(MissingPermissionError, match="not active"): + manifest.require( + source="ASTRA-dev", scope="adapter:astra-photometry", on=date(2027, 1, 1) + ) + + +def test_not_yet_granted_permission_refused(tmp_path): + manifest = make_manifest(tmp_path, [ENTRY]) + assert not manifest.permits( + source="ASTRA-dev", scope="adapter:astra-photometry", on=date(2026, 6, 1) + ) + + +def test_scope_wildcard(tmp_path): + wildcard = {**ENTRY, "scope": "module:swarm/*"} + manifest = make_manifest(tmp_path, [wildcard]) + assert manifest.permits( + source="ASTRA-dev", scope="module:swarm/pheromone_dynamics", on=date(2026, 8, 1) + ) + assert not manifest.permits(source="ASTRA-dev", scope="module:other/x", on=date(2026, 8, 1)) + + +def test_wrong_source_refused(tmp_path): + manifest = make_manifest(tmp_path, [ENTRY]) + assert not manifest.permits( + source="SLATE", scope="adapter:astra-photometry", on=date(2026, 8, 1) + ) + + +@pytest.mark.parametrize( + "raw", + [ + {"version": 2, "entries": []}, # unsupported version + {"version": 1}, # missing entries list + {"version": 1, "entries": [{"source": "x"}]}, # incomplete entry + {"version": 1, "entries": [{**ENTRY, "granted_on": "not-a-date"}]}, + {"version": 1, "entries": [{**ENTRY, "expires_on": "2026-01-01"}]}, # before grant + {"version": 1, "entries": [{**ENTRY, "surprise_field": 1}]}, # unknown field + {"version": 1, "entries": [ENTRY, ENTRY]}, # duplicates + ], +) +def test_malformed_manifests_fail_loudly(tmp_path, raw): + path = tmp_path / "bad.json" + path.write_text(json.dumps(raw)) + with pytest.raises(ManifestFormatError): + PermissionManifest.load(path) + + +def test_entry_requires_nonempty_fields(): + with pytest.raises(ManifestFormatError): + PermissionEntry.from_dict({**ENTRY, "licence": ""}) diff --git a/tests/test_photometry_adapter.py b/tests/test_photometry_adapter.py index a57316f..7a669b7 100644 --- a/tests/test_photometry_adapter.py +++ b/tests/test_photometry_adapter.py @@ -4,7 +4,11 @@ from sapiens.adapters import SyntheticPhotometryAdapter, SyntheticThresholdAdapter from sapiens.budget import ExecutionContext -CTX = ExecutionContext(10, 2) + +def ctx() -> ExecutionContext: + # Fresh context per call: a module-level time-budgeted context starts its + # wall clock at import/collection time and goes stale as the suite grows. + return ExecutionContext(10, 10) def test_photometry_true_period_promotes_to_l3(tmp_path: Path): @@ -13,9 +17,9 @@ def test_photometry_true_period_promotes_to_l3(tmp_path: Path): adapter = SyntheticPhotometryAdapter() candidate = adapter.propose(seed=5, limit=1)[0] kernel.register(candidate) - assert kernel.validate_next(adapter, candidate, seed=40, context=CTX) == EvidenceLevel.L1 - assert kernel.validate_next(adapter, candidate, seed=41, context=CTX) == EvidenceLevel.L2 - assert kernel.validate_next(adapter, candidate, seed=42, context=CTX) == EvidenceLevel.L3 + assert kernel.validate_next(adapter, candidate, seed=40, context=ctx()) == EvidenceLevel.L1 + assert kernel.validate_next(adapter, candidate, seed=41, context=ctx()) == EvidenceLevel.L2 + assert kernel.validate_next(adapter, candidate, seed=42, context=ctx()) == EvidenceLevel.L3 assert ledger.verify() is True @@ -25,7 +29,7 @@ def test_photometry_wrong_period_does_not_promote(tmp_path: Path): adapter = SyntheticPhotometryAdapter() candidate = adapter.propose(seed=5, limit=2)[1] # the wrong-period candidate kernel.register(candidate) - assert kernel.validate_next(adapter, candidate, seed=40, context=CTX) == EvidenceLevel.L0 + assert kernel.validate_next(adapter, candidate, seed=40, context=ctx()) == EvidenceLevel.L0 def test_photometry_evidence_is_well_formed(): diff --git a/tests/test_registry.py b/tests/test_registry.py new file mode 100644 index 0000000..fe18151 --- /dev/null +++ b/tests/test_registry.py @@ -0,0 +1,115 @@ +import json +from datetime import date + +import pytest + +from sapiens.models import AdapterManifest, Candidate +from sapiens.permissions import MissingPermissionError, PermissionManifest +from sapiens.registry import AdapterRegistry, TrustTier, derive_tier + + +class SyntheticDouble: + manifest = AdapterManifest("synth", "1", "synth-domain", ("x",)) + + def propose(self, *, seed: int, limit: int): + return () + + def validate(self, candidate, *, stage: str, seed: int, context): + return () + + def import_structure(self, structure, *, candidate_id: str): + return Candidate(candidate_id, "synth-domain", "claim") + + +class CoreDouble(SyntheticDouble): + manifest = AdapterManifest( + "core-real", + "1", + "core-domain", + ("x",), + synthetic_only=False, + data_sources=("https://example.org/public",), + ) + + +class ThirdPartyDouble(SyntheticDouble): + manifest = AdapterManifest( + "tp", + "1", + "tp-domain", + ("x",), + synthetic_only=False, + code_origin="third-party", + third_party_source="ASTRA-dev", + data_sources=("https://example.org/public",), + ) + + +PERMISSION = { + "source": "ASTRA-dev", + "scope": "adapter:tp", + "licence": "MIT", + "granted_by": "owner@example.org", + "reference": "https://example.org/permission/1", + "granted_on": "2026-07-01", + "expires_on": None, +} + + +def manifest_with(entries, tmp_path): + path = tmp_path / "permissions.json" + path.write_text(json.dumps({"version": 1, "entries": entries})) + return PermissionManifest.load(path) + + +def test_derive_tier_from_manifest_facts(): + assert derive_tier(SyntheticDouble()) == TrustTier.SYNTHETIC + assert derive_tier(CoreDouble()) == TrustTier.CORE + assert derive_tier(ThirdPartyDouble()) == TrustTier.UNTRUSTED + + +def test_unregistered_adapters_auto_tier_on_validate(): + registry = AdapterRegistry(today=date(2026, 7, 20)) + assert registry.validate_adapter(SyntheticDouble()) == TrustTier.SYNTHETIC + assert registry.validate_adapter(CoreDouble()) == TrustTier.CORE + + +def test_third_party_requires_permission_entry(tmp_path): + registry = AdapterRegistry(today=date(2026, 7, 20)) + with pytest.raises(MissingPermissionError): + registry.validate_adapter(ThirdPartyDouble()) + permitted = AdapterRegistry( + manifest_with([PERMISSION], tmp_path), today=date(2026, 7, 20) + ) + assert permitted.validate_adapter(ThirdPartyDouble()) == TrustTier.UNTRUSTED + assert permitted.requires_isolation(ThirdPartyDouble()) + + +def test_expired_permission_blocks_validation(tmp_path): + expired = {**PERMISSION, "expires_on": "2026-07-19"} + registry = AdapterRegistry(manifest_with([expired], tmp_path), today=date(2026, 7, 20)) + with pytest.raises(MissingPermissionError): + registry.validate_adapter(ThirdPartyDouble()) + + +def test_registration_pins_tier_and_rejects_incoherent(tmp_path): + registry = AdapterRegistry(today=date(2026, 7, 20)) + registry.register(SyntheticDouble(), TrustTier.SYNTHETIC) + with pytest.raises(ValueError, match="already registered"): + registry.register(SyntheticDouble(), TrustTier.CORE) + with pytest.raises(ValueError, match="real data sources"): + registry.register(CoreDouble(), TrustTier.SYNTHETIC) + with pytest.raises(ValueError, match="clean-room"): + registry.register(ThirdPartyDouble(), TrustTier.CORE) + + +def test_register_third_party_untrusted_with_permission(tmp_path): + registry = AdapterRegistry(manifest_with([PERMISSION], tmp_path), today=date(2026, 7, 20)) + registry.register(ThirdPartyDouble(), TrustTier.UNTRUSTED) + assert registry.tier_of(ThirdPartyDouble()) == TrustTier.UNTRUSTED + + +def test_non_adapter_rejected(): + registry = AdapterRegistry() + with pytest.raises(TypeError): + registry.validate_adapter(object())