fix(cases): validate browser metadata against pinned owner source
Assistant: codex Assistant-Model: gpt-6-astra Assistant-Session: 01a140c4-354a-7823-8f0d-8f717397f786
This commit is contained in:
parent
d2cd3fa940
commit
958352089b
5 changed files with 356 additions and 9 deletions
|
|
@ -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
96
src/warden/case_inputs.py
Normal 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
|
||||
|
|
@ -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
185
tests/test_case_inputs.py
Normal 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
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue