Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .github/workflows/integrity-gate.yml
Original file line number Diff line number Diff line change
Expand Up @@ -168,7 +168,7 @@ jobs:
--lock e2e.warden.lock \
--verify \
--certificate-identity \
"https://github.com/ernestprovo23/mcp-warden/.github/workflows/integrity-gate.yml@${GITHUB_REF}" \
"https://github.com/DataScience-EngineeringExperts/mcp-warden/.github/workflows/integrity-gate.yml@${GITHUB_REF}" \
--certificate-oidc-issuer "https://token.actions.githubusercontent.com"

# NEGATIVE PROOF (real crypto): tamper the signed lock's overall_digest and
Expand All @@ -185,7 +185,7 @@ jobs:
--lock e2e.warden.lock \
--verify \
--certificate-identity \
"https://github.com/ernestprovo23/mcp-warden/.github/workflows/integrity-gate.yml@${GITHUB_REF}" \
"https://github.com/DataScience-EngineeringExperts/mcp-warden/.github/workflows/integrity-gate.yml@${GITHUB_REF}" \
--certificate-oidc-issuer "https://token.actions.githubusercontent.com" ; then
echo "ERROR: real sigstore verified a TAMPERED overall_digest — fail closed is broken"
exit 1
Expand Down
18 changes: 14 additions & 4 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,15 +22,25 @@ CI. The v0.3 `guard` proxy adds deterministic runtime *result* inspection
**Explicitly out of scope in v1 (documented post-1.0 roadmap):**

