Skip to content

Cheat sheet — LLM-Native PowerShell & an MCP Surface

Companion to Module 07 — LLM-Native PowerShell & an MCP Surface · CC BY 4.0 — print it, pin it, share it.

Last reviewed: 2026-07

The trust-boundary view of tool-calling

model context  ──►  MODEL  ──►  MCP client  ──►  tools/call {name, arguments}  ──►  YOUR handler
   (attacker can                 (transport:                                          ▲
    prompt-inject                 stdio / HTTP)                                        │
    upstream)                                                    everything crossing this line
                                                                is UNTRUSTED INPUT — validate it

The inputSchema you advertise is a hint to the model. It enforces nothing on your side. The security control is the validation in your handler — write it as if the schema didn't exist.

MCP tool-definition shape (advertised in tools/list)

@{
    name        = 'Get-VigilEvent'                       # the tool the model calls
    description = 'Return suspicious Windows events from a bundled log. READ-ONLY.'
    inputSchema = @{                                     # JSON Schema — a HINT to the model
        type       = 'object'
        properties = @{
            severity = @{ type = 'string'; enum = @('Low','Medium','High') }  # tight: enumerated
            source   = @{ type = 'string'; enum = @('sample','sysmon') }      # tight: allow-list
        }
        required             = @('source')
        additionalProperties = $false                    # no free-form fields
    }
}

Keep schemas tight: enumerate values, no command/script/free-text field, additionalProperties=$false. Tight schema plus handler re-validation — two separate jobs.

Expose only read-only verbs (the surface IS the control)

# The registry is an explicit allow-list. There is no destructive tool to call.
$script:VigilTools = @{
    'Get-VigilEvent'      = { param($a) Invoke-VigilGetEvent      @a }   # Get-  : read-only
    'Read-VigilEnrichment'= { param($a) Invoke-VigilEnrichment    @a }   # Read- : read-only
}                                                                        # NO Set-/Remove-/Invoke-

function Resolve-VigilTool {
    param([string]$Name)
    if (-not $script:VigilTools.ContainsKey($Name)) {
        throw "unknown or non-exposed tool: $Name"   # -> clean MCP error, not a fallback run
    }
    $script:VigilTools[$Name]
}

A prompt-injected model's worst case is calling a tool that exists and is read-only. It cannot call what you never registered.

Validating tool args — allow-list, typed parse, path confinement

# 1. Enumerated field -> ValidateSet (reject anything not in the set)
function Test-VigilSeverity {
    param([ValidateSet('Low','Medium','High')][string]$Severity)
    $Severity                                            # returns only on a valid value; else throws
}

# 2. Typed field -> parse, don't trust the JSON string
[int]$count = 0
if (-not [int]::TryParse($arguments.count, [ref]$count)) { throw 'count: not an integer' }

# 3. Path-like field -> resolve + confine to an allowed root (reject .., absolute escape, symlink out)
function Resolve-VigilSafePath {
    param([string]$Candidate, [string]$Root)
    $full = [System.IO.Path]::GetFullPath((Join-Path $Root $Candidate))
    $rootFull = [System.IO.Path]::GetFullPath($Root)
    if (-not $full.StartsWith($rootFull + [System.IO.Path]::DirectorySeparatorChar)) {
        throw "path escapes allowed root: $Candidate"
    }
    $full
}

The move is always: loosest thing the model can send → tightest thing your cmdlet needs, rejecting everything in between. A failed check throws → your handler returns an MCP error result, never a coerced run.

Never pass model output into anything executable

# WRONG — model output crosses into an executable string (LLM injection)
Invoke-Expression $arguments.filter                     # NEVER
Get-Content $arguments.path                              # unconfined path = arbitrary read

# RIGHT — typed, named, validated fields only
$sev  = Test-VigilSeverity -Severity $arguments.severity
$path = Resolve-VigilSafePath -Candidate $arguments.source -Root $DataRoot
Get-VigilEvent -Path $path -Severity $sev               # cmdlet gets only validated, typed values

Minimal JSON-RPC / stdio handler (offline, no SDK)

function Invoke-VigilMcpRequest {
    param([Parameter(Mandatory)][string]$Json)          # one JSON-RPC request in, one response out
    $req = $Json | ConvertFrom-Json
    $result = switch ($req.method) {
        'initialize' { @{ protocolVersion = '2025-06-18'; serverInfo = @{ name = 'vigil'; version = '0.7.0' } } }
        'tools/list' { @{ tools = Get-VigilToolDefinitions } }
        'tools/call' {
            $handler = Resolve-VigilTool -Name $req.params.name       # allow-list gate
            $args    = ConvertTo-VigilValidatedArgument -Name $req.params.name -Raw $req.params.arguments  # validate!
            @{ content = @(@{ type = 'text'; text = (& $handler $args | ConvertTo-Json -Depth 5) }) }
        }
        default { throw "unsupported method: $($req.method)" }
    }
    @{ jsonrpc = '2.0'; id = $req.id; result = $result } | ConvertTo-Json -Depth 8
}

A validation throw becomes a JSON-RPC error object the model can see — a safe rejection, not a partial run.

Pester — prove the boundary holds

Describe 'Vigil MCP surface' {
    It 'returns objects for a good tools/call' {
        $r = Invoke-VigilMcpRequest -Json $goodCall | ConvertFrom-Json
        $r.result.content | Should -Not -BeNullOrEmpty
    }
    It 'rejects a path-traversal argument' {
        { ConvertTo-VigilValidatedArgument -Name 'Get-VigilEvent' -Raw @{ source = '../../etc/passwd' } } |
            Should -Throw
    }
    It 'rejects an out-of-set severity' {
        { Test-VigilSeverity -Severity "'; Remove-Item" } | Should -Throw
    }
    It 'exposes no state-changing verb' {
        $script:VigilTools.Keys | ForEach-Object { $_ | Should -Not -Match '^(Set|Remove|New|Invoke|Start|Stop)-' }
    }
}

The one-line rule

The tool arguments the model sends are the least-trusted input in the pipeline — parse, allow-list, and type them before they touch a cmdlet, expose only read-only verbs, and never let model output reach anything executable.

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