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
11 changes: 11 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -59,3 +59,14 @@ jobs:
exit 1
fi
echo "breaking drift correctly detected (exit 1)"
- name: covenant check catches behavioral drift (schema identical)
run: |
set +e
COVENANT_BEHAVIOR_DRIFT=1 covenant check
code=$?
set -e
if [ "$code" -ne 1 ]; then
echo "::error::expected exit 1 (behavioral drift), got $code"
exit 1
fi
echo "behavioral drift correctly detected (exit 1)"
3 changes: 3 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ pytest # Postgres tests skip unless COVENANT_TEST_DB is
ruff check . && mypy covenant # both must pass; mypy is strict
covenant check # lint the example server against the committed baseline
COVENANT_DRIFT=1 covenant check # inject a real breaking change; must exit 1
COVENANT_BEHAVIOR_DRIFT=1 covenant check # body-only drift (schema identical); probes must catch it, exit 1
```

- `covenant` spawns the stdio server with `python` from PATH — the venv must be active (or its Scripts dir on PATH) or introspection fails with `ModuleNotFoundError: mcp`.
Expand All @@ -22,6 +23,7 @@ COVENANT_DRIFT=1 covenant check # inject a real breaking change; must exit 1
| 0 contract core | `covenant/*.py` | Depends only on `mcp`, `typer`, `rich`. `diff.py` is pure (no I/O). |
| 1 proxy + quarantine | `covenant/proxy/` | `fastapi`/`httpx`/`uvicorn` are the `[proxy]` extra, lazily imported in `cli.py`. |
| 2 store | `covenant/store/` | `asyncpg` is the `[store]` extra. Store writes are best-effort: log and swallow, never fail the request path. |
| 3 probes + judge | `covenant/fingerprint.py`, `covenant/judge/` | Probes *execute* tools at snapshot/check time (list read-only tools only). `anthropic`/`google-genai` are the `[judge]` extra, imported on use; the model-name prefix picks the provider. |

Design specs (rationale, rule tables, named decisions) live in `docs/superpowers/specs/` — read the relevant spec before changing classifier or proxy behavior.

Expand All @@ -31,5 +33,6 @@ Design specs (rationale, rule tables, named decisions) live in `docs/superpowers
- `covenant.lock.json` is deterministic: sorted keys, no timestamp. Re-snapshotting an unchanged server must be byte-identical.
- `schema_hash` covers schemas only, never `description` (a typo fix must not read as an identity change).
- Drift detection is Covenant-owned (`POST /covenant/refresh` re-lists the upstream itself). Never make enforcement depend on the client's `tools/list` timing — the SDK can list *after* the call it should have protected.
- Judge verdicts are advisory: DEGRADED, never BREAKING — a probabilistic detector must not trigger quarantine (Layer 3 spec).
- CLI errors are typed `CovenantError` → one clean line, exit 2. Never a stack trace, never swallowed.
- Exit codes are load-bearing (CI contract): 0 clean, 1 breaking (or degraded under `--strict`), 2 config/connection error.
66 changes: 54 additions & 12 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ Covenant makes the contract explicit, versioned, and enforced:
- **`covenant snapshot`** — introspect an MCP server (stdio or streamable-HTTP) and commit its tool contracts to a deterministic `covenant.lock.json`
- **`covenant check`** — diff the live server against the baseline, classify every change **BREAKING / DEGRADED / COMPATIBLE**, and exit non-zero in CI when the contract breaks
- **`covenant proxy`** — a transparent reverse-proxy that **quarantines** drifted tools at runtime, so downstream agents get a clean "tool unavailable" instead of silently hallucinating
- **`[[probes]]` + `--judge`** — behavioral fingerprints of what tools **actually return** (works even when a server declares no `outputSchema`), plus an optional LLM judge for semantic drift

## Quickstart

Expand All @@ -26,21 +27,25 @@ Snapshot the bundled example server, then break it for real — `COVENANT_DRIFT=

```bash
$ covenant check
OK no schema drift - contract matches the baseline.
OK no drift - contract matches the baseline.

$ COVENANT_DRIFT=1 covenant check
Covenant - contract drift
+-------------------------------------------------------------------------+
| tier | location | change |
|------------+----------+-------------------------------------------------|
| BREAKING | output | output field 'balance_usd' removed |
| COMPATIBLE | output | output required field 'available_balance' added |
+-------------------------------------------------------------------------+
x 1 breaking change(s) - downstream agents would fail silently.
Covenant - contract drift
+--------------------------------------------------------------------------------------------+
| tier | location | change |
|------------+----------+--------------------------------------------------------------------|
| BREAKING | behavior | probe get_account: output field 'balance_usd' removed |
| BREAKING | output | output field 'balance_usd' removed |
| COMPATIBLE | behavior | probe get_account: output optional field 'available_balance' added |
| COMPATIBLE | output | output required field 'available_balance' added |
+--------------------------------------------------------------------------------------------+
x 2 breaking change(s) - downstream agents would fail silently. Fix or quarantine.
$ echo $?
1
```

The lie is caught twice: in the declared schema (`output` rows) and — because the committed config probes the tool — in the actual response body (`behavior` rows).

Point it at your own server via [covenant.toml](covenant.toml) (a stdio launch command **or** an HTTP URL), or override inline with `--server`:

```bash
Expand Down Expand Up @@ -79,6 +84,43 @@ Commit `covenant.toml` + `covenant.lock.json`, then:

This repo runs exactly that against its own example server on every push — including a job that *injects* the breaking change and asserts Covenant catches it. See [ci.yml](.github/workflows/ci.yml).

## Behavioral drift: probes + judge

A schema check can't see a server that *lies* — schema unchanged, response different. And most real MCP tools declare no `outputSchema` at all, so there is nothing to diff. Probes cover both. Commit safe, **read-only** example calls in `covenant.toml`:

```toml
[[probes]]
tool = "get_transactions"
args = { account_id = "acct-001" }
```

`covenant snapshot` runs each probe and stores its response **fingerprint** — the type shape of what actually came back, never the values, which legitimately change — in the lock. `covenant check` re-runs the probes and classifies shape drift with the same severity model, at location `behavior`:

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

Document that probe snapshots persist a sample response.

Line 97 says the lock stores only the fingerprint and "never the values," but the new snapshot format also persists a sample response. That misstates retention and can mislead users about what lands in covenant.lock.json.

Suggested wording
-`covenant snapshot` runs each probe and stores its response **fingerprint** — the type shape of what actually came back, never the values, which legitimately change — in the lock.
+`covenant snapshot` runs each probe and stores its response **fingerprint** plus a sample response in the lock; use only safe, read-only probes and avoid sensitive outputs.
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
`covenant snapshot` runs each probe and stores its response **fingerprint** — the type shape of what actually came back, never the values, which legitimately change — in the lock. `covenant check` re-runs the probes and classifies shape drift with the same severity model, at location `behavior`:
`covenant snapshot` runs each probe and stores its response **fingerprint** plus a sample response in the lock; use only safe, read-only probes and avoid sensitive outputs. `covenant check` re-runs the probes and classifies shape drift with the same severity model, at location `behavior`:
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@README.md` at line 97, The README wording in the covenant snapshot/check
description is outdated and says the lock stores only a fingerprint and never
values; update that sentence to reflect the new snapshot format that also
persists a sample response in covenant.lock.json. Adjust the text around the
covenant snapshot and covenant check description so it accurately describes what
is retained, while keeping the explanation of fingerprint/shape drift and
behavior severity consistent.


```bash
$ COVENANT_BEHAVIOR_DRIFT=1 covenant check # response body renames a field; schema untouched
Covenant - contract drift
+-------------------------------------------------------------------------------------------------+
| tier | location | change |
|------------+----------+-------------------------------------------------------------------------|
| BREAKING | behavior | probe get_transactions: output field 'transactions[].amount_usd' |
| | | removed |
| COMPATIBLE | behavior | probe get_transactions: output optional field |
| | | 'transactions[].amount_cents' added |
+-------------------------------------------------------------------------------------------------+
x 1 breaking change(s) - downstream agents would fail silently. Fix or quarantine.
```

For drift a fingerprint can't see — same shape, changed *meaning*, like a balance quietly rescaled from dollars to cents — add the LLM judge:

```bash
pip install -e ".[judge]"
covenant check --judge # try it: COVENANT_SEMANTIC_DRIFT=1 covenant check --judge --strict
```

The judge model is set with `[judge] model` in `covenant.toml`; the name picks the provider — `claude-*` models use `ANTHROPIC_API_KEY`, `gemini-*` models use `GOOGLE_API_KEY`.

Judge verdicts are **advisory by design**: they render DEGRADED (fail only under `--strict`), never BREAKING — a probabilistic detector must not trigger quarantine. Full rationale: [Layer 3 design spec](docs/superpowers/specs/2026-07-03-covenant-layer3-behavioral-probes-design.md).

## Runtime guard: the proxy

The linter catches drift at ship time; the proxy contains it at runtime. It forwards every JSON-RPC exchange byte-for-byte (SSE passthrough included) so the client can't tell it's there — but a `tools/call` to a quarantined tool is short-circuited with a clean MCP `isError` result and never forwarded.
Expand Down Expand Up @@ -117,7 +159,7 @@ Covenant is built in dependency-ordered layers; each ships alone and each higher
| 0 | Contract core — introspection, committed baseline, severity classifier, CLI | ✅ shipped |
| 1 | Transparent proxy + quarantine | ✅ shipped |
| 2 | Postgres contract store (call log, drift events, durable quarantine) | ✅ shipped |
| 3 | Probe agent + RAG — behavioral fingerprints, LLM-judge for semantic drift | roadmap |
| 3 | Behavioral probes — response fingerprints + LLM judge for semantic drift | ✅ shipped |
| 4 | Observability — OTel spans, Prometheus, dashboard | roadmap |
| 5 | K8s operator + Helm — `MCPContract` CRD, probes as Jobs | roadmap |

Expand All @@ -127,11 +169,11 @@ Design specs for the shipped layers live in [docs/superpowers/specs](docs/superp

```bash
pip install -e ".[dev]"
pytest # 86 tests; Postgres-backed tests skip without a DB
pytest # 112 tests; Postgres-backed tests skip without a DB
ruff check . && mypy covenant
```

Layer boundaries are enforced by imports: the core (`covenant/*.py`) depends only on `mcp`, `typer`, `rich`; the proxy extras (`fastapi`, `httpx`, `uvicorn`) and store extras (`asyncpg`) are optional and lazily imported.
Layer boundaries are enforced by imports: the core (`covenant/*.py`) depends only on `mcp`, `typer`, `rich`; the proxy extras (`fastapi`, `httpx`, `uvicorn`), store extra (`asyncpg`), and judge extra (`anthropic`) are optional and imported on use.

## License

Expand Down
95 changes: 95 additions & 0 deletions covenant.lock.json
Original file line number Diff line number Diff line change
@@ -1,5 +1,82 @@
{
"covenant_version": "0.1.0",
"probes": [
{
"args": {
"account_id": "acct-001"
},
"fingerprint": {
"properties": {
"account_id": {
"type": "string"
},
"balance_usd": {
"type": "number"
},
"currency": {
"type": "string"
},
"holder": {
"type": "string"
}
},
"type": "object"
},
"sample": {
"account_id": "acct-001",
"balance_usd": 4210.0,
"currency": "USD",
"holder": "Ada Lovelace"
},
"tool": "get_account"
},
{
"args": {
"account_id": "acct-001"
},
"fingerprint": {
"properties": {
"account_id": {
"type": "string"
},
"transactions": {
"items": {
"properties": {
"amount_usd": {
"type": "number"
},
"merchant": {
"type": "string"
},
"txn_id": {
"type": "string"
}
},
"type": "object"
},
"type": "array"
}
},
"type": "object"
},
"sample": {
"account_id": "acct-001",
"transactions": [
{
"amount_usd": -42.5,
"merchant": "Grocer & Co",
"txn_id": "t-1001"
},
{
"amount_usd": 1800.0,
"merchant": "Payroll Inc",
"txn_id": "t-1002"
}
]
},
"tool": "get_transactions"
}
],
"server": "python examples/mcp_server.py",
"tools": {
"convert_currency": {
Expand Down Expand Up @@ -92,6 +169,24 @@
},
"schema_hash": "sha256:43abb25bb6afff16632fdcf65f2e92ecf5f64ab3e1ccf367b6c5b3d25b3288df"
},
"get_transactions": {
"description": "List recent transactions for an account.",
"inputSchema": {
"properties": {
"account_id": {
"title": "Account Id",
"type": "string"
}
},
"required": [
"account_id"
],
"title": "get_transactionsArguments",
"type": "object"
},
"outputSchema": null,
"schema_hash": "sha256:554a5ad1c8a17f1bcfd7eb11c48a7080d6b5bea1dfff99688b887268305e310b"
},
"get_weather": {
"description": "Return current weather for a city.",
"inputSchema": {
Expand Down
15 changes: 15 additions & 0 deletions covenant.toml
Original file line number Diff line number Diff line change
Expand Up @@ -8,3 +8,18 @@ command = "python examples/mcp_server.py"

[baseline]
path = "covenant.lock.json"

[judge]
# The model name picks the provider: claude-* -> ANTHROPIC_API_KEY, gemini-* -> GOOGLE_API_KEY.
model = "gemini-2.5-flash"

# Layer 3: behavioral probes — example calls executed at snapshot/check time to
# fingerprint what tools *actually return*. Only list read-only tools.

[[probes]]
tool = "get_account"
args = { account_id = "acct-001" }

[[probes]]
tool = "get_transactions"
args = { account_id = "acct-001" }
Loading
Loading