the-custodian/docs/changes/netkingdom-target-2026-10-16/warden-browser/implementation.patch
codex 7a3632ffcc
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 1s
Reject generated-only commits / generated-commit-guard (push) Successful in 11s
docs: preserve netkingdom-target-2026-10-16 session evidence (batch 6)
2026-11-01 22:16:29 +01:00

425 lines
18 KiB
Diff

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.