Lab 07 — Expose Vigil over MCP, and Validate the AI Edge¶
Setup¶
This is a reference lab — it ships a one-command environment in the companion
plaintext-labs repo at
plaintext-labs/powershell-for-security/07-llm-native-mcp/: a PowerShell 7 container
(mcr.microsoft.com/powershell) with PSScriptAnalyzer and Pester preinstalled, the cumulative Vigil
module (now with an MCP server + tool-handler), a small bundled event sample and threat-feed snapshot, and a
set of good and hostile tool-argument payloads the gate feeds directly to the handler.
git clone https://github.com/plaintext-security/plaintext-labs
cd plaintext-labs/powershell-for-security/07-llm-native-mcp
make up # build the pwsh + PSScriptAnalyzer + Pester container
make shell # drop into pwsh with the Vigil module + MCP handler
make demo # runs the gate: PSScriptAnalyzer + Pester over the server, feeding good AND hostile payloads
make down # stop when done
The demo is fully offline and deterministic: it exercises the tool-handler and validation layer directly, feeding it good and hostile tool-argument payloads as JSON-RPC requests — no live LLM and no network required. The prose shows how to wire the same handler to a real MCP client; the gate proves the part that matters (the validation) works.
Scenario¶
You've been asked to make Vigil callable by an LLM so an analyst can ask, in natural language, "pull the
suspicious PowerShell events and enrich the indicators." The obvious build — wrap Get-VigilEvent and the
enrichment lookup as MCP tools — is five minutes of work. The rest of this lab is the part that keeps it
from being a liability: because the arguments the model sends are untrusted input, you'll expose only
read-only verbs, validate every argument before it reaches a cmdlet, and prove a hostile argument is
rejected by validation rather than run.
Only test systems you own or have explicit written permission to test. Everything here runs locally in the lab container against bundled sample data; the "attacker" is a set of hostile tool-argument payloads you feed to your own handler.
Do¶
- [ ] Reproduce the baseline surface. Import
Vigil, list the read-only hunt verbs you intend to expose (Get-VigilEventand an enrichment lookup), and run each by hand against the bundled sample. This is the set of tools — and only this set — that your MCP server will advertise. - [ ] Write the tool contract first. For each verb, write the MCP tool definition:
name, adescription, and aninputSchema(JSON Schema) that is as tight as you can make it — enumerated values where you can, no free-formcommand/scriptfield anywhere. Note to yourself: this schema is a hint to the model, not a guard — the enforcement comes next. - [ ] Build the read-only tool registry. Implement the server as an explicit allow-list: a registry
mapping each tool
nameto a handler, containing only read-only verbs. Prove there is no path to aSet-/Remove-/Invoke-verb — atools/callfor an unknown or non-registered name returns an MCP error, not a fallback that runs something. - [ ] Insert the validation layer between "parsed" and "used". For every tool, validate each
model-supplied argument before it touches a cmdlet:
[ValidateSet]/allow-list for enumerated fields, a typed parse ([int],[datetime]) for typed ones, and — for anypath-like argument — resolve it and confine it to an allowed root, rejecting.., absolute escapes, and symlinks out. A failed validation returns a safe MCP error result the model can see; it never coerces or runs a slightly-wrong command. - [ ] Drive it as JSON-RPC, offline. Feed the handler
initialize,tools/list, andtools/callrequests as JSON (stdio-style) and confirm a well-formedtools/callreturns typedVigilobjects. No live model — you are standing in for the MCP client. - [ ] Attack your own surface. Feed hostile tool arguments: a
pathwith../../etc/passwd, aseverityof'; Remove-Item', an out-of-set enum, a wrong-typed field, and atools/callfor a destructive verb name. Each must be rejected by validation (a clean MCP error) — captured as failing safely, not as an exception that leaks a stack trace or, worse, a partial run. - [ ] Write the
Pesterproof. Add tests asserting: a good payload returns objects; each hostile payload is rejected by validation (not by accident); and no state-changing verb is exposed by the registry. Get the modulePSScriptAnalyzer-clean and the tests green. - [ ] Automate & own it. Commit the MCP server — the tool registry, the validation layer, and the
Pestertests — as the deliverable. In the commit/PR, note what the copilot generated for the MCP plumbing, and the one thing it defaulted to that you had to fix (the missing re-validation, the trustedpath, the destructive verb it registered "for completeness", or the schema-as-guard assumption).
Success criteria — you're done when¶
- [ ] The MCP server advertises only read-only
Vigilverbs; atools/callfor any state-changing or unknown verb returns a clean MCP error, and aPestertest proves no destructive verb is exposed. - [ ] A well-formed
tools/callreturns typedVigilobjects (not strings), driven entirely offline as JSON-RPC. - [ ] Every hostile tool-argument payload (bad path, injection-looking enum, wrong type, out-of-set value) is rejected by validation, and a
Pestertest proves each rejection. - [ ] No model-supplied value is ever passed into
Invoke-Expressionor any executable string;PSScriptAnalyzeris clean. - [ ] The whole gate (
make demo) passes with no live LLM and no network.
Deliverables¶
The MCP server for Vigil: the tool registry (read-only verbs only), the argument-validation layer, the
JSON-RPC/stdio handler, and Vigil.Tests.ps1 proving (a) a good payload succeeds, (b) each hostile/malformed
argument is rejected by validation, and (c) no state-changing verb is exposed. Commit all of it alongside the
existing Vigil module. Do not commit any API keys, real event exports, or MCP client secrets — the
server is offline by design; wiring it to a real client is a config step outside the repo.
AI acceleration¶
Have the copilot generate the MCP JSON-RPC scaffolding and the tool handlers from your contract, then review
the diff as an adversary. The high-value catch is the copilot's default: it trusts the arguments dictionary
the moment it's parsed from JSON — it takes the model's path and reads it, advertises a tight schema but
skips re-validation in the handler, and may register a Set-/Remove- verb because it "completes" the CRUD
surface. Inserting the validation layer between parse and use — and writing the test that proves a hostile
argument dies there — is the whole point. You're reviewing the AI's trust assumptions, which is exactly where
this class of risk lives.
Connects forward¶
This is the AI edge of the same objects, not strings discipline the track has run since Module 02: the
typed validation that guarded event fields (M2) and untrusted telemetry (M3) now guards untrusted LLM
output (M7), and Module 09's eval harness will measure the whole thing on a held-out corpus. It also sets
up Module 08, where you'll attack Vigil-adjacent PowerShell and build the detection — the same trust-boundary
thinking, pointed at the language itself. For securing the AI system around this surface — prompt-injection
defense, sandboxing, monitoring end to end — cross to Track 12 (AI-Augmented Ops), which owns that depth;
this module owns building the tool well in PowerShell.
Marketable proof¶
"I expose PowerShell security tooling to an LLM over MCP as a read-only tool surface, and I treat every model-supplied tool argument as untrusted input — validated against allow-lists and typed parses before it reaches a cmdlet — with
Pestertests that prove a malicious argument is rejected by validation, not by luck."
Stretch (optional)¶
- Wire the offline handler to a real MCP client (Claude Desktop config, or the Claude API's
mcp_servers+mcp_toolsetwith betamcp-client-2025-11-20and a current model id such asclaude-opus-4-8) and confirm the model can callGet-VigilEvent— then re-run your hostile payloads through the model by crafting an indirect prompt injection in a bundled eventMessage, and watch your validation layer hold. - Add structured MCP error codes (JSON-RPC error objects) so a validation rejection is machine-distinguishable from a genuine tool failure, and a client can react appropriately.
Comments
Sign in with GitHub to comment. Choose the type: Feedback (errors or suggestions on this page) · Hints (help for fellow learners — no spoilers) · General (anything else).