fix(cases): validate browser metadata against pinned owner source
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 2s

Assistant: codex
Assistant-Model: gpt-6-astra
Assistant-Session: 01a140c4-354a-7823-8f0d-8f717397f786
This commit is contained in:
tegwick 2026-10-16 01:58:20 +02:00
parent d2cd3fa940
commit 958352089b
5 changed files with 356 additions and 9 deletions

View file

@ -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.

96
src/warden/case_inputs.py Normal file
View file

@ -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

View file

@ -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)

185
tests/test_case_inputs.py Normal file
View file

@ -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

View file

@ -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.