From 9a84ccf13ecc24528a7ae5880a54e34439044790 Mon Sep 17 00:00:00 2001 From: Imran Siddique Date: Thu, 6 Aug 2026 13:01:25 -0700 Subject: [PATCH] fix(demos): refuse to run on a busy port, and cover demos 4-9 in CI ## The bug _wait_for_port() returns as soon as *anything* answers on the port. A cMCP gateway left running by an earlier demo satisfies it instantly, so every call in the next demo is routed to that gateway and decided by *its* policy bundle. The demo then prints allow/deny lines that are simply wrong, with no error anywhere. I hit this while auditing: demo-05 reported `write_file [clinical, baa_covered=true]` as DENIED, because demo-04's gateway still held :8443. On a clean port demo-05 is correct (two allowed, one denied). There was also a real cmcp.exe on this machine that had been holding :8443 since 2026-08-04. ## Changes - `_assert_port_free()` on both :9001 and :8443 before anything starts, in all four gateway demos. The demo now aborts with an actionable message instead of silently borrowing someone else's gateway. - `_wait_for_port_release()` in teardown, so a demo cannot hand the next one a port that is still closing. - `run.sh` for demos 1, 2, 4 and 5 now exec `run.py`. They had diverged badly: still doing `sleep 2` (the race #34 fixed in run.py), no port checks, and in demo-02's case running `cmcp verify` while the servers were still up. One implementation means the shell path and the CI path cannot drift again. Verified run.py covers all six of demo-02's steps. - CI now runs demos 4 through 9. Only 1-3 were covered; the other six were never exercised. - CI installs `-r requirements.txt` rather than `cmcp-runtime` alone. requirements.txt already pinned `weight-custody-manifest>=0.23.0`, which demos 6-9 import; installing only cmcp-runtime would have made the new steps fail on `import wcm`. ## Verified Precheck fires: with :8443 held, demo-04 exits 1 with the guidance message. It also caught a stray MCP server on :9001 I did not know was running. Demos 1-5 run **sequentially** in one shell and all pass, which is the case that previously produced wrong verdicts, and both ports are free afterwards. Demos 6-9 pass. `run.sh` wrappers for 03 and 04 pass. ## Not done No assertion that the claim's `policy.bundle_hash` matches the demo's own bundle. It would be a good backstop, but cmcp does not log its bundle hash at startup, so there is nothing to compare against without reimplementing the hashing recipe, and guessing it risks false failures. The port precheck removes the situation that made it necessary. Co-Authored-By: Claude Opus 5 (1M context) Signed-off-by: Imran Siddique --- .github/workflows/ci.yml | 21 +++- demo-01-cmcp-in-action/run.py | 40 +++++++ demo-01-cmcp-in-action/run.sh | 42 +------ demo-02-policy-swap/run.py | 40 +++++++ demo-02-policy-swap/run.sh | 169 ++--------------------------- demo-04-context-enforcement/run.py | 40 +++++++ demo-04-context-enforcement/run.sh | 42 +------ demo-05-compliance-domain/run.py | 40 +++++++ demo-05-compliance-domain/run.sh | 44 ++------ 9 files changed, 205 insertions(+), 273 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 48c1e14..cd9f550 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -23,8 +23,11 @@ jobs: with: python-version: "3.11" - - name: Install cmcp-runtime - run: pip install cmcp-runtime + # requirements.txt is the single source of truth: it already pins both + # cmcp-runtime (demos 1-5) and weight-custody-manifest (demos 6-9). + # Installing cmcp-runtime alone silently left the WCM demos unrunnable. + - name: Install demo dependencies + run: pip install -r requirements.txt # Run in order: demo-01 produces workspace/trace-claim.json, which # demo-02 and demo-03 consume. @@ -36,3 +39,17 @@ jobs: - name: Demo 3 - offline TRACE verification run: python demo-03-offline-trace/run.py + + - name: Demo 4 - context-aware enforcement + run: python demo-04-context-enforcement/run.py + + - name: Demo 5 - attribute-based enforcement (BAA coverage) + run: python demo-05-compliance-domain/run.py + + # Demos 6-9 are offline: no gateway, no ports, no MCP server. + - name: Demos 6-9 - weight custody + run: | + for d in demo-06-weight-custody demo-07-closed-weight demo-08-derivative-lineage demo-09-sovereign-threshold; do + echo "== $d ==" + python "$d/run.py" + done diff --git a/demo-01-cmcp-in-action/run.py b/demo-01-cmcp-in-action/run.py index 9d38267..17d2223 100644 --- a/demo-01-cmcp-in-action/run.py +++ b/demo-01-cmcp-in-action/run.py @@ -54,7 +54,45 @@ def _wait_for_port(port: int, what: str, timeout: float = 60.0) -> None: "See the *.log files in this demo folder.") +def _assert_port_free(port: int, what: str) -> None: + """Refuse to start if something already owns the port. + + _wait_for_port() returns as soon as *anything* answers, so a gateway left + running by an earlier demo satisfies it instantly. Every call then goes to + that gateway and is decided by its policy bundle, not this demo's. The + verdicts still look plausible, which is what makes it dangerous: the demo + prints allow/deny lines that are simply wrong, with no error anywhere. + """ + try: + with socket.create_connection(("127.0.0.1", port), timeout=1): + pass + except OSError: + return + sys.exit( + f"Port {port} is already in use, so {what} cannot start and this demo " + f"would be scored against whatever is already listening. Stop it first " + f"(a cMCP gateway left over from another demo is the usual cause), then " + f"re-run." + ) + + +def _wait_for_port_release(port: int, timeout: float = 10.0) -> None: + """Block until the port is actually free, so the next demo starts clean.""" + deadline = time.monotonic() + timeout + while time.monotonic() < deadline: + try: + with socket.create_connection(("127.0.0.1", port), timeout=1): + time.sleep(0.25) + except OSError: + return + print(f"warning: port {port} still held after teardown", file=sys.stderr) + + def main() -> None: + # Before anything starts: both ports must be ours. + _assert_port_free(9001, "the MCP filesystem server") + _assert_port_free(8443, "the cMCP Runtime") + token = os.environ.setdefault("CMCP_BEARER_TOKEN", "demo-token") # noqa: F841 log_dir = SCRIPT_DIR @@ -91,6 +129,8 @@ def main() -> None: proc.wait(timeout=5) except subprocess.TimeoutExpired: proc.kill() + _wait_for_port_release(8443) + _wait_for_port_release(9001) server_log.close() cmcp_log.close() diff --git a/demo-01-cmcp-in-action/run.sh b/demo-01-cmcp-in-action/run.sh index 2cf2ed8..21ac7d2 100644 --- a/demo-01-cmcp-in-action/run.sh +++ b/demo-01-cmcp-in-action/run.sh @@ -1,44 +1,14 @@ #!/usr/bin/env bash # Demo 1: cMCP in action # -# Starts the local MCP filesystem server and the cMCP Runtime (CMCP_DEV_MODE=1), -# then calls three tools through the Runtime: -# file.write -> Cedar allows it, real file written to workspace/hello.txt -# file.read -> Cedar allows it, reads file back -# file.list -> Cedar DENIES it (HTTP 403) -# -# On real Intel TDX hardware, the policy bundle hash flows into RTMR[2] -# at startup. Here it appears in trace.policy.bundle_hash in the TRACE claim. +# Thin wrapper around run.py, which is the cross-platform launcher and the one +# CI exercises. This script used to start the servers itself with a fixed +# `sleep 2`, which raced cMCP startup and, worse, carried no check that the +# ports were free. Keeping the logic in one place means the shell path and the +# CI path cannot drift apart again. # # Usage: bash demo-01-cmcp-in-action/run.sh (from repo root) set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" - -CMCP_BEARER_TOKEN="${CMCP_BEARER_TOKEN:-demo-token}" -export CMCP_BEARER_TOKEN - -cleanup() { - kill "${CMCP_PID:-}" "${SERVER_PID:-}" 2>/dev/null || true - wait "${CMCP_PID:-}" "${SERVER_PID:-}" 2>/dev/null || true -} -trap cleanup EXIT - -echo "" -echo "=== Demo 1: cMCP in action ===" -echo "" - -echo "-- Starting MCP filesystem server on :9001 --" -python "$REPO_ROOT/server/server.py" & -SERVER_PID=$! -sleep 1 - -echo "-- Starting cMCP Runtime (CMCP_DEV_MODE=1) on :8443 --" -cd "$SCRIPT_DIR" -CMCP_DEV_MODE=1 cmcp start --config cmcp-config.yaml & -CMCP_PID=$! -sleep 2 - -echo "" -python "$SCRIPT_DIR/call.py" +exec python "$SCRIPT_DIR/run.py" "$@" diff --git a/demo-02-policy-swap/run.py b/demo-02-policy-swap/run.py index 9bc0993..ddeeca2 100644 --- a/demo-02-policy-swap/run.py +++ b/demo-02-policy-swap/run.py @@ -74,7 +74,45 @@ def _post(url: str, payload: dict, token: str) -> dict: return json.loads(exc.read()) +def _assert_port_free(port: int, what: str) -> None: + """Refuse to start if something already owns the port. + + _wait_for_port() returns as soon as *anything* answers, so a gateway left + running by an earlier demo satisfies it instantly. Every call then goes to + that gateway and is decided by its policy bundle, not this demo's. The + verdicts still look plausible, which is what makes it dangerous: the demo + prints allow/deny lines that are simply wrong, with no error anywhere. + """ + try: + with socket.create_connection(("127.0.0.1", port), timeout=1): + pass + except OSError: + return + sys.exit( + f"Port {port} is already in use, so {what} cannot start and this demo " + f"would be scored against whatever is already listening. Stop it first " + f"(a cMCP gateway left over from another demo is the usual cause), then " + f"re-run." + ) + + +def _wait_for_port_release(port: int, timeout: float = 10.0) -> None: + """Block until the port is actually free, so the next demo starts clean.""" + deadline = time.monotonic() + timeout + while time.monotonic() < deadline: + try: + with socket.create_connection(("127.0.0.1", port), timeout=1): + time.sleep(0.25) + except OSError: + return + print(f"warning: port {port} still held after teardown", file=sys.stderr) + + def main() -> None: + # Before anything starts: both ports must be ours. + _assert_port_free(9001, "the MCP filesystem server") + _assert_port_free(8443, "the cMCP Runtime") + if not CLAIM_PATH.exists(): sys.exit("Run demo-01 first to produce a TRACE claim:\n python demo-01-cmcp-in-action/run.py") @@ -182,6 +220,8 @@ def main() -> None: proc.wait(timeout=5) except subprocess.TimeoutExpired: proc.kill() + _wait_for_port_release(8443) + _wait_for_port_release(9001) server_log.close() cmcp_log.close() diff --git a/demo-02-policy-swap/run.sh b/demo-02-policy-swap/run.sh index 4750dd7..e0b25e1 100644 --- a/demo-02-policy-swap/run.sh +++ b/demo-02-policy-swap/run.sh @@ -1,169 +1,14 @@ #!/usr/bin/env bash -# Demo 2: Policy swap = attestation failure +# Demo 2: policy swap # -# Shows that swapping the Cedar policy bundle changes policy.bundle_hash -# in the TRACE claim. A verifier that pinned the v1 hash detects POLICY_HASH_MISMATCH. -# -# On real Intel TDX hardware, the policy hash flows into RTMR[2] at startup. -# Swapping the bundle changes the TEE measurement itself, not just the claim field. -# -# Requires demo-01 to have run first (needs workspace/trace-claim.json). +# Thin wrapper around run.py, which is the cross-platform launcher and the one +# CI exercises. This script used to start the servers itself with a fixed +# `sleep 2`, which raced cMCP startup and, worse, carried no check that the +# ports were free. Keeping the logic in one place means the shell path and the +# CI path cannot drift apart again. # # Usage: bash demo-02-policy-swap/run.sh (from repo root) set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" - -CMCP_BEARER_TOKEN="${CMCP_BEARER_TOKEN:-demo-token}" -export CMCP_BEARER_TOKEN - -cleanup() { - kill "${CMCP_PID:-}" "${SERVER_PID:-}" 2>/dev/null || true - wait "${CMCP_PID:-}" "${SERVER_PID:-}" 2>/dev/null || true -} -trap cleanup EXIT - -# Bash can handle POSIX paths for file existence; Python needs Windows paths -CLAIM_PATH="$REPO_ROOT/workspace/trace-claim.json" -V2_CLAIM_PATH="$REPO_ROOT/workspace/trace-claim-v2.json" -# Convert to Windows paths for Python pathlib (cygpath available in Git Bash) -CLAIM_PATH_W=$(cygpath -w "$CLAIM_PATH") -V2_CLAIM_PATH_W=$(cygpath -w "$V2_CLAIM_PATH") - -if [[ ! -f "$CLAIM_PATH" ]]; then - echo "Run demo-01 first to produce a TRACE claim:" - echo " bash demo-01-cmcp-in-action/run.sh" - exit 1 -fi - -echo "" -echo "=== Demo 2: Policy swap = attestation failure ===" -echo "" - -# Step 0: Show that v1 and v2 hashes differ -echo "-- Step 0: Policy bundle hashes --" -python "$SCRIPT_DIR/check_hash.py" - -# Step 1: Show the v1 claim's policy hash -echo "" -echo "-- Step 1: TRACE claim from demo-01 --" -python3 -c " -import json, pathlib -claim = json.loads(pathlib.Path(r'$CLAIM_PATH_W').read_text()) -policy_hash = claim['trace']['policy']['bundle_hash'] -catalog_hash = claim['gateway']['catalog']['hash'] -print(f' v1 policy.bundle_hash: {policy_hash}') -print(f' catalog.hash: {catalog_hash}') -print() -print(' A verifier who approved v1 pins the policy hash above.') -" - -# Step 2: Start v2 Runtime (Cedar policy now forbids write_file) -echo "" -echo "-- Step 2: Start cMCP with v2 policy (write_file DENIED) --" -python "$REPO_ROOT/server/server.py" & -SERVER_PID=$! -sleep 1 - -cd "$SCRIPT_DIR" -CMCP_DEV_MODE=1 cmcp start --config cmcp-config-v2.yaml & -CMCP_PID=$! -sleep 2 - -# Step 3: Show write_file is denied under v2 -echo "" -echo "-- Step 3: write_file -> DENIED by v2 Cedar policy --" -python3 -c " -import json, os, urllib.request, urllib.error - -TOKEN = os.environ.get('CMCP_BEARER_TOKEN', 'demo-token') -payload = json.dumps({ - 'jsonrpc': '2.0', 'id': 1, 'method': 'tools/call', - 'params': { - 'name': 'write_file', - 'arguments': {'path': 'post-swap.txt', 'content': 'written after policy swap'}, - '_cmcp': {'workflow_id': 'demo-02'}, - }, -}).encode() -req = urllib.request.Request( - 'http://localhost:8443/mcp', - data=payload, - headers={'Content-Type': 'application/json', 'Authorization': f'Bearer {TOKEN}'}, - method='POST', -) -try: - with urllib.request.urlopen(req, timeout=10) as resp: - body = json.loads(resp.read()) -except urllib.error.HTTPError as exc: - body = json.loads(exc.read()) - -error = body.get('error', {}) -code = error.get('data', {}).get('error_code', '?') -print(f' HTTP 403 -- {error.get(\"message\", \"?\")} [{code}]') -print(' Cedar forbid rule matched: Action::\"WriteFile\" denied by v2 policy') -" - -# Step 4: Get v2 TRACE claim (via allowed read_file call) -echo "" -echo "-- Step 4: Get v2 TRACE claim --" -python3 -c " -import json, os, pathlib, urllib.request - -TOKEN = os.environ.get('CMCP_BEARER_TOKEN', 'demo-token') -headers = {'Content-Type': 'application/json', 'Authorization': f'Bearer {TOKEN}'} - -payload = json.dumps({ - 'jsonrpc': '2.0', 'id': 1, 'method': 'tools/call', - 'params': {'name': 'read_file', 'arguments': {'path': 'hello.txt'}, '_cmcp': {'workflow_id': 'demo-02'}}, -}).encode() -req = urllib.request.Request('http://localhost:8443/mcp', data=payload, headers=headers, method='POST') -with urllib.request.urlopen(req, timeout=10) as resp: - r = json.loads(resp.read()) -session_id = r['result']['_cmcp']['session_id'] - -req2 = urllib.request.Request( - f'http://localhost:8443/sessions/{session_id}/close', - data=b'{}', headers=headers, method='POST', -) -with urllib.request.urlopen(req2, timeout=10) as resp2: - v2_claim = json.loads(resp2.read()) - -v1_claim = json.loads(pathlib.Path(r'$CLAIM_PATH_W').read_text()) -v1_hash = v1_claim['trace']['policy']['bundle_hash'] -v2_hash = v2_claim['trace']['policy']['bundle_hash'] -print(f' v1 policy.bundle_hash: {v1_hash}') -print(f' v2 policy.bundle_hash: {v2_hash}') -print() -if v1_hash != v2_hash: - print(' Hashes differ. A verifier with the v1 approved hash will reject v2 claims.') -else: - print(' ERROR: hashes should differ but are identical.') - -pathlib.Path(r'$V2_CLAIM_PATH_W').write_text(json.dumps(v2_claim, indent=2)) -print(f' v2 claim saved.') -" 2>&1 - -# Steps 5-6: Verify the v2 claim against pinned hashes -V1_HASH=$(python3 "$SCRIPT_DIR/check_hash.py" v1 | awk '{print $2}') -V2_HASH=$(python3 "$SCRIPT_DIR/check_hash.py" v2 | awk '{print $2}') -CATALOG_HASH=$(python3 -c " -import json, pathlib -c = json.loads(pathlib.Path(r'$V2_CLAIM_PATH_W').read_text()) -print(c['gateway']['catalog']['hash']) -") - -echo "" -echo "-- Step 5: Verify v2 claim with v1 (pinned) hash -> POLICY_HASH_MISMATCH --" -echo " A verifier that approved v1 now rejects any claim from the v2 Runtime." -cmcp verify "$V2_CLAIM_PATH_W" --policy-hash "$V1_HASH" --catalog-hash "$CATALOG_HASH" || true - -echo "" -echo "-- Step 6: Verify v2 claim with v2 hash -> passes --" -echo " (Confirms the v2 claim itself is well-formed; only the pinned hash differs.)" -cmcp verify "$V2_CLAIM_PATH_W" --policy-hash "$V2_HASH" --catalog-hash "$CATALOG_HASH" || true - -echo "" -echo " On real TDX hardware: RTMR[2] changes on policy swap." -echo " No claim from the v2 Runtime can pass v1 verification." -echo "" +exec python "$SCRIPT_DIR/run.py" "$@" diff --git a/demo-04-context-enforcement/run.py b/demo-04-context-enforcement/run.py index c19db3e..60aadd3 100644 --- a/demo-04-context-enforcement/run.py +++ b/demo-04-context-enforcement/run.py @@ -54,7 +54,45 @@ def _wait_for_port(port: int, what: str, timeout: float = 60.0) -> None: "See the *.log files in this demo folder.") +def _assert_port_free(port: int, what: str) -> None: + """Refuse to start if something already owns the port. + + _wait_for_port() returns as soon as *anything* answers, so a gateway left + running by an earlier demo satisfies it instantly. Every call then goes to + that gateway and is decided by its policy bundle, not this demo's. The + verdicts still look plausible, which is what makes it dangerous: the demo + prints allow/deny lines that are simply wrong, with no error anywhere. + """ + try: + with socket.create_connection(("127.0.0.1", port), timeout=1): + pass + except OSError: + return + sys.exit( + f"Port {port} is already in use, so {what} cannot start and this demo " + f"would be scored against whatever is already listening. Stop it first " + f"(a cMCP gateway left over from another demo is the usual cause), then " + f"re-run." + ) + + +def _wait_for_port_release(port: int, timeout: float = 10.0) -> None: + """Block until the port is actually free, so the next demo starts clean.""" + deadline = time.monotonic() + timeout + while time.monotonic() < deadline: + try: + with socket.create_connection(("127.0.0.1", port), timeout=1): + time.sleep(0.25) + except OSError: + return + print(f"warning: port {port} still held after teardown", file=sys.stderr) + + def main() -> None: + # Before anything starts: both ports must be ours. + _assert_port_free(9001, "the MCP filesystem server") + _assert_port_free(8443, "the cMCP Runtime") + os.environ.setdefault("CMCP_BEARER_TOKEN", "demo-token") log_dir = SCRIPT_DIR @@ -91,6 +129,8 @@ def main() -> None: proc.wait(timeout=5) except subprocess.TimeoutExpired: proc.kill() + _wait_for_port_release(8443) + _wait_for_port_release(9001) server_log.close() cmcp_log.close() diff --git a/demo-04-context-enforcement/run.sh b/demo-04-context-enforcement/run.sh index 9472713..fac9a2a 100644 --- a/demo-04-context-enforcement/run.sh +++ b/demo-04-context-enforcement/run.sh @@ -1,44 +1,14 @@ #!/usr/bin/env bash # Demo 4: context-aware enforcement # -# Starts the local MCP filesystem server and the cMCP Runtime (CMCP_DEV_MODE=1), -# then makes three calls through the Runtime: -# write_file workflow=invoice-run -> Cedar permits (approved workflow) -# write_file workflow=chat-freeform -> Cedar DENIES (same tool + args, HTTP 403) -# read_file workflow=chat-freeform -> Cedar permits (reads allowed anywhere) -# -# The point: the same capability is scoped by the call's declared workflow, not -# by the tool's identity. The agent cannot widen its authority by restating intent. +# Thin wrapper around run.py, which is the cross-platform launcher and the one +# CI exercises. This script used to start the servers itself with a fixed +# `sleep 2`, which raced cMCP startup and, worse, carried no check that the +# ports were free. Keeping the logic in one place means the shell path and the +# CI path cannot drift apart again. # # Usage: bash demo-04-context-enforcement/run.sh (from repo root) set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" - -CMCP_BEARER_TOKEN="${CMCP_BEARER_TOKEN:-demo-token}" -export CMCP_BEARER_TOKEN - -cleanup() { - kill "${CMCP_PID:-}" "${SERVER_PID:-}" 2>/dev/null || true - wait "${CMCP_PID:-}" "${SERVER_PID:-}" 2>/dev/null || true -} -trap cleanup EXIT - -echo "" -echo "=== Demo 4: context-aware enforcement ===" -echo "" - -echo "-- Starting MCP filesystem server on :9001 --" -python "$REPO_ROOT/server/server.py" & -SERVER_PID=$! -sleep 1 - -echo "-- Starting cMCP Runtime (CMCP_DEV_MODE=1) on :8443 --" -cd "$SCRIPT_DIR" -CMCP_DEV_MODE=1 cmcp start --config cmcp-config.yaml & -CMCP_PID=$! -sleep 2 - -echo "" -python "$SCRIPT_DIR/call.py" +exec python "$SCRIPT_DIR/run.py" "$@" diff --git a/demo-05-compliance-domain/run.py b/demo-05-compliance-domain/run.py index 7262bee..a9d2988 100644 --- a/demo-05-compliance-domain/run.py +++ b/demo-05-compliance-domain/run.py @@ -54,7 +54,45 @@ def _wait_for_port(port: int, what: str, timeout: float = 60.0) -> None: "See the *.log files in this demo folder.") +def _assert_port_free(port: int, what: str) -> None: + """Refuse to start if something already owns the port. + + _wait_for_port() returns as soon as *anything* answers, so a gateway left + running by an earlier demo satisfies it instantly. Every call then goes to + that gateway and is decided by its policy bundle, not this demo's. The + verdicts still look plausible, which is what makes it dangerous: the demo + prints allow/deny lines that are simply wrong, with no error anywhere. + """ + try: + with socket.create_connection(("127.0.0.1", port), timeout=1): + pass + except OSError: + return + sys.exit( + f"Port {port} is already in use, so {what} cannot start and this demo " + f"would be scored against whatever is already listening. Stop it first " + f"(a cMCP gateway left over from another demo is the usual cause), then " + f"re-run." + ) + + +def _wait_for_port_release(port: int, timeout: float = 10.0) -> None: + """Block until the port is actually free, so the next demo starts clean.""" + deadline = time.monotonic() + timeout + while time.monotonic() < deadline: + try: + with socket.create_connection(("127.0.0.1", port), timeout=1): + time.sleep(0.25) + except OSError: + return + print(f"warning: port {port} still held after teardown", file=sys.stderr) + + def main() -> None: + # Before anything starts: both ports must be ours. + _assert_port_free(9001, "the MCP filesystem server") + _assert_port_free(8443, "the cMCP Runtime") + os.environ.setdefault("CMCP_BEARER_TOKEN", "demo-token") log_dir = SCRIPT_DIR @@ -91,6 +129,8 @@ def main() -> None: proc.wait(timeout=5) except subprocess.TimeoutExpired: proc.kill() + _wait_for_port_release(8443) + _wait_for_port_release(9001) server_log.close() cmcp_log.close() diff --git a/demo-05-compliance-domain/run.sh b/demo-05-compliance-domain/run.sh index 89bdeb4..bb0ae2b 100644 --- a/demo-05-compliance-domain/run.sh +++ b/demo-05-compliance-domain/run.sh @@ -1,44 +1,14 @@ #!/usr/bin/env bash -# Demo 5: attribute-based enforcement (compliance domain / BAA coverage) +# Demo 5: attribute-based enforcement (BAA coverage) # -# Starts the local MCP filesystem server and the cMCP Runtime (CMCP_DEV_MODE=1), -# then makes three calls through the Runtime: -# write_file clinical, baa_covered=true -> Cedar permits -# read_file clinical, baa_covered=true -> Cedar permits -# list_dir external-analytics, baa_covered=false -> Cedar DENIES (HTTP 403) -# -# The deny is decided on the tool's compliance attribute (context.baa_covered), -# not on its name. One guardrail rule covers every non-BAA-covered tool. +# Thin wrapper around run.py, which is the cross-platform launcher and the one +# CI exercises. This script used to start the servers itself with a fixed +# `sleep 2`, which raced cMCP startup and, worse, carried no check that the +# ports were free. Keeping the logic in one place means the shell path and the +# CI path cannot drift apart again. # # Usage: bash demo-05-compliance-domain/run.sh (from repo root) set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" - -CMCP_BEARER_TOKEN="${CMCP_BEARER_TOKEN:-demo-token}" -export CMCP_BEARER_TOKEN - -cleanup() { - kill "${CMCP_PID:-}" "${SERVER_PID:-}" 2>/dev/null || true - wait "${CMCP_PID:-}" "${SERVER_PID:-}" 2>/dev/null || true -} -trap cleanup EXIT - -echo "" -echo "=== Demo 5: attribute-based enforcement (BAA coverage) ===" -echo "" - -echo "-- Starting MCP filesystem server on :9001 --" -python "$REPO_ROOT/server/server.py" & -SERVER_PID=$! -sleep 1 - -echo "-- Starting cMCP Runtime (CMCP_DEV_MODE=1) on :8443 --" -cd "$SCRIPT_DIR" -CMCP_DEV_MODE=1 cmcp start --config cmcp-config.yaml & -CMCP_PID=$! -sleep 2 - -echo "" -python "$SCRIPT_DIR/call.py" +exec python "$SCRIPT_DIR/run.py" "$@"