diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9a5b174..8f8f13f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -47,6 +47,38 @@ jobs: with: fail_ci_if_error: false + governance: + runs-on: ubuntu-latest + needs: test + + steps: + - uses: actions/checkout@v7 + + - uses: actions/setup-python@v6 + with: + python-version: "3.12" + + - name: Install dependencies + run: python -m pip install --upgrade pip setuptools && pip install -e ".[dev]" agent-compliance + + - name: Generate evidence file + run: python scripts/gen_agt_evidence.py + + - name: AGT governance verify (strict) + run: agt verify --evidence agt-evidence.json + + - name: Save attestation JSON + run: agt --json verify --evidence agt-evidence.json > agt-attestation.json + + - name: Upload governance artifacts + uses: actions/upload-artifact@v7 + with: + name: agt-governance-${{ github.sha }} + path: | + agt-evidence.json + agt-attestation.json + if-no-files-found: warn + benchmark: runs-on: ubuntu-latest needs: test diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 21e622a..c76e067 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -42,3 +42,34 @@ jobs: path: dist/ - uses: pypa/gh-action-pypi-publish@cef221092ed1bacb1cc03d23a2d87d1d172e277b # v1.14.0 + + governance-release: + needs: publish + runs-on: ubuntu-latest + permissions: + contents: write # upload release assets + + steps: + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + + - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 + with: + python-version: "3.12" + + - name: Install package and AGT + run: python -m pip install --upgrade pip && pip install -e "." agent-governance-toolkit-core + + - name: Generate evidence file + run: python scripts/gen_agt_evidence.py + + - name: AGT governance verify (strict) + run: agt verify --evidence agt-evidence.json + + - name: Save attestation JSON + run: agt --json verify --evidence agt-evidence.json > agt-attestation.json + + - name: Attach evidence to release + if: github.event_name == 'release' + run: gh release upload ${{ github.ref_name }} agt-evidence.json agt-attestation.json + env: + GITHUB_TOKEN: ${{ github.token }} diff --git a/governance/cmcp-enforcement.yaml b/governance/cmcp-enforcement.yaml new file mode 100644 index 0000000..7197a31 --- /dev/null +++ b/governance/cmcp-enforcement.yaml @@ -0,0 +1,26 @@ +# Default-deny governance policy descriptor for the cMCP gateway. +# This YAML document is consumed by `agt verify --evidence` to establish that +# cMCP enforces deny-by-default semantics at the MCP tool-call boundary. +deny_by_default: true +default_action: deny + +name: cmcp-gateway-governance +version: "1.0" +description: > + cMCP enforces default-deny Cedar policy on all MCP tool calls at the gateway + boundary inside a TEE. The Cedar policy bundle is measured into hardware at + startup — any tool call not matching an explicit permit rule is denied without + any code path the operator can override at runtime. + +enforcement_model: cedar +enforcement_point: tee-gateway +hardware_roots: + - amd-sev-snp + - intel-tdx + - tpm2 + +owasp_coverage: + ASI-01: PromptInjectionDetector — blocks injected instructions in tool responses + ASI-02: catalog + Cedar permit rules — enforces approved tool list + ASI-03: Cedar scope boundary — agent cannot call tools outside explicit permit + ASI-06: hash-chained audit inside TEE — tamper-evident call log per session diff --git a/scripts/gen_agt_evidence.py b/scripts/gen_agt_evidence.py new file mode 100644 index 0000000..3e4efe8 --- /dev/null +++ b/scripts/gen_agt_evidence.py @@ -0,0 +1,94 @@ +#!/usr/bin/env python3 +"""Generate agt-evidence.json describing cMCP's governance state. + +Run from the repo root: + python scripts/gen_agt_evidence.py [output-path] + +The output path defaults to agt-evidence.json in the current directory. +Policy file paths in the evidence are relative to the output file so that +`agt verify --evidence agt-evidence.json` can locate them. +""" + +from __future__ import annotations + +import importlib.metadata +import json +import sys +from datetime import datetime, timezone +from pathlib import Path + +REPO_ROOT = Path(__file__).parent.parent + + +def _pkg_version(package: str) -> str: + try: + return importlib.metadata.version(package) + except importlib.metadata.PackageNotFoundError: + return "not-installed" + + +def generate_evidence() -> dict: + """Return the evidence dict describing this cMCP deployment's governance state. + + All policy file paths are relative to the evidence file location (repo root) + so that ``agt verify --evidence agt-evidence.json`` resolves them correctly + in any working directory. + """ + catalog_path = REPO_ROOT / "examples" / "bfsi-demo" / "catalog.json" + try: + catalog = json.loads(catalog_path.read_text(encoding="utf-8")) + registered_tools = [ + entry["tool_name"] for entry in catalog if isinstance(entry, dict) and "tool_name" in entry + ] + except (FileNotFoundError, json.JSONDecodeError, KeyError): + registered_tools = [] + + return { + "schema": "agt-runtime-evidence/v1", + "generated_at": "", # populated by main() + "toolkit_version": _pkg_version("agent-governance-toolkit-core"), + "deployment": { + # Relative to this evidence file (repo root). + # governance/cmcp-enforcement.yaml has deny_by_default: true. + "policy_files_loaded": [ + "governance/cmcp-enforcement.yaml", + ], + "registered_tools": registered_tools, + "audit_sink": { + "enabled": True, + "target": "src/cmcp_runtime/audit/chain.py", + "type": "tee-hash-chained", + }, + "identity": { + "enabled": True, + "type": "spiffe", + "backend": "agent_os", + }, + "packages": [ + { + "package": "cmcp-runtime", + "version": _pkg_version("cmcp-runtime"), + }, + { + "package": "agent-governance-toolkit-core", + "version": _pkg_version("agent-governance-toolkit-core"), + }, + { + "package": "agentrust-trace", + "version": _pkg_version("agentrust-trace"), + }, + ], + }, + } + + +def main(out_path: str = "agt-evidence.json") -> None: + evidence = generate_evidence() + evidence["generated_at"] = datetime.now(timezone.utc).isoformat() + path = Path(out_path) + path.write_text(json.dumps(evidence, indent=2), encoding="utf-8") + print(f"Generated {path} ({path.stat().st_size} bytes)") + + +if __name__ == "__main__": + main(sys.argv[1] if len(sys.argv) > 1 else "agt-evidence.json") diff --git a/tests/unit/test_agt_evidence.py b/tests/unit/test_agt_evidence.py new file mode 100644 index 0000000..62ab784 --- /dev/null +++ b/tests/unit/test_agt_evidence.py @@ -0,0 +1,220 @@ +"""Tests for scripts/gen_agt_evidence.py and the generated agt-evidence.json.""" + +from __future__ import annotations + +import importlib.util +import json +import shutil +from pathlib import Path + +import pytest + +_SCRIPTS_DIR = Path(__file__).parent.parent.parent / "scripts" +_REPO_ROOT = Path(__file__).parent.parent.parent + + +def _load_generator(): + spec = importlib.util.spec_from_file_location( + "gen_agt_evidence", _SCRIPTS_DIR / "gen_agt_evidence.py" + ) + mod = importlib.util.module_from_spec(spec) # type: ignore[arg-type] + spec.loader.exec_module(mod) # type: ignore[union-attr] + return mod + + +@pytest.fixture(scope="module") +def generator(): + return _load_generator() + + +@pytest.fixture(scope="module") +def evidence(generator) -> dict: + return generator.generate_evidence() + + +# --------------------------------------------------------------------------- # +# Schema and top-level structure # +# --------------------------------------------------------------------------- # + + +def test_schema_field(evidence): + assert evidence["schema"] == "agt-runtime-evidence/v1" + + +def test_has_toolkit_version(evidence): + assert "toolkit_version" in evidence + assert isinstance(evidence["toolkit_version"], str) + + +def test_has_deployment_object(evidence): + assert isinstance(evidence.get("deployment"), dict) + + +# --------------------------------------------------------------------------- # +# Deployment sub-fields # +# --------------------------------------------------------------------------- # + + +def test_policy_files_not_empty(evidence): + pf = evidence["deployment"]["policy_files_loaded"] + assert isinstance(pf, list) + assert len(pf) > 0, "policy_files_loaded must contain at least one entry" + + +def test_policy_file_paths_exist(evidence): + """Every reported policy file must exist on disk relative to repo root.""" + for rel in evidence["deployment"]["policy_files_loaded"]: + full = _REPO_ROOT / rel + assert full.exists(), f"Policy file missing: {full}" + + +def test_registered_tools_not_empty(evidence): + tools = evidence["deployment"]["registered_tools"] + assert isinstance(tools, list) + assert len(tools) > 0, "registered_tools must not be empty" + + +def test_registered_tools_from_catalog(evidence): + """Tool list is derived from the bfsi-demo catalog.""" + catalog_path = _REPO_ROOT / "examples" / "bfsi-demo" / "catalog.json" + catalog = json.loads(catalog_path.read_text(encoding="utf-8")) + expected = [e["tool_name"] for e in catalog if isinstance(e, dict) and "tool_name" in e] + assert evidence["deployment"]["registered_tools"] == expected + + +def test_audit_sink_enabled(evidence): + sink = evidence["deployment"]["audit_sink"] + assert sink.get("enabled") is True + assert sink.get("target") or sink.get("path") or sink.get("url"), ( + "audit_sink must have a non-empty target, path, or url" + ) + + +def test_identity_enabled(evidence): + assert evidence["deployment"]["identity"]["enabled"] is True + + +def test_packages_valid(evidence): + packages = evidence["deployment"]["packages"] + assert isinstance(packages, list) + assert len(packages) > 0 + for pkg in packages: + assert isinstance(pkg.get("package"), str) and pkg["package"].strip() + assert isinstance(pkg.get("version"), str) and pkg["version"].strip() + + +def test_cmcp_runtime_in_packages(evidence): + names = [p["package"] for p in evidence["deployment"]["packages"]] + assert "cmcp-runtime" in names + + +def test_agt_in_packages(evidence): + names = [p["package"] for p in evidence["deployment"]["packages"]] + assert "agent-governance-toolkit-core" in names + + +# --------------------------------------------------------------------------- # +# main() writes valid JSON # +# --------------------------------------------------------------------------- # + + +def test_main_writes_valid_json(tmp_path, generator): + out = tmp_path / "agt-evidence.json" + generator.main(str(out)) + assert out.exists() + data = json.loads(out.read_text(encoding="utf-8")) + assert data["schema"] == "agt-runtime-evidence/v1" + assert data["generated_at"] # populated by main() + + +# --------------------------------------------------------------------------- # +# Integration: GovernanceVerifier.verify_evidence() must pass all checks # +# --------------------------------------------------------------------------- # + + +def _try_import_verifier(): + try: + from agent_compliance.verify import GovernanceVerifier + return GovernanceVerifier + except ImportError: + return None + + +@pytest.mark.skipif( + _try_import_verifier() is None, + reason="agent-governance-toolkit-core not installed", +) +def test_verify_evidence_passes(tmp_path, generator): + """GovernanceVerifier.verify_evidence() must succeed with no evidence failures.""" + from agent_compliance.verify import GovernanceVerifier + + # Write evidence file to tmp_path root so relative policy paths resolve. + # governance/cmcp-enforcement.yaml must exist relative to the evidence file. + gov_dir = tmp_path / "governance" + gov_dir.mkdir() + shutil.copy( + _REPO_ROOT / "governance" / "cmcp-enforcement.yaml", + gov_dir / "cmcp-enforcement.yaml", + ) + + # Catalog must also be accessible (policy_files_loaded uses paths relative to + # the evidence file location; the audit target is a string, not checked for existence). + evidence_path = tmp_path / "agt-evidence.json" + ev = generator.generate_evidence() + from datetime import datetime + ev["generated_at"] = datetime.now(datetime.UTC).isoformat() + evidence_path.write_text(json.dumps(ev, indent=2), encoding="utf-8") + + attestation = GovernanceVerifier().verify_evidence(evidence_path, strict=True) + + failures = [c for c in attestation.evidence_checks if c.status == "fail"] + assert not failures, ( + "Evidence checks failed:\n" + + "\n".join(f" {c.check_id}: {c.message}" for c in failures) + ) + + +@pytest.mark.skipif( + _try_import_verifier() is None, + reason="agent-governance-toolkit-core not installed", +) +def test_verify_evidence_json_is_valid(tmp_path, generator): + """to_json() output is valid JSON with expected top-level keys.""" + from agent_compliance.verify import GovernanceVerifier + + gov_dir = tmp_path / "governance" + gov_dir.mkdir() + shutil.copy( + _REPO_ROOT / "governance" / "cmcp-enforcement.yaml", + gov_dir / "cmcp-enforcement.yaml", + ) + + evidence_path = tmp_path / "agt-evidence.json" + ev = generator.generate_evidence() + from datetime import datetime + ev["generated_at"] = datetime.now(datetime.UTC).isoformat() + evidence_path.write_text(json.dumps(ev, indent=2), encoding="utf-8") + + attestation = GovernanceVerifier().verify_evidence(evidence_path) + output = json.loads(attestation.to_json()) + + assert output["schema"] == "governance-attestation/v1" + assert output["mode"] == "evidence" + assert isinstance(output["controls_total"], int) + assert isinstance(output["controls_passed"], int) + assert isinstance(output["attestation_hash"], str) and len(output["attestation_hash"]) == 64 + + +# --------------------------------------------------------------------------- # +# Governance YAML has deny semantics # +# --------------------------------------------------------------------------- # + + +def test_governance_yaml_has_deny_by_default(): + """cmcp-enforcement.yaml must have deny_by_default: true at the top level.""" + import yaml + gov_path = _REPO_ROOT / "governance" / "cmcp-enforcement.yaml" + data = yaml.safe_load(gov_path.read_text(encoding="utf-8")) + assert data.get("deny_by_default") is True, ( + "governance/cmcp-enforcement.yaml must have deny_by_default: true" + )