merge: mcp stdio config trust

This commit is contained in:
Keisuke Hirata 2026-06-20 16:27:12 +09:00
commit 9b7c4e279d
No known key found for this signature in database
8 changed files with 515 additions and 10 deletions

1
Cargo.lock generated
View File

@ -2032,6 +2032,7 @@ dependencies = [
"llm-worker",
"mlua",
"protocol",
"secrets",
"serde",
"serde_ignored",
"serde_json",

View File

@ -13,6 +13,7 @@ serde = { workspace = true, features = ["derive"] }
serde_json = { workspace = true }
serde_ignored = "0.1.14"
sha2 = "0.10"
secrets = { workspace = true }
thiserror = { workspace = true }
toml = { workspace = true }
tracing = { workspace = true }

View File

@ -6,7 +6,7 @@
//! via [`PodManifestConfig::merge`] and the final config is converted to
//! a validated [`PodManifest`] via `TryFrom`.
use std::collections::HashMap;
use std::collections::{BTreeSet, HashMap};
use std::num::NonZeroU32;
use std::path::{Path, PathBuf};
@ -17,10 +17,10 @@ use crate::defaults;
use crate::model::{AuthRef, ModelManifest, ReasoningControl};
use crate::plugin::PluginConfig;
use crate::{
CompactionConfig, FeatureConfig, FeatureFlagConfig, FileUploadLimits, MemoryConfig,
PodManifest, PodMeta, ScopeConfig, SessionConfig, SkillsConfig, TicketFeatureAccessConfig,
TicketFeatureConfig, ToolOutputLimits, ToolPermissionConfig, ToolPermissionRule, WebConfig,
WorkerManifest,
CompactionConfig, FeatureConfig, FeatureFlagConfig, FileUploadLimits, McpConfig, McpEnvValue,
McpStdioCwdPolicy, MemoryConfig, PodManifest, PodMeta, ScopeConfig, SessionConfig,
SkillsConfig, TicketFeatureAccessConfig, TicketFeatureConfig, ToolOutputLimits,
ToolPermissionConfig, ToolPermissionRule, WebConfig, WorkerManifest,
};
/// Partial-form Pod manifest. Every field is optional; one or more
@ -57,6 +57,10 @@ pub struct PodManifestConfig {
/// separate step and does not run during config merge.
#[serde(default)]
pub plugins: PluginConfig,
/// Explicit Model Context Protocol provider declarations. Config parsing
/// never starts a local MCP subprocess.
#[serde(default)]
pub mcp: McpConfig,
#[serde(default)]
pub compaction: Option<CompactionConfigPartial>,
/// First-class web tool opt-in. See [`WebConfig`].
@ -322,6 +326,11 @@ pub enum ResolveError {
MissingField(&'static str),
#[error("path must be absolute ({field}): {}", .path.display())]
RelativePath { field: &'static str, path: PathBuf },
#[error("invalid MCP config ({field}): {message}")]
InvalidMcpConfig {
field: &'static str,
message: String,
},
}
/// Reject manifest fields that were intentionally removed and must not be
@ -436,6 +445,11 @@ impl PodManifestConfig {
*dir = join_if_relative(base, dir);
}
}
for server in &mut self.mcp.stdio_servers {
if let Some(McpStdioCwdPolicy::Path { path }) = &mut server.cwd {
*path = join_if_relative(base, path);
}
}
self
}
@ -458,6 +472,7 @@ impl PodManifestConfig {
),
feature: self.feature.merge(upper.feature),
plugins: merge_plugin_config(self.plugins, upper.plugins),
mcp: merge_mcp_config(self.mcp, upper.mcp),
compaction: merge_option(
self.compaction,
upper.compaction,
@ -487,6 +502,11 @@ fn merge_plugin_config(mut base: PluginConfig, upper: PluginConfig) -> PluginCon
base
}
fn merge_mcp_config(mut base: McpConfig, upper: McpConfig) -> McpConfig {
base.stdio_servers.extend(upper.stdio_servers);
base
}
impl WebConfig {
fn merge(self, upper: Self) -> Self {
Self {
@ -708,6 +728,149 @@ fn validate_model_paths(model: &ModelManifest, field: &'static str) -> Result<()
Ok(())
}
pub(crate) fn validate_mcp_config(mcp: &McpConfig) -> Result<(), ResolveError> {
let mut names = BTreeSet::new();
for server in &mcp.stdio_servers {
if server.name.trim().is_empty() {
return Err(invalid_mcp(
"mcp.stdio_server.name",
"server name must not be empty",
));
}
if contains_nul(&server.name) {
return Err(invalid_mcp(
"mcp.stdio_server.name",
"server name must not contain NUL",
));
}
if !names.insert(server.name.as_str()) {
return Err(invalid_mcp(
"mcp.stdio_server.name",
format!(
"duplicate stdio server name `{}`",
bounded_label(&server.name)
),
));
}
if server.command.trim().is_empty() {
return Err(invalid_mcp(
"mcp.stdio_server.command",
"command must not be empty",
));
}
if contains_nul(&server.command) {
return Err(invalid_mcp(
"mcp.stdio_server.command",
"command must not contain NUL",
));
}
for arg in &server.args {
if contains_nul(arg) {
return Err(invalid_mcp(
"mcp.stdio_server.args",
"argument must not contain NUL",
));
}
}
if let Some(McpStdioCwdPolicy::Path { path }) = &server.cwd {
if path.as_os_str().is_empty() {
return Err(invalid_mcp(
"mcp.stdio_server.cwd.path",
"cwd path must not be empty",
));
}
if !path.is_absolute() {
return Err(invalid_mcp(
"mcp.stdio_server.cwd.path",
"cwd path must be absolute after profile/manifest path resolution",
));
}
}
for name in &server.env.inherit {
validate_env_name("mcp.stdio_server.env.inherit", name)?;
}
for (name, value) in &server.env.set {
validate_env_name("mcp.stdio_server.env.set", name)?;
match value {
McpEnvValue::Literal { value } => {
if contains_nul(value) {
return Err(invalid_mcp(
"mcp.stdio_server.env.set",
"literal env value must not contain NUL",
));
}
}
McpEnvValue::SecretRef { ref_ } => {
if secrets::validate_id(ref_).is_err() {
return Err(invalid_mcp(
"mcp.stdio_server.env.set.secret_ref",
"secret_ref must be a valid local secret id",
));
}
}
McpEnvValue::EnvRef { name } => {
validate_env_name("mcp.stdio_server.env.set.env_ref", name)?;
}
}
}
}
Ok(())
}
fn validate_env_name(field: &'static str, name: &str) -> Result<(), ResolveError> {
let mut chars = name.chars();
let Some(first) = chars.next() else {
return Err(invalid_mcp(
field,
"environment variable name must not be empty",
));
};
if !(first == '_' || first.is_ascii_alphabetic()) {
return Err(invalid_mcp(
field,
"environment variable name must start with ASCII letter or underscore",
));
}
if !chars.all(|ch| ch == '_' || ch.is_ascii_alphanumeric()) {
return Err(invalid_mcp(
field,
"environment variable name must contain only ASCII letters, digits, and underscore",
));
}
Ok(())
}
fn invalid_mcp(field: &'static str, message: impl Into<String>) -> ResolveError {
ResolveError::InvalidMcpConfig {
field,
message: message.into(),
}
}
fn contains_nul(value: &str) -> bool {
value.as_bytes().contains(&0)
}
fn bounded_label(value: &str) -> String {
const MAX: usize = 80;
let mut out = String::new();
for (idx, ch) in value.chars().enumerate() {
if idx >= MAX {
out.push('…');
break;
}
if ch.is_control() {
out.push('?');
} else {
out.push(ch);
}
}
out
}
impl TryFrom<PodManifestConfig> for PodManifest {
type Error = ResolveError;
@ -842,6 +1005,8 @@ impl TryFrom<PodManifestConfig> for PodManifest {
}
}
validate_mcp_config(&cfg.mcp)?;
Ok(PodManifest {
pod: PodMeta { name, prompt_pack },
model: cfg.model,
@ -852,6 +1017,7 @@ impl TryFrom<PodManifestConfig> for PodManifest {
permissions,
feature: FeatureConfig::from(cfg.feature),
plugins: cfg.plugins,
mcp: cfg.mcp,
compaction,
web: cfg.web,
memory: cfg.memory,
@ -899,6 +1065,7 @@ mod tests {
permissions: None,
feature: FeatureConfigPartial::default(),
plugins: PluginConfig::default(),
mcp: McpConfig::default(),
session: None,
compaction: None,
web: None,
@ -915,6 +1082,139 @@ mod tests {
assert!(manifest.permissions.is_none());
}
#[test]
fn resolve_mcp_stdio_config_preserves_explicit_policy() {
let mut cfg = minimal_valid();
cfg.mcp.stdio_servers.push(crate::McpStdioServerConfig {
name: "filesystem".into(),
command: "node".into(),
args: vec!["server.js".into(), "--root".into()],
cwd: Some(McpStdioCwdPolicy::Path { path: abs("/mcp") }),
env: crate::McpEnvConfig {
inherit: vec!["PATH".into()],
set: std::collections::BTreeMap::from([
(
"SAFE_MODE".into(),
McpEnvValue::Literal { value: "1".into() },
),
(
"TOKEN".into(),
McpEnvValue::SecretRef {
ref_: "providers/mcp-token".into(),
},
),
(
"UPSTREAM".into(),
McpEnvValue::EnvRef {
name: "MCP_UPSTREAM_TOKEN".into(),
},
),
]),
},
});
let manifest: PodManifest = cfg.try_into().unwrap();
assert_eq!(manifest.mcp.stdio_servers.len(), 1);
let server = &manifest.mcp.stdio_servers[0];
assert_eq!(server.name, "filesystem");
assert_eq!(server.command, "node");
assert_eq!(server.env.inherit, ["PATH"]);
assert!(matches!(
server.env.set["TOKEN"],
McpEnvValue::SecretRef { .. }
));
}
#[test]
fn resolve_mcp_rejects_empty_command_and_duplicates() {
let mut cfg = minimal_valid();
cfg.mcp.stdio_servers.push(crate::McpStdioServerConfig {
name: "dup".into(),
command: "".into(),
args: Vec::new(),
cwd: None,
env: crate::McpEnvConfig::default(),
});
let err = PodManifest::try_from(cfg).unwrap_err();
assert!(matches!(
err,
ResolveError::InvalidMcpConfig {
field: "mcp.stdio_server.command",
..
}
));
let mut cfg = minimal_valid();
for command in ["one", "two"] {
cfg.mcp.stdio_servers.push(crate::McpStdioServerConfig {
name: "dup".into(),
command: command.into(),
args: Vec::new(),
cwd: None,
env: crate::McpEnvConfig::default(),
});
}
let err = PodManifest::try_from(cfg).unwrap_err();
assert!(matches!(
err,
ResolveError::InvalidMcpConfig {
field: "mcp.stdio_server.name",
..
}
));
}
#[test]
fn resolve_mcp_rejects_invalid_env_and_secret_ref_without_leaking_values() {
let mut cfg = minimal_valid();
cfg.mcp.stdio_servers.push(crate::McpStdioServerConfig {
name: "secret".into(),
command: "no-such-command-is-not-started".into(),
args: Vec::new(),
cwd: None,
env: crate::McpEnvConfig {
inherit: Vec::new(),
set: std::collections::BTreeMap::from([(
"TOKEN".into(),
McpEnvValue::SecretRef {
ref_: "bad secret id with spaces".into(),
},
)]),
},
});
let err = PodManifest::try_from(cfg).unwrap_err();
let rendered = err.to_string();
assert!(rendered.contains("secret_ref"));
assert!(!rendered.contains("bad secret id with spaces"));
let value = McpEnvValue::Literal {
value: "plaintext-secret-value".into(),
};
assert!(!format!("{value:?}").contains("plaintext-secret-value"));
}
#[test]
fn resolve_mcp_accepts_nonexistent_command_without_autostart() {
let mut cfg = minimal_valid();
cfg.mcp.stdio_servers.push(crate::McpStdioServerConfig {
name: "later".into(),
command: "definitely-not-a-command-yoi-must-spawn".into(),
args: Vec::new(),
cwd: None,
env: crate::McpEnvConfig::default(),
});
let manifest: PodManifest = cfg.try_into().unwrap();
assert_eq!(
manifest.mcp.stdio_servers[0].command,
"definitely-not-a-command-yoi-must-spawn"
);
}
#[test]
fn resolve_session_record_event_trace() {
let mut cfg = minimal_valid();

View File

@ -24,10 +24,12 @@ pub use profile::{
pub use protocol::{Permission, ScopeRule};
pub use scope::{DelegationScope, Scope, ScopeError, SharedScope};
use std::collections::HashMap;
use std::collections::{BTreeMap, HashMap};
use std::fmt;
use std::num::NonZeroU32;
use std::path::PathBuf;
use serde::de::Error as _;
use serde::{Deserialize, Serialize};
/// Declarative configuration for a Pod.
@ -62,6 +64,12 @@ pub struct PodManifest {
/// source-qualified entries listed here may resolve to active plugin metadata.
#[serde(default)]
pub plugins: plugin::PluginConfig,
/// Explicit external Model Context Protocol provider configuration. This
/// is config data only: declaring a server never starts a subprocess or
/// grants OS sandboxing. Runtime MCP lifecycle/registration is a separate
/// consumer boundary.
#[serde(default)]
pub mcp: McpConfig,
#[serde(default)]
pub compaction: Option<CompactionConfig>,
/// Memory subsystem configuration. Presence of `[memory]` configures memory
@ -194,6 +202,92 @@ pub struct SkillsConfig {
pub directories: Vec<PathBuf>,
}
/// Explicit Model Context Protocol configuration.
///
/// The manifest layer records local stdio MCP server declarations but never
/// starts them. Future lifecycle code must opt in to spawning and must keep MCP
/// process authority separate from Plugin permissions and `pod::feature` flags.
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
#[serde(deny_unknown_fields)]
pub struct McpConfig {
/// Named local stdio servers. The list form keeps declarations explicit and
/// lets validation reject duplicate names after profile/override merging.
#[serde(default, rename = "stdio_server")]
pub stdio_servers: Vec<McpStdioServerConfig>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(deny_unknown_fields)]
pub struct McpStdioServerConfig {
/// Stable profile-local name used by later lifecycle/tool-surface code.
pub name: String,
/// Executable path/name passed directly to process-spawn code in a later
/// ticket. This is not a shell string and is not executed by config parsing.
pub command: String,
#[serde(default)]
pub args: Vec<String>,
/// Optional working-directory policy for the future subprocess. Omitted
/// means no config-level cwd override. Relative `path` values are resolved
/// against the manifest/profile layer before final validation.
#[serde(default)]
pub cwd: Option<McpStdioCwdPolicy>,
/// Explicit environment policy. There is no implicit environment discovery;
/// future spawn code should inherit only names listed here and set only
/// entries declared here.
#[serde(default)]
pub env: McpEnvConfig,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
pub enum McpStdioCwdPolicy {
/// Leave cwd selection to the lifecycle caller.
Inherit,
/// Use this absolute (after path resolution) working directory.
Path { path: PathBuf },
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
#[serde(deny_unknown_fields)]
pub struct McpEnvConfig {
/// Host environment variable names to copy explicitly at spawn time.
#[serde(default)]
pub inherit: Vec<String>,
/// Environment variables to set explicitly.
#[serde(default)]
pub set: BTreeMap<String, McpEnvValue>,
}
#[derive(Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
pub enum McpEnvValue {
/// Literal value. Use only for non-secret values; Debug/diagnostics redact
/// it defensively because env values often become credentials over time.
Literal { value: String },
/// Local secret-store id. The plaintext is resolved only by a future runtime
/// consumer and is never loaded during manifest/profile parsing.
#[serde(rename = "secret_ref")]
SecretRef {
#[serde(rename = "ref")]
ref_: String,
},
/// Name of a host environment variable to read explicitly at spawn time.
EnvRef { name: String },
}
impl fmt::Debug for McpEnvValue {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Literal { .. } => f
.debug_struct("Literal")
.field("value", &"[redacted]")
.finish(),
Self::SecretRef { ref_ } => f.debug_struct("SecretRef").field("ref_", ref_).finish(),
Self::EnvRef { name } => f.debug_struct("EnvRef").field("name", name).finish(),
}
}
}
/// Configuration for WebSearch and WebFetch built-in tools.
///
/// Network tools are fail-closed: absent config or `enabled = false` disables
@ -712,7 +806,10 @@ impl PodManifest {
/// Parse a manifest from a TOML string.
pub fn from_toml(s: &str) -> Result<Self, toml::de::Error> {
config::reject_removed_manifest_fields(s)?;
toml::from_str(s)
let manifest: Self = toml::from_str(s)?;
config::validate_mcp_config(&manifest.mcp)
.map_err(|error| toml::de::Error::custom(error.to_string()))?;
Ok(manifest)
}
}

View File

@ -19,8 +19,9 @@ use crate::config::{
use crate::model::{AuthRef, ModelManifest};
use crate::plugin::PluginConfig;
use crate::{
MemoryConfig, Permission, PodManifest, PodManifestConfig, PodMetaConfig, ResolveError,
ScopeConfig, ScopeRule, SkillsConfig, WebConfig, WorkerManifestConfig, paths,
McpConfig, McpStdioCwdPolicy, MemoryConfig, Permission, PodManifest, PodManifestConfig,
PodMetaConfig, ResolveError, ScopeConfig, ScopeRule, SkillsConfig, WebConfig,
WorkerManifestConfig, paths,
};
const PROFILE_FORMAT_V1: &str = "yoi.lua-profile.v1";
@ -628,6 +629,7 @@ fn resolve_lua_profile_value(
permissions: profile.permissions,
feature: profile.feature,
plugins: profile.plugins,
mcp: profile.mcp,
compaction,
web: profile.web,
memory: profile.memory,
@ -691,6 +693,8 @@ struct ProfileConfig {
#[serde(default)]
plugins: PluginConfig,
#[serde(default)]
mcp: McpConfig,
#[serde(default)]
compaction: Option<serde_json::Value>,
#[serde(default)]
web: Option<WebConfig>,
@ -1247,6 +1251,16 @@ fn validate_profile_paths(profile: &ProfileConfig) -> Result<(), ProfileError> {
}
}
}
for server in &profile.mcp.stdio_servers {
if let Some(McpStdioCwdPolicy::Path { path }) = &server.cwd
&& path.is_absolute()
{
return Err(ProfileError::InvalidProfile(
"field `mcp.stdio_server.cwd.path` must be profile-relative in reusable Profiles"
.into(),
));
}
}
Ok(())
}
fn reject_absolute_auth_file(
@ -1693,6 +1707,66 @@ return profile {
Some("coder")
);
}
#[test]
fn lua_profile_resolves_named_mcp_stdio_config_without_starting_command() {
let tmp = TempDir::new().unwrap();
let profile = write_profile(
tmp.path(),
"mcp.lua",
r#"
local profile = require("yoi.profile")
return profile {
slug = "mcp",
model = { scheme = "anthropic", model_id = "claude-sonnet-4-20250514" },
mcp = {
stdio_server = {
{
name = "filesystem",
command = "definitely-not-spawned-during-profile-resolution",
args = { "--root", "." },
cwd = { kind = "path", path = "servers" },
env = {
inherit = { "PATH" },
set = {
SAFE_MODE = { kind = "literal", value = "1" },
API_TOKEN = { kind = "secret_ref", ref = "providers/mcp-token" },
FROM_ENV = { kind = "env_ref", name = "MCP_TOKEN" },
},
},
},
},
},
}
"#,
);
std::fs::create_dir(tmp.path().join("servers")).unwrap();
let workspace = tmp.path().join("workspace");
std::fs::create_dir(&workspace).unwrap();
let resolved = ProfileResolver::new()
.with_workspace_base(&workspace)
.resolve(
&ProfileSelector::path(&profile),
ProfileResolveOptions::with_pod_name("runtime-pod"),
)
.unwrap();
let server = &resolved.manifest.mcp.stdio_servers[0];
assert_eq!(server.name, "filesystem");
assert_eq!(
server.command,
"definitely-not-spawned-during-profile-resolution"
);
assert!(matches!(
server.cwd,
Some(McpStdioCwdPolicy::Path { ref path }) if path == &tmp.path().join("servers")
));
assert!(matches!(
server.env.set["API_TOKEN"],
crate::McpEnvValue::SecretRef { .. }
));
}
#[test]
fn resolves_lua_profile_feature_flags_without_runtime_state() {
let tmp = TempDir::new().unwrap();

View File

@ -776,6 +776,7 @@ fn manifest_to_reusable_config(manifest: &PodManifest) -> PodManifestConfig {
}),
feature: manifest.feature.clone().into(),
plugins: manifest.plugins.clone(),
mcp: manifest.mcp.clone(),
compaction: manifest
.compaction
.as_ref()

View File

@ -29,6 +29,37 @@ Source/partial layers may omit fields. Resolved manifests should be explicit eno
For normal Profile/default startup, a workspace may add `.yoi/override.local.toml` as a final local manifest layer. Yoi discovers the nearest ancestor `.yoi/override.local.toml` from the workspace base used for profile resolution, resolves relative paths in that file against its containing `.yoi` directory, and applies it after the selected Profile and builtin defaults. This file is intended for machine-local choices such as provider/model, worker language, prompt pack, and permission policy tweaks; it is ignored by git via the repository `*.local.*` rule. It is not applied in explicit `--manifest <path>` mode, and it cannot set `pod.name` because Pod identity remains a runtime input.
## Local stdio MCP server declarations
Profiles and manifest layers may declare named local stdio MCP servers under `mcp.stdio_server`. This is a typed configuration surface only. Declaring a server does not start a subprocess, discover packages, negotiate MCP capabilities, or register tools/resources/prompts.
Example Lua Profile fragment:
```lua
mcp = {
stdio_server = {
{
name = "filesystem",
command = "node",
args = { "server.js", "--root", "." },
cwd = { kind = "path", path = "./mcp" },
env = {
inherit = { "PATH" },
set = {
SAFE_MODE = { kind = "literal", value = "1" },
API_TOKEN = { kind = "secret_ref", ref = "providers/mcp-token" },
UPSTREAM_TOKEN = { kind = "env_ref", name = "MCP_UPSTREAM_TOKEN" },
},
},
},
},
}
```
`command` is a direct executable name/path, not a shell string. `args` are passed as argv entries by future lifecycle code. `cwd.kind = "path"` is resolved relative to the Profile or manifest layer; omit `cwd` or use `{ kind = "inherit" }` when the lifecycle caller should choose. Environment handling is explicit: future spawn code should inherit only names listed in `env.inherit` and set only variables in `env.set`. `literal` values are for non-secret data; credentials should use `secret_ref` or explicit `env_ref`. Diagnostics and Debug output must redact env literal values and must not print secret plaintext.
Local stdio MCP servers are ordinary local executables running with the user's OS permissions. Yoi's feature flags, Plugin permissions, and MCP config validation are not an operating-system sandbox and cannot prevent filesystem/network/process side effects once a later lifecycle implementation chooses to spawn a configured server.
## Spawned Pods
`SpawnPod.profile` is optional and resolves through defaults when omitted. The only concrete capability delegation in the tool call is `SpawnPod.scope`, and it must be a subset of the parent's effective scope.

View File

@ -40,7 +40,7 @@ rustPlatform.buildRustPackage rec {
filter = sourceFilter;
};
cargoHash = "sha256-ci9h0U83YQQBeT3xlsGuKULnl1Aphgpg3pR4n0se16I=";
cargoHash = "sha256-Q+z7HDTkLtflth79ptEFy1lkDR9Y5VRrmX0m9NtLVqM=";
depsExtraArgs = {
# Older fetchCargoVendor utilities used crates.io's API download endpoint,