From b46446e01b6b906112b0f542ad64a92a09345592 Mon Sep 17 00:00:00 2001 From: DSE Builder Date: Fri, 4 Sep 2026 21:23:26 +0000 Subject: [PATCH 1/2] fix(corpus): --require-consensus, git floor, scoped GIT_SSH_COMMAND (DSE-1528) CSO re-verify follow-ups on check --against-community: - --require-consensus makes WRD-CONSENSUS-NOVEL / -INSUFFICIENT high + exit 1 so a corpus that withholds an entry cannot turn a MISMATCH into a pass; evidence suppression documented as the residual limit of a git corpus - git resolved to an absolute path once per process; refuse git < 2.14.1 - GIT_SSH_COMMAND forwarded only to the clone of an ssh:// or git@ source - docstring: statement is built from the coordinate that determines the directory - non-injective coordinate -> directory mapping documented as fail-closed - redundant pre-read of each corpus lock removed (stat() enforces the cap) --- CHANGELOG.md | 16 ++++ README.md | 4 +- docs/COMMUNITY_CORPUS.md | 54 ++++++++++---- src/mcp_warden/cli.py | 7 +- src/mcp_warden/cli_corpus.py | 9 ++- src/mcp_warden/corpus.py | 91 +++++++++++++++++++---- src/mcp_warden/corpus_verify.py | 52 +++++++++---- tests/test_corpus.py | 126 +++++++++++++++++++++++++++++--- tests/test_corpus_cli.py | 21 +++++- 9 files changed, 321 insertions(+), 59 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9ffc364..83c39fb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. + ### Added - **`doctor` — zero-config MCP posture scan (DSE-1516).** One command, no arguments: diff --git a/README.md b/README.md index 8e1d2ad..7207706 100644 --- a/README.md +++ b/README.md @@ -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 [--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 \| --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 \| --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] -- ` | **(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, `` is passed to the OS as an **argv array, never through a @@ -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 diff --git a/docs/COMMUNITY_CORPUS.md b/docs/COMMUNITY_CORPUS.md index 916c8db..a0d22d5 100644 --- a/docs/COMMUNITY_CORPUS.md +++ b/docs/COMMUNITY_CORPUS.md @@ -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 @@ -105,6 +118,13 @@ locks////.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. - `` 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). @@ -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** | @@ -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. @@ -181,11 +202,13 @@ 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//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 `=@`; the identity itself @@ -194,9 +217,13 @@ contains `@`, so the issuer is split at the last one. A URL must start with `htt 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 -- ` 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]`. +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 @@ -208,12 +235,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 : 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 diff --git a/src/mcp_warden/cli.py b/src/mcp_warden/cli.py index 1e82449..5c0159a 100644 --- a/src/mcp_warden/cli.py +++ b/src/mcp_warden/cli.py @@ -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. @@ -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 @@ -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 diff --git a/src/mcp_warden/cli_corpus.py b/src/mcp_warden/cli_corpus.py index 8e2db11..ac1df00 100644 --- a/src/mcp_warden/cli_corpus.py +++ b/src/mcp_warden/cli_corpus.py @@ -38,7 +38,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: @@ -55,6 +55,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) @@ -89,11 +90,13 @@ 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.""" 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 diff --git a/src/mcp_warden/corpus.py b/src/mcp_warden/corpus.py index b3c8013..71bc1eb 100644 --- a/src/mcp_warden/corpus.py +++ b/src/mcp_warden/corpus.py @@ -77,13 +77,68 @@ ] #: Environment git may see. Everything else (proxies, GIT_* overrides, tokens) #: is dropped so the caller's environment cannot redirect the clone. -_GIT_ENV_KEYS = ("PATH", "HOME", "SSH_AUTH_SOCK", "GIT_SSH_COMMAND") +_GIT_ENV_KEYS = ("PATH", "HOME", "SSH_AUTH_SOCK") +#: Forwarded ONLY for ``ssh://`` / ``git@`` sources: on an https corpus it is +#: dead weight, and in CI with an attacker-writable env it is arbitrary exec +#: (CSO re-verify N4). ``SSH_AUTH_SOCK`` stays so deploy-key/agent auth works. +_GIT_ENV_SSH_KEYS = ("GIT_SSH_COMMAND",) +_SSH_PREFIXES: tuple[str, ...] = ("ssh://", "git@") + +#: Oldest git whose ``ssh://`` URL handling refuses a host starting with ``-`` +#: (CVE-2017-1000117); the option-injection defenses above assume it. +MIN_GIT_VERSION = (2, 14, 1) +_GIT_VERSION_RE = re.compile(r"git version (\d+)\.(\d+)(?:\.(\d+))?") + +#: Resolved once per process (CSO re-verify N4): an absolute path, so a later +#: ``PATH`` change cannot swap the binary under us. ``None`` = not yet resolved. +_GIT_BIN: str | None = None +_GIT_VERSION_OK: bool | None = None def _is_url(source: str) -> bool: return source.startswith(_URL_PREFIXES) +def _is_ssh(source: str) -> bool: + return source.startswith(_SSH_PREFIXES) + + +def _git_binary() -> str: + """Absolute path of ``git``, resolved once; UNREACHABLE when absent.""" + global _GIT_BIN + if _GIT_BIN is None: + found = shutil.which("git") + if found is None: + raise CorpusError(RULE_UNREACHABLE, "git is not installed; cannot fetch or inspect a corpus checkout") + _GIT_BIN = str(Path(found).resolve()) + return _GIT_BIN + + +def _ensure_git_version() -> None: + """Refuse a git older than :data:`MIN_GIT_VERSION` (checked once per process).""" + global _GIT_VERSION_OK + if _GIT_VERSION_OK: + return + try: + out = subprocess.run( + [_git_binary(), "--version"], check=True, capture_output=True, text=True, + timeout=_GIT_TIMEOUT_S, env=_git_env(False), + ).stdout + except (subprocess.SubprocessError, OSError) as exc: + raise CorpusError(RULE_UNREACHABLE, f"cannot determine git version ({type(exc).__name__})") from exc + m = _GIT_VERSION_RE.search(out) + if m is None: + raise CorpusError(RULE_UNREACHABLE, f"cannot parse git version from {out.strip()[:60]!r}") + have = tuple(int(x or 0) for x in m.groups()) + if have < MIN_GIT_VERSION: + raise CorpusError( + RULE_UNREACHABLE, + f"git {'.'.join(map(str, have))} is older than the required " + f"{'.'.join(map(str, MIN_GIT_VERSION))} (ssh URL option-injection defenses)", + ) + _GIT_VERSION_OK = True + + def _reject_unsafe_source(source: str) -> None: """Refuse anything git could read as a transport helper or an option.""" if source.startswith("-"): @@ -94,23 +149,28 @@ def _reject_unsafe_source(source: str) -> None: ) -def _git_env() -> dict[str, str]: - env = {k: os.environ[k] for k in _GIT_ENV_KEYS if k in os.environ} +def _git_env(ssh: bool) -> dict[str, str]: + keys = (*_GIT_ENV_KEYS, *(_GIT_ENV_SSH_KEYS if ssh else ())) + env = {k: os.environ[k] for k in keys if k in os.environ} env["GIT_TERMINAL_PROMPT"] = "0" return env def _git_argv(args: list[str]) -> list[str]: - """``git `` — the single place every git argv is built.""" - return ["git", *_GIT_CONFIG, *args] + """`` `` — the single place every git argv is built.""" + return [_git_binary(), *_GIT_CONFIG, *args] -def _git(args: list[str], cwd: Path | None = None) -> str: - """Run one git command as an argv list (never a shell); any failure is UNREACHABLE.""" +def _git(args: list[str], cwd: Path | None = None, *, ssh: bool = False) -> str: + """Run one git command as an argv list (never a shell); any failure is UNREACHABLE. + + ``ssh`` forwards ``GIT_SSH_COMMAND`` — only the clone of an ssh source needs it. + """ + _ensure_git_version() try: out = subprocess.run( _git_argv(args), cwd=cwd, check=True, capture_output=True, text=True, - timeout=_GIT_TIMEOUT_S, env=_git_env(), + timeout=_GIT_TIMEOUT_S, env=_git_env(ssh), ) except subprocess.TimeoutExpired as exc: raise CorpusError(RULE_UNREACHABLE, f"git {args[0]} timed out after {_GIT_TIMEOUT_S:.0f}s") from exc @@ -138,11 +198,9 @@ def fetch_corpus(source: str, ref: str | None) -> Iterator[Path]: if _is_url(source): if ref is None: raise CorpusError(RULE_UNREACHABLE, "--corpus-ref is required when --corpus is a URL") - if shutil.which("git") is None: - raise CorpusError(RULE_UNREACHABLE, "git is not installed; cannot fetch a corpus URL") with tempfile.TemporaryDirectory(prefix="warden-corpus-") as tmp: dest = Path(tmp) / "corpus" - _git(["clone", "--quiet", "--no-checkout", "--", source, str(dest)]) + _git(["clone", "--quiet", "--no-checkout", "--", source, str(dest)], ssh=_is_ssh(source)) _git(["checkout", "--quiet", ref], cwd=dest) if _head_sha(dest) != ref: raise CorpusError(RULE_UNREACHABLE, f"corpus HEAD is not {ref} after checkout") @@ -164,11 +222,14 @@ def run_consensus( ref: str | None, pin: dict[str, Attester], min_attesters: int = 2, + require_consensus: bool = False, ) -> ConsensusResult: """Fetch the corpus, verify every trusted entry for ``coord``, and adjudicate. ``pin`` is the consumer's trust root (:func:`corpus_trust.load_consumer_pin`). - Raises :class:`CorpusError` (exit 2 at the CLI) for any fail-closed condition. + ``require_consensus`` makes NOVEL / INSUFFICIENT blocking (see + :func:`corpus_verify.consensus`). Raises :class:`CorpusError` (exit 2 at the + CLI) for any fail-closed condition. """ if not signing._SIGSTORE_AVAILABLE: raise CorpusError(RULE_UNVERIFIABLE, "sigstore is not installed; run: pip install 'mcp-warden[sigstore]'") @@ -176,5 +237,7 @@ def run_consensus( declared = load_corpus_attesters(root) trusted, warnings = resolve_trust(declared, pin) attested = verified_digests(root, coord, trusted, declared) - result = consensus(observed_digest, coord, attested, min_attesters=min_attesters) - return ConsensusResult(result.coordinate, result.findings, result.matched, warnings) + result = consensus( + observed_digest, coord, attested, min_attesters=min_attesters, require_consensus=require_consensus + ) + return ConsensusResult(result.coordinate, result.findings, result.matched, warnings, strict=result.strict) diff --git a/src/mcp_warden/corpus_verify.py b/src/mcp_warden/corpus_verify.py index 11fa12a..508316b 100644 --- a/src/mcp_warden/corpus_verify.py +++ b/src/mcp_warden/corpus_verify.py @@ -7,14 +7,21 @@ ``SCHEMA_VERSION`` (CSO L2); 3. name an attester the CONSUMER pinned (:mod:`corpus_trust`); 4. carry a ``.lock.sigstore`` bundle that verifies, against that attester's - identity/issuer, over the v2 statement ``{digest, coordinate}`` where the - coordinate is derived from the DIRECTORY the entry sits in — so a genuine - signature relocated under another package fails (CSO C2); + identity/issuer, over the v2 statement ``{digest, coordinate}`` built from + the coordinate that DETERMINES the directory being read (the caller's + ``coord``, whose ``relative_dir()`` is the only place entries are looked + for) — so a genuine signature relocated under another package's directory + is checked against that package's coordinate and fails (CSO C2); 5. reproduce its signed ``overall_digest`` from its entries, so the derived launch-independent :func:`~mcp_warden.lockfile.surface_digest` is covered. One bad entry rejects the whole coordinate — a corpus that cannot be verified is not evidence. **Consensus attests observation, not safety.** + +What consensus canNOT see: an absent directory is indistinguishable from "nobody +attested this" — a hostile or forked corpus can turn an exit-1 MISMATCH into a +NOVEL by withholding entries. Pin ``--corpus-ref`` to an audited commit, and use +``--require-consensus`` where the coordinate is expected to be attested. """ from __future__ import annotations @@ -51,10 +58,13 @@ class ConsensusResult: findings: list[Finding] matched: list[str] = field(default_factory=list) # attester ids agreeing with observed warnings: list[str] = field(default_factory=list) # non-fatal notes for stderr + strict: bool = False # --require-consensus: NOVEL / INSUFFICIENT block too @property def blocking(self) -> bool: - return any(f.rule_id in (RULE_MISMATCH, RULE_SPLIT) for f in self.findings) + rules = (RULE_MISMATCH, RULE_SPLIT, RULE_NOVEL, RULE_INSUFFICIENT) if self.strict \ + else (RULE_MISMATCH, RULE_SPLIT) + return any(f.rule_id in rules for f in self.findings) def _short(digest: str) -> str: @@ -76,11 +86,16 @@ def _confined(root: Path, path: Path, what: str) -> Path: return resolved -def _read_bounded(path: Path, cap: int, what: str) -> str: +def _check_bounded(path: Path, cap: int, what: str) -> None: + """The size cap is enforced by ``stat()``; nothing is read here.""" if not path.is_file(): raise _fail(f"{what}: not a regular file") if path.stat().st_size > cap: raise _fail(f"{what}: exceeds {cap} bytes") + + +def _read_bounded(path: Path, cap: int, what: str) -> str: + _check_bounded(path, cap, what) try: return path.read_text(encoding="utf-8") except (OSError, UnicodeDecodeError) as exc: @@ -130,12 +145,11 @@ def verified_digests( if att is None: continue # declared but not in the consumer pin: ignored, never verified (CSO C3) lock_path = _confined(root, lock_path, name) - text = _read_bounded(lock_path, MAX_LOCK_BYTES, name) + _check_bounded(lock_path, MAX_LOCK_BYTES, name) # stat only; read_lock reads once try: lock = read_lock(lock_path) except (FileNotFoundError, ValueError) as exc: raise _fail(f"{name}: lock does not parse ({_error_locus(exc)})") from exc - del text if lock.schema_version != SCHEMA_VERSION: raise CorpusError( RULE_SCHEMA_MISMATCH, @@ -162,16 +176,24 @@ def verified_digests( def consensus( - observed_digest: str, coord: Coordinate, attested: dict[str, str], *, min_attesters: int = 2 + observed_digest: str, coord: Coordinate, attested: dict[str, str], *, + min_attesters: int = 2, require_consensus: bool = False, ) -> ConsensusResult: - """Compare the observed digest against verified attestations.""" + """Compare the observed digest against verified attestations. + + With ``require_consensus`` (``--require-consensus``) NOVEL and INSUFFICIENT are + ``high`` and blocking: a CI job that expects the coordinate to be attested must + not pass because the corpus (or a fork of it) simply has no entry. + """ target = f"corpus/{coord}" snippet = f"observed={_short(observed_digest)}" + soft = "low" if not require_consensus else "high" + strict_note = "" if not require_consensus else " (--require-consensus: blocking)" if not attested: return ConsensusResult(coord, [Finding( - rule_id=RULE_NOVEL, severity="low", target=target, snippet=snippet, - message=f"no attestation exists for {coord}; nothing to compare — {NOT_SAFETY}", - )]) + rule_id=RULE_NOVEL, severity=soft, target=target, snippet=snippet, + message=f"no attestation exists for {coord}; nothing to compare{strict_note} — {NOT_SAFETY}", + )], strict=require_consensus) findings: list[Finding] = [] distinct = sorted(set(attested.values())) matched = sorted(a for a, d in attested.items() if d == observed_digest) @@ -190,8 +212,8 @@ def consensus( )) elif not findings and len(matched) < min_attesters: findings.append(Finding( - rule_id=RULE_INSUFFICIENT, severity="low", target=target, snippet=snippet, + rule_id=RULE_INSUFFICIENT, severity=soft, target=target, snippet=snippet, message=(f"only {len(matched)} trusted attester(s) observed this surface for {coord}; " - f"{min_attesters} required for consensus — {NOT_SAFETY}"), + f"{min_attesters} required for consensus{strict_note} — {NOT_SAFETY}"), )) - return ConsensusResult(coord, findings, matched) + return ConsensusResult(coord, findings, matched, strict=require_consensus) diff --git a/tests/test_corpus.py b/tests/test_corpus.py index 4e1ce16..d0256fe 100644 --- a/tests/test_corpus.py +++ b/tests/test_corpus.py @@ -85,8 +85,11 @@ def _verify(statement: bytes, bundle, identity: str, issuer: str, *, verifier=No monkeypatch.setattr(corpus_verify, "verify_statement", _verify) -def run(observed, coord, source=str(CORPUS), ref=None, pin=None, min_attesters=2): - return run_consensus(observed, coord, source, ref, consumer_pin() if pin is None else pin, min_attesters) +def run(observed, coord, source=str(CORPUS), ref=None, pin=None, min_attesters=2, require_consensus=False): + return run_consensus( + observed, coord, source, ref, consumer_pin() if pin is None else pin, min_attesters, + require_consensus=require_consensus, + ) def _rules(result): @@ -483,25 +486,102 @@ def _boom(*a, **k): assert ei.value.rule_id == RULE_UNREACHABLE -def test_git_argv_is_hardened_and_source_is_last(monkeypatch, tmp_path): - seen: list[list[str]] = [] +def _fake_git(seen: list[list[str]], version: str = "git version 2.44.0"): + """A subprocess.run stand-in answering --version and faking clone/checkout/rev-parse.""" def _fake_run(argv, **kw): - seen.append(argv) + seen.append((argv, kw)) assert "shell" not in kw - assert kw["env"]["GIT_TERMINAL_PROMPT"] == "0" and set(kw["env"]) <= {*corpus._GIT_ENV_KEYS, "GIT_TERMINAL_PROMPT"} - if argv[-2:-1] and "clone" in argv: + if argv[-1] == "--version": + return subprocess.CompletedProcess(argv, 0, stdout=version + "\n", stderr="") + if "clone" in argv: Path(argv[-1]).mkdir(parents=True) return subprocess.CompletedProcess(argv, 0, stdout="0" * 40 + "\n", stderr="") - monkeypatch.setattr(subprocess, "run", _fake_run) + return _fake_run + + +@pytest.fixture +def _fresh_git_state(monkeypatch): + """Reset the once-per-process git resolution so each test sees its own fake.""" + monkeypatch.setattr(corpus, "_GIT_BIN", None) + monkeypatch.setattr(corpus, "_GIT_VERSION_OK", None) + + +def test_git_argv_is_hardened_and_source_is_last(monkeypatch, tmp_path, _fresh_git_state): + seen: list = [] + monkeypatch.setattr(subprocess, "run", _fake_git(seen)) + monkeypatch.setenv("GIT_SSH_COMMAND", "ssh -i /tmp/evil") with fetch_corpus("https://example.invalid/corpus.git", "0" * 40): pass - clone = seen[0] - assert clone[:1] == ["git"] and clone[1:1 + len(corpus._GIT_CONFIG)] == corpus._GIT_CONFIG + gits = [(a, k) for a, k in seen if a[-1] != "--version"] + clone, kw = gits[0] + git_bin = corpus._git_binary() + assert Path(git_bin).is_absolute() and clone[0] == git_bin # resolved once, absolute (N4) + assert clone[1:1 + len(corpus._GIT_CONFIG)] == corpus._GIT_CONFIG assert "protocol.allow=never" in clone and "core.hooksPath=/dev/null" in clone assert clone[-3] == "--" and clone[-2] == "https://example.invalid/corpus.git" - assert all(a[0] == "git" and a[1:1 + len(corpus._GIT_CONFIG)] == corpus._GIT_CONFIG for a in seen) + assert all(a[0] == git_bin and a[1:1 + len(corpus._GIT_CONFIG)] == corpus._GIT_CONFIG for a, _ in gits) + for _a, k in seen: + assert k["env"]["GIT_TERMINAL_PROMPT"] == "0" + assert set(k["env"]) <= {*corpus._GIT_ENV_KEYS, "GIT_TERMINAL_PROMPT"} + assert "GIT_SSH_COMMAND" not in k["env"] # https source: never forwarded (N4) + + +def test_git_ssh_command_is_forwarded_only_for_ssh_sources(monkeypatch, _fresh_git_state): + seen: list = [] + monkeypatch.setattr(subprocess, "run", _fake_git(seen)) + monkeypatch.setenv("GIT_SSH_COMMAND", "ssh -i /home/ci/deploy_key") + with fetch_corpus("git@github.com:example/corpus.git", "0" * 40): + pass + envs = {a[-3] if "clone" in a else a[-1]: k["env"] for a, k in seen} + clone_env = next(k["env"] for a, k in seen if "clone" in a) + assert clone_env["GIT_SSH_COMMAND"] == "ssh -i /home/ci/deploy_key" + # every other invocation (--version, checkout, rev-parse) stays scrubbed + for a, k in seen: + if "clone" not in a: + assert "GIT_SSH_COMMAND" not in k["env"] + assert envs # sanity: something ran + + +@pytest.mark.parametrize("version, ok", [ + ("git version 2.14.1", True), + ("git version 2.44.0 (Apple Git-150)", True), + ("git version 2.14.0", False), + ("git version 2.13.6", False), + ("git version 1.9.1", False), +]) +def test_git_version_floor(monkeypatch, _fresh_git_state, version, ok): + # DSE-1528 N3: the ssh option-injection defenses assume git >= 2.14.1. + seen: list = [] + monkeypatch.setattr(subprocess, "run", _fake_git(seen, version=version)) + if ok: + with fetch_corpus("https://example.invalid/corpus.git", "0" * 40): + pass + assert any("clone" in a for a, _ in seen) + else: + with pytest.raises(CorpusError) as ei: + with fetch_corpus("https://example.invalid/corpus.git", "0" * 40): + pass + assert ei.value.rule_id == RULE_UNREACHABLE and "older than" in str(ei.value) + assert not any("clone" in a for a, _ in seen) # refused before any clone + + +def test_unparseable_git_version_is_unreachable(monkeypatch, _fresh_git_state): + seen: list = [] + monkeypatch.setattr(subprocess, "run", _fake_git(seen, version="gitt vershun ?")) + with pytest.raises(CorpusError) as ei: + with fetch_corpus("https://example.invalid/corpus.git", "0" * 40): + pass + assert ei.value.rule_id == RULE_UNREACHABLE + + +def test_missing_git_is_unreachable(monkeypatch, _fresh_git_state): + monkeypatch.setattr(corpus.shutil, "which", lambda _name: None) + with pytest.raises(CorpusError) as ei: + with fetch_corpus("https://example.invalid/corpus.git", "0" * 40): + pass + assert ei.value.rule_id == RULE_UNREACHABLE and "not installed" in str(ei.value) def test_fetch_url_clones_at_exact_ref_and_cleans_up(corpus_repo, monkeypatch): @@ -567,3 +647,27 @@ def test_tampered_entries_under_a_valid_signature_are_rejected(monkeypatch, tmp_ with pytest.raises(CorpusError) as ei: verified_digests(tmp_path, CLEAN, consumer_pin()) assert ei.value.rule_id == RULE_UNVERIFIABLE and "reproduce" in str(ei.value) + + +# --- --require-consensus (DSE-1528 N2) ----------------------------------------- + + +def test_require_consensus_makes_novel_and_insufficient_blocking(): + novel = consensus(CLEAN_DIGEST, CLEAN, {}, require_consensus=True) + assert [f.rule_id for f in novel.findings] == [RULE_NOVEL] + assert novel.findings[0].severity == "high" and novel.blocking and novel.strict + assert "--require-consensus" in novel.findings[0].message + solo = consensus(CLEAN_DIGEST, CLEAN, {"alice": CLEAN_DIGEST}, min_attesters=2, require_consensus=True) + assert [f.rule_id for f in solo.findings] == [RULE_INSUFFICIENT] and solo.blocking + # the default stays advisory + assert not consensus(CLEAN_DIGEST, CLEAN, {}).blocking + assert not consensus(CLEAN_DIGEST, CLEAN, {"alice": CLEAN_DIGEST}, min_attesters=2).blocking + # a real match is unaffected by the flag + both = consensus(CLEAN_DIGEST, CLEAN, {"alice": CLEAN_DIGEST, "bob": CLEAN_DIGEST}, require_consensus=True) + assert both.findings == [] and not both.blocking + + +def test_run_consensus_threads_require_consensus(monkeypatch): + install_fake_verify(monkeypatch) + r = run(CLEAN_DIGEST, Coordinate("npm", "@example/unknown", "1.0.0"), str(CORPUS), None, require_consensus=True) + assert [f.rule_id for f in r.findings] == [RULE_NOVEL] and r.blocking and r.strict diff --git a/tests/test_corpus_cli.py b/tests/test_corpus_cli.py index 30c3038..0b80b21 100644 --- a/tests/test_corpus_cli.py +++ b/tests/test_corpus_cli.py @@ -78,7 +78,7 @@ def test_requires_consumer_trust_pin(): @pytest.mark.parametrize( "flags", [["--corpus", "x"], ["--corpus-ref", "0" * 40], ["--coordinate", "npm:a@1.0.0"], ["--attester", "a=b@c"], - ["--attesters-file", "x"], ["--min-attesters", "1"]], + ["--attesters-file", "x"], ["--min-attesters", "1"], ["--require-consensus"]], ) def test_community_options_without_the_flag_are_an_error(flags): # CSO L1: a typo'd invocation must not exit 0 having compared nothing. @@ -173,6 +173,25 @@ def test_insufficient_attesters_and_min_attesters_flag(monkeypatch): assert r.exit_code == 0 and _jsonl_rules(r.stdout) == [] +def test_require_consensus_blocks_novel_and_insufficient(monkeypatch, tmp_path): + # DSE-1528 N2: a CI job that expects the coordinate to be attested must not pass + # because the corpus (or a fork of it) merely has no entry. + install_fake_verify(monkeypatch) + r = _check("--coordinate", "npm:@example/unknown@1.0.0", "--require-consensus", json_out=True) + assert r.exit_code == 1 and _jsonl_rules(r.stdout) == [RULE_NOVEL] + assert "--require-consensus" in r.stdout + r = _check("--coordinate", "npm:@example/solo@1.0.0", "--require-consensus", json_out=True) + assert r.exit_code == 1 and _jsonl_rules(r.stdout) == [RULE_INSUFFICIENT] + # a real match still exits 0 under the flag, and the SARIF carries the high-severity finding + r = _check("--coordinate", "npm:@example/solo@1.0.0", "--require-consensus", "--min-attesters", "1") + assert r.exit_code == 0 + sarif = tmp_path / "strict.sarif" + r = _check("--coordinate", "npm:@example/unknown@1.0.0", "--require-consensus", sarif=sarif) + assert r.exit_code == 1 + doc = json.loads(sarif.read_text()) + assert any(res["ruleId"] == RULE_NOVEL and res["level"] == "error" for res in doc["runs"][0]["results"]) + + def test_unverifiable_corpus_is_exit_two_without_traceback(monkeypatch): install_fake_verify(monkeypatch) r = _check("--coordinate", "npm:@example/corrupt@1.0.0") From e390b73768f4114f7c413fd6289e333646dc1f2f Mon Sep 17 00:00:00 2001 From: DSE Builder Date: Fri, 4 Sep 2026 21:42:15 +0000 Subject: [PATCH 2/2] =?UTF-8?q?fix(corpus):=20disable=20credential=20helpe?= =?UTF-8?q?rs=20on=20clone,=20warn=20on=20strict+unpinned=20corpus=20?= =?UTF-8?q?=E2=80=94=20CSO=20review=20of=20#106?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - L1: -c credential.helper= -c core.askPass= on every git argv (closes the ~/.gitconfig exec vector on https clones); hardened-argv test asserts both - L3: --require-consensus with a local --corpus and no --corpus-ref prints one stderr warning that the tree is unpinned (tested both ways) - CHANGELOG: blank line before ### Fixed --- CHANGELOG.md | 1 + docs/COMMUNITY_CORPUS.md | 4 +++- src/mcp_warden/cli_corpus.py | 9 +++++++++ src/mcp_warden/corpus.py | 4 ++++ tests/test_corpus.py | 1 + tests/test_corpus_cli.py | 12 ++++++++++++ 6 files changed, 30 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 88c4570..81289b4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -45,6 +45,7 @@ Streamable HTTP; the v0.3 `guard` proxy adds deterministic runtime *result* insp 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 diff --git a/docs/COMMUNITY_CORPUS.md b/docs/COMMUNITY_CORPUS.md index a0d22d5..8886869 100644 --- a/docs/COMMUNITY_CORPUS.md +++ b/docs/COMMUNITY_CORPUS.md @@ -216,7 +216,9 @@ contains `@`, so the issuer is split at the last one. A URL must start with `htt `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 -- ` with a +core.symlinks=false -c submodule.recurse=false -c credential.helper= -c core.askPass= +clone --no-checkout -- ` (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 diff --git a/src/mcp_warden/cli_corpus.py b/src/mcp_warden/cli_corpus.py index ac1df00..f011acc 100644 --- a/src/mcp_warden/cli_corpus.py +++ b/src/mcp_warden/cli_corpus.py @@ -22,6 +22,7 @@ RULE_UNVERIFIABLE, ConsensusResult, CorpusError, + _is_url, run_consensus, ) from .corpus_coordinate import Coordinate, resolve_coordinate @@ -93,6 +94,14 @@ def adjudicate( 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 so the tree cannot change between runs", + soft_wrap=True, + ) try: result = run_consensus( observed_digest, coord, corpus, corpus_ref, pin, min_attesters, require_consensus=require_consensus diff --git a/src/mcp_warden/corpus.py b/src/mcp_warden/corpus.py index 71bc1eb..05f3afc 100644 --- a/src/mcp_warden/corpus.py +++ b/src/mcp_warden/corpus.py @@ -74,6 +74,10 @@ "-c", "core.hooksPath=/dev/null", "-c", "core.symlinks=false", "-c", "submodule.recurse=false", + # A ~/.gitconfig credential.helper / core.askPass is an arbitrary program git + # would exec on an https clone; empty values disable both (CSO #106 L1). + "-c", "credential.helper=", + "-c", "core.askPass=", ] #: Environment git may see. Everything else (proxies, GIT_* overrides, tokens) #: is dropped so the caller's environment cannot redirect the clone. diff --git a/tests/test_corpus.py b/tests/test_corpus.py index d0256fe..da36873 100644 --- a/tests/test_corpus.py +++ b/tests/test_corpus.py @@ -520,6 +520,7 @@ def test_git_argv_is_hardened_and_source_is_last(monkeypatch, tmp_path, _fresh_g assert Path(git_bin).is_absolute() and clone[0] == git_bin # resolved once, absolute (N4) assert clone[1:1 + len(corpus._GIT_CONFIG)] == corpus._GIT_CONFIG assert "protocol.allow=never" in clone and "core.hooksPath=/dev/null" in clone + assert "credential.helper=" in clone and "core.askPass=" in clone # CSO #106 L1 assert clone[-3] == "--" and clone[-2] == "https://example.invalid/corpus.git" assert all(a[0] == git_bin and a[1:1 + len(corpus._GIT_CONFIG)] == corpus._GIT_CONFIG for a, _ in gits) for _a, k in seen: diff --git a/tests/test_corpus_cli.py b/tests/test_corpus_cli.py index 0b80b21..068c80a 100644 --- a/tests/test_corpus_cli.py +++ b/tests/test_corpus_cli.py @@ -180,6 +180,7 @@ def test_require_consensus_blocks_novel_and_insufficient(monkeypatch, tmp_path): r = _check("--coordinate", "npm:@example/unknown@1.0.0", "--require-consensus", json_out=True) assert r.exit_code == 1 and _jsonl_rules(r.stdout) == [RULE_NOVEL] assert "--require-consensus" in r.stdout + assert "unpinned local corpus" in r.output # CSO #106 L3: strict + unpinned tree warns r = _check("--coordinate", "npm:@example/solo@1.0.0", "--require-consensus", json_out=True) assert r.exit_code == 1 and _jsonl_rules(r.stdout) == [RULE_INSUFFICIENT] # a real match still exits 0 under the flag, and the SARIF carries the high-severity finding @@ -192,6 +193,17 @@ def test_require_consensus_blocks_novel_and_insufficient(monkeypatch, tmp_path): assert any(res["ruleId"] == RULE_NOVEL and res["level"] == "error" for res in doc["runs"][0]["results"]) +def test_require_consensus_unpinned_warning_is_silent_when_ref_is_pinned(monkeypatch): + # CSO #106 L3: the warning is about the missing pin, not about strict mode itself. + install_fake_verify(monkeypatch) + r = _check("--coordinate", "npm:@example/clean@1.0.0") # no strict flag → no warning + assert r.exit_code == 0 and "unpinned local corpus" not in r.output + sha = os.popen(f"git -C {CORPUS} rev-parse HEAD 2>/dev/null").read().strip() + if len(sha) == 40: # fixture lives inside the repo checkout; pin its HEAD + r = _check("--coordinate", "npm:@example/clean@1.0.0", "--require-consensus", "--corpus-ref", sha) + assert "unpinned local corpus" not in r.output + + def test_unverifiable_corpus_is_exit_two_without_traceback(monkeypatch): install_fake_verify(monkeypatch) r = _check("--coordinate", "npm:@example/corrupt@1.0.0")