Class: Pikuri::Mcp::Verifier

Inherits:
Object
  • Object
show all
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

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).

Parameters:

  • transport (Pikuri::Agent::ChatTransport, nil) (defaults to: nil)

    builds a Thinker (with cancellable:); the passes run against this model.

  • cancellable (Pikuri::Agent::Control::Cancellable, nil) (defaults to: nil)

    forwarded to the Thinker so a boot-time Ctrl+C aborts. Transport path only.

  • thinker (#call, nil) (defaults to: nil)

    thinker.call(prompt), replacing the built-in Thinker.

  • cache (Cache, Cache::NULL, nil) (defaults to: nil)

    LLM-pass cache; nil builds the on-disk Cache on the transport: path, Cache::NULL on the thinker: path (the test default).

Raises:

  • (ArgumentError)

    when neither or both of +transport:+/+thinker:+ are given, or cancellable: is combined with thinker:.



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.

Parameters:

Raises:



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