feat(runtime): add live tool guards, provenance tagging, and schema drift ledger - #1
Merged
Merged
Conversation
Static rules in mcpscan.rules catch tool poisoning, secrets, and command injection before install. Nothing catches the same classes of problem once an agent is actually talking to a live server: a tool loaded from a transport that was never scanned, output large/adversarial enough to blow the context window, or a previously-vetted tool whose description/schema silently changes between calls. Adds mcpscan/runtime/, three independent guards plus a facade: - sanitizer.MCPDescriptionSanitizer: screens text live using MCP002's own INJECTION/HIDDEN_UNICODE patterns (mcpscan.rules.tool_poisoning) as the single source of truth, so a static scan and a live check never disagree. - provenance.ProvenanceWrapper: caps and boundary-tags tool output before it re-enters agent context (<untrusted_mcp_content>) — nothing in the static scanner does this, since it never executes a tool. - rugpull.RugPullLedger: SHA-256 fingerprints a tool's own description/schema across calls to the same server. Complementary to MCP014 (mcpscan.drift), which only fingerprints remote server *domains* across --discover runs; this covers local stdio servers too, and checks on every call rather than only via --discover. - guard.MCPToolGuard: composite facade over all three. None of these are wired into the static Rule/scanner pipeline — same reasoning mcpscan.drift.DomainDriftRule already documents for itself: a Rule is a stateless function of the files in front of it, but these need state that persists across live calls, or content that only exists at call time. 33 new tests (tests/test_runtime_*.py, unittest-style to match the existing suite). Full suite: 184 passed. Dogfood scan (mcpscan . --min-severity low) is clean — test fixtures containing literal trigger phrases/hidden Unicode are marked with the existing `# mcpscan: ignore[MCP002]` convention, same as test_scanner.py's own INJECTION regex regression tests. Local branch only — not pushed.
ruff format wrapped several lines onto multiple lines, which moved a few `# mcpscan: ignore[MCP002]` suppression comments below the finding line instead of on it or the line directly above (mcpscan.suppress only checks those two positions). Moved each marker back to a valid position; dogfood scan (mcpscan . --min-severity low) is clean again. Also applies ruff's PLE2515 fix: literal zero-width-space characters in test string literals are now backslash-u-200b escapes instead of invisible raw bytes in the source.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Static rules in
mcpscan.rulescatch tool poisoning, secrets, and command injection before install. Nothing catches the same classes of problem once an agent is actually talking to a live server: a tool loaded from a transport that was never scanned, output large/adversarial enough to blow the context window, or a previously-vetted tool whose description/schema silently changes between calls.Adds
mcpscan/runtime/— three independent guards plus a composite facade:sanitizer.MCPDescriptionSanitizer— screens text live using MCP002's ownINJECTION/HIDDEN_UNICODEpatterns (mcpscan.rules.tool_poisoning) as the single source of truth, so a static scan and a live check never disagree.provenance.ProvenanceWrapper— caps and boundary-tags tool output before it re-enters agent context (<untrusted_mcp_content>). Nothing in the static scanner does this, since it never executes a tool.rugpull.RugPullLedger— SHA-256 fingerprints a tool's own description/schema across calls to the same server. Complementary to MCP014 (mcpscan.drift), which only fingerprints remote server domains across--discoverruns — this covers local stdio servers too, and checks on every call rather than only via--discover.guard.MCPToolGuard— composite facade over all three.None of these are wired into the static
Rule/scanner pipeline, for the same reasonmcpscan.drift.DomainDriftRuledocuments for itself: aRuleis a stateless function of the files in front of it, but these need state that persists across live calls, or content that only exists at call time.Test Coverage & Verification
tests/test_runtime_*.py,unittest-style to match the existing suite.python -m unittest discover -s testsandpython -m pytest tests/both 184 passed.ruff check/ruff format --checkon the new files: clean. (Caught a real issue along the way: literal zero-width-space characters in test fixtures neededescapes instead of raw invisible bytes — fixed.)mcpscan . --min-severity low) is clean. Test fixtures that intentionally contain trigger phrases / hidden Unicode are marked with the existing# mcpscan: ignore[MCP002]convention, same astest_scanner.py's ownINJECTIONregex regression tests.mcpscan tests/fixtures/clean→ exit 0;mcpscan tests/fixtures/vulnerable→ exit 1 (unchanged).Notes
Self-review PR — opening for CI to run and as a record of the diff before merging.