Class: Pikuri::Mcp::Verifier
- Inherits:
-
Object
- Object
- Pikuri::Mcp::Verifier
- Defined in:
- lib/pikuri/mcp/verifier.rb
Overview
Pre-flight check for an MCP server's textual surface — prompt-injection / exfiltration / tool-chain-hijacking patterns a malicious server could embed in its handshake, tool descriptions, or parameter schemas. Wired into Servers#start_one before Servers#resolve_description, so a flagged server never enters Servers#live_ids.
Two passes: (1) a mechanical Unicode pre-pass raising InjectionDetected
on any SUSPICIOUS_UNICODE code point (zero-width, bidi overrides, tag
chars, BOMs — no legitimate place in tool docs, almost always an attempt to
hide text from a reader), free because it precedes any LLM call; then (2)
LLM verification handing the structured surface to @thinker, where a
clean response is the literal "OK" and anything else raises
InjectionDetected with the model's reasoning.
Only the LLM-pass "OK" is cached (keyed on the full surface, so an
unchanged server skips the round-trip next boot); rejections are NOT cached
— a rejected server re-verifies next boot in case the operator fixed it
(any change alters the key anyway). The verifier checks for injection,
not capability: a tool that honestly says "runs bash commands" is not
flagged — the user wired it in on purpose; the job is to catch text that
manipulates the agent into doing something other than the tool's stated
purpose. See #build_prompt for the precise patterns.
Defined Under Namespace
Classes: InjectionDetected
Constant Summary collapse
- PROMPT_VERSION =
Bump when #build_prompt changes meaningfully. Cache folds it into the key fingerprint so a prompt edit invalidates every cached "OK" without anyone +rm+-ing the cache directory.
2- CACHE_DIR =
Where the production cache lives — a sibling of Cache::DIR so verification verdicts never collide with synthesized descriptions (same key fingerprint, different meaning).
File.join(File.dirname(Cache::DIR), 'mcp_verifications')
- SUSPICIOUS_UNICODE =
Code points with no legitimate place in human-readable tool documentation. Each range catches one category of "hide something from a reader":
- +U+200B+–+U+200F+ — zero-width space / non-joiner / joiner, LRM/RLM directionality marks.
- +U+202A+–+U+202E+ — bidi overrides (LRE, RLE, PDF, LRO, RLO).
- +U+2060+–+U+2064+ — word joiner, invisible math operators.
- +U+2066+–+U+2069+ — isolate markers (LRI, RLI, FSI, PDI).
- +U+FEFF+ — ZWNBSP / byte-order mark.
- +U+FFF9+–+U+FFFB+ — interlinear annotation markers.
- +U+E0000+–+U+E007F+ — Unicode tag characters (the "ASCII as PUA" channel made famous by recent prompt-injection PoCs).
/[\u{200B}-\u{200F}\u{202A}-\u{202E}\u{2060}-\u{2064}\u{2066}-\u{2069}\u{FEFF}\u{FFF9}-\u{FFFB}\u{E0000}-\u{E007F}]/.freeze
Instance Method Summary collapse
-
#call(entry:, client:, tools:) ⇒ void
Verify the server's surface.
-
#initialize(transport: nil, cancellable: nil, thinker: nil, cache: nil) ⇒ Verifier
constructor
The easy path is
Verifier.new(transport: ...)— the Thinker and the production on-disk Cache (under CACHE_DIR) are built here;thinker:is the explicit override, mutually exclusive withtransport:(same shape as Synthesizer).
Constructor Details
#initialize(transport: nil, cancellable: nil, thinker: nil, cache: nil) ⇒ Verifier
The easy path is Verifier.new(transport: ...) — the Thinker and the
production on-disk Cache (under CACHE_DIR) are built here; thinker:
is the explicit override, mutually exclusive with transport: (same
shape as Synthesizer).
78 79 80 81 82 83 84 85 86 87 88 89 |
# File 'lib/pikuri/mcp/verifier.rb', line 78 def initialize(transport: nil, cancellable: nil, thinker: nil, cache: nil) raise ArgumentError, 'pass exactly one of transport: or thinker:' if transport.nil? == thinker.nil? raise ArgumentError, 'cancellable: only applies to the transport: path' if thinker && cancellable @thinker = thinker || Thinker.new(transport: transport, cancellable: cancellable) @cache = cache || if transport Cache.new(model_id: transport.model, prompt_version: PROMPT_VERSION, dir: CACHE_DIR) else Cache::NULL end end |
Instance Method Details
#call(entry:, client:, tools:) ⇒ void
This method returns an undefined value.
Verify the server's surface. Returns nothing on success; raises InjectionDetected on failure.
100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 |
# File 'lib/pikuri/mcp/verifier.rb', line 100 def call(entry:, client:, tools:) check_unicode!(entry, client, tools) # The cache stores ONLY the literal string "OK". A rejection # raises {InjectionDetected} from inside the cache block, and # {UrlCache#fetch} skips persistence when the block raises — # so a rejected server gets re-verified on the next boot. @cache.fetch(entry: entry, client: client, tools: tools) do # Cache miss → real LLM round-trip. The same heads-up # rationale as {Synthesizer#call} applies — verifying # silently for tens of seconds confuses the user. LOGGER.info("Verifying MCP server #{entry.id.inspect} for prompt-injection patterns, please wait...") response = @thinker.call(build_prompt(entry, client, tools)) next 'OK' if response.to_s.strip.upcase == 'OK' raise InjectionDetected, "MCP server #{entry.id.inspect} rejected by verifier: #{response.to_s.strip}" end nil end |