From 23e1ac189e1b0cd926df73d835e171168d697776 Mon Sep 17 00:00:00 2001 From: Imran Siddique Date: Sat, 6 Jun 2026 13:31:32 -0700 Subject: [PATCH 1/2] fix(security): require CMCP_POLICY_HASH and CMCP_CATALOG_HASH in production Closes #136, #137. Without pinned hashes, a compromised policy bundle or tool catalog would load silently. Gateway now exits with code 1 if either env var is unset and CMCP_DEV_MODE is not set, making tampered artifacts detectable at startup rather than at runtime. Co-Authored-By: Claude Sonnet 4.6 --- src/cmcp_gateway/startup.py | 21 ++++++++++++++++++ tests/unit/test_startup.py | 43 +++++++++++++++++++++++++++++++++++++ 2 files changed, 64 insertions(+) diff --git a/src/cmcp_gateway/startup.py b/src/cmcp_gateway/startup.py index 4c2be11b..4ca3cbad 100644 --- a/src/cmcp_gateway/startup.py +++ b/src/cmcp_gateway/startup.py @@ -106,6 +106,17 @@ def run_startup(config_path: str) -> GatewayContext: # Step 4: policy bundle policy_expected_hash = os.environ.get("CMCP_POLICY_HASH") + if policy_expected_hash is None and not config.dev_mode: + # POLICY-001 (CRITICAL): without a pinned hash, a compromised policy bundle + # loads silently. Require CMCP_POLICY_HASH in production; set CMCP_DEV_MODE=1 + # only for local development. + _fatal( + "POLICY_HASH_REQUIRED", + "CMCP_POLICY_HASH env var is not set. " + "Set it to the sha256: of the policy bundle to prevent policy tampering. " + "Set CMCP_DEV_MODE=1 only in development to skip this check.", + ) + sys.exit(1) try: policy_bundle = load_policy_bundle(config.policy_bundle_path, expected_hash=policy_expected_hash) except PolicyHashMismatch as exc: @@ -124,6 +135,16 @@ def run_startup(config_path: str) -> GatewayContext: # Step 5: catalog catalog_expected_hash = os.environ.get("CMCP_CATALOG_HASH") + if catalog_expected_hash is None and not config.dev_mode: + # POLICY-002 (CRITICAL): without a pinned hash, a compromised catalog loads + # silently, allowing unauthorized tools. Require CMCP_CATALOG_HASH in production. + _fatal( + "CATALOG_HASH_REQUIRED", + "CMCP_CATALOG_HASH env var is not set. " + "Set it to the sha256: of the tool catalog to prevent catalog tampering. " + "Set CMCP_DEV_MODE=1 only in development to skip this check.", + ) + sys.exit(1) try: catalog = load_catalog(config.catalog_path, expected_hash=catalog_expected_hash) except CatalogHashMismatch as exc: diff --git a/tests/unit/test_startup.py b/tests/unit/test_startup.py index e755977d..2c76b3e6 100644 --- a/tests/unit/test_startup.py +++ b/tests/unit/test_startup.py @@ -118,3 +118,46 @@ def test_startup_fails_on_catalog_hash_mismatch(complete_setup, monkeypatch): with pytest.raises(SystemExit) as exc_info: run_startup(complete_setup) assert exc_info.value.code == 1 + + +def test_startup_fails_when_policy_hash_unset_and_not_dev_mode(tmp_path): + """POLICY-001 (CRITICAL): CMCP_POLICY_HASH must be set outside dev mode.""" + config_path = tmp_path / "cmcp-config.yaml" + policy_dir = tmp_path / "policy" + policy_dir.mkdir() + catalog_path = tmp_path / "catalog.json" + config_path.write_text(f"policy_bundle_path: {policy_dir}\ncatalog_path: {catalog_path}\n") + (policy_dir / "manifest.json").write_text(json.dumps(MANIFEST)) + (policy_dir / "allow.cedar").write_text(CEDAR_POLICY) + (policy_dir / "schema.cedarschema").write_text(SCHEMA) + catalog_path.write_text(json.dumps([CATALOG_ENTRY])) + + env = {"CMCP_DEV_MODE": "0"} + with patch.dict(os.environ, env, clear=True), pytest.raises(SystemExit) as exc_info: + run_startup(str(config_path)) + assert exc_info.value.code == 1 + + +def test_startup_fails_when_catalog_hash_unset_and_not_dev_mode(tmp_path, monkeypatch): + """POLICY-002 (CRITICAL): CMCP_CATALOG_HASH must be set outside dev mode.""" + config_path = tmp_path / "cmcp-config.yaml" + policy_dir = tmp_path / "policy" + policy_dir.mkdir() + catalog_path = tmp_path / "catalog.json" + config_path.write_text(f"policy_bundle_path: {policy_dir}\ncatalog_path: {catalog_path}\n") + (policy_dir / "manifest.json").write_text(json.dumps(MANIFEST)) + (policy_dir / "allow.cedar").write_text(CEDAR_POLICY) + (policy_dir / "schema.cedarschema").write_text(SCHEMA) + catalog_path.write_text(json.dumps([CATALOG_ENTRY])) + + from cmcp_gateway.policy.bundle import _canonical_bundle_hash + import json as _json + manifest_raw = _json.loads((policy_dir / "manifest.json").read_text()) + policy_files = {"allow.cedar": CEDAR_POLICY} + computed = _canonical_bundle_hash(manifest_raw, policy_files, SCHEMA) + policy_hash = f"sha256:{computed}" + + env = {"CMCP_DEV_MODE": "0", "CMCP_POLICY_HASH": policy_hash} + with patch.dict(os.environ, env, clear=True), pytest.raises(SystemExit) as exc_info: + run_startup(str(config_path)) + assert exc_info.value.code == 1 From 02ee97c68057e79dc3933a88a7e4a7fd2df1b1d8 Mon Sep 17 00:00:00 2001 From: Imran Siddique Date: Sat, 6 Jun 2026 13:35:55 -0700 Subject: [PATCH 2/2] fix(lint): sort inline import block in test_startup.py Co-Authored-By: Claude Sonnet 4.6 --- tests/unit/test_startup.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/unit/test_startup.py b/tests/unit/test_startup.py index 2c76b3e6..83dc9aea 100644 --- a/tests/unit/test_startup.py +++ b/tests/unit/test_startup.py @@ -150,8 +150,9 @@ def test_startup_fails_when_catalog_hash_unset_and_not_dev_mode(tmp_path, monkey (policy_dir / "schema.cedarschema").write_text(SCHEMA) catalog_path.write_text(json.dumps([CATALOG_ENTRY])) - from cmcp_gateway.policy.bundle import _canonical_bundle_hash import json as _json + + from cmcp_gateway.policy.bundle import _canonical_bundle_hash manifest_raw = _json.loads((policy_dir / "manifest.json").read_text()) policy_files = {"allow.cedar": CEDAR_POLICY} computed = _canonical_bundle_hash(manifest_raw, policy_files, SCHEMA)