Skip to content

feat(catalog): detect upstream tool-definition drift, and stop the scanner failing open - #523

Merged
imran-siddique merged 1 commit into
mainfrom
feat/upstream-catalog-drift
Aug 17, 2026
Merged

feat(catalog): detect upstream tool-definition drift, and stop the scanner failing open#523
imran-siddique merged 1 commit into
mainfrom
feat/upstream-catalog-drift

Conversation

@imran-siddique

Copy link
Copy Markdown
Member

Closes #521. Meets the substance of #58, which was closed in June with its acceptance criteria unmet.

What was wrong

SPEC.md rates P4.2 (rug-pull via upstream tool-definition mutation) as covered. The detector written for it, CatalogScanner.check_drift, was referenced only by its own module, its unit test, and an experiment directory named claim3-rug-pull-detection. Nothing in src/ called it. The catalog_drift deny path that does exist compares our own catalog file against itself and never talks to the upstream server.

What this does

On first contact with each upstream server, the gateway fetches tools/list and compares every advertised definition against its approved entry, using a digest of the semantic triple: description, input schema, output schema. Mismatch fails the call closed by default, writes a catalog_drift audit entry naming the tool and the kind of change (definition_changed or withdrawn), and surfaces in the session TRACE Claim.

catalog.drift_policy: warn_only routes the call and still records the drift, so an operator can measure their own fleet before enforcing. It is not meant as a resting state.

Three design points worth reviewing

The enforcing comparison uses only the standard library. CatalogScanner is backed by an optional dependency. A control that stops working when a dependency is missing is not a control, so the scanner classifies the kind of change and never decides. It is now wired at startup so tool fingerprints get registered, which is what makes classification possible at all.

The trigger is first contact per server, not notifications/tools/list_changed. Only stdio upstreams could carry that notification today, since HTTP upstreams here are plain request/response. A notification-driven check would have silently covered one transport and not the other, which is the same shape of partial coverage that produced #521. First-contact checking covers both identically. This is the one place where I have not met #58's literal first bullet, and it is deliberate.

Absence is no longer reported as safety. CatalogScanResult and DriftResult gain available. Without agent-os-kernel they now return safe=False, tools_scanned=0, available=False instead of safe=True, tools_scanned=len(catalog). The test that asserted the old behaviour has been rewritten, and its name, test_scan_catalog_safe_without_agt, was itself the defect.

Also fixes ToolCatalogInfo.drift_detected, hardcoded False at session/manager.py:256, so the TRACE Claim carried a drift field that could never be true.

What it does not do

Stated in LIMITATIONS.md rather than left implied: no mid-session detection, no notification subscription, and a server that will not answer tools/list is recorded as unchecked rather than denied. The gateway already serves the approved description rather than the live one, so a mutated description does not reach the model even in those windows, but that is a structural property of proxying an approved catalog and not a detection result.

SPEC.md is untouched, per the decision to keep the rating and put the honest detail in LIMITATIONS.md.

Testing

1209 passing, 23 skipped. 10 new tests in tests/unit/test_upstream_catalog_drift.py covering the digest primitive, camelCase and snake_case agreement, description-only mutation, fail-closed and warn-only, withdrawn tools, the unchecked path, once-per-server caching, and drift being caught with the optional scanner absent.

tests/conformance/test_gateway_conformance.py::test_mcp_initialize fails on main as well, an unrelated protocolVersion mismatch (2025-11-25 against an expected 2026-07-28). Deselected locally, not touched here.

…anner failing open

Closes the gap in #521 and meets the substance of #58.

The gateway now compares what each upstream server advertises against the
approved catalog entry on first contact with that server, using a digest of the
semantic triple (description, input schema, output schema). A mismatch fails the
call closed by default, writes a catalog_drift audit entry naming the tool and
the kind of change, and surfaces in the session TRACE Claim.

Three deliberate design points:

The enforcing comparison is standard-library only. CatalogScanner is backed by
an optional dependency, and a control that stops working when a dependency is
missing is not a control. The scanner now classifies the kind of change and is
wired at startup so tool fingerprints are registered, but it never decides.

The trigger is first contact per server, not notifications/tools/list_changed.
Only stdio upstreams could carry that notification today, since HTTP upstreams
are request/response here, so a notification-driven check would silently cover
one transport and not the other. That asymmetry is what produced #521.

CatalogScanResult and DriftResult now carry `available`, and report safe=False
with tools_scanned=0 when agent-os-kernel is absent. They previously returned
safe=True, so a deployment without the dependency received a clean result it had
not earned. The test asserting that behaviour has been rewritten; its name,
test_scan_catalog_safe_without_agt, was itself the bug.

Also fixes ToolCatalogInfo.drift_detected, which was hardcoded False, so the
TRACE Claim carried a drift field that could never be true.

LIMITATIONS.md states the three things this does not do: no mid-session
detection, no notification subscription, and a server that will not answer
tools/list is recorded as unchecked rather than denied.
@imran-siddique

Copy link
Copy Markdown
Member Author

Correcting the last line of the description, which was wrong in a way that matters.

I wrote that tests/conformance/test_gateway_conformance.py::test_mcp_initialize is "an unrelated protocolVersion mismatch". It is unrelated to this PR, and that part stands. But it is not merely a stale assertion.

CI runs pytest tests/unit/ only (.github/workflows/ci.yml:86), while pyproject.toml sets testpaths = ["tests"]. So tests/conformance/ has never been executed by CI, and it has been red on main with nothing to say so. Same shape as the defect this PR fixes: a check that exists, that it is reasonable to assume is running, and is not.

Filed as #524, with the counts. 58 conformance tests and 1 integration test are unrun.

The gateway is correct on the failing assertion. Per #509 a legacy initialize is answered with the legacy version, and the test encodes the pre-#509 behaviour. Not touched here.

@imran-siddique
imran-siddique merged commit 88d93ec into main Aug 17, 2026
13 checks passed
@imran-siddique
imran-siddique deleted the feat/upstream-catalog-drift branch August 17, 2026 19:36
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.

docs: SPEC.md rates P4.2 rug-pull Strong, but the upstream drift detector is not wired into the gateway

1 participant