diff --git a/.github/workflows/consent-plane-surface.yml b/.github/workflows/consent-plane-surface.yml new file mode 100644 index 00000000000..901697660b3 --- /dev/null +++ b/.github/workflows/consent-plane-surface.yml @@ -0,0 +1,17 @@ +name: Consent Plane Surface +on: + pull_request: + push: + branches: [main] +permissions: + contents: read +jobs: + verify: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: { python-version: '3.x' } + - run: python -m pip install pyyaml + - run: python consent-plane/self_test.py + - run: python consent-plane/verify_surface.py diff --git a/consent-plane/self_test.py b/consent-plane/self_test.py new file mode 100644 index 00000000000..98b736230a3 --- /dev/null +++ b/consent-plane/self_test.py @@ -0,0 +1,21 @@ +#!/usr/bin/env python3 +"""Prove verify_surface fires both ways: passes on the real envelope, fires when +the containment is weakened or the surface_id is switched.""" +from __future__ import annotations +import copy, sys +from pathlib import Path +sys.path.insert(0, str(Path(__file__).resolve().parent)) +import verify_surface as v # noqa: E402 + +cp = v.load() +assert v.check(cp) == [], f"real envelope should pass: {v.check(cp)}" + +if cp.get("space_deny"): + weak = copy.deepcopy(cp); weak["space_deny"] = weak["space_deny"][:-1] + assert v.check(weak), "verifier did not fire on a weakened space_deny" + +switched = copy.deepcopy(cp) +switched["surface_id"] = "browser" if cp["surface_id"] != "browser" else "terminal" +assert v.check(switched), "verifier did not fire on a switched surface_id" + +print("OK: verify_surface fires both ways (holds on real; catches weakening + switch).") diff --git a/consent-plane/surface.yaml b/consent-plane/surface.yaml new file mode 100644 index 00000000000..df67a754163 --- /dev/null +++ b/consent-plane/surface.yaml @@ -0,0 +1,9 @@ +# Consent-plane surface envelope. Conforms to socioprophet-agent-standards +# consent-plane/001 + sourceos-spec isolation-spaces-and-taints. Enforced by +# consent-plane/verify_surface.py (consent-plane-surface CI). +surface_id: terminal +conforms_to: socioprophet-agent-standards/standards/consent-plane/surfaces_v1.yaml#terminal +purposes: [discover, implement, verify] +deny_purposes: [egress, operate] # a terminal must not egress or operate live infra +data_classes: [source-and-config, first-party-source] +space_deny: [kernel-space, system-space] # no OS-core / infra ring from a shell diff --git a/consent-plane/verify_surface.py b/consent-plane/verify_surface.py new file mode 100644 index 00000000000..1395500f767 --- /dev/null +++ b/consent-plane/verify_surface.py @@ -0,0 +1,72 @@ +#!/usr/bin/env python3 +"""Enforce this repo's consent-plane surface envelope (fail-closed). + +This repo IS the terminal surface; EXPECTED_SURFACE pins it so surface.yaml +cannot be silently switched to a weaker surface. Reads consent-plane/surface.yaml +and asserts the hard invariants. Proven both ways by consent-plane/self_test.py. +Conforms to socioprophet-agent-standards consent-plane/001 + sourceos-spec +isolation-spaces-and-taints. +""" +from __future__ import annotations +import sys +from pathlib import Path +try: + import yaml # type: ignore +except Exception as exc: # pragma: no cover + raise SystemExit("PyYAML is required (python -m pip install pyyaml)") from exc + +EXPECTED_SURFACE = "terminal" + +# Minimum containment each surface MUST assert (subset checks). +EXPECTED = { + "terminal": {"deny_purposes": {"egress", "operate"}, + "space_deny": {"kernel-space", "system-space"}}, + "notes": {"deny_purposes": {"egress", "operate"}, + "space_deny": {"kernel-space", "system-space", "data-namespace"}, + "consent_required": "per-purpose"}, + "browser": {"deny_purposes": {"implement", "operate"}, + "space_deny": {"kernel-space", "system-space", "user-space", "data-namespace"}, + "untrusted_input": True}, +} + + +def check(cp: dict) -> list[str]: + errors: list[str] = [] + sid = cp.get("surface_id") + if sid != EXPECTED_SURFACE: + return [f"surface_id must be {EXPECTED_SURFACE!r} for this repo, got {sid!r}"] + for key, want in EXPECTED[sid].items(): + got = cp.get(key) + if isinstance(want, set): + if not isinstance(got, list): + errors.append(f"{key} must be a list, got {type(got).__name__}") + continue + missing = want - set(got) + if missing: + errors.append(f"{key} must include {sorted(want)}; missing {sorted(missing)}") + elif got != want: + errors.append(f"{key} must be {want!r}, got {got!r}") + return errors + + +def load() -> dict: + cfg = Path(__file__).resolve().parent / "surface.yaml" + cp = yaml.safe_load(cfg.read_text()) + if not isinstance(cp, dict): + raise SystemExit("consent-plane/surface.yaml top-level must be a mapping") + return cp + + +def main() -> int: + errors = check(load()) + if errors: + print(f"FAIL: {EXPECTED_SURFACE} surface envelope violated:", file=sys.stderr) + for e in errors: + print(f" - {e}", file=sys.stderr) + return 1 + print(f"OK: {EXPECTED_SURFACE} surface envelope holds.") + return 0 + + +if __name__ == "__main__": + sys.exit(main())