- **HTTP/SSE transport** — v1 is stdio-only; HTTP/SSE is the headline v1.x item (#9).
- **DNS-name resolution** of exfil-domain matches (raw-IP-literal handling is the D6
work item) and **prompt-injection default-block** (stays opt-in / MONITOR until
field false-positive data justifies blocking by default).
- **Prompt-injection default-block** — stays opt-in / MONITOR until field
false-positive data justifies blocking by default.
- Behavioral-attack defense (`T-BEHAVE`), full agent-firewall mediation, and any
compliance/regulatory claim. See `docs/THREAT_MODEL.md` for the limits.

## [Unreleased]

_No unreleased changes yet._
### Added

- **Runtime DNS resolution SSRF bypass detection (`WRD-RES-EXFIL-DNS-SSRF`)** (#11):
the `guard` proxy now resolves URL hostnames from `tools/call` results at runtime
and blocks (error-replace) when any resolved IP falls in a deny range
(`SSRF_NETWORKS` — link-local, loopback, RFC1918, IPv6 ULA/loopback/link-local).
This closes the bypass where `WRD-RES-EXFIL-IP-LITERAL` could not fire because the
result contained a DNS hostname (e.g. `169.254.169.254.nip.io`) rather than a raw
IP literal. Resolution is bounded by 1 s across all hostnames per result frame,
fail-open (any DNS error = no hit), and opt-out via `--no-block-exfil-dns-ssrf`
(or `--no-block-deterministic`). Raw IP literals and the offline `inspect` command
are unchanged. New module `res_dns.py`; 23 new tests.

## [1.0.1] — 2026-06-13

Expand Down
84 changes: 81 additions & 3 deletions src/mcp_warden/capture.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
"""MCP stdio capture client.
"""MCP capture client — stdio and HTTP/SSE transports.

Spawns the target MCP server **over stdio as an argv array, never via a shell**
(WARDEN_LOCK_SCHEMA.md §10.4), runs ``initialize`` + ``tools/list`` +
``resources/list`` + ``prompts/list``, and captures the declared surface.
(WARDEN_LOCK_SCHEMA.md §10.4), *or* connects to an already-running server over
HTTP/SSE (Streamable HTTP), then runs ``initialize`` + ``tools/list`` +
``resources/list`` + ``prompts/list`` and captures the declared surface.

A server that hangs, crashes, or exits nonzero must produce a clear
``CaptureError``, not a traceback.
Expand All @@ -16,6 +17,7 @@
import anyio
from mcp import ClientSession, StdioServerParameters
from mcp.client.stdio import stdio_client
from mcp.client.streamable_http import streamable_http_client

from .models import (
CapturedPrompt,
Expand Down Expand Up @@ -195,3 +197,79 @@ def capture_surface_sync(
CaptureError: On any capture failure (see :func:`capture_surface`).
"""
return anyio.run(capture_surface, command, args, timeout_s)


async def _capture_http_async(url: str, timeout_s: float) -> CapturedSurface:
"""Inner async HTTP/SSE capture; wrapped with a timeout by :func:`capture_surface_http`."""
async with streamable_http_client(url) as (read_stream, write_stream, _get_session_id):
async with ClientSession(read_stream, write_stream) as session:
init_result = await session.initialize()
protocol_version = str(getattr(init_result, "protocolVersion", "") or "")

tools = await _list_tools(session)
resources = await _list_resources(session)
prompts = await _list_prompts(session)

return CapturedSurface(
url=url,
protocol_version=protocol_version,
tools=tools,
resources=resources,
prompts=prompts,
)


async def capture_surface_http(
url: str,
timeout_s: float = DEFAULT_TIMEOUT_S,
) -> CapturedSurface:
"""Connect to a running MCP server over HTTP/SSE and capture its declared surface.

Connects to ``url`` using the Streamable HTTP transport (MCP SDK
``streamable_http_client``). The server must already be running and
reachable; no process is spawned.

Args:
url: HTTP/HTTPS endpoint of the MCP server (e.g. ``https://example.com/mcp``).
timeout_s: Wall-clock timeout for the whole handshake.

Returns:
The :class:`CapturedSurface` with ``url`` set and ``command``/``args`` empty.

Raises:
CaptureError: On timeout, connection error, or MCP handshake failure.
"""
logger.debug("connecting to MCP server over HTTP/SSE: url=%r", url)
try:
with anyio.fail_after(timeout_s):
return await _capture_http_async(url, timeout_s)
except TimeoutError as exc:
raise CaptureError(
f"MCP server at '{url}' did not complete the handshake within {timeout_s:.0f}s "
f"(it may be unreachable or hung)."
) from exc
except CaptureError:
raise
except Exception as exc:
raise CaptureError(
f"Failed to capture MCP server at '{url}': {type(exc).__name__}: {exc}"
) from exc


def capture_surface_http_sync(
url: str,
timeout_s: float = DEFAULT_TIMEOUT_S,
) -> CapturedSurface:
"""Synchronous wrapper around :func:`capture_surface_http` for the CLI.

Args:
url: HTTP/HTTPS endpoint URL.
timeout_s: Wall-clock timeout.

Returns:
The captured surface.

Raises:
CaptureError: On any capture failure.
"""
return anyio.run(capture_surface_http, url, timeout_s)
23 changes: 17 additions & 6 deletions src/mcp_warden/check_core.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,13 @@
(issue: "a hook that disagrees with CI is worse than no hook").

The sequence here mirrors what ``check`` has always done:
``read_lock`` -> ``capture_surface_sync`` -> ``run_checks`` -> ``build_lock``
``read_lock`` -> capture -> ``run_checks`` -> ``build_lock``
(an in-memory CURRENT lock, never persisted) -> ``compute_drift``.

Capture routing:
- stdio (command + args): :func:`~mcp_warden.capture.capture_surface_sync`
- HTTP/SSE (url): :func:`~mcp_warden.capture.capture_surface_http_sync`

# INTERNAL STABILITY NOTE: the pre-commit wrapper (precommit.py) depends on this
# function's signature and exception contract (CaptureError for spawn/timeout
# failures; FileNotFoundError / ValueError for a missing/invalid lock). Do not
Expand All @@ -26,7 +30,7 @@
from dataclasses import dataclass
from pathlib import Path

from .capture import capture_surface_sync
from .capture import capture_surface_http_sync, capture_surface_sync
from .checks import run_checks
from .drift import DriftItem, compute_drift
from .lockfile import build_lock, read_lock
Expand All @@ -50,6 +54,8 @@ def run_check_full(
args: list[str],
lock_path: Path,
timeout_s: float,
*,
url: str | None = None,
) -> CheckResult:
"""Run the full check verdict path: read lock -> capture -> checks -> drift.

Expand All @@ -58,21 +64,26 @@ def run_check_full(
calls the thinner :func:`run_check` which discards ``findings``.

Args:
command: The MCP server launch command (argv[0]).
args: The remaining server launch argv.
command: The MCP server launch command (argv[0]). Ignored when ``url`` is set.
args: The remaining server launch argv. Ignored when ``url`` is set.
lock_path: Path to the baseline ``warden.lock``.
timeout_s: Capture timeout in seconds.
url: When set, connect to this HTTP/SSE endpoint instead of spawning a
subprocess. Mutually exclusive with meaningful ``command``/``args``.

Returns:
A :class:`CheckResult` (``drift`` empty == clean).

Raises:
FileNotFoundError: The lock file does not exist.
ValueError: The lock file is invalid JSON or fails schema validation.
CaptureError: The server could not be spawned or did not respond in time.
CaptureError: The server could not be spawned/reached or did not respond in time.
"""
baseline = read_lock(lock_path)
surface = capture_surface_sync(command, args, timeout_s=timeout_s)
if url:
surface = capture_surface_http_sync(url, timeout_s=timeout_s)
else:
surface = capture_surface_sync(command, args, timeout_s=timeout_s)
findings = run_checks(surface)
# build_lock constructs an IN-MEMORY current lock for diffing only; it is
# never written to disk on the check path.
Expand Down
48 changes: 40 additions & 8 deletions src/mcp_warden/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@
from rich.table import Table

from . import __version__
from .capture import CaptureError, capture_surface_sync
from .capture import CaptureError, capture_surface_http_sync, capture_surface_sync
from .check_core import run_check_full
from .checks import run_checks
from .cli_diff import register as register_diff_command
Expand Down Expand Up @@ -94,7 +94,9 @@ def _split_server_cmd(server_cmd: list[str]) -> tuple[str, list[str]]:

@app.command()
def pin(
server_cmd: list[str] = typer.Argument(..., help="MCP server launch argv (e.g. node ./server.js)"),
server_cmd: Optional[list[str]] = typer.Argument(
None, help="MCP server launch argv (e.g. node ./server.js); omit when using --url"
),
lock: Path = typer.Option(Path(DEFAULT_LOCK_NAME), "--lock", help="Output lock path"),
approve: bool = typer.Option(False, "--approve", help="Record a human approval attestation"),
approver: Optional[str] = typer.Option(None, "--approver", help="Approver identity (or WARDEN_APPROVER env)"),
Expand All @@ -105,16 +107,32 @@ def pin(
identity_token: Optional[str] = typer.Option(
None, "--identity-token", help="Explicit OIDC token for signing (default: ambient/CI OIDC)"
),
url: Optional[str] = typer.Option(
None, "--url", help="HTTP/SSE endpoint of a running MCP server (mutually exclusive with server-cmd)"
),
) -> None:
"""Pin an MCP server's declared surface into ``warden.lock`` (TOFU baseline)."""
command, args = _split_server_cmd(server_cmd)
"""Pin an MCP server's declared surface into ``warden.lock`` (TOFU baseline).

Pass either a server launch command (stdio) or ``--url`` (HTTP/SSE).
"""
if url and server_cmd:
err_console.print("[red]error:[/red] --url and server-cmd are mutually exclusive")
raise typer.Exit(code=2)
if not url and not server_cmd:
err_console.print("[red]error:[/red] provide either a server command or --url")
raise typer.Exit(code=2)

approver_id = approver or os.environ.get("WARDEN_APPROVER")
if approve and not approver_id:
err_console.print("[red]error:[/red] --approve requires --approver <id> or WARDEN_APPROVER env")
raise typer.Exit(code=2)

try:
surface = capture_surface_sync(command, args, timeout_s=timeout)
if url:
surface = capture_surface_http_sync(url, timeout_s=timeout)
else:
command, args = _split_server_cmd(server_cmd)
surface = capture_surface_sync(command, args, timeout_s=timeout)
except CaptureError as exc:
err_console.print(f"[red]capture failed:[/red] {exc}")
raise typer.Exit(code=2) from exc
Expand Down Expand Up @@ -146,7 +164,7 @@ def pin(
@app.command()
def check(
server_cmd: Optional[list[str]] = typer.Argument(
None, help="MCP server launch argv (must match the pinned launch); omit with --verify"
None, help="MCP server launch argv (must match the pinned launch); omit with --url or --verify"
),
lock: Path = typer.Option(Path(DEFAULT_LOCK_NAME), "--lock", help="Baseline lock path"),
json_out: bool = typer.Option(False, "--json", help="Emit findings+drift as JSONL to stdout"),
Expand All @@ -164,9 +182,13 @@ def check(
offline_bundle: Optional[Path] = typer.Option(
None, "--offline-bundle", help="Explicit bundle path (default: <lockname>.sigstore next to the lock)"
),
url: Optional[str] = typer.Option(
None, "--url", help="HTTP/SSE endpoint of a running MCP server (mutually exclusive with server-cmd)"
),
) -> None:
"""Re-capture and verify a server against ``warden.lock``; fail on drift.

Pass either a server launch command (stdio) or ``--url`` (HTTP/SSE).
With ``--verify`` the command ALSO/INSTEAD verifies the lock's Sigstore
signature (a no-server-spawn cryptographic check). ``--verify`` requires
``--certificate-identity`` and ``--certificate-oidc-issuer`` and exits 0 only
Expand All @@ -179,12 +201,22 @@ def check(
)
return

command, args = _split_server_cmd(server_cmd)
if url and server_cmd:
err_console.print("[red]error:[/red] --url and server-cmd are mutually exclusive")
raise typer.Exit(code=2)
if not url and not server_cmd:
err_console.print("[red]error:[/red] provide either a server command or --url")
raise typer.Exit(code=2)

if url:
command, args = "", []
else:
command, args = _split_server_cmd(server_cmd)

try:
# Single source of truth shared with the pre-commit wrapper (precommit.py)
# so a local hook and CI can never disagree on a drift verdict.
result = run_check_full(command, args, lock, timeout_s=timeout)
result = run_check_full(command, args, lock, timeout_s=timeout, url=url)
except (FileNotFoundError, ValueError) as exc:
err_console.print(f"[red]error:[/red] {exc}")
raise typer.Exit(code=2) from exc
Expand Down
2 changes: 2 additions & 0 deletions src/mcp_warden/cli_guard.py
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,7 @@ def guard(
no_block_exfil_domain: bool = typer.Option(False, "--no-block-exfil-domain", help="Demote WRD-RES-EXFIL-DOMAIN to shadow"),
allow_exfil_domain: bool = typer.Option(False, "--allow-exfil-domain", help="Alias of --no-block-exfil-domain"),
no_block_exfil_ip_literal: bool = typer.Option(False, "--no-block-exfil-ip-literal", help="Demote WRD-RES-EXFIL-IP-LITERAL to shadow"),
no_block_exfil_dns_ssrf: bool = typer.Option(False, "--no-block-exfil-dns-ssrf", help="Demote WRD-RES-EXFIL-DNS-SSRF to shadow (disables runtime DNS resolution)"),
no_block_list_changed: bool = typer.Option(False, "--no-block-list-changed", help="Demote tools/list_changed gate to shadow"),
no_block_policy: bool = typer.Option(False, "--no-block-policy", help="Demote argument-policy deny to shadow"),
no_block_deterministic: bool = typer.Option(False, "--no-block-deterministic", help="Demote the WHOLE deterministic tier + both gates"),
Expand Down Expand Up @@ -177,6 +178,7 @@ def guard(
no_block_secret_echo=no_block_secret_echo or no_block_deterministic,
no_block_exfil_domain=no_block_exfil_domain or allow_exfil_domain or no_block_deterministic,
no_block_exfil_ip_literal=no_block_exfil_ip_literal or no_block_deterministic,
no_block_exfil_dns_ssrf=no_block_exfil_dns_ssrf or no_block_deterministic,
no_block_list_changed=no_block_list_changed or no_block_deterministic,
no_block_policy=no_block_policy or no_block_deterministic,
block_inject_phrase=block_inject_phrase,
Expand Down
2 changes: 2 additions & 0 deletions src/mcp_warden/guard_loop.py
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,7 @@ class GuardConfig:
no_block_secret_echo: bool = False
no_block_exfil_domain: bool = False
no_block_exfil_ip_literal: bool = False
no_block_exfil_dns_ssrf: bool = False
no_block_list_changed: bool = False
no_block_policy: bool = False
block_inject_phrase: bool = False
Expand Down Expand Up @@ -116,6 +117,7 @@ class GuardConfig:
"WRD-RES-SECRET-ECHO": "no_block_secret_echo",
"WRD-RES-EXFIL-DOMAIN": "no_block_exfil_domain",
"WRD-RES-EXFIL-IP-LITERAL": "no_block_exfil_ip_literal",
"WRD-RES-EXFIL-DNS-SSRF": "no_block_exfil_dns_ssrf",
}

def category_enabled(self, rule_id: str) -> bool:
Expand Down
Loading
Loading