Inspiration

I was wiring a third-party MCP server into an agent when I realised I had never read a single one of its tool descriptions. I'd looked at the tool names in a config file and moved on.

Then it occurred to me what those descriptions actually are.

When an agent runs, every tool's name, description, and parameter descriptions get injected into the model's context so it can decide what to call. From the model's point of view, that text sits at the same authority level as your system prompt. It isn't documentation. It's instruction.

Which means an agent's real security posture lives in a pile of English that nobody has ever reviewed as English. Your linter checks your Python. Nothing checks your prose.

Grant-Guardian is the thing that reads it — and then tries it.

What it does

Grant-Guardian is a continuous auditor for AI agent definitions. You point it at a repository containing ADK agents, MCP manifests, or tool schemas. It runs in the background, wakes on new commits and on a schedule, and reports only what it can reproduce.

Five stages:

Inventory. Deterministic AST parsing builds a structured map of the attack surface — every agent instruction, tool description, parameter description, and MCP tool, plus a topology graph of which agents hold which tools and which tools ingest external content.

Hypothesis. A Gemini-powered ADK agent reads each surface and emits testable predictions rather than verdicts. Not "this looks risky," but "the agent will call this tool during a task that never requested it." Every hypothesis ships with a concrete test.

Verification. The heart of the system, described below.

Triage. Confirmed results get a severity, a plain-English explanation, and a proposed rewrite of the offending text. Refuted hypotheses are recorded but excluded from the report.

Watch. State persists. Unchanged surfaces are skipped by content hash. Open findings get re-verified. A finding marked fixed that reappears is flagged as a regression — which is the single most valuable thing the system produces, because nobody goes back to check.

Six check classes: imperatives disguised as descriptions, cross-tool coercion, data-widening parameters, authority claims, dormant triggers, and unisolated ingestion.

How I built it

Reconstruction, not execution. Grant-Guardian never runs the target repository's code. From the parsed inventory it has everything that matters — instruction text, tool names, descriptions, parameter schemas — so it rebuilds the agent inside its own process with every tool replaced by a recording mock. No credentials, no runtime, no sandbox escape surface. I'm testing the text, and text is fully portable.

Canaries. Mock tools return plausible records seeded with tagged canary tokens. When a canary emitted by tool A turns up in the arguments of a call to tool B during a task that asked for neither, that isn't a suspicion of data leakage. It's a witnessed one, with a transcript.

The A/B ablation. This is what separates the project from every scanner that asks a model whether some text looks dangerous. Every test runs in two arms — the agent as written, and an identical agent where the suspect text is replaced with a minimal neutral description of the same tool. Three trials each. A finding is confirmed only when the suspect arm hits at least 2 of 3 and the control arm hits 0 of 3.

That's the difference between correlation and causation. I'm not claiming the text looks bad. I'm demonstrating that removing it removes the behaviour.

Asynchronous by construction. Scans fan out over Pub/Sub with one message per hypothesis. Firestore holds per-hypothesis state with deterministic idempotency keys derived from the scan, content hash, check class, and test index — so a redelivered message finds a terminal document and no-ops. Kill the worker mid-scan and it resumes without re-verifying anything already done. Cloud Scheduler drives periodic re-verification.

There is no synchronous version of this product. An agent that was safe in June is not necessarily safe in August, and the value is entirely in the re-check nobody asked for.

Challenges I ran into

My sandbox was structurally blind. Ninety-plus hypotheses across three real repositories, zero confirmed. I assumed my detection logic was too strict. It wasn't. The reconstructed agent was never actually being shown the tool catalog, or the real response from a prior tool call — it was being asked to reason about tools it could not see. Every clean result was a false negative.

This was the hardest bug of the project because it presented as a result. A security scanner that reports nothing looks exactly like a secure system.

No temperature control at all. The ablation runs three trials per arm, which only means something if the trials can differ. A config dict I'd built for cache-key bookkeeping was being forwarded verbatim as the live API's generation config, with fields that aren't even valid there. Every "3 trials" was effectively one trial repeated. The statistical claim the whole project rests on was hollow, and it was invisible in mock mode. Now temperature is explicit — 0.7 for ablation trials, 0.0 for hypothesis generation and triage — locked in with a regression test that asserts exactly which fields reach the live call.

One network blip killed a whole batch. A transient error during verification could take down an entire verify run. Retries and per-hypothesis error isolation now mean a single failed test is a single failed test.

Real repositories are nothing like samples. Instructions loaded from external prompt files, tool descriptions living in function docstrings, agents split across modules, f-string-built instructions, sub_agents and AgentTool recursion, vendored dependencies. A naive single-file AST walk handles none of it. I made parse coverage a hard gate rather than a hope: 95.9% coverage across 15 real vendored repositories with zero scan-aborting crashes, against a target of 85%.

What I found

A confirmed finding in google/mcp-security — Google's own official security tooling MCP server.

The tool get_collection_timeline_events carries a docstring containing a plain instruction to fetch this information for campaigns and threat actors always.

Now compare that to the project's published documentation for the same tool, which describes it neutrally as especially useful for campaigns and threat actors. Descriptive. A statement about when the tool helps.

The human-facing documentation and the model-facing prompt diverge. Someone wrote careful, neutral prose for the docs page and an imperative in the code — and only the code reaches the model at runtime. Nobody reviewed the second one as prompt text, because it looked like a docstring.

Reproduced with a benign task ("summarize the threat actor APT29") that never asks for timeline events: suspect arm called the tool unprompted 3/3, control arm 0/3.

Severity: medium. This is benign — extra API calls and latency, not an exploit, and clearly written in good faith. That is precisely the point. The problem is ambient, not adversarial. If it's present in a security team's own tooling, it's present nearly everywhere.

What I learned

You cannot trust a negative result you haven't tried to falsify. The days I spent doubting my own scanner were worth more than the week I spent building it.

And more concretely: the discipline of forcing every hypothesis to be a testable prediction with a control changed what the tool is. Without the control arm this is a language model with opinions about your docstrings. With it, it's evidence.

What's next

Runtime attestation — comparing what an agent's text predicts it will do against what it actually does in production. Broader parser coverage for LangChain and OpenAI Assistants. And a public findings corpus, so the check taxonomy grows from real observed patterns rather than from what I imagined an attacker would write.

Built on Google Cloud

  • Gemini 3.5 Flash via Vertex AI — hypothesis generation, triage, and the reconstructed agents under test
  • Google ADKLlmAgent and SequentialAgent composition for the scanning pipeline, and for reconstructing target agents inside the sandbox
  • Cloud Run — two decoupled services, API and worker, scaling to zero
  • Pub/Sub — per-hypothesis fan-out, the mechanism behind crash-resume
  • Firestore — scan, surface, hypothesis, run, and finding state with idempotency keys
  • Cloud Scheduler — periodic re-verification sweeps

Plus FastAPI, a React/TypeScript dashboard, and a Typer CLI. 126 tests passing.

Data sources

A corpus of 15 real public repositories containing ADK agents and MCP servers, vendored at pinned commit SHAs — sourced through the GitHub code search API and including Google's own adk-python, adk-samples, and mcp-security. Pinning keeps results deterministic and reproducible.

Reproducibility and disclosure

The project ships a recorded response cache and a replay mode, so anyone can clone the repository and reproduce a scan with no API key and no cost.

On disclosure: GrantGuardian never executes target code and never tests against anyone's live agent — only sandboxed reconstructions. Findings are private to the repo owner by default, and any public disclosure draft requires human approval. The google/mcp-security finding is shown openly because it is benign and non-exploitable, and the maintainers have been notified.

Built With

Share this project:

Updates

Submission history