diff --git a/tools/security-case-types/README.md b/tools/security-case-types/README.md index 45e1106..dc4fc91 100644 --- a/tools/security-case-types/README.md +++ b/tools/security-case-types/README.md @@ -50,3 +50,48 @@ This ordinary command refuses:1.2 is acknowledged, not implemented. For source-o The Python API is load_type(default_root, exact_reference, version_roots={"1.2": explicit_root}). Each key must be an exact major.minor and each root a Path. CLI --version-root VERSION=PATH may repeat for DIFFERENT versions. A matching explicit version selects exactly one root; a missing/wrong/ambiguous source refuses without trying the default or another revision. Versions without a mapping continue using --types. No directory discovery, highest/latest selection, status promotion or automatic owner-adapter adoption occurs. Duplicate mappings (even identical), duplicate YAML fields, mismatched source ID/version, malformed/traversal references and selected symlinks refuse. Direct case validation also requires the case reference to match the supplied type ID/version. An explicit root is source input, not an owner acknowledgment or grant. Callers retain exact source hashes/type/case/render in their own reviewed receipts. Existing two-argument load_type callers, including ops-mason, retain their default behavior until their owner deliberately adopts the optional input. Adapter/global provenance/native first-reference gates remain separate in NK-WP-0048-T06 and the existing ORGREF driver. + +## Check existing owner evidence compatibility + +The normal validator CLI now accepts `check-evidence` using the same exact type +selection, declarations, validation and deterministic render as `render`: + +```sh +python3 tools/security-case-types/security_case_validator.py check-evidence \ + security-cases/forgejo-netkingdom-oidc.yaml --types security-case-types \ + --structure-request /path/to/mason-structure-request.json \ + --warden-journal /path/to/existing/journal.sqlite +``` + +Normal admission still refuses the acknowledged 1.1 and 1.2 types. Explicit +`--allow-unimplemented` permits source preparation, with the existing +`--version-root 1.2=security-case-type-revisions/1.2` for that version. No owner +protocol is automatically adopted, no journal is created, and no status changes. + +This is the cross-owner **compatibility preflight**, not native acceptance. It +consumes the existing `ops-mason.case-structure-request.v1` and reads latest rows +from the existing `warden.case.step-journal.v1` SQLite journal in read-only mode. +Mason source/request hashes, complete current structural inventory and structural +projection must match the fresh render. Warden case/type and type-declared machine +descriptor pins, owners, bounded histories, created-object scope and successful +attempt/revocation fingerprints must match. Unknown or duplicate fields, source +substitution and missing completed-attempt fingerprints refuse. Running, uncertain, +failed or rolled-back outcomes retain their exact meanings; an earlier success +never overrides the latest journal row. Generated-value paths require distinct +parent-session and broker-grant fingerprint fields. + +Exit 0 means these supplied artifacts are source-compatible; it never means the +case is complete. No artifact is required solely to render missing gates. Output +always has `native_complete: false`, `execution.permitted: false`, and explicit +unresolved native gates. A Mason Request has no native Observation provenance; +a Warden journal retains fingerprints rather than portable signed/native proof. +This command deliberately offers no trust callback, native-success flag, dispatch +or way to promote self-authored JSON into native evidence. Actual independent +owner evidence admission, status-hash binding, accepted machine descriptors, +delivery, human sign-in/consumer denial, revocation and created-only rollback +remain NK-WP-0048-T06 owner integration work. It does not mark any type implemented. + +Integration tests invoke actual sibling Mason `prepare` and Warden `Journal.save` +with metadata fixtures only. They need those owner source checkouts, and existing +type suites need the public KeyCape request file and provider declarations beside +the checkout. `NET_KINGDOM_ROOT` selects the source root for the 1.2 package tests. diff --git a/tools/security-case-types/security_case_evidence.py b/tools/security-case-types/security_case_evidence.py new file mode 100644 index 0000000..0bf0a84 --- /dev/null +++ b/tools/security-case-types/security_case_evidence.py @@ -0,0 +1,200 @@ +"""Read-only compatibility checks for existing Mason and Warden evidence seams. + +This checks real owner artifacts against a fresh NetKingdom render. These source +protocols carry no portable authenticated native evidence. A matching request or +journal can NEVER establish materialized/live/implemented or authorize dispatch. +""" +from __future__ import annotations + +import hashlib +import json +from pathlib import Path +import re +import sqlite3 + +import yaml + +LIMIT = 262144 +DIGEST = re.compile(r"sha256:[0-9a-f]{64}\Z") +ID = re.compile(r"[A-Za-z0-9][A-Za-z0-9_.:/@-]{0,191}\Z") + + +class EvidenceRefused(ValueError): + pass + + +def sha(source): + return "sha256:" + hashlib.sha256(source).hexdigest() + + +def read(path): + path = Path(path) + if path.is_symlink() or not path.is_file() or path.stat().st_size > LIMIT: + raise EvidenceRefused("unsafe or oversized metadata source") + return path.read_bytes() + + +def _unique(pairs): + result = {} + for key, value in pairs: + if key in result: + raise EvidenceRefused("duplicate metadata field") + result[key] = value + return result + + +def decode(source): + if type(source) is not bytes or len(source) > LIMIT: + raise EvidenceRefused("oversized metadata input") + try: + return json.loads(source, object_pairs_hook=_unique) + except (ValueError, UnicodeError): + raise EvidenceRefused("invalid metadata JSON") from None + + +def check_structure(request, case_source, type_source, rendered): + """Consume ops-mason.case-structure-request.v1, not a new native flag.""" + fields = {"schema", "case", "case_digest", "type_ref", "type_digest", + "render_digest", "plan_digest", "bundle_digest", "structural_digest", + "reviewed_render_digest", "run_source_changed", "objects"} + if type(request) is not dict or set(request) != fields: + raise EvidenceRefused("unknown Mason structural request fields") + if request["schema"] != "ops-mason.case-structure-request.v1": + raise EvidenceRefused("unknown Mason structural request protocol") + for field in fields & {"case_digest", "type_digest", "render_digest", "plan_digest", + "bundle_digest", "structural_digest", "reviewed_render_digest"}: + if type(request[field]) is not str or not DIGEST.fullmatch(request[field]): + raise EvidenceRefused("malformed structural source pin") + if (request["case"], request["case_digest"], request["type_ref"], + request["type_digest"], request["render_digest"]) != ( + rendered["case"], sha(case_source), rendered["type"], sha(type_source), rendered["digest"]): + raise EvidenceRefused("Mason request has stale case/type/render pins") + if type(request["run_source_changed"]) is not bool or request["run_source_changed"] != ( + request["reviewed_render_digest"] != rendered["digest"]): + raise EvidenceRefused("Mason run drift flag disagrees with pins") + owners = {"openbao_policy": ("railiance-platform", "native-structure"), + "openbao_role": ("railiance-platform", "native-structure"), + "kv_path": ("railiance-platform", "layout-only"), + "catalog_entry": ("ops-warden", "pointer-source")} + expected = [] + for item in rendered["materialization"]: + if item["kind"] not in owners: + raise EvidenceRefused("unsupported structural owner kind") + owner, proof = owners[item["kind"]] + expected.append({"id": item["id"], "kind": item["kind"], "target": item["template"], + "expected_digest": sha(json.dumps(item, sort_keys=True, separators=(",", ":")).encode()), + "evidence_owner": owner, "proof": proof}) + if request["objects"] != expected: + raise EvidenceRefused("Mason structural inventory differs from exact render") + case = yaml.safe_load(case_source) + acc = case["spec"].get("acceptance") or {} + acceptance = ({"record": acc["record"], "at": acc.get("at"), + "by": [item.get("role") for item in acc.get("by", [])]} if acc.get("record") else None) + structural = {"case": rendered["case"], "type": rendered["type"], + "acceptance": acceptance, "materialization": rendered["materialization"]} + if request["structural_digest"] != sha(json.dumps(structural, sort_keys=True, separators=(",", ":")).encode()): + raise EvidenceRefused("Mason structural projection pin differs from current source") + # A request has neither Observation provenance nor StatusBinding authority. + return {"request_digest": sha(json.dumps(request, sort_keys=True, separators=(",", ":")).encode()), + "objects": len(expected), "run_source_changed": request["run_source_changed"], + "native_materialization_verified": False} + + +def read_journal(path): + """Read bounded Warden rows through SQLite read-only mode; never create a DB.""" + path = Path(path) + if path.is_symlink() or not path.is_file() or path.stat().st_size > 16 * 1024 * 1024: + raise EvidenceRefused("unsafe or oversized Warden journal") + try: + connection = sqlite3.connect(path.resolve().as_uri() + "?mode=ro", uri=True) + try: + connection.execute("PRAGMA query_only=ON") + connection.execute("PRAGMA trusted_schema=OFF") + rows = connection.execute( + "SELECT seq,binding,payload FROM receipts ORDER BY seq LIMIT 1025").fetchall() + finally: + connection.close() + if len(rows) > 1024: + raise EvidenceRefused("Warden journal inventory exceeds limit") + latest = {} + for seq, binding, payload in rows: + if type(seq) is not int or type(binding) is not str or not ID.fullmatch(binding): + raise EvidenceRefused("invalid Warden journal identity") + if type(payload) is not str: + raise EvidenceRefused("invalid Warden journal payload") + latest[binding] = decode(payload.encode()) + return tuple(latest.values()) + except sqlite3.Error: + raise EvidenceRefused("invalid Warden journal database") from None + + +def check_steps(rows, case_source, type_source, rendered): + """Validate latest durable Warden states; they remain local evidence only.""" + expected = {item["id"]: item for item in rendered["follow_through"]} + fields = {"step_id", "owner", "pins", "allowed_created", "retry_safe", "receipts", + "journal_schema", "evidence_digest", "session_revoked", "session_revocation_digest", + "grant_revocation_digest", "attempt_id", "operation"} + type_spec = yaml.safe_load(type_source)["spec"] + descriptors = type_spec.get("execution_descriptors", {}) + seen, result = set(), [] + for row in rows: + if type(row) is not dict or set(row) != fields or row["journal_schema"] != "warden.case.step-journal.v1": + raise EvidenceRefused("unknown Warden journal receipt fields") + step = row["step_id"] + if type(step) is not str or step not in expected or step in seen or row["owner"] != expected[step]["owner"]: + raise EvidenceRefused("unknown, duplicate or wrong-owner Warden step") + seen.add(step) + pins = row["pins"] + if type(pins) is not dict or set(pins) != {"case", "case_type", "adapter", "catalog", "acceptance", "run_sheet"}: + raise EvidenceRefused("unknown Warden source pins") + if any(type(value) is not str or not DIGEST.fullmatch(value) for value in pins.values()): + raise EvidenceRefused("malformed Warden source pin") + if (pins["case"], pins["case_type"]) != (sha(case_source), sha(type_source)): + raise EvidenceRefused("Warden receipt has stale case/type pins") + if descriptors.get(step) != pins["adapter"]: + raise EvidenceRefused("Warden receipt has no exact type-pinned machine descriptor") + history = row["receipts"] + if type(history) is not list or not 1 <= len(history) <= 128: + raise EvidenceRefused("invalid Warden receipt history") + outcomes = {"running", "verified-success", "verified-no-change", "failed-before-change", + "failed-after-change", "uncertain", "rollback-running", "verified-rolled-back", "rollback-uncertain"} + allowed = row["allowed_created"] + if type(allowed) is not list or len(allowed) > 32 or len(set(allowed)) != len(allowed) or any( + type(value) is not str or not ID.fullmatch(value) for value in allowed): + raise EvidenceRefused("invalid Warden created-object scope") + if type(row["retry_safe"]) is not bool: + raise EvidenceRefused("invalid Warden retry policy") + for number, receipt in enumerate(history, 1): + if type(receipt) is not dict or set(receipt) != {"attempt", "outcome", "created"} or ( + type(receipt["attempt"]) is not int or receipt["attempt"] != number or + receipt["outcome"] not in outcomes or type(receipt["created"]) is not list or + any(type(v) is not str or v not in allowed for v in receipt["created"]) or + len(set(receipt["created"])) != len(receipt["created"])): + raise EvidenceRefused("invalid Warden receipt state") + last = history[-1]["outcome"] + if last in {"verified-success", "verified-no-change", "verified-rolled-back"}: + proofs = [row["evidence_digest"], row["session_revocation_digest"]] + if expected[step]["value_handling"] == "generated_in_custody": + proofs.append(row["grant_revocation_digest"]) + if row["session_revoked"] is not True or any( + type(value) is not str or not DIGEST.fullmatch(value) for value in proofs): + raise EvidenceRefused("Warden completed state lacks revocation fingerprints") + if type(row["attempt_id"]) is not str or not ID.fullmatch(row["attempt_id"]) or row["operation"] not in {"run", "rollback", "reconcile"}: + raise EvidenceRefused("Warden completed state lacks exact attempt identity") + result.append({"step": step, "outcome": last, "native_authenticated": False}) + return result, sorted(set(expected) - seen) + + +def audit(case_source, type_source, rendered, *, structure=None, journal_rows=()): + structural = check_structure(structure, case_source, type_source, rendered) if structure is not None else None + steps, missing = check_steps(journal_rows, case_source, type_source, rendered) + type_spec = yaml.safe_load(type_source)["spec"] + return {"schema": "netkingdom.case-evidence-compatibility.v1", + "case_digest": sha(case_source), "type_digest": sha(type_source), "render_digest": rendered["digest"], + "structure": structural, "steps": steps, "missing_follow_through": missing, + "required_verification": [item["id"] for item in rendered["verification"]], + "missing_machine_descriptors": [item["id"] for item in rendered["follow_through"] + if item["id"] not in type_spec.get("execution_descriptors", {})], + "unresolved_native_gates": ["authenticated-owner-evidence-admission", "accepted-to-materialized-status-binding", + "human-sign-in", "consumer-group-denial", "parent-session-and-broker-grant-revocation", "created-only-rollback"], + "native_complete": False, "execution": {"permitted": False}} diff --git a/tools/security-case-types/security_case_validator.py b/tools/security-case-types/security_case_validator.py index 03e00d3..1dc63d6 100644 --- a/tools/security-case-types/security_case_validator.py +++ b/tools/security-case-types/security_case_validator.py @@ -630,6 +630,15 @@ def main(argv: list[str] | None = None) -> int: rd.add_argument("--version-root", action="append", default=[], metavar="VERSION=PATH", help="select an exact version root; no search or fallback") rd.add_argument("--allow-unimplemented", action="store_true") + ev = sub.add_parser("check-evidence", help="check existing Mason/Warden source artifacts; never native acceptance") + ev.add_argument("case") + ev.add_argument("declarations", nargs="*") + ev.add_argument("--repos-root", default=str(TOOL_DIR.parents[2])) + ev.add_argument("--types", required=True) + ev.add_argument("--version-root", action="append", default=[], metavar="VERSION=PATH") + ev.add_argument("--allow-unimplemented", action="store_true") + ev.add_argument("--structure-request", help="ops-mason structure-request JSON") + ev.add_argument("--warden-journal", help="existing Warden journal.sqlite; opened read-only") args = ap.parse_args(argv) if args.cmd == "validate-type": @@ -644,7 +653,7 @@ def main(argv: list[str] | None = None) -> int: selection_issues, version_roots = parse_version_roots(args.version_root) if has_errors(selection_issues): return _report(selection_issues) - issues, type_data, _ = load_type(Path(args.types), (case.get("spec") or {}).get("type", ""), + issues, type_data, selected_type_dir = load_type(Path(args.types), (case.get("spec") or {}).get("type", ""), version_roots=version_roots) if type_data is None: return _report(issues) @@ -663,6 +672,23 @@ def main(argv: list[str] | None = None) -> int: issues = load_issues + issues if rendered is None: return _report(issues) + if args.cmd == "check-evidence": + import security_case_evidence as evidence + try: + source = evidence.read(args.case) + type_source = evidence.read(selected_type_dir / "type.yaml") + # Refuse source changes between ordinary validation/render and audit. + if yaml.safe_load(source) != case or yaml.safe_load(type_source) != type_data: + raise evidence.EvidenceRefused("case/type source changed during validation") + structure = evidence.decode(evidence.read(args.structure_request)) if args.structure_request else None + rows = evidence.read_journal(args.warden_journal) if args.warden_journal else () + report = evidence.audit(source, type_source, rendered, structure=structure, journal_rows=rows) + except (evidence.EvidenceRefused, OSError, ValueError, TypeError, KeyError): + print("ERROR evidence: incompatible or unsafe owner metadata (diagnostics withheld)", file=sys.stderr) + return 1 + json.dump(report, sys.stdout, indent=2, sort_keys=True) + print() + return 0 json.dump(rendered, sys.stdout, indent=2, sort_keys=True) print() return _report(issues) diff --git a/tools/security-case-types/tests/test_case_evidence.py b/tools/security-case-types/tests/test_case_evidence.py new file mode 100644 index 0000000..135ff60 --- /dev/null +++ b/tools/security-case-types/tests/test_case_evidence.py @@ -0,0 +1,200 @@ +"""Real owner source API compatibility, with synthetic metadata only; no native evidence.""" +import json +from pathlib import Path +import shutil +import subprocess +import sys + +import pytest +import yaml + +TOOL = Path(__file__).resolve().parents[1] +REPO = TOOL.parents[1] +OWNERS = Path("/home/worsch") +sys.path.insert(0, str(TOOL)) +import security_case_evidence as e +import security_case_validator as v + + +@pytest.fixture +def source(): + case_path = REPO / "security-cases/forgejo-netkingdom-oidc.yaml" + raw = case_path.read_bytes() + case = yaml.safe_load(raw) + issues, doc, folder = v.load_type(REPO / "security-case-types", case["spec"]["type"]) + paths, issues = v.discover_declarations(doc, OWNERS) + declarations, issues = v.composer.load_declarations(paths) + issues, rendered = v.render_case(case, doc, declarations, require_implemented=False) + assert rendered is not None and not v.has_errors(issues) + return raw, (folder / "type.yaml").read_bytes(), rendered + + +@pytest.fixture +def mason(tmp_path, source): + sys.path.insert(0, str(OWNERS / "ops-mason/src")) + from ops_mason import case_structure + root = OWNERS / "ops-mason" + bundle = tmp_path / "bundle" + shutil.copytree(root / "builds/case-forgejo-netkingdom-oidc", bundle) + # Existing Mason source tests identify this historic bundle's wrong consumer. + rel = "credential-change-requests/CCR-DRAFT-FORGEJO-NETKINGDOM-OIDC-FORGEJO.yaml" + ccr = yaml.safe_load((bundle / rel).read_bytes()) + ccr["target"]["workload"] = "forgejo" + (bundle / rel).write_text(yaml.safe_dump(ccr, sort_keys=False, width=110)) + manifest = json.loads((bundle / "manifest.json").read_bytes()) + manifest["files"][rel] = e.sha((bundle / rel).read_bytes()).split(":")[1] + (bundle / "manifest.json").write_text(json.dumps(manifest)) + request = case_structure.prepare(REPO / "security-cases/forgejo-netkingdom-oidc.yaml", + root / "plans/case-forgejo-netkingdom-oidc.md", bundle, + nk_root=OWNERS / "net-kingdom", allow_unimplemented=True) + return json.loads(json.dumps(request.as_dict())) + + +def test_real_mason_request_recomputes_all_source_and_projection_pins(source, mason): + result = e.audit(*source, structure=mason) + assert result["structure"]["objects"] == 5 + assert result["native_complete"] is False + assert result["structure"]["native_materialization_verified"] is False + assert "mint-secret" in result["missing_follow_through"] + assert "register-client" in result["missing_machine_descriptors"] + assert "consumer-enforces-groups" in result["required_verification"] + + +@pytest.mark.parametrize("field", ["case_digest", "type_digest", "render_digest", "structural_digest"]) +def test_changed_source_or_projection_refused(source, mason, field): + mason[field] = "sha256:" + "0" * 64 + with pytest.raises(e.EvidenceRefused): + e.audit(*source, structure=mason) + + +def test_rehashed_structural_inventory_cannot_add_role_or_mint(source, mason): + mason["objects"][0]["target"] += "-sibling" + with pytest.raises(e.EvidenceRefused): + e.audit(*source, structure=mason) + + +def test_structure_unknown_native_success_flag_is_refused(source, mason): + mason["native_complete"] = True + with pytest.raises(e.EvidenceRefused): + e.audit(*source, structure=mason) + + +@pytest.fixture +def warden(tmp_path, source): + sys.path.insert(0, str(OWNERS / "ops-warden/src")) + from warden.case_run import Invocation, Journal, OwnerResult + from warden.case_receipt_state import Outcome, Pins, Receipt, StepState + raw, type_raw, rendered = source + # Test-only migration: current acknowledged real type has no descriptors. + doc = yaml.safe_load(type_raw) + descriptor_digest = e.sha(b"fixture-descriptor") + doc["spec"]["execution_descriptors"] = {"mint-secret": descriptor_digest} + type_raw = yaml.safe_dump(doc).encode() + pins = Pins(e.sha(raw), e.sha(type_raw), descriptor_digest, *([e.sha(b"fixture")] * 3)) + state = StepState("mint-secret", "railiance-platform", pins, (), False, + (Receipt(1, Outcome.RUNNING), Receipt(2, Outcome.SUCCESS))) + invocation = Invocation("mint-secret", "railiance-platform", "forgejo", "human", "fixture-human", + "keycape-client-secret-generate", pins, "attempt-fixture", "run") + receipt = OwnerResult(Outcome.SUCCESS, "fixture-evidence", (), True, + "fixture-parent-revocation", "fixture-grant-revocation", "attempt-fixture", "run") + journal = Journal(tmp_path / "private-journal") + journal.save("mint-secret:forgejo", state, receipt, invocation) + return (raw, type_raw, rendered), journal + + +def test_actual_warden_journal_save_is_read_without_mutation_and_not_native_proof(warden): + source, journal = warden + path = journal.directory / "journal.sqlite" + before = path.read_bytes() + rows = e.read_journal(path) + result = e.audit(*source, journal_rows=rows) + assert path.read_bytes() == before + assert result["steps"] == [{"step": "mint-secret", "outcome": "verified-success", "native_authenticated": False}] + assert result["native_complete"] is False + + +@pytest.mark.parametrize("field", ["session_revocation_digest", "grant_revocation_digest", "evidence_digest"]) +def test_missing_revocation_or_owner_fingerprint_refused(warden, field): + source, journal = warden + row = e.read_journal(journal.directory / "journal.sqlite")[0] + row[field] = None + with pytest.raises(e.EvidenceRefused): + e.audit(*source, journal_rows=(row,)) + + +def test_unresolved_attempt_not_promoted_to_success(warden): + source, journal = warden + row = e.read_journal(journal.directory / "journal.sqlite")[0] + row["receipts"][-1]["outcome"] = "uncertain" + assert e.audit(*source, journal_rows=(row,))["steps"][0]["outcome"] == "uncertain" + + +def test_no_descriptor_in_actual_acknowledged_type_refuses_journal_success(source, warden): + _, journal = warden + row = e.read_journal(journal.directory / "journal.sqlite")[0] + row["pins"]["case_type"] = e.sha(source[1]) + with pytest.raises(e.EvidenceRefused, match="machine descriptor"): + e.audit(*source, journal_rows=(row,)) + + +def test_missing_or_symlink_journal_never_created(tmp_path): + path = tmp_path / "missing.sqlite" + with pytest.raises(e.EvidenceRefused): + e.read_journal(path) + assert not path.exists() + target = tmp_path / "target.sqlite" + target.write_bytes(b"not sqlite") + path.symlink_to(target) + with pytest.raises(e.EvidenceRefused): + e.read_journal(path) + + +def test_duplicate_and_oversized_json_refused(): + for source in (b'{"schema":1,"schema":2}', b"x" * (e.LIMIT + 1)): + with pytest.raises(e.EvidenceRefused): + e.decode(source) + + +@pytest.mark.parametrize("version", ["1.1", "1.2"]) +def test_existing_cli_exact_source_selection_and_normal_admission_refusal(version): + case = REPO / ("security-cases/forgejo-netkingdom-oidc.yaml" if version == "1.1" else + "security-case-type-revisions/1.2/oidc-browser-relying-party/examples/forgejo.yaml") + argv = [sys.executable, str(TOOL / "security_case_validator.py"), "check-evidence", str(case), + "--types", str(REPO / "security-case-types"), "--repos-root", str(OWNERS), + "--version-root", "1.2=" + str(REPO / "security-case-type-revisions/1.2")] + refused = subprocess.run(argv, capture_output=True, text=True) + assert refused.returncode == 1 and "only implemented types" in refused.stderr + result = subprocess.run(argv + ["--allow-unimplemented"], capture_output=True, text=True) + assert result.returncode == 0, result.stderr + report = json.loads(result.stdout) + assert report["native_complete"] is False and report["execution"]["permitted"] is False + assert ("deliver-consumer-secret" in report["missing_follow_through"]) == (version == "1.2") + + +def test_cli_consumes_actual_mason_request_and_withholds_rejected_payload(tmp_path, mason): + request = tmp_path / "request.json" + request.write_text(json.dumps(mason)) + args = [sys.executable, str(TOOL / "security_case_validator.py"), "check-evidence", + str(REPO / "security-cases/forgejo-netkingdom-oidc.yaml"), "--types", str(REPO / "security-case-types"), + "--repos-root", str(OWNERS), "--allow-unimplemented", "--structure-request", str(request)] + result = subprocess.run(args, capture_output=True, text=True) + assert result.returncode == 0, result.stderr + assert json.loads(result.stdout)["structure"]["objects"] == 5 + mason["unexpected"] = "sensitive-fixture-diagnostic" + request.write_text(json.dumps(mason)) + result = subprocess.run(args, capture_output=True, text=True) + assert result.returncode == 1 and "sensitive-fixture-diagnostic" not in result.stderr + result.stdout + + +def test_actual_journal_latest_uncertainty_does_not_replay_prior_success(warden): + source, journal = warden + from warden.case_receipt_state import Pins, Receipt, StepState, Outcome + row = e.read_journal(journal.directory / "journal.sqlite")[0] + history = tuple(Receipt(item["attempt"], Outcome(item["outcome"]), tuple(item["created"])) + for item in row["receipts"]) + state = StepState(row["step_id"], row["owner"], Pins(**row["pins"]), (), False, + history + (Receipt(3, Outcome.UNCERTAIN),)) + journal.save("mint-secret:forgejo", state) + rows = e.read_journal(journal.directory / "journal.sqlite") + assert len(rows) == 1 + assert e.audit(*source, journal_rows=rows)["steps"][0]["outcome"] == "uncertain" diff --git a/workplans/NK-WP-0048-security-case-types.md b/workplans/NK-WP-0048-security-case-types.md index d50a1cb..074efd1 100644 --- a/workplans/NK-WP-0048-security-case-types.md +++ b/workplans/NK-WP-0048-security-case-types.md @@ -559,3 +559,28 @@ docs/evidence/2026-10-11-explicit-type-version-selection/source-integration-vali FormalNetownerreview/reference-source integration and all existing native adapter, provenance/broker/materialization/attendedproof gates remain; T06 stayswait, no new residual or firstcase/typeimplementation/native grant is inferred. + + +2026-10-16 — isolated source preparation for T06 (canonical integration pending): +Added `check-evidence` to the existing validator CLI. It reuses exact version +selection and ordinary type/case/render admission, checks actual Mason structural +request source/inventory/projection pins and reads actual Warden journal rows +read-only. Successful rows require exact type-declared descriptors and attempt/ +revocation fingerprints. Existing acknowledged types without machine descriptors +remain plan-only. The compatibility result never claims authenticated native +completion: Request and journal fingerprints are not portable native evidence. +Owner authentication/status binding and actual native gates remain unresolved; +T06/T07 statuses and both type/case lifecycles remain unchanged. No dispatch, +credential access or authority expansion is introduced. See tool README for the +existing owner protocol boundaries and source integration tests. + +Concrete next Warden owner integration: current `Executor.admit` refuses every +case parameter whose sensitivity is not public/operational, so browser +`redirect_uris`, `allowed_scopes`, `client_type`, `mfa_required` (and 1.2 +`consumer_namespace`) fail after otherwise prepared admission. The sensitive +classification is a security-change review level, not secret content. The +Warden owner must validate through NetKingdom's existing exact-version case/type +validator, then project only descriptor-declared safe target metadata; do not +remove the credential/value refusal or copy complete parameters into journals. +Actual owner descriptor acknowledgment and independent evidence admission remain +prerequisites, with normal admission still refusing unimplemented types.