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
16 changes: 16 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,22 @@ Streamable HTTP; the v0.3 `guard` proxy adds deterministic runtime *result* insp

## [Unreleased]

### Changed

- **`check --against-community` follow-ups from security re-verification (DSE-1528).**
New **`--require-consensus`**: `WRD-CONSENSUS-NOVEL` and `-INSUFFICIENT` become `high`
and **exit 1**, so a CI job that expects a package to be attested cannot pass because
the corpus — or a fork of it — simply withholds the entry (evidence suppression is
now documented as the residual limit of a git corpus, with `--corpus-ref` pinning as
the other half of the control). `git` is resolved to an absolute path once per
process and must be **≥ 2.14.1** (older/missing is `UNREACHABLE`; the ssh option-
injection defenses assume that release). `GIT_SSH_COMMAND` is forwarded only to the
clone of an `ssh://`/`git@` corpus, never for https and never to checkout/rev-parse.
The non-injective coordinate→directory mapping (`@org/name` vs `@org__name`) is
documented as fail-closed; a redundant pre-read of each corpus lock was removed (the
size cap is enforced by `stat()`); the verifier docstring now states precisely which
coordinate the statement is built from.

### Fixed

- **`doctor` follow-ups from the security review of #98 (DSE-1529).** `#servers` is no
Expand Down
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -367,7 +367,7 @@ run the gate only on push:
| `mcp-warden deploy-gate --policy F --evidence F [--json] [--sarif F]` | **(v1.2)** Fail-closed CI gate for agent deployments: verifies declared eval thresholds, required guardrails, a budget/quota, and a human-approval receipt. Adjudicates evidence — it does **not** run evals. See [`docs/AGENT_GATES.md`](docs/AGENT_GATES.md) | 0 only when every control is satisfied; 1 on any gate finding; 2 on unreadable/malformed input (fail closed) |
| `mcp-warden doctor [--config F]... [--no-discover] [--json] [--sarif F] [--pin [--yes]]` | **(v1.2)** Zero-config posture scan: discovers every MCP client config on the machine (Claude Code / Claude Desktop / Cursor / VS Code / Windsurf / Codex), runs `auth audit` + `WRD-SUP-*` launch checks per server, reports servers with no `warden.lock` (`WRD-DOCTOR-NO-LOCK`) and prints the exact `pin` command. Static by default; `--pin` is the opt-in that spawns, and refuses non-interactively without `--yes`. See [`docs/DOCTOR.md`](docs/DOCTOR.md) | 0 clean or no configs; 1 on any finding; 2 on unreadable/malformed config or a `--pin` failure (fail closed) |
| `mcp-warden auth audit <config...> [--json] [--sarif F]` | **(v1.2)** Static MCP auth-posture audit over client/server config: remote endpoints without auth, cleartext `http://`, credential literals committed into config. No server spawn, no network. See [`docs/AGENT_GATES.md`](docs/AGENT_GATES.md) | 0 clean; 1 on any finding; 2 on read/parse error (fail closed) |
| `mcp-warden check <server-cmd...> \| --url URL --against-community --corpus P\|URL --attester ID=IDENTITY@ISSUER… \| --attesters-file F [--corpus-ref SHA] [--coordinate C] [--min-attesters N]` | **(DSE-1515, phase 1)** Also compare the captured surface to Sigstore-signed attestations by attesters **you pin** (`--attester`/`--attesters-file` is required — the corpus's own list is discovery, never the trust root). The signature binds identity, `overall_digest` **and** the package coordinate (`npm:`/`pypi:` name@version, inferred from `npx`/`uvx`/`pipx run` argv or given explicitly). Only `https://`/`ssh://`/`git@` corpus URLs; unverifiable/unreachable/unpinnable/unpinned-trust is exit 2, never a skip. See [`docs/COMMUNITY_CORPUS.md`](docs/COMMUNITY_CORPUS.md) | **1 on `WRD-CONSENSUS-MISMATCH`/`-SPLIT`** (composes with drift); 0 on match, `-NOVEL` or `-INSUFFICIENT`; 2 fail closed |
| `mcp-warden check <server-cmd...> \| --url URL --against-community --corpus P\|URL --attester ID=IDENTITY@ISSUER… \| --attesters-file F [--corpus-ref SHA] [--coordinate C] [--min-attesters N] [--require-consensus]` | **(DSE-1515, phase 1)** Also compare the captured surface to Sigstore-signed attestations by attesters **you pin** (`--attester`/`--attesters-file` is required — the corpus's own list is discovery, never the trust root). The signature binds identity, `overall_digest` **and** the package coordinate (`npm:`/`pypi:` name@version, inferred from `npx`/`uvx`/`pipx run` argv or given explicitly). Only `https://`/`ssh://`/`git@` corpus URLs; unverifiable/unreachable/unpinnable/unpinned-trust is exit 2, never a skip. See [`docs/COMMUNITY_CORPUS.md`](docs/COMMUNITY_CORPUS.md) | **1 on `WRD-CONSENSUS-MISMATCH`/`-SPLIT`** (composes with drift); 0 on match, `-NOVEL` or `-INSUFFICIENT` (**1** for those two with `--require-consensus`); 2 fail closed |
| `mcp-warden-precommit [--lock F] [--timeout N] [--strict] -- <server-cmd...>` | **(v0.3)** pre-commit hook entry point (see [pre-commit hook](#pre-commit-hook--the-local-pre-ci-gate)). Runs the same check verdict path; check-only (never pins, never writes the lock) | 0 clean / **1 drift** / 2 config error; server-unavailable → 0+warning (non-strict) or 2 (`--strict`) |

For stdio, `<server-cmd...>` is passed to the OS as an **argv array, never through a
Expand Down Expand Up @@ -437,6 +437,8 @@ mcp-warden check npx -y @foo/server@1.2.3 --lock warden.lock \
# -> WRD-CONSENSUS-SPLIT: attesters disagree — corpus or upstream may be compromised (exit 1)
# -> WRD-CONSENSUS-NOVEL: no trusted attestation exists yet (exit 0)
# -> WRD-CONSENSUS-INSUFFICIENT: fewer than --min-attesters (default 2) agree (exit 0)
# add --require-consensus in CI that EXPECTS the package to be attested: NOVEL and
# INSUFFICIENT then exit 1 — a corpus that withholds an entry cannot turn a MISMATCH into a pass
```

The trust root is yours: the corpus's `attesters.json` is only a discovery list, and
Expand Down
58 changes: 44 additions & 14 deletions docs/COMMUNITY_CORPUS.md
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,19 @@ A poisoned server that is poisoned *identically for everyone* matches consensus.
is the residual risk and it is deliberate: the corpus turns a targeted attack into a
loud one; it does not review content. Pair it with a static tool-poisoning scanner.

**Evidence suppression — what a corpus can hide.** The corpus can prove an entry is
genuine; it cannot prove an entry *does not exist*. If the directory for your
coordinate is absent the verdict is `WRD-CONSENSUS-NOVEL` (advisory, exit 0), and a
hostile or forked corpus can turn an exit-1 `MISMATCH` into that pass simply by
withholding the entries. There is no authenticated "no entry" proof in a git tree.
Two controls close the gap, and you should use both in CI:

- pin `--corpus-ref` to a commit you (or someone you trust) have audited, so the
operator cannot swap the tree under you between runs;
- pass **`--require-consensus`** wherever the coordinate is *expected* to be
attested — then `NOVEL` and `INSUFFICIENT` are `high` and **exit 1**, and a run
that compared nothing can no longer be green.

**Network.** Verifying a Sigstore bundle initialises the production trust root
(`Verifier.production()`, TUF). That needs network on first use (cached afterwards);
an air-gapped runner gets `WRD-CONSENSUS-UNVERIFIABLE` (exit 2) on every run unless
Expand Down Expand Up @@ -105,6 +118,13 @@ locks/<ecosystem>/<segment>/<version>/<attester-id>.lock.sigstore
- `segment` is the package name with `/` replaced by `__`, so the scoped npm package
`@example/clean` lives under `locks/npm/@example__clean/`. PyPI names are PEP 503
normalised (`Mcp_Server.Foo` → `mcp-server-foo`).
**This mapping is not injective**: an unscoped npm package literally named
`@example__clean`, or a name differing only by case on a case-insensitive
filesystem, maps to the *same* directory. That collision **fails closed** — the
signed statement carries the exact coordinate, so an entry from the other package
fails verification (`UNVERIFIABLE`) rather than being counted — but it means such a
package cannot share a corpus with its twin. Percent-encoding the segment would
lift the restriction if a real `__`-named package ever matters.
- `<attester-id>` is a `[A-Za-z0-9._-]{1,64}` id that **must** appear in
`attesters.json` (an entry from an undeclared attester rejects the coordinate) and
in your pin (otherwise it is ignored).
Expand Down Expand Up @@ -157,8 +177,8 @@ server is spawned.
| Rule | Severity | Meaning | Exit |
|---|---|---|---|
| *(match)* | — | ≥ `--min-attesters` trusted attesters saw your surface; one stderr line | 0 |
| `WRD-CONSENSUS-INSUFFICIENT` | low | fewer trusted attesters agree than `--min-attesters` | 0 (finding is emitted) |
| `WRD-CONSENSUS-NOVEL` | low | no trusted attestation exists for the coordinate | 0 (finding is emitted) |
| `WRD-CONSENSUS-INSUFFICIENT` | low (high with `--require-consensus`) | fewer trusted attesters agree than `--min-attesters` | 0 — **1 with `--require-consensus`** |
| `WRD-CONSENSUS-NOVEL` | low (high with `--require-consensus`) | no trusted attestation exists for the coordinate | 0 — **1 with `--require-consensus`** |
| `WRD-CONSENSUS-MISMATCH` | high | your surface differs from **every** trusted attested digest | **1** |
| `WRD-CONSENSUS-SPLIT` | high | trusted attesters disagree among themselves — reported even if you match one | **1** |
| `WRD-CONSENSUS-UNPINNED-TRUST` | — | no consumer pin, duplicate id, or pin/corpus identity divergence | **2** |
Expand All @@ -168,7 +188,8 @@ server is spawned.
| `WRD-CONSENSUS-UNREACHABLE` | — | corpus path/URL unreachable or not allowed, clone/checkout failed, `--corpus-ref` missing or not `HEAD` | **2** |

Findings go through the same SARIF and `--json` JSONL emitters as static checks and
drift; the exit code composes with drift (`drift or MISMATCH or SPLIT → 1`). With the
drift; the exit code composes with drift (`drift or MISMATCH or SPLIT → 1`, and with
`--require-consensus` also `NOVEL or INSUFFICIENT → 1`). With the
flag absent, `check` is byte-for-byte unchanged — and any community option given
*without* `--against-community` is exit 2, never a silent no-op.

Expand All @@ -181,22 +202,30 @@ mcp-warden check npx -y @foo/server@1.2.3 --lock warden.lock \
--attester alice=https://github.com/…/attest.yml@refs/heads/main@https://token.actions.githubusercontent.com \
--attester bob=https://github.com/…/attest.yml@refs/heads/main@https://token.actions.githubusercontent.com

# against a git URL, pinned to an exact corpus commit, trust root from a file you keep
# against a git URL, pinned to an exact corpus commit, trust root from a file you keep;
# --require-consensus because CI expects this package to be attested (NOVEL would be a red flag)
mcp-warden check node ./build/index.js --lock warden.lock \
--against-community --corpus https://github.com/<org>/mcp-warden-locks.git \
--corpus-ref 0123456789abcdef0123456789abcdef01234567 \
--coordinate npm:@foo/server@1.2.3 --attesters-file trusted-attesters.json
--coordinate npm:@foo/server@1.2.3 --attesters-file trusted-attesters.json \
--require-consensus
```

`--attester` is `<id>=<certificate_identity>@<oidc_issuer>`; the identity itself
contains `@`, so the issuer is split at the last one. A URL must start with `https://`,
`ssh://` or `git@` — `ext::`, `file://`, `git://`, scp shorthand and anything starting
with `-` are refused before git runs. The clone is `git -c protocol.allow=never -c
protocol.https.allow=always -c protocol.ssh.allow=always -c core.hooksPath=/dev/null -c
core.symlinks=false -c submodule.recurse=false clone --no-checkout -- <url>` with a
scrubbed environment (`PATH`, `HOME`, `SSH_AUTH_SOCK`, `GIT_SSH_COMMAND`,
`GIT_TERMINAL_PROMPT=0`), into a temporary directory removed afterwards; the
checkout's `HEAD` must equal `--corpus-ref`. Requires `mcp-warden[sigstore]`.
core.symlinks=false -c submodule.recurse=false -c credential.helper= -c core.askPass=
clone --no-checkout -- <url>` (the last two disable any credential helper or askpass
program a `~/.gitconfig` would otherwise make git exec) with a
scrubbed environment (`PATH`, `HOME`, `SSH_AUTH_SOCK`, `GIT_TERMINAL_PROMPT=0`;
`GIT_SSH_COMMAND` is forwarded **only** to the clone of an `ssh://`/`git@` source,
never for https and never to checkout/rev-parse), into a temporary directory removed
afterwards; the checkout's `HEAD` must equal `--corpus-ref`. `git` is resolved to an
absolute path once per process and must be **≥ 2.14.1** (the release that refuses an
ssh host starting with `-`, which the option-injection defenses above rely on) —
older or missing git is `UNREACHABLE`. Requires `mcp-warden[sigstore]`.

### Producing a corpus entry

Expand All @@ -208,12 +237,13 @@ mcp-warden check --verify --lock alice.lock --coordinate npm:@foo/server@1.2.3 \
--certificate-identity … --certificate-oidc-issuer …
```

## 8. Phase 2 — the live corpus (pending)
## 8. Phase 2 — the live corpus

Planned, not shipped: a public append-only repo `mcp-warden-locks` (PR-only merges; a
required check rejects any modify/delete under `locks/`) fed by a nightly attester
that reads the official MCP registry, spawns each top-N server in a sandbox, and
opens a PR of `pin --sign --coordinate` outputs containing only *new* paths.
Live at <https://github.com/DataScience-EngineeringExperts/mcp-warden-locks>: a public
append-only repo (a required check rejects any modify/delete under `locks/`) fed by a
nightly attester that spawns each target package in a sandbox and lands
`pin --sign --coordinate` outputs containing only *new* paths. Its README carries the
current attester identity to pin and the consumer invocation.

**Sandbox contract an attester must meet** (so a malicious server cannot poison the
attester itself): ephemeral runner, non-root, network egress cut after package
Expand Down
7 changes: 6 additions & 1 deletion src/mcp_warden/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -234,6 +234,10 @@ def check(
None, "--min-attesters", min=1,
help=f"Trusted attesters that must agree for a match (default {DEFAULT_MIN_ATTESTERS}; fewer is WRD-CONSENSUS-INSUFFICIENT)",
),
require_consensus: bool = typer.Option(
False, "--require-consensus",
help="Strict: WRD-CONSENSUS-NOVEL / -INSUFFICIENT also fail (exit 1) — for CI that expects the coordinate to be attested",
),
) -> None:
"""Re-capture and verify a server against ``warden.lock``; fail on drift.

Expand All @@ -247,7 +251,7 @@ def check(
consensus_validate_flags(
against_community=against_community, verify=verify, corpus=corpus, corpus_ref=corpus_ref,
coordinate=coordinate, attester=attester or [], attesters_file=attesters_file,
min_attesters=min_attesters, err_console=err_console,
min_attesters=min_attesters, require_consensus=require_consensus, err_console=err_console,
)
if verify:
coord_text = None
Expand Down Expand Up @@ -303,6 +307,7 @@ def check(
verdict = consensus_adjudicate(
result.surface_digest, coord, corpus or "", corpus_ref, pin,
min_attesters if min_attesters is not None else DEFAULT_MIN_ATTESTERS, err_console,
require_consensus=require_consensus,
)
findings = [*findings, *verdict.findings]
blocking = verdict.blocking
Expand Down
18 changes: 15 additions & 3 deletions src/mcp_warden/cli_corpus.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
RULE_UNVERIFIABLE,
ConsensusResult,
CorpusError,
_is_url,
run_consensus,
)
from .corpus_coordinate import Coordinate, resolve_coordinate
Expand All @@ -38,7 +39,7 @@ def _fail(err_console: Console, exc: CorpusError) -> NoReturn:
def validate_flags(
*, against_community: bool, verify: bool, corpus: str | None, corpus_ref: str | None,
coordinate: str | None, attester: list[str], attesters_file: Path | None,
min_attesters: int | None, err_console: Console,
min_attesters: int | None, require_consensus: bool = False, err_console: Console,
) -> None:
"""A community option without ``--against-community`` is a mistake, not a no-op (CSO L1)."""
if against_community and verify:
Expand All @@ -55,6 +56,7 @@ def validate_flags(
name for name, val in (
("--corpus", corpus), ("--corpus-ref", corpus_ref), ("--attester", attester or None),
("--attesters-file", attesters_file), ("--min-attesters", min_attesters),
("--require-consensus", require_consensus or None),
) if val is not None
]
if coordinate is not None and not verify: # --coordinate is also valid with --verify (v2 statements)
Expand Down Expand Up @@ -89,11 +91,21 @@ def preflight(

def adjudicate(
observed_digest: str, coord: Coordinate, corpus: str, corpus_ref: str | None,
pin: dict[str, Attester], min_attesters: int, err_console: Console,
pin: dict[str, Attester], min_attesters: int, err_console: Console, *, require_consensus: bool = False,
) -> ConsensusResult:
"""Run the consensus verdict; exit 2 on any fail-closed corpus condition."""
if require_consensus and corpus_ref is None and not _is_url(corpus):
# CSO #106 L3: strict mode only closes the evidence-suppression gap together
# with a pinned tree; an unpinned checkout can be swapped between runs.
err_console.print(
"[yellow]warning:[/yellow] --require-consensus against an unpinned local corpus "
f"({escape(corpus)}): pass --corpus-ref <sha> so the tree cannot change between runs",
soft_wrap=True,
)
try:
result = run_consensus(observed_digest, coord, corpus, corpus_ref, pin, min_attesters)
result = run_consensus(
observed_digest, coord, corpus, corpus_ref, pin, min_attesters, require_consensus=require_consensus
)
except CorpusError as exc:
_fail(err_console, exc)
except Exception as exc: # noqa: BLE001 - a hostile corpus must not turn into exit 1 + traceback
Expand Down
Loading
Loading