This repository is a reference implementation. Its value depends on the security model holding under inspection. We treat the threat model as part of the API: changing it without thinking is a breaking change.
Do not open a public GitHub issue for security problems. Instead, open a GitHub Security Advisory on this repository — that channel is private to maintainers until disclosure.
Please include:
- A clear description of the issue and the affected file/lab.
- A minimal reproducer (the smallest input that demonstrates the gap).
- Your assessment of impact (which security pillar is breached: integrity, availability, or log hygiene).
We aim to acknowledge within 5 business days. Fixes for high-severity issues land before any non-security work.
Because the lab's whole purpose is to demonstrate a posture, the bar is broader than a typical project:
- A way to reach a code path not enumerated in the relevant lab's
threat_model.md. - An input that bypasses the allowlist for a tool.
- An input that triggers an unhandled exception instead of a typed
ToolExecutionError+WARNINGlog. - A log line that can be forged or fragmented by attacker-controlled bytes.
- A way for tool descriptions returned to the model to reflect runtime data.
If you find behaviour outside any of those, it's a bug — open a regular issue.
Every tool in this lab passes through three gates, in this order. The order is not negotiable.
Every rejection raises ToolExecutionError (a ValueError subclass). The
framework propagates the error to the MCP client, where it surfaces as a
tool-call failure to the model. There is no fallback path: no try/except
that returns a default, no coercion, no "best-effort" execution.
Each rejection is logged at WARNING on stderr, with the field name and a
bounded repr of the offending value.
Where a tool exposes a finite set of operations (e.g. compute), the set is
declared as a frozenset constant and checked before any dispatch.
Dispatch is an explicit if/elif block — never getattr, eval,
globals()[op], or operator.__dict__[op]. This is a source-level invariant
enforced at code-review time. Pull requests that introduce dynamic dispatch
will be closed, regardless of how convenient the abstraction is.
Every input has a numeric upper bound checked before it is used:
| Limit | Constant | Why |
|---|---|---|
|x| ≤ 1e9 |
MAX_ABS_OPERAND |
Avoid numeric overflow / loss-of-precision attacks. |
len(text) ≤ 500 |
MAX_ECHO_LEN |
Avoid memory and log-flooding DoS. |
Unicode C* blocked |
(in safe_echo) |
Avoid terminal/log injection via ANSI/NUL/BEL/ESC. |
math.isfinite |
(in safe_compute) |
Reject NaN/Infinity smuggled through JSON. |
bool ≠ numeric |
(in safe_compute) |
Python's True == 1 would otherwise pass isinstance(int). |
New tools must declare their own limits in the same explicit way and prove them with at least one rejection test per limit.
- Confidentiality of the local
stdiochannel (assumed trusted; the host owns this). - Authentication of the MCP client (delegated to the host, e.g. Claude Desktop).
- Supply-chain attacks against the upstream
mcpSDK or its transitive deps. - Operating-system level isolation (sandboxing, seccomp, etc.).
These will get their own labs in time (mcp-auth-lab is the next planned
addition for client authentication).
- No
eval,exec,getattr-by-string, or dynamic imports anywhere inprojects/. - No network or filesystem I/O in tools unless the lab's whole point is to demonstrate the guard for it.
- No tool description that contains runtime data — descriptions are static literals.
- Every public function exposed as a tool has a pure, testable core that the test suite can call without going through the MCP transport.