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
2 changes: 1 addition & 1 deletion .specify/feature.json
Original file line number Diff line number Diff line change
@@ -1 +1 @@
{"feature_directory": "specs/030-dot-project-spec-sync"}
{"feature_directory": "specs/031-mcp-server-handler"}
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -381,5 +381,5 @@ else:
<!-- SPECKIT START -->
For additional context about technologies to be used, project structure,
shell commands, and other important information, read the current plan:
[`specs/030-dot-project-spec-sync/plan.md`](specs/030-dot-project-spec-sync/plan.md)
[`specs/031-mcp-server-handler/plan.md`](specs/031-mcp-server-handler/plan.md)
<!-- SPECKIT END -->
86 changes: 86 additions & 0 deletions packages/darnit/src/darnit/config/framework_schema.py
Original file line number Diff line number Diff line change
Expand Up @@ -1023,6 +1023,86 @@ class PluginConfig(BaseModel):
model_config = ConfigDict(extra="allow")


class McpServerConfig(BaseModel):
"""One allowlist entry describing an external MCP server darnit may spawn.

A control's ``handler = "mcp"`` pass declares ``server = "<name>"``;
that name MUST match a key under ``[mcp_servers.<name>]`` in the
effective framework config (or in ``.baseline.toml``, which wins
per-name per spec FR-016). Absence of the entry produces ERROR at
audit time without spawning anything -- allowlist is the primary
trust boundary.

Fields locked at the schema layer:

* ``command`` is required and non-empty; the first element is the
executable name (resolved via ``PATH``) or an absolute path.
* ``env`` values MAY contain ``$VAR`` placeholders, substituted from
the operator's shell at spawn time. Unset variables substitute as
empty string, matching the ``exec`` handler.
* ``trusted_publisher`` triggers Sigstore sidecar verification when
set; failure to verify produces ERROR without ever entering
evidence (spec FR-007).
* ``optional = true`` (default) turns a missing binary into
INCONCLUSIVE; ``optional = false`` promotes it to FAIL.
* ``install_hint`` surfaces in the INCONCLUSIVE/FAIL message.

``extra = "forbid"`` locks spec FR-015: unknown fields such as a
hypothetical future ``transport = "http"`` MUST raise
:class:`ValidationError` at load time rather than silently accepting.
v0 supports stdio only; a future transport addition changes the
schema at that time.
"""

command: list[str] = Field(..., min_length=1)
env: dict[str, str] = Field(default_factory=dict)
trusted_publisher: str | None = None
optional: bool = True
install_hint: str = ""

model_config = ConfigDict(extra="forbid")

@field_validator("command")
@classmethod
def _validate_command_nonempty(cls, v: list[str]) -> list[str]:
if not v:
raise ValueError(
"mcp_servers[*].command must be a non-empty list; the first "
"element names the executable (resolved via PATH) or is an "
"absolute path."
)
return v

@field_validator("trusted_publisher")
@classmethod
def _validate_trusted_publisher_shape(cls, v: str | None) -> str | None:
if v is None:
return None
stripped = v.strip()
if not stripped:
raise ValueError(
"mcp_servers[*].trusted_publisher, when set, must not be blank."
)
# Advisory-only shape check: accept github.com URLs and bare
# owner/repo strings. Any other shape is permitted at the schema
# layer but produces a runtime verification failure at spawn.
import logging as _logging

looks_like_github = (
stripped.startswith("https://github.com/")
or stripped.startswith("http://github.com/")
or "/" in stripped.strip("/")
)
if not looks_like_github:
_logging.getLogger(__name__).warning(
"mcp_servers[*].trusted_publisher=%r does not look like a "
"github.com URL or owner/repo pair; verification will "
"likely fail at spawn time.",
stripped,
)
return stripped


class PluginsConfig(BaseModel):
"""Container for plugin configurations.

Expand Down Expand Up @@ -1420,6 +1500,12 @@ class FrameworkConfig(BaseModel):
# Plugin configurations (for extending framework with additional handlers)
plugins: PluginsConfig = Field(default_factory=PluginsConfig)

# Allowlist of external MCP servers this framework may consult via the
# built-in ``mcp`` sieve handler. Keyed by operator-chosen server name;
# the pass references it as ``server = "<name>"``. Empty dict preserves
# backward-compatible behavior for every existing framework TOML.
mcp_servers: dict[str, McpServerConfig] = Field(default_factory=dict)

# Named audit profiles (optional, for multi-scenario implementations)
audit_profiles: dict[str, AuditProfileConfig] = Field(default_factory=dict)

Expand Down
16 changes: 16 additions & 0 deletions packages/darnit/src/darnit/config/merger.py
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,7 @@
ControlConfig,
FrameworkConfig,
FrameworkDefaults,
McpServerConfig,
)
from .user_schema import (
ControlOverride,
Expand Down Expand Up @@ -160,6 +161,11 @@ class EffectiveConfig:
cache_ttl: int = 300
timeout: int = 300

# Merged MCP-server allowlist: framework + .baseline.toml, with
# per-name replacement (spec FR-016). Empty dict is the pre-feature
# default and preserves backward compatibility.
mcp_servers: dict[str, "McpServerConfig"] = field(default_factory=dict)

# Source configs (for reference)
_framework_config: FrameworkConfig | None = None
_user_config: UserConfig | None = None
Expand Down Expand Up @@ -376,6 +382,16 @@ def merge_configs(
for name, adapter in user.adapters.items():
effective.adapters[name] = adapter

# Merge MCP-server allowlist (spec FR-016).
# Precedence: framework provides the base; each key present in
# `.baseline.toml` REPLACES the framework's block for that name
# entirely (no deep merge within a block; the operator's entry is
# authoritative). Disjoint names coexist.
effective.mcp_servers = dict(framework.mcp_servers)
if user:
for name, srv in user.mcp_servers.items():
effective.mcp_servers[name] = srv

# Apply user settings
if user:
effective.cache_results = user.settings.cache_results
Expand Down
6 changes: 6 additions & 0 deletions packages/darnit/src/darnit/config/user_schema.py
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@
CheckConfig,
ControlConfig,
HandlerInvocation,
McpServerConfig,
RemediationConfig,
)

Expand Down Expand Up @@ -257,6 +258,11 @@ class UserConfig(BaseModel):
# Control groups for batch configuration
control_groups: dict[str, ControlGroup] = Field(default_factory=dict)

# Per-fleet MCP-server allowlist entries. Keys here fully replace the
# framework's ``[mcp_servers.<name>]`` block of the same name at merge
# time (spec FR-016); disjoint names coexist.
mcp_servers: dict[str, McpServerConfig] = Field(default_factory=dict)

model_config = ConfigDict(extra="allow")

# =========================================================================
Expand Down
8 changes: 8 additions & 0 deletions packages/darnit/src/darnit/core/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,14 @@ class ExecutionContext:
# Already-computed check results
cached_results: dict[str, CheckResult] = field(default_factory=dict)

# Feature 031: allowlist of external MCP servers (merged framework +
# `.baseline.toml`, per-name replacement) available to the built-in
# ``mcp`` sieve handler. Values are ``McpServerConfig`` instances but
# typed as ``Any`` here to avoid a config->core import cycle. Empty
# dict is the pre-feature default -- audits that never consult an
# MCP server pay zero cost.
mcp_servers: dict[str, Any] = field(default_factory=dict)

# Threading locks
_lock: threading.Lock = field(default_factory=threading.Lock, init=False, repr=False)
_tool_locks: dict[str, threading.Lock] = field(default_factory=dict, init=False, repr=False)
Expand Down
Loading
Loading