Build LabGuard MCP Safety Kernel
Build a scope-enforced MCP server for audited local Nmap reconnaissance.
Introduction
30 Second Summary
An AI assistant can act on a request before a person has checked every detail. In security work, one unchecked target can cross the line from a safe lab exercise into unauthorized testing.
In this project, you will build LabGuard, a Python MCP server that checks each reconnaissance request against a written engagement before Nmap can run. Its typed results and audit hash chain make every policy decision inspectable.
What You'll Build
Your live demo shows Claude Desktop receiving structured service evidence from 127.0.0.1:3000 only after LabGuard approves that exact target and port.
By the end of this project, you'll have:
- A typed scope decision for an exact target and port, followed by a fixed scan preview that never accepts client-supplied Nmap flags.
- A pytest regression test that proves example.com is denied before the scanner subprocess can start.
- A loopback-only reconnaissance result from OWASP Juice Shop with service details, XML evidence, a SHA-256 hash, and a traceable audit.jsonl history.
- Secret Mission: Tamper with a historical audit event and add a security test that proves LabGuard detects the broken hash chain.
Do I need anything before starting?
You need a Windows computer with Docker Desktop, Python, and Visual Studio Code already installed.
The guide sets up Nmap, Claude Desktop, and the pinned Python dependencies. The final Claude Desktop demonstration also requires a Claude account.
Before We Start
Before any hands-on work, lock in LabGuard's authorization boundary so everything outside written permission stays denied by default. That boundary permits only the student-owned OWASP Juice Shop lab at 127.0.0.1:3000.
Set Up the Windows Toolchain
LabGuard depends on predictable local tools before it can enforce authorization or produce repeatable evidence. An unsupported runtime or missing scanner would break the safety demonstration before the policy gate gets a chance to run.
In this step, you will prepare Windows PowerShell with a supported Python runtime. You will also install the local scanner plus the client that hosts the MCP server.
In this step, get ready to:
- Confirm Python compatibility plus the pinned Nmap installation.
- Install Claude Desktop for the later MCP demonstration.
- Create an isolated workspace with pinned Python dependencies.
Check and install the Windows tools
The MCP Python SDK requires Python 3.11 or newer. Checking the current runtime first tells you whether the existing Python installation is ready.
- Press the Windows key to open Windows search.
- Type PowerShell into the search field.
- Press Enter to open PowerShell.
- Check the available Python runtime by running this command:
python --version
What does this command check?
The command asks the Python executable available to PowerShell to report its version. LabGuard needs version 3.11 or newer because that is the minimum supported by the pinned MCP SDK.
✔️ I see Python 3.11 or higher
Your current Python runtime supports the project. Keep PowerShell available for the Nmap check.
ⓧ I see an older Python version
The installed runtime is below the project minimum. A supported Python installation lets the virtual environment use the pinned MCP SDK.
- Visit the official Python downloads page for Windows.
- Download a supported Windows installer for Python 3.11 or newer.
- Start the downloaded installer.
- Complete the installation with support for the global python command enabled.
- Close the current PowerShell window.
- Reopen PowerShell through Windows search.
- Repeat the Python version check shown above.
Still seeing the older runtime?
PowerShell may still resolve an earlier Python installation. Close every PowerShell window before checking again.
Help me find which Python installation PowerShell is using.
ⓧ Command not found
PowerShell cannot locate a Python installation. The official Windows installer can expose a supported runtime through the global command.
- Visit the official Python downloads page for Windows.
- Download a supported Windows installer for Python 3.11 or newer.
- Start the downloaded installer.
- Complete the installation with support for the global python command enabled.
- Close the current PowerShell window.
- Reopen PowerShell through Windows search.
- Repeat the Python version check shown above.
Python still unavailable?
The terminal may still hold the environment from before the installation. Reopen PowerShell before repeating the check.
Help me make Python available in Windows PowerShell.
LabGuard uses Nmap through a fixed reconnaissance profile. Version 7.992 keeps the scanner behavior aligned with the project evidence.
- Check the Nmap installation by running this command:
nmap --version
What does this command check?
The command reports the Nmap version that PowerShell can locate. A successful result also proves that LabGuard can find the scanner later.
✔️ I see Nmap 7.992
The pinned scanner is installed. PowerShell can also locate its executable.
ⓧ I see a different Nmap version
The scanner version differs from the assessed toolchain. Installing the pinned self-installer makes the later Nmap evidence reproducible.
- Visit the official Nmap download page.
- Download nmap-7.992-setup.exe.
- Start the Nmap self-installer.
- Keep Npcap selected when the installer presents its components.
- Complete the Nmap installation.
- Close the current PowerShell window.
- Reopen PowerShell through Windows search.
- Repeat the Nmap version check shown above.
Still seeing the previous version?
A previous Nmap installation may still resolve first. Confirm that the 7.992 installer completed before reopening PowerShell.
Help me find which Nmap installation PowerShell is using.
ⓧ Command not found
Nmap is not available to PowerShell yet. The official Windows self-installer includes the scanner plus its Npcap dependency.
- Visit the official Nmap download page.
- Download nmap-7.992-setup.exe.
- Start the Nmap self-installer.
- Keep Npcap selected when the installer presents its components.
- Complete the Nmap installation.
- Close the current PowerShell window.
- Reopen PowerShell through Windows search.
- Repeat the Nmap version check shown above.
Nmap still unavailable?
PowerShell may still contain the command lookup state from before the installation. Close every PowerShell window before checking again.
Help me make Nmap available in Windows PowerShell.
Claude Desktop acts as the local MCP client during the final demonstration. Launching the signed-in client once creates the Windows configuration directory used later.
- Press the Windows key to open Windows search.
- Type Claude into the search field.
- Press Enter.
✔️ Claude Desktop opens and I am signed in
Claude Desktop is ready for the later LabGuard connection. Launching the signed-in client has created its Windows configuration directory.
ⓧ Claude Desktop opens but I am signed out
- Start the sign-in flow shown by Claude Desktop.
- Complete the account authentication in the browser.
- Return to the Claude Desktop window.
The main conversation screen confirms that the client is signed in. Its Windows configuration directory now exists.
Sign-in flow not completing?
The browser authentication needs to hand control back to Claude Desktop. Fully close the client before retrying if it remains signed out.
Help me troubleshoot Claude Desktop sign-in on Windows.
ⓧ Claude Desktop is not installed
- Visit the official Claude Desktop installation page.
- Download the Windows installer.
- Open the downloaded installer.
- Complete the Claude Desktop installation.
- Launch Claude Desktop from Windows search.
- Complete the sign-in flow.
The main conversation screen confirms that the client is signed in. Launching it also creates the Windows configuration directory.
Claude Desktop not opening?
Claude Desktop supports Windows 10 or higher. Confirm that the installation completed before launching it again.
Help me troubleshoot Claude Desktop on Windows.
Create the LabGuard workspace
Visual Studio Code uses an opened folder as the project workspace. The two setup files pin the dependencies plus keep generated evidence out of source control.
- Press the Windows key to open Windows search.
- Type Visual Studio Code into the search field.
- Press Enter to open Visual Studio Code.
- Select File from the top menu.
- Select Open Folder.
You will see the Windows folder chooser. This is where the LabGuard workspace gets its permanent location.
- Select your Desktop location.
- Create a folder named labguard-mcp.
- Select the new labguard-mcp folder.
- Confirm the folder selection.
- Trust the workspace if Visual Studio Code asks about code from this folder.
You will see labguard-mcp at the top of the Explorer sidebar. That confirms the correct project folder is open.
- Select the new-file control in the Explorer sidebar.
- Enter requirements.txt as the file name.
- Press Enter.
- Pin the project dependencies by copying this content into requirements.txt:
mcp==2.3.0
pydantic==2.14.1
pytest==9.1.1
What do these dependency pins control?
- The MCP Python SDK pin fixes the server framework at version 2.3.0.
- Pydantic version 2.14.1 validates the structured output models.
- pytest version 9.1.1 runs the repeatable security tests.
- Save requirements.txt with Ctrl+S.
- Confirm that the editor contains all three pinned package lines.
Dependency pins look different?
Each package needs two equals signs before its version. A changed pin can install an API that differs from the project code.
Help me compare my dependency pins with the project requirements.
- Select the new-file control in the Explorer sidebar.
- Enter .gitignore as the file name.
- Press Enter.
- Exclude local environments plus generated evidence by copying this content into .gitignore:
.venv/
__pycache__/
.pytest_cache/
artifacts/
audit.jsonl
What does this file exclude?
- The environment plus cache entries exclude generated Python files.
- The artifacts/ entry excludes generated Nmap evidence.
- The audit.jsonl entry excludes local audit events that can contain assessment context.
- Save .gitignore with Ctrl+S.
- Confirm that the Explorer sidebar lists .gitignore.
- Confirm that the Explorer sidebar lists requirements.txt.
Missing a setup file?
Both files must sit directly inside labguard-mcp. Check the leading dot in .gitignore.
Help me find a missing LabGuard setup file in Visual Studio Code.
✔️ Awesome, I've got everything!
Both setup files are saved directly inside labguard-mcp. Your workspace is ready for its isolated environment.
ⓧ I'd like to double check the full code
Compare your two setup files with these complete references.
mcp==2.3.0
pydantic==2.14.1
pytest==9.1.1
What should match?
The file contains exactly three package pins. Each version matches the assessed LabGuard toolchain.
.venv/
__pycache__/
.pytest_cache/
artifacts/
audit.jsonl
What should match here?
The file contains five exclusions. These entries cover the virtual environment plus generated caches and assessment evidence.
Build and verify the virtual environment
A virtual environment gives LabGuard its own Python executable plus package directory. Activating it keeps the pinned dependencies separate from your other projects.
- Select View from the Visual Studio Code top menu.
- Select Terminal.
You will see an integrated PowerShell terminal inside the open labguard-mcp folder. Its prompt identifies the folder where the environment will be created.
- Create the isolated environment by running this command:
python -m venv .venv
What does this command create?
Python creates a local environment named .venv inside labguard-mcp. The folder contains the isolated interpreter plus its package directory.
- Confirm that .venv appears in the Explorer sidebar.
Virtual environment missing?
Confirm that the terminal prompt points to labguard-mcp. Confirm that the earlier Python check reported version 3.11 or newer.
Help me create the LabGuard virtual environment.
- Activate the new environment by running this command:
.\.venv\Scripts\Activate.ps1
What does activation change?
The activation script makes the executables inside .venv the defaults for this PowerShell session. The prompt displays the environment name after activation succeeds.
✔️ The environment name appears in my prompt
The isolated environment is active. Packages installed from this terminal now stay inside the LabGuard workspace.
ⓧ PowerShell blocks the activation script
Changing an execution policy can feel risky. This documented command applies the policy only to your current Windows user.
- Apply the current-user execution policy by running this command:
Set-ExecutionPolicy -ExecutionPolicy RemoteSigned -Scope CurrentUser
What does this policy change?
PowerShell permits locally created activation scripts for the current user. Other Windows accounts keep their existing policy.
- Close the blocked integrated terminal.
- Create a new PowerShell terminal from the Visual Studio Code terminal menu.
- Activate the environment again by running this command:
.\.venv\Scripts\Activate.ps1
What should change now?
The new PowerShell session reads the current-user execution policy. The environment name appears in the prompt when activation succeeds.
Activation still blocked?
Confirm that you created a new PowerShell session after changing the policy. Confirm that the activation script exists inside .venv\Scripts.
Help me activate the LabGuard virtual environment.
- Install the pinned project dependencies by running this command:
python -m pip install -r requirements.txt
What does this command install?
The active environment reads each exact package pin from requirements.txt. This makes the MCP server framework plus its model validation and testing tools reproducible.
The installation finishes without an error. The activated environment remains visible in the PowerShell prompt.
Dependency installation failed?
Confirm that the environment name appears in the prompt. Compare requirements.txt with the full-code reference above.
Help me troubleshoot the pinned dependency installation.
Before you run the final check, do you expect all three commands to resolve from this activated PowerShell session?
- Verify the active Python runtime plus Nmap and pytest by running these commands:
python --version
nmap --version
pytest --version
What do these checks prove?
- The Python check reports the interpreter selected by the activated environment.
- The Nmap check proves that PowerShell can locate the pinned scanner.
- The pytest check reports the test runner installed from requirements.txt.
You will see Python 3.11 or newer. You will also see Nmap 7.992.
The final result reports pytest 9.1.1. That completes the reproducible Windows foundation for LabGuard.
One version does not match?
A Python or pytest mismatch usually means the virtual environment is inactive. A missing Nmap result means the current PowerShell session cannot locate the scanner.
Help me diagnose my LabGuard version checks.
Your Windows toolchain is ready. Next up, you will expose LabGuard's first typed reconnaissance preview without connecting the real scanner.
Expose a Typed Recon Preview
Your Windows toolchain is ready. You can now prove that an MCP server can return a predictable reconnaissance plan.
A typed preview gives the client a stable contract before Nmap executes. This first version deliberately trusts the supplied target so you can expose that authorization weakness in the next step.
In this step, get ready to:
- Create the engagement policy for the loopback lab.
- Build a typed reconnaissance preview with Pydantic.
- Verify the direct preview output and the stdio server behavior.
Create the engagement policy
An engagement policy records the exact system that the assessment permits. This policy limits the lab to 127.0.0.1 on port 3000.
- Use the new-file control in the VS Code Explorer sidebar to create engagement.json inside labguard-mcp.
You should see the empty engagement.json file in the Explorer sidebar.
- Define the authorized engagement by pasting this policy into engagement.json:
{
"engagement_id": "capstone-lab-001",
"owner": "student-owned local lab",
"authorization_reference": "student self-authorization for local Docker lab",
"consent_confirmed": true,
"valid_until": "2027-06-30",
"allowed_targets": {
"127.0.0.1": [3000]
}
}
What Does This Policy Define?
- The engagement_id gives this assessment a stable identifier.
- The consent fields record why the student-owned lab is authorized.
- The validity date defines when the written authorization ends.
- The allowed_targets object permits one exact target with one exact port.
- Save engagement.json.
- Confirm the editor shows capstone-lab-001 with the target 127.0.0.1 and port 3000.
Policy File Showing a Syntax Warning?
Check every comma and quotation mark against the reference above. JSON requires double quotation marks around each text value.
Ask for help with the policy structure: Help me compare my engagement.json file with the required LabGuard policy structure.
Build the typed preview server
A typed output model fixes the shape of every preview result. The client receives named fields instead of an unstructured block of text.
- Use the new-file control in the VS Code Explorer sidebar to create server.py inside labguard-mcp.
- Add the server and its output model by pasting this first chunk into server.py:
from mcp.server import MCPServer
from pydantic import BaseModel, Field
mcp = MCPServer("LabGuard MCP")
class ScanPlan(BaseModel):
engagement_id: str
target: str
port: int
allowed: bool
reason: str
command_profile: list[str] = Field(
description="Fixed Nmap argument profile. It is empty when authorization is denied."
)
audit_event_id: str
What Does This Code Do?
- The imports provide the high-level MCP server and the typed model building blocks.
- The MCPServer("LabGuard MCP") call names the server that an MCP host can connect to.
- The ScanPlan model declares every field that a preview must return.
- The Field description documents why a denied result eventually needs an empty command profile.
- Save server.py.
- Confirm the editor recognizes ScanPlan without showing a Python syntax warning.
Imports Showing a Warning?
Confirm the VS Code terminal still shows the activated .venv environment. The selected Python interpreter must point to that environment.
Ask for help checking the interpreter: Why does VS Code show unresolved MCP or Pydantic imports when my virtual environment is activated?
The preview tool accepts typed inputs and returns a ScanPlan. Its command profile is fixed inside the server so the client cannot submit arbitrary scanner flags.
- Add the preview tool below ScanPlan by pasting this final chunk:
@mcp.tool()
def preview_lab_recon(engagement_id: str, target: str, port: int, actor_label: str) -> ScanPlan:
"""Preview the fixed reconnaissance profile for the supplied inputs."""
return ScanPlan(
engagement_id=engagement_id,
target=target,
port=port,
allowed=True,
reason="Fixed reconnaissance preview created from the supplied inputs.",
command_profile=[
"nmap",
"-sT",
"-Pn",
"-p",
str(port),
"-sV",
"--version-light",
"-oX",
"artifacts/authorized-scan.xml",
target,
],
audit_event_id="",
)
if __name__ == "__main__":
mcp.run()
How Does the Preview Work?
- The @mcp.tool() decorator exposes preview_lab_recon as an MCP tool.
- The return annotation makes ScanPlan the validated output contract.
- The command profile contains a fixed TCP reconnaissance sequence with XML output.
- The mcp.run() call starts the server when you execute server.py directly.
- Save server.py.
Preview Code Showing a Syntax Warning?
Check that the preview function starts below the complete ScanPlan class. Confirm every opening parenthesis and square bracket has a matching closing character.
Ask for help locating the mismatch: Help me find the syntax problem in my typed preview function.
✔️ Awesome, I've got everything!
Your two server chunks now form one typed preview server.
ⓧ I'd like to double check the full code
from mcp.server import MCPServer
from pydantic import BaseModel, Field
mcp = MCPServer("LabGuard MCP")
class ScanPlan(BaseModel):
engagement_id: str
target: str
port: int
allowed: bool
reason: str
command_profile: list[str] = Field(
description="Fixed Nmap argument profile. It is empty when authorization is denied."
)
audit_event_id: str
@mcp.tool()
def preview_lab_recon(engagement_id: str, target: str, port: int, actor_label: str) -> ScanPlan:
"""Preview the fixed reconnaissance profile for the supplied inputs."""
return ScanPlan(
engagement_id=engagement_id,
target=target,
port=port,
allowed=True,
reason="Fixed reconnaissance preview created from the supplied inputs.",
command_profile=[
"nmap",
"-sT",
"-Pn",
"-p",
str(port),
"-sV",
"--version-light",
"-oX",
"artifacts/authorized-scan.xml",
target,
],
audit_event_id="",
)
if __name__ == "__main__":
mcp.run()
What Should Match?
Compare the imports, model fields, preview parameters, fixed command profile, and main guard with your saved file. These pieces form the complete first version of server.py.
Check the typed preview and stdio server
The direct call isolates the tool from any desktop client. Before you run it, what fields do you expect a typed reconnaissance plan to display?
- Run the authorized preview call from the activated PowerShell terminal:
python -c "from server import preview_lab_recon; print(preview_lab_recon('capstone-lab-001', '127.0.0.1', 3000, 'student-demo'))"
What Did This Prove?
You should see a typed ScanPlan containing the engagement identifier, loopback target, port, reason, and fixed command profile.
The allowed=True value comes directly from this naive function. The function does not read engagement.json yet.
That is your first MCP slice working. The server now produces a validated plan without accepting client-supplied Nmap flags.
Direct Preview Call Failed?
Confirm PowerShell is inside the labguard-mcp folder. Confirm the terminal prompt still shows the activated .venv environment.
Ask for help with the direct call: Help me diagnose why Python cannot import or call preview_lab_recon.
The same file can also run as a long-lived stdio server. Before you start it, do you expect a normal PowerShell prompt to return immediately?
- Start the MCP stdio server from the activated PowerShell terminal:
python server.py
Why Does the Terminal Wait?
The mcp.run() call starts the server over stdio. The process waits silently because an MCP host has not connected to send requests.
You should see the terminal remain occupied without returning to the PowerShell prompt. That silent wait confirms the server is ready for an MCP stdio host.
- Stop the waiting server by pressing Ctrl+C.
- Confirm the PowerShell prompt returns after the server stops.
Server Exits Instead of Waiting?
Read the terminal output for the first reported file and line. A startup exit usually points to an unsaved file, an indentation problem, or a missing package in the active environment.
Ask for help with startup: Why does my LabGuard MCP server exit instead of waiting for a stdio host?
Your typed preview server is running with a fixed scanner profile. Next, you will test the dangerous assumption hidden inside its unconditional allow decision.
Prove the Authorization Gap
Your typed MCP preview now returns a structured ScanPlan for any supplied inputs. That result proves the tool contract can carry a fixed reconnaissance plan.
The server still trusts the target supplied by the caller. This step turns that authorization weakness into a repeatable negative security test without starting Nmap.
A client could mistake a valid-looking plan for policy approval. The test captures the required denial before any scanner process exists.
In this step, get ready to:
- Create a temporary engagement policy for an isolated test.
- Define the denial behavior for an out-of-scope target.
- Run the named test to capture the current authorization gap.
Create an isolated policy fixture
Negative security tests should keep your real engagement policy untouched. The pytest fixtures place the test policy inside a temporary directory.
- Use the file controls in the VS Code Explorer sidebar to create tests/test_server.py inside your labguard-mcp folder.
- Add the temporary test-path helper by pasting this code into tests/test_server.py:
import json
from pathlib import Path
import server
def _configure_test_paths(monkeypatch, tmp_path: Path) -> Path:
engagement_path = tmp_path / "engagement.json"
audit_path = tmp_path / "audit.jsonl"
artifact_dir = tmp_path / "artifacts"
engagement_path.write_text(
json.dumps(
{
"engagement_id": "capstone-lab-001",
"owner": "test owner",
"authorization_reference": "test authorization",
"consent_confirmed": True,
"valid_until": "2099-12-31",
"allowed_targets": {"127.0.0.1": [3000]},
}
),
encoding="utf-8",
)
monkeypatch.setattr(server, "ENGAGEMENT_PATH", engagement_path)
monkeypatch.setattr(server, "AUDIT_PATH", audit_path)
monkeypatch.setattr(server, "ARTIFACT_DIR", artifact_dir)
return audit_path
What Does This Test Setup Do?
- The tmp_path fixture gives the test a unique temporary directory.
- The helper writes a consented JSON policy with a future validity date.
- The temporary allow-list contains only 127.0.0.1 on port 3000.
- The monkeypatch.setattr() calls redirect LabGuard away from your real project files.
- Save tests/test_server.py.
- Confirm that the VS Code Explorer sidebar now shows test_server.py inside the tests folder.
Test File in the Wrong Place?
Check that tests sits directly inside labguard-mcp. The test file should sit inside that folder.
Make sure the filename is exactly test_server.py.
Help me check the location of my LabGuard test file.
Define the denial contract
A default-deny contract describes the safe result for anything outside the allow-list. An unlisted target must receive a denied plan with no command profile.
- In tests/test_server.py, add this test below _configure_test_paths():
def test_preview_denies_out_of_scope(monkeypatch, tmp_path):
_configure_test_paths(monkeypatch, tmp_path)
plan = server.preview_lab_recon(
"capstone-lab-001", "example.com", 3000, "pytest"
)
assert plan.allowed is False
assert plan.command_profile == []
What Does This Test Require?
- The helper activates the temporary policy for this test.
- The call requests example.com. That target is absent from the temporary allow-list.
- The first assertion requires plan.allowed to be False.
- The second assertion requires an empty command_profile.
- Save tests/test_server.py.
- Confirm that the file now ends with assert plan.command_profile == [].
✔️ Awesome, I've got everything!
Your test file now contains the isolated policy helper plus the denial contract.
ⓧ I'd like to double check the full code
- Compare your complete tests/test_server.py file with this reference.
import json
from pathlib import Path
import server
def _configure_test_paths(monkeypatch, tmp_path: Path) -> Path:
engagement_path = tmp_path / "engagement.json"
audit_path = tmp_path / "audit.jsonl"
artifact_dir = tmp_path / "artifacts"
engagement_path.write_text(
json.dumps(
{
"engagement_id": "capstone-lab-001",
"owner": "test owner",
"authorization_reference": "test authorization",
"consent_confirmed": True,
"valid_until": "2099-12-31",
"allowed_targets": {"127.0.0.1": [3000]},
}
),
encoding="utf-8",
)
monkeypatch.setattr(server, "ENGAGEMENT_PATH", engagement_path)
monkeypatch.setattr(server, "AUDIT_PATH", audit_path)
monkeypatch.setattr(server, "ARTIFACT_DIR", artifact_dir)
return audit_path
def test_preview_denies_out_of_scope(monkeypatch, tmp_path):
_configure_test_paths(monkeypatch, tmp_path)
plan = server.preview_lab_recon(
"capstone-lab-001", "example.com", 3000, "pytest"
)
assert plan.allowed is False
assert plan.command_profile == []
What Should Match?
Your file should contain one temporary-path helper. It should finish with the single out-of-scope preview test.
Run the negative security test
Prediction: choose whether the current preview satisfies both denial assertions or exposes its policy gap.
- Run only the authorization test from your activated PowerShell environment by running this command:
pytest -q tests/test_server.py::test_preview_denies_out_of_scope
This Failure Is the Evidence
- The pytest report shows one failed test.
- The first assertion finds that plan.allowed remains True for example.com.
- Execution can stop at the first failed assertion. The empty command-profile assertion remains part of the required behavior.
- No scan starts because this preview only returns a plan.
That red test is useful evidence. Authorization must run before LabGuard returns a tool plan or reaches any future subprocess.
Test Not Reaching the Assertion?
Confirm that your PowerShell prompt still shows the activated virtual environment. Make sure tests/test_server.py is saved.
If the test cannot be collected, compare the function name with test_preview_denies_out_of_scope in the full-file reference.
Help me diagnose why my LabGuard authorization-gap test is not reaching its assertions.
- Take a screenshot of PowerShell showing the named test plus its failed assertion.
- Add the screenshot to your project diary as baseline evidence.
You have turned a hidden policy gap into a failing security requirement. Next, you will make every LabGuard tool evaluate scope before it returns a plan or starts Nmap.
Enforce Scope and Audit Every Call
The intentional failure from the previous step proves that the typed MCP preview trusts its caller. Client approval cannot enforce the engagement boundary inside the server.
Model instructions cannot enforce that boundary either. The server now becomes the enforcement point for every MCP tool.
Every decision also enters a hash-chained JSON Lines audit log. That trace makes policy outcomes inspectable before LabGuard reaches Nmap.
In this step, get ready to:
- Enforce exact engagement scope for all three tools.
- Record each policy decision in a hash-chained audit log.
- Use pytest to prove denied subprocess prevention plus authorized XML parsing.
Enforce the shared policy gate
The shared policy gate converts the rules in engagement.json into executable authorization. A request earns allowed=True only after every required check succeeds.
How does default deny work here?
The evaluator begins with no authority granted to the request. It returns a typed denial as soon as one policy requirement fails.
- The engagement identifier must match the active policy.
- The consent record must be confirmed.
- The validity date must include today.
- The exact target must contain the requested port.
- Select server.py in the VS Code Explorer sidebar.
- Replace the complete file with the implementation in the double-check tab below.
✔️ Awesome, I've got everything!
Your server.py now contains the shared scope gate, audit chain, fixed scanner adapter, XML parser, plus three typed MCP tools.
ⓧ I'd like to double check the full code
from __future__ import annotations
import getpass
import hashlib
import json
import os
import shutil
import subprocess
import threading
import uuid
import xml.etree.ElementTree as ET
from datetime import date, datetime, timezone
from pathlib import Path
from mcp.server import MCPServer
from pydantic import BaseModel, Field
BASE_DIR = Path(__file__).resolve().parent
ENGAGEMENT_PATH = Path(
os.environ.get("LABGUARD_ENGAGEMENT_FILE", str(BASE_DIR / "engagement.json"))
)
AUDIT_PATH = Path(
os.environ.get("LABGUARD_AUDIT_FILE", str(BASE_DIR / "audit.jsonl"))
)
ARTIFACT_DIR = Path(
os.environ.get("LABGUARD_ARTIFACT_DIR", str(BASE_DIR / "artifacts"))
)
AUDIT_LOCK = threading.Lock()
mcp = MCPServer("LabGuard MCP")
class ScopeDecision(BaseModel):
engagement_id: str
target: str
port: int
allowed: bool
reason: str
audit_event_id: str = ""
class ScanPlan(BaseModel):
engagement_id: str
target: str
port: int
allowed: bool
reason: str
command_profile: list[str] = Field(
description="Fixed Nmap argument profile. It is empty when authorization is denied."
)
audit_event_id: str
class ReconResult(BaseModel):
engagement_id: str
target: str
port: int
allowed: bool
status: str
reason: str
state: str = ""
service: str = ""
product: str = ""
version: str = ""
xml_path: str = ""
evidence_sha256: str = ""
audit_event_id: str = ""
def _load_engagement() -> dict:
try:
return json.loads(ENGAGEMENT_PATH.read_text(encoding="utf-8"))
except FileNotFoundError:
return {}
except json.JSONDecodeError:
return {}
def _evaluate_scope(engagement_id: str, target: str, port: int) -> ScopeDecision:
policy = _load_engagement()
if not policy:
return ScopeDecision(
engagement_id=engagement_id,
target=target,
port=port,
allowed=False,
reason="Engagement policy is missing or malformed.",
)
if policy.get("engagement_id") != engagement_id:
return ScopeDecision(
engagement_id=engagement_id,
target=target,
port=port,
allowed=False,
reason="Engagement identifier does not match the active policy.",
)
if policy.get("consent_confirmed") is not True:
return ScopeDecision(
engagement_id=engagement_id,
target=target,
port=port,
allowed=False,
reason="Explicit consent is not confirmed.",
)
try:
valid_until = date.fromisoformat(str(policy["valid_until"]))
except (KeyError, TypeError, ValueError):
return ScopeDecision(
engagement_id=engagement_id,
target=target,
port=port,
allowed=False,
reason="The engagement validity date is missing or invalid.",
)
if date.today() > valid_until:
return ScopeDecision(
engagement_id=engagement_id,
target=target,
port=port,
allowed=False,
reason="The engagement authorization has expired.",
)
allowed_ports = policy.get("allowed_targets", {}).get(target, [])
if port not in allowed_ports:
return ScopeDecision(
engagement_id=engagement_id,
target=target,
port=port,
allowed=False,
reason="The exact target and port are not on the allow-list.",
)
return ScopeDecision(
engagement_id=engagement_id,
target=target,
port=port,
allowed=True,
reason="The engagement, consent, validity window, target, and port are authorized.",
)
def _canonical_bytes(value: dict) -> bytes:
return json.dumps(
value,
sort_keys=True,
separators=(",", ":"),
ensure_ascii=False,
).encode("utf-8")
def verify_audit_chain(path: Path | None = None) -> tuple[bool, int, str]:
audit_path = path or AUDIT_PATH
if not audit_path.exists():
return True, 0, "Audit log does not exist yet."
previous_hash = "GENESIS"
event_count = 0
for line_number, raw_line in enumerate(
audit_path.read_text(encoding="utf-8").splitlines(), start=1
):
if not raw_line.strip():
continue
try:
stored = json.loads(raw_line)
except json.JSONDecodeError:
return False, event_count, f"Line {line_number} is not valid JSON."
recorded_hash = stored.pop("event_hash", "")
if stored.get("previous_hash") != previous_hash:
return False, event_count, f"Line {line_number} has a previous-hash mismatch."
calculated_hash = hashlib.sha256(_canonical_bytes(stored)).hexdigest()
if calculated_hash != recorded_hash:
return False, event_count, f"Line {line_number} has an event-hash mismatch."
previous_hash = recorded_hash
event_count += 1
return True, event_count, "Audit hash chain is valid."
def _append_audit(
*,
actor_label: str,
tool_name: str,
target: str,
port: int,
phase: str,
decision: str,
result_summary: str,
) -> str:
AUDIT_PATH.parent.mkdir(parents=True, exist_ok=True)
with AUDIT_LOCK:
valid, _, reason = verify_audit_chain(AUDIT_PATH)
if not valid:
raise RuntimeError(f"Audit integrity failure: {reason}")
previous_hash = "GENESIS"
if AUDIT_PATH.exists():
lines = [line for line in AUDIT_PATH.read_text(encoding="utf-8").splitlines() if line]
if lines:
previous_hash = json.loads(lines[-1])["event_hash"]
event = {
"event_id": str(uuid.uuid4()),
"timestamp_utc": datetime.now(timezone.utc).isoformat(),
"os_user": getpass.getuser(),
"actor_label": actor_label,
"tool_name": tool_name,
"target": target,
"port": port,
"phase": phase,
"decision": decision,
"result_summary": result_summary,
"previous_hash": previous_hash,
}
event_hash = hashlib.sha256(_canonical_bytes(event)).hexdigest()
stored_event = {**event, "event_hash": event_hash}
with AUDIT_PATH.open("a", encoding="utf-8", newline="\n") as audit_file:
audit_file.write(json.dumps(stored_event, sort_keys=True) + "\n")
audit_file.flush()
os.fsync(audit_file.fileno())
return event["event_id"]
def _fixed_profile(target: str, port: int, xml_path: Path) -> list[str]:
return [
"nmap",
"-sT",
"-Pn",
"-p",
str(port),
"-sV",
"--version-light",
"-oX",
str(xml_path),
target,
]
def _parse_nmap_xml(xml_path: Path, expected_port: int) -> tuple[str, str, str, str]:
root = ET.parse(xml_path).getroot()
matching_port = None
for candidate in root.findall(".//port"):
if candidate.get("protocol") == "tcp" and candidate.get("portid") == str(expected_port):
matching_port = candidate
break
if matching_port is None:
return "unknown", "", "", ""
state_element = matching_port.find("state")
service_element = matching_port.find("service")
state = state_element.get("state", "unknown") if state_element is not None else "unknown"
service = service_element.get("name", "") if service_element is not None else ""
product = service_element.get("product", "") if service_element is not None else ""
version = service_element.get("version", "") if service_element is not None else ""
return state, service, product, version
@mcp.tool()
def verify_engagement_scope(
engagement_id: str,
target: str,
port: int,
actor_label: str,
) -> ScopeDecision:
"""Verify explicit authorization for one exact engagement, target, and port."""
decision = _evaluate_scope(engagement_id, target, port)
decision.audit_event_id = _append_audit(
actor_label=actor_label,
tool_name="verify_engagement_scope",
target=target,
port=port,
phase="result",
decision="allow" if decision.allowed else "deny",
result_summary=decision.reason,
)
return decision
@mcp.tool()
def preview_lab_recon(
engagement_id: str,
target: str,
port: int,
actor_label: str,
) -> ScanPlan:
"""Preview the fixed reconnaissance profile after checking scope."""
decision = _evaluate_scope(engagement_id, target, port)
placeholder_xml = ARTIFACT_DIR / "authorized-scan.xml"
profile = _fixed_profile(target, port, placeholder_xml) if decision.allowed else []
event_id = _append_audit(
actor_label=actor_label,
tool_name="preview_lab_recon",
target=target,
port=port,
phase="result",
decision="allow" if decision.allowed else "deny",
result_summary=decision.reason,
)
return ScanPlan(
engagement_id=engagement_id,
target=target,
port=port,
allowed=decision.allowed,
reason=decision.reason,
command_profile=profile,
audit_event_id=event_id,
)
@mcp.tool()
def run_lab_recon(
engagement_id: str,
target: str,
port: int,
actor_label: str,
) -> ReconResult:
"""Run one fixed Nmap profile against an explicitly allowed lab target."""
decision = _evaluate_scope(engagement_id, target, port)
authorization_event_id = _append_audit(
actor_label=actor_label,
tool_name="run_lab_recon",
target=target,
port=port,
phase="authorization",
decision="allow" if decision.allowed else "deny",
result_summary=decision.reason,
)
if not decision.allowed:
return ReconResult(
engagement_id=engagement_id,
target=target,
port=port,
allowed=False,
status="denied",
reason=decision.reason,
audit_event_id=authorization_event_id,
)
nmap_path = shutil.which("nmap")
if nmap_path is None:
event_id = _append_audit(
actor_label=actor_label,
tool_name="run_lab_recon",
target=target,
port=port,
phase="result",
decision="allow",
result_summary="Nmap executable was not found on PATH.",
)
return ReconResult(
engagement_id=engagement_id,
target=target,
port=port,
allowed=True,
status="error",
reason="Nmap executable was not found on PATH.",
audit_event_id=event_id,
)
ARTIFACT_DIR.mkdir(parents=True, exist_ok=True)
scan_suffix = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ")
xml_path = ARTIFACT_DIR / f"nmap-{scan_suffix}-{uuid.uuid4().hex[:8]}.xml"
command = _fixed_profile(target, port, xml_path)
command[0] = nmap_path
try:
completed = subprocess.run(
command,
shell=False,
capture_output=True,
text=True,
timeout=45,
check=False,
)
except subprocess.TimeoutExpired:
event_id = _append_audit(
actor_label=actor_label,
tool_name="run_lab_recon",
target=target,
port=port,
phase="result",
decision="allow",
result_summary="Nmap exceeded the 45-second project timeout.",
)
return ReconResult(
engagement_id=engagement_id,
target=target,
port=port,
allowed=True,
status="timeout",
reason="Nmap exceeded the 45-second project timeout.",
audit_event_id=event_id,
)
if completed.returncode != 0 or not xml_path.exists():
summary = completed.stderr.strip() or "Nmap failed without XML evidence."
event_id = _append_audit(
actor_label=actor_label,
tool_name="run_lab_recon",
target=target,
port=port,
phase="result",
decision="allow",
result_summary=summary,
)
return ReconResult(
engagement_id=engagement_id,
target=target,
port=port,
allowed=True,
status="error",
reason=summary,
audit_event_id=event_id,
)
try:
state, service, product, version = _parse_nmap_xml(xml_path, port)
except ET.ParseError as error:
summary = f"Nmap XML could not be parsed: {error}"
event_id = _append_audit(
actor_label=actor_label,
tool_name="run_lab_recon",
target=target,
port=port,
phase="result",
decision="allow",
result_summary=summary,
)
return ReconResult(
engagement_id=engagement_id,
target=target,
port=port,
allowed=True,
status="error",
reason=summary,
xml_path=str(xml_path),
audit_event_id=event_id,
)
evidence_hash = hashlib.sha256(xml_path.read_bytes()).hexdigest()
result_summary = f"state={state}; service={service}; product={product}; version={version}"
event_id = _append_audit(
actor_label=actor_label,
tool_name="run_lab_recon",
target=target,
port=port,
phase="result",
decision="allow",
result_summary=result_summary,
)
return ReconResult(
engagement_id=engagement_id,
target=target,
port=port,
allowed=True,
status="complete",
reason="Authorized fixed-profile reconnaissance completed.",
state=state,
service=service,
product=product,
version=version,
xml_path=str(xml_path),
evidence_sha256=evidence_hash,
audit_event_id=event_id,
)
if __name__ == "__main__":
mcp.run()
What does this code do?
- The ScopeDecision, ScanPlan, plus ReconResult models validate every tool result.
- The _evaluate_scope() function denies requests that fail any engagement requirement.
- The verify_audit_chain() function recalculates each event hash.
- The _append_audit() function validates the existing chain before appending an event.
- The run_lab_recon() function returns before subprocess.run() when scope is denied.
- The authorized path resolves the scanner executable before using the fixed argument sequence.
- Save server.py.
Before you rerun the focused test, do you think the same request for example.com now reaches an allow decision or a deny decision?
- Confirm the original authorization-gap test now passes by running:
pytest -q tests/test_server.py::test_preview_denies_out_of_scope
What should you see?
You should see one passing test. This proves the preview returns allowed=False with an empty command_profile for example.com.
Still seeing the intentional failure?
Confirm that preview_lab_recon() calls _evaluate_scope() before constructing its profile.
Check that ENGAGEMENT_PATH reads the temporary policy installed by the test.
Help me debug why the focused LabGuard authorization test still fails.
That failing regression now passes. Your preview rejects an unauthorized target before it can receive a scanner profile.
Test denial plus authorized evidence
One passing preview test covers only the visible policy decision. The expanded suite also proves that a denied scan cannot reach the subprocess boundary.
The authorized case uses mocked XML evidence. This keeps the security test repeatable without starting a real scan.
- Select tests/test_server.py in the VS Code Explorer sidebar.
- Replace the complete file with the test suite in the double-check tab below.
✔️ Awesome, I've got everything!
Your test suite now covers preview denial, subprocess prevention, authorized XML parsing, evidence hashing, plus audit-chain validation.
ⓧ I'd like to double check the full code
import json
import subprocess
from pathlib import Path
import server
def _configure_test_paths(monkeypatch, tmp_path: Path) -> Path:
engagement_path = tmp_path / "engagement.json"
audit_path = tmp_path / "audit.jsonl"
artifact_dir = tmp_path / "artifacts"
engagement_path.write_text(
json.dumps(
{
"engagement_id": "capstone-lab-001",
"owner": "test owner",
"authorization_reference": "test authorization",
"consent_confirmed": True,
"valid_until": "2099-12-31",
"allowed_targets": {"127.0.0.1": [3000]},
}
),
encoding="utf-8",
)
monkeypatch.setattr(server, "ENGAGEMENT_PATH", engagement_path)
monkeypatch.setattr(server, "AUDIT_PATH", audit_path)
monkeypatch.setattr(server, "ARTIFACT_DIR", artifact_dir)
return audit_path
def test_preview_denies_out_of_scope(monkeypatch, tmp_path):
_configure_test_paths(monkeypatch, tmp_path)
plan = server.preview_lab_recon(
"capstone-lab-001", "example.com", 3000, "pytest"
)
assert plan.allowed is False
assert plan.command_profile == []
def test_denied_scan_never_invokes_subprocess(monkeypatch, tmp_path):
_configure_test_paths(monkeypatch, tmp_path)
def forbidden_run(*args, **kwargs):
raise AssertionError("subprocess.run must not execute for a denied target")
monkeypatch.setattr(server.subprocess, "run", forbidden_run)
result = server.run_lab_recon(
"capstone-lab-001", "example.com", 3000, "pytest"
)
assert result.status == "denied"
assert result.allowed is False
def test_authorized_scan_parses_xml_and_preserves_audit(monkeypatch, tmp_path):
audit_path = _configure_test_paths(monkeypatch, tmp_path)
def fake_run(args, **kwargs):
xml_path = Path(args[args.index("-oX") + 1])
xml_path.parent.mkdir(parents=True, exist_ok=True)
xml_path.write_text(
"""<?xml version="1.0"?>
<nmaprun>
<host>
<ports>
<port protocol="tcp" portid="3000">
<state state="open" />
<service name="http" product="OWASP Juice Shop" version="test-fixture" />
</port>
</ports>
</host>
</nmaprun>
""",
encoding="utf-8",
)
return subprocess.CompletedProcess(args=args, returncode=0, stdout="", stderr="")
monkeypatch.setattr(server.shutil, "which", lambda name: r"C:\Program Files (x86)\Nmap\nmap.exe")
monkeypatch.setattr(server.subprocess, "run", fake_run)
result = server.run_lab_recon(
"capstone-lab-001", "127.0.0.1", 3000, "pytest"
)
assert result.status == "complete"
assert result.state == "open"
assert result.service == "http"
assert result.evidence_sha256
valid, event_count, reason = server.verify_audit_chain(audit_path)
assert valid is True, reason
assert event_count == 2
What does this test suite prove?
- The preview test proves an unauthorized target receives no command profile.
- The denied-scan test replaces subprocess.run() with a function that raises immediately.
- The authorized test writes controlled XML evidence through a mocked subprocess.
- The authorized result must report an open HTTP service.
- The final assertions require a valid two-event audit chain.
- Save tests/test_server.py.
Prepare the host template and verify the suite
Claude Desktop needs an absolute interpreter path when it starts a local stdio server. A reusable example file preserves the required configuration shape without storing one machine's path as the shared default.
- Create claude_desktop_config.example.json inside the labguard-mcp folder through the VS Code Explorer sidebar.
- Add the complete template from the double-check tab below.
✔️ Awesome, I've got everything!
Your example configuration defines the LabGuard MCP server entry with placeholders for the virtual-environment interpreter plus server.py.
ⓧ I'd like to double check the full code
{
"mcpServers": {
"LabGuard MCP": {
"command": "C:\\ABSOLUTE\\PATH\\TO\\labguard-mcp\\.venv\\Scripts\\python.exe",
"args": [
"C:\\ABSOLUTE\\PATH\\TO\\labguard-mcp\\server.py"
]
}
}
}
What does this configuration do?
The command value points the MCP host to the project virtual-environment interpreter. The args list identifies the server entry point.
The placeholder paths keep the example portable. You replace them with absolute Windows paths during the Claude Desktop demonstration.
- Save claude_desktop_config.example.json.
Before you run the full suite, do you expect the denied scan test to trigger its forbidden subprocess function?
- Verify the complete safety kernel by running:
pytest -q
What should you see?
You should see 3 passed. The denied scan returned before its mocked subprocess could raise.
The authorized mocked scan also parsed its XML evidence successfully. Its two audit events preserved a valid hash chain.
Test suite not passing?
Confirm that tests/test_server.py contains all three test functions from the double-check tab.
Check that run_lab_recon() returns immediately after a denied authorization decision.
Help me debug the failing LabGuard security test suite.
You have closed the authorization gap. LabGuard now denies out-of-scope requests before execution while preserving evidence of every policy decision.
Your safety kernel now has executable authorization plus repeatable evidence. Next up, you will run its fixed reconnaissance profile against the authorized loopback lab.
Run Authorized Recon
Your three passing pytest tests prove LabGuard blocks unsafe work in isolation. A capstone demonstration also needs a real target that the same gate can safely allow.
You will run OWASP Juice Shop through Docker Desktop on loopback. LabGuard's MCP tools then use Nmap for one authorized scan before Claude Desktop requests the same workflow.
In this step, get ready to:
- Run OWASP Juice Shop on the host loopback interface.
- Generate structured evidence through a direct LabGuard call.
- Demonstrate reviewed authorization decisions through Claude Desktop.
Start the loopback-only lab
The Docker port mapping controls where the deliberately vulnerable application can be reached. Binding host port 3000 to 127.0.0.1 keeps the target available only from your Windows machine.
- Press the Windows key to open Windows search.
- Type PowerShell and press Enter to start a separate PowerShell window.
- Start the foreground Juice Shop container by running this command:
docker run --rm -p 127.0.0.1:3000:3000 bkimminich/juice-shop
What does this command do?
- The mapping 127.0.0.1:3000:3000 publishes container port 3000 on the host loopback interface.
- The --rm flag removes the container after it stops.
- The process stays in the foreground until you press Ctrl+C.
Before you load the application, do you expect the loopback address to reach the container from your browser?
- Open http://localhost:3000 in your browser.
You'll see the OWASP Juice Shop application load. This confirms that the authorized target is listening on local port 3000.
Your real lab target is online now. Its loopback-only binding keeps the demonstration contained to your machine.
Juice Shop not loading?
Confirm Docker Desktop has finished starting. Check whether another local application already uses port 3000.
If the container stopped, return to its PowerShell window to inspect the final output. Help me diagnose why the loopback-only Juice Shop container is not loading.
An image digest identifies the exact image content used during your assessment. Recording it makes the demonstration evidence reproducible even when an image name later points to different content.
- Switch back to the activated PowerShell environment from earlier.
- Display the repository digest for the pulled image by running this command:
docker image inspect -f '{{range .RepoDigests}}{{println .}}{{end}}' bkimminich/juice-shop
What does this command show?
Docker inspects the local image and prints its RepoDigests entries. Each entry ties the repository name to an immutable content digest.
You'll see one or more repository digest entries for the local Juice Shop image.
- Record v20.3.0 as the stable release at the project research date in your assessment evidence pack.
- Record the displayed repository digest in your assessment evidence pack: bkimminich/juice-shop@sha256:....
Run the authorized reconnaissance
The direct call tests the complete allowed path without an AI client in the middle. LabGuard must authorize the exact target before the fixed scan profile can reach Nmap.
Before you run this call, do you expect the engagement policy to allow or deny the exact loopback target?
- Run the authorized LabGuard call from the activated PowerShell environment by using this command:
python -c "from server import run_lab_recon; print(run_lab_recon('capstone-lab-001', '127.0.0.1', 3000, 'student-demo'))"
What happens during this call?
- The run_lab_recon() function evaluates the engagement identifier.
- The shared policy gate checks the consent record.
- The gate checks the validity window.
- The gate requires the exact target and port before starting Nmap.
- A successful scan returns a validated ReconResult with an XML path and evidence hash.
You'll see allowed=True with status='complete'. The result also includes service evidence plus non-empty xml_path and evidence_sha256 fields.
Recon did not complete?
If the result says Nmap was not found on the path, confirm the Nmap installation is still available in this PowerShell session. If the returned state is closed, confirm Juice Shop is still running in the first PowerShell window.
Keep the target fixed at 127.0.0.1 while troubleshooting. Help me diagnose why my authorized LabGuard recon did not complete.
- Expand the artifacts folder in the VS Code Explorer sidebar.
- Open the newest artifacts/nmap-<UTC timestamp>-<id>.xml file.
You'll see XML evidence for TCP port 3000 with the service details returned by the fixed reconnaissance profile.
- Open audit.jsonl from the VS Code Explorer sidebar.
How does the audit trail support the result?
- The identity fields record the event identifier, time, Windows user, and caller-supplied actor label.
- The request fields record the tool, target, port, phase, and policy decision.
- The evidence fields record the result summary plus the previous and current event hashes.
Connect Claude Desktop to LabGuard
Claude Desktop starts the local server as a stdio process. Its configuration needs absolute paths because the client does not start inside your labguard-mcp folder.
- Expand .venv\Scripts in the VS Code Explorer sidebar.
- Copy the absolute path to python.exe from its context menu.
- Record the copied interpreter path here: C:\...\labguard-mcp\.venv\Scripts\python.exe.
- Copy the absolute path to server.py from its context menu.
- Record the copied server path here: C:\...\labguard-mcp\server.py.
Keep the paths absolute
Each recorded path should start with a Windows drive letter. The interpreter path must point inside this project's .venv folder.
- Press Windows+R to open the Windows Run dialog.
- Enter %APPDATA%\Claude and press Enter.
File Explorer opens the Claude configuration directory created when you launched the client earlier.
- Drag claude_desktop_config.example.json from the VS Code Explorer sidebar into the Claude configuration directory.
- Rename the copied file to claude_desktop_config.json.
You'll see claude_desktop_config.json inside %APPDATA%\Claude.
- Drag claude_desktop_config.json from File Explorer into VS Code.
- Replace C:\ABSOLUTE\PATH\TO\labguard-mcp\.venv\Scripts\python.exe with C:\...\labguard-mcp\.venv\Scripts\python.exe.
- Replace C:\ABSOLUTE\PATH\TO\labguard-mcp\server.py with C:\...\labguard-mcp\server.py.
Both configured paths now point to files inside your existing labguard-mcp folder.
- Save claude_desktop_config.json.
✔️ Awesome, I've got everything!
Your local configuration keeps the same server name and JSON structure. Only the two absolute Windows paths differ from the template.
ⓧ I'd like to double check the full code
{
"mcpServers": {
"LabGuard MCP": {
"command": "C:\\ABSOLUTE\\PATH\\TO\\labguard-mcp\\.venv\\Scripts\\python.exe",
"args": [
"C:\\ABSOLUTE\\PATH\\TO\\labguard-mcp\\server.py"
]
}
}
}
- Fully quit Claude Desktop.
Claude Desktop is now stopped. Restarting it makes the client reload the saved local server configuration.
- Press the Windows key to open Windows search.
- Type Claude and press Enter to reopen Claude Desktop.
Before you send the demonstration request, do you expect Claude Desktop or the LabGuard server to make the final authorization decision?
- Start a new conversation in Claude Desktop.
- Submit the authorized demonstration request by using this prompt:
Use LabGuard MCP to verify engagement capstone-lab-001 for target 127.0.0.1 port 3000, then run the authorized lab recon as actor professor-demo. Do not scan any other target.
How the request stays controlled
Claude Desktop turns the request into reviewed tool calls. LabGuard independently applies the engagement policy before it returns a plan or starts Nmap.
The actor label provides audit context. Human review still confirms the requested tool and target before execution.
- Review the first tool request for engagement capstone-lab-001 on target 127.0.0.1 and port 3000.
- Approve the scope verification request.
You'll see an allowed scope decision with a reason confirming the engagement, consent, validity window, target, and port.
- Review the second tool request for the same exact target and port.
- Approve the reconnaissance request.
You'll see status='complete' with structured service evidence. The result includes an XML evidence path plus its SHA-256 hash.
That completes the capstone workflow. An AI client requested the work while the Python server enforced the safety boundary.
Before you test the boundary, do you expect LabGuard to start Nmap for a target missing from the engagement allow-list?
- Submit an out-of-scope request by using this prompt:
Use LabGuard MCP to run lab recon for engagement capstone-lab-001, target example.com, port 3000, as actor professor-demo.
What changes in this check?
This request keeps the engagement identifier and port while changing the target. The exact-target rule is the only authorization condition under test.
- Review the requested target before execution.
- Approve the tool call to observe LabGuard's policy decision.
You'll see status='denied' with a reason explaining that the exact target and port are missing from the allow-list. Nmap does not start.
- Return to audit.jsonl in VS Code.
- Reload the file to include the latest Claude Desktop events.
You'll see hash-linked records for the approved calls plus the denied example.com request. Each record attributes the request to professor-demo.
Secret mission
Detect Audit-Log Tampering
A trustworthy audit trail must reveal historical edits. Build an isolated security regression test that changes one stored result and proves LabGuard detects the broken hash chain.
Clean Up Your Resources
Clean Up Your Resources
All project resources run locally. They create no ongoing project cost whether you keep the workspace or remove it.
Resources you used:
- Local service: The running OWASP Juice Shop foreground container in Docker with only the loopback binding 127.0.0.1:3000:3000.
- Local workspace: The labguard-mcp folder. It holds the complete safety kernel plus audit.jsonl and Nmap XML evidence.
- Desktop integration: The LabGuard MCP entry in %APPDATA%\Claude\claude_desktop_config.json. This connects Claude Desktop to the local server.
Keep everything running
No action is needed while you are actively demonstrating LabGuard. This option preserves the complete project for future capstone work.
- In the PowerShell window running Juice Shop, press Ctrl+C when the demonstration ends.
- Retain the labguard-mcp folder for future demonstrations.
- Preserve your assessment evidence pack for the dissertation appendix.
The active container exits when you stop it. Your saved project remains ready for your next demonstration.
Keep Your Evidence Together
The audit log links events through their hashes. Preserve the complete audit.jsonl file when using it as assessment evidence.
Pause - I'll come back to this later
Pausing shuts down the active local processes while preserving the project files. Your evidence remains available for the next session.
- In the PowerShell window running Juice Shop, press Ctrl+C.
- Fully quit Claude Desktop.
- Leave the labguard-mcp folder unchanged.
You should see the PowerShell prompt return after the container exits. The local code plus generated evidence remain on your computer.
Delete - I don't want to use this again
Remove all LabGuard project resources if you do not plan to use this workspace again. Deleting the labguard-mcp folder is permanent, so the first action gives you a chance to preserve dissertation evidence.
- Copy any dissertation evidence you want to keep to a location outside labguard-mcp.
- In the PowerShell window running Juice Shop, press Ctrl+C.
- In Claude Desktop, select Settings.
- Select Developer.
- Select Edit Config to open %APPDATA%\Claude\claude_desktop_config.json.
- Remove the LabGuard MCP object from mcpServers.
Keep the Config Valid
A configuration file must stay valid JSON after an entry is removed. Any other MCP server entries still belong inside mcpServers.
- Save %APPDATA%\Claude\claude_desktop_config.json.
- Confirm the file no longer contains LabGuard MCP.
- Fully quit Claude Desktop.
- Delete the labguard-mcp folder in File Explorer.
- Confirm the labguard-mcp folder no longer appears in File Explorer.
Because the container used --rm, stopping it removes the container automatically. Your LabGuard workspace plus its Claude Desktop connection are now removed.
Nice Work!
Nice Work!
You did it! Your LabGuard MCP safety kernel now keeps AI-assisted reconnaissance inside an authorized local lab.
You've learned how to:
- Built a scope-enforced MCP server that returns typed Pydantic decisions, scan plans, and reconnaissance results.
- Proved with pytest that default-deny authorization rejects unauthorized targets before the Nmap subprocess can start.
- Completed an authorized reconnaissance demonstration through Claude Desktop with structured service evidence, an XML artifact, a SHA-256 hash, and a hash-linked audit trail.
- Secret Mission: Added an audit tamper test that changes result_summary and proves verify_audit_chain() detects the broken event hash.
Ready to quiz yourself?