Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 19 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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
40 changes: 40 additions & 0 deletions demo-01-cmcp-in-action/run.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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()

Expand Down
42 changes: 6 additions & 36 deletions demo-01-cmcp-in-action/run.sh
Original file line number Diff line number Diff line change
@@ -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" "$@"
40 changes: 40 additions & 0 deletions demo-02-policy-swap/run.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")

Expand Down Expand Up @@ -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()

Expand Down
169 changes: 7 additions & 162 deletions demo-02-policy-swap/run.sh
Original file line number Diff line number Diff line change
@@ -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" "$@"
Loading