Skip to content

feat(runtime): add live tool guards, provenance tagging, and schema drift ledger - #1

Merged
glatinone merged 2 commits into
mainfrom
feat/runtime-guards
Aug 17, 2026
Merged

glatinone merged 2 commits into
mainfrom
feat/runtime-guards

Conversation

@glatinone

Copy link
Copy Markdown
Owner

Summary

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 composite 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, for the same reason mcpscan.drift.DomainDriftRule 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.

Test Coverage & Verification

  • 33 new tests in tests/test_runtime_*.py, unittest-style to match the existing suite.
  • Full suite: python -m unittest discover -s tests and python -m pytest tests/ both 184 passed.
  • ruff check / ruff format --check on the new files: clean. (Caught a real issue along the way: literal zero-width-space characters in test fixtures needed ​ escapes instead of raw invisible bytes — fixed.)
  • Dogfood scan (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 as test_scanner.py's own INJECTION regex 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.

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.
@glatinone
glatinone merged commit 32854bd into main Aug 17, 2026
4 checks passed
@glatinone
glatinone deleted the feat/runtime-guards branch August 17, 2026 08:38
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