Skip to content

feat(layer-3): behavioral probes + LLM semantic judge - #2

Merged
Mhemd139 merged 2 commits into
mainfrom
feat/layer3-behavioral-probes
Jul 2, 2026
Merged

feat(layer-3): behavioral probes + LLM semantic judge#2
Mhemd139 merged 2 commits into
mainfrom
feat/layer3-behavioral-probes

Conversation

@Mhemd139

@Mhemd139 Mhemd139 commented Jul 2, 2026

Copy link
Copy Markdown
Owner

What

Layer 3 of the roadmap, end-to-end. Two detectors for drift that schema diffing cannot see:

  1. Behavioral probes[[probes]] in covenant.toml are safe, read-only example calls. covenant snapshot runs them and stores each response's fingerprint (type shape, never values) plus a sample in the lock. covenant check re-runs them and classifies shape drift with the unchanged Layer 0 classifier — responses are output-side by definition, so the direction principle applies verbatim (lost field = BREAKING, scalar retype = DEGRADED). Rendered at location behavior.
  2. Semantic judgecovenant check --judge (optional [judge] extra) sends baseline sample + live response to an LLM for meaning-level drift (a balance quietly rescaled to cents). Verdicts are advisory DEGRADED, never BREAKING: a probabilistic detector must not trigger quarantine.

Why it matters

Most real MCP tools declare no outputSchema at all — Layer 0 has nothing to diff there. Probes fingerprint what tools actually return, so a server whose schema says one thing and whose body does another is caught. This closes the caveat the clean-check note has carried since Layer 0.

Demo levers (examples/mcp_server.py)

  • COVENANT_BEHAVIOR_DRIFT=1 — body-only field rename, schema identical → probe catches BREAKING, exit 1. CI dogfoods this on every push.
  • COVENANT_SEMANTIC_DRIFT=1 — balance ×100, schema and shape identical → only --judge sees it.

Verified

  • 112 tests (22 new: fingerprint rules, probe diffs, config parsing, lock round-trip, judge parsing, CLI e2e against the real stdio server), ruff + strict mypy clean
  • Lock re-snapshot remains byte-identical (determinism invariant holds with probes)
  • covenant check 0 · COVENANT_DRIFT=1 1 (now caught in schema and body) · COVENANT_BEHAVIOR_DRIFT=1 1 (schema check alone would pass)
  • Design spec: docs/superpowers/specs/2026-07-03-covenant-layer3-behavioral-probes-design.md

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features

    • Added support for behavioral probes that re-check live tool responses and detect shape drift.
    • Introduced an optional semantic judge mode for deeper drift detection.
    • Expanded the example server and configuration to support additional probe-driven checks.
  • Bug Fixes

    • Improved drift reporting so probe failures and response changes are surfaced more clearly.
    • Updated CI to verify behavioral drift detection works as expected.
  • Documentation

    • Updated the README and related docs with new probe and judge workflows.

Schema checks can't see a lying server, and most real MCP tools declare no
outputSchema at all. Layer 3 closes both gaps:

- [[probes]] in covenant.toml: safe read-only example calls. snapshot
  fingerprints each response's type shape (never values) into the lock;
  check re-runs them and classifies drift with the unchanged Layer 0
  classifier (responses are output-side by definition), location "behavior".
- covenant check --judge ([judge] extra, anthropic): compares baseline
  sample vs live response for semantic drift a shape can't see
  (dollars->cents). Verdicts are advisory DEGRADED, never BREAKING -
  a probabilistic detector must not trigger quarantine.
- Example server levers: COVENANT_BEHAVIOR_DRIFT=1 (body-only rename,
  schema identical - probes catch BREAKING, CI dogfoods it) and
  COVENANT_SEMANTIC_DRIFT=1 (balance x100 - judge territory).

112 tests (22 new), ruff + strict mypy clean, lock stays byte-deterministic.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Jul 2, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

@Mhemd139, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 39 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 6e5cdf95-fb9a-4b93-ada0-03213bcc4ea3

📥 Commits

Reviewing files that changed from the base of the PR and between 92c2c02 and 08f2646.

📒 Files selected for processing (6)
  • CLAUDE.md
  • README.md
  • covenant.toml
  • covenant/judge/__init__.py
  • pyproject.toml
  • tests/test_judge.py
📝 Walkthrough

Walkthrough

This PR adds a "Layer 3" behavioral probe system to Covenant: probes fingerprint live tool responses at snapshot/check time, an optional --judge LLM mode detects semantic drift, and CLI, config, baseline persistence, diffing, an example server, CI, docs, and tests are updated accordingly.

Changes

Behavioral probes and judge feature

