From 958352089b443459a6368efc0e591c8a0a8b8604 Mon Sep 17 00:00:00 2001 From: tegwick Date: Fri, 16 Oct 2026 01:58:20 +0200 Subject: [PATCH] fix(cases): validate browser metadata against pinned owner source Assistant: codex Assistant-Model: gpt-6-astra Assistant-Session: 01a140c4-354a-7823-8f0d-8f717397f786 --- docs/security-case-execution-kernel-v1.md | 41 ++++ src/warden/case_inputs.py | 96 +++++++++ src/warden/case_run.py | 31 ++- tests/test_case_inputs.py | 185 ++++++++++++++++++ ...EN-WP-0044-security-case-follow-through.md | 12 ++ 5 files changed, 356 insertions(+), 9 deletions(-) create mode 100644 src/warden/case_inputs.py create mode 100644 tests/test_case_inputs.py diff --git a/docs/security-case-execution-kernel-v1.md b/docs/security-case-execution-kernel-v1.md index bb9d775..35289d7 100644 --- a/docs/security-case-execution-kernel-v1.md +++ b/docs/security-case-execution-kernel-v1.md @@ -84,3 +84,44 @@ lose a response then reconcile without mint, require explicit retry, check exact pins/session expiry, withhold malicious output and unconfirmed revocation, and prove created-only reverse undo plus uncertain-undo refusal. Ruff formatting and checks pass. No live token or target action occurred. + +## Browser input projection — 2026-10-16 source preparation + +An explicitly configured `BrowserInputs(netkingdom_root)` now validates browser +cases before admission. It calls the real NetKingdom type/case validator over +verified producer bytes in a disposable Python process (`-I`, empty environment, +timeout, no inherited output). The validator, composer, capability contract and lazy evidence +modules have exact source pins; missing or changed code refuses. There is no +automatic discovery or download. A producer upgrade needs reviewed pin changes. + +Only the exact reviewed 1.1/1.2 browser parameter definitions qualify. A digest +of each complete parameter definition list prevents reclassifying secrets as +metadata, loosening constraints, or adding fields under a recognized version. +The complete case passes producer validation, including required fields, +constraints and credential-shape checks. Explicit fixed/default overrides, +unknown fields and secret references refuse. Security-sensitive browser metadata +can therefore be present without being passed to an owner adapter: the only +projection is its descriptor's exact public/operational target identifier. +No raw parameter values are added to invocations or persisted receipts. + +`require_implemented=False` validates the acknowledged type's source, not its +execution authority. Existing descriptor/type/source/catalog/caller provenance +gates stay mandatory. Neither historical browser type contains the required +exact machine-descriptor map, and neither is promoted by this change. There are +still no installed native owner adapters, authenticated provenance verifier, +contained session/ordered run orchestration or live CLI dispatch. A journal digest +is a fingerprint and cannot authenticate native evidence. Completion still needs +owner-admitted descriptors and actual structural, mint/delivery, consumer and +revocation/rollback evidence. Local fixture success is not native acceptance. + +Tests use the actual producer checkout (override sibling location with +`WARDEN_TEST_NETKINGDOM_ROOT`), the real Executor, SQLite journal and injected +test-only owner adapter/evidence verifier. Their descriptor extensions and case +materialized labels exist only in memory. Canonical case/type bytes stay intact. + +Producer integration refinement: the accepted NetKingdom browser-case patch +`d32a7f51cbf3012bee3e4bb329f4259dea867eff1c59a2b01a65d72774488d05` +is now the tested producer revision. Its evidence module is imported lazily only +by `check-evidence`, outside this admission path; Warden nevertheless pins and +copies it with the complete reviewed producer set. Missing or modified bytes in +any of the four modules refuse admission. The test covers every dependency. diff --git a/src/warden/case_inputs.py b/src/warden/case_inputs.py new file mode 100644 index 0000000..baa00b2 --- /dev/null +++ b/src/warden/case_inputs.py @@ -0,0 +1,96 @@ +"""Reviewed browser metadata projection; no native trust or command dispatch. + +The producer validator and all reviewed producer modules are pinned below. +Their verified bytes run in a disposable tree, without caller environment or +ambient Python imports. Type parameters must match the exact reviewed version. +Machine descriptor extensions still require Executor's independent admission. +""" + +import hashlib +import json +from pathlib import Path +import subprocess +import sys +import tempfile + +from .case_receipt_state import Refused + +SOURCE_PINS = { + "tools/security-case-types/security_case_evidence.py": "3c5d021ca7f1fc41fba4b2ff42fcb036b916a2a09ffc5e147cc3db1874dd2401", + "tools/security-case-types/security_case_validator.py": "cbb9ab532d328f78cf37aca57891400e2e65d63a8ab3c5fe7bd1a4ff21dee56f", + "tools/security-scenario-composer/security_scenario_composer.py": "9f0cc69930aa8ee2cc3fb92236e6a99728df17b8c0fe36e1250241c38fc01c4f", + "tools/playbook-capability-contract/playbook_contract_validator.py": "2e1e0c809d4bc9d64c2d974cc23fecd1c5636785154c2554edd9191852fd926d", +} +PARAMETER_PINS = { + "1.1": "9ec2d6113bf70dbd35cfc247412363ff187e792bc82e24159a6f78e090aa3e4c", + "1.2": "c04385d176f5f70257fb0fa6fef7e48ab26ed7cf26f6b1c7f14b16237b0ebea6", +} + +_VALIDATE = """ +import json, sys +from pathlib import Path +sys.path.insert(0, str(Path(sys.argv[1]) / 'tools/security-case-types')) +import security_case_validator as v +case, typ = json.load(sys.stdin) +issues = v.validate_type(typ) + v.validate_case(case, typ, require_implemented=False) +print('valid' if not v.has_errors(issues) else 'invalid') +""" + + +class BrowserInputs: + """Explicit owner configuration, never inferred from a case-supplied path.""" + + def __init__(self, netkingdom_root: Path): + self.root = Path(netkingdom_root) + + def target(self, case: dict, typ: dict, target_parameter: str) -> str: + try: + spec = typ["spec"] + parameters = spec["parameters"] + fingerprint = hashlib.sha256( + json.dumps(parameters, sort_keys=True, separators=(",", ":")).encode() + ).hexdigest() + if ( + spec["id"] != "oidc-browser-relying-party" + or PARAMETER_PINS.get(spec["version"]) != fingerprint + ): + raise ValueError + declared = {item["name"]: item for item in parameters} + definition = declared.get(target_parameter, {}) + if ( + definition.get("sensitivity") not in ("public", "operational") + or definition.get("case_settable") is not True + ): + raise ValueError + # No secret references or newly classified metadata are grandfathered. + for name in case["spec"]["parameters"]: + item = declared.get(name, {}) + if item.get("case_settable") is not True or item.get( + "sensitivity" + ) not in ("public", "operational", "security_sensitive"): + raise ValueError + with tempfile.TemporaryDirectory( + prefix="warden-case-validator-" + ) as directory: + for relative, expected in SOURCE_PINS.items(): + source = (self.root / relative).read_bytes() + if hashlib.sha256(source).hexdigest() != expected: + raise ValueError + dest = Path(directory) / relative + dest.parent.mkdir(parents=True, exist_ok=True) + dest.write_bytes(source) + result = subprocess.run( + [sys.executable, "-I", "-c", _VALIDATE, directory], + input=json.dumps([case, typ]), + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.DEVNULL, + env={}, + timeout=10, + check=True, + ) + if result.stdout != "valid\n": + raise ValueError + return case["spec"]["parameters"][target_parameter] + except (OSError, ValueError, TypeError, KeyError, subprocess.SubprocessError): + raise Refused("browser metadata failed exact-version validation") from None diff --git a/src/warden/case_run.py b/src/warden/case_run.py index 7f1cce1..f16ab2a 100644 --- a/src/warden/case_run.py +++ b/src/warden/case_run.py @@ -20,6 +20,7 @@ import uuid from typing import Callable import yaml +from .case_inputs import BrowserInputs from .case_receipt_state import ( Outcome, Pins, @@ -329,6 +330,8 @@ class Executor: journal: Journal, adapters: dict[str, Adapter], evidence_verifier: Callable[[str, str, str], bool], + *, + browser_inputs: BrowserInputs | None = None, ): self.journal, self.adapters, self.evidence_verifier = ( journal, @@ -336,6 +339,7 @@ class Executor: evidence_verifier, ) self._admitted = {} + self.browser_inputs = browser_inputs def admit( self, @@ -421,15 +425,24 @@ class Executor: or descriptor.target_parameter not in declared ): raise Refused("target parameter is not declared by acknowledged type") - for name in parameters: - definition = declared.get(name) - if ( - definition is None - or definition.get("sensitivity") not in ("public", "operational") - or definition.get("case_settable") is False - ): - raise Refused("unknown, fixed or secret-sensitivity case parameter") - target = parameters.get(descriptor.target_parameter) + if case_type.get("id") == "oidc-browser-relying-party": + if type(self.browser_inputs) is not BrowserInputs: + raise Refused("browser metadata validator is not configured") + target = self.browser_inputs.target( + yaml.safe_load(case_source), + yaml.safe_load(type_source), + descriptor.target_parameter, + ) + else: + for name in parameters: + definition = declared.get(name) + if ( + definition is None + or definition.get("sensitivity") not in ("public", "operational") + or definition.get("case_settable") is False + ): + raise Refused("unknown, fixed or secret-sensitivity case parameter") + target = parameters.get(descriptor.target_parameter) if not isinstance(target, str) or not IDENTIFIER.fullmatch(target): raise Refused("target must be exact case metadata") adapter = self.adapters.get(descriptor.adapter) diff --git a/tests/test_case_inputs.py b/tests/test_case_inputs.py new file mode 100644 index 0000000..01e9407 --- /dev/null +++ b/tests/test_case_inputs.py @@ -0,0 +1,185 @@ +"""Actual producer validation and Executor; fake owner evidence, no native claims.""" + +import os +from pathlib import Path + +import pytest +import yaml + +from warden.case_inputs import BrowserInputs, SOURCE_PINS +from warden.case_run import digest +from warden.case_receipt_state import Outcome, Refused +from tests.test_case_run import rig as base_rig + + +@pytest.fixture +def rig(tmp_path): + return base_rig.__wrapped__(tmp_path) + + +NK = Path( + os.environ.get( + "WARDEN_TEST_NETKINGDOM_ROOT", + Path(__file__).resolve().parents[2] / "net-kingdom", + ) +) + + +def browser(rig, version="1.1", mutate=None): + executor, _, calls, sources, kwargs = rig + case = yaml.safe_load( + (NK / "security-cases/forgejo-netkingdom-oidc.yaml").read_bytes() + ) + relative = ( + "security-case-types" + if version == "1.1" + else "security-case-type-revisions/1.2" + ) + typ = yaml.safe_load( + (NK / relative / "oidc-browser-relying-party/type.yaml").read_bytes() + ) + case["spec"]["type"] = "oidc-browser-relying-party@" + version + case["spec"]["status"] = "materialized" + if version == "1.2": + case["spec"]["parameters"]["consumer_namespace"] = "forgejo" + # This extension and all native provenance are TEST ONLY, never source assent. + typ["spec"]["execution_descriptors"] = {"mint-secret": digest(sources[2])} + if mutate: + mutate(case, typ) + changed = ( + yaml.safe_dump(case).encode(), + yaml.safe_dump(typ).encode(), + *sources[2:], + ) + old = executor.evidence_verifier + + def verified(purpose, source, evidence): + return old( + purpose, + source.replace(digest(changed[0]), digest(sources[0])).replace( + digest(changed[1]), digest(sources[1]) + ), + evidence, + ) + + executor.evidence_verifier = verified + executor.browser_inputs = BrowserInputs(NK) + return executor, calls, changed, kwargs + + +@pytest.mark.parametrize("version", ["1.1", "1.2"]) +def test_actual_browser_executor_and_owner_projection(rig, version): + executor, calls, sources, kwargs = browser(rig, version) + args = executor.admit(*sources, **kwargs) + assert args[1].target == "forgejo" + assert not calls + assert executor.run(*args).last.outcome is Outcome.SUCCESS + assert calls[0].target == "forgejo" + assert not hasattr(calls[0], "parameters") + # Owner callbacks/journal get target and fingerprints, no full metadata. + persisted = (executor.journal.directory / "journal.sqlite").read_bytes() + assert b"https://forgejo.coulomb.social" not in persisted + assert b"forge-admins" not in persisted + + +@pytest.mark.parametrize( + "field,value", + [ + ("redirect_uris", "https://*.example.org/callback"), + ("redirect_uris", "http://example.org/callback"), + ("allowed_scopes", "openid,admin"), + ("client_type", "arbitrary"), + ("mfa_required", False), + ("unknown", "value"), + ("issuer", "https://other.example"), + ("display_name", "hvs." + "A" * 30), + ], +) +def test_case_refusals_even_with_test_evidence(rig, field, value): + executor, calls, sources, kwargs = browser( + rig, mutate=lambda c, t: c["spec"]["parameters"].update({field: value}) + ) + with pytest.raises(Refused, match="exact-version"): + executor.admit(*sources, **kwargs) + assert not calls + + +@pytest.mark.parametrize( + "mutation", + [ + lambda c, t: t["spec"].update(version="1.3"), + lambda c, t: t["spec"]["parameters"][2].update(sensitivity="public"), + lambda c, t: t["spec"]["parameters"][2].update(constraints={}), + lambda c, t: t["spec"]["parameters"].append( + { + "name": "secret", + "type": "string", + "case_settable": True, + "sensitivity": "secret_reference", + } + ), + ], +) +def test_type_revision_cannot_widen_reviewed_parameters(rig, mutation): + def change(case, typ): + mutation(case, typ) + case["spec"]["type"] = typ["spec"]["id"] + "@" + typ["spec"]["version"] + + executor, calls, sources, kwargs = browser(rig, mutate=change) + with pytest.raises(Refused, match="exact-version"): + executor.admit(*sources, **kwargs) + assert not calls + + +def test_namespace_and_missing_required_are_validated(rig): + executor, calls, sources, kwargs = browser( + rig, "1.2", lambda c, t: c["spec"]["parameters"].update(consumer_namespace="*") + ) + with pytest.raises(Refused, match="exact-version"): + executor.admit(*sources, **kwargs) + assert not calls + + +@pytest.mark.parametrize("changed_dependency", list(SOURCE_PINS)) +def test_missing_producer_and_changed_transitive_source_refuse( + rig, tmp_path, changed_dependency +): + executor, calls, sources, kwargs = browser(rig) + executor.browser_inputs = BrowserInputs(tmp_path) + for relative in SOURCE_PINS: + path = tmp_path / relative + path.parent.mkdir(parents=True, exist_ok=True) + if relative != changed_dependency: + path.write_bytes((NK / relative).read_bytes()) + with pytest.raises(Refused, match="exact-version"): + executor.admit(*sources, **kwargs) + (tmp_path / changed_dependency).write_text('raise RuntimeError("must not execute")') + with pytest.raises(Refused, match="exact-version"): + executor.admit(*sources, **kwargs) + assert not calls + + +def test_browser_without_validator_still_refuses(rig): + executor, calls, sources, kwargs = browser(rig) + executor.browser_inputs = None + with pytest.raises(Refused, match="not configured"): + executor.admit(*sources, **kwargs) + assert not calls + + +def test_real_unextended_type_stays_plan_only(rig): + executor, calls, sources, kwargs = browser(rig) + typ = yaml.safe_load(sources[1]) + del typ["spec"]["execution_descriptors"] + with pytest.raises(Refused, match="plan only"): + executor.admit(sources[0], yaml.safe_dump(typ).encode(), *sources[2:], **kwargs) + assert not calls + + +def test_required_browser_parameter_is_not_defaulted_away(rig): + executor, calls, sources, kwargs = browser( + rig, mutate=lambda c, t: c["spec"]["parameters"].pop("redirect_uris") + ) + with pytest.raises(Refused, match="exact-version"): + executor.admit(*sources, **kwargs) + assert not calls diff --git a/workplans/WARDEN-WP-0044-security-case-follow-through.md b/workplans/WARDEN-WP-0044-security-case-follow-through.md index b9ef795..7d6799d 100644 --- a/workplans/WARDEN-WP-0044-security-case-follow-through.md +++ b/workplans/WARDEN-WP-0044-security-case-follow-through.md @@ -251,3 +251,15 @@ and receipt provenance. No installed native adapter or live CLI execution is claimed. Current prose types stay plan-only; acknowledged descriptor/type machine seams and authenticated owner/caller receipts are still required. T03 progresses for implementation but cannot finish before ordered native Forgejo acceptance. + +2026-10-16 source preparation (isolated checkout; no native acceptance): +T03's browser parameter mismatch now has a producer-validator-backed projection +in `case_inputs.py`, explicitly configured on the real Executor. Exact 1.1/1.2 +parameter definitions and all four reviewed producer modules are pinned; full case +validation admits reviewed security-sensitive metadata while unknown/fixed/secret +inputs and type/producer drift refuse. Only the reviewed descriptor target +reaches the owner callback; no parameter journal payload is added. Actual +Executor/producer tests cover positive versions and refusal/containment paths. +Acknowledged machine descriptors, authenticated owner evidence, real Forgejo +materialization and contained native sessions remain mandatory and unimplemented; +T03 stays progress, T04 stays wait. No type or case status changed.