From 426db271133a5d1ec3b7fab00e8c06e1e6d16a5c Mon Sep 17 00:00:00 2001 From: quantamixsol Date: Mon, 27 Jul 2026 15:26:41 +0200 Subject: [PATCH] =?UTF-8?q?CR-LIC-03b:=20tier-trust=20hardening=20(public)?= =?UTF-8?q?=20=E2=80=94=20unverified=20hint=20grants=20no=20entitlement?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Public cherry-pick of private-merged #315. Ships in the wheel (activation is Community), so the fix reaches users once it lands here. resolve_tier_mode now consults the VERIFIED manager.current_tier first; GRAQLE_LICENSE_TIER env is demoted to a governance-mode toggle that confers NO entitlement (docstring security invariant: paid gating must use manager.current_tier, never this fn/env). Sentinel APPROVE 91% 0-BLOCKER. 20 tier + 219 activation tests. Own version lane — NOT 0.81.0. Co-Authored-By: Claude Opus 4.8 --- graqle/activation/tier_gate.py | 79 ++++++++++++------ .../test_activation/test_activation_layer.py | 81 +++++++++++++++++++ 2 files changed, 137 insertions(+), 23 deletions(-) diff --git a/graqle/activation/tier_gate.py b/graqle/activation/tier_gate.py index ae519bc6..38ce7817 100644 --- a/graqle/activation/tier_gate.py +++ b/graqle/activation/tier_gate.py @@ -1,16 +1,28 @@ -"""pre-reason-activation design — License-tier detection for the pre-reason activation layer. +"""pre-reason-activation design — governance-MODE detection for the activation layer. -Rule (plain English): - Free tier → ADVISORY mode (scores visible, upgrade chip shown on block-worthy turns, turn continues) - Pro / Enterprise → ENFORCED mode (turn halts on block-worthy safety verdicts) +This resolves ONLY the activation *governance mode* (whether block-worthy safety +verdicts halt the turn), NOT a paid entitlement: + + ADVISORY mode → scores visible, upgrade chip on block-worthy turns, turn CONTINUES + ENFORCED mode → turn HALTS on a block-worthy safety verdict + +⚠️ CR-LIC-03b (ADR-245) SECURITY INVARIANT — this function MUST NEVER be used to grant a +paid feature, raise a cap, or confer any entitlement. It only makes governance STRICTER +(ENFORCED blocks your own unsafe turns). Because of that, an UNVERIFIED signal (env var, +config file) is allowed to opt INTO stricter governance, but it can never buy anything. +Any code that gates a PAID capability MUST consult ``manager.current_tier`` (a +cryptographically verified signed licence) — never this function, never GRAQLE_LICENSE_TIER. Detection order (first match wins): - 1. Environment variable GRAQLE_LICENSE_TIER (explicit override) - 2. Environment variable GRAQLE_LICENSE_KEY (presence = Pro at minimum) - 3. Config file graqle.yaml -> license.tier - 4. Default: ADVISORY (Free) + 1. VERIFIED licence tier (manager.current_tier) — the trustworthy source; a real + paid licence → ENFORCED. Checked FIRST so a genuine entitlement always wins. + 2. GRAQLE_LICENSE_TIER env — governance-mode dev toggle ONLY (unverified; can raise + to ENFORCED but confers NO entitlement — see invariant above). + 3. GRAQLE_LICENSE_KEY presence — a key is present (its validity is enforced elsewhere). + 4. Config file graqle.yaml -> license.tier (unverified hint; mode only). + 5. Default: ADVISORY (Free). -Never raises: unknown tier strings fall back to ADVISORY. +Never raises: unknown/error → ADVISORY (fail-safe = the least-strict, non-blocking mode). """ from __future__ import annotations @@ -27,20 +39,41 @@ _FREE_TIERS = frozenset({"free", "community", ""}) -def resolve_tier_mode(config: dict[str, Any] | None = None) -> TierMode: - """Resolve the current tier's activation mode. +def _verified_tier_mode() -> TierMode | None: + """Governance mode from the VERIFIED licence, or None if no valid paid licence. - Parameters - ---------- - config: - Optional parsed graqle.yaml content. If omitted, only env vars are - consulted. + Consults manager.current_tier, which returns a paid tier ONLY from a signed licence + that passed verification (signature + CRL + nonce). This is the trustworthy source; + it is checked first so a real entitlement is never shadowed by an unverified hint. + Fail-safe: any error → None (fall through to the mode-only hints). + """ + try: + # Lazy import: graqle.licensing may import graqle.activation transitively, so a + # module-level import here risks a circular dependency. Import inside the call. + from graqle.licensing.manager import LicenseTier, _get_manager - Returns - ------- - TierMode.ENFORCED if Pro/Enterprise/Team key is present; else ADVISORY. + tier = _get_manager().current_tier + if tier in (LicenseTier.PRO, LicenseTier.TEAM, LicenseTier.ENTERPRISE): + return TierMode.ENFORCED + return TierMode.ADVISORY + except Exception: # noqa: BLE001 — mode detection must never raise + return None + + +def resolve_tier_mode(config: dict[str, Any] | None = None) -> TierMode: + """Resolve the activation GOVERNANCE MODE (not an entitlement — see module docstring). + + Returns TierMode.ENFORCED (halt on block-worthy verdicts) or ADVISORY (never halt). + NEVER call this to decide a paid feature/cap — use manager.current_tier for that. """ - # 1. Explicit override + # 1. VERIFIED licence wins — a real paid entitlement always resolves ENFORCED. + verified = _verified_tier_mode() + if verified is TierMode.ENFORCED: + return verified + # (A verified FREE tier does NOT short-circuit: an unverified env/config hint may + # still opt a free user INTO stricter governance — that grants nothing, only rigor.) + + # 2. GRAQLE_LICENSE_TIER — governance-mode dev toggle ONLY (unverified; no entitlement). explicit = os.environ.get("GRAQLE_LICENSE_TIER", "").strip().lower() if explicit: if explicit in _PRO_TIERS: @@ -50,11 +83,11 @@ def resolve_tier_mode(config: dict[str, Any] | None = None) -> TierMode: logger.warning("unknown GRAQLE_LICENSE_TIER value %r; falling back to ADVISORY", explicit) return TierMode.ADVISORY - # 2. License key presence + # 3. License key presence (validity enforced elsewhere; mode signal only). if os.environ.get("GRAQLE_LICENSE_KEY", "").strip(): return TierMode.ENFORCED - # 3. Config file + # 4. Config file (unverified hint; mode only). if isinstance(config, dict): lic = config.get("license") if isinstance(lic, dict): @@ -64,5 +97,5 @@ def resolve_tier_mode(config: dict[str, Any] | None = None) -> TierMode: if tier_cfg in _FREE_TIERS: return TierMode.ADVISORY - # 4. Default + # 5. Default return TierMode.ADVISORY diff --git a/tests/test_activation/test_activation_layer.py b/tests/test_activation/test_activation_layer.py index 0dacc3ca..22dc941b 100644 --- a/tests/test_activation/test_activation_layer.py +++ b/tests/test_activation/test_activation_layer.py @@ -223,3 +223,84 @@ def test_default_factory_always_returns_layer(monkeypatch): # Should be runnable end-to-end without raising v = asyncio.run(layer.run("test msg", {})) assert isinstance(v, ActivationVerdict) + + +# ── CR-LIC-03b (ADR-245) — tier-trust security invariants ──────────────────── +# resolve_tier_mode resolves the governance MODE only; it must NEVER confer a paid +# entitlement, and a VERIFIED paid licence must win over an unverified env/config hint. + +def test_verified_paid_licence_wins_over_env(monkeypatch): + """A real verified PRO licence resolves ENFORCED even if the env says 'free'.""" + from types import SimpleNamespace + + import graqle.activation.tier_gate as tg + from graqle.licensing.manager import LicenseTier + + monkeypatch.setenv("GRAQLE_LICENSE_TIER", "free") # unverified hint says free + fake_mgr = SimpleNamespace(current_tier=LicenseTier.PRO) # verified = PRO + monkeypatch.setattr( + "graqle.licensing.manager._get_manager", lambda: fake_mgr + ) + assert tg.resolve_tier_mode() is TierMode.ENFORCED + + +def test_env_tier_confers_mode_not_entitlement(monkeypatch): + """GRAQLE_LICENSE_TIER=enterprise (unverified) yields ENFORCED *mode* only — it + does not touch manager.current_tier, which stays FREE (the real entitlement).""" + from types import SimpleNamespace + + import graqle.activation.tier_gate as tg + from graqle.licensing.manager import LicenseTier + + monkeypatch.setenv("GRAQLE_LICENSE_TIER", "enterprise") # unverified + fake_mgr = SimpleNamespace(current_tier=LicenseTier.FREE) # verified stays FREE + monkeypatch.setattr( + "graqle.licensing.manager._get_manager", lambda: fake_mgr + ) + # Mode is ENFORCED (stricter governance — grants nothing)... + assert tg.resolve_tier_mode() is TierMode.ENFORCED + # ...but the ENTITLEMENT (what paid features consult) is unchanged: still FREE. + assert fake_mgr.current_tier is LicenseTier.FREE + + +def test_verified_free_still_advisory_without_hints(monkeypatch): + """No env/config hints + verified FREE → ADVISORY (no false enforcement).""" + from types import SimpleNamespace + + import graqle.activation.tier_gate as tg + from graqle.licensing.manager import LicenseTier + + monkeypatch.delenv("GRAQLE_LICENSE_TIER", raising=False) + monkeypatch.delenv("GRAQLE_LICENSE_KEY", raising=False) + fake_mgr = SimpleNamespace(current_tier=LicenseTier.FREE) + monkeypatch.setattr( + "graqle.licensing.manager._get_manager", lambda: fake_mgr + ) + assert tg.resolve_tier_mode() is TierMode.ADVISORY + + +def test_manager_error_falls_through_safely(monkeypatch): + """If the verified-tier lookup raises, resolve_tier_mode must not crash — it falls + through to the mode-only hints (fail-safe), never blocking mode detection.""" + import graqle.activation.tier_gate as tg + + def _boom(): + raise RuntimeError("licence subsystem unavailable") + + monkeypatch.setattr("graqle.licensing.manager._get_manager", _boom) + monkeypatch.delenv("GRAQLE_LICENSE_TIER", raising=False) + monkeypatch.delenv("GRAQLE_LICENSE_KEY", raising=False) + # No hints + manager error → default ADVISORY (fail-safe, least-strict). + assert tg.resolve_tier_mode() is TierMode.ADVISORY + + +def test_no_import_cycle_tier_gate_and_manager(): + """CR-LIC-03b MINOR: tier_gate lazy-imports manager to avoid a cycle. Importing + both in either order must not deadlock or ImportError (guards the cycle boundary).""" + import importlib + + importlib.import_module("graqle.activation.tier_gate") + importlib.import_module("graqle.licensing.manager") + # Reverse order too. + importlib.reload(importlib.import_module("graqle.licensing.manager")) + importlib.reload(importlib.import_module("graqle.activation.tier_gate"))