#!/usr/bin/env python3 """Compose a deterministic, plan-only NetKingdom security scenario.""" from __future__ import annotations import argparse import hashlib import json import re import sys from pathlib import Path from typing import Any TOOL_DIR = Path(__file__).resolve().parent CONTRACT_TOOL_DIR = TOOL_DIR.parent / "playbook-capability-contract" sys.path.insert(0, str(CONTRACT_TOOL_DIR)) import playbook_contract_validator as contract # noqa: E402 PLAN_API_VERSION = "netkingdom.io/security-scenario-composition/v0.1" PLAN_KIND = "SecurityScenarioComposition" SCENARIO_ID = re.compile(r"^scenario:[a-z0-9][a-z0-9._:-]*$") SCENARIO_KEYS = { "id", "authority", "initial_trust", "requires", "providers", "parameter_overrides", } Issue = contract.Issue Declaration = contract.Declaration def error(path: str, message: str) -> Issue: return Issue("ERROR", path, message) def has_errors(issues: list[Issue]) -> bool: return any(item.level == "ERROR" for item in issues) def validate_scenario(scenario: dict[str, Any]) -> list[Issue]: issues: list[Issue] = [] unknown_keys = sorted(set(scenario) - SCENARIO_KEYS) if unknown_keys: issues.append(error("scenario", f"unknown fields: {unknown_keys}")) scenario_id = scenario.get("id") if not isinstance(scenario_id, str) or not SCENARIO_ID.fullmatch(scenario_id): issues.append(error("scenario.id", "must match 'scenario:'")) authority = scenario.get("authority") if authority not in contract.SCENARIO_AUTHORITIES: issues.append(error("scenario.authority", f"unknown authority {authority!r}")) requires = scenario.get("requires") if not isinstance(requires, dict): issues.append(error("scenario.requires", "must be an object")) required_caps: list[Any] = [] else: unknown_requires = sorted(set(requires) - {"capabilities"}) if unknown_requires: issues.append(error("scenario.requires", f"unknown fields: {unknown_requires}")) required_caps = requires.get("capabilities", []) if not isinstance(required_caps, list) or not required_caps: issues.append(error("scenario.requires.capabilities", "must be a non-empty list")) required_caps = [] seen_caps: set[str] = set() for index, cap_id in enumerate(required_caps): path = f"scenario.requires.capabilities[{index}]" if cap_id not in contract.CAPABILITIES: issues.append(error(path, f"unknown capability id {cap_id!r}")) if cap_id in seen_caps: issues.append(error(path, f"duplicate capability id {cap_id!r}")) if isinstance(cap_id, str): seen_caps.add(cap_id) initial_trust = scenario.get("initial_trust", []) if not isinstance(initial_trust, list): issues.append(error("scenario.initial_trust", "must be a list")) initial_trust = [] seen_trust: set[str] = set() for index, state in enumerate(initial_trust): path = f"scenario.initial_trust[{index}]" if state not in contract.TRUST_STATES: issues.append(error(path, f"unknown trust state {state!r}")) if state in seen_trust: issues.append(error(path, f"duplicate trust state {state!r}")) if isinstance(state, str): seen_trust.add(state) providers = scenario.get("providers", {}) if not isinstance(providers, dict): issues.append(error("scenario.providers", "must be an object")) providers = {} for cap_id, declaration_id in providers.items(): path = f"scenario.providers.{cap_id}" if cap_id not in seen_caps: issues.append(error(path, "provider pin targets an unrequested capability")) if not isinstance(declaration_id, str) or not declaration_id: issues.append(error(path, "provider declaration id must be a non-empty string")) overrides = scenario.get("parameter_overrides", {}) if not isinstance(overrides, dict): issues.append(error("scenario.parameter_overrides", "must be an object")) else: for declaration_id, values in overrides.items(): if not isinstance(declaration_id, str) or not declaration_id: issues.append(error("scenario.parameter_overrides", "declaration ids must be non-empty strings")) if not isinstance(values, dict): issues.append(error(f"scenario.parameter_overrides.{declaration_id}", "must be an object")) return issues def validate_declarations(declarations: list[Declaration]) -> list[Issue]: issues: list[Issue] = [] ids: dict[str, list[str]] = {} for declaration in declarations: for item in contract.validate_declaration(declaration): issues.append( Issue( item.level, f"declarations[{declaration.path}].{item.path}", item.message, ) ) ids.setdefault(declaration.id, []).append(str(declaration.path)) for declaration_id, paths in sorted(ids.items()): if not declaration_id: continue if len(paths) > 1: issues.append( error( "declarations", f"duplicate declaration id {declaration_id!r}: {sorted(paths)}", ) ) return issues def declaration_trust(declaration: Declaration, section: str) -> list[dict[str, Any]]: trust = declaration.data.get("spec", {}).get("trust", {}) values = trust.get(section, []) if isinstance(trust, dict) else [] return values if isinstance(values, list) else [] def trust_states(declaration: Declaration, section: str) -> set[str]: return { str(item.get("state")) for item in declaration_trust(declaration, section) if isinstance(item, dict) and item.get("state") } def stable_digest(scenario: dict[str, Any], selected: dict[str, Declaration]) -> str: payload = { "scenario": scenario, "declarations": { declaration_id: selected[declaration_id].data for declaration_id in sorted(selected) }, } encoded = json.dumps(payload, sort_keys=True, separators=(",", ":")).encode("utf-8") return f"sha256:{hashlib.sha256(encoded).hexdigest()}" def select_declarations( declarations: list[Declaration], scenario: dict[str, Any], ) -> tuple[list[Issue], dict[str, Declaration], dict[str, str]]: issues: list[Issue] = [] required_caps = scenario["requires"]["capabilities"] pins = scenario.get("providers", {}) selected: dict[str, Declaration] = {} capability_providers: dict[str, str] = {} for cap_id in required_caps: matches = sorted( (declaration for declaration in declarations if cap_id in declaration.capabilities), key=lambda declaration: declaration.id, ) pin = pins.get(cap_id) if pin is not None: pinned = [declaration for declaration in matches if declaration.id == pin] if not pinned: available = [declaration.id for declaration in matches] issues.append( error( f"scenario.providers.{cap_id}", f"pinned declaration {pin!r} does not provide {cap_id!r}; available={available}", ) ) continue chosen = pinned[0] elif not matches: issues.append( error( "scenario.requires.capabilities", f"no declaration provides {cap_id!r}", ) ) continue elif len(matches) > 1: issues.append( error( f"scenario.providers.{cap_id}", f"ambiguous providers {[item.id for item in matches]}; pin one explicitly", ) ) continue else: chosen = matches[0] selected[chosen.id] = chosen capability_providers[str(cap_id)] = chosen.id return issues, selected, capability_providers def effective_parameters( declaration: Declaration, overrides: dict[str, Any], authority: str, ) -> tuple[list[Issue], dict[str, Any]]: issues: list[Issue] = [] params_out: dict[str, Any] = {} for name in sorted(overrides): if name not in declaration.parameters: issues.append( error( f"scenario.parameter_overrides.{declaration.id}.{name}", "unknown parameter override", ) ) for name, param in sorted(declaration.parameters.items()): overridden, value = contract.effective_parameter_value(param, overrides, declaration.id) path = f"scenario.parameter_overrides.{declaration.id}.{name}" if param.get("required") is True and value is None: issues.append(error(path, "required parameter has no default or override")) if overridden: issues.extend(contract.validate_override_allowed(param, value, authority, path)) params_out[name] = { "value": value, "source": "override" if overridden else "default", "sensitivity": param.get("sensitivity"), "tuning_authority": param.get("tuning_authority"), } return issues, params_out def sequence_declarations( selected: dict[str, Declaration], initial_trust: set[str], ) -> tuple[list[Issue], list[Declaration], set[str]]: issues: list[Issue] = [] established = set(initial_trust) remaining = dict(selected) ordered: list[Declaration] = [] while remaining: eligible = sorted( ( declaration for declaration in remaining.values() if trust_states(declaration, "requires") <= established ), key=lambda declaration: declaration.id, ) if not eligible: for declaration_id, declaration in sorted(remaining.items()): missing = sorted(trust_states(declaration, "requires") - established) issues.append( error( f"composition.execution_steps.{declaration_id}", f"cannot sequence declaration; unresolved trust states: {missing}", ) ) break chosen = eligible[0] ordered.append(chosen) established.update(trust_states(chosen, "satisfies")) del remaining[chosen.id] return issues, ordered, established def compose_scenario( declarations: list[Declaration], scenario: dict[str, Any], ) -> tuple[list[Issue], dict[str, Any] | None]: issues = validate_scenario(scenario) issues.extend(validate_declarations(declarations)) if has_errors(issues): return issues, None selection_issues, selected, capability_providers = select_declarations(declarations, scenario) issues.extend(selection_issues) if has_errors(issues): return issues, None overrides = scenario.get("parameter_overrides", {}) for declaration_id in sorted(overrides): if declaration_id not in selected: issues.append( error( f"scenario.parameter_overrides.{declaration_id}", "override targets an unselected declaration", ) ) parameters: dict[str, dict[str, Any]] = {} authority = str(scenario["authority"]) for declaration_id, declaration in sorted(selected.items()): param_issues, values = effective_parameters( declaration, overrides.get(declaration_id, {}), authority, ) issues.extend(param_issues) parameters[declaration_id] = values sequence_issues, ordered, planned_trust = sequence_declarations( selected, set(scenario.get("initial_trust", [])), ) issues.extend(sequence_issues) if has_errors(issues): return issues, None execution_steps: list[dict[str, Any]] = [] responsibilities: list[dict[str, Any]] = [] for index, declaration in enumerate(ordered, start=1): metadata = declaration.data["metadata"] spec = declaration.data["spec"] required = declaration_trust(declaration, "requires") satisfied = declaration_trust(declaration, "satisfies") readiness = [ { "state": item["state"], "checks": item.get("readiness_checks", []), } for item in satisfied ] execution_steps.append( { "order": index, "declaration_id": declaration.id, "executor_owner": metadata["owner"], "repo": metadata["repo"], "capabilities": sorted( cap_id for cap_id, provider in capability_providers.items() if provider == declaration.id ), "entry_point": spec["playbook"], "parameters": parameters[declaration.id], "requires_trust": required, "satisfies_trust": satisfied, "readiness_obligations": readiness, } ) for responsibility in spec["responsibilities"]: responsibilities.append( { "declaration_id": declaration.id, **responsibility, } ) composition = { "apiVersion": PLAN_API_VERSION, "kind": PLAN_KIND, "scenario": scenario["id"], "authority": authority, "composition_digest": stable_digest(scenario, selected), "requested_capabilities": list(scenario["requires"]["capabilities"]), "capability_providers": capability_providers, "initial_trust": sorted(scenario.get("initial_trust", [])), "planned_final_trust": sorted(planned_trust), "execution": { "mode": "plan-only", "permitted": False, "reason": "Composition does not authorize or perform provider execution.", }, "execution_steps": execution_steps, "responsibility_map": responsibilities, } return issues, composition def load_declarations(paths: list[str]) -> tuple[list[Declaration], list[Issue]]: declarations: list[Declaration] = [] issues: list[Issue] = [] for raw_path in paths: path = Path(raw_path) try: data = contract.load_yaml(path) except Exception as exc: issues.append(error(f"declarations[{path}]", f"failed to load: {exc}")) continue declarations.append(Declaration(path=path, data=data)) return declarations, issues def build_parser() -> argparse.ArgumentParser: parser = argparse.ArgumentParser( description="Compose a deterministic, non-executing NetKingdom security scenario." ) parser.add_argument("declarations", nargs="+", help="Playbook capability declaration YAML files") parser.add_argument("--scenario", required=True, help="Security scenario YAML file") return parser def main(argv: list[str] | None = None) -> int: args = build_parser().parse_args(argv) declarations, issues = load_declarations(args.declarations) try: scenario = contract.load_yaml(Path(args.scenario)) except Exception as exc: issues.append(error("scenario", f"failed to load {args.scenario}: {exc}")) scenario = {} composition: dict[str, Any] | None = None if not has_errors(issues): composition_issues, composition = compose_scenario(declarations, scenario) issues.extend(composition_issues) payload = { "ok": not has_errors(issues), "issues": [item.__dict__ for item in issues], "composition": composition, } print(json.dumps(payload, indent=2, sort_keys=True)) return 1 if has_errors(issues) else 0 if __name__ == "__main__": sys.exit(main())