From be70039dd60f5f2e41494cef9588fd95f26ff2b9 Mon Sep 17 00:00:00 2001 From: quantamixsol Date: Sun, 13 Sep 2026 16:02:03 +0200 Subject: [PATCH 1/5] CR-012/PR-012a: single-source DAG flag, DagSettings, .env.example, ground-truth addendum DAG-2026 foundation (ADR-RT-004, CR-012 s4.5/s5.1). Adds graqle.assurance with is_dag_enabled() (positive allowlist, default OFF), DagSettings (every DAG symbol typed, GRAQLE_DAG_ prefix, frozen, 0 rejected for gate thresholds and cap, required-when-enabled fail-closed), load_dag_settings() (env > private file > safe default, cached only on success, drift lock on unknown GRAQLE_DAG_* names), config_version() (joint HMAC commitment, secret values never in clear), validate_flag_consistency() wired into GraqleConfig.from_yaml(). GraqleConfig gains a read-only assurance.enabled derived from the env flag; assurance.enabled in graqle.yaml is rejected before env interpolation. Adds .env.example (placeholders only) and docs/dag/ground-truth-addendum.md with line-anchor verification notes. Flag off => GovernanceMiddleware.check() untouched. 84 new tests; tests/test_config unchanged (217 pass). Deviations from the CR and the D1 P0 reproduction are in the PR body and issue #338. Co-Authored-By: Claude Fable 5.1 (cherry picked from commit 93adcced88dcc332e988535e32e1571c0e214a4e) --- .env.example | 60 +++ docs/dag/ground-truth-addendum.md | 32 ++ graqle/assurance/__init__.py | 39 ++ graqle/assurance/settings.py | 474 ++++++++++++++++ graqle/config/settings.py | 100 +++- tests/test_assurance/__init__.py | 0 tests/test_assurance/test_settings.py | 508 ++++++++++++++++++ tests/test_docs/__init__.py | 0 tests/test_docs/test_ground_truth_addendum.py | 65 +++ 9 files changed, 1270 insertions(+), 8 deletions(-) create mode 100644 .env.example create mode 100644 docs/dag/ground-truth-addendum.md create mode 100644 graqle/assurance/__init__.py create mode 100644 graqle/assurance/settings.py create mode 100644 tests/test_assurance/__init__.py create mode 100644 tests/test_assurance/test_settings.py create mode 100644 tests/test_docs/__init__.py create mode 100644 tests/test_docs/test_ground_truth_addendum.py diff --git a/.env.example b/.env.example new file mode 100644 index 00000000..8aec9ad8 --- /dev/null +++ b/.env.example @@ -0,0 +1,60 @@ +# graqle — environment template (copy to .env; never commit .env) +# +# Every variable below is a NAME. Values marked are +# TS-2/TS-3 tuning values that live in the gitignored private config file +# (GRAQLE_DAG_SECRETS_PATH) or in the deployment environment — never here and +# never in the public repository. Values marked have a non-zero +# operational default compiled into the SDK; leave them unset unless you must +# override. The environment always wins over the private config file. + +# ── DAG-2026 Decision Assurance Gate (CR-012 foundation) ─────────────────────── +# Single source of truth. `assurance.enabled` in graqle.yaml is DERIVED from this +# and must NOT be set in yaml (ConfigurationError if present). +GRAQLE_DAG_ENABLED=false + +# Private config file holding TS-2/TS-3 numeric values (gitignored). Optional; env wins. +GRAQLE_DAG_SECRETS_PATH=.graqle/graqle_secrets.yaml + +# Trace schema (CR-012 §4.4). Pin readers; STRICT=true rejects unknown versions instead of WARNING. +GRAQLE_TRACE_SCHEMA_VERSION=3 +GRAQLE_TRACE_SCHEMA_STRICT=false + +# CR-013 hard gates + cap — REQUIRED when enabled; 0 is rejected. +GRAQLE_DAG_CAP_VALUE= +GRAQLE_DAG_HG05_POISONING_THRESHOLD= +GRAQLE_DAG_HG07_MATERIALITY_THRESHOLD= +GRAQLE_DAG_HG03_IMPACT_TIER_MIN=HIGH +GRAQLE_DAG_RBAC_TIMEOUT_MS= +GRAQLE_DAG_GATE_TIMEOUT_MS= + +# CR-014 confidence vector — REQUIRED when enabled +GRAQLE_DAG_CALIBRATOR_VERSION= +GRAQLE_DAG_CALIBRATOR_TTL_HOURS= +GRAQLE_DAG_CV_WEIGHTS_REF=cv_weights_v1 # key name inside the private config file, never the values +GRAQLE_DAG_CV_MAX_RAW_CAL_DIVERGENCE= +GRAQLE_DAG_TEMPORAL_HALFLIFE_DAYS= + +# CR-015 trajectory / replan / escalation — REQUIRED when enabled +GRAQLE_DAG_TRAJECTORY_ESTIMATOR= +GRAQLE_DAG_MAX_RETRY_BUDGET= +GRAQLE_DAG_CIRCUIT_BREAKER_WINDOW= +GRAQLE_DAG_ESCALATION_ROLE=lead +GRAQLE_DAG_ESCALATION_SLA_MINUTES= + +# CR-016 / CR-017 ontology + propagation +GRAQLE_DAG_ONTOLOGY_SHAPES_PATH= +GRAQLE_DAG_PROV_EXPORT_ENABLED=false +GRAQLE_DAG_PROPAGATION_MAX_DEPTH= +GRAQLE_DAG_RECOMPUTE_QUEUE_PATH=.graqle/dag/recompute_queue.jsonl + +# CR-018 provenance event + action binding — REQUIRED when enabled +GRAQLE_DAG_SIGNING_KEY_ID= +GRAQLE_DAG_SIGNING_KEY_VERSION= +GRAQLE_DAG_SIGNING_KEY_MAX_AGE_DAYS= +GRAQLE_DAG_ARGS_HASH_ALGO=sha256 +GRAQLE_DAG_PROVENANCE_MERKLE_BATCH=false + +# CR-019 benchmark harness +GRAQLE_DAG_BENCH_RESULTS_DIR=.graqle/dag/bench +GRAQLE_DAG_BENCH_SEED= +# ── /DAG-2026 ────────────────────────────────────────────────────────────────── diff --git a/docs/dag/ground-truth-addendum.md b/docs/dag/ground-truth-addendum.md new file mode 100644 index 00000000..e59f3d57 --- /dev/null +++ b/docs/dag/ground-truth-addendum.md @@ -0,0 +1,32 @@ +# DAG-2026 — Ground-truth addendum to the charter (CR-012 §0.3) + +**Programme:** DAG-2026 (Decision Assurance Gate) | **CR:** CR-012 / PR-012a | **ADR:** ADR-RT-004 | **SDK baseline:** graqle 0.83.0 | **Re-verified against:** private tree `research-development-graqle` master `7c1cf8b5` on 2026-09-13 by the SDK team + +The "Gracle Technical and Architectural Research Brief" (baseline 2026-09-11) §2 describes the current admission mechanism as "≥0.70 accept / 0.40–0.69 hold / <0.40 reject". That statement is **false for graqle 0.83.0**. This addendum is the binding correction (DAG-00 §2, ADR-RT-004). Every later DAG CR reads the SDK as described here, not as described in the charter. + +| Charter §2 claim | v0.83.0 reality | Source | +|---|---|---| +| ≥0.70 accept / 0.40–0.69 hold / <0.40 reject | `review_threshold=0.70`, `block_threshold=0.90` are DANGER scores; tiers T1 / T2 / T3 / TS-BLOCK; **no hold band** | `core/governance.py:283-284, 793-930` | +| "confidence" gate | `gate_score = 0.5·risk_weight + 0.5·radius_weight`; not a confidence | `core/governance.py:742-750` | +| Article 14 human-review threshold | `0.75` placeholder, `threshold_status="placeholder"` always | `compliance/article_14_gate.py:64`, `settings.py:560` | +| "Five deterministic scoring dimensions" | DRACE D .25 / R .25 / A .20 / C .15 / E .15, fixed-weight linear, compensatory except `evaluate_constraint` cap | `intelligence/governance/drace.py:74-90, 266-288` | +| "Decision trail hashes" | R25-EU01 Merkle batch commitment + two narrow prev_hash chains; no per-event sequence numbers | `governance/tamper_evidence/*`, `compliance/eu_ai_act_latch.py:216`, `intelligence/governance/audit.py:68` | +| EXECUTE/REPLAN/HOLD/REJECT/ESCALATE as current outcomes | **proposed** DAG outcomes; current enum is `Decision{PASS,BLOCK,WARN}` | `governance/trace_schema.py:53` | +| Reason-code registry | none; `GovernanceDecision.reason` is free text ≤200 chars | `trace_schema.py:108` | +| `assurance.enabled` config key | does not exist; introduced by this CR as DERIVED from `GRAQLE_DAG_ENABLED` | this CR §3.3 | + +## Verification notes (SDK team, 2026-09-13) + +Every citation above was checked against the private master tree. Anchors that moved relative to the Research Team's 0.83.0 wheel inspection are recorded here so nobody re-targets them silently (brief §1 item 4). Nothing below changes a row of the table. + +- `core/governance.py`: `GovernanceConfig` starts at :268 (CR §0.1 cites :270-309); `GateResult` at :317; `GovernanceBypassNode` at :351; the lazy RBAC `except ImportError` sites are :877, :906 and :937 (CR cites :861, which is now the `_rbac_check` call). `:283-284`, `:513`, `:681`, `:742-750`, `:825`, `:897` are unchanged. +- `config/settings.py`: `GraqleConfig.governance` at :811 (CR: :810); the `_reject_yaml_secrets(raw)` call is at :997 (CR: :1004, which is now the `model_validate` return); `_YAML_FORBIDDEN_SECRET_PATHS` at :1084; `def _reject_yaml_secrets` at :1089. +- `governance/trace_schema.py`: `schema_version: str = "2"` at :223 (CR: :220); `policy_version` at :224; `classify_schema_version` at :137 (CR: :139); `model_config = ConfigDict(extra="forbid")` at :186 (CR: :184). +- `compliance/eu_ai_act_latch.py`: `_signed_record` at :218 (CR: :216). `intelligence/governance/audit.py:68` unchanged. +- `governance/reliability_diagram.py:29` imports `graqle.governance.calibration`, which **exists in the source tree** (`CalibrationModel` at :73, `Calibrator` at :380; `calibration_store.py` exists too) and is present on the public `master` as well. The `ModuleNotFoundError` the Research Team observed is reproducible only from the **built wheel**: `pyproject.toml` `[tool.hatch.build.targets.wheel] exclude` carves out `graqle/governance/calibration.py` and `calibration_store.py` (WS-F trade-secret gate) while still shipping `reliability_diagram.py` and `cli/commands/calibrate_governance.py`. D3 is therefore a wheel-content defect (dead import shipped), not a missing module; PR-012c will fix it at the packaging/import boundary rather than by creating a second `CalibrationModel`. The Research Team is asked to amend CR-012 §0.1/§4.6 accordingly. +- `plugins/mcp_dev_server.py`: `_handle_ingest` :13108 and the raw-path block :13120-13135 unchanged; helper `_project_root_from_graph_file` :5216 unchanged. Two additional raw `Path(str(_raw)).resolve().parent` sites exist at :12487 and :12711 that the CR's 10-site list omits; PR-012c sweeps 12 sites. +- `pyproject.toml:60` already declares `pydantic-settings>=2.0` — CR-012 OQ-2 resolved: no new dependency. + +## What this addendum does not change + +`GovernanceMiddleware.check()` is untouched by CR-012. With `GRAQLE_DAG_ENABLED` unset, every `GateResult.to_dict()` is byte-identical to 0.83.0 (CR-012 AC-9 golden fixture, PR-012c). diff --git a/graqle/assurance/__init__.py b/graqle/assurance/__init__.py new file mode 100644 index 00000000..d99691d5 --- /dev/null +++ b/graqle/assurance/__init__.py @@ -0,0 +1,39 @@ +"""``graqle.assurance`` — DAG-2026 Decision Assurance Gate (foundation, CR-012). + +Dependency direction: ``graqle.assurance`` → ``graqle.governance``, never the +reverse (CR-012 AC-21). With ``GRAQLE_DAG_ENABLED`` unset nothing in this +package changes SDK behaviour; ``GovernanceMiddleware.check()`` is untouched. + +PR-012a exports the flag and settings surface only. ``GateOutcome``, the +reason-code registry and ``GateVerdict`` arrive in PR-012b; the +``DecisionAssuranceGate`` component itself arrives in CR-015. +""" + +# ── graqle:intelligence ── +# module: graqle.assurance.__init__ +# risk: LOW (impact radius: 0 modules — new package, no existing callers) +# dependencies: graqle.assurance.settings +# constraints: never imported by graqle.governance.* +# ── /graqle:intelligence ── + +from graqle.assurance.settings import ( + ConfigurationError, + DagSettings, + config_provenance, + config_version, + is_dag_enabled, + load_dag_settings, + reset_dag_settings_cache, + validate_flag_consistency, +) + +__all__ = [ + "ConfigurationError", + "DagSettings", + "config_provenance", + "config_version", + "is_dag_enabled", + "load_dag_settings", + "reset_dag_settings_cache", + "validate_flag_consistency", +] diff --git a/graqle/assurance/settings.py b/graqle/assurance/settings.py new file mode 100644 index 00000000..3aba75af --- /dev/null +++ b/graqle/assurance/settings.py @@ -0,0 +1,474 @@ +"""DAG-2026 Decision Assurance Gate — single-source feature flag and typed settings. + +CR-012 §4.5 / §5.1 (PR-012a). This module is the ONLY place the DAG feature +flag is parsed and the ONLY typed home for every DAG configuration symbol used +by CR-013 … CR-019. Later CRs *consume* fields declared here; they never add a +second parse of ``GRAQLE_DAG_ENABLED`` (brief §6.1) or a second config surface +(Senior chain 1). + +Semantics (CR-012 §2.1): + +* ``INV-FLAG-1`` — ``GraqleConfig.assurance.enabled`` is derived from + :func:`is_dag_enabled`; it is never a settable input. +* ``INV-FLAG-3`` — with the flag off nothing in this module changes SDK + behaviour; :class:`~graqle.core.governance.GovernanceMiddleware` is untouched. +* ``INV-FLAG-4`` — with the flag on, any absent required value raises + :class:`ConfigurationError` at the first load. There is no placeholder + fallback for a security-critical value. + +Trade-secret rule (CR-012 §12, brief §13): every threshold, cap, weight, +half-life, retry budget and window is a SYMBOL here. The only numeric literals +in this file are non-zero *operational* safe defaults (timeouts, TTL, SLA, +depth, key max age, bench seed), which brief §13.2 permits. Gate thresholds and +the cap have no default at all. Validators reject ``0`` (lesson R5). + +Resolution order (pydantic-settings): explicit init kwargs > environment > +field default. :func:`load_dag_settings` therefore drops every private-file key +whose environment variable is present *before* constructing the model, so the +environment always wins over the private file (brief §8.3). +""" + +from __future__ import annotations + +# ── graqle:intelligence ── +# module: graqle.assurance.settings +# risk: LOW (impact radius: 1 module — graqle.config.settings lazy imports only) +# dependencies: graqle.config.exceptions, pydantic, pydantic_settings, yaml +# constraints: MUST NOT import graqle.config.settings (it lazily imports this +# module); MUST NOT import graqle.governance at module level (assurance -> +# governance is the allowed direction, but the canonicaliser is loaded lazily +# inside config_version() to keep flag-off import cost at zero). +# ── /graqle:intelligence ── +import hashlib +import hmac +import logging +import os +from collections.abc import Mapping +from pathlib import Path +from types import MappingProxyType +from typing import Any, Literal + +import yaml +from pydantic import Field, ValidationError, field_validator, model_validator +from pydantic_settings import BaseSettings, SettingsConfigDict + +from graqle.config.exceptions import GraqleConfigError + +logger = logging.getLogger(__name__) + +__all__ = [ + "ENV_FLAG", + "ENV_PREFIX", + "DEFAULT_SECRETS_PATH", + "ConfigurationError", + "DagSettings", + "REQUIRED_WHEN_ENABLED", + "SECRET_VALUED_FIELDS", + "config_provenance", + "config_version", + "env_name", + "is_dag_enabled", + "load_dag_settings", + "reset_dag_settings_cache", + "validate_flag_consistency", +] + +#: The single feature flag. Positive allowlist; anything else is OFF. +ENV_FLAG = "GRAQLE_DAG_ENABLED" +#: Prefix shared by every DAG setting (``GRAQLE_DAG_``). +ENV_PREFIX = "GRAQLE_DAG_" +#: Default location of the gitignored private-values file (brief §8.3). +DEFAULT_SECRETS_PATH = ".graqle/graqle_secrets.yaml" + +_TRUTHY: frozenset[str] = frozenset({"1", "true", "yes", "on"}) + +#: Fields that MUST be present when the flag is on (CR-012 §4.5, blueprint B2). +REQUIRED_WHEN_ENABLED: tuple[str, ...] = ( + "cap_value", # CR-013 + "hg05_poisoning_threshold", # CR-013 (blueprint B4) + "hg07_materiality_threshold", # CR-013 + "calibrator_version", # CR-014 + "trajectory_estimator", # CR-015 (blueprint M2) + "max_retry_budget", # CR-015 (blueprint M5) + "circuit_breaker_window", # CR-015 + "signing_key_id", # CR-018 (blueprint B3) + "signing_key_version", # CR-018 (blueprint B3) +) + +#: TS-2/TS-3 valued fields. Their VALUES never enter a log, trace, anchor or +#: fingerprint in clear (Senior chain 4); see :func:`config_version`. +SECRET_VALUED_FIELDS: frozenset[str] = frozenset({ + "cap_value", + "hg05_poisoning_threshold", + "hg07_materiality_threshold", + "cv_max_raw_cal_divergence", + "temporal_halflife_days", + "max_retry_budget", + "circuit_breaker_window", +}) + +# Domain-separation label for the joint commitment over secret-valued fields. +_SECRETS_DIGEST_LABEL = b"graqle.assurance.config_version.secrets.v1" + +_ImpactTier = Literal["LOW", "MEDIUM", "HIGH", "CRITICAL"] + + +class ConfigurationError(GraqleConfigError): + """Raised on flag mismatch, on missing required DAG config while the flag is + on, or on an invalid value. Never a silent placeholder fallback. + + Messages carry setting NAMES only — never the offending value — so a + rejected private value cannot leak through logs (Senior chain 4). + """ + + +def env_name(field: str) -> str: + """Environment-variable name for a :class:`DagSettings` field.""" + return ENV_PREFIX + field.upper() + + +def is_dag_enabled() -> bool: + """Pure environment read of :data:`ENV_FLAG`. + + ``"1" | "true" | "yes" | "on"`` (any case, padded) ⇒ ``True``; unset or any + other value ⇒ ``False``. A positive allowlist was chosen over the resolver's + negative list (``config/resolver.py:78``) so an unrecognised value fails + CLOSED (CR-012 §2.1, Article 14 precedent). + """ + return os.environ.get(ENV_FLAG, "").strip().lower() in _TRUTHY + + +class DagSettings(BaseSettings): + """Every DAG configuration symbol, typed, in one place (CR-012 §4.5). + + Values come from the environment (``GRAQLE_DAG_``) or from the + private file at ``GRAQLE_DAG_SECRETS_PATH``; the environment wins. The model + is frozen and forbids unknown fields. + """ + + model_config = SettingsConfigDict( + env_prefix=ENV_PREFIX, + extra="forbid", + frozen=True, + case_sensitive=False, + validate_default=True, + ) + + # ── CR-012 ──────────────────────────────────────────────────────────── + enabled: bool = False + secrets_path: str | None = None + + # ── CR-013 hard gates + cap (values TS-3; 0 rejected; no default) ───── + cap_value: float | None = Field(default=None, gt=0.0, lt=1.0) + hg05_poisoning_threshold: float | None = Field(default=None, gt=0.0, le=1.0) + hg07_materiality_threshold: float | None = Field(default=None, gt=0.0, le=1.0) + hg03_impact_tier_min: _ImpactTier = "HIGH" + rbac_timeout_ms: int = Field(default=1500, gt=0) # operational safe default + gate_timeout_ms: int = Field(default=2000, gt=0) # operational safe default + + # ── CR-014 confidence vector ────────────────────────────────────────── + calibrator_version: str | None = None + calibrator_ttl_hours: int = Field(default=720, gt=0) # operational safe default + cv_weights_ref: str = "cv_weights_v1" # key NAME in the private file, not values + cv_max_raw_cal_divergence: float | None = Field(default=None, gt=0.0, le=1.0) + temporal_halflife_days: int | None = Field(default=None, gt=0) + + # ── CR-015 trajectory / replan / escalation ─────────────────────────── + trajectory_estimator: str | None = None # validated against the registry in CR-015 + max_retry_budget: int | None = Field(default=None, gt=0) + circuit_breaker_window: int | None = Field(default=None, gt=0) + escalation_role: str = "lead" # must be a core.rbac.ROLE_PERMISSIONS key + escalation_sla_minutes: int = Field(default=240, gt=0) # operational safe default + + # ── CR-016 / CR-017 ontology + propagation ──────────────────────────── + ontology_shapes_path: str | None = None + prov_export_enabled: bool = False + propagation_max_depth: int = Field(default=8, gt=0, le=64) # operational safe default + recompute_queue_path: str = ".graqle/dag/recompute_queue.jsonl" + + # ── CR-018 provenance event + action binding ────────────────────────── + signing_key_id: str | None = None + signing_key_version: str | None = None + signing_key_max_age_days: int = Field(default=90, gt=0) # operational safe default + args_hash_algo: Literal["sha256"] = "sha256" + provenance_merkle_batch: bool = False + + # ── CR-019 benchmark harness ────────────────────────────────────────── + bench_results_dir: str = ".graqle/dag/bench" + bench_seed: int = 20260911 # operational safe default + + @field_validator("enabled", mode="before") + @classmethod + def _parse_flag(cls, value: object) -> bool: + if isinstance(value, bool): + return value + if value is None: + return False + return str(value).strip().lower() in _TRUTHY + + @field_validator("escalation_role") + @classmethod + def _role_known(cls, value: str) -> str: + from graqle.core.rbac import ROLE_PERMISSIONS # lazy: rbac is a stdlib leaf + + if value not in ROLE_PERMISSIONS: + raise ValueError( + f"escalation_role {value!r} is not a key of core.rbac.ROLE_PERMISSIONS" + ) + return value + + @model_validator(mode="after") + def _required_when_enabled(self) -> DagSettings: + # Read-only: the model is frozen; this validator never assigns. + if not self.enabled: + return self + missing = [f for f in REQUIRED_WHEN_ENABLED if getattr(self, f) is None] + if missing: + # Non-ValueError exceptions propagate unwrapped from pydantic + # validators, so callers see ConfigurationError directly (AC-3). + raise ConfigurationError( + f"{ENV_FLAG}=true but required DAG settings are absent: " + + ", ".join(env_name(m) for m in missing) + + f". Set them in the environment or in {ENV_PREFIX}SECRETS_PATH. " + "No placeholder fallback exists." + ) + return self + + +# ── private-file layering ──────────────────────────────────────────────────── + +_cache: DagSettings | None = None +_provenance: dict[str, str] = {} + + +def _secrets_path() -> Path: + raw = os.environ.get(ENV_PREFIX + "SECRETS_PATH", "").strip() or DEFAULT_SECRETS_PATH + return Path(raw).expanduser() + + +def _load_secrets_file(path: Path) -> dict[str, Any]: + """Read the optional private-values yaml into ``{field_name: value}``. + + Missing file ⇒ ``{}``. Keys may be bare field names or ``GRAQLE_DAG_`` + prefixed names (either case). Error messages name the file and the + exception class only — never file content (parse-error oracle). + """ + if not path.is_file(): + return {} + try: + data = yaml.safe_load(path.read_text(encoding="utf-8")) + except yaml.YAMLError as exc: + mark = getattr(exc, "problem_mark", None) + where = f" (line {mark.line + 1})" if mark is not None else "" + raise ConfigurationError( + f"DAG private config {path} is not valid YAML{where}: {type(exc).__name__}" + ) from None + except OSError as exc: + raise ConfigurationError( + f"DAG private config {path} could not be read: {type(exc).__name__}" + ) from None + if data is None: + return {} + if not isinstance(data, dict): + raise ConfigurationError( + f"DAG private config {path} must be a mapping of setting names to values" + ) + out: dict[str, Any] = {} + for key, value in data.items(): + name = str(key).strip() + if name.upper().startswith(ENV_PREFIX): + name = name[len(ENV_PREFIX):] + out[name.lower()] = value + return out + + +def _unknown_prefixed_env_vars() -> list[str]: + """Drift lock: every ``GRAQLE_DAG_*`` variable must map to exactly one field. + + pydantic-settings silently ignores unknown prefixed variables even under + ``extra="forbid"`` (verified against pydantic-settings 2.13), so the check + is explicit here. Returns NAMES only. + """ + known = {env_name(f).upper() for f in DagSettings.model_fields} + return sorted( + k for k in os.environ + if k.upper().startswith(ENV_PREFIX) and k.upper() not in known + ) + + +def load_dag_settings(*, force: bool = False) -> DagSettings: + """Load (and cache) :class:`DagSettings`. + + Precedence: environment > private file > field default. The cache is only + assigned on success (AC-3: no partial object is ever cached) and is + invalidated automatically if the flag's environment value changes. + + Raises: + ConfigurationError: on any invalid or missing-required value. The + message carries setting names, never values. + """ + global _cache, _provenance + + if _cache is not None and not force and _cache.enabled == is_dag_enabled(): + return _cache + + unknown = _unknown_prefixed_env_vars() + if unknown: + raise ConfigurationError( + "unknown DAG environment variables (one name must map to exactly one " + f"DagSettings field): {', '.join(unknown)}" + ) + + file_values = _load_secrets_file(_secrets_path()) + if "enabled" in file_values: + raise ConfigurationError( + f"{ENV_FLAG} is environment-only; remove 'enabled' from the DAG private config" + ) + unknown_keys = sorted(k for k in file_values if k not in DagSettings.model_fields) + if unknown_keys: + raise ConfigurationError( + "unknown keys in DAG private config: " + ", ".join(unknown_keys) + ) + + # Environment wins: pydantic-settings ranks init kwargs ABOVE env, so any + # file key that also has an env var is dropped before construction. + overrides = {k: v for k, v in file_values.items() if env_name(k) not in os.environ} + + try: + settings = DagSettings(**overrides) + except ValidationError as exc: + # Do not chain (`from exc`): the pydantic error object carries the + # rejected input value and would leak through __context__ (chain 4). + details = "; ".join( + f"{'.'.join(str(p) for p in e.get('loc', ()))}: {e.get('type')}" + for e in exc.errors(include_url=False, include_input=False, include_context=False) + ) + raise ConfigurationError(f"DAG settings invalid: {details}") from None + + if settings.enabled != is_dag_enabled(): # pragma: no cover — defensive (single source) + raise ConfigurationError( + f"DagSettings.enabled disagrees with {ENV_FLAG}; single source violated" + ) + + provenance: dict[str, str] = {} + for name in DagSettings.model_fields: + if env_name(name) in os.environ: + provenance[name] = "ENV" + elif name in overrides: + provenance[name] = "SECRETS_FILE" + elif getattr(settings, name) is not None: + provenance[name] = "SAFE_DEFAULT" + else: + provenance[name] = "UNSET" + + _provenance = provenance + _cache = settings + logger.info( + "dag.settings.loaded enabled=%s config_version=%s", + settings.enabled, + config_version(settings)[:23], + ) + return settings + + +def reset_dag_settings_cache() -> None: + """Drop the cached settings and provenance (tests, ``graq serve`` reload).""" + global _cache, _provenance + _cache = None + _provenance = {} + + +def config_provenance() -> Mapping[str, str]: + """Per-field source of the last successful load: ``ENV`` | ``SECRETS_FILE`` + | ``SAFE_DEFAULT`` | ``UNSET``. Empty until :func:`load_dag_settings` ran. + Names and sources only — never values (brief §13.2 ``SAFE_DEFAULT`` stamp). + """ + return MappingProxyType(dict(_provenance)) + + +# ── deterministic fingerprint ──────────────────────────────────────────────── + +def _config_version_payload(settings: DagSettings) -> dict[str, Any]: + """Public projection used by :func:`config_version`. + + Non-secret fields enter in clear. Secret-valued fields enter ONLY as (a) the + sorted list of names that are set and (b) ONE joint HMAC-SHA256 whose key is + the canonical encoding of all set secret values. A joint commitment is used + instead of the CR's per-field ``sha256(repr(value))`` because a lone + threshold in ``(0, 1)`` with a few decimals is brute-forceable from its own + hash in milliseconds (sentinel B1, 2026-09-13); the joint form requires + guessing every set value at once. Residual risk (attacker who already knows + all but one value) is recorded in the PR and left for the Research Team + ruling on a deployment salt (CR-018 signing key). + """ + from graqle.governance.tamper_evidence.canonicalize import ( + canon, # lazy: assurance -> governance + ) + + dumped = settings.model_dump(mode="json") + public = {k: v for k, v in dumped.items() if k not in SECRET_VALUED_FIELDS} + secret_items = { + k: dumped[k] for k in sorted(SECRET_VALUED_FIELDS) if dumped.get(k) is not None + } + digest: str | None = None + if secret_items: + key = canon(secret_items) + digest = hmac.new(key, _SECRETS_DIGEST_LABEL, hashlib.sha256).hexdigest() + public["_secret_fields_set"] = sorted(secret_items) + public["_secrets_digest"] = digest + return public + + +def config_version(settings: DagSettings) -> str: + """``"sha256:"`` fingerprint of the settings for + ``DeterminismRecord.config_version`` (CR-012 §4.3) and run logs. + + Deterministic across processes for the same configuration; secret values + never appear in clear (see :func:`_config_version_payload`). Any + canonicalisation failure is fail-closed — never coerced. + """ + try: + from graqle.governance.tamper_evidence.canonicalize import canon # lazy + + data = canon(_config_version_payload(settings)) + except ConfigurationError: + raise + except Exception as exc: # TamperEvidenceError, ImportError, ... + raise ConfigurationError( + f"config_version could not be computed: {type(exc).__name__}" + ) from None + return "sha256:" + hashlib.sha256(data).hexdigest() + + +# ── startup validator (blueprint B1) ───────────────────────────────────────── + +def validate_flag_consistency(cfg: Any) -> None: + """Fatal on any disagreement between the environment flag and the config + object; when the flag is on, eagerly loads settings so a missing required + value is a startup error (INV-FLAG-4), never a request-time surprise. + + Called from ``GraqleConfig.from_yaml()`` and from MCP server boot. With the + flag off this is a single environment read. + """ + env_on = is_dag_enabled() + assurance = getattr(cfg, "assurance", None) + if assurance is None: + raise ConfigurationError( + "config object has no 'assurance' section; cannot validate the DAG flag" + ) + cfg_on = bool(getattr(assurance, "enabled", False)) + if env_on != cfg_on: # only reachable via monkey-patching; still fatal + raise ConfigurationError( + f"assurance.enabled={cfg_on} but {ENV_FLAG}={env_on}; single source violated" + ) + if env_on: + settings = load_dag_settings() # raises ConfigurationError if incomplete + from graqle.__version__ import __version__ + + # Truncated prefix: enough for operators to correlate, not a durable + # copy of the full fingerprint in every log sink (sentinel B4). + logger.warning( + "DAG enabled — DecisionAssuranceGate components active (v%s); config_version=%s…", + __version__, + config_version(settings)[:23], + ) diff --git a/graqle/config/settings.py b/graqle/config/settings.py index 325672bd..e1b0cbbe 100644 --- a/graqle/config/settings.py +++ b/graqle/config/settings.py @@ -16,7 +16,7 @@ from typing import Any, ClassVar, Literal import yaml -from pydantic import BaseModel, Field, model_validator +from pydantic import BaseModel, ConfigDict, Field, model_validator from graqle.config.attestation_config import AttestationConfig @@ -176,7 +176,7 @@ class ActivationConfig(BaseModel): model_config = {"validate_assignment": True} @model_validator(mode="after") - def _promote_legacy_activation_schema(self) -> "ActivationConfig": + def _promote_legacy_activation_schema(self) -> ActivationConfig: """v0.62.3: promote legacy `strategy:` / `top_k:` into new fields. Conflict-resolution table is documented in SPEC-v0623-activation-schema.md §2.7. @@ -414,7 +414,7 @@ class RoutingRuleConfig(BaseModel): profile: str | None = None @model_validator(mode="after") - def require_bedrock_fields(self) -> "RoutingRuleConfig": + def require_bedrock_fields(self) -> RoutingRuleConfig: """Bedrock routing rules must specify region and profile. FB-006: without these fields, Bedrock routing silently routes to the @@ -564,7 +564,7 @@ class GovernancePolicyConfig(BaseModel): eu_ai_act: EuAiActConfig = Field(default_factory=EuAiActConfig) @model_validator(mode="after") - def _validate_edit_enforcement_requires_plan(self) -> "GovernancePolicyConfig": + def _validate_edit_enforcement_requires_plan(self) -> GovernancePolicyConfig: """edit_enforcement=True requires plan_mandatory=True. CG-03 (edit_enforcement) gates native Edit calls and redirects them @@ -601,7 +601,7 @@ class DebateConfig(BaseModel): clearance_levels: dict[str, str] = Field(default_factory=dict) # panelist -> clearance level @model_validator(mode="after") - def _load_private_defaults(self) -> "DebateConfig": + def _load_private_defaults(self) -> DebateConfig: """Fill None fields from private config at runtime.""" from graqle.orchestration.debate_config import get as _cfg if self.convergence_threshold is None: @@ -787,6 +787,33 @@ class ChatConfig(BaseModel): permission_mode: str = "ask" # "ask", "auto_allow", "deny" +class AssuranceConfig(BaseModel): + """CR-012 (DAG-2026): ``assurance:`` section of ``graqle.yaml``. + + ``enabled`` is DERIVED from the single-source environment flag + ``GRAQLE_DAG_ENABLED`` (``graqle.assurance.settings.is_dag_enabled``) and is + deliberately a read-only *property*, not a field (INV-FLAG-1): + + * it cannot be set from yaml — ``from_yaml`` rejects ``assurance.enabled`` + before env interpolation (``_reject_yaml_derived``) and ``extra="forbid"`` + rejects it at validation (INV-FLAG-2); + * it is never serialised by ``model_dump()``, so a dumped config can be fed + back through ``from_yaml`` without smuggling the flag into yaml + (blueprint B1, Senior chain 1). + + The import of ``graqle.assurance`` is lazy so that, with the flag off, no + pre-existing module loads the assurance package at import time. + """ + + model_config = ConfigDict(extra="forbid") + + @property + def enabled(self) -> bool: + from graqle.assurance.settings import is_dag_enabled + + return is_dag_enabled() + + class GraqleConfig(BaseModel): """Root configuration for a GraQle instance.""" @@ -816,6 +843,9 @@ class GraqleConfig(BaseModel): backends: BackendsConfig = Field(default_factory=BackendsConfig) chat: ChatConfig = Field(default_factory=ChatConfig) attestation: AttestationConfig = Field(default_factory=AttestationConfig) + # CR-012 (DAG-2026): `assurance.enabled` is derived from GRAQLE_DAG_ENABLED and + # is rejected if present in graqle.yaml (see _reject_yaml_derived). + assurance: AssuranceConfig = Field(default_factory=AssuranceConfig) # G4 (Wave 2 Phase 4): additional protected file patterns requiring # reviewer approval on write. Extends CG-14 defaults (graqle.yaml, @@ -845,7 +875,7 @@ class GraqleConfig(BaseModel): ) @model_validator(mode="after") - def _warn_deprecated_connector(self) -> "GraqleConfig": + def _warn_deprecated_connector(self) -> GraqleConfig: """Emit deprecation warning if graph.connector is neo4j/neptune.""" import warnings if self.graph.connector.lower() in ("neo4j", "neptune"): @@ -864,7 +894,7 @@ def _warn_deprecated_connector(self) -> "GraqleConfig": return self @model_validator(mode="after") - def _validate_debate_panelists(self) -> "GraqleConfig": + def _validate_debate_panelists(self) -> GraqleConfig: """Ensure debate panelists reference defined model profiles.""" if self.debate.mode == "off": return self @@ -995,13 +1025,23 @@ def from_yaml(cls, path: str | Path) -> GraqleConfig: # from yaml — whether as a literal or an env-reference. This is # defense-in-depth ahead of the per-field validators in attestation_config. _reject_yaml_secrets(raw) + # CR-012 (DAG-2026, INV-FLAG-2): `assurance.enabled` is DERIVED from the + # GRAQLE_DAG_ENABLED environment flag. Its mere presence in yaml — even + # as a ${ENV_REF}, even when equal to the env value — is an error, so + # this also runs BEFORE env interpolation. + _reject_yaml_derived(raw) # Interpolate environment variables raw = _interpolate_env(raw) # Pass source="yaml" so secret-class field validators (e.g. # AttestationConfig.security.webhook_alert_url) can reject secrets that # must be supplied via environment variables, not graqle.yaml. - return cls.model_validate(raw, context={"source": "yaml"}) + cfg = cls.model_validate(raw, context={"source": "yaml"}) + # CR-012 (DAG-2026, blueprint B1 / INV-FLAG-4): startup validator. With + # the flag off this is a single environment read; with the flag on it + # fails closed (ConfigurationError) if any required DAG setting is absent. + _validate_dag_flag(cfg) + return cfg @classmethod def default(cls) -> GraqleConfig: @@ -1111,6 +1151,50 @@ def _reject_yaml_secrets(raw: Any) -> None: ) +# CR-012 (DAG-2026): config keys that are DERIVED from the environment and must +# never appear in graqle.yaml, not even with a value equal to the environment's. +# Dotted paths into the raw yaml dict, like _YAML_FORBIDDEN_SECRET_PATHS above. +_YAML_FORBIDDEN_DERIVED_PATHS: tuple[tuple[str, ...], ...] = ( + ("assurance", "enabled"), +) + + +def _reject_yaml_derived(raw: Any) -> None: + """Raise ``ConfigurationError`` if a derived key is present in the raw yaml. + + Runs BEFORE ``_interpolate_env`` (a ``${GRAQLE_DAG_ENABLED}`` reference is + still a yaml-sourced flag). Unlike ``_reject_yaml_secrets``, presence with a + ``null`` value is also rejected: the key has no legitimate yaml form. + """ + if not isinstance(raw, dict): + return + for path in _YAML_FORBIDDEN_DERIVED_PATHS: + node: Any = raw + for key in path[:-1]: + if not isinstance(node, dict) or key not in node: + node = None + break + node = node[key] + if isinstance(node, dict) and path[-1] in node: + from graqle.assurance.settings import ENV_FLAG, ConfigurationError + + dotted = ".".join(path) + raise ConfigurationError( + f"{dotted} is derived from the {ENV_FLAG} environment variable and " + "must not be set in graqle.yaml (its presence is the violation, even " + "when equal to the environment value or given as a ${ENV_REF}). " + f"Remove the key and set {ENV_FLAG} in the environment." + ) + + +def _validate_dag_flag(cfg: GraqleConfig) -> None: + """CR-012 startup validator (blueprint B1). Lazy import keeps the assurance + package out of the flag-off import graph of every other module.""" + from graqle.assurance.settings import validate_flag_consistency + + validate_flag_consistency(cfg) + + def _interpolate_env(obj: Any) -> Any: """Recursively interpolate ${ENV_VAR} patterns in config values.""" if isinstance(obj, str): diff --git a/tests/test_assurance/__init__.py b/tests/test_assurance/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/test_assurance/test_settings.py b/tests/test_assurance/test_settings.py new file mode 100644 index 00000000..0cf6f265 --- /dev/null +++ b/tests/test_assurance/test_settings.py @@ -0,0 +1,508 @@ +"""CR-012 / PR-012a — ``graqle.assurance.settings`` (AC-1 … AC-5 + review checklist). + +Every numeric value in this file is a NON-INFERENTIAL SUBSTITUTE (CR-012 §12, +CR-013 §12: cap 0.31, HG-05 0.61, HG-07 0.51). No real threshold, cap, weight, +half-life or budget appears here. + +Covers: + AC-1 flag parse — positive allowlist, unknown ⇒ OFF + AC-2 yaml ``assurance.enabled`` ⇒ ConfigurationError at ``from_yaml`` BEFORE env interpolation + AC-3 flag on + missing required ⇒ ConfigurationError naming the exact env vars; nothing cached + AC-4 0 rejected for cap / HG-05 / HG-07 / retry budget (lesson R5) + AC-5 ``.env.example`` names every DagSettings field; no numeric TS values + checklist (1)-(6): env wins over private file; env-only flag; config_version + hides secret values; startup validator; no import of graqle.assurance from + graqle.config.settings at import time; ``model_dump`` never carries ``enabled``. + +See: .gsm/external/Change Requests/DAG-2026/ + CR-012-DAG-foundation-flag-schema-reason-codes-hygiene.md +""" + +from __future__ import annotations + +# -- graqle:intelligence -- +# module: tests.test_assurance.test_settings +# risk: LOW (impact radius: 0 modules) +# dependencies: pytest, graqle.assurance.settings, graqle.config.settings +# constraints: substitute values only (TS-2/TS-3) +# -- /graqle:intelligence -- +import logging +import re +import subprocess +import sys +from pathlib import Path + +import pytest + +import graqle.assurance.settings as dag +from graqle.assurance.settings import ( + REQUIRED_WHEN_ENABLED, + SECRET_VALUED_FIELDS, + ConfigurationError, + DagSettings, + config_provenance, + config_version, + env_name, + is_dag_enabled, + load_dag_settings, + reset_dag_settings_cache, + validate_flag_consistency, +) +from graqle.config.settings import AssuranceConfig, GraqleConfig + +_REPO_ROOT = Path(__file__).resolve().parents[2] +_ENV_EXAMPLE = _REPO_ROOT / ".env.example" + +# Substitute values — deliberately NOT the real ones. +_SUBSTITUTE_REQUIRED: dict[str, str] = { + "GRAQLE_DAG_CAP_VALUE": "0.31", + "GRAQLE_DAG_HG05_POISONING_THRESHOLD": "0.61", + "GRAQLE_DAG_HG07_MATERIALITY_THRESHOLD": "0.51", + "GRAQLE_DAG_CALIBRATOR_VERSION": "cal-test-v0", + "GRAQLE_DAG_TRAJECTORY_ESTIMATOR": "causal_baseline", + "GRAQLE_DAG_MAX_RETRY_BUDGET": "3", + "GRAQLE_DAG_CIRCUIT_BREAKER_WINDOW": "5", + "GRAQLE_DAG_SIGNING_KEY_ID": "kid-test-2026", + "GRAQLE_DAG_SIGNING_KEY_VERSION": "1", +} + + +@pytest.fixture(autouse=True) +def _isolated_dag_env(monkeypatch: pytest.MonkeyPatch, tmp_path: Path): + """Strip every GRAQLE_DAG_* / GRAQLE_TRACE_SCHEMA_* var, point the private + file at a non-existent path, and reset the module cache before AND after.""" + for key in list(dag.os.environ): + if key.upper().startswith("GRAQLE_DAG_") or key.upper().startswith("GRAQLE_TRACE_SCHEMA_"): + monkeypatch.delenv(key, raising=False) + monkeypatch.setenv("GRAQLE_DAG_SECRETS_PATH", str(tmp_path / "absent-private.yaml")) + reset_dag_settings_cache() + yield + reset_dag_settings_cache() + + +def _set_required(monkeypatch: pytest.MonkeyPatch, **overrides: str) -> None: + for k, v in {**_SUBSTITUTE_REQUIRED, **overrides}.items(): + monkeypatch.setenv(k, v) + + +# ─────────────── AC-1 flag parse ───────────────────────────────────────────── + + +@pytest.mark.parametrize("raw", [None, "", "false", "0", "garbage", "no", "off", "TRUE ISH", "2"]) +def test_ac1_flag_off_values(monkeypatch: pytest.MonkeyPatch, raw: str | None) -> None: + if raw is None: + monkeypatch.delenv("GRAQLE_DAG_ENABLED", raising=False) + else: + monkeypatch.setenv("GRAQLE_DAG_ENABLED", raw) + assert is_dag_enabled() is False + + +@pytest.mark.parametrize("raw", ["1", "true", "yes", "on", " TRUE ", "On", "\tYes\n", "ON"]) +def test_ac1_flag_on_values(monkeypatch: pytest.MonkeyPatch, raw: str) -> None: + monkeypatch.setenv("GRAQLE_DAG_ENABLED", raw) + assert is_dag_enabled() is True + + +def test_ac1_dagsettings_enabled_uses_same_allowlist(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("GRAQLE_DAG_ENABLED", "garbage") + assert DagSettings().enabled is False + assert DagSettings(enabled="yes", **_lower(_SUBSTITUTE_REQUIRED)).enabled is True + + +def _lower(env: dict[str, str]) -> dict[str, str]: + return {k[len("GRAQLE_DAG_"):].lower(): v for k, v in env.items()} + + +# ─────────────── AC-2 yaml assurance.enabled rejected ──────────────────────── + + +def _write_yaml(tmp_path: Path, body: str) -> Path: + p = tmp_path / "graqle.yaml" + p.write_text(body, encoding="utf-8") + return p + + +@pytest.mark.parametrize( + "body", + [ + "assurance:\n enabled: false\n", + "assurance:\n enabled: true\n", + "assurance:\n enabled: null\n", + # an env-ref must be caught BEFORE interpolation + "assurance:\n enabled: ${GRAQLE_DAG_ENABLED}\n", + ], +) +def test_ac2_yaml_assurance_enabled_rejected( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, body: str +) -> None: + # Equal to the env value on purpose: presence alone is the violation (INV-FLAG-2). + monkeypatch.setenv("GRAQLE_DAG_ENABLED", "false") + path = _write_yaml(tmp_path, body) + with pytest.raises(ConfigurationError, match="assurance.enabled"): + GraqleConfig.from_yaml(path) + + +def test_ac2_empty_assurance_block_is_fine(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + path = _write_yaml(tmp_path, "assurance: {}\n") + cfg = GraqleConfig.from_yaml(path) + assert cfg.assurance.enabled is False + monkeypatch.setenv("GRAQLE_DAG_ENABLED", "on") + _set_required(monkeypatch) + assert cfg.assurance.enabled is True # derived live, never stored + + +def test_ac2_assurance_config_forbids_enabled_field_directly() -> None: + with pytest.raises(Exception): # pydantic ValidationError (extra forbidden) + AssuranceConfig.model_validate({"enabled": True}) + + +def test_assurance_enabled_never_serialised(monkeypatch: pytest.MonkeyPatch) -> None: + """A dumped config must be re-loadable without smuggling the flag into yaml.""" + monkeypatch.setenv("GRAQLE_DAG_ENABLED", "true") + dumped = GraqleConfig().model_dump() + assert dumped["assurance"] == {} + assert "enabled" not in GraqleConfig().model_dump(mode="json")["assurance"] + + +# ─────────────── AC-3 required-when-enabled ────────────────────────────────── + + +def test_ac3_flag_on_missing_required_names_every_var(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("GRAQLE_DAG_ENABLED", "true") + with pytest.raises(ConfigurationError) as ei: + load_dag_settings() + msg = str(ei.value) + for field in REQUIRED_WHEN_ENABLED: + assert env_name(field) in msg + assert "No placeholder fallback exists" in msg + assert dag._cache is None, "no partial object may be cached" + assert config_provenance() == {} + + +def test_ac3_flag_on_partial_required_names_only_missing(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("GRAQLE_DAG_ENABLED", "true") + _set_required(monkeypatch) + monkeypatch.delenv("GRAQLE_DAG_SIGNING_KEY_ID") + monkeypatch.delenv("GRAQLE_DAG_MAX_RETRY_BUDGET") + with pytest.raises(ConfigurationError) as ei: + load_dag_settings() + msg = str(ei.value) + assert "GRAQLE_DAG_SIGNING_KEY_ID" in msg and "GRAQLE_DAG_MAX_RETRY_BUDGET" in msg + assert "GRAQLE_DAG_CAP_VALUE" not in msg + # names only — never the present values + assert "0.31" not in msg and "0.61" not in msg and "0.51" not in msg + + +def test_ac3_flag_on_complete_env_loads_and_caches(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("GRAQLE_DAG_ENABLED", "true") + _set_required(monkeypatch) + s = load_dag_settings() + assert s.enabled is True + assert s is load_dag_settings() # cached + prov = config_provenance() + assert prov["cap_value"] == "ENV" + assert prov["rbac_timeout_ms"] == "SAFE_DEFAULT" + assert prov["ontology_shapes_path"] == "UNSET" + + +def test_flag_off_needs_nothing(monkeypatch: pytest.MonkeyPatch) -> None: + s = load_dag_settings() + assert s.enabled is False and s.cap_value is None + + +def test_cache_invalidates_when_flag_changes(monkeypatch: pytest.MonkeyPatch) -> None: + first = load_dag_settings() + assert first.enabled is False + monkeypatch.setenv("GRAQLE_DAG_ENABLED", "true") + _set_required(monkeypatch) + second = load_dag_settings() + assert second is not first and second.enabled is True + + +# ─────────────── AC-4 zero rejected ────────────────────────────────────────── + + +@pytest.mark.parametrize( + "var", + [ + "GRAQLE_DAG_CAP_VALUE", + "GRAQLE_DAG_HG05_POISONING_THRESHOLD", + "GRAQLE_DAG_HG07_MATERIALITY_THRESHOLD", + "GRAQLE_DAG_MAX_RETRY_BUDGET", + ], +) +@pytest.mark.parametrize("flag", ["true", "false"]) +def test_ac4_zero_rejected(monkeypatch: pytest.MonkeyPatch, var: str, flag: str) -> None: + monkeypatch.setenv("GRAQLE_DAG_ENABLED", flag) + _set_required(monkeypatch, **{var: "0"}) + with pytest.raises(ConfigurationError, match="DAG settings invalid") as ei: + load_dag_settings() + assert var[len("GRAQLE_DAG_"):].lower() in str(ei.value) + assert dag._cache is None + + +@pytest.mark.parametrize( + ("var", "bad"), + [ + ("GRAQLE_DAG_CAP_VALUE", "1"), # lt=1 + ("GRAQLE_DAG_CAP_VALUE", "-0.31"), + ("GRAQLE_DAG_HG05_POISONING_THRESHOLD", "1.5"), + ("GRAQLE_DAG_CAP_VALUE", "nan"), + ("GRAQLE_DAG_CAP_VALUE", ""), + ("GRAQLE_DAG_PROPAGATION_MAX_DEPTH", "0"), + ("GRAQLE_DAG_RBAC_TIMEOUT_MS", "0"), + ("GRAQLE_DAG_ESCALATION_ROLE", "not-a-role"), + ("GRAQLE_DAG_HG03_IMPACT_TIER_MIN", "EXTREME"), + ("GRAQLE_DAG_ARGS_HASH_ALGO", "md5"), + ], +) +def test_boundary_values_rejected_without_echoing_input( + monkeypatch: pytest.MonkeyPatch, var: str, bad: str +) -> None: + _set_required(monkeypatch, **{var: bad}) + with pytest.raises(ConfigurationError) as ei: + load_dag_settings() + msg = str(ei.value) + assert bad == "" or bad not in msg, "rejected input value must not be echoed" + assert ei.value.__cause__ is None and ei.value.__suppress_context__ + + +def test_boundary_one_accepted_for_le_fields(monkeypatch: pytest.MonkeyPatch) -> None: + _set_required( + monkeypatch, + GRAQLE_DAG_HG05_POISONING_THRESHOLD="1", + GRAQLE_DAG_HG07_MATERIALITY_THRESHOLD="1.0", + ) + s = load_dag_settings() + assert s.hg05_poisoning_threshold == 1.0 and s.hg07_materiality_threshold == 1.0 + + +def test_frozen(monkeypatch: pytest.MonkeyPatch) -> None: + s = load_dag_settings() + with pytest.raises(Exception): + s.enabled = True # type: ignore[misc] + + +# ─────────────── AC-5 .env.example ─────────────────────────────────────────── + + +def _env_example_assignments() -> dict[str, str]: + assert _ENV_EXAMPLE.is_file(), ".env.example missing at repo root" + out: dict[str, str] = {} + for line in _ENV_EXAMPLE.read_text(encoding="utf-8").splitlines(): + if not line or line.lstrip().startswith("#") or "=" not in line: + continue + name, _, value = line.partition("=") + out[name.strip()] = value.split("#", 1)[0].strip() + return out + + +def test_ac5_every_field_has_an_env_example_line() -> None: + names = _env_example_assignments() + missing = [env_name(f) for f in DagSettings.model_fields if env_name(f) not in names] + assert not missing, f".env.example lacks: {missing}" + + +def test_ac5_env_example_has_no_numeric_tuning_values() -> None: + numeric = re.compile(r"^-?\d+(\.\d+)?$") + offenders = { + k: v for k, v in _env_example_assignments().items() + if k.startswith("GRAQLE_DAG_") and numeric.match(v) + } + assert not offenders, f"numeric literals are TS-3 and must be placeholders: {offenders}" + + +def test_ac5_env_example_declares_flag_off_and_schema_pins() -> None: + names = _env_example_assignments() + assert names["GRAQLE_DAG_ENABLED"] == "false" + assert names["GRAQLE_TRACE_SCHEMA_VERSION"] == "3" + assert names["GRAQLE_TRACE_SCHEMA_STRICT"] == "false" + + +# ─────────────── private file layering (brief §8.3) ────────────────────────── + + +def _write_private(tmp_path: Path, monkeypatch: pytest.MonkeyPatch, body: str) -> Path: + p = tmp_path / "graqle_secrets.yaml" + p.write_text(body, encoding="utf-8") + monkeypatch.setenv("GRAQLE_DAG_SECRETS_PATH", str(p)) + return p + + +def test_private_file_supplies_values(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + _write_private(tmp_path, monkeypatch, "cap_value: 0.31\nGRAQLE_DAG_MAX_RETRY_BUDGET: 3\n") + s = load_dag_settings() + assert s.cap_value == 0.31 and s.max_retry_budget == 3 + prov = config_provenance() + assert prov["cap_value"] == "SECRETS_FILE" and prov["max_retry_budget"] == "SECRETS_FILE" + + +def test_env_wins_over_private_file(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + _write_private(tmp_path, monkeypatch, "cap_value: 0.31\n") + monkeypatch.setenv("GRAQLE_DAG_CAP_VALUE", "0.37") + s = load_dag_settings() + assert s.cap_value == 0.37 + assert config_provenance()["cap_value"] == "ENV" + + +def test_private_file_cannot_set_flag(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + _write_private(tmp_path, monkeypatch, "enabled: true\ncap_value: 0.31\n") + with pytest.raises(ConfigurationError, match="environment-only"): + load_dag_settings() + assert is_dag_enabled() is False + + +def test_private_file_unknown_key_rejected(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + _write_private(tmp_path, monkeypatch, "cap_valeu: 0.31\n") + with pytest.raises(ConfigurationError, match="unknown keys.*cap_valeu"): + load_dag_settings() + + +@pytest.mark.parametrize("body", ["- a\n- b\n", "cap_value: [0.31\n"]) +def test_private_file_malformed_rejected_without_content( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, body: str +) -> None: + p = _write_private(tmp_path, monkeypatch, body) + with pytest.raises(ConfigurationError) as ei: + load_dag_settings() + assert str(p) in str(ei.value) + assert "0.31" not in str(ei.value) + + +def test_unknown_prefixed_env_var_is_a_drift_error(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("GRAQLE_DAG_NOT_A_FIELD", "1") + with pytest.raises(ConfigurationError, match="GRAQLE_DAG_NOT_A_FIELD"): + load_dag_settings() + + +# ─────────────── config_version (Senior chain 4) ───────────────────────────── + + +def test_config_version_stable_and_prefixed(monkeypatch: pytest.MonkeyPatch) -> None: + _set_required(monkeypatch) + a = config_version(load_dag_settings()) + b = config_version(load_dag_settings(force=True)) + assert a == b and re.fullmatch(r"sha256:[0-9a-f]{64}", a) + + +def test_config_version_changes_with_a_secret_value(monkeypatch: pytest.MonkeyPatch) -> None: + _set_required(monkeypatch) + a = config_version(load_dag_settings()) + monkeypatch.setenv("GRAQLE_DAG_CAP_VALUE", "0.37") + b = config_version(load_dag_settings(force=True)) + assert a != b + + +def test_config_version_payload_never_carries_secret_values( + monkeypatch: pytest.MonkeyPatch, +) -> None: + _set_required( + monkeypatch, + GRAQLE_DAG_CV_MAX_RAW_CAL_DIVERGENCE="0.41", + GRAQLE_DAG_TEMPORAL_HALFLIFE_DAYS="7", + ) + s = load_dag_settings() + payload = dag._config_version_payload(s) + for f in SECRET_VALUED_FIELDS: + assert f not in payload + flat = repr(payload) + for v in ("0.31", "0.61", "0.51", "0.41"): + assert v not in flat + assert payload["_secret_fields_set"] == sorted(SECRET_VALUED_FIELDS) + assert re.fullmatch(r"[0-9a-f]{64}", payload["_secrets_digest"]) + # non-secret fields ARE visible (they are public symbols) + assert payload["escalation_role"] == "lead" and payload["args_hash_algo"] == "sha256" + + +def test_config_version_flag_off_has_no_secret_digest() -> None: + payload = dag._config_version_payload(load_dag_settings()) + assert payload["_secret_fields_set"] == [] and payload["_secrets_digest"] is None + + +def test_config_version_fails_closed_on_canon_error(monkeypatch: pytest.MonkeyPatch) -> None: + def _boom(_: object) -> bytes: + raise RuntimeError("canon down") + + import graqle.governance.tamper_evidence.canonicalize as canonmod + + monkeypatch.setattr(canonmod, "canon", _boom) + with pytest.raises(ConfigurationError, match="config_version could not be computed"): + config_version(load_dag_settings()) + + +# ─────────────── startup validator (blueprint B1) ──────────────────────────── + + +def test_validate_flag_consistency_off_is_noop() -> None: + validate_flag_consistency(GraqleConfig()) + assert dag._cache is None # nothing loaded when off + + +def test_validate_flag_consistency_on_loads_and_warns( + monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture +) -> None: + monkeypatch.setenv("GRAQLE_DAG_ENABLED", "true") + _set_required(monkeypatch) + with caplog.at_level(logging.WARNING, logger="graqle.assurance.settings"): + validate_flag_consistency(GraqleConfig()) + assert dag._cache is not None + rec = [r for r in caplog.records if "DAG enabled" in r.getMessage()] + assert rec and "config_version=sha256:" in rec[0].getMessage() + assert "0.31" not in rec[0].getMessage() + + +def test_validate_flag_consistency_on_incomplete_is_fatal(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("GRAQLE_DAG_ENABLED", "true") + with pytest.raises(ConfigurationError, match="required DAG settings are absent"): + validate_flag_consistency(GraqleConfig()) + + +def test_validate_flag_consistency_mismatch_is_fatal(monkeypatch: pytest.MonkeyPatch) -> None: + class _Fake: + class assurance: # noqa: N801 — stand-in for a monkey-patched config + enabled = True + + with pytest.raises(ConfigurationError, match="single source violated"): + validate_flag_consistency(_Fake()) + + +def test_validate_flag_consistency_requires_assurance_section() -> None: + with pytest.raises(ConfigurationError, match="no 'assurance' section"): + validate_flag_consistency(object()) + + +def test_from_yaml_runs_startup_validator(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("GRAQLE_DAG_ENABLED", "true") + path = _write_yaml(tmp_path, "project_name: t\n") + with pytest.raises(ConfigurationError, match="required DAG settings are absent"): + GraqleConfig.from_yaml(path) + _set_required(monkeypatch) + assert GraqleConfig.from_yaml(path).assurance.enabled is True + + +# ─────────────── import isolation (review checklist item 6) ────────────────── + + +@pytest.mark.slow_subprocess +def test_config_settings_import_does_not_load_assurance() -> None: + code = ( + "import sys, graqle.config.settings; " + "assert not [m for m in sys.modules if m.startswith('graqle.assurance')], " + "[m for m in sys.modules if m.startswith('graqle.assurance')]" + ) + subprocess.run([sys.executable, "-c", code], cwd=_REPO_ROOT, check=True) + + +@pytest.mark.slow_subprocess +def test_assurance_settings_import_does_not_load_governance() -> None: + """assurance -> governance is the allowed direction, but the canonicaliser is + loaded lazily inside config_version() so flag-off import cost stays zero. + (graqle.config.settings IS loaded transitively via the graqle.config package + __init__ — that is package init, not a cycle: config.settings never imports + assurance at module level; see the test above.)""" + code = ( + "import sys, graqle.assurance.settings; " + "bad = [m for m in sys.modules if m.startswith('graqle.governance')]; " + "assert not bad, bad" + ) + subprocess.run([sys.executable, "-c", code], cwd=_REPO_ROOT, check=True) diff --git a/tests/test_docs/__init__.py b/tests/test_docs/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/test_docs/test_ground_truth_addendum.py b/tests/test_docs/test_ground_truth_addendum.py new file mode 100644 index 00000000..a344461c --- /dev/null +++ b/tests/test_docs/test_ground_truth_addendum.py @@ -0,0 +1,65 @@ +"""CR-012 AC-6 — ``docs/dag/ground-truth-addendum.md`` exists and carries the +eight rows of CR-012 §0.3 (string match on the file:line citations). + +See: .gsm/external/Change Requests/DAG-2026/ + CR-012-DAG-foundation-flag-schema-reason-codes-hygiene.md §0.3 +""" + +from __future__ import annotations + +from pathlib import Path + +import pytest + +_REPO_ROOT = Path(__file__).resolve().parents[2] +_ADDENDUM = _REPO_ROOT / "docs" / "dag" / "ground-truth-addendum.md" + +# (charter claim fragment, source citation) — verbatim from CR-012 §0.3. +_ROWS: tuple[tuple[str, str], ...] = ( + ( + "≥0.70 accept / 0.40–0.69 hold / <0.40 reject", + "`core/governance.py:283-284, 793-930`", + ), + ('"confidence" gate', "`core/governance.py:742-750`"), + ("Article 14 human-review threshold", "`compliance/article_14_gate.py:64`, `settings.py:560`"), + ( + '"Five deterministic scoring dimensions"', + "`intelligence/governance/drace.py:74-90, 266-288`", + ), + ( + '"Decision trail hashes"', + "`governance/tamper_evidence/*`, `compliance/eu_ai_act_latch.py:216`, " + "`intelligence/governance/audit.py:68`", + ), + ("EXECUTE/REPLAN/HOLD/REJECT/ESCALATE as current outcomes", "`governance/trace_schema.py:53`"), + ("Reason-code registry", "`trace_schema.py:108`"), + ("`assurance.enabled` config key", "this CR §3.3"), +) + + +def test_addendum_exists() -> None: + assert _ADDENDUM.is_file(), f"missing {_ADDENDUM}" + + +@pytest.mark.parametrize(("claim", "source"), _ROWS, ids=[r[0][:24] for r in _ROWS]) +def test_addendum_has_row(claim: str, source: str) -> None: + text = _ADDENDUM.read_text(encoding="utf-8") + assert claim in text, f"charter claim missing from addendum: {claim!r}" + assert source in text, f"source citation missing from addendum: {source!r}" + + +def test_addendum_has_exactly_eight_table_rows() -> None: + text = _ADDENDUM.read_text(encoding="utf-8") + table_rows = [ + line for line in text.splitlines() + if line.startswith("| ") + and not line.startswith("| Charter") + and not line.startswith("|---") + ] + assert len(table_rows) == 8, table_rows + + +def test_addendum_states_no_hold_band_and_flag_derivation() -> None: + text = _ADDENDUM.read_text(encoding="utf-8") + assert "**no hold band**" in text + assert "DERIVED from `GRAQLE_DAG_ENABLED`" in text From b143035e3216bc3c5dc70a071c5a1af8308da92b Mon Sep 17 00:00:00 2001 From: quantamixsol Date: Sun, 13 Sep 2026 19:04:04 +0200 Subject: [PATCH 2/5] CR-012/PR-012a: post-implementation sentinel fixes (B-1 lock, M-1 HMAC binding, M-3 number-free hints, from_yaml contract) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit graq_reason sentinel pass 1 (81%) REQUEST CHANGES: B-1 cache critical section now under an RLock with double-checked freshness; M-1 secrets digest message bound to the non-secret payload under a domain label (key = secret values, never serialised); M-3 ValidationError conversion maps pydantic error types to number-free operator hints and names the env var; B-2 documented as the intended fail-closed startup boundary in from_yaml's Raises (policy question stays with issue #338 N5). Refuted: m-4 (drift scan already case-insensitive). M-2 (computed_field vs property) left for the #338 N3 ruling — two sentinel passes disagreed. 301 tests pass. Co-Authored-By: Claude Fable 5.1 (cherry picked from commit 5204add5e30c770085f9adff33cc35ab06b0e372) --- graqle/assurance/settings.py | 51 +++++++++++++++++++++++---- graqle/config/settings.py | 9 +++++ tests/test_assurance/test_settings.py | 3 +- 3 files changed, 56 insertions(+), 7 deletions(-) diff --git a/graqle/assurance/settings.py b/graqle/assurance/settings.py index 3aba75af..19748e06 100644 --- a/graqle/assurance/settings.py +++ b/graqle/assurance/settings.py @@ -43,6 +43,7 @@ import hmac import logging import os +import threading from collections.abc import Mapping from pathlib import Path from types import MappingProxyType @@ -110,6 +111,24 @@ # Domain-separation label for the joint commitment over secret-valued fields. _SECRETS_DIGEST_LABEL = b"graqle.assurance.config_version.secrets.v1" +# Operator-readable, NUMBER-FREE hints for pydantic error types (sentinel M-3). +# Bounds are deliberately not spelled out: the message must never let a +# rejected value or a tuning bound be inferred from a log line. +_TYPE_HINTS: dict[str, str] = { + "greater_than": "must be above the lower bound (zero is rejected)", + "greater_than_equal": "must be at or above the lower bound", + "less_than": "must be below the upper bound", + "less_than_equal": "must be at or below the upper bound", + "float_parsing": "must be a number", + "int_parsing": "must be a whole number", + "int_from_float": "must be a whole number", + "bool_parsing": "must be a boolean", + "literal_error": "is not one of the allowed values", + "extra_forbidden": "is not a known DAG setting", + "value_error": "was rejected by a validator", + "missing": "is required", +} + _ImpactTier = Literal["LOW", "MEDIUM", "HIGH", "CRITICAL"] @@ -239,6 +258,10 @@ def _required_when_enabled(self) -> DagSettings: _cache: DagSettings | None = None _provenance: dict[str, str] = {} +# Sentinel B-1 (2026-09-13): the drift scan, file read, construction and cache +# assignment form one critical section; concurrent first loads (threaded +# servers, parallel fixtures) must not interleave or lose provenance. +_cache_lock = threading.RLock() def _secrets_path() -> Path: @@ -296,21 +319,32 @@ def _unknown_prefixed_env_vars() -> list[str]: ) +def _cache_is_fresh() -> bool: + return _cache is not None and _cache.enabled == is_dag_enabled() + + def load_dag_settings(*, force: bool = False) -> DagSettings: """Load (and cache) :class:`DagSettings`. Precedence: environment > private file > field default. The cache is only assigned on success (AC-3: no partial object is ever cached) and is invalidated automatically if the flag's environment value changes. + Thread-safe: the whole load is one critical section (double-checked lock). Raises: ConfigurationError: on any invalid or missing-required value. The message carries setting names, never values. """ - global _cache, _provenance + if not force and _cache_is_fresh(): + return _cache # type: ignore[return-value] + with _cache_lock: + if not force and _cache_is_fresh(): + return _cache # type: ignore[return-value] + return _load_dag_settings_unlocked() - if _cache is not None and not force and _cache.enabled == is_dag_enabled(): - return _cache + +def _load_dag_settings_unlocked() -> DagSettings: + global _cache, _provenance unknown = _unknown_prefixed_env_vars() if unknown: @@ -340,7 +374,8 @@ def load_dag_settings(*, force: bool = False) -> DagSettings: # Do not chain (`from exc`): the pydantic error object carries the # rejected input value and would leak through __context__ (chain 4). details = "; ".join( - f"{'.'.join(str(p) for p in e.get('loc', ()))}: {e.get('type')}" + f"{env_name('.'.join(str(p) for p in e.get('loc', ())))}: " + f"{_TYPE_HINTS.get(str(e.get('type')), str(e.get('type')))}" for e in exc.errors(include_url=False, include_input=False, include_context=False) ) raise ConfigurationError(f"DAG settings invalid: {details}") from None @@ -410,11 +445,15 @@ def _config_version_payload(settings: DagSettings) -> dict[str, Any]: secret_items = { k: dumped[k] for k in sorted(SECRET_VALUED_FIELDS) if dumped.get(k) is not None } + public["_secret_fields_set"] = sorted(secret_items) digest: str | None = None if secret_items: + # key = the secret values (PRF key, never serialised); msg = the + # non-secret payload under a domain label, so the digest is BOUND to + # the configuration it fingerprints (sentinel M-1), not to a constant. key = canon(secret_items) - digest = hmac.new(key, _SECRETS_DIGEST_LABEL, hashlib.sha256).hexdigest() - public["_secret_fields_set"] = sorted(secret_items) + msg = _SECRETS_DIGEST_LABEL + b"|" + canon(public) + digest = hmac.new(key, msg, hashlib.sha256).hexdigest() public["_secrets_digest"] = digest return public diff --git a/graqle/config/settings.py b/graqle/config/settings.py index e1b0cbbe..75f6930c 100644 --- a/graqle/config/settings.py +++ b/graqle/config/settings.py @@ -941,6 +941,15 @@ def from_yaml(cls, path: str | Path) -> GraqleConfig: Migration: replace ``GraqleConfig.from_yaml("graqle.yaml")`` with ``resolve_config().yaml_source`` (then call ``from_yaml`` on the returned path) or use the resolver's higher-level helpers. + + Raises: + graqle.assurance.settings.ConfigurationError: CR-012 (DAG-2026). + (a) ``assurance.enabled`` is present in the yaml — it is derived + from ``GRAQLE_DAG_ENABLED`` and has no yaml form; or (b) the + flag is ON and a required ``GRAQLE_DAG_*`` setting is absent + or invalid. This is the intended fail-closed startup boundary + (INV-FLAG-4): every yaml load is a startup boundary. With the + flag OFF (the default) neither condition can fire. """ # CR-002 PR-002c: emit deprecation warning when resolver is enabled # but the caller still routes through direct from_yaml. Suppress for diff --git a/tests/test_assurance/test_settings.py b/tests/test_assurance/test_settings.py index 0cf6f265..980879eb 100644 --- a/tests/test_assurance/test_settings.py +++ b/tests/test_assurance/test_settings.py @@ -237,7 +237,8 @@ def test_ac4_zero_rejected(monkeypatch: pytest.MonkeyPatch, var: str, flag: str) _set_required(monkeypatch, **{var: "0"}) with pytest.raises(ConfigurationError, match="DAG settings invalid") as ei: load_dag_settings() - assert var[len("GRAQLE_DAG_"):].lower() in str(ei.value) + assert var in str(ei.value) # names the env var, never the value + assert "zero is rejected" in str(ei.value) assert dag._cache is None From a25a94ba94ba6911122221f1ac88eba793608b38 Mon Sep 17 00:00:00 2001 From: quantamixsol Date: Sun, 13 Sep 2026 22:44:41 +0200 Subject: [PATCH 3/5] =?UTF-8?q?CR-012/PR-012a:=20round-2=20fixes=20?= =?UTF-8?q?=E2=80=94=20B1=20salt-keyed=20config=5Fversion,=20M1=20blank-is?= =?UTF-8?q?-absent,=20M2=20cache=20key,=20M3=20path/file=20safety,=20N1/N3?= =?UTF-8?q?/N4=20(rulings=20N2/N3/N4;=20PR-339=20round=201)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit B1: new DagSettings.config_salt (GRAQLE_DAG_CONFIG_SALT, REQUIRED_WHEN_ENABLED, >=32 bytes, SecretStr, never logged/serialised, excluded from the payload); config_version HMAC key = salt, message = label | canon(secret values) | canon(non-secret payload); keying tag deployment_salt_v1 / joint_canonical_v1 in config_provenance (ruling N2). M1: '', whitespace and None are absent for every REQUIRED_WHEN_ENABLED field. M2: cache token = (flag snapshot, sha256 of all GRAQLE_DAG_* env values, resolved secrets path, mtime_ns, size); flag snapshotted once per load; reset clears the token under the lock. M3: path-typed fields excluded from the fingerprint; secrets file resolve(), regular-file check, POSIX 0o077 refusal with chmod 600 message, Windows WARNING. N1: core.rbac ImportError -> ConfigurationError. N3: tautology documented. N4: DEBUG log of exception type before from None. Tests: +22 (salt/blank/cache/path/mode/rbac/debug); 393 pass across tests/test_assurance tests/test_docs tests/test_config tests/test_public_api tests/test_packaging. Co-Authored-By: Claude Fable 5.1 (cherry picked from commit df5434443e0babb133999eb29aa8c00afed6b960) --- .env.example | 4 + graqle/assurance/settings.py | 204 ++++++++++++++++++++++---- tests/test_assurance/test_settings.py | 186 +++++++++++++++++++++++ 3 files changed, 363 insertions(+), 31 deletions(-) diff --git a/.env.example b/.env.example index 8aec9ad8..1849ac86 100644 --- a/.env.example +++ b/.env.example @@ -15,6 +15,10 @@ GRAQLE_DAG_ENABLED=false # Private config file holding TS-2/TS-3 numeric values (gitignored). Optional; env wins. GRAQLE_DAG_SECRETS_PATH=.graqle/graqle_secrets.yaml +# Deployment secret keying the config_version fingerprint (PR-339 B1 / ruling N2). +# REQUIRED when enabled; at least 32 bytes; never logged, never serialised. +GRAQLE_DAG_CONFIG_SALT= + # Trace schema (CR-012 §4.4). Pin readers; STRICT=true rejects unknown versions instead of WARNING. GRAQLE_TRACE_SCHEMA_VERSION=3 GRAQLE_TRACE_SCHEMA_STRICT=false diff --git a/graqle/assurance/settings.py b/graqle/assurance/settings.py index 19748e06..493d8f6c 100644 --- a/graqle/assurance/settings.py +++ b/graqle/assurance/settings.py @@ -50,7 +50,7 @@ from typing import Any, Literal import yaml -from pydantic import Field, ValidationError, field_validator, model_validator +from pydantic import Field, SecretStr, ValidationError, field_validator, model_validator from pydantic_settings import BaseSettings, SettingsConfigDict from graqle.config.exceptions import GraqleConfigError @@ -85,6 +85,7 @@ #: Fields that MUST be present when the flag is on (CR-012 §4.5, blueprint B2). REQUIRED_WHEN_ENABLED: tuple[str, ...] = ( + "config_salt", # CR-012 (PR-339 B1 / ruling N2): fingerprint key "cap_value", # CR-013 "hg05_poisoning_threshold", # CR-013 (blueprint B4) "hg07_materiality_threshold", # CR-013 @@ -108,9 +109,35 @@ "circuit_breaker_window", }) -# Domain-separation label for the joint commitment over secret-valued fields. +# Domain-separation label for the commitment over secret-valued fields. _SECRETS_DIGEST_LABEL = b"graqle.assurance.config_version.secrets.v1" +#: Deployment-layout fields (PR-339 M3): they describe WHERE a deployment keeps +#: things, not WHAT the configuration means, and would otherwise be anchored by +#: CR-018. Excluded from the fingerprint payload entirely. +PATH_FIELDS: frozenset[str] = frozenset({ + "secrets_path", + "recompute_queue_path", + "bench_results_dir", + "ontology_shapes_path", +}) + +#: Minimum length of ``GRAQLE_DAG_CONFIG_SALT`` in bytes (PR-339 B1). +_MIN_SALT_BYTES = 32 + +#: ``keying`` tags recorded in :func:`config_provenance` (ruling N2). +KEYING_DEPLOYMENT_SALT = "deployment_salt_v1" +KEYING_JOINT_CANONICAL = "joint_canonical_v1" + + +def _is_absent(value: object) -> bool: + """``None``, ``""`` and whitespace-only strings are ABSENT (PR-339 M1).""" + if value is None: + return True + if isinstance(value, SecretStr): + value = value.get_secret_value() + return isinstance(value, str) and not value.strip() + # Operator-readable, NUMBER-FREE hints for pydantic error types (sentinel M-3). # Bounds are deliberately not spelled out: the message must never let a # rejected value or a tuning bound be inferred from a log line. @@ -176,6 +203,10 @@ class DagSettings(BaseSettings): # ── CR-012 ──────────────────────────────────────────────────────────── enabled: bool = False secrets_path: str | None = None + # PR-339 B1 / ruling N2: deployment secret keying config_version. SecretStr + # ⇒ never in repr/model_dump; excluded from the fingerprint payload and from + # SECRET_VALUED_FIELDS (it is the KEY, never part of the message). + config_salt: SecretStr | None = Field(default=None, repr=False) # ── CR-013 hard gates + cap (values TS-3; 0 rejected; no default) ───── cap_value: float | None = Field(default=None, gt=0.0, lt=1.0) @@ -228,20 +259,41 @@ def _parse_flag(cls, value: object) -> bool: @field_validator("escalation_role") @classmethod def _role_known(cls, value: str) -> str: - from graqle.core.rbac import ROLE_PERMISSIONS # lazy: rbac is a stdlib leaf - + try: + from graqle.core.rbac import ROLE_PERMISSIONS # lazy: rbac is a stdlib leaf + except ImportError as exc: # PR-339 N1: attributable, never an opaque value_error + raise ConfigurationError( + f"core.rbac unavailable ({type(exc).__name__}); cannot validate " + f"{ENV_PREFIX}ESCALATION_ROLE" + ) from None if value not in ROLE_PERMISSIONS: raise ValueError( f"escalation_role {value!r} is not a key of core.rbac.ROLE_PERMISSIONS" ) return value + @field_validator("config_salt") + @classmethod + def _salt_long_enough(cls, value: SecretStr | None) -> SecretStr | None: + # PR-339 B1: blank is "absent" (handled by _required_when_enabled); a + # present salt must carry at least _MIN_SALT_BYTES bytes. The message + # never includes the value. + if value is None or _is_absent(value): + return value + if len(value.get_secret_value().encode("utf-8")) < _MIN_SALT_BYTES: + raise ConfigurationError( + f"{ENV_PREFIX}CONFIG_SALT is too short; it must be at least " + f"{_MIN_SALT_BYTES} bytes of high-entropy secret" + ) + return value + @model_validator(mode="after") def _required_when_enabled(self) -> DagSettings: # Read-only: the model is frozen; this validator never assigns. if not self.enabled: return self - missing = [f for f in REQUIRED_WHEN_ENABLED if getattr(self, f) is None] + # PR-339 M1: "", whitespace and None are all ABSENT. + missing = [f for f in REQUIRED_WHEN_ENABLED if _is_absent(getattr(self, f))] if missing: # Non-ValueError exceptions propagate unwrapped from pydantic # validators, so callers see ConfigurationError directly (AC-3). @@ -262,11 +314,63 @@ def _required_when_enabled(self) -> DagSettings: # assignment form one critical section; concurrent first loads (threaded # servers, parallel fixtures) must not interleave or lose provenance. _cache_lock = threading.RLock() +# PR-339 M2: the cache is keyed on everything that can change a load — the flag, +# every GRAQLE_DAG_* value, and the resolved private file (path, mtime_ns, size) +# — so a rotated secret or a changed variable is never served stale in a +# long-lived MCP process. Token of the last successful load: +_cache_token: tuple[bool, str, str, int, int] | None = None def _secrets_path() -> Path: raw = os.environ.get(ENV_PREFIX + "SECRETS_PATH", "").strip() or DEFAULT_SECRETS_PATH - return Path(raw).expanduser() + # PR-339 M3: resolve once (symlinks, relative segments) so the safety checks + # and the cache key see the real file. + return Path(raw).expanduser().resolve() + + +def _env_fingerprint() -> str: + """sha256 over every ``GRAQLE_DAG_*`` name=value pair (sorted). Values enter + the hash only; the digest is never logged (it commits to the salt).""" + items = sorted( + (k.upper(), v) for k, v in os.environ.items() if k.upper().startswith(ENV_PREFIX) + ) + h = hashlib.sha256() + for k, v in items: + h.update(k.encode("utf-8") + b"=" + v.encode("utf-8", "surrogateescape") + b"\0") + return h.hexdigest() + + +def _cache_key(flag: bool) -> tuple[bool, str, str, int, int]: + path = _secrets_path() + try: + st = path.stat() + mtime_ns, size = st.st_mtime_ns, st.st_size + except OSError: + mtime_ns, size = -1, -1 + return (flag, _env_fingerprint(), str(path), mtime_ns, size) + + +def _check_secrets_file_safety(path: Path) -> None: + """PR-339 M3: refuse non-regular files; on POSIX refuse group/world access + bits; on Windows warn that mode bits are not checked. The path is not a + secret and stays in the message; content never does.""" + if not path.is_file(): + raise ConfigurationError( + f"DAG private config {path} is not a regular file (directory, FIFO or device)" + ) + if os.name == "posix": + mode = path.stat().st_mode & 0o777 + if mode & 0o077: + raise ConfigurationError( + f"DAG private config {path} is readable by group or others " + f"(mode {mode:04o}); run: chmod 600 {path}" + ) + else: + logger.warning( + "DAG private config %s: file permission bits are not checked on this " + "platform; restrict the file ACL to the service account", + path, + ) def _load_secrets_file(path: Path) -> dict[str, Any]: @@ -276,8 +380,9 @@ def _load_secrets_file(path: Path) -> dict[str, Any]: prefixed names (either case). Error messages name the file and the exception class only — never file content (parse-error oracle). """ - if not path.is_file(): + if not path.exists(): return {} + _check_secrets_file_safety(path) try: data = yaml.safe_load(path.read_text(encoding="utf-8")) except yaml.YAMLError as exc: @@ -319,8 +424,8 @@ def _unknown_prefixed_env_vars() -> list[str]: ) -def _cache_is_fresh() -> bool: - return _cache is not None and _cache.enabled == is_dag_enabled() +def _cache_is_fresh(key: tuple[bool, str, str, int, int]) -> bool: + return _cache is not None and _cache_token == key def load_dag_settings(*, force: bool = False) -> DagSettings: @@ -328,23 +433,27 @@ def load_dag_settings(*, force: bool = False) -> DagSettings: Precedence: environment > private file > field default. The cache is only assigned on success (AC-3: no partial object is ever cached) and is - invalidated automatically if the flag's environment value changes. - Thread-safe: the whole load is one critical section (double-checked lock). + invalidated automatically when the flag, any ``GRAQLE_DAG_*`` value, or the + private file (path, mtime_ns, size) changes (PR-339 M2). Thread-safe: the + whole load is one critical section (double-checked lock). The flag is + snapshotted once per call. Raises: ConfigurationError: on any invalid or missing-required value. The message carries setting names, never values. """ - if not force and _cache_is_fresh(): + flag = is_dag_enabled() + key = _cache_key(flag) + if not force and _cache_is_fresh(key): return _cache # type: ignore[return-value] with _cache_lock: - if not force and _cache_is_fresh(): + if not force and _cache_is_fresh(key): return _cache # type: ignore[return-value] - return _load_dag_settings_unlocked() + return _load_dag_settings_unlocked(flag, key) -def _load_dag_settings_unlocked() -> DagSettings: - global _cache, _provenance +def _load_dag_settings_unlocked(flag: bool, key: tuple[bool, str, str, int, int]) -> DagSettings: + global _cache, _provenance, _cache_token unknown = _unknown_prefixed_env_vars() if unknown: @@ -380,7 +489,7 @@ def _load_dag_settings_unlocked() -> DagSettings: ) raise ConfigurationError(f"DAG settings invalid: {details}") from None - if settings.enabled != is_dag_enabled(): # pragma: no cover — defensive (single source) + if settings.enabled != flag: # pragma: no cover — defensive (single source) raise ConfigurationError( f"DagSettings.enabled disagrees with {ENV_FLAG}; single source violated" ) @@ -395,22 +504,28 @@ def _load_dag_settings_unlocked() -> DagSettings: provenance[name] = "SAFE_DEFAULT" else: provenance[name] = "UNSET" + # Ruling N2: record which keying config_version used (never the key). + provenance["keying"] = _keying(settings) _provenance = provenance _cache = settings + _cache_token = key logger.info( - "dag.settings.loaded enabled=%s config_version=%s", + "dag.settings.loaded enabled=%s keying=%s config_version=%s", settings.enabled, + provenance["keying"], config_version(settings)[:23], ) return settings def reset_dag_settings_cache() -> None: - """Drop the cached settings and provenance (tests, ``graq serve`` reload).""" - global _cache, _provenance - _cache = None - _provenance = {} + """Drop the cached settings, token and provenance (tests, ``graq serve`` reload).""" + global _cache, _provenance, _cache_token + with _cache_lock: + _cache = None + _cache_token = None + _provenance = {} def config_provenance() -> Mapping[str, str]: @@ -441,30 +556,48 @@ def _config_version_payload(settings: DagSettings) -> dict[str, Any]: ) dumped = settings.model_dump(mode="json") - public = {k: v for k, v in dumped.items() if k not in SECRET_VALUED_FIELDS} + # PR-339 M3: deployment-layout paths never enter the fingerprint; the salt + # (the KEY) never enters it either. Everything else non-secret is in clear. + excluded = SECRET_VALUED_FIELDS | PATH_FIELDS | {"config_salt"} + public = {k: v for k, v in dumped.items() if k not in excluded} secret_items = { k: dumped[k] for k in sorted(SECRET_VALUED_FIELDS) if dumped.get(k) is not None } public["_secret_fields_set"] = sorted(secret_items) + public["_keying"] = _keying(settings) digest: str | None = None if secret_items: - # key = the secret values (PRF key, never serialised); msg = the - # non-secret payload under a domain label, so the digest is BOUND to - # the configuration it fingerprints (sentinel M-1), not to a constant. - key = canon(secret_items) - msg = _SECRETS_DIGEST_LABEL + b"|" + canon(public) + # PR-339 B1 / ruling N2: key = deployment salt (GRAQLE_DAG_CONFIG_SALT); + # message = label ‖ canon(secret values) ‖ canon(non-secret payload). + # Without a salt (flag off, no key configured) the joint canonical form + # is the fallback key; the `_keying` tag says which one was used. + msg = _SECRETS_DIGEST_LABEL + b"|" + canon(secret_items) + b"|" + canon(public) + key = _salt_bytes(settings) or canon(secret_items) digest = hmac.new(key, msg, hashlib.sha256).hexdigest() public["_secrets_digest"] = digest return public +def _salt_bytes(settings: DagSettings) -> bytes | None: + salt = settings.config_salt + if salt is None or _is_absent(salt): + return None + return salt.get_secret_value().encode("utf-8") + + +def _keying(settings: DagSettings) -> str: + """Which key material :func:`config_version` uses (ruling N2 `keying` tag).""" + return KEYING_DEPLOYMENT_SALT if _salt_bytes(settings) else KEYING_JOINT_CANONICAL + + def config_version(settings: DagSettings) -> str: """``"sha256:"`` fingerprint of the settings for ``DeterminismRecord.config_version`` (CR-012 §4.3) and run logs. - Deterministic across processes for the same configuration; secret values - never appear in clear (see :func:`_config_version_payload`). Any - canonicalisation failure is fail-closed — never coerced. + Deterministic across processes for the same configuration AND the same + deployment salt; secret values and the salt never appear in clear (see + :func:`_config_version_payload`). Any canonicalisation failure is + fail-closed — never coerced. """ try: from graqle.governance.tamper_evidence.canonicalize import canon # lazy @@ -473,6 +606,9 @@ def config_version(settings: DagSettings) -> str: except ConfigurationError: raise except Exception as exc: # TamperEvidenceError, ImportError, ... + # PR-339 N4: keep the failure diagnosable (type only, never the message, + # which could carry a value) before suppressing the chain. + logger.debug("config_version: canonicalisation failed with %s", type(exc).__name__) raise ConfigurationError( f"config_version could not be computed: {type(exc).__name__}" ) from None @@ -488,6 +624,12 @@ def validate_flag_consistency(cfg: Any) -> None: Called from ``GraqleConfig.from_yaml()`` and from MCP server boot. With the flag off this is a single environment read. + + PR-339 N3: today ``cfg.assurance.enabled`` reads the same environment + variable as :func:`is_dag_enabled`, so the mismatch branch is a tautology + by construction. It is kept deliberately: it is the guard that fires the + moment anyone monkey-patches or subclasses ``AssuranceConfig`` into a second + flag surface (INV-FLAG-1), which is exactly the failure the design forbids. """ env_on = is_dag_enabled() assurance = getattr(cfg, "assurance", None) diff --git a/tests/test_assurance/test_settings.py b/tests/test_assurance/test_settings.py index 980879eb..dba24e0d 100644 --- a/tests/test_assurance/test_settings.py +++ b/tests/test_assurance/test_settings.py @@ -54,7 +54,10 @@ _ENV_EXAMPLE = _REPO_ROOT / ".env.example" # Substitute values — deliberately NOT the real ones. +# The salt is a 40-byte test substitute (PR-339 B1: >= 32 bytes required). +_SUBSTITUTE_SALT = "test-substitute-salt-0123456789abcdef-XYZ" _SUBSTITUTE_REQUIRED: dict[str, str] = { + "GRAQLE_DAG_CONFIG_SALT": _SUBSTITUTE_SALT, "GRAQLE_DAG_CAP_VALUE": "0.31", "GRAQLE_DAG_HG05_POISONING_THRESHOLD": "0.61", "GRAQLE_DAG_HG07_MATERIALITY_THRESHOLD": "0.51", @@ -326,6 +329,7 @@ def test_ac5_env_example_declares_flag_off_and_schema_pins() -> None: def _write_private(tmp_path: Path, monkeypatch: pytest.MonkeyPatch, body: str) -> Path: p = tmp_path / "graqle_secrets.yaml" p.write_text(body, encoding="utf-8") + p.chmod(0o600) # PR-339 M3: the loader refuses group/world-readable files on POSIX monkeypatch.setenv("GRAQLE_DAG_SECRETS_PATH", str(p)) return p @@ -507,3 +511,185 @@ def test_assurance_settings_import_does_not_load_governance() -> None: "assert not bad, bad" ) subprocess.run([sys.executable, "-c", code], cwd=_REPO_ROOT, check=True) + + +# ─────────────── PR-339 round 2 — B1 salt · M1 blank strings · M2 cache key · M3 paths ── + + +def test_b1_salt_required_when_enabled(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("GRAQLE_DAG_ENABLED", "true") + _set_required(monkeypatch) + monkeypatch.delenv("GRAQLE_DAG_CONFIG_SALT") + with pytest.raises(ConfigurationError, match="GRAQLE_DAG_CONFIG_SALT"): + load_dag_settings() + assert dag._cache is None + + +def test_b1_short_salt_rejected_without_echo(monkeypatch: pytest.MonkeyPatch) -> None: + _set_required(monkeypatch, GRAQLE_DAG_CONFIG_SALT="too-short") + with pytest.raises(ConfigurationError) as ei: + load_dag_settings() + assert "GRAQLE_DAG_CONFIG_SALT" in str(ei.value) + assert "too-short" not in str(ei.value) + + +def test_b1_identical_secrets_different_salts_differ(monkeypatch: pytest.MonkeyPatch) -> None: + _set_required(monkeypatch) + a = config_version(load_dag_settings()) + assert config_provenance()["keying"] == "deployment_salt_v1" + monkeypatch.setenv("GRAQLE_DAG_CONFIG_SALT", _SUBSTITUTE_SALT[::-1]) + b = config_version(load_dag_settings()) + assert a != b + + +def test_b1_salt_never_in_payload_dump_repr_logs_or_provenance( + monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture +) -> None: + _set_required(monkeypatch) + with caplog.at_level(logging.DEBUG, logger="graqle.assurance.settings"): + s = load_dag_settings() + config_version(s) + payload = dag._config_version_payload(s) + assert "config_salt" not in payload + flat = " ".join( + [ + repr(payload), + repr(s), + str(s.model_dump(mode="json")), + repr(dict(config_provenance())), + *(r.getMessage() for r in caplog.records), + ] + ) + assert _SUBSTITUTE_SALT not in flat + assert s.config_salt is not None + assert s.config_salt.get_secret_value() == _SUBSTITUTE_SALT + + +def test_b1_flag_off_without_salt_uses_joint_keying() -> None: + s = load_dag_settings() + assert s.config_salt is None + assert re.fullmatch(r"sha256:[0-9a-f]{64}", config_version(s)) + assert config_provenance()["keying"] == "joint_canonical_v1" + + +@pytest.mark.parametrize( + "var", + [ + "GRAQLE_DAG_CALIBRATOR_VERSION", + "GRAQLE_DAG_TRAJECTORY_ESTIMATOR", + "GRAQLE_DAG_SIGNING_KEY_ID", + "GRAQLE_DAG_SIGNING_KEY_VERSION", + "GRAQLE_DAG_CONFIG_SALT", + ], +) +@pytest.mark.parametrize("blank", ["", " ", "\t\n"]) +def test_m1_blank_string_counts_as_absent( + monkeypatch: pytest.MonkeyPatch, var: str, blank: str +) -> None: + monkeypatch.setenv("GRAQLE_DAG_ENABLED", "true") + _set_required(monkeypatch, **{var: blank}) + with pytest.raises(ConfigurationError, match=var): + load_dag_settings() + assert dag._cache is None + + +def test_m2_env_change_invalidates_cache(monkeypatch: pytest.MonkeyPatch) -> None: + _set_required(monkeypatch) + first = load_dag_settings() + monkeypatch.setenv("GRAQLE_DAG_CAP_VALUE", "0.37") + second = load_dag_settings() + assert second is not first and second.cap_value == 0.37 + + +def test_m2_rotated_private_file_invalidates_cache( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + p = _write_private(tmp_path, monkeypatch, "cap_value: 0.31\n") + first = load_dag_settings() + assert first.cap_value == 0.31 + p.write_text("cap_value: 0.37\n", encoding="utf-8") + st = p.stat() + dag.os.utime(p, ns=(st.st_atime_ns, st.st_mtime_ns + 5_000_000)) + second = load_dag_settings() + assert second is not first and second.cap_value == 0.37 + + +def test_m2_unchanged_environment_serves_cache() -> None: + assert load_dag_settings() is load_dag_settings() + + +def test_m3_path_fields_excluded_from_fingerprint(monkeypatch: pytest.MonkeyPatch) -> None: + _set_required( + monkeypatch, + GRAQLE_DAG_BENCH_RESULTS_DIR="bench-a", + GRAQLE_DAG_ONTOLOGY_SHAPES_PATH="shapes-a.ttl", + ) + s = load_dag_settings() + payload = dag._config_version_payload(s) + for f in ("secrets_path", "recompute_queue_path", "bench_results_dir", "ontology_shapes_path"): + assert f not in payload + assert payload["signing_key_id"] == "kid-test-2026" # a public kid stays in clear + a = config_version(s) + monkeypatch.setenv("GRAQLE_DAG_BENCH_RESULTS_DIR", "bench-b") + assert config_version(load_dag_settings()) == a # topology change, same semantics + + +def test_m3_non_regular_secrets_file_rejected( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.setenv("GRAQLE_DAG_SECRETS_PATH", str(tmp_path)) # a directory, not a file + with pytest.raises(ConfigurationError, match="regular file"): + load_dag_settings() + + +@pytest.mark.skipif(sys.platform == "win32", reason="POSIX mode bits") +def test_m3_posix_group_or_world_readable_rejected( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + p = _write_private(tmp_path, monkeypatch, "cap_value: 0.31\n") + p.chmod(0o644) + with pytest.raises(ConfigurationError, match="chmod 600"): + load_dag_settings() + p.chmod(0o600) + assert load_dag_settings().cap_value == 0.31 + + +@pytest.mark.skipif(sys.platform != "win32", reason="Windows warning path") +def test_m3_windows_warns_that_mode_bits_are_unchecked( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture +) -> None: + _write_private(tmp_path, monkeypatch, "cap_value: 0.31\n") + with caplog.at_level(logging.WARNING, logger="graqle.assurance.settings"): + load_dag_settings() + assert any("permission" in r.getMessage().lower() for r in caplog.records) + + +def test_n1_rbac_import_failure_is_attributable(monkeypatch: pytest.MonkeyPatch) -> None: + import builtins + + real_import = builtins.__import__ + + def _fake(name, *args, **kwargs): # noqa: ANN001 + if name == "graqle.core.rbac": + raise ImportError("simulated") + return real_import(name, *args, **kwargs) + + monkeypatch.setattr(builtins, "__import__", _fake) + with pytest.raises(ConfigurationError, match="core.rbac unavailable"): + DagSettings(escalation_role="lead") + + +def test_n4_canon_failure_type_logged_at_debug( + monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture +) -> None: + import graqle.governance.tamper_evidence.canonicalize as canonmod + + def _boom(_: object) -> bytes: + raise RuntimeError("canon down") + + monkeypatch.setattr(canonmod, "canon", _boom) + with caplog.at_level(logging.DEBUG, logger="graqle.assurance.settings"): + with pytest.raises(ConfigurationError): + config_version(load_dag_settings()) + assert any("RuntimeError" in r.getMessage() for r in caplog.records) + assert not any("canon down" in r.getMessage() for r in caplog.records) From d6fb42297f1b348ab4155c793830c7e0db6c690a Mon Sep 17 00:00:00 2001 From: quantamixsol Date: Sun, 13 Sep 2026 22:51:10 +0200 Subject: [PATCH 4/5] =?UTF-8?q?CR-012/PR-012a:=20sentinel=20BLK-1=20?= =?UTF-8?q?=E2=80=94=20open-then-check=20secrets=20file=20on=20one=20descr?= =?UTF-8?q?iptor=20(O=5FNOFOLLOW=20+=20fstat),=20symlink=20test?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Post-implementation sentinel pass 1 on df543444 (graq_reason tool 0.685 / synth 81%) flagged the resolve->stat->open sequence as a TOCTOU/symlink race. Fix: _open_secrets_file opens with O_RDONLY|O_NOFOLLOW|O_CLOEXEC on POSIX and runs the regular-file and 0o077 mode checks on fstat() of the open descriptor before reading from it; ELOOP/IsADirectory -> ConfigurationError naming the path; Windows keeps the best-effort path + WARNING. Refuted after verification (not changed): MAJ-1 'salt rotation invisible to the cache token' (the env fingerprint covers GRAQLE_DAG_CONFIG_SALT; test_b1_identical_secrets_different_salts_differ reloads without force), MAJ-2 'keying tag is an oracle' (with the salt required when enabled the tag is a function of the public flag state; the tag is required by ruling N2). 393 pass, 7 platform skips. Co-Authored-By: Claude Fable 5.1 (cherry picked from commit 8828d6e067d661ce84b29464dc2d6bc311f6100d) --- graqle/assurance/settings.py | 83 ++++++++++++++++++++------- tests/test_assurance/test_settings.py | 20 +++++++ 2 files changed, 83 insertions(+), 20 deletions(-) diff --git a/graqle/assurance/settings.py b/graqle/assurance/settings.py index 493d8f6c..87cad1c2 100644 --- a/graqle/assurance/settings.py +++ b/graqle/assurance/settings.py @@ -43,6 +43,7 @@ import hmac import logging import os +import stat import threading from collections.abc import Mapping from pathlib import Path @@ -350,27 +351,69 @@ def _cache_key(flag: bool) -> tuple[bool, str, str, int, int]: return (flag, _env_fingerprint(), str(path), mtime_ns, size) -def _check_secrets_file_safety(path: Path) -> None: - """PR-339 M3: refuse non-regular files; on POSIX refuse group/world access - bits; on Windows warn that mode bits are not checked. The path is not a - secret and stays in the message; content never does.""" +def _open_secrets_file(path: Path) -> str | None: + """PR-339 M3 + sentinel BLK-1: open-then-check on ONE descriptor. + + Missing file ⇒ ``None``. On POSIX the file is opened with ``O_NOFOLLOW`` + (a symlink at the resolved path is refused) and every check — regular + file, group/world mode bits — runs on ``fstat`` of the open descriptor, so + nothing can be swapped between the check and the read (TOCTOU). On other + platforms mode bits cannot be checked and a WARNING says so. The path is + not a secret and stays in the message; content never does. + """ + if os.name == "posix": + flags = os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0) | getattr(os, "O_CLOEXEC", 0) + try: + fd = os.open(path, flags) + except FileNotFoundError: + return None + except OSError as exc: + if getattr(exc, "errno", None) == getattr(os, "ELOOP", -1) or isinstance( + exc, IsADirectoryError + ): + raise ConfigurationError( + f"DAG private config {path} is a symlink or directory; " + "point GRAQLE_DAG_SECRETS_PATH at a regular file" + ) from None + raise ConfigurationError( + f"DAG private config {path} could not be opened: {type(exc).__name__}" + ) from None + try: + st = os.fstat(fd) + if not stat.S_ISREG(st.st_mode): + raise ConfigurationError( + f"DAG private config {path} is not a regular file (directory, FIFO or device)" + ) + mode = st.st_mode & 0o777 + if mode & 0o077: + raise ConfigurationError( + f"DAG private config {path} is readable by group or others " + f"(mode {mode:04o}); run: chmod 600 {path}" + ) + with os.fdopen(fd, "r", encoding="utf-8") as fh: + fd = -1 # ownership transferred to the file object + return fh.read() + finally: + if fd != -1: + os.close(fd) + # Non-POSIX (Windows): no O_NOFOLLOW / mode bits; best effort + WARNING. + if not path.exists(): + return None if not path.is_file(): raise ConfigurationError( f"DAG private config {path} is not a regular file (directory, FIFO or device)" ) - if os.name == "posix": - mode = path.stat().st_mode & 0o777 - if mode & 0o077: - raise ConfigurationError( - f"DAG private config {path} is readable by group or others " - f"(mode {mode:04o}); run: chmod 600 {path}" - ) - else: - logger.warning( - "DAG private config %s: file permission bits are not checked on this " - "platform; restrict the file ACL to the service account", - path, - ) + logger.warning( + "DAG private config %s: file permission bits are not checked on this " + "platform; restrict the file ACL to the service account", + path, + ) + try: + return path.read_text(encoding="utf-8") + except OSError as exc: + raise ConfigurationError( + f"DAG private config {path} could not be read: {type(exc).__name__}" + ) from None def _load_secrets_file(path: Path) -> dict[str, Any]: @@ -380,11 +423,11 @@ def _load_secrets_file(path: Path) -> dict[str, Any]: prefixed names (either case). Error messages name the file and the exception class only — never file content (parse-error oracle). """ - if not path.exists(): + text = _open_secrets_file(path) + if text is None: return {} - _check_secrets_file_safety(path) try: - data = yaml.safe_load(path.read_text(encoding="utf-8")) + data = yaml.safe_load(text) except yaml.YAMLError as exc: mark = getattr(exc, "problem_mark", None) where = f" (line {mark.line + 1})" if mark is not None else "" diff --git a/tests/test_assurance/test_settings.py b/tests/test_assurance/test_settings.py index dba24e0d..2d895c2b 100644 --- a/tests/test_assurance/test_settings.py +++ b/tests/test_assurance/test_settings.py @@ -654,6 +654,26 @@ def test_m3_posix_group_or_world_readable_rejected( assert load_dag_settings().cap_value == 0.31 +@pytest.mark.skipif(sys.platform == "win32", reason="POSIX O_NOFOLLOW") +def test_m3_symlinked_secrets_file_rejected_at_open( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """Sentinel BLK-1 (round-2 pass 1): the file is opened with O_NOFOLLOW and + checked via fstat on the open descriptor, so a symlink swapped in after + resolve() cannot redirect the read (TOCTOU).""" + real = tmp_path / "real.yaml" + real.write_text("cap_value: 0.31\n", encoding="utf-8") + real.chmod(0o600) + link = tmp_path / "link.yaml" + link.symlink_to(real) + monkeypatch.setenv("GRAQLE_DAG_SECRETS_PATH", str(link)) + # resolve() follows the link to the real file, so a stable symlink works … + assert load_dag_settings().cap_value == 0.31 + # … but the descriptor-level guard rejects a symlink at the resolved path itself. + with pytest.raises(ConfigurationError, match="symlink"): + dag._open_secrets_file(link) + + @pytest.mark.skipif(sys.platform != "win32", reason="Windows warning path") def test_m3_windows_warns_that_mode_bits_are_unchecked( tmp_path: Path, monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture From ffe77e0186c61aecd83be9aa413d89b685a3932f Mon Sep 17 00:00:00 2001 From: quantamixsol Date: Mon, 14 Sep 2026 08:03:51 +0200 Subject: [PATCH 5/5] CR-012/PR-012a: C1-C3 conditional-approval fixes (unkeyed checksum, st_ino in cache token, operator docs) Research-Team rounds 2+3 APPROVED conditional on C1-C3 (pull/339#issuecomment-5656126399). C1: the value-keyed HMAC fallback is removed - a MAC keyed by the data it authenticates must not exist as a precedent even when unreachable. With a salt the digest is HMAC-SHA256 under GRAQLE_DAG_CONFIG_SALT (keying=deployment_salt_v1); without one it is an explicitly unkeyed sha256 over label | canon(secrets) | canon(public) (keying=unkeyed_checksum_v1). KEYING_JOINT_CANONICAL deleted; CR-018 note recorded that anchoring must refuse any keying other than deployment_salt_v1. C2: st_ino added to the cache token - an atomic rename-replacement can preserve mtime_ns and size. C3: .env.example requires a CSPRNG-generated salt (openssl rand -base64 48, never a passphrase); new 'Operating the DAG flag' section in docs/dag/ground-truth-addendum.md covers salt generation and rotation, the Kubernetes 0644 refusal and its defaultMode 0400 remedy, and the Windows residual. +4 tests (unkeyed digest recomputed and shown not to be the old MAC, source grep against the precedent, inode-change reload with mtime_ns and size held equal). 395 passed, 7 skipped; ruff clean; governance/drace/rbac/config-settings diff empty. Co-Authored-By: Claude Opus 5 (1M context) (cherry picked from commit 5aa4ae78e8790c190e11114e45ff9178af52c12b) --- .env.example | 3 ++ docs/dag/ground-truth-addendum.md | 8 ++++ graqle/assurance/settings.py | 60 ++++++++++++++++++--------- tests/test_assurance/test_settings.py | 56 ++++++++++++++++++++++++- 4 files changed, 105 insertions(+), 22 deletions(-) diff --git a/.env.example b/.env.example index 1849ac86..bd6c79a7 100644 --- a/.env.example +++ b/.env.example @@ -17,6 +17,9 @@ GRAQLE_DAG_SECRETS_PATH=.graqle/graqle_secrets.yaml # Deployment secret keying the config_version fingerprint (PR-339 B1 / ruling N2). # REQUIRED when enabled; at least 32 bytes; never logged, never serialised. +# Generate it with a CSPRNG — `openssl rand -base64 48` — never a passphrase or +# any human-chosen string. Rotating it changes every config_version computed +# afterwards; see docs/dag/ground-truth-addendum.md ("Operating the DAG flag"). GRAQLE_DAG_CONFIG_SALT= # Trace schema (CR-012 §4.4). Pin readers; STRICT=true rejects unknown versions instead of WARNING. diff --git a/docs/dag/ground-truth-addendum.md b/docs/dag/ground-truth-addendum.md index e59f3d57..284a0382 100644 --- a/docs/dag/ground-truth-addendum.md +++ b/docs/dag/ground-truth-addendum.md @@ -27,6 +27,14 @@ Every citation above was checked against the private master tree. Anchors that m - `plugins/mcp_dev_server.py`: `_handle_ingest` :13108 and the raw-path block :13120-13135 unchanged; helper `_project_root_from_graph_file` :5216 unchanged. Two additional raw `Path(str(_raw)).resolve().parent` sites exist at :12487 and :12711 that the CR's 10-site list omits; PR-012c sweeps 12 sites. - `pyproject.toml:60` already declares `pydantic-settings>=2.0` — CR-012 OQ-2 resolved: no new dependency. +## Operating the DAG flag (CR-012 / PR-012a, condition C3) + +These notes apply only when `GRAQLE_DAG_ENABLED` is on. With the flag off (the default) none of them is reachable. + +- **`GRAQLE_DAG_CONFIG_SALT` must come from a CSPRNG.** It keys the HMAC that commits to every private tuning value in `config_version`, so a guessable salt makes that commitment guessable. Generate it with `openssl rand -base64 48` (or `python -c "import secrets; print(secrets.token_urlsafe(48))"`), store it with the other deployment secrets, and never use a passphrase or any human-chosen string. The loader enforces a 32-byte floor and nothing else — length alone is not entropy. Rotating the salt changes every `config_version` computed afterwards; earlier fingerprints in run logs stay valid for the configuration they recorded but will not reproduce under the new salt, so rotate deliberately and record when. When no salt is configured the fingerprint is an explicitly unkeyed checksum tagged `keying="unkeyed_checksum_v1"`; CR-018's anchoring path refuses anything other than `deployment_salt_v1`. +- **Kubernetes secret mounts default to `0644` and will be refused.** The private-values file is opened with `O_NOFOLLOW` and rejected when its mode carries any group or world bit, so a `secret` volume mounted with the default permissions fails closed at startup with a `chmod 600` message naming the path. Set `defaultMode: 0400` on the volume (or project the file into a directory the service account alone can read). This is deliberate: the file holds TS-2/TS-3 values. +- **Windows residual.** `O_NOFOLLOW` and POSIX mode bits do not exist there, so symlink refusal is best effort and permissions are not checked — the loader logs a WARNING saying so. Restrict the file's ACL to the service account. A `realpath` comparison after open is a candidate for PR-012c, not a guarantee today. + ## What this addendum does not change `GovernanceMiddleware.check()` is untouched by CR-012. With `GRAQLE_DAG_ENABLED` unset, every `GateResult.to_dict()` is byte-identical to 0.83.0 (CR-012 AC-9 golden fixture, PR-012c). diff --git a/graqle/assurance/settings.py b/graqle/assurance/settings.py index 87cad1c2..076157f0 100644 --- a/graqle/assurance/settings.py +++ b/graqle/assurance/settings.py @@ -127,8 +127,15 @@ _MIN_SALT_BYTES = 32 #: ``keying`` tags recorded in :func:`config_provenance` (ruling N2). +#: +#: ``deployment_salt_v1`` is the only keyed form: HMAC-SHA256 under +#: ``GRAQLE_DAG_CONFIG_SALT``. Without a salt — reachable only with the flag off, +#: where no required value is loaded — the fingerprint is an explicitly UNKEYED +#: checksum. A MAC keyed by the data it authenticates must not exist in this +#: codebase, even unreachable (PR-339 C1). CR-018: the anchoring path must refuse +#: any fingerprint whose ``keying != "deployment_salt_v1"``. KEYING_DEPLOYMENT_SALT = "deployment_salt_v1" -KEYING_JOINT_CANONICAL = "joint_canonical_v1" +KEYING_UNKEYED_CHECKSUM = "unkeyed_checksum_v1" def _is_absent(value: object) -> bool: @@ -315,11 +322,13 @@ def _required_when_enabled(self) -> DagSettings: # assignment form one critical section; concurrent first loads (threaded # servers, parallel fixtures) must not interleave or lose provenance. _cache_lock = threading.RLock() -# PR-339 M2: the cache is keyed on everything that can change a load — the flag, -# every GRAQLE_DAG_* value, and the resolved private file (path, mtime_ns, size) -# — so a rotated secret or a changed variable is never served stale in a -# long-lived MCP process. Token of the last successful load: -_cache_token: tuple[bool, str, str, int, int] | None = None +# PR-339 M2 + C2: the cache is keyed on everything that can change a load — the +# flag, every GRAQLE_DAG_* value, and the resolved private file (path, inode, +# mtime_ns, size) — so a rotated secret or a changed variable is never served +# stale in a long-lived MCP process. The inode is in the token because an atomic +# rename-replacement can preserve both mtime_ns and size (C2). Token of the last +# successful load: +_cache_token: tuple[bool, str, str, int, int, int] | None = None def _secrets_path() -> Path: @@ -341,14 +350,17 @@ def _env_fingerprint() -> str: return h.hexdigest() -def _cache_key(flag: bool) -> tuple[bool, str, str, int, int]: +def _cache_key(flag: bool) -> tuple[bool, str, str, int, int, int]: path = _secrets_path() try: st = path.stat() - mtime_ns, size = st.st_mtime_ns, st.st_size + # PR-339 C2: st_ino as well — an atomic rename-replacement can preserve + # mtime_ns and size. (st_ino is 0 on some Windows filesystems; the env + # fingerprint and the other components still change there.) + ino, mtime_ns, size = st.st_ino, st.st_mtime_ns, st.st_size except OSError: - mtime_ns, size = -1, -1 - return (flag, _env_fingerprint(), str(path), mtime_ns, size) + ino, mtime_ns, size = -1, -1, -1 + return (flag, _env_fingerprint(), str(path), ino, mtime_ns, size) def _open_secrets_file(path: Path) -> str | None: @@ -467,7 +479,7 @@ def _unknown_prefixed_env_vars() -> list[str]: ) -def _cache_is_fresh(key: tuple[bool, str, str, int, int]) -> bool: +def _cache_is_fresh(key: tuple[bool, str, str, int, int, int]) -> bool: return _cache is not None and _cache_token == key @@ -495,7 +507,9 @@ def load_dag_settings(*, force: bool = False) -> DagSettings: return _load_dag_settings_unlocked(flag, key) -def _load_dag_settings_unlocked(flag: bool, key: tuple[bool, str, str, int, int]) -> DagSettings: +def _load_dag_settings_unlocked( + flag: bool, key: tuple[bool, str, str, int, int, int] +) -> DagSettings: global _cache, _provenance, _cache_token unknown = _unknown_prefixed_env_vars() @@ -610,13 +624,19 @@ def _config_version_payload(settings: DagSettings) -> dict[str, Any]: public["_keying"] = _keying(settings) digest: str | None = None if secret_items: - # PR-339 B1 / ruling N2: key = deployment salt (GRAQLE_DAG_CONFIG_SALT); - # message = label ‖ canon(secret values) ‖ canon(non-secret payload). - # Without a salt (flag off, no key configured) the joint canonical form - # is the fallback key; the `_keying` tag says which one was used. + # PR-339 B1 / ruling N2: message = label ‖ canon(secret values) ‖ + # canon(non-secret payload), so the commitment is bound to the whole + # configuration. With a deployment salt it is an HMAC under that salt; + # without one (flag off ⇒ no required value is loaded) it is an + # explicitly UNKEYED checksum — never a MAC keyed by its own message + # (PR-339 C1). `_keying` records which form was used. msg = _SECRETS_DIGEST_LABEL + b"|" + canon(secret_items) + b"|" + canon(public) - key = _salt_bytes(settings) or canon(secret_items) - digest = hmac.new(key, msg, hashlib.sha256).hexdigest() + salt = _salt_bytes(settings) + digest = ( + hmac.new(salt, msg, hashlib.sha256).hexdigest() + if salt is not None + else hashlib.sha256(msg).hexdigest() + ) public["_secrets_digest"] = digest return public @@ -629,8 +649,8 @@ def _salt_bytes(settings: DagSettings) -> bytes | None: def _keying(settings: DagSettings) -> str: - """Which key material :func:`config_version` uses (ruling N2 `keying` tag).""" - return KEYING_DEPLOYMENT_SALT if _salt_bytes(settings) else KEYING_JOINT_CANONICAL + """Which form :func:`config_version` uses (ruling N2 `keying` tag).""" + return KEYING_DEPLOYMENT_SALT if _salt_bytes(settings) else KEYING_UNKEYED_CHECKSUM def config_version(settings: DagSettings) -> str: diff --git a/tests/test_assurance/test_settings.py b/tests/test_assurance/test_settings.py index 2d895c2b..9d95b65d 100644 --- a/tests/test_assurance/test_settings.py +++ b/tests/test_assurance/test_settings.py @@ -26,6 +26,8 @@ # dependencies: pytest, graqle.assurance.settings, graqle.config.settings # constraints: substitute values only (TS-2/TS-3) # -- /graqle:intelligence -- +import hashlib +import hmac import logging import re import subprocess @@ -565,11 +567,61 @@ def test_b1_salt_never_in_payload_dump_repr_logs_or_provenance( assert s.config_salt.get_secret_value() == _SUBSTITUTE_SALT -def test_b1_flag_off_without_salt_uses_joint_keying() -> None: +def test_c1_flag_off_without_salt_is_an_unkeyed_checksum( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """PR-339 C1: with no salt the digest is a plain sha256 over the message — + never an HMAC keyed by the values it authenticates.""" + monkeypatch.setenv("GRAQLE_DAG_CAP_VALUE", "0.31") # a secret value is present s = load_dag_settings() assert s.config_salt is None assert re.fullmatch(r"sha256:[0-9a-f]{64}", config_version(s)) - assert config_provenance()["keying"] == "joint_canonical_v1" + assert config_provenance()["keying"] == "unkeyed_checksum_v1" + + payload = dag._config_version_payload(s) + assert payload["_keying"] == "unkeyed_checksum_v1" + from graqle.governance.tamper_evidence.canonicalize import canon + + public = {k: v for k, v in payload.items() if k != "_secrets_digest"} + secret_items = {"cap_value": 0.31} + msg = dag._SECRETS_DIGEST_LABEL + b"|" + canon(secret_items) + b"|" + canon(public) + assert payload["_secrets_digest"] == hashlib.sha256(msg).hexdigest() + # and NOT the old value-keyed MAC + assert payload["_secrets_digest"] != hmac.new( + canon(secret_items), msg, hashlib.sha256 + ).hexdigest() + + +def test_c1_no_value_keyed_mac_remains_in_the_source() -> None: + """The fallback must not exist as a precedent, even unreachable.""" + src = (_REPO_ROOT / "graqle" / "assurance" / "settings.py").read_text(encoding="utf-8") + assert "or canon(secret_items)" not in src + assert "joint_canonical" not in src + + +def test_c2_inode_change_invalidates_cache( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """PR-339 C2: an atomic rename-replacement can preserve mtime_ns and size, + so the token carries st_ino too.""" + p = _write_private(tmp_path, monkeypatch, "cap_value: 0.31\n") + first = load_dag_settings() + assert first.cap_value == 0.31 + st = p.stat() + + replacement = tmp_path / "replacement.yaml" + replacement.write_text("cap_value: 0.37\n", encoding="utf-8") + replacement.chmod(0o600) + dag.os.utime(replacement, ns=(st.st_atime_ns, st.st_mtime_ns)) + dag.os.replace(replacement, p) # same path, same mtime_ns, same size + after = p.stat() + assert (after.st_mtime_ns, after.st_size) == (st.st_mtime_ns, st.st_size) + + second = load_dag_settings() + if after.st_ino and after.st_ino != st.st_ino: + assert second is not first and second.cap_value == 0.37 + else: # filesystems that report st_ino == 0 (some Windows volumes) + pytest.skip("filesystem does not expose a distinguishing inode") @pytest.mark.parametrize(