feat(sieve): add mcp handler for external MCP servers as observation sources - #380
Conversation
…n sources Adds a new built-in sieve handler `mcp` symmetric with `exec`/`api_call`. TOML controls declare `handler = "mcp"` passes referencing an allowlisted server (`[mcp_servers.<name>]` block on the framework TOML or `.baseline.toml`); the handler dispatches over stdio, exposes the response as `result.*` to CEL `expr`, and records the raw response plus trust label in evidence. Trust is allowlist-required (no allowlist entry -> ERROR without spawn); optional `trusted_publisher` triggers Sigstore sidecar verification. Child processes inherit only a curated env safe-set (PATH/HOME/LANG/LC_*/XDG_*/SSL_CERT_FILE) plus the operator's TOML `env` block. Sessions are spawned lazily on first use, pooled across the audit run via a persistent asyncio loop in a daemon thread, and torn down in `verify_batch`'s finally block. Absent binary defaults to INCONCLUSIVE (FAIL if `optional = false`); tool-side `isError=True` is ERROR without marking the session broken; a session crash triggers exactly one respawn. The orchestrator emits `[N/M] <control_id> dispatching_mcp <server>.<tool>` on `darnit.harness` at INFO before dispatch, matching feature 026's `dispatching_llm` pattern. Zero new runtime deps: uses existing `mcp>=1.23,<2` client-side APIs and the already-declared `darnit-core[attestation]` sigstore extra for the optional verification path. Scoped entirely to `packages/darnit/` core; no plugin package touched. Sandboxing beyond env curation (bubblewrap, nono.sh, landlock, nsjail) tracked as issue darnitdevorg#375; `mcp_trust.verify` is deliberately isolated so that follow-up can extend the pre-spawn hooks without touching the pool. Spec: `specs/031-mcp-server-handler/` (52 tasks, all closed).
|
Thanks, reviewed the diff and ran the suite. Two things in mcp_trust.py, plus a test-isolation issue.
Everything else looks clean. |
…derr capture (darnitdevorg#380 review) Addresses three findings from Marc-cn's review of PR darnitdevorg#380: 1. Sigstore verification now binds the bundle to the on-disk binary. `mcp_trust.verify` computes the binary's SHA-256 and: - tries `verify_artifact(Hashed(...), bundle, policy)` first (direct-artifact / `cosign sign-blob` shape -- the SDK does the binding as part of the signature check), and - falls back to `verify_dsse` + explicit `subject.digest.sha256` match against the binary's SHA-256 (in-toto / SLSA shape). The previous code called `verify_dsse` alone and never touched the binary bytes, so any valid bundle from the trusted publisher's workflow would have passed regardless of which binary sat beside it. Tests lock the mismatch-rejected, digest-match-accepted, and direct-artifact-accepted paths. 2. `_spawn` now execs the resolved absolute path (`str(binary_path)`) rather than handing the caller's relative command name to `StdioServerParameters`. The OS was re-resolving PATH at exec time, so a substituted binary between our `shutil.which` / Sigstore verification and the actual exec would run instead of the one we just verified. 3. Suite-order isolation: MCP `stdio_client` binds its `errlog=sys.stderr` default at module import time. If the mcp module was first imported while a pytest capsys-active test held `sys.stderr` replaced with a non-fd stream, every subsequent subprocess spawn raised `io.UnsupportedOperation: fileno`. Under Marc's repro (`pytest tests/darnit/harness tests/darnit/sieve/test_mcp_handler.py`) this caused 8/16 mcp tests to fail with `MCP handshake failed for mock: fileno`. Fix: resolve child stderr at call time via `_resolve_child_stderr` (prefer live `sys.stderr` with a working `fileno`, fall back to `sys.__stderr__`, then `os.devnull`). New regression test monkeypatches `sys.stderr` to a no-fileno stream and asserts spawn still succeeds. Zero product-package additions; all changes scoped to `packages/darnit/src/darnit/sieve/`. No new runtime dependencies.
|
Thanks for the sharp read. All three fixed in cc7431c. 1. Bundle-binding gap (verify_dsse alone). Correct read on my part -- the previous code never touched the binary bytes.
Both success paths now reject a bundle whose subject digest does not match the on-disk binary. New tests lock the mismatch-rejected, digest-match-accepted, and direct-artifact-accepted paths. 2. PATH TOCTOU. Same fix as you suggested -- 3. Test isolation. Traced this to a subtler cause than my initial guess. Also moved the Verified locally:
The regression tests would have caught the stderr-capture issue in CI even without your ordering. Let me know if you want any of the fixes split into separate commits before merge. |
|
Thanks, re-ran on my side, lgtm. |
Summary
Adds a new built-in sieve handler
mcpsymmetric withexec/api_call. TOML controls declarehandler = "mcp"passes referencing an allowlisted server ([mcp_servers.<name>]block on the framework TOML or.baseline.toml); the handler dispatches over stdio, exposes the response asresult.*to CELexpr, and records the raw response plus trust label in evidence.[mcp_servers.<name>]entry -> ERROR without spawn). Optionaltrusted_publishertriggers Sigstore sidecar verification; failure produces ERROR, never PASS.envblock with\$VARsubstitution from the parent shell.verify_batch'sfinallyblock (SC-002: exactly one spawn + one teardown across N controls).optional = false); tool-sideisError=Trueis ERROR without marking the session broken; a session crash triggers exactly one respawn; a double-broken session resolves INCONCLUSIVE.[N/M] <control_id> dispatching_mcp <server>.<tool>ondarnit.harnessat INFO before dispatch, matching feature 026'sdispatching_llmpattern.Zero new runtime deps: uses existing
mcp>=1.23,<2client-side APIs and the already-declareddarnit-core[attestation]sigstore extra for the optional verification path. Scoped entirely topackages/darnit/core; no plugin package touched.The
mcp_trust.verifymodule is deliberately isolated so the sandboxing follow-up tracked in #375 (bubblewrap, nono.sh, landlock, nsjail) can extend the pre-spawn hooks without touching the pool.Spec:
specs/031-mcp-server-handler/(52 tasks, all closed).Test plan
uv run pytest tests/darnit/sieve/test_mcp_handler.py tests/darnit/sieve/test_mcp_pool.py tests/darnit/sieve/test_mcp_trust.py tests/darnit/config/test_mcp_server_config.py tests/darnit/config/test_merger_mcp_servers.py-- 27 pass, 1 skip (sigstore extra not in dev env)uv run pytest tests/ -q --deselect tests/darnit/context/test_dot_project_upstream.py::TestUpstreamSpecSync::test_upstream_spec_unchanged-- 2737 pass, 17 skip, 0 failuv run ruff checkon all feature-touched files -- cleanuv run python scripts/validate_sync.py --verbose-- PASS (TOML schema, handler-name registry, SARIF source)packages/(darnit-baseline|darnit-gittuf|darnit-reproducibility)/src/)pyproject.tomluwu-tools/scorecard-mcpwhen available) to confirm the operator experience matches the two worked examples inspecs/031-mcp-server-handler/quickstart.mdBackward compatibility: strict addition. Every framework TOML and
.baseline.tomlthat parsed successfully before continues to parse identically; the[mcp_servers]schema section is optional; absence is the pre-feature state.