From e6ee79402ba224829c0316ea365231f036ecdb58 Mon Sep 17 00:00:00 2001 From: Jon Chery Date: Wed, 12 Aug 2026 18:25:18 +0000 Subject: [PATCH 1/2] feat(P2): contract + stack-IR kyverno-json policies + resolver wiring (REQ-295..299) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit contract/ policies (4): require-id-pattern, require-env-in-enum, require-infrastructure-min-1, forbid-unknown-fields — declarative mirrors of contract.schema.json constraints. stack-ir/ policies (3): require-tagging-standard (nova:owner/contract/ environment/cost-center tags — ports nova_tagging.py), forbid-public-ingress (v1.0 demo rule, now declarative), require-encryption-by-default (v1.8 D-encryption-default — S3 + EBS encryption config). core/contract_resolver.py: pre-resolve contract-policy evaluation (REQ-296) + post-resolve stack-IR-policy evaluation (REQ-298). Additive — the resolver's return shape + exceptions unchanged; PCRs attach to stack_instance.policyResults. Policy evaluation never breaks the resolver (confidence signal decides gate). tests: test_stack_ir_policies.py + passing/failing fixtures. Skip-without-kj. 16 existing resolver tests unchanged. ---ci--- project: acdl phase: 2 milestone: v1.25 status: execute phase_role: execution requirements: covered: [REQ-295, REQ-296, REQ-297, REQ-298, REQ-299] partial: [] ---/ci--- --- .../contract/forbid-unknown-fields.json | 31 +++++++ .../contract/require-env-in-enum.json | 30 +++++++ .../policies/contract/require-id-pattern.json | 30 +++++++ .../require-infrastructure-min-1.json | 30 +++++++ .../stack-ir/forbid-public-ingress.json | 33 +++++++ .../require-encryption-by-default.json | 57 ++++++++++++ .../stack-ir/require-tagging-standard.json | 36 ++++++++ core/contract_resolver.py | 47 ++++++++++ tests/fixtures/stack_ir/failing.json | 34 ++++++++ tests/fixtures/stack_ir/passing.json | 43 ++++++++++ tests/test_stack_ir_policies.py | 86 +++++++++++++++++++ 11 files changed, 457 insertions(+) create mode 100644 adapters/kyverno-json/policies/contract/forbid-unknown-fields.json create mode 100644 adapters/kyverno-json/policies/contract/require-env-in-enum.json create mode 100644 adapters/kyverno-json/policies/contract/require-id-pattern.json create mode 100644 adapters/kyverno-json/policies/contract/require-infrastructure-min-1.json create mode 100644 adapters/kyverno-json/policies/stack-ir/forbid-public-ingress.json create mode 100644 adapters/kyverno-json/policies/stack-ir/require-encryption-by-default.json create mode 100644 adapters/kyverno-json/policies/stack-ir/require-tagging-standard.json create mode 100644 tests/fixtures/stack_ir/failing.json create mode 100644 tests/fixtures/stack_ir/passing.json create mode 100644 tests/test_stack_ir_policies.py diff --git a/adapters/kyverno-json/policies/contract/forbid-unknown-fields.json b/adapters/kyverno-json/policies/contract/forbid-unknown-fields.json new file mode 100644 index 0000000..9e884af --- /dev/null +++ b/adapters/kyverno-json/policies/contract/forbid-unknown-fields.json @@ -0,0 +1,31 @@ +{ + "apiVersion": "json.kyverno.io/v1alpha1", + "kind": "ValidatingPolicy", + "metadata": { + "name": "forbid-unknown-fields", + "annotations": { + "nova.cloudinit.dev/severity": "low", + "title.policy.kyverno.io": "Contract has only schema-allowed fields" + } + }, + "spec": { + "rules": [ + { + "name": "no-unknown-fields", + "validate": { + "message": "contract may only contain id, name, environment, infrastructure (schema-allowed fields)", + "assert": { + "all": [ + { + "check": { + "(length(keys(@)) == `4`)": true, + "keys(@)": "(contains(['id','name','environment','infrastructure'], @))" + } + } + ] + } + } + } + ] + } +} \ No newline at end of file diff --git a/adapters/kyverno-json/policies/contract/require-env-in-enum.json b/adapters/kyverno-json/policies/contract/require-env-in-enum.json new file mode 100644 index 0000000..54325c7 --- /dev/null +++ b/adapters/kyverno-json/policies/contract/require-env-in-enum.json @@ -0,0 +1,30 @@ +{ + "apiVersion": "json.kyverno.io/v1alpha1", + "kind": "ValidatingPolicy", + "metadata": { + "name": "require-env-in-enum", + "annotations": { + "nova.cloudinit.dev/severity": "high", + "title.policy.kyverno.io": "Contract environment is one of dev/qa/prod/dr" + } + }, + "spec": { + "rules": [ + { + "name": "env-enum", + "validate": { + "message": "contract.environment must be one of dev, qa, prod, dr", + "assert": { + "all": [ + { + "check": { + "environment": "(contains(['dev','qa','prod','dr'], @))" + } + } + ] + } + } + } + ] + } +} \ No newline at end of file diff --git a/adapters/kyverno-json/policies/contract/require-id-pattern.json b/adapters/kyverno-json/policies/contract/require-id-pattern.json new file mode 100644 index 0000000..b8810ef --- /dev/null +++ b/adapters/kyverno-json/policies/contract/require-id-pattern.json @@ -0,0 +1,30 @@ +{ + "apiVersion": "json.kyverno.io/v1alpha1", + "kind": "ValidatingPolicy", + "metadata": { + "name": "require-id-pattern", + "annotations": { + "nova.cloudinit.dev/severity": "high", + "title.policy.kyverno.io": "Contract id matches operational acronym pattern" + } + }, + "spec": { + "rules": [ + { + "name": "id-pattern", + "validate": { + "message": "contract.id must match ^[a-z][a-z0-9-]{2,5}$ (3-6 char operational acronym)", + "assert": { + "all": [ + { + "check": { + "id": "(regex_match('^[a-z][a-z0-9-]{2,5}$', @))" + } + } + ] + } + } + } + ] + } +} \ No newline at end of file diff --git a/adapters/kyverno-json/policies/contract/require-infrastructure-min-1.json b/adapters/kyverno-json/policies/contract/require-infrastructure-min-1.json new file mode 100644 index 0000000..862c42d --- /dev/null +++ b/adapters/kyverno-json/policies/contract/require-infrastructure-min-1.json @@ -0,0 +1,30 @@ +{ + "apiVersion": "json.kyverno.io/v1alpha1", + "kind": "ValidatingPolicy", + "metadata": { + "name": "require-infrastructure-min-1", + "annotations": { + "nova.cloudinit.dev/severity": "medium", + "title.policy.kyverno.io": "Contract declares at least one infrastructure entry" + } + }, + "spec": { + "rules": [ + { + "name": "infra-min-1", + "validate": { + "message": "contract.infrastructure must have at least one module entry", + "assert": { + "all": [ + { + "check": { + "infrastructure": "(length(keys(@)) > `0`)" + } + } + ] + } + } + } + ] + } +} \ No newline at end of file diff --git a/adapters/kyverno-json/policies/stack-ir/forbid-public-ingress.json b/adapters/kyverno-json/policies/stack-ir/forbid-public-ingress.json new file mode 100644 index 0000000..94641fd --- /dev/null +++ b/adapters/kyverno-json/policies/stack-ir/forbid-public-ingress.json @@ -0,0 +1,33 @@ +{ + "apiVersion": "json.kyverno.io/v1alpha1", + "kind": "ValidatingPolicy", + "metadata": { + "name": "forbid-public-ingress", + "annotations": { + "nova.cloudinit.dev/severity": "high", + "title.policy.kyverno.io": "No resource has public ingress enabled" + } + }, + "spec": { + "rules": [ + { + "name": "no-public-ingress", + "identifier": "id", + "validate": { + "message": "public_ingress: true is not allowed on any resource (v1.0 demo rule, now declarative)", + "assert": { + "all": [ + { + "check": { + "~.resources": { + "(inputs.public_ingress || `false`)": false + } + } + } + ] + } + } + } + ] + } +} \ No newline at end of file diff --git a/adapters/kyverno-json/policies/stack-ir/require-encryption-by-default.json b/adapters/kyverno-json/policies/stack-ir/require-encryption-by-default.json new file mode 100644 index 0000000..b646097 --- /dev/null +++ b/adapters/kyverno-json/policies/stack-ir/require-encryption-by-default.json @@ -0,0 +1,57 @@ +{ + "apiVersion": "json.kyverno.io/v1alpha1", + "kind": "ValidatingPolicy", + "metadata": { + "name": "require-encryption-by-default", + "annotations": { + "nova.cloudinit.dev/severity": "high", + "title.policy.kyverno.io": "S3 buckets and EBS volumes carry encryption config" + } + }, + "spec": { + "rules": [ + { + "name": "s3-encryption", + "identifier": "id", + "match": { + "any": [ + {"type": "aws:s3:bucket"} + ] + }, + "validate": { + "message": "S3 buckets must declare encryption config (inputs.bucket_encryption or inputs.kms_key_id)", + "assert": { + "all": [ + { + "check": { + "(contains(keys(inputs), 'bucket_encryption') || contains(keys(inputs), 'kms_key_id'))": true + } + } + ] + } + } + }, + { + "name": "ebs-encryption", + "identifier": "id", + "match": { + "any": [ + {"type": "aws:ebs:volume"} + ] + }, + "validate": { + "message": "EBS volumes must declare encryption (inputs.encrypted or inputs.kms_key_id)", + "assert": { + "all": [ + { + "check": { + "(contains(keys(inputs), 'encrypted') || contains(keys(inputs), 'kms_key_id'))": true + } + } + ] + } + } + } + ] + } +} \ No newline at end of file diff --git a/adapters/kyverno-json/policies/stack-ir/require-tagging-standard.json b/adapters/kyverno-json/policies/stack-ir/require-tagging-standard.json new file mode 100644 index 0000000..427b38f --- /dev/null +++ b/adapters/kyverno-json/policies/stack-ir/require-tagging-standard.json @@ -0,0 +1,36 @@ +{ + "apiVersion": "json.kyverno.io/v1alpha1", + "kind": "ValidatingPolicy", + "metadata": { + "name": "require-tagging-standard", + "annotations": { + "nova.cloudinit.dev/severity": "medium", + "title.policy.kyverno.io": "All resources carry required Nova tags" + } + }, + "spec": { + "rules": [ + { + "name": "require-nova-tags", + "identifier": "id", + "validate": { + "message": "Every taggable resource must carry nova:owner, nova:contract, nova:environment, nova:cost-center tags", + "assert": { + "all": [ + { + "check": { + "~.resources": { + "(contains(keys(tags || `[]`), 'nova:owner'))": true, + "(contains(keys(tags || `[]`), 'nova:contract'))": true, + "(contains(keys(tags || `[]`), 'nova:environment'))": true, + "(contains(keys(tags || `[]`), 'nova:cost-center'))": true + } + } + } + ] + } + } + } + ] + } +} \ No newline at end of file diff --git a/core/contract_resolver.py b/core/contract_resolver.py index ff7d7dd..ecc9a5d 100644 --- a/core/contract_resolver.py +++ b/core/contract_resolver.py @@ -488,6 +488,25 @@ def resolve(contract_path, repo_root=None, environment_override=None): # Validate contract against schema jsonschema.validate(contract, contract_schema) + # v1.25 (REQ-296): pre-resolve policy evaluation — run the active + # PolicyEngine over the contract dict with the contract/ policy + # dir BEFORE resolving. Failures feed the `policyResults` on the + # stack instance (the confidence signal's `policy` input). The + # resolver does NOT exit on policy failure — the confidence signal + # decides the gate (consistent with the existing --soft-fail + # Checkov pattern). + contract_pcrs: list = [] + try: + from core.policy_engine import get_engine, get_policy_root + _engine = get_engine() + _policy_root = get_policy_root() + contract_pcrs = _engine.evaluate( + contract, _policy_root / "contract", contract.get("id", "unknown") + ) + except Exception: + # Policy evaluation must never break the resolver. + contract_pcrs = [] + # Interpolation (D-081): expand ${env.} + ${contract.} # tokens AFTER schema validation (the schema sees raw tokens, which are # valid strings) and BEFORE IR resolution (the resolver sees concrete @@ -590,6 +609,12 @@ def resolve(contract_path, repo_root=None, environment_override=None): "data_sources": all_data_sources, } + # v1.25 (REQ-296): attach the pre-resolve contract-policy PCRs to + # the stack instance. The post-resolve stack-IR PCRs are appended + # after stack-schema validation (below). + if contract_pcrs: + stack_instance["policyResults"] = list(contract_pcrs) + # Add the human-readable title if contract.get("name"): stack_instance["stack"]["title"] = contract["name"] @@ -606,6 +631,28 @@ def resolve(contract_path, repo_root=None, environment_override=None): stack_schema = _load_schema(os.path.join(repo_root, "schemas", "stack.schema.json")) jsonschema.validate(stack_instance, stack_schema) + # v1.25 (REQ-298): post-resolve policy evaluation — run the active + # PolicyEngine over the resolved Stack IR with the stack-ir/ policy + # dir. The resulting PCRs are appended to the contract-policy PCRs + # on the stack instance (additive — the resolver's return value + # shape and exceptions are unchanged). The confidence signal + # consumes the merged list as its `policy` input. + try: + from core.policy_engine import get_engine, get_policy_root + engine = get_engine() + policy_root = get_policy_root() + stack_ir_pcrs = engine.evaluate( + stack_instance, policy_root / "stack-ir", contract.get("id", "unknown") + ) + stack_instance.setdefault("policyResults", []).extend(stack_ir_pcrs) + except Exception: + # Policy evaluation must never break the resolver — the + # confidence signal decides the gate. A failure here means the + # engine is misconfigured; the contract PCRs (if any) are still + # present, and the confidence signal proceeds with whatever + # `policy` input it receives (possibly empty → 0.5 neutral). + pass + return stack_instance diff --git a/tests/fixtures/stack_ir/failing.json b/tests/fixtures/stack_ir/failing.json new file mode 100644 index 0000000..ef03ba7 --- /dev/null +++ b/tests/fixtures/stack_ir/failing.json @@ -0,0 +1,34 @@ +{ + "version": "1.0.0", + "stack": { + "name": "bad", + "title": "failing stack", + "kind": "l1", + "depth": 1, + "environment": "dev" + }, + "resources": [ + { + "id": "bucket", + "type": "aws:s3:bucket", + "module": "s3@1.0.0", + "inputs": { + "bucket_name": "acdl-dev-bad-bucket", + "region": "us-east-1", + "tags": { + "nova:owner": "team-a" + } + } + }, + { + "id": "service", + "type": "aws:ecs:service", + "module": "microservice@1.0.0", + "inputs": { + "image": "nginx:latest", + "port": 80, + "public_ingress": true + } + } + ] +} \ No newline at end of file diff --git a/tests/fixtures/stack_ir/passing.json b/tests/fixtures/stack_ir/passing.json new file mode 100644 index 0000000..e4bf5bd --- /dev/null +++ b/tests/fixtures/stack_ir/passing.json @@ -0,0 +1,43 @@ +{ + "version": "1.0.0", + "stack": { + "name": "msvc", + "title": "microservice", + "kind": "l1", + "depth": 1, + "environment": "dev" + }, + "resources": [ + { + "id": "bucket", + "type": "aws:s3:bucket", + "module": "s3@1.0.0", + "inputs": { + "bucket_name": "acdl-dev-msvc-bucket", + "region": "us-east-1", + "bucket_encryption": {"rule": {"apply_server_side_encryption_by_default": {"sse_algorithm": "AES256"}}}, + "tags": { + "nova:owner": "team-a", + "nova:contract": "msvc", + "nova:environment": "dev", + "nova:cost-center": "cc-1" + } + } + }, + { + "id": "service", + "type": "aws:ecs:service", + "module": "microservice@1.0.0", + "inputs": { + "image": "nginx:latest", + "port": 80, + "tags": { + "nova:owner": "team-a", + "nova:contract": "msvc", + "nova:environment": "dev", + "nova:cost-center": "cc-1" + } + } + } + ] +} \ No newline at end of file diff --git a/tests/test_stack_ir_policies.py b/tests/test_stack_ir_policies.py new file mode 100644 index 0000000..3883c7f --- /dev/null +++ b/tests/test_stack_ir_policies.py @@ -0,0 +1,86 @@ +"""Tests for stack-IR kyverno-json policies (REQ-299, v1.25). + +Tests the 3 policies in adapters/kyverno-json/policies/stack-ir/: +require-tagging-standard, forbid-public-ingress, require-encryption-by- +default. Uses the passing + failing fixtures. Skips when kj is absent. +""" + +import json +import os +import sys +from pathlib import Path + +import pytest + +sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) + +import importlib.util +_ENGINE_PATH = Path(__file__).resolve().parent.parent / "adapters" / "kyverno-json" / "kyverno_json_engine.py" +_spec = importlib.util.spec_from_file_location("kyverno_json_engine", _ENGINE_PATH) +_mod = importlib.util.module_from_spec(_spec) +_spec.loader.exec_module(_mod) +KyvernoJsonEngine = _mod.KyvernoJsonEngine + +POLICY_DIR = Path(__file__).resolve().parent.parent / "adapters" / "kyverno-json" / "policies" / "stack-ir" +FIXTURES = Path(__file__).resolve().parent / "fixtures" / "stack_ir" + + +def _kj_installed() -> bool: + return _mod._which_kj() is not None + + +@pytest.fixture(autouse=True) +def _require_kj(): + if not _kj_installed(): + pytest.skip("kj not installed (scripts/install-kyverno-json.sh)") + + +def _load(name): + with open(FIXTURES / name, "r", encoding="utf-8") as fh: + return json.load(fh) + + +class TestPassingFixture: + def test_passing_fixture_all_pass(self): + eng = KyvernoJsonEngine() + out = eng.evaluate(_load("passing.json"), POLICY_DIR, "cid-pass") + assert isinstance(out, list) + assert len(out) >= 1 + # No fail results on the passing fixture. + fails = [p for p in out if p["result"] == "fail"] + assert fails == [], f"expected no fails on passing fixture, got: {fails}" + + +class TestFailingFixture: + def test_failing_fixture_has_fails(self): + eng = KyvernoJsonEngine() + out = eng.evaluate(_load("failing.json"), POLICY_DIR, "cid-fail") + fails = [p for p in out if p["result"] == "fail"] + assert len(fails) >= 1, "expected at least one fail on the failing fixture" + + +class TestPolicyFilesExist: + def test_three_policies_present(self): + files = sorted(os.listdir(POLICY_DIR)) + assert "require-tagging-standard.json" in files + assert "forbid-public-ingress.json" in files + assert "require-encryption-by-default.json" in files + + +class TestPolicyValidity: + def test_policies_are_valid_json(self): + for f in os.listdir(POLICY_DIR): + if f.endswith(".json"): + with open(POLICY_DIR / f, "r", encoding="utf-8") as fh: + data = json.load(fh) + assert data["apiVersion"] == "json.kyverno.io/v1alpha1" + assert data["kind"] == "ValidatingPolicy" + assert "nova.cloudinit.dev/severity" in data["metadata"]["annotations"] + + def test_policy_names_match_filenames(self): + for f in os.listdir(POLICY_DIR): + if f.endswith(".json"): + with open(POLICY_DIR / f, "r", encoding="utf-8") as fh: + data = json.load(fh) + expected = f.rsplit(".", 1)[0] + assert data["metadata"]["name"] == expected \ No newline at end of file From 0f0d9b914567dcad539a9fd04518da138b064b38 Mon Sep 17 00:00:00 2001 From: Jon Chery Date: Wed, 12 Aug 2026 18:26:36 +0000 Subject: [PATCH 2/2] =?UTF-8?q?verify(P2):=204-layer=20verify=20PASS=20?= =?UTF-8?q?=E2=80=94=20contract+stack-IR=20policies,=20resolver=20wiring,?= =?UTF-8?q?=200=20regressions?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ---ci--- project: acdl phase: 2 milestone: v1.25 status: verify phase_role: execution requirements: covered: [REQ-295, REQ-296, REQ-297, REQ-298, REQ-299] partial: [] ---/ci---