Layer / File(s) Summary
Fingerprint and probe key primitives
covenant/fingerprint.py, tests/test_fingerprint.py
Adds fingerprint() to infer JSON-Schema-like shapes and probe_key() for canonical probe identity, with unit tests.
Probe and judge configuration model
covenant/config.py, tests/test_probes.py
Adds Probe dataclass, _parse_probes(), judge_model, and DEFAULT_BASELINE, wired into load_config().
Probe execution via MCP session
covenant/introspect.py, tests/test_introspect.py
Unifies session setup and adds run_probes/_run_probes to call tools and normalize responses.
Baseline persistence for probes
covenant/contract.py, tests/test_contract.py, tests/test_probes.py
to_baseline/write_baseline/read_baseline extended to serialize and load sorted probe records.
Probe diffing logic
covenant/diff.py, tests/test_probes.py
Adds diff_probes() to classify shape drift and errors, relabeling changes to the behavior location.
LLM semantic judge
covenant/judge/__init__.py, tests/test_judge.py, pyproject.toml
New judge_probe() calls Anthropic to produce advisory Verdict objects; adds judge extra dependency.
CLI snapshot/check integration
covenant/cli.py, covenant/report.py, tests/test_cli.py
snapshot/check handle probe baselines, diffing, and --judge; proxy and report messaging updated.
Example server levers, config fixtures, and CI/docs
examples/mcp_server.py, covenant.toml, covenant.lock.json, .github/workflows/ci.yml, README.md, CLAUDE.md, docs/BUILD_LOG.md, docs/superpowers/specs/*
Adds drift levers and get_transactions tool, probe fixtures, a CI drift-check step, and documentation.

Estimated code review effort: 4 (Complex) | ~60 minutes

Sequence Diagram(s)

sequenceDiagram
  participant User
  participant CLI
  participant Probes
  participant Judge
  participant Report
  User->>CLI: covenant check --judge
  CLI->>Probes: run_probes + diff_probes
  Probes-->>CLI: probe changes (behavior)
  CLI->>Judge: judge_probe(baseline, live)
  Judge-->>CLI: Verdict(drift, reason)
  CLI->>Report: render(changes)
  Report-->>User: OK / BREAKING / DEGRADED
Loading

Related Issues: None referenced in the diff.

Related PRs: None referenced in the diff.

Suggested labels: feature, cli, tests, documentation

Suggested reviewers: Mhemd139

Poem:

A probe hops in to check the shape,
Of balances and accounts agape,
A judge weighs in with careful eye,
"Advisory only," it won't lie.
Snapshot, check, and drift now found—
Layer 3 rabbits thump the ground. 🐇

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes the main change: Layer 3 behavioral probes plus an LLM semantic judge.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/layer3-behavioral-probes

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 12

🧹 Nitpick comments (4)
covenant/cli.py (1)

51-57: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Missing-probe error message can't disambiguate probes sharing a tool name.

missing is built from p.tool alone; two probes on the same tool with different args that are both missing render as a duplicated, indistinguishable tool name in the error text.

♻️ Include args for disambiguation
-    missing = [p.tool for p in cfg.probes if probe_key(p.tool, p.args) not in base_by]
+    missing = [f"{p.tool} {p.args}" for p in cfg.probes if probe_key(p.tool, p.args) not in base_by]
🤖 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 `@covenant/cli.py` around lines 51 - 57, The missing-probe error in the
baseline check is ambiguous because `missing` in `covenant.cli` only collects
`p.tool`, so probes using the same tool with different args collapse to the same
name in the message. Update the probe-identification logic around `probe_key`,
`base_by`, and the `CovenantError` construction so the error text includes
enough probe detail to distinguish entries by both tool and args, and make sure
the missing list is built from that disambiguated representation.
examples/mcp_server.py (1)

106-111: 🎯 Functional Correctness | 🔵 Trivial | 💤 Low value

Prefer round() over int() for cents conversion.

int(t["amount_usd"] * 100) truncates toward zero; floating-point multiplication can yield values like 420999.9999999999 for otherwise-exact cent amounts, silently losing a cent. Current hardcoded samples happen to avoid this, but the pattern is fragile.

♻️ Proposed fix
-            {"txn_id": t["txn_id"], "amount_cents": int(t["amount_usd"] * 100),
+            {"txn_id": t["txn_id"], "amount_cents": round(t["amount_usd"] * 100),
              "merchant": t["merchant"]}
🤖 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 `@examples/mcp_server.py` around lines 106 - 111, The cents conversion in the
BEHAVIOR_DRIFT normalization block is using truncation, which can silently drop
a cent for floating-point values. Update the txns transformation in
examples/mcp_server.py to use rounding when computing amount_cents from
amount_usd, and keep the change local to the existing behavior-drift handling so
the txn_id, amount_cents, and merchant mapping in that comprehension remains
intact.
tests/test_cli.py (1)

84-92: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick win

Unchecked snapshot invocations weaken the failure-path assertions.

In both test_probe_missing_from_baseline_errors (Line 87) and test_judge_without_probes_errors (Line 96), the snapshot invoke's result is discarded. If snapshot itself failed (e.g. bad server command), check could still return exit code 2 for an unrelated reason, letting these tests pass without exercising the intended "probe not in baseline" / "--judge needs probes" error paths.

✅ Proposed fix
 def test_probe_missing_from_baseline_errors(tmp_path, monkeypatch):
     monkeypatch.chdir(tmp_path)
     _write_toml(tmp_path)
-    runner.invoke(app, ["snapshot"])
+    r = runner.invoke(app, ["snapshot"])
+    assert r.exit_code == 0, r.output
     _write_toml(tmp_path, '\n[[probes]]\ntool = "get_weather"\nargs = { city = "Haifa" }\n')
     r = runner.invoke(app, ["check"])
     assert r.exit_code == 2
     assert "snapshot" in r.output


 def test_judge_without_probes_errors(tmp_path, monkeypatch):
     monkeypatch.chdir(tmp_path)
-    runner.invoke(app, ["snapshot", "--server", SERVER])
+    r = runner.invoke(app, ["snapshot", "--server", SERVER])
+    assert r.exit_code == 0, r.output
     r = runner.invoke(app, ["check", "--server", SERVER, "--judge"])
     assert r.exit_code == 2

Also applies to: 94-98

🤖 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 `@tests/test_cli.py` around lines 84 - 92, The tests are discarding the result
of the initial app.invoke("snapshot") call, so they may pass even if snapshot
failed for an unrelated reason. Update test_probe_missing_from_baseline_errors
and test_judge_without_probes_errors to assert the snapshot invocation succeeded
before running check, using the existing runner.invoke(app, ["snapshot"]) call
site in these test functions. This keeps the failure-path assertions focused on
the intended probe/baseline and --judge validation behavior.
pyproject.toml (1)

60-60: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Keep anthropic>=0.40 in one place. dev still duplicates the same dependency already declared in judge; make dev depend on covenant-mcp[judge] instead of pinning it again.

🤖 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 `@pyproject.toml` at line 60, The `dev` extra is duplicating the
`anthropic>=0.40` dependency that is already declared under `judge`; update the
dependency definition so `dev` depends on `covenant-mcp[judge]` instead of
listing `anthropic` again, and keep the single source of truth in the `judge`
extra.
🤖 Prompt for all review comments with 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.

Inline comments:
In `@covenant/cli.py`:
- Around line 32-44: The snapshot path in _snapshot_probes() can let a
ValueError from fingerprint() escape, which bypasses the CLI’s CovenantError
handling. Wrap the fingerprint(r["response"]) call (or the whole record-building
block in snapshot()/ _snapshot_probes()) and convert any fingerprint-related
ValueError into a CovenantError with a clean message, so snapshot() still exits
via the existing one-line error path and code 2. Use _snapshot_probes,
fingerprint, and snapshot as the key places to update.
- Around line 111-130: The probe diff path can let raw fingerprint parsing
failures escape `check()`, so update `_check_probes()`/`diff_probes()` to catch
`ValueError` from `fingerprint()` and re-raise it as `CovenantError` like
`judge_probe()` does. Keep the handling close to the `diff_probes()` call site
so `check()`’s existing `CovenantError` exit path is used for non-JSON probe
results instead of a stack trace.
- Around line 47-81: The probe-judging logic in _check_probes is collapsing
drift tracking to tool name instead of probe identity, which can cause unrelated
probes for the same tool to be skipped; change shape_drifted to track full probe
identity via probe_key for each Change and only skip the matching probe in the
live loop. Also replace the direct base_by[probe_key(...)] lookup with a safe
.get() check and raise a CovenantError when the probe key is missing, so
mismatched echoed args from run_probes do not crash _check_probes unexpectedly.

In `@covenant/config.py`:
- Around line 80-82: The judge config parsing in load_config is unsafe because
`data.get("judge")` may be a non-dict and the chained `.get("model")` can raise
a raw AttributeError instead of ConfigError. Add the same shape guard used in
`_parse_probes` by checking that the `[judge]` value is a dict before reading
`model`, and raise a ConfigError with a clear message when it is not. Keep the
existing `judge_model` validation logic, but make sure malformed `judge` entries
are converted into clean config errors rather than uncaught exceptions.

In `@covenant/diff.py`:
- Around line 245-267: Handle malformed baseline probe records in the probe
comparison logic so a missing fingerprint does not raise a raw KeyError. Update
the baseline lookup and fingerprint access in the change-building loop in
covenant/diff.py to validate bp before using bp["fingerprint"], and route the
failure through the existing CovenantError path used by covenant check. Keep the
fix localized around the probe comparison code that uses base_by, probe_key, and
fingerprint.

In `@covenant/introspect.py`:
- Around line 91-104: The per-probe failure handling in _run_probes is too
generic because exceptions from session.call_tool are later surfaced as a
ConnectionError, hiding which probe/tool failed and making config mistakes look
like connectivity issues. Update the probe execution path in _run_probes and the
caller in run_probes to catch per-tool exceptions, preserve the failing probe’s
tool/args context, and propagate or record that specific error so snapshot/check
can report a probe/config problem instead of a generic server connection
failure.
- Around line 91-104: The probe execution path in _run_probes (and the shared
run_probes flow) calls session.call_tool without any timeout, so a stalled
server can block snapshot/check indefinitely. Update the call to enforce a
timeout by passing a default/configurable read_timeout_seconds or by wrapping
the tool invocation in asyncio.wait_for, and make sure the timeout is applied
consistently wherever probes are executed.
- Around line 91-104: The _run_probes flow currently executes every Probe.tool
without verifying it is read-only, so a misconfigured snapshot/check probe can
mutate state. Add a runtime guard in _run_probes (or earlier in the
Probe/_tool_to_dict pipeline) to reject any probe whose tool is not marked
read-only, using the existing Probe and _resolve_result/_session flow to keep
the check centralized.

In `@covenant/judge/__init__.py`:
- Around line 76-80: The verdict parsing in the judge module is coercing
`data["drift"]` with `bool(...)`, which can turn string values like "false" into
a true drift result. Update the `Verdict` construction in
`covenant/judge/__init__.py` to require `drift` to already be a real boolean and
reject any non-boolean value by raising `TypeError` or otherwise letting the
existing exception handling convert it to `CovenantError`. Keep the
`json.loads`/`Verdict(...)` flow in the same parsing block so invalid model
output fails loudly rather than being coerced.

In
`@docs/superpowers/specs/2026-07-03-covenant-layer3-behavioral-probes-design.md`:
- Around line 34-36: The array fingerprinting behavior in the spec leaves a
blind spot in diff_probes() for collection-returning probes, since a uniform
array that later becomes heterogeneous is still treated as a bare array and the
drift is missed. Update the documentation around the array-shape rules to
explicitly mark this as a non-goal, or add a regression test covering the array
fingerprinting behavior so the limitation is captured; use the existing
array/list shape language in the spec to locate the affected section.

In `@examples/mcp_server.py`:
- Around line 103-112: get_transactions currently returns an unparameterized
dict, which violates the strict mypy settings; update the return type on
get_transactions to a concrete typed mapping or a TypedDict that matches the
response shape. Define and use a named response type for the
account_id/transactions payload, and make sure the txns transformation in
get_transactions still fits that declared shape.

In `@README.md`:
- 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.

---

Nitpick comments:
In `@covenant/cli.py`:
- Around line 51-57: The missing-probe error in the baseline check is ambiguous
because `missing` in `covenant.cli` only collects `p.tool`, so probes using the
same tool with different args collapse to the same name in the message. Update
the probe-identification logic around `probe_key`, `base_by`, and the
`CovenantError` construction so the error text includes enough probe detail to
distinguish entries by both tool and args, and make sure the missing list is
built from that disambiguated representation.

In `@examples/mcp_server.py`:
- Around line 106-111: The cents conversion in the BEHAVIOR_DRIFT normalization
block is using truncation, which can silently drop a cent for floating-point
values. Update the txns transformation in examples/mcp_server.py to use rounding
when computing amount_cents from amount_usd, and keep the change local to the
existing behavior-drift handling so the txn_id, amount_cents, and merchant
mapping in that comprehension remains intact.

In `@pyproject.toml`:
- Line 60: The `dev` extra is duplicating the `anthropic>=0.40` dependency that
is already declared under `judge`; update the dependency definition so `dev`
depends on `covenant-mcp[judge]` instead of listing `anthropic` again, and keep
the single source of truth in the `judge` extra.

In `@tests/test_cli.py`:
- Around line 84-92: The tests are discarding the result of the initial
app.invoke("snapshot") call, so they may pass even if snapshot failed for an
unrelated reason. Update test_probe_missing_from_baseline_errors and
test_judge_without_probes_errors to assert the snapshot invocation succeeded
before running check, using the existing runner.invoke(app, ["snapshot"]) call
site in these test functions. This keeps the failure-path assertions focused on
the intended probe/baseline and --judge validation behavior.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: caef019f-6868-4aa5-9b8c-c0cedd747cb8

📥 Commits

Reviewing files that changed from the base of the PR and between 258d3ea and 92c2c02.

📒 Files selected for processing (23)
  • .github/workflows/ci.yml
  • CLAUDE.md
  • README.md
  • covenant.lock.json
  • covenant.toml
  • covenant/cli.py
  • covenant/config.py
  • covenant/contract.py
  • covenant/diff.py
  • covenant/fingerprint.py
  • covenant/introspect.py
  • covenant/judge/__init__.py
  • covenant/report.py
  • docs/BUILD_LOG.md
  • docs/superpowers/specs/2026-07-03-covenant-layer3-behavioral-probes-design.md
  • examples/mcp_server.py
  • pyproject.toml
  • tests/test_cli.py
  • tests/test_contract.py
  • tests/test_fingerprint.py
  • tests/test_introspect.py
  • tests/test_judge.py
  • tests/test_probes.py

Comment thread covenant/cli.py
Comment on lines +32 to +44
def _snapshot_probes(cfg: Config) -> list[JsonDict]:
"""Run the configured probes and build their baseline records."""
records: list[JsonDict] = []
for r in run_probes(cfg, cfg.probes):
if r["is_error"]:
raise CovenantError(f"probe {r['tool']} failed at snapshot: {r['error']}")
records.append({
"tool": r["tool"],
"args": r["args"],
"fingerprint": fingerprint(r["response"]),
"sample": r["response"],
})
return records

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Uncaught ValueError from fingerprint() leaks a stack trace.

fingerprint() raises ValueError for non-JSON-serializable values. That call happens inside snapshot()'s try block, but snapshot() only catches CovenantError. If a probed tool ever returns a value fingerprint() can't classify (e.g. bytes, a tuple), this propagates as an unhandled exception with a full stack trace instead of the required clean one-line error + exit code 2.

As per coding guidelines, "CLI errors must be converted from CovenantError into one clean line with exit code 2; never emit a stack trace or swallow the error."

🛡️ Proposed fix
 def _snapshot_probes(cfg: Config) -> list[JsonDict]:
     """Run the configured probes and build their baseline records."""
     records: list[JsonDict] = []
     for r in run_probes(cfg, cfg.probes):
         if r["is_error"]:
             raise CovenantError(f"probe {r['tool']} failed at snapshot: {r['error']}")
+        try:
+            fp = fingerprint(r["response"])
+        except ValueError as e:
+            raise CovenantError(f"probe {r['tool']}: {e}") from e
         records.append({
             "tool": r["tool"],
             "args": r["args"],
-            "fingerprint": fingerprint(r["response"]),
+            "fingerprint": fp,
             "sample": r["response"],
         })
     return records
📝 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
def _snapshot_probes(cfg: Config) -> list[JsonDict]:
"""Run the configured probes and build their baseline records."""
records: list[JsonDict] = []
for r in run_probes(cfg, cfg.probes):
if r["is_error"]:
raise CovenantError(f"probe {r['tool']} failed at snapshot: {r['error']}")
records.append({
"tool": r["tool"],
"args": r["args"],
"fingerprint": fingerprint(r["response"]),
"sample": r["response"],
})
return records
def _snapshot_probes(cfg: Config) -> list[JsonDict]:
"""Run the configured probes and build their baseline records."""
records: list[JsonDict] = []
for r in run_probes(cfg, cfg.probes):
if r["is_error"]:
raise CovenantError(f"probe {r['tool']} failed at snapshot: {r['error']}")
try:
fp = fingerprint(r["response"])
except ValueError as e:
raise CovenantError(f"probe {r['tool']}: {e}") from e
records.append({
"tool": r["tool"],
"args": r["args"],
"fingerprint": fp,
"sample": r["response"],
})
return records
🤖 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 `@covenant/cli.py` around lines 32 - 44, The snapshot path in
_snapshot_probes() can let a ValueError from fingerprint() escape, which
bypasses the CLI’s CovenantError handling. Wrap the fingerprint(r["response"])
call (or the whole record-building block in snapshot()/ _snapshot_probes()) and
convert any fingerprint-related ValueError into a CovenantError with a clean
message, so snapshot() still exits via the existing one-line error path and code
2. Use _snapshot_probes, fingerprint, and snapshot as the key places to update.

Source: Coding guidelines

Comment thread covenant/cli.py
Comment on lines +47 to +81
def _check_probes(
cfg: Config, base_tools: list[JsonDict], base_probes: list[JsonDict], judge: bool
) -> list[Change]:
"""Re-run the probes, diff fingerprints, and (optionally) judge semantics."""
base_by = {probe_key(p["tool"], p.get("args")): p for p in base_probes}
missing = [p.tool for p in cfg.probes if probe_key(p.tool, p.args) not in base_by]
if missing:
raise CovenantError(
f"probe(s) not in baseline: {', '.join(missing)} - "
"re-run `covenant snapshot --force`"
)
live = run_probes(cfg, cfg.probes)
changes = diff_probes(base_probes, live)
if not judge:
return changes
from .judge import judge_probe # the [judge] extra is optional; import on use

descriptions = {t["name"]: t.get("description") for t in base_tools}
shape_drifted = {c.tool for c in changes}
for r in live:
if r["is_error"] or r["tool"] in shape_drifted:
continue # errors and shape drift are already reported; judge only clean shapes
sample = base_by[probe_key(r["tool"], r["args"])].get("sample")
verdict = judge_probe(
r["tool"], descriptions.get(r["tool"]), r["args"],
sample, r["response"], model=cfg.judge_model,
)
if verdict.drift:
changes.append(Change(
r["tool"], "behavior", None, "semantic_drift", "degraded",
f"probe {r['tool']}: semantic drift suspected - {verdict.reason}",
note="LLM-judge verdict - review manually",
))
return changes

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

shape_drifted filters by tool name, not probe identity — can wrongly skip judging a clean probe.

probe_key (tool + canonical args) is the documented identity of a probe, so covenant.toml can legitimately hold multiple probes for the same tool with different args. shape_drifted = {c.tool for c in changes} collapses this to tool name only: if any probe on tool foo has shape drift or errors, every live probe for foo (including a distinct, genuinely clean probe with different args) is skipped from judging at Line 67-68.

Separately, base_by[probe_key(r["tool"], r["args"])] at Line 69 indexes directly rather than using .get() — if run_probes's echoed args ever fail to exactly match the config's canonicalized args, this raises an unhandled KeyError instead of a clean CovenantError.

♻️ Proposed fix — key by probe identity, not tool name
-    changes = diff_probes(base_probes, live)
+    changes = diff_probes(base_probes, live)
     if not judge:
         return changes
     from .judge import judge_probe  # the [judge] extra is optional; import on use

     descriptions = {t["name"]: t.get("description") for t in base_tools}
-    shape_drifted = {c.tool for c in changes}
     for r in live:
-        if r["is_error"] or r["tool"] in shape_drifted:
-            continue  # errors and shape drift are already reported; judge only clean shapes
-        sample = base_by[probe_key(r["tool"], r["args"])].get("sample")
+        if r["is_error"]:
+            continue
+        bp = base_by.get(probe_key(r["tool"], r["args"]))
+        if bp is None or fingerprint(r["response"]) != bp.get("fingerprint"):
+            continue  # errors and shape drift are already reported; judge only clean shapes
+        sample = bp.get("sample")
         verdict = judge_probe(
📝 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
def _check_probes(
cfg: Config, base_tools: list[JsonDict], base_probes: list[JsonDict], judge: bool
) -> list[Change]:
"""Re-run the probes, diff fingerprints, and (optionally) judge semantics."""
base_by = {probe_key(p["tool"], p.get("args")): p for p in base_probes}
missing = [p.tool for p in cfg.probes if probe_key(p.tool, p.args) not in base_by]
if missing:
raise CovenantError(
f"probe(s) not in baseline: {', '.join(missing)} - "
"re-run `covenant snapshot --force`"
)
live = run_probes(cfg, cfg.probes)
changes = diff_probes(base_probes, live)
if not judge:
return changes
from .judge import judge_probe # the [judge] extra is optional; import on use
descriptions = {t["name"]: t.get("description") for t in base_tools}
shape_drifted = {c.tool for c in changes}
for r in live:
if r["is_error"] or r["tool"] in shape_drifted:
continue # errors and shape drift are already reported; judge only clean shapes
sample = base_by[probe_key(r["tool"], r["args"])].get("sample")
verdict = judge_probe(
r["tool"], descriptions.get(r["tool"]), r["args"],
sample, r["response"], model=cfg.judge_model,
)
if verdict.drift:
changes.append(Change(
r["tool"], "behavior", None, "semantic_drift", "degraded",
f"probe {r['tool']}: semantic drift suspected - {verdict.reason}",
note="LLM-judge verdict - review manually",
))
return changes
def _check_probes(
cfg: Config, base_tools: list[JsonDict], base_probes: list[JsonDict], judge: bool
) -> list[Change]:
"""Re-run the probes, diff fingerprints, and (optionally) judge semantics."""
base_by = {probe_key(p["tool"], p.get("args")): p for p in base_probes}
missing = [p.tool for p in cfg.probes if probe_key(p.tool, p.args) not in base_by]
if missing:
raise CovenantError(
f"probe(s) not in baseline: {', '.join(missing)} - "
"re-run `covenant snapshot --force`"
)
live = run_probes(cfg, cfg.probes)
changes = diff_probes(base_probes, live)
if not judge:
return changes
from .judge import judge_probe # the [judge] extra is optional; import on use
descriptions = {t["name"]: t.get("description") for t in base_tools}
for r in live:
if r["is_error"]:
continue
bp = base_by.get(probe_key(r["tool"], r["args"]))
if bp is None or fingerprint(r["response"]) != bp.get("fingerprint"):
continue # errors and shape drift are already reported; judge only clean shapes
sample = bp.get("sample")
verdict = judge_probe(
r["tool"], descriptions.get(r["tool"]), r["args"],
sample, r["response"], model=cfg.judge_model,
)
if verdict.drift:
changes.append(Change(
r["tool"], "behavior", None, "semantic_drift", "degraded",
f"probe {r['tool']}: semantic drift suspected - {verdict.reason}",
note="LLM-judge verdict - review manually",
))
return changes
🤖 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 `@covenant/cli.py` around lines 47 - 81, The probe-judging logic in
_check_probes is collapsing drift tracking to tool name instead of probe
identity, which can cause unrelated probes for the same tool to be skipped;
change shape_drifted to track full probe identity via probe_key for each Change
and only skip the matching probe in the live loop. Also replace the direct
base_by[probe_key(...)] lookup with a safe .get() check and raise a
CovenantError when the probe key is missing, so mismatched echoed args from
run_probes do not crash _check_probes unexpectedly.

Comment thread covenant/cli.py
Comment on lines 111 to +130
@app.command()
def check(
server: str | None = _server_opt,
strict: bool = typer.Option(False, "--strict", help="Fail on degraded changes too."),
json_out: bool = typer.Option(False, "--json", help="Emit changes as JSON."),
judge: bool = typer.Option(
False, "--judge",
help="Judge probe responses for semantic drift with an LLM (needs ANTHROPIC_API_KEY).",
),
) -> None:
"""Diff the live server against the baseline and classify every change."""
try:
cfg = load_config(server_override=server)
_, base_tools = read_baseline(cfg.baseline_path)
_, base_tools, base_probes = read_baseline(cfg.baseline_path)
current = introspect(cfg)
changes = diff_tools(base_tools, current)
if cfg.probes:
changes += _check_probes(cfg, base_tools, base_probes, judge)
elif judge:
raise CovenantError("--judge needs [[probes]] in covenant.toml to judge")

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Confirm all exceptions raised inside judge_probe's call chain are CovenantError
ast-grep run --pattern 'def _complete($$$) { $$$ }' --lang python covenant/judge/__init__.py
rg -n -A5 'def _complete' covenant/judge/__init__.py

Repository: Mhemd139/Covenant

Length of output: 715


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo '== covenant/cli.py outline =='
ast-grep outline covenant/cli.py --match check --view expanded || true

echo
echo '== covenant/judge/__init__.py outline =='
ast-grep outline covenant/judge/__init__.py --view expanded || true

echo
echo '== relevant function bodies =='
sed -n '1,220p' covenant/judge/__init__.py | cat -n

echo
echo '== diff/fingerprint/probe references =='
rg -n -A4 -B4 'def (diff_probes|fingerprint|judge_probe|_complete)|CovenantError|raise .*from e' covenant/judge/__init__.py covenant/cli.py

Repository: Mhemd139/Covenant

Length of output: 12636


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo '== covenant/diff.py outline =='
ast-grep outline covenant/diff.py --view expanded || true

echo
echo '== covenant/fingerprint.py outline =='
ast-grep outline covenant/fingerprint.py --view expanded || true

echo
echo '== relevant bodies in diff.py =='
sed -n '1,260p' covenant/diff.py | cat -n

echo
echo '== relevant bodies in fingerprint.py =='
sed -n '1,240p' covenant/fingerprint.py | cat -n

echo
echo '== _snapshot_probes context in cli.py =='
sed -n '1,90p' covenant/cli.py | cat -n

Repository: Mhemd139/Covenant

Length of output: 19596


Wrap fingerprint() errors before they escape check()

judge_probe() already turns API and verdict parsing failures into CovenantError, but diff_probes() still calls fingerprint() directly. A non-JSON probe result will raise ValueError and bypass the check() error handler, producing a stack trace instead of the clean exit-2 path.

🤖 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 `@covenant/cli.py` around lines 111 - 130, The probe diff path can let raw
fingerprint parsing failures escape `check()`, so update
`_check_probes()`/`diff_probes()` to catch `ValueError` from `fingerprint()` and
re-raise it as `CovenantError` like `judge_probe()` does. Keep the handling
close to the `diff_probes()` call site so `check()`’s existing `CovenantError`
exit path is used for non-JSON probe results instead of a stack trace.

Comment thread covenant/config.py
Comment on lines +80 to +82
judge_model = (data.get("judge") or {}).get("model")
if judge_model is not None and not isinstance(judge_model, str):
raise ConfigError("[judge].model must be a string")

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Unguarded .get() on [judge] can crash with a raw AttributeError instead of ConfigError.

If [judge] isn't a table (e.g. a malformed judge = "x" in covenant.toml), data.get("judge") returns a non-dict truthy value, so (data.get("judge") or {}).get("model") calls .get on a non-dict and raises AttributeError. _parse_probes guards against this shape mismatch with isinstance(entry, dict) checks (Line 45-47), but the judge section has no equivalent guard. An uncaught AttributeError would propagate past load_config as a stack trace rather than a clean ConfigError, which conflicts with the CLI's error-handling contract.

🐛 Proposed fix
-    judge_model = (data.get("judge") or {}).get("model")
+    judge_section = data.get("judge") or {}
+    if not isinstance(judge_section, dict):
+        raise ConfigError("[judge] must be a table")
+    judge_model = judge_section.get("model")

As per coding guidelines, "CLI errors must be converted from CovenantError into one clean line with exit code 2; never emit a stack trace or swallow the error" — this bug's root cause in load_config would defeat that guarantee.

📝 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
judge_model = (data.get("judge") or {}).get("model")
if judge_model is not None and not isinstance(judge_model, str):
raise ConfigError("[judge].model must be a string")
judge_section = data.get("judge") or {}
if not isinstance(judge_section, dict):
raise ConfigError("[judge] must be a table")
judge_model = judge_section.get("model")
if judge_model is not None and not isinstance(judge_model, str):
raise ConfigError("[judge].model must be a string")
🤖 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 `@covenant/config.py` around lines 80 - 82, The judge config parsing in
load_config is unsafe because `data.get("judge")` may be a non-dict and the
chained `.get("model")` can raise a raw AttributeError instead of ConfigError.
Add the same shape guard used in `_parse_probes` by checking that the `[judge]`
value is a dict before reading `model`, and raise a ConfigError with a clear
message when it is not. Keep the existing `judge_model` validation logic, but
make sure malformed `judge` entries are converted into clean config errors
rather than uncaught exceptions.

Source: Coding guidelines

Comment thread covenant/diff.py
Comment on lines +245 to +267
base_by = {probe_key(p["tool"], p.get("args")): p for p in baseline}
changes: list[Change] = []
for lp in live:
bp = base_by.get(probe_key(lp["tool"], lp.get("args")))
if bp is None:
continue
tool = str(lp["tool"])
if lp.get("is_error"):
changes.append(Change(
tool, "behavior", None, "probe_errored", "degraded",
f"probe {tool}: live call returned an error - {lp.get('error')}",
))
continue
base_fp, live_fp = bp["fingerprint"], fingerprint(lp["response"])
if _type_set(base_fp) == {"object"} and _type_set(live_fp) == {"object"}:
raw = _diff_object(tool, "output", base_fp, live_fp)
else:
raw = _diff_field(tool, "output", "response", base_fp, live_fp)
changes += [
replace(c, location="behavior", message=f"probe {tool}: {c.message}")
for c in raw
]
return changes

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Map relevant functions/symbols in covenant/diff.py, then inspect targeted ranges.
ast-grep outline covenant/diff.py --view expanded || true
printf '\n---\n'
sed -n '1,340p' covenant/diff.py

printf '\n=== SEARCH fingerprint / CovenantError / check handling ===\n'
rg -n "def fingerprint|fingerprint\\(|CovenantError|check\\b|probe_errored|probe_key|Change\\(" covenant -S

Repository: Mhemd139/Covenant

Length of output: 16656


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '=== covenant/fingerprint.py ===\n'
sed -n '1,220p' covenant/fingerprint.py

printf '\n=== covenant/cli.py (check path) ===\n'
sed -n '1,220p' covenant/cli.py

printf '\n=== baseline / fingerprint key searches ===\n'
rg -n '"fingerprint"|probe_errored|snapshot\(' covenant -S

Repository: Mhemd139/Covenant

Length of output: 9590


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '=== covenant/contract.py ===\n'
sed -n '1,260p' covenant/contract.py

printf '\n=== covenant/errors.py ===\n'
sed -n '1,120p' covenant/errors.py

printf '\n=== read_baseline / write_baseline usages ===\n'
rg -n "read_baseline\\(|write_baseline\\(" covenant -S

Repository: Mhemd139/Covenant

Length of output: 4624


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '=== covenant/introspect.py ===\n'
sed -n '1,260p' covenant/introspect.py

printf '\n=== covenant/config.py (probe shape) ===\n'
sed -n '1,220p' covenant/config.py

printf '\n=== search for non-JSON-native probe responses ===\n'
rg -n "run_probes|response.*object|response.*json|is_error|json.loads|json.dumps|result" covenant -S

Repository: Mhemd139/Covenant

Length of output: 12806


🌐 Web query:

Model Context Protocol CallToolResult structuredContent JSON compatible text content response semantics

💡 Result:

In the Model Context Protocol (MCP), the CallToolResult object is used to return the output of a tool invocation [1]. It supports two primary fields for conveying results: content and structuredContent [2][3]. The content field is for unstructured results and consists of an array of content blocks (e.g., text, images, audio, or resource links) [2]. This field is the primary mechanism for conveying human-readable or multimodal information to the model [4]. The structuredContent field is an optional JSON value (which can be an object, array, string, number, boolean, or null) designed to provide machine-readable output [2][4][3]. If a tool definition includes an outputSchema, any structuredContent returned by the server must conform to that schema [2][3]. Clients are encouraged to validate this content against the defined schema [2][3]. For backwards compatibility, it is recommended that if a tool returns structuredContent, it should also provide a serialized version of that data within a TextContent block inside the content array [5][2][6]. In summary, the semantics are as follows: - content: Used for unstructured, multimodal, or human-readable data [2][4]. - structuredContent: Used for machine-readable data, ideally validated against a tool's outputSchema [2][3]. - Backwards compatibility: Best practice is to include a serialized copy of the structured data in the content field so that clients unaware of the structuredContent field can still access the data [5][2].

Citations:


Guard missing probe fingerprints in baseline records
covenant/introspect.py already normalizes live probe responses, but bp["fingerprint"] can still raise a raw KeyError if a baseline probe record is malformed. Handle that path here so covenant check returns a clean CovenantError instead of a traceback.

🤖 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 `@covenant/diff.py` around lines 245 - 267, Handle malformed baseline probe
records in the probe comparison logic so a missing fingerprint does not raise a
raw KeyError. Update the baseline lookup and fingerprint access in the
change-building loop in covenant/diff.py to validate bp before using
bp["fingerprint"], and route the failure through the existing CovenantError path
used by covenant check. Keep the fix localized around the probe comparison code
that uses base_by, probe_key, and fingerprint.

Comment thread covenant/introspect.py
Comment on lines +91 to +104
async def _run_probes(config: Config, probes: list[Probe]) -> list[JsonDict]:
records: list[JsonDict] = []
async with _session(config) as session:
for probe in probes:
result = await session.call_tool(probe.tool, probe.args)
response, is_error, error = _resolve_result(result)
records.append({
"tool": probe.tool,
"args": probe.args,
"response": response,
"is_error": is_error,
"error": error,
})
return records

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

Per-probe failures are reported as generic connection errors.

If session.call_tool raises (e.g., unknown tool name, invalid arguments, protocol error from the server) partway through the loop, run_probes swallows it into ConnectionError(f"could not probe MCP server ({target}): {e}") — losing which probe/tool actually failed. For a config-level mistake (typo'd tool name, wrong args) this misleadingly reads as a connectivity problem rather than a probe/config issue, making covenant snapshot/check failures harder to diagnose.

🔍 Suggested fix
     async with _session(config) as session:
         for probe in probes:
-            result = await session.call_tool(probe.tool, probe.args)
+            try:
+                result = await session.call_tool(probe.tool, probe.args)
+            except Exception as e:
+                raise ConnectionError(f"probe {probe.tool} failed: {e}") from e
             response, is_error, error = _resolve_result(result)

Also applies to: 118-126

🤖 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 `@covenant/introspect.py` around lines 91 - 104, The per-probe failure handling
in _run_probes is too generic because exceptions from session.call_tool are
later surfaced as a ConnectionError, hiding which probe/tool failed and making
config mistakes look like connectivity issues. Update the probe execution path
in _run_probes and the caller in run_probes to catch per-tool exceptions,
preserve the failing probe’s tool/args context, and propagate or record that
specific error so snapshot/check can report a probe/config problem instead of a
generic server connection failure.

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '== File outline ==\n'
ast-grep outline covenant/introspect.py --view expanded || true

printf '\n== Relevant lines in covenant/introspect.py ==\n'
cat -n covenant/introspect.py | sed -n '1,220p'

printf '\n== Search for call_tool usages and timeout handling ==\n'
rg -n "call_tool\\(|read_timeout_seconds|timedelta\\(" covenant . || true

printf '\n== Search for MCP session type / docs references ==\n'
rg -n "read_timeout_seconds|call_tool" . || true

Repository: Mhemd139/Covenant

Length of output: 6997


🌐 Web query:

MCP Python SDK session.call_tool read_timeout_seconds default hangs forever docs

💡 Result:

In the Model Context Protocol (MCP) Python SDK, the session.call_tool method does not have a default timeout that automatically terminates requests, which can lead to situations where the client hangs indefinitely if the server fails to respond [1][2]. While the call_tool method signature accepts an optional read_timeout_seconds parameter (often as a timedelta object), it is not always enforced by default across all transport layers or internal states, leading to the reported "hang forever" behavior [1][3][2]. Key technical insights regarding this behavior: 1. Lack of Mandatory Default: There is no global, enforced default timeout for call_tool requests in older versions or specific transport implementations (like early WebSocket connectors), meaning the call relies on the underlying connection remaining active [1][2][4]. 2. Transport Limitations: Even if a transport (such as SSE) has a configured read timeout, it may not propagate effectively to the specific call_tool request, causing the client to wait indefinitely for an asyncio.Future that never resolves [1][2][5]. 3. Recommended Workaround: Developers are advised to wrap session.call_tool calls in an explicit asyncio.wait_for block to ensure that tool execution is bounded by a concrete timeout, allowing the application to handle failures or trigger reconnections gracefully [2]. Example of the recommended pattern: import asyncio from datetime import timedelta try: # Use asyncio.wait_for to enforce a strict timeout result = await asyncio.wait_for( session.call_tool("tool_name", arguments={}), timeout=30.0) except asyncio.TimeoutError: # Handle the timeout, e.g., by logging or reconnecting print("Tool call timed out") Recent updates to the SDK (e.g., in v2 and associated patches) have focused on improving timeout propagation, ensuring that read_timeout_seconds is respected by connectors and correctly interrupting pending requests [5][6]. Always check the specific version of the SDK you are using, as older versions were more susceptible to these indefinite hangs [2][6].

Citations:


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo '== covenant/introspect.py relevant excerpt =='
nl -ba covenant/introspect.py | sed -n '70,150p'

echo
echo '== Timeout-related references in covenant/introspect.py =='
rg -n "read_timeout_seconds|call_tool|timeout|timedelta" covenant/introspect.py || true

echo
echo '== MCP session/call_tool references elsewhere in repo =='
rg -n "read_timeout_seconds|call_tool" covenant || true

Repository: Mhemd139/Covenant

Length of output: 241


Add a timeout to probe tool calls. session.call_tool(...) here has no read_timeout_seconds (and the same path is used by run_probes), so a stalled server can hang snapshot/check indefinitely. Pass a default/configurable timeout or wrap the call in asyncio.wait_for.

🤖 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 `@covenant/introspect.py` around lines 91 - 104, The probe execution path in
_run_probes (and the shared run_probes flow) calls session.call_tool without any
timeout, so a stalled server can block snapshot/check indefinitely. Update the
call to enforce a timeout by passing a default/configurable read_timeout_seconds
or by wrapping the tool invocation in asyncio.wait_for, and make sure the
timeout is applied consistently wherever probes are executed.

🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Check for any readOnlyHint / annotation-based guard on probe execution
rg -n "readOnlyHint|annotations" covenant/ tests/

Repository: Mhemd139/Covenant

Length of output: 1110


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== file list =="
git ls-files covenant/introspect.py covenant/config.py covenant/cli.py covenant/fingerprint.py covenant/_types.py

echo
echo "== outlines =="
ast-grep outline covenant/introspect.py --view expanded || true
echo
ast-grep outline covenant/config.py --view expanded || true
echo
ast-grep outline covenant/cli.py --view expanded || true
echo
ast-grep outline covenant/fingerprint.py --view expanded || true

echo
echo "== targeted reads =="
for f in covenant/introspect.py covenant/config.py covenant/cli.py covenant/fingerprint.py; do
  echo "--- $f ---"
  wc -l "$f"
done

echo
echo "== covenant/introspect.py relevant slice =="
sed -n '1,220p' covenant/introspect.py

echo
echo "== covenant/config.py relevant slice =="
sed -n '1,260p' covenant/config.py

echo
echo "== covenant/cli.py relevant slice =="
sed -n '1,260p' covenant/cli.py

echo
echo "== covenant/fingerprint.py relevant slice =="
sed -n '1,260p' covenant/fingerprint.py

Repository: Mhemd139/Covenant

Length of output: 19013


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== find readOnlyHint and probe-related validation =="
rg -n "readOnlyHint|read_only|read-only|probe|Probe|tool.*readonly|readonly.*tool" covenant tests/

echo
echo "== locate schema_hash =="
rg -n "schema_hash|description" covenant

Repository: Mhemd139/Covenant

Length of output: 12280


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== covenant/introspect.py =="
sed -n '1,220p' covenant/introspect.py

echo
echo "== covenant/config.py =="
sed -n '1,260p' covenant/config.py

echo
echo "== covenant/cli.py =="
sed -n '1,240p' covenant/cli.py

echo
echo "== covenant/fingerprint.py =="
sed -n '1,220p' covenant/fingerprint.py

echo
echo "== search for any read-only gating or tool metadata shaping =="
rg -n "readOnlyHint|readonly|read-only|annotations|tool.*dict|_tool_to_dict|call_tool|list_tools|probes" covenant tests/

Repository: Mhemd139/Covenant

Length of output: 24770


Reject non-read-only probes. _run_probes will execute any configured tool during snapshot/check, but Probe and _tool_to_dict carry no readOnlyHint, so a misconfigured probe can still mutate the server. Add a runtime read-only check or thread the annotation into the probe pipeline.

🤖 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 `@covenant/introspect.py` around lines 91 - 104, The _run_probes flow currently
executes every Probe.tool without verifying it is read-only, so a misconfigured
snapshot/check probe can mutate state. Add a runtime guard in _run_probes (or
earlier in the Probe/_tool_to_dict pipeline) to reject any probe whose tool is
not marked read-only, using the existing Probe and _resolve_result/_session flow
to keep the check centralized.

Source: Coding guidelines

Comment on lines +76 to +80
try:
data = json.loads(raw)
return Verdict(drift=bool(data["drift"]), reason=str(data["reason"]))
except (json.JSONDecodeError, KeyError, TypeError) as e:
raise CovenantError(f"judge returned an unparseable verdict: {raw!r}") from e

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

bool(data["drift"]) can mint a false positive from a stringy verdict.

If the model replies with a quoted value (e.g. {"drift": "false"}), bool("false") is True, so a non-drift verdict is reported as drift (a spurious DEGRADED). Given the module's loud-failure philosophy, reject a non-boolean drift instead of coercing it — TypeError is already caught and converted to a clean CovenantError.

🛡️ Proposed fix
         data = json.loads(raw)
-        return Verdict(drift=bool(data["drift"]), reason=str(data["reason"]))
+        drift = data["drift"]
+        if not isinstance(drift, bool):
+            raise TypeError("drift must be a JSON boolean")
+        return Verdict(drift=drift, reason=str(data["reason"]))
📝 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
try:
data = json.loads(raw)
return Verdict(drift=bool(data["drift"]), reason=str(data["reason"]))
except (json.JSONDecodeError, KeyError, TypeError) as e:
raise CovenantError(f"judge returned an unparseable verdict: {raw!r}") from e
try:
data = json.loads(raw)
drift = data["drift"]
if not isinstance(drift, bool):
raise TypeError("drift must be a JSON boolean")
return Verdict(drift=drift, reason=str(data["reason"]))
except (json.JSONDecodeError, KeyError, TypeError) as e:
raise CovenantError(f"judge returned an unparseable verdict: {raw!r}") from e
🤖 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 `@covenant/judge/__init__.py` around lines 76 - 80, The verdict parsing in the
judge module is coercing `data["drift"]` with `bool(...)`, which can turn string
values like "false" into a true drift result. Update the `Verdict` construction
in `covenant/judge/__init__.py` to require `drift` to already be a real boolean
and reject any non-boolean value by raising `TypeError` or otherwise letting the
existing exception handling convert it to `CovenantError`. Keep the
`json.loads`/`Verdict(...)` flow in the same parsing block so invalid model
output fails loudly rather than being coerced.

Comment on lines +34 to +36
- `list` → array; `items` kept only when every element fingerprints identically, else bare
array. Accepted limitation: a uniform array going heterogeneous fingerprints as bare and
is not flagged — probe tools that return uniform collections.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
ast-grep outline covenant/fingerprint.py --view expanded
rg -n -C3 '\b(list|array|items|fingerprint)\b' covenant/fingerprint.py covenant/diff.py tests/test_fingerprint.py tests/test_probes.py

Repository: Mhemd139/Covenant

Length of output: 11741


🏁 Script executed:

sed -n '240,320p' covenant/diff.py
printf '\n---\n'
sed -n '1,120p' tests/test_fingerprint.py
printf '\n---\n'
sed -n '1,220p' tests/test_probes.py

Repository: Mhemd139/Covenant

Length of output: 6592


🏁 Script executed:

sed -n '1,140p' covenant/diff.py
printf '\n---\n'
python3 - <<'PY'
from covenant.fingerprint import fingerprint
from covenant.diff import diff_probes

baseline = [{
    "tool": "probe",
    "args": {},
    "fingerprint": fingerprint([1, 2]),
    "sample": [1, 2],
}]
live = [{
    "tool": "probe",
    "args": {},
    "response": [1, "x"],
}]
print("baseline_fp:", baseline[0]["fingerprint"])
print("live_fp:", fingerprint(live[0]["response"]))
print("changes:", diff_probes(baseline, live))
PY

Repository: Mhemd139/Covenant

Length of output: 5781


Document the array-shape blind spot or add coverage
A uniform array that later becomes heterogeneous is fingerprinted as bare {"type":"array"}, so diff_probes() will miss that drift entirely. Add a regression test if collection-returning probes are in scope, or mark this as an explicit non-goal.

🤖 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
`@docs/superpowers/specs/2026-07-03-covenant-layer3-behavioral-probes-design.md`
around lines 34 - 36, The array fingerprinting behavior in the spec leaves a
blind spot in diff_probes() for collection-returning probes, since a uniform
array that later becomes heterogeneous is still treated as a bare array and the
drift is missed. Update the documentation around the array-shape rules to
explicitly mark this as a non-goal, or add a regression test covering the array
fingerprinting behavior so the limitation is captured; use the existing
array/list shape language in the spec to locate the affected section.

Comment thread examples/mcp_server.py
Comment on lines +103 to +112
def get_transactions(account_id: str) -> dict:
"""List recent transactions for an account."""
txns = _TRANSACTIONS.get(account_id, [])
if BEHAVIOR_DRIFT: # the response body changes; the declared (loose) schema does not
txns = [
{"txn_id": t["txn_id"], "amount_cents": int(t["amount_usd"] * 100),
"merchant": t["merchant"]}
for t in txns
]
return {"account_id": account_id, "transactions": txns}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Description: Check mypy strict config and whether examples/ is included/excluded.
rg -n 'strict|disallow_any_generics|mypy' pyproject.toml
rg -n 'exclude|files' pyproject.toml -A3 | rg -n 'mypy' -B3

Repository: Mhemd139/Covenant

Length of output: 235


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Inspect mypy config around strict mode and overrides.
sed -n '70,120p' pyproject.toml

# Inspect the target file around the function.
sed -n '90,120p' examples/mcp_server.py

# Find any mypy-related overrides or file exclusions that mention examples/.
rg -n 'examples/|exclude|files|strict|disallow_any_generics|disable_error_code|ignore_errors' pyproject.toml -A4 -B4

Repository: Mhemd139/Covenant

Length of output: 1796


Type the return value here
get_transactions returns a bare dict, which will fail this repo's strict mypy checks. Use a parameterized mapping or a TypedDict for the response shape.

🤖 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 `@examples/mcp_server.py` around lines 103 - 112, get_transactions currently
returns an unparameterized dict, which violates the strict mypy settings; update
the return type on get_transactions to a concrete typed mapping or a TypedDict
that matches the response shape. Define and use a named response type for the
account_id/transactions payload, and make sure the txns transformation in
get_transactions still fits that declared shape.

Comment thread README.md
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.

gemini-* models use GOOGLE_API_KEY via google-genai; everything else stays
on the Anthropic SDK. No new config: [judge].model already existed.

Fixes a real Python 3.14 hazard found running the judge live: a chained
`genai.Client().models.generate_content(...)` lets the temporary Client be
garbage-collected mid-call, and the SDK's __del__ closes its httpx
transport ("Cannot send a request, as the client has been closed").
Both providers now hold the client in a local for the duration of the call.

Verified live with GOOGLE_API_KEY: COVENANT_SEMANTIC_DRIFT=1 check --judge
--strict flags "balance_usd ... 100x larger than the baseline" (DEGRADED,
exit 1); clean check --judge stays 0. 114 tests, ruff + strict mypy clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@Mhemd139
Mhemd139 merged commit fff1cd3 into main Jul 2, 2026
5 checks passed
Mhemd139 added a commit that referenced this pull request Jul 2, 2026
fix: CodeRabbit review fixes for Layer 3 (missed by the #2 merge)
Mhemd139 added a commit that referenced this pull request Jul 4, 2026
feat(layer-3): behavioral probes + LLM semantic judge
Mhemd139 added a commit that referenced this pull request Jul 4, 2026
fix: CodeRabbit review fixes for Layer 3 (missed by the #2 merge)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant