diff --git a/canon/schemas/security-case-type_v0.1.schema.json b/canon/schemas/security-case-type_v0.1.schema.json new file mode 100644 index 0000000..10bebb1 --- /dev/null +++ b/canon/schemas/security-case-type_v0.1.schema.json @@ -0,0 +1,127 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://netkingdom.local/schemas/security-case-type_v0.1.schema.json", + "title": "NetKingdom SecurityCaseType v0.1", + "description": "Structure of security-case-types//type.yaml (canon/standards/security-case-types_v0.1.md). Semantic rules (rollback coverage, template placeholders, secret-shaped values, acceptance by risk class) are enforced by tools/security-case-types/security_case_validator.py.", + "type": "object", + "additionalProperties": false, + "required": ["apiVersion", "kind", "spec"], + "properties": { + "apiVersion": {"const": "netkingdom.io/security-case-type/v0.1"}, + "kind": {"const": "SecurityCaseType"}, + "spec": { + "type": "object", + "additionalProperties": false, + "required": ["id", "version", "status", "title", "purpose", "parameters", "composition", + "materialization", "follow_through", "verification", "rollback", "acceptance"], + "properties": { + "id": {"type": "string", "pattern": "^[a-z0-9][a-z0-9-]*$"}, + "version": {"type": "string", "pattern": "^\\d+\\.\\d+$"}, + "status": {"enum": ["draft", "proposed", "acknowledged", "implemented", "deprecated", "retired"]}, + "title": {"type": "string", "minLength": 1}, + "purpose": {"type": "string", "minLength": 1}, + "parameters": {"type": "array", "minItems": 1, "items": {"$ref": "#/$defs/parameter"}}, + "composition": {"$ref": "#/$defs/composition"}, + "materialization": {"type": "array", "minItems": 1, "items": {"$ref": "#/$defs/materialization"}}, + "follow_through": {"type": "array", "minItems": 1, "items": {"$ref": "#/$defs/step"}}, + "verification": {"type": "array", "minItems": 2, "items": {"$ref": "#/$defs/check"}}, + "rollback": {"type": "array", "minItems": 1, "items": {"$ref": "#/$defs/rollback"}}, + "acceptance": { + "type": "object", + "additionalProperties": false, + "required": ["accepted_by", "risk_class"], + "properties": { + "accepted_by": {"type": "array", "minItems": 1, "uniqueItems": true, + "items": {"enum": ["owner", "founder"]}}, + "risk_class": {"enum": ["low", "medium", "high"]} + } + }, + "history": {"type": "array", "items": {"type": "object"}} + } + } + }, + "$defs": { + "parameter": { + "type": "object", + "additionalProperties": false, + "required": ["name", "type", "case_settable", "sensitivity", "description"], + "properties": { + "name": {"type": "string", "pattern": "^[a-z][a-z0-9_]*$"}, + "type": {"enum": ["string", "integer", "number", "boolean", "array", "object"]}, + "case_settable": {"type": "boolean"}, + "required": {"type": "boolean"}, + "default": {}, + "sensitivity": {"enum": ["public", "operational", "security_sensitive", "secret_reference"]}, + "constraints": { + "type": "object", + "additionalProperties": false, + "properties": { + "pattern": {"type": "string"}, + "enum": {"type": "array"}, + "min_items": {"type": "integer", "minimum": 0}, + "unique": {"type": "boolean"} + } + }, + "description": {"type": "string", "minLength": 1} + } + }, + "composition": { + "type": "object", + "additionalProperties": false, + "required": ["authority", "initial_trust", "capabilities", "providers"], + "properties": { + "authority": {"enum": ["platform", "netkingdom", "tenant"]}, + "initial_trust": {"type": "array", "minItems": 1, "items": {"type": "string"}}, + "capabilities": {"type": "array", "minItems": 1, "uniqueItems": true, "items": {"type": "string"}}, + "providers": {"type": "object", "additionalProperties": {"type": "string", "minLength": 1}}, + "parameter_bindings": {"type": "object", "additionalProperties": {"type": "string"}} + } + }, + "materialization": { + "type": "object", + "additionalProperties": false, + "required": ["id", "kind", "template"], + "properties": { + "id": {"type": "string", "minLength": 1}, + "kind": {"enum": ["openbao_policy", "openbao_role", "kv_path", "catalog_entry"]}, + "template": {"type": "string", "minLength": 1}, + "description": {"type": "string"} + } + }, + "step": { + "type": "object", + "additionalProperties": false, + "required": ["id", "owner", "entry_point", "auth", "warden_executes"], + "properties": { + "id": {"type": "string", "minLength": 1}, + "owner": {"type": "string", "minLength": 1}, + "entry_point": {"type": "string", "minLength": 1}, + "auth": {"type": "string", "minLength": 1}, + "warden_executes": {"type": "boolean"}, + "attended": {"type": "boolean"}, + "attended_justification": {"type": "string"}, + "description": {"type": "string"} + } + }, + "check": { + "type": "object", + "additionalProperties": false, + "required": ["id", "check", "expect", "reads_secret_value"], + "properties": { + "id": {"type": "string", "minLength": 1}, + "check": {"type": "string", "minLength": 1}, + "expect": {"enum": ["allow", "present", "deny"]}, + "reads_secret_value": {"const": false} + } + }, + "rollback": { + "type": "object", + "additionalProperties": false, + "required": ["reverses", "action"], + "properties": { + "reverses": {"type": "string", "minLength": 1}, + "action": {"type": "string", "minLength": 1} + } + } + } +} diff --git a/canon/schemas/security-case_v0.1.schema.json b/canon/schemas/security-case_v0.1.schema.json new file mode 100644 index 0000000..a71370b --- /dev/null +++ b/canon/schemas/security-case_v0.1.schema.json @@ -0,0 +1,56 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://netkingdom.local/schemas/security-case_v0.1.schema.json", + "title": "NetKingdom SecurityCase v0.1", + "description": "Structure of a security case file (canon/standards/security-case-types_v0.1.md section 4). Parameter values are checked against the referenced type by tools/security-case-types/security_case_validator.py.", + "type": "object", + "additionalProperties": false, + "required": ["apiVersion", "kind", "spec"], + "properties": { + "apiVersion": {"const": "netkingdom.io/security-case/v0.1"}, + "kind": {"const": "SecurityCase"}, + "spec": { + "type": "object", + "additionalProperties": false, + "required": ["id", "type", "status", "owner", "parameters"], + "properties": { + "id": {"type": "string", "pattern": "^[a-z0-9][a-z0-9-]*$"}, + "type": {"type": "string", "pattern": "^[a-z0-9][a-z0-9-]*@\\d+\\.\\d+$"}, + "status": {"enum": ["proposed", "accepted", "materialized", "live", "retired"]}, + "owner": { + "type": "object", + "additionalProperties": false, + "required": ["repo", "person"], + "properties": { + "repo": {"type": "string", "minLength": 1}, + "person": {"type": "string", "minLength": 1} + } + }, + "parameters": {"type": "object"}, + "acceptance": { + "type": "object", + "additionalProperties": false, + "required": ["at", "record", "by"], + "properties": { + "at": {"type": "string", "minLength": 1}, + "record": {"type": "string", "minLength": 1}, + "by": { + "type": "array", + "minItems": 1, + "items": { + "type": "object", + "additionalProperties": false, + "required": ["role", "name"], + "properties": { + "role": {"enum": ["owner", "founder"]}, + "name": {"type": "string", "minLength": 1} + } + } + } + } + }, + "receipts": {"type": "array", "items": {"type": "object"}} + } + } + } +} diff --git a/canon/standards/security-case-types_v0.1.md b/canon/standards/security-case-types_v0.1.md index 45ac004..fcc4d95 100644 --- a/canon/standards/security-case-types_v0.1.md +++ b/canon/standards/security-case-types_v0.1.md @@ -3,7 +3,7 @@ id: netkingdom-security-case-types-v0.1 type: standard title: "NetKingdom Security Case Types v0.1" domain: netkingdom -status: proposed +status: accepted version: "0.1" owner: net-kingdom created: "2026-10-08" @@ -11,6 +11,15 @@ updated: "2026-10-08" last_reviewed: "2026-10-08" review_interval: 3m scope: meta-orchestration +accepted: "2026-10-08" +accepted_by: founder +schema: + - canon/schemas/security-case-type_v0.1.schema.json + - canon/schemas/security-case_v0.1.schema.json +validator: + - tools/security-case-types/security_case_validator.py +template: + - security-case-types/_template/ related: - canon/standards/playbook-capability-contract_v0.2.md - canon/standards/security-scenario-composition_v0.1.md @@ -163,7 +172,7 @@ incomplete. - Existing hand-built instances keep working. They are adopted as cases when their type is implemented, not rewritten in place. -## 10. Open questions for v0.1 review +## 10. Open questions (carried after acceptance, 2026-10-08) - Where should case files live by default: with the requester, or centrally in net-kingdom? diff --git a/security-case-types/_template/README.md b/security-case-types/_template/README.md new file mode 100644 index 0000000..251f29e --- /dev/null +++ b/security-case-types/_template/README.md @@ -0,0 +1,40 @@ +# SecurityCaseType template + +Copy `_template/` to `security-case-types//` and rewrite every +section. Canon: `canon/standards/security-case-types_v0.1.md`. A type is +acknowledged through informed-decision only when this README, `type.yaml`, the +example cases and the tests are complete and green. + +## Purpose + +Which recurring use case the type serves, and the hand-built instances it +replaces or adopts (reference instances). For the template: a loopback bootstrap +identity for one operator, composed from `net-kingdom.local-identity`. + +## Threat model + +What the type protects, from whom, and how. Name the assets (credentials, +sessions, signing keys), the actors (anonymous, low-assurance operator, tenant, +compromised workload) and what each step does to prevent misuse. For the +template: the store is loopback-only and private to its owner; the signing key +is referenced from custody, never written into a case. + +## Alternatives considered + +The other shapes this pattern could have taken and why they were not chosen. +This is the part an informed-decision is mostly about. + +## Attended steps + +Every step marked `attended: true`, why it cannot run unattended, and how +ops-warden groups such steps into as few attended sessions as possible. + +## Known limits + +What the type does not cover, and when a request should become a new type or a +new major version instead of a case. + +## Verification and rollback + +How the checks in `type.yaml` prove a case is live without reading any value, +and how rollback restores the state from before the case. diff --git a/security-case-types/_template/examples/example-operator.yaml b/security-case-types/_template/examples/example-operator.yaml new file mode 100644 index 0000000..cfc7d80 --- /dev/null +++ b/security-case-types/_template/examples/example-operator.yaml @@ -0,0 +1,15 @@ +# Example SecurityCase for the template type. Example values only. +apiVersion: netkingdom.io/security-case/v0.1 +kind: SecurityCase +spec: + id: example-operator + type: template-example@0.1 + status: proposed + owner: + repo: net-kingdom + person: example-owner + parameters: + operator_username: bootstrap-admin + operator_fullname: Bootstrap Reference Operator + operator_email: bootstrap@example.invalid + signing_key_ref: bao:platform/workloads/example/local-identity-signing diff --git a/security-case-types/_template/tests/test_type.py b/security-case-types/_template/tests/test_type.py new file mode 100644 index 0000000..69475eb --- /dev/null +++ b/security-case-types/_template/tests/test_type.py @@ -0,0 +1,53 @@ +"""Tests every SecurityCaseType carries: the type validates, its example cases +validate against it, and each example renders into a composer plan. + +Copied with the template; keep it and add type-specific tests beside it. +""" + +import importlib.util +import sys +from pathlib import Path + +import yaml + +TYPE_DIR = Path(__file__).resolve().parents[1] +REPO = TYPE_DIR.parents[1] +VALIDATOR = REPO / "tools" / "security-case-types" / "security_case_validator.py" +SPEC = importlib.util.spec_from_file_location("security_case_validator", VALIDATOR) +validator = importlib.util.module_from_spec(SPEC) +sys.modules[SPEC.name] = validator +SPEC.loader.exec_module(validator) + +TYPE = yaml.safe_load((TYPE_DIR / "type.yaml").read_text()) +EXAMPLES = sorted((TYPE_DIR / "examples").glob("*.yaml")) +DECLARATIONS = sorted(str(p) for p in (REPO / "capabilities" / "playbooks").glob("*.yaml")) +# Until the type is implemented, examples are checked as if it were. +IMPLEMENTED = TYPE["spec"]["status"] == "implemented" + + +def errors(issues): + return [f"{i.path}: {i.message}" for i in issues if i.level == "ERROR"] + + +def test_type_is_complete(): + assert errors(validator.validate_type(TYPE, TYPE_DIR)) == [] + + +def test_examples_exist_and_validate(): + assert EXAMPLES + for path in EXAMPLES: + case = yaml.safe_load(path.read_text()) + assert errors(validator.validate_case(case, TYPE, require_implemented=IMPLEMENTED)) == [], path.name + + +def test_examples_render_deterministically(): + declarations, load_issues = validator.composer.load_declarations(DECLARATIONS) + assert errors(load_issues) == [] + for path in EXAMPLES: + case = yaml.safe_load(path.read_text()) + issues, first = validator.render_case(case, TYPE, declarations, require_implemented=IMPLEMENTED) + assert errors(issues) == [], path.name + _, second = validator.render_case(case, TYPE, declarations, require_implemented=IMPLEMENTED) + assert first["digest"] == second["digest"] + assert first["execution"] == {"permitted": False} + assert first["composition"]["execution"]["permitted"] is False diff --git a/security-case-types/_template/type.yaml b/security-case-types/_template/type.yaml new file mode 100644 index 0000000..fcdb9e5 --- /dev/null +++ b/security-case-types/_template/type.yaml @@ -0,0 +1,115 @@ +# SecurityCaseType template (canon/standards/security-case-types_v0.1.md). +# +# Copy this directory to security-case-types// and replace every part. +# This template is itself a valid, runnable type: it composes the checked-in +# net-kingdom.local-identity declaration (a C0 bootstrap identity), so that its +# tests prove the validator and renderer end to end. It is not for real cases. +apiVersion: netkingdom.io/security-case-type/v0.1 +kind: SecurityCaseType +spec: + id: template-example + version: "0.1" + status: draft + title: "Template: local bootstrap identity for one operator" + purpose: >- + Shows every section a type must define, using a loopback bootstrap identity + as the example. A real type states which recurring use case it serves and + which hand-built instances it replaces. + + # What a case fills in. Secret values are never parameters: a secret appears + # only as a custody reference (sensitivity: secret_reference). + parameters: + - name: operator_username + type: string + case_settable: true + constraints: {pattern: "^[a-z_][a-z0-9_-]{0,31}$"} + sensitivity: operational + description: Username of the one operator this case is for. + - name: operator_fullname + type: string + case_settable: true + constraints: {pattern: "^[^\\n\\r]{1,120}$"} + sensitivity: operational + description: Display name of the operator. + - name: operator_email + type: string + case_settable: true + constraints: {pattern: "^[^@\\s]+@[^@\\s]+$"} + sensitivity: operational + description: Contact address of the operator. + - name: signing_key_ref + type: string + case_settable: true + constraints: {pattern: "^bao:platform/workloads/[a-z0-9/-]+$"} + sensitivity: secret_reference + description: Custody reference for the signing key; never the key itself. + - name: store_home + type: string + case_settable: false + default: .local/local-identity + sensitivity: operational + description: Where the store lives; fixed by the type, not by cases. + + # Which declared playbook capabilities the type composes, with exact provider + # pins, and how case parameters bind to the declarations' parameters. + composition: + authority: netkingdom + initial_trust: [bare_host_trust] + capabilities: [c0.bootstrap-identity] + providers: + c0.bootstrap-identity: net-kingdom.local-identity + parameter_bindings: + net-kingdom.local-identity.bootstrap_username: operator_username + net-kingdom.local-identity.bootstrap_fullname: operator_fullname + net-kingdom.local-identity.bootstrap_email: operator_email + net-kingdom.local-identity.local_identity_home: store_home + + # What ops-mason builds per case (structure only, never values). + materialization: + - id: signing-key-path + kind: kv_path + template: "{signing_key_ref}" + - id: signing-key-policy + kind: openbao_policy + template: "local-identity-{operator_username}-read" + - id: catalog-entry + kind: catalog_entry + template: "local-identity-{operator_username}" + + # What ops-warden carries out, step by step, on whose entry point. + follow_through: + - id: init-store + owner: net-kingdom + entry_point: "uv run --project local-identity local-identity init" + auth: "local operator shell on the target host" + warden_executes: false + attended: true + attended_justification: >- + The bootstrap identity exists before any managed identity, so no + unattended principal can run it. + + # Checks that prove the case is live; never reading a secret value. + verification: + - id: discovery-serves + check: "loopback discovery document issuer matches the planned port" + expect: present + reads_secret_value: false + - id: store-private + check: "store under {store_home} is not readable by other users" + expect: deny + reads_secret_value: false + + # The exact inverse of every follow-through step and materialized item. + rollback: + - reverses: init-store + action: "stop the loopback issuer and remove {store_home}" + - reverses: signing-key-path + action: "remove the KV path {signing_key_ref}" + - reverses: signing-key-policy + action: "delete policy local-identity-{operator_username}-read" + - reverses: catalog-entry + action: "remove catalog entry local-identity-{operator_username}" + + acceptance: + accepted_by: [owner] + risk_class: low diff --git a/tools/security-case-types/README.md b/tools/security-case-types/README.md new file mode 100644 index 0000000..61938da --- /dev/null +++ b/tools/security-case-types/README.md @@ -0,0 +1,34 @@ +# security-case-types tools + +Validator and renderer for SecurityCaseTypes and SecurityCases +(`canon/standards/security-case-types_v0.1.md`, schemas +`canon/schemas/security-case-type_v0.1.schema.json` and +`security-case_v0.1.schema.json`). + +```bash +# a type directory: type.yaml, README.md sections, tests, example cases +python3 tools/security-case-types/security_case_validator.py validate-type security-case-types/ + +# a case against its type (security-case-types//type.yaml) +python3 tools/security-case-types/security_case_validator.py validate-case --types security-case-types + +# render a case into the composer's plan plus materialization, follow-through, +# verification and rollback; execution is never permitted +python3 tools/security-case-types/security_case_validator.py render \ + --types security-case-types capabilities/playbooks/*.yaml +``` + +Only `implemented` types accept cases. `--allow-unimplemented` exists for a type's +own example cases while the type is being written and acknowledged. + +The validator refuses, among other things: missing sections, unknown or unpinned +capabilities, attended steps without justification, steps or materialized items +without a rollback, verification without a negative check or that reads a value, +templates with unknown parameters, credential-shaped values anywhere, secret +parameters that are not custody references, and high-risk types without founder +acceptance. + +To start a type: copy `security-case-types/_template/`, rename the directory to +the type id, and rewrite every section. The template's tests travel with it. + +Tests: `python3 -m pytest tools/security-case-types/tests security-case-types/*/tests`. diff --git a/tools/security-case-types/security_case_validator.py b/tools/security-case-types/security_case_validator.py new file mode 100644 index 0000000..aaae1c7 --- /dev/null +++ b/tools/security-case-types/security_case_validator.py @@ -0,0 +1,500 @@ +#!/usr/bin/env python3 +"""Validate SecurityCaseTypes and SecurityCases, and render a case into a plan. + +Canon: canon/standards/security-case-types_v0.1.md. A type is a NetKingdom-owned +composition of declared playbook capabilities; a case fills in the type's +parameters. Rendering produces the security-scenario composer's deterministic, +plan-only output (execution is never permitted here) plus the type's +materialization and follow-through, with parameters substituted. + + security_case_validator.py validate-type security-case-types/ + security_case_validator.py validate-case --types security-case-types + security_case_validator.py render --types security-case-types +""" + +from __future__ import annotations + +import argparse +import hashlib +import json +import re +import string +import sys +from pathlib import Path +from typing import Any + +import yaml + +TOOL_DIR = Path(__file__).resolve().parent +COMPOSER_DIR = TOOL_DIR.parent / "security-scenario-composer" +sys.path.insert(0, str(COMPOSER_DIR)) + +import security_scenario_composer as composer # noqa: E402 + +contract = composer.contract +Issue = contract.Issue + +TYPE_API_VERSION = "netkingdom.io/security-case-type/v0.1" +CASE_API_VERSION = "netkingdom.io/security-case/v0.1" +RENDER_API_VERSION = "netkingdom.io/security-case-render/v0.1" + +ID = re.compile(r"^[a-z0-9][a-z0-9-]*$") +VERSION = re.compile(r"^\d+\.\d+$") +TYPE_REF = re.compile(r"^([a-z0-9][a-z0-9-]*)@(\d+\.\d+)$") +TYPE_STATUSES = ["draft", "proposed", "acknowledged", "implemented", "deprecated", "retired"] +CASE_STATUSES = ["proposed", "accepted", "materialized", "live", "retired"] +RISK_CLASSES = {"low", "medium", "high"} +ACCEPTERS = {"owner", "founder"} +MATERIALIZATION_KINDS = {"openbao_policy", "openbao_role", "kv_path", "catalog_entry"} +TYPE_SECTIONS = [ + "id", "version", "status", "title", "purpose", "parameters", "composition", + "materialization", "follow_through", "verification", "rollback", "acceptance", +] +REQUIRED_README_HEADINGS = ["## Threat model", "## Alternatives considered", "## Known limits"] + +# Values that look like credentials are refused anywhere in a case. A +# secret_reference parameter must hold a custody reference, never a value. +SECRET_SHAPES = [ + re.compile(r"-----BEGIN [A-Z ]*PRIVATE KEY-----"), + re.compile(r"\b(?:hvs|hvb|hvr)\.[A-Za-z0-9_-]{8,}"), + re.compile(r"\b(?:sk|pk|rk)-[A-Za-z0-9_-]{16,}"), + re.compile(r"\bAKIA[0-9A-Z]{16}\b"), + re.compile(r"\beyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\."), + re.compile(r"\bgh[pousr]_[A-Za-z0-9]{20,}"), +] +CUSTODY_REFERENCE = re.compile(r"^(?:bao|openbao|vault|env|file):[^\s]+$") + + +def err(path: str, message: str) -> Issue: + return Issue("ERROR", path, message) + + +def has_errors(issues: list[Issue]) -> bool: + return any(i.level == "ERROR" for i in issues) + + +def placeholders(text: str) -> set[str]: + return {f[1] for f in string.Formatter().parse(text) if f[1]} + + +def _walk_strings(value: Any, path: str): + if isinstance(value, str): + yield path, value + elif isinstance(value, dict): + for k, v in value.items(): + yield from _walk_strings(v, f"{path}.{k}") + elif isinstance(value, list): + for i, v in enumerate(value): + yield from _walk_strings(v, f"{path}[{i}]") + + +def secret_shaped(text: str) -> bool: + return any(p.search(text) for p in SECRET_SHAPES) + + +# ── Types ──────────────────────────────────────────────────────────────────── + +def validate_type(data: dict[str, Any], type_dir: Path | None = None) -> list[Issue]: + issues: list[Issue] = [] + if data.get("apiVersion") != TYPE_API_VERSION: + issues.append(err("type.apiVersion", f"must be {TYPE_API_VERSION}")) + if data.get("kind") != "SecurityCaseType": + issues.append(err("type.kind", "must be SecurityCaseType")) + spec = data.get("spec") + if not isinstance(spec, dict): + return issues + [err("type.spec", "missing")] + for section in TYPE_SECTIONS: + if section not in spec: + issues.append(err(f"type.spec.{section}", "required section is missing")) + if has_errors(issues): + return issues + + if not ID.fullmatch(str(spec["id"])): + issues.append(err("type.spec.id", "must be lowercase letters, digits and hyphens")) + if not VERSION.fullmatch(str(spec["version"])): + issues.append(err("type.spec.version", "must be .")) + if spec["status"] not in TYPE_STATUSES: + issues.append(err("type.spec.status", f"must be one of {TYPE_STATUSES}")) + + params = _validate_type_parameters(spec["parameters"], issues) + step_ids = _validate_follow_through(spec["follow_through"], issues) + _validate_composition(spec["composition"], params, issues) + _validate_materialization(spec["materialization"], params, issues) + _validate_verification(spec["verification"], params, issues) + _validate_rollback(spec["rollback"], step_ids, spec["materialization"], issues) + _validate_acceptance(spec["acceptance"], issues) + + for path, text in _walk_strings(spec, "type.spec"): + if secret_shaped(text): + issues.append(err(path, "contains a credential-shaped value")) + + if type_dir is not None: + readme = type_dir / "README.md" + if not readme.is_file(): + issues.append(err("type.README.md", "missing")) + else: + text = readme.read_text() + for heading in REQUIRED_README_HEADINGS: + if heading not in text: + issues.append(err("type.README.md", f"missing section {heading!r}")) + tests = type_dir / "tests" + if not tests.is_dir() or not any(tests.glob("test_*.py")): + issues.append(err("type.tests", "a type needs runnable tests (tests/test_*.py)")) + examples = sorted((type_dir / "examples").glob("*.yaml")) if (type_dir / "examples").is_dir() else [] + if not examples: + issues.append(err("type.examples", "a type needs at least one example case")) + return issues + + +def _validate_type_parameters(params: Any, issues: list[Issue]) -> dict[str, dict]: + out: dict[str, dict] = {} + if not isinstance(params, list) or not params: + issues.append(err("type.spec.parameters", "must be a non-empty list")) + return out + for i, p in enumerate(params): + path = f"type.spec.parameters[{i}]" + if not isinstance(p, dict) or not ID.fullmatch(str(p.get("name", "")).replace("_", "-")): + issues.append(err(path, "needs a name of lowercase letters, digits, '-' or '_'")) + continue + name = p["name"] + if name in out: + issues.append(err(path, f"duplicate parameter {name!r}")) + if p.get("type") not in contract.PARAM_TYPES: + issues.append(err(f"{path}.type", f"must be one of {sorted(contract.PARAM_TYPES)}")) + if p.get("sensitivity") not in contract.SENSITIVITIES: + issues.append(err(f"{path}.sensitivity", f"must be one of {sorted(contract.SENSITIVITIES)}")) + if p.get("sensitivity") == "secret_reference" and p.get("type") != "string": + issues.append(err(path, "a secret_reference parameter must be a string custody reference")) + if not isinstance(p.get("case_settable"), bool): + issues.append(err(f"{path}.case_settable", "must be true or false")) + if not p.get("description"): + issues.append(err(f"{path}.description", "required")) + if not p.get("case_settable") and "default" not in p: + issues.append(err(path, "a parameter cases cannot set needs a default")) + out[name] = p + return out + + +def _validate_composition(comp: Any, params: dict[str, dict], issues: list[Issue]) -> None: + path = "type.spec.composition" + if not isinstance(comp, dict): + issues.append(err(path, "must be a mapping")) + return + if comp.get("authority") not in contract.SCENARIO_AUTHORITIES: + issues.append(err(f"{path}.authority", f"must be one of {sorted(contract.SCENARIO_AUTHORITIES)}")) + trust = comp.get("initial_trust") + if not isinstance(trust, list) or not trust or not set(trust) <= contract.TRUST_STATES: + issues.append(err(f"{path}.initial_trust", "must be a non-empty list of known trust states")) + caps = comp.get("capabilities") + if not isinstance(caps, list) or not caps: + issues.append(err(f"{path}.capabilities", "must list at least one capability")) + caps = [] + for c in caps: + if c not in contract.CAPABILITIES: + issues.append(err(f"{path}.capabilities", f"unknown capability {c!r}")) + providers = comp.get("providers") or {} + for c in caps: + if not providers.get(c): + issues.append(err(f"{path}.providers", f"capability {c!r} needs an exact provider pin")) + for target, source in (comp.get("parameter_bindings") or {}).items(): + if not re.fullmatch(r"[a-z0-9][a-z0-9._-]*\.[a-z_][a-z0-9_]*", str(target)): + issues.append(err(f"{path}.parameter_bindings", f"{target!r} must be .")) + if source not in params: + issues.append(err(f"{path}.parameter_bindings.{target}", f"binds unknown case parameter {source!r}")) + + +def _validate_templates(items: Any, path: str, params: dict[str, dict], issues: list[Issue]) -> None: + for i, item in enumerate(items): + for spath, text in _walk_strings(item, f"{path}[{i}]"): + for name in placeholders(text): + if name not in params: + issues.append(err(spath, f"template uses unknown parameter {{{name}}}")) + + +def _validate_materialization(items: Any, params: dict[str, dict], issues: list[Issue]) -> None: + path = "type.spec.materialization" + if not isinstance(items, list) or not items: + issues.append(err(path, "must list what ops-mason builds per case")) + return + seen = set() + for i, item in enumerate(items): + if not isinstance(item, dict) or item.get("kind") not in MATERIALIZATION_KINDS: + issues.append(err(f"{path}[{i}].kind", f"must be one of {sorted(MATERIALIZATION_KINDS)}")) + continue + if not item.get("id") or item["id"] in seen: + issues.append(err(f"{path}[{i}].id", "needs a unique id")) + seen.add(item.get("id")) + if not item.get("template"): + issues.append(err(f"{path}[{i}].template", "required")) + _validate_templates(items, path, params, issues) + + +def _validate_follow_through(steps: Any, issues: list[Issue]) -> set[str]: + path = "type.spec.follow_through" + ids: set[str] = set() + if not isinstance(steps, list) or not steps: + issues.append(err(path, "must list the ops-warden steps")) + return ids + for i, s in enumerate(steps): + p = f"{path}[{i}]" + if not isinstance(s, dict) or not s.get("id"): + issues.append(err(p, "needs an id")) + continue + if s["id"] in ids: + issues.append(err(p, f"duplicate step {s['id']!r}")) + ids.add(s["id"]) + for key in ("owner", "entry_point", "auth"): + if not s.get(key): + issues.append(err(f"{p}.{key}", "required")) + if not isinstance(s.get("warden_executes"), bool): + issues.append(err(f"{p}.warden_executes", "must be true or false")) + if s.get("attended") is True and not s.get("attended_justification"): + issues.append(err(f"{p}.attended_justification", "an attended step must say why it cannot be unattended")) + return ids + + +def _validate_verification(checks: Any, params: dict[str, dict], issues: list[Issue]) -> None: + path = "type.spec.verification" + if not isinstance(checks, list) or not checks: + issues.append(err(path, "must list checks that prove a case is live")) + return + expects = {c.get("expect") for c in checks if isinstance(c, dict)} + if "deny" not in expects: + issues.append(err(path, "needs at least one negative check (expect: deny)")) + if not expects & {"allow", "present"}: + issues.append(err(path, "needs at least one positive check (expect: allow or present)")) + for i, c in enumerate(checks): + if not isinstance(c, dict) or not c.get("id") or not c.get("check"): + issues.append(err(f"{path}[{i}]", "needs id, check and expect")) + if isinstance(c, dict) and c.get("reads_secret_value") is not False: + issues.append(err(f"{path}[{i}].reads_secret_value", "must be false: verification never reads a value")) + _validate_templates(checks, path, params, issues) + + +def _validate_rollback(steps: Any, step_ids: set[str], materialization: Any, issues: list[Issue]) -> None: + path = "type.spec.rollback" + if not isinstance(steps, list) or not steps: + issues.append(err(path, "must give the exact inverse of each step")) + return + covered = {s.get("reverses") for s in steps if isinstance(s, dict)} + mat_ids = {m.get("id") for m in materialization if isinstance(m, dict)} if isinstance(materialization, list) else set() + for missing in sorted((step_ids | mat_ids) - covered): + issues.append(err(path, f"no rollback reverses {missing!r}")) + + +def _validate_acceptance(acc: Any, issues: list[Issue]) -> None: + path = "type.spec.acceptance" + if not isinstance(acc, dict): + issues.append(err(path, "must be a mapping")) + return + who = acc.get("accepted_by") + if not isinstance(who, list) or not who or not set(who) <= ACCEPTERS: + issues.append(err(f"{path}.accepted_by", f"must be a non-empty subset of {sorted(ACCEPTERS)}")) + if acc.get("risk_class") not in RISK_CLASSES: + issues.append(err(f"{path}.risk_class", f"must be one of {sorted(RISK_CLASSES)}")) + if acc.get("risk_class") == "high" and "founder" not in (who or []): + issues.append(err(f"{path}.accepted_by", "a high-risk type needs founder acceptance of each case")) + + +# ── Cases ──────────────────────────────────────────────────────────────────── + +def load_type(types_root: Path, ref: str) -> tuple[list[Issue], dict[str, Any] | None, Path | None]: + m = TYPE_REF.fullmatch(ref or "") + if not m: + return [err("case.spec.type", "must be @.")], None, None + type_dir = types_root / m.group(1) + type_file = type_dir / "type.yaml" + if not type_file.is_file(): + return [err("case.spec.type", f"unknown type {m.group(1)!r}")], None, None + data = yaml.safe_load(type_file.read_text()) or {} + if str(data.get("spec", {}).get("version")) != m.group(2): + return [err("case.spec.type", f"type {m.group(1)!r} is at version " + f"{data.get('spec', {}).get('version')}, not {m.group(2)}")], None, None + return [], data, type_dir + + +def validate_case(case: dict[str, Any], type_data: dict[str, Any], *, require_implemented: bool = True) -> list[Issue]: + issues: list[Issue] = [] + if case.get("apiVersion") != CASE_API_VERSION: + issues.append(err("case.apiVersion", f"must be {CASE_API_VERSION}")) + if case.get("kind") != "SecurityCase": + issues.append(err("case.kind", "must be SecurityCase")) + spec = case.get("spec") or {} + if not ID.fullmatch(str(spec.get("id", ""))): + issues.append(err("case.spec.id", "must be lowercase letters, digits and hyphens")) + if spec.get("status") not in CASE_STATUSES: + issues.append(err("case.spec.status", f"must be one of {CASE_STATUSES}")) + owner = spec.get("owner") or {} + if not owner.get("repo") or not owner.get("person"): + issues.append(err("case.spec.owner", "needs repo and person")) + tspec = type_data.get("spec", {}) + if require_implemented and tspec.get("status") != "implemented": + issues.append(err("case.spec.type", f"type {tspec.get('id')!r} is {tspec.get('status')!r}; " + "only implemented types accept cases")) + if spec.get("status") != "proposed": + acc = spec.get("acceptance") or {} + needed = set(tspec.get("acceptance", {}).get("accepted_by", [])) + given = {a.get("role") for a in acc.get("by", []) if isinstance(a, dict)} + if not acc.get("at") or not acc.get("record") or not needed <= given: + issues.append(err("case.spec.acceptance", f"needs at, record and acceptance by {sorted(needed)}")) + + params = {p["name"]: p for p in tspec.get("parameters", [])} + values = spec.get("parameters") or {} + for name in sorted(set(values) - set(params)): + issues.append(err(f"case.spec.parameters.{name}", "not a parameter of this type")) + for name, p in params.items(): + path = f"case.spec.parameters.{name}" + if name not in values: + if p.get("case_settable") and p.get("required", True) and "default" not in p: + issues.append(err(path, "required")) + continue + if not p.get("case_settable"): + issues.append(err(path, "this type does not let cases set it")) + continue + issues.extend(_check_value(path, values[name], p)) + for path, text in _walk_strings(spec, "case.spec"): + if secret_shaped(text): + issues.append(err(path, "contains a credential-shaped value; cases hold references, never secrets")) + return issues + + +def _check_value(path: str, value: Any, p: dict[str, Any]) -> list[Issue]: + issues: list[Issue] = [] + kinds = {"string": str, "integer": int, "number": (int, float), "boolean": bool, + "array": list, "object": dict} + if not isinstance(value, kinds[p["type"]]) or (p["type"] in {"integer", "number"} and isinstance(value, bool)): + return [err(path, f"must be {p['type']}")] + c = p.get("constraints") or {} + items = value if isinstance(value, list) else [value] + for item in items: + if isinstance(item, str): + if "pattern" in c and not re.fullmatch(c["pattern"], item): + issues.append(err(path, f"{item!r} does not match {c['pattern']}")) + if p.get("sensitivity") == "secret_reference" and not CUSTODY_REFERENCE.fullmatch(item): + issues.append(err(path, "must be a custody reference such as bao:/")) + if "enum" in c and item not in c["enum"]: + issues.append(err(path, f"{item!r} is not one of {c['enum']}")) + if isinstance(value, list): + if "min_items" in c and len(value) < c["min_items"]: + issues.append(err(path, f"needs at least {c['min_items']} items")) + if c.get("unique") and len(set(map(str, value))) != len(value): + issues.append(err(path, "items must be unique")) + return issues + + +# ── Rendering ──────────────────────────────────────────────────────────────── + +def effective_values(case: dict[str, Any], type_data: dict[str, Any]) -> dict[str, Any]: + values = {p["name"]: p.get("default") for p in type_data["spec"]["parameters"] if "default" in p} + values.update(case["spec"].get("parameters") or {}) + return values + + +def _fill(value: Any, values: dict[str, Any]) -> Any: + if isinstance(value, str): + return value.format_map({k: (",".join(map(str, v)) if isinstance(v, list) else v) for k, v in values.items()}) + if isinstance(value, dict): + return {k: _fill(v, values) for k, v in value.items()} + if isinstance(value, list): + return [_fill(v, values) for v in value] + return value + + +def render_case(case: dict[str, Any], type_data: dict[str, Any], declarations: list, + *, require_implemented: bool = True) -> tuple[list[Issue], dict[str, Any] | None]: + issues = validate_case(case, type_data, require_implemented=require_implemented) + issues += validate_type(type_data) + if has_errors(issues): + return issues, None + tspec, cspec = type_data["spec"], case["spec"] + values = effective_values(case, type_data) + comp = tspec["composition"] + overrides: dict[str, dict[str, Any]] = {} + for target, source in (comp.get("parameter_bindings") or {}).items(): + decl_id, param = target.rsplit(".", 1) + overrides.setdefault(decl_id, {})[param] = values[source] + scenario = { + "id": f"scenario:case:{cspec['id']}", + "authority": comp["authority"], + "initial_trust": list(comp["initial_trust"]), + "requires": {"capabilities": list(comp["capabilities"])}, + "providers": dict(comp["providers"]), + } + if overrides: + scenario["parameter_overrides"] = overrides + scenario_issues, plan = composer.compose_scenario(declarations, scenario) + issues.extend(scenario_issues) + if plan is None or has_errors(issues): + return issues, None + rendered = { + "apiVersion": RENDER_API_VERSION, + "kind": "SecurityCaseRender", + "case": cspec["id"], + "type": f"{tspec['id']}@{tspec['version']}", + "composition": plan, + "materialization": _fill(tspec["materialization"], values), + "follow_through": _fill(tspec["follow_through"], values), + "verification": _fill(tspec["verification"], values), + "rollback": _fill(tspec["rollback"], values), + "execution": {"permitted": False}, + } + rendered["digest"] = "sha256:" + hashlib.sha256( + json.dumps(rendered, sort_keys=True, separators=(",", ":")).encode()).hexdigest() + return issues, rendered + + +# ── CLI ────────────────────────────────────────────────────────────────────── + +def _report(issues: list[Issue]) -> int: + for i in issues: + print(f"{i.level} {i.path}: {i.message}", file=sys.stderr) + return 1 if has_errors(issues) else 0 + + +def main(argv: list[str] | None = None) -> int: + ap = argparse.ArgumentParser(description=__doc__.splitlines()[0]) + sub = ap.add_subparsers(dest="cmd", required=True) + vt = sub.add_parser("validate-type") + vt.add_argument("type_dir") + vc = sub.add_parser("validate-case") + vc.add_argument("case") + vc.add_argument("--types", required=True) + vc.add_argument("--allow-unimplemented", action="store_true", + help="validate an example case against a type that is not implemented yet") + rd = sub.add_parser("render") + rd.add_argument("case") + rd.add_argument("declarations", nargs="+") + rd.add_argument("--types", required=True) + rd.add_argument("--allow-unimplemented", action="store_true") + args = ap.parse_args(argv) + + if args.cmd == "validate-type": + d = Path(args.type_dir) + data = yaml.safe_load((d / "type.yaml").read_text()) or {} + code = _report(validate_type(data, d)) + if code == 0: + print(f"OK {data['spec']['id']}@{data['spec']['version']} ({data['spec']['status']})") + return code + + case = yaml.safe_load(Path(args.case).read_text()) or {} + issues, type_data, _ = load_type(Path(args.types), (case.get("spec") or {}).get("type", "")) + if type_data is None: + return _report(issues) + if args.cmd == "validate-case": + code = _report(validate_case(case, type_data, require_implemented=not args.allow_unimplemented)) + if code == 0: + print(f"OK case {case['spec']['id']}") + return code + declarations, load_issues = composer.load_declarations(args.declarations) + issues, rendered = render_case(case, type_data, declarations, + require_implemented=not args.allow_unimplemented) + issues = load_issues + issues + if rendered is None: + return _report(issues) + json.dump(rendered, sys.stdout, indent=2, sort_keys=True) + print() + return _report(issues) + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tools/security-case-types/tests/test_security_case_validator.py b/tools/security-case-types/tests/test_security_case_validator.py new file mode 100644 index 0000000..a938d25 --- /dev/null +++ b/tools/security-case-types/tests/test_security_case_validator.py @@ -0,0 +1,159 @@ +import copy +import importlib.util +import sys +from pathlib import Path + +import yaml + +TOOL_DIR = Path(__file__).resolve().parents[1] +REPO = TOOL_DIR.parents[1] +SPEC = importlib.util.spec_from_file_location("security_case_validator", TOOL_DIR / "security_case_validator.py") +v = importlib.util.module_from_spec(SPEC) +sys.modules[SPEC.name] = v +SPEC.loader.exec_module(v) + +TEMPLATE_DIR = REPO / "security-case-types" / "_template" +TYPE = yaml.safe_load((TEMPLATE_DIR / "type.yaml").read_text()) +CASE = yaml.safe_load((TEMPLATE_DIR / "examples" / "example-operator.yaml").read_text()) +DECLS, _ = v.composer.load_declarations([str(REPO / "capabilities/playbooks/net-kingdom.local-identity.yaml")]) + + +def msgs(issues): + return [f"{i.path}: {i.message}" for i in issues if i.level == "ERROR"] + + +def implemented(t=None): + t = copy.deepcopy(t or TYPE) + t["spec"]["status"] = "implemented" + return t + + +def test_template_type_validates(): + assert msgs(v.validate_type(TYPE, TEMPLATE_DIR)) == [] + + +def test_missing_section_is_refused(): + t = copy.deepcopy(TYPE) + del t["spec"]["rollback"] + assert any("rollback" in m for m in msgs(v.validate_type(t))) + + +def test_unpinned_provider_is_refused(): + t = copy.deepcopy(TYPE) + t["spec"]["composition"]["providers"] = {} + assert any("provider pin" in m for m in msgs(v.validate_type(t))) + + +def test_unknown_capability_is_refused(): + t = copy.deepcopy(TYPE) + t["spec"]["composition"]["capabilities"] = ["c9.invented"] + assert any("unknown capability" in m for m in msgs(v.validate_type(t))) + + +def test_attended_step_needs_justification(): + t = copy.deepcopy(TYPE) + del t["spec"]["follow_through"][0]["attended_justification"] + assert any("attended" in m for m in msgs(v.validate_type(t))) + + +def test_every_step_needs_a_rollback(): + t = copy.deepcopy(TYPE) + t["spec"]["rollback"] = [r for r in t["spec"]["rollback"] if r["reverses"] != "init-store"] + assert any("init-store" in m for m in msgs(v.validate_type(t))) + + +def test_verification_needs_a_negative_check_and_never_reads_values(): + t = copy.deepcopy(TYPE) + t["spec"]["verification"] = [c for c in t["spec"]["verification"] if c["expect"] != "deny"] + t["spec"]["verification"][0]["reads_secret_value"] = True + found = msgs(v.validate_type(t)) + assert any("negative check" in m for m in found) + assert any("never reads a value" in m for m in found) + + +def test_template_with_unknown_parameter_is_refused(): + t = copy.deepcopy(TYPE) + t["spec"]["materialization"][1]["template"] = "policy-{nonexistent}" + assert any("unknown parameter" in m for m in msgs(v.validate_type(t))) + + +def test_high_risk_type_needs_founder_acceptance(): + t = copy.deepcopy(TYPE) + t["spec"]["acceptance"]["risk_class"] = "high" + assert any("founder" in m for m in msgs(v.validate_type(t))) + + +def test_credential_shaped_value_in_type_is_refused(): + t = copy.deepcopy(TYPE) + t["spec"]["purpose"] = "token hvs.CAESIabcdefghijklmnop" + assert any("credential-shaped" in m for m in msgs(v.validate_type(t))) + + +def test_case_against_unimplemented_type_is_refused(): + assert any("only implemented types" in m for m in msgs(v.validate_case(CASE, TYPE))) + assert msgs(v.validate_case(CASE, implemented())) == [] + + +def test_case_cannot_carry_a_secret_value(): + c = copy.deepcopy(CASE) + c["spec"]["parameters"]["signing_key_ref"] = "-----BEGIN PRIVATE KEY-----" + found = msgs(v.validate_case(c, implemented())) + assert any("custody reference" in m for m in found) + assert any("credential-shaped" in m for m in found) + + +def test_case_parameter_rules(): + c = copy.deepcopy(CASE) + c["spec"]["parameters"]["operator_username"] = "Not Valid" + c["spec"]["parameters"]["store_home"] = "/tmp/elsewhere" + c["spec"]["parameters"]["surprise"] = 1 + del c["spec"]["parameters"]["operator_email"] + found = msgs(v.validate_case(c, implemented())) + assert any("does not match" in m for m in found) + assert any("does not let cases set it" in m for m in found) + assert any("not a parameter of this type" in m for m in found) + assert any("operator_email: required" in m for m in found) + + +def test_accepted_case_needs_acceptance_record(): + c = copy.deepcopy(CASE) + c["spec"]["status"] = "accepted" + assert any("acceptance" in m for m in msgs(v.validate_case(c, implemented()))) + c["spec"]["acceptance"] = {"at": "2026-10-08", "record": "decision:x", "by": [{"role": "owner", "name": "a"}]} + assert msgs(v.validate_case(c, implemented())) == [] + + +def test_render_is_deterministic_plan_only_and_substitutes_parameters(): + issues, r = v.render_case(CASE, implemented(), DECLS) + assert msgs(issues) == [] + assert r["execution"] == {"permitted": False} + assert r["materialization"][1]["template"] == "local-identity-bootstrap-admin-read" + assert r["rollback"][1]["action"].endswith("bao:platform/workloads/example/local-identity-signing") + _, again = v.render_case(CASE, implemented(), DECLS) + assert r["digest"] == again["digest"] + + +def test_load_type_checks_reference_and_version(tmp_path): + root = tmp_path / "types" + (root / "template-example").mkdir(parents=True) + (root / "template-example" / "type.yaml").write_text(yaml.safe_dump(TYPE)) + issues, data, _ = v.load_type(root, "template-example@0.1") + assert data is not None and not msgs(issues) + issues, data, _ = v.load_type(root, "template-example@1.0") + assert data is None and any("version" in m for m in msgs(issues)) + issues, data, _ = v.load_type(root, "nope") + assert data is None + + +def test_template_conforms_to_the_json_schemas(): + import json + import pytest + jsonschema = pytest.importorskip("jsonschema") + type_schema = json.loads((REPO / "canon/schemas/security-case-type_v0.1.schema.json").read_text()) + case_schema = json.loads((REPO / "canon/schemas/security-case_v0.1.schema.json").read_text()) + jsonschema.validate(TYPE, type_schema) + jsonschema.validate(CASE, case_schema) + bad = copy.deepcopy(CASE) + bad["spec"]["surprise"] = True + with pytest.raises(jsonschema.ValidationError): + jsonschema.validate(bad, case_schema) diff --git a/workplans/NK-WP-0048-security-case-types.md b/workplans/NK-WP-0048-security-case-types.md index e255b86..33a394a 100644 --- a/workplans/NK-WP-0048-security-case-types.md +++ b/workplans/NK-WP-0048-security-case-types.md @@ -47,9 +47,7 @@ Companion plans: ops-mason `MASON-WP-0010` (materialization) and ops-warden ```task id: NK-WP-0048-T01 -status: wait -needs_human: true -blocking_reason: "The founder reviews and accepts the security-case-types_v0.1 canon proposal." +status: done priority: high state_hub_task_id: "930ac3e9-20ec-5473-98cd-3fe8fddb730f" ``` @@ -60,6 +58,9 @@ Founder review of `canon/standards/security-case-types_v0.1.md`, including its section 10 open questions. On acceptance the canon status goes from `proposed` to `accepted`. +2026-10-08: accepted by the founder (in session). Canon status `accepted`; +section 10's open questions are carried forward to the first type (T04, T05). + ```task id: NK-WP-0048-T02 status: done @@ -96,9 +97,8 @@ the forge), workload-kv-read second (largest payoff), then forge-read-identity. ```task id: NK-WP-0048-T03 -status: wait +status: done depends_on: [NK-WP-0048-T01] -blocking_reason: "The schema and template follow the accepted canon." priority: high state_hub_task_id: "e487975b-73b1-516c-971d-da4cbbb93063" ``` @@ -116,6 +116,13 @@ Acceptance: the validator rejects missing sections, secret-shaped parameter values, unpinned providers and cases against non-`implemented` types, with tests for each. +2026-10-08: done. `canon/schemas/security-case-type_v0.1.schema.json` and +`security-case_v0.1.schema.json`; `tools/security-case-types/security_case_validator.py` +(`validate-type`, `validate-case`, `render`, reusing the scenario composer); +`security-case-types/_template/` (a valid, runnable example type over +`net-kingdom.local-identity`, with README sections, an example case and tests that +travel with every copy). 31 tests pass, including the composer's existing suite. + ```task id: NK-WP-0048-T04 status: